Skip to content

fix(objectql): the roll-up summary index's registry read propagates, and a failed read is never cached as an empty index (#9154) - #9284

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-9154-summary-index-swallow
Aug 17, 2026
Merged

fix(objectql): the roll-up summary index's registry read propagates, and a failed read is never cached as an empty index (#9154)#9284
os-zhuang merged 2 commits into
mainfrom
claude/issue-9154-summary-index-swallow

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes#9154

ObjectQL.buildSummaryIndex() opened by answering a registry read that could not
run with an invented "no object declares a roll-up", and ensureSummaryIndexes()
then memoized that invention, stamped with the registry's current
objectRevision. recomputeSummaries() consults that index after every insert /
update / delete to decide which parent roll-ups a child write must recompute, so an
empty index means no roll-up is ever recomputed — every parent summary field keeps a
stale value, nothing is logged, and every write reports success.

What changed

1. The read propagates — the canonical shape PR #9163 landed for the two
delete-cascade seams:

-letobjects: any[]=[];-try{objects=(this._registryasany).getAllObjects?.()??[];}catch{objects=[];}+constobjects: ServiceObject[]=this._registry.getAllObjects();

Same family as #8895 (discriminate or propagate) and #9002, same reasoning:
discrimination needs a benign failure class and there is none — an unreadable
registry is never truthfully "no roll-ups". Both halves of the swallow are gone, the
catch and the optional call ?.(), which absorbed a registry that omits
getAllObjects entirely — the structural omission that never throws and is therefore
invisible.

2. A failed build leaves no cache entry — the limb that made this card worse than
its two siblings. The build now runs to completion into a local before anything is
published to the instance, the revision stamp is written last, and a throw clears any
cached index and resets the stamp before rethrowing unchanged. A poisoned cache
entry must not survive the read that poisoned it.

Measured, not assumed

PM assumption 1 — getAllObjects() has no throwing path — HOLDS. Re-derived on
today's tree: SchemaRegistry.getAllObjects() walks objectContributors and calls
resolveObject(), which returns undefined on every failure branch it models (no
contributors, no owner — the orphan-overlay branch after a console.warn); below it
foldExtendersOntoDefinitionmergeObjectDefinitions is spreads and conditionals,
and scalarOverridesPackagedBase is three comparisons. No I/O, no driver, no throw.
This is a structural close, not a live defect.

PM assumption 2 — the poisoned cache's lifetime — HOLDS, and is now measured
rather than argued.
objectRevision moves on exactly seven sites, all metadata
mutations: registerObject, removeObjectOverlay, unregisterObjectsByPackage,
unregisterObject, invalidate, invalidateAll, reset. None is on a data write.
A probe run against the pre-fix engine — poison the first index build, heal the
registry, then perform four ordinary writes with no registry mutation at all:

pre-fix seamAnswer: [] rev 2 -> 2 parentRow: {"name":"INV-1","id":"r_1"} children: 3, sum 47
post-fix seamAnswer: THREW boom rev 2 -> 2 parentRow: {"name":"INV-1","line_total":47,"line_count":3,"id":"r_1"}

Pre-fix the parent row carries no roll-up keys at all — not even the insert-time
seed of 0 — while three children summing 47 sit in the store and every write returned
success. The registry was healthy for all four writes; only the cached emptiness kept
it wrong. So the severity claim is right: not "briefly wrong", but wrong until a
metadata mutation or a process restart.

PM assumption 3 — check:durability-log-level is blind to this — HOLDS. Census
measured on both trees, identical: 26 durability-critical catch seams, 66 read seams.
The gate's green certifies nothing here (the #8845 blind spot), and it was not
touched. Note the new catch/rethrow in ensureSummaryIndexes did not enter the
census either.

PM assumption 4 — a swallow removal exposes vacuous tests — HOLDS, nine files
worth.
Removing the optional call turned 83 tests red across 9 suites, every one
with TypeError: this._registry.getAllObjects is not a function. Their
vi.mock('./registry') doubles never modelled the method, and the swallow made an
incomplete double indistinguishable from an empty registry — so the write path in all
nine was silently skipping the insert-time roll-up seed (#5749) and the post-write
recompute, and no assertion could see it. Each double now declares
getAllObjects: vi.fn(() => []), which is the truthful body there: none of the nine
declares a summary field, so the roll-up index over it is empty either way, and now
it says so instead of the engine inventing it. Repaired, not suppressed.

Reverse verification

Direction predicted in writing before running: controls green, seam and cache tests
red. Observed exactly that — with the fix reverted and the pins kept, 6 failed / 2
passed; the two that stayed green are the positive controls (a readable registry
recomputes the roll-up; the parent-side descriptors are reported), so no assertion
below them can pass vacuously.

One thing the reverse run taught, and the test file records it: the index is built
lazily, so the failing read must land on a real build, not a cache hit. Arming the
failure after a successful write proves nothing — an insert over a warm index makes
zero getAllObjects() calls. The pins therefore poison the FIRST build, which is the
shape a booting deployment has.

Tests

packages/objectql/src/engine-summary-index-registry-read-failure.test.ts — 8 tests:
2 positive controls, the seam's propagation (error identity plus literal code /
status / message, and the read count), the missing-method case, and four cache-limb
tests that re-read registry.objectRevision and pin it unchanged across the
failure, so the recovery they prove cannot be the accidental one a metadata mutation
would have produced.

Verification at 65a5e2afa (this PR's head)

  • pnpm --filter @objectstack/objectql test214 files, 3763 tests passed
    (pristine origin/main baseline measured for comparison: 213 files, 3755 tests).
  • pnpm --filter @objectstack/objectql typecheck — clean.
  • Downstream consumer sweep (prefix filter, i.e. packages that DEPEND ON objectql):
    @objectstack/metadata-protocol 116 files / 1606 tests, @objectstack/rest 122
    files / 1896 tests, @objectstack/runtime 165 files / 2464 tests — all green. (The
    first rest and runtime runs showed suite-LOAD failures naming unbuilt sibling
    packages, zero test failures; building each package's own dependency closure cleared
    them, so they were an artefact of a scoped build, not of this change.)
  • Gates from scripts/pm/dispatch-gates.mjs at this head, all green:
    check:changeset-gate-self-tests, check:durability-log-level,
    check:objectui-changeset, check:stack-collection-maps,
    check:query-options-erasure, check:engine-double-contract,
    check:where-matcher, check:nul-bytes, check:type-check-coverage,
    check:cross-package-test-inputs, check-engine-split-ratio.
  • check:type-check-debt --re-measure refuses without a fully built workspace, so the
    ledger delta was measured directly instead, with objectql's closure built and its
    tests included in the program: 355 before, 355 after — the gate's exact recorded
    number for @objectstack/objectql, unmoved. No ledger entry is raised by this PR.

Changeset: .changeset/summary-index-registry-read-propagates.md (patch).


Generated by Claude Code

…and a failed read is never cached (#9154)
`buildSummaryIndex()` answered a registry read that could not run with an
invented "no object declares a roll-up", and `ensureSummaryIndexes()` then
MEMOIZED that invention stamped with the registry's current `objectRevision`.
Since `objectRevision` moves only on a metadata mutation and never on a data
write, the failure outlived its cause: every parent roll-up silently stopped
recomputing until a restart or an unrelated publish.
Both halves of the swallow are gone -- the `catch` and the optional call
`?.()`, which absorbed a registry that omits `getAllObjects` entirely without
ever throwing -- matching the canonical shape #9002 landed for the two
delete-cascade seams. And a build that throws now clears any cached index and
resets the revision stamp before rethrowing unchanged, so the next call
rebuilds: a poisoned cache entry must not survive the read that poisoned it.
Structural close, not a live defect: `SchemaRegistry.getAllObjects()` has no
throwing path on today's tree (re-measured).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…e; repair nine registry doubles (#9154)
The new pin file measures both halves: the read propagates, and the failed
read leaves no cached index -- every recovery assertion re-reads
`registry.objectRevision` and pins it UNCHANGED across the failure, so the
recovery it proves cannot be the accidental one a metadata mutation would
have produced.
Nine suites went red the moment the optional call `?.()` was removed, all
with `TypeError: this._registry.getAllObjects is not a function`: their
`vi.mock('./registry')` doubles never modelled the method, and the swallow
made an incomplete double indistinguishable from an empty registry. The
prior green was vacuous -- the write path was silently skipping the
insert-time roll-up seed and the post-write recompute in all nine. Each
double now declares `getAllObjects: () => []`, which is the truthful body
there: none of the nine declares a `summary` field.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 59 pages)

Coarse fallback — 14 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json origin/mainpackageMentionDocs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 17, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 17, 2026 11:46
@os-zhuang
os-zhuang added this pull request to the merge queueAug 17, 2026
Merged via the queue into main with commit b2a451fAug 17, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9154-summary-index-swallow branch August 17, 2026 13:15
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-zhuang@claude