feat(pg): Work open, metadata, notes and delete on the facade (L-W-1 3/4)

Merged
FO
12h ago
+1567-95

Requested by Nikola · project thread

Summary

Slice L-W-1, PR 3 of 4, on top of the Work reads and create from #775. The running app still uses SQLite only.

Before: on PostgreSQL a Work could be created and read, but not opened, edited or deleted. Those facade calls raised NotPortedError.

After: the facade records opens, patches metadata and notes, and deletes Works with SQLite's results and revision behavior. Five more methods are @ported, each with a direct parity test: mark_work_opened, update_work_metadata, get_work_metadata_state, get_work_notes_state and delete_work_record.

How:

  • backend/domain/works/revisions.py implements §7.2 revision locking. It ensures the revision row exists, locks it FOR UPDATE, and advances it only when the value changes.
  • metadata.py, notes.py and source.py hold the field, note and source decision tables. They import the SQLite sync modules' validation, codecs and conflict shapes rather than copying them. The sync handlers in PR 4 call these commands.
  • Every command locks the Work row first, so an edit and a delete of one Work queue behind each other instead of deadlocking.
  • Delete removes the Work's notes, Assets, Manifestations and the Work level by level, because the keys are NO ACTION (§5.2). It tombstones every revision scope of the Work and reports whether another Asset of the same Library still names the managed file. It never touches the file on disk: claiming and removing bytes belongs to L-A-1 (§11.2).
  • An open goes through a max-register at server time, so concurrent opens converge to the latest.

Related issues

Refs #613
Refs #628

Scope / non-goals

  • Mutations only. The sync handlers (CREATE_WORK, DELETE_WORK, MARK_WORK_OPENED, SET_WORK_METADATA_FIELD, SET_WORK_SOURCE and the note operations) are PR 4.
  • No server.py change and no schema change. People, Organization and Research files are untouched.
  • Documented PostgreSQL differences, each pinned by a test:
    • update_work_metadata(file_path=...) raises NotPortedError. Adopting or replacing a managed file belongs to the objects lane.
    • text_content through a patch is written as the Research Note with its revision. SQLite writes the column without one.
    • An Asset field (thumb_page, source_mime) on a Work with no file or stream is refused.
    • delete_work_record returns the path but never claims or removes the file.

Validation

Local, PostgreSQL 18 cluster, on master 2aad8e1 plus this branch:

  • scripts/test-postgres (all of tests/pg): 596 tests OK. scripts/test-postgres --parity: OK.
  • tests/pg/test_works_mutations.py covers:
    • every decision-table row for fields, notes and sources;
    • concurrent writers of one scope: one is acknowledged and the rest get REVISION_CONFLICT;
    • writers of different fields never blocking each other out;
    • concurrent opens converging to the latest;
    • delete and edit ordering in both directions;
    • the tombstones a delete leaves;
    • another Library being unable to touch a Work;
    • a reader being refused every change.
  • tests/test_works_mutations_parity.py, tests/test_works_core_parity.py, tests/test_works_projection_shape.py: OK. Shared parity setup moved into tests/works_parity_helpers.py.
  • scripts/check_pg_schema.py: matches the snapshot. scripts/check_invariants.py, scripts/check_correlated_catalog_counts.py --base origin/master, scripts/check_schema_change.py: OK.
  • ruff check --config ruff.toml prks_app.py backend and the changed tests: clean.
  • Pyright: pyrightconfig.typed-slice.json 0 errors, now including the four new Works modules. 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.

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, and AGENTS.md. The new Works entries sit before the Jobs entries, and both lanes' lines are kept.
  • ActorTests.reader_context moved to the shared WorksFacadeTestCase so the mutation tests can use it.
  • PR 4 (sync handlers) is built locally on top of this branch and opens after this one merges.

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 by Claude Code


Summary by cubic

On PostgreSQL, Works could be created and read but not opened, edited, or deleted; those calls raised NotPortedError. This PR ports the five mutation facade methods — mark_work_opened, update_work_metadata, get_work_metadata_state, get_work_notes_state, and delete_work_record — with parity tests. The running app still uses SQLite; the sync handlers land in PR 4.

Behavior and implementation notes

  • Field, note, and source decision tables import the SQLite sync modules' validation, codecs, and conflict shapes; revision locking moves to per-scope FOR UPDATE rows (§7.2), advancing only when a value changes. Each field is stored on the Work, primary Manifestation, or primary Asset row that owns it.
  • Every command locks the Work row first, then reads the Manifestation and Asset in a fresh statement, so a command queued behind a concurrent writer decides on the values that writer committed. An edit and a delete of one Work queue behind each other instead of deadlocking.
  • Opens go through a server-time max-register, so concurrent opens converge to the latest.
  • Delete removes the Work's notes, Assets, Manifestations, and Work level by level, tombstones every revision scope of the Work, and reports whether another Asset of the Library still names the managed file; it never touches the file on disk.
  • update_work_metadata(file_path=...) raises NotPortedError (managed files belong to the objects lane), text_content is written as the Research Note with a revision, and an Asset field on a Work with no file or stream is refused.
  • No schema change; SQLite behavior is unchanged.