Skip to content

feat(digital-archive): digital-asset manifest schema + watermark trigger - #2323

Merged
jakebromberg merged 4 commits into
mainfrom
task/digital-asset-manifest-schema
Aug 29, 2026
Merged

feat(digital-archive): digital-asset manifest schema + watermark trigger#2323
jakebromberg merged 4 commits into
mainfrom
task/digital-asset-manifest-schema

Conversation

@jakebromberg

Copy link
Copy Markdown
Member

Summary

  • Declares digital_asset_store, digital_asset, digital_asset_file, and catalog_export_flag_state in wxyc_schema (migration 0158_digital-asset-manifest) — the manifest substrate for the digital archive: which library.ids have audio, in which store, under what object keys, with what codec and checksums. Generalized so today's hand-uploaded auto-DJ MP3s (provenance = 'rotation_upload') and tomorrow's verified CD rips ('cd_rip') share the same tables; the rip-evidence columns on digital_asset are all nullable now so the CD-rip phase adds rows, not columns.
  • Attaches the library_watermark statement trigger to digital_asset, narrowed to UPDATE OF status, library_id (mirroring migration 0142's narrowing of the library trigger — those are the two columns that affect export eligibility), and adds a callable touch_library_watermark_now() wrapper reproducing migration 0104's monotonic GREATEST(now(), last_modified_at) formula exactly, since the existing trigger function is RETURNS trigger and can't be invoked from application code (migration 0159_digital-asset-watermark).
  • Both migrations are additive DDL only — no data backfill.

Closes #2318

Test plan

  • npm run lint:migrations, scripts/check-precondition-guards.sh, node scripts/check-bulk-update-analyze.mjs --strict all green
  • npm run typecheck, npm run lint, npm run format:check all green
  • npm run test:unit — 503 suites / 8887 tests pass, including the new tests/unit/database/schema.digital-asset.test.ts
  • Hand-verified against a disposable PostgreSQL 14 container (matching prod): the full pending migration chain applies cleanly in one transaction; SELECT wxyc_schema.touch_library_watermark_now() advances library_watermark.last_modified_at; an INSERT or a status-column UPDATE on digital_asset advances the watermark, an unrelated-column UPDATE does not, and DELETE still advances it

…trigger

Declares digital_asset_store, digital_asset, digital_asset_file, and catalog_export_flag_state in wxyc_schema (migration 0158) — the manifest substrate for the digital archive (epic WXYC/wxyc-dj-ios#135): which library.id has audio, in which store, under what object key, with what codec and checksums. Generalized so hand-uploaded auto-DJ MP3s and future verified CD rips share the same tables rather than two schemas; the rip-evidence columns on digital_asset are all nullable now so the CD-rip phase adds rows, not columns.

A second migration (0159) attaches library_watermark's statement trigger to digital_asset, narrowed to UPDATE OF status, library_id (mirroring 0142's narrowing of the library trigger — those are the two columns that affect export eligibility), and adds a callable touch_library_watermark_now() wrapper reproducing 0104's monotonic GREATEST(now(), last_modified_at) formula exactly, since the existing trigger function is RETURNS trigger and can't be invoked from application code.

Both migrations are additive DDL only, with no data backfill. Unit tests pin the generated DDL by regex, following the schema.library-watermark.test.ts / schema.concerts.test.ts pattern. Verified by hand against a disposable PostgreSQL 14 container (matching prod): the full migration chain applies cleanly, touch_library_watermark_now() advances the watermark, and the trigger correctly narrows — an INSERT or a status-column UPDATE advances the watermark, an unrelated-column UPDATE does not, and DELETE still advances it.
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

Schema constraint shape report

Probed: automated prod RDS snapshot 2026-08-29T05:04:39.282000+00:00, restored to ephemeral sandbox for run 33277374676 (schema wxyc_schema), before the pending migrations were applied.

This PR adds:

  • query failed: relation "wxyc_schema.digital_asset_store" does not existUNIQUE (name) on digital_asset_store (digital_asset_store_name_idx)
  • query failed: relation "wxyc_schema.digital_asset" does not existUNIQUE (library_id, provenance, disc_number) on digital_asset (digital_asset_library_provenance_disc_idx)
  • query failed: relation "wxyc_schema.digital_asset_file" does not existUNIQUE (store_id, object_key) on digital_asset_file (digital_asset_file_store_object_key_idx)
  • query failed: relation "wxyc_schema.digital_asset" does not existFOREIGN KEY (library_id) REFERENCES library(id) on digital_asset
  • query failed: relation "wxyc_schema.digital_asset" does not existFOREIGN KEY (ripped_by) REFERENCES auth_user(id) on digital_asset
  • query failed: relation "wxyc_schema.digital_asset_file" does not existFOREIGN KEY (asset_id) REFERENCES digital_asset(id) on digital_asset_file
  • query failed: relation "wxyc_schema.digital_asset_file" does not existFOREIGN KEY (store_id) REFERENCES digital_asset_store(id) on digital_asset_file

one or more probes failed; see above. The check status is non-blocking.

`digital_asset.library_id` is a real FK at `library.id`, so the dedup job's
enforced-fk-actions invariant test correctly refused a schema that added it
without a matching `FK_TARGETS` entry: on a merge the loser's assets would
have been left pointing at a row the merge deletes.

`(library_id, provenance, disc_number)` is UNIQUE with all three NOT NULL
(`disc_number` defaults to 1), so the key is total and needs no
`uniqueWhenNull`. Where both sides of a merge hold the same
(provenance, disc_number), the loser's binding row is dropped as a
collision; the audio objects themselves hang off `digital_asset_file` under
the store and are untouched, so nothing in object storage is orphaned.
The FK_TARGETS comment claimed the collision-delete "drops only the binding row" and that "nothing in object storage is orphaned". Both halves are false: `digital_asset_file.asset_id` cascades off `digital_asset(id)`, so dropping a colliding binding row takes every file row under it — object_key, the digests, bitrate, duration, tags — and `fillNullsFromLoser` carries none of it across first. The objects survive in the store with nothing pointing at them, and a comment asserting otherwise is exactly what would stop someone building the reclamation path.

The delete stays, because BS#2319's bind job discovers assets by scanning the store, so an orphan is re-bindable rather than lost. What was missing is that nothing said so: `repointTarget` now names the orphaned object keys before the cascade removes the rows that identify them. Preferring the servable asset over the survivor's arbitrary one is the correct behaviour and is deliberately left to its own change rather than smuggled into a schema PR.

Also pins the new FK's enforced delete action in the integration spec's EXPECTED map — NO ACTION is deliberate here, so an incomplete repoint fails loudly instead of cascading — corrects the now-stale "three of thirteen sites" counts in the header and README, and widens the rip-evidence nullability regex to end-of-line: it stopped at the first whitespace, so for a multi-word type like `timestamp with time zone` it captured only `"ripped_at" timestamp` and could never observe a NOT NULL.
…atalog

The previous commit incremented a stale count rather than correcting one. "Fourteen sites" matches no inventory in the repo: FK_TARGETS holds 15 entries, the database enforces 13 FKs on `library.id`, and the README table lists 16 rows. The meaningful denominator for "still raise" is the 13 real FK constraints, since the two FK-less entries in FK_TARGETS cannot raise either. The adjacent "six of the sites cascade" was stale the same way — the enforced count is seven — and the README total predates `digital_asset`.

The comment claiming this is "the only collision-delete that reaches beyond its own row" was also literally false: `flowsheet.rotation_id` references `rotation`, so a rotation collision-delete reaches beyond too. That FK is ON DELETE set null, so it loses a link and never a row — which is the distinction the warning exists for, and the claim is now narrowed to destroying rows rather than merely reaching.

The EXPECTED omission this PR already fixed could recur, because the test written to catch omissions compares the catalog against FK_TARGETS while its sibling iterates EXPECTED — so an FK added with its FK_TARGETS entry but no EXPECTED entry passes both, which is exactly what happened. The catalog-to-code test now asserts against both maps, closing the hole rather than the instance.
@jakebromberg
jakebromberg merged commit b747ac1 into main Aug 29, 2026
7 checks passed
@jakebromberg
jakebromberg deleted the task/digital-asset-manifest-schema branch August 29, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

digital_asset manifest schema: store / asset / file / catalog_export_flag_state tables + library_watermark trigger and callable wrapper

1 participant