From a8f08c97e23c16bddcbc3a63147917f58fbe0bf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 03:53:51 +0000 Subject: [PATCH 1/3] perf(metadata-protocol,rest,runtime): /meta/app's object gate reads the execution face; diagnostics verdicts are memoized by content and schema WIP for #25783. Claude-Session: https://claude.ai/code/session_019d4JoyFgaZCRubmNWh91uZ Co-authored-by: Claude --- .../src/metadata-diagnostics.ts | 146 ++++++++++++++++++ packages/rest/src/meta-item-read-gate.ts | 4 + packages/rest/src/rest-server.ts | 17 ++ packages/runtime/src/domains/meta.ts | 17 +- 4 files changed, 181 insertions(+), 3 deletions(-) diff --git a/packages/metadata-protocol/src/metadata-diagnostics.ts b/packages/metadata-protocol/src/metadata-diagnostics.ts index 4e632251f0d..d14d9b98d4c 100644 --- a/packages/metadata-protocol/src/metadata-diagnostics.ts +++ b/packages/metadata-protocol/src/metadata-diagnostics.ts @@ -23,6 +23,7 @@ * across the validate / write / read surfaces. */ +import { createHash } from 'node:crypto'; import type { z } from 'zod'; import { getMetadataTypeSchema, stripReadDecorations } from '@objectstack/spec/kernel'; import type { MetadataValidationResult } from '@objectstack/spec/kernel'; @@ -43,6 +44,132 @@ import { redactMetadataItem } from './metadata-redaction.js'; */ export type MetadataDiagnostics = MetadataValidationResult; +// --------------------------------------------------------------------------- +// [#25783] The verdict memo +// --------------------------------------------------------------------------- +// +// Every served metadata read decorates every item it answers, and the +// decoration is a full `safeParse` of the item's type schema: measured at +// 60 ms of an 86 ms `GET /meta/object` over 108 objects, re-run on every +// read although the documents had not changed. So a verdict is remembered by +// WHAT WAS PARSED, never by where the document came from: +// +// - **The schema object.** One memo per schema, held weakly, so a +// `registerMetadataTypeSchema()` replacement starts from nothing and the +// verdicts of a schema nobody holds any more are collected with it. +// - **The document's content.** A SHA-256 of an encoding that tells apart +// everything the parse can tell apart. A plain JSON document (the stored +// rows, every HTTP body) is its `JSON.stringify`; one that holds +// `undefined` — a strict schema refuses an undeclared key whose value is +// `undefined`, and `JSON.stringify` drops that key — is spelled by +// {@link encodeWithUndefined}. A document holding anything else (a +// function, a class instance, a non-finite number, `-0`, a nesting +// deeper than {@link KEYABLE_DEPTH}) has no faithful key and is parsed +// every time, as before. +// +// Nothing else is an input: a schema's refinements read only the document +// and the spec's own constants (the node-config contract maps are fixed at +// first build), and no code path installs a global zod error map. +// +// ⛔ Not a cache of any read's ANSWER. The overlay cache's position in +// `readActiveOverlayRows` explains why a merged answer is never cached; this +// memo sits below every merge, on a pure function of its own input, so a +// metadata change is a different document and a different key. +// +// The cached verdict never leaves this module: each caller gets its own copy +// ({@link copyDiagnostics}), so a caller that edits the verdict it was handed +// edits nobody else's. Bounded per schema ({@link VERDICTS_PER_SCHEMA}, +// least-recently-used first out); an evicted verdict is recomputed, never +// served stale. + +/** How many verdicts one schema keeps before the least recently used goes. */ +const VERDICTS_PER_SCHEMA = 4096; +/** Nesting past this depth is not keyed (a cycle would otherwise never end). */ +const KEYABLE_DEPTH = 256; + +const VERDICTS_BY_SCHEMA = new WeakMap>(); + +/** `json`: `JSON.stringify` is faithful · `undefined`: faithful once `undefined` is spelled · `none`: no key. */ +type KeyableShape = 'json' | 'undefined' | 'none'; + +function keyableShape(value: unknown, depth: number): KeyableShape { + if (value === null) return 'json'; + switch (typeof value) { + case 'string': + case 'boolean': + return 'json'; + case 'number': + return Number.isFinite(value) && !Object.is(value, -0) ? 'json' : 'none'; + case 'undefined': + return 'undefined'; + case 'object': + break; + default: + return 'none'; + } + if (depth >= KEYABLE_DEPTH) return 'none'; + let shape: KeyableShape = 'json'; + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const inner = keyableShape(value[i], depth + 1); + if (inner === 'none') return 'none'; + if (inner === 'undefined') shape = 'undefined'; + } + return shape; + } + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return 'none'; + for (const key in value as Record) { + const inner = keyableShape((value as Record)[key], depth + 1); + if (inner === 'none') return 'none'; + if (inner === 'undefined') shape = 'undefined'; + } + return shape; +} + +/** + * The encoding of a document {@link keyableShape} answered `undefined` for: + * JSON's, with `undefined` spelled `u` wherever it stands, an array hole + * included (the parse reads a hole as `undefined`). + */ +function encodeWithUndefined(value: unknown): string { + if (value === undefined) return 'u'; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + let out = '['; + for (let i = 0; i < value.length; i++) out += (i ? ',' : '') + encodeWithUndefined(value[i]); + return out + ']'; + } + let out = '{'; + let first = true; + for (const key in value as Record) { + out += (first ? '' : ',') + JSON.stringify(key) + ':' + encodeWithUndefined((value as Record)[key]); + first = false; + } + return out + '}'; +} + +/** The memo key of a document, or `undefined` when it has no faithful one. */ +function contentKey(document: unknown): string | undefined { + const shape = keyableShape(document, 0); + if (shape === 'none') return undefined; + // The two encodings are told apart by their first character, so no + // document's JSON can be another document's `undefined` spelling. + const encoded = shape === 'json' ? `j${JSON.stringify(document)}` : `u${encodeWithUndefined(document)}`; + return createHash('sha256').update(encoded).digest('base64'); +} + +/** + * A caller's own copy of a verdict, key for key and in the same order: its + * lists are flat lists of flat entries, so copying each entry copies it all. + */ +function copyDiagnostics(verdict: MetadataDiagnostics): MetadataDiagnostics { + const copy: MetadataDiagnostics = { ...verdict }; + if (verdict.errors) copy.errors = verdict.errors.map((entry) => ({ ...entry })); + if (verdict.warnings) copy.warnings = verdict.warnings.map((entry) => ({ ...entry })); + return copy; +} + /** * Compute spec diagnostics for a single metadata document. * @@ -94,6 +221,25 @@ export function computeMetadataDiagnostics( // whereas this one is read by the UI and belongs on the response. const candidate = stripReadDecorations(item); + // [#25783] A document parsed before against this same schema is answered + // from the memo — see "The verdict memo" above. + const key = contentKey(candidate); + if (key === undefined) return parseDiagnostics(schema, candidate); + let verdicts = VERDICTS_BY_SCHEMA.get(schema); + if (!verdicts) VERDICTS_BY_SCHEMA.set(schema, (verdicts = new Map())); + let verdict = verdicts.get(key); + if (verdict) { + verdicts.delete(key); + } else { + verdict = parseDiagnostics(schema, candidate); + if (verdicts.size >= VERDICTS_PER_SCHEMA) verdicts.delete(verdicts.keys().next().value as string); + } + verdicts.set(key, verdict); + return copyDiagnostics(verdict); +} + +/** The parse {@link computeMetadataDiagnostics} answers with, uncached. */ +function parseDiagnostics(schema: z.ZodType, candidate: unknown): MetadataDiagnostics { const parsed = (schema as z.ZodTypeAny).safeParse(candidate); if (parsed.success) { return { valid: true }; diff --git a/packages/rest/src/meta-item-read-gate.ts b/packages/rest/src/meta-item-read-gate.ts index 176c8a2d23b..7c0cfd166b2 100644 --- a/packages/rest/src/meta-item-read-gate.ts +++ b/packages/rest/src/meta-item-read-gate.ts @@ -148,6 +148,10 @@ export interface MetaReadGateListSource { * this transport's protocol has no list read at all. A THROW is a fault, * ⛔ never an empty list: an empty book list is "no gated book anywhere" and * an empty doc corpus is "every doc unclaimed, so `org`" — both GRANT. + * [#25783] For `object` a transport hands in the protocol's execution face + * (`getMetaItemsForExecution`) where it has one: every reader of that list + * ({@link loadObjectItems}) reads an `enable` and serves nothing, so the + * served face's per-object diagnostic is pure cost there. */ listMetaItems(type: 'book' | 'doc' | 'object'): Promise | undefined; /** diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 83f58a2ac0b..6ad1c9ab5d0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -264,9 +264,15 @@ import { sendError as sendEnvelopeError } from '@objectstack/types'; * API-exposure gate feature-detects it ({@link RestServer.enforceApiAccess}); * a protocol without it is asked `getMetaItem`, as before. ⛔ No door serves * what it returns. + * + * [#25783] …and its LIST twin, `getMetaItemsForExecution`, equally optional: + * the object list every gate reads only to learn each object's `enable` + * ({@link RestServer.metaListSource}). A protocol without it is asked + * `getMetaItems`, as before. ⛔ No door serves what it returns either. */ export type RestProtocol = DataProtocol & MetadataProtocol & { getMetaItemForExecution?: MetadataProtocol['getMetaItem']; + getMetaItemsForExecution?: MetadataProtocol['getMetaItems']; }; // [#23050 slice 10] `TransportScopedMetaRequest`, the meta doors' request envelope, moved to @@ -3639,6 +3645,14 @@ export class RestServer { * [#20193] The list half of {@link metaItemReadGateSources}: one * `getMetaItems` read of a whole type, env-scoped, `undefined` when this * protocol has no list read at all. + * + * [#25783] The `object` list on the protocol's EXECUTION face where it has + * one: its readers — the nav-servability gate on `/meta/app`, the batch + * route's exposure gate, the by-name gate's list fallback — read each + * object's `enable` and serve none of it, and the served face diagnosed + * every object of the registry on every such request (a full schema + * `safeParse` each). Same sources, merge and governance either way; the + * `book` and `doc` lists stay on the served face, unchanged. */ private metaListSource(p: RestProtocol, environmentId: string | undefined): MetaReadGateListSource { return { @@ -3647,6 +3661,9 @@ export class RestServer { type, ...(environmentId ? { environmentId } : {}), }; + if (type === 'object' && typeof p.getMetaItemsForExecution === 'function') { + return p.getMetaItemsForExecution(listRequest); + } return p.getMetaItems?.(listRequest); }, }; diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 25cbfb0c3ac..d7f49d46d24 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -176,12 +176,14 @@ import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry. * decision. Its signature is DECLARED — on `ObjectStackProtocolImplementation` * itself — so it is `Pick`ed from that class, never restated: a rename at the * producer is a compile error here, the same move `domains/automation.ts` - * makes for `packagedBaseRefusal`. + * makes for `packagedBaseRefusal`. [#25783] So is `getMetaItemsForExecution`, + * the undecorated list the read gates' object list asks + * ({@link metaItemReadGateSources}). */ export type MetaDomainProtocol = Partial> - & Partial> + & Partial> & { /** ⚠️ Undeclared request shapes — see "Where the ledger honestly ends". */ listDrafts?(request: any): Promise; @@ -519,7 +521,16 @@ function metaItemReadGateSources( return mayWriteItem === undefined || !caller ? caller : { ...caller, mayWriteItem }; }, resolveSecurityService: () => deps.resolveService(context, 'security'), - listMetaItems: (type) => (typeof protocol?.getMetaItems === 'function' ? protocol.getMetaItems({ type }) : undefined), + // [#25783] The `object` list on the protocol's execution face where it + // has one, as `RestServer.metaListSource` reads it: its readers judge + // each object's `enable` and serve none of it, and the served face + // diagnosed every object on every `/meta/app` read. + listMetaItems: (type) => { + if (type === 'object' && typeof protocol?.getMetaItemsForExecution === 'function') { + return protocol.getMetaItemsForExecution({ type }); + } + return typeof protocol?.getMetaItems === 'function' ? protocol.getMetaItems({ type }) : undefined; + }, serviceProbe: () => async (name: string) => { try { return (await deps.resolveService(context, name)) != null; From bb46886e0c4ea3077ce34dd353cfd18fa81f152f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 04:45:34 +0000 Subject: [PATCH 2/3] test(metadata-protocol,rest,runtime): pin /meta/app's execution-face object read and the diagnostics verdict memo - rest: GET /meta/app (list and by name) prunes an unlistable object nav entry with zero object diagnostics, unscoped and environment-scoped; a protocol with no execution list face falls back to the served list with the same prune; GET /meta/object still serves _diagnostics. - runtime: the dispatcher's /meta/app reads `object` on getMetaItemsForExecution, never on the served face, and falls back to getMetaItems without it. - metadata-protocol: one parse per document per schema; a hit serves the bytes a fresh parse serves on the real object schema; changed content, a replaced schema, undefined vs missing, a hole vs null and NaN vs null each get their own verdict; unkeyable documents parse every time; callers get independent copies; 4096-entry LRU bound. - rest-exposure-gate-execution-read: the recording proxy is registered per test, since verdicts are memoized per schema object and content. - changeset: patch for the three packages. Claude-Session: https://claude.ai/code/session_019d4JoyFgaZCRubmNWh91uZ Co-authored-by: Claude --- .../25783-console-open-metadata-cost.md | 14 ++ .../src/metadata-diagnostics.memo.test.ts | 216 ++++++++++++++++++ ...app-nav-servability-execution-read.test.ts | 177 ++++++++++++++ .../rest-exposure-gate-execution-read.test.ts | 8 +- ...meta-app-nav-object-execution-face.test.ts | 107 +++++++++ 5 files changed, 519 insertions(+), 3 deletions(-) create mode 100644 .changeset/25783-console-open-metadata-cost.md create mode 100644 packages/metadata-protocol/src/metadata-diagnostics.memo.test.ts create mode 100644 packages/rest/src/meta-app-nav-servability-execution-read.test.ts create mode 100644 packages/runtime/src/domains/meta-app-nav-object-execution-face.test.ts diff --git a/.changeset/25783-console-open-metadata-cost.md b/.changeset/25783-console-open-metadata-cost.md new file mode 100644 index 00000000000..a48ab86e5c8 --- /dev/null +++ b/.changeset/25783-console-open-metadata-cost.md @@ -0,0 +1,14 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/rest': patch +'@objectstack/runtime': patch +--- + +perf(metadata-protocol,rest,runtime): opening the console no longer schema-parses every object on each `/meta/app` read, and served metadata lists parse each unchanged document once + +Clause-②: no +Metadata-protocol: none + +- **`GET /meta/app` judges its navigation on the execution face.** The nav-servability gate reads each object's `enable` to prune the entries whose object refuses `list` (#24031). It read them through the served list, `getMetaItems`, which stamps `_diagnostics` on every object, so every `/meta/app` read ran a full schema parse of every object definition. The gate now reads `getMetaItemsForExecution` for `object` where the protocol has it, in `RestServer` and in the dispatcher's `/meta/app` alike, and the served list otherwise. The same list, without the served decorations. Nothing it serves changes. +- **Diagnostics verdicts are memoized.** `computeMetadataDiagnostics` remembers a verdict per schema object and per document content (a SHA-256 of an encoding that keeps apart everything the parse keeps apart), so a served list of unchanged documents parses each one once instead of on every read. A replaced type schema starts from nothing, each caller gets its own copy, and a document with no faithful key is parsed every time as before. The served bytes are unchanged. +- **Measured** on the showcase app (108 objects, local, main-thread CPU per request): `GET /meta/app` 73.25 ms → 16.75 ms, `GET /meta/object` 85.75 ms → 37 ms. diff --git a/packages/metadata-protocol/src/metadata-diagnostics.memo.test.ts b/packages/metadata-protocol/src/metadata-diagnostics.memo.test.ts new file mode 100644 index 00000000000..5e5cf26d433 --- /dev/null +++ b/packages/metadata-protocol/src/metadata-diagnostics.memo.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25783 — the diagnostics verdict memo answers what a fresh parse answers, + * and parses a document once per schema however many reads serve it. + * + * Every served metadata read stamps `_diagnostics` on every item, and that + * stamp was a full `safeParse` per item per read: 60 ms of an 86 ms + * `GET /meta/object` over 108 unchanged objects. `computeMetadataDiagnostics` + * now remembers a verdict by the schema object and the document's content + * ("The verdict memo" in `metadata-diagnostics.ts`). These rows pin the two + * halves of that promise: + * + * - **The served bytes do not move.** A memo hit serves, byte for byte, what + * a fresh parse of the same document under a schema with no memo serves — + * a valid document and an invalid one, on the real `object` schema. + * - **Nothing the parse can tell apart shares a key.** A changed document, a + * replaced schema, `undefined` against a missing key, an array hole against + * `null`, `NaN` against `null` — each is parsed again and gets its own + * verdict. A document with no faithful key (a function, `-0`, a nesting past + * the keyable depth) is parsed every time, as before the memo. + * + * The instrument: a schema behind a proxy that counts its `safeParse` calls, + * registered for a probe type through `registerMetadataTypeSchema`. A fresh + * proxy per test is a fresh memo, because the memo is held per schema object. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { z } from 'zod'; +import { getMetadataTypeSchema, registerMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { computeMetadataDiagnostics, decorateMetadataItem } from './metadata-diagnostics.js'; + +/** A type no build registers, so binding it touches no real type's schema. */ +const PROBE = 'memo_probe_25783'; + +const PROBE_SCHEMA = z.object({ + name: z.string(), + tags: z.array(z.string().nullable()).optional(), + weight: z.number().nullable().optional(), + nested: z.unknown().optional(), + handler: z.unknown().optional(), +}).strict(); + +let parses = 0; + +/** Bind `schema` to `type` behind a proxy that counts parses; returns the proxy. */ +function bindCounting(type: string, schema: z.ZodType): z.ZodType { + const counting = new Proxy(schema, { + get(target, prop, receiver) { + if (prop === 'safeParse') { + return (...args: unknown[]) => { + parses += 1; + return (target.safeParse as (...a: unknown[]) => unknown).apply(target, args); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + registerMetadataTypeSchema(type, counting); + return counting; +} + +beforeEach(() => { + parses = 0; + bindCounting(PROBE, PROBE_SCHEMA); +}); + +describe('#25783 the verdict memo — one parse per document per schema', () => { + it('answers a repeated document from the memo, a structurally equal copy included', () => { + const doc = { name: 'a', tags: ['x'] }; + expect(computeMetadataDiagnostics(PROBE, doc)).toEqual({ valid: true }); + expect(computeMetadataDiagnostics(PROBE, doc)).toEqual({ valid: true }); + // A row read again from the store is a new object with the same content. + expect(computeMetadataDiagnostics(PROBE, JSON.parse(JSON.stringify(doc)))).toEqual({ valid: true }); + expect(parses).toBe(1); + }); + + it('keys the document without its read decorations, as the parse sees it', () => { + const doc = { name: 'a' }; + computeMetadataDiagnostics(PROBE, doc); + // The served body of the same document, decorations and all. + computeMetadataDiagnostics(PROBE, { ...doc, _diagnostics: { valid: true } }); + expect(parses).toBe(1); + }); + + it('parses a changed document again', () => { + expect(computeMetadataDiagnostics(PROBE, { name: 'a' })?.valid).toBe(true); + expect(computeMetadataDiagnostics(PROBE, { name: 'a', bogus: 1 })?.valid).toBe(false); + expect(computeMetadataDiagnostics(PROBE, { name: 'b' })?.valid).toBe(true); + expect(parses).toBe(3); + }); + + it('starts from nothing when the type schema is replaced', () => { + const doc = { name: 'a', extra: 1 }; + expect(computeMetadataDiagnostics(PROBE, doc)?.valid).toBe(false); + bindCounting(PROBE, PROBE_SCHEMA.passthrough()); + expect(computeMetadataDiagnostics(PROBE, doc)?.valid).toBe(true); + expect(parses).toBe(2); + }); + + it('hands each caller its own copy of a verdict', () => { + const doc = { name: 'a', bogus: 1 }; + const first = computeMetadataDiagnostics(PROBE, doc)!; + const pristine = JSON.stringify(first); + first.valid = true; + first.errors![0]!.message = 'edited by a caller'; + first.errors!.push({ path: 'x', message: 'pushed by a caller', code: 'custom' } as never); + expect(JSON.stringify(computeMetadataDiagnostics(PROBE, doc))).toBe(pristine); + expect(parses).toBe(1); + }); + + it('keeps at most 4096 verdicts per schema, the least recently used out first', () => { + computeMetadataDiagnostics(PROBE, { name: 'kept' }); + computeMetadataDiagnostics(PROBE, { name: 'evicted' }); + for (let i = 0; i < 4094; i++) computeMetadataDiagnostics(PROBE, { name: `n${i}` }); + // 4096 held. Touch `kept`, so `evicted` is now the least recently used. + computeMetadataDiagnostics(PROBE, { name: 'kept' }); + computeMetadataDiagnostics(PROBE, { name: 'one more' }); + const before = parses; + computeMetadataDiagnostics(PROBE, { name: 'kept' }); + expect(parses - before).toBe(0); + computeMetadataDiagnostics(PROBE, { name: 'evicted' }); + expect(parses - before).toBe(1); + }); +}); + +describe('#25783 the verdict memo — no two documents the parse tells apart share a verdict', () => { + // Each pair is run in both orders: whichever is diagnosed first, the + // second must get its own parse and its own verdict. + const pairs: Array<[label: string, accepted: () => unknown, refused: () => unknown]> = [ + ['a missing key against the same key holding `undefined` (strict schema)', + () => ({ name: 'a' }), + () => ({ name: 'a', undeclared: undefined })], + ['`null` against an array hole', + () => ({ name: 'a', tags: [null, 'x'] }), + () => { + const tags: unknown[] = []; + tags[1] = 'x'; // index 0 is a hole: JSON.stringify spells it `null` + return { name: 'a', tags }; + }], + ['`null` against `NaN`', + () => ({ name: 'a', weight: null }), + () => ({ name: 'a', weight: Number.NaN })], + ]; + + for (const [label, accepted, refused] of pairs) { + for (const order of ['accepted first', 'refused first'] as const) { + it(`${label} — ${order}`, () => { + if (order === 'accepted first') { + expect(computeMetadataDiagnostics(PROBE, accepted())?.valid).toBe(true); + expect(computeMetadataDiagnostics(PROBE, refused())?.valid).toBe(false); + } else { + expect(computeMetadataDiagnostics(PROBE, refused())?.valid).toBe(false); + expect(computeMetadataDiagnostics(PROBE, accepted())?.valid).toBe(true); + } + expect(parses).toBe(2); + }); + } + } + + it('parses a document with no faithful key every time: a function, `-0`, a nesting past the keyable depth', () => { + let deep: Record = {}; + const root = deep; + for (let i = 0; i < 300; i++) deep = (deep.next = {}) as Record; + const unkeyable = [ + { name: 'a', handler: () => 1 }, + { name: 'a', weight: -0 }, + { name: 'a', nested: root }, + { name: 'a', nested: new Date(0) }, + ]; + for (const doc of unkeyable) { + const before = parses; + const first = computeMetadataDiagnostics(PROBE, doc); + const second = computeMetadataDiagnostics(PROBE, doc); + expect(second).toEqual(first); + expect(parses - before).toBe(2); + } + }); +}); + +describe('#25783 the verdict memo — a hit serves the bytes a fresh parse serves (real `object` schema)', () => { + const OBJECT_SCHEMA = getMetadataTypeSchema('object')!; + const documents: Array<[label: string, valid: boolean, doc: Record]> = [ + ['a valid object', true, { + name: 'memo_widget', label: 'Memo Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get', 'list'] }, + }], + ['an invalid object', false, { + name: 'memo_widget', label: 'Memo Widget', + fields: { title: { name: 'title', type: 'no_such_field_type' } }, + bogusTopLevelKey: true, + }], + ]; + + for (const [label, valid, doc] of documents) { + it(label, () => { + try { + // A schema with no memo: the reference answer. + bindCounting('object', OBJECT_SCHEMA); + parses = 0; + const fresh = JSON.stringify(decorateMetadataItem('object', structuredClone(doc))); + // The same schema object again: the second read is a memo hit. + const hit = JSON.stringify(decorateMetadataItem('object', structuredClone(doc))); + expect(JSON.parse(fresh)._diagnostics.valid).toBe(valid); + expect(parses).toBe(1); + expect(hit).toBe(fresh); + // And a further fresh schema answers it the same way. + bindCounting('object', OBJECT_SCHEMA); + expect(JSON.stringify(decorateMetadataItem('object', structuredClone(doc)))).toBe(fresh); + expect(parses).toBe(2); + } finally { + registerMetadataTypeSchema('object', OBJECT_SCHEMA); + } + }); + } +}); diff --git a/packages/rest/src/meta-app-nav-servability-execution-read.test.ts b/packages/rest/src/meta-app-nav-servability-execution-read.test.ts new file mode 100644 index 00000000000..eb733df0832 --- /dev/null +++ b/packages/rest/src/meta-app-nav-servability-execution-read.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#25783] `GET /api/v1/meta/app` computes NO object diagnostics to judge its +// navigation — the `/meta/app` door of the class #24031 / #24535 closed for +// `/data`. +// +// The defect: the nav-servability gate (`resolveNavServability`) reads every +// object's `enable` through `loadObjectItems`, which asked the SERVED list +// read, `getMetaItems`, and that decorates every item with `_diagnostics` — a +// full schema `safeParse` of each object definition. Measured at 63 of 73 ms +// CPU per `/meta/app` read over 108 objects. The gate now reads the protocol's +// EXECUTION face, `getMetaItemsForExecution`: the same list, undecorated +// (`RestServer.metaListSource`). +// +// The instrument is `rest-exposure-gate-execution-read.test.ts`'s: the +// `object` schema behind a proxy that records the name of each document it +// parses, registered through `registerMetadataTypeSchema` — a FRESH proxy per +// test, because verdicts are memoized per schema object and document content. +// Everything else is real: the REST route handlers over the real +// `ObjectStackProtocolImplementation` over a real `SchemaRegistry`. +// +// Rows, unscoped and environment-scoped: +// 1. COST — the app list and the app by name prune the entry whose object +// refuses `list` and keep the one that grants it, recording ZERO object +// diagnostics. +// 2. FALLBACK — a protocol with no execution list face is asked +// `getMetaItems`, as before: the same prune, and every object diagnosed. +// This row keeps the zeros above from being vacuous. +// 3. CONTROL — `GET /api/v1/meta/object` still serves `_diagnostics` on +// every object, and the instrument records them. + +import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vitest'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { getMetadataTypeSchema, registerMetadataTypeSchema } from '@objectstack/spec/kernel'; +import { RestServer } from './rest-server.js'; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; + +/** Grants `list`: its nav entry is served. */ +const LISTED = { + name: 'listed_widget', label: 'Listed Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get', 'list'] }, +}; +/** Refuses `list` (405 for every user): its nav entry is pruned. */ +const UNLISTED = { + name: 'unlisted_widget', label: 'Unlisted Widget', + fields: { title: { name: 'title', type: 'text' } }, + enable: { apiMethods: ['get'] }, +}; +const APP = { + name: 'widgets', label: 'Widgets', + navigation: [ + { id: 'nav_listed', type: 'object', label: 'Listed', objectName: LISTED.name }, + { id: 'nav_unlisted', type: 'object', label: 'Unlisted', objectName: UNLISTED.name }, + ], +}; + +// ── The instrument ─────────────────────────────────────────────────────────── + +const OBJECT_SCHEMA = getMetadataTypeSchema('object'); +/** The `name` of every object document diagnosed since the last reset. */ +let diagnosed: unknown[] = []; + +beforeEach(() => { + if (!OBJECT_SCHEMA) throw new Error('no registered `object` schema to instrument'); + const recording = new Proxy(OBJECT_SCHEMA, { + get(target, prop, receiver) { + if (prop === 'safeParse') { + return (input: unknown, ...rest: unknown[]) => { + diagnosed.push((input as { name?: unknown } | null)?.name); + return (target.safeParse as (...a: unknown[]) => unknown).call(target, input, ...rest); + }; + } + return Reflect.get(target, prop, receiver); + }, + }); + registerMetadataTypeSchema('object', recording); +}); +afterAll(() => { + if (OBJECT_SCHEMA) registerMetadataTypeSchema('object', OBJECT_SCHEMA); +}); +afterEach(() => { vi.restoreAllMocks(); }); + +// ── The host ───────────────────────────────────────────────────────────────── + +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), + }; +} + +function host(opts: { environmentId?: string; withoutExecutionListFace?: boolean } = {}) { + const registry = new SchemaRegistry(); + registry.registerObject(clone(LISTED) as never, 'pkg_code', undefined, 'own'); + registry.registerObject(clone(UNLISTED) as never, 'pkg_code', undefined, 'own'); + registry.registerItem('app', clone(APP) as never, 'name' as never, 'pkg_code'); + const engine = { + registry, + find: async () => [], + count: async () => 0, + }; + const protocol: any = new ObjectStackProtocolImplementation(engine as never, () => new Map(), opts.environmentId); + if (opts.withoutExecutionListFace) protocol.getMetaItemsForExecution = undefined; + 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', systemPermissions: [] }); + rest.registerRoutes(); + return { rest, protocol }; +} + +async function get(h: ReturnType, path: string, params: Record) { + 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, + }; + diagnosed = []; + await route.handler({ method: 'GET', path, params, query: {}, headers: {}, body: {} }, res); + return { status, body, diagnosed: [...diagnosed] }; +} + +const META_LIST = '/api/v1/meta/:type'; +const META_ITEM = '/api/v1/meta/:type/:name'; +const navIds = (app: any): string[] => (app?.navigation ?? []).map((e: any) => e.id); +const listedApp = (body: any): any => (body?.items ?? []).find((a: any) => a?.name === APP.name); + +for (const scope of [{ label: 'unscoped kernel', environmentId: undefined }, { label: 'environment-scoped kernel', environmentId: 'env_test' }]) { + describe(`[#25783] GET /api/v1/meta/app judges its navigation without object diagnostics — ${scope.label}`, () => { + it('the app LIST: the unlistable entry is pruned, the listable one kept, ZERO object diagnostics', async () => { + const h = host({ environmentId: scope.environmentId }); + const execution = vi.spyOn(h.protocol, 'getMetaItemsForExecution'); + const out = await get(h, META_LIST, { type: 'app' }); + expect(out.status, JSON.stringify(out.body)).toBe(200); + expect(navIds(listedApp(out.body))).toEqual(['nav_listed']); + expect(execution).toHaveBeenCalledWith(expect.objectContaining({ type: 'object' })); + expect(out.diagnosed).toEqual([]); + }); + + it('the app BY NAME: the same prune, ZERO object diagnostics', async () => { + const h = host({ environmentId: scope.environmentId }); + const out = await get(h, META_ITEM, { type: 'app', name: APP.name }); + expect(out.status, JSON.stringify(out.body)).toBe(200); + expect(navIds(out.body.item)).toEqual(['nav_listed']); + expect(out.diagnosed).toEqual([]); + }); + + it('FALLBACK: a protocol with no execution list face is asked `getMetaItems` — the same prune, every object diagnosed', async () => { + const h = host({ environmentId: scope.environmentId, withoutExecutionListFace: true }); + const served = vi.spyOn(h.protocol, 'getMetaItems'); + const out = await get(h, META_LIST, { type: 'app' }); + expect(out.status, JSON.stringify(out.body)).toBe(200); + expect(navIds(listedApp(out.body))).toEqual(['nav_listed']); + expect(served).toHaveBeenCalledWith(expect.objectContaining({ type: 'object' })); + expect([...out.diagnosed].sort()).toEqual([LISTED.name, UNLISTED.name].sort()); + }); + + it('CONTROL: GET /api/v1/meta/object still serves `_diagnostics` on every object', async () => { + const h = host({ environmentId: scope.environmentId }); + const out = await get(h, META_LIST, { type: 'object' }); + expect(out.status, JSON.stringify(out.body)).toBe(200); + const items = (out.body?.items ?? []).filter((o: any) => o?.name === LISTED.name || o?.name === UNLISTED.name); + expect(items).toHaveLength(2); + for (const item of items) expect(item._diagnostics).toBeDefined(); + expect(out.diagnosed).toEqual(expect.arrayContaining([LISTED.name, UNLISTED.name])); + }); + }); +} diff --git a/packages/rest/src/rest-exposure-gate-execution-read.test.ts b/packages/rest/src/rest-exposure-gate-execution-read.test.ts index af4509e4713..71f244e3b47 100644 --- a/packages/rest/src/rest-exposure-gate-execution-read.test.ts +++ b/packages/rest/src/rest-exposure-gate-execution-read.test.ts @@ -17,7 +17,9 @@ // The `object` binding is replaced, for this file, by the same schema behind a // proxy that records the name of each document it parses, through the // registry's own public overlay (`registerMetadataTypeSchema`), and restored -// after. Everything else is real: the REST route handlers over the real +// after. [#25783] A FRESH proxy per test: verdicts are memoized per schema +// object and document content, so a document an earlier test diagnosed would +// otherwise be answered without a parse, and its row would record nothing. Everything else is real: the REST route handlers over the real // `ObjectStackProtocolImplementation` over a real `SchemaRegistry`, with the // real data call behind the gate for the code object (see `host`). // @@ -33,7 +35,7 @@ // 3. CONTROL — the served read is unchanged: `GET /api/v1/meta/object/NAME` // still serves `_diagnostics`, and the instrument records it. -import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterAll, afterEach } from 'vitest'; import { SchemaRegistry, assertEngineFindOnePredicate } from '@objectstack/objectql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { getMetadataTypeSchema, registerMetadataTypeSchema } from '@objectstack/spec/kernel'; @@ -67,7 +69,7 @@ const OBJECT_SCHEMA = getMetadataTypeSchema('object'); /** The `name` of every object document diagnosed since the last reset. */ let diagnosed: unknown[] = []; -beforeAll(() => { +beforeEach(() => { if (!OBJECT_SCHEMA) throw new Error('no registered `object` schema to instrument'); const recording = new Proxy(OBJECT_SCHEMA, { get(target, prop, receiver) { diff --git a/packages/runtime/src/domains/meta-app-nav-object-execution-face.test.ts b/packages/runtime/src/domains/meta-app-nav-object-execution-face.test.ts new file mode 100644 index 00000000000..e8d45a7e857 --- /dev/null +++ b/packages/runtime/src/domains/meta-app-nav-object-execution-face.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25783] The dispatcher's `/meta/app` judges each object nav entry on the + * protocol's EXECUTION list face, as `RestServer`'s does. + * + * The nav-servability gate reads every object's `enable` to prune the entries + * whose object refuses `list` (#24031). It read them through the SERVED list, + * `getMetaItems`, which stamps `_diagnostics` on every object — a full schema + * parse per object per `/meta/app` read, 63 of 73 ms CPU over 108 objects. + * `metaItemReadGateSources.listMetaItems` now asks `getMetaItemsForExecution` + * for `object` where the protocol has it (the same list, undecorated), and the + * served list otherwise, as `RestServer.metaListSource` does. + * + * Rows, through `dispatch()` (the `${prefix}/*` catch-all's delegate): + * 1. the app list and the app by name prune the unlistable entry and keep the + * listable one, reading `object` on the execution face and never on the + * served one; + * 2. FALLBACK — a protocol with no execution list face is asked + * `getMetaItems`, with the same prune. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +const LISTED = { name: 'listed_widget', label: 'Listed Widget', enable: { apiMethods: ['get', 'list'] } }; +/** Refuses `list` (405 for every user): its nav entry is pruned. */ +const UNLISTED = { name: 'unlisted_widget', label: 'Unlisted Widget', enable: { apiMethods: ['get'] } }; +const APP = { + name: 'widgets', + label: 'Widgets', + navigation: [ + { id: 'nav_listed', type: 'object', label: 'Listed', objectName: LISTED.name }, + { id: 'nav_unlisted', type: 'object', label: 'Unlisted', objectName: UNLISTED.name }, + ], +}; +const STORE: Record = { app: [APP], object: [LISTED, UNLISTED] }; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)); +const singular = (type: unknown): string => String(type ?? '').replace(/s$/, ''); + +function protocolDouble(opts: { executionFace: boolean }) { + const list = async ({ type }: any) => clone(STORE[singular(type)] ?? []); + return { + getMetaTypes: vi.fn(async () => ({ types: Object.keys(STORE) })), + getMetaItems: vi.fn(list), + ...(opts.executionFace ? { getMetaItemsForExecution: vi.fn(list) } : {}), + getMetaItem: vi.fn(async ({ type, name }: any) => { + const found = (STORE[singular(type)] ?? []).find((i) => i.name === name); + return { type: singular(type), name, item: found ? clone(found) : undefined, lock: 'none', editable: true, deletable: true, resettable: false }; + }), + }; +} + +function bootDispatcher(opts: { executionFace: boolean }) { + const protocol = protocolDouble(opts); + const services: Record = { + protocol, + security: { resolvePermissionSetNames: async () => [], getMetadataReadableFields: async () => [] }, + }; + const get = (n: string) => services[n] ?? null; + const kernel: any = { context: { getService: get }, getService: get, getServiceAsync: async (n: string) => get(n) }; + const dispatcher = new HttpDispatcher(kernel); + (dispatcher as any).timedResolveExecutionContext = async () => ({ userId: 'u_member', isSystem: false, systemPermissions: [] }); + const read = async (path: string) => { + const res = await dispatcher.dispatch('GET', path, undefined, {}, { request: { headers: {} } } as any); + return { status: res.response?.status ?? 0, body: res.response?.body }; + }; + return { read, protocol }; +} + +const navIds = (app: any): string[] => (app?.navigation ?? []).map((e: any) => e.id); +const listedApp = (body: any): any => { + const data = body?.data; + const items = Array.isArray(data) ? data : data?.items; + return (items ?? []).find((a: any) => a?.name === APP.name); +}; +const askedForObject = (spy: ReturnType): boolean => + spy.mock.calls.some(([req]: any[]) => singular(req?.type) === 'object'); + +describe('[#25783] the dispatcher judges /meta/app object nav entries on the execution list face', () => { + it('the app LIST: the unlistable entry pruned, `object` read on the execution face, never the served one', async () => { + const { read, protocol } = bootDispatcher({ executionFace: true }); + const out = await read('/meta/app'); + expect(out.status, JSON.stringify(out.body)).toBe(200); + expect(navIds(listedApp(out.body))).toEqual(['nav_listed']); + expect(askedForObject(protocol.getMetaItemsForExecution!)).toBe(true); + expect(askedForObject(protocol.getMetaItems)).toBe(false); + }); + + it('the app BY NAME: the same prune, `object` read on the execution face, never the served one', async () => { + const { read, protocol } = bootDispatcher({ executionFace: true }); + const out = await read(`/meta/app/${APP.name}`); + expect(out.status, JSON.stringify(out.body)).toBe(200); + expect(navIds(out.body?.data?.item)).toEqual(['nav_listed']); + expect(askedForObject(protocol.getMetaItemsForExecution!)).toBe(true); + expect(askedForObject(protocol.getMetaItems)).toBe(false); + }); + + it('FALLBACK: a protocol with no execution list face is asked `getMetaItems` — the same prune', async () => { + const { read, protocol } = bootDispatcher({ executionFace: false }); + const out = await read('/meta/app'); + expect(out.status, JSON.stringify(out.body)).toBe(200); + expect(navIds(listedApp(out.body))).toEqual(['nav_listed']); + expect(askedForObject(protocol.getMetaItems)).toBe(true); + }); +}); From 22f886e1d2be780b9b159b9fbcdfa98b0b4bcb45 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 05:26:02 +0000 Subject: [PATCH 3/3] chore(changeset): declare the RestProtocol widening on its own @objectstack/rest changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RestProtocol` is exported from the rest barrel, and it gains the optional `getMetaItemsForExecution?` member: a widening of the published type surface. Measured on the built dist/index.d.ts before and after: rest keeps its export list and differs by that one member line outside doc comments; the metadata-protocol and runtime declarations are byte-identical, so their changeset keeps `Clause-②: no`. All three stay `patch`: under `Metadata-protocol: none` the level axis refuses a `minor` entry. Claude-Session: https://claude.ai/code/session_019d4JoyFgaZCRubmNWh91uZ Co-authored-by: Claude --- .changeset/25783-console-open-metadata-cost.md | 9 ++++----- .changeset/25783-rest-meta-app-execution-face.md | 13 +++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 .changeset/25783-rest-meta-app-execution-face.md diff --git a/.changeset/25783-console-open-metadata-cost.md b/.changeset/25783-console-open-metadata-cost.md index a48ab86e5c8..d7d9c665901 100644 --- a/.changeset/25783-console-open-metadata-cost.md +++ b/.changeset/25783-console-open-metadata-cost.md @@ -1,14 +1,13 @@ --- '@objectstack/metadata-protocol': patch -'@objectstack/rest': patch '@objectstack/runtime': patch --- -perf(metadata-protocol,rest,runtime): opening the console no longer schema-parses every object on each `/meta/app` read, and served metadata lists parse each unchanged document once +perf(metadata-protocol,runtime): served metadata lists parse each unchanged document once, and the dispatcher's `/meta/app` reads objects on the execution face Clause-②: no Metadata-protocol: none -- **`GET /meta/app` judges its navigation on the execution face.** The nav-servability gate reads each object's `enable` to prune the entries whose object refuses `list` (#24031). It read them through the served list, `getMetaItems`, which stamps `_diagnostics` on every object, so every `/meta/app` read ran a full schema parse of every object definition. The gate now reads `getMetaItemsForExecution` for `object` where the protocol has it, in `RestServer` and in the dispatcher's `/meta/app` alike, and the served list otherwise. The same list, without the served decorations. Nothing it serves changes. -- **Diagnostics verdicts are memoized.** `computeMetadataDiagnostics` remembers a verdict per schema object and per document content (a SHA-256 of an encoding that keeps apart everything the parse keeps apart), so a served list of unchanged documents parses each one once instead of on every read. A replaced type schema starts from nothing, each caller gets its own copy, and a document with no faithful key is parsed every time as before. The served bytes are unchanged. -- **Measured** on the showcase app (108 objects, local, main-thread CPU per request): `GET /meta/app` 73.25 ms → 16.75 ms, `GET /meta/object` 85.75 ms → 37 ms. +- **Diagnostics verdicts are memoized.** `computeMetadataDiagnostics` remembers a verdict per schema object and per document content (a SHA-256 of an encoding that keeps apart everything the parse keeps apart), so a served list of unchanged documents parses each one once instead of on every read. A replaced type schema starts from nothing, each caller gets its own copy, and a document with no faithful key is parsed every time as before. The served bytes are unchanged. Measured on the showcase app (108 objects, local, main-thread CPU per request): `GET /meta/object` 85.75 ms → 37 ms. +- **The dispatcher's `/meta/app`** (the `${prefix}/*` catch-all) reads each object's `enable` for its nav prune through `getMetaItemsForExecution` where the protocol has it, as `RestServer` does, and `getMetaItems` otherwise. +- **No public surface moves.** The built `dist/index.d.ts` of both packages is byte-identical at `cb0a2fcf` and at this head. diff --git a/.changeset/25783-rest-meta-app-execution-face.md b/.changeset/25783-rest-meta-app-execution-face.md new file mode 100644 index 00000000000..083f84bdc04 --- /dev/null +++ b/.changeset/25783-rest-meta-app-execution-face.md @@ -0,0 +1,13 @@ +--- +'@objectstack/rest': patch +--- + +perf(rest): `GET /meta/app` judges its navigation on the protocol's execution face, so it no longer schema-parses every object on each read + +Clause-②: yes (widening): the exported `RestProtocol` type gains one optional member, `getMetaItemsForExecution?`. Measured on the built `dist/index.d.ts` at `cb0a2fcf` (before) and at this head (after): the export list is unchanged (67 names), and outside doc comments the one changed line is that member. Every other member signature is byte-identical. The member is optional, so nothing a protocol must provide changes. + +Metadata-protocol: none + +- **What.** The nav-servability gate (#24031) reads each object's `enable` to prune the nav entries whose object refuses `list`. It read them through the served `getMetaItems`, which stamps `_diagnostics` on every object, so every `/meta/app` read ran a full schema parse of every object definition. `RestServer.metaListSource` now asks `getMetaItemsForExecution` for `object` when the protocol has it (the same list, without the serving decorations) and `getMetaItems` otherwise. The batch route's exposure gate and the by-name gate's list fallback read the same source. The `book` and `doc` lists stay on the served face. +- **Measured** on the showcase app (108 objects, local, main-thread CPU per request): `GET /meta/app` 73.25 ms → 16.75 ms. +- **Unchanged.** Every `/meta` answer: the same nav prune, and `GET /meta/object` still serves `_diagnostics` on every object.