diff --git a/.changeset/24371-data-read-one-overlay-read.md b/.changeset/24371-data-read-one-overlay-read.md new file mode 100644 index 00000000000..b83e8e96603 --- /dev/null +++ b/.changeset/24371-data-read-one-overlay-read.md @@ -0,0 +1,11 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +Clause-②: no + +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 + +- **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. +- **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. +- **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). diff --git a/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts b/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts index 528f5949da2..b1984e33fd4 100644 --- a/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts +++ b/packages/metadata-protocol/src/protocol-publish-drafts-object-field-refs.test.ts @@ -81,6 +81,12 @@ function keyOf(w: Record) { return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matchesMetadataWhere(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { @@ -89,7 +95,7 @@ function matchesMetadataWhere(r: Row, where: Record): boolean { continue; } if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts b/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts index 17e335d6697..3b82fd076a9 100644 --- a/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts +++ b/packages/metadata-protocol/src/protocol.audit-field-governance.test.ts @@ -61,10 +61,16 @@ interface Row { version?: number; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts index bbe6ad22482..128dc927170 100644 --- a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -93,11 +93,17 @@ const shadowRow = (name: string): Row => ({ checksum: 'sha256_pre_fix_shadow', }); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + const matchesWhere = (row: Record, where: Record) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v === null || v === undefined) return row[k] === null || row[k] === undefined; - return row[k] === v; + return whereValueMatches(row[k], v); }); /** diff --git a/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts index ca73c19dfe4..84fc22ec65c 100644 --- a/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts @@ -245,7 +245,7 @@ function makeKernel(opts: { seed?: Row[] } = {}) { if (k === '$or') return (v as Array>).some((c) => match(r, c)); return v === null || v === undefined ? (r as any)[k] === null || (r as any)[k] === undefined - : (r as any)[k] === v; + : whereValueMatches((r as any)[k], v); }); const engine: any = { @@ -343,6 +343,12 @@ const FINDING_PROSE = "Field 'b' removed — existing data in this column will b // 1. The duplication is real — both channels carry the same findings // ═══════════════════════════════════════════════════════════════════════════ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + describe('[#10886] the 409 renders its findings into the message AND attaches them', () => { it('declares the ADR-0112 envelope and attaches the structured findings', async () => { const err = await destructiveRefusal(); diff --git a/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts b/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts index 2fabedc86d1..b499e93f902 100644 --- a/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts +++ b/packages/metadata-protocol/src/protocol.destructive-gate-reachable-types.test.ts @@ -168,7 +168,7 @@ function makeKernel(seed: Row[] = [], artifacts: Record = {}) { if (k === '$or') return (v as Array>).some((c) => match(r, c)); return v === null || v === undefined ? (r as any)[k] === null || (r as any)[k] === undefined - : (r as any)[k] === v; + : whereValueMatches((r as any)[k], v); }); const engine: any = { @@ -261,6 +261,12 @@ afterEach(() => { // 0. CONTROL — the harness really does reach the gate // ═══════════════════════════════════════════════════════════════════════════ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + describe('[#11014 §0 CONTROL] the gate fires for `object` through this harness', () => { it('refuses a field-dropping `object` save with the ADR-0112 envelope', async () => { const { protocol } = makeKernel([objectRow(PARENT, ['a', 'b'])]); diff --git a/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts b/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts index b3fa2b1d9d8..eb9ce79badb 100644 --- a/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts +++ b/packages/metadata-protocol/src/protocol.flow-by-name-shipped-name.test.ts @@ -74,6 +74,12 @@ const storedRow = (type: string, name: string, body: unknown, extra: Partial + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function registryDouble() { const byType = new Map>>(); const collection = (type: string) => { @@ -143,7 +149,7 @@ function harness(rows: StoredRow[], services: Map = new Map()) if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); } return rows.filter((r) => - Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), ); }; const engine: any = { diff --git a/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts b/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts index d3196fffc95..598bba29e56 100644 --- a/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts +++ b/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts @@ -61,10 +61,16 @@ interface Row { version?: number; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.item-active-rows-one-read.test.ts b/packages/metadata-protocol/src/protocol.item-active-rows-one-read.test.ts new file mode 100644 index 00000000000..6e6cbcc718f --- /dev/null +++ b/packages/metadata-protocol/src/protocol.item-active-rows-one-read.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24371] The by-name item read takes the item's ACTIVE rows from ONE store +// read of both spellings (`readItemActiveRows`), and each of its three +// active-row selections — the package-less binding (`packagelessAddress`), +// the served row (`findServedOverlayRow`) and the lock's scope +// (`overlayLockLayerAt`) — selects from those rows by the `where` it used to +// send the store. Before, they were five serial store reads on every `/data` +// request (the API-exposure gate reads the object by name). +// +// The served answer must not move. This file is the differential half of that +// claim: over every arrangement of up to three stored rows drawn from a pool +// (both spellings, the package-unbound row and two packages' rows, a bundled +// type's canonical and non-canonical member, three lock states), and every +// address a read can name, each selection made from the one read equals the +// selection the store answers by itself. The store double answers `where` as +// a driver does: equality, `null` as IS NULL, `$in` membership, rows in +// insertion order; anything else is refused. +// +// The count half — one read per `GET /api/v1/data/:object` — is pinned at the +// door, `packages/rest/src/rest-data-read-one-overlay-read.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { SINGULAR_TO_PLURAL } from '@objectstack/spec/shared'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +type Row = Record; + +const valueMatches = (actual: unknown, want: unknown): boolean => { + if (want !== null && typeof want === 'object') { + const set = (want as { $in?: unknown }).$in; + if (!Array.isArray(set)) throw new Error(`store double: unsupported operator in ${JSON.stringify(want)}`); + return set.includes(actual ?? null); + } + return (actual ?? null) === (want ?? null); +}; +const matches = (row: Row, where: Row = {}): boolean => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`store double: unsupported combinator ${k}`); + return v === undefined || valueMatches(row[k], v); +}); + +function protocolOver(rows: readonly Row[]): any { + const engine = { + registry: undefined, + find: async (table: string, q: { where?: Row; limit?: number } = {}) => { + if (table !== 'sys_metadata') return []; + const hits = rows.filter((r) => matches(r, q.where)); + return typeof q.limit === 'number' ? hits.slice(0, q.limit) : hits; + }, + findOne: async (table: string, q: { where?: Row } = {}) => { + assertEngineFindOnePredicate(table, q as never); + return table === 'sys_metadata' ? rows.find((r) => matches(r, q.where)) ?? null : null; + }, + }; + return new ObjectStackProtocolImplementation(engine as never, () => new Map()); +} + +const LOCKS = [undefined, 'full', 'no-delete'] as const; + +/** Every distinct row key a type's item can hold, each with a lock from {@link LOCKS} by position. */ +function pool(type: string, discriminated: boolean): Row[] { + const spellings = [type, SINGULAR_TO_PLURAL[type]].filter((t): t is string => typeof t === 'string'); + const members = discriminated ? [null, 'zh-CN'] : [null]; + const out: Row[] = []; + for (const spelling of spellings) { + for (const pkg of [null, 'pkg_a', 'pkg_b']) { + for (const member of members) { + const lock = LOCKS[out.length % LOCKS.length]; + out.push({ + id: `r${out.length}`, type: spelling, name: 'item', state: 'active', package_id: pkg, + bundle_member: member, + metadata: JSON.stringify({ name: 'item', label: `row ${out.length}`, ...(lock ? { _lock: lock } : {}) }), + }); + } + } + } + return out; +} + +function arrangements(rows: readonly Row[]): Row[][] { + const out: Row[][] = []; + for (let i = 0; i < rows.length; i++) { + out.push([rows[i]!]); + for (let j = i + 1; j < rows.length; j++) { + out.push([rows[i]!, rows[j]!], [rows[j]!, rows[i]!]); + for (let k = j + 1; k < rows.length; k++) out.push([rows[i]!, rows[j]!, rows[k]!], [rows[k]!, rows[j]!, rows[i]!]); + } + } + return out; +} + +const idOf = (served: { row: Row } | undefined): unknown => served?.row.id; + +for (const { type, discriminated } of [ + { type: 'object', discriminated: false }, + { type: 'email_template', discriminated: true }, +]) { + describe(`[#24371] ${type}: each active-row selection made from the one read equals the store's own answer`, () => { + const all = arrangements(pool(type, discriminated)); + const members = discriminated ? [undefined, 'zh-CN'] : [undefined]; + + it('the matrix is the one this file claims (not silently emptied)', () => { + expect(SINGULAR_TO_PLURAL[type]).toBeTypeOf('string'); + // 6 row keys for `object` (2 spellings x 3 bindings), 12 for the + // bundled type (x 2 members): every 1-row arrangement, and every + // 2- and 3-row one in two store orders. + expect(all.length).toBe(discriminated ? 584 : 76); + }); + + it('one read serves every row the selections consult, of both spellings and nothing else', async () => { + for (const rows of all) { + const p = protocolOver([...rows, { ...rows[0]!, id: 'other_name', name: 'another' }]); + const read = await p.readItemActiveRows(type, 'item'); + expect(read.map((r: Row) => r.id)).toEqual(rows.map((r) => r.id)); + } + }); + + it('the package-less binding, the served row and the lock are the store\'s answers', async () => { + let compared = 0; + for (const rows of all) { + const p = protocolOver(rows); + const read = await p.readItemActiveRows(type, 'item'); + for (const member of members) { + expect(await p.packagelessAddress(type, 'item', member, 'active', read)) + .toEqual(await p.packagelessAddress(type, 'item', member, 'active')); + for (const packageId of [undefined, null, 'pkg_a', 'pkg_b']) { + const args = { type, name: 'item', state: 'active', packageId, ...(member ? { member } : {}) }; + expect(idOf(await p.findServedOverlayRow(args, read))) + .toBe(idOf(await p.findServedOverlayRow(args))); + compared += 1; + } + } + for (const packageId of [undefined, 'pkg_a', 'pkg_b']) { + const address = { type, name: 'item', packageId }; + expect(await p.overlayLockLayerAt(address, { otherSpelling: true }, read)) + .toEqual(await p.overlayLockLayerAt(address, { otherSpelling: true })); + } + } + expect(compared).toBe(all.length * members.length * 4); + }); + }); +} diff --git a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts index 0c6dadffbb1..e336e77f3d3 100644 --- a/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts +++ b/packages/metadata-protocol/src/protocol.list-slot-prefer-local.test.ts @@ -77,6 +77,12 @@ interface StoredRow { metadata: string; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function stored(key: RowKey, state: 'active' | 'draft' = 'active', type: string = TYPE): StoredRow { const spec: RowSpec = ROWS[key]; const label = state === 'draft' ? `${spec.label} (draft)` : spec.label; @@ -123,7 +129,7 @@ function harness(rows: StoredRow[], artifact = false, type: string = TYPE) { if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); } return rows.filter((r) => - Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), ); }; const engine: any = { diff --git a/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts b/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts index 19a97a7d71d..8e23c0111ac 100644 --- a/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts @@ -86,6 +86,12 @@ const packaged = (name: string, lock: Lock, extra: Record = {}) * that keeps nothing (the `_lock` gate records its denial through it). No * write verbs beyond that — an admitted write is read off the gate itself. */ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function harness(environmentId: string | undefined, rows: StoredRow[] = [], artifacts: Artifacts = {}) { const registry = { getArtifactItem(type: string, name: string) { @@ -109,7 +115,7 @@ function harness(environmentId: string | undefined, rows: StoredRow[] = [], arti if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); } return rows.filter((r) => - Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), ); }; const engine: any = { diff --git a/packages/metadata-protocol/src/protocol.lock-one-resolution.kit.test.ts b/packages/metadata-protocol/src/protocol.lock-one-resolution.kit.test.ts index 1eaf769e7c4..1682d6a123c 100644 --- a/packages/metadata-protocol/src/protocol.lock-one-resolution.kit.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-one-resolution.kit.test.ts @@ -271,6 +271,12 @@ function describePackageRow(r: PackageRowAxis): string { function describeOtherPackageRow(r: PackageRowAxis): string { return r.row === 'none' ? '' : `env-wide other package's row _lock=${r.lock}`; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + export function titleOf(row: Row): string { return [ `${row.topology.kernel} kernel`, @@ -465,7 +471,7 @@ export function harness( if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); } return rows.filter((r) => - Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), ); }; const engine: any = { diff --git a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts index be8f6d603ad..aa63229d614 100644 --- a/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts +++ b/packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts @@ -63,6 +63,12 @@ interface StoredRow { } /** A tenant-authored row (a `view` unless named), as an author's save of a body declaring `_lock` leaves it at rest. */ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function viewRow(name: string, lock: Lock, type = 'view'): StoredRow { return { id: `r_${name}_env`, @@ -103,7 +109,7 @@ function harness(environmentId: string | undefined, rows: StoredRow[]) { if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); } return rows.filter((r) => - Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), ); }; const inserted: Array<{ table: string; values: Record }> = []; diff --git a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts index 1c3d4b01b12..2c5bb4a2644 100644 --- a/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts +++ b/packages/metadata-protocol/src/protocol.metadata-redaction.test.ts @@ -66,10 +66,16 @@ interface Row { version?: number; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 966ff64d9af..1ebcf665431 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -79,6 +79,12 @@ const keyOf = (w: Record) => `${w.type}|${w.name}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; /** Top-level eq + `$or` + explicit-NULL, the subset these paths emit. */ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matchesWhere(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { @@ -87,7 +93,7 @@ function matchesWhere(r: Row, where: Record): boolean { continue; } if (v === undefined) continue; - if ((r as unknown as Record)[k] !== v) return false; + if (!whereValueMatches((r as unknown as Record)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts index 68a613f6fc4..de5fc27b010 100644 --- a/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts +++ b/packages/metadata-protocol/src/protocol.packageless-save-address.test.ts @@ -79,12 +79,20 @@ const CODE_PACKAGE = 'com.example.showcase'; const INSTALLED_READ_ONLY = 'com.vendor.installed'; /** Equality and `null`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(row: Row, where: Record = {}): boolean { for (const [key, want] of Object.entries(where)) { if (want === undefined) continue; if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); - if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`); - if ((row[key] ?? null) !== want) return false; + if (want !== null && typeof want === 'object' && !Array.isArray((want as { $in?: unknown }).$in)) { + throw new Error(`stub engine: unsupported operator on ${key}`); + } + if (!whereValueMatches(row[key] ?? null, want)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.read-decorations.test.ts b/packages/metadata-protocol/src/protocol.read-decorations.test.ts index c47b81adf42..d8a8249d65f 100644 --- a/packages/metadata-protocol/src/protocol.read-decorations.test.ts +++ b/packages/metadata-protocol/src/protocol.read-decorations.test.ts @@ -51,10 +51,16 @@ interface Row { version?: number; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 60cece3064a..5564368cc81 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -131,7 +131,8 @@ function makeStubEngine() { }, async find(table: string, opts: { where: Record }) { return Array.from(tableOf(table).values()).filter((r) => { - if (opts.where.type && r.type !== opts.where.type) return false; + if (opts.where.type && !whereValueMatches(r.type, opts.where.type)) return false; + if (opts.where.name !== undefined && r.name !== opts.where.name) return false; if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false; if (opts.where.state && r.state !== opts.where.state) return false; @@ -206,6 +207,12 @@ const flowRows = (rows: Map) => const save = (protocol: any, item: unknown, extra: Record = {}) => protocol.saveMetaItem({ type: 'flow', name: 'leave_approval', item, ...extra }); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + describe('runtime authoring gate on saveMetaItem (#4463)', () => { let warn: ReturnType; beforeEach(() => { diff --git a/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts b/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts index 47305778ee3..be5eebe2e6c 100644 --- a/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts +++ b/packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts @@ -53,12 +53,20 @@ const PACKAGE_ID = 'com.example.locks'; const LOCK_KEYS = ['_lock', '_lockReason', '_lockDocsUrl', '_lockSource'] as const; /** Equality and `null`; any other operator is refused, so a matcher that ignored one cannot prove anything. */ +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(row: Row, where: Record = {}): boolean { for (const [key, want] of Object.entries(where)) { if (want === undefined) continue; if (key.startsWith('$')) throw new Error(`stub engine: unsupported operator ${key}`); - if (want !== null && typeof want === 'object') throw new Error(`stub engine: unsupported operator on ${key}`); - if ((row[key] ?? null) !== want) return false; + if (want !== null && typeof want === 'object' && !Array.isArray((want as { $in?: unknown }).$in)) { + throw new Error(`stub engine: unsupported operator on ${key}`); + } + if (!whereValueMatches(row[key] ?? null, want)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts index 7aab1635215..7f16b9d1821 100644 --- a/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts +++ b/packages/metadata-protocol/src/protocol.save-flow-canonicalization.test.ts @@ -137,7 +137,8 @@ function makeStubEngine() { }, async find(table: string, opts: { where: Record }) { return Array.from(tableOf(table).values()).filter((r) => { - if (opts.where.type && r.type !== opts.where.type) return false; + if (opts.where.type && !whereValueMatches(r.type, opts.where.type)) return false; + if (opts.where.name !== undefined && r.name !== opts.where.name) return false; if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false; if (opts.where.state && r.state !== opts.where.state) return false; @@ -205,6 +206,12 @@ async function seedStoredFlow(engine: any, body: Record, state: const save = (protocol: any, item: unknown, extra: Record = {}) => protocol.saveMetaItem({ type: 'flow', name: 'purge_flow', item, ...extra }); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + describe('saveMetaItem canonicalizes flow bodies (#4542)', () => { const legacyBody = () => flowBody({ objectName: 'lead', filters: { status: 'stale' } }); diff --git a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts index 6ee6350c48f..f6d4d003cb0 100644 --- a/packages/metadata-protocol/src/protocol.save-union-issues.test.ts +++ b/packages/metadata-protocol/src/protocol.save-union-issues.test.ts @@ -47,6 +47,12 @@ interface Row { const keyOf = (w: Record) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + /** * The engine surface the repository write path touches. * @@ -72,18 +78,25 @@ function makeProtocol(seed: Array<{ type: string; name: string; metadata: Record }; const rows = tableOf('sys_metadata'); let nextId = 0; + const seedRowsAt = (where: Record) => seed + .filter((r) => whereValueMatches(r.type, where.type) && r.name === where.name + && (where.state ?? 'active') === 'active' && (where.organization_id ?? null) === null) + .map((hit) => ({ + id: `seed_${hit.name}`, type: hit.type, name: hit.name, organization_id: null, state: 'active', + metadata: JSON.stringify(hit.metadata), + })); const engine: any = { async findOne(object: string, query?: EngineFindOneQueryInput) { assertEngineFindOnePredicate(object, query); - if (object !== 'sys_metadata' || seed.length === 0) return null; - const where = ((query as { where?: Record } | undefined)?.where ?? {}); - const hit = seed.find((r) => r.type === where.type && r.name === where.name - && (where.state ?? 'active') === 'active' && (where.organization_id ?? null) === null); - return hit - ? { id: `seed_${hit.name}`, type: hit.type, name: hit.name, organization_id: null, state: 'active', metadata: JSON.stringify(hit.metadata) } - : null; + if (object !== 'sys_metadata') return null; + return seedRowsAt((query as { where?: Record } | undefined)?.where ?? {})[0] ?? null; + }, + // [#24371] The by-name read takes the item's active rows from ONE + // `find` (both spellings, `type: { $in }`), so the seed is found there too. + async find(object: string, query?: { where?: Record }) { + if (object !== 'sys_metadata') return []; + return seedRowsAt(query?.where ?? {}); }, - async find() { return []; }, async insert(table: string, data: Record) { nextId += 1; const row = { id: `r_${nextId}`, ...(data as any) } as Row; diff --git a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts index f3bff94265b..6de1306593d 100644 --- a/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts +++ b/packages/metadata-protocol/src/protocol.scoped-list-fallback.test.ts @@ -77,6 +77,12 @@ interface StoredRow { metadata: string; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function stored( key: RowKey, options: { state?: 'active' | 'draft'; type?: string; name?: string; body?: Record } = {}, @@ -130,7 +136,7 @@ function harness( if (k.startsWith('$')) throw new Error(`[test double] unsupported WHERE combinator '${k}'`); } return rows.filter((r) => - Object.entries(where).every(([k, v]) => v === undefined || (r as unknown as Record)[k] === v), + Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as unknown as Record)[k], v)), ); }; const engine: any = { diff --git a/packages/metadata-protocol/src/protocol.served-content-hash.test.ts b/packages/metadata-protocol/src/protocol.served-content-hash.test.ts index 44877841166..4c693143fe0 100644 --- a/packages/metadata-protocol/src/protocol.served-content-hash.test.ts +++ b/packages/metadata-protocol/src/protocol.served-content-hash.test.ts @@ -74,6 +74,12 @@ function keyOf(w: Record) { return `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matchesWhere(r: Record, where: Record = {}): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { @@ -83,7 +89,7 @@ function matchesWhere(r: Record, where: Record // Any other combinator is REFUSED, never read as a field name. if (k.startsWith('$')) throw new Error(`stub engine: combinator '${k}' is not implemented`); if (v === undefined) continue; - if (r[k] !== v) return false; + if (!whereValueMatches(r[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts index 492e7d4e90b..bba8a3b8dd5 100644 --- a/packages/metadata-protocol/src/protocol.stored-conversions.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-conversions.test.ts @@ -37,10 +37,16 @@ interface Row { metadata: string; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8a1b5360a43..af22d251fb1 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2804,6 +2804,19 @@ function servedOverlayRowCandidates(address: { packages.map((packageId) => ({ spelling, packageId }))); } +/** + * [#24371] Whether a stored row read by + * {@link ObjectStackProtocolImplementation.readItemActiveRows} is one the + * store would answer for `where`, an equality `where` of the shape the + * by-name read's selections send ({@link servedOverlayRowCandidates}' + * candidates, the package-less binding and the lock's scope): every key equal, + * and `null` matching an absent value as the store's IS NULL does. + */ +function rowMatchesOverlayWhere(row: object, where: Readonly>): boolean { + const stored = row as Readonly>; + return Object.entries(where).every(([key, value]) => (stored[key] ?? null) === value); +} + /** * [#21967, ADR-0048] The package dimension of * {@link servedOverlayRowCandidates}, in its order: with a package, that @@ -11005,6 +11018,11 @@ export class ObjectStackProtocolImplementation implements * The reads keep the at-rest tolerance they have always had: a row stored * under the type's other spelling (pre-#4432 residue) is the last resort. * + * [#24371] `activeRows`, when given, is the item's ACTIVE rows under both + * spellings as {@link readItemActiveRows} read them: the candidates are then + * selected from those rows, each by the `where` it would have sent the + * store ({@link rowMatchesOverlayWhere}), instead of one `findOne` each. + * * Returns `undefined` when no row is found. A failed read propagates: each * caller owns its #5532 discrimination. */ @@ -11016,7 +11034,7 @@ export class ObjectStackProtocolImplementation implements packageId?: string | null; /** [#22877] The bundle member a by-name door's selector named; omitted, the canonical one. */ member?: string; - }): Promise<{ row: any } | undefined> { + }, activeRows?: readonly StoredOverlayRow[]): Promise<{ row: any } | undefined> { // [#21804] The order is {@link servedOverlayRowCandidates}, the one // the list's merge takes over the rows it read: the canonical // spelling then the other, and within each, the package's @@ -11040,7 +11058,9 @@ export class ObjectStackProtocolImplementation implements // already authorized the caller, and the protocol scopes the row // itself ({@link findEnvironmentRow}: the environment's rows only). // It no longer reaches the data engine as a principal-less context. - const row = await this.findEnvironmentRow('sys_metadata', { where, context: { isSystem: true } }); + const row = activeRows !== undefined && args.state === 'active' + ? activeRows.find((stored) => rowMatchesOverlayWhere(stored, where)) + : await this.findEnvironmentRow('sys_metadata', { where, context: { isSystem: true } }); if (row) return { row }; } return undefined; @@ -11067,23 +11087,58 @@ export class ObjectStackProtocolImplementation implements * is what #4432 refused. So the two agree on every row stored under the * canonical spelling, which is every row a live write can mint. * + * [#24371] `activeRows`, when given, is the item's ACTIVE rows under both + * spellings as {@link readItemActiveRows} read them, and each spelling's + * rows are taken from them instead of read again. + * * A failed read propagates: each caller owns its #5532 / #5706 * discrimination. */ - private overlayLockLayerAt(address: ItemAddress, options: { readonly otherSpelling: boolean }): Promise { + private overlayLockLayerAt( + address: ItemAddress, + options: { readonly otherSpelling: boolean }, + activeRows?: readonly StoredOverlayRow[], + ): Promise { const other = PLURAL_TO_SINGULAR[address.type] ?? SINGULAR_TO_PLURAL[address.type]; return resolveOverlayLockLayer(address, async (spelling) => { const type = spelling === 'canonical' ? address.type : other; if (type === undefined) return []; + const where = { type, name: address.name, state: 'active' }; + if (activeRows !== undefined) return activeRows.filter((row) => rowMatchesOverlayWhere(row, where)); // [#21911] The explicit system opt-in — see findServedOverlayRow. const rows: StoredOverlayRow[] | null | undefined = await this.findEnvironmentRows('sys_metadata', { - where: { type, name: address.name, state: 'active' }, + where, context: { isSystem: true }, }); return rows ?? []; }, options); } + /** + * [#24371, ADR-0131 D6] The item's stored ACTIVE rows under BOTH spellings + * of its type, in ONE store read: every row any of the by-name read's + * active-row selections consults ({@link packagelessAddress}'s binding, + * {@link findServedOverlayRow}'s candidates and {@link overlayLockLayerAt}'s + * scope), each of which then selects from these rows by the `where` it + * would have sent the store. Before, {@link getMetaItem} sent those + * selections to the store one by one, five serial reads of the same rows + * on every `/data` request (the API-exposure gate reads the object by + * name), and every read is a round trip to a hosted database. + * + * Read per call, never kept: a save is seen by the next read, as before. + * The environment's rows only ({@link findEnvironmentRows}). A failed read + * propagates: the caller owns its #5532 discrimination. + */ + private async readItemActiveRows(type: string, name: string): Promise { + const other = PLURAL_TO_SINGULAR[type] ?? SINGULAR_TO_PLURAL[type]; + // [#21911] The explicit system opt-in — see findServedOverlayRow. + const rows: StoredOverlayRow[] | null | undefined = await this.findEnvironmentRows('sys_metadata', { + where: { type: other === undefined ? type : { $in: [type, other] }, name, state: 'active' }, + context: { isSystem: true }, + }); + return rows ?? []; + } + /** * [#23183] The packages {@link resolveAppPackageIdKey} has reported as * holding more than one app, keyed by package and app set, so the operator @@ -11331,8 +11386,31 @@ export class ObjectStackProtocolImplementation implements const draftContentAddress = !request.packageId && (readState === 'draft' || request.previewDrafts) ? await this.strictDraftReadAddress(request.type, request.name, member) : undefined; - const contentPackageId = request.packageId - || (readState === 'draft' ? undefined : await this.packagelessReadAddress(request.type, request.name, member)); + // [#24371] The item's ACTIVE rows, both spellings, read ONCE + // ({@link readItemActiveRows}): the package-less binding, the served + // row and the lock below each select from them by the `where` it would + // have sent the store. They were five serial reads of the same rows, + // on every `/data` request. Read for this call only, never kept. + let activeRows: StoredOverlayRow[] | undefined; + if (readState === 'active') { + try { + activeRows = await this.readItemActiveRows(request.type, request.name); + } catch (error) { + // [#5532] Only an unprovisioned store holds no row; any other + // failure is a read that did not happen, never "no row". + this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); + activeRows = []; + } + } + // The package-less binding ({@link packagelessReadAddress}'s), selected + // from those rows; the `version` below ({@link readVersionToken}) + // takes the same binding instead of reading it again. + const packagelessBinding = readState === 'active' && !request.packageId + ? (await this.packagelessAddress( + PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name, member, 'active', activeRows, + )).packageId + : undefined; + const contentPackageId = request.packageId || (packagelessBinding ?? undefined); // ADR-0033 draft-overlay preview (non-strict): when the caller opts in // (admin-gated upstream), prefer a `state='draft'` row if one exists, else @@ -11408,7 +11486,7 @@ export class ObjectStackProtocolImplementation implements ? { packageId: draftContentAddress.packageId } : contentPackageId ? { packageId: contentPackageId } : {}), ...memberOption(member), - }))?.row; + }, activeRows))?.row; // [#23715] Two or more packages and no package-unbound draft: the // best-effort first match ADR-0048 §3.3 states, as before, served // with `version: null` (no row at the address the save naming none @@ -11479,7 +11557,7 @@ export class ObjectStackProtocolImplementation implements type: request.type, name: request.name, packageId: request.packageId, - }, { otherSpelling: true }); + }, { otherSpelling: true }, activeRows); } catch (error) { // [#5532] The same rule as the row read above: a lock read that did // not happen is not an unlocked item. @@ -11759,7 +11837,11 @@ export class ObjectStackProtocolImplementation implements // editor reads `state: 'draft'` or the plain read. ...(request.previewDrafts ? {} - : { version: storedRowServed ? await this.readVersionToken({ ...request, member }, 'active') : null }), + : { + version: storedRowServed + ? await this.readVersionToken({ ...request, member }, 'active', packagelessBinding) + : null, + }), ...(readRegistration ? { registration: readRegistration(request.name) } : {}), }; } @@ -19472,6 +19554,12 @@ export class ObjectStackProtocolImplementation implements /** [#22877] `member` — the bundle member the read addressed; omitted, the canonical one. */ request: { type: string; name: string; packageId?: string; member?: string }, state: 'active' | 'draft', + /** + * [#24371] The item's package-less binding ({@link packagelessAddress} + * at `active`) when the caller has already resolved it; omitted, it is + * read here. + */ + packagelessPackageId?: string | null, ): Promise { const repo = this.getOverlayRepo(); const ref = { @@ -19480,7 +19568,9 @@ export class ObjectStackProtocolImplementation implements org: 'env', } as Parameters[0]; const packageId = request.packageId == null && state === 'active' - ? (await this.packagelessAddress(ref.type, ref.name, request.member)).packageId + ? packagelessPackageId !== undefined + ? packagelessPackageId + : (await this.packagelessAddress(ref.type, ref.name, request.member)).packageId : request.packageId ?? null; const stored = await this.storedHeadAt(repo, ref, state, packageId, request.member); return stored === null ? null : this.receiptVersion(stored); @@ -19592,14 +19682,16 @@ export class ObjectStackProtocolImplementation implements /** [#22877] The bundle member the write addresses (a save's body's, a delete's selector); omitted, the canonical one. */ member?: string, state: 'active' | 'draft' = 'active', + /** [#24371] The item's active rows as {@link readItemActiveRows} read them: selected from, not read again. */ + activeRows?: readonly StoredOverlayRow[], ): Promise<{ packageId: string | null; candidates: readonly string[]; unbound: boolean }> { let rows: Array<{ package_id?: unknown }> | null | undefined; + const where = { type, name, state, ...overlayMemberWhere(type, member) }; try { // [#21911] The explicit system opt-in — see findServedOverlayRow. - rows = await this.findEnvironmentRows('sys_metadata', { - where: { type, name, state, ...overlayMemberWhere(type, member) }, - context: { isSystem: true }, - }); + rows = activeRows !== undefined && state === 'active' + ? activeRows.filter((row) => rowMatchesOverlayWhere(row, where)) + : await this.findEnvironmentRows('sys_metadata', { where, context: { isSystem: true } }); } catch (error) { // [#5532] An unprovisioned store holds no row, so no binding; any // other failure is a read that did not happen, never "no binding". diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 78ea7ddafa1..5ae41fc150d 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -40,10 +40,16 @@ interface Row { package_id: string | null; state: string; metadata: string; checksum?: string; version?: number; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matches(r: Row, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (v === undefined) continue; - if ((r as any)[k] !== v) return false; + if (!whereValueMatches((r as any)[k], v)) return false; } return true; } diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts index 4803c252b97..01fdd860523 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -85,6 +85,11 @@ function makeMemoryDriver() { } if (k.startsWith('$')) continue; const rowVal = row[k]; + // [#24371] The by-name read's both-spellings `type: { $in: [...] }`. + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.includes(rowVal === undefined ? null : rowVal)) return false; + continue; + } const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; const a = rowVal === undefined ? null : rowVal; const b = expected === undefined ? null : expected; diff --git a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts index 0992739f2fa..7faed49b0b3 100644 --- a/packages/objectql/src/environment-overlay-email-locale-restart.test.ts +++ b/packages/objectql/src/environment-overlay-email-locale-restart.test.ts @@ -102,6 +102,11 @@ function makeMemoryDriver(stores: Map ({ const rowKey = (w: Record) => [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + const matchesWhere = (row: Record, where: Record) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v === null) return row[k] === null || row[k] === undefined; - return row[k] === v; + return whereValueMatches(row[k], v); }); /** diff --git a/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts b/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts index 375ee3d7ef6..8032da9ef76 100644 --- a/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts +++ b/packages/objectql/src/protocol-lock-artifact-package-axis.test.ts @@ -41,6 +41,12 @@ interface Arrangement { readonly disabled?: readonly string[]; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + /** One protocol over a real registry with the packages registered in `order`, and the stored row, if any. */ function protocolFor(arrangement: Arrangement, order: readonly string[]) { const registry = new SchemaRegistry({ multiTenant: false, logLevel: 'silent' }); @@ -65,7 +71,7 @@ function protocolFor(arrangement: Arrangement, order: readonly string[]) { }), }]; const matching = (where: Record = {}) => - rows.filter((r) => Object.entries(where).every(([k, v]) => v === undefined || (r as Record)[k] === v)); + rows.filter((r) => Object.entries(where).every(([k, v]) => v === undefined || whereValueMatches((r as Record)[k], v))); const engine = { registry, async find(table: string, opts?: { where?: Record; limit?: number }) { diff --git a/packages/objectql/src/protocol-meta-type-canonicalization.test.ts b/packages/objectql/src/protocol-meta-type-canonicalization.test.ts index 9a4abd6c00e..7b56c863a9b 100644 --- a/packages/objectql/src/protocol-meta-type-canonicalization.test.ts +++ b/packages/objectql/src/protocol-meta-type-canonicalization.test.ts @@ -37,6 +37,12 @@ const OVERLAY_ROW = { metadata: JSON.stringify({ name: 'rc1_probe', label: 'Probe' }), }; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + describe('#4432 — canonical `/meta` type segment', () => { let registry: SchemaRegistry; let engine: any; @@ -56,7 +62,7 @@ describe('#4432 — canonical `/meta` type segment', () => { find: vi.fn(async (_table: string, opts: any) => { const w = opts?.where ?? {}; return rows.filter((r) => - (w.type === undefined || r.type === w.type) + (w.type === undefined || whereValueMatches(r.type, w.type)) && (w.name === undefined || r.name === w.name) && (w.state === undefined || r.state === w.state) && (w.organization_id === undefined || r.organization_id === w.organization_id)); diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index dc620874b51..0b5535de7c8 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -75,14 +75,16 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }; it('getMetaItem serves the environment row, and never names an organization', async () => { - mockEngine.findOne.mockImplementation(envOnly( - { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }) }, - )); + // [#24371] The item's active rows are ONE `find`; the head read + // behind `version` is a `findOne`. Neither names an organization. + const envRow = { type: 'view', name: 'test_grid', state: 'active', metadata: JSON.stringify({ ...sampleView, label: 'Env Default' }) }; + mockEngine.find.mockImplementation(envOnly([envRow])); + mockEngine.findOne.mockImplementation(envOnly(envRow)); const result = await protocol.getMetaItem({ type: 'view', name: 'test_grid', organizationId: 'org_alpha', } as { type: string; name: string }); expect((result.item as any).label).toBe('Env Default'); - for (const [table, query] of mockEngine.findOne.mock.calls) { + for (const [table, query] of [...mockEngine.find.mock.calls, ...mockEngine.findOne.mock.calls]) { if (table === 'sys_metadata') expect((query as any)?.where ?? {}).not.toHaveProperty('organization_id'); } }); @@ -161,13 +163,17 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('falls back to active when previewDrafts but no draft exists (no no_draft 404)', async () => { + const active = { type: 'object', name: 'lead', state: 'active', metadata: JSON.stringify({ name: 'lead', label: 'Active Lead' }) }; mockEngine.findOne.mockImplementation((_t: string, opts: any) => { const w = opts?.where ?? {}; - if (w.state === 'active' && w.name === 'lead') { - return Promise.resolve({ type: 'object', name: 'lead', state: 'active', metadata: JSON.stringify({ name: 'lead', label: 'Active Lead' }) }); - } + if (w.state === 'active' && w.name === 'lead') return Promise.resolve(active); return Promise.resolve(null); // no draft row }); + // [#24371] The active rows are one `find` (both spellings). + mockEngine.find.mockImplementation((_t: string, opts: any) => { + const w = opts?.where ?? {}; + return Promise.resolve(w.state === 'active' && w.name === 'lead' ? [active] : []); + }); const res: any = await protocol.getMetaItem({ type: 'object', name: 'lead', previewDrafts: true }); expect(res.item.label).toBe('Active Lead'); expect(res.item._draft).toBeUndefined(); @@ -602,10 +608,11 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' }); // Registry value is still returned (no overlay row exists), - // but sys_metadata MUST have been queried. + // but sys_metadata MUST have been queried — [#24371] in ONE read + // of the item's active rows under both spellings. expect(result.item).toMatchObject(sampleApp); - expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({ - where: expect.objectContaining({ type: 'app', name: 'test_app', state: 'active' }), + expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', expect.objectContaining({ + where: { type: { $in: ['app', 'apps'] }, name: 'test_app', state: 'active' }, })); }); @@ -619,18 +626,20 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('should return overlay row content from DB when present', async () => { - mockEngine.findOne.mockResolvedValue({ + mockEngine.find.mockResolvedValue([{ type: 'app', name: 'test_app', state: 'active', metadata: JSON.stringify(sampleApp), - }); + }]); const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' }); expect(result.item).toMatchObject(sampleApp); - expect(mockEngine.findOne).toHaveBeenCalledWith('sys_metadata', { - where: { type: 'app', name: 'test_app', state: 'active' }, + // [#24371] The item's active rows, both spellings, in ONE read. + expect(mockEngine.find).toHaveBeenCalledTimes(1); + expect(mockEngine.find).toHaveBeenCalledWith('sys_metadata', { + where: { type: { $in: ['app', 'apps'] }, name: 'test_app', state: 'active' }, // [#21911] The platform store read carries the explicit system opt-in. context: { isSystem: true }, }); @@ -653,28 +662,30 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { }); it('should try alternate type name in DB when primary type not found', async () => { - // 'app' not found, try 'apps' - mockEngine.findOne - .mockResolvedValueOnce(null) // first call: type='app' not found - .mockResolvedValueOnce({ // second call: type='apps' found - type: 'apps', - name: 'test_app', - state: 'active', - metadata: JSON.stringify(sampleApp), - }); + // Only an 'apps' row is stored: the canonical 'app' has none. + mockEngine.find.mockResolvedValue([{ + type: 'apps', + name: 'test_app', + state: 'active', + metadata: JSON.stringify(sampleApp), + }]); const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' }); expect(result.item).toMatchObject(sampleApp); - // Two overlay reads, the canonical spelling and then the other — + // [#24371] ONE overlay read, both spellings (the served row is the + // canonical spelling's, else the other's, selected from it) — // and [#22114] then ONE head read at the SAVE's address (canonical // spelling, env-wide, package-unbound), which serves the read's // `version`. The row served here sits under the other spelling, // which no save writes, so that head is empty and `version` is null. - const reads = mockEngine.findOne.mock.calls.map(([table, opts]: any[]) => ({ table, where: opts?.where })); - expect(reads.map((r: any) => r.where?.type)).toEqual(['app', 'apps', 'app']); - expect(reads.every((r: any) => r.table === 'sys_metadata')).toBe(true); - expect(reads[2].where).toMatchObject({ + expect(mockEngine.find.mock.calls.map(([table, opts]: any[]) => [table, opts?.where])).toEqual([ + ['sys_metadata', { type: { $in: ['app', 'apps'] }, name: 'test_app', state: 'active' }], + ]); + const heads = mockEngine.findOne.mock.calls.map(([table, opts]: any[]) => ({ table, where: opts?.where })); + expect(heads).toHaveLength(1); + expect(heads[0].table).toBe('sys_metadata'); + expect(heads[0].where).toMatchObject({ type: 'app', name: 'test_app', state: 'active', package_id: null, }); expect((result as any).version).toBeNull(); @@ -699,6 +710,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // reason; everything else is reported. it('an unreadable store is REPORTED, not answered as an absent item (#5532)', async () => { const outage = new Error('DB down'); + mockEngine.find.mockRejectedValue(outage); mockEngine.findOne.mockRejectedValue(outage); await expect(protocol.getMetaItem({ type: 'app', name: 'test_app' })) @@ -710,6 +722,7 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { // rows, so the registry answer is the truth and first boot must not // explode. This is the half of the old test that was right. registry.registerItem('app', sampleApp, 'name' as any); + mockEngine.find.mockRejectedValue(new Error('SQLITE_ERROR: no such table: sys_metadata')); mockEngine.findOne.mockRejectedValue(new Error('SQLITE_ERROR: no such table: sys_metadata')); const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' }); @@ -799,6 +812,11 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { mockEngine.findOne.mockImplementation(async (_collection: string, query: any) => query?.where?.package_id ? (rows[query.where.package_id] ?? null) : null, ); + // [#24371] The item's active rows are one `find`, and the served + // row is selected from them by the same package-scoped `where`. + mockEngine.find.mockImplementation(async (_collection: string, query: any) => + query?.where?.name === 'intro' && query?.where?.state === 'active' ? Object.values(rows) : [], + ); const studio = await protocol.getMetaItem({ type: 'doc', name: 'intro', packageId: 'com.objectstack.studio' }); const setup = await protocol.getMetaItem({ type: 'doc', name: 'intro', packageId: 'com.objectstack.setup' }); @@ -815,6 +833,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { state: 'active', metadata: JSON.stringify(complexData), }); + // [#24371] The item's active rows are one `find` (both spellings). + mockEngine.find.mockResolvedValue([{ + type: 'object', + name: 'complex', + state: 'active', + metadata: JSON.stringify(complexData), + }]); const result = await protocol.getMetaItem({ type: 'object', name: 'complex' }); @@ -828,6 +853,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { state: 'active', metadata: sampleApp, // already an object, not a string }); + // [#24371] The item's active rows are one `find` (both spellings). + mockEngine.find.mockResolvedValue([{ + type: 'app', + name: 'test_app', + state: 'active', + metadata: sampleApp, // already an object, not a string + }]); const result = await protocol.getMetaItem({ type: 'app', name: 'test_app' }); @@ -856,6 +888,13 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { state: 'active', metadata: JSON.stringify(sampleObject), }); + // [#24371] The item's active rows are one `find` (both spellings). + mockEngine.find.mockResolvedValue([{ + type: 'object', + name: 'customer', + state: 'active', + metadata: JSON.stringify(sampleObject), + }]); }); it('produces distinct ETags for distinct locales', async () => { @@ -1957,16 +1996,23 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { state: 'active', metadata: JSON.stringify(userHook), }); + // [#24371] The item's active rows are one `find` (both spellings). + mockEngine.find.mockResolvedValue([{ + type: 'hook', + name: 'my_runtime_hook', + state: 'active', + metadata: JSON.stringify(userHook), + }]); const result = await scoped.getMetaItem({ type: 'hook', name: 'my_runtime_hook' }); expect(result.item).toMatchObject(userHook); // The first call must query sys_metadata — proves the read // path consults the DB regardless of supportsOverlay:false. - expect(mockEngine.findOne).toHaveBeenCalledWith( + expect(mockEngine.find).toHaveBeenCalledWith( 'sys_metadata', expect.objectContaining({ - where: expect.objectContaining({ type: 'hook', name: 'my_runtime_hook', state: 'active' }), + where: expect.objectContaining({ type: { $in: ['hook', 'hooks'] }, name: 'my_runtime_hook', state: 'active' }), }), ); }); diff --git a/packages/objectql/src/protocol-packaged-dashboard-base.test.ts b/packages/objectql/src/protocol-packaged-dashboard-base.test.ts index ac005743f26..8b3350d88f0 100644 --- a/packages/objectql/src/protocol-packaged-dashboard-base.test.ts +++ b/packages/objectql/src/protocol-packaged-dashboard-base.test.ts @@ -90,11 +90,17 @@ const BUNDLE: TranslationBundle = { } as any, }; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + const matchesWhere = (row: Record, where: Record) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v === null) return row[k] === null || row[k] === undefined; - return row[k] === v; + return whereValueMatches(row[k], v); }); /** diff --git a/packages/objectql/src/protocol-packaged-view-base.test.ts b/packages/objectql/src/protocol-packaged-view-base.test.ts index 219220d4c9d..98d24eb7fc9 100644 --- a/packages/objectql/src/protocol-packaged-view-base.test.ts +++ b/packages/objectql/src/protocol-packaged-view-base.test.ts @@ -65,11 +65,17 @@ const BUNDLE: TranslationBundle = { 'zh-CN': { objects: { [OBJ]: { _views: { in_progress: { label: '进行中' }, urgent: { label: '紧急' } } } } } as any, }; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + const matchesWhere = (row: Record, where: Record) => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v === null) return row[k] === null || row[k] === undefined; - return row[k] === v; + return whereValueMatches(row[k], v); }); /** diff --git a/packages/objectql/src/protocol-publish-rollback.test.ts b/packages/objectql/src/protocol-publish-rollback.test.ts index f7e4cccd676..5cd84a48454 100644 --- a/packages/objectql/src/protocol-publish-rollback.test.ts +++ b/packages/objectql/src/protocol-publish-rollback.test.ts @@ -112,7 +112,8 @@ function makeStubEngine() { return out; } return Array.from(rows.values()).filter((r) => { - if (opts.where.type && r.type !== opts.where.type) return false; + if (opts.where.type && !whereValueMatches(r.type, opts.where.type)) return false; + if (opts.where.name !== undefined && r.name !== opts.where.name) return false; if (opts.where.organization_id !== undefined && r.organization_id !== opts.where.organization_id) return false; if (opts.where.state && r.state !== opts.where.state) return false; @@ -167,6 +168,12 @@ function makeStubEngine() { return { engine, rows, historyRows }; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + describe('publishMetaItem / rollbackMetaItem / diffMetaItem', () => { const sampleBody = (label: string) => ({ name: 'case_grid', type: 'grid', label, columns: ['id', 'title'], diff --git a/packages/objectql/src/registry-field-type-refused-at-door.test.ts b/packages/objectql/src/registry-field-type-refused-at-door.test.ts index 49e3af66459..02b886dca3f 100644 --- a/packages/objectql/src/registry-field-type-refused-at-door.test.ts +++ b/packages/objectql/src/registry-field-type-refused-at-door.test.ts @@ -200,6 +200,11 @@ function makeStubDriver() { if (k === '$and' && Array.isArray(v)) { if (!v.every((w: any) => matchesWhere(row, w))) return false; continue; } if (k === '$or' && Array.isArray(v)) { if (!v.some((w: any) => matchesWhere(row, w))) return false; continue; } if (k.startsWith('$')) continue; + // [#24371] The by-name read's both-spellings `type: { $in: [...] }`. + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) { + if (!(v as any).$in.includes(row[k] === undefined ? null : row[k])) return false; + continue; + } const expected = (v && typeof v === 'object' && '$eq' in (v as any)) ? (v as any).$eq : v; const a = row[k] === undefined ? null : row[k]; const b = expected === undefined ? null : expected; diff --git a/packages/rest/src/meta-compound-save-force-parity.test.ts b/packages/rest/src/meta-compound-save-force-parity.test.ts index e5b4ddaad10..54ea1e5dd9f 100644 --- a/packages/rest/src/meta-compound-save-force-parity.test.ts +++ b/packages/rest/src/meta-compound-save-force-parity.test.ts @@ -109,6 +109,12 @@ const STORED_FIELDS = ['a', 'b', 'c', 'd']; /** The body every case submits: three of the four columns, gone. */ const SHRUNK_FIELDS = ['a']; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function mockServer() { return { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), @@ -190,7 +196,7 @@ function boot() { return Object.entries(where ?? {}).every(([k, v]) => v === null || v === undefined ? r[k] === null || r[k] === undefined - : r[k] === v, + : whereValueMatches(r[k], v), ); }; diff --git a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts index bdac26105f1..aea6edf7abc 100644 --- a/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts +++ b/packages/rest/src/meta-dashboard-view-i18n-explicit-override.test.ts @@ -153,6 +153,12 @@ const LOCALES = ['en', 'zh-CN'] as const; type OverlayRow = { type: 'dashboard' | 'view'; name: string; body: Record }; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function createMockServer() { return { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), @@ -188,7 +194,7 @@ function makeHost(overlays: OverlayRow[], who: Who) { const matches = (r: Record, w: Record) => Object.entries(w ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return (r[k] ?? null) === (v ?? null); + return whereValueMatches(r[k] ?? null, v ?? null); }); const engine = { registry, diff --git a/packages/rest/src/meta-disabled-package-doors.test.ts b/packages/rest/src/meta-disabled-package-doors.test.ts index e3134a49e81..b35ac9a8623 100644 --- a/packages/rest/src/meta-disabled-package-doors.test.ts +++ b/packages/rest/src/meta-disabled-package-doors.test.ts @@ -45,11 +45,17 @@ const objectBody = (name: string) => ({ const rowKey = (w: Record) => [w.type, w.name, w.organization_id ?? '', w.package_id ?? '', w.state ?? 'active'].join('|'); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + const matchesWhere = (row: Record, where: Record) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); if (v === null) return row[k] === null || row[k] === undefined; - return row[k] === v; + return whereValueMatches(row[k], v); }); /** In-memory `sys_metadata` carrying a REAL `SchemaRegistry`. */ diff --git a/packages/rest/src/meta-view-label-not-invented.test.ts b/packages/rest/src/meta-view-label-not-invented.test.ts index a98b2ba27ad..c89ada049b7 100644 --- a/packages/rest/src/meta-view-label-not-invented.test.ts +++ b/packages/rest/src/meta-view-label-not-invented.test.ts @@ -109,6 +109,12 @@ const CELLS = LOCALES.flatMap((locale) => [BARE, NAMED, TAGGED, SELF].map((name) expected: EXPECTED[locale][name] === undefined ? 'no label' : JSON.stringify(EXPECTED[locale][name]), }))); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + /** The served `label`, asserting the key is absent where nothing was authored or translated. */ function expectServedLabel(doc: any, name: string, locale: string): void { const expected = EXPECTED[locale][name]; @@ -160,7 +166,7 @@ function makeHost(i18n: unknown = i18nService) { const matches = (r: Record, w: Record) => Object.entries(w ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return (r[k] ?? null) === (v ?? null); + return whereValueMatches(r[k] ?? null, v ?? null); }); const engine = { registry, diff --git a/packages/rest/src/rest-data-read-one-overlay-read.test.ts b/packages/rest/src/rest-data-read-one-overlay-read.test.ts new file mode 100644 index 00000000000..2da92e50e50 --- /dev/null +++ b/packages/rest/src/rest-data-read-one-overlay-read.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#24371] One `GET /api/v1/data/:object` reads the target object's own +// stored `sys_metadata` rows ONCE. +// +// The defect: the API-exposure gate reads the object by name +// (`getMetaItem`), and that read sent its active-row selections to the store +// one by one — the package-less binding, the served row once per spelling +// (`object`, then `objects`), and the lock's scope once per spelling. Five +// serial reads of the same rows on every data read, about 250 ms of a data +// read at a hosted 50 ms round trip (measured on Postgres; four data reads per +// console open). Now the item's active rows are ONE read of both spellings +// (`type: { $in: ['object', 'objects'] }`), and each selection takes its rows +// from it. +// +// What this file holds, over the REAL protocol (`ObjectStackProtocolImplementation`) +// over a REAL `SchemaRegistry`, with the REAL `findData` behind the route: +// +// 1. COUNT — a list and a get of a code-defined object (no stored row) make +// exactly ONE `sys_metadata` read. An object stored in the environment +// makes that one read plus the ONE head read behind the item read's +// `version` (the repository's write-address resolution, which this card +// does not re-derive). +// 2. ANSWERS — the gate still judges the object the read serves: a stored +// row's `enable` whitelist (stored under either spelling) still refuses a +// method, and a code object with no row is still served. +// 3. FRESHNESS — nothing is kept between requests: a row written after one +// request is seen by the next. +// +// ⛔ The count rows are the regression this file exists for: put the per- +// spelling reads back and they go red while every answer row stays green — +// "still 200" is exactly what the slow read looked like too. + +import { describe, it, expect, vi } from 'vitest'; +import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { RestServer } from './rest-server.js'; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +/** Code-registered; no stored row. */ +const CODE = { name: 'code_widget', label: 'Code Widget', fields: { title: { name: 'title', type: 'text' } } }; +/** + * Stored in the environment (`sys_metadata`), registered by no package; reads + * only. The gate's verdict on it can come from the stored row alone, and the + * data path then 404s the unregistered object itself (#3770). + */ +const STORED = { + name: 'stored_widget', label: 'Stored Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get'] }, +}; + +type Row = Record; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +/** One `where` value as a store reads it: `$in` membership, `null` as IS NULL, else equality. */ +const valueMatches = (actual: unknown, want: unknown): boolean => { + if (want !== null && typeof want === 'object') { + const set = (want as { $in?: unknown }).$in; + if (!Array.isArray(set)) throw new Error(`fake engine: unsupported operator in ${JSON.stringify(want)}`); + return set.includes(actual ?? null); + } + return (actual ?? null) === (want ?? null); +}; +const matches = (row: Row, where: Row = {}): boolean => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: unsupported combinator ${k}`); + return v === undefined || valueMatches(row[k], v); +}); + +function host() { + const registry = new SchemaRegistry(); + registry.registerObject(clone(CODE) as never, 'pkg_code', undefined, 'own'); + const rows: Row[] = []; + /** Every `sys_metadata` read the engine served, in order. */ + const reads: Array<{ verb: 'find' | 'findOne'; where: Row }> = []; + const engine = { + registry, + find: async (table: string, q: { where?: Row; limit?: number } = {}) => { + if (table !== 'sys_metadata') return []; + reads.push({ verb: 'find', where: q.where ?? {} }); + const hits = rows.filter((r) => matches(r, q.where)); + return typeof q.limit === 'number' ? hits.slice(0, q.limit) : hits; + }, + findOne: async (table: string, q: { where?: Row } = {}) => { + assertEngineFindOnePredicate(table, q as never); + if (table !== 'sys_metadata') return null; + reads.push({ verb: 'findOne', where: q.where ?? {} }); + return rows.find((r) => matches(r, q.where)) ?? null; + }, + count: async () => 0, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine as never, () => new Map()); + const rest = new RestServer(createMockServer() as never, protocol as never, { api: { requireAuth: false } } as never); + (rest as unknown as { resolveExecCtx: () => Promise }).resolveExecCtx = + async () => ({ userId: 'test-user' }); + rest.registerRoutes(); + const store = (type: 'object' | 'objects', body: Row): Row => { + const row: Row = { + id: `row_${rows.length + 1}`, type, name: body.name, state: 'active', package_id: null, bundle_member: null, + metadata: JSON.stringify(body), + }; + rows.push(row); + return row; + }; + return { rest, protocol, reads, store }; +} + +async function call(h: ReturnType, door: 'list' | 'get', object: string) { + const path = door === 'list' ? '/api/v1/data/:object' : '/api/v1/data/:object/:id'; + const route = (h.rest as any).getRoutes().find((r: any) => r.method === 'GET' && r.path === path); + if (!route) throw new Error(`route not registered: GET ${path}`); + let status = 200; + let body: any; + const res: any = { + status: (c: number) => { status = c; return res; }, + json: (b: unknown) => { body = b; return res; }, + send: (b: unknown) => { body = b; return res; }, + header: () => res, setHeader: () => res, write: () => true, end: () => res, + }; + const params: Record = door === 'list' ? { object } : { object, id: 'r1' }; + await route.handler({ method: 'GET', path, params, query: {}, headers: {}, body: {} }, res); + return { status, body }; +} + +const errorCode = (body: any): unknown => body?.code ?? body?.error?.code; + +describe('[#24371] one GET /api/v1/data/:object reads the object\'s own sys_metadata rows once', () => { + it.each(['list', 'get'] as const)('a %s of a code-defined object makes exactly ONE sys_metadata read, of both spellings', async (door) => { + const h = host(); + const out = await call(h, door, CODE.name); + expect(out.status, JSON.stringify(out.body)).toBeLessThan(500); + expect(h.reads).toEqual([ + { verb: 'find', where: { type: { $in: ['object', 'objects'] }, name: CODE.name, state: 'active' } }, + ]); + }); + + it('a list of a code-defined object is served (no row, nothing to refuse)', async () => { + const h = host(); + const out = await call(h, 'list', CODE.name); + expect(out.status, JSON.stringify(out.body)).toBe(200); + }); + + it('an object stored in the environment: the ONE row read, then the ONE head read behind `version`', async () => { + const h = host(); + h.store('object', STORED); + const out = await call(h, 'get', STORED.name); + expect(out.status, JSON.stringify(out.body)).toBeLessThan(500); + expect(h.reads.map((r) => r.verb)).toEqual(['find', 'findOne']); + expect(h.reads[0]!.where).toEqual({ type: { $in: ['object', 'objects'] }, name: STORED.name, state: 'active' }); + expect(h.reads[1]!.where).toMatchObject({ type: 'object', name: STORED.name, state: 'active', package_id: null }); + }); + + it.each(['object', 'objects'] as const)('the gate still judges the STORED row served (stored as `%s`): a list it does not allow is refused 405', async (spelling) => { + const h = host(); + h.store(spelling, STORED); + const out = await call(h, 'list', STORED.name); + expect(out.status, JSON.stringify(out.body)).toBe(405); + expect(errorCode(out.body)).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + expect(out.body.allowed).toEqual(['get']); + expect(h.reads.filter((r) => r.verb === 'find')).toHaveLength(1); + }); + + it('nothing is kept between requests: a row edited after one request is judged by the next', async () => { + const h = host(); + const row = h.store('object', { ...STORED, enable: { apiMethods: ['get', 'list'] } }); + const before = await call(h, 'list', STORED.name); + // Cleared by the gate; the data path's own registry 404 answers. + expect(before.status, JSON.stringify(before.body)).toBe(404); + expect(errorCode(before.body)).toBe('OBJECT_NOT_FOUND'); + row.metadata = JSON.stringify(STORED); + const after = await call(h, 'list', STORED.name); + expect(after.status, JSON.stringify(after.body)).toBe(405); + expect(errorCode(after.body)).toBe('OBJECT_API_METHOD_NOT_ALLOWED'); + }); +}); diff --git a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts index 94dad2586a9..556449da735 100644 --- a/packages/rest/src/rest-exposure-gate-one-object-read.test.ts +++ b/packages/rest/src/rest-exposure-gate-one-object-read.test.ts @@ -84,6 +84,12 @@ const OFF_PACKAGE = 'pkg_off'; const DATA_METHODS = ['findData', 'getData', 'createData', 'updateData', 'deleteData'] as const; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function createMockServer() { return { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), @@ -124,7 +130,7 @@ function host(opts: { environmentId?: string; withoutByName?: boolean } = {}): H const matches = (r: Record, w: Record) => Object.entries(w).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return (r[k] ?? null) === (v ?? null); + return whereValueMatches(r[k] ?? null, v ?? null); }); const engine = { registry, diff --git a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts index d8eafacab9a..242d1ace5f4 100644 --- a/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts +++ b/packages/rest/src/rest-server-meta-cached-etag-door-scope.test.ts @@ -97,6 +97,12 @@ const row = (type: string, organization_id: string | null = null): Row => ({ ), }); +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matchesWhere(r: Record, where: Record): boolean { for (const [k, v] of Object.entries(where)) { // ⛔ REFUSE a combinator rather than reading it as a field name: a @@ -106,7 +112,7 @@ function matchesWhere(r: Record, where: Record throw new Error(`stub engine: unsupported WHERE combinator \`${k}\``); } if (v === undefined) continue; - if (r[k] !== v) return false; + if (!whereValueMatches(r[k], v)) return false; } return true; } diff --git a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts index 3589abdb7ca..582c6be533c 100644 --- a/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-history-diff-org-scope.test.ts @@ -94,6 +94,12 @@ interface HistoryRow { const keyOf = (w: Record) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matchesWhere(r: Record, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { @@ -114,7 +120,7 @@ function matchesWhere(r: Record, where: Record throw new Error(`stub engine: unsupported WHERE combinator \`${k}\``); } if (v === undefined) continue; - if (r[k] !== v) return false; + if (!whereValueMatches(r[k], v)) return false; } return true; } diff --git a/packages/rest/src/rest-server-meta-read-org-scope.test.ts b/packages/rest/src/rest-server-meta-read-org-scope.test.ts index 2476f250b5c..0807bb23ee8 100644 --- a/packages/rest/src/rest-server-meta-read-org-scope.test.ts +++ b/packages/rest/src/rest-server-meta-read-org-scope.test.ts @@ -111,6 +111,12 @@ interface HistoryRow { const keyOf = (w: Record) => `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}|${w.package_id ?? '__nopkg__'}`; +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function matchesWhere(r: Record, where: Record): boolean { for (const [k, v] of Object.entries(where)) { if (k === '$or') { @@ -128,7 +134,7 @@ function matchesWhere(r: Record, where: Record throw new Error(`stub engine: unsupported WHERE combinator \`${k}\``); } if (v === undefined) continue; - if (r[k] !== v) return false; + if (!whereValueMatches(r[k], v)) return false; } return true; } diff --git a/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts b/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts index c2e77efe5a3..0d58f59d352 100644 --- a/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts +++ b/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts @@ -98,6 +98,12 @@ interface StoredRow { state: string; metadata: string; checksum: string; version: number; } +/** [#24371] One `where` value as the store reads it: `{ $in: [...] }` (the by-name read's both-spellings `type`) or equality. */ +const whereValueMatches = (actual: unknown, want: unknown): boolean => + want !== null && typeof want === 'object' && Array.isArray((want as { $in?: unknown }).$in) + ? (want as { $in: unknown[] }).$in.includes(actual) + : actual === want; + function boot() { const rows = new Map(); rows.set('row_task', { @@ -142,7 +148,7 @@ function boot() { return Object.entries(where ?? {}).every(([k, v]) => v === null || v === undefined ? r[k] === null || r[k] === undefined - : r[k] === v, + : whereValueMatches(r[k], v), ); }; diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index a1c377579b8..f2d8fed37e1 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -791,6 +791,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.item-active-rows-one-read.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.item-name-every-door.test.ts", "verb": "delete", @@ -3891,6 +3896,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/rest-data-read-one-overlay-read.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/rest/src/rest-declared-refusal-relay.test.ts", "verb": "findOne",