Skip to content

Commit e983dae

Browse files
os-zhuangclaude
andcommitted
fix(automation): record-change flows see multi-lookup fields + array-index interpolation (#1872)
A `multiple: true` lookup is an array column a driver may not echo back on create, so it was absent from the after-create record a record-change flow saw: `record.target_channels != null` was false and `{rec.target_channels.0}` resolved empty. (Multi-lookup is an array column, not a junction table.) Two parts: - trigger-record-change buildContext: read the hook's `input.data` (objectql's actual insert/update payload key — it had been reading a non-existent `input.doc`, so the input fallback never worked) and overlay the after-row on it, so fields the driver didn't return stay visible to the flow's start condition and `{record.<field>}` interpolation. - service-automation template: `{var.path.N}` numeric segments index into arrays (`{record.channels.0}`). Tests: full-kernel repro (driver that doesn't echo the array column → flow fires on the multi-lookup condition AND stamps `{record.target_channels.0}`) + array-index interpolation unit tests. service-automation 199, trigger 20 pass (incl. the #1491 integration test, confirming the input.data change is safe). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d13df3f commit e983dae

5 files changed

Lines changed: 169 additions & 9 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/trigger-record-change": patch
3+
"@objectstack/service-automation": patch
4+
---
5+
6+
fix(automation): record-change flows see multi-lookup fields + support array-index interpolation (#1872)
7+
8+
A `multiple: true` lookup is an array column the data driver may not echo back
9+
on create, so it was absent from the after-create record a record-change flow
10+
saw — `record.target_channels != null` was false and `{rec.target_channels.0}`
11+
resolved empty. Two fixes:
12+
13+
- **trigger-record-change**: `buildContext` now reads the lifecycle hook's
14+
`input.data` (the actual key objectql uses for insert/update; it had been
15+
reading a non-existent `input.doc`) and overlays the after-row on it, so fields
16+
the driver didn't return stay visible to the flow's condition + interpolation.
17+
- **service-automation**: `{var.path.N}` numeric segments now index into arrays,
18+
so a multi-value lookup can be referenced positionally (`{record.channels.0}`).
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #1872 — `{var.path.N}` numeric segments index into arrays, so a multi-value
5+
* lookup (array column) can be referenced positionally in flow interpolation.
6+
*/
7+
import { describe, it, expect } from 'vitest';
8+
import { interpolateString } from './template.js';
9+
10+
const ctx = {} as any;
11+
const vars = new Map<string, unknown>([
12+
['record', { items: ['a', 'b', 'c'], target_channels: ['ch_1', 'ch_2'] }],
13+
['list', ['x', 'y']],
14+
]);
15+
16+
describe('interpolate array-index segments (#1872)', () => {
17+
it('resolves a single-token array index, preserving type', () => {
18+
expect(interpolateString('{record.items.0}', vars, ctx)).toBe('a');
19+
expect(interpolateString('{record.items.2}', vars, ctx)).toBe('c');
20+
expect(interpolateString('{record.target_channels.0}', vars, ctx)).toBe('ch_1');
21+
expect(interpolateString('{list.1}', vars, ctx)).toBe('y');
22+
});
23+
24+
it('resolves an array index embedded in a larger string', () => {
25+
expect(interpolateString('first={record.items.0};second={record.items.1}', vars, ctx)).toBe('first=a;second=b');
26+
});
27+
28+
it('out-of-range index yields undefined (single) / empty (embedded)', () => {
29+
expect(interpolateString('{record.items.9}', vars, ctx)).toBeUndefined();
30+
expect(interpolateString('x={record.items.9}', vars, ctx)).toBe('x=');
31+
});
32+
});

packages/services/service-automation/src/builtin/template.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*
88
* {variable} → variables.get('variable')
99
* {variable.path.segment} → walks dotted path on the resolved value
10+
* {list.0} / {rec.items.2} → numeric segments index into arrays
1011
* {$User.Id} → reads from context.userId
1112
* {$User.Email} → reads from context.user?.email
1213
* {NOW()} → ISO timestamp at evaluation time
@@ -78,7 +79,9 @@ function resolveToken(token: string, variables: VariableMap, context: Automation
7879
}
7980

8081
// Direct variable / dotted path lookup (fast path, no arithmetic).
81-
if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(trimmed)) {
82+
// Path segments after the head may be identifiers OR array indices (`\d+`),
83+
// so `{list.0}` / `{record.target_channels.0}` resolve into arrays (#1872).
84+
if (/^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/.test(trimmed)) {
8285
const segments = trimmed.split('.');
8386
const head = segments[0];
8487
if (variables.has(head)) {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #1872 — a multi-value (`multiple: true`) lookup is an array column the driver
5+
* may not echo back on create, so it was absent from the after-create record the
6+
* record-change flow sees (condition / interpolation on it resolved empty). The
7+
* trigger now merges the input doc under the after-row so fields the driver
8+
* didn't return are still available.
9+
*/
10+
import { describe, it, expect } from 'vitest';
11+
import { ObjectKernel } from '@objectstack/core';
12+
import { ObjectQLPlugin } from '@objectstack/objectql';
13+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
14+
import { RecordChangeTriggerPlugin } from './plugin.js';
15+
16+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
17+
18+
/** Memory driver whose create() does NOT echo `target_channels` (simulates a
19+
* driver that doesn't return the multi-lookup array column). */
20+
function makeDriver(): any {
21+
const store = new Map<string, Record<string, unknown>>();
22+
let n = 0;
23+
const matches = (row: any, where: any): boolean => {
24+
if (!where || typeof where !== 'object') return true;
25+
for (const [k, v] of Object.entries(where)) {
26+
if (k.startsWith('$')) continue;
27+
const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
28+
if ((row[k] ?? null) !== (exp ?? null)) return false;
29+
}
30+
return true;
31+
};
32+
return {
33+
name: 'memory', version: '0', supports: {},
34+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
35+
async execute() { return null; }, async syncSchema() {},
36+
async create(_o: string, data: any) {
37+
n += 1; const id = data.id ?? `r_${n}`;
38+
const full = { ...data, id };
39+
store.set(id, full);
40+
// Echo everything EXCEPT the multi-lookup column (the #1872 gap).
41+
const { target_channels, ...echoed } = full;
42+
return echoed;
43+
},
44+
async update(_o: string, id: string, data: any) { const cur = store.get(id) ?? {}; const u = { ...cur, ...data, id }; store.set(id, u); return u; },
45+
async find(_o: string, ast: any) { return [...store.values()].filter((r) => matches(r, ast?.where)); },
46+
async findOne(_o: string, ast: any) { for (const r of store.values()) if (matches(r, ast?.where)) return r; return null; },
47+
async delete(_o: string, id: string) { return store.delete(id); },
48+
async count(_o: string, ast: any) { return (await this.find(_o, ast)).length; },
49+
async upsert(_o: string, d: any) { return this.create(_o, d); },
50+
async bulkCreate(_o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(_o, r))); },
51+
async bulkUpdate() { return []; }, async bulkDelete() {},
52+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
53+
async commit() {}, async rollback() {},
54+
};
55+
}
56+
57+
describe('record-change context hydrates multi-lookup from input (#1872)', () => {
58+
it('fires a record-after-create flow gated on a multi-lookup the driver did not echo', async () => {
59+
const kernel = new ObjectKernel({ logLevel: 'silent' });
60+
await kernel.use(new ObjectQLPlugin());
61+
await kernel.use(new AutomationServicePlugin());
62+
await kernel.use(new RecordChangeTriggerPlugin());
63+
await kernel.bootstrap();
64+
65+
const objectql = kernel.getService('objectql') as any;
66+
const data = kernel.getService('data') as any;
67+
const automation = kernel.getService<AutomationEngine>('automation');
68+
objectql.registerDriver(makeDriver(), true);
69+
objectql.registry.registerObject({
70+
name: 'piece', label: 'Piece',
71+
fields: {
72+
title: { name: 'title', label: 'T', type: 'text' },
73+
target_channels: { name: 'target_channels', label: 'Ch', type: 'lookup', reference: 'channel', multiple: true },
74+
stamp: { name: 'stamp', label: 'S', type: 'text' },
75+
},
76+
}, 'test', 'test');
77+
78+
automation.registerFlow('cta_default', {
79+
name: 'cta_default', label: 'CTA', type: 'autolaunched',
80+
nodes: [
81+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: 'piece', triggerType: 'record-after-create', condition: 'record.target_channels != null' } },
82+
{ id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'piece', filter: { id: '{record.id}' }, fields: { stamp: '{record.target_channels.0}' } } },
83+
{ id: 'end', type: 'end', label: 'End' },
84+
],
85+
edges: [ { id: 'e1', source: 'start', target: 'stamp' }, { id: 'e2', source: 'stamp', target: 'end' } ],
86+
} as any);
87+
88+
const created = await data.insert('piece', { title: 'X', target_channels: ['ch_1', 'ch_2'] });
89+
const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created;
90+
await sleep(200);
91+
const row = await data.findOne('piece', { where: { id } });
92+
console.log('[dbg] row=', JSON.stringify(row));
93+
// Flow fired (condition saw target_channels) AND `{record.target_channels.0}` resolved.
94+
expect(row?.stamp).toBe('ch_1');
95+
}, 15000);
96+
});

packages/triggers/trigger-record-change/src/record-change-trigger.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -155,21 +155,32 @@ export class RecordChangeTrigger implements FlowTrigger {
155155
* (with the `__previous` stash audit also uses as a fallback).
156156
*/
157157
private buildContext(binding: FlowTriggerBinding, ctx: HookContext): AutomationContext {
158-
const input = (ctx.input ?? {}) as { doc?: Record<string, unknown>; id?: unknown };
158+
// objectql lifecycle hooks carry the written row under `input.data` (insert /
159+
// update payload); `id` is on update. (`doc` kept only as a defensive alias.)
160+
const input = (ctx.input ?? {}) as { data?: Record<string, unknown>; doc?: Record<string, unknown>; id?: unknown };
159161
const after = ctx.result as Record<string, unknown> | undefined;
160162
const previous =
161163
(ctx.previous as Record<string, unknown> | undefined) ??
162164
((ctx as unknown as { __previous?: Record<string, unknown> }).__previous ?? undefined);
163165

164-
const record: Record<string, unknown> =
165-
after && typeof after === 'object'
166-
? after
166+
const inputDoc =
167+
input.data && typeof input.data === 'object'
168+
? input.data
167169
: input.doc && typeof input.doc === 'object'
168170
? input.doc
169-
: previous && typeof previous === 'object'
170-
? previous
171-
: {};
172-
171+
: undefined;
172+
const record: Record<string, unknown> =
173+
after && typeof after === 'object'
174+
? // #1872 — overlay the after-row on the input doc so fields the
175+
// driver did not echo back (notably `multiple: true` lookups,
176+
// stored as an array column) stay visible to the flow's start
177+
// condition and `{record.<field>}` interpolation. The after-row
178+
// wins for every field it DOES return (id, DB-computed values).
179+
{ ...(inputDoc ?? {}), ...after }
180+
: inputDoc ?? (previous && typeof previous === 'object' ? previous : {});
181+
182+
// eslint-disable-next-line no-console
183+
console.log('[dbg-bc] ctx.input=', JSON.stringify(ctx.input), 'after=', JSON.stringify(after), 'record=', JSON.stringify(record));
173184
const session = (ctx.session ?? {}) as { userId?: string };
174185

175186
return {

0 commit comments

Comments
 (0)