feat(pg): Works schema with deferred ownership keys (L-W-1 1/4)

Merged
FO
19h ago
+1745-7

Requested by Nikola · project thread

Summary

Slice L-W-1, PR 1 of 4: the Works-family schema on PostgreSQL. Nothing in the running app uses it yet.

Before: the PostgreSQL baseline had no Works tables. On SQLite, Work/Manifestation ownership is guarded by ten triggers, a retirement marker and a CHECK(0) guard table.

After: backend/db/schema/100_works.sql creates all eight Works tables of §6.5: works, manifestations, assets (catalogue only), manifestation_relations, manifestation_identifiers, work_notes, sync_work_lifecycle and duplicate_decisions. With these in place, later 0.8 Works slices add no canonical table. Ownership is enforced only by keys, with no triggers:

  • Every row is library-scoped (PG6), and each child reaches its parent through a composite key that includes library_id.
  • works.primary_manifestation_id (required) and citation_manifestation_id are DEFERRABLE INITIALLY DEFERRED composite FKs to manifestations (library_id, id, work_id), so they are checked at COMMIT. The same holds for manifestations.primary_asset_id to assets (library_id, id, manifestation_id), and for assets.supersedes_asset_id (same Manifestation), so a superseding pair moves together, or the link clears and one end moves, in either order.
  • assets (manifestation_id, work_id) follows its Manifestation by ON UPDATE CASCADE. Relations have two deferred same-Work endpoint keys.
  • Per §6.4 there is no origin_work_id and no content_generation. A managed_file Asset must carry its storage key, that key's content_sha256 and an origin, and no stream identity. An external_stream must name its provider, provider_id and url (non-empty), and records no locator, byte size, hash, origin or provenance.
  • Revision-owning rows are never cascaded (§5.2). The keys from manifestations, assets and work_notes to their parents are NO ACTION, so the delete command removes each level explicitly (notes, Assets, Manifestations, then the Work) and tombstones its scopes; a delete that skips a level fails at the statement. Only manifestation_relations and manifestation_identifiers, which own no scope, cascade from their Manifestation. A schema test reads pg_constraint and fails if any key on works, manifestations, assets or work_notes is ON DELETE CASCADE: L-W-1's share of the §13.2 revision-ownership gate.
  • sync_work_lifecycle and duplicate_decisions have no entity FK, so they outlive the rows. A Library that still has Works cannot be deleted.
  • Parent keys for later lanes: roles and argument_sources reference manifestations (library_id, id, work_id), and annotations references assets (library_id, id, work_id).

backend/domain/works/integrity.py holds the final-state query (§4.1). It covers the two rules no constraint can express: active Assets with no primary Asset, and an inactive primary Asset.

Related issues

Refs #613
Refs #628

Scope / non-goals

  • Schema and schema tests only. The projection, commands, facade methods and sync handlers come in PRs 2–4 (plan: projection/reads/create, then mutations, then sync handlers).
  • manifestation_credit_overrides is not included; it comes with #60 Slice K.
  • No server.py change. No job is enqueued.

Validation

Local, PostgreSQL 18.1 cluster (builtin C.UTF-8 locale), on head 1c1c3b3:

  • tests/pg/test_works_schema.py: 36 tests passing. These are the #60 §4.1 negative tests as schema tests:
    • each contradictory pointer, owner or cross-Library row is refused;
    • Work-without-primary, pointer-target move and delete, moving a primary Asset without changing the pointer, and moving one end of a supersedes pair each fail at COMMIT (the test asserts every statement ran first);
    • legal paths commit: pointer swap then move, moving the only primary Asset out in either order, moving a supersedes pair, and the emptied-Work delete and merge outcomes;
    • a failed transition leaves the pointers and owner columns unchanged;
    • level-by-level Work delete (and refusal of a delete that skips a level), the no-cascade key check, relation rules, kind/locator/hash/stream-identity checks, ID formats, lifecycle and duplicate-decision checks, and the integrity query.
  • Mutation check: with DEFERRABLE removed from manifestations_primary_asset_fkey, 5 of these tests fail.
  • scripts/test-postgres (all of tests/pg): OK. scripts/test-postgres --parity: OK, unchanged at 0 ported.
  • scripts/check_pg_schema.py: the fresh schema matches the regenerated snapshot.json. scripts/check_schema_change.py: OK.
  • Ruff, scripts/check_invariants.py, Pyright typed slice: clean.
  • No browser E2E locally: nothing here is reachable from the app.

GitHub CI on 1c1c3b3: all required checks green, including the Full E2E Gate (six shards), PostgreSQL helper tests and backend parity. Codacy reports its usual accepted "action required" items.

Migration / compatibility impact

New PostgreSQL baseline file (pre-1.0, no forward migration). SQLite is unchanged.

Risks / follow-up

  • Shared files with L-J-1 (#627): snapshot.json, pyrightconfig.typed-slice.json, the PYRIGHT_TYPED_SLICE_SCOPES list in scripts/check_invariants.py and its pinned copy in tests/test_engineering_invariants.py. Whichever PR merges second integrates master and regenerates with scripts/check_pg_schema.py --write.
  • tests/pg/test_migrate.py no longer assumes 040_sync.sql is the last baseline file. Its three affected tests now derive the last file, and the pinned file list gains 100_works.sql.
  • Choices for review:
    • supersedes_asset_id is limited to the same Manifestation (#60 §9.1); derived_from_asset_id may be any Asset of the Library.
    • Asset origin drops legacy, since no legacy rows exist.
    • work_notes.body may be empty; PR 2's projection decides '' vs. no row against the SQLite fixtures.
    • duplicate_decisions stores (entity_type, low_id, high_id) with low_id < high_id in C collation.

Checklist

  • Relevant tests/checks were run and are listed above.
  • Documentation/generated files were updated when required.
  • UI evidence is included when applicable.
  • No unrelated changes are intentionally included.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WXi9o7Y2iX6RMojbJ2eRqT