Skip to content

Commit 1827571

Browse files
perf(metadata-protocol): one GET /api/v1/data read makes one sys_metadata read of its object, not five (#24391)
Fixes #24371 Clause-②: no (no accept set or public surface moves: the same answer with fewer reads; owes the seat's review of record at `TIER_DEFAULT` before the queue) ## What changed Every `GET /api/v1/data/OBJECT` goes through the API-exposure gate, which reads the object by name (`getMetaItem`). That read sent three active-row lookups to the store one at a time, five round trips in all: 1. the package-less binding (`packagelessAddress`), one read with no limit; 2. and 3. the served row (`findServedOverlayRow`), one `findOne` per spelling (`object`, then `objects`); 4. and 5. the rows the lock is chosen from (`overlayLockLayerAt`), one read per spelling with no limit. Now `readMetaItem` reads the item's ACTIVE rows once, for both spellings (`readItemActiveRows`: `type IN (canonical, other), name, state = 'active'`, which uses the `(type, name, …)` overlay index). Each lookup then picks its rows from that read using the same `where` it used to send the store (`rowMatchesOverlayWhere`). The `version` token reuses the package binding the read already selected, so it no longer repeats lookup 1. The rows are read once per call and never kept, so the next request sees a metadata write. Only `packages/metadata-protocol/src/protocol.ts` changes at runtime. The other callers of the three lookups (`getMetaItemLayered`, the `_lock` gate, the save and delete doors) are unchanged: each still reads the store itself. ## Trace (M1): measured, before any fix A real backend (`pnpm dev:todo`, SQLite, knex query log) and one signed-in `GET /api/v1/data/todo_task` on base `6a49efb9` logged exactly the five statements the card names, in this order. All five come from the single `getMetaItem` call that `enforceApiAccess` makes (`packages/rest/src/rest-server.ts`): ``` select * from sys_metadata where type = ? and name = ? and state = ? ['object','todo_task','active'] packagelessAddress select … where type = ? and name = ? and state = ? limit ? ['object','todo_task','active',1] findServedOverlayRow, canonical select … where type = ? and name = ? and state = ? limit ? ['objects','todo_task','active',1] findServedOverlayRow, other spelling select * from sys_metadata where type = ? and name = ? and state = ? ['object','todo_task','active'] overlayLockLayerAt, canonical select * from sys_metadata where type = ? and name = ? and state = ? ['objects','todo_task','active'] overlayLockLayerAt, other spelling ``` `findData` itself made no `sys_metadata` read: the registry already holds the definition. No other `sys_metadata` read happens per request. The `sys_metadata_activation` read in the session prelude is a different table and belongs to #10757. H1 was confirmed, with one correction. The fifth read is not a second caller. It is `packagelessAddress`, the package-less binding the served-row lookup is given. After the fix, the same request logs one statement: ``` select * from sys_metadata where type in (?, ?) and name = ? and state = ? ['object','objects','todo_task','active'] ``` ## Latency (M2): measured at 50 ms RTT I ran Postgres 16 locally behind a TCP delay proxy that adds 25 ms each way. The todo app ran on a database seeded fresh for each leg. Each leg did 3 warm-up reads, then 15 timed `GET /api/v1/data/todo_task` as the signed-in admin. The before leg ran `protocol.ts` from base `6a49efb9`; the after leg ran this branch. | | median | range | `sys_metadata` statements per request | |---|---|---|---| | before | 551 ms | 540–646 ms | 5 (15 of 15 requests) | | after | 340 ms | 334–573 ms | 1 (15 of 15 requests) | The 211 ms saved is about four round trips. With ids and timestamps masked, the response bodies of the two legs are identical (8 records). These are wall times on a shared box. ## Pins - **Count, at the door.** `packages/rest/src/rest-data-read-one-overlay-read.test.ts` uses the real `RestServer`, the real protocol, the real `SchemaRegistry` and the real `findData`, over an engine double that records every `sys_metadata` read: - a list and a get of a code-defined object make exactly ONE read, `type: { $in: ['object', 'objects'] }`; - an object stored in the environment makes that read plus the ONE head read behind `version`; - a stored `enable` whitelist, stored under either spelling, still refuses a list 405; - a row edited between two requests is judged by the second. - **Answers, differential.** `packages/metadata-protocol/src/protocol.item-active-rows-one-read.test.ts` compares each lookup made from the one read with the store's own answer. It covers 76 arrangements of `object` rows and 584 of a bundled type (`email_template`): both spellings, the package-unbound row and two packages, canonical and non-canonical member, three lock states, two store orders. Every address is checked. Each lookup returns the same answer either way. - **Ablation (one, on the behaviour the pins test).** I swapped `activeRows = await this.readItemActiveRows(…)` for an undefined read through `scripts/ablation-replace.mjs`. On disk the anchor count went 1 → 0 and the marker count 0 → 1. `ablation-dist-preflight` then reported the marker present in 2 built files. The count rows went red (5 failed, 39 passed across this file and `rest-exposure-gate-one-object-read.test.ts`): - code object: 5 reads instead of 1; - stored object: 4 instead of 2; - the two 405 rows failed on their read count after their status and code assertions passed. Every answer row stayed green. Restore leg: `git hash-object` matches the HEAD blob, `--absent` preflight passed with a clean tree, and the same run gave 44 passed. ## Pin sweep: test doubles that answer `where` by equality only The single read sends `type: { $in: [...] }`. Many hand-written `sys_metadata` doubles compared every `where` value by plain equality, so the `$in` value matched nothing and the stored row silently vanished. That made 27 metadata-protocol files fail (5803 tests), plus 8 in rest, 11 in objectql and 1 in runtime. Each double now compares `$in` membership as a driver does. It still refuses (throws on) every other operator it refused before. Every real driver (sql, memory, mongodb) already supports `$in`, and `findEnvironmentRows` already sends `$nin`. `packages/objectql/src/protocol-meta.test.ts` had pins on the old read sequence: the `findOne` order `['app', 'apps', 'app']`, and overlay rows served through `findOne` mocks. These were flipped to the new contract: one `find` with `$in`, then the one head `findOne`. The flipped rows carry weight; they are the ones the ablation would turn red. Three doubles (`protocol.save-flow-canonicalization`, `protocol.runtime-authoring-gate`, objectql `protocol-publish-rollback`) answered `find` without filtering by `name`. They now filter by `name`, because the served row now comes from `find`. ## Verification record (final head `f95842334`, merged with `origin/main` at `5da2e888`) - Build: `pnpm build` (76 of 76 tasks), then the touched closure. - Typecheck, exit 0 with 0 errors for each of `@objectstack/metadata-protocol`, `@objectstack/rest`, `@objectstack/objectql` and `@objectstack/runtime`. The rest and metadata-protocol test layers compile; for rest, `check:test-typecheck` passed with 0 debt. - Tests (exit 0): | package | tests | |---|---| | metadata-protocol | 20655 passed | | rest | 6051 passed | | objectql | 8301 passed | | runtime | 6608 passed | | plugin-approvals | 1108 | | plugin-security | 3736 | | service-automation | 2533 | | service-package | 125 | | client | 671 | | cloud-connection | 614 | | cli `--project unit` | 4609 | These are every package whose tests construct the protocol over a double. The two rest files and one objectql file that the merge brought in were re-run on the merged tree (21 and 107 passed). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` with no paths derived 79 commands on `f95842334`. All 79 were run with the exit code captured before any pipe, and all exited 0. `--ran` reports: `79 derived, 79 run, 0 NOT-MEASURED, 0 UNRUN` (a derived zero; every line carries its exit code). `check:engine-double-contract` asked for the two new doubles to be recorded, and `scripts/engine-double-contract.pinned.json` gained two rows from `--write`. - Not run locally: `test:repo` and the repo-wide lint, which CI owns. The `cli` integration tier was not run, as declared for CI: no spawn entry was touched. ## Files outside `domain:engine` The runtime change stays in `packages/metadata-protocol/src/protocol.ts`. The sweep touches test files only, in `packages/rest/src` (9, one of them the new count pin), `packages/objectql/src` (11) and `packages/runtime/src/domains` (1), plus the ledger `scripts/engine-double-contract.pinned.json`. ## Acceptance notes - The card's bar is at most one `sys_metadata` read per data read. An object with no stored row (every code-defined object, which covers all of the console's opening reads that the card measured) meets it: 5 → 1. An object with a stored environment row still makes a second read: the head read behind `getMetaItem`'s `version`. It goes through `SysMetadataRepository.headAt`, the repository's one write-address resolution, whose own contract forbids re-deriving it elsewhere. Such an object is now at 2 (measured live: 3 before the `version` change, 2 in the count pin), down from 5 by the trace on base. The base count for a stored-row object was traced, not measured live. - Lookups the data path does not reach still issue their own reads, unchanged: `getMetaItemLayered`, the `_lock` gate on writes, the save and delete doors. - **Case-exact selection (record F1).** The one read's rows are selected in JavaScript with exact string comparison. On a SQL store whose column collation is case-insensitive (MySQL's default `_ci`), a by-name read whose `name`, `?package=` or bundle member differs from the stored row only by letter case used to be served that row and its lock. It is now not served it. Every grammatical address is unaffected, because the save and publish doors refuse a name outside `METADATA_ITEM_NAME_PATTERN` (lowercase snake_case). The registry, the metadata service and the `/data` registry gate were already case-exact, as SQLite and Postgres always were. --- _Generated by [Claude Code](https://claude.ai/code/session_01Fe2h3xpvRmGq9TqyCTV9JC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent eebaf3b commit 1827571

48 files changed

Lines changed: 839 additions & 91 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
Clause-②: no
6+
7+
perf(metadata-protocol): the by-name item read reads the item's stored active rows once, so a `GET /api/v1/data/:object` makes one `sys_metadata` read of the object instead of five
8+
9+
- **What changed.** `getMetaItem` (the read the API-exposure gate makes on every `/data` request) used to send its three active-row selections to the store one at a time: which package the item belongs to, the served row once per spelling of the type (`object`, then `objects`), and the rows its lock is chosen from, once per spelling. Now it reads the item's active rows once, for both spellings (`type IN ('object', 'objects')`), and each selection picks its rows from that one read with the same conditions it used to send the store. The item read's `version` reuses the package binding the read already selected.
10+
- **Measured.** On Postgres behind a 50 ms round-trip proxy, the median `GET /api/v1/data/todo_task` (example todo app, signed-in admin) went from 551 ms with 5 `sys_metadata` statements to 340 ms with 1.
11+
- **Unchanged.** The served item, its lock and `version`, and every refusal stay the same. Nothing is cached across calls, so a metadata write is seen by the next request. An object with a stored row still has one more read behind `version` (the repository's write-address head).

‎packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ function keyOf(w: Record<string, unknown>) {
8181
return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`;
8282
}
8383

84+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
85+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
86+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
87+
? (want as { $in: unknown[] }).$in.includes(actual)
88+
: actual === want;
89+
8490
function matchesMetadataWhere(r: Row, where: Record<string, unknown>): boolean {
8591
for (const [k, v] of Object.entries(where)) {
8692
if (k === '$or') {
@@ -89,7 +95,7 @@ function matchesMetadataWhere(r: Row, where: Record<string, unknown>): boolean {
8995
continue;
9096
}
9197
if (v === undefined) continue;
92-
if ((r as any)[k] !== v) return false;
98+
if (!whereValueMatches((r as any)[k], v)) return false;
9399
}
94100
return true;
95101
}

‎packages/metadata-protocol/src/protocol.audit-field-governance.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,16 @@ interface Row {
6161
version?: number;
6262
}
6363

64+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
65+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
66+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
67+
? (want as { $in: unknown[] }).$in.includes(actual)
68+
: actual === want;
69+
6470
function matches(r: Row, where: Record<string, unknown>): boolean {
6571
for (const [k, v] of Object.entries(where)) {
6672
if (v === undefined) continue;
67-
if ((r as any)[k] !== v) return false;
73+
if (!whereValueMatches((r as any)[k], v)) return false;
6874
}
6975
return true;
7076
}

‎packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,17 @@ const shadowRow = (name: string): Row => ({
9393
checksum: 'sha256_pre_fix_shadow',
9494
});
9595

96+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
97+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
98+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
99+
? (want as { $in: unknown[] }).$in.includes(actual)
100+
: actual === want;
101+
96102
const matchesWhere = (row: Record<string, unknown>, where: Record<string, unknown>) =>
97103
Object.entries(where ?? {}).every(([k, v]) => {
98104
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
99105
if (v === null || v === undefined) return row[k] === null || row[k] === undefined;
100-
return row[k] === v;
106+
return whereValueMatches(row[k], v);
101107
});
102108

103109
/**

‎packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ function makeKernel(opts: { seed?: Row[] } = {}) {
245245
if (k === '$or') return (v as Array<Record<string, unknown>>).some((c) => match(r, c));
246246
return v === null || v === undefined
247247
? (r as any)[k] === null || (r as any)[k] === undefined
248-
: (r as any)[k] === v;
248+
: whereValueMatches((r as any)[k], v);
249249
});
250250

251251
const engine: any = {
@@ -343,6 +343,12 @@ const FINDING_PROSE = "Field 'b' removed — existing data in this column will b
343343
// 1. The duplication is real — both channels carry the same findings
344344
// ═══════════════════════════════════════════════════════════════════════════
345345

346+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
347+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
348+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
349+
? (want as { $in: unknown[] }).$in.includes(actual)
350+
: actual === want;
351+
346352
describe('[#10886] the 409 renders its findings into the message AND attaches them', () => {
347353
it('declares the ADR-0112 envelope and attaches the structured findings', async () => {
348354
const err = await destructiveRefusal();

‎packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function makeKernel(seed: Row[] = [], artifacts: Record<string, unknown> = {}) {
168168
if (k === '$or') return (v as Array<Record<string, unknown>>).some((c) => match(r, c));
169169
return v === null || v === undefined
170170
? (r as any)[k] === null || (r as any)[k] === undefined
171-
: (r as any)[k] === v;
171+
: whereValueMatches((r as any)[k], v);
172172
});
173173

174174
const engine: any = {
@@ -261,6 +261,12 @@ afterEach(() => {
261261
// 0. CONTROL — the harness really does reach the gate
262262
// ═══════════════════════════════════════════════════════════════════════════
263263

264+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
265+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
266+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
267+
? (want as { $in: unknown[] }).$in.includes(actual)
268+
: actual === want;
269+
264270
describe('[#11014 §0 CONTROL] the gate fires for `object` through this harness', () => {
265271
it('refuses a field-dropping `object` save with the ADR-0112 envelope', async () => {
266272
const { protocol } = makeKernel([objectRow(PARENT, ['a', 'b'])]);

‎packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ const storedRow = (type: string, name: string, body: unknown, extra: Partial<Sto
7474
* entry the way `applyProtection` does and leaves a bare registration's
7575
* provenance alone.
7676
*/
77+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
78+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
79+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
80+
? (want as { $in: unknown[] }).$in.includes(actual)
81+
: actual === want;
82+
7783
function registryDouble() {
7884
const byType = new Map<string, Map<string, Record<string, unknown>>>();
7985
const collection = (type: string) => {
@@ -143,7 +149,7 @@ function harness(rows: StoredRow[], services: Map<string, unknown> = new Map())
143149
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
144150
}
145151
return rows.filter((r) =>
146-
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
152+
Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record<string, unknown>)[k], v)),
147153
);
148154
};
149155
const engine: any = {

‎packages/metadata-protocol/src/protocol.injected-system-columns.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,16 @@ interface Row {
6161
version?: number;
6262
}
6363

64+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
65+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
66+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
67+
? (want as { $in: unknown[] }).$in.includes(actual)
68+
: actual === want;
69+
6470
function matches(r: Row, where: Record<string, unknown>): boolean {
6571
for (const [k, v] of Object.entries(where)) {
6672
if (v === undefined) continue;
67-
if ((r as any)[k] !== v) return false;
73+
if (!whereValueMatches((r as any)[k], v)) return false;
6874
}
6975
return true;
7076
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#24371] The by-name item read takes the item's ACTIVE rows from ONE store
4+
// read of both spellings (`readItemActiveRows`), and each of its three
5+
// active-row selections — the package-less binding (`packagelessAddress`),
6+
// the served row (`findServedOverlayRow`) and the lock's scope
7+
// (`overlayLockLayerAt`) — selects from those rows by the `where` it used to
8+
// send the store. Before, they were five serial store reads on every `/data`
9+
// request (the API-exposure gate reads the object by name).
10+
//
11+
// The served answer must not move. This file is the differential half of that
12+
// claim: over every arrangement of up to three stored rows drawn from a pool
13+
// (both spellings, the package-unbound row and two packages' rows, a bundled
14+
// type's canonical and non-canonical member, three lock states), and every
15+
// address a read can name, each selection made from the one read equals the
16+
// selection the store answers by itself. The store double answers `where` as
17+
// a driver does: equality, `null` as IS NULL, `$in` membership, rows in
18+
// insertion order; anything else is refused.
19+
//
20+
// The count half — one read per `GET /api/v1/data/:object` — is pinned at the
21+
// door, `packages/rest/src/rest-data-read-one-overlay-read.test.ts`.
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { SINGULAR_TO_PLURAL } from '@objectstack/spec/shared';
25+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
26+
import { ObjectStackProtocolImplementation } from './protocol.js';
27+
28+
type Row = Record<string, unknown>;
29+
30+
const valueMatches = (actual: unknown, want: unknown): boolean => {
31+
if (want !== null && typeof want === 'object') {
32+
const set = (want as { $in?: unknown }).$in;
33+
if (!Array.isArray(set)) throw new Error(`store double: unsupported operator in ${JSON.stringify(want)}`);
34+
return set.includes(actual ?? null);
35+
}
36+
return (actual ?? null) === (want ?? null);
37+
};
38+
const matches = (row: Row, where: Row = {}): boolean => Object.entries(where).every(([k, v]) => {
39+
if (k.startsWith('$')) throw new Error(`store double: unsupported combinator ${k}`);
40+
return v === undefined || valueMatches(row[k], v);
41+
});
42+
43+
function protocolOver(rows: readonly Row[]): any {
44+
const engine = {
45+
registry: undefined,
46+
find: async (table: string, q: { where?: Row; limit?: number } = {}) => {
47+
if (table !== 'sys_metadata') return [];
48+
const hits = rows.filter((r) => matches(r, q.where));
49+
return typeof q.limit === 'number' ? hits.slice(0, q.limit) : hits;
50+
},
51+
findOne: async (table: string, q: { where?: Row } = {}) => {
52+
assertEngineFindOnePredicate(table, q as never);
53+
return table === 'sys_metadata' ? rows.find((r) => matches(r, q.where)) ?? null : null;
54+
},
55+
};
56+
return new ObjectStackProtocolImplementation(engine as never, () => new Map());
57+
}
58+
59+
const LOCKS = [undefined, 'full', 'no-delete'] as const;
60+
61+
/** Every distinct row key a type's item can hold, each with a lock from {@link LOCKS} by position. */
62+
function pool(type: string, discriminated: boolean): Row[] {
63+
const spellings = [type, SINGULAR_TO_PLURAL[type]].filter((t): t is string => typeof t === 'string');
64+
const members = discriminated ? [null, 'zh-CN'] : [null];
65+
const out: Row[] = [];
66+
for (const spelling of spellings) {
67+
for (const pkg of [null, 'pkg_a', 'pkg_b']) {
68+
for (const member of members) {
69+
const lock = LOCKS[out.length % LOCKS.length];
70+
out.push({
71+
id: `r${out.length}`, type: spelling, name: 'item', state: 'active', package_id: pkg,
72+
bundle_member: member,
73+
metadata: JSON.stringify({ name: 'item', label: `row ${out.length}`, ...(lock ? { _lock: lock } : {}) }),
74+
});
75+
}
76+
}
77+
}
78+
return out;
79+
}
80+
81+
function arrangements(rows: readonly Row[]): Row[][] {
82+
const out: Row[][] = [];
83+
for (let i = 0; i < rows.length; i++) {
84+
out.push([rows[i]!]);
85+
for (let j = i + 1; j < rows.length; j++) {
86+
out.push([rows[i]!, rows[j]!], [rows[j]!, rows[i]!]);
87+
for (let k = j + 1; k < rows.length; k++) out.push([rows[i]!, rows[j]!, rows[k]!], [rows[k]!, rows[j]!, rows[i]!]);
88+
}
89+
}
90+
return out;
91+
}
92+
93+
const idOf = (served: { row: Row } | undefined): unknown => served?.row.id;
94+
95+
for (const { type, discriminated } of [
96+
{ type: 'object', discriminated: false },
97+
{ type: 'email_template', discriminated: true },
98+
]) {
99+
describe(`[#24371] ${type}: each active-row selection made from the one read equals the store's own answer`, () => {
100+
const all = arrangements(pool(type, discriminated));
101+
const members = discriminated ? [undefined, 'zh-CN'] : [undefined];
102+
103+
it('the matrix is the one this file claims (not silently emptied)', () => {
104+
expect(SINGULAR_TO_PLURAL[type]).toBeTypeOf('string');
105+
// 6 row keys for `object` (2 spellings x 3 bindings), 12 for the
106+
// bundled type (x 2 members): every 1-row arrangement, and every
107+
// 2- and 3-row one in two store orders.
108+
expect(all.length).toBe(discriminated ? 584 : 76);
109+
});
110+
111+
it('one read serves every row the selections consult, of both spellings and nothing else', async () => {
112+
for (const rows of all) {
113+
const p = protocolOver([...rows, { ...rows[0]!, id: 'other_name', name: 'another' }]);
114+
const read = await p.readItemActiveRows(type, 'item');
115+
expect(read.map((r: Row) => r.id)).toEqual(rows.map((r) => r.id));
116+
}
117+
});
118+
119+
it('the package-less binding, the served row and the lock are the store\'s answers', async () => {
120+
let compared = 0;
121+
for (const rows of all) {
122+
const p = protocolOver(rows);
123+
const read = await p.readItemActiveRows(type, 'item');
124+
for (const member of members) {
125+
expect(await p.packagelessAddress(type, 'item', member, 'active', read))
126+
.toEqual(await p.packagelessAddress(type, 'item', member, 'active'));
127+
for (const packageId of [undefined, null, 'pkg_a', 'pkg_b']) {
128+
const args = { type, name: 'item', state: 'active', packageId, ...(member ? { member } : {}) };
129+
expect(idOf(await p.findServedOverlayRow(args, read)))
130+
.toBe(idOf(await p.findServedOverlayRow(args)));
131+
compared += 1;
132+
}
133+
}
134+
for (const packageId of [undefined, 'pkg_a', 'pkg_b']) {
135+
const address = { type, name: 'item', packageId };
136+
expect(await p.overlayLockLayerAt(address, { otherSpelling: true }, read))
137+
.toEqual(await p.overlayLockLayerAt(address, { otherSpelling: true }));
138+
}
139+
}
140+
expect(compared).toBe(all.length * members.length * 4);
141+
});
142+
});
143+
}

‎packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ interface StoredRow {
7777
metadata: string;
7878
}
7979

80+
/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */
81+
const whereValueMatches = (actual: unknown, want: unknown): boolean =>
82+
want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in)
83+
? (want as { $in: unknown[] }).$in.includes(actual)
84+
: actual === want;
85+
8086
function stored(key: RowKey, state: 'active' | 'draft' = 'active', type: string = TYPE): StoredRow {
8187
const spec: RowSpec = ROWS[key];
8288
const label = state === 'draft' ? `${spec.label} (draft)` : spec.label;
@@ -123,7 +129,7 @@ function harness(rows: StoredRow[], artifact = false, type: string = TYPE) {
123129
if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`);
124130
}
125131
return rows.filter((r) =>
126-
Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record<string, unknown>)[k] === v),
132+
Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record<string, unknown>)[k], v)),
127133
);
128134
};
129135
const engine: any = {

0 commit comments

Comments
 (0)