feat: thingiverse sync-state tables (schema v17)#225
Conversation
Version 17: thingiverse_things (managed things: thing_id, status enum, publish/sync timestamps) and thingiverse_files (per-file diff ledger: blueprint/image/ad-hoc local source, local md5+sha256, remote file id/name/hash, file-type enum for mixed model/image/zip content). CHECK enforces at least one local source; blueprint/image FKs are RESTRICT (SET NULL would trip the CHECK and make parent deletes fail confusingly); thing deletion cascades its ledger. local_sha256 included defensively pending hash-algorithm confirmation from the HAR contract (openforge_catalog-hnr). Local-only state: prod receives just the derived mapping via the fixture path (openforge_catalog-8yb). 8 schema-semantics tests; up/down round-trip verified on local db. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
devonjones
left a comment
There was a problem hiding this comment.
Review scope
This is a domain/schema-soundness review of PR #225 (feat: thingiverse sync-state tables (schema v17), openforge_catalog-750). Migration-discipline, serverless-architecture, and test-coverage concerns are being handled by separate reviews and are intentionally out of scope here; this focuses on whether the data model actually serves the sync engine (ad5) that depends on it, constraint semantics, and house-style consistency with version_02/12/16.
Critical
None. The schema is sound enough to build on; nothing here corrupts data or blocks bin/db_update up/down.
Medium
-
No uniqueness on
(thingiverse_thing_id, blueprint_id)/(..., image_id)/(..., local_path). Nothing stops twothingiverse_filesrows in the same thing from pointing at the same blueprint (or image, or local path). Given the ledger model appears to be "one row per remote file, updated in place asremote_file_id/remote_hashchange," a duplicate row for the same logical source is an ambiguous state the sync engine (ad5) will have to defend against at query time instead of the database preventing it outright. Partial unique indexes would close this without conflicting with the tri-source CHECK, e.g.:CREATE UNIQUE INDEX idx_thingiverse_files_thing_blueprint ON thingiverse_files(thingiverse_thing_id, blueprint_id) WHERE blueprint_id IS NOT NULL; -- same pattern for image_id, local_path
(A blueprint appearing in two different things is fine and should stay allowed — this is only about dupes within one thing.)
-
No persisted signal for thing-level metadata drift. The CHECK/hash machinery here only covers file content drift (
local_md5/local_sha256vsremote_hash).ad5's own notes call out the "shared-boilerplate description" case from the v1 tool as a lesson learned — i.e., the sync engine needs to detect when a thing's name/description has drifted, not just its files, and needs to do so without hammering the API for every managed thing on every run (the same notes flag throttling as a requirement). There's no column here to cache a last-known description/name hash onthingiverse_thingsto short-circuit that check cheaply. This may be a deliberate deferral tod7a/ad5(diff against a live fetch each time, no persisted state needed) — but if so it's worth confirming now, because it's exactly the kind of thing that forces a v18 migration next sprint if it turns out caching is wanted. Recommend at least a comment inad5/d7a's design about which approach is intended. -
thingiverse_files.blueprint_id/image_idmodel implies a blueprint can belong to multiple things, but8yb's prod fixture is framed as a single(blueprint_id, thing_id, public_url)mapping. Nothing here forces 1:1, and moving the blueprint linkage down to the file-ledger (rather than puttingblueprint_idonthingiverse_thingsas the original ticket description sketched) is the right domain call — a Thing legitimately bundles multiple files/blueprints, and a blueprint could legitimately be bundled into more than one Thing. But that means8yb's "derive the mapping" step has an open question baked in: what happens when a blueprint resolves to more than onething_id? Worth flagging now so8ybdoesn't quietly assume 1:1.
Minor
file_typeenum isn't tied to which source column is populated. The CHECK only enforces "at least one ofblueprint_id/image_id/local_pathis set" — nothing enforces thatfile_type = 'image'impliesimage_id IS NOT NULL, etc. The code comment describes the intended mapping ("catalog models point at blueprints, gallery photos at images...") but the DB doesn't enforce it. Might be intentional looseness (e.g. an ad-hoc STL not yet cataloged could still befile_type = 'model'with onlylocal_pathset), but if that's not intended, a stricter CHECK would catch a sync-engine bug that mislabels a row instead of silently persisting bad state.- No
deleted_atonthingiverse_things.published_at/last_synced_atare tracked but thestatus = 'deleted'transition has no matching timestamp, unlike its siblings. Minor asymmetry, cheap to add now, more annoying to backfill later if audit/history ever matters. - No size/mtime fields for the
local_path(ad-hoc artifact) case.blueprintsalready established a size+mtime+hash pattern (file_size,file_modified_at,file_md5) for cheap dirty-checking before a full hash;thingiverse_filesonly carries hashes for the zip/ad-hoc case, so any staleness check there is full-hash-only. Probably fine if these artifacts are always regenerated rather than checked for staleness, but worth a conscious call rather than an oversight. - No index on
thingiverse_things(status)or(thing_id)for lookups by status. Table will be tiny (managed-things count, not blueprint count) so this is very unlikely to matter, but noting for completeness since the PR was otherwise careful about FK-join indexes. - PR body says
test_file_accepts_each_local_source_kindcovers "each local-source kind" but the test only exercisesblueprint_idandlocal_path, notimage_id. Small enough that it's arguably test-coverage-agent territory, but it's also a schema-semantics gap (the third leg of the tri-source CHECK is untested), so flagging here too.
Positive
UNIQUE(thing_id)correctly relies on Postgres's NULL-are-distinct semantics to let multiple local drafts coexist before Thingiverse assigns an id, and the round-trip is directly tested (test_thing_id_unique_but_nullable). This is exactly right for the domain (drafts exist locally before publish).- RESTRICT-over-SET NULL rationale is sound and well-documented — both in the PR body and in-file comments. SET NULL would have created a confusing interaction with the tri-source CHECK (an UPDATE ... SET NULL that trips a CHECK is a much worse failure mode than a clean FK violation), and RESTRICT correctly forces the sync engine to unlink/delete-remote-first. This is a case where being more explicit than the codebase's existing implicit-NO-ACTION convention (
version_02's bareREFERENCES blueprints(id)) is a genuine improvement, not just a style deviation. - Plural table names (
thingiverse_things/thingiverse_files) over the ticket's literal singular wording, singular enum type names (thingiverse_thing_status) — both match the establishedblueprints/blueprint_typeconvention exactly. - Moving the blueprint linkage into the file ledger rather than the thing row (departing from the ticket description's
thingiverse_thing (blueprint_id <-> thing_id)sketch) is the correct domain call: a Thingiverse Thing is fundamentally a bundle of files, and modeling the blueprint relationship at the file level is what actually lets the sync engine detect per-file changes. - Migration structure, docstring format, helper-method breakdown, and down_impl ordering (indexes → files → things → types) all match
version_12/version_16's conventions precisely. Nosql.Identifier/sql.Literalneeded here since it's static DDL, consistent with existing schema files. conftest.pychange is correct and minimal — new tables added to the existing explicitTRUNCATE ... CASCADElist, consistent with how every other table is listed there (even thoughCASCADEalone would technically coverthingiverse_filesvia the FK fromthingiverse_things).- Test suite directly exercises the two trickiest semantics call-outs (nullable-unique
thing_id, CASCADE vs RESTRICT split) rather than just round-tripping the happy path.
Recommendations
- Add partial unique indexes on
(thingiverse_thing_id, blueprint_id),(..., image_id),(..., local_path)(eachWHERE ... IS NOT NULL) beforead5starts writing sync logic that assumes one ledger row per (thing, source). - Before or during
ad5, explicitly decide (and document) whether thing-level metadata drift detection (name/description) will be a live-fetch-and-diff or needs a cached hash column here — if the latter, that's a v18 migration to plan for now rather than discover mid-ad5. - Clarify in
8ybwhether a blueprint resolving to multiplething_ids is expected/handled, since this schema permits it. - Consider adding the missing
image_idcase totest_file_accepts_each_local_source_kindwhile touching this file.
Overall
Solid, appropriately-scoped migration that follows house style closely and gets the trickier constraint calls (nullable-unique, RESTRICT-vs-CASCADE, tri-source CHECK) right, with good rationale captured in both the PR body and inline comments. The main gap is forward-looking: the schema currently only models file-level diffing, and the notes on ad5 (shared-boilerplate description drift, throttling) suggest thing-level metadata drift detection is also in scope for the very next ticket this one unblocks — worth a deliberate decision now rather than a v18 migration next week. The lack of per-(thing, source) uniqueness is the one concrete gap I'd want closed before ad5 lands.
Response to Claude ReviewMedium 1 (duplicate ledger rows): Fixed — three partial unique indexes ( Medium 2 (no thing-level metadata drift signal): Fixed — added Medium 3 (8yb mapping is 1:N): Fixed in process — design note added to Minor 1 (file_type ↔ source column not DB-enforced): Won't fix — Minor 2 (no Minor 3 (no size/mtime for ad-hoc files): Fixed — Minor 4 (no status index): Won't fix — the table will hold hundreds of rows; a status index is noise at that scale. Minor 5 (PR body claimed image_id tested): Fixed — the image_id source-kind test (and image RESTRICT test) landed this round via test-coverage-reviewer's P1; the claim is now true. |
- partial unique indexes prevent duplicate ledger rows per thing (same source across different things stays legal, tested both ways) - remote_metadata_hash on thingiverse_things: thing-level metadata drift signal so the sync engine catches boilerplate/description changes without a v18 migration - local_size/local_mtime on thingiverse_files: fallback change signal for file types the remote API may not hash (anticipated by ad5) - docstring: deployment note reworded (schema ships everywhere; the DATA split is what's local-only) and new columns/indexes enumerated - tests: 8 -> 13 (image_id source kind, image RESTRICT, UPDATE-path unique and CHECK enforcement, duplicate prevention both ways) Part of openforge_catalog-750 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Verification of round-1 fixes (commit ede9be7)M1 (partial unique indexes): Verified. All three partial unique indexes present in M2 (remote_metadata_hash): Verified. Column added to M3 (8yb 1:N design note): Verified. m3 (local_size/local_mtime): Verified. Both columns added to m5 (image test): Verified. Won't-fix rationales:
down_impl order: Verified. Drops in exact reverse of creation order: Tests: New-issue scan of ede9be7: No new issues found. Overall: LGTM. All "Fixed" items confirmed in code/tests, won't-fix rationales hold up under scrutiny, and the diff introduces nothing new to flag. |
…nding) Part of openforge_catalog-750 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
CI's Postgres raises RestrictViolation (SQLSTATE 23001) for ON DELETE RESTRICT; older versions raise ForeignKeyViolation (23503). Accept either so the tests pass on both. Part of openforge_catalog-750 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Ticket
openforge_catalog-750— the Postgres side of the Thingiverse publish/sync epic (4kx). Unblocks the sync engine (ad5) and the prod-propagation fixture path (8yb).openforge/db/schema/version_17.py:thingiverse_things— one row per managed thing: nullable-uniquething_id(drafts exist locally before Thingiverse assigns an id),statusenum (draft/published/deleted),manifest_path, publish/sync timestampsthingiverse_files— the per-file diff ledger. Each row ties a remote file to its local source:blueprint_id(catalog models),image_id(gallery photos), orlocal_path(ad-hoc zips/artifacts), with afile_typeenum discriminator. Carrieslocal_md5+local_sha256(defensive, pending hash-algo confirmation from the HAR tickethnr) andremote_file_id/remote_file_name/remote_hashCHECK num_nonnulls(blueprint_id, image_id, local_path) >= 1; blueprint/image FKs are RESTRICT (SET NULLwould trip the CHECK and make parent deletes fail confusingly — RESTRICT makes unlinking deliberate, so the sync engine can delete the remote file first); thing deletion CASCADEs its ledger rowsThese tables are local-only operational state — prod gets just the derived
(blueprint_id, thing_id, public_url)mapping via the fixture path (see8yb), per the two-DB split.Test plan
bin/db_update upon local DB — applies cleanlybin/db_update down 16→up— full down/up round-trip verifiedtests/test_thingiverse_schema.py— 8 tests covering round-trip, nullable-unique thing_id, the CHECK, each local-source kind, CASCADE, RESTRICT, and both enumstest_incrementalfailures reproduce on cleantest; CI green there)Beads:
openforge_catalog-750(closed viabd closeon merge).🤖 Generated with Claude Code