From 4a1597ad6b8741b7575783ce0ef4fde8d193fcd7 Mon Sep 17 00:00:00 2001 From: Thor Whalen <1906276+thorwhalen@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:29:35 +0000 Subject: [PATCH] Key the Postgres tenant on an id minted at Project.init (#109) project.json gets a tenant_id at init, preserved by write_spec and the graph migration. Postgres keys the graph on it, so a recreated project at the same path and title starts empty. Projects without one keep their historical tenant, so existing graphs stay readable. open_graph_store / iter_scope_stores gain an optional tenant argument. Co-Authored-By: Claude Sonnet 5.5 --- nw/graph_backend.py | 19 +++++++- nw/migrate.py | 25 +++++++++- nw/project.py | 16 ++++++- nw/storage.py | 34 +++++++++++-- nw/storyboard.py | 15 +++++- tests/test_tenant_id.py | 104 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 tests/test_tenant_id.py diff --git a/nw/graph_backend.py b/nw/graph_backend.py index df1f7b0..03c7869 100644 --- a/nw/graph_backend.py +++ b/nw/graph_backend.py @@ -138,6 +138,7 @@ def open_graph_store( db_path: Path | str, *, asset_id: AssetIdLike, + tenant: Optional[AssetIdLike] = None, scope: Optional[str] = None, rate: Optional[int] = None, env: Mapping[str, str] | None = None, @@ -156,6 +157,10 @@ def open_graph_store( tenant anchor — or a zero-argument callable returning it. Ignored in SQLite mode, where a callable is never called (so a hot path can pass ``lambda: storage.asset_id`` without paying to derive it). + tenant: What keys the Postgres tenant (same string-or-callable form), + when it is not the asset id. A project's graph is keyed on the id + minted when it was created, so a recreated project does not + inherit the old one's graph. Defaults to ``asset_id``. scope: The logical scope name (``"graph"`` / ``"storyboard"`` / ``"alignment"`` / …). Defaults to deriving it from ``db_path``. rate: Project-wide rate for the Postgres store. Defaults to lacing's @@ -174,7 +179,12 @@ def open_graph_store( scope = scope_name_for_db(db_path) if selected_backend(env) == _POSTGRES: - store = _open_postgres(asset_id=asset_id, scope=scope, rate=rate, env=env) + store = _open_postgres( + asset_id=tenant if tenant is not None else asset_id, + scope=scope, + rate=rate, + env=env, + ) if store is not None: return store # _open_postgres logged a warning and we fall through to SQLite. @@ -225,6 +235,7 @@ def iter_scope_stores( scope_paths: Mapping[str, Path], *, asset_id: AssetIdLike, + tenant: Optional[AssetIdLike] = None, env: Mapping[str, str] | None = None, ) -> Iterator[Iterator[IntervalAnnotationStore]]: """Yield an iterator of open stores, one per scope, backend chosen by env. @@ -243,6 +254,8 @@ def iter_scope_stores( asset_id: The project's ``project_asset_id`` (Postgres tenant anchor), or a zero-argument callable returning it (never called in SQLite mode). + tenant: What keys each Postgres tenant instead of ``asset_id``; see + :func:`open_graph_store`. env: Environment mapping. Defaults to ``os.environ``. Yields: @@ -258,7 +271,9 @@ def _gen() -> Iterator[IntervalAnnotationStore]: for scope, db_path in scope_paths.items(): if backend == _SQLITE and not Path(db_path).exists(): continue - store = open_graph_store(db_path, asset_id=asset_id, scope=scope, env=env) + store = open_graph_store( + db_path, asset_id=asset_id, tenant=tenant, scope=scope, env=env + ) try: yield store finally: diff --git a/nw/migrate.py b/nw/migrate.py index 4f0d207..1b3dfab 100644 --- a/nw/migrate.py +++ b/nw/migrate.py @@ -101,7 +101,10 @@ def open_project_graph(project_root: Path) -> IntervalAnnotationStore: """ db = project_graph_db_path(project_root) store = open_graph_store( - db, asset_id=lambda: project_asset_id(project_root), scope=SCOPE_GRAPH + db, + asset_id=lambda: project_asset_id(project_root), + tenant=lambda: project_tenant_id(project_root), + scope=SCOPE_GRAPH, ) _ensure_tiers(store) return store @@ -157,7 +160,10 @@ def open_project_graph_readonly(project_root: Path) -> IntervalAnnotationStore: db = project_graph_db_path(project_root) if selected_backend(os.environ) == "postgres": return open_graph_store( - db, asset_id=lambda: project_asset_id(project_root), scope=SCOPE_GRAPH + db, + asset_id=lambda: project_asset_id(project_root), + tenant=lambda: project_tenant_id(project_root), + scope=SCOPE_GRAPH, ) if not Path(db).exists(): raise FileNotFoundError(f"no graph database for project: {project_root}") @@ -188,6 +194,20 @@ def project_asset_id(project_root: Path) -> str: return asset_id_of(project_root, spec) +def project_tenant_id(project_root: Path) -> str: + """The id a shared backend keys the project's graph on. + + The id minted at ``Project.init`` (``project.json``'s ``tenant_id``); for a + project that predates it, its historical :func:`project_asset_id`. The rule + is :func:`nw.storage.tenant_id_of`. + """ + from .storage import tenant_id_of + + project_json = Path(project_root) / "project.json" + spec = json.loads(project_json.read_text()) if project_json.exists() else None + return tenant_id_of(project_root, spec) + + # --------------------------------------------------------------------------- # Migration # --------------------------------------------------------------------------- @@ -370,6 +390,7 @@ def migrate_to_graph( "song": spec.get("song"), "global_style": spec.get("global_style", ""), "notes": spec.get("notes", ""), + **({"tenant_id": spec["tenant_id"]} if spec.get("tenant_id") else {}), # Tombstone marker so a human reading project.json knows where the # graph data went. "_graph_db": _PROJECT_GRAPH_DB_NAME, diff --git a/nw/project.py b/nw/project.py index 11c9662..9eacf27 100644 --- a/nw/project.py +++ b/nw/project.py @@ -25,6 +25,7 @@ import shutil from datetime import datetime, timezone from pathlib import Path +from uuid import uuid4 from typing import Any, Iterable, Optional @@ -72,7 +73,7 @@ def _seconds_to_rt(seconds: float): remove_annotations_with_traces, ) from .bodies import GENRE_ENVELOPE_TIER, UNPRODUCED_OUTPUT_TIER, VERIFYING_TRACE_TIER -from .storage import FolderStorage, ProjectStorage, as_project_storage +from .storage import TENANT_KEY, FolderStorage, ProjectStorage, as_project_storage from .migrate import ( _TIER_CHARACTER_REF, _TIER_DECISION, @@ -261,7 +262,14 @@ def init( schema_version=SCHEMA_VERSION, title=title or root.name, ) - docs[_PROJECT_FILE_NAME] = json.loads(spec.model_dump_json()) + # ``tenant_id`` is minted here, once, and is what a shared (Postgres) + # backend keys the graph on: root + title is not an identity, because + # a removed folder recreated at the same path would inherit the old + # graph (nw#109). + docs[_PROJECT_FILE_NAME] = { + **json.loads(spec.model_dump_json()), + TENANT_KEY: uuid4().hex, + } # New projects are graph-native from the start; mark migrated so the # auto-migrator skips them. The graph store is created lazily on first @@ -380,6 +388,10 @@ def write_spec(self, spec: ProjectSpec) -> None: "notes": spec.notes, "_graph_db": "project.annot.sqlite", } + # The minted tenant id outlives every rewrite of project.json. + tenant = self._docs[_PROJECT_FILE_NAME].get(TENANT_KEY) + if tenant: + meta[TENANT_KEY] = tenant self._docs[_PROJECT_FILE_NAME] = meta # Graph entities — sync each tier to the spec by removing entries diff --git a/nw/storage.py b/nw/storage.py index 6517110..e85bc70 100644 --- a/nw/storage.py +++ b/nw/storage.py @@ -151,6 +151,25 @@ def asset_id_of(root: str | Path, spec: Optional[Mapping[str, Any]]) -> str: return hashlib.sha256(seed).hexdigest() +#: The key in ``project.json`` holding the id minted at ``Project.init``. +TENANT_KEY = "tenant_id" + + +def tenant_id_of(root: str | Path, spec: Optional[Mapping[str, Any]]) -> str: + """The id a shared (Postgres) backend keys this project's graph on. + + The id minted at ``Project.init`` and stored in ``project.json``, so a + project folder removed and created again at the same path with the same + title is a *new* tenant with an empty graph. A project that predates the + mint has none; it keeps its historical tenant (:func:`asset_id_of`), so the + graph it already has stays readable. + """ + minted = (spec or {}).get(TENANT_KEY) + if isinstance(minted, str) and minted: + return minted + return asset_id_of(root, spec) + + def _project_doc(docs: Mapping[str, Any]) -> Optional[Mapping[str, Any]]: try: return docs[PROJECT_FILE_NAME] @@ -202,6 +221,11 @@ def asset_id(self) -> str: return project_asset_id(self.root) + @property + def tenant_id(self) -> str: + """The shared-backend tenant key (:func:`tenant_id_of`).""" + return tenant_id_of(self.root, _project_doc(self.docs)) + def scope_paths(self) -> dict[str, Path]: """``{scope_name: legacy_sqlite_path}`` for this project's stores.""" from .migrate import project_graph_db_path @@ -224,10 +248,12 @@ def open_graph_readonly(self) -> IntervalAnnotationStore: @contextmanager def open_stores(self) -> Iterator[Iterator[IntervalAnnotationStore]]: - # Lazy: only the Postgres backend keys a tenant on the asset id, and - # deriving it hashes the song file. + # Lazy: only the Postgres backend keys a tenant, and deriving the + # asset id hashes the song file. with iter_scope_stores( - self.scope_paths(), asset_id=lambda: self.asset_id + self.scope_paths(), + asset_id=lambda: self.asset_id, + tenant=lambda: self.tenant_id, ) as stores: yield stores @@ -393,6 +419,8 @@ def as_project_storage(project: Any) -> ProjectStorage: "ProjectStorage", "as_project_storage", "asset_id_of", + "tenant_id_of", + "TENANT_KEY", "json_docs", "project_storages", "register_project_storage", diff --git a/nw/storyboard.py b/nw/storyboard.py index 21553b5..3822cee 100644 --- a/nw/storyboard.py +++ b/nw/storyboard.py @@ -61,6 +61,7 @@ from .graph import remove_annotations_with_traces from .graph_backend import SCOPE_STORYBOARD, open_graph_store, selected_backend +from .migrate import project_tenant_id from .project import Project from .transforms import OnFailure @@ -107,7 +108,12 @@ def open_storyboard(project: Project) -> Storyboard: # panels. if selected_backend() == "sqlite" and not db.exists(): return Storyboard(asset_id=asset_id) - store = open_graph_store(db, asset_id=asset_id, scope=SCOPE_STORYBOARD) + store = open_graph_store( + db, + asset_id=asset_id, + tenant=lambda: project_tenant_id(project.root), + scope=SCOPE_STORYBOARD, + ) try: return load_storyboard(store, asset_id=asset_id) finally: @@ -130,7 +136,12 @@ def save_storyboard( """ db = storyboard_db_path(project) asset_id = project_asset_id(project) - store = open_graph_store(db, asset_id=asset_id, scope=SCOPE_STORYBOARD) + store = open_graph_store( + db, + asset_id=asset_id, + tenant=lambda: project_tenant_id(project.root), + scope=SCOPE_STORYBOARD, + ) try: # The store enforces a foreign key on tier; register it first so the # save is not rejected. Idempotent — re-registering is a no-op in lacing. diff --git a/tests/test_tenant_id.py b/tests/test_tenant_id.py new file mode 100644 index 0000000..facdd1b --- /dev/null +++ b/tests/test_tenant_id.py @@ -0,0 +1,104 @@ +"""A project's shared-backend tenant is minted at ``Project.init`` (nw#109). + +Under Postgres a project's graph lives in a tenant keyed on an id. Keyed on +root + title (or the song's hash), a project folder removed and created again +with the same name and title reused the old tenant and the old graph — including +its ``project-meta`` row and stable id — came back with the new project. + +No Postgres is needed: a stand-in ``PostgresStore`` keeps one in-memory store +per ``project_id``, which is exactly the persistence a real tenant has. +""" + +from __future__ import annotations + +import json +import shutil + +import pytest +from lacing import MemoryStore, TimeInterval +import lacing.store + +import nw +from nw.migrate import project_asset_id, project_tenant_id +from nw.storage import TENANT_KEY, tenant_id_of + +_TENANTS: dict[str, MemoryStore] = {} + + +class _FakePostgresStore: + """Delegates to one shared ``MemoryStore`` per ``project_id``.""" + + def __init__(self, db_url, *, rate, owner_id, project_id): + self._project_id = project_id + self._inner = _TENANTS.setdefault(project_id, MemoryStore()) + + def __getattr__(self, name): + return getattr(self._inner, name) + + def close(self): # the tenant outlives the connection + pass + + +@pytest.fixture +def pg(monkeypatch): + _TENANTS.clear() + monkeypatch.setenv("NW_GRAPH_BACKEND", "postgres") + monkeypatch.setenv("NW_GRAPH_DB_URL", "postgresql://stand-in") + monkeypatch.setattr(lacing.store, "PostgresStore", _FakePostgresStore) + yield _TENANTS + _TENANTS.clear() + + +def _graph_size(project: nw.Project) -> int: + return len(list(nw.iter_all_annotations(project.root))) + + +def test_init_mints_a_tenant_id_that_survives_write_spec(tmp_path): + project = nw.Project.init(tmp_path / "p", title="t") + minted = json.loads((project.root / "project.json").read_text())[TENANT_KEY] + assert minted + project.write_spec(project.read_spec()) + assert json.loads((project.root / "project.json").read_text())[TENANT_KEY] == minted + assert project.storage.tenant_id == minted + + +def test_two_projects_get_distinct_tenant_ids(tmp_path): + a = nw.Project.init(tmp_path / "a", title="same") + b = nw.Project.init(tmp_path / "b", title="same") + assert a.storage.tenant_id != b.storage.tenant_id + + +def test_a_project_that_predates_the_mint_keeps_its_tenant(tmp_path): + root = tmp_path / "old" + root.mkdir() + spec = {"schema_version": 1, "title": "old"} + (root / "project.json").write_text(json.dumps(spec)) + assert tenant_id_of(root, spec) == project_asset_id(root) + assert project_tenant_id(root) == project_asset_id(root) + + +def test_recreated_project_does_not_inherit_the_old_graph(tmp_path, pg): + root = tmp_path / "p" + first = nw.Project.init(root, title="t") + first.graph.upsert_shot(_shot_body(), TimeInterval.from_seconds(0, 4)) + assert _graph_size(first) == 1 + old_tenants = set(pg) + + shutil.rmtree(root) + second = nw.Project.init(root, title="t") + assert _graph_size(second) == 0 + assert set(pg) - old_tenants, "the new project got a tenant of its own" + # The old tenant is untouched. + assert set(old_tenants) <= set(pg) + + +def test_storyboard_and_graph_follow_the_same_minted_tenant(tmp_path, pg): + project = nw.Project.init(tmp_path / "p", title="t") + project.graph.upsert_shot(_shot_body(), TimeInterval.from_seconds(0, 4)) + assert all(project.storage.tenant_id in pid for pid in pg) + + +def _shot_body(): + from nw.bodies import ShotBodyV1 + + return ShotBodyV1(shot_id="s01")