diff --git a/.changeset/keyvalue-node-config-schemas.md b/.changeset/keyvalue-node-config-schemas.md new file mode 100644 index 0000000000..207d432c85 --- /dev/null +++ b/.changeset/keyvalue-node-config-schemas.md @@ -0,0 +1,26 @@ +--- +"@objectstack/service-automation": patch +--- + +feat(automation): publish configSchemas for the keyValue-capable nodes (flow designer parity, #3304) + +The `assignment`, `create_record` / `update_record` / `delete_record` / +`get_record`, and `screen` nodes shipped no `configSchema`, so the flow designer +had no server-driven form for them. Each descriptor now carries one that mirrors +the objectui hardcoded field group field-for-field: object references as `xRef`, +the screen repeater's `visibleWhen` as `xExpression: 'expression'`, and the +free-form maps (`fields` / `filter` / `assignments` / `defaults`) as JSON-Schema +open objects (`additionalProperties: true`, no fixed `properties`) — the shape +the designer's schema adapter renders with its flat keyValue editor. Values stay +fully permissive because real metadata carries operator objects (`{"$ne": null}`), +`{var}` templates, and non-string literals. + +Deliberately still schemaless (no online/offline divergence exists for a node +with no configSchema, and a partial schema would drop editors): `decision` +(virtual Target column derived from edges), `wait` (top-level `waitEventConfig`), +`script` (actionType-conditional form), `subflow` (top-level `timeoutMs`). + +Additive and backward-compatible: descriptor metadata only, no runtime behavior +change. Requires an objectui with the keyValue schema mapping (objectui #2708) +for the maps to render as structured editors; older designers keep their +hardcoded forms. diff --git a/packages/services/service-automation/src/builtin/config-schemas.test.ts b/packages/services/service-automation/src/builtin/config-schemas.test.ts new file mode 100644 index 0000000000..b8a9cbb163 --- /dev/null +++ b/packages/services/service-automation/src/builtin/config-schemas.test.ts @@ -0,0 +1,110 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Designer-parity configSchemas (#3304 — descriptor counterpart to objectui + * #2670 Phase 3). The keyValue-capable nodes now publish a `configSchema` that + * mirrors objectui's hardcoded field group, so the online (schema-driven) form + * matches the offline one. The load-bearing shape is the free-form map — + * `type: 'object'` + `additionalProperties: true` and NO fixed `properties` — + * which the designer renders with its flat keyValue editor; `true`-permissive + * because real metadata carries operator objects (`{"$ne": null}`), `{var}` + * templates, and non-string literals as values. + * + * Deliberately schemaless (stay on the hardcoded designer form; a node with no + * configSchema has NO online/offline divergence): `decision` (virtual Target + * column derived from edges), `wait` (top-level `waitEventConfig` block), + * `script` (actionType-conditional form) and `subflow` (top-level `timeoutMs`) + * — a partial schema would drop those editors. + */ + +import { describe, it, expect } from 'vitest'; +import { AutomationEngine } from '../engine.js'; +import { registerCrudNodes } from './crud-nodes.js'; +import { registerLogicNodes } from './logic-nodes.js'; +import { registerScreenNodes } from './screen-nodes.js'; + +function silentLogger() { + return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any; +} +function ctx() { + return { logger: silentLogger(), getService() { throw new Error('none'); } } as any; +} + +interface SchemaProp { + type?: string; + format?: string; + properties?: Record; + additionalProperties?: unknown; + items?: SchemaProp; + enum?: unknown[]; + xRef?: { kind?: string }; + xExpression?: string; +} + +function schemaOf(engine: AutomationEngine, type: string) { + const schema = engine.getActionDescriptor(type)?.configSchema as + | { properties?: Record; required?: string[] } + | undefined; + expect(schema, `${type} should publish a configSchema`).toBeDefined(); + return schema!; +} + +/** The keyValue contract: an open map with a value schema and no fixed props. */ +function expectKeyValueMap(prop: SchemaProp | undefined, label: string) { + expect(prop, label).toBeDefined(); + expect(prop!.type).toBe('object'); + expect(prop!.additionalProperties).toBe(true); + expect(prop!.properties).toBeUndefined(); +} + +describe('builtin node configSchemas — designer parity (#3304)', () => { + const engine = new AutomationEngine(silentLogger()); + registerCrudNodes(engine, ctx()); + registerLogicNodes(engine, ctx()); + registerScreenNodes(engine, ctx()); + + it('CRUD quartet: object reference + keyValue maps, objectName required', () => { + const get = schemaOf(engine, 'get_record'); + expect(get.properties?.objectName?.xRef?.kind).toBe('object'); + expectKeyValueMap(get.properties?.filter, 'get_record.filter'); + expect(get.properties?.limit?.type).toBe('integer'); + expect(get.required).toEqual(['objectName']); + + const create = schemaOf(engine, 'create_record'); + expect(create.properties?.objectName?.xRef?.kind).toBe('object'); + expectKeyValueMap(create.properties?.fields, 'create_record.fields'); + + const update = schemaOf(engine, 'update_record'); + expectKeyValueMap(update.properties?.filter, 'update_record.filter'); + expectKeyValueMap(update.properties?.fields, 'update_record.fields'); + + const del = schemaOf(engine, 'delete_record'); + expectKeyValueMap(del.properties?.filter, 'delete_record.filter'); + }); + + it('assignment: a single free-form assignments map, nothing required', () => { + const schema = schemaOf(engine, 'assignment'); + expectKeyValueMap(schema.properties?.assignments, 'assignment.assignments'); + expect(schema.required).toBeUndefined(); + }); + + it('screen: field list with a CEL visibleWhen column, object-form refs, defaults map', () => { + const schema = schemaOf(engine, 'screen'); + // The repeater's visibleWhen column is bare CEL → expression column. + expect(schema.properties?.fields?.items?.properties?.visibleWhen?.xExpression).toBe('expression'); + expect(schema.properties?.fields?.items?.properties?.required?.type).toBe('boolean'); + expect(schema.properties?.objectName?.xRef?.kind).toBe('object'); + expect(schema.properties?.mode?.enum).toEqual(['create', 'edit']); + expect(schema.properties?.description?.format).toBe('multiline'); + expectKeyValueMap(schema.properties?.defaults, 'screen.defaults'); + }); + + it('decision / script stay deliberately schemaless (no partial forms)', () => { + // A node with no configSchema renders identically online and offline (the + // hardcoded fallback), so there is no divergence — and publishing a partial + // schema would DROP editors the adapter cannot express (decision's virtual + // Target column; script's actionType-conditional fields). + expect(engine.getActionDescriptor('decision')?.configSchema).toBeUndefined(); + expect(engine.getActionDescriptor('script')?.configSchema).toBeUndefined(); + }); +}); diff --git a/packages/services/service-automation/src/builtin/crud-nodes.ts b/packages/services/service-automation/src/builtin/crud-nodes.ts index 4dbcb1e605..bf2f0fdcb5 100644 --- a/packages/services/service-automation/src/builtin/crud-nodes.ts +++ b/packages/services/service-automation/src/builtin/crud-nodes.ts @@ -41,6 +41,22 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): type: 'get_record', version: '1.0.0', name: 'Get Records', description: 'Query records from an object.', icon: 'search', category: 'data', source: 'builtin', + // Structured designer form (ADR-0018, #3304) — mirrors objectui's + // hardcoded `get_record` field group. `filter` is a free-form + // string-keyed map (JSON-Schema `additionalProperties`), which the + // designer renders with its flat keyValue editor; values stay + // `true`-permissive because real metadata carries operator objects + // (e.g. `{"$ne": null}`) alongside `{var}` templates and literals. + configSchema: { + type: 'object', + properties: { + objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } }, + filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs to match (e.g. status → active). Operator values like {"$ne": null} are preserved.' }, + limit: { type: 'integer', title: 'Limit' }, + outputVariable: { type: 'string', title: 'Output variable' }, + }, + required: ['objectName'], + }, }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; @@ -86,6 +102,17 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): type: 'create_record', version: '1.0.0', name: 'Create Record', description: 'Insert a new record into an object.', icon: 'plus-circle', category: 'data', source: 'builtin', + // Designer form (ADR-0018, #3304) — see get_record for the + // keyValue-map rationale. + configSchema: { + type: 'object', + properties: { + objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } }, + fields: { type: 'object', additionalProperties: true, title: 'Field values', description: 'Field values to write on the new record.' }, + outputVariable: { type: 'string', title: 'Output variable' }, + }, + required: ['objectName'], + }, }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; @@ -135,6 +162,17 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): type: 'update_record', version: '1.0.0', name: 'Update Records', description: 'Update records matching a filter.', icon: 'edit', category: 'data', source: 'builtin', + // Designer form (ADR-0018, #3304) — see get_record for the + // keyValue-map rationale. + configSchema: { + type: 'object', + properties: { + objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } }, + filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs identifying the record(s) to update (e.g. id → {recordId}).' }, + fields: { type: 'object', additionalProperties: true, title: 'Field values', description: 'Field values to write.' }, + }, + required: ['objectName'], + }, }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; @@ -171,6 +209,16 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext): type: 'delete_record', version: '1.0.0', name: 'Delete Records', description: 'Delete records matching a filter.', icon: 'trash', category: 'data', source: 'builtin', + // Designer form (ADR-0018, #3304) — see get_record for the + // keyValue-map rationale. + configSchema: { + type: 'object', + properties: { + objectName: { type: 'string', title: 'Object', xRef: { kind: 'object' } }, + filter: { type: 'object', additionalProperties: true, title: 'Filter', description: 'Field/value pairs identifying the record(s) to delete.' }, + }, + required: ['objectName'], + }, }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record; diff --git a/packages/services/service-automation/src/builtin/logic-nodes.ts b/packages/services/service-automation/src/builtin/logic-nodes.ts index 31e71b89b0..099bf0a360 100644 --- a/packages/services/service-automation/src/builtin/logic-nodes.ts +++ b/packages/services/service-automation/src/builtin/logic-nodes.ts @@ -57,6 +57,18 @@ export function registerLogicNodes(engine: AutomationEngine, ctx: PluginContext) type: 'assignment', version: '1.0.0', name: 'Assignment', description: 'Set flow variables.', icon: 'variable', category: 'logic', source: 'builtin', + // Designer form (ADR-0018, #3304): the canonical Studio shape — a + // single free-form `assignments` map, rendered by the designer's + // flat keyValue editor. Values stay `true`-permissive (literals, + // `{var}` templates, numbers…). The legacy array / bare-config + // shapes the executor also accepts are read-compatible and not + // offered for new authoring. No `required`: an empty node is valid. + configSchema: { + type: 'object', + properties: { + assignments: { type: 'object', additionalProperties: true, title: 'Assignments', description: 'Set variables: each key is a variable, each value an expression or literal.' }, + }, + }, }), async execute(node, variables, context) { const config = (node.config ?? {}) as Record; diff --git a/packages/services/service-automation/src/builtin/screen-nodes.ts b/packages/services/service-automation/src/builtin/screen-nodes.ts index b3fb82c2ed..65c4a6ee92 100644 --- a/packages/services/service-automation/src/builtin/screen-nodes.ts +++ b/packages/services/service-automation/src/builtin/screen-nodes.ts @@ -43,6 +43,37 @@ export function registerScreenNodes(engine: AutomationEngine, ctx: PluginContext icon: 'window', category: 'human', source: 'builtin', // Human-input nodes suspend the flow awaiting input. supportsPause: true, isAsync: true, + // Designer form (ADR-0018, #3304) — mirrors objectui's hardcoded `screen` + // field group: flat input list OR an object form, plus title/description. + // `visibleWhen` is bare CEL (xExpression), `defaults` a free-form keyValue + // map (values may be `{var}` templates → `true`-permissive). + configSchema: { + type: 'object', + properties: { + title: { type: 'string', title: 'Title', description: 'Heading shown above the screen.' }, + description: { type: 'string', format: 'multiline', title: 'Description', description: 'Body text. Interpolates {var} references (e.g. {approval_path}).' }, + fields: { + type: 'array', + title: 'Fields', + description: 'Input fields collected on this screen. Leave empty for a message-only screen.', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + label: { type: 'string', title: 'Label' }, + type: { type: 'string', title: 'Type' }, + required: { type: 'boolean', title: 'Required' }, + visibleWhen: { type: 'string', title: 'Visible when', xExpression: 'expression' }, + }, + }, + }, + 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.' }, + 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.' }, + idVariable: { type: 'string', title: 'Saved-record variable', description: 'Object form only: variable bound to the saved record’s id, for later steps.' }, + mode: { type: 'string', enum: ['create', 'edit'], default: 'create', title: 'Form mode', description: 'Object form only.' }, + defaults: { type: 'object', additionalProperties: true, title: 'Form defaults', description: 'Object form only: prefilled values (e.g. account → {account_id}).' }, + }, + }, }), async execute(node, variables, context) { const cfg = (node.config ?? {}) as Record;