Skip to content

Commit b320158

Browse files
authored
feat(automation): publish configSchemas for the keyValue-capable nodes (designer parity, #3304) (#3327)
assignment, create/update/delete/get_record, and screen now publish configSchemas mirroring objectui's hardcoded field groups — free-form maps as JSON-Schema open objects (additionalProperties: true) rendered by the designer's keyValue editor (objectui #2708), object references as xRef, screen's visibleWhen as an xExpression CEL column. decision/wait/script/subflow stay deliberately schemaless (documented + tested — a partial schema would drop editors). Verified: parity tests, tsc, 335-test suite green. Descriptor metadata only; no runtime behavior change. Closes the #3304 implementation series: loop (#3313), map (#3321), objectui adapter (#2708), this PR.
1 parent 16ebe1a commit b320158

5 files changed

Lines changed: 227 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
feat(automation): publish configSchemas for the keyValue-capable nodes (flow designer parity, #3304)
6+
7+
The `assignment`, `create_record` / `update_record` / `delete_record` /
8+
`get_record`, and `screen` nodes shipped no `configSchema`, so the flow designer
9+
had no server-driven form for them. Each descriptor now carries one that mirrors
10+
the objectui hardcoded field group field-for-field: object references as `xRef`,
11+
the screen repeater's `visibleWhen` as `xExpression: 'expression'`, and the
12+
free-form maps (`fields` / `filter` / `assignments` / `defaults`) as JSON-Schema
13+
open objects (`additionalProperties: true`, no fixed `properties`) — the shape
14+
the designer's schema adapter renders with its flat keyValue editor. Values stay
15+
fully permissive because real metadata carries operator objects (`{"$ne": null}`),
16+
`{var}` templates, and non-string literals.
17+
18+
Deliberately still schemaless (no online/offline divergence exists for a node
19+
with no configSchema, and a partial schema would drop editors): `decision`
20+
(virtual Target column derived from edges), `wait` (top-level `waitEventConfig`),
21+
`script` (actionType-conditional form), `subflow` (top-level `timeoutMs`).
22+
23+
Additive and backward-compatible: descriptor metadata only, no runtime behavior
24+
change. Requires an objectui with the keyValue schema mapping (objectui #2708)
25+
for the maps to render as structured editors; older designers keep their
26+
hardcoded forms.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Designer-parity configSchemas (#3304 — descriptor counterpart to objectui
5+
* #2670 Phase 3). The keyValue-capable nodes now publish a `configSchema` that
6+
* mirrors objectui's hardcoded field group, so the online (schema-driven) form
7+
* matches the offline one. The load-bearing shape is the free-form map —
8+
* `type: 'object'` + `additionalProperties: true` and NO fixed `properties` —
9+
* which the designer renders with its flat keyValue editor; `true`-permissive
10+
* because real metadata carries operator objects (`{"$ne": null}`), `{var}`
11+
* templates, and non-string literals as values.
12+
*
13+
* Deliberately schemaless (stay on the hardcoded designer form; a node with no
14+
* configSchema has NO online/offline divergence): `decision` (virtual Target
15+
* column derived from edges), `wait` (top-level `waitEventConfig` block),
16+
* `script` (actionType-conditional form) and `subflow` (top-level `timeoutMs`)
17+
* — a partial schema would drop those editors.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { AutomationEngine } from '../engine.js';
22+
import { registerCrudNodes } from './crud-nodes.js';
23+
import { registerLogicNodes } from './logic-nodes.js';
24+
import { registerScreenNodes } from './screen-nodes.js';
25+
26+
function silentLogger() {
27+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
28+
}
29+
function ctx() {
30+
return { logger: silentLogger(), getService() { throw new Error('none'); } } as any;
31+
}
32+
33+
interface SchemaProp {
34+
type?: string;
35+
format?: string;
36+
properties?: Record<string, SchemaProp>;
37+
additionalProperties?: unknown;
38+
items?: SchemaProp;
39+
enum?: unknown[];
40+
xRef?: { kind?: string };
41+
xExpression?: string;
42+
}
43+
44+
function schemaOf(engine: AutomationEngine, type: string) {
45+
const schema = engine.getActionDescriptor(type)?.configSchema as
46+
| { properties?: Record<string, SchemaProp>; required?: string[] }
47+
| undefined;
48+
expect(schema, `${type} should publish a configSchema`).toBeDefined();
49+
return schema!;
50+
}
51+
52+
/** The keyValue contract: an open map with a value schema and no fixed props. */
53+
function expectKeyValueMap(prop: SchemaProp | undefined, label: string) {
54+
expect(prop, label).toBeDefined();
55+
expect(prop!.type).toBe('object');
56+
expect(prop!.additionalProperties).toBe(true);
57+
expect(prop!.properties).toBeUndefined();
58+
}
59+
60+
describe('builtin node configSchemas — designer parity (#3304)', () => {
61+
const engine = new AutomationEngine(silentLogger());
62+
registerCrudNodes(engine, ctx());
63+
registerLogicNodes(engine, ctx());
64+
registerScreenNodes(engine, ctx());
65+
66+
it('CRUD quartet: object reference + keyValue maps, objectName required', () => {
67+
const get = schemaOf(engine, 'get_record');
68+
expect(get.properties?.objectName?.xRef?.kind).toBe('object');
69+
expectKeyValueMap(get.properties?.filter, 'get_record.filter');
70+
expect(get.properties?.limit?.type).toBe('integer');
71+
expect(get.required).toEqual(['objectName']);
72+
73+
const create = schemaOf(engine, 'create_record');
74+
expect(create.properties?.objectName?.xRef?.kind).toBe('object');
75+
expectKeyValueMap(create.properties?.fields, 'create_record.fields');
76+
77+
const update = schemaOf(engine, 'update_record');
78+
expectKeyValueMap(update.properties?.filter, 'update_record.filter');
79+
expectKeyValueMap(update.properties?.fields, 'update_record.fields');
80+
81+
const del = schemaOf(engine, 'delete_record');
82+
expectKeyValueMap(del.properties?.filter, 'delete_record.filter');
83+
});
84+
85+
it('assignment: a single free-form assignments map, nothing required', () => {
86+
const schema = schemaOf(engine, 'assignment');
87+
expectKeyValueMap(schema.properties?.assignments, 'assignment.assignments');
88+
expect(schema.required).toBeUndefined();
89+
});
90+
91+
it('screen: field list with a CEL visibleWhen column, object-form refs, defaults map', () => {
92+
const schema = schemaOf(engine, 'screen');
93+
// The repeater's visibleWhen column is bare CEL → expression column.
94+
expect(schema.properties?.fields?.items?.properties?.visibleWhen?.xExpression).toBe('expression');
95+
expect(schema.properties?.fields?.items?.properties?.required?.type).toBe('boolean');
96+
expect(schema.properties?.objectName?.xRef?.kind).toBe('object');
97+
expect(schema.properties?.mode?.enum).toEqual(['create', 'edit']);
98+
expect(schema.properties?.description?.format).toBe('multiline');
99+
expectKeyValueMap(schema.properties?.defaults, 'screen.defaults');
100+
});
101+
102+
it('decision / script stay deliberately schemaless (no partial forms)', () => {
103+
// A node with no configSchema renders identically online and offline (the
104+
// hardcoded fallback), so there is no divergence — and publishing a partial
105+
// schema would DROP editors the adapter cannot express (decision's virtual
106+
// Target column; script's actionType-conditional fields).
107+
expect(engine.getActionDescriptor('decision')?.configSchema).toBeUndefined();
108+
expect(engine.getActionDescriptor('script')?.configSchema).toBeUndefined();
109+
});
110+
});

packages/services/service-automation/src/builtin/crud-nodes.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
4141
type: 'get_record', version: '1.0.0', name: 'Get Records',
4242
description: 'Query records from an object.',
4343
icon: 'search', category: 'data', source: 'builtin',
44+
// Structured designer form (ADR-0018, #3304) — mirrors objectui's
45+
// hardcoded `get_record` field group. `filter` is a free-form
46+
// string-keyed map (JSON-Schema `additionalProperties`), which the
47+
// designer renders with its flat keyValue editor; values stay
48+
// `true`-permissive because real metadata carries operator objects
49+
// (e.g. `{"$ne": null}`) alongside `{var}` templates and literals.
50+
configSchema: {
51+
type: 'object',
52+
properties: {
53+
objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } },
54+
filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs to match (e.g. status → active). Operator values like {"$ne": null} are preserved.' },
55+
limit: { type: 'integer', title: 'Limit' },
56+
outputVariable: { type: 'string', title: 'Output variable' },
57+
},
58+
required: ['objectName'],
59+
},
4460
}),
4561
async execute(node, variables, context) {
4662
const cfg = (node.config ?? {}) as Record<string, unknown>;
@@ -86,6 +102,17 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
86102
type: 'create_record', version: '1.0.0', name: 'Create Record',
87103
description: 'Insert a new record into an object.',
88104
icon: 'plus-circle', category: 'data', source: 'builtin',
105+
// Designer form (ADR-0018, #3304) — see get_record for the
106+
// keyValue-map rationale.
107+
configSchema: {
108+
type: 'object',
109+
properties: {
110+
objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } },
111+
fields: { type: 'object', additionalProperties: true, title: 'Field values', description: 'Field values to write on the new record.' },
112+
outputVariable: { type: 'string', title: 'Output variable' },
113+
},
114+
required: ['objectName'],
115+
},
89116
}),
90117
async execute(node, variables, context) {
91118
const cfg = (node.config ?? {}) as Record<string, unknown>;
@@ -135,6 +162,17 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
135162
type: 'update_record', version: '1.0.0', name: 'Update Records',
136163
description: 'Update records matching a filter.',
137164
icon: 'edit', category: 'data', source: 'builtin',
165+
// Designer form (ADR-0018, #3304) — see get_record for the
166+
// keyValue-map rationale.
167+
configSchema: {
168+
type: 'object',
169+
properties: {
170+
objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } },
171+
filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs identifying the record(s) to update (e.g. id → {recordId}).' },
172+
fields: { type: 'object', additionalProperties: true, title: 'Field values', description: 'Field values to write.' },
173+
},
174+
required: ['objectName'],
175+
},
138176
}),
139177
async execute(node, variables, context) {
140178
const cfg = (node.config ?? {}) as Record<string, unknown>;
@@ -171,6 +209,16 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
171209
type: 'delete_record', version: '1.0.0', name: 'Delete Records',
172210
description: 'Delete records matching a filter.',
173211
icon: 'trash', category: 'data', source: 'builtin',
212+
// Designer form (ADR-0018, #3304) — see get_record for the
213+
// keyValue-map rationale.
214+
configSchema: {
215+
type: 'object',
216+
properties: {
217+
objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } },
218+
filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs identifying the record(s) to delete.' },
219+
},
220+
required: ['objectName'],
221+
},
174222
}),
175223
async execute(node, variables, context) {
176224
const cfg = (node.config ?? {}) as Record<string, unknown>;

packages/services/service-automation/src/builtin/logic-nodes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext)
5757
type: 'assignment', version: '1.0.0', name: 'Assignment',
5858
description: 'Set flow variables.',
5959
icon: 'variable', category: 'logic', source: 'builtin',
60+
// Designer form (ADR-0018, #3304): the canonical Studio shape — a
61+
// single free-form `assignments` map, rendered by the designer's
62+
// flat keyValue editor. Values stay `true`-permissive (literals,
63+
// `{var}` templates, numbers…). The legacy array / bare-config
64+
// shapes the executor also accepts are read-compatible and not
65+
// offered for new authoring. No `required`: an empty node is valid.
66+
configSchema: {
67+
type: 'object',
68+
properties: {
69+
assignments: { type: 'object', additionalProperties: true, title: 'Assignments', description: 'Set variables: each key is a variable, each value an expression or literal.' },
70+
},
71+
},
6072
}),
6173
async execute(node, variables, context) {
6274
const config = (node.config ?? {}) as Record<string, unknown>;

packages/services/service-automation/src/builtin/screen-nodes.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,37 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext
4343
icon: 'window', category: 'human', source: 'builtin',
4444
// Human-input nodes suspend the flow awaiting input.
4545
supportsPause: true, isAsync: true,
46+
// Designer form (ADR-0018, #3304) — mirrors objectui's hardcoded `screen`
47+
// field group: flat input list OR an object form, plus title/description.
48+
// `visibleWhen` is bare CEL (xExpression), `defaults` a free-form keyValue
49+
// map (values may be `{var}` templates → `true`-permissive).
50+
configSchema: {
51+
type: 'object',
52+
properties: {
53+
title: { type: 'string', title: 'Title', description: 'Heading shown above the screen.' },
54+
description: { type: 'string', format: 'multiline', title: 'Description', description: 'Body text. Interpolates {var} references (e.g. {approval_path}).' },
55+
fields: {
56+
type: 'array',
57+
title: 'Fields',
58+
description: 'Input fields collected on this screen. Leave empty for a message-only screen.',
59+
items: {
60+
type: 'object',
61+
properties: {
62+
name: { type: 'string', title: 'Name' },
63+
label: { type: 'string', title: 'Label' },
64+
type: { type: 'string', title: 'Type' },
65+
required: { type: 'boolean', title: 'Required' },
66+
visibleWhen: { type: 'string', title: 'Visible when', xExpression: 'expression' },
67+
},
68+
},
69+
},
70+
waitForInput: { type: 'boolean', title: 'Wait for input', description: 'Pause to show this screen even with no fields (a message / confirmation). A field-less screen with this off is a server pass-through.' },
71+
objectName: { type: 'string', title: 'Object form', xRef: { kind: 'object' }, description: 'Render this object’s full create/edit form (incl. master-detail) instead of a flat field list.' },
72+
idVariable: { type: 'string', title: 'Saved-record variable', description: 'Object form only: variable bound to the saved record’s id, for later steps.' },
73+
mode: { type: 'string', enum: ['create', 'edit'], default: 'create', title: 'Form mode', description: 'Object form only.' },
74+
defaults: { type: 'object', additionalProperties: true, title: 'Form defaults', description: 'Object form only: prefilled values (e.g. account → {account_id}).' },
75+
},
76+
},
4677
}),
4778
async execute(node, variables, context) {
4879
const cfg = (node.config ?? {}) as Record<string, unknown>;

0 commit comments

Comments
 (0)