Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/21986-metadata-protocol-declines-stored-row-public.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@objectstack/metadata-protocol": minor
---

`ObjectStackProtocolImplementation.declinesStoredRow(type, name)` is now public, so a door that serves a stored row out of the layered read can ask the same decision the reads make

Clause-②: yes (widening)

- The method answers `true` for exactly the names whose stored `sys_metadata` row the active reads (`getMetaItem`, the list, and the `effective` layer of `getMetaItemLayered`) do not adopt: a flow name a managed package ships (the answer `isShippedFlowName` gives), and a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`). Every other type and name answers `false`.
- The `GET /meta/:type/:name/published` doors in `@objectstack/rest` and `@objectstack/runtime` now ask this method in place of `isShippedFlowName`. A door asks it, and does not restate either half or the host's code-datasource set.
- `isShippedFlowName` stays public and unchanged.
- The only change to the public surface is this one added method. No signature, schema or accept set changes, and no behaviour of this package changes.
10 changes: 10 additions & 0 deletions .changeset/21986-rest-published-door-code-datasource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/rest": patch
---

`GET /api/v1/meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists

Clause-②: no

- For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the published door now serves the layered read's `effective` layer, which is the code definition. Before this change it served the leftover stored `sys_metadata` row, with that row's label, `origin` and connection settings, while `GET /api/v1/meta/datasource/:name`, the `/meta/datasource` list and `/layers` all served the code definition. The door now asks the protocol's `declinesStoredRow`, the one decision those reads make, in place of `isShippedFlowName`. A shipped flow name is answered as before.
- Unchanged: a runtime datasource's stored row is still what the door serves, and so is every stored row of every other type. A protocol that does not provide `declinesStoredRow` still gets the stored row. The row itself stays at rest, `/layers` still reports it in `overlay`, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair.
10 changes: 10 additions & 0 deletions .changeset/21986-runtime-published-door-code-datasource.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/runtime": patch
---

The runtime dispatcher's `GET /meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists

Clause-②: no

- This is the dispatcher twin of the `@objectstack/rest` published door, and it now answers the same way. For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the door serves the layered read's `effective` layer, which is the code definition, instead of the leftover stored row. It asks the protocol's `declinesStoredRow` in place of `isShippedFlowName`. A shipped flow name is answered as before.
- Unchanged: a runtime datasource's stored row, and every stored row of every other type, is served as before. So is every row when the protocol does not provide `declinesStoredRow`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#21986] `declinesStoredRow` is PUBLIC on `ObjectStackProtocolImplementation`,
* so the published-snapshot doors (`GET /meta/:type/:name/published`, the REST
* route and its dispatcher twin) can ask it instead of `isShippedFlowName`.
*
* What a door relies on, pinned here once: the one predicate answers BOTH name
* classes whose stored row the active reads decline, and nothing else.
*
* - a FLOW name the loader's set holds (`isShippedFlowName`'s answer);
* - a CODE-DEFINED DATASOURCE name: one an installed package declares, or one
* the host registers from code (`code-datasource-names`, here `default`).
*
* Every other name answers false: a flow no package ships, a runtime
* datasource, the same name under another type, a missing or empty name.
*
* The calls below go through the class's own declared type, never an `any`
* cast, so `tsc --noEmit` over this file (the package's `typecheck` includes
* `src/**`) fails if the member stops being public.
*/
import { describe, expect, it } from 'vitest';
import { ObjectStackProtocolImplementation } from './protocol.js';

const FLOW_PACKAGE = 'com.example.pkg';
const SHIPPED_FLOW = 'pkg_flow';
const CUSTOMER_FLOW = 'customer_flow';
const CODE_DS = 'showcase_external';
const HOST_DS = 'default';
const RUNTIME_DS = 'rt_datasource_21986';

/**
* A partial registry: the loader's entry for {@link SHIPPED_FLOW} carries its
* package id (the shape `lookupArtifactItem` reads off a registry with no
* `getArtifactItem`), and one installed package declares {@link CODE_DS}. The
* services registry carries the host's code-datasource set.
*/
function makeProtocol(): ObjectStackProtocolImplementation {
const loaderEntry = { name: SHIPPED_FLOW, label: 'Loader', _packageId: FLOW_PACKAGE };
const engine = {
registry: {
getItem: (type: string, name: string) =>
(type === 'flow' || type === 'flows') && name === SHIPPED_FLOW ? loaderEntry : undefined,
getAllPackages: () => [{
manifest: { id: 'com.example.showcase', datasources: [{ name: CODE_DS, driver: 'sqlite', config: {} }] },
}],
},
};
const services = new Map<string, unknown>([['code-datasource-names', new Set([HOST_DS])]]);
return new ObjectStackProtocolImplementation(engine as never, () => services);
}

describe('[#21986] the published predicate: declinesStoredRow answers both name classes, and only those', () => {
it('a shipped flow name, in either type spelling — what isShippedFlowName answers', () => {
const protocol = makeProtocol();
for (const type of ['flow', 'flows']) {
expect(protocol.isShippedFlowName(type, SHIPPED_FLOW), type).toBe(true);
expect(protocol.declinesStoredRow(type, SHIPPED_FLOW), type).toBe(true);
}
});

it('a code-defined datasource name: one a package declares, and one the host registers from code', () => {
const protocol = makeProtocol();
for (const type of ['datasource', 'datasources']) {
expect(protocol.declinesStoredRow(type, CODE_DS), type).toBe(true);
expect(protocol.declinesStoredRow(type, HOST_DS), type).toBe(true);
// Not a flow answer: the datasource half is its own.
expect(protocol.isShippedFlowName(type, CODE_DS), type).toBe(false);
}
});

it('control: every other name keeps its stored row', () => {
const protocol = makeProtocol();
expect(protocol.declinesStoredRow('flow', CUSTOMER_FLOW)).toBe(false);
expect(protocol.declinesStoredRow('datasource', RUNTIME_DS)).toBe(false);
// The same names under another type.
expect(protocol.declinesStoredRow('object', CODE_DS)).toBe(false);
expect(protocol.declinesStoredRow('object', HOST_DS)).toBe(false);
expect(protocol.declinesStoredRow('view', SHIPPED_FLOW)).toBe(false);
// A name that is not one.
for (const name of [undefined, null, '', 42]) {
expect(protocol.declinesStoredRow('datasource', name), String(name)).toBe(false);
expect(protocol.declinesStoredRow('flow', name), String(name)).toBe(false);
}
});
});
19 changes: 14 additions & 5 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16746,10 +16746,9 @@ export class ObjectStackProtocolImplementation implements
*
* [#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.
* code-defined datasource name. [#21986] The published doors ask
* {@link declinesStoredRow} in its place, so for such a name they serve
* the code definition too.
*/
isShippedFlowName(type: string, name: unknown): boolean {
if ((PLURAL_TO_SINGULAR[type] ?? type) !== 'flow') return false;
Expand Down Expand Up @@ -16806,8 +16805,18 @@ export class ObjectStackProtocolImplementation implements
* (`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.
*
* [#21986] PUBLIC so a door that serves a stored row out of the layered
* read can ASK it, never re-derive it: `GET /meta/:type/:name/published`
* (the REST route and its dispatcher twin) reads {@link getMetaItemLayered}
* and, when a stored row is present and this predicate holds for the
* answer's `type` and `name`, serves the `effective` layer (the loader's
* body, or the code definition) instead of the row. Every other stored row
* is served as before. A door asks this method alone: ⛔ no door restates
* either half, or the host's code-datasource set. A write door does not
* ask it; the writes keep their own refusals.
*/
private declinesStoredRow(type: string, name: unknown): boolean {
declinesStoredRow(type: string, name: unknown): boolean {
if (this.isShippedFlowName(type, name)) return true;
return typeof name === 'string' && name !== '' && this.isDeclaredCodeDatasource(type, name);
}
Expand Down
113 changes: 110 additions & 3 deletions packages/rest/src/meta-published-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,11 +586,14 @@ describe('[#21002] the published door follows the layered read for a shipped flo
it('control: a protocol that brings no such predicate keeps today\'s answer, the stored row', async () => {
const { engine, metadata, protocol } = shippedHarness();
await storeActiveRow(engine, 'flow', SHIPPED, flowBody(SHIPPED, 'STORED'));
// The same protocol with the predicate hidden from the door only; its
// own methods keep calling it on the real instance.
// The same protocol with the predicate the door asks hidden from the
// door only; its own methods keep calling it on the real instance.
// [#21986] That predicate is `declinesStoredRow`. `isShippedFlowName`
// stays visible, so this also pins that the door does not fall back
// to it: a protocol without the one predicate gets the stored row.
const withoutPredicate = new Proxy(protocol, {
get(target, key) {
if (key === 'isShippedFlowName') return undefined;
if (key === 'declinesStoredRow') return undefined;
const value = Reflect.get(target, key);
return typeof value === 'function' ? value.bind(target) : value;
},
Expand All @@ -602,3 +605,107 @@ describe('[#21002] the published door follows the layered read for a shipped flo
expect(res.body).toMatchObject({ name: SHIPPED, label: 'STORED' });
}, 60_000);
});

/**
* [#21986] The other name class whose stored row the layered read declines: a
* CODE-DEFINED DATASOURCE name (here one an installed package declares). The
* by-name read, the list and the layered read's effective layer serve the code
* definition, the MetadataService's registration, and the stored row is
* residue, still reported in `overlay`. This door asks the protocol's one
* predicate for both name classes, `declinesStoredRow`, with the answer's own
* `type` / `name`, so it serves that effective layer too. A runtime
* datasource's stored row is still served.
*
* The cold-boot reading over the real showcase composition is recorded on the
* PR; the predicate's two halves are pinned in `metadata-protocol`.
*/
describe('[#21986] the published door serves a code-defined datasource\'s code definition over a stored row', () => {
const CODE_DS = 'showcase_external';
const RUNTIME_DS = 'rt_datasource_21986';
const CODE_LABEL = 'External Analytics (SQLite)';
const SHADOW_LABEL = 'Shadow 21986';
const dsBody = (name: string, label: string, origin: 'code' | 'runtime', filename: string) => ({
name, label, driver: 'sqlite', config: { filename }, origin,
});

/**
* The file's engine double, with an installed package that declares
* {@link CODE_DS}, and the MetadataService holding the code definition the
* runtime registers at boot (and a copy of the runtime datasource under a
* label its row does not carry, so the control can tell which layer answered).
*/
async function datasourceHarness() {
const { engine, rows } = makeStubEngine();
engine.registry = {
registerItem: () => {},
registerObject: () => {},
getPackage: () => undefined,
getItem: () => undefined,
getAllPackages: () => [{
manifest: {
id: 'com.example.showcase',
datasources: [{ name: CODE_DS, label: CODE_LABEL, driver: 'sqlite', config: { filename: 'x.db' } }],
},
}],
};
const metadata = new MetadataManager({});
await metadata.register('datasource', CODE_DS, dsBody(CODE_DS, CODE_LABEL, 'code', `${CODE_DS}.db`));
await metadata.register('datasource', RUNTIME_DS, dsBody(RUNTIME_DS, 'Runtime (MetadataService copy)', 'runtime', 'rt.db'));
const protocol = makeProtocol(engine, metadata);
return { engine, rows, metadata, protocol };
}

async function storeActiveRow(engine: any, name: string, body: unknown) {
await engine.insert('sys_metadata', {
type: 'datasource', name, organization_id: null, package_id: null, state: 'active',
metadata: JSON.stringify(body), checksum: 'sha256:stored', version: 1,
});
}

/**
* {@link setup}, read by a caller the `/meta` doors admit to a datasource
* read: `datasource` reads require `manage_platform_settings`, the
* capability the datasource admin door requires.
*/
function setupAsPlatformAdmin(protocol: unknown, metadata: unknown) {
const rest = setup(protocol, metadata);
(rest as any).resolveExecCtx = async () => ({ userId: 'u_platform', systemPermissions: ['manage_platform_settings'] });
return rest;
}

it('a stored row under a code-defined datasource name: the door answers the code definition, the layered effective layer', async () => {
const { engine, rows, metadata, protocol } = await datasourceHarness();
await storeActiveRow(engine, CODE_DS, dsBody(CODE_DS, SHADOW_LABEL, 'runtime', 'shadow-external.db'));
expect(rows.size).toBe(1);

// The decision this door follows: a stored layer IS present, and the
// layered read put the code definition over it.
const layered: any = await protocol.getMetaItemLayered({ type: 'datasource', name: CODE_DS });
expect(layered.overlay).toMatchObject({ origin: 'runtime', label: SHADOW_LABEL });
expect(layered.effective).toMatchObject({ origin: 'code', label: CODE_LABEL });

const rest = setupAsPlatformAdmin(protocol, metadata);
for (const type of ['datasource', 'datasources']) {
const res = await callPublished(rest, { type, name: CODE_DS });

expect(res.statusCode, type).toBe(200);
expect(res.body, type).toMatchObject({ name: CODE_DS, origin: 'code', label: CODE_LABEL });
expect(JSON.stringify(res.body), type).not.toContain('shadow-external.db');
expect(res.body, type).toEqual(layered.effective);
}
// The row stays at rest: the door read past it, nothing removed it.
expect(rows.size).toBe(1);
}, 60_000);

it('control: a runtime datasource\'s stored row is still what the door serves', async () => {
const { engine, metadata, protocol } = await datasourceHarness();
await storeActiveRow(engine, RUNTIME_DS, dsBody(RUNTIME_DS, 'Runtime (stored row)', 'runtime', 'rt.db'));

const layered: any = await protocol.getMetaItemLayered({ type: 'datasource', name: RUNTIME_DS });
const res = await callPublished(setupAsPlatformAdmin(protocol, metadata), { type: 'datasource', name: RUNTIME_DS });

expect(res.statusCode).toBe(200);
expect(res.body).toMatchObject({ name: RUNTIME_DS, label: 'Runtime (stored row)' });
expect(res.body).toEqual(layered.overlay);
}, 60_000);
});
30 changes: 18 additions & 12 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8621,24 +8621,30 @@ export class RestServer {
: {}),
});
if (layered?.overlay !== undefined && layered?.overlay !== null) {
// [#21002, ADR-0126 §2] When the layered
// read put the LOADER's body over this stored
// row — a shipped flow name, decided by the
// protocol's `isShippedFlowName` — this door
// serves that effective layer, not the row:
// `flow` is Regime C, "never an overlay read
// path". The predicate is ASKED of its owner
// with the answer's own `type` / `name`,
// never re-derived here, so this door and
// [#21002, #21986, ADR-0126 §2, ADR-0062 D4]
// When the layered read put a code layer
// over this stored row, this door serves
// that effective layer, not the row. The
// protocol decides it with one predicate,
// `declinesStoredRow`, for both name
// classes: a shipped flow name (the
// loader's body; `flow` is Regime C,
// "never an overlay read path") and a
// code-defined datasource name (the code
// definition; "code wins on collision").
// The predicate is ASKED of its owner with
// the answer's own `type` / `name`, never
// re-derived here, so this door, the
// by-name read, the list and
// `getMetaItemLayered` read one rule. Every
// other stored row is served exactly as
// before — an `object` too, whose effective
// layer differs from its row by folding, not
// by this decision — and so is every row of a
// protocol that brings no such predicate.
const shippedFlow: { isShippedFlowName?(type: string, name: unknown): boolean } = publishedProtocol;
publishedOverlay = typeof shippedFlow.isShippedFlowName === 'function'
&& shippedFlow.isShippedFlowName(layered.type, layered.name)
const decliner: { declinesStoredRow?(type: string, name: unknown): boolean } = publishedProtocol;
publishedOverlay = typeof decliner.declinesStoredRow === 'function'
&& decliner.declinesStoredRow(layered.type, layered.name)
? layered.effective
: layered.overlay;
}
Expand Down
Loading
Loading