3333 * a flow variable leaves the family table unchanged, and the three nodes write
3434 * an ordinary object exactly as before.
3535 *
36+ * [#21654] The save-time half. `FlowSchema` now refuses a write node whose
37+ * STATIC `objectName` names a family table, and `registerFlow` parses first,
38+ * so a flow carrying one is refused before it can run. Every static case here
39+ * therefore asserts that refusal first (`registerFlow` throws, the issue sits at
40+ * the node's `config.objectName`, the table is unchanged), and only then reaches
41+ * the run-time guard, with a definition the parse never judged: see
42+ * {@link registerForRun}. A dynamic target (`{record.target}`) is not judged at
43+ * save and registers as before.
44+ *
3645 * Composition: `ObjectKernel`, `ObjectQLPlugin`, `driver-sql` on
3746 * better-sqlite3 `:memory:` and the real `AutomationServicePlugin`, the stack
3847 * the family read pins boot; the secured composition adds the real
@@ -60,6 +69,49 @@ type RunAs = 'system' | 'user';
6069const STORED_BODY = JSON . stringify ( { name : 'pin_body' , label : 'Pin body' } ) ;
6170const BODY_FRAGMENT = '"label":"Pin body"' ;
6271
72+ /**
73+ * [#21654] The target a static family case is registered under, so that the
74+ * parse lets it through; {@link registerForRun} then puts the family table back
75+ * on the registered definition. No object of this name exists: a definition
76+ * whose retarget did not land fails its run with a not-found error, never with
77+ * the family refusal its case asserts.
78+ */
79+ const STAND_IN_TARGET = 'pin_stand_in_target' ;
80+
81+ /** One write node in a flow definition aimed at a family table, and where it sits. */
82+ interface FamilyTarget {
83+ readonly path : string ;
84+ readonly object : string ;
85+ }
86+
87+ /**
88+ * Every write node in `def`, at any depth (a `try_catch` region's nodes
89+ * included), whose `config.objectName` is `match` — or, with no `match`, is a
90+ * family table by name. Paths in the parse's dotted spelling
91+ * (`nodes.1.config.objectName`).
92+ */
93+ function writeTargetsIn ( def : unknown , match ?: string ) : Array < FamilyTarget & { readonly node : Record < string , unknown > } > {
94+ const found : Array < FamilyTarget & { readonly node : Record < string , unknown > } > = [ ] ;
95+ const visit = ( value : unknown , path : string [ ] ) : void => {
96+ if ( Array . isArray ( value ) ) {
97+ value . forEach ( ( item , i ) => visit ( item , [ ...path , String ( i ) ] ) ) ;
98+ return ;
99+ }
100+ if ( value === null || typeof value !== 'object' ) return ;
101+ const rec = value as Record < string , unknown > ;
102+ const config = rec . config as Record < string , unknown > | undefined ;
103+ if ( ( WRITE_NODES as readonly unknown [ ] ) . includes ( rec . type ) && config && typeof config . objectName === 'string' ) {
104+ const object = config . objectName ;
105+ if ( match === undefined ? ( FAMILY as readonly string [ ] ) . includes ( object ) : object === match ) {
106+ found . push ( { path : [ ...path , 'config' , 'objectName' ] . join ( '.' ) , object, node : config } ) ;
107+ }
108+ }
109+ for ( const [ key , child ] of Object . entries ( rec ) ) visit ( child , [ ...path , key ] ) ;
110+ } ;
111+ visit ( def , [ ] ) ;
112+ return found ;
113+ }
114+
63115/** An ordinary object: the non-family control. */
64116const PLAIN_OBJECT = {
65117 name : 'pin_write_plain' ,
@@ -169,9 +221,71 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
169221 return { objectName : object , filter : { id } } ;
170222 }
171223
224+ /**
225+ * [#21654] Register `def` so that it can RUN. A definition with no static
226+ * family target registers as it always did. One that carries such a target is
227+ * refused by the parse at save, now that `FlowSchema` judges it, so this first
228+ * asserts that refusal — `registerFlow` throws, the issue is a `custom` one at
229+ * each such node's `config.objectName` carrying the metadata-protocol
230+ * prescription, nothing is registered under the name, and the target table is
231+ * unchanged — and only then reaches the run-time guard with a definition the
232+ * parse never judged: the same definition registered with
233+ * {@link STAND_IN_TARGET} in place of each family table, after which the
234+ * family table is put back on the definition the engine holds.
235+ *
236+ * The engine behaviour this leans on, none of which the save-time refusal
237+ * changes: `registerFlow` stores the parsed definition it returns, by
238+ * reference (`this.flows.set(name, parsed)`, then `return parsed`), and
239+ * `execute` runs `this.flows.get(name)` as stored, never re-parsing it. Both
240+ * are read back here rather than assumed: `getFlow(name)` must answer the
241+ * family table at every retargeted path before the run. Were either to stop
242+ * holding — a copy, a freeze, a re-parse — the retarget would fail to land,
243+ * that read-back would go red, and the run would refuse nothing for the family
244+ * reason; a frozen definition throws on the write itself.
245+ */
246+ async function registerForRun ( def : { name : string } ) : Promise < void > {
247+ const targets = writeTargetsIn ( def ) ;
248+ if ( targets . length === 0 ) {
249+ automation . registerFlow ( def . name , def as any ) ;
250+ return ;
251+ }
252+
253+ // Save time: refused, located at each family target, nothing registered, the table unchanged.
254+ const tables = [ ...new Set ( targets . map ( ( t ) => t . object ) ) ] ;
255+ const before = await Promise . all ( tables . map ( ( object ) => snapshot ( object ) ) ) ;
256+ let thrown : { issues ?: Array < { code : string ; path : PropertyKey [ ] ; message : string } > } | undefined ;
257+ try {
258+ automation . registerFlow ( def . name , def as any ) ;
259+ } catch ( err ) {
260+ thrown = err as typeof thrown ;
261+ }
262+ expect ( thrown , `${ def . name } : registerFlow must refuse a static family target at save` ) . toBeDefined ( ) ;
263+ expect (
264+ ( thrown ! . issues ?? [ ] ) . map ( ( i ) => ( { code : i . code , path : i . path . join ( '.' ) } ) ) ,
265+ `${ def . name } : the save-time refusal's issues` ,
266+ ) . toEqual ( targets . map ( ( t ) => ( { code : 'custom' , path : t . path } ) ) ) ;
267+ for ( const issue of thrown ! . issues ?? [ ] ) expect ( issue . message ) . toContain ( 'the metadata protocol' ) ;
268+ expect ( await automation . getFlow ( def . name ) , `${ def . name } : a refused flow was registered` ) . toBeNull ( ) ;
269+ expect ( await Promise . all ( tables . map ( ( object ) => snapshot ( object ) ) ) , `${ def . name } : the save-time refusal changed a table` )
270+ . toEqual ( before ) ;
271+
272+ // Run time: a definition the parse never judged — registered aimed at the stand-in, then retargeted.
273+ const standIn = JSON . parse ( JSON . stringify ( def ) ) as { name : string } ;
274+ for ( const target of writeTargetsIn ( standIn ) ) target . node . objectName = STAND_IN_TARGET ;
275+ const registered = automation . registerFlow ( def . name , standIn as any ) ;
276+ const placeholders = writeTargetsIn ( registered , STAND_IN_TARGET ) ;
277+ expect ( placeholders . map ( ( p ) => p . path ) , `${ def . name } : the stand-in sits where the family targets did` )
278+ . toEqual ( targets . map ( ( t ) => t . path ) ) ;
279+ placeholders . forEach ( ( placeholder , i ) => {
280+ placeholder . node . objectName = targets [ i ] ! . object ;
281+ } ) ;
282+ expect ( writeTargetsIn ( await automation . getFlow ( def . name ) ) , `${ def . name } : the engine holds the retargeted definition` )
283+ . toEqual ( targets . map ( ( t ) => expect . objectContaining ( { path : t . path , object : t . object } ) ) ) ;
284+ }
285+
172286 /** Run `def` with the engine's write verbs watched; count the calls aimed at the family. */
173287 async function runWatched ( def : { name : string } , trigger : Record < string , unknown > ) {
174- automation . registerFlow ( def . name , def as any ) ;
288+ await registerForRun ( def ) ;
175289 const insert = vi . spyOn ( ql , 'insert' ) ;
176290 const update = vi . spyOn ( ql , 'update' ) ;
177291 const remove = vi . spyOn ( ql , 'delete' ) ;
@@ -193,7 +307,7 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
193307 async function codeAsAFlowReadsIt ( runAs : RunAs , node : Record < string , unknown > , trigger : Record < string , unknown > ) {
194308 const name = `pin_code_${ seq ++ } ` ;
195309 captured . length = 0 ;
196- automation . registerFlow ( name , {
310+ await registerForRun ( {
197311 name, label : name , type : 'autolaunched' , runAs,
198312 nodes : [
199313 { id : 'start' , type : 'start' , label : 'Start' } ,
@@ -208,7 +322,7 @@ function harness(ql: ObjectQL, automation: AutomationEngine) {
208322 { id : 'end' , type : 'end' , label : 'End' } ,
209323 ] ,
210324 edges : [ { id : 'e1' , source : 'start' , target : 'guarded' } , { id : 'e2' , source : 'guarded' , target : 'end' } ] ,
211- } as any ) ;
325+ } as { name : string } ) ;
212326 await automation . execute ( name , { ...trigger } as any ) ;
213327 expect ( captured , 'the catch region must have run once' ) . toHaveLength ( 1 ) ;
214328 return captured [ 0 ] ! ;
0 commit comments