|
| 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