Skip to content

Commit 950bd94

Browse files
hotlongclaude
andauthored
perf(metadata-protocol): drop diffMetaItem's discarded historyMetaItem round trip (#8841)
* perf(metadata-protocol): drop diffMetaItem's discarded historyMetaItem round trip `diffMetaItem` awaited a full `historyMetaItem` read, mapped it into a `versions` array, and threw it away via `const _used = versions; void _used;` while the real read happened a few lines below through the engine. Every request to the routed, live `GET /api/v1/meta/:type/:name/diff` therefore paid for two reads of `sys_metadata_history` where one is used. The `void _used` marker is the reason this was worth a card rather than a tidy-up: a deliberate-looking marker on a value that is not load-bearing is what makes the next reader reason confidently from dead code. Measured before deleting, rather than inherited from the report: the one behaviour the call could still have provided is `historyMetaItem`'s early return for a type that is neither `isOverlayAllowed` nor `isRuntimeCreateAllowed`. It answers `{ events: [] }` without throwing and without touching the engine, and `diffMetaItem` never consulted that gate for its own output -- the five gated-shut types (`field`, `job`, `api`, `capability`, `agent`) had their diffs served by the direct read regardless. One measured behaviour change, toward the function's only stated intent: the discarded call was unguarded, so a `sys_metadata_history` outage was fatal for gated-open types while gated-shut ones fell into the `try`/`catch` below and answered an empty diff. One outage, two answers, decided by an authorization gate unrelated to reading history. Every type now takes the `catch`. Refs #8798 * docs(metadata-protocol): reference #8833 for the swallowed history-outage question --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d61924 commit 950bd94

3 files changed

Lines changed: 323 additions & 12 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
perf(metadata-protocol): `diffMetaItem` stops awaiting a `historyMetaItem` read it discarded, halving the history round trips on the live diff endpoint (#8798)
6+
7+
`diffMetaItem` opened by awaiting a full `historyMetaItem` read, mapped it into a
8+
`versions` array, and threw it away (`const _used = versions; void _used;`) while
9+
the read it actually uses ran a few lines below through the engine. Every request
10+
to the routed `GET /api/v1/meta/:type/:name/diff` paid for two reads of
11+
`sys_metadata_history` where one is used.
12+
13+
Diff bodies are unchanged. The authorization gate the discarded call passed
14+
through never reached this function's output: `historyMetaItem`'s early return
15+
answers `{ events: [] }` for a type that is neither `isOverlayAllowed` nor
16+
`isRuntimeCreateAllowed`, without throwing and without touching the engine, and
17+
`diffMetaItem` reads the history rows directly — so the five gated-shut types
18+
(`field`, `job`, `api`, `capability`, `agent`) were already served a full diff
19+
regardless.
20+
21+
One behaviour change, on the outage path only. The discarded call was unguarded,
22+
so an unavailable `sys_metadata_history` was fatal for gated-open types while
23+
gated-shut types fell into the `try`/`catch` below it and answered an empty diff
24+
— one outage, two answers, decided by an authorization gate unrelated to reading
25+
history. Every type now takes the `catch`, which is the function's only stated
26+
intent for that failure. Whether swallowing that outage is the right answer at
27+
all is tracked in #8833.
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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+
});

packages/metadata-protocol/src/protocol.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15563,17 +15563,28 @@ export class ObjectStackProtocolImplementation implements
1556315563
}> {
1556415564
const singularType = PLURAL_TO_SINGULAR[request.type] ?? request.type;
1556515565
const orgId = request.organizationId ?? null;
15566-
const events = (await this.historyMetaItem({
15567-
type: singularType,
15568-
name: request.name,
15569-
...(orgId ? { organizationId: orgId } : {}),
15570-
})).events;
15571-
const versions = events
15572-
.map((ev: any) => (ev as any).version as number | undefined)
15573-
.filter((v): v is number => typeof v === 'number');
15574-
// The `historyMetaItem` MetadataEvent shape doesn't carry the
15575-
// per-(type,name) `version` directly — re-fetch via the repo
15576-
// to read the underlying history rows with their version.
15566+
// [#8798] Read the history rows DIRECTLY, once. `historyMetaItem`
15567+
// cannot serve this function: its `MetadataEvent` shape doesn't carry
15568+
// the per-(type,name) `version` a diff selects versions by, so its
15569+
// result was computed into a `versions` array and then discarded
15570+
// (`const _used = versions; void _used;`) while the real read happened
15571+
// below — a second, unused round trip over `sys_metadata_history` on
15572+
// every request to a routed, live endpoint.
15573+
//
15574+
// ⛔ Do not reinstate a `historyMetaItem` call here "for the
15575+
// authorization check". It never performed one for this path, measured
15576+
// both ways: its early return (`isOverlayAllowed` / `isRuntimeCreateAllowed`)
15577+
// answers `{ events: [] }` WITHOUT throwing and without touching the
15578+
// engine, so the five types that take it (`field`, `job`, `api`,
15579+
// `capability`, `agent`) had their diff served by the read below
15580+
// regardless — the gate never reached this function's output. What the
15581+
// discarded call did change was failure behaviour, and only by accident:
15582+
// being unguarded, it made a `sys_metadata_history` outage FATAL for
15583+
// gated-open types while the `try` below answered an empty diff for the
15584+
// five gated-shut ones. One outage, two answers, decided by type. The
15585+
// `catch` below is this function's only stated intent for that failure,
15586+
// so removing the call makes every type take it. Pinned in
15587+
// `protocol.diff-dead-history-read.test.ts`.
1557715588
const repo = this.getOverlayRepo(orgId);
1557815589
const fullRef = {
1557915590
type: singularType,
@@ -15665,7 +15676,6 @@ export class ObjectStackProtocolImplementation implements
1566515676
changed: diff.changed.map((e) => ({ path: e.path, from: servedFrom[e.path], to: servedTo[e.path] })),
1566615677
};
1566715678
}
15668-
const _used = versions; void _used;
1566915679
return {
1567015680
type: request.type,
1567115681
name: request.name,

0 commit comments

Comments
 (0)