Skip to content

Commit 5d4e3f0

Browse files
committed
fix(objectql): buildSummaryIndex skips LOUDLY when a roll-up's reference carrier is unreadable
The child->parent FK was resolved by comparing the child field's `reference` carrier to the parent's name. 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 the `continue` dropped a DECLARED `summary` field out of both indexes with no diagnostic anywhere. The parent's stored summary value then kept whatever it held through every insert / update / delete of the child, while each write reported success. The resolution RULE is deliberately unchanged (a looser comparison would trade a silent stall for a mis-matched foreign key, which is more expensive). The carrier is read through the one arbiter, `referenceCarrierOf`, and the skip now reports itself at `error` with both the consequence and the fix. Absence (undefined/null/'') stays silent, every readable carrier resolves exactly as before, and the arbiter's refusal is caught rather than propagated so an unreadable sibling cannot hide the readable field that IS the foreign key. Claude-Session: https://claude.ai/code/session_01NcPSwnmJHczmTu6FG7NMjE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7ec8534 commit 5d4e3f0

2 files changed

Lines changed: 401 additions & 3 deletions

File tree

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)