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
49 changes: 49 additions & 0 deletions .changeset/time-relative-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
'@objectstack/trigger-schedule': minor
'@objectstack/service-automation': minor
'@objectstack/spec': minor
'@objectstack/lint': minor
'@objectstack/cli': patch
---

feat(triggers): declarative time-relative trigger — daily sweep instead of fragile date-equality (#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 only evaluated
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 instead:

```ts
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; defaults to daily 08:00 UTC
}
```

The new `time_relative` trigger (shipped in `@objectstack/trigger-schedule` as
`TimeRelativeTriggerPlugin`) 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. The
discovery query runs as a system operation (RLS-bypassing) and is capped
(`maxRecords`, default 1000) so a mis-scoped window can't fan out unboundedly;
per-record failures are isolated so one bad row never aborts the sweep.

The automation engine routes a start node carrying `config.timeRelative` to the
`time_relative` trigger (ahead of the plain `schedule` trigger, whose behavior is
unchanged), and `os validate` gains readiness checks for the new descriptor
(unknown swept object, ambiguous draft status). New authorable spec key:
`TimeRelativeTriggerSchema` (`@objectstack/spec/automation`).
1 change: 1 addition & 0 deletions content/docs/references/automation/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta
<Card href="/docs/references/automation/node-executor" title="Node Executor" description="Source: packages/spec/src/automation/node-executor.zod.ts" />
<Card href="/docs/references/automation/state-machine" title="State Machine" description="Source: packages/spec/src/automation/state-machine.zod.ts" />
<Card href="/docs/references/automation/sync" title="Sync" description="Source: packages/spec/src/automation/sync.zod.ts" />
<Card href="/docs/references/automation/time-relative-trigger" title="Time Relative Trigger" description="Source: packages/spec/src/automation/time-relative-trigger.zod.ts" />
<Card href="/docs/references/automation/trigger-registry" title="Trigger Registry" description="Source: packages/spec/src/automation/trigger-registry.zod.ts" />
<Card href="/docs/references/automation/webhook" title="Webhook" description="Source: packages/spec/src/automation/webhook.zod.ts" />
</Cards>
1 change: 1 addition & 0 deletions content/docs/references/automation/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"offline",
"state-machine",
"sync",
"time-relative-trigger",
"trigger-registry",
"webhook"
]
Expand Down
129 changes: 129 additions & 0 deletions content/docs/references/automation/time-relative-trigger.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
title: Time Relative Trigger
description: Time Relative Trigger protocol schemas
---

{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}

Time-Relative Trigger Protocol

A **declarative** trigger for time-relative business rules — "act on records

whose date field is coming up (or overdue) relative to today" — without the

author hand-writing a cron job + range query, and without the fragile

date-equality-on-record-change anti-pattern (#1874).

## The anti-pattern it replaces

Authors used to express "alert 60 days before `end_date`" as a `record_change`

flow gated on `end_date == daysFromNow(60)`. That predicate is only evaluated

when the record *happens to change*, so it fires only if the record is edited

on exactly that day — i.e. almost never, unattended. The robust alternative

was a hand-written `schedule` flow that queries a date range every day, which

every author re-implemented (contracts `renewal_alert`, hr

`document_expiring_soon`, procurement `po_overdue`, …).

## What this declares instead

A `time_relative` trigger sweeps an object on a schedule (daily by default)

and launches the flow **once per matching record**, with that record in the

automation context (so `\{record.<field>\}` interpolation and the start-node

`condition` gate work exactly as they do for record-change flows). The

descriptor is carried on the flow's start node as `config.timeRelative`.

@example T-minus renewal reminders (fires on the day a contract is 60/30/7 days out)

```ts

// flow start node

config: \{

timeRelative: \{

object: 'contracts',

dateField: 'end_date',

offsetDays: [60, 30, 7],

filter: \{ status: 'active' \},

\},

// optional sweep cadence — defaults to daily at 08:00 UTC

schedule: \{ type: 'cron', expression: '0 8 * * *' \},

\}

```

@example "Expiring soon" range (fires every day a document is within 30 days of expiry)

```ts

config: \{

timeRelative: \{ object: 'hr_document', dateField: 'expires_on', withinDays: 30 \},

\}

```

@example Overdue sweep (fires for POs up to 14 days past due)

```ts

config: \{

timeRelative: \{ object: 'purchase_order', dateField: 'due_date', withinDays: -14, filter: \{ status: 'open' \} \},

\}

```

<Callout type="info">
**Source:** `packages/spec/src/automation/time-relative-trigger.zod.ts`
</Callout>

## TypeScript Usage

```typescript
import { TimeRelativeTrigger } from '@objectstack/spec/automation';
import type { TimeRelativeTrigger } from '@objectstack/spec/automation';

// Validate data
const result = TimeRelativeTrigger.parse(data);
```

---

## TimeRelativeTrigger

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **object** | `string` | ✅ | Object (machine name) to sweep, e.g. "contracts". |
| **dateField** | `string` | ✅ | Date or datetime field evaluated relative to today, e.g. "end_date". |
| **withinDays** | `integer` | optional | Range mode: fire while dateField is within N days of today. Positive = upcoming, negative = overdue lookback, 0 = today. |
| **offsetDays** | `integer[]` | optional | Offset mode: fire when dateField is exactly today + each offset (e.g. [60, 30, 7]). |
| **filter** | `Record<string, any>` | optional | Extra ObjectQL where-map ANDed with the date window (e.g. `{ status: "active" }`). |
| **maxRecords** | `integer` | optional | Max records launched per sweep (default 1000). The sweep logs when it clamps. |


---

9 changes: 9 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1784,6 +1784,15 @@ export default class Serve extends Command {
export: 'ScheduleTriggerPlugin',
nameMatch: ['trigger-schedule', 'ScheduleTriggerPlugin'],
},
{
// Declarative time-relative sweep (#1874) — arms flows whose start
// node declares `config.timeRelative` (fire daily for records whose
// date field is within N days / at T-minus offsets). Ships in
// @objectstack/trigger-schedule; needs the job service + ObjectQL.
pkg: '@objectstack/trigger-schedule',
export: 'TimeRelativeTriggerPlugin',
nameMatch: ['trigger-schedule', 'TimeRelativeTriggerPlugin'],
},
{
// Inbound webhook/HTTP trigger (ADR-0041 Tier 1) — arms
// `type: 'api'` flows with HMAC-verified, queue-backed hooks.
Expand Down
52 changes: 52 additions & 0 deletions packages/lint/src/validate-flow-trigger-readiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,58 @@ describe('validateFlowTriggerReadiness', () => {
expect(findings.map((f) => f.rule)).toEqual([FLOW_DRAFT_STATUS_AMBIGUOUS]);
});

it('treats a time-relative flow (config.timeRelative) as auto-triggered — flags missing status', () => {
const findings = validateFlowTriggerReadiness({
objects: [{ name: 'contracts', label: 'Contracts', fields: {} }],
flows: [
{
name: 'renewal_alert',
type: 'schedule',
nodes: [
{
id: 'start',
type: 'start',
config: {
timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] },
},
},
{ id: 'end', type: 'end' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
},
],
});
expect(findings.map((f) => f.rule)).toEqual([FLOW_DRAFT_STATUS_AMBIGUOUS]);
});

it('warns when a time-relative flow sweeps an object the stack does not define', () => {
const findings = validateFlowTriggerReadiness({
objects: [{ name: 'contracts', label: 'Contracts', fields: {} }],
flows: [
{
name: 'renewal_alert',
type: 'schedule',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
config: {
timeRelative: { object: 'contract', dateField: 'end_date', withinDays: 60 },
},
},
{ id: 'end', type: 'end' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
},
],
});
expect(findings).toHaveLength(1);
expect(findings[0].rule).toBe(FLOW_TRIGGER_UNKNOWN_OBJECT);
expect(findings[0].message).toContain("'contract'");
expect(findings[0].path).toBe('flows[0].nodes[0].config.timeRelative.object');
});

it('handles map-keyed flows/objects and stacks with no flows', () => {
expect(validateFlowTriggerReadiness({})).toEqual([]);
const findings = validateFlowTriggerReadiness({
Expand Down
25 changes: 24 additions & 1 deletion packages/lint/src/validate-flow-trigger-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,10 @@ export function validateFlowTriggerReadiness(stack: AnyRec): FlowTriggerReadines
const config = (start?.node.config ?? {}) as AnyRec;
const triggerType = typeof config.triggerType === 'string' ? config.triggerType : undefined;
const isRecordTriggered = !!triggerType && triggerType.startsWith('record-');
const isTimeRelative = config.timeRelative != null && typeof config.timeRelative === 'object';
const isAutoTriggered =
isRecordTriggered || triggerType === 'api' || config.schedule != null ||
flow.type === 'schedule' || flow.type === 'api';
isTimeRelative || flow.type === 'schedule' || flow.type === 'api';

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

// 1b. Time-relative flow sweeping an object this stack does not define. Like
// the record-change case, a wrong object name makes the sweep match
// nothing forever with no runtime output.
if (isTimeRelative && start) {
const tr = config.timeRelative as AnyRec;
const objectName = typeof tr.object === 'string' ? tr.object : undefined;
if (objectName && !objectNames.has(objectName) && !objectName.startsWith('sys_')) {
findings.push({
severity: 'warning',
rule: FLOW_TRIGGER_UNKNOWN_OBJECT,
where: `flow "${flowName}" › start node`,
path: `flows[${flowIndex}].nodes[${start.index}].config.timeRelative.object`,
message:
`sweeps object '${objectName}', which this stack does not define — if the name is wrong, ` +
`the sweep will match nothing (and the runtime stays quiet about it).`,
hint:
`Object names match exactly. Check config.timeRelative.object against the object's registered name. ` +
`If the object comes from another installed package, this warning can be ignored.`,
});
}
}

// 2. Auto-triggered flow whose status is 'draft' — authored or defaulted
// (defineFlow parses at definition time, so the two are the same here).
if (isAutoTriggered && (flow.status == null || flow.status === 'draft')) {
Expand Down
67 changes: 67 additions & 0 deletions packages/services/service-automation/src/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2459,6 +2459,73 @@ describe('AutomationEngine - Flow Trigger Wiring', () => {
await rec.fire('rc_flow', { record: { status: 'done' }, previous: { status: 'open' }, object: 'task', event: 'record-after-update' });
expect(seen).toEqual([{ status: 'done', prevStatus: 'open' }]);
});

it('binds a time-relative flow (config.timeRelative) to the time_relative trigger (#1874)', () => {
const rec = recordingTrigger('time_relative');
engine.registerTrigger(rec.trigger);
engine.registerFlow('renewal_alert', {
name: 'renewal_alert',
label: 'Renewal Alert',
type: 'schedule' as const,
nodes: [
{
id: 'start',
type: 'start' as const,
label: 'Start',
config: {
timeRelative: { object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] },
schedule: { type: 'cron', expression: '0 8 * * *' },
condition: 'status == "active"',
},
},
{ id: 'end', type: 'end' as const, label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

expect(engine.getActiveTriggerBindings()).toEqual([
{ flowName: 'renewal_alert', triggerType: 'time_relative' },
]);
expect(rec.started[0]).toMatchObject({
flowName: 'renewal_alert',
object: 'contracts',
schedule: { type: 'cron', expression: '0 8 * * *' },
condition: 'status == "active"',
});
// The raw descriptor rides along in config for the trigger to parse.
expect((rec.started[0].config as Record<string, any>)?.timeRelative?.offsetDays).toEqual([60, 30, 7]);
});

it('routes a timeRelative flow to time_relative even when a schedule trigger is present too (precedence)', () => {
const sched = recordingTrigger('schedule');
const timeRel = recordingTrigger('time_relative');
engine.registerTrigger(sched.trigger);
engine.registerTrigger(timeRel.trigger);
engine.registerFlow('expiring', {
name: 'expiring',
label: 'Expiring',
type: 'schedule' as const,
nodes: [
{
id: 'start',
type: 'start' as const,
label: 'Start',
config: {
timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 },
schedule: { type: 'cron', expression: '0 7 * * *' },
},
},
{ id: 'end', type: 'end' as const, label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

expect(engine.getActiveTriggerBindings()).toEqual([
{ flowName: 'expiring', triggerType: 'time_relative' },
]);
expect(timeRel.started).toHaveLength(1);
expect(sched.started).toHaveLength(0);
});
});

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