CLI: explicit 'paskia migrate' subcommand for legacy conversion

Serve never converts databases: with no paskia.kantadb it points at
'paskia init', or at 'paskia migrate' when legacy *.paskiadb candidates
exist. migrate converts a lone candidate, or the one named by --rp-id
when several exist; the rest stay in place. devserver fails fast with
the same hint. gitignore covers paskia.kantadb and *.converted-bak.
This commit is contained in:
2026-09-06 22:11:17 +00:00
parent 9b10663c10
commit fefd54f02a
7 changed files with 148 additions and 56 deletions
+2
View File
@@ -6,6 +6,8 @@ dist/
package-lock.json
paskia.sqlite
*.paskiadb
*.converted-bak
*.kantadb
*.data
/paskia/frontend-build
/paskia/_version.py
+4 -3
View File
@@ -55,8 +55,9 @@ uv tool install paskia
Bootstrapping is done once with `paskia init`; after that, `paskia` serves all configured realms from the database `paskia.kantadb` in the current directory. Realm configuration (rp-name, auth host, origins) is managed via the admin web interface, including adding further realms (rp-ids).
```text
paskia init [options] # one-time bootstrap
paskia [-l endpoint] # serve
paskia init [options] # one-time bootstrap
paskia migrate [--rp-id] # convert a legacy {rp-id}.paskiadb database
paskia [-l endpoint] # serve
```
| init option | Description | Default |
@@ -67,7 +68,7 @@ paskia [-l endpoint] # serve
| --origin *url* | Only sites listed can login on the default realm (repeatable) | rp-id and all subdomains |
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
The `paskia` serve command accepts only `--listen` (overriding the stored value). An existing legacy `{rp-id}.paskiadb` database is converted to `paskia.kantadb` automatically on first serve.
The `paskia` serve command accepts only `--listen` (overriding the stored value) and never converts databases: with no `paskia.kantadb` it tells you to run `paskia init`, or `paskia migrate` when a legacy `{rp-id}.paskiadb` database is present. `paskia migrate` converts the legacy database; with several candidates, `--rp-id` selects one by name and the rest are left in place.
## Tutorial: From Local Testing to Production
+23 -19
View File
@@ -188,15 +188,19 @@ instance:
- `--listen`: stored into `Config.listen` (process-global).
- Seeds the admin user + registration reset link (link URL from the
default realm) and prints the link. Refuses to run if
`paskia.kantadb` already exists, or if an un-adopted legacy
`*.paskiadb` is present (serve adopts it first).
`paskia.kantadb` already exists, or if an unconverted legacy
`*.paskiadb` is present (`paskia migrate` converts it first).
- **`paskia migrate`** — converts a legacy `<rp-id>.paskiadb` database
(§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id`
selects `<rp-id>.paskiadb` by name; the others are left in place.
- **`paskia`** — serve. Takes **no realm options**; only `--listen`
(per-run override of stored `Config.listen`, never persisted). Startup:
legacy-adoption pre-flight → open `paskia.kantadb` → validate the
stored realm set cross-realm (rp-ids distinct; auth hosts distinct from
each other and from every rp-id; related origins capped and
collision-free) → build the realm registry → serve. Missing database →
startup error pointing at `paskia init`.
open `paskia.kantadb` → validate the stored realm set cross-realm
(rp-ids distinct; auth hosts distinct from each other and from every
rp-id; related origins capped and collision-free) → build the realm
registry → serve. The serve command never converts databases: with no
`paskia.kantadb`, the startup error points at `paskia init`, or at
`paskia migrate` when legacy `*.paskiadb` candidates are present.
Nested rp-ids are allowed (longest-suffix dispatch determinism). Adding a
child rp-id moves **no data** — users are global; only new ceremonies
@@ -397,17 +401,17 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
deployment.
- **User files** (avatars) live in the fixed sibling directory
**`paskia.data/users/`**.
- **Legacy adoption**: if `paskia.kantadb` is absent and exactly one
`*.paskiadb` candidate exists in CWD — a directory containing
`main.db`, or a legacy single-file database — it is adopted: `main.db`
(or the single file) becomes `paskia.kantadb`, `users/` becomes
`paskia.data/users/`, and the old directory is renamed aside to
`<name>.converted-bak`. Multiple candidates → startup error listing
them, asking the operator to remove or rename strays (e.g. a
`*.bak.paskiadb` backup); empty directories are ignored. Adoption runs
as an explicit pre-flight step in the serve command, before the
read-only startup open — read-only opens never trigger adoption or
writes.
- **Legacy conversion**: `paskia migrate` converts a legacy
`*.paskiadb` database — a directory containing `main.db`, or a legacy
single-file database — into `paskia.kantadb`: `main.db` (or the single
file) becomes `paskia.kantadb`, `users/` becomes `paskia.data/users/`,
and the old directory is renamed aside to `<name>.converted-bak`. A
lone candidate converts without options; with several candidates
`--rp-id <rp-id>` selects `<rp-id>.paskiadb` by name and the rest are
left in place (e.g. a `*.bak.paskiadb` backup does not block
conversion). Empty directories are ignored. Conversion is an explicit
operator action, never a serve side effect — read-only opens never
trigger conversion or writes.
- The legacy database's structs live in a separate module
(`paskia/db/legacy.py`). There is no multi-database merging.
- The startup box prints per-realm lines.
@@ -417,7 +421,7 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
- One `Kanta` for `paskia.kantadb`, opened once in the lifespan; one
background cleanup task (DB is global).
- The kanta bootstrap hook only ever fires for a database created by
`paskia init`; the serve command never bootstraps.
`paskia init` or `paskia migrate`; the serve command never bootstraps.
- The registry is built from the stored `Config` after open; per-realm
`Passkey` instances constructed (each realm's origins validated at
startup — fail-fast, including related-origin cap checks).
+28 -6
View File
@@ -28,6 +28,7 @@ from paskia.util.runtime import ServeConfig
EPILOG = """\
Examples:
paskia init --rp-id example.com --rp-name "Example Corporation" --auth-host auth.example.com
paskia migrate --rp-id example.com
paskia
"""
@@ -87,8 +88,8 @@ def cmd_init(args: argparse.Namespace) -> None:
if found := legacy.find_legacy_databases():
names = ", ".join(str(p) for p in found)
raise SystemExit(
f"Legacy database(s) found ({names}) — run 'paskia' to adopt "
"and convert, not 'paskia init'."
f"Legacy database(s) found ({names}) — run 'paskia migrate' to "
"convert, not 'paskia init'."
)
rp_ids = _split_multi(args.rp_id) or ["localhost"]
@@ -148,14 +149,22 @@ def cmd_init(args: argparse.Namespace) -> None:
)
def cmd_migrate(args: argparse.Namespace) -> None:
"""Convert a legacy <rp-id>.paskiadb database to paskia.kantadb."""
rp_id = legacy.migrate_legacy_database(args.rp_id)
print(f"✅ Converted legacy database to {db_file_path()} (realm: {rp_id})")
def cmd_serve(args: argparse.Namespace) -> None:
"""Open the combined database and serve all configured realms."""
db_path = db_file_path()
if not db_path.exists():
adopted = legacy.adopt_legacy_if_present()
if adopted:
print(f"✅ Converted legacy database to {db_path} (realm: {adopted})")
if not db_path.exists():
if found := legacy.find_legacy_databases():
names = ", ".join(str(p) for p in found)
raise SystemExit(
f"Database {db_path} not found, but legacy database(s) exist "
f"({names}) — run 'paskia migrate' to convert."
)
raise SystemExit(f"Database {db_path} not found — run 'paskia init' first.")
config = _load_stored_config(db_path)
@@ -233,9 +242,22 @@ def main():
)
_add_listen_option(init_parser, help_extra=" (stored in the database)")
migrate_parser = argparse.ArgumentParser(
prog="paskia migrate",
description="Convert a legacy <rp-id>.paskiadb database to paskia.kantadb",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
migrate_parser.add_argument(
"--rp-id",
help="rp-id of the legacy database to convert, selecting "
"<rp-id>.paskiadb when several legacy candidates exist.",
)
argv = sys.argv[1:]
if argv and argv[0] == "init":
cmd_init(init_parser.parse_args(argv[1:]))
elif argv and argv[0] == "migrate":
cmd_migrate(migrate_parser.parse_args(argv[1:]))
else:
cmd_serve(parser.parse_args(argv))
+27 -17
View File
@@ -8,7 +8,7 @@ current schema are redefined here; unchanged structs are imported from
Assumes the on-disk records are in the latest legacy format (schema
migrations were discarded together with the old format). This module will
be deleted once legacy adoption is no longer supported.
be deleted once legacy conversion is no longer supported.
"""
from __future__ import annotations
@@ -204,31 +204,41 @@ def find_legacy_databases(cwd: Path | None = None) -> list[Path]:
return candidates
def adopt_legacy_if_present() -> str | None:
"""Convert a lone legacy database to ``paskia.kantadb`` if present.
def migrate_legacy_database(rp_id: str | None = None) -> str:
"""Convert a legacy database to ``paskia.kantadb``.
Returns the adopted realm's rp-id, or None when ``paskia.kantadb``
already exists or no legacy database is present. The converted legacy
directory/file is renamed aside to ``<name>.converted-bak`` rather than
deleted.
With ``rp_id``, selects the ``<rp-id>.paskiadb`` candidate by name;
without it, exactly one candidate must exist. Returns the migrated
realm's rp-id. The converted legacy directory/file is renamed aside
to ``<name>.converted-bak`` rather than deleted.
Raises SystemExit when multiple legacy databases are found — automatic
merging is not supported.
Raises SystemExit when ``paskia.kantadb`` already exists, when no
candidate matches, or when several candidates exist and no ``rp_id``
was given to select one.
"""
target = db_file_path()
if target.exists():
return None
raise SystemExit(f"Database {target} already exists — nothing to migrate.")
candidates = find_legacy_databases()
if not candidates:
return None
if len(candidates) > 1:
if rp_id is not None:
name = f"{rp_id}.paskiadb"
matches = [c for c in candidates if c.name == name]
if not matches:
found = ", ".join(str(c) for c in candidates) or "none"
raise SystemExit(
f"No legacy database {name} in this directory (candidates: {found})."
)
src = matches[0]
elif not candidates:
raise SystemExit("No legacy *.paskiadb database found — nothing to migrate.")
elif len(candidates) > 1:
names = ", ".join(str(c) for c in candidates)
raise SystemExit(
f"Multiple legacy databases found ({names}). Automatic merging is "
"not supported — remove or rename all but the one to adopt."
f"Multiple legacy databases found ({names}) — select one with "
"'paskia migrate --rp-id <rp-id>'."
)
src = candidates[0]
else:
src = candidates[0]
legacy_file = src / "main.db" if src.is_dir() else src
config = convert_legacy_database(legacy_file, target)
+8 -3
View File
@@ -151,11 +151,16 @@ def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) ->
"""Bootstrap paskia.kantadb via 'paskia init' when no database exists.
Realm options are init-only; 'paskia' (serve) reads all configuration
from the database. A legacy *.paskiadb database is adopted by serve,
so no init is run in that case either.
from the database. A legacy *.paskiadb database must be converted with
'paskia migrate' first.
"""
if db_file_path().exists() or find_legacy_databases():
if db_file_path().exists():
return
if find_legacy_databases():
raise SystemExit(
"Legacy *.paskiadb database found — run 'paskia migrate' to "
"convert it before starting the dev server."
)
cmd = [sys.executable, "-m", "paskia", "init", f"--listen={listen}"]
for rp_id in rp_ids:
+56 -8
View File
@@ -1,8 +1,9 @@
"""Tests for the CLI entry point in paskia/__main__.py.
The CLI is split into ``paskia init`` (create the combined paskia.kantadb
with the initial realm(s)) and bare ``paskia`` (serve the stored realms,
adopting a lone legacy ``<rp-id>.paskiadb`` database if present).
with the initial realm(s)), ``paskia migrate`` (convert a legacy
``<rp-id>.paskiadb`` database), and bare ``paskia`` (serve the stored
realms; never migrates).
"""
from __future__ import annotations
@@ -165,7 +166,13 @@ def test_serve_listen_override_not_persisted(run_cli, tmp_path):
assert stored_config(tmp_path).listen == ["4402"]
def test_serve_adopts_legacy_database(run_cli, tmp_path):
def test_serve_suggests_migrate_when_legacy_present(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="example.com"))
with pytest.raises(SystemExit, match="paskia migrate"):
run_cli()
def test_migrate_converts_legacy_database(run_cli, tmp_path):
src_dir = write_legacy_db(
tmp_path, legacy.LegacyConfig(rp_id="example.com", rp_name="Legacy Name")
)
@@ -174,12 +181,12 @@ def test_serve_adopts_legacy_database(run_cli, tmp_path):
avatar.mkdir(parents=True)
(avatar / "profile.webp").write_bytes(b"RIFF1234WEBP")
run_cli()
run_cli("migrate")
config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["example.com"]
assert config.realms[0].rp_name == "Legacy Name"
# Legacy directory renamed aside, user files adopted
# Legacy directory renamed aside, user files moved over
assert not src_dir.exists()
assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir()
assert (
@@ -191,11 +198,41 @@ def test_serve_adopts_legacy_database(run_cli, tmp_path):
).read_bytes() == b"RIFF1234WEBP"
def test_serve_multiple_legacy_databases_abort(run_cli, tmp_path):
def test_migrate_multiple_legacy_databases_require_rp_id(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
with pytest.raises(SystemExit, match="Multiple legacy"):
run_cli()
with pytest.raises(SystemExit, match="--rp-id"):
run_cli("migrate")
def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="two.com"))
run_cli("migrate", "--rp-id", "two.com")
config = stored_config(tmp_path)
assert [r.rp_id for r in config.realms] == ["two.com"]
# The other candidate is left in place
assert (tmp_path / "one.com.paskiadb").is_dir()
assert (tmp_path / "two.com.paskiadb.converted-bak").is_dir()
def test_migrate_unknown_rp_id(run_cli, tmp_path):
write_legacy_db(tmp_path, legacy.LegacyConfig(rp_id="one.com"))
with pytest.raises(SystemExit, match="nope.com.paskiadb"):
run_cli("migrate", "--rp-id", "nope.com")
def test_migrate_refuses_existing_database(run_cli):
run_cli("init")
with pytest.raises(SystemExit, match="already exists"):
run_cli("migrate")
def test_migrate_without_legacy_database(run_cli):
with pytest.raises(SystemExit, match="No legacy"):
run_cli("migrate")
def test_cli_help():
@@ -218,3 +255,14 @@ def test_cli_init_help():
)
assert result.returncode == 0
assert "Bootstrap" in result.stdout
def test_cli_migrate_help():
result = subprocess.run(
[sys.executable, "-m", "paskia", "migrate", "--help"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert "Convert" in result.stdout