|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #8798 — `diffMetaItem` awaited a full `historyMetaItem` read and discarded it. |
| 5 | + * |
| 6 | + * The discarded call was marked `const _used = versions; void _used;`, which is |
| 7 | + * why this got a card rather than a tidy-up: a deliberate-looking marker on a |
| 8 | + * value that is not load-bearing is exactly the input that makes the next reader |
| 9 | + * (human or agent) reason confidently from dead code. |
| 10 | + * |
| 11 | + * ## What the deletion had to prove, and what these pins are |
| 12 | + * |
| 13 | + * The one behaviour the dead call could still have been providing is |
| 14 | + * `historyMetaItem`'s EARLY RETURN — it answers `{ events: [] }` for a type that |
| 15 | + * is neither `isOverlayAllowed` nor `isRuntimeCreateAllowed`. A test that only |
| 16 | + * exercised an ordinary type would prove nothing about the deletion, so the |
| 17 | + * fixture below is pinned to a type that genuinely takes that early return. |
| 18 | + * |
| 19 | + * Measured, not inherited (`DEFAULT_METADATA_TYPE_REGISTRY`, both flags false): |
| 20 | + * `field`, `job`, `api`, `capability`, `agent`. `field` is the fixture; `view` |
| 21 | + * is the ordinary-type control. |
| 22 | + * |
| 23 | + * ⛔ `earlyReturnFixtureIsStillEarlyReturn` below is the anti-vacuity arm and is |
| 24 | + * not decoration. If `field` ever gains `allowOrgOverride` or |
| 25 | + * `allowRuntimeCreate`, every other assertion here silently stops covering the |
| 26 | + * early-return case while staying green. That test going red is the signal to |
| 27 | + * re-pick the fixture from the registry, not to delete the assertion. |
| 28 | + */ |
| 29 | +import { describe, expect, it } from 'vitest'; |
| 30 | +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, hashSpec } from '@objectstack/metadata-core'; |
| 31 | +import { ObjectStackProtocolImplementation } from './index.js'; |
| 32 | + |
| 33 | +/** Takes `historyMetaItem`'s early return — neither flag set in the registry. */ |
| 34 | +const EARLY_RETURN_TYPE = 'field'; |
| 35 | +/** Passes the same gate — the control that keeps the pins discriminating. */ |
| 36 | +const ORDINARY_TYPE = 'view'; |
| 37 | + |
| 38 | +/** |
| 39 | + * Scalar equality only, and it REFUSES anything else rather than guessing. |
| 40 | + * |
| 41 | + * Both readers here issue flat filters: `diffMetaItem` queries |
| 42 | + * `sys_metadata_history` by `{ organization_id, type, name }`, and |
| 43 | + * `SysMetadataRepository.history` by the same three. No combinator ever arrives. |
| 44 | + * |
| 45 | + * The `throw` is the point (`check:where-matcher`). Treating a `$or` / `$and` |
| 46 | + * key as an ordinary column name is that gate's shape (b): `r.$or` is |
| 47 | + * `undefined`, the comparison fails, the row is silently excluded, and the suite |
| 48 | + * goes green while asserting on a query nobody wrote. |
| 49 | + */ |
| 50 | +function matches(r: Record<string, unknown>, where: Record<string, unknown>): boolean { |
| 51 | + for (const [k, v] of Object.entries(where)) { |
| 52 | + if (k.startsWith('$')) { |
| 53 | + throw new Error( |
| 54 | + `stub engine: WHERE combinator '${k}' is not implemented by this double — ` |
| 55 | + + 'it matches scalar equality only. Implement it here rather than letting it ' |
| 56 | + + 'be read as a field name.', |
| 57 | + ); |
| 58 | + } |
| 59 | + if (v === undefined) continue; |
| 60 | + if (r[k] !== v) return false; |
| 61 | + } |
| 62 | + return true; |
| 63 | +} |
| 64 | + |
| 65 | +/** |
| 66 | + * Table-aware and READ-COUNTING. The count is the subject of this card: the |
| 67 | + * defect was a second, unused read of `sys_metadata_history` per request, and |
| 68 | + * a value-only assertion cannot see it — both bodies were always correct. |
| 69 | + */ |
| 70 | +function makeStubEngine(opts: { throwOnHistory?: boolean } = {}) { |
| 71 | + const tables: Record<string, Array<Record<string, unknown>>> = { |
| 72 | + sys_metadata: [], |
| 73 | + sys_metadata_history: [], |
| 74 | + }; |
| 75 | + const findCalls: string[] = []; |
| 76 | + const engine: any = { |
| 77 | + async find(table: string, o: { where: Record<string, unknown> }) { |
| 78 | + findCalls.push(table); |
| 79 | + if (opts.throwOnHistory && table === 'sys_metadata_history') { |
| 80 | + throw new Error('history table unavailable (simulated outage)'); |
| 81 | + } |
| 82 | + return (tables[table] ?? []).filter((r) => matches(r, o.where)); |
| 83 | + }, |
| 84 | + async findOne(table: string, o: { where: Record<string, unknown> }) { |
| 85 | + return (tables[table] ?? []).find((r) => matches(r, o.where)) ?? null; |
| 86 | + }, |
| 87 | + async insert() { return { id: 'stub' }; }, |
| 88 | + async update(_t: string, data: Record<string, unknown>, o: { where: Record<string, unknown> }) { |
| 89 | + assertEngineUpdateDispatch(data, o); |
| 90 | + return { id: null }; |
| 91 | + }, |
| 92 | + async delete(_t: string, o?: Record<string, unknown>) { |
| 93 | + assertEngineDeleteDispatch(o); |
| 94 | + return { deleted: 0 }; |
| 95 | + }, |
| 96 | + async transaction<T>(cb: (ctx: any, info: { owned: boolean }) => Promise<T>): Promise<T> { |
| 97 | + return cb(undefined, { owned: true }); |
| 98 | + }, |
| 99 | + async syncObjectSchema() { /* no DDL in this stub */ }, |
| 100 | + registry: { |
| 101 | + listItems: () => [], |
| 102 | + isPackageDisabled: () => false, |
| 103 | + getItem: () => undefined, |
| 104 | + registerItem: () => {}, |
| 105 | + registerObject: () => {}, |
| 106 | + getPackage: () => undefined, |
| 107 | + }, |
| 108 | + }; |
| 109 | + /** Reads of the history table only — the quantity the card is about. */ |
| 110 | + const historyReads = () => findCalls.filter((t) => t === 'sys_metadata_history').length; |
| 111 | + return { engine, tables, findCalls, historyReads }; |
| 112 | +} |
| 113 | + |
| 114 | +/** Two versions differing in exactly one top-level key, so the diff is unambiguous. */ |
| 115 | +function seedTwoVersions( |
| 116 | + tables: Record<string, Array<Record<string, unknown>>>, |
| 117 | + type: string, |
| 118 | + name: string, |
| 119 | +) { |
| 120 | + const base = { organization_id: null, type, name }; |
| 121 | + [{ name, label: 'A' }, { name, label: 'B' }].forEach((body, i) => { |
| 122 | + tables.sys_metadata_history!.push({ |
| 123 | + ...base, |
| 124 | + id: `h_${i + 1}`, |
| 125 | + version: i + 1, |
| 126 | + event_seq: i + 1, |
| 127 | + operation_type: i === 0 ? 'create' : 'update', |
| 128 | + metadata: JSON.stringify(body), |
| 129 | + checksum: hashSpec(body), |
| 130 | + recorded_at: new Date(i + 1).toISOString(), |
| 131 | + }); |
| 132 | + }); |
| 133 | +} |
| 134 | + |
| 135 | +/** The diff both types must answer, byte for byte. */ |
| 136 | +const EXPECTED_DIFF_BODY = { |
| 137 | + added: [], |
| 138 | + removed: [], |
| 139 | + changed: [{ path: 'label', from: 'A', to: 'B' }], |
| 140 | +}; |
| 141 | + |
| 142 | +describe('#8798 — the early-return gate never reached diffMetaItem`s output', () => { |
| 143 | + it('earlyReturnFixtureIsStillEarlyReturn: `field` short-circuits BEFORE any engine read', async () => { |
| 144 | + // Anti-vacuity. Rows ARE seeded, so an empty answer here can only come |
| 145 | + // from the gate — and zero engine reads proves it returns before I/O |
| 146 | + // rather than reading and finding nothing. |
| 147 | + const { engine, tables, findCalls } = makeStubEngine(); |
| 148 | + seedTwoVersions(tables, EARLY_RETURN_TYPE, 'my_field'); |
| 149 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 150 | + |
| 151 | + const res = await protocol.historyMetaItem({ type: EARLY_RETURN_TYPE, name: 'my_field' }); |
| 152 | + |
| 153 | + expect(res.events).toEqual([]); |
| 154 | + expect(findCalls).toEqual([]); |
| 155 | + }); |
| 156 | + |
| 157 | + it('serves a FULL diff for that same gated-shut type — the gate never gated this path', async () => { |
| 158 | + // The case the dead call notionally covered. `diffMetaItem` reads the |
| 159 | + // history rows through the engine directly and never consults |
| 160 | + // `isOverlayAllowed`, so the type whose history endpoint refuses to |
| 161 | + // answer still gets a complete, correct diff. Identical to what the |
| 162 | + // pre-#8798 code returned. |
| 163 | + const { engine, tables, historyReads } = makeStubEngine(); |
| 164 | + seedTwoVersions(tables, EARLY_RETURN_TYPE, 'my_field'); |
| 165 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 166 | + |
| 167 | + const res: any = await protocol.diffMetaItem({ |
| 168 | + type: EARLY_RETURN_TYPE, |
| 169 | + name: 'my_field', |
| 170 | + fromVersion: 1, |
| 171 | + toVersion: 2, |
| 172 | + }); |
| 173 | + |
| 174 | + expect(res).toEqual({ |
| 175 | + type: EARLY_RETURN_TYPE, |
| 176 | + name: 'my_field', |
| 177 | + fromVersion: 1, |
| 178 | + toVersion: 2, |
| 179 | + ...EXPECTED_DIFF_BODY, |
| 180 | + }); |
| 181 | + expect(historyReads()).toBe(1); |
| 182 | + }); |
| 183 | + |
| 184 | + it('an ordinary type answers the SAME body — so the fixture choice is not doing the work', async () => { |
| 185 | + const { engine, tables } = makeStubEngine(); |
| 186 | + seedTwoVersions(tables, ORDINARY_TYPE, 'grid'); |
| 187 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 188 | + |
| 189 | + const res: any = await protocol.diffMetaItem({ |
| 190 | + type: ORDINARY_TYPE, |
| 191 | + name: 'grid', |
| 192 | + fromVersion: 1, |
| 193 | + toVersion: 2, |
| 194 | + }); |
| 195 | + |
| 196 | + expect(res).toEqual({ |
| 197 | + type: ORDINARY_TYPE, |
| 198 | + name: 'grid', |
| 199 | + fromVersion: 1, |
| 200 | + toVersion: 2, |
| 201 | + ...EXPECTED_DIFF_BODY, |
| 202 | + }); |
| 203 | + }); |
| 204 | +}); |
| 205 | + |
| 206 | +describe('#8798 — one request, one read of sys_metadata_history', () => { |
| 207 | + it('reads the history table exactly ONCE for a gated-open type', async () => { |
| 208 | + // THE REGRESSION PIN. Red before the deletion (2 reads), green after — |
| 209 | + // and the only assertion in this file that was. A reinstated |
| 210 | + // `historyMetaItem` call makes this 2 again while every value |
| 211 | + // assertion above stays green, which is precisely how the dead read |
| 212 | + // survived unnoticed in the first place. |
| 213 | + const { engine, tables, historyReads } = makeStubEngine(); |
| 214 | + seedTwoVersions(tables, ORDINARY_TYPE, 'grid'); |
| 215 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 216 | + |
| 217 | + await protocol.diffMetaItem({ |
| 218 | + type: ORDINARY_TYPE, |
| 219 | + name: 'grid', |
| 220 | + fromVersion: 1, |
| 221 | + toVersion: 2, |
| 222 | + }); |
| 223 | + |
| 224 | + expect(historyReads()).toBe(1); |
| 225 | + }); |
| 226 | +}); |
| 227 | + |
| 228 | +describe('#8798 — a history-table outage now answers the same way for every type', () => { |
| 229 | + /** |
| 230 | + * Pre-#8798 this pair DISAGREED, and only by accident: the discarded |
| 231 | + * `historyMetaItem` call was unguarded, so an outage threw for a gated-open |
| 232 | + * type, while a gated-shut type never reached the engine through that call |
| 233 | + * and fell into the `try`/`catch` below it. One outage, two answers, decided |
| 234 | + * by whether the type happened to pass an authorization gate that has |
| 235 | + * nothing to do with reading history. |
| 236 | + * |
| 237 | + * These pins do not endorse swallowing the outage — that `catch` predates |
| 238 | + * this card and is filed as #8833. They pin that the answer no longer |
| 239 | + * depends on the type. |
| 240 | + */ |
| 241 | + it('gated-open type falls through to an empty diff instead of throwing', async () => { |
| 242 | + const { engine, tables } = makeStubEngine({ throwOnHistory: true }); |
| 243 | + seedTwoVersions(tables, ORDINARY_TYPE, 'grid'); |
| 244 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 245 | + |
| 246 | + const res: any = await protocol.diffMetaItem({ |
| 247 | + type: ORDINARY_TYPE, |
| 248 | + name: 'grid', |
| 249 | + fromVersion: 1, |
| 250 | + toVersion: 2, |
| 251 | + }); |
| 252 | + |
| 253 | + expect(res.added).toEqual([]); |
| 254 | + expect(res.removed).toEqual([]); |
| 255 | + expect(res.changed).toEqual([]); |
| 256 | + }); |
| 257 | + |
| 258 | + it('gated-shut type answers identically — unchanged by #8798', async () => { |
| 259 | + const { engine, tables } = makeStubEngine({ throwOnHistory: true }); |
| 260 | + seedTwoVersions(tables, EARLY_RETURN_TYPE, 'my_field'); |
| 261 | + const protocol = new ObjectStackProtocolImplementation(engine); |
| 262 | + |
| 263 | + const res: any = await protocol.diffMetaItem({ |
| 264 | + type: EARLY_RETURN_TYPE, |
| 265 | + name: 'my_field', |
| 266 | + fromVersion: 1, |
| 267 | + toVersion: 2, |
| 268 | + }); |
| 269 | + |
| 270 | + expect(res.added).toEqual([]); |
| 271 | + expect(res.removed).toEqual([]); |
| 272 | + expect(res.changed).toEqual([]); |
| 273 | + }); |
| 274 | +}); |
0 commit comments