@nanobpm/urban 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -78,7 +78,7 @@ console.log(app.inspect()); // { app, name, httpPort, ... }
78
78
 
79
79
  `runFromEnv` reads the engine address and transport from the environment, starts
80
80
  the app (validate → deploy → provision datasources → start workers → serve
81
- surfaces and webhook triggers), and installs SIGINT/SIGTERM handlers for a
81
+ surfaces and webhook + cron triggers), and installs SIGINT/SIGTERM handlers for a
82
82
  graceful shutdown. For full control, assemble the pieces yourself:
83
83
 
84
84
  ```ts
@@ -141,6 +141,37 @@ wiring; `WorkflowClient` deploys and starts, `Worker` hosts your `run` steps.
141
141
  inspectable in a modeller/Operate — `@nanobpm/urban` bundles `bpmn-auto-layout`
142
142
  so this works out of the box.
143
143
 
144
+ ### Triggers — the inbound I/O edge
145
+
146
+ Declare `triggers[]` in the app manifest to turn outside events into engine
147
+ calls (start a process or publish a message). Two source kinds are built in:
148
+
149
+ - **`webhook`** — mounts an HTTP `POST` route (`/hooks/<id>` by default), with
150
+ optional `hmac:<connection>` signature verification and delivery-id
151
+ idempotency.
152
+ - **`cron`** — arms a background timer from a 5-field crontab `spec` (evaluated
153
+ in **UTC**), firing its `action` on schedule and rescheduling itself.
154
+
155
+ ```jsonc
156
+ {
157
+ "triggers": [
158
+ { "id": "nightly", "type": "cron", "spec": "0 6 * * *",
159
+ "action": { "start": "daily-report" } },
160
+ { "id": "gh", "type": "webhook", "auth": "hmac:github",
161
+ "action": { "message": "pr-opened", "correlationKey": "= body.number" } }
162
+ ]
163
+ }
164
+ ```
165
+
166
+ Cron scheduling is **app-side**: per-replica, in-memory, and it stops when the
167
+ process stops — glue for invoking handlers on a clock, not a durable clustered
168
+ scheduler. It therefore only honours `onMissed: "skip"` (the default); a declared
169
+ `"once"`/`"all"` catch-up needs a persisted last-fire the runtime does not keep,
170
+ so it warns and degrades to `skip`. For **durable, clustered** scheduling that
171
+ survives restarts, model a timer **start**/**intermediate** event instead with
172
+ `w.startOn(...)` / `w.timer(...)` from [`@nanobpm/workflow`](../workflow) — the
173
+ engine owns those.
174
+
144
175
  ## Related packages
145
176
 
146
177
  - [`@nanobpm/workflow`](../workflow) — the code-first process surface (`defineFlow`).
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export * from "./runtime/index.ts";
2
2
  export { createCamundaClient } from "@nanobpm/nano-sdk";
3
3
  export * from "./toolkit/index.ts";
4
4
  export { defineFlow, declarativeToBpmn, declarativeToLayoutedBpmn, externalJobTypes, envelope, layoutBpmn, toBpmn, toDeployableBpmn, Worker, WorkflowClient, WorkflowError, } from "@nanobpm/workflow";
5
- export type { FlowBuilder, Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, WorkerOptions, ActivityEvent, } from "@nanobpm/workflow";
5
+ export type { FlowBuilder, TimerStart, Envelope, EnvelopeType, EnvelopeField, FieldSpec, ScalarType, WorkflowClientOptions, NanoSdkClient, NanoJobWorker, JobWorkerConfig, ActivatedJob, WorkerOptions, ActivityEvent, } from "@nanobpm/workflow";
@@ -46,9 +46,28 @@ export interface TriggerDecl {
46
46
  type: string;
47
47
  path?: string;
48
48
  auth?: string;
49
+ /** cron: the 5-field crontab spec (UTC), e.g. "0 6 * * *". */
50
+ spec?: string;
51
+ /** cron catch-up policy for fires missed while the app was down. Default "skip". */
52
+ onMissed?: "skip" | "once" | "all";
53
+ /** Source-kind-specific settings (e.g. file `{ pollMs }`). */
54
+ config?: Record<string, unknown>;
55
+ /** Name of a connections[] entry supplying this source's credentials. */
56
+ connection?: string;
57
+ /** Maps the event to one engine call: start a process OR publish a message (ADR 0025 §1). */
49
58
  action?: {
59
+ /** Process id/name to start. */
60
+ start?: string;
61
+ /** messageName to publish as a CorrelateMessage. */
50
62
  message?: string;
63
+ /** correlationKey (literal, or `= body.path`) for a message action. */
51
64
  correlationKey?: string;
65
+ /**
66
+ * Variables for the started instance / published message. Per the manifest schema
67
+ * (`#/$defs/triggerAction.variables`) this is a FEEL string over the event body
68
+ * (e.g. `= body.payload`); an inline object literal is also accepted for convenience.
69
+ */
70
+ variables?: string | Record<string, unknown>;
52
71
  };
53
72
  }
54
73
  export interface SurfaceDecl {
@@ -0,0 +1,27 @@
1
+ /** A parsed schedule: the allowed value set per field, plus whether dom/dow are wildcards. */
2
+ export interface CronSchedule {
3
+ minutes: Set<number>;
4
+ hours: Set<number>;
5
+ daysOfMonth: Set<number>;
6
+ months: Set<number>;
7
+ daysOfWeek: Set<number>;
8
+ /** dom / dow were `*` (unrestricted) — controls the OR-vs-AND day rule. */
9
+ domWildcard: boolean;
10
+ dowWildcard: boolean;
11
+ }
12
+ /** Parse a 5-field crontab spec into a `CronSchedule`. Throws on a malformed spec. */
13
+ export declare function parseCron(spec: string): CronSchedule;
14
+ /** Does `date` (read in UTC) match the schedule? */
15
+ export declare function cronMatches(s: CronSchedule, date: Date): boolean;
16
+ /**
17
+ * The first fire strictly after `after`. Scans minute-by-minute (UTC) up to a bounded
18
+ * horizon; returns `undefined` if nothing matches within ~4 years (an impossible spec,
19
+ * e.g. Feb 30). The bound keeps a pathological spec from spinning forever.
20
+ */
21
+ export declare function nextCronFire(s: CronSchedule, after: Date): Date | undefined;
22
+ /**
23
+ * The fire instants strictly between `after` and `until` (both exclusive on `after`,
24
+ * inclusive on `until`). Used by the `onMissed` catch-up policy to enumerate fires that
25
+ * were due while the app was down. Bounded to avoid unbounded output on a huge gap.
26
+ */
27
+ export declare function cronFiresBetween(s: CronSchedule, after: Date, until: Date, max?: number): Date[];
@@ -0,0 +1,138 @@
1
+ // A small, dependency-free 5-field crontab evaluator (ADR 0025 §2) — the schedule engine
2
+ // behind `cron` triggers. Specs are evaluated in **UTC**. Fields, in order:
3
+ //
4
+ // minute (0-59) hour (0-23) day-of-month (1-31) month (1-12) day-of-week (0-6, 0=Sun)
5
+ //
6
+ // Each field supports `*`, a number, `a-b` ranges, `*/n` and `a-b/n` steps, and comma lists
7
+ // of any of those. Month and day-of-week also accept three-letter names (jan…dec, sun…sat).
8
+ // Day-of-month and day-of-week follow the standard cron rule: when **both** are restricted
9
+ // (neither is `*`), a day matches if it satisfies **either** field.
10
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
11
+ const DOWS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
12
+ function nameToNumber(token, names, offset) {
13
+ const i = names.indexOf(token.toLowerCase());
14
+ return i >= 0 ? String(i + offset) : token;
15
+ }
16
+ /** Parse one field into the set of matching integers in [min,max]. */
17
+ function parseField(field, min, max, names) {
18
+ const out = new Set();
19
+ for (const partRaw of field.split(",")) {
20
+ const part = partRaw.trim();
21
+ if (part === "")
22
+ throw new Error(`cron: empty term in "${field}"`);
23
+ let step = 1;
24
+ let range = part;
25
+ const slash = part.indexOf("/");
26
+ if (slash >= 0) {
27
+ range = part.slice(0, slash);
28
+ if (range === "")
29
+ throw new Error(`cron: missing range before "/" in "${part}"`);
30
+ const stepText = part.slice(slash + 1);
31
+ step = Number(stepText);
32
+ if (stepText === "" || !Number.isInteger(step) || step <= 0) {
33
+ throw new Error(`cron: bad step in "${part}"`);
34
+ }
35
+ }
36
+ let lo;
37
+ let hi;
38
+ if (range === "*") {
39
+ lo = min;
40
+ hi = max;
41
+ }
42
+ else {
43
+ const dash = range.indexOf("-", range.startsWith("-") ? 1 : 0);
44
+ if (dash > 0) {
45
+ lo = Number(names ? nameToNumber(range.slice(0, dash), names, min) : range.slice(0, dash));
46
+ hi = Number(names ? nameToNumber(range.slice(dash + 1), names, min) : range.slice(dash + 1));
47
+ }
48
+ else {
49
+ lo = hi = Number(names ? nameToNumber(range, names, min) : range);
50
+ }
51
+ }
52
+ if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) {
53
+ throw new Error(`cron: value out of range [${min},${max}] in "${part}"`);
54
+ }
55
+ for (let v = lo; v <= hi; v += step)
56
+ out.add(v);
57
+ }
58
+ return out;
59
+ }
60
+ /** Parse a 5-field crontab spec into a `CronSchedule`. Throws on a malformed spec. */
61
+ export function parseCron(spec) {
62
+ const fields = spec.trim().split(/\s+/);
63
+ if (fields.length !== 5) {
64
+ throw new Error(`cron: expected 5 fields, got ${fields.length} in "${spec}"`);
65
+ }
66
+ const [min, hr, dom, mon, dow] = fields;
67
+ // Normalise day-of-week 7 → 0 (both are Sunday in common cron dialects).
68
+ const daysOfWeek = parseField(dow, 0, 7, DOWS);
69
+ if (daysOfWeek.delete(7))
70
+ daysOfWeek.add(0);
71
+ return {
72
+ minutes: parseField(min, 0, 59),
73
+ hours: parseField(hr, 0, 23),
74
+ daysOfMonth: parseField(dom, 1, 31),
75
+ months: parseField(mon, 1, 12, MONTHS),
76
+ daysOfWeek,
77
+ // The OR-vs-AND day rule keys off whether dom/dow are "star" fields. Following Vixie
78
+ // cron, a field counts as a wildcard when it *starts* with `*` (so `*` and `*/n` both
79
+ // qualify); an explicit full range like `1-31` is a restricted field, not a wildcard.
80
+ domWildcard: dom.trim().startsWith("*"),
81
+ dowWildcard: dow.trim().startsWith("*"),
82
+ };
83
+ }
84
+ /** Does `date` (read in UTC) match the schedule? */
85
+ export function cronMatches(s, date) {
86
+ if (!s.minutes.has(date.getUTCMinutes()))
87
+ return false;
88
+ if (!s.hours.has(date.getUTCHours()))
89
+ return false;
90
+ if (!s.months.has(date.getUTCMonth() + 1))
91
+ return false;
92
+ const domHit = s.daysOfMonth.has(date.getUTCDate());
93
+ const dowHit = s.daysOfWeek.has(date.getUTCDay());
94
+ // Standard cron day rule: both restricted → OR; otherwise the restricted one(s) must hit.
95
+ if (!s.domWildcard && !s.dowWildcard)
96
+ return domHit || dowHit;
97
+ if (!s.domWildcard)
98
+ return domHit;
99
+ if (!s.dowWildcard)
100
+ return dowHit;
101
+ return true;
102
+ }
103
+ /** Ceiling to the start of the next minute (UTC), so scheduling lands on minute boundaries. */
104
+ function nextMinute(after) {
105
+ const d = new Date(after.getTime());
106
+ d.setUTCSeconds(0, 0);
107
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
108
+ return d;
109
+ }
110
+ /**
111
+ * The first fire strictly after `after`. Scans minute-by-minute (UTC) up to a bounded
112
+ * horizon; returns `undefined` if nothing matches within ~4 years (an impossible spec,
113
+ * e.g. Feb 30). The bound keeps a pathological spec from spinning forever.
114
+ */
115
+ export function nextCronFire(s, after) {
116
+ let cur = nextMinute(after);
117
+ const limit = 4 * 366 * 24 * 60; // minutes in ~4 years
118
+ for (let i = 0; i < limit; i++) {
119
+ if (cronMatches(s, cur))
120
+ return cur;
121
+ cur = new Date(cur.getTime() + 60_000);
122
+ }
123
+ return undefined;
124
+ }
125
+ /**
126
+ * The fire instants strictly between `after` and `until` (both exclusive on `after`,
127
+ * inclusive on `until`). Used by the `onMissed` catch-up policy to enumerate fires that
128
+ * were due while the app was down. Bounded to avoid unbounded output on a huge gap.
129
+ */
130
+ export function cronFiresBetween(s, after, until, max = 10_000) {
131
+ const fires = [];
132
+ let next = nextCronFire(s, after);
133
+ while (next && next.getTime() <= until.getTime() && fires.length < max) {
134
+ fires.push(next);
135
+ next = nextCronFire(s, next);
136
+ }
137
+ return fires;
138
+ }
@@ -1,15 +1,49 @@
1
- import type { AppApi, RuntimeContext } from "../context.ts";
1
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
2
2
  import { type Route } from "../router.ts";
3
- export interface TriggersHandle {
4
- readonly name: string;
3
+ import type { TriggerDecl } from "../manifest.ts";
4
+ export interface TriggersHandle extends Mounted {
5
5
  routes: Route[];
6
- describe(): Record<string, unknown>;
7
6
  }
8
- /** Evaluate a tiny correlation expression: `= body.a.b`, or a literal string. */
9
- export declare function evalCorrelation(expr: string | undefined, scope: {
7
+ /** Injectable timer + clock seam so cron scheduling is deterministic under test. */
8
+ export interface SchedulerDeps {
9
+ setTimer: (fn: () => void, delayMs: number) => unknown;
10
+ clearTimer: (handle: unknown) => void;
11
+ /** Current wall-clock time in ms since epoch. */
12
+ now: () => number;
13
+ }
14
+ type Scope = {
10
15
  body: unknown;
11
16
  headers: Record<string, string>;
12
17
  query: Record<string, string>;
13
- }): string | undefined;
14
- /** Mount webhook triggers and return their routes. */
15
- export declare function mountTriggers(ctx: RuntimeContext, app: AppApi): TriggersHandle;
18
+ };
19
+ /**
20
+ * Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
21
+ * returns the raw value at that path (or `undefined` if any segment is missing); a literal
22
+ * (non-`=`) string is returned verbatim.
23
+ */
24
+ export declare function resolveExpr(expr: string, scope: Scope): unknown;
25
+ /** Evaluate a correlation expression to a string key: `= body.a.b`, or a literal string. */
26
+ export declare function evalCorrelation(expr: string | undefined, scope: Scope): string | undefined;
27
+ /**
28
+ * Resolve `action.variables` to the record handed to the engine (schema: a FEEL string over
29
+ * the event body — `#/$defs/triggerAction.variables`). A `= body.<path>` expression must
30
+ * resolve to an object; a plain object literal is accepted for convenience; anything else
31
+ * (missing, or a non-object result) falls back to the raw event body when that is an object,
32
+ * else `{}`.
33
+ */
34
+ export declare function resolveActionVariables(expr: unknown, scope: Scope): Record<string, unknown>;
35
+ /**
36
+ * Fire a trigger's action against the engine: start a process or publish a message
37
+ * (ADR 0025 §1). `scope` supplies the event body/headers/query for FEEL-ish resolution
38
+ * of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
39
+ * was done.
40
+ */
41
+ export declare function runTriggerAction(app: AppApi, action: TriggerDecl["action"], scope: Scope): Promise<{
42
+ kind: "start" | "message" | "none";
43
+ target?: string;
44
+ correlationKey?: string;
45
+ }>;
46
+ /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
47
+ * background timers that fire their action on schedule. `stop()` clears every timer. */
48
+ export declare function mountTriggers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): TriggersHandle;
49
+ export {};
@@ -3,24 +3,54 @@
3
3
  // message to the engine for correlation. HMAC verification uses Web Crypto (global in both
4
4
  // Node 18+ and Deno), so this stays runtime-agnostic.
5
5
  import { json, normalizeRoutePath } from "../router.js";
6
- /** Evaluate a tiny correlation expression: `= body.a.b`, or a literal string. */
7
- export function evalCorrelation(expr, scope) {
8
- if (!expr)
9
- return undefined;
6
+ import { nextCronFire, parseCron } from "./cron.js";
7
+ /**
8
+ * Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
9
+ * returns the raw value at that path (or `undefined` if any segment is missing); a literal
10
+ * (non-`=`) string is returned verbatim.
11
+ */
12
+ export function resolveExpr(expr, scope) {
10
13
  const trimmed = expr.trim();
11
14
  if (!trimmed.startsWith("="))
12
15
  return trimmed; // literal
13
16
  const pathExpr = trimmed.slice(1).trim(); // e.g. "body.taskId"
14
17
  let cur = scope;
15
18
  for (const seg of pathExpr.split(".")) {
16
- if (cur && typeof cur === "object" && seg in cur) {
19
+ if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
17
20
  cur = cur[seg];
18
21
  }
19
22
  else {
20
23
  return undefined;
21
24
  }
22
25
  }
23
- return cur == null ? undefined : String(cur);
26
+ return cur;
27
+ }
28
+ /** Evaluate a correlation expression to a string key: `= body.a.b`, or a literal string. */
29
+ export function evalCorrelation(expr, scope) {
30
+ if (!expr)
31
+ return undefined;
32
+ const cur = resolveExpr(expr, scope);
33
+ // A correlation key must be scalar; a non-scalar (object/array) is not a usable key —
34
+ // return undefined rather than forcing a "[object Object]" string.
35
+ if (typeof cur === "string" || typeof cur === "number" || typeof cur === "boolean") {
36
+ return String(cur);
37
+ }
38
+ return undefined;
39
+ }
40
+ /**
41
+ * Resolve `action.variables` to the record handed to the engine (schema: a FEEL string over
42
+ * the event body — `#/$defs/triggerAction.variables`). A `= body.<path>` expression must
43
+ * resolve to an object; a plain object literal is accepted for convenience; anything else
44
+ * (missing, or a non-object result) falls back to the raw event body when that is an object,
45
+ * else `{}`.
46
+ */
47
+ export function resolveActionVariables(expr, scope) {
48
+ const asRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
49
+ if (typeof expr === "string") {
50
+ return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
51
+ }
52
+ // Back-compat: an inline object literal used directly as the variables.
53
+ return asRecord(expr) ?? asRecord(scope.body) ?? {};
24
54
  }
25
55
  async function verifyHmac(secret, body, signature) {
26
56
  const enc = new TextEncoder();
@@ -45,12 +75,136 @@ function headerRecord(req) {
45
75
  req.headers.forEach((v, k) => (out[k.toLowerCase()] = v));
46
76
  return out;
47
77
  }
48
- /** Mount webhook triggers and return their routes. */
49
- export function mountTriggers(ctx, app) {
78
+ /**
79
+ * Fire a trigger's action against the engine: start a process or publish a message
80
+ * (ADR 0025 §1). `scope` supplies the event body/headers/query for FEEL-ish resolution
81
+ * of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
82
+ * was done.
83
+ */
84
+ export async function runTriggerAction(app, action, scope) {
85
+ if (!action)
86
+ return { kind: "none" };
87
+ const variables = resolveActionVariables(action.variables, scope);
88
+ if (action.start) {
89
+ await app.engine.createInstance({ processDefinitionId: action.start, variables });
90
+ return { kind: "start", target: action.start };
91
+ }
92
+ if (action.message) {
93
+ const correlationKey = evalCorrelation(action.correlationKey, scope);
94
+ await app.engine.publishMessage({ name: action.message, correlationKey, variables });
95
+ return { kind: "message", target: action.message, correlationKey };
96
+ }
97
+ return { kind: "none" };
98
+ }
99
+ const emptyScope = { body: {}, headers: {}, query: {} };
100
+ /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
101
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
102
+ /** Default (live) scheduler seam, backed by the global timer functions. */
103
+ function defaultScheduler() {
104
+ return {
105
+ setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
106
+ clearTimer: (h) => globalThis.clearTimeout(h),
107
+ now: () => Date.now(),
108
+ };
109
+ }
110
+ /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
111
+ * background timers that fire their action on schedule. `stop()` clears every timer. */
112
+ export function mountTriggers(ctx, app, sched = defaultScheduler()) {
50
113
  const routes = [];
51
114
  const mounted = [];
115
+ const scheduled = [];
52
116
  const seenDeliveries = new Set();
117
+ const timers = new Set();
118
+ let stopped = false;
119
+ // Arm a cron trigger: compute the next UTC fire, sleep until it, fire the action, repeat.
120
+ const armCron = (trig) => {
121
+ const spec = trig.spec;
122
+ if (!spec) {
123
+ ctx.host.log("warn", `trigger "${trig.id}": cron trigger has no spec, skipped`);
124
+ return;
125
+ }
126
+ let schedule;
127
+ try {
128
+ schedule = parseCron(spec);
129
+ }
130
+ catch (e) {
131
+ app.log("error", `trigger "${trig.id}": invalid cron spec "${spec}": ${String(e)}`);
132
+ return;
133
+ }
134
+ if (!trig.action?.start && !trig.action?.message) {
135
+ // A timer that can never do anything is pure waste — don't arm it (unlike a webhook,
136
+ // which is request-driven and costs nothing while idle).
137
+ app.log("warn", `trigger "${trig.id}": cron trigger has no action.start/message; not scheduled`);
138
+ return;
139
+ }
140
+ if (trig.onMissed && trig.onMissed !== "skip") {
141
+ // once/all catch-up needs a durable last-fire timestamp the runtime does not persist;
142
+ // without it, honour the safe default (skip) and say so rather than silently dropping.
143
+ app.log("warn", `trigger "${trig.id}": onMissed="${trig.onMissed}" not supported without ` +
144
+ `durable trigger state; scheduling forward as "skip"`);
145
+ }
146
+ // Probe the first fire before recording the trigger as scheduled, so an impossible spec
147
+ // (e.g. Feb 30) is reported honestly and never shows up in describe()/startup logs.
148
+ if (!nextCronFire(schedule, new Date(sched.now()))) {
149
+ app.log("warn", `trigger "${trig.id}": cron spec "${spec}" never fires; not scheduled`);
150
+ return;
151
+ }
152
+ scheduled.push(`${trig.id}@${spec}`);
153
+ const arm = () => {
154
+ if (stopped)
155
+ return;
156
+ // Capture the clock once so `next` and `delay` are derived from the same instant.
157
+ const nowMs = sched.now();
158
+ const next = nextCronFire(schedule, new Date(nowMs));
159
+ if (!next)
160
+ return; // spec became unsatisfiable (defensive; the probe above already vetted it)
161
+ const remaining = Math.max(0, next.getTime() - nowMs);
162
+ // setTimeout delays are clamped to a 32-bit signed int (~24.8 days); a larger delay
163
+ // overflows and fires immediately. For far-future fires (monthly/annual specs), sleep in
164
+ // bounded chunks and re-arm — without firing — until the real deadline is within range.
165
+ if (remaining > MAX_TIMER_DELAY_MS) {
166
+ let chunk;
167
+ chunk = sched.setTimer(() => {
168
+ timers.delete(chunk);
169
+ if (stopped)
170
+ return;
171
+ arm();
172
+ }, MAX_TIMER_DELAY_MS);
173
+ timers.add(chunk);
174
+ return;
175
+ }
176
+ // Declare `handle` before arming so the callback can reference it even if a scheduler
177
+ // double invokes it synchronously (avoids a TDZ ReferenceError on `timers.delete`).
178
+ let handle;
179
+ handle = sched.setTimer(() => {
180
+ timers.delete(handle);
181
+ if (stopped)
182
+ return;
183
+ void (async () => {
184
+ try {
185
+ const res = await runTriggerAction(app, trig.action, {
186
+ ...emptyScope,
187
+ body: { firedAt: next.toISOString() },
188
+ });
189
+ app.log("info", `trigger "${trig.id}" fired`, { firedAt: next.toISOString(), ...res });
190
+ }
191
+ catch (err) {
192
+ app.log("error", `trigger "${trig.id}": action failed`, { error: String(err) });
193
+ }
194
+ finally {
195
+ arm(); // reschedule regardless of success (at-least-once, idempotent handlers)
196
+ }
197
+ })();
198
+ }, remaining);
199
+ timers.add(handle);
200
+ };
201
+ arm();
202
+ };
53
203
  for (const trig of (ctx.manifest.triggers ?? [])) {
204
+ if (trig.type === "cron") {
205
+ armCron(trig);
206
+ continue;
207
+ }
54
208
  if (trig.type !== "webhook") {
55
209
  ctx.host.log("warn", `trigger "${trig.id}": type "${trig.type}" not implemented, skipped`);
56
210
  continue;
@@ -94,26 +248,31 @@ export function mountTriggers(ctx, app) {
94
248
  catch {
95
249
  return json({ error: "body must be JSON" }, 400);
96
250
  }
97
- const message = trig.action?.message;
98
- if (!message) {
99
- app.log("warn", `trigger "${trig.id}": no action.message; nothing published`);
100
- return json({ ok: true, published: false });
251
+ if (!trig.action?.message && !trig.action?.start) {
252
+ app.log("warn", `trigger "${trig.id}": no action.message/start; nothing to do`);
253
+ return json({ ok: true, fired: false });
101
254
  }
102
- const correlationKey = evalCorrelation(trig.action?.correlationKey, {
255
+ const res = await runTriggerAction(app, trig.action, {
103
256
  body,
104
257
  headers,
105
258
  query: Object.fromEntries(req.query),
106
259
  });
107
- await app.engine.publishMessage({
108
- name: message,
109
- correlationKey,
110
- variables: (body && typeof body === "object" ? body : {}),
111
- });
112
- app.log("info", `trigger "${trig.id}" published`, { message, correlationKey });
113
- return json({ ok: true, published: true, message, correlationKey });
260
+ // `res.kind` is the honest verb (start | message); don't hard-code "published".
261
+ app.log("info", `trigger "${trig.id}" fired`, { ...res });
262
+ return json({ ok: true, fired: true, ...res });
114
263
  },
115
264
  });
116
265
  }
117
- ctx.host.log("info", "triggers mounted", { mounted });
118
- return { name: "triggers", routes, describe: () => ({ mounted }) };
266
+ ctx.host.log("info", "triggers mounted", { mounted, scheduled });
267
+ return {
268
+ name: "triggers",
269
+ routes,
270
+ describe: () => ({ mounted, scheduled }),
271
+ async stop() {
272
+ stopped = true;
273
+ for (const h of timers)
274
+ sched.clearTimer(h);
275
+ timers.clear();
276
+ },
277
+ };
119
278
  }
@@ -132,7 +132,8 @@ export async function createUrbanApp(opts) {
132
132
  if (flags.triggers) {
133
133
  const t = mountTriggers(ctx, api);
134
134
  routes.push(...t.routes);
135
- describe.triggers = t.describe();
135
+ mounted.push(t);
136
+ describe.triggers = t.describe?.();
136
137
  }
137
138
  if (security)
138
139
  describe.security = security.describe();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  "dependencies": {
61
61
  "@nanobpm/nano-sdk": "^1.2.0",
62
- "@nanobpm/workflow": "^0.6.0",
62
+ "@nanobpm/workflow": "^0.7.0",
63
63
  "bpmn-auto-layout": "^2.0.0-alpha.2",
64
64
  "create-urban-app": "^0.2.0"
65
65
  },
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export {
41
41
  } from "@nanobpm/workflow";
42
42
  export type {
43
43
  FlowBuilder,
44
+ TimerStart,
44
45
  Envelope,
45
46
  EnvelopeType,
46
47
  EnvelopeField,
@@ -55,7 +55,29 @@ export interface TriggerDecl {
55
55
  type: string;
56
56
  path?: string;
57
57
  auth?: string;
58
- action?: { message?: string; correlationKey?: string };
58
+ /** cron: the 5-field crontab spec (UTC), e.g. "0 6 * * *". */
59
+ spec?: string;
60
+ /** cron catch-up policy for fires missed while the app was down. Default "skip". */
61
+ onMissed?: "skip" | "once" | "all";
62
+ /** Source-kind-specific settings (e.g. file `{ pollMs }`). */
63
+ config?: Record<string, unknown>;
64
+ /** Name of a connections[] entry supplying this source's credentials. */
65
+ connection?: string;
66
+ /** Maps the event to one engine call: start a process OR publish a message (ADR 0025 §1). */
67
+ action?: {
68
+ /** Process id/name to start. */
69
+ start?: string;
70
+ /** messageName to publish as a CorrelateMessage. */
71
+ message?: string;
72
+ /** correlationKey (literal, or `= body.path`) for a message action. */
73
+ correlationKey?: string;
74
+ /**
75
+ * Variables for the started instance / published message. Per the manifest schema
76
+ * (`#/$defs/triggerAction.variables`) this is a FEEL string over the event body
77
+ * (e.g. `= body.payload`); an inline object literal is also accepted for convenience.
78
+ */
79
+ variables?: string | Record<string, unknown>;
80
+ };
59
81
  }
60
82
 
61
83
  export interface SurfaceDecl {