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
18 changes: 18 additions & 0 deletions .changeset/multilookup-record-change-context.md
Original file line number Diff line number Diff line change
@@ -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}`).
Original file line number Diff line number Diff line change
@@ -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<string, unknown>([
['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=');
});
});
5 changes: 4 additions & 1 deletion packages/services/service-automation/src/builtin/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, Record<string, unknown>>();
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<AutomationEngine>('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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>; 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<string, unknown>; doc?: Record<string, unknown>; id?: unknown };
const after = ctx.result as Record<string, unknown> | undefined;
const previous =
(ctx.previous as Record<string, unknown> | undefined) ??
((ctx as unknown as { __previous?: Record<string, unknown> }).__previous ?? undefined);

const record: Record<string, unknown> =
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<string, unknown> =
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.<field>}` 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 {
Expand Down
Loading