feat(pg): Work projection, reads and create on the facade (L-W-1 2/4)

Merged
FO
13h ago
+1627-20

Requested by Nikola · project thread

Summary

Slice L-W-1, PR 2 of 4, on top of the Works schema from #770. The running app still uses SQLite only.

Before: the PostgreSQL facade had no Works methods. Any Work call on it raised NotPortedError.

After: the facade creates and reads Works and returns the same JSON as SQLite. Fifteen methods are @ported with their PRKSDatabase signatures:

  • create: add_work, generate_id;
  • reads: get_work, get_all_works, get_works_browse_catalog, get_recent_browse, get_recently_added_browse, get_recent_works, get_recently_added_works, get_work_summaries_by_ids_ordered, get_primary_thumbnail_fields;
  • ETags: etag_for_representation, etag_works_catalog, etag_recent_works, etag_recently_added_works.

How:

  • backend/domain/works/projection.py builds today's Work JSON from the Work, its primary Manifestation and that Manifestation's primary Asset. It keeps SQLite's key order, row ordering and timestamp text. Manifestation and active-Asset counts come from GROUP BY work_id derived tables, not per-row subqueries.
  • queries.py runs inside library_query, a REPEATABLE READ READ ONLY snapshot, so a multi-statement projection never mixes pre- and post-write state.
  • commands.py runs inside library_command, which takes the Library maintenance lock, authorizes, and checks the epoch.
  • Create is SERIALIZABLE. It inserts the Work, the primary Manifestation, the Asset for a source and the notes in one transaction, so the deferred pointer keys are checked at COMMIT.
  • Create checks that the bound actor holds EDIT_LIBRARY before it looks at or hashes a managed file, so a caller who may not edit can't learn whether a storage key exists. The command transaction re-authorizes and checks the epoch.
  • Create refuses a managed PDF whose storage key an Asset of another Library already names (one data root serves every Library, §6.2). The check runs in the SERIALIZABLE transaction, so two Libraries racing for one key cannot both commit. The full §11.2 rule stays with L-A-1.
  • backend/pg_facade/context.py binds the RequestContext a facade call acts for through a context variable, so PRKSDatabase signatures and server.py stay as they are. A call with no bound context is refused, never run as an implicit owner. The parity fixture binds a signed-in owner of a fresh Library.

Related issues

Refs #613
Refs #628

Scope / non-goals

  • Reads and create only. Mutations (open, metadata, notes, source, delete, with §7.2 revision locking) are PR 3. The sync handlers (CREATE_WORK, DELETE_WORK, MARK_WORK_OPENED, SET_WORK_METADATA_FIELD, SET_WORK_SOURCE, notes) are PR 4.
  • No server.py change. No job is enqueued. People, Organization and Research files are untouched.
  • Roles, tags, folders and playlists appear in the Work JSON as empty until their lanes port them. Any test that creates them reaches an unported method, so it is skipped and never counted as parity.
  • Documented PostgreSQL differences, each pinned by a test:
    • A managed file must exist, because create hashes it.
    • A video with a file is refused, because a stream stores no bytes.
    • A kind-only pdf with no file reads source_kind None.
    • Source presentation fields need an Asset.
    • A managed PDF another Library's Asset names is refused.

Validation

Local, PostgreSQL 18 cluster, on head 2612b3c (master 79a7ad0 merged in):

  • scripts/test-postgres (all of tests/pg): 550 tests OK. scripts/test-postgres --parity: OK. tests.test_works_core_parity, tests.test_works_projection_shape: OK. Each ported method has a direct parity test against a SQLite PRKSDatabase.
  • tests/pg/test_works_facade.py covers refusal without an actor or with the wrong actor, refusal before any managed file is touched (existing and missing keys, unbound and reader callers), two-Library isolation, the cross-Library PDF refusal and race, the rows create writes and their pointer keys, timestamp precision and ordering, and concurrent creates.
  • scripts/check_pg_schema.py: matches the snapshot (no schema change). scripts/check_invariants.py: OK. scripts/check_correlated_catalog_counts.py against master: OK. scripts/check_schema_change.py: OK.
  • ruff check --config ruff.toml prks_app.py backend: clean.
  • Pyright: pyrightconfig.typed-slice.json 0 errors. pyrightconfig.json 0 errors; its 3 warnings are the unresolved pymupdf import in this environment.
  • tests.test_engineering_invariants, tests.test_pg_facade_registry: OK.
  • No browser E2E locally: nothing here is reachable from the app.

CI on 2612b3c: every GitHub Actions job passed, including the Full E2E Gate (all six shards and pointer capture), PostgreSQL helper tests, backend parity, unit/API/contract, Pyright, Ruff, ESLint, Vue, CodeQL and the workflow audits.

Migration / compatibility impact

None. No schema change, and SQLite behavior is unchanged.

Risks / follow-up

  • Shared files with L-J-1 (#627): pyrightconfig.typed-slice.json, PYRIGHT_TYPED_SLICE_SCOPES in scripts/check_invariants.py and its pinned copy in tests/test_engineering_invariants.py, plus AGENTS.md. This branch already carries both lanes' entries from master. #777 touches the adjacent AGENTS.md lane line, so whichever of the two merges second needs that one line resolved.
  • tests/backend_parity.py gains the session-context binding for the postgres leg. L-J-1 does not edit it.
  • tests/test_pg_facade_registry.py now uses a bare Library stand-in for its synthetic registrations, because PostgresLibrary now has real Works methods.
  • PRs 3 and 4 are built and tested locally on top of this branch. They open after this one.

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