Skip to content

Commit fe0ec2b

Browse files
fix(spec)!: refuse an object's external binding on a managed datasource (ADR-0015 §4.2) (#25192)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg6zwDuV2XvoDkqC7bXgqnpc)_ Fixes #25168 Clause-②: no (narrowing) Metadata-protocol: narrowed ## What ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'`, and declares the rule "enforced at metadata-load time". No load door enforced it. This PR makes the spec judge it, once, through one predicate (`packages/spec/src/data/external-binding-datasource.ts`, internal), at each door that can see the halves it needs. This follows the route in triage comment 6333828054: a declaration rule at the parse, inherited by the save door and `os validate`, and no second check in `packages/objectql`. | The object's `datasource` | Where it is judged | Answer now | |---|---|---| | omitted or `'default'` (the primary, managed by construction) | `ObjectSchema`'s parse (superRefine), at `external` | `ObjectSchema.create` throws at module load. `defineStack` and `os validate` give `STACK_SCHEMA_INVALID` (422) at `objects.N.external`. `PUT /api/v1/meta/object/NAME` gives `422 INVALID_METADATA` with the finding at `external`, and nothing is stored. | | a datasource the same stack declares with `schemaMode: 'managed'` or with no `schemaMode` | `defineStack`'s cross-reference pass (`validateCrossReferences`) | `STACK_CROSS_REFERENCE_INVALID` (422), one finding per bound object. `os validate` and `os build` inherit it. | | a datasource declared `schemaMode: 'external'` or `'validate-only'` | (both doors) | accepted, as before | | a datasource the stack does not declare | no door can see its `schemaMode` | no verdict, as before (see Acceptance notes) | A package `defaultDatasource` or a `datasourceMapping` rule does not count as the object's datasource. They route queries, while the boot validation (`validateObjectUsing`) and the write gate (`assertWriteAllowed`) read only the object's own `datasource`. The refusal message says so. ADR-0087 kit: the semantic entry `object-external-binding-managed-datasource-refused` (`packages/spec/src/migrations/entries/semantic/`), and a `REFUSED_STORED_SHAPES` detector that lists each stored object in the refused shape for `os migrate meta --stored` (no lossless rewrite: dropping `external` would turn the remote table into one ObjectStack creates). The changeset carries the `registered` disposition, `minor`, BREAKING with FROM and TO, and the one-line fix. ## Measured on the base (b6e8707), before any edit - `defineStack` accepted all six probe stacks: datasource omitted, `'default'`, declared `managed`, declared with no `schemaMode`, declared `external` (control), and undeclared. - `os validate` answered `valid: true`, exit 0, for both a declared-`managed` and a no-`schemaMode` datasource. This was the card's first NOT MEASURED reading. - The object save door stored all three probes (datasource omitted, `'default'`, and a stored managed datasource named `wh`) and answered success. Each save logged the platform's own "published but its storage was NOT created" warning. This was the card's second NOT MEASURED reading. ## After this PR (same probes) - `defineStack`: managed and no-`schemaMode` give `STACK_CROSS_REFERENCE_INVALID` 422. Omitted and `'default'` give `STACK_SCHEMA_INVALID` 422. The `external` control and the undeclared datasource are accepted. - `os validate`: exit 1, `valid: false`, with those codes and messages. - Save door: `422 INVALID_METADATA` at `external` for omitted and `'default'`, and nothing is stored. A named stored datasource still saves; see Acceptance notes. ## Producers (the stop condition) - In the repo, outside tests and including `examples/**`, the only object with `external` is the showcase's, and it is compliant: `showcase_external` is declared `schemaMode: 'external'`. - Templates, CLI scaffolds and published skills have none. - The Sync/import producer is gone since #25167, which was already merged when this branch was cut. - HotCRM at c8a7a7047 has none. No producer was found, so no conversion question applies. ## Tests - `packages/spec/src/data/external-binding-datasource.test.ts`: the parse refuses at `['external']` (code `custom`) for datasource omitted, `'default'`, and an empty binding. `ObjectSchema.create` throws with the same issue. Controls and the predicate's unit cases. - `packages/spec/src/stack-external-binding-datasource.test.ts`: `code` + `status` for both stack verdicts, one finding per bound object, the `external` and `validate-only` controls, an undeclared datasource accepted, `strict: false` skips, and a manifest `defaultDatasource` does not count. - `packages/spec/src/conversions/refused-shapes.test.ts`: the stored-row detector lists each object. - `packages/metadata-protocol/src/protocol.external-binding-primary-datasource-refusal.test.ts`: the save door answers `INVALID_METADATA` 422, issues at `external`, and stores nothing; controls save. - `packages/spec/src/data/object-strictness-batch20.test.ts`: four fixtures bound `external` with no datasource. They now name their federated datasource (`remote_crm`), per fixture triage (add the missing declaration). Ablation, one time, on 488c2fe: - The mutation made the predicate answer nothing, which drops all three halves at once. It was checked on disk (grep of the injected text in the source, and in 12 built `.mjs` files), and `@objectstack/spec` was rebuilt. - Under the mutation, the three spec files went red, 16 failed of 76, and the save-door file went red, 2 failed of 4 (exactly the two refusal cases). - The source was restored with `git checkout HEAD`, and the restore was proved by comparing blob hashes (equal to HEAD) and by an empty `git diff HEAD`. `@objectstack/spec` was then rebuilt with declarations, and the mutation is in 0 dist files. - After the restore, the spec files pass 76 of 76 and the save-door file passes 4 of 4. Full `@objectstack/spec` suite on 03234e2: 780 files passed and 1 skipped; 22737 tests passed, 1 skipped, 1 todo. The next commit, 488c2fe, only narrows the assertions in two of the new test files, and those files pass 76 of 76 on it. ## Acceptance notes - **The save door cannot see a named datasource's `schemaMode`.** `PUT /api/v1/meta/object/NAME` with `datasource: 'wh'`, where `wh` is a stored managed datasource, plus `external`, still saves (measured on this head). The spec parse sees only the object. The minimal place is the metadata-protocol object save door, calling this same predicate with the datasource it resolves, so the rule is not copied. That needs the predicate exported and is outside this card's file surface. Raised in the report as an open question. - **Not covered here, also noted in the report:** the `composeStacks` artifact pass (it does not re-run `validateCrossReferences`); the reverse direction (saving a datasource as `managed` while objects bind it with `external`); and the `/* ... */` example in `content/docs` external-datasources §5, which should show the object's `datasource`. - **Runtime fixtures not rewritten.** 62 fixtures in 37 test files across 12 packages hand an object with `external` and no datasource straight to a driver, the engine or a lint rule, without the parse. Those suites are green on this head. They describe a shape the parse now refuses, but they test runtime behaviour that a stored legacy row can still reach: such a row still registers under `[metadata_spec_invalid]`. They were left as they are. - **File surface.** `scripts/engine-double-contract.pinned.json` gains 15 rows from `node scripts/check-engine-double-contract.mjs --write`, for the save-door test's engine double. It is a mechanical ledger row, as in #25181 and #25122. - **Branch name.** The branch is the session's designated `claude/project-thread-5h4pzn`, not `claude/issue-25168-…`. The claim names it. --- _Generated by [Claude Code](https://claude.ai/code/session_013dX1BHfcVSJNSkfA6cvwJN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 27dc53b commit fe0ec2b

12 files changed

Lines changed: 717 additions & 9 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: an object's `external` binding on a managed datasource is refused at the parse and by `defineStack`, as ADR-0015 §4.2 declares
6+
7+
Clause-②: no (narrowing)
8+
9+
Metadata-protocol: narrowed
10+
11+
<!-- adr-0087: registered object-external-binding-managed-datasource-refused -->
12+
13+
**BREAKING** (what an author can declare narrows), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'`, and declares the rule "enforced at metadata-load time". No load door enforced it: `defineStack`, `os validate` and the object save door (`PUT /api/v1/meta/object/NAME`) all accepted an `external` binding on a managed datasource, the primary `default` included. The runtime then disagreed with itself about such an object: its schema sync treated it as federated and never created its table, while the boot validation skipped it and the write gate exempted it because its datasource is managed.
16+
17+
- **FROM:** an object declaring `external` was accepted whatever its datasource: `datasource` omitted or `'default'`, or a datasource declared with `schemaMode: 'managed'` or with no `schemaMode`.
18+
- **TO:** the rule is judged where each half is visible.
19+
- An `external` binding whose object `datasource` is `'default'` (also the value when none is written) is refused by `ObjectSchema`'s parse at `external`: `ObjectSchema.create` throws at module load, `defineStack` and `os validate` refuse with `STACK_SCHEMA_INVALID` (422), and the object save door answers `422 INVALID_METADATA` and stores nothing. The platform's primary datasource is managed by construction.
20+
- An `external` binding on a datasource the same stack declares with `schemaMode: 'managed'`, or with no `schemaMode`, is refused by `defineStack` (and so `os validate` and `os build`) with `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object.
21+
- A package `defaultDatasource` or a `datasourceMapping` rule does not make an object federated: they route its queries, while the boot validation and the write gate read the object's own `datasource`.
22+
- **Unchanged:** a binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted. A binding on a datasource the stack does not declare (registered by the host or another package, or stored at runtime) gets no verdict at these doors, because they cannot see its `schemaMode`. `defineStack(config, { strict: false })` skips the stack-level judgement by contract. A stored object row in the refused shape still registers, under a `[metadata_spec_invalid]` diagnostic, and its next save is refused; `os migrate meta --stored` lists each such row under `object-external-binding-managed-datasource-refused`.
23+
- **The fix for an author:** set the object's `datasource` to the datasource that holds the remote table and declare that datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings, for example `datasource: 'warehouse'` beside `defineDatasource({ name: 'warehouse', schemaMode: 'external', external: { … } })`. An object whose rows ObjectStack should own drops `external` instead.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0015 §4.2 · #25168] The object save door refuses an `external` binding
5+
* on the platform's primary datasource.
6+
*
7+
* ADR-0015 §4.2 allows `object.external` only on a datasource whose
8+
* `schemaMode` is not `'managed'`, declared "enforced at metadata-load time".
9+
* `saveMetaItem` used to store such an object: `datasource` omitted or
10+
* `'default'`, `external` present. `ObjectSchema`'s refinement now refuses the
11+
* binding at `external` (the primary datasource is managed by construction),
12+
* and this door inherits it through its spec-conformance parse: `422
13+
* INVALID_METADATA`, the finding at `external`, nothing stored.
14+
*
15+
* The half that needs a named datasource's definition is not this door's
16+
* parse: it cannot see the datasource, so a binding that names one saves here
17+
* whatever that datasource declares.
18+
*/
19+
import { describe, expect, it } from 'vitest';
20+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
21+
import { ObjectStackProtocolImplementation } from './protocol.js';
22+
23+
/** The engine surface the repository write path touches (as the select-shorthand harness). */
24+
function makeProtocol() {
25+
const rows = new Map<string, Record<string, unknown>>();
26+
let nextId = 0;
27+
const engine: any = {
28+
async findOne(object: string, query?: EngineFindOneQueryInput) {
29+
assertEngineFindOnePredicate(object, query); return null; },
30+
async find() { return []; },
31+
async insert(table: string, data: Record<string, unknown>) {
32+
nextId += 1;
33+
if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data);
34+
return { id: `r_${nextId}` };
35+
},
36+
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
37+
assertEngineUpdateDispatch(data, opts);
38+
return { id: null };
39+
},
40+
async delete(_t: string, opts?: Record<string, unknown>) {
41+
assertEngineDeleteDispatch(opts);
42+
return { deleted: 0 };
43+
},
44+
registry: { registerItem: () => {}, registerObject: () => {} },
45+
};
46+
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
47+
return { protocol, rows };
48+
}
49+
50+
async function rejection(promise: Promise<unknown>): Promise<any> {
51+
try {
52+
await promise;
53+
} catch (err) {
54+
return err;
55+
}
56+
throw new Error('expected the save to be rejected, but it resolved');
57+
}
58+
59+
const order = (extra: Record<string, unknown>) => ({
60+
name: 'wh_order',
61+
label: 'Order',
62+
sharingModel: 'private',
63+
fields: { title: { name: 'title', label: 'Title', type: 'text' } },
64+
...extra,
65+
});
66+
67+
describe('#25168 the object save door refuses an `external` binding on the primary datasource', () => {
68+
it.each([
69+
['`datasource` omitted', { external: { remoteName: 'orders' } }],
70+
["`datasource: 'default'`", { datasource: 'default', external: { remoteName: 'orders' } }],
71+
])('%s: 422 INVALID_METADATA at `external`, and nothing is stored', async (_label, extra) => {
72+
const { protocol, rows } = makeProtocol();
73+
74+
const err = await rejection(protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: order(extra) }));
75+
76+
expect(err.code).toBe('INVALID_METADATA');
77+
expect(err.status).toBe(422);
78+
expect(err.issues).toEqual([
79+
expect.objectContaining({ path: 'external', code: 'custom' }),
80+
]);
81+
expect(err.issues[0].message).toMatch(/^Object 'wh_order' declares `external`, but its `datasource` is 'default'/);
82+
expect(rows.size).toBe(0);
83+
});
84+
85+
it.each([
86+
['a binding naming a datasource (this half needs only the object)', { datasource: 'lake', external: { remoteName: 'orders' } }],
87+
['no binding, on the primary datasource', { datasource: 'default' }],
88+
])('control — %s saves', async (_label, extra) => {
89+
const { protocol, rows } = makeProtocol();
90+
91+
const result = await protocol.saveMetaItem({ type: 'object', name: 'wh_order', item: order(extra) });
92+
93+
expect(result.success).toBe(true);
94+
expect(rows.size).toBe(1);
95+
});
96+
});

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ describe('the refused-shape list — registered against the migration chain', ()
6161
'cbp-master-relation-tiers-retired',
6262
'view-data-api-provider-retired',
6363
'flow-value-slot-template-dialect-refused',
64+
'object-external-binding-managed-datasource-refused',
6465
]);
6566
});
6667

@@ -470,3 +471,66 @@ describe('#23317 — a stored page whose component binds the retired API source
470471
}
471472
});
472473
});
474+
475+
// ── #25168 ────────────────────────────────────────────────────────────────
476+
477+
const federatedRow = (extra: Record<string, unknown>) => ({
478+
name: 'wh_order',
479+
label: 'Order',
480+
sharingModel: 'private',
481+
fields: { title: { type: 'text', label: 'Title' } },
482+
external: { remoteName: 'orders' },
483+
...extra,
484+
});
485+
486+
const externalIssue = (row: unknown) => {
487+
const parsed = ObjectSchema.safeParse(row);
488+
return parsed.success ? undefined : parsed.error.issues.find((i) => i.path.join('.') === 'external');
489+
};
490+
491+
describe('#25168 — a stored object whose `external` binding is on a managed datasource is listed by object', () => {
492+
it.each([
493+
['`datasource` omitted', {}],
494+
["`datasource: 'default'`", { datasource: 'default' }],
495+
])('%s: one TODO naming the object, the rule and the owner\'s choice; the row is not rewritten', (_label, extra) => {
496+
const row = federatedRow(extra);
497+
const { item, todos } = storedTodos('object', row);
498+
expect(item).toEqual(row);
499+
expect(todos).toHaveLength(1);
500+
const todo = todos[0]!;
501+
expect(todo.conversionId).toBe('object-external-binding-managed-datasource-refused');
502+
expect(todo.path).toBe('objects[0].external');
503+
expect(todo.reason).toMatch(/^No lossless rewrite: dropping `external` turns the remote table into one ObjectStack creates/);
504+
expect(todo.reason).toContain("Object 'wh_order' declares `external`, but its `datasource` is 'default'");
505+
expect(JSON.parse(todo.from)).toEqual({ datasource: (extra as { datasource?: string }).datasource ?? null, external: { remoteName: 'orders' } });
506+
// The listing and the parse refuse ONE set: the stored row still fails the parse, at the same key.
507+
expect(externalIssue(item)).toBeDefined();
508+
});
509+
510+
it('a stack that declares the named datasource managed is listed by the stack-level half', () => {
511+
const stack = {
512+
datasources: [{ name: 'warehouse', driver: 'sqlite', config: {} }],
513+
objects: [federatedRow({ datasource: 'warehouse' })],
514+
};
515+
const { todos } = todosOf(stack);
516+
const listed = todos.filter((t) => t.conversionId === 'object-external-binding-managed-datasource-refused');
517+
expect(listed).toHaveLength(1);
518+
expect(listed[0]!.reason).toContain("declares `external` on datasource 'warehouse', whose `schemaMode` is 'managed'");
519+
});
520+
521+
it.each([
522+
['a named datasource the row cannot see', federatedRow({ datasource: 'warehouse' })],
523+
['an object with no `external`', { ...federatedRow({}), external: undefined }],
524+
])('%s is not listed — and the parse accepts it', (_label, row) => {
525+
expect(storedTodos('object', row).todos).toEqual([]);
526+
expect(externalIssue(row)).toBeUndefined();
527+
});
528+
529+
it('a datasource the stack declares external is not listed', () => {
530+
const stack = {
531+
datasources: [{ name: 'warehouse', driver: 'sqlite', config: {}, schemaMode: 'external', external: {} }],
532+
objects: [federatedRow({ datasource: 'warehouse' })],
533+
};
534+
expect(todosOf(stack).todos.filter((t) => t.conversionId === 'object-external-binding-managed-datasource-refused')).toEqual([]);
535+
});
536+
});

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@
3838
* component-props gate parses at the CLI doors and at the runtime save
3939
* door — and the value-slot judge
4040
* `AutomationEngine.registerFlow` and `objectstack validate` run
41-
* ({@link flowNodeValueTemplateRefusals}). A site is listed here exactly
41+
* ({@link flowNodeValueTemplateRefusals}), and the `external` binding judge
42+
* the object refinement and `defineStack` share
43+
* ({@link externalBindingDatasourceRefusal}). A site is listed here exactly
4244
* when that door refuses it.
4345
* - It never prints a credential. A view-level API source could carry request
4446
* `headers`. The listing names the URL and the method, and withholds the rest.
@@ -53,6 +55,7 @@ import {
5355
isControlledByParentMasterField,
5456
type ControlledByParentMasterRefusal,
5557
} from '../data/controlled-by-parent-master.js';
58+
import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js';
5659
import { ComponentPropsMap } from '../ui/component.zod.js';
5760
import { ViewDataSchema } from '../ui/view.zod.js';
5861
import type { ConversionTodoDetail } from './types.js';
@@ -175,6 +178,43 @@ const controlledByParentWithoutOneMaster: RefusedStoredShape = {
175178
},
176179
};
177180

181+
// ── #25168: an object's `external` binding on a managed datasource ──
182+
183+
const externalBindingOnManagedDatasource: RefusedStoredShape = {
184+
id: 'object-external-binding-managed-datasource-refused',
185+
toMajor: 18,
186+
surface: 'object.external — a federated binding on the primary datasource or on a managed one the stack declares',
187+
detect(stack, report) {
188+
// The datasources this stack declares, as the stack-level half of the rule
189+
// reads them. A stored object row arrives alone, so for it only the
190+
// primary half (`datasource: 'default'`, written or omitted) can answer.
191+
const declared = new Map<string, Dict>();
192+
for (const ds of Array.isArray(stack.datasources) ? stack.datasources : []) {
193+
if (isDict(ds) && typeof ds.name === 'string') declared.set(ds.name, ds);
194+
}
195+
mapCollection(stack, 'objects', (obj, path) => {
196+
const datasource = typeof obj.datasource === 'string' ? obj.datasource : undefined;
197+
const refusal = externalBindingDatasourceRefusal(
198+
obj.name,
199+
obj.external,
200+
datasource,
201+
datasource === undefined ? undefined : declared.get(datasource),
202+
);
203+
if (refusal === undefined) return obj;
204+
report({
205+
path: `${path}.${refusal.path}`,
206+
from: JSON.stringify({ datasource: datasource ?? null, external: obj.external }),
207+
reason: 'No lossless rewrite: dropping `external` turns the remote table into one ObjectStack creates, '
208+
+ 'and naming another datasource or re-declaring this one external says where the rows live, which '
209+
+ 'only the owner knows. Left as stored, the object registers under `[metadata_spec_invalid]` and its '
210+
+ 'next save is refused with 422 INVALID_METADATA. '
211+
+ refusal.message,
212+
});
213+
return obj;
214+
});
215+
},
216+
};
217+
178218
// ── #23271: a view whose data source is the retired view-level API binding ──
179219

180220
/** The request keys the listing names; every other key of a request block is withheld. */
@@ -361,6 +401,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
361401
controlledByParentWithoutOneMaster,
362402
viewDataApiProvider,
363403
flowValueSlotTemplateToken,
404+
externalBindingOnManagedDatasource,
364405
];
365406

366407
/**
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [ADR-0015 §4.2 · #25168] The object half of "`object.external` only when the
5+
* object's datasource has `schemaMode !== 'managed'`": an `external` binding
6+
* whose object `datasource` is `'default'` — written, or the parsed value of an
7+
* omitted one — is refused by `ObjectSchema`'s parse, at `external`, because
8+
* the platform's primary datasource is managed by construction.
9+
*
10+
* Pinned: both spellings of the primary refused at the same path with the same
11+
* message, by `safeParse` and by `ObjectSchema.create` at module load; the
12+
* CONTROLS (a named datasource, which this half cannot judge, and an object
13+
* with no `external`) accepted; and the predicate's other half, which needs
14+
* the datasource's definition, answering from it alone.
15+
*/
16+
import { describe, expect, it } from 'vitest';
17+
import { ObjectSchema } from './object.zod';
18+
import { externalBindingDatasourceRefusal } from './external-binding-datasource';
19+
20+
const object = (extra: Record<string, unknown>) => ({
21+
name: 'wh_order',
22+
label: 'Order',
23+
sharingModel: 'private',
24+
fields: { title: { type: 'text', label: 'Title' } },
25+
...extra,
26+
});
27+
28+
const issuesOf = (value: unknown) => {
29+
const parsed = ObjectSchema.safeParse(value);
30+
return parsed.success ? [] : parsed.error.issues;
31+
};
32+
33+
const PRIMARY = /^Object 'wh_order' declares `external`, but its `datasource` is 'default' \(also the value when none is written\)/;
34+
35+
describe('ObjectSchema refuses an `external` binding on the primary datasource', () => {
36+
it.each([
37+
['`datasource` omitted', { external: { remoteName: 'orders' } }],
38+
["`datasource: 'default'`", { datasource: 'default', external: { remoteName: 'orders' } }],
39+
['an empty binding', { external: {} }],
40+
])('%s: one issue at `external`, naming the object and its datasource', (_label, extra) => {
41+
const issues = issuesOf(object(extra));
42+
expect(issues).toHaveLength(1);
43+
expect(issues[0]).toMatchObject({ code: 'custom', path: ['external'] });
44+
expect(issues[0]!.message).toMatch(PRIMARY);
45+
});
46+
47+
it('`ObjectSchema.create` refuses it at module load, with the same issue', () => {
48+
let thrown: { issues?: Array<{ path: unknown[]; message: string }> } | undefined;
49+
try {
50+
ObjectSchema.create(object({ external: { remoteName: 'orders' } }) as never);
51+
} catch (e) {
52+
thrown = e as typeof thrown;
53+
}
54+
expect(thrown?.issues).toEqual([expect.objectContaining({ path: ['external'], message: expect.stringMatching(PRIMARY) })]);
55+
});
56+
57+
it.each([
58+
['a named datasource', { datasource: 'warehouse', external: { remoteName: 'orders' } }],
59+
['no `external`, on the primary', { datasource: 'default' }],
60+
['no `external`, `datasource` omitted', {}],
61+
])('control — %s parses', (_label, extra) => {
62+
expect(issuesOf(object(extra))).toEqual([]);
63+
});
64+
});
65+
66+
describe('externalBindingDatasourceRefusal — the one judgement both halves read', () => {
67+
it('answers nothing without a binding, whatever the datasource', () => {
68+
expect(externalBindingDatasourceRefusal('o', undefined, 'default')).toBeUndefined();
69+
expect(externalBindingDatasourceRefusal('o', null, 'warehouse', { schemaMode: 'managed' })).toBeUndefined();
70+
});
71+
72+
it('answers nothing for a named datasource it cannot see', () => {
73+
expect(externalBindingDatasourceRefusal('o', {}, 'warehouse')).toBeUndefined();
74+
});
75+
76+
it.each([
77+
["declared 'managed'", { schemaMode: 'managed' }],
78+
['declared with no `schemaMode` (managed by default)', {}],
79+
])('refuses a binding on a datasource %s', (_label, declared) => {
80+
const refusal = externalBindingDatasourceRefusal('o', {}, 'warehouse', declared);
81+
expect(refusal).toMatchObject({ path: 'external', shape: 'managed', datasource: 'warehouse' });
82+
expect(refusal!.message).toMatch(/^Object 'o' declares `external` on datasource 'warehouse', whose `schemaMode` is 'managed'/);
83+
});
84+
85+
it.each(['external', 'validate-only'])("accepts a binding on a datasource declared '%s'", (schemaMode) => {
86+
expect(externalBindingDatasourceRefusal('o', {}, 'warehouse', { schemaMode })).toBeUndefined();
87+
});
88+
89+
it('judges the primary by name alone: a definition passed for it changes nothing', () => {
90+
expect(externalBindingDatasourceRefusal('o', {}, 'default', { schemaMode: 'external' })?.shape).toBe('primary');
91+
});
92+
});

0 commit comments

Comments
 (0)