Skip to content

Commit 5c274e3

Browse files
os-zhuangclaude
andcommitted
fix(automation): create_record outputVariable exposes the record so {var.id} resolves (#1873)
A `create_record` node set `variables[outputVariable]` to the bare inserted id STRING, so a later `{var.id}` (the documented pattern, e.g. content `signal_to_topic_promotion`'s `promoted_topic: '{topic.id}'`) traversed into a string and resolved to empty — the created record was unreferenceable downstream. `get_record` already stores the record object (hence `{rec.field}` works); this aligns `create_record` with that convention. Now stores the created record object (array → first row; bare-id driver return → wrapped as `{ id }`). Updated the one in-repo flow that relied on bare `{newTaskId}` (app-todo) to `{newTaskId.id}`. Adds an engine-level regression test (create_record → update_record `{topic.id}` / `{topic.title}`). Full automation suite + app-todo/app-crm builds green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 39a51e1 commit 5c274e3

4 files changed

Lines changed: 118 additions & 6 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-automation": minor
3+
---
4+
5+
fix(automation): `create_record` outputVariable exposes the created record so `{var.id}` resolves (#1873)
6+
7+
A `create_record` node stored only the created record's **id string** in its
8+
`outputVariable`, so a later node referencing `{var.id}` (or any `{var.<field>}`)
9+
traversed into a string and resolved to empty — the created record was
10+
effectively unreferenceable downstream. `get_record` already stores the record
11+
object (that's why `{rec.field}` works there); `create_record` now matches.
12+
13+
Behavior change: `outputVariable` holds the created **record** (an object with
14+
`id` + fields), not the bare id. Reference the id explicitly as `{var.id}`. A
15+
bare `{var}` that previously yielded the id now yields the record — update such
16+
references to `{var.id}` (the in-repo `app-todo` create-task flow was updated).
17+
When the driver returns a bare id, it's wrapped as `{ id }` so `{var.id}` still works.

examples/app-todo/src/flows/task.flow.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export const QuickAddTaskFlow: Flow = {
194194
message: 'Task "{subject}" created successfully!',
195195
buttons: [
196196
{ label: 'Create Another', action: 'restart' },
197-
{ label: 'View Task', action: 'navigate', target: '/task/{newTaskId}' },
197+
{ label: 'View Task', action: 'navigate', target: '/task/{newTaskId.id}' },
198198
{ label: 'Done', action: 'finish' },
199199
],
200200
},

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

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,28 @@ export function registerCrudNodes(engine: AutomationEngine, ctx: PluginContext):
9090
const data = getData();
9191
if (!data) {
9292
ctx.logger.warn(`[create_record] no data engine; skipping ${objectName}`);
93-
if (outputVariable) variables.set(outputVariable, `mock-${objectName}-${Date.now()}`);
94-
return { success: true, output: { id: `mock-${objectName}-${Date.now()}`, object: objectName } };
93+
const mockId = `mock-${objectName}-${Date.now()}`;
94+
if (outputVariable) variables.set(outputVariable, { id: mockId });
95+
return { success: true, output: { id: mockId, object: objectName } };
9596
}
9697

9798
try {
9899
const created = await data.insert(objectName, fields);
99-
const insertedId = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
100-
if (outputVariable) variables.set(outputVariable, insertedId);
101-
return { success: true, output: { id: insertedId, record: created, object: objectName } };
100+
const createdRecord = Array.isArray(created) ? created[0] : created;
101+
const insertedId =
102+
createdRecord && typeof createdRecord === 'object'
103+
? (createdRecord as Record<string, unknown>).id
104+
: createdRecord;
105+
if (outputVariable) {
106+
// #1873 — expose the created RECORD so later nodes can reference
107+
// `{var.id}` (and other fields), not just the bare id string. When the
108+
// driver returns a bare id, wrap it as `{ id }` so `{var.id}` still works.
109+
variables.set(
110+
outputVariable,
111+
createdRecord && typeof createdRecord === 'object' ? createdRecord : { id: insertedId },
112+
);
113+
}
114+
return { success: true, output: { id: insertedId, record: createdRecord, object: objectName } };
102115
} catch (err) {
103116
return { success: false, error: `create_record(${objectName}) failed: ${(err as Error).message}` };
104117
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #1873 — a `create_record` node's `outputVariable` must expose the created
5+
* record so a later node can reference `{var.id}` (and other fields). Before the
6+
* fix the output variable held only the bare id STRING, so `{var.id}` traversed
7+
* into a string and resolved to empty.
8+
*/
9+
import { describe, it, expect } from 'vitest';
10+
import { AutomationEngine } from '../engine.js';
11+
import { registerCrudNodes } from './crud-nodes.js';
12+
13+
function makeLogger(): any {
14+
const l: any = { info() {}, warn() {}, error() {}, debug() {} };
15+
l.child = () => l;
16+
return l;
17+
}
18+
19+
function fakeData() {
20+
const updates: Array<{ obj: string; fields: any; opts: any }> = [];
21+
let n = 0;
22+
const data: any = {
23+
async insert(obj: string, fields: any) { n += 1; return { id: `${obj}_${n}`, ...fields }; },
24+
async update(obj: string, fields: any, opts: any) { updates.push({ obj, fields, opts }); return { ok: true }; },
25+
async find() { return []; },
26+
async findOne() { return null; },
27+
};
28+
return { data, updates };
29+
}
30+
31+
const ctxWith = (data: any): any => ({ logger: makeLogger(), getService: (n: string) => (n === 'data' ? data : undefined) });
32+
33+
describe('create_record outputVariable (#1873)', () => {
34+
it('exposes the created record so {var.id} resolves in a later node', async () => {
35+
const engine = new AutomationEngine(makeLogger());
36+
const { data, updates } = fakeData();
37+
registerCrudNodes(engine, ctxWith(data));
38+
39+
engine.registerFlow('promote', {
40+
name: 'promote', label: 'P', type: 'autolaunched',
41+
nodes: [
42+
{ id: 'start', type: 'start', label: 'Start' },
43+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', outputVariable: 'topic', fields: { title: 'X' } } },
44+
{ id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fields: { promoted_topic: '{topic.id}' } } },
45+
{ id: 'end', type: 'end', label: 'End' },
46+
],
47+
edges: [
48+
{ id: 'e1', source: 'start', target: 'mk' },
49+
{ id: 'e2', source: 'mk', target: 'upd' },
50+
{ id: 'e3', source: 'upd', target: 'end' },
51+
],
52+
} as any);
53+
54+
const res = await engine.execute('promote');
55+
expect(res.success).toBe(true);
56+
expect(updates).toHaveLength(1);
57+
expect(updates[0].fields.promoted_topic).toBe('topic_1');
58+
});
59+
60+
it('exposes non-id fields of the created record too ({var.title})', async () => {
61+
const engine = new AutomationEngine(makeLogger());
62+
const { data, updates } = fakeData();
63+
registerCrudNodes(engine, ctxWith(data));
64+
engine.registerFlow('promote2', {
65+
name: 'promote2', label: 'P', type: 'autolaunched',
66+
nodes: [
67+
{ id: 'start', type: 'start', label: 'Start' },
68+
{ id: 'mk', type: 'create_record', label: 'Create', config: { objectName: 'topic', outputVariable: 'topic', fields: { title: 'X' } } },
69+
{ id: 'upd', type: 'update_record', label: 'Update', config: { objectName: 'signal', filter: { id: 'sig1' }, fields: { ref: '{topic.title}' } } },
70+
{ id: 'end', type: 'end', label: 'End' },
71+
],
72+
edges: [
73+
{ id: 'e1', source: 'start', target: 'mk' },
74+
{ id: 'e2', source: 'mk', target: 'upd' },
75+
{ id: 'e3', source: 'upd', target: 'end' },
76+
],
77+
} as any);
78+
const res = await engine.execute('promote2');
79+
expect(res.success).toBe(true);
80+
expect(updates[0].fields.ref).toBe('X');
81+
});
82+
});

0 commit comments

Comments
 (0)