Skip to content

Commit 4e4776c

Browse files
committed
wip(spec): the stored-shape listing judges an external binding against the datasource the save door resolves
Claude-Session: https://claude.ai/code/session_01P6EWuSSDC4q1RNpSTNbyCU Co-authored-by: Claude <noreply@anthropic.com>
1 parent c9be979 commit 4e4776c

6 files changed

Lines changed: 248 additions & 17 deletions

File tree

‎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([
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)