fix(metadata-protocol): four read seams that failed no longer answer from an empty accumulator - #9067
Conversation
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
🔴 CI red —
|
🔬 Root-caused — reproduced locally, and it is a REAL regression, not a flakePM, session Failing: The change is CORRECT. The fixtures were wrong.Those tests build a stub engine that never implemented ⛔ The discrimination will not be softened, ⭐ What the red actually revealed — the more valuable halfAll 10 of those tests were vacuous on this path. Because the swallow caught the missing They were green because of the defect this card fixes. That is a stronger argument for the change than the seam analysis alone, and it is exactly the shape this lane keeps re-learning: a passing test is not evidence its subject works.
|
…d-seam-empty-accumulator
Fixes #8896
Four reads in
@objectstack/metadata-protocolsat behind a barecatchthat fell through — or, in one case, jumped — above a value the read was supposed to fill. Each handed its caller an answer indistinguishable from a legitimate one, with nothing logged and no field saying the answer was incomplete (ADR-0110 D3).Each
catchis discriminated by error type, never removed, through the sharedisMissingTableErrorpredicate (@objectstack/metadata/errors) — the same callDatabaseLoader,SysMetadataRepositoryandcascadeDeleteRelations(#8895 / PR #9006) already make. No new error code and no new response field, per the maintainer's #8833 ruling for this family.The four seams, and what each now does
seed-loader.tsloadExistingRecordsMap= "no existing rows" = write these rowsprotocol.tssearchAlltotalHits/truncatedstill reported as a complete sweepprotocol.tsfindReferencesToMetagetMetaItemsprotocol.tspublishPackageDraftssys_metadatanot provisioned1.
loadExistingRecords— the highest-severity oneThe map is not a cache; it IS the write decision, in all three callers, and "empty" means write these rows. The upsert pre-load turns every update into an INSERT — and
bulkWrite'sattempt2+ recheck, the only thing standing between an at-least-once retry and a duplicate of every row the first attempt already committed (framework#3149), is silently disarmed.Measured pre-fix, both shapes, in the pins' ablation:
The swallowed comment ("Object may not have records yet") also named a case that cannot reach it: an object that merely has no rows answers
findwith[], it does not throw.2.
searchAll— and a comment that named a failure mode I could not findThe old comment read "RBAC denial or driver hiccup — skip silently per object". Measured on this tree, RBAC denial is not a failure mode of this seam: object-level authorization is enforced at the REST door (
enforceAuth) beforesearchAllis reached, row-level security narrowsfind's result set rather than throwing, and nothing in-repo registers abeforeFindhook that denies by throwing. So the one real benign case is the unprovisioned table — routine here, since the registry lists every declared object whether or not a deployment provisioned it. Were a throwing permission hook added later, the ruling still applies: a read that could not run must not be answered "no matches here".3.
findReferencesToMeta— the one seam that gets no predicate of its ownThis is where measuring per seam changed the answer rather than confirming it. This read goes through
getMetaItems, which already performs exactly this discrimination (rethrowUnlessMetadataStoreUnprovisioned, #5532): the benign case returns normally, a real outage becomes a 503SERVICE_UNAVAILABLEcarrying the driver error ascause. A source type this deployment does not declare is not an error at all —listItemsanswers[].So the only thing the
catch { return; }could swallow was the 503 raised deliberately one line below it. Thecatchis simply gone; adding a secondisMissingTableErrorhere would be a second vocabulary of "benign", the exact debt@objectstack/metadata/errorsexists to retire.Promise.allmakes propagation right-shaped: the first rejection rejects the whole scan, so no half-scanned list reaches a caller — which matters because this list is the admin UI's "Used by" panel, and a short list reads as "nothing depends on it, safe to delete".4.
publishPackageDrafts— the comment/code contradiction, decidedThe card asks which is wrong. Both were, in different directions:
{ existedBefore: false, prevVersion: null }, the literal opposite of the healthy branch'sexistedBefore: !!activeRow.existedBefore: falsemeans "revert = soft-remove", so reverting that commit DELETES an artifact whose previous version was supposed to be restored. A read that failed was answered with a value, and the value chosen was the destructive one.Both are one defect — a revert plan derived from a read that did not happen — so neither was the survivor. The comment now describes the discrimination the code does.
With
sys_metadataunprovisioned there genuinely is no active row for anything, soexistedBefore: falseIS the truth and that push is kept byte-for-byte. Everything else propagates, and the loop's position is what makes that safe: the capture pass runs BEFORE Phase 1's transaction, so a refusal leaves the draft pending, no active row, no commit recorded. Verified on the post-#8986 tree.⭐ What this fix made visible: 10 tests that were green on a path that never ran
This is the strongest argument for the change, and it was found by CI on the first push rather than by the seam analysis.
@objectstack/objectqlowns the orchestration tests forpublishPackageDrafts(it depends on@objectstack/metadata-protocol). Their fixtures built a protocol over an engine that never implementedfindOne— so the ADR-0067 pre-publish capture threwTypeError: this.engine.findOne is not a functionon every item of every case, the barecatchswallowed it, and the fabricated{ existedBefore: false, prevVersion: null }was pushed in its place.The consequence: no test in the repo had ever exercised the real revert-plan capture.
existedBeforewasfalseeverywhere, not because a fixture said so but because the read crashed and the crash was hidden — including in the cases whose names claim end-to-end coverage (publishes every draft…,all-or-nothing…,wraps the batch in ONE engine transaction…). TheexistedBefore: truebranch, which decides whether a revert restores or deletes, had never once been reached. A regression turning every revert into a deletion would have kept that suite green.Discriminating the catch is what surfaced it:
isMissingTableError(TypeError)isfalse, so the broken engine stops being invisible. The production behaviour is right and the fixtures were lying, so the repair is in the fixtures:makeProtocolnow installs a real capture double —findOneanswers from a seedable set of active rows and returnsnullexplicitly for "no active row" (the two are now distinguishable, and only one is truthful), andinsertrecords the commit row so the revert plan is observable instead of being swallowed a second time byrecordCommit's own catch (recordCommit swallows a failed sys_metadata_commit write — the publish reports success and the turn is silently not revertible #9066).protocol.enginewholesale now spread the double instead — replacing it takesfindOneaway again and re-arms the same vacuity.existedBefore: true, prevVersion: 4, its new siblingexistedBefore: false, prevVersion: null, with the capture reads themselves asserted (first N, in draft order, each in the draft's own scope) so the values are evidence rather than defaults.Verified not to be vacuity in a new form: with the double's answer ablated to always-
null, that case goes red on theexistedBefore: trueentry; restored byte-identical afterwards.Tests
Three new pin files in metadata-protocol, 23 cases, plus the objectql fixture repair. Every expectation is written against literals — the exact injected error object, its literal message and code, the literal 503 envelope, literal row counts, the
existedBefore/prevVersionvalues read out of the stored commit row. Each failure assertion is paired with anti-vacuity controls in the same describe, and every benign branch carries proof that the injected throw actually fired. Both Postgres and SQLite phrasings, plus thecolumn "x" of relation "y" does not existsuperstring case that must stay loud.Reverse verification, direction predicted before running: ordinary red, 8 of 23. Observed exactly that, with the predicted per-seam split (3 / 2 / 1 / 2) — every benign case and every positive control stayed green. Source restored byte-identical and re-run green.
Gates — union re-run at
6366de753, after the final commitSuites:
pnpm --filter @objectstack/objectql test— 212 files, 3730 tests, all passing (this is the suite that caught the fixture defect; a package-local metadata-protocol run structurally could not, because these tests live in the consumer's package).pnpm --filter @objectstack/metadata-protocol test— 111 files, 1555 tests, all passing.Green:
check:cross-package-test-inputs(+ theci.ymlscript form) ·check:durability-log-level·check:filter-alias-parity·check:changeset-gate-self-tests·check:objectui-changeset·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check:nul-bytes·check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:type-check-coverage·check:type-check-debt.Re-deriving with
scripts/pm/dispatch-gates.mjsagainst the actual diff added nine families the dispatch list did not name, and two caught real defects in my own test code:check:where-matcher— my publish fixture'smatchesWhereimplemented$orbut read$andas a field name. Repaired by refusing the combinators the double does not implement, the convention 147 of 246 discovered matchers already follow. Baseline unchanged.check:type-check-debt— the new seed-loader pin's extensionless'./seed-loader'import added a TS2835, drifting@objectstack/metadata-protocol63 → 64. Fixed at the source with thenodenext-explicit specifier. The ledger was not raised — re-measure reports every entry at its recorded number.check:durability-log-levelwas expected to be blind here, and the honest measurement is more specific than "byte-identical": its read-seam census moved 67 → 66, and thetype-discriminated benign branchbucket stayed at 7 in both. The one seam that left the census is the deletedcatchinfindReferencesToMeta— there is no longer a seam there to count — while the threeisMissingTableErrordiscriminations stayed invisible to it exactly as predicted, because they return no expression. Neither number is certification, and the gate was not touched (#8845 deliberately did not extend it).One measurement trap worth recording: after merging
origin/main,protocol.driver-text-disclosure.test.tsfailed in my worktree. It was staledist/, not a regression — the merge brought #9030's MySQL leak-predicate change into@objectstack/typeswhile my worktree still resolved the pre-merge build. Rebuilding the closure returned it to green; nothing in this PR touches it.Scope
Carve-outs respected:
getMetaDiagnostics(#8855, landed),diffMetaItem(#8833, landed),checkGovernance(#8906,domain:engine-core, not this lane). No edits tomigrateStoredMetadata,recordMetadataAuditorapplyObjectRegistryMutation. File surface extended by the PM this round to the two objectql fixture files, which are the consumer face of this change; no other objectql file is touched.One out-of-scope finding filed unassigned as #9066:
recordCommit's barecatchswallows a failedsys_metadata_commitwrite, so a publish reports success while the turn is silently not revertible. That is the write half of the same neighbourhood and a different function — searched first, no open duplicate.Generated by Claude Code