Skip to content

Commit a2795f6

Browse files
authored
feat(triggers): declarative time-relative trigger (#1874) (#3230)
Declarative time_relative trigger: a flow start node declaring config.timeRelative is swept on a schedule (daily by default) and launched once per record whose date field falls in the window (withinDays range / offsetDays T-minus). Ships TimeRelativeTrigger + TimeRelativeTriggerPlugin in @objectstack/trigger-schedule; engine routes the descriptor ahead of the plain schedule trigger; new TimeRelativeTriggerSchema in @objectstack/spec; os validate readiness checks; serve.ts wiring. Closes #1874
1 parent dccffdb commit a2795f6

20 files changed

Lines changed: 1619 additions & 2 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/trigger-schedule': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/lint': minor
6+
'@objectstack/cli': patch
7+
---
8+
9+
feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#1874)
10+
11+
Time-relative business rules ("alert 60 days before a contract's `end_date`")
12+
could only be expressed as a `record_change` flow gated on a date-equality
13+
condition like `end_date == daysFromNow(60)`. That predicate is only evaluated
14+
when the record *happens to change*, so it fires only if a record is edited on
15+
exactly the threshold day — i.e. almost never, unattended. The robust
16+
alternative was a hand-written cron + range query that every author
17+
re-implemented (contracts `renewal_alert`, hr `document_expiring_soon`,
18+
procurement `po_overdue`, …).
19+
20+
A flow's start node can now declare a `timeRelative` descriptor instead:
21+
22+
```ts
23+
config: {
24+
timeRelative: {
25+
object: 'contracts',
26+
dateField: 'end_date',
27+
offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day
28+
// — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback
29+
filter: { status: 'active' }, // optional, ANDed with the date window
30+
},
31+
schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC
32+
}
33+
```
34+
35+
The new `time_relative` trigger (shipped in `@objectstack/trigger-schedule` as
36+
`TimeRelativeTriggerPlugin`) sweeps the object on that schedule and launches the
37+
flow **once per matching record**, with the record on the automation context —
38+
so the start-node `condition` gate and `{record.<field>}` interpolation work
39+
exactly as for a record-change flow. Because the window is evaluated every day,
40+
a threshold is never missed regardless of when the record last changed. The
41+
discovery query runs as a system operation (RLS-bypassing) and is capped
42+
(`maxRecords`, default 1000) so a mis-scoped window can't fan out unboundedly;
43+
per-record failures are isolated so one bad row never aborts the sweep.
44+
45+
The automation engine routes a start node carrying `config.timeRelative` to the
46+
`time_relative` trigger (ahead of the plain `schedule` trigger, whose behavior is
47+
unchanged), and `os validate` gains readiness checks for the new descriptor
48+
(unknown swept object, ambiguous draft status). New authorable spec key:
49+
`TimeRelativeTriggerSchema` (`@objectstack/spec/automation`).

content/docs/references/automation/index.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
1515
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
1616
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
1717
<Card href="/docs/references/automation/sync" title="Sync" description="Source: packages/spec/src/automation/sync.zod.ts" />
18+
<Card href="/docs/references/automation/time-relative-trigger" title="Time Relative Trigger" description="Source: packages/spec/src/automation/time-relative-trigger.zod.ts" />
1819
<Card href="/docs/references/automation/trigger-registry" title="Trigger Registry" description="Source: packages/spec/src/automation/trigger-registry.zod.ts" />
1920
<Card href="/docs/references/automation/webhook" title="Webhook" description="Source: packages/spec/src/automation/webhook.zod.ts" />
2021
</Cards>

content/docs/references/automation/meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"offline",
1414
"state-machine",
1515
"sync",
16+
"time-relative-trigger",
1617
"trigger-registry",
1718
"webhook"
1819
]
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
---
2+
title: Time Relative Trigger
3+
description: Time Relative Trigger protocol schemas
4+
---
5+
6+
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
7+
8+
Time-Relative Trigger Protocol
9+
10+
A **declarative** trigger for time-relative business rules — "act on records
11+
12+
whose date field is coming up (or overdue) relative to today" — without the
13+
14+
author hand-writing a cron job + range query, and without the fragile
15+
16+
date-equality-on-record-change anti-pattern (#1874).
17+
18+
## The anti-pattern it replaces
19+
20+
Authors used to express "alert 60 days before `end_date`" as a `record_change`
21+
22+
flow gated on `end_date == daysFromNow(60)`. That predicate is only evaluated
23+
24+
when the record *happens to change*, so it fires only if the record is edited
25+
26+
on exactly that day — i.e. almost never, unattended. The robust alternative
27+
28+
was a hand-written `schedule` flow that queries a date range every day, which
29+
30+
every author re-implemented (contracts `renewal_alert`, hr
31+
32+
`document_expiring_soon`, procurement `po_overdue`, …).
33+
34+
## What this declares instead
35+
36+
A `time_relative` trigger sweeps an object on a schedule (daily by default)
37+
38+
and launches the flow **once per matching record**, with that record in the
39+
40+
automation context (so `\{record.<field>\}` interpolation and the start-node
41+
42+
`condition` gate work exactly as they do for record-change flows). The
43+
44+
descriptor is carried on the flow's start node as `config.timeRelative`.
45+
46+
@example T-minus renewal reminders (fires on the day a contract is 60/30/7 days out)
47+
48+
```ts
49+
50+
// flow start node
51+
52+
config: \{
53+
54+
timeRelative: \{
55+
56+
object: 'contracts',
57+
58+
dateField: 'end_date',
59+
60+
offsetDays: [60, 30, 7],
61+
62+
filter: \{ status: 'active' \},
63+
64+
\},
65+
66+
// optional sweep cadence — defaults to daily at 08:00 UTC
67+
68+
schedule: \{ type: 'cron', expression: '0 8 * * *' \},
69+
70+
\}
71+
72+
```
73+
74+
@example "Expiring soon" range (fires every day a document is within 30 days of expiry)
75+
76+
```ts
77+
78+
config: \{
79+
80+
timeRelative: \{ object: 'hr_document', dateField: 'expires_on', withinDays: 30 \},
81+
82+
\}
83+
84+
```
85+
86+
@example Overdue sweep (fires for POs up to 14 days past due)
87+
88+
```ts
89+
90+
config: \{
91+
92+
timeRelative: \{ object: 'purchase_order', dateField: 'due_date', withinDays: -14, filter: \{ status: 'open' \} \},
93+
94+
\}
95+
96+
```
97+
98+
<Callout type="info">
99+
**Source:** `packages/spec/src/automation/time-relative-trigger.zod.ts`
100+
</Callout>
101+
102+
## TypeScript Usage
103+
104+
```typescript
105+
import { TimeRelativeTrigger } from '@objectstack/spec/automation';
106+
import type { TimeRelativeTrigger } from '@objectstack/spec/automation';
107+
108+
// Validate data
109+
const result = TimeRelativeTrigger.parse(data);
110+
```
111+
112+
---
113+
114+
## TimeRelativeTrigger
115+
116+
### Properties
117+
118+
| Property | Type | Required | Description |
119+
| :--- | :--- | :--- | :--- |
120+
| **object** | `string` || Object (machine name) to sweep, e.g. "contracts". |
121+
| **dateField** | `string` || Date or datetime field evaluated relative to today, e.g. "end_date". |
122+
| **withinDays** | `integer` | optional | Range mode: fire while dateField is within N days of today. Positive = upcoming, negative = overdue lookback, 0 = today. |
123+
| **offsetDays** | `integer[]` | optional | Offset mode: fire when dateField is exactly today + each offset (e.g. [60, 30, 7]). |
124+
| **filter** | `Record<string, any>` | optional | Extra ObjectQL where-map ANDed with the date window (e.g. `{ status: "active" }`). |
125+
| **maxRecords** | `integer` | optional | Max records launched per sweep (default 1000). The sweep logs when it clamps. |
126+
127+
128+
---
129+

packages/cli/src/commands/serve.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,6 +1901,15 @@ export default class Serve extends Command {
19011901
export: 'ScheduleTriggerPlugin',
19021902
nameMatch: ['trigger-schedule', 'ScheduleTriggerPlugin'],
19031903
},
1904+
{
1905+
// Declarative time-relative sweep (#1874) — arms flows whose start
1906+
// node declares `config.timeRelative` (fire daily for records whose
1907+
// date field is within N days / at T-minus offsets). Ships in
1908+
// @objectstack/trigger-schedule; needs the job service + ObjectQL.
1909+
pkg: '@objectstack/trigger-schedule',
1910+
export: 'TimeRelativeTriggerPlugin',
1911+
nameMatch: ['trigger-schedule', 'TimeRelativeTriggerPlugin'],
1912+
},
19041913
{
19051914
// Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms
19061915
// `type: 'api'` flows with HMAC-verified, queue-backed hooks.

packages/lint/src/validate-flow-trigger-readiness.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,58 @@ describe('validateFlowTriggerReadiness', () => {
123123
expect(findings.map((f) => f.rule)).toEqual([FLOW_DRAFT_STATUS_AMBIGUOUS]);
124124
});
125125

126+
it('treats a time-relative flow (config.timeRelative) as auto-triggered — flags missing status', () => {
127+
const findings = validateFlowTriggerReadiness({
128+
objects: [{ name: 'contracts', label: 'Contracts', fields: {} }],
129+
flows: [
130+
{
131+
name: 'renewal_alert',
132+
type: 'schedule',
133+
nodes: [
134+
{
135+
id: 'start',
136+
type: 'start',
137+
config: {
138+
timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] },
139+
},
140+
},
141+
{ id: 'end', type: 'end' },
142+
],
143+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
144+
},
145+
],
146+
});
147+
expect(findings.map((f) => f.rule)).toEqual([FLOW_DRAFT_STATUS_AMBIGUOUS]);
148+
});
149+
150+
it('warns when a time-relative flow sweeps an object the stack does not define', () => {
151+
const findings = validateFlowTriggerReadiness({
152+
objects: [{ name: 'contracts', label: 'Contracts', fields: {} }],
153+
flows: [
154+
{
155+
name: 'renewal_alert',
156+
type: 'schedule',
157+
status: 'active',
158+
nodes: [
159+
{
160+
id: 'start',
161+
type: 'start',
162+
config: {
163+
timeRelative: { object: 'contract', dateField: 'end_date', withinDays: 60 },
164+
},
165+
},
166+
{ id: 'end', type: 'end' },
167+
],
168+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
169+
},
170+
],
171+
});
172+
expect(findings).toHaveLength(1);
173+
expect(findings[0].rule).toBe(FLOW_TRIGGER_UNKNOWN_OBJECT);
174+
expect(findings[0].message).toContain("'contract'");
175+
expect(findings[0].path).toBe('flows[0].nodes[0].config.timeRelative.object');
176+
});
177+
126178
it('handles map-keyed flows/objects and stacks with no flows', () => {
127179
expect(validateFlowTriggerReadiness({})).toEqual([]);
128180
const findings = validateFlowTriggerReadiness({

packages/lint/src/validate-flow-trigger-readiness.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
8383
const config = (start?.node.config ?? {}) as AnyRec;
8484
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;
8585
const isRecordTriggered = !!triggerType && triggerType.startsWith('record-');
86+
const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === 'object';
8687
const isAutoTriggered =
8788
isRecordTriggered || triggerType === 'api' || config.schedule != null ||
88-
flow.type === 'schedule' || flow.type === 'api';
89+
isTimeRelative || flow.type === 'schedule' || flow.type === 'api';
8990

9091
// 1. Record-triggered flow targeting an object this stack does not define.
9192
if (isRecordTriggered && start) {
@@ -107,6 +108,28 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
107108
}
108109
}
109110

111+
// 1b. Time-relative flow sweeping an object this stack does not define. Like
112+
// the record-change case, a wrong object name makes the sweep match
113+
// nothing forever with no runtime output.
114+
if (isTimeRelative && start) {
115+
const tr = config.timeRelative as AnyRec;
116+
const objectName = typeof tr.object === 'string' ? tr.object : undefined;
117+
if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) {
118+
findings.push({
119+
severity: 'warning',
120+
rule: FLOW_TRIGGER_UNKNOWN_OBJECT,
121+
where: `flow "${flowName}" › start node`,
122+
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative.object`,
123+
message:
124+
`sweeps object '${objectName}', which this stack does not define — if the name is wrong, ` +
125+
`the sweep will match nothing (and the runtime stays quiet about it).`,
126+
hint:
127+
`Object names match exactly. Check config.timeRelative.object against the object's registered name. ` +
128+
`If the object comes from another installed package, this warning can be ignored.`,
129+
});
130+
}
131+
}
132+
110133
// 2. Auto-triggered flow whose status is 'draft' — authored or defaulted
111134
// (defineFlow parses at definition time, so the two are the same here).
112135
if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) {

packages/services/service-automation/src/engine.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2459,6 +2459,73 @@ describe('AutomationEngine - Flow Trigger Wiring', () => {
24592459
await rec.fire('rc_flow', { record: { status: 'done' }, previous: { status: 'open' }, object: 'task', event: 'record-after-update' });
24602460
expect(seen).toEqual([{ status: 'done', prevStatus: 'open' }]);
24612461
});
2462+
2463+
it('binds a time-relative flow (config.timeRelative) to the time_relative trigger (#1874)', () => {
2464+
const rec = recordingTrigger('time_relative');
2465+
engine.registerTrigger(rec.trigger);
2466+
engine.registerFlow('renewal_alert', {
2467+
name: 'renewal_alert',
2468+
label: 'Renewal Alert',
2469+
type: 'schedule' as const,
2470+
nodes: [
2471+
{
2472+
id: 'start',
2473+
type: 'start' as const,
2474+
label: 'Start',
2475+
config: {
2476+
timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] },
2477+
schedule: { type: 'cron', expression: '0 8 * * *' },
2478+
condition: 'status == "active"',
2479+
},
2480+
},
2481+
{ id: 'end', type: 'end' as const, label: 'End' },
2482+
],
2483+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
2484+
});
2485+
2486+
expect(engine.getActiveTriggerBindings()).toEqual([
2487+
{ flowName: 'renewal_alert', triggerType: 'time_relative' },
2488+
]);
2489+
expect(rec.started[0]).toMatchObject({
2490+
flowName: 'renewal_alert',
2491+
object: 'contracts',
2492+
schedule: { type: 'cron', expression: '0 8 * * *' },
2493+
condition: 'status == "active"',
2494+
});
2495+
// The raw descriptor rides along in config for the trigger to parse.
2496+
expect((rec.started[0].config as Record<string, any>)?.timeRelative?.offsetDays).toEqual([60, 30, 7]);
2497+
});
2498+
2499+
it('routes a timeRelative flow to time_relative even when a schedule trigger is present too (precedence)', () => {
2500+
const sched = recordingTrigger('schedule');
2501+
const timeRel = recordingTrigger('time_relative');
2502+
engine.registerTrigger(sched.trigger);
2503+
engine.registerTrigger(timeRel.trigger);
2504+
engine.registerFlow('expiring', {
2505+
name: 'expiring',
2506+
label: 'Expiring',
2507+
type: 'schedule' as const,
2508+
nodes: [
2509+
{
2510+
id: 'start',
2511+
type: 'start' as const,
2512+
label: 'Start',
2513+
config: {
2514+
timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 },
2515+
schedule: { type: 'cron', expression: '0 7 * * *' },
2516+
},
2517+
},
2518+
{ id: 'end', type: 'end' as const, label: 'End' },
2519+
],
2520+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
2521+
});
2522+
2523+
expect(engine.getActiveTriggerBindings()).toEqual([
2524+
{ flowName: 'expiring', triggerType: 'time_relative' },
2525+
]);
2526+
expect(timeRel.started).toHaveLength(1);
2527+
expect(sched.started).toHaveLength(0);
2528+
});
24622529
});
24632530

24642531
describe('AutomationEngine - flow status enable/disable gate', () => {

0 commit comments

Comments
 (0)