diff --git a/.changeset/record-change-trigger-e2e-1491.md b/.changeset/record-change-trigger-e2e-1491.md new file mode 100644 index 0000000000..24b852b2a2 --- /dev/null +++ b/.changeset/record-change-trigger-e2e-1491.md @@ -0,0 +1,14 @@ +--- +--- + +test(trigger-record-change): add end-to-end regression coverage for record-change flows (#1491) + +Test-only — no shipped-code change, so no version bump. #1491 reported that +record-change flows never fired on data writes (7.4.1–7.7.0); it no longer +reproduces on current `main` (fixed by the triggers-first-class-dir + flow-engine +alignment refactor). The existing tests only used a fake data engine, so the real +path was uncovered. Adds a full-kernel integration test (ObjectQL + automation + +record-change trigger + in-memory driver) asserting a `record-after-create` flow +fires and its `update_record` writes back — in both registration orderings +(direct `registerFlow`, and registry-pull at `automation.start()` with the +trigger binding on `kernel:ready`). diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 2dbf9f37ba..9edce3e32c 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -21,6 +21,8 @@ "@objectstack/spec": "workspace:*" }, "devDependencies": { + "@objectstack/objectql": "workspace:*", + "@objectstack/service-automation": "workspace:*", "@types/node": "^25.9.2", "typescript": "^6.0.3", "vitest": "^4.1.8" diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts new file mode 100644 index 0000000000..f0af24038e --- /dev/null +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -0,0 +1,195 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * End-to-end integration test for the record-change trigger (#1491). + * + * #1491 reported that record-change flows never fired on data writes (observed + * 7.4.1–7.7.0). The existing unit tests only exercised a *fake* data engine, so + * they never covered the real path: a flow pulled into the automation engine, + * the trigger binding to an ObjectQL lifecycle hook on `kernel:ready`, an actual + * insert firing that hook, and the flow's `update_record` writing back through + * the live data engine. This test boots a real kernel (ObjectQL + automation + + * record-change trigger + in-memory driver) and asserts the full chain — in BOTH + * registration orderings, since the engine relies on re-activating already-pulled + * flows when the trigger registers later. + */ + +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)); + +/** + * A tiny equality-WHERE in-memory driver — enough to exercise the real engine's + * insert/update/find path without pulling a driver package as a dependency + * (mirrors objectql's own real-engine test helper). One record store per object. + */ +function makeMemoryDriver(): any { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + if (Array.isArray(where.$and)) return where.$and.every((w: any) => matches(row, w)); + if (Array.isArray(where.$or)) return where.$or.some((w: any) => matches(row, w)); + for (const [k, v] of Object.entries(where)) { + if (k.startsWith('$')) continue; + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + const a = row[k] === undefined ? null : row[k]; + const b = expected === undefined ? null : expected; + if (a !== b) return false; + } + return true; + }; + return { + name: 'memory', version: '0.0.0', supports: {}, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, async syncSchema() {}, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + }, + findStream() { throw new Error('not implemented'); }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) throw new Error(`not found: ${object}/${id}`); + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, async bulkDelete() {}, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; +} + +/** A flow that stamps `stamp: 'done'` on the just-created record of `object`. */ +function stampFlow(name: string, object: string) { + return { + name, + label: name, + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-create' } }, + { id: 'stamp', type: 'update_record', label: 'Stamp', config: { objectName: object, filter: { id: '{record.id}' }, fields: { stamp: 'done' } } }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'stamp' }, + { id: 'e2', source: 'stamp', target: 'end' }, + ], + }; +} + +const objectDef = (name: string) => ({ + name, + label: name, + fields: { + status: { name: 'status', label: 'S', type: 'text' }, + stamp: { name: 'stamp', label: 'St', type: 'text' }, + }, +}); + +describe('record-change trigger — end-to-end (#1491)', () => { + it('fires a record-after-create flow registered AFTER the trigger (engine.registerFlow path)', 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(makeMemoryDriver(), true); + objectql.registry.registerObject(objectDef('wid'), 'test', 'test'); + automation.registerFlow('stamp_flow', stampFlow('stamp_flow', 'wid') as any); + + // The flow bound to the trigger… + expect((automation as any).getActiveTriggerBindings()).toContainEqual({ + flowName: 'stamp_flow', + triggerType: 'record_change', + }); + + const created = await data.insert('wid', { status: 'new' }); + const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; + await sleep(200); + + const row = await data.findOne('wid', { where: { id } }); + expect(row?.stamp).toBe('done'); + }, 15000); + + it('fires a flow PULLED FROM THE REGISTRY at automation.start(), bound when the trigger registers on kernel:ready (production ordering)', async () => { + const flowDef = stampFlow('stamp_flow2', 'wid2'); + + // Seeds the driver + object + flow into the registry in start(), which runs + // before AutomationServicePlugin.start() pulls flows — the production + // sequence (metadata seeds → automation pulls → trigger binds on + // kernel:ready via re-activation of the already-registered flow). + const seeder = { + name: 'test.seeder', + type: 'standard', + version: '1.0.0', + dependencies: ['com.objectstack.engine.objectql'], + async init() {}, + async start(ctx: any) { + const ql = ctx.getService('objectql'); + ql.registerDriver(makeMemoryDriver(), true); + ql.registry.registerObject(objectDef('wid2'), 'test', 'test'); + ql.registry.registerItem('flow', flowDef, 'name', 'test'); + }, + }; + + const kernel = new ObjectKernel({ logLevel: 'silent' }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(seeder as any); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new RecordChangeTriggerPlugin()); + await kernel.bootstrap(); + + const data = kernel.getService('data') as any; + const automation = kernel.getService('automation'); + + // The registry-pulled flow bound to the trigger after kernel:ready. + expect((automation as any).getActiveTriggerBindings()).toContainEqual({ + flowName: 'stamp_flow2', + triggerType: 'record_change', + }); + + const created = await data.insert('wid2', { status: 'new' }); + const id = Array.isArray(created) ? created[0]?.id : created?.id ?? created; + await sleep(200); + + const row = await data.findOne('wid2', { where: { id } }); + expect(row?.stamp).toBe('done'); + }, 15000); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c42905e331..9c1d0096e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2100,6 +2100,12 @@ importers: specifier: workspace:* version: link:../../spec devDependencies: + '@objectstack/objectql': + specifier: workspace:* + version: link:../../objectql + '@objectstack/service-automation': + specifier: workspace:* + version: link:../../services/service-automation '@types/node': specifier: ^25.9.2 version: 25.9.2 @@ -6891,10 +6897,6 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} - engines: {node: '>=12.20.0'} - on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -10419,7 +10421,7 @@ snapshots: dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.1.1 svelte: 5.55.3 vite: 8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -10429,7 +10431,7 @@ snapshots: dependencies: deepmerge: 4.3.1 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.1.1 svelte: 5.55.3 vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) vitefu: 1.1.3(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -13649,8 +13651,6 @@ snapshots: obug@2.1.1: {} - obug@2.1.3: {} - on-exit-leak-free@2.1.2: {} on-finished@2.4.1: