Skip to content

Commit 4c509cc

Browse files
os-zhuangclaude
andcommitted
fix(verify): skip read-only federated (external) objects in CRUD verification
`objectstack verify` probe-inserts into every object; a read-only external object (ADR-0015) correctly rejects the insert via the write gate, which verify reported as a create-failed runtime failure (breaking the Dogfood Regression Gate once the showcase gained federated objects). deriveCrudCases now marks external objects that aren't fully write-opted-in (datasource.external.allowWrites && object.external.writable) as `blocked`/skipped. Adds derive.test.ts covering the skip + the write-opted-in and managed pass-through cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e245d3 commit 4c509cc

3 files changed

Lines changed: 74 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/verify": patch
3+
---
4+
5+
fix(verify): skip read-only federated (external) objects in CRUD verification.
6+
7+
`objectstack verify` probe-inserts a record into every object. A federated object
8+
on an external datasource is read-only unless BOTH the datasource and the object
9+
opt into writes (ADR-0015 write gate), so that insert is correctly rejected —
10+
which `verify` was reporting as a `create-failed` runtime failure. `deriveCrudCases`
11+
now marks such objects `blocked` (skipped), matching the write gate's double
12+
opt-in rule, so the dogfood gate stays honest while supporting external datasource
13+
example apps.

packages/verify/src/derive.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { deriveCrudCases } from './derive';
5+
6+
describe('deriveCrudCases — federated (external) objects (ADR-0015)', () => {
7+
it('blocks a read-only external object so verify never probe-inserts it', () => {
8+
const config = {
9+
datasources: [{ name: 'wh', schemaMode: 'external', external: { allowWrites: false } }],
10+
objects: [
11+
{ name: 'wh_order', datasource: 'wh', external: { remoteName: 'orders' }, fields: { amount: { type: 'number' } } },
12+
],
13+
};
14+
const c = deriveCrudCases(config).find((x) => x.object === 'wh_order');
15+
expect(c?.blocked).toMatch(/external read-only/);
16+
});
17+
18+
it('blocks when the object opts in but the datasource does not', () => {
19+
const config = {
20+
datasources: [{ name: 'wh', schemaMode: 'external', external: { allowWrites: false } }],
21+
objects: [
22+
{ name: 'wh_order', datasource: 'wh', external: { remoteName: 'orders', writable: true }, fields: { amount: { type: 'number' } } },
23+
],
24+
};
25+
const c = deriveCrudCases(config).find((x) => x.object === 'wh_order');
26+
expect(c?.blocked).toMatch(/external read-only/);
27+
});
28+
29+
it('does NOT block a fully write-opted-in external object (datasource + object)', () => {
30+
const config = {
31+
datasources: [{ name: 'wh', schemaMode: 'external', external: { allowWrites: true } }],
32+
objects: [
33+
{ name: 'wh_order', datasource: 'wh', external: { remoteName: 'orders', writable: true }, fields: { amount: { type: 'number' } } },
34+
],
35+
};
36+
const c = deriveCrudCases(config).find((x) => x.object === 'wh_order');
37+
expect(c?.blocked).toBeFalsy();
38+
});
39+
40+
it('leaves managed objects unaffected', () => {
41+
const config = { objects: [{ name: 'task', fields: { title: { type: 'text' } } }] };
42+
const c = deriveCrudCases(config).find((x) => x.object === 'task');
43+
expect(c?.blocked).toBeFalsy();
44+
});
45+
});

packages/verify/src/derive.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ export function deriveCrudCases(config: any): CrudCase[] {
129129
const objects: any[] = config?.objects ?? [];
130130
const byName = new Map<string, any>();
131131
for (const o of objects) if (o?.name) byName.set(o.name, o);
132+
const dsByName = new Map<string, any>();
133+
for (const ds of (config?.datasources ?? [])) if (ds?.name) dsByName.set(ds.name, ds);
132134

133135
const drafts = new Map<string, Draft>();
134136

@@ -140,6 +142,20 @@ export function deriveCrudCases(config: any): CrudCase[] {
140142
name: obj.name, body: {}, asserts: [], skippedFields: [], relationalRefs: [], requiredTargets: [],
141143
};
142144

145+
// Federated (external) objects are read-only unless BOTH the datasource and
146+
// the object opt into writes (ADR-0015 write gate). The CRUD probe insert is
147+
// correctly rejected by the gate for them, so mark the case blocked (skipped)
148+
// rather than letting a guaranteed-rejected insert surface as a failure.
149+
if (obj.external) {
150+
const ds = obj.datasource ? dsByName.get(obj.datasource) : undefined;
151+
const writable = ds?.external?.allowWrites === true && obj.external?.writable === true;
152+
if (!writable) {
153+
d.blocked = `external read-only object (federated datasource "${obj.datasource ?? 'default'}"; no inserts)`;
154+
drafts.set(obj.name, d);
155+
continue;
156+
}
157+
}
158+
143159
for (const [name, f] of Object.entries(fields)) {
144160
const type = String((f as any)?.type ?? '').toLowerCase();
145161
const isRequired = !!(f as any)?.required;

0 commit comments

Comments
 (0)