Skip to content

Commit 6464df3

Browse files
perf(rest): GET /data/OBJECT no longer computes metadata diagnostics; the exposure gate reads getMetaItemForExecution (#24666)
Fixes #24535 Clause-②: yes (widening): measured on each package's built `dist/index.d.ts` at `f89d29e38` (before) and at this head (after); see "The export reading" below. `@objectstack/metadata-protocol` keeps its export list (131 names), and `ObjectStackProtocolImplementation` gains the public method `getMetaItemForExecution`. `@objectstack/rest` keeps its export list (81 names), and `RestProtocol` gains the optional member `getMetaItemForExecution?`. Every other member signature is unchanged in both. The API-exposure gate on the `/data` doors (`enforceApiAccess`) reads the object it judges by name, and it uses only `enable`. It read through the served `getMetaItem`, which decorates the item with `_diagnostics`: a full schema `safeParse` of the object's definition, paid on every `GET /api/v1/data/OBJECT`. The card measured this at 1.84 ms of 15.60 ms CPU per HotCRM read. The by-name read now has an execution twin, as the list read already had. The gate reads that twin, and no served read changes. ## The factoring: what the two reads share and the one place they differ `packages/metadata-protocol/src/protocol.ts`: - `getMetaItem(request)` and the new `getMetaItemForExecution(request)` each fold the type at their own boundary (`canonicalizeMetaRequestType`), as `getMetaItems` and `getMetaItemsForExecution` do. Each then calls one private body, `readMetaItemFace(request, face)`. That body is `getMetaItem`'s old body, moved: the disabled-package refusal (`404 OBJECT_PACKAGE_DISABLED`), then `readMetaItem(request, { face })`, then the disabled-package withholding. - `readMetaItem`'s option `servedRead?: boolean` (from #10757 leg 6) becomes `face?: 'served' | 'execution'`. With either face, the item's active rows are read through the overlay cache (`readItemActiveRows(..., cached)`). The cache key is `{ type, name }` and does not depend on the face, so both faces share one entry. Authoring callers pass no face. They read the store, decorated, as before. - **The one place they differ is the serving layer at the read's exits.** `serveItem` is `decorateMetadataItem` (`_diagnostics` computed on the raw body, then the type's redaction) on the served face, and the identity on the execution face. It applies at all three exits: the draft-preview return, the strict-draft return and the active return. The active exit also adds two things only on the served face: a view's cross-document reference diagnostics (merged into `_diagnostics`) and `registration`. These follow the list read's precedent, whose execution face skips `decorateMetadataItems` and `registrations`. Every source, merge, lock, version token and governance step runs on both faces. - Naming and the warning follow `getMetaItemsForExecution`: ⛔ never serve what this returns from a door that answers a caller (HTTP, MCP, an export, a log line). A gate inside such a door may read a decision off it, as the exposure gate reads `enable`, but no byte of the body may reach the response. `packages/rest`: - `rest-server.ts`: `RestProtocol = DataProtocol & MetadataProtocol & { getMetaItemForExecution?: MetadataProtocol['getMetaItem'] }`. `enforceApiAccess` asks `getMetaItemForExecution` when it is a function, else `getMetaItem`, else the list (the existing fallback). No other caller of `getMetaItem` changes. - `meta-item-read-gate.ts` is inside `packages/rest`, so it is this lane's file. Only two doc comments change: `getObjectItem` and `loadObjectItem` now say that REST hands in the execution face. `loadObjectItem`'s contract is unchanged, because the execution face answers the same envelope and the same refusals: - the `{ item }` envelope; - the name must match; - a throw fails open and is logged; - `OBJECT_PACKAGE_DISABLED` means no object. ## The parity proof 1. **Permanent:** `packages/metadata-protocol/src/protocol.item-execution-read.test.ts` (9 tests). In each row, `decorateMetadataItem(type, execution.item)` equals `served.item`: the served item minus `_diagnostics` and redaction. Every other envelope key is equal except `registration`. Each row also asserts that the served item is decorated, so the equality cannot hold vacuously. - A code item. - An environment-overlay item, read through ONE overlay-cache entry for both faces. The served read fills the entry, and the execution read is a hit: one store read. A write retires the entry for both. - A redacting type (`datasource`). The execution item carries the stored password; the served item does not. - The strict draft read. - The draft-preview read, with and without a draft. - `NO_DRAFT` on both faces. - `OBJECT_PACKAGE_DISABLED` on both faces, with the same message. - A missing name. - `registration`, which only the served face carries. 2. **One-off proof that the served body is byte-identical to before.** The previous build (`dist` at `f89d29e38`) and this head's build were run over identical hosts, and the JSON answers were compared byte for byte. The matrix was: - 4 hosts: unscoped, environment-scoped, with the overlay-cache seam, and a disabled package; - 3 served entry points: `getMetaItem`, `getMetaItemCached`, and the authoring `readMetaItem`; - 20 requests; - 2 passes each, cold and warm. **480 answers, 0 mismatches.** The requests cover: - object: code, plural spelling, extended by another package, stored, stored and invalid, package-scoped; - drafts: strict draft, draft preview, missing draft; - a missing name; - a disabled package's object (refused) and its view (withheld); - `datasource` (redaction), `view` (reference diagnostics), and `flow` (`registration` and drafts). Two normalisations were applied, both outside the change: - Each module instance keys version tokens under its own random process key, so the host supplies one deterministic keyed digest. - `getMetaItemCached` stamps `lastModified` from the wall clock, so the clock is frozen. Control: the same harness comparing the old served face with the NEW execution face differs on 16 of 20 answers. Those are exactly the 16 decorated answers; the 3 `NO_DRAFT` refusals and the one missing name are equal. ## The counting test, its ablation and its controls `packages/rest/src/rest-exposure-gate-execution-read.test.ts` (12 tests) drives the real REST route handlers, over the real `ObjectStackProtocolImplementation`, over a real `SchemaRegistry`. The code object's data call is the real `findData`. Every diagnostic of an object goes through one seam, `computeMetadataDiagnostics`, which calls `getMetadataTypeSchema('object').safeParse`. For this file, the `object` binding is replaced through the registry's public overlay, `registerMetadataTypeSchema`: the same schema behind a proxy that records the `name` of each document it parses. It is restored afterwards. - **Count:** `GET /api/v1/data/OBJECT` records **zero** diagnostics for a code-registered object and for an object stored in the environment, unscoped and environment-scoped. A request the gate refuses (`405`, verdict taken from the stored row's `enable`) also records zero. - **Fallback (in-test control on the same `/data` path):** a protocol with no execution face is asked `getMetaItem`. The answers are the same, and the test records exactly one diagnostic of that object. This row keeps the zeros from being vacuous: the instrument does see the gate's read when the read decorates. - **Control:** `GET /api/v1/meta/object/NAME` still serves `_diagnostics`, and the instrument records it, for both objects and both scopes. - **Ablation (one-off):** run with `scripts/ablation-replace.mjs` against the committed head `32d829ffc`. It replaced `const byName = typeof p.getMetaItemForExecution === 'function'` with `const byName = false`, so the gate read `getMetaItem` again (anchor count 1 to 0, blob `1aa8eeb5a2b2` to `68a515a0f7c2`). Result: **6 failed, 6 passed**. - The 6 zero-count rows went red, with `expected [ 'code_widget' ] to deeply equal []` and `expected [ 'stored_widget' ] to deeply equal []`. - The fallback and control rows stayed green. - Restored: blob equals HEAD (`1aa8eeb5a2b2`), and `git diff HEAD` is empty. - **Three pins in the #24031 file** (`rest-exposure-gate-one-object-read.test.ts`) named the face the gate reads, and now name the execution face: the by-name spy, the throwing read, and the "no by-name read" case, which now means neither face. - **Cost reading (optional; shared box, so read the ratio).** Measured on the built `dist` at `32d829ffc`: an object with N fields, a warm overlay cache, median per-read time: | N fields | served `getMetaItem` | `getMetaItemForExecution` | ratio | |---|---|---|---| | 10 | 390.9 µs | 41.5 µs | 0.106 | | 60 | 1028.9 µs | 32.2 µs | 0.031 | | 200 | 2723.7 µs | 63.1 µs | 0.023 | ## The export reading (Clause-②) - **Method:** the TypeScript compiler API over each package's built entry `dist/index.d.ts`. Every export is listed with its kind, and every member of every exported class, interface and type alias is listed with its full untruncated signature. Lines are sorted, and the before and after lists are diffed order-insensitively. This was not a grep of a barrel line. - `@objectstack/metadata-protocol`: 131 exports before and after, with the same names. The diff is exactly two added lines: `ObjectStackProtocolImplementation member getMetaItemForExecution` (the request type and the return type are the same as `getMetaItem`'s) and `member private readMetaItemFace`. `getMetaItem`'s signature line is identical. - `@objectstack/rest`: 81 exports before and after, with the same names. The diff is exactly one added line: `RestProtocol member getMetaItemForExecution?: ...`. - **Changesets:** both packages are graded `minor`, not `patch`. A `Clause-②: yes` takes at least `minor` (AGENTS.md, Post-Task Checklist step 3), and `check-changeset-no-major.mjs`'s level axis refuses `yes` when every moved package is `patch`. Both published surfaces widen, so both are graded. ## Acceptance notes - `scripts/engine-double-contract.pinned.json` gains two rows: the two new test files' pinned `findOne` doubles. `check:engine-double-contract` prescribed `--write` for these rows. - The other in-process consumers of the served by-name read that act on a definition rather than serve it are the seed loader's `getObject` adapter and the post-publish build probes' `getItem`. They run per publish, not per request, and are left on the served face. Carrier: none. ## Verification at head `8f2b83379` (round 0; see Patch round 1 for the current head) All of the following ran after the last commit, `8f2b83379`. Every exit code was captured before any pipe. - **Typecheck:** `pnpm --filter @objectstack/metadata-protocol typecheck` exit 0, and `pnpm --filter @objectstack/rest typecheck` exit 0. The rest typecheck includes `check:test-typecheck` over the test layer: 0 errors. - **Tests, every project of both packages:** - `pnpm --filter @objectstack/metadata-protocol test`: 250 files passed, 3 skipped; 20780 tests passed, 19 skipped. - `pnpm --filter @objectstack/rest test` (`local`): 301 files passed; 6107 tests passed, 326 skipped. - `pnpm --filter @objectstack/rest test:repo`: 5 files passed; 278 tests passed, 18 skipped. - The first full metadata-protocol run, at `a691f190f`, turned `protocol.read-verb-canonical-fold.test.ts` red. That pin derives its population of `/meta` request-boundary verbs from the code, and the new face was not yet in it. `b163268a0` makes each face fold its type at its own boundary, as the list faces do, and registers the seventeenth verb. - **Lint:** `eslint --no-inline-config` over the seven changed source and test files exited 0, and `pnpm lint` (the full union) exited 0. - **Derived gate families:** `node scripts/pm/dispatch-gates.mjs --commands` derived 78 commands. All 78 exited 0. `--ran` reports "78 derived, 78 run, 0 NOT-MEASURED, 0 UNRUN". - An earlier battery, at `a691f190f`, turned `check:engine-double-contract` red because the ledger lacked the two new pinned doubles. They were recorded at `8f2b83379`, and the gate is green. - **Changeset level axis**, driven offline with this body (`check-changeset-no-major.mjs --event`): clean, because `Clause-②: yes (widening)` is carried by `minor` grades. On a throwaway local probe commit grading both packages `patch`, the same command exited 1 and named both lines. - **Not run locally:** families that only CI runs (the wide-population and artifact-roster families). Their verdicts are in this PR's check runs. ## Patch round 1 (head `a132ad0cf`) - **Red on `8f2b83379`:** Test Core (6/6). `packages/runtime/src/domains/data-write-request-gate.test.ts` failed 12 tests: every `locked` and `hidden` row answered `400 VALIDATION_FAILED`. Its rig gives `RestServer` a real `ObjectStackProtocolImplementation` with no metadata store, and it answered only `getMetaItem` from the registry. The exposure gate now reads `getMetaItemForExecution`, which the rig left unstubbed, so the gate failed open and the body gate answered first. - **Fix:** a test-only rider in `packages/runtime`, declared in the seat's REWORK. The rig installs its one registry-backed by-name stub on both faces, and its note names both. Reverse check: with the stub on `getMetaItem` alone, the file fails 12 of 73, which reproduces the CI red; with both faces stubbed, 73 of 73 pass. - **Test census:** 18 test files build a real protocol and override `getMetaItem`. Three of them reach `RestServer`'s exposure gate on a real instance: this rig (fixed) and this PR's two exposure-gate files (already on the execution face). The other 15 never mount that instance in `RestServer` behind a `/data` door. Beyond those, 98 test files override `getMetaItem` and name the REST layer; the rest of them hand `RestServer` an object-literal protocol, which has no execution face, so the gate still asks their override. - **Production census:** nothing in this repository reassigns `getMetaItem` on a protocol instance or subclasses `ObjectStackProtocolImplementation`, and `RestApiPlugin` hands `RestServer` the kernel's `protocol` service unwrapped. `objectstack-ai/cloud` is not measured here. - **Merged `origin/main`:** `e47f72d38`, a merge commit, clean. - **Verification at `a132ad0cf`:** - `@objectstack/runtime`: typecheck exit 0. `test`: 425 files; 6678 passed, 19 skipped. `test:repo`: 3 files; 808 passed. - `@objectstack/metadata-protocol`: typecheck exit 0. `test`: 250 files passed, 3 skipped; 20790 tests passed, 19 skipped. - `@objectstack/rest`: typecheck exit 0. `test`: 301 files; 6107 passed, 326 skipped. `test:repo`: 5 files; 278 passed, 18 skipped. - Lint: eslint over the changed files and `pnpm lint`, exit 0. - Derived gates: 78 of 78 exit 0; `--ran` reports 0 NOT-MEASURED and 0 UNRUN. - CI on `a132ad0cf`: every check green, including Test Core (6/6). --- _Generated by [Claude Code](https://claude.ai/code/session_01SYEJqDL3NXxrJRBVqaMu9T)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e2722a commit 6464df3

11 files changed

Lines changed: 668 additions & 37 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
feat(metadata-protocol): `getMetaItemForExecution`, the by-name metadata read without the serving decorations
6+
7+
Clause-②: yes (widening): `ObjectStackProtocolImplementation` gains one public method on its published declaration. Measured on the built `dist/index.d.ts` before and after: the export list is unchanged (131 names), and the class gains `getMetaItemForExecution` and a private member. Every other member signature is byte-identical, `getMetaItem` included.
8+
9+
- **What.** `getMetaItemForExecution(request)` takes `getMetaItem`'s request and answers its envelope. It shares the same sources, the same overlay cache entry, merge, lock and governance, the same disabled-package refusal (`404 OBJECT_PACKAGE_DISABLED`) and withholding, and the same `NO_DRAFT` refusal. The item comes back without the serving decorations: no `_diagnostics` (the full schema `safeParse` of `computeMetadataDiagnostics`, and a view's cross-document reference diagnostics), no per-type credential redaction, and no `registration`. It is the by-name twin of `getMetaItemsForExecution`.
10+
- **Why.** It is for an in-process caller that acts on a definition and serves none of it. The REST API-exposure gate reads the object behind every `GET /api/v1/data/OBJECT` to judge `enable`. On the served face, that read paid a full diagnostic of the object's definition per request: 11.8% of a measured hosted CRM read's CPU, and more on larger objects.
11+
- **Never served.** Do not serve what it returns from a door that answers a caller (HTTP, MCP, an export, a log line). Every such exit serves `getMetaItem`, whose redaction is why the two faces are separate.
12+
- **Unchanged.** `getMetaItem`, `getMetaItemCached` and the authoring read answer byte-identical JSON. This was measured over 480 served answers, comparing the previous build and this one: types, drafts, the overlay cache, refusals and withholding.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
perf(rest): `GET /api/v1/data/OBJECT` no longer computes the object's metadata diagnostics; the API-exposure gate reads the protocol's execution face
6+
7+
Clause-②: yes (widening): the exported `RestProtocol` type gains one optional member, `getMetaItemForExecution?`. Measured on the built `dist/index.d.ts` before and after: the export list is unchanged (81 names), and every other member signature is byte-identical. The member is optional, so nothing a protocol must provide changes.
8+
9+
- **What.** The API-exposure gate on the `/data` doors (`enforceApiAccess`) reads the object it judges through `getMetaItemForExecution` when the protocol has it, and through `getMetaItem` otherwise. Both are the by-name read with the same sources, overlay cache and governance. The execution face skips the serving decorations, so a `/data` request computes no `_diagnostics` (a full schema `safeParse` of the object's definition) to read one `enable` block. On a fixture object with 60 fields, a warm by-name read took about 1.0 ms on the served face and about 32 µs on the execution face; this was measured on a shared box, and the ratio is about 0.03.
10+
- **Unchanged.** Every `/data` answer: the same `404`/`405` verdicts and `allowed` set, a thrown read still fails open and logs, and an object of a disabled package still gets the data plane's `404 OBJECT_PACKAGE_DISABLED`. `GET /api/v1/meta/object/NAME` still serves `_diagnostics`.
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#24535] The by-name read's EXECUTION face (`getMetaItemForExecution`) is
4+
// the served face (`getMetaItem`) minus the serving decorations, and nothing
5+
// else.
6+
//
7+
// Both faces are one body (`readMetaItemFace` → `readMetaItem`); the face
8+
// decides only what happens at the read's exits. So the parity this file pins
9+
// is exact, not "close enough":
10+
//
11+
// - THE ITEM: decorating the execution face's item reproduces the served
12+
// face's item, `toEqual` — `decorateMetadataItem` is the served exit's
13+
// decoration (`_diagnostics` computed on the raw body, then the type's
14+
// credential redaction), so this one equality says "the served item minus
15+
// `_diagnostics` and redaction", for a redacting type as for a plain one.
16+
// - THE ENVELOPE: every other key (`lock`, `editable`, `version`, `packageId`,
17+
// …) is equal, save `registration`, which the served face alone carries,
18+
// as the list read's served face alone carries `registrations`.
19+
// - THE REFUSALS: a disabled package's object and a missing draft are refused
20+
// with the same code on both faces.
21+
// - THE CACHE: an environment-overlay item's active rows are read through ONE
22+
// overlay-cache entry for both faces, retired by a write for both.
23+
//
24+
// Every parity row also asserts the served item IS decorated, so the equality
25+
// cannot hold by both faces skipping the decoration.
26+
//
27+
// Test-path resolution: every subject is imported by relative specifier, so
28+
// vitest resolves it from this package's source.
29+
30+
import { describe, it, expect } from 'vitest';
31+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
32+
import { ObjectStackProtocolImplementation } from './protocol.js';
33+
import { decorateMetadataItem } from './metadata-diagnostics.js';
34+
import { metaOverlayCacheEntryCount } from './meta-overlay-cache.js';
35+
36+
type Row = Record<string, unknown>;
37+
38+
const matches = (row: Row, where: Row = {}): boolean => Object.entries(where).every(([k, v]) => {
39+
if (k.startsWith('$')) throw new Error(`store double: unsupported combinator ${k}`);
40+
if (v !== null && typeof v === 'object') {
41+
const set = (v as { $in?: unknown }).$in;
42+
if (!Array.isArray(set)) throw new Error(`store double: unsupported operator in ${JSON.stringify(v)}`);
43+
return set.includes(row[k] ?? null);
44+
}
45+
return v === undefined || (row[k] ?? null) === (v ?? null);
46+
});
47+
48+
/** A code-registered object: the registry's item, no stored row. */
49+
const CODE_OBJECT = {
50+
name: 'code_widget',
51+
label: 'Code Widget',
52+
fields: { title: { name: 'title', label: 'Title', type: 'text' } },
53+
enable: { apiMethods: ['get', 'list'] },
54+
};
55+
56+
const objectRow = (name: string, state: 'active' | 'draft', body: Row): Row => ({
57+
id: `r_${name}_${state}`, type: 'object', name, state, package_id: null,
58+
metadata: JSON.stringify({ name, fields: {}, ...body }),
59+
});
60+
61+
/** A `datasource` row holding an inline credential — the type with a registered redactor. */
62+
const DATASOURCE_ROW: Row = {
63+
id: 'r_warehouse', type: 'datasource', name: 'warehouse', state: 'active', package_id: null,
64+
metadata: JSON.stringify({
65+
name: 'warehouse', label: 'Warehouse', driver: 'postgres',
66+
config: { host: 'db.internal', database: 'warehouse', username: 'reporting', password: 'hunter2' },
67+
}),
68+
};
69+
70+
const FLOW_ROW: Row = {
71+
id: 'r_flow', type: 'flow', name: 'notify_owner', state: 'active', package_id: null,
72+
metadata: JSON.stringify({
73+
name: 'notify_owner', label: 'Notify Owner', type: 'autolaunched',
74+
nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
75+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
76+
}),
77+
};
78+
79+
function harness(rows: Row[], opts: { disabledObjects?: string[]; automation?: boolean } = {}) {
80+
const reads: Row[] = [];
81+
let epoch = 0;
82+
const seam = {
83+
get current() { return epoch; },
84+
bump(): number { epoch += 1; return epoch; },
85+
subscribe(): () => void { return () => {}; },
86+
};
87+
const disabled = new Set(opts.disabledObjects ?? []);
88+
const engine: Record<string, unknown> = {
89+
registry: {
90+
getItem: (type: string, name: string) =>
91+
(type === 'object' || type === 'objects') && name === CODE_OBJECT.name
92+
? structuredClone(CODE_OBJECT)
93+
: undefined,
94+
getObject: () => undefined,
95+
listItems: () => [],
96+
getPackage: () => undefined,
97+
getArtifactItem: () => undefined,
98+
isPackageDisabled: () => false,
99+
isObjectPackageDisabled: (name: string) => disabled.has(name),
100+
registerItem: () => undefined,
101+
registerObject: () => undefined,
102+
applyNavContributions: (app: unknown) => app,
103+
},
104+
writeEpoch: seam,
105+
find: async (table: string, q: { where?: Row; limit?: number } = {}) => {
106+
if (table !== 'sys_metadata') return [];
107+
reads.push(structuredClone(q.where ?? {}));
108+
const hits = rows.filter((r) => matches(r, q.where));
109+
return typeof q.limit === 'number' ? hits.slice(0, q.limit) : hits;
110+
},
111+
findOne: async (table: string, q: { where?: Row } = {}) => {
112+
assertEngineFindOnePredicate(table, q as never);
113+
if (table !== 'sys_metadata') return null;
114+
reads.push(structuredClone(q.where ?? {}));
115+
return rows.find((r) => matches(r, q.where)) ?? null;
116+
},
117+
};
118+
type Middleware = (ctx: { object: string; operation: string }, next: () => Promise<void>) => Promise<void>;
119+
const middlewares: Middleware[] = [];
120+
engine.registerMiddleware = (fn: Middleware) => {
121+
middlewares.push(fn);
122+
};
123+
/** A `sys_metadata` write as the engine makes one: the epoch first, then the chain around the row. */
124+
const write = async (apply: () => void) => {
125+
seam.bump();
126+
const chain = [...middlewares];
127+
let i = 0;
128+
const next = async (): Promise<void> => {
129+
if (i < chain.length) return chain[i++]!({ object: 'sys_metadata', operation: 'update' }, next);
130+
apply();
131+
};
132+
await next();
133+
};
134+
const services = new Map<string, unknown>();
135+
if (opts.automation) {
136+
services.set('automation', { getFlowRegistration: () => ({ state: 'registered' }) });
137+
}
138+
const protocol = new ObjectStackProtocolImplementation(engine as never, () => services as Map<string, never>) as any;
139+
return { protocol, engine, reads, write };
140+
}
141+
142+
/** Read one request on both faces. */
143+
async function bothFaces(h: ReturnType<typeof harness>, request: Row) {
144+
const served = await h.protocol.getMetaItem(structuredClone(request));
145+
const execution = await h.protocol.getMetaItemForExecution(structuredClone(request));
146+
return { served, execution };
147+
}
148+
149+
/**
150+
* THE parity: the served item is the execution item decorated, every other
151+
* envelope key is equal, and the served item really is decorated.
152+
*/
153+
function expectParity(type: string, served: any, execution: any) {
154+
expect(served.item, 'the served face found an item').toBeTruthy();
155+
expect(served.item._diagnostics, 'the served item is decorated (anti-vacuity)').toBeDefined();
156+
expect(Object.prototype.hasOwnProperty.call(execution.item, '_diagnostics')).toBe(false);
157+
expect(served.item).toEqual(decorateMetadataItem(type, execution.item));
158+
const { item: _servedItem, registration: _servedOnly, ...servedEnvelope } = served;
159+
const { item: _executionItem, ...executionEnvelope } = execution;
160+
expect(executionEnvelope).toEqual(servedEnvelope);
161+
}
162+
163+
const byNameReads = (reads: Row[], name: string) =>
164+
reads.filter((w) => w.name === name && JSON.stringify(w.type) === JSON.stringify({ $in: ['object', 'objects'] }));
165+
166+
describe('[#24535] the by-name execution read is the served read minus the serving decorations', () => {
167+
it('a CODE item: the registry\'s object, no stored row', async () => {
168+
const h = harness([]);
169+
const { served, execution } = await bothFaces(h, { type: 'object', name: CODE_OBJECT.name });
170+
expectParity('object', served, execution);
171+
expect(execution.item.enable).toEqual(CODE_OBJECT.enable);
172+
expect(execution.version).toBeNull();
173+
});
174+
175+
it('an ENVIRONMENT-OVERLAY item: the stored row, read through ONE overlay-cache entry for both faces', async () => {
176+
const rows = [objectRow('stored_widget', 'active', { label: 'Stored', enable: { apiMethods: ['get'] } })];
177+
const h = harness(rows);
178+
const served = await h.protocol.getMetaItem({ type: 'object', name: 'stored_widget' });
179+
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(1);
180+
// The execution face is served by the entry the served face filled.
181+
const execution = await h.protocol.getMetaItemForExecution({ type: 'object', name: 'stored_widget' });
182+
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(1);
183+
expect(metaOverlayCacheEntryCount(h.engine)).toBe(1);
184+
expectParity('object', served, execution);
185+
expect(execution.item.enable).toEqual({ apiMethods: ['get'] });
186+
187+
// A write retires the entry for BOTH faces: the execution face reads
188+
// the store and serves the new row, and the served face is then
189+
// answered from the entry the execution face filled.
190+
await h.write(() => {
191+
rows[0] = objectRow('stored_widget', 'active', { label: 'Stored', enable: { apiMethods: ['get', 'list'] } });
192+
});
193+
const executionAfter = await h.protocol.getMetaItemForExecution({ type: 'object', name: 'stored_widget' });
194+
expect(executionAfter.item.enable).toEqual({ apiMethods: ['get', 'list'] });
195+
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(2);
196+
const servedAfter = await h.protocol.getMetaItem({ type: 'object', name: 'stored_widget' });
197+
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(2);
198+
expectParity('object', servedAfter, executionAfter);
199+
});
200+
201+
it('a REDACTING type: the execution item is the stored body, the served one its projection', async () => {
202+
const h = harness([DATASOURCE_ROW]);
203+
const { served, execution } = await bothFaces(h, { type: 'datasource', name: 'warehouse' });
204+
expectParity('datasource', served, execution);
205+
expect(execution.item.config.password).toBe('hunter2');
206+
expect(served.item.config.password).toBeUndefined();
207+
});
208+
209+
it('the STRICT DRAFT read: the draft row, undecorated, with the same version token', async () => {
210+
const rows = [
211+
objectRow('stored_widget', 'active', { label: 'Active' }),
212+
objectRow('stored_widget', 'draft', { label: 'Draft', enable: { apiMethods: ['get'] } }),
213+
];
214+
const h = harness(rows);
215+
const { served, execution } = await bothFaces(h, { type: 'object', name: 'stored_widget', state: 'draft' });
216+
expectParity('object', served, execution);
217+
expect(execution.item.label).toBe('Draft');
218+
});
219+
220+
it('the DRAFT-PREVIEW read: the draft where one exists, the active item where none does', async () => {
221+
const withDraft = harness([
222+
objectRow('stored_widget', 'active', { label: 'Active' }),
223+
objectRow('stored_widget', 'draft', { label: 'Draft' }),
224+
]);
225+
const preview = await bothFaces(withDraft, { type: 'object', name: 'stored_widget', previewDrafts: true });
226+
expectParity('object', preview.served, preview.execution);
227+
expect(preview.execution.item).toMatchObject({ label: 'Draft', _draft: true });
228+
229+
const withoutDraft = harness([objectRow('stored_widget', 'active', { label: 'Active' })]);
230+
const fallback = await bothFaces(withoutDraft, { type: 'object', name: 'stored_widget', previewDrafts: true });
231+
expectParity('object', fallback.served, fallback.execution);
232+
expect(fallback.execution.item.label).toBe('Active');
233+
});
234+
235+
it('a missing draft is refused `NO_DRAFT` on both faces', async () => {
236+
const h = harness([objectRow('stored_widget', 'active', { label: 'Active' })]);
237+
const request = { type: 'object', name: 'stored_widget', state: 'draft' };
238+
await expect(h.protocol.getMetaItem({ ...request })).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 });
239+
await expect(h.protocol.getMetaItemForExecution({ ...request })).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 });
240+
});
241+
242+
it('an object of a DISABLED package is refused `OBJECT_PACKAGE_DISABLED` on both faces', async () => {
243+
const h = harness([], { disabledObjects: [CODE_OBJECT.name] });
244+
const request = { type: 'object', name: CODE_OBJECT.name };
245+
const servedRefusal = await h.protocol.getMetaItem({ ...request }).catch((e: unknown) => e);
246+
const executionRefusal = await h.protocol.getMetaItemForExecution({ ...request }).catch((e: unknown) => e);
247+
expect(servedRefusal).toMatchObject({ code: 'OBJECT_PACKAGE_DISABLED', status: 404 });
248+
expect(executionRefusal).toMatchObject({ code: 'OBJECT_PACKAGE_DISABLED', status: 404 });
249+
expect((executionRefusal as Error).message).toBe((servedRefusal as Error).message);
250+
});
251+
252+
it('a name with nothing behind it answers no item on both faces', async () => {
253+
const h = harness([]);
254+
const { served, execution } = await bothFaces(h, { type: 'object', name: 'no_such_object' });
255+
expect(served.item).toBeUndefined();
256+
expect(execution).toEqual(served);
257+
});
258+
259+
it('`registration` is the served face\'s alone, as the list read\'s `registrations` are', async () => {
260+
const h = harness([FLOW_ROW], { automation: true });
261+
const { served, execution } = await bothFaces(h, { type: 'flow', name: 'notify_owner' });
262+
expect(served.registration).toEqual({ state: 'registered' });
263+
expect(Object.prototype.hasOwnProperty.call(execution, 'registration')).toBe(false);
264+
expectParity('flow', served, execution);
265+
});
266+
});

‎packages/metadata-protocol/src/protocol.read-verb-canonical-fold.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
210210
}
211211

212212
describe('#9157 — the population, re-derived from the code rather than from the card', () => {
213-
it('every `/meta` request-boundary verb with a required `type` calls the fold — all sixteen', () => {
213+
it('every `/meta` request-boundary verb with a required `type` calls the fold — all seventeen', () => {
214214
// ⭐ The card hand-listed "nine fold, three do not". Hand-listed sets of
215215
// this shape have shipped short before, so the set is DERIVED here and
216216
// the derivation is the pin: a tenth verb arriving unfolded turns this
@@ -274,6 +274,9 @@ describe('#9157 — the population, re-derived from the code rather than from th
274274
'findReferencesToMeta',
275275
'getMetaItem',
276276
'getMetaItemCached',
277+
// [#24535] The execution face of the by-name read — the API-exposure
278+
// gate hands it the type it was asked for.
279+
'getMetaItemForExecution',
277280
'getMetaItemLayered',
278281
'getMetaItems',
279282
// [#20552] The execution face of the same flattened read — an

0 commit comments

Comments
 (0)