Skip to content

Commit fb3831c

Browse files
committed
2 parents 08fbbb4 + 13d8653 commit fb3831c

14 files changed

Lines changed: 1200 additions & 3 deletions

File tree

.changeset/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
"@objectstack/plugin-security",
3838
"@objectstack/plugin-sharing",
3939
"@objectstack/plugin-webhooks",
40+
"@objectstack/plugin-trigger-record-change",
41+
"@objectstack/connector-rest",
42+
"@objectstack/connector-slack",
4043
"@objectstack/express",
4144
"@objectstack/fastify",
4245
"@objectstack/hono",
@@ -55,6 +58,7 @@
5558
"@objectstack/service-i18n",
5659
"@objectstack/service-job",
5760
"@objectstack/service-knowledge",
61+
"@objectstack/service-messaging",
5862
"@objectstack/service-package",
5963
"@objectstack/service-queue",
6064
"@objectstack/service-realtime",
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@objectstack/plugin-trigger-record-change": minor
3+
"@objectstack/service-automation": minor
4+
"@objectstack/spec": patch
5+
---
6+
7+
Record-change flow trigger — auto-launch flows on data mutations.
8+
9+
Completes the automation engine's `FlowTrigger` extension point so flows whose
10+
`start` node declares a record-change trigger (`config: { objectName,
11+
triggerType: 'record-after-update', condition }`) actually fire on the matching
12+
mutation. Previously the slot was dead — nothing called `trigger.start` — so
13+
such flows could only run via a manual `engine.execute()`.
14+
15+
**Engine baseline (`@objectstack/service-automation`)**
16+
- Redefines `FlowTrigger` around a parsed `FlowTriggerBinding` (flowName,
17+
object, event, condition, schedule, raw config). The engine parses the start
18+
node and hands the trigger a normalized binding, keeping trigger plugins
19+
decoupled from flow-definition internals (mirrors `connector_action`
20+
`connector-rest`).
21+
- Ordering-independent, bidirectional wiring: `registerFlow`/`toggleFlow`
22+
activate bindings; `registerTrigger` retro-binds already-registered flows (a
23+
trigger plugin wires up on `kernel:ready`, after flows are pulled in);
24+
`unregisterFlow`/`unregisterTrigger`/disable tear them down.
25+
- Centralized start-condition gate in `execute()`: the start node's `condition`
26+
(e.g. `status == 'done' && previous.status != 'done'`) is evaluated once for
27+
every trigger type and manual runs; false ⇒ `{ skipped: true }`.
28+
- Seeds `record`, flattened record fields, and `previous` into flow variables.
29+
- New `getActiveTriggerBindings()` getter + exports `FlowTriggerBinding`.
30+
31+
**Spec (`@objectstack/spec`)**
32+
- Adds `previous?` to `AutomationContext` — the pre-update "old" row, so flows
33+
can gate on transitions.
34+
35+
**New package (`@objectstack/plugin-trigger-record-change`)**
36+
- The concrete trigger: subscribes to ObjectQL lifecycle hooks
37+
(`record-after-update``afterUpdate`, etc.), builds an `AutomationContext`
38+
from the new/old record, and runs the flow. Error-isolated (a flow failure
39+
never breaks the CRUD write); graceful degrade when the automation service or
40+
ObjectQL engine is absent (mirrors `plugin-audit`).
41+
42+
The `schedule` trigger (ticker/cron + `sys_job` lifecycle) is a follow-up.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# @objectstack/plugin-trigger-record-change
2+
3+
Auto-launch ObjectStack flows on record changes.
4+
5+
The automation engine ships the `FlowTrigger` extension point and the wiring
6+
that turns a flow's `start` node into a normalized trigger binding — but the
7+
*concrete* record-change trigger lives here, as a plugin. This mirrors the
8+
connector split (engine baseline + `connector-rest` plugin) and reuses the same
9+
`kernel:ready``getService('objectql')` pattern `plugin-audit` uses to reach
10+
the data engine's lifecycle-hook surface.
11+
12+
## What it does
13+
14+
A flow whose `start` node declares a record-change trigger:
15+
16+
```ts
17+
{
18+
type: 'start',
19+
config: {
20+
objectName: 'showcase_task',
21+
triggerType: 'record-after-update',
22+
condition: "status == 'done' && previous.status != 'done'", // optional
23+
},
24+
}
25+
```
26+
27+
auto-launches whenever a matching mutation happens — no manual
28+
`engine.execute()`. The engine evaluates the optional `condition` (the start
29+
node gate) before running the flow, with `record` (the new row) and `previous`
30+
(the old row) in scope.
31+
32+
### Trigger event → hook mapping
33+
34+
| `triggerType` | ObjectQL hook |
35+
| ------------------------ | -------------- |
36+
| `record-after-create` | `afterInsert` |
37+
| `record-after-update` | `afterUpdate` |
38+
| `record-after-delete` | `afterDelete` |
39+
| `record-before-create` | `beforeInsert` |
40+
| `record-before-update` | `beforeUpdate` |
41+
| `record-before-delete` | `beforeDelete` |
42+
43+
## Usage
44+
45+
```ts
46+
import { AutomationServicePlugin } from '@objectstack/service-automation';
47+
import { MessagingServicePlugin } from '@objectstack/service-messaging';
48+
import { RecordChangeTriggerPlugin } from '@objectstack/plugin-trigger-record-change';
49+
50+
kernel
51+
.use(new AutomationServicePlugin()) // engine + flows
52+
.use(new MessagingServicePlugin()) // notify channels (optional)
53+
.use(new RecordChangeTriggerPlugin()); // ← makes record-change flows live
54+
```
55+
56+
Requires the ObjectQL engine (`com.objectstack.engine.objectql`). If either the
57+
automation service or the data engine is unavailable at `kernel:ready`, the
58+
plugin logs a warning and no-ops rather than failing startup.
59+
60+
## Error isolation
61+
62+
A flow that throws during a triggered run is logged and swallowed — it never
63+
breaks the CRUD write that triggered it.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"name": "@objectstack/plugin-trigger-record-change",
3+
"version": "7.3.0",
4+
"license": "Apache-2.0",
5+
"description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)",
6+
"main": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"exports": {
9+
".": {
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.mjs",
12+
"require": "./dist/index.js"
13+
}
14+
},
15+
"scripts": {
16+
"build": "tsup --config ../../../tsup.config.ts",
17+
"test": "vitest run --passWithNoTests"
18+
},
19+
"dependencies": {
20+
"@objectstack/core": "workspace:*",
21+
"@objectstack/spec": "workspace:*"
22+
},
23+
"devDependencies": {
24+
"@types/node": "^25.9.1",
25+
"typescript": "^6.0.3",
26+
"vitest": "^4.1.7"
27+
},
28+
"keywords": [
29+
"objectstack",
30+
"plugin",
31+
"automation",
32+
"flow",
33+
"trigger",
34+
"record-change"
35+
],
36+
"author": "ObjectStack",
37+
"repository": {
38+
"type": "git",
39+
"url": "https://github.com/objectstack-ai/framework.git",
40+
"directory": "packages/plugins/plugin-trigger-record-change"
41+
},
42+
"homepage": "https://objectstack.ai/docs",
43+
"bugs": "https://github.com/objectstack-ai/framework/issues",
44+
"publishConfig": {
45+
"access": "public"
46+
},
47+
"files": [
48+
"dist",
49+
"README.md"
50+
],
51+
"engines": {
52+
"node": ">=18.0.0"
53+
}
54+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
export { RecordChangeTriggerPlugin } from './plugin.js';
4+
export {
5+
RecordChangeTrigger,
6+
triggerTypeToHookEvent,
7+
} from './record-change-trigger.js';
8+
export type {
9+
FlowTrigger,
10+
FlowTriggerBinding,
11+
RecordChangeDataEngine,
12+
TriggerLogger,
13+
} from './record-change-trigger.js';
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { Plugin, PluginContext } from '@objectstack/core';
4+
import { RecordChangeTrigger } from './record-change-trigger.js';
5+
import type { FlowTrigger, RecordChangeDataEngine } from './record-change-trigger.js';
6+
7+
/**
8+
* The slice of the automation engine this plugin needs: register a trigger on
9+
* its `FlowTrigger` extension point. Declared structurally so the plugin does
10+
* not take a build dependency on `@objectstack/service-automation`.
11+
*/
12+
interface AutomationTriggerRegistry {
13+
registerTrigger(trigger: FlowTrigger): void;
14+
unregisterTrigger?(type: string): void;
15+
}
16+
17+
/**
18+
* RecordChangeTriggerPlugin
19+
*
20+
* Makes record-change-triggered flows actually fire. The automation engine
21+
* ships the `FlowTrigger` wiring (it parses each flow's start node into a
22+
* binding and calls `trigger.start(...)`), but the *concrete* record-change
23+
* trigger — the one that subscribes to ObjectQL lifecycle hooks — lives here as
24+
* a plugin. This mirrors the connector split (engine baseline + connector-rest
25+
* plugin) and reuses plugin-audit's `kernel:ready` → `getService('objectql')`
26+
* pattern to reach the data engine's hook surface.
27+
*
28+
* With this plugin installed, a flow whose start node declares
29+
* `config: { objectName, triggerType: 'record-after-update', condition }`
30+
* auto-launches on the matching mutation — no manual `engine.execute()`.
31+
*/
32+
export class RecordChangeTriggerPlugin implements Plugin {
33+
name = 'com.objectstack.trigger.record-change';
34+
type = 'standard';
35+
version = '7.3.0';
36+
dependencies = ['com.objectstack.engine.objectql'];
37+
38+
async init(ctx: PluginContext): Promise<void> {
39+
ctx.logger.info('Record-change trigger plugin initialized');
40+
}
41+
42+
async start(ctx: PluginContext): Promise<void> {
43+
// ObjectQL engine + the automation service are only resolvable once the
44+
// kernel is ready (kernel:ready fires after AutomationServicePlugin.start()
45+
// has pulled flows into the engine, so binding order is correct).
46+
ctx.hook('kernel:ready', async () => {
47+
const automation = this.resolveService<AutomationTriggerRegistry>(ctx, 'automation');
48+
if (!automation || typeof automation.registerTrigger !== 'function') {
49+
ctx.logger.warn(
50+
'RecordChangeTriggerPlugin: automation service not available — record-change trigger NOT installed',
51+
);
52+
return;
53+
}
54+
55+
const engine = this.resolveDataEngine(ctx);
56+
if (!engine || typeof engine.registerHook !== 'function') {
57+
ctx.logger.warn(
58+
'RecordChangeTriggerPlugin: ObjectQL engine not available — record-change trigger NOT installed',
59+
);
60+
return;
61+
}
62+
63+
const trigger = new RecordChangeTrigger(engine, ctx.logger);
64+
automation.registerTrigger(trigger);
65+
ctx.logger.info('RecordChangeTriggerPlugin: record-change trigger registered');
66+
});
67+
}
68+
69+
private resolveService<T>(ctx: PluginContext, name: string): T | null {
70+
try {
71+
return ctx.getService<T>(name) ?? null;
72+
} catch {
73+
return null;
74+
}
75+
}
76+
77+
private resolveDataEngine(ctx: PluginContext): RecordChangeDataEngine | null {
78+
// Primary alias 'objectql', fallback 'data' (some kernels register the
79+
// engine under both) — same lookup plugin-audit uses.
80+
return (
81+
this.resolveService<RecordChangeDataEngine>(ctx, 'objectql') ??
82+
this.resolveService<RecordChangeDataEngine>(ctx, 'data')
83+
);
84+
}
85+
}

0 commit comments

Comments
 (0)