Skip to content

Commit 875e9ad

Browse files
huangyiireneclaude
andauthored
fix(objectql): buildSummaryIndex reports the skip when a roll-up's reference carrier is unreadable (#19293)
Fixes #19082 Clause-②: no A diagnostic added to an internal, private index neither loosens an accept set nor widens a published surface. No schema changed; `buildSummaryIndex` is `private` and nothing about its signature, its return shape or its resolution rule moved. ## The premise, re-taken by symbol Triage said it had not re-taken the reading and asked the executor to. `packages/objectql/src/engine.ts` took a lander after the card's reading ref `221dabb72` — `a675ad4e` (#19080, the ten-residual-readers round) — so the site was re-located by **symbol**, never by the card's line numbers. It still resolves by carrier equality. `buildSummaryIndex` is at `engine.ts:9009`; the comparison the card quotes at `:9033` is byte-identical and now sits at the same line, and the silent `continue` at `:9039` is unchanged. #19080 routed `planCascadeAtomicity` and `cascadeDeleteRelations` through the arbiter and left this third site alone. **`premise_still_valid: true`.** ## The defect The child-to-parent foreign key is resolved by scanning the child object's `master_detail` / `lookup` fields for one whose `reference` names the parent. That comparison read the carrier raw, so a carrier **no reader can read** — a non-string, where `FieldSchema.reference` declares an optional string — compared `false` against every name, `fkField` stayed unset, and ```ts if (!fkField) continue; // can't resolve the relationship — skip ``` dropped a **declared** `summary` field out of *both* indexes. `recomputeSummaries()` then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents *"nothing to recompute"*; the first, its registry read, was closed as #9154. ## The boundary this card asked to reopen — and where it now stands PR #18503 recorded this site in its **C2** list and the #18550 round left it there deliberately. **That boundary stands: the resolution rule is untouched.** Loosening the comparison would trade a silent stall for a **mis-matched foreign key**, which is more expensive — a roll-up quietly aggregating the wrong children reads exactly like a correct one, while a roll-up that stopped moving is at least visible to anyone who looks at the value. What ends here is only the **silence**, which triage named as the half available today: - the carrier is read through the one arbiter, `referenceCarrierOf` — the accessor #19080 routed the two cascade seams through; - its refusal is **caught** rather than propagated, because this is a *scan* looking for the FK across every relation field: a propagating refusal on one unreadable field would hide a readable sibling that really is the foreign key, turning a roll-up that works today into a hard failure of every write to that child. Pinned (§5 of the new test); - the skip reports itself at **`error`**, once per index build. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class by AGENTS.md's own question, and the line carries both halves it owes: the consequence (which field will not recompute, and that the system keeps looking healthy) and the fix (spell the carrier as the target object's name, or name the FK with `summaryOperations.relationshipField`); - **absence is untouched.** `undefined`, `null` and `''` mean "this field names no target", which is legal; they skip silently exactly as before. Every readable carrier resolves exactly as before. The decision and its reasoning are recorded on the card and in the function's own docblock, so the next reader of the skip branch finds them instead of re-filing. ## Reachability — measured, and deliberately not inflated The card recorded this as **not established**, and it is now measured on this tree rather than argued. One probe, three doors, each with a readable-carrier control that passes: | door | shape `{ object: 'bad' }` on a `master_detail` | control `reference: 'bad'` | |---|---|---| | `ObjectSchema.safeParse` (the contract door) | **REFUSED** — `fields.bad.reference: invalid_type` | accepted | | `getMetadataTypeSchema('object')` — what `saveMetaItem` resolves for a stored `/meta` write | **REFUSED** | accepted | | `registry.registerObject` — the choke point every metadata door funnels through | **ACCEPTED**, carrier stored verbatim as `{"object":"bad"}`; `referenceCarrierOf` on the registered field throws | accepted | So: **not a live outage** — the live authoring and stored-write doors refuse this shape today — and **not unreachable either**. The registry takes it raw, which is the population `engine.ts`'s own #9689 note already names for the sibling seam: "a raw `registerObject`, or a stored/artifact row written before the tightening — the two populations parse-time rejection measurably cannot catch, since the engine registers raw objects and never re-parses". Graded exactly there, and ⛔ not escalated: no stored `summary` field was measured to have never recomputed, which is this card's only escalation condition. One honest qualifier, measured in the same probe: registration **does** already emit an ADR-0078 completeness warning for this field (`field/relationship-without-reference` fires on `typeof def.reference !== 'string'`). That is a one-shot, console-carried note about the **child field** at registration; it does not name the **parent's** declared `summary` field, does not say the roll-up was dropped, and this package's own vitest config quiets `[Registry]` output to `warn`. It is a neighbouring signal, not this one. ## Tests `packages/objectql/src/engine-summary-index-unreadable-carrier.test.ts`, 7 cases, both directions — because without the second, a change that simply stopped resolving anything would be indistinguishable from a fix: - **§1 control** — a normal `reference` still resolves `fkField` (`inv_line` / `inv`), and the recorder stays at zero; - **§2 the defect** — an unreadable carrier emits the skip signal, at `error` and not `warn`, naming the field, the consequence and both fixes; - **§3** — both in one index build: the readable roll-up is indexed while the unreadable one is reported; - **§4** — absence stays silent; - **§5** — an unreadable sibling declared *before* the real FK does not hide it; - **§6** — said once per index **build**: five consults report once, and a registry mutation makes it report again (without that second leg a "1" could equally mean "once per process"). The zeros in §1, §4 and §5 are readings rather than a dead instrument: §2 drives the same recorder through the same handle and measures it at 1. Every command below captured its exit code before any pipe, at HEAD `308a3403`: | command | result | |---|---| | `pnpm --filter @objectstack/objectql test` | **301 files / 5016 tests passed** | | `pnpm --filter @objectstack/objectql typecheck` | exit 0 — and `check:test-typecheck` holds at 40 files / 234 errors / 65 signatures, unmoved | | `pnpm --filter '@objectstack/objectql^...' build` | exit 0 | | `pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*'` | 72/72 successful | | `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` then `--ran` | **62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN** — every family carries a recorded exit code and none is 3 | | `pnpm lint` (`eslint . --no-inline-config`, whole repo — no narrowing to declare) | exit 0 | Five of the 62 first returned exit 2 or 3 — never a pass, nothing measured — and each was cleared rather than reported as one: `check-engine-split-ratio` and `check-plugin-teardown-shape --self-test` refused on a shallow clone (deepened with `git fetch --shallow-since=2026-06-15`, both then exit 0), and `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` refused for want of built output (built, then exit 0). ## Acceptance notes Out-of-scope observations, noted and deliberately **not** filed: - **noted, not filed** — the `!fkField` skip is still silent in its *other* branch: a roll-up whose child declares **no** relation field at all is dropped with no diagnostic here. It is not this card's input, it is loud at a different layer (the ADR-0078 completeness rule fires on exactly that shape at registration, at `severity: 'error'`), and widening the new diagnostic to cover it would make every legitimately-unresolvable `summary` declaration log per index build. Successor: whoever next reopens PR #18503's C2 boundary for this function — the decision is now recorded in `buildSummaryIndex`'s docblock, where they will meet it. - **noted, not filed** — when an unreadable sibling carrier sits beside a readable FK that does resolve, the unreadable one is passed over silently (§5 pins that it does not break resolution). Nothing is dropped on that path, so there is no defect to report; the carrier itself is already reported by the ADR-0078 rule at registration. Successor: none — no PR or reader reaches this path with a question the ADR-0078 warning does not already answer. Sibling card #19081 shares the same root (an unreadable `reference` carrier) and is deliberately **not** folded in: different file, different failure direction (it leaks the carrier onward; this one silently drops work), different lane. Triage ruled both should be taken, in either order. --- _Generated by [Claude Code](https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4fef271 commit 875e9ad

3 files changed

Lines changed: 421 additions & 3 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@objectstack/objectql": patch
3+
---
4+
5+
`buildSummaryIndex` no longer drops a declared `summary` field silently when the roll-up's `reference` carrier cannot be read — the skip now reports itself at `error`, naming the field, the consequence and the fix (#19082).
6+
7+
The child→parent foreign key is resolved by scanning the child object's `master_detail` / `lookup` fields for one whose `reference` names the parent. That comparison read the carrier raw (`cd.reference === parent.name`), so a carrier **no reader can read** — a non-string, where `FieldSchema.reference` declares an optional string — compared `false` against every name, `fkField` stayed unset, and
8+
9+
```ts
10+
if (!fkField) continue; // can't resolve the relationship — skip
11+
```
12+
13+
removed the roll-up from **both** summary indexes. `recomputeSummaries()` then had nothing to do after every insert / update / delete of the child, so the parent's stored summary value kept whatever it held while each of those writes reported success, and nothing anywhere said so. It is the second way this one function invents *"nothing to recompute"*; the first, its registry read, was closed as #9154.
14+
15+
- **⛔ The resolution rule is deliberately unchanged.** Loosening the comparison would trade a silent stall for a **mis-matched foreign key**, which is more expensive: a roll-up quietly aggregating the wrong children reads exactly like a correct one. PR #18503 recorded this site in its C2 list and the #18550 round left it there on purpose; that boundary still stands. What ends is only the silence.
16+
- **The carrier is read through the one arbiter**, `referenceCarrierOf` — the same accessor #19080 routed the two delete-cascade seams through. Its refusal is **caught** here rather than propagated, because this is a *scan* looking for the foreign key across every relation field: a propagating refusal on one unreadable field would hide a readable sibling that really is the FK, turning a roll-up that works today into a hard failure of every write to that child.
17+
- **`error`, not `warn`**, and said once per index build rather than once per write. A persisted summary that silently stops tracking its children while every write keeps reporting success is the durability class, and the line it prints carries both halves an operator needs: what is not being maintained and will not recompute, and the two ways to fix it — spell the carrier as the target object's name, or name the FK explicitly with `summaryOperations.relationshipField`.
18+
- **Absence is untouched.** `undefined`, `null` and `''` mean "this field names no target", which is a legal thing to declare; they skip silently exactly as before. Every readable carrier resolves exactly as before.
19+
20+
No schema changed, no key was added or removed, and nothing that resolved before resolves differently now. `engine-summary-index-unreadable-carrier.test.ts` pins both directions — the unreadable carrier reporting its skip, and a normal `reference` still resolving `fkField` — because without the second one, a change that simply stopped resolving anything would look identical to a fix.
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19082] `buildSummaryIndex()` must not answer an UNREADABLE `reference`
5+
* carrier with a silent *"this parent declares no roll-up"*.
6+
*
7+
* The child→parent foreign key is resolved by scanning the child's
8+
* `master_detail` / `lookup` fields for one whose `reference` names the parent.
9+
* The comparison used to read the carrier raw, so a carrier no reader can read
10+
* — a non-string, where `FieldSchema.reference` declares an optional string —
11+
* compared false against every name, `fkField` stayed unset, and
12+
*
13+
* ```ts
14+
* if (!fkField) continue; // can't resolve the relationship — skip
15+
* ```
16+
*
17+
* dropped a DECLARED `summary` field out of both indexes with no diagnostic
18+
* anywhere. `recomputeSummaries()` then found nothing to do after every insert
19+
* / update / delete of the child, so the parent's stored summary value kept
20+
* whatever it held while every one of those writes reported success. It is the
21+
* SECOND way this one function invents "nothing to recompute" — the first, its
22+
* registry read, is #9154 (`engine-summary-index-registry-read-failure.test.ts`).
23+
*
24+
* ## What is pinned here, and why BOTH halves are required
25+
*
26+
* ⛔ The remedy is deliberately NOT a looser comparison: that would trade a
27+
* silent stall for a MIS-MATCHED foreign key, which is more expensive. So the
28+
* skip itself is unchanged and two pins are needed to tell "fixed" apart from
29+
* "this path was closed off":
30+
*
31+
* 1. an unreadable carrier makes the skip OBSERVABLE (§2 below);
32+
* 2. a normal `reference` still resolves `fkField` (§1 below) — without this
33+
* one, a change that simply stopped resolving anything would pass §2.
34+
*
35+
* §3 runs both in ONE index build, which is the shape a real registry has.
36+
*
37+
* ## Why the zero counts below are readings and not a dead instrument
38+
*
39+
* Every case drives the SAME `RecordingLogger` through the SAME public handle
40+
* (`getOwnedSummaryDescriptors`, the engine's own parent-side read of the
41+
* index — #6063 — which reaches `buildSummaryIndex()` with no driver in the
42+
* path). §2 measures that recorder at **1**, so the **0** in §1, §4 and §5 is
43+
* this instrument reporting silence rather than this instrument being unable
44+
* to report at all.
45+
*
46+
* ⚠️ Scope, stated so the next reader of the skip branch does not re-file it:
47+
* PR #18503 recorded this site in its **C2** list and #18550 left it there
48+
* deliberately. This change does not move that boundary — the RESOLUTION RULE
49+
* is untouched, and only the SILENCE is closed.
50+
*/
51+
52+
import { describe, it, expect } from 'vitest';
53+
import type { ServiceObject } from '@objectstack/spec/data';
54+
import type { Logger } from '@objectstack/spec/contracts';
55+
import { ObjectQL } from './engine.js';
56+
57+
/** The package id every fixture below is registered under. */
58+
const OWNER_PACKAGE = 'test-19082';
59+
60+
/**
61+
* A `Logger` that keeps what it was told. `error` is a real method, not an
62+
* optional one: the engine reaches for `error` and falls back to `warn`, and a
63+
* recorder missing `error` would silently measure the fallback instead of the
64+
* level this card is about.
65+
*/
66+
class RecordingLogger implements Logger {
67+
readonly errors: string[] = [];
68+
readonly warns: string[] = [];
69+
debug(): void { /* not read by these cases */ }
70+
info(): void { /* not read by these cases */ }
71+
warn(message: string): void { this.warns.push(message); }
72+
error(message: string): void { this.errors.push(message); }
73+
}
74+
75+
/*
76+
* Fixtures are typed as `ServiceObject` (and registered WITH their
77+
* `packageId`) rather than left to inference, so this file adds nothing to
78+
* `@objectstack/objectql`'s TEST_DEBT ledger — a shrink-only ratchet (#5278).
79+
* The one exception is `badLine`, whose whole point is a `reference` the type
80+
* forbids; it is cast once, at its declaration, and the cast is the statement
81+
* that this value never came through a parse.
82+
*/
83+
84+
/** Parent whose roll-up resolves — the control. */
85+
const inv: ServiceObject = {
86+
name: 'inv',
87+
label: 'Invoice',
88+
fields: {
89+
id: { name: 'id', label: 'ID', type: 'text' as const },
90+
line_total: {
91+
name: 'line_total',
92+
label: 'Line total',
93+
type: 'summary' as const,
94+
summaryOperations: { object: 'inv_line', field: 'amount', function: 'sum' as const },
95+
},
96+
},
97+
};
98+
99+
/** Child with a READABLE carrier. */
100+
const invLine: ServiceObject = {
101+
name: 'inv_line',
102+
label: 'Invoice line',
103+
fields: {
104+
id: { name: 'id', label: 'ID', type: 'text' as const },
105+
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
106+
inv: { name: 'inv', label: 'Invoice', type: 'master_detail' as const, reference: 'inv' },
107+
},
108+
};
109+
110+
/** Parent whose roll-up cannot resolve — the defect. */
111+
const bad: ServiceObject = {
112+
name: 'bad',
113+
label: 'Bad invoice',
114+
fields: {
115+
id: { name: 'id', label: 'ID', type: 'text' as const },
116+
line_total: {
117+
name: 'line_total',
118+
label: 'Line total',
119+
type: 'summary' as const,
120+
summaryOperations: { object: 'bad_line', field: 'amount', function: 'sum' as const },
121+
},
122+
},
123+
};
124+
125+
/**
126+
* Child whose carrier NO READER CAN READ. `{ object: 'bad' }` is the shape an
127+
* author reaches for when they think `reference` takes a descriptor;
128+
* `ObjectSchema.safeParse` refuses it with a located `invalid_type`, so a
129+
* definition in this shape reached the registry around the parse seam — a raw
130+
* `registerObject`, or a metadata row stored before that tightening.
131+
*/
132+
const badLine = {
133+
name: 'bad_line',
134+
label: 'Bad invoice line',
135+
fields: {
136+
id: { name: 'id', label: 'ID', type: 'text' },
137+
amount: { name: 'amount', label: 'Amount', type: 'number' },
138+
bad: { name: 'bad', label: 'Invoice', type: 'master_detail', reference: { object: 'bad' } },
139+
},
140+
} as unknown as ServiceObject;
141+
142+
/** Child that names NO target at all — absence, which is legal and silent. */
143+
const absentLine: ServiceObject = {
144+
name: 'bad_line',
145+
label: 'Bad invoice line',
146+
fields: {
147+
id: { name: 'id', label: 'ID', type: 'text' as const },
148+
amount: { name: 'amount', label: 'Amount', type: 'number' as const },
149+
bad: { name: 'bad', label: 'Invoice', type: 'lookup' as const },
150+
},
151+
};
152+
153+
/**
154+
* Child carrying BOTH an unreadable carrier and, after it, the readable
155+
* `master_detail` that really is the foreign key. Key order matters: the
156+
* unreadable one is declared FIRST, so a scan that let the arbiter's refusal
157+
* propagate would never reach the field below it.
158+
*/
159+
const mixedLine = {
160+
name: 'bad_line',
161+
label: 'Bad invoice line',
162+
fields: {
163+
id: { name: 'id', label: 'ID', type: 'text' },
164+
stale_ref: { name: 'stale_ref', label: 'Stale', type: 'lookup', reference: ['bad'] },
165+
amount: { name: 'amount', label: 'Amount', type: 'number' },
166+
bad: { name: 'bad', label: 'Invoice', type: 'master_detail', reference: 'bad' },
167+
},
168+
} as unknown as ServiceObject;
169+
170+
/** A fresh engine with `objects` registered and a recorder on the log sink. */
171+
function makeEngine(objects: ServiceObject[]): { engine: ObjectQL; logger: RecordingLogger } {
172+
const logger = new RecordingLogger();
173+
const engine = new ObjectQL({ logger });
174+
for (const o of objects) engine.registry.registerObject(o, OWNER_PACKAGE);
175+
return { engine, logger };
176+
}
177+
178+
/** Errors this card's diagnostic is responsible for, isolated from any other. */
179+
const skipDiagnostics = (logger: RecordingLogger): string[] =>
180+
logger.errors.filter((m) => m.startsWith('[summary-index]'));
181+
182+
describe('[#19082] buildSummaryIndex — an unreadable `reference` carrier skips LOUDLY', () => {
183+
describe('§1 control — a readable carrier still resolves `fkField`', () => {
184+
it('indexes the roll-up and says nothing', () => {
185+
const { engine, logger } = makeEngine([inv, invLine]);
186+
187+
const owned = engine.getOwnedSummaryDescriptors('inv');
188+
189+
// Without this half, a change that simply stopped resolving
190+
// anything would satisfy §2 — "fixed" and "closed this path off"
191+
// would be indistinguishable.
192+
expect(owned).toHaveLength(1);
193+
expect(owned[0].summaryField).toBe('line_total');
194+
expect(owned[0].childObject).toBe('inv_line');
195+
expect(owned[0].fkField).toBe('inv');
196+
expect(skipDiagnostics(logger)).toEqual([]);
197+
expect(logger.warns.filter((m) => m.startsWith('[summary-index]'))).toEqual([]);
198+
});
199+
});
200+
201+
describe('§2 the defect — an unreadable carrier emits an observable skip signal', () => {
202+
it('reports the skip once, naming the consequence and the fix', () => {
203+
const { engine, logger } = makeEngine([bad, badLine]);
204+
205+
const owned = engine.getOwnedSummaryDescriptors('bad');
206+
207+
// ⛔ The skip is NOT repaired by guessing the foreign key: a looser
208+
// comparison would index a MIS-MATCHED FK, which is worse than the
209+
// stall. The descriptor stays absent; what ends is the silence.
210+
expect(owned).toEqual([]);
211+
212+
const reported = skipDiagnostics(logger);
213+
expect(reported).toHaveLength(1);
214+
const [msg] = reported;
215+
// WHO: the declared summary field that is not being maintained.
216+
expect(msg).toContain('bad.line_total');
217+
// WHERE: the field whose carrier could not be read.
218+
expect(msg).toContain('bad_line.bad');
219+
// THE CONSEQUENCE, concretely — the half a bare "could not resolve"
220+
// leaves out, and the reason this is an `error` and not a `warn`.
221+
expect(msg).toContain('will NOT recompute');
222+
expect(msg).toContain('reports success');
223+
// THE FIX, both spellings the author can reach for.
224+
expect(msg).toContain("reference: 'bad'");
225+
expect(msg).toContain('summaryOperations.relationshipField');
226+
});
227+
228+
it('speaks at `error`, not at `warn`', () => {
229+
const { engine, logger } = makeEngine([bad, badLine]);
230+
engine.getOwnedSummaryDescriptors('bad');
231+
232+
// A persisted summary silently stops tracking its children while
233+
// every write keeps reporting success — the durability class, whose
234+
// level is `error` by AGENTS.md's own question. A `warn` here is the
235+
// one level at which an operator is never told.
236+
expect(skipDiagnostics(logger)).toHaveLength(1);
237+
expect(logger.warns.filter((m) => m.startsWith('[summary-index]'))).toEqual([]);
238+
});
239+
});
240+
241+
describe('§3 both in ONE build — the control and the defect do not interfere', () => {
242+
it('the readable roll-up is indexed while the unreadable one is reported', () => {
243+
const { engine, logger } = makeEngine([inv, invLine, bad, badLine]);
244+
245+
const good = engine.getOwnedSummaryDescriptors('inv');
246+
const broken = engine.getOwnedSummaryDescriptors('bad');
247+
248+
expect(good.map((d) => d.fkField)).toEqual(['inv']);
249+
expect(broken).toEqual([]);
250+
expect(skipDiagnostics(logger)).toHaveLength(1);
251+
expect(skipDiagnostics(logger)[0]).toContain('bad.line_total');
252+
});
253+
});
254+
255+
describe('§4 absence is not unreadability — and stays silent', () => {
256+
it('a relation field that names no target skips without a diagnostic', () => {
257+
const { engine, logger } = makeEngine([bad, absentLine]);
258+
259+
// `FieldSchema.reference` is `.optional()` and `StrictField`
260+
// declares it nullable: naming no target is a legal thing for a
261+
// field to say, it was silent before this change, and it is silent
262+
// after. Only UNREADABILITY is new.
263+
expect(engine.getOwnedSummaryDescriptors('bad')).toEqual([]);
264+
expect(skipDiagnostics(logger)).toEqual([]);
265+
});
266+
});
267+
268+
describe('§5 an unreadable SIBLING must not hide the real foreign key', () => {
269+
it('resolves the readable `master_detail` declared after it, silently', () => {
270+
const { engine, logger } = makeEngine([bad, mixedLine]);
271+
272+
// The arbiter THROWS on an unreadable carrier, and the two cascade
273+
// seams #19080 routed through it let that throw propagate. This
274+
// scan cannot: it is looking FOR the foreign key across every
275+
// relation field, so a propagating refusal on `stale_ref` would
276+
// turn a roll-up that works today into a hard failure of every
277+
// write to `bad_line`. Nothing is dropped here, so nothing is
278+
// reported here either.
279+
const owned = engine.getOwnedSummaryDescriptors('bad');
280+
expect(owned).toHaveLength(1);
281+
expect(owned[0].fkField).toBe('bad');
282+
expect(skipDiagnostics(logger)).toEqual([]);
283+
});
284+
});
285+
286+
describe('§6 said once per index BUILD, never once per read', () => {
287+
it('repeats only when the registry moves, not on every consult', () => {
288+
const { engine, logger } = makeEngine([bad, badLine]);
289+
290+
for (let i = 0; i < 5; i++) engine.getOwnedSummaryDescriptors('bad');
291+
292+
// `ensureSummaryIndexes()` memoises the built pair against the
293+
// registry's `objectRevision`, which moves on a metadata MUTATION
294+
// and never on a data write — so this diagnostic cannot become a
295+
// per-write log line.
296+
expect(skipDiagnostics(logger)).toHaveLength(1);
297+
298+
// A metadata mutation invalidates the stamp, the index rebuilds,
299+
// and the condition — still present — is reported again. Measured
300+
// rather than assumed: without this leg, a "1" above could equally
301+
// mean the diagnostic is emitted exactly once per process.
302+
engine.registry.registerObject(inv, OWNER_PACKAGE);
303+
engine.getOwnedSummaryDescriptors('bad');
304+
expect(skipDiagnostics(logger)).toHaveLength(2);
305+
});
306+
});
307+
});

0 commit comments

Comments
 (0)