Skip to content
Merged
12 changes: 12 additions & 0 deletions .changeset/24535-metadata-protocol-item-execution-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/metadata-protocol': minor
---

feat(metadata-protocol): `getMetaItemForExecution`, the by-name metadata read without the serving decorations

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.

- **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`.
- **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.
- **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.
- **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.
10 changes: 10 additions & 0 deletions .changeset/24535-rest-exposure-gate-execution-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/rest': minor
---

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

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.

- **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.
- **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`.
266 changes: 266 additions & 0 deletions packages/metadata-protocol/src/protocol.item-execution-read.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#24535] The by-name read's EXECUTION face (`getMetaItemForExecution`) is
// the served face (`getMetaItem`) minus the serving decorations, and nothing
// else.
//
// Both faces are one body (`readMetaItemFace` → `readMetaItem`); the face
// decides only what happens at the read's exits. So the parity this file pins
// is exact, not "close enough":
//
// - THE ITEM: decorating the execution face's item reproduces the served
// face's item, `toEqual` — `decorateMetadataItem` is the served exit's
// decoration (`_diagnostics` computed on the raw body, then the type's
// credential redaction), so this one equality says "the served item minus
// `_diagnostics` and redaction", for a redacting type as for a plain one.
// - THE ENVELOPE: every other key (`lock`, `editable`, `version`, `packageId`,
// …) is equal, save `registration`, which the served face alone carries,
// as the list read's served face alone carries `registrations`.
// - THE REFUSALS: a disabled package's object and a missing draft are refused
// with the same code on both faces.
// - THE CACHE: an environment-overlay item's active rows are read through ONE
// overlay-cache entry for both faces, retired by a write for both.
//
// Every parity row also asserts the served item IS decorated, so the equality
// cannot hold by both faces skipping the decoration.
//
// Test-path resolution: every subject is imported by relative specifier, so
// vitest resolves it from this package's source.

import { describe, it, expect } from 'vitest';
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
import { ObjectStackProtocolImplementation } from './protocol.js';
import { decorateMetadataItem } from './metadata-diagnostics.js';
import { metaOverlayCacheEntryCount } from './meta-overlay-cache.js';

type Row = Record<string, unknown>;

const matches = (row: Row, where: Row = {}): boolean => Object.entries(where).every(([k, v]) => {
if (k.startsWith('$')) throw new Error(`store double: unsupported combinator ${k}`);
if (v !== null && typeof v === 'object') {
const set = (v as { $in?: unknown }).$in;
if (!Array.isArray(set)) throw new Error(`store double: unsupported operator in ${JSON.stringify(v)}`);
return set.includes(row[k] ?? null);
}
return v === undefined || (row[k] ?? null) === (v ?? null);
});

/** A code-registered object: the registry's item, no stored row. */
const CODE_OBJECT = {
name: 'code_widget',
label: 'Code Widget',
fields: { title: { name: 'title', label: 'Title', type: 'text' } },
enable: { apiMethods: ['get', 'list'] },
};

const objectRow = (name: string, state: 'active' | 'draft', body: Row): Row => ({
id: `r_${name}_${state}`, type: 'object', name, state, package_id: null,
metadata: JSON.stringify({ name, fields: {}, ...body }),
});

/** A `datasource` row holding an inline credential — the type with a registered redactor. */
const DATASOURCE_ROW: Row = {
id: 'r_warehouse', type: 'datasource', name: 'warehouse', state: 'active', package_id: null,
metadata: JSON.stringify({
name: 'warehouse', label: 'Warehouse', driver: 'postgres',
config: { host: 'db.internal', database: 'warehouse', username: 'reporting', password: 'hunter2' },
}),
};

const FLOW_ROW: Row = {
id: 'r_flow', type: 'flow', name: 'notify_owner', state: 'active', package_id: null,
metadata: JSON.stringify({
name: 'notify_owner', label: 'Notify Owner', type: 'autolaunched',
nodes: [{ id: 'start', type: 'start', label: 'Start' }, { id: 'end', type: 'end', label: 'End' }],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
}),
};

function harness(rows: Row[], opts: { disabledObjects?: string[]; automation?: boolean } = {}) {
const reads: Row[] = [];
let epoch = 0;
const seam = {
get current() { return epoch; },
bump(): number { epoch += 1; return epoch; },
subscribe(): () => void { return () => {}; },
};
const disabled = new Set(opts.disabledObjects ?? []);
const engine: Record<string, unknown> = {
registry: {
getItem: (type: string, name: string) =>
(type === 'object' || type === 'objects') && name === CODE_OBJECT.name
? structuredClone(CODE_OBJECT)
: undefined,
getObject: () => undefined,
listItems: () => [],
getPackage: () => undefined,
getArtifactItem: () => undefined,
isPackageDisabled: () => false,
isObjectPackageDisabled: (name: string) => disabled.has(name),
registerItem: () => undefined,
registerObject: () => undefined,
applyNavContributions: (app: unknown) => app,
},
writeEpoch: seam,
find: async (table: string, q: { where?: Row; limit?: number } = {}) => {
if (table !== 'sys_metadata') return [];
reads.push(structuredClone(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(structuredClone(q.where ?? {}));
return rows.find((r) => matches(r, q.where)) ?? null;
},
};
type Middleware = (ctx: { object: string; operation: string }, next: () => Promise<void>) => Promise<void>;
const middlewares: Middleware[] = [];
engine.registerMiddleware = (fn: Middleware) => {
middlewares.push(fn);
};
/** A `sys_metadata` write as the engine makes one: the epoch first, then the chain around the row. */
const write = async (apply: () => void) => {
seam.bump();
const chain = [...middlewares];
let i = 0;
const next = async (): Promise<void> => {
if (i < chain.length) return chain[i++]!({ object: 'sys_metadata', operation: 'update' }, next);
apply();
};
await next();
};
const services = new Map<string, unknown>();
if (opts.automation) {
services.set('automation', { getFlowRegistration: () => ({ state: 'registered' }) });
}
const protocol = new ObjectStackProtocolImplementation(engine as never, () => services as Map<string, never>) as any;
return { protocol, engine, reads, write };
}

/** Read one request on both faces. */
async function bothFaces(h: ReturnType<typeof harness>, request: Row) {
const served = await h.protocol.getMetaItem(structuredClone(request));
const execution = await h.protocol.getMetaItemForExecution(structuredClone(request));
return { served, execution };
}

/**
* THE parity: the served item is the execution item decorated, every other
* envelope key is equal, and the served item really is decorated.
*/
function expectParity(type: string, served: any, execution: any) {
expect(served.item, 'the served face found an item').toBeTruthy();
expect(served.item._diagnostics, 'the served item is decorated (anti-vacuity)').toBeDefined();
expect(Object.prototype.hasOwnProperty.call(execution.item, '_diagnostics')).toBe(false);
expect(served.item).toEqual(decorateMetadataItem(type, execution.item));
const { item: _servedItem, registration: _servedOnly, ...servedEnvelope } = served;
const { item: _executionItem, ...executionEnvelope } = execution;
expect(executionEnvelope).toEqual(servedEnvelope);
}

const byNameReads = (reads: Row[], name: string) =>
reads.filter((w) => w.name === name && JSON.stringify(w.type) === JSON.stringify({ $in: ['object', 'objects'] }));

describe('[#24535] the by-name execution read is the served read minus the serving decorations', () => {
it('a CODE item: the registry\'s object, no stored row', async () => {
const h = harness([]);
const { served, execution } = await bothFaces(h, { type: 'object', name: CODE_OBJECT.name });
expectParity('object', served, execution);
expect(execution.item.enable).toEqual(CODE_OBJECT.enable);
expect(execution.version).toBeNull();
});

it('an ENVIRONMENT-OVERLAY item: the stored row, read through ONE overlay-cache entry for both faces', async () => {
const rows = [objectRow('stored_widget', 'active', { label: 'Stored', enable: { apiMethods: ['get'] } })];
const h = harness(rows);
const served = await h.protocol.getMetaItem({ type: 'object', name: 'stored_widget' });
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(1);
// The execution face is served by the entry the served face filled.
const execution = await h.protocol.getMetaItemForExecution({ type: 'object', name: 'stored_widget' });
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(1);
expect(metaOverlayCacheEntryCount(h.engine)).toBe(1);
expectParity('object', served, execution);
expect(execution.item.enable).toEqual({ apiMethods: ['get'] });

// A write retires the entry for BOTH faces: the execution face reads
// the store and serves the new row, and the served face is then
// answered from the entry the execution face filled.
await h.write(() => {
rows[0] = objectRow('stored_widget', 'active', { label: 'Stored', enable: { apiMethods: ['get', 'list'] } });
});
const executionAfter = await h.protocol.getMetaItemForExecution({ type: 'object', name: 'stored_widget' });
expect(executionAfter.item.enable).toEqual({ apiMethods: ['get', 'list'] });
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(2);
const servedAfter = await h.protocol.getMetaItem({ type: 'object', name: 'stored_widget' });
expect(byNameReads(h.reads, 'stored_widget')).toHaveLength(2);
expectParity('object', servedAfter, executionAfter);
});

it('a REDACTING type: the execution item is the stored body, the served one its projection', async () => {
const h = harness([DATASOURCE_ROW]);
const { served, execution } = await bothFaces(h, { type: 'datasource', name: 'warehouse' });
expectParity('datasource', served, execution);
expect(execution.item.config.password).toBe('hunter2');
expect(served.item.config.password).toBeUndefined();
});

it('the STRICT DRAFT read: the draft row, undecorated, with the same version token', async () => {
const rows = [
objectRow('stored_widget', 'active', { label: 'Active' }),
objectRow('stored_widget', 'draft', { label: 'Draft', enable: { apiMethods: ['get'] } }),
];
const h = harness(rows);
const { served, execution } = await bothFaces(h, { type: 'object', name: 'stored_widget', state: 'draft' });
expectParity('object', served, execution);
expect(execution.item.label).toBe('Draft');
});

it('the DRAFT-PREVIEW read: the draft where one exists, the active item where none does', async () => {
const withDraft = harness([
objectRow('stored_widget', 'active', { label: 'Active' }),
objectRow('stored_widget', 'draft', { label: 'Draft' }),
]);
const preview = await bothFaces(withDraft, { type: 'object', name: 'stored_widget', previewDrafts: true });
expectParity('object', preview.served, preview.execution);
expect(preview.execution.item).toMatchObject({ label: 'Draft', _draft: true });

const withoutDraft = harness([objectRow('stored_widget', 'active', { label: 'Active' })]);
const fallback = await bothFaces(withoutDraft, { type: 'object', name: 'stored_widget', previewDrafts: true });
expectParity('object', fallback.served, fallback.execution);
expect(fallback.execution.item.label).toBe('Active');
});

it('a missing draft is refused `NO_DRAFT` on both faces', async () => {
const h = harness([objectRow('stored_widget', 'active', { label: 'Active' })]);
const request = { type: 'object', name: 'stored_widget', state: 'draft' };
await expect(h.protocol.getMetaItem({ ...request })).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 });
await expect(h.protocol.getMetaItemForExecution({ ...request })).rejects.toMatchObject({ code: 'NO_DRAFT', status: 404 });
});

it('an object of a DISABLED package is refused `OBJECT_PACKAGE_DISABLED` on both faces', async () => {
const h = harness([], { disabledObjects: [CODE_OBJECT.name] });
const request = { type: 'object', name: CODE_OBJECT.name };
const servedRefusal = await h.protocol.getMetaItem({ ...request }).catch((e: unknown) => e);
const executionRefusal = await h.protocol.getMetaItemForExecution({ ...request }).catch((e: unknown) => e);
expect(servedRefusal).toMatchObject({ code: 'OBJECT_PACKAGE_DISABLED', status: 404 });
expect(executionRefusal).toMatchObject({ code: 'OBJECT_PACKAGE_DISABLED', status: 404 });
expect((executionRefusal as Error).message).toBe((servedRefusal as Error).message);
});

it('a name with nothing behind it answers no item on both faces', async () => {
const h = harness([]);
const { served, execution } = await bothFaces(h, { type: 'object', name: 'no_such_object' });
expect(served.item).toBeUndefined();
expect(execution).toEqual(served);
});

it('`registration` is the served face\'s alone, as the list read\'s `registrations` are', async () => {
const h = harness([FLOW_ROW], { automation: true });
const { served, execution } = await bothFaces(h, { type: 'flow', name: 'notify_owner' });
expect(served.registration).toEqual({ state: 'registered' });
expect(Object.prototype.hasOwnProperty.call(execution, 'registration')).toBe(false);
expectParity('flow', served, execution);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async function expectSpellingRefusal(run: () => Promise<unknown>) {
}

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