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+ }
3346export 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
4155function 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 */
92128export 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+ }
0 commit comments