From e983dae61407fa502f948afe733099885dc1e18b Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 15 Jun 2026 22:31:06 +0800 Subject: [PATCH] fix(automation): record-change flows see multi-lookup fields + array-index interpolation (#1872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.}` 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) --- .../multilookup-record-change-context.md | 18 ++++ .../src/builtin/template-array-index.test.ts | 32 +++++++ .../src/builtin/template.ts | 5 +- .../src/multilookup-context.test.ts | 96 +++++++++++++++++++ .../src/record-change-trigger.ts | 27 ++++-- 5 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 .changeset/multilookup-record-change-context.md create mode 100644 packages/services/service-automation/src/builtin/template-array-index.test.ts create mode 100644 packages/triggers/trigger-record-change/src/multilookup-context.test.ts diff --git a/.changeset/multilookup-record-change-context.md b/.changeset/multilookup-record-change-context.md new file mode 100644 index 0000000000..0345cbd163 --- /dev/null +++ b/.changeset/multilookup-record-change-context.md @@ -0,0 +1,18 @@ +--- +"@objectstack/trigger-record-change": patch +"@objectstack/service-automation": patch +--- + +fix(automation): record-change flows see multi-lookup fields + support array-index interpolation (#1872) + +A `multiple: true` lookup is an array column the data 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. Two fixes: + +- **trigger-record-change**: `buildContext` now reads the lifecycle hook's + `input.data` (the actual key objectql uses for insert/update; it had been + reading a non-existent `input.doc`) and overlays the after-row on it, so fields + the driver didn't return stay visible to the flow's condition + interpolation. +- **service-automation**: `{var.path.N}` numeric segments now index into arrays, + so a multi-value lookup can be referenced positionally (`{record.channels.0}`). diff --git a/packages/services/service-automation/src/builtin/template-array-index.test.ts b/packages/services/service-automation/src/builtin/template-array-index.test.ts new file mode 100644 index 0000000000..92c94fe346 --- /dev/null +++ b/packages/services/service-automation/src/builtin/template-array-index.test.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #1872 — `{var.path.N}` numeric segments index into arrays, so a multi-value + * lookup (array column) can be referenced positionally in flow interpolation. + */ +import { describe, it, expect } from 'vitest'; +import { interpolateString } from './template.js'; + +const ctx = {} as any; +const vars = new Map([ + ['record', { items: ['a', 'b', 'c'], target_channels: ['ch_1', 'ch_2'] }], + ['list', ['x', 'y']], +]); + +describe('interpolate array-index segments (#1872)', () => { + it('resolves a single-token array index, preserving type', () => { + expect(interpolateString('{record.items.0}', vars, ctx)).toBe('a'); + expect(interpolateString('{record.items.2}', vars, ctx)).toBe('c'); + expect(interpolateString('{record.target_channels.0}', vars, ctx)).toBe('ch_1'); + expect(interpolateString('{list.1}', vars, ctx)).toBe('y'); + }); + + it('resolves an array index embedded in a larger string', () => { + expect(interpolateString('first={record.items.0};second={record.items.1}', vars, ctx)).toBe('first=a;second=b'); + }); + + it('out-of-range index yields undefined (single) / empty (embedded)', () => { + expect(interpolateString('{record.items.9}', vars, ctx)).toBeUndefined(); + expect(interpolateString('x={record.items.9}', vars, ctx)).toBe('x='); + }); +}); diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index 2c68510e4e..f827f73635 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -7,6 +7,7 @@ * * {variable} → variables.get('variable') * {variable.path.segment} → walks dotted path on the resolved value + * {list.0} / {rec.items.2} → numeric segments index into arrays * {$User.Id} → reads from context.userId * {$User.Email} → reads from context.user?.email * {NOW()} → ISO timestamp at evaluation time @@ -78,7 +79,9 @@ function resolveToken(token: string, variables: VariableMap, context: Automation } // Direct variable / dotted path lookup (fast path, no arithmetic). - if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*$/.test(trimmed)) { + // Path segments after the head may be identifiers OR array indices (`\d+`), + // so `{list.0}` / `{record.target_channels.0}` resolve into arrays (#1872). + if (/^[A-Za-z_$][\w$]*(?:\.(?:[A-Za-z_$][\w$]*|\d+))*$/.test(trimmed)) { const segments = trimmed.split('.'); const head = segments[0]; if (variables.has(head)) { diff --git a/packages/triggers/trigger-record-change/src/multilookup-context.test.ts b/packages/triggers/trigger-record-change/src/multilookup-context.test.ts new file mode 100644 index 0000000000..37f030fb1f --- /dev/null +++ b/packages/triggers/trigger-record-change/src/multilookup-context.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #1872 — a multi-value (`multiple: true`) lookup is an array column the driver + * may not echo back on create, so it was absent from the after-create record the + * record-change flow sees (condition / interpolation on it resolved empty). The + * trigger now merges the input doc under the after-row so fields the driver + * didn't return are still available. + */ +import { describe, it, expect } from 'vitest'; +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; +import { RecordChangeTriggerPlugin } from './plugin.js'; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Memory driver whose create() does NOT echo `target_channels` (simulates a + * driver that doesn't return the multi-lookup array column). */ +function makeDriver(): any { + const store = new Map>(); + let n = 0; + const matches = (row: any, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const exp = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (exp ?? null)) return false; + } + return true; + }; + return { + name: 'memory', version: '0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, async syncSchema() {}, + async create(_o: string, data: any) { + n += 1; const id = data.id ?? `r_${n}`; + const full = { ...data, id }; + store.set(id, full); + // Echo everything EXCEPT the multi-lookup column (the #1872 gap). + const { target_channels, ...echoed } = full; + return echoed; + }, + async update(_o: string, id: string, data: any) { const cur = store.get(id) ?? {}; const u = { ...cur, ...data, id }; store.set(id, u); return u; }, + async find(_o: string, ast: any) { return [...store.values()].filter((r) => matches(r, ast?.where)); }, + async findOne(_o: string, ast: any) { for (const r of store.values()) if (matches(r, ast?.where)) return r; return null; }, + async delete(_o: string, id: string) { return store.delete(id); }, + async count(_o: string, ast: any) { return (await this.find(_o, ast)).length; }, + async upsert(_o: string, d: any) { return this.create(_o, d); }, + async bulkCreate(_o: string, rows: any[]) { return Promise.all(rows.map((r) => this.create(_o, r))); }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; +} + +describe('record-change context hydrates multi-lookup from input (#1872)', () => { + it('fires a record-after-create flow gated on a multi-lookup the driver did not echo', async () => { + const kernel = new ObjectKernel({ logLevel: 'silent' }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new RecordChangeTriggerPlugin()); + await kernel.bootstrap(); + + const objectql = kernel.getService('objectql') as any; + const data = kernel.getService('data') as any; + const automation = kernel.getService('automation'); + objectql.registerDriver(makeDriver(), true); + objectql.registry.registerObject({ + name: 'piece', label: 'Piece', + fields: { + title: { name: 'title', label: 'T', type: 'text' }, + target_channels: { name: 'target_channels', label: 'Ch', type: 'lookup', reference: 'channel', multiple: true }, + stamp: { name: 'stamp', label: 'S', type: 'text' }, + }, + }, 'test', 'test'); + + automation.registerFlow('cta_default', { + name: 'cta_default', label: 'CTA', type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'piece', triggerType: 'record-after-create', condition: 'record.target_channels != null' } }, + { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: 'piece', filter: { id: '{record.id}' }, fields: { stamp: '{record.target_channels.0}' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ { id: 'e1', source: 'start', target: 'stamp' }, { id: 'e2', source: 'stamp', target: 'end' } ], + } as any); + + const created = await data.insert('piece', { title: 'X', target_channels: ['ch_1', 'ch_2'] }); + const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; + await sleep(200); + const row = await data.findOne('piece', { where: { id } }); + console.log('[dbg] row=', JSON.stringify(row)); + // Flow fired (condition saw target_channels) AND `{record.target_channels.0}` resolved. + expect(row?.stamp).toBe('ch_1'); + }, 15000); +}); diff --git a/packages/triggers/trigger-record-change/src/record-change-trigger.ts b/packages/triggers/trigger-record-change/src/record-change-trigger.ts index 36a005d77e..e6db88f0de 100644 --- a/packages/triggers/trigger-record-change/src/record-change-trigger.ts +++ b/packages/triggers/trigger-record-change/src/record-change-trigger.ts @@ -155,21 +155,32 @@ export class RecordChangeTrigger implements FlowTrigger { * (with the `__previous` stash audit also uses as a fallback). */ private buildContext(binding: FlowTriggerBinding, ctx: HookContext): AutomationContext { - const input = (ctx.input ?? {}) as { doc?: Record; id?: unknown }; + // objectql lifecycle hooks carry the written row under `input.data` (insert / + // update payload); `id` is on update. (`doc` kept only as a defensive alias.) + const input = (ctx.input ?? {}) as { data?: Record; doc?: Record; id?: unknown }; const after = ctx.result as Record | undefined; const previous = (ctx.previous as Record | undefined) ?? ((ctx as unknown as { __previous?: Record }).__previous ?? undefined); - const record: Record = - after && typeof after === 'object' - ? after + const inputDoc = + input.data && typeof input.data === 'object' + ? input.data : input.doc && typeof input.doc === 'object' ? input.doc - : previous && typeof previous === 'object' - ? previous - : {}; - + : undefined; + const record: Record = + after && typeof after === 'object' + ? // #1872 — overlay the after-row on the input doc so fields the + // driver did not echo back (notably `multiple: true` lookups, + // stored as an array column) stay visible to the flow's start + // condition and `{record.}` interpolation. The after-row + // wins for every field it DOES return (id, DB-computed values). + { ...(inputDoc ?? {}), ...after } + : inputDoc ?? (previous && typeof previous === 'object' ? previous : {}); + + // eslint-disable-next-line no-console + console.log('[dbg-bc] ctx.input=', JSON.stringify(ctx.input), 'after=', JSON.stringify(after), 'record=', JSON.stringify(record)); const session = (ctx.session ?? {}) as { userId?: string }; return {