Skip to content

Commit 1abfc58

Browse files
fix(metadata-protocol): the metadata door serves a code-defined datasource's code definition while a stored row exists (#21985)
Fixes #21922 Clause-②: no ## What changes The metadata door no longer serves a stored `sys_metadata` row under a datasource name the host registers from code. While such a row exists, `GET /api/v1/meta/datasource/:name`, the `GET /api/v1/meta/datasource` list and the `effective` layer of `/layers` all serve the in-memory code definition. That is what the admin door and the boot restore already serve. This is the remaining half of this card, after the boot restore and admin door half landed in PR #21965. The change is the shape triage's answer A names (comment 6013780883), the one the shipped-flow read already uses. In `packages/metadata-protocol/src/protocol.ts`: - **One predicate.** `declinesStoredRow(type, name)` asks "is this a name whose stored row the active reads never adopt?". It holds for exactly two kinds of name: a shipped flow name (`isShippedFlowName`, unchanged) and a code-defined datasource name (`isDeclaredCodeDatasource`, unchanged, reading the host's `code-datasource-names` set and then the installed packages). Its registry twin, `isStoredEntryOfDeclinedName`, catches the hydrated bare-key copy of such a row (`!isCodeArtifactBody`). ⛔ No third precedence path, and ⛔ no decision read from any `origin`. - **`getMetaItem`**: the stored-row step skips the row on the ACTIVE read (`storedRowDeclined`). Step 2 then serves the MetadataService's in-memory registration. The row is still FOUND (`storedRowServed`), so `servedLockState` keeps `deletable: true` while it exists. A draft read (`state: 'draft'`, or `previewDrafts`) is answered as a draft. - **The list** (`readFlattenedMetaItems`, both faces): the registry half drops the hydrated copy, and the stored-row half keeps the row out of the merge and out of the package stand-ins. The MetadataService base, merged in below, supplies the code definition. - **`getMetaItemLayered`**: `effective` asks the same predicate. The spec defines `effective` as "the value an ordinary `GET /meta/:type/:name` would return under `item`" (`GetMetaItemLayeredResponseSchema`), so it has to move with the by-name read. The row stays reported in `overlay`. - Every other type keeps ADR-0005's read order. The `/meta` DELETE path is untouched: its own row probe still finds and removes the row. ## Hypotheses (Partition 2), measured at `f76c6221ac` - **H1, confirmed.** `getMetaItem` adopted the row at `protocol.ts:10038` (`findServedOverlayRow`) and `:10047` (`if (record && !shippedFlowActiveRead)`), whatever the MetadataService held. - **H2, confirmed and extended.** The #20946 shape is the by-name stored-row half (`:9952`–`:9978`), the list's registry half (`:8923`–`:8926`) and the list's stored-row half (`:8979`–`:8998`). `isDeclaredCodeDatasource` (`:16326`) reads the host set at `:16328`. Measured addition: the list needs the registry half too. The boot pull (`loadMetaFromDb`) and an unscoped list hydrate the row under the bare key, and the registry layer is merged OVER the MetadataService base (`:9287`). Ablation B below shows that half is load-bearing on its own. `getMetaItem` needs no registry half for datasource, because step 2 answers before step 3. - **H3, confirmed with no new rule.** `servedLockState` (`:16488`) reads `storedRowServed`, which is set from the row FOUND (`:10045`) before the adoption check, so a declined row keeps `deletable: true`. That is exactly what `originGatedRemovalRefusal`'s docblock states ("`deletable` true exactly while the read found a stored row"). `deleteMetaItem` (`:25771`) decides from its own probe and never reads the served body. The `_lock` gate's overlay layer is read whether or not the row is adopted, as for flows. Triage's stop condition is not met. - **H4, confirmed disjoint.** #21967's branch touches `protocol.ts` hunks at `:2003`, `:9051`–`:9107`, `:9610`–`:9715` and `:19206` (read from its pushed branch). None of this PR's sites overlaps them. `origin/main` was merged (`65d7b740b2`); #21967 had not landed. ## Pins - **Unit, `metadata-protocol`** (`protocol.code-defined-datasource-door.test.ts`, a new `[#21922]` block on both kernel shapes, 14 cases): - (a) by name (both spellings), in the list before and after the row is hydrated, `default` through the host set, and the layered `effective`; - (b) the DELETE still removes the row, and the reads serve code after it; - (c) a runtime datasource's row is still served by name, in the list and as `effective`; - (d) the strict draft read and the preview arm serve the draft row. - The block reuses the file's pinned engine double, so the pinned ledger is untouched. That double's `find` now honours `where` and `limit` (it returned every row), and its registry gains an opt-in hydrating slot plus `isPackageDisabled`, which the list calls. - **Dogfood** (`datasource-restore-code-wins.dogfood.test.ts`, one case): after a restart over stored rows under `showcase_external` and `default`, `GET /api/v1/meta/datasource/:name` and the `/meta` list serve the code definition (label, `origin: 'code'`, `_packageId`, and never the row's file). A runtime datasource's row is still what both doors serve. The repair case that follows still removes each row. - **Fixture triage:** `meta-door-code-datasource.dogfood.test.ts` read `SHADOW_LABEL` after its restart. That read pinned exactly the branch this change removes, so it now expects the code label. The restore file's header paragraph saying the meta door serves the row is replaced. ## Reverse verification (on committed HEAD, restore proven by blob) - **Ablation A (unit):** `return typeof name === 'string' && name !== '' && this.isDeclaredCodeDatasource(type, name);` was replaced by `return false; // ABLATION-21922` via `scripts/ablation-replace.mjs` (anchor 1 to 0, blob `7890c4b99f6e` to `6d70364fc822`). **10 failed / 41 passed**: (a)x4 and (d) on both kernels. (b) and (c) stay green, as predicted, because they do not depend on the decline. Restored: blob == HEAD (`7890c4b99f6e`), `git diff HEAD` empty. - **Ablation B (unit, registry half only):** the list's filter reverted to `isStoredFlowEntryOfShippedName`. **4 failed / 47 passed**: exactly the post-hydration list cases (`showcase_external` and `default`) on both kernels. Restored by blob. - **Ablation C (public door, through `dist/`):** the same arm replaced by `return name === 'ABLATION-21922';`, then `pnpm --filter @objectstack/metadata-protocol build`, then `ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATION-21922` (exit 0, marker in dist), then both dogfood files: **3 failed / 9 passed**. The two direct reds are the new case and the flipped read. The third ("after the repair and a restart") is a cascade: the flipped case failed before its DELETE, so the row survived the restart and the ablated read served it. Restore leg: blob == HEAD, rebuild, then `ablation-dist-preflight --absent` (exit 0). ## Readings at head `65d7b740b2` (after the `origin/main` merge) - `pnpm --filter @objectstack/metadata-protocol test`: **218 files passed, 3 skipped; 28010 tests passed, 19 skipped**. `typecheck` (`tsc --noEmit`): exit 0. The package `tsconfig` includes `src/**/*`, so the edited test file is compiled. - Dogfood, both files: **12/12 passed**. - `pnpm --filter @objectstack/spec check:generated`: all 15 artifacts up to date (spec moved on the `main` side of the merge). - `node scripts/pm/dispatch-gates.mjs --commands` (no paths; 5 paths vs merge base `04e776b39`): **68 derived; `--ran`: 68 run, 0 NOT-MEASURED, 0 UNRUN**. `check:dual-build-cjs-loads` first answered `PREREQUISITE NOT MET` (exit 3, eight packages outside the dogfood closure had no `dist/`). It answered exit 0 after those were built, and the record carries that reading. - Artifact-roster block (53 rows, outside the total): 50 exit 0. `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` answered NOT WIRED (exit 2: no PR context existed yet). They are NOT MEASURED here, and the report carries their run against this PR. - Symbol-anchor sweeps: `check:adr-symbol-anchors`, `check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors` and `check:adr-anchors` all exit 0. - **Lint, a proven narrowing** (repo-wide `pnpm lint` is CI's): `eslint --no-inline-config --format json` over the four touched TypeScript files at `65d7b740b2` reports 4 files linted, none ignored, 0 errors and 0 warnings. The config never enables type-aware linting (no `parserOptions.project`, as `eslint.config.mjs` states), so this diff cannot move any untouched file's verdict. ## Changeset, measured against the built entry declarations `@objectstack/metadata-protocol` patch, with `Clause-②: no`. The BASE (`f76c6221ac`) and HEAD builds of `dist/index.d.ts` and `index.d.cts` differ in two non-comment lines only: `private declinesStoredRow;` and `private isStoredEntryOfDeclinedName;` on `ObjectStackProtocolImplementation`. No public member, parameter or return type moves. ## Named gap, not moved here: the `/published` door Measured with a throwaway probe at `8c4bd140de` (never committed): after a restart over a stored row, `GET /api/v1/meta/datasource/showcase_external` serves the code definition, and `/layers` answers `effective` = code with `overlay` = the row. `GET /api/v1/meta/datasource/showcase_external/published` still serves the row (`origin: runtime`). The REST door and its runtime dispatcher twin decide by asking the public `isShippedFlowName` alone. The spec describes that route as serving the active overlay row, so no published text is made false. But following the layered read there needs a public predicate on the exported class, which is a widening this claim's `Clause-②: no` does not cover. It is reported to the seat with its options. ## Acceptance notes - `getMetaItem`'s step-3 registry half stays flow-only. A code datasource is never a SchemaRegistry item, and step 2 has already served it. - A declined row is still hydrated into the registry as the tenant row it is, following the flow precedent. `git grep` finds no reader of registry `datasource` entries outside `protocol.ts`. - Read-only inference, not measured: a name declared by a package installed after boot is code to `isDeclaredCodeDatasource` (manifest) with no in-memory registration. For such a name the reads now fall through to whatever the MetadataService loaders hold, not the row. - Deviation, self-inflicted and recovered: a declaration measurement's restore (`git checkout HEAD -- protocol.ts`) discarded one uncommitted docblock edit. It was detected by a marker count, re-applied and committed as `0b2a164598`. Every reading above was taken after it. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c563af commit 1abfc58

5 files changed

Lines changed: 384 additions & 30 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
The metadata door serves a code-defined datasource's code definition while a stored row under its name still exists
6+
7+
Clause-②: no
8+
9+
- `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.
10+
- 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.
11+
- 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.
12+
- 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.
13+
- Not moved: `GET /api/v1/meta/datasource/:name/published` still serves the stored row, which is the active overlay row that route describes.
14+
- ⛔ No public export, signature, schema or accept-set change. The built entry declarations gain two `private` member names on `ObjectStackProtocolImplementation`.

‎packages/metadata-protocol/src/protocol.code-defined-datasource-door.test.ts‎

Lines changed: 205 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,10 +111,19 @@ function makeSession(opts: {
111111
seed?: Row[];
112112
/** [#21944] The kernel services the protocol resolves — the host's code-datasource set among them. */
113113
services?: Map<string, unknown>;
114+
/**
115+
* [#21922] A registry that keeps what is registered under the bare key and
116+
* lists it, as the real one does, so a stored row the hydrator registers
117+
* (the boot pull, an unscoped list) is read back. Off: the registry lists
118+
* nothing, as the other cases assume.
119+
*/
120+
hydrating?: boolean;
114121
} = {}) {
115122
const rows = new Map<string, Row>();
116123
for (const r of opts.seed ?? []) rows.set(r.id, r);
117124
const historyRows: Array<Record<string, unknown>> = [];
125+
/** [#21922] The bare-key entries a hydrating registry holds, per type. */
126+
const registered = new Map<string, Map<string, Record<string, unknown>>>();
118127

119128
const engine: any = {
120129
async findOne(table: string, o: { where: Record<string, unknown> }) {
@@ -126,10 +135,15 @@ function makeSession(opts: {
126135
for (const row of rows.values()) if (matchesWhere(row as any, o.where)) return row;
127136
return null;
128137
},
129-
async find(table: string) {
138+
async find(table: string, o?: { where?: Record<string, unknown>; limit?: number }) {
130139
if (table === 'sys_metadata_history') return historyRows;
131140
if (table !== 'sys_metadata') return [];
132-
return Array.from(rows.values());
141+
// [#21922] The caller's predicate, as the real engine applies it:
142+
// the list reads one type's ACTIVE rows, so a draft row of the
143+
// same name must not reach it. `check:objectql-double-limit` —
144+
// the caller's bound, applied after the filter.
145+
const matched = Array.from(rows.values()).filter((row) => matchesWhere(row as any, o?.where ?? {}));
146+
return o?.limit === undefined ? matched : matched.slice(0, o.limit);
133147
},
134148
async insert(table: string, data: Record<string, unknown>) {
135149
if (table === 'sys_metadata_history') {
@@ -158,15 +172,21 @@ function makeSession(opts: {
158172
return { deleted: existed ? 1 : 0 };
159173
},
160174
registry: {
161-
registerItem: () => {},
175+
registerItem: (type: string, item: Record<string, unknown>, keyField = 'name') => {
176+
if (!opts.hydrating) return;
177+
if (!registered.has(type)) registered.set(type, new Map());
178+
registered.get(type)!.set(String(item[keyField]), item);
179+
},
162180
registerObject: () => {},
163-
listItems: () => [],
164-
// The served code definition, as the runtime's in-memory
165-
// registration serves it on a read.
166-
getItem: (type: string, name: string) =>
167-
(type === 'datasource' && name === CODE_DS && (opts.packages ?? []).length > 0
181+
listItems: (type: string) => [...(registered.get(type)?.values() ?? [])],
182+
// The real registry answers the bare slot first; with nothing
183+
// registered there, the served code definition, as the runtime's
184+
// in-memory registration serves it on a read.
185+
getItem: (type: string, name: string) => registered.get(type)?.get(name)
186+
?? (type === 'datasource' && name === CODE_DS && (opts.packages ?? []).length > 0
168187
? { ...body(CODE_DS, 'External Analytics (SQLite)'), origin: 'code' }
169188
: undefined),
189+
isPackageDisabled: () => false,
170190
applyNavContributions: (app: unknown) => app,
171191
// A code-defined datasource is never a SchemaRegistry item — the
172192
// artifact-only lookup misses it, exactly as on a booted showcase.
@@ -530,3 +550,180 @@ for (const { label, environmentId } of KERNELS) {
530550
});
531551
});
532552
}
553+
554+
/**
555+
* [#21922] The active reads DECLINE a stored row under a code-defined
556+
* datasource name (triage's answer A, the #20946 shape).
557+
*
558+
* A row the `/meta` door saved before it refused these names, or one an earlier
559+
* runtime write left, sits under a name the host registers from code. The boot
560+
* restore no longer registers it over the code definition, so the
561+
* MetadataService holds the code definition. But the by-name read still served
562+
* the row first (ADR-0005's read order, `findServedOverlayRow`), and the list
563+
* did too, because its stored rows are the higher layer over the
564+
* MetadataService's. Now the one predicate the reads already asked of a shipped
565+
* flow name (`declinesStoredRow`) answers for these names too:
566+
*
567+
* - (a) the by-name read, the list and the layered read's `effective` serve
568+
* the code definition while the row exists — the list also after the row
569+
* was hydrated into the registry's bare slot (the registry half);
570+
* - (b) the row stays FOUND, so `deletable` still offers the repair, and the
571+
* `/meta` DELETE still removes it;
572+
* - (c) a runtime datasource's stored row is served exactly as before;
573+
* - (d) a draft is answered as a draft.
574+
*
575+
* The MetadataService double is the composition's: the in-memory registrations
576+
* the runtime made at boot, read first (`MetadataManager.get` / `list`).
577+
*/
578+
const CODE_LABEL = 'External Analytics (SQLite)';
579+
const DEFAULT_LABEL = 'Host Default 21922';
580+
const SHADOW_LABEL = 'Shadow 21922';
581+
const RUNTIME_ROW_LABEL = 'Runtime 21922 (stored row)';
582+
const DRAFT_LABEL = 'Draft 21922';
583+
584+
/** A row an earlier runtime write left under `name`: it asserts `origin: 'runtime'` and its own file. */
585+
const residueRow = (name: string, overrides: { label?: string; state?: string } = {}): Row => ({
586+
id: `row_residue_${name}_${overrides.state ?? 'active'}`,
587+
type: 'datasource',
588+
name,
589+
organization_id: null,
590+
package_id: null,
591+
state: overrides.state ?? 'active',
592+
metadata: JSON.stringify({
593+
...body(name, overrides.label ?? SHADOW_LABEL),
594+
origin: 'runtime',
595+
config: { filename: `shadow-${name}.db` },
596+
}),
597+
checksum: `sha256_residue_${name}`,
598+
});
599+
600+
/** The `metadata` service: what the runtime registered in memory, read first. */
601+
const metadataServiceOf = (items: Array<Record<string, unknown>>) => {
602+
const byName = new Map(items.map((it) => [String(it.name), it] as const));
603+
const read = (type: string, name: string) => (type === 'datasource' ? byName.get(name) : undefined);
604+
return {
605+
get: async (type: string, name: string) => read(type, name),
606+
getDiagnosed: async (type: string, name: string) => ({ data: read(type, name), degraded: false, errors: [] as string[] }),
607+
list: async (type: string) => (type === 'datasource' ? [...byName.values()] : []),
608+
};
609+
};
610+
611+
/**
612+
* The two code definitions (`AppPlugin`, `DefaultDatasourcePlugin`), and the
613+
* runtime datasource as the restore registered it, under a label its row does
614+
* not carry, so the control can tell which layer answered.
615+
*/
616+
const IN_MEMORY: Array<Record<string, unknown>> = [
617+
{ ...body(CODE_DS, CODE_LABEL), origin: 'code', _packageId: PACKAGE_ID },
618+
{ name: 'default', label: DEFAULT_LABEL, driver: 'sqlite', config: { filename: 'host.db' }, origin: 'code' },
619+
{ ...body(RUNTIME_DS, 'Runtime 21922 (MetadataService copy)'), origin: 'runtime' },
620+
];
621+
622+
for (const { label, environmentId } of KERNELS) {
623+
describe(`[#21922] the active reads decline a stored row under a code-defined datasource name — ${label}`, () => {
624+
beforeEach(resetEnvHatch);
625+
afterEach(resetEnvHatch);
626+
627+
const session = (seed: Row[]) => makeSession({
628+
...(environmentId ? { environmentId } : {}),
629+
packages: [SHOWCASE_PACKAGE],
630+
services: new Map<string, unknown>([
631+
[CODE_NAMES_SERVICE, new Set([CODE_DS, 'default'])],
632+
['metadata', metadataServiceOf(IN_MEMORY)],
633+
]),
634+
seed,
635+
hydrating: true,
636+
});
637+
const byName = (protocol: any, request: Record<string, unknown>) =>
638+
protocol.getMetaItem({ type: 'datasource', ...request });
639+
const listed = async (protocol: any, name: string): Promise<Array<Record<string, any>>> =>
640+
((await protocol.getMetaItems({ type: 'datasource' })).items as Array<Record<string, any>>)
641+
.filter((it) => it.name === name);
642+
/** The boot pull's call (`loadMetaFromDb`): the row, hydrated under the registry's bare key. */
643+
const hydrate = (protocol: any, row: Row) => {
644+
protocol.hydrateOverlayIntoRegistry('datasource', JSON.parse(row.metadata), { organizationId: null });
645+
expect(protocol.engine.registry.getItem('datasource', row.name)?.label).toBe(SHADOW_LABEL);
646+
};
647+
648+
it('(a) by name: the code definition is served while the row exists, and the envelope still offers the repair', async () => {
649+
const { protocol, rows } = session([residueRow(CODE_DS)]);
650+
651+
for (const type of ['datasource', 'datasources']) {
652+
const read = await byName(protocol, { type, name: CODE_DS });
653+
expect(read.item, type).toMatchObject({ origin: 'code', label: CODE_LABEL, _packageId: PACKAGE_ID });
654+
expect(read.item.config, type).toEqual({ filename: `${CODE_DS}.db` });
655+
expect({ editable: read.editable, deletable: read.deletable }, type).toEqual({ editable: false, deletable: true });
656+
}
657+
expect(rows.size).toBe(1);
658+
});
659+
660+
it('(a) in the list: one entry under the name, the code definition, before and after the row is hydrated into the registry', async () => {
661+
const row = residueRow(CODE_DS);
662+
const { protocol } = session([row]);
663+
664+
const before = await listed(protocol, CODE_DS);
665+
hydrate(protocol, row);
666+
const after = await listed(protocol, CODE_DS);
667+
668+
for (const list of [before, after]) {
669+
expect(list.map((it) => it.label)).toEqual([CODE_LABEL]);
670+
expect(list[0]).toMatchObject({ origin: 'code', _packageId: PACKAGE_ID });
671+
}
672+
// The by-name read agrees with the list after the hydration too.
673+
expect((await byName(protocol, { name: CODE_DS })).item.label).toBe(CODE_LABEL);
674+
});
675+
676+
it('(a) the host\'s `default`, which no package declares, the same way', async () => {
677+
const row = residueRow('default');
678+
const { protocol } = session([row]);
679+
680+
expect((await byName(protocol, { name: 'default' })).item).toMatchObject({ origin: 'code', label: DEFAULT_LABEL });
681+
hydrate(protocol, row);
682+
expect((await listed(protocol, 'default')).map((it) => it.label)).toEqual([DEFAULT_LABEL]);
683+
});
684+
685+
it('(a) the layered read: `effective` is the code definition, and the row is still reported in `overlay`', async () => {
686+
const { protocol } = session([residueRow(CODE_DS)]);
687+
688+
const layered = await protocol.getMetaItemLayered({ type: 'datasource', name: CODE_DS });
689+
690+
expect(layered.effective).toMatchObject({ origin: 'code', label: CODE_LABEL });
691+
expect(layered.code).toMatchObject({ origin: 'code', label: CODE_LABEL });
692+
expect(layered.overlay).toMatchObject({ origin: 'runtime', label: SHADOW_LABEL });
693+
expect(layered.overlayScope).toBe('env');
694+
expect({ editable: layered.editable, deletable: layered.deletable }).toEqual({ editable: false, deletable: true });
695+
});
696+
697+
it('(b) the /meta DELETE still removes the row (the repair), and the reads serve the code definition after it', async () => {
698+
const { protocol, rows } = session([residueRow(CODE_DS)]);
699+
700+
const res = await protocol.deleteMetaItem({ type: 'datasource', name: CODE_DS });
701+
702+
expect(res).toMatchObject({ success: true, reset: true });
703+
expect(rows.size).toBe(0);
704+
const read = await byName(protocol, { name: CODE_DS });
705+
expect(read.item).toMatchObject({ origin: 'code', label: CODE_LABEL });
706+
expect(read.deletable).toBe(false);
707+
expect((await listed(protocol, CODE_DS)).map((it) => it.label)).toEqual([CODE_LABEL]);
708+
});
709+
710+
it('(c) control: a runtime datasource\'s stored row is still served, by name and in the list', async () => {
711+
const row = residueRow(RUNTIME_DS, { label: RUNTIME_ROW_LABEL });
712+
const { protocol } = session([row]);
713+
714+
expect((await byName(protocol, { name: RUNTIME_DS })).item).toMatchObject({ label: RUNTIME_ROW_LABEL });
715+
expect((await listed(protocol, RUNTIME_DS)).map((it) => it.label)).toEqual([RUNTIME_ROW_LABEL]);
716+
const layered = await protocol.getMetaItemLayered({ type: 'datasource', name: RUNTIME_DS });
717+
expect(layered.effective).toMatchObject({ label: RUNTIME_ROW_LABEL });
718+
});
719+
720+
it('(d) a draft is answered as a draft: the strict draft read and the preview arm serve the draft row', async () => {
721+
const { protocol } = session([residueRow(CODE_DS), residueRow(CODE_DS, { state: 'draft', label: DRAFT_LABEL })]);
722+
723+
expect((await byName(protocol, { name: CODE_DS, state: 'draft' })).item).toMatchObject({ label: DRAFT_LABEL });
724+
expect((await byName(protocol, { name: CODE_DS, previewDrafts: true })).item).toMatchObject({ label: DRAFT_LABEL, _draft: true });
725+
// The active read beside them still serves the code definition.
726+
expect((await byName(protocol, { name: CODE_DS })).item).toMatchObject({ label: CODE_LABEL });
727+
});
728+
});
729+
}

0 commit comments

Comments
 (0)