Skip to content

Commit 1696291

Browse files
committed
feat(verify): related-record topological synthesis (ADR-0055 P0)
deriveCrudCases no longer skips objects with required relations. It builds the object dependency graph from required relational fields, topologically orders it (targets before dependents), and emits `relationalRefs` the runner fills with real ids — so relationship-dense objects (the core of real apps) are verified, not skipped. Both runners (runCrudVerification, runRlsProofs) thread a created-id registry via fillRelationalRefs. Honest `blocked` verdicts remain for the genuinely unsatisfiable: required relation to an external/missing target, required-reference cycle (incl. required self-reference), and cascade (target itself blocked). Optional relations never block — filled best-effort, else left null. This is the prerequisite harness for the master-detail controlled-by-parent RLS proof (ADR-0055 P2), and independently widens `objectstack verify` coverage. Tests: 10 unit cases (ordering, optional/required, external/missing, cascade, cycle, self-ref, id threading). Full dogfood suite green (69 tests); rls cross-owner counts unchanged on example-crm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 62bc8c6 commit 1696291

5 files changed

Lines changed: 336 additions & 28 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Unit proof of ADR-0055 P0 — related-record topological synthesis in
4+
// `deriveCrudCases`. Pure-function tests over synthetic configs (no stack boot):
5+
// dependency ordering, optional vs required relations, and the honest `blocked`
6+
// verdicts (external/missing target, cascade, required-reference cycle).
7+
8+
import { describe, it, expect } from 'vitest';
9+
import { deriveCrudCases, fillRelationalRefs, type CrudCase } from '@objectstack/verify';
10+
11+
const obj = (name: string, fields: Record<string, any>) => ({ name, fields });
12+
const cfg = (...objects: any[]) => ({ manifest: { id: 'fixture' }, objects });
13+
14+
function byName(cases: CrudCase[]): Map<string, CrudCase> {
15+
return new Map(cases.map((c) => [c.object, c]));
16+
}
17+
function liveOrder(cases: CrudCase[]): string[] {
18+
return cases.filter((c) => !c.blocked).map((c) => c.object);
19+
}
20+
21+
describe('deriveCrudCases — topological synthesis (ADR-0055 P0)', () => {
22+
it('orders a required master_detail chain master-before-detail', () => {
23+
const cases = deriveCrudCases(
24+
cfg(
25+
obj('line', {
26+
name: { type: 'text', required: true },
27+
order: { type: 'master_detail', reference: 'order', required: true },
28+
}),
29+
obj('order', {
30+
name: { type: 'text', required: true },
31+
account: { type: 'lookup', reference: 'account', required: true },
32+
}),
33+
obj('account', { name: { type: 'text', required: true } }),
34+
),
35+
);
36+
// account (no deps) → order (needs account) → line (needs order)
37+
expect(liveOrder(cases)).toEqual(['account', 'order', 'line']);
38+
const line = byName(cases).get('line')!;
39+
expect(line.blocked).toBeUndefined();
40+
expect(line.relationalRefs).toEqual([
41+
{ field: 'order', target: 'order', required: true, multiple: false },
42+
]);
43+
});
44+
45+
it('records an optional relation as a ref but never blocks on it', () => {
46+
const cases = deriveCrudCases(
47+
cfg(
48+
obj('note', {
49+
name: { type: 'text', required: true },
50+
related: { type: 'lookup', reference: 'account' }, // optional
51+
}),
52+
obj('account', { name: { type: 'text', required: true } }),
53+
),
54+
);
55+
const note = byName(cases).get('note')!;
56+
expect(note.blocked).toBeUndefined();
57+
expect(note.relationalRefs?.[0]).toMatchObject({ field: 'related', target: 'account', required: false });
58+
});
59+
60+
it('blocks an object whose REQUIRED relation target is external/missing', () => {
61+
const cases = deriveCrudCases(
62+
cfg(
63+
obj('thing', {
64+
name: { type: 'text', required: true },
65+
ext: { type: 'lookup', reference: 'not_in_app', required: true },
66+
}),
67+
),
68+
);
69+
expect(byName(cases).get('thing')!.blocked).toMatch(/not in app config/);
70+
});
71+
72+
it('skips (does not block) an OPTIONAL relation to an external target', () => {
73+
const cases = deriveCrudCases(
74+
cfg(
75+
obj('thing', {
76+
name: { type: 'text', required: true },
77+
ext: { type: 'lookup', reference: 'not_in_app' }, // optional
78+
}),
79+
),
80+
);
81+
const t = byName(cases).get('thing')!;
82+
expect(t.blocked).toBeUndefined();
83+
expect(t.skippedFields?.some((s) => s.reason.includes('external'))).toBe(true);
84+
});
85+
86+
it('cascade-blocks a dependent when its required target is itself blocked', () => {
87+
const cases = deriveCrudCases(
88+
cfg(
89+
obj('detail', {
90+
name: { type: 'text', required: true },
91+
parent: { type: 'master_detail', reference: 'parent', required: true },
92+
}),
93+
obj('parent', {
94+
name: { type: 'text', required: true },
95+
ext: { type: 'lookup', reference: 'missing', required: true }, // blocks parent
96+
}),
97+
),
98+
);
99+
const m = byName(cases);
100+
expect(m.get('parent')!.blocked).toMatch(/not in app config/);
101+
expect(m.get('detail')!.blocked).toMatch(/required relational target "parent"/);
102+
});
103+
104+
it('blocks a required-reference cycle (incl. required self-reference)', () => {
105+
const cases = deriveCrudCases(
106+
cfg(
107+
obj('a', { name: { type: 'text', required: true }, b: { type: 'lookup', reference: 'b', required: true } }),
108+
obj('b', { name: { type: 'text', required: true }, a: { type: 'lookup', reference: 'a', required: true } }),
109+
obj('tree', { name: { type: 'text', required: true }, parent: { type: 'tree', reference: 'tree', required: true } }),
110+
),
111+
);
112+
const m = byName(cases);
113+
expect(m.get('a')!.blocked).toMatch(/cycle/);
114+
expect(m.get('b')!.blocked).toMatch(/cycle/);
115+
expect(m.get('tree')!.blocked).toMatch(/cycle/);
116+
});
117+
118+
it('allows an OPTIONAL self-reference (a tree root with null parent)', () => {
119+
const cases = deriveCrudCases(
120+
cfg(obj('cat', { name: { type: 'text', required: true }, parent: { type: 'tree', reference: 'cat' } })),
121+
);
122+
expect(byName(cases).get('cat')!.blocked).toBeUndefined();
123+
});
124+
});
125+
126+
describe('fillRelationalRefs — id threading', () => {
127+
const c: CrudCase = {
128+
object: 'line',
129+
body: { name: 'verify-sample' },
130+
relationalRefs: [
131+
{ field: 'order', target: 'order', required: true, multiple: false },
132+
{ field: 'tags', target: 'tag', required: false, multiple: true },
133+
],
134+
};
135+
136+
it('fills required + multiple refs from the created registry', () => {
137+
const created = new Map([['order', 'o1'], ['tag', 't1']]);
138+
const { body, missing } = fillRelationalRefs(c, created);
139+
expect(missing).toBeUndefined();
140+
expect(body).toEqual({ name: 'verify-sample', order: 'o1', tags: ['t1'] });
141+
});
142+
143+
it('reports missing when a REQUIRED target was not created', () => {
144+
const { missing } = fillRelationalRefs(c, new Map());
145+
expect(missing).toMatch(/required relation "order"/);
146+
});
147+
148+
it('leaves an OPTIONAL ref unset when its target was not created', () => {
149+
const created = new Map([['order', 'o1']]);
150+
const { body, missing } = fillRelationalRefs(c, created);
151+
expect(missing).toBeUndefined();
152+
expect(body).toEqual({ name: 'verify-sample', order: 'o1' }); // no tags
153+
});
154+
});

packages/verify/src/derive.ts

Lines changed: 158 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@
99
// write it → read it back → assert type fidelity" for every object, then runs
1010
// it against the real in-process stack.
1111
//
12-
// v0 derives per-object CRUD round-trip cases. RLS cross-owner denial (the
13-
// #1994 invariant) is v1 — it needs the multi-user harness + sharing service.
12+
// v0 derived per-object CRUD round-trip cases and SKIPPED any object with a
13+
// required relation (lookup / master_detail) — it had no target id to write.
14+
// v1 (ADR-0055 P0) closes that gap with RELATED-RECORD TOPOLOGICAL SYNTHESIS:
15+
// build the object dependency graph from required relational fields, topologically
16+
// order it (targets before dependents), and have the runner thread real ids — so
17+
// relationship-dense objects (the core of real apps) are verified, not skipped.
18+
// What it still can't satisfy (required-reference cycles, external/missing targets)
19+
// is reported `blocked` with a precise reason — the gate stays honest.
1420

1521
/* eslint-disable @typescript-eslint/no-explicit-any */
1622

@@ -30,12 +36,20 @@ export interface DerivedAssert {
3036
value: unknown;
3137
kind: AssertKind;
3238
}
39+
/** A relational field to fill with a real target id at run time (threaded by the runner). */
40+
export interface RelationalRef {
41+
field: string; // the FK field key on this object
42+
target: string; // the referenced object name
43+
required: boolean;
44+
multiple: boolean; // store as an array of ids
45+
}
3346
export interface CrudCase {
3447
object: string;
35-
blocked?: string; // why this object can't be auto-CRUD'd (e.g. required lookup)
48+
blocked?: string; // why this object can't be auto-CRUD'd (e.g. required-reference cycle)
3649
body?: Record<string, unknown>;
3750
asserts?: DerivedAssert[];
3851
skippedFields?: Array<{ name: string; type: string; reason: string }>;
52+
relationalRefs?: RelationalRef[]; // resolved against created-record ids by the runner
3953
}
4054

4155
function clampNum(f: any, fallback: number): number {
@@ -84,44 +98,166 @@ function synth(type: string, f: any): { value: unknown; kind: AssertKind } | nul
8498
}
8599
}
86100

101+
/** The target object a relational field references (snake_case object name), or null. */
102+
function relationTarget(f: any): string | null {
103+
const ref = f?.reference ?? f?.reference_to ?? f?.referenceTo;
104+
return typeof ref === 'string' && ref.length > 0 ? ref : null;
105+
}
106+
107+
interface Draft {
108+
name: string;
109+
body: Record<string, unknown>;
110+
asserts: DerivedAssert[];
111+
skippedFields: Array<{ name: string; type: string; reason: string }>;
112+
relationalRefs: RelationalRef[];
113+
requiredTargets: string[]; // referenced objects that MUST exist + be ordered-before
114+
blocked?: string;
115+
}
116+
87117
/**
88-
* Derive one CRUD round-trip case per authorable object in the config.
89-
* An object whose REQUIRED fields can't be synthesized (e.g. a required lookup
90-
* needing a target record) is reported `blocked` rather than silently skipped.
118+
* Derive one CRUD round-trip case per authorable object, in DEPENDENCY ORDER.
119+
*
120+
* Required relational fields no longer block the object outright: the referenced
121+
* target is created first (topological order) and the runner threads its real id
122+
* in. Only genuinely unsatisfiable shapes are `blocked`:
123+
* - a required relation whose target is missing from the app config (external), or
124+
* - a required-reference cycle (incl. a required self-reference), or
125+
* - a required relation whose target is itself blocked (cascade), or
126+
* - a required non-relational field that can't be synthesized (unchanged from v0).
91127
*/
92128
export function deriveCrudCases(config: any): CrudCase[] {
93-
const cases: CrudCase[] = [];
94-
for (const obj of config?.objects ?? []) {
129+
const objects: any[] = config?.objects ?? [];
130+
const byName = new Map<string, any>();
131+
for (const o of objects) if (o?.name) byName.set(o.name, o);
132+
133+
const drafts = new Map<string, Draft>();
134+
135+
// ── Pass 1: per-object field classification ───────────────────────────────
136+
for (const obj of objects) {
137+
if (!obj?.name) continue;
95138
const fields: Record<string, any> = obj?.fields ?? {};
96-
const body: Record<string, unknown> = {};
97-
const asserts: DerivedAssert[] = [];
98-
const skippedFields: Array<{ name: string; type: string; reason: string }> = [];
99-
let blocked: string | undefined;
139+
const d: Draft = {
140+
name: obj.name, body: {}, asserts: [], skippedFields: [], relationalRefs: [], requiredTargets: [],
141+
};
100142

101143
for (const [name, f] of Object.entries(fields)) {
102144
const type = String((f as any)?.type ?? '').toLowerCase();
145+
const isRequired = !!(f as any)?.required;
103146
if (SYSTEM_NAMES.has(name) || (f as any)?.system || (f as any)?.readonly) continue;
104147
if (COMPUTED.has(type)) continue;
105148

106-
if (RELATIONAL.has(type) || STRUCTURED.has(type) || MEDIA.has(type)) {
107-
if ((f as any)?.required) { blocked = `required ${type} field "${name}" (needs target/structured value)`; break; }
108-
skippedFields.push({ name, type, reason: 'unsynthesizable-optional' });
149+
if (RELATIONAL.has(type)) {
150+
const target = relationTarget(f);
151+
if (!target) {
152+
if (isRequired) { d.blocked = `required ${type} field "${name}" has no \`reference\` target`; break; }
153+
d.skippedFields.push({ name, type, reason: 'relation-missing-reference' });
154+
continue;
155+
}
156+
if (!byName.has(target)) {
157+
// External / cross-app target — we can't synthesize a record for it.
158+
if (isRequired) { d.blocked = `required ${type} field "${name}" → target "${target}" not in app config`; break; }
159+
d.skippedFields.push({ name, type, reason: `relation-target-external:${target}` });
160+
continue;
161+
}
162+
d.relationalRefs.push({ field: name, target, required: isRequired, multiple: !!(f as any)?.multiple });
163+
if (isRequired) d.requiredTargets.push(target);
164+
continue;
165+
}
166+
167+
if (STRUCTURED.has(type) || MEDIA.has(type)) {
168+
if (isRequired) { d.blocked = `required ${type} field "${name}" (needs structured/media value)`; break; }
169+
d.skippedFields.push({ name, type, reason: 'unsynthesizable-optional' });
109170
continue;
110171
}
111172

112173
const s = synth(type, f);
113174
if (!s) {
114-
if ((f as any)?.required) { blocked = `required field "${name}" of type "${type}" is not synthesizable`; break; }
115-
skippedFields.push({ name, type, reason: 'no-synth' });
175+
if (isRequired) { d.blocked = `required field "${name}" of type "${type}" is not synthesizable`; break; }
176+
d.skippedFields.push({ name, type, reason: 'no-synth' });
116177
continue;
117178
}
118-
body[name] = s.value;
119-
if (s.kind !== 'none') asserts.push({ field: name, type, value: s.value, kind: s.kind });
179+
d.body[name] = s.value;
180+
if (s.kind !== 'none') d.asserts.push({ field: name, type, value: s.value, kind: s.kind });
181+
}
182+
183+
drafts.set(obj.name, d);
184+
}
185+
186+
// ── Pass 2: cascade-block on missing/blocked required targets (to fixpoint) ─
187+
let changed = true;
188+
while (changed) {
189+
changed = false;
190+
for (const d of drafts.values()) {
191+
if (d.blocked) continue;
192+
for (const t of d.requiredTargets) {
193+
const td = drafts.get(t);
194+
if (!td || td.blocked) {
195+
d.blocked = `required relational target "${t}" is ${!td ? 'missing' : 'not synthesizable'}`;
196+
changed = true;
197+
break;
198+
}
199+
}
200+
}
201+
}
202+
203+
// ── Pass 3: topological order (targets before dependents) over required edges ─
204+
const emitted = new Set<string>();
205+
const order: string[] = [];
206+
const live = [...drafts.values()].filter((d) => !d.blocked).map((d) => d.name);
207+
let progress = true;
208+
while (progress) {
209+
progress = false;
210+
for (const name of live) {
211+
if (emitted.has(name)) continue;
212+
const d = drafts.get(name)!;
213+
if (d.requiredTargets.every((t) => emitted.has(t))) {
214+
emitted.add(name);
215+
order.push(name);
216+
progress = true;
217+
}
120218
}
219+
}
220+
// Residue = a required-reference cycle (incl. required self-reference).
221+
for (const name of live) {
222+
if (!emitted.has(name)) drafts.get(name)!.blocked = 'unsatisfiable required-reference cycle';
223+
}
121224

122-
// Every object needs a name-ish required text; synth already covers `name`.
123-
if (blocked) cases.push({ object: obj.name, blocked });
124-
else cases.push({ object: obj.name, body, asserts, skippedFields });
225+
// ── Assemble: ordered live cases first, then blocked ones ──────────────────
226+
const cases: CrudCase[] = [];
227+
for (const name of order) {
228+
const d = drafts.get(name)!;
229+
cases.push({
230+
object: d.name,
231+
body: d.body,
232+
asserts: d.asserts,
233+
skippedFields: d.skippedFields,
234+
...(d.relationalRefs.length ? { relationalRefs: d.relationalRefs } : {}),
235+
});
236+
}
237+
for (const d of drafts.values()) {
238+
if (d.blocked) cases.push({ object: d.name, blocked: d.blocked });
125239
}
126240
return cases;
127241
}
242+
243+
/**
244+
* Resolve a case's relational fields against the registry of already-created
245+
* record ids, returning the body to POST. When a REQUIRED target has no created
246+
* record (its own creation failed at run time), returns a `missing` reason so the
247+
* caller can skip rather than POST an invalid body.
248+
*/
249+
export function fillRelationalRefs(
250+
c: CrudCase,
251+
created: Map<string, string>,
252+
): { body: Record<string, unknown>; missing?: string } {
253+
const body: Record<string, unknown> = { ...(c.body ?? {}) };
254+
for (const ref of c.relationalRefs ?? []) {
255+
const id = created.get(ref.target);
256+
if (id == null) {
257+
if (ref.required) return { body, missing: `required relation "${ref.field}" → no created "${ref.target}" record` };
258+
continue; // optional: leave unset
259+
}
260+
body[ref.field] = ref.multiple ? [id] : id;
261+
}
262+
return { body };
263+
}

packages/verify/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
export { bootStack } from './harness.js';
1111
export type { VerifyStack, BootOptions } from './harness.js';
1212

13-
export { deriveCrudCases } from './derive.js';
14-
export type { CrudCase, DerivedAssert, AssertKind } from './derive.js';
13+
export { deriveCrudCases, fillRelationalRefs } from './derive.js';
14+
export type { CrudCase, DerivedAssert, AssertKind, RelationalRef } from './derive.js';
1515

1616
export { runCrudVerification, formatReport } from './verify.js';
1717
export type { VerifyReport, ObjectVerifyResult } from './verify.js';

0 commit comments

Comments
 (0)