From 5071d41fb59651b0e726c3506c5390bf9bf6f230 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:46:40 +0000 Subject: [PATCH 1/5] fix(metadata-protocol): the active reads decline a stored row under a code-defined datasource name getMetaItem, the flattened list and the layered read's effective layer now ask one predicate, declinesStoredRow, before they serve a stored row: a shipped flow name (the existing #20946 shape) or a datasource name the host registers from code (isDeclaredCodeDatasource). The read falls through to the MetadataService's in-memory code definition. The row stays found, so deletable still offers the /meta DELETE repair; a draft is answered as a draft; every other type keeps ADR-0005's order. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tocol.code-defined-datasource-door.test.ts | 213 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 116 ++++++++-- 2 files changed, 308 insertions(+), 21 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts index cb9a354553c..aa8d986a5d7 100644 --- a/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts +++ b/packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts @@ -111,10 +111,19 @@ function makeSession(opts: { seed?: Row[]; /** [#21944] The kernel services the protocol resolves — the host's code-datasource set among them. */ services?: Map; + /** + * [#21922] A registry that keeps what is registered under the bare key and + * lists it, as the real one does, so a stored row the hydrator registers + * (the boot pull, an unscoped list) is read back. Off: the registry lists + * nothing, as the other cases assume. + */ + hydrating?: boolean; } = {}) { const rows = new Map(); for (const r of opts.seed ?? []) rows.set(r.id, r); const historyRows: Array> = []; + /** [#21922] The bare-key entries a hydrating registry holds, per type. */ + const registered = new Map>>(); const engine: any = { async findOne(table: string, o: { where: Record }) { @@ -126,10 +135,15 @@ function makeSession(opts: { for (const row of rows.values()) if (matchesWhere(row as any, o.where)) return row; return null; }, - async find(table: string) { + async find(table: string, o?: { where?: Record; limit?: number }) { if (table === 'sys_metadata_history') return historyRows; if (table !== 'sys_metadata') return []; - return Array.from(rows.values()); + // [#21922] The caller's predicate, as the real engine applies it: + // the list reads one type's ACTIVE rows, so a draft row of the + // same name must not reach it. `check:objectql-double-limit` — + // the caller's bound, applied after the filter. + const matched = Array.from(rows.values()).filter((row) => matchesWhere(row as any, o?.where ?? {})); + return o?.limit === undefined ? matched : matched.slice(0, o.limit); }, async insert(table: string, data: Record) { if (table === 'sys_metadata_history') { @@ -158,15 +172,21 @@ function makeSession(opts: { return { deleted: existed ? 1 : 0 }; }, registry: { - registerItem: () => {}, + registerItem: (type: string, item: Record, keyField = 'name') => { + if (!opts.hydrating) return; + if (!registered.has(type)) registered.set(type, new Map()); + registered.get(type)!.set(String(item[keyField]), item); + }, registerObject: () => {}, - listItems: () => [], - // The served code definition, as the runtime's in-memory - // registration serves it on a read. - getItem: (type: string, name: string) => - (type === 'datasource' && name === CODE_DS && (opts.packages ?? []).length > 0 + listItems: (type: string) => [...(registered.get(type)?.values() ?? [])], + // The real registry answers the bare slot first; with nothing + // registered there, the served code definition, as the runtime's + // in-memory registration serves it on a read. + getItem: (type: string, name: string) => registered.get(type)?.get(name) + ?? (type === 'datasource' && name === CODE_DS && (opts.packages ?? []).length > 0 ? { ...body(CODE_DS, 'External Analytics (SQLite)'), origin: 'code' } : undefined), + isPackageDisabled: () => false, applyNavContributions: (app: unknown) => app, // A code-defined datasource is never a SchemaRegistry item — the // artifact-only lookup misses it, exactly as on a booted showcase. @@ -530,3 +550,180 @@ for (const { label, environmentId } of KERNELS) { }); }); } + +/** + * [#21922] The active reads DECLINE a stored row under a code-defined + * datasource name (triage's answer A, the #20946 shape). + * + * A row the `/meta` door saved before it refused these names, or one an earlier + * runtime write left, sits under a name the host registers from code. The boot + * restore no longer registers it over the code definition, so the + * MetadataService holds the code definition. But the by-name read still served + * the row first (ADR-0005's read order, `findServedOverlayRow`), and the list + * did too, because its stored rows are the higher layer over the + * MetadataService's. Now the one predicate the reads already asked of a shipped + * flow name (`declinesStoredRow`) answers for these names too: + * + * - (a) the by-name read, the list and the layered read's `effective` serve + * the code definition while the row exists — the list also after the row + * was hydrated into the registry's bare slot (the registry half); + * - (b) the row stays FOUND, so `deletable` still offers the repair, and the + * `/meta` DELETE still removes it; + * - (c) a runtime datasource's stored row is served exactly as before; + * - (d) a draft is answered as a draft. + * + * The MetadataService double is the composition's: the in-memory registrations + * the runtime made at boot, read first (`MetadataManager.get` / `list`). + */ +const CODE_LABEL = 'External Analytics (SQLite)'; +const DEFAULT_LABEL = 'Host Default 21922'; +const SHADOW_LABEL = 'Shadow 21922'; +const RUNTIME_ROW_LABEL = 'Runtime 21922 (stored row)'; +const DRAFT_LABEL = 'Draft 21922'; + +/** A row an earlier runtime write left under `name`: it asserts `origin: 'runtime'` and its own file. */ +const residueRow = (name: string, overrides: { label?: string; state?: string } = {}): Row => ({ + id: `row_residue_${name}_${overrides.state ?? 'active'}`, + type: 'datasource', + name, + organization_id: null, + package_id: null, + state: overrides.state ?? 'active', + metadata: JSON.stringify({ + ...body(name, overrides.label ?? SHADOW_LABEL), + origin: 'runtime', + config: { filename: `shadow-${name}.db` }, + }), + checksum: `sha256_residue_${name}`, +}); + +/** The `metadata` service: what the runtime registered in memory, read first. */ +const metadataServiceOf = (items: Array>) => { + const byName = new Map(items.map((it) => [String(it.name), it] as const)); + const read = (type: string, name: string) => (type === 'datasource' ? byName.get(name) : undefined); + return { + get: async (type: string, name: string) => read(type, name), + getDiagnosed: async (type: string, name: string) => ({ data: read(type, name), degraded: false, errors: [] as string[] }), + list: async (type: string) => (type === 'datasource' ? [...byName.values()] : []), + }; +}; + +/** + * The two code definitions (`AppPlugin`, `DefaultDatasourcePlugin`), and the + * runtime datasource as the restore registered it, under a label its row does + * not carry, so the control can tell which layer answered. + */ +const IN_MEMORY: Array> = [ + { ...body(CODE_DS, CODE_LABEL), origin: 'code', _packageId: PACKAGE_ID }, + { name: 'default', label: DEFAULT_LABEL, driver: 'sqlite', config: { filename: 'host.db' }, origin: 'code' }, + { ...body(RUNTIME_DS, 'Runtime 21922 (MetadataService copy)'), origin: 'runtime' }, +]; + +for (const { label, environmentId } of KERNELS) { + describe(`[#21922] the active reads decline a stored row under a code-defined datasource name — ${label}`, () => { + beforeEach(resetEnvHatch); + afterEach(resetEnvHatch); + + const session = (seed: Row[]) => makeSession({ + ...(environmentId ? { environmentId } : {}), + packages: [SHOWCASE_PACKAGE], + services: new Map([ + [CODE_NAMES_SERVICE, new Set([CODE_DS, 'default'])], + ['metadata', metadataServiceOf(IN_MEMORY)], + ]), + seed, + hydrating: true, + }); + const byName = (protocol: any, request: Record) => + protocol.getMetaItem({ type: 'datasource', ...request }); + const listed = async (protocol: any, name: string): Promise>> => + ((await protocol.getMetaItems({ type: 'datasource' })).items as Array>) + .filter((it) => it.name === name); + /** The boot pull's call (`loadMetaFromDb`): the row, hydrated under the registry's bare key. */ + const hydrate = (protocol: any, row: Row) => { + protocol.hydrateOverlayIntoRegistry('datasource', JSON.parse(row.metadata), { organizationId: null }); + expect(protocol.engine.registry.getItem('datasource', row.name)?.label).toBe(SHADOW_LABEL); + }; + + it('(a) by name: the code definition is served while the row exists, and the envelope still offers the repair', async () => { + const { protocol, rows } = session([residueRow(CODE_DS)]); + + for (const type of ['datasource', 'datasources']) { + const read = await byName(protocol, { type, name: CODE_DS }); + expect(read.item, type).toMatchObject({ origin: 'code', label: CODE_LABEL, _packageId: PACKAGE_ID }); + expect(read.item.config, type).toEqual({ filename: `${CODE_DS}.db` }); + expect({ editable: read.editable, deletable: read.deletable }, type).toEqual({ editable: false, deletable: true }); + } + expect(rows.size).toBe(1); + }); + + it('(a) in the list: one entry under the name, the code definition, before and after the row is hydrated into the registry', async () => { + const row = residueRow(CODE_DS); + const { protocol } = session([row]); + + const before = await listed(protocol, CODE_DS); + hydrate(protocol, row); + const after = await listed(protocol, CODE_DS); + + for (const list of [before, after]) { + expect(list.map((it) => it.label)).toEqual([CODE_LABEL]); + expect(list[0]).toMatchObject({ origin: 'code', _packageId: PACKAGE_ID }); + } + // The by-name read agrees with the list after the hydration too. + expect((await byName(protocol, { name: CODE_DS })).item.label).toBe(CODE_LABEL); + }); + + it('(a) the host\'s `default`, which no package declares, the same way', async () => { + const row = residueRow('default'); + const { protocol } = session([row]); + + expect((await byName(protocol, { name: 'default' })).item).toMatchObject({ origin: 'code', label: DEFAULT_LABEL }); + hydrate(protocol, row); + expect((await listed(protocol, 'default')).map((it) => it.label)).toEqual([DEFAULT_LABEL]); + }); + + it('(a) the layered read: `effective` is the code definition, and the row is still reported in `overlay`', async () => { + const { protocol } = session([residueRow(CODE_DS)]); + + const layered = await protocol.getMetaItemLayered({ type: 'datasource', name: CODE_DS }); + + expect(layered.effective).toMatchObject({ origin: 'code', label: CODE_LABEL }); + expect(layered.code).toMatchObject({ origin: 'code', label: CODE_LABEL }); + expect(layered.overlay).toMatchObject({ origin: 'runtime', label: SHADOW_LABEL }); + expect(layered.overlayScope).toBe('env'); + expect({ editable: layered.editable, deletable: layered.deletable }).toEqual({ editable: false, deletable: true }); + }); + + it('(b) the /meta DELETE still removes the row (the repair), and the reads serve the code definition after it', async () => { + const { protocol, rows } = session([residueRow(CODE_DS)]); + + const res = await protocol.deleteMetaItem({ type: 'datasource', name: CODE_DS }); + + expect(res).toMatchObject({ success: true, reset: true }); + expect(rows.size).toBe(0); + const read = await byName(protocol, { name: CODE_DS }); + expect(read.item).toMatchObject({ origin: 'code', label: CODE_LABEL }); + expect(read.deletable).toBe(false); + expect((await listed(protocol, CODE_DS)).map((it) => it.label)).toEqual([CODE_LABEL]); + }); + + it('(c) control: a runtime datasource\'s stored row is still served, by name and in the list', async () => { + const row = residueRow(RUNTIME_DS, { label: RUNTIME_ROW_LABEL }); + const { protocol } = session([row]); + + expect((await byName(protocol, { name: RUNTIME_DS })).item).toMatchObject({ label: RUNTIME_ROW_LABEL }); + expect((await listed(protocol, RUNTIME_DS)).map((it) => it.label)).toEqual([RUNTIME_ROW_LABEL]); + const layered = await protocol.getMetaItemLayered({ type: 'datasource', name: RUNTIME_DS }); + expect(layered.effective).toMatchObject({ label: RUNTIME_ROW_LABEL }); + }); + + it('(d) a draft is answered as a draft: the strict draft read and the preview arm serve the draft row', async () => { + const { protocol } = session([residueRow(CODE_DS), residueRow(CODE_DS, { state: 'draft', label: DRAFT_LABEL })]); + + expect((await byName(protocol, { name: CODE_DS, state: 'draft' })).item).toMatchObject({ label: DRAFT_LABEL }); + expect((await byName(protocol, { name: CODE_DS, previewDrafts: true })).item).toMatchObject({ label: DRAFT_LABEL, _draft: true }); + // The active read beside them still serves the code definition. + expect((await byName(protocol, { name: CODE_DS })).item).toMatchObject({ label: CODE_LABEL }); + }); + }); +} diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ae59646ef9d..7890c4b99f6 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -8923,7 +8923,10 @@ export class ObjectStackProtocolImplementation implements // [#20913] A shipped flow name serves the loader's entries only — see // {@link isShippedFlowName}. This is the registry half: a stored row // the hydration registered under the bare key is not one of them. - items = items.filter((it) => !this.isStoredFlowEntryOfShippedName(request.type, it)); + // [#21922] …and a code-defined datasource name serves the + // MetadataService's code definition, merged in below. One predicate + // for both: {@link declinesStoredRow}, whose registry half this is. + items = items.filter((it) => !this.isStoredEntryOfDeclinedName(request.type, it)); // Always consult the DB so metadata persisted by the seeder / // bulkRegister shows up even when the registry already has unrelated @@ -8979,8 +8982,11 @@ export class ObjectStackProtocolImplementation implements // [#20913] …and the stored-row half: a row of a shipped flow name // is not merged into the package's slot. It is still hydrated // below, as the tenant row it is, so the boot pull reports it. + // [#21922] The same for a row under a code-defined datasource + // name ({@link declinesStoredRow}): the MetadataService's code + // definition, merged in below, is the item the list serves. const mergeable = overlays.filter( - ({ data }) => !this.isShippedFlowName(request.type, (data as { name?: unknown } | null)?.name), + ({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name), ); // [#21804] Each row travels with its place (scope and stored // spelling), so the merge picks a package's slot by the @@ -8990,9 +8996,9 @@ export class ObjectStackProtocolImplementation implements data, packageId: recPkg, stored: { organizationId: recOrg, type: recType }, })); // [#21817] The package-less rows, placed the same way, as - // stand-ins: the same parse and the same shipped-flow rule. + // stand-ins: the same parse and the same stored-row rule. const standIns = this.storedOverlayEntries(request, standInRows) - .filter(({ data }) => !this.isShippedFlowName(request.type, (data as { name?: unknown } | null)?.name)) + .filter(({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name)) .map(({ data, organizationId: recOrg, type: recType }) => ({ data, packageId: undefined, stored: { organizationId: recOrg, type: recType }, standIn: true, })); @@ -9975,7 +9981,22 @@ export class ObjectStackProtocolImplementation implements // `orgId` is `undefined` for `flow`, which declares no org override. // What becomes of the stored rows themselves (keep, refuse, migrate) is // not decided here. - const shippedFlowActiveRead = readState === 'active' && this.isShippedFlowName(request.type, request.name); + // + // ── [#21922, ADR-0062 D4, ADR-0126 §3] A code-defined DATASOURCE name ── + // + // The stored-row half covers a second name class, through the same + // predicate ({@link declinesStoredRow}): a datasource name the host + // registers from code ({@link isDeclaredCodeDatasource}). "Code wins on + // collision": its code definition is the MetadataService's in-memory + // registration, which step 2 serves, and a stored row under the name is + // residue, never a layer of it. Adopted, the row was served here while + // the list, the admin door and the boot restore all served the code + // definition. This read needs no registry half for it: step 2 answers + // before step 3's bare registry slot, which is where a hydrated copy of + // the row sits. The row is still FOUND (`storedRowServed` below), so the + // `/meta` DELETE that removes it as repair stays `deletable`. A draft + // is answered as a draft, as above. + const storedRowDeclined = readState === 'active' && this.declinesStoredRow(request.type, request.name); // ADR-0033 draft-overlay preview (non-strict): when the caller opts in // (admin-gated upstream), prefer a `state='draft'` row if one exists, else @@ -10043,8 +10064,8 @@ export class ObjectStackProtocolImplementation implements ...(request.packageId ? { packageId: request.packageId } : {}), }))?.row; storedRowServed = record !== undefined && record !== null; - // [#20946] The stored-row half — see `shippedFlowActiveRead` above. - if (record && !shippedFlowActiveRead) { + // [#20946, #21922] The stored-row half — see `storedRowDeclined` above. + if (record && !storedRowDeclined) { item = this.convertStoredItem( String(record.type ?? request.type), storedRowDocument(record), @@ -10093,8 +10114,9 @@ export class ObjectStackProtocolImplementation implements // the `_lock` gate makes: the strictest lock among the item's stored // rows in scope, whichever package each is bound to. Not the row served // above, which is the address's preferred CONTENT (ADR-0048), and read - // whether or not that row is adopted (a shipped flow name does not - // serve its stored row, #20946, and the gate binds it all the same). + // whether or not that row is adopted (a shipped flow name, #20946, and a + // code-defined datasource name, #21922, do not serve their stored row, + // and the gate binds it all the same). let overlayLockLayer: unknown; try { overlayLockLayer = await this.overlayLockLayerAt({ @@ -10206,10 +10228,12 @@ export class ObjectStackProtocolImplementation implements const alt = PLURAL_TO_SINGULAR[request.type] ?? SINGULAR_TO_PLURAL[request.type]; if (alt) item = this.engine.registry.getItem(alt, request.name, request.packageId); } - // [#20946] The registry half — see `shippedFlowActiveRead` above. + // [#20946] The registry half — see `storedRowDeclined` above. // `getItem` answers the bare slot first, and for a shipped flow name // that slot holds the hydrated stored row, which is not one of the // loader's entries; the loader's entry is the one the list serves. + // [#21922] Flow-only: a code-defined datasource's definition is not + // in the registry, and step 2 has already served it. if (this.isStoredFlowEntryOfShippedName(request.type, item)) { item = this.lookupArtifactItem(request.type, request.name, request.packageId); } @@ -10342,7 +10366,9 @@ export class ObjectStackProtocolImplementation implements * would return, i.e. overlay-wins merge — except for a flow name the * loader ships, where `getMetaItem` serves the loader's body, so * `effective` is the code layer and a stored row of that name is - * reported in `overlay` as a shadowed layer, #21002). + * reported in `overlay` as a shadowed layer, #21002; and likewise for a + * code-defined datasource name, whose code layer is the MetadataService's + * in-memory registration, #21922). * * Drives the "Code default vs Overlay vs Effective" diff tab in the * generic Metadata Resource Edit page. Admins can see exactly what @@ -10737,12 +10763,19 @@ export class ObjectStackProtocolImplementation implements // package ships, keeps overlay-wins. What becomes of the stored rows // themselves (keep, refuse, migrate) is not decided here. // + // [#21922] The same holds for a code-defined DATASOURCE name, through + // the one predicate both name classes share ({@link declinesStoredRow}): + // `getMetaItem` serves the MetadataService's code definition, so that is + // `effective` here (the code layer above reads the MetadataService + // first), and the stored row stays reported in `overlay` — the residue + // the `/meta` DELETE removes as repair. + // // [#21442] A name a stored container expands (above) takes the expanded // item as its effective layer: the container row in `overlay` is the // layer it derives from, not the value the by-name read serves. const effectiveBase: unknown | null = expandedFrom !== undefined ? expandedFrom.item - : overlay !== null && !this.isShippedFlowName(request.type, request.name) + : overlay !== null && !this.declinesStoredRow(request.type, request.name) ? this.foldObjectExtendersFromRegistry(request.type, request.name, overlay) : code; // [#21738] The lock is the one item-lock resolution's — the @@ -16536,7 +16569,9 @@ export class ObjectStackProtocolImplementation implements * neither merged into the package's slot nor lets it stand in for it — * {@link isStoredFlowEntryOfShippedName} for the registry's list, this * predicate by NAME for a row read from the store, whose own bytes decide - * nothing. Merged, such a row was served under the package's provenance + * nothing ([#21922] the reads ask both through {@link declinesStoredRow} + * and its registry half, which add the code-defined datasource names). + * Merged, such a row was served under the package's provenance * and the automation engine's `kernel:ready` sync armed it over the body * the boot pull had armed: the stored body dispatched while every receipt * named the package. @@ -16576,6 +16611,61 @@ export class ObjectStackProtocolImplementation implements && !isCodeArtifactBody(item); } + /** + * [#21922, ADR-0062 D4, ADR-0126 §3] Is `name` one whose STORED row the + * active reads never adopt, judged by NAME? The one decision the by-name + * read ({@link getMetaItem}), the flattened view + * ({@link readFlattenedMetaItems}, both faces) and the layered read's + * effective layer ({@link getMetaItemLayered}) take before they serve a + * stored row. ⛔ No read carries a copy of it, and it opens no precedence + * path of its own: the row is skipped, and each read falls through to the + * code layer it already reads next. + * + * Exactly two answers, each its own type's, neither re-derived here: + * + * - a FLOW name the loader's set holds ({@link isShippedFlowName}, + * #20913 / #20946 / #21002) — Regime C, "never an overlay read path"; + * the reads serve the loader's entry; + * - a CODE-DEFINED DATASOURCE name ({@link isDeclaredCodeDatasource}: the + * host's code-datasource set, then the installed packages' + * declarations) — "code wins on collision", the datasource-admin + * service's invariant, which the boot restore obeys too. The code + * definition is the MetadataService's in-memory registration, and that + * is the layer the reads serve. A stored row under such a name is never + * a layer of it, only residue ({@link originGatedRemovalRefusal}): it + * stays at rest, the boot restore names it in a warning, and the `/meta` + * DELETE removes it as repair. + * + * ⛔ Never a row's, slot's or body's `origin` — the caller sets it. + * + * Every other type, and a name neither predicate holds for, keeps + * ADR-0005's read order: the stored overlay wins. Each caller scopes it to + * the ACTIVE read, so a draft is answered as a draft. + * + * What it does not move: whether a read FOUND a stored row + * (`storedRowServed`, the fact {@link servedLockState}'s `deletable` + * reads), the `_lock` gate's overlay layer ({@link overlayLockLayerAt}, + * read whether or not the row is adopted), and the DELETE's own row probe. + */ + private declinesStoredRow(type: string, name: unknown): boolean { + if (this.isShippedFlowName(type, name)) return true; + return typeof name === 'string' && name !== '' && this.isDeclaredCodeDatasource(type, name); + } + + /** + * [#21922] The registry half of {@link declinesStoredRow}: a registry entry + * under such a name that is not a code artifact body — the stored row + * {@link hydrateOverlayIntoRegistry} registered under the bare key, + * tenant-marked. For a flow name it answers what + * {@link isStoredFlowEntryOfShippedName} answers. A code-defined datasource + * is never a SchemaRegistry item at all (its code definition is the + * MetadataService's), so every entry it matches is a hydrated row. + */ + private isStoredEntryOfDeclinedName(type: string, item: unknown): boolean { + return this.declinesStoredRow(type, (item as { name?: unknown } | null | undefined)?.name) + && !isCodeArtifactBody(item); + } + /** * [#20761, ADR-0126 §2 / §7.3] THE LOADER'S SET, read: the package that * ships `(type, name)` as a code artifact, or `undefined` when none does. From cebec78046d270df674f9740fcd3f79f95586ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 11:01:02 +0000 Subject: [PATCH 2/5] test(dogfood): the /meta door serves a code datasource's definition while a stored row exists One case in the restore file reads GET /api/v1/meta/datasource/:name and the /meta list over the showcase after a restart with a stored row under showcase_external and default: both serve the code definition, the envelope still offers the repair, and a runtime datasource's row is still served. The sibling file's post-restart read, which pinned the row's label, now expects the code definition. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tasource-restore-code-wins.dogfood.test.ts | 62 ++++++++++++++++--- .../meta-door-code-datasource.dogfood.test.ts | 5 +- 2 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts index 9372c5fecba..457b817e389 100644 --- a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts +++ b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts @@ -33,13 +33,13 @@ // naming the host's database configuration — no `*.datasource.ts` // declares `default`; // - a runtime datasource with no code twin still restores, and one still -// saves through the `/meta` door. -// -// ⚠ Not pinned here, and named rather than hidden: while a stored row exists, -// `GET /api/v1/meta/datasource/:name` serves THAT row — the door reads its -// stored overlay first (ADR-0005's read order), whatever the MetadataService -// holds. `meta-door-code-datasource.dogfood.test.ts` pins that read as it is. -// After the repair below it serves the code definition, in the same boot. +// saves through the `/meta` door; +// - [#21922's metadata-door half] while each row still exists, +// `GET /api/v1/meta/datasource/:name` and the `/meta` list serve the code +// definition, not the row: the reads decline a stored row under a name the +// host registers from code, as they do for a shipped flow name. The row is +// still offered for the repair (`deletable: true`), and a runtime +// datasource's row is still what both doors serve. // // The verify harness composes the datasource-admin service but not its REST // routes, so this file mounts `registerDatasourceAdminRoutes` the way @@ -55,9 +55,10 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -/** The showcase's code-defined datasource. */ +/** The showcase's code-defined datasource, and the package that declares it. */ const EXTERNAL = 'showcase_external'; const EXTERNAL_LABEL = 'External Analytics (SQLite)'; +const SHOWCASE_PACKAGE = 'com.example.showcase'; /** The label and origin the stored rows assert — what an earlier runtime write could leave. */ const SHADOW_LABEL = 'Shadow 21922'; /** A runtime datasource created through the admin door before the restart. */ @@ -123,6 +124,14 @@ describe('[#21922 / #21944] a stored datasource row never displaces a code datas }; return ql.find('sys_metadata', { where: { type: 'datasource', name }, context: SYS }); }; + /** `GET /meta/datasource`: the served list, unwrapped as the shipped-flow list pins unwrap it. */ + const metaList = async (): Promise>> => { + const read = await call('GET', '/meta/datasource'); + expect(read.status, JSON.stringify(read.json)).toBe(200); + const json = read.json as unknown as Record; + const data = (json?.data ?? json) as Record | unknown[]; + return (Array.isArray(data) ? data : (data as { items?: unknown[] })?.items ?? []) as Array>; + }; const adminEntry = async (name: string) => { const listed = await call('GET', '/datasources'); expect(listed.status, JSON.stringify(listed.json)).toBe(200); @@ -258,6 +267,43 @@ describe('[#21922 / #21944] a stored datasource row never displaces a code datas expect(runtimePatch.status, JSON.stringify(runtimePatch.json)).toBe(200); }, 180_000); + it('[#21922] with each row present, the /meta door serves the code definition by name and in its list, and still offers the repair', async () => { + // Both rows are still at rest: this is the read before any repair. + expect(await storedRows(EXTERNAL)).toHaveLength(1); + expect(await storedRows('default')).toHaveLength(1); + + // By name: the code definition, never the row's label, origin or file. + const external = await call('GET', `/meta/datasource/${EXTERNAL}`); + expect(external.status, JSON.stringify(external.json)).toBe(200); + expect(external.json.item).toMatchObject({ origin: 'code', label: EXTERNAL_LABEL, _packageId: SHOWCASE_PACKAGE }); + expect(JSON.stringify(external.json.item)).not.toContain('shadow-external.db'); + const def = await call('GET', '/meta/datasource/default'); + expect(def.status, JSON.stringify(def.json)).toBe(200); + expect(def.json.item).toMatchObject({ origin: 'code' }); + expect(def.json.item?.label).not.toBe(SHADOW_LABEL); + expect(JSON.stringify(def.json.item)).not.toContain('shadow-default.db'); + // The row is still found, so the envelope offers the repair below. + for (const read of [external, def]) { + expect(read.json, JSON.stringify(read.json)).toMatchObject({ editable: false, deletable: true }); + } + + // The list: one entry under each code name, the same code definition. + const listed = await metaList(); + const entries = (name: string) => listed.filter((it) => it?.name === name); + expect(entries(EXTERNAL)).toHaveLength(1); + expect(entries(EXTERNAL)[0]).toMatchObject({ origin: 'code', label: EXTERNAL_LABEL, _packageId: SHOWCASE_PACKAGE }); + expect(entries('default')).toHaveLength(1); + expect(entries('default')[0]).toMatchObject({ origin: 'code', label: def.json.item?.label }); + + // Control: a runtime datasource's row is still what both doors serve. + const runtime = await call('GET', `/meta/datasource/${RUNTIME}`); + expect(runtime.status, JSON.stringify(runtime.json)).toBe(200); + const admin = await adminEntry(RUNTIME); + expect(runtime.json.item).toMatchObject({ origin: 'runtime', label: admin?.label }); + expect(entries(RUNTIME)).toHaveLength(1); + expect(entries(RUNTIME)[0]).toMatchObject({ origin: 'runtime', label: admin?.label }); + }); + it('the /meta DELETE of each row (the repair) removes it, and what the admin door serves does not change', async () => { const before = { external: await adminEntry(EXTERNAL), def: await adminEntry('default') }; diff --git a/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts b/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts index 55e7ea9ceb4..43b7c23d9dc 100644 --- a/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts +++ b/packages/qa/dogfood/test/meta-door-code-datasource.dogfood.test.ts @@ -231,7 +231,10 @@ describe('[#21899] the metadata door answers a code-defined datasource as read-o // fix carries. await restart(); expect(await storedRows(DATASOURCE)).toHaveLength(1); - expect((await servedBody()).label).toBe(SHADOW_LABEL); + // [#21922] The row is at rest, and the read declines it: a stored row under + // a code-defined name is residue, never a layer, so the door serves the + // code definition before any repair. + expect((await servedBody()).label).toBe(CODE_LABEL); const put = refusal(await call('PUT', `/meta/datasource/${DATASOURCE}`, { ...(await servedBody()), label: 'Meta Renamed 21899' })); expect({ status: put.status, code: put.code }).toEqual({ status: 403, code: 'NOT_OVERRIDABLE' }); From 8c4bd140de33628f19525bb809459c19d94fea10 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 11:06:12 +0000 Subject: [PATCH 3/5] test(dogfood): read the /meta door's answer as it is served The REST by-name answer is the served item; its envelope flags are pinned in metadata-protocol's unit suite, and the repair case that follows is the public proof that each row is still removable. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- .../datasource-restore-code-wins.dogfood.test.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts index 457b817e389..5aec6f19865 100644 --- a/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts +++ b/packages/qa/dogfood/test/datasource-restore-code-wins.dogfood.test.ts @@ -37,9 +37,9 @@ // - [#21922's metadata-door half] while each row still exists, // `GET /api/v1/meta/datasource/:name` and the `/meta` list serve the code // definition, not the row: the reads decline a stored row under a name the -// host registers from code, as they do for a shipped flow name. The row is -// still offered for the repair (`deletable: true`), and a runtime -// datasource's row is still what both doors serve. +// host registers from code, as they do for a shipped flow name. The repair +// below still removes each row, and a runtime datasource's row is still +// what both doors serve. // // The verify harness composes the datasource-admin service but not its REST // routes, so this file mounts `registerDatasourceAdminRoutes` the way @@ -267,8 +267,9 @@ describe('[#21922 / #21944] a stored datasource row never displaces a code datas expect(runtimePatch.status, JSON.stringify(runtimePatch.json)).toBe(200); }, 180_000); - it('[#21922] with each row present, the /meta door serves the code definition by name and in its list, and still offers the repair', async () => { - // Both rows are still at rest: this is the read before any repair. + it('[#21922] with each row present, the /meta door serves the code definition by name and in its list', async () => { + // Both rows are still at rest: this is the read before any repair, and the + // repair in the next case still finds and removes each one. expect(await storedRows(EXTERNAL)).toHaveLength(1); expect(await storedRows('default')).toHaveLength(1); @@ -282,10 +283,6 @@ describe('[#21922 / #21944] a stored datasource row never displaces a code datas expect(def.json.item).toMatchObject({ origin: 'code' }); expect(def.json.item?.label).not.toBe(SHADOW_LABEL); expect(JSON.stringify(def.json.item)).not.toContain('shadow-default.db'); - // The row is still found, so the envelope offers the repair below. - for (const read of [external, def]) { - expect(read.json, JSON.stringify(read.json)).toMatchObject({ editable: false, deletable: true }); - } // The list: one entry under each code name, the same code definition. const listed = await metaList(); From 0b2a164598bfed079404d2f55129b30d3d6654ea Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 11:12:21 +0000 Subject: [PATCH 4/5] docs(metadata-protocol): say which reads the published doors follow after the datasource decline isShippedFlowName's docblock names the layered read as deciding its effective layer with this predicate. It now asks it through declinesStoredRow, which also declines a code-defined datasource name's row; the published doors still ask this predicate alone. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 7890c4b99f6..2e0e42d4153 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -16590,6 +16590,13 @@ export class ObjectStackProtocolImplementation implements * effective layer — the loader's body — is what the door serves, and in * every other case the door serves the stored row as before. One decision * point for the three reads; the doors hold no copy of the rule. + * + * [#21922] The layered read asks this predicate through + * {@link declinesStoredRow}, which also declines the stored row of a + * code-defined datasource name. The published doors ask this predicate + * alone, so for such a name they still serve the stored row: the active + * overlay row, as the route's spec describes it. That door is not moved + * here. */ isShippedFlowName(type: string, name: unknown): boolean { if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'flow') return false; From 085c662e121e226037ea68b4d32e4dce4a1add57 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 11:12:51 +0000 Subject: [PATCH 5/5] chore(changeset): metadata-protocol patch for the datasource stored-row decline Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude --- ...tocol-meta-read-declines-code-datasource-row.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/21922-metadata-protocol-meta-read-declines-code-datasource-row.md diff --git a/.changeset/21922-metadata-protocol-meta-read-declines-code-datasource-row.md b/.changeset/21922-metadata-protocol-meta-read-declines-code-datasource-row.md new file mode 100644 index 00000000000..a7fd70272eb --- /dev/null +++ b/.changeset/21922-metadata-protocol-meta-read-declines-code-datasource-row.md @@ -0,0 +1,14 @@ +--- +"@objectstack/metadata-protocol": patch +--- + +The metadata door serves a code-defined datasource's code definition while a stored row under its name still exists + +Clause-②: no + +- `GET /api/v1/meta/datasource/:name`, the `GET /api/v1/meta/datasource` list and the `effective` layer of `GET /api/v1/meta/datasource/:name/layers` now skip a stored `sys_metadata` row under a datasource name the host registers from code: one an installed package declares in `*.datasource.ts`, or the host's `default`. They serve the in-memory code definition instead. The datasource admin door and the boot restore already serve that ("code wins on collision"). Before this change the stored row was served first, so the two doors answered with two different bodies for one name. +- The decision is made by name, through the same predicate the reads already ask for a shipped flow name. It never reads a row's `origin`. Every other type keeps ADR-0005's read order, in which the stored overlay wins. +- Unchanged: the row stays at rest and is still reported in the layered read's `overlay`. The read envelope stays `deletable: true` while the row exists, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. A draft read (`state: 'draft'`, or the draft preview) is still answered from the draft row. A runtime datasource's stored row is served as before. +- The `/meta` and admin doors already refuse to write such a row. This change affects only how a row left from before that refusal is read. +- Not moved: `GET /api/v1/meta/datasource/:name/published` still serves the stored row, which is the active overlay row that route describes. +- ⛔ No public export, signature, schema or accept-set change. The built entry declarations gain two `private` member names on `ObjectStackProtocolImplementation`.