Compare commits

..
Author SHA1 Message Date
9qeklajc bc5c56ecc3 clean up 2026-08-06 22:58:44 +02:00
thefux de62a27fb6 fix(docker): exclude runtime state from build context 2026-08-06 19:57:27 +00:00
9qeklajcandGitHub 438ea9035f Merge pull request #650 from Routstr/unique-provider-id
make provider id unique
2026-08-05 14:45:35 +02:00
9qeklajc 943e083a89 make provider id unique 2026-08-05 00:49:17 +02:00
9qeklajcandGitHub 91f9cbec0b Merge pull request #649 from Routstr/improve-fallback-loop
better fallback
2026-08-05 00:40:29 +02:00
5 changed files with 207 additions and 6 deletions
+46 -6
View File
@@ -1,13 +1,53 @@
.env
.venv
# Git and repository metadata
.git
.gitignore
.dockerignore
compose.yml
compose.testing.yml
.todo
.github
.vscode
.DS_Store
**/node_modules
.todo
# Local configuration and secrets
**/.env
**/.env.*
**/routstr_secret.key
# Python environments, caches, and build artifacts
**/.venv
**/__pycache__
**/*.py[cod]
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.coverage
**/htmlcov
**/*.egg-info
**/build
**/dist
# Runtime state must never be baked into the image
logs
logs.*
**/*.log
**/.wallet*
**/.cashu
**/*.db
**/*.db-*
**/*.sqlite3
**/*.sqlite3-*
**/keys
**/proof_backups
**/relay-data
# The UI output is built by its own service and mounted at runtime
ui_out
ui/.next
ui/out
**/node_modules
# Compose and local development files
compose.yml
compose.testing.yml
compose.override.yml
plans
.worktrees
@@ -0,0 +1,36 @@
"""never reuse upstream provider ids
Revision ID: f2a7c9d4e8b1
Revises: ecfa0d6e2a36
Create Date: 2026-08-05 00:40:19.000000
"""
from __future__ import annotations
from alembic import op
revision = "f2a7c9d4e8b1"
down_revision = "ecfa0d6e2a36"
branch_labels = None
depends_on = None
def _recreate_sqlite_table(*, sqlite_autoincrement: bool) -> None:
with op.batch_alter_table(
"upstream_providers",
recreate="always",
table_kwargs={"sqlite_autoincrement": sqlite_autoincrement},
):
pass
def upgrade() -> None:
# A bare SQLite INTEGER PRIMARY KEY can reuse the highest deleted row ID.
# AUTOINCREMENT records a durable high-water mark in sqlite_sequence.
if op.get_bind().dialect.name == "sqlite":
_recreate_sqlite_table(sqlite_autoincrement=True)
def downgrade() -> None:
if op.get_bind().dialect.name == "sqlite":
_recreate_sqlite_table(sqlite_autoincrement=False)
+1
View File
@@ -630,6 +630,7 @@ class UpstreamProviderRow(SQLModel, table=True): # type: ignore
UniqueConstraint(
"base_url", "api_key", name="uq_upstream_providers_base_url_api_key"
),
{"sqlite_autoincrement": True},
)
id: int | None = Field(default=None, primary_key=True)
slug: str | None = Field(
+48
View File
@@ -0,0 +1,48 @@
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_backend_docker_context_excludes_generated_and_runtime_state() -> None:
patterns = {
line.strip()
for line in (REPO_ROOT / ".dockerignore").read_text().splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
required_patterns = {
"**/.env",
"**/.env.*",
"**/routstr_secret.key",
"**/.venv",
"**/__pycache__",
"**/*.py[cod]",
"**/.pytest_cache",
"**/.mypy_cache",
"**/.ruff_cache",
"**/.coverage",
"**/htmlcov",
"**/*.egg-info",
"**/build",
"**/dist",
"logs",
"logs.*",
"**/*.log",
"**/.wallet*",
"**/.cashu",
"**/*.db",
"**/*.db-*",
"**/*.sqlite3",
"**/*.sqlite3-*",
"**/keys",
"**/proof_backups",
"**/relay-data",
"ui_out",
"ui/.next",
"ui/out",
}
assert required_patterns <= patterns, (
"The backend Docker context must exclude local build artifacts and "
f"runtime state; missing patterns: {sorted(required_patterns - patterns)}"
)
+76
View File
@@ -0,0 +1,76 @@
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
def _run_alembic(root: Path, database_url: str, command: str, revision: str) -> None:
env = os.environ.copy()
env["DATABASE_URL"] = database_url
subprocess.run(
[sys.executable, "-m", "alembic", command, revision],
cwd=root,
env=env,
check=True,
capture_output=True,
text=True,
)
def _insert_provider(connection: sqlite3.Connection, slug: str) -> int:
cursor = connection.execute(
"INSERT INTO upstream_providers "
"(slug, provider_type, base_url, api_key, enabled, provider_fee) "
"VALUES (?, 'custom', ?, ?, 1, 1.01)",
(slug, f"https://{slug}.example.com", f"key-{slug}"),
)
assert cursor.lastrowid is not None
return cursor.lastrowid
def test_provider_ids_are_not_reused_after_deletion(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[2]
database_path = tmp_path / "provider-id-migration.db"
database_url = f"sqlite+aiosqlite:///{database_path}"
previous_head = "ecfa0d6e2a36"
_run_alembic(root, database_url, "upgrade", previous_head)
with sqlite3.connect(database_path) as connection:
assert _insert_provider(connection, "first") == 1
assert _insert_provider(connection, "second") == 2
connection.execute("DELETE FROM upstream_providers WHERE id = 2")
assert _insert_provider(connection, "before-migration") == 2
connection.commit()
_run_alembic(root, database_url, "upgrade", "f2a7c9d4e8b1")
with sqlite3.connect(database_path) as connection:
connection.execute("DELETE FROM upstream_providers WHERE id = 2")
assert _insert_provider(connection, "after-migration") == 3
table_sql = connection.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'upstream_providers'"
).fetchone()
assert table_sql is not None
assert "PRIMARY KEY AUTOINCREMENT" in table_sql[0]
def test_provider_id_migration_downgrades(tmp_path: Path) -> None:
root = Path(__file__).resolve().parents[2]
database_path = tmp_path / "provider-id-downgrade.db"
database_url = f"sqlite+aiosqlite:///{database_path}"
_run_alembic(root, database_url, "upgrade", "f2a7c9d4e8b1")
_run_alembic(root, database_url, "downgrade", "ecfa0d6e2a36")
with sqlite3.connect(database_path) as connection:
table_sql = connection.execute(
"SELECT sql FROM sqlite_master "
"WHERE type = 'table' AND name = 'upstream_providers'"
).fetchone()
assert table_sql is not None
assert "AUTOINCREMENT" not in table_sql[0]