Skip to content

Commit a9d2d5d

Browse files
committed
test(service-automation): the run-time family pins assert the save-time refusal first, then run a definition the parse never judged
Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent f7d1216 commit a9d2d5d

1 file changed

Lines changed: 117 additions & 3 deletions

File tree

‎packages/services/service-automation/src/builtin/write-nodes-stored-metadata-family-refusal.integration.test.ts‎

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@
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';
6069
const STORED_BODY = JSON.stringify({ name: 'pin_body', label: 'Pin body' });
6170
const 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. */
64116
const 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

Comments
 (0)