diff --git a/.changeset/time-relative-trigger.md b/.changeset/time-relative-trigger.md new file mode 100644 index 0000000000..6bcfd74d2c --- /dev/null +++ b/.changeset/time-relative-trigger.md @@ -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.}` 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`). diff --git a/content/docs/references/automation/index.mdx b/content/docs/references/automation/index.mdx index b8a7de94b8..74dce5c365 100644 --- a/content/docs/references/automation/index.mdx +++ b/content/docs/references/automation/index.mdx @@ -15,6 +15,7 @@ This section contains all protocol schemas for the automation layer of ObjectSta + diff --git a/content/docs/references/automation/meta.json b/content/docs/references/automation/meta.json index 34625eeb7c..bb448181dd 100644 --- a/content/docs/references/automation/meta.json +++ b/content/docs/references/automation/meta.json @@ -13,6 +13,7 @@ "offline", "state-machine", "sync", + "time-relative-trigger", "trigger-registry", "webhook" ] diff --git a/content/docs/references/automation/time-relative-trigger.mdx b/content/docs/references/automation/time-relative-trigger.mdx new file mode 100644 index 0000000000..65ed41773e --- /dev/null +++ b/content/docs/references/automation/time-relative-trigger.mdx @@ -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.\}` 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' \} \}, + +\} + +``` + + +**Source:** `packages/spec/src/automation/time-relative-trigger.zod.ts` + + +## 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` | 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. | + + +--- + diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 471f148bc6..835d4a75a6 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -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. diff --git a/packages/lint/src/validate-flow-trigger-readiness.test.ts b/packages/lint/src/validate-flow-trigger-readiness.test.ts index 7dd920546b..84c1e9267f 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.test.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.test.ts @@ -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({ diff --git a/packages/lint/src/validate-flow-trigger-readiness.ts b/packages/lint/src/validate-flow-trigger-readiness.ts index 4e6d0c3a57..c6482bc5f0 100644 --- a/packages/lint/src/validate-flow-trigger-readiness.ts +++ b/packages/lint/src/validate-flow-trigger-readiness.ts @@ -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) { @@ -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')) { diff --git a/packages/services/service-automation/src/engine.test.ts b/packages/services/service-automation/src/engine.test.ts index 46a742646c..95ee9f6418 100644 --- a/packages/services/service-automation/src/engine.test.ts +++ b/packages/services/service-automation/src/engine.test.ts @@ -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)?.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', () => { diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index d321d4896b..cda726006f 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -781,6 +781,31 @@ export class AutomationEngine implements IAutomationService { }; } + // Declarative time-relative sweep (#1874): a start node carrying a + // `timeRelative` descriptor is swept on a schedule and launched once per + // record whose date field falls in the window. Checked BEFORE `schedule` + // because such a flow ALSO carries a `schedule` cadence (the sweep + // interval) — without this precedence it would bind to the plain schedule + // trigger and fire once with no record instead of once per record. + if (config.timeRelative != null && typeof config.timeRelative === 'object') { + const tr = config.timeRelative as Record; + return { + triggerType: 'time_relative', + binding: { + flowName, + object: + typeof tr.object === 'string' + ? tr.object + : typeof config.objectName === 'string' + ? config.objectName + : undefined, + schedule: config.schedule, + condition: (config.condition as FlowTriggerBinding['condition']) ?? undefined, + config, + }, + }; + } + if (config.schedule != null || flow.type === 'schedule') { return { triggerType: 'schedule', @@ -1218,7 +1243,7 @@ export class AutomationEngine implements IAutomationService { if (!resolved) continue; // manual / screen flow — nothing to bind const reason = this.triggers.has(resolved.triggerType) ? `trigger '${resolved.triggerType}' is registered but binding failed — see earlier warnings` - : `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/api ship in @objectstack/trigger-*)`; + : `no '${resolved.triggerType}' trigger is registered — add requires: ['triggers'] (record_change/schedule/time_relative/api ship in @objectstack/trigger-*)`; audit.push({ flowName: name, triggerType: resolved.triggerType, reason }); } return audit; diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 5b564e2adf..5678a7ddac 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2069,7 +2069,12 @@ "SyncExecutionStatusSchema (const)", "SyncMode (type)", "SyncModeSchema (const)", + "TIME_RELATIVE_DEFAULT_CRON (const)", + "TIME_RELATIVE_DEFAULT_MAX_RECORDS (const)", "TRY_CATCH_NODE_TYPE (const)", + "TimeRelativeTrigger (type)", + "TimeRelativeTriggerInput (type)", + "TimeRelativeTriggerSchema (const)", "Transition (type)", "TransitionSchema (const)", "TryCatchConfig (type)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 3e4fb37a21..6a34a66df8 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -573,6 +573,7 @@ "automation/SyncExecutionResult", "automation/SyncExecutionStatus", "automation/SyncMode", + "automation/TimeRelativeTrigger", "automation/Transition", "automation/TryCatchConfig", "automation/WaitEventType", diff --git a/packages/spec/src/automation/index.ts b/packages/spec/src/automation/index.ts index 4e64af0bd4..8ff2a3321a 100644 --- a/packages/spec/src/automation/index.ts +++ b/packages/spec/src/automation/index.ts @@ -9,6 +9,7 @@ export * from './webhook.zod'; export * from './approval.zod'; export * from './etl.zod'; export * from './trigger-registry.zod'; +export * from './time-relative-trigger.zod'; export * from './sync.zod'; export * from './state-machine.zod'; export * from './node-executor.zod'; diff --git a/packages/spec/src/automation/time-relative-trigger.test.ts b/packages/spec/src/automation/time-relative-trigger.test.ts new file mode 100644 index 0000000000..9f45959516 --- /dev/null +++ b/packages/spec/src/automation/time-relative-trigger.test.ts @@ -0,0 +1,86 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + TimeRelativeTriggerSchema, + TIME_RELATIVE_DEFAULT_CRON, + TIME_RELATIVE_DEFAULT_MAX_RECORDS, +} from './time-relative-trigger.zod'; + +describe('TimeRelativeTriggerSchema', () => { + it('accepts a range-mode descriptor (withinDays)', () => { + const parsed = TimeRelativeTriggerSchema.parse({ + object: 'contracts', + dateField: 'end_date', + withinDays: 60, + filter: { status: 'active' }, + }); + expect(parsed.withinDays).toBe(60); + expect(parsed.offsetDays).toBeUndefined(); + }); + + it('accepts an offset-mode descriptor (offsetDays)', () => { + const parsed = TimeRelativeTriggerSchema.parse({ + object: 'contracts', + dateField: 'end_date', + offsetDays: [60, 30, 7], + }); + expect(parsed.offsetDays).toEqual([60, 30, 7]); + }); + + it('accepts negative offsets/windows (overdue / day-after)', () => { + expect(() => + TimeRelativeTriggerSchema.parse({ object: 'po', dateField: 'due_date', withinDays: -14 }), + ).not.toThrow(); + expect(() => + TimeRelativeTriggerSchema.parse({ object: 'po', dateField: 'due_date', offsetDays: [-1] }), + ).not.toThrow(); + }); + + it('rejects a descriptor with neither windowing mode', () => { + const r = TimeRelativeTriggerSchema.safeParse({ object: 'contracts', dateField: 'end_date' }); + expect(r.success).toBe(false); + }); + + it('rejects a descriptor with BOTH windowing modes (mutually exclusive)', () => { + const r = TimeRelativeTriggerSchema.safeParse({ + object: 'contracts', + dateField: 'end_date', + withinDays: 30, + offsetDays: [7], + }); + expect(r.success).toBe(false); + }); + + it('rejects an empty offsetDays array', () => { + const r = TimeRelativeTriggerSchema.safeParse({ + object: 'contracts', + dateField: 'end_date', + offsetDays: [], + }); + expect(r.success).toBe(false); + }); + + it('rejects non-snake_case object / field names (contract-first)', () => { + expect( + TimeRelativeTriggerSchema.safeParse({ object: 'Contracts', dateField: 'end_date', withinDays: 1 }).success, + ).toBe(false); + expect( + TimeRelativeTriggerSchema.safeParse({ object: 'contracts', dateField: 'endDate', withinDays: 1 }).success, + ).toBe(false); + }); + + it('rejects a non-integer / non-positive maxRecords', () => { + expect( + TimeRelativeTriggerSchema.safeParse({ object: 'c', dateField: 'd', withinDays: 1, maxRecords: 0 }).success, + ).toBe(false); + expect( + TimeRelativeTriggerSchema.safeParse({ object: 'c', dateField: 'd', withinDays: 1, maxRecords: 2.5 }).success, + ).toBe(false); + }); + + it('exposes sane defaults as constants', () => { + expect(TIME_RELATIVE_DEFAULT_CRON).toBe('0 8 * * *'); + expect(TIME_RELATIVE_DEFAULT_MAX_RECORDS).toBeGreaterThan(0); + }); +}); diff --git a/packages/spec/src/automation/time-relative-trigger.zod.ts b/packages/spec/src/automation/time-relative-trigger.zod.ts new file mode 100644 index 0000000000..760a3431eb --- /dev/null +++ b/packages/spec/src/automation/time-relative-trigger.zod.ts @@ -0,0 +1,170 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { z } from 'zod'; +import { lazySchema } from '../shared/lazy-schema'; + +/** + * 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.}` 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' } }, + * } + * ``` + */ + +/** snake_case machine-name pattern (object / field names). */ +const MACHINE_NAME = /^[a-z_][a-z0-9_]*$/; + +/** + * Declarative descriptor for a time-relative trigger. Lives on a flow's start + * node under `config.timeRelative`. Exactly ONE windowing mode — `withinDays` + * (a range) or `offsetDays` (discrete thresholds) — must be set. + */ +export const TimeRelativeTriggerSchema = lazySchema(() => + z + .object({ + /** + * Object whose records are swept. Its machine name — the canonical id + * everywhere (matches exactly, snake_case). + */ + object: z + .string() + .regex(MACHINE_NAME) + .describe('Object (machine name) to sweep, e.g. "contracts".'), + + /** + * The `date` / `datetime` field compared against "now". Its value is + * matched day-granular against the computed window/offsets. + */ + dateField: z + .string() + .regex(MACHINE_NAME) + .describe('Date or datetime field evaluated relative to today, e.g. "end_date".'), + + /** + * **Range mode.** Fire for every record whose `dateField` lies within this + * many days of today (inclusive, day-granular): + * - `withinDays > 0` → upcoming: `dateField ∈ [startOfToday, endOf(today + N)]` + * (the "expiring soon" case). Fires every day the record stays in range. + * - `withinDays < 0` → overdue: `dateField ∈ [startOf(today − |N|), endOfToday]` + * (a bounded "past due" lookback — bounded on purpose, so an ancient + * record does not re-alert forever). + * - `withinDays === 0` → due today. + * + * Mutually exclusive with {@link offsetDays}. + */ + withinDays: z + .number() + .int() + .optional() + .describe( + 'Range mode: fire while dateField is within N days of today. Positive = upcoming, negative = overdue lookback, 0 = today.', + ), + + /** + * **Offset mode.** Fire when `dateField` falls exactly `offset` days from + * today, for each offset listed — the robust form of the T-minus reminder + * (`[60, 30, 7]` = alert at 60, 30, and 7 days out). Evaluated by the daily + * sweep, so it fires on the right day regardless of when the record last + * changed. Positive = future, negative = past (e.g. `[-1]` the day after). + * + * Mutually exclusive with {@link withinDays}. + */ + offsetDays: z + .array(z.number().int()) + .min(1) + .optional() + .describe('Offset mode: fire when dateField is exactly today + each offset (e.g. [60, 30, 7]).'), + + /** + * Optional additional filter, ANDed with the computed date window — a + * plain ObjectQL `where` map (e.g. `{ status: 'active' }`) so the sweep + * only launches the flow for records in a relevant state. + */ + filter: z + .record(z.string(), z.unknown()) + .optional() + .describe('Extra ObjectQL where-map ANDed with the date window (e.g. { status: "active" }).'), + + /** + * Cap on how many records one sweep launches the flow for, so a + * misconfigured window can't fan out unboundedly. Defaults to + * {@link TIME_RELATIVE_DEFAULT_MAX_RECORDS}; the sweep logs when it clamps. + */ + maxRecords: z + .number() + .int() + .positive() + .optional() + .describe('Max records launched per sweep (default 1000). The sweep logs when it clamps.'), + }) + .refine((v) => (v.withinDays === undefined) !== (v.offsetDays === undefined), { + message: 'Provide exactly one of `withinDays` (range mode) or `offsetDays` (offset mode).', + }), +); + +export type TimeRelativeTrigger = z.infer; +/** Authoring input for {@link TimeRelativeTrigger} (defaulted fields optional). */ +export type TimeRelativeTriggerInput = z.input; + +/** + * Default per-sweep record cap when a descriptor omits `maxRecords`. Keeps a + * mis-scoped window (e.g. `withinDays: 3650`) from launching the flow for an + * entire table in one tick. + */ +export const TIME_RELATIVE_DEFAULT_MAX_RECORDS = 1000; + +/** + * Default sweep cadence when a time-relative flow's start node carries no + * `schedule` descriptor: once a day at 08:00 UTC. A daily cadence is the point + * of the feature (evaluate the window every day so a threshold is never missed), + * so this default — not "never" — is what an author who omits it expects. + */ +export const TIME_RELATIVE_DEFAULT_CRON = '0 8 * * *'; diff --git a/packages/triggers/trigger-schedule/README.md b/packages/triggers/trigger-schedule/README.md index d426d30351..b71dec130d 100644 --- a/packages/triggers/trigger-schedule/README.md +++ b/packages/triggers/trigger-schedule/README.md @@ -66,3 +66,57 @@ rather than failing startup. A flow that throws during a scheduled run is logged and swallowed — it never crashes the job runner. + +## Time-relative trigger (`TimeRelativeTriggerPlugin`) + +The **declarative** answer to "act on records whose date field is coming up (or +overdue)" (#1874) — without the fragile date-equality-on-record-change pattern +(which only fires if a record happens to be edited on the threshold day) or a +hand-rolled cron + range query per flow. + +A flow whose `start` node declares a `timeRelative` descriptor is swept on a +schedule and launched **once per matching record**: + +```ts +{ + type: 'start', + 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 + maxRecords: 1000, // optional per-sweep cap (default 1000) + }, + schedule: { type: 'cron', expression: '0 8 * * *' }, // optional; defaults to daily 08:00 UTC + condition: '...', // optional per-record start-condition gate + }, +} +``` + +The matched record rides on the automation context (`event: 'time_relative'`, +`record`, `params`), so the start-node `condition` gate and `{record.}` +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. + +| Mode | Semantics (day-granular, UTC, always includes today) | +| ------------------- | --------------------------------------------------------------------- | +| `withinDays: N` | `dateField ∈ [today, today + N]` (upcoming). `N < 0` = overdue lookback. | +| `offsetDays: [a,b]` | one single-day match per offset (`today + a`, `today + b`, …). | + +It needs both the job service (sweep cadence) **and** the ObjectQL engine (the +date-window query); register it alongside the schedule trigger: + +```ts +import { ScheduleTriggerPlugin, TimeRelativeTriggerPlugin } from '@objectstack/plugin-trigger-schedule'; + +kernel + .use(new ScheduleTriggerPlugin()) // plain schedule flows + .use(new TimeRelativeTriggerPlugin()); // ← time-relative sweeps (needs the ObjectQL engine) +``` + +The discovery query runs as a system operation (RLS-bypassing — a background +sweep sees all rows), is capped at `maxRecords` per tick (logged when it +clamps), and isolates per-record failures so one bad row never aborts the sweep. diff --git a/packages/triggers/trigger-schedule/src/index.ts b/packages/triggers/trigger-schedule/src/index.ts index a639fe7ca7..ea4efb0abc 100644 --- a/packages/triggers/trigger-schedule/src/index.ts +++ b/packages/triggers/trigger-schedule/src/index.ts @@ -8,3 +8,11 @@ export type { JobServiceSurface, TriggerLogger, } from './schedule-trigger.js'; + +export { TimeRelativeTriggerPlugin } from './time-relative-plugin.js'; +export { + TimeRelativeTrigger, + computeDateWindows, + buildWindowWhere, +} from './time-relative-trigger.js'; +export type { TimeRelativeDataEngine, DateWindow } from './time-relative-trigger.js'; diff --git a/packages/triggers/trigger-schedule/src/schedule-trigger.ts b/packages/triggers/trigger-schedule/src/schedule-trigger.ts index e95355291b..c17e8dcd21 100644 --- a/packages/triggers/trigger-schedule/src/schedule-trigger.ts +++ b/packages/triggers/trigger-schedule/src/schedule-trigger.ts @@ -46,6 +46,14 @@ export interface TriggerLogger { info(msg: string, ...args: unknown[]): void; warn(msg: string, ...args: unknown[]): void; debug?(msg: string, ...args: unknown[]): void; + /** + * Execution failures log here when available (falling back to `warn`). + * ERROR matters operationally: the CLI's boot-quiet window swallows + * stdout (debug/info/warn) but stderr (error/fatal) always lands — so a + * per-record sweep failure stays visible. Mirrors the record-change + * trigger's logger surface. + */ + error?(msg: string, ...args: unknown[]): void; } const JOB_PREFIX = 'flow-schedule'; diff --git a/packages/triggers/trigger-schedule/src/time-relative-plugin.ts b/packages/triggers/trigger-schedule/src/time-relative-plugin.ts new file mode 100644 index 0000000000..33c63ae43f --- /dev/null +++ b/packages/triggers/trigger-schedule/src/time-relative-plugin.ts @@ -0,0 +1,99 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Plugin, PluginContext } from '@objectstack/core'; +import { TimeRelativeTrigger } from './time-relative-trigger.js'; +import type { TimeRelativeDataEngine } from './time-relative-trigger.js'; +import type { FlowTrigger, JobServiceSurface } from './schedule-trigger.js'; + +/** + * The slice of the automation engine this plugin needs: register a trigger on + * its `FlowTrigger` extension point. Declared structurally so the plugin does + * not take a build dependency on `@objectstack/service-automation`. + */ +interface AutomationTriggerRegistry { + registerTrigger(trigger: FlowTrigger): void; + unregisterTrigger?(type: string): void; +} + +/** + * TimeRelativeTriggerPlugin + * + * Arms **declarative time-relative flows** (#1874): a flow whose start node + * declares `config.timeRelative` (object + dateField + `withinDays`/`offsetDays`) + * is swept on a schedule and launched once per record whose date field falls in + * the window — no hand-written cron + range query, no fragile + * date-equality-on-record-change. + * + * It ships in `@objectstack/trigger-schedule` alongside the plain schedule + * trigger (both are schedule-driven) but is a **separate** plugin: the + * time-relative trigger additionally needs the ObjectQL engine (for the sweep + * query), so keeping it separate leaves the plain `ScheduleTriggerPlugin`'s + * dependency surface unchanged. Depends on the job service (sweep cadence) and + * the ObjectQL engine (record discovery); both are resolved lazily per `start()` + * so adapter upgrades are always picked up. + */ +export class TimeRelativeTriggerPlugin implements Plugin { + name = 'com.objectstack.trigger.time-relative'; + type = 'standard'; + version = '1.0.0'; + dependencies = ['com.objectstack.service.job', 'com.objectstack.engine.objectql']; + + async init(ctx: PluginContext): Promise { + ctx.logger.info('Time-relative trigger plugin initialized'); + } + + async start(ctx: PluginContext): Promise { + // The automation service, job service, and ObjectQL engine are all + // resolvable once the kernel is ready (kernel:ready fires after + // AutomationServicePlugin.start() has pulled flows in and after the job + // service upgrades its adapter). + ctx.hook('kernel:ready', async () => { + const automation = this.resolveService(ctx, 'automation'); + if (!automation || typeof automation.registerTrigger !== 'function') { + ctx.logger.warn( + 'TimeRelativeTriggerPlugin: automation service not available — time-relative trigger NOT installed', + ); + return; + } + + // Probe once for a clear startup warning; the trigger re-resolves + // both collaborators lazily on each start()/sweep so late upgrades + // are always picked up. + if (!this.resolveService(ctx, 'job')) { + ctx.logger.warn( + 'TimeRelativeTriggerPlugin: job service not available — time-relative sweeps will not run until one is registered', + ); + } + if (!this.resolveDataEngine(ctx)) { + ctx.logger.warn( + 'TimeRelativeTriggerPlugin: ObjectQL engine not available — time-relative sweeps will find no records until it is', + ); + } + + const trigger = new TimeRelativeTrigger( + () => this.resolveService(ctx, 'job'), + () => this.resolveDataEngine(ctx), + ctx.logger, + ); + automation.registerTrigger(trigger); + ctx.logger.info('TimeRelativeTriggerPlugin: time-relative trigger registered'); + }); + } + + private resolveService(ctx: PluginContext, name: string): T | null { + try { + return ctx.getService(name) ?? null; + } catch { + return null; + } + } + + private resolveDataEngine(ctx: PluginContext): TimeRelativeDataEngine | null { + // Primary alias 'objectql', fallback 'data' (some kernels register the + // engine under both) — same lookup the record-change trigger uses. + return ( + this.resolveService(ctx, 'objectql') ?? + this.resolveService(ctx, 'data') + ); + } +} diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts new file mode 100644 index 0000000000..7ef6174dc3 --- /dev/null +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts @@ -0,0 +1,480 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/spec/contracts'; +import { + TimeRelativeTrigger, + computeDateWindows, + buildWindowWhere, + type TimeRelativeDataEngine, + type FlowTriggerBinding, + type JobServiceSurface, + type TriggerLogger, +} from './index.js'; +import { TimeRelativeTriggerPlugin } from './time-relative-plugin.js'; + +// ─── Test doubles ─────────────────────────────────────────────────── + +interface ScheduledJob { + name: string; + schedule: JobSchedule; + handler: JobHandler; +} + +/** Fake IJobService slice: records schedule()/cancel() and can fire a job. */ +function fakeJobService() { + const jobs = new Map(); + const service: JobServiceSurface = { + async schedule(name, schedule, handler) { + jobs.set(name, { name, schedule, handler }); + }, + async cancel(name) { + jobs.delete(name); + }, + }; + return { + service, + jobs, + async fire(name: string, jobId = 'run1') { + await jobs.get(name)?.handler({ jobId }); + }, + }; +} + +type Row = Record; + +interface FindCall { + objectName: string; + where: Record; + limit?: number; + context?: { isSystem?: boolean }; +} + +/** + * Fake ObjectQL surface. `find` filters the dataset by the date-field range in + * the `where` (compared temporally, exactly as the real driver does after + * per-column coercion) plus any scalar equality keys, and records every call so + * tests can assert the emitted filter shape. + */ +function fakeDataEngine(rows: Row[], knownObjects: string[] = ['contracts']) { + const calls: FindCall[] = []; + const engine: TimeRelativeDataEngine = { + async find(objectName, query) { + const where = (query?.where ?? {}) as Record; + calls.push({ objectName, where, limit: query?.limit, context: query?.context }); + const out = rows.filter((row) => matches(row, where)); + return typeof query?.limit === 'number' ? out.slice(0, query.limit) : out; + }, + getObject(name) { + return knownObjects.includes(name) ? { name } : undefined; + }, + }; + return { engine, calls }; +} + +/** Minimal where matcher: temporal range on the date field + scalar equality. */ +function matches(row: Row, where: Record): boolean { + for (const [key, cond] of Object.entries(where)) { + const val = row[key]; + if (cond && typeof cond === 'object' && !Array.isArray(cond)) { + const c = cond as Record; + const t = typeof val === 'string' || val instanceof Date ? Date.parse(String(val)) : NaN; + if ('$gte' in c && !(t >= Date.parse(String(c.$gte)))) return false; + if ('$lte' in c && !(t <= Date.parse(String(c.$lte)))) return false; + } else if (val !== cond) { + return false; + } + } + return true; +} + +function silentLogger(): TriggerLogger { + return { info: () => {}, warn: () => {}, debug: () => {} }; +} + +/** Fixed reference clock: 2026-07-18 (noon UTC). */ +const NOW = () => new Date('2026-07-18T12:00:00.000Z'); + +function binding(timeRelative: unknown, overrides: Partial = {}): FlowTriggerBinding { + return { + flowName: 'renewal_alert', + object: 'contracts', + config: { timeRelative }, + ...overrides, + }; +} + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +// ─── computeDateWindows (pure) ────────────────────────────────────── + +describe('computeDateWindows', () => { + const now = new Date('2026-07-18T12:00:00.000Z'); + + it('withinDays > 0 → one window [startOfToday, endOf(today+N)]', () => { + const w = computeDateWindows({ object: 'c', dateField: 'd', withinDays: 60 }, now); + expect(w).toEqual([{ gte: '2026-07-18T00:00:00.000Z', lte: '2026-09-16T23:59:59.999Z' }]); + }); + + it('withinDays === 0 → just today', () => { + const w = computeDateWindows({ object: 'c', dateField: 'd', withinDays: 0 }, now); + expect(w).toEqual([{ gte: '2026-07-18T00:00:00.000Z', lte: '2026-07-18T23:59:59.999Z' }]); + }); + + it('withinDays < 0 → overdue lookback [startOf(today-|N|), endOfToday]', () => { + const w = computeDateWindows({ object: 'c', dateField: 'd', withinDays: -14 }, now); + expect(w).toEqual([{ gte: '2026-07-04T00:00:00.000Z', lte: '2026-07-18T23:59:59.999Z' }]); + }); + + it('offsetDays → one single-day window per offset', () => { + const w = computeDateWindows({ object: 'c', dateField: 'd', offsetDays: [60, 30, 7] }, now); + expect(w).toEqual([ + { gte: '2026-09-16T00:00:00.000Z', lte: '2026-09-16T23:59:59.999Z' }, + { gte: '2026-08-17T00:00:00.000Z', lte: '2026-08-17T23:59:59.999Z' }, + { gte: '2026-07-25T00:00:00.000Z', lte: '2026-07-25T23:59:59.999Z' }, + ]); + }); + + it('is independent of the time-of-day of `now` (day-granular)', () => { + const morning = computeDateWindows({ object: 'c', dateField: 'd', withinDays: 7 }, new Date('2026-07-18T00:01:00Z')); + const night = computeDateWindows({ object: 'c', dateField: 'd', withinDays: 7 }, new Date('2026-07-18T23:59:00Z')); + expect(morning).toEqual(night); + }); +}); + +describe('buildWindowWhere', () => { + const window = { gte: '2026-07-18T00:00:00.000Z', lte: '2026-09-16T23:59:59.999Z' }; + + it('ANDs the static filter with the date range', () => { + const where = buildWindowWhere( + { object: 'contracts', dateField: 'end_date', withinDays: 60, filter: { status: 'active' } }, + window, + ); + expect(where).toEqual({ status: 'active', end_date: { $gte: window.gte, $lte: window.lte } }); + }); + + it('emits just the date range when there is no filter', () => { + const where = buildWindowWhere({ object: 'contracts', dateField: 'end_date', withinDays: 60 }, window); + expect(where).toEqual({ end_date: { $gte: window.gte, $lte: window.lte } }); + }); +}); + +// ─── TimeRelativeTrigger ───────────────────────────────────────────── + +describe('TimeRelativeTrigger', () => { + it('schedules a daily sweep with the explicit schedule descriptor', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + + trigger.start( + binding( + { object: 'contracts', dateField: 'end_date', withinDays: 60 }, + { schedule: { type: 'cron', expression: '0 6 * * *' } }, + ), + async () => {}, + ); + await flush(); + + expect(job.jobs.size).toBe(1); + expect(job.jobs.get('flow-time-relative:renewal_alert')?.schedule).toEqual({ + type: 'cron', + expression: '0 6 * * *', + }); + }); + + it('defaults to a daily cron when the flow declares no schedule', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async () => {}); + await flush(); + + expect(job.jobs.get('flow-time-relative:renewal_alert')?.schedule).toEqual({ + type: 'cron', + expression: '0 8 * * *', + }); + }); + + it('queries the window and launches the flow once per matching record', async () => { + const rows: Row[] = [ + { id: 'c1', end_date: '2026-08-01T00:00:00.000Z', status: 'active' }, // in 60d window + { id: 'c2', end_date: '2026-12-31T00:00:00.000Z', status: 'active' }, // out of window + { id: 'c3', end_date: '2026-07-25T00:00:00.000Z', status: 'active' }, // in window + ]; + const job = fakeJobService(); + const { engine, calls } = fakeDataEngine(rows); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const seen: AutomationContext[] = []; + + trigger.start( + binding({ object: 'contracts', dateField: 'end_date', withinDays: 60, filter: { status: 'active' } }), + async (ctx) => { + seen.push(ctx); + }, + ); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + // Only c1 + c3 fall in [today, today+60d]. + expect(seen.map((c) => (c.record as Row).id)).toEqual(['c1', 'c3']); + // Context is record-shaped (so `{record.x}` + start conditions work). + expect(seen[0]).toMatchObject({ object: 'contracts', event: 'time_relative' }); + expect(seen[0].record).toBe(seen[0].params); + // The sweep queries as a system op (sees all rows, RLS-bypassing). + expect(calls[0].context).toEqual({ isSystem: true }); + expect(calls[0].where).toEqual({ + status: 'active', + end_date: { $gte: '2026-07-18T00:00:00.000Z', $lte: '2026-09-16T23:59:59.999Z' }, + }); + }); + + it('offset mode fires one query per offset and dedups records by id', async () => { + const rows: Row[] = [ + { id: 'c1', end_date: '2026-09-16T09:00:00.000Z' }, // T-60 + { id: 'c2', end_date: '2026-07-25T09:00:00.000Z' }, // T-7 + ]; + const job = fakeJobService(); + const { engine, calls } = fakeDataEngine(rows); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + const launched: string[] = []; + + trigger.start( + binding({ object: 'contracts', dateField: 'end_date', offsetDays: [60, 30, 7] }), + async (ctx) => { + launched.push((ctx.record as Row).id as string); + }, + ); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(calls).toHaveLength(3); // one find per offset + expect(launched.sort()).toEqual(['c1', 'c2']); // each fired exactly once + }); + + it('caps the number of records launched per sweep at maxRecords (and warns)', async () => { + const rows: Row[] = Array.from({ length: 5 }, (_, i) => ({ + id: `c${i}`, + end_date: '2026-07-20T00:00:00.000Z', + })); + const job = fakeJobService(); + const { engine } = fakeDataEngine(rows); + const warn = vi.fn(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => engine, + { info: () => {}, warn, debug: () => {} }, + NOW, + ); + const launched: string[] = []; + + trigger.start( + binding({ object: 'contracts', dateField: 'end_date', withinDays: 30, maxRecords: 2 }), + async (ctx) => { + launched.push((ctx.record as Row).id as string); + }, + ); + await flush(); + await job.fire('flow-time-relative:renewal_alert'); + + expect(launched).toHaveLength(2); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('2-record cap')); + }); + + it('isolates a per-record failure so the rest of the batch still runs', async () => { + const rows: Row[] = [ + { id: 'c1', end_date: '2026-07-20T00:00:00.000Z' }, + { id: 'boom', end_date: '2026-07-21T00:00:00.000Z' }, + { id: 'c3', end_date: '2026-07-22T00:00:00.000Z' }, + ]; + const job = fakeJobService(); + const { engine } = fakeDataEngine(rows); + const error = vi.fn(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => engine, + { info: () => {}, warn: () => {}, debug: () => {}, error }, + NOW, + ); + const ok: string[] = []; + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async (ctx) => { + const id = (ctx.record as Row).id as string; + if (id === 'boom') throw new Error('flow blew up'); + ok.push(id); + }); + await flush(); + await expect(job.fire('flow-time-relative:renewal_alert')).resolves.toBeUndefined(); + + expect(ok).toEqual(['c1', 'c3']); + expect(error).toHaveBeenCalledWith(expect.stringContaining("record 'boom'")); + }); + + it('isolates a query failure so the job runner is never broken', async () => { + const job = fakeJobService(); + const engine: TimeRelativeDataEngine = { + async find() { + throw new Error('db down'); + }, + }; + const warn = vi.fn(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => engine, + { info: () => {}, warn, debug: () => {} }, + NOW, + ); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async () => {}); + await flush(); + + await expect(job.fire('flow-time-relative:renewal_alert')).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('sweep failed')); + }); + + it('does not bind when the timeRelative descriptor is invalid', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const warn = vi.fn(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => engine, + { info: () => {}, warn, debug: () => {} }, + NOW, + ); + + // Neither withinDays nor offsetDays → invalid. + trigger.start(binding({ object: 'contracts', dateField: 'end_date' }), async () => {}); + await flush(); + + expect(job.jobs.size).toBe(0); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no valid `timeRelative` descriptor')); + }); + + it('does not bind when both windowing modes are set (mutually exclusive)', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + + trigger.start( + binding({ object: 'contracts', dateField: 'end_date', withinDays: 30, offsetDays: [7] }), + async () => {}, + ); + await flush(); + expect(job.jobs.size).toBe(0); + }); + + it('warns (but still binds) when the swept object is unknown', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([], ['other_object']); + const warn = vi.fn(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => engine, + { info: () => {}, warn, debug: () => {} }, + NOW, + ); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async () => {}); + await flush(); + + expect(job.jobs.size).toBe(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknown object 'contracts'")); + }); + + it('skips the sweep (warns) when the data engine is unavailable at fire time', async () => { + const job = fakeJobService(); + const warn = vi.fn(); + const trigger = new TimeRelativeTrigger( + () => job.service, + () => null, + { info: () => {}, warn, debug: () => {} }, + NOW, + ); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async () => {}); + await flush(); + await expect(job.fire('flow-time-relative:renewal_alert')).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('data engine unavailable')); + }); + + it('stop() cancels the flow\'s sweep job; re-binding is idempotent', async () => { + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => job.service, () => engine, silentLogger(), NOW); + + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async () => {}); + await flush(); + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 90 }), async () => {}); + await flush(); + expect(job.jobs.size).toBe(1); // idempotent — one job + + trigger.stop('renewal_alert'); + await flush(); + expect(job.jobs.size).toBe(0); + }); + + it('does not schedule when the job service is unavailable', () => { + const { engine } = fakeDataEngine([]); + const trigger = new TimeRelativeTrigger(() => null, () => engine, silentLogger(), NOW); + expect(() => + trigger.start(binding({ object: 'contracts', dateField: 'end_date', withinDays: 30 }), async () => {}), + ).not.toThrow(); + }); +}); + +// ─── TimeRelativeTriggerPlugin ────────────────────────────────────── + +describe('TimeRelativeTriggerPlugin', () => { + function fakePluginCtx(services: Record) { + const readyHandlers: Array<() => Promise | void> = []; + return { + readyHandlers, + ctx: { + logger: silentLogger() as TriggerLogger, + getService(name: string): T { + if (!(name in services)) throw new Error(`no service '${name}'`); + return services[name] as T; + }, + hook(event: string, handler: () => Promise | void) { + if (event === 'kernel:ready') readyHandlers.push(handler); + }, + }, + }; + } + + it('registers the time_relative trigger when automation + job + objectql exist', async () => { + const registerTrigger = vi.fn(); + const job = fakeJobService(); + const { engine } = fakeDataEngine([]); + const fake = fakePluginCtx({ automation: { registerTrigger }, job: job.service, objectql: engine }); + + const plugin = new TimeRelativeTriggerPlugin(); + await plugin.start(fake.ctx as never); + await fake.readyHandlers[0](); + + expect(registerTrigger).toHaveBeenCalledTimes(1); + expect((registerTrigger.mock.calls[0][0] as TimeRelativeTrigger).type).toBe('time_relative'); + }); + + it('still registers when job/objectql are missing (warns, lazy pickup)', async () => { + const registerTrigger = vi.fn(); + const fake = fakePluginCtx({ automation: { registerTrigger } }); + + const plugin = new TimeRelativeTriggerPlugin(); + await plugin.start(fake.ctx as never); + await fake.readyHandlers[0](); + + expect(registerTrigger).toHaveBeenCalledTimes(1); + }); + + it('skips gracefully when the automation service is absent', async () => { + const job = fakeJobService(); + const fake = fakePluginCtx({ job: job.service }); + + const plugin = new TimeRelativeTriggerPlugin(); + await plugin.start(fake.ctx as never); + await expect(fake.readyHandlers[0]()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/triggers/trigger-schedule/src/time-relative-trigger.ts b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts new file mode 100644 index 0000000000..8cc6b3fd3d --- /dev/null +++ b/packages/triggers/trigger-schedule/src/time-relative-trigger.ts @@ -0,0 +1,349 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { AutomationContext, JobSchedule, JobHandler } from '@objectstack/spec/contracts'; +import { + TimeRelativeTriggerSchema, + TIME_RELATIVE_DEFAULT_CRON, + TIME_RELATIVE_DEFAULT_MAX_RECORDS, +} from '@objectstack/spec/automation'; +import type { TimeRelativeTrigger as TimeRelativeDescriptor } from '@objectstack/spec/automation'; +import { normalizeSchedule } from './schedule-trigger.js'; +import type { FlowTrigger, FlowTriggerBinding, JobServiceSurface, TriggerLogger } from './schedule-trigger.js'; + +/** + * The slice of the ObjectQL data engine this trigger needs: run a filtered + * `find` (to discover the records whose date field falls in the window) and, + * optionally, probe whether an object is registered. Typed structurally — same + * decoupling pattern the record-change trigger uses for its hook surface — so + * this plugin does not take a build dependency on the engine package. + */ +export interface TimeRelativeDataEngine { + find( + objectName: string, + query?: { + where?: Record; + fields?: string[]; + limit?: number; + /** Elevated context — a background sweep must see all rows, not RLS-scoped ones. */ + context?: { isSystem?: boolean }; + }, + ): Promise> | undefined>; + /** + * Optional object-existence probe (the ObjectQL engine's `getObject`). + * When present, {@link TimeRelativeTrigger.start} uses it to call out a + * descriptor whose `object` matches no registered object at bind time — + * otherwise the sweep just quietly finds nothing forever. + */ + getObject?(name: string): unknown; +} + +/** Job-name namespace so time-relative sweeps never collide with plain schedule jobs. */ +const JOB_PREFIX = 'flow-time-relative'; + +const MS_PER_DAY = 86_400_000; + +/** A closed, inclusive instant window `[gte, lte]` as ISO-8601 strings. */ +export interface DateWindow { + /** Lower bound (inclusive), ISO-8601. */ + gte: string; + /** Upper bound (inclusive), ISO-8601. */ + lte: string; +} + +// ─── Pure window math (day-granular, UTC) ─────────────────────────── + +/** Start of `d`'s UTC calendar day (00:00:00.000Z). */ +function startOfUtcDay(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); +} + +/** End of `d`'s UTC calendar day (23:59:59.999Z) — inclusive upper bound. */ +function endOfUtcDay(d: Date): Date { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 23, 59, 59, 999)); +} + +/** `d`'s UTC day shifted by `n` whole days (exact in UTC — no DST drift). */ +function addUtcDays(d: Date, n: number): Date { + return new Date(startOfUtcDay(d).getTime() + n * MS_PER_DAY); +} + +/** + * Compute the inclusive date window(s) a descriptor selects, relative to `now`. + * + * - `offsetDays` → one single-day window per offset (`today + offset`), so the + * sweep fires exactly on each threshold day (the robust T-minus reminder). + * - `withinDays` → one range window: `[today, today + N]` when N ≥ 0 (upcoming), + * or `[today − |N|, today]` when N < 0 (overdue lookback). Always includes today. + * + * Day-granular and computed in UTC. The upper bound is the *end* of its day + * (`23:59:59.999Z`), so a `datetime` field matches for the whole day and a + * `date` field (compared as `YYYY-MM-DD` after the driver truncates) is inclusive. + */ +export function computeDateWindows(desc: TimeRelativeDescriptor, now: Date): DateWindow[] { + const today = startOfUtcDay(now); + + if (desc.offsetDays && desc.offsetDays.length > 0) { + return desc.offsetDays.map((offset) => { + const day = addUtcDays(today, offset); + return { gte: startOfUtcDay(day).toISOString(), lte: endOfUtcDay(day).toISOString() }; + }); + } + + const n = desc.withinDays ?? 0; + if (n >= 0) { + return [{ gte: startOfUtcDay(today).toISOString(), lte: endOfUtcDay(addUtcDays(today, n)).toISOString() }]; + } + // Negative: window extends into the past, still anchored to (and including) today. + return [{ gte: startOfUtcDay(addUtcDays(today, n)).toISOString(), lte: endOfUtcDay(today).toISOString() }]; +} + +/** + * Build the ObjectQL `where` map for one date window: the descriptor's static + * `filter` (if any) ANDed with a `$gte`/`$lte` range on the date field. The map + * form is the canonical filter shape both drivers evaluate verbatim (the same + * shape the platform's own retention sweep uses). + */ +export function buildWindowWhere(desc: TimeRelativeDescriptor, window: DateWindow): Record { + return { + ...(desc.filter ?? {}), + [desc.dateField]: { $gte: window.gte, $lte: window.lte }, + }; +} + +function errMessage(err: unknown): string { + return (err as Error)?.message ?? String(err); +} + +/** + * TimeRelativeTrigger + * + * The declarative answer to "act on records whose date field is coming up (or + * overdue)" (#1874). Instead of the fragile date-equality-on-record-change + * pattern (which only fires if the record happens to be edited on the threshold + * day) or a hand-rolled cron + range query per flow, a flow whose start node + * declares `config.timeRelative` is swept on a schedule (daily by default) and + * launched **once per matching record**. + * + * It composes the schedule trigger's two collaborators: + * - the platform {@link JobServiceSurface} owns the sweep cadence (like the + * plain schedule trigger), and + * - the {@link TimeRelativeDataEngine} runs the date-window query (like the + * record-change trigger reaching ObjectQL). + * + * Both are resolved lazily (per call) so adapter upgrades — the durable job + * adapter that replaces the bootstrap ticker on `kernel:ready`, a late-registered + * data engine — are always picked up. The engine owns the start-node `condition` + * gate and `runAs` identity, so this trigger only has to put the matched record + * on the {@link AutomationContext}; `{record.}` interpolation and the + * condition work exactly as they do for a record-change flow. + */ +export class TimeRelativeTrigger implements FlowTrigger { + readonly type = 'time_relative'; + + private readonly getJobService: () => JobServiceSurface | null; + private readonly getDataEngine: () => TimeRelativeDataEngine | null; + private readonly logger: TriggerLogger; + /** Injectable clock so window math is deterministic under test. */ + private readonly now: () => Date; + /** flowName → job name registered for it, so stop() can cancel it. */ + private readonly bound = new Map(); + + constructor( + getJobService: () => JobServiceSurface | null, + getDataEngine: () => TimeRelativeDataEngine | null, + logger: TriggerLogger, + now: () => Date = () => new Date(), + ) { + this.getJobService = getJobService; + this.getDataEngine = getDataEngine; + this.logger = logger; + this.now = now; + } + + start(binding: FlowTriggerBinding, callback: (ctx: AutomationContext) => Promise): void { + const raw = (binding.config as Record | undefined)?.timeRelative; + const parsed = TimeRelativeTriggerSchema.safeParse(raw); + if (!parsed.success) { + this.logger.warn( + `[time-relative] flow '${binding.flowName}' has no valid \`timeRelative\` descriptor — not bound. ` + + `Provide { object, dateField, and exactly one of withinDays | offsetDays }. ` + + `(${parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ')})`, + ); + return; + } + const desc = parsed.data; + + // Cadence: the flow's start-node schedule descriptor, or a daily default. + // A daily sweep is the whole point (evaluate the window every day so a + // threshold day is never missed), so an omitted schedule means "daily", + // not "never". + const schedule: JobSchedule = + normalizeSchedule(binding.schedule) ?? { type: 'cron', expression: TIME_RELATIVE_DEFAULT_CRON }; + + const jobService = this.getJobService(); + if (!jobService || typeof jobService.schedule !== 'function') { + this.logger.warn( + `[time-relative] job service unavailable — flow '${binding.flowName}' not scheduled`, + ); + return; + } + + // Best-effort object-existence probe at bind time (the engine may be + // available now even though the sweep resolves it lazily). A descriptor + // targeting an unknown object would sweep forever finding nothing. + const engineNow = this.getDataEngine(); + if (desc.object && engineNow && typeof engineNow.getObject === 'function') { + let known: unknown; + try { + known = engineNow.getObject(desc.object); + } catch { + known = undefined; + } + if (!known) { + this.logger.warn( + `[time-relative] flow '${binding.flowName}' targets unknown object '${desc.object}' — the sweep is bound but will match nothing until that object is registered. ` + + `Object names match exactly; check config.timeRelative.object.`, + ); + } + } + + // Idempotent: drop any prior schedule for this flow before re-binding + // (covers disable→enable cycles and hot reload). + this.stop(binding.flowName); + + const jobName = `${JOB_PREFIX}:${binding.flowName}`; + const maxRecords = desc.maxRecords ?? TIME_RELATIVE_DEFAULT_MAX_RECORDS; + + const handler: JobHandler = async () => { + try { + await this.sweep(binding.flowName, desc, maxRecords, callback); + } catch (err) { + // Error isolation: a sweep failure must not crash the job + // runner / ticker. Log and swallow. + this.logger.warn( + `[time-relative] flow '${binding.flowName}' sweep failed: ${errMessage(err)}`, + ); + } + }; + + this.bound.set(binding.flowName, jobName); + // FlowTrigger.start is sync; the job service's schedule() is async. + // Fire-and-forget with error logging (mirrors ScheduleTrigger). + void Promise.resolve(jobService.schedule(jobName, schedule, handler)) + .then(() => { + const mode = desc.offsetDays + ? `offsets [${desc.offsetDays.join(', ')}]d` + : `within ${desc.withinDays}d`; + this.logger.info( + `[time-relative] bound flow '${binding.flowName}' → sweep '${desc.object}.${desc.dateField}' ${mode} on ${schedule.type}` + + (schedule.expression ? ` '${schedule.expression}'` : '') + + (schedule.intervalMs ? ` every ${schedule.intervalMs}ms` : ''), + ); + }) + .catch((err) => { + this.bound.delete(binding.flowName); + this.logger.warn( + `[time-relative] failed to schedule flow '${binding.flowName}': ${errMessage(err)}`, + ); + }); + } + + /** + * Run one sweep: query each date window, union the matched records (deduped + * by id, capped at `maxRecords`), and launch the flow once per record. A + * per-record failure is isolated so one bad row never aborts the batch. + */ + private async sweep( + flowName: string, + desc: TimeRelativeDescriptor, + maxRecords: number, + callback: (ctx: AutomationContext) => Promise, + ): Promise { + const engine = this.getDataEngine(); + if (!engine || typeof engine.find !== 'function') { + this.logger.warn( + `[time-relative] data engine unavailable — flow '${flowName}' sweep skipped this tick`, + ); + return; + } + + const windows = computeDateWindows(desc, this.now()); + const seenIds = new Set(); + const matched: Array> = []; + + for (const window of windows) { + if (matched.length >= maxRecords) break; + const where = buildWindowWhere(desc, window); + const rows = + (await engine.find(desc.object, { + where, + limit: maxRecords, + context: { isSystem: true }, + })) ?? []; + for (const row of rows) { + const id = (row as { id?: unknown }).id; + // Dedup across windows (offset mode) by id; rows without an id + // are always kept (can't dedup, better than dropping). + if (id != null) { + if (seenIds.has(id)) continue; + seenIds.add(id); + } + matched.push(row); + if (matched.length >= maxRecords) break; + } + } + + if (matched.length >= maxRecords) { + this.logger.warn( + `[time-relative] flow '${flowName}' sweep hit the ${maxRecords}-record cap — some matching records were NOT processed this tick. ` + + `Narrow the window/filter, or raise config.timeRelative.maxRecords.`, + ); + } + + let launched = 0; + let failed = 0; + for (const record of matched) { + try { + const ctx: AutomationContext = { + record, + object: desc.object, + event: 'time_relative', + // Expose the record as params too, so flows with named `isInput` + // variables matching record fields get them seeded (parity with + // the record-change trigger). + params: record, + }; + await callback(ctx); + launched++; + } catch (err) { + failed++; + // Error isolation per record: one failing flow run must not stop + // the sweep. ERROR when available (stderr survives the CLI's + // boot-quiet stdout window), else warn. + const log = this.logger.error?.bind(this.logger) ?? this.logger.warn.bind(this.logger); + log( + `[time-relative] flow '${flowName}' failed for record '${String((record as { id?: unknown }).id ?? '?')}': ${errMessage(err)}`, + ); + } + } + + this.logger.debug?.( + `[time-relative] flow '${flowName}' swept '${desc.object}': ${matched.length} matched, ${launched} launched, ${failed} failed`, + ); + } + + stop(flowName: string): void { + const jobName = this.bound.get(flowName); + if (!jobName) return; + this.bound.delete(flowName); + const jobService = this.getJobService(); + if (!jobService || typeof jobService.cancel !== 'function') return; + void Promise.resolve(jobService.cancel(jobName)) + .then(() => this.logger.debug?.(`[time-relative] unbound flow '${flowName}'`)) + .catch((err) => { + this.logger.warn( + `[time-relative] failed to unbind flow '${flowName}': ${errMessage(err)}`, + ); + }); + } +}