Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/keyvalue-node-config-schemas.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, SchemaProp>;
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<string, SchemaProp>; 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();
});
});
48 changes: 48 additions & 0 deletions packages/services/service-automation/src/builtin/crud-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down
12 changes: 12 additions & 0 deletions packages/services/service-automation/src/builtin/logic-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down
31 changes: 31 additions & 0 deletions packages/services/service-automation/src/builtin/screen-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down
Loading