Skip to content

Commit 0df03d6

Browse files
committed
feat(triggers): declarative time-relative trigger (#1874)
Time-relative business rules ("alert 60 days before a contract's end_date") could only be expressed as a record_change flow gated on a date-equality condition like `end_date == daysFromNow(60)`. That predicate is evaluated only when the record happens to change, so it fires only if a record is edited on exactly the threshold day — i.e. almost never, unattended. The robust alternative was a hand-written cron + range query that every author re-implemented (contracts renewal_alert, hr document_expiring_soon, procurement po_overdue, ...). A flow's start node can now declare a `timeRelative` descriptor: config: { timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7], // T-minus reminders — fires on each threshold day // — or — withinDays: 30 // "expiring soon" range; negative = overdue lookback filter: { status: 'active' }, // optional, ANDed with the date window }, schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; default daily 08:00 UTC } The new time_relative trigger (TimeRelativeTriggerPlugin, shipped in @objectstack/trigger-schedule) sweeps the object on that schedule and launches the flow once per matching record, with the record on the automation context — so the start-node condition gate and {record.<field>} interpolation work exactly as for a record-change flow. Because the window is evaluated every day, a threshold is never missed regardless of when the record last changed. - spec: new TimeRelativeTriggerSchema (@objectstack/spec/automation), the Zod source of truth; exactly one of withinDays | offsetDays is required. - engine: resolveTriggerBinding routes a start node carrying config.timeRelative to the time_relative trigger, ahead of the plain schedule trigger (whose behavior is unchanged) since such a flow also carries a schedule cadence. - trigger: composes the schedule trigger's job service (sweep cadence) with the ObjectQL engine (date-window query). The discovery query runs as a system operation (RLS-bypassing), is capped at maxRecords/tick (default 1000), and isolates per-record failures so one bad row never aborts the sweep. - lint: os validate gains readiness checks for the new descriptor (unknown swept object, ambiguous draft status). - cli: serve.ts arms the plugin in the triggers group. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHzW68suiFuu6GdJyea8U4
1 parent 06cb319 commit 0df03d6

16 files changed

Lines changed: 1483 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`).

packages/cli/src/commands/serve.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1784,6 +1784,15 @@ export default class Serve extends Command {
17841784
export: 'ScheduleTriggerPlugin',
17851785
nameMatch: ['trigger-schedule', 'ScheduleTriggerPlugin'],
17861786
},
1787+
{
1788+
// Declarative time-relative sweep (#1874) — arms flows whose start
1789+
// node declares `config.timeRelative` (fire daily for records whose
1790+
// date field is within N days / at T-minus offsets). Ships in
1791+
// @objectstack/trigger-schedule; needs the job service + ObjectQL.
1792+
pkg: '@objectstack/trigger-schedule',
1793+
export: 'TimeRelativeTriggerPlugin',
1794+
nameMatch: ['trigger-schedule', 'TimeRelativeTriggerPlugin'],
1795+
},
17871796
{
17881797
// Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms
17891798
// `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', () => {

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

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,31 @@ export class AutomationEngine implements IAutomationService {
781781
};
782782
}
783783

784+
// Declarative time-relative sweep (#1874): a start node carrying a
785+
// `timeRelative` descriptor is swept on a schedule and launched once per
786+
// record whose date field falls in the window. Checked BEFORE `schedule`
787+
// because such a flow ALSO carries a `schedule` cadence (the sweep
788+
// interval) — without this precedence it would bind to the plain schedule
789+
// trigger and fire once with no record instead of once per record.
790+
if (config.timeRelative != null && typeof config.timeRelative === 'object') {
791+
const tr = config.timeRelative as Record<string, unknown>;
792+
return {
793+
triggerType: 'time_relative',
794+
binding: {
795+
flowName,
796+
object:
797+
typeof tr.object === 'string'
798+
? tr.object
799+
: typeof config.objectName === 'string'
800+
? config.objectName
801+
: undefined,
802+
schedule: config.schedule,
803+
condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined,
804+
config,
805+
},
806+
};
807+
}
808+
784809
if (config.schedule != null || flow.type === 'schedule') {
785810
return {
786811
triggerType: 'schedule',
@@ -1218,7 +1243,7 @@ export class AutomationEngine implements IAutomationService {
12181243
if (!resolved) continue; // manual / screen flow — nothing to bind
12191244
const reason = this.triggers.has(resolved.triggerType)
12201245
? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings`
1221-
: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/api ship in @objectstack/trigger-*)`;
1246+
: `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`;
12221247
audit.push({ flowName: name, triggerType: resolved.triggerType, reason });
12231248
}
12241249
return audit;

packages/spec/json-schema.manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@
573573
"automation/SyncExecutionResult",
574574
"automation/SyncExecutionStatus",
575575
"automation/SyncMode",
576+
"automation/TimeRelativeTrigger",
576577
"automation/Transition",
577578
"automation/TryCatchConfig",
578579
"automation/WaitEventType",

packages/spec/src/automation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './webhook.zod';
99
export * from './approval.zod';
1010
export * from './etl.zod';
1111
export * from './trigger-registry.zod';
12+
export * from './time-relative-trigger.zod';
1213
export * from './sync.zod';
1314
export * from './state-machine.zod';
1415
export * from './node-executor.zod';
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
TimeRelativeTriggerSchema,
6+
TIME_RELATIVE_DEFAULT_CRON,
7+
TIME_RELATIVE_DEFAULT_MAX_RECORDS,
8+
} from './time-relative-trigger.zod';
9+
10+
describe('TimeRelativeTriggerSchema', () => {
11+
it('accepts a range-mode descriptor (withinDays)', () => {
12+
const parsed = TimeRelativeTriggerSchema.parse({
13+
object: 'contracts',
14+
dateField: 'end_date',
15+
withinDays: 60,
16+
filter: { status: 'active' },
17+
});
18+
expect(parsed.withinDays).toBe(60);
19+
expect(parsed.offsetDays).toBeUndefined();
20+
});
21+
22+
it('accepts an offset-mode descriptor (offsetDays)', () => {
23+
const parsed = TimeRelativeTriggerSchema.parse({
24+
object: 'contracts',
25+
dateField: 'end_date',
26+
offsetDays: [60, 30, 7],
27+
});
28+
expect(parsed.offsetDays).toEqual([60, 30, 7]);
29+
});
30+
31+
it('accepts negative offsets/windows (overdue / day-after)', () => {
32+
expect(() =>
33+
TimeRelativeTriggerSchema.parse({ object: 'po', dateField: 'due_date', withinDays: -14 }),
34+
).not.toThrow();
35+
expect(() =>
36+
TimeRelativeTriggerSchema.parse({ object: 'po', dateField: 'due_date', offsetDays: [-1] }),
37+
).not.toThrow();
38+
});
39+
40+
it('rejects a descriptor with neither windowing mode', () => {
41+
const r = TimeRelativeTriggerSchema.safeParse({ object: 'contracts', dateField: 'end_date' });
42+
expect(r.success).toBe(false);
43+
});
44+
45+
it('rejects a descriptor with BOTH windowing modes (mutually exclusive)', () => {
46+
const r = TimeRelativeTriggerSchema.safeParse({
47+
object: 'contracts',
48+
dateField: 'end_date',
49+
withinDays: 30,
50+
offsetDays: [7],
51+
});
52+
expect(r.success).toBe(false);
53+
});
54+
55+
it('rejects an empty offsetDays array', () => {
56+
const r = TimeRelativeTriggerSchema.safeParse({
57+
object: 'contracts',
58+
dateField: 'end_date',
59+
offsetDays: [],
60+
});
61+
expect(r.success).toBe(false);
62+
});
63+
64+
it('rejects non-snake_case object / field names (contract-first)', () => {
65+
expect(
66+
TimeRelativeTriggerSchema.safeParse({ object: 'Contracts', dateField: 'end_date', withinDays: 1 }).success,
67+
).toBe(false);
68+
expect(
69+
TimeRelativeTriggerSchema.safeParse({ object: 'contracts', dateField: 'endDate', withinDays: 1 }).success,
70+
).toBe(false);
71+
});
72+
73+
it('rejects a non-integer / non-positive maxRecords', () => {
74+
expect(
75+
TimeRelativeTriggerSchema.safeParse({ object: 'c', dateField: 'd', withinDays: 1, maxRecords: 0 }).success,
76+
).toBe(false);
77+
expect(
78+
TimeRelativeTriggerSchema.safeParse({ object: 'c', dateField: 'd', withinDays: 1, maxRecords: 2.5 }).success,
79+
).toBe(false);
80+
});
81+
82+
it('exposes sane defaults as constants', () => {
83+
expect(TIME_RELATIVE_DEFAULT_CRON).toBe('0 8 * * *');
84+
expect(TIME_RELATIVE_DEFAULT_MAX_RECORDS).toBeGreaterThan(0);
85+
});
86+
});

0 commit comments

Comments
 (0)