Skip to content

Commit 7db1697

Browse files
fix(spec): os migrate meta --stored lists a stored object bound external to a stored or registered managed datasource (#25299)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_ Fixes #25279 Clause-②: yes (widening) Metadata-protocol: none The stored-shape listing for `object-external-binding-managed-datasource-refused` resolved an object's named datasource only from the stack being converted. `os migrate meta --stored` hands the chain one stored row at a time, so for a stored object row the listing could judge only a binding on the primary datasource (`'default'`). The object save door from #25257 also refuses a binding on a datasource stored or registered beside the object, so the pass missed exactly the rows whose next save is refused. This takes the card's first branch: the pass hands down the datasource the door would read, and the detector judges it through the same predicate. - **One read for both:** a new private `externalBindingDatasourceDefinition(name)` in `protocol.ts` is the `readMetaItem({ type: 'datasource', name })` read that `externalBindingSaveRefusal` already did inline (the environment's stored row, else the definition the code layer registers). The object half of the save door now calls it, and so does the pass, so the two cannot judge a binding against different datasources. - **The pass:** `migrateStoredMetadata`, for each non-flow row, asks `storedRowKnownDatasources(singular, body)`. For an `object` row with `external` and a named `datasource`, it resolves that one datasource and passes it down as a one-entry map through `convertStoredItemDetailed` → `applyConversionsToStoredItem`. Any other row passes nothing. A failed read propagates, as it does at the door. - **`@objectstack/spec`:** an optional `ApplyConversionsOptions.knownDatasources`, a read-only map from datasource name to that datasource's definition (only its `schemaMode` is read), which `StoredConversionOptions` inherits. `applyConversions` hands it to `reportRefusedStoredShapes` as a `RefusedShapeContext`, so only the refused-shape listing reads it (only with an `onTodo` sink). The `external` detector reads the stack's own `datasources` first, then the map. `refused-shapes.ts` stays internal (not re-exported). - **`surface` string** now reads "…on the primary datasource, or on a managed one the stack declares or the caller resolves (a stored or registered datasource)". ## Measure first (base `c9be979dd`) 1. **The save door** (`externalBindingSaveRefusal`, object half): one read, `readMetaItem({ type: 'datasource', name })`. That read takes the environment's active stored row first and falls back to the code layer's definition (the MetadataService registration, then the registry). A code-defined datasource name declines a stored row (`declinesStoredRow`), so the code definition wins there. 2. **The pass** (`migrateStoredMetadata`): it reads every `active` and `draft` `sys_metadata` row in two queries before the loop. So the stored datasource rows are in reach before any object row is converted. The pass now uses the door's own read for each object row, so registered datasources and the code-wins rule are covered too. 3. **Census of `onTodo` callers:** `applyConversionsToStoredItem` is called with a sink only by `convertStoredItemDetailed` (the pass). The other sink, `canonicalizeStoredFlow` in `service-automation`, goes through `applyConversionsToFlow` over `{ flows: [...] }`, which the `external` detector never reads. Neither changes behaviour without the new option. ## Tests - `packages/spec/src/conversions/refused-shapes.test.ts` (+7): a stored row on a known datasource declared `'managed'`, or with no `schemaMode`, is listed once and not rewritten. A known `'external'` or `'validate-only'` datasource, or one the row does not name, is not listed. A datasource the stack declares is read before the caller's. Without an `onTodo` sink the map is never read. - `packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts` (+6). An object row is planted straight into the `sys_metadata` double (stored before the door existed), and then `migrateStoredMetadata` runs in preview. Pinned cases: - on a stored managed datasource (both shapes), the row is listed `skipped` with this entry's TODO at `objects[0].external`, and the same body offered to `saveMetaItem` is refused `422 INVALID_METADATA` at `external`; - on a registered managed datasource, the row is listed; - when a stored federated row overlays a registered managed definition, the row is not listed and the save goes through; - on a federated stored datasource, or one no layer holds, the row is not listed (the card's control). - **Full runs:** `@objectstack/spec` test 708 files / 21527 tests passed, typecheck exit 0. `@objectstack/metadata-protocol` test 258 files / 20921 tests passed, typecheck exit 0. - **Ablation, protocol half:** the pass's `await this.storedRowKnownDatasources(singular, body)` was replaced with `undefined` via `scripts/ablation-replace.mjs` (anchor 1 → 0, blob `919137f3` → `cad58c17`). Result: 3 failed / 25 passed, with the three listing pins red and every control green. This is the base behaviour of the pass. Restore proven: blob == HEAD `919137f3`, `git diff HEAD` empty. `@objectstack/spec` resolved to a `dist/` built from this branch. - **Ablation, spec half:** the detector's `?? context.knownDatasources?.get(name)` was dropped (anchor 1 → 0, blob `a387f79e` → `39524e03`). Result: 2 failed / 60 passed, the two listing pins red. Restore proven: blob == HEAD `a387f79e`, `git diff HEAD` empty. The test imports `src` relatively. ## Gates All local readings were taken on head `aa0ca3a33` (the final commit, `origin/main` `49185cdb8` merged in). - **Derived subset:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 88 families. `--ran` reconciliation: "88 derived famil(ies) accounted for — 87 run, 1 NOT-MEASURED". 87 exit 0. `pnpm check:dual-build-cjs-loads` exits 3 (PREREQUISITE NOT MET: it loads every package's `dist`, and this worktree built only the closure), so it is NOT MEASURED locally and left to CI. - **Two re-runs after their prerequisites:** on the first pass `check-plugin-teardown-shape.mjs --self-test` (shallow history; the named commit was fetched) and `pnpm check:lean-entry-closure` (`@objectstack/objectql` not yet built) exited 3. Both exit 0 on re-run. - `pnpm --filter @objectstack/spec check:generated`: "All 14 generated artifacts are up to date", against a `dist` built from this branch without `OS_SKIP_DTS`. - CI families: this PR's check-runs. They were in progress when it opened. ## Acceptance notes - **The datasource side is listed only through its objects.** The datasource save door also refuses re-saving a stored datasource as managed while objects bind it `external`. The pass lists the pair at each object row, not at the datasource row. Either fix the owner chooses (federate the datasource, or drop `external`) clears both. A datasource-row TODO would need its own D3 id and was not asked for. - **Draft object rows** are judged against the active datasource, as the door judges a draft save. Organization-scoped rows are skipped before this step, as before. - **One read per object row with `external`**, not cached across rows. The read is the door's own, and an `apply` run may rewrite rows between two object rows. - **The semantic entry's acceptance criteria** already said `os migrate meta --stored` lists each such row. That sentence is now true for stored and registered datasources, so the entry is unchanged and the second branch's guidance edit is not needed. - **File surface:** `packages/spec/src/conversions/apply.ts` was added to the claim's declared surface. The option has to pass through `applyConversions` to reach the detector. The seat was told before the edit. --- _Generated by [Claude Code](https://claude.ai/code/session_01P6EWuSSDC4q1RNpSTNbyCU)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5801a4f commit 7db1697

7 files changed

Lines changed: 263 additions & 17 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/metadata-protocol': patch
4+
---
5+
6+
fix(spec): `os migrate meta --stored` lists a stored object bound `external` to a stored or registered managed datasource
7+
8+
Clause-②: yes (widening)
9+
10+
Metadata-protocol: none
11+
12+
The stored-shape listing for `object-external-binding-managed-datasource-refused` resolved an object's named datasource only from the stack being converted. A stored object row reaches the conversion chain alone, so the listing judged only a binding on the primary datasource (`'default'`), while the object save door also refuses one on a datasource stored or registered beside the object. The stored-metadata pass therefore did not list a stored object row bound `external` to a managed datasource, the row whose next save is refused with `422 INVALID_METADATA`.
13+
14+
- **Now:** for each stored `object` row carrying `external` and naming a datasource, the pass reads that datasource the way the object save door does (the environment's stored row, else the definition the code layer registers; both go through one read) and hands it to the chain. The row is listed under `rows[].todos` with this entry's id exactly when that door refuses its next save. A datasource declared `schemaMode: 'external'` or `'validate-only'`, and a datasource no layer holds, give no listing, as at the door. Nothing is rewritten.
15+
- **`@objectstack/spec`:** `ApplyConversionsOptions` (and so `StoredConversionOptions` for `applyConversionsToStoredItem`) gains an optional `knownDatasources`, a map of datasource definitions by name that the caller's door can see beyond the stack. Only the refused-shape listing reads it, so only when an `onTodo` sink is passed. A datasource the stack declares is read from the stack first. Without it, behaviour is unchanged.

‎packages/metadata-protocol/src/protocol.external-binding-named-datasource-refusal.test.ts‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,3 +327,76 @@ describe('[#25211] controls — what the doors do not refuse', () => {
327327
expect((await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh') })).success).toBe(true);
328328
});
329329
});
330+
331+
/**
332+
* [#25279] `os migrate meta --stored` lists the object rows the object save
333+
* door above refuses: a stored object bound `external` to a stored or
334+
* registered managed datasource. The row is planted as stored before that door
335+
* existed (straight into the table), then the pass is run in preview, and the
336+
* same body is offered to the door, so the listing and the refusal are read
337+
* off one environment.
338+
*/
339+
describe('[#25279] the stored-migration pass lists the object rows that door refuses', () => {
340+
const plantObjectRow = (rows: Map<string, Row>, body: Record<string, unknown>) => {
341+
const id = `r_${rows.size + 1}`;
342+
rows.set(id, {
343+
id, type: 'object', name: String(body.name), state: 'active', package_id: null,
344+
metadata: JSON.stringify(body),
345+
});
346+
};
347+
const LISTED_ID = 'object-external-binding-managed-datasource-refused';
348+
const listedRows = (report: any) =>
349+
report.rows
350+
.filter((r: any) => r.todos.some((t: any) => t.conversionId === LISTED_ID))
351+
.map((r: any) => `${r.type}/${r.name}|${r.outcome}`);
352+
353+
for (const [shape, dsExtra] of MANAGED_SHAPES) {
354+
it(`a stored object on a stored datasource with ${shape} is listed, and its next save is refused`, async () => {
355+
const { protocol, rows } = makeSession();
356+
await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', dsExtra) });
357+
plantObjectRow(rows, object('wh_order'));
358+
359+
const report = await protocol.migrateStoredMetadata({});
360+
361+
expect(listedRows(report)).toEqual(['object/wh_order|skipped']);
362+
const todo = report.rows.find((r: any) => r.name === 'wh_order').todos.find((t: any) => t.conversionId === LISTED_ID);
363+
expect(todo.path).toBe('objects[0].external');
364+
expect(todo.reason).toContain(managedFinding('wh_order'));
365+
const err = await refusalOf(protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') }));
366+
expectRefusal(err, 'object/wh_order', 'external', ['wh_order']);
367+
});
368+
}
369+
370+
it('a stored object on a datasource the code layer registers as managed is listed the same way', async () => {
371+
const { protocol, rows } = makeSession({ registryDatasources: { wh: datasource('wh', { schemaMode: 'managed' }) } });
372+
plantObjectRow(rows, object('wh_order'));
373+
374+
expect(listedRows(await protocol.migrateStoredMetadata({}))).toEqual(['object/wh_order|skipped']);
375+
});
376+
377+
it("the environment's stored datasource row is the one judged, over the registered definition", async () => {
378+
const { protocol, rows } = makeSession({ registryDatasources: { wh: datasource('wh', { schemaMode: 'managed' }) } });
379+
await protocol.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) });
380+
plantObjectRow(rows, object('wh_order'));
381+
382+
const report = await protocol.migrateStoredMetadata({});
383+
384+
expect(listedRows(report)).toEqual([]);
385+
expect(report.canonical).toBeGreaterThanOrEqual(1);
386+
await expect(protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: object('wh_order') })).resolves.toBeDefined();
387+
});
388+
389+
it.each([
390+
['a federated stored datasource', (p: any) => p.saveMetaItem({ type: 'datasource', name: 'wh', item: datasource('wh', FEDERATED) })],
391+
['a datasource no layer holds', async () => {}],
392+
])('a stored object on %s is not listed', async (_label, seed) => {
393+
const { protocol, rows } = makeSession();
394+
await seed(protocol);
395+
plantObjectRow(rows, object('wh_order'));
396+
397+
const report = await protocol.migrateStoredMetadata({});
398+
399+
expect(listedRows(report)).toEqual([]);
400+
expect(report.rows.filter((r: any) => r.name === 'wh_order')).toEqual([]);
401+
});
402+
});

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6817,11 +6817,15 @@ export class ObjectStackProtocolImplementation implements
68176817
* site as stored and emits NO notice for it, so "no notices" alone means
68186818
* "nothing to persist", never "on protocol": {@link migrateStoredMetadata}
68196819
* reads `todos` to tell the two apart. They change nothing in `item`.
6820+
*
6821+
* [#25279] `knownDatasources` is handed to the chain for the refused-shape
6822+
* listing ({@link storedRowKnownDatasources}); it changes nothing either.
68206823
*/
68216824
private convertStoredItemDetailed(
68226825
type: string,
68236826
data: unknown,
68246827
onNotice?: (notice: ConversionNotice) => void,
6828+
knownDatasources?: ReadonlyMap<string, { readonly schemaMode?: unknown }>,
68256829
): { item: unknown; notices: ConversionNotice[]; todos: ConversionTodoNotice[] } {
68266830
const singular = PLURAL_TO_SINGULAR[type] ?? type;
68276831
if (singular === 'flow') return { item: data, notices: [], todos: [] };
@@ -6835,10 +6839,37 @@ export class ObjectStackProtocolImplementation implements
68356839
onTodo: (t) => {
68366840
todos.push(t);
68376841
},
6842+
...(knownDatasources ? { knownDatasources } : {}),
68386843
});
68396844
return { item, notices, todos };
68406845
}
68416846

6847+
/**
6848+
* [ADR-0015 §4.2 · #25279] The datasource a stored `object` row's
6849+
* `external` binding names, as {@link externalBindingSaveRefusal} would
6850+
* read it at that row's next save ({@link externalBindingDatasourceDefinition}),
6851+
* for {@link migrateStoredMetadata}'s refused-shape listing.
6852+
*
6853+
* A stored object row reaches the conversion chain alone, so the listing's
6854+
* `external` detector could answer only the primary half (`'default'`)
6855+
* while the save door also refuses a binding on a stored or registered
6856+
* managed datasource. Handed down here, the listing names the row exactly
6857+
* when that door refuses it. `undefined` for any other row, a row with no
6858+
* `external`, and a datasource no layer holds (no verdict, as at the door).
6859+
* A read that fails propagates, as it does at the door.
6860+
*/
6861+
private async storedRowKnownDatasources(
6862+
singularType: string,
6863+
body: unknown,
6864+
): Promise<ReadonlyMap<string, { readonly schemaMode?: unknown }> | undefined> {
6865+
if (singularType !== 'object' || !body || typeof body !== 'object' || Array.isArray(body)) return undefined;
6866+
const { external, datasource } = body as { external?: unknown; datasource?: unknown };
6867+
if (external === undefined || external === null) return undefined;
6868+
if (typeof datasource !== 'string' || datasource === '') return undefined;
6869+
const definition = await this.externalBindingDatasourceDefinition(datasource);
6870+
return definition ? new Map([[datasource, definition]]) : undefined;
6871+
}
6872+
68426873
/**
68436874
* Resolve a flow canonicalizer from the live services registry (#4498).
68446875
*
@@ -22553,11 +22584,8 @@ export class ObjectStackProtocolImplementation implements
2255322584
const datasource = typeof body.datasource === 'string' && body.datasource !== '' ? body.datasource : undefined;
2255422585
const definition = datasource === undefined
2255522586
? undefined
22556-
: (await this.readMetaItem({ type: 'datasource', name: datasource }))?.item;
22557-
const refusal = externalBindingDatasourceRefusal(
22558-
name, body.external, datasource,
22559-
definition && typeof definition === 'object' ? definition as { schemaMode?: unknown } : undefined,
22560-
);
22587+
: await this.externalBindingDatasourceDefinition(datasource);
22588+
const refusal = externalBindingDatasourceRefusal(name, body.external, datasource, definition);
2256122589
if (refusal) issues.push({ path: refusal.path, message: refusal.message, code: 'custom' });
2256222590
} else if (singularType === 'datasource') {
2256322591
for (const bound of await this.objectsBoundExternallyTo(name)) {
@@ -22575,6 +22603,22 @@ export class ObjectStackProtocolImplementation implements
2257522603
return err;
2257622604
}
2257722605

22606+
/**
22607+
* [#25211 · #25279] The definition of the datasource an `external` object
22608+
* names, as this environment resolves it by name ({@link readMetaItem}: the
22609+
* stored row, else the definition the code layer registers), or `undefined`
22610+
* when no layer holds one. The ONE read behind the object half of
22611+
* {@link externalBindingSaveRefusal} and the stored-migration pass's listing
22612+
* of the same refusal ({@link storedRowKnownDatasources}), so the two cannot
22613+
* judge a binding against different datasources.
22614+
*/
22615+
private async externalBindingDatasourceDefinition(
22616+
datasource: string,
22617+
): Promise<{ readonly schemaMode?: unknown } | undefined> {
22618+
const definition = (await this.readMetaItem({ type: 'datasource', name: datasource }))?.item;
22619+
return definition && typeof definition === 'object' ? definition as { schemaMode?: unknown } : undefined;
22620+
}
22621+
2257822622
/**
2257922623
* [#25211] Every object this environment holds that binds `datasource`
2258022624
* with `external`, for {@link externalBindingSaveRefusal}'s datasource
@@ -25058,9 +25102,15 @@ export class ObjectStackProtocolImplementation implements
2505825102
// flow carrying a `{…}` token at a value slot — a CRUD filter
2505925103
// operand among them — parsed, rewrote nothing and was counted
2506025104
// `canonical`, while `registerFlow` refused it at boot.
25105+
//
25106+
// [#25279] A stored object's `external` binding is judged against the
25107+
// datasource the save door reads for it, so the listing names the
25108+
// row exactly when that door refuses its next save.
2506125109
const { item, notices, todos } = flowResult
2506225110
? { item: flowResult.storable, notices: flowResult.notices, todos: flowResult.todos ?? [] }
25063-
: this.convertStoredItemDetailed(singular, body);
25111+
: this.convertStoredItemDetailed(
25112+
singular, body, undefined, await this.storedRowKnownDatasources(singular, body),
25113+
);
2506425114
const changed = flowResult ? item !== body : notices.length > 0;
2506525115
// [#17321] A site the chain left as stored — no lossless rewrite
2506625116
// exists for it (ADR-0087 D3: a structured TODO, never silence) —

‎packages/spec/src/conversions/apply.ts‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,21 @@ export interface ApplyConversionsOptions {
8080
* nothing of it runs, and in either case nothing is rewritten.
8181
*/
8282
onTodo?: (todo: ConversionTodoNotice) => void;
83+
/**
84+
* [#25279] Datasource definitions the caller's door can see beyond the stack
85+
* being converted, by name. Read only by the refused-shape listing above
86+
* (so only with an `onTodo` sink), and there only by the `external`-binding
87+
* detector: a stored object row arrives alone, so without this the listing
88+
* could judge only a binding on the primary datasource, while the save door
89+
* also judges one on a datasource stored or registered beside it.
90+
*
91+
* Each value is the named datasource's definition as the caller's door
92+
* resolves it (an absent `schemaMode` is the spec default, `'managed'`); a
93+
* name absent from the map gets no verdict, exactly as a door that sees no
94+
* definition gives none. A datasource the stack itself declares is read from
95+
* the stack first. Nothing is converted from it and nothing is rewritten.
96+
*/
97+
knownDatasources?: ReadonlyMap<string, { readonly schemaMode?: unknown }>;
8398
/**
8499
* Conversion ids this seam refuses to replay, whatever `includeRetired`
85100
* says. Empty/absent by default: every seam replays the whole window it
@@ -122,7 +137,7 @@ export function applyConversions(
122137
stack: Record<string, unknown>,
123138
options: ApplyConversionsOptions = {},
124139
): Record<string, unknown> {
125-
const { onNotice, onTodo, includeRetired = false, excludeConversionIds } = options;
140+
const { onNotice, onTodo, includeRetired = false, excludeConversionIds, knownDatasources } = options;
126141
const excluded = excludeConversionIds && excludeConversionIds.length > 0
127142
? new Set(excludeConversionIds)
128143
: null;
@@ -185,7 +200,7 @@ export function applyConversions(
185200
// judged on the converted stack and listed through the same sink: a row the
186201
// chain leaves refused must not read as already on protocol.
187202
if (onTodo) {
188-
reportRefusedStoredShapes(current, (refused, detail) =>
203+
reportRefusedStoredShapes(current, { knownDatasources }, (refused, detail) =>
189204
onTodo({
190205
code: CONVERSION_TODO_CODE,
191206
conversionId: refused.id,

‎packages/spec/src/conversions/refused-shapes.test.ts‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,17 @@ function todosOf(stack: Record<string, unknown>): { stack: Record<string, unknow
3636
return { stack: out, todos };
3737
}
3838

39-
function storedTodos(type: string, row: Record<string, unknown>) {
39+
function storedTodos(
40+
type: string,
41+
row: Record<string, unknown>,
42+
knownDatasources?: ReadonlyMap<string, { readonly schemaMode?: unknown }>,
43+
) {
4044
const todos: ConversionTodoNotice[] = [];
4145
const notices: ConversionNotice[] = [];
4246
const item = applyConversionsToStoredItem(type, row, {
4347
onTodo: (t) => todos.push(t),
4448
onNotice: (n) => notices.push(n),
49+
...(knownDatasources ? { knownDatasources } : {}),
4550
}) as Record<string, any>;
4651
return { item, todos, notices };
4752
}
@@ -534,3 +539,63 @@ describe('#25168 — a stored object whose `external` binding is on a managed da
534539
expect(todosOf(stack).todos.filter((t) => t.conversionId === 'object-external-binding-managed-datasource-refused')).toEqual([]);
535540
});
536541
});
542+
543+
describe('#25279 — a stored object bound `external` to a datasource the caller resolves is judged against it', () => {
544+
const listed = (todos: ConversionTodoNotice[]) =>
545+
todos.filter((t) => t.conversionId === 'object-external-binding-managed-datasource-refused');
546+
547+
it.each<[string, Record<string, unknown>]>([
548+
["declared `schemaMode: 'managed'`", { name: 'warehouse', driver: 'sqlite', config: {}, schemaMode: 'managed' }],
549+
['declaring no `schemaMode` (managed by the spec default)', { name: 'warehouse', driver: 'sqlite', config: {} }],
550+
])('a stored row on a known datasource %s is listed, once, and not rewritten', (_label, datasource) => {
551+
const row = federatedRow({ datasource: 'warehouse' });
552+
const { item, todos, notices } = storedTodos('object', row, new Map([['warehouse', datasource]]));
553+
expect(item).toEqual(row);
554+
expect(notices).toEqual([]);
555+
expect(todos).toHaveLength(1);
556+
const todo = todos[0]!;
557+
expect(todo.conversionId).toBe('object-external-binding-managed-datasource-refused');
558+
expect(todo.path).toBe('objects[0].external');
559+
expect(todo.reason).toContain("Object 'wh_order' declares `external` on datasource 'warehouse', whose `schemaMode` is 'managed'");
560+
expect(JSON.parse(todo.from)).toEqual({ datasource: 'warehouse', external: { remoteName: 'orders' } });
561+
});
562+
563+
it.each([
564+
["a federated datasource (`schemaMode: 'external'`)", 'external'],
565+
["a `schemaMode: 'validate-only'` datasource", 'validate-only'],
566+
])('a stored row on %s is not listed', (_label, schemaMode) => {
567+
const known = new Map([['warehouse', { name: 'warehouse', schemaMode }]]);
568+
expect(listed(storedTodos('object', federatedRow({ datasource: 'warehouse' }), known).todos)).toEqual([]);
569+
});
570+
571+
it('a known datasource the row does not name gives no verdict', () => {
572+
const known = new Map([['other', { name: 'other', schemaMode: 'managed' }]]);
573+
expect(listed(storedTodos('object', federatedRow({ datasource: 'warehouse' }), known).todos)).toEqual([]);
574+
});
575+
576+
it('a datasource the stack declares is read before the caller\'s', () => {
577+
const stack = {
578+
datasources: [{ name: 'warehouse', driver: 'sqlite', config: {}, schemaMode: 'external', external: {} }],
579+
objects: [federatedRow({ datasource: 'warehouse' })],
580+
};
581+
const todos: ConversionTodoNotice[] = [];
582+
applyConversions(stack, {
583+
includeRetired: true,
584+
onTodo: (t) => todos.push(t),
585+
knownDatasources: new Map([['warehouse', { schemaMode: 'managed' }]]),
586+
});
587+
expect(listed(todos)).toEqual([]);
588+
});
589+
590+
it('without an `onTodo` sink nothing reads the known datasources', () => {
591+
const known = new Map<string, { schemaMode?: unknown }>();
592+
const get = known.get.bind(known);
593+
let reads = 0;
594+
known.get = (name: string) => {
595+
reads++;
596+
return get(name);
597+
};
598+
applyConversionsToStoredItem('object', federatedRow({ datasource: 'warehouse' }), { knownDatasources: known });
599+
expect(reads).toBe(0);
600+
});
601+
});

0 commit comments

Comments
 (0)