Uh oh!
There was an error while loading. Please reload this page.
fix(objectql): surface a failed lifecycle governance row-count probe instead of skipping the object (#8906) - #9105
Conversation
…instead of skipping the object (#8906)
📓 Docs Drift CheckThis PR changes 1 package(s): 13 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:
|
os-zhuang
commented
Aug 16, 2026
Consumer-direction addendum, after the body was written: Generated by Claude Code Generated by Claude Code |
Fixes#8906
LifecycleService.checkGovernance()probed every declared object's row count and threw theresult away on any failure:
A driver outage was therefore indistinguishable from a quiet, healthy object — and the
damage outlived the sweep it happened in: the object got no
quota-exceededand nogrowthalert, and it dropped out of the map that becomes the next sweep's baseline,so the following sweep had no
lastcount to diff against either. Nothing was logged andnothing appeared in the sweep report.
What changed
packages/objectql/src/lifecycle/lifecycle-service.ts— the probe'scatch, and theimport the predicate needs (this seam had none; the sibling in
engine.tsdid):isMissingTableError,@objectstack/metadata/errors):skipped silently, unchanged. A table that does not exist holds no rows, so there is no
quota to breach and no growth to measure. It stays out of the baseline deliberately —
seeding a
0for a table that does not exist would fire a phantomgrowthalert on thefirst sweep after it is provisioned and seeded.
surfaces. One entry per object in the sweep report's existing
errorslist, plus awarn— both naming the consequence and the lost baseline, which is the half anoperator cannot infer from a report that otherwise looks healthy. Other objects are
unaffected; the sweep still finishes.
The predicate is asked through the shared, declared
isMissingTableError, never ahand-rolled code test — so the front-exclusion holds too: Postgres phrases a missing
column on an existing relation as
column "x" of relation "y" does not exist, whichcontains a legal missing-table phrase; that error surfaces rather than passing as benign.
The question this card had to answer, and why this answer
The card offered two shapes for the non-benign case — log at the durability level, or
carry an explicit incomplete fact in the sweep report — while the #8833 ruling forbids a
new response field. The fork dissolves on inspection:
LifecycleSweepReportalready hasthe field.
errors: Array< { object; error } >is documented as "isolated per-objectfailures", is filled the same way by
sweep()'s own object loop 200 lines above, and isalready counted in the sweep's summary line. So this PR does both halves through
channels that already exist, and adds no new report field and no new error code.
Rethrowing — the #8895 / PR #9006 shape — is deliberately not the shape here, and not
for uniformity's sake in either direction. There the caller is a
delete()that must fail.Here the only caller is
sweep(), whose scheduler entry point isvoid this.sweep(): athrow would land as an unhandled rejection, abandon governance for every object still
queued, skip
this.lastCounts = nextCountsentirely — losing every object's baselinerather than one — and break the documented invariant that a sweep failure is isolated and
never thrown into the scheduler. The PR #9067 shape (delete the catch) does not fit either:
nothing upstream discriminates,
engine.getDriverForObject()hands back the raw driver anddriver.count()throws dialect errors straight out.Not taken, and named so it is a decision rather than an omission: carrying the previous
count forward as the baseline on a failed probe. That changes what a
growthdelta means(two sweep intervals instead of one) and is a behaviour decision this card did not ask for.
The lost baseline is therefore reported, not repaired — and pinned that way.
Tests
packages/objectql/src/lifecycle/lifecycle-service.test.ts, one new describe, six cases,every expectation a literal rather than anything derived from the code under test:
refusals below cannot be satisfied by a fixture that never alerts;
report.errorsentry, exactwarntext,errornotcalled (the level is a decision: reduced alerting, not a write that claimed to persist);
the sweep after that does;
fired (
count.mock.results[0].valuerejects) — otherwise "the sweep continued" wouldalso describe a harness that never probed;
phrase as a substring.
Reverse verification (fix committed first, ablated with
git restore --source=origin/main,restored with
git checkout HEAD --): 4 of the 6 go red, the positive control and thebenign case stay green — the expected direction, and the reason the benign case is paired
with the non-benign ones rather than trusted alone.
Verification
All at
5c4ba963c, the final commit.pnpm --filter @objectstack/objectql exec vitest run src/lifecycle/lifecycle-service.test.ts→ 87 passed; whole-package
pnpm --filter @objectstack/objectql test→ 3735 passed;pnpm --filter @objectstack/objectql typecheck→ clean....@objectstack/objectql, downstream — 43 packages):runtime(165 files / 2456 tests),
service-queue,service-storage,service-knowledge,service-settingsall green viaturbo run test; the remainder rides CI.check:durability-log-level: byte-identical before and after —67 read seam(s) … (7 … discriminated) (1 pass … through) (1 baselined)on both trees,measured by ablating the fix and re-running. Predicted and confirmed: the rule classifies
a catch by the expression it returns, and both exits here are valueless (
continue),which
inventedEmptyValueandidentityPassThrougheach decline — the samebyte-identical result ObjectQL.cascadeDeleteRelations fails OPEN: a failed dependents probe skips the
restrictguard entirely, so a delete that should be refused succeeds silently #8895 measured atcascadeDeleteRelations. The gate is blind tothis seam in both states; that is the measured blind spot, not a certification.
check:changeset-gate-self-tests,check:objectui-changeset,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:nul-bytes,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-engine-split-ratio.Generated by Claude Code