@nanobpm/urban 0.10.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.
@@ -3,35 +3,77 @@
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
 
6
- import type { AppApi, RuntimeContext } from "../context.ts";
6
+ import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
7
7
  import type { HttpRequest } from "../host.ts";
8
8
  import { json, normalizeRoutePath, type Route } from "../router.ts";
9
9
  import type { TriggerDecl } from "../manifest.ts";
10
+ import { nextCronFire, parseCron } from "./cron.ts";
10
11
 
11
- export interface TriggersHandle {
12
- readonly name: string;
12
+ export interface TriggersHandle extends Mounted {
13
13
  routes: Route[];
14
- describe(): Record<string, unknown>;
15
14
  }
16
15
 
17
- /** Evaluate a tiny correlation expression: `= body.a.b`, or a literal string. */
18
- export function evalCorrelation(
19
- expr: string | undefined,
20
- scope: { body: unknown; headers: Record<string, string>; query: Record<string, string> },
21
- ): string | undefined {
22
- if (!expr) return undefined;
16
+ /** Injectable timer + clock seam so cron scheduling is deterministic under test. */
17
+ export interface SchedulerDeps {
18
+ setTimer: (fn: () => void, delayMs: number) => unknown;
19
+ clearTimer: (handle: unknown) => void;
20
+ /** Current wall-clock time in ms since epoch. */
21
+ now: () => number;
22
+ }
23
+
24
+ type Scope = { body: unknown; headers: Record<string, string>; query: Record<string, string> };
25
+
26
+ /**
27
+ * Resolve a tiny FEEL-ish expression against the event scope: a `= body.a.b` path walk
28
+ * returns the raw value at that path (or `undefined` if any segment is missing); a literal
29
+ * (non-`=`) string is returned verbatim.
30
+ */
31
+ export function resolveExpr(expr: string, scope: Scope): unknown {
23
32
  const trimmed = expr.trim();
24
33
  if (!trimmed.startsWith("=")) return trimmed; // literal
25
34
  const pathExpr = trimmed.slice(1).trim(); // e.g. "body.taskId"
26
35
  let cur: unknown = scope;
27
36
  for (const seg of pathExpr.split(".")) {
28
- if (cur && typeof cur === "object" && seg in (cur as Record<string, unknown>)) {
37
+ if (cur && typeof cur === "object" && Object.prototype.hasOwnProperty.call(cur, seg)) {
29
38
  cur = (cur as Record<string, unknown>)[seg];
30
39
  } else {
31
40
  return undefined;
32
41
  }
33
42
  }
34
- return cur == null ? undefined : String(cur);
43
+ return cur;
44
+ }
45
+
46
+ /** Evaluate a correlation expression to a string key: `= body.a.b`, or a literal string. */
47
+ export function evalCorrelation(expr: string | undefined, scope: Scope): string | undefined {
48
+ if (!expr) return undefined;
49
+ const cur = resolveExpr(expr, scope);
50
+ // A correlation key must be scalar; a non-scalar (object/array) is not a usable key —
51
+ // return undefined rather than forcing a "[object Object]" string.
52
+ if (typeof cur === "string" || typeof cur === "number" || typeof cur === "boolean") {
53
+ return String(cur);
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ /**
59
+ * Resolve `action.variables` to the record handed to the engine (schema: a FEEL string over
60
+ * the event body — `#/$defs/triggerAction.variables`). A `= body.<path>` expression must
61
+ * resolve to an object; a plain object literal is accepted for convenience; anything else
62
+ * (missing, or a non-object result) falls back to the raw event body when that is an object,
63
+ * else `{}`.
64
+ */
65
+ export function resolveActionVariables(
66
+ expr: unknown,
67
+ scope: Scope,
68
+ ): Record<string, unknown> {
69
+ const asRecord = (v: unknown): Record<string, unknown> | undefined =>
70
+ v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
71
+
72
+ if (typeof expr === "string") {
73
+ return asRecord(resolveExpr(expr, scope)) ?? asRecord(scope.body) ?? {};
74
+ }
75
+ // Back-compat: an inline object literal used directly as the variables.
76
+ return asRecord(expr) ?? asRecord(scope.body) ?? {};
35
77
  }
36
78
 
37
79
  async function verifyHmac(secret: string, body: string, signature: string): Promise<boolean> {
@@ -64,13 +106,146 @@ function headerRecord(req: HttpRequest): Record<string, string> {
64
106
  return out;
65
107
  }
66
108
 
67
- /** Mount webhook triggers and return their routes. */
68
- export function mountTriggers(ctx: RuntimeContext, app: AppApi): TriggersHandle {
109
+ /**
110
+ * Fire a trigger's action against the engine: start a process or publish a message
111
+ * (ADR 0025 §1). `scope` supplies the event body/headers/query for FEEL-ish resolution
112
+ * of `correlationKey`/`variables`; cron passes a minimal `{ firedAt }` body. Returns what
113
+ * was done.
114
+ */
115
+ export async function runTriggerAction(
116
+ app: AppApi,
117
+ action: TriggerDecl["action"],
118
+ scope: Scope,
119
+ ): Promise<{ kind: "start" | "message" | "none"; target?: string; correlationKey?: string }> {
120
+ if (!action) return { kind: "none" };
121
+ const variables = resolveActionVariables(action.variables, scope);
122
+
123
+ if (action.start) {
124
+ await app.engine.createInstance({ processDefinitionId: action.start, variables });
125
+ return { kind: "start", target: action.start };
126
+ }
127
+ if (action.message) {
128
+ const correlationKey = evalCorrelation(action.correlationKey, scope);
129
+ await app.engine.publishMessage({ name: action.message, correlationKey, variables });
130
+ return { kind: "message", target: action.message, correlationKey };
131
+ }
132
+ return { kind: "none" };
133
+ }
134
+
135
+ const emptyScope = { body: {}, headers: {} as Record<string, string>, query: {} as Record<string, string> };
136
+
137
+ /** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
138
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
139
+
140
+ /** Default (live) scheduler seam, backed by the global timer functions. */
141
+ function defaultScheduler(): SchedulerDeps {
142
+ return {
143
+ setTimer: (fn, ms) => globalThis.setTimeout(fn, ms),
144
+ clearTimer: (h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>),
145
+ now: () => Date.now(),
146
+ };
147
+ }
148
+
149
+ /** Mount webhook + cron triggers. Webhooks contribute HTTP routes; cron triggers arm
150
+ * background timers that fire their action on schedule. `stop()` clears every timer. */
151
+ export function mountTriggers(
152
+ ctx: RuntimeContext,
153
+ app: AppApi,
154
+ sched: SchedulerDeps = defaultScheduler(),
155
+ ): TriggersHandle {
69
156
  const routes: Route[] = [];
70
157
  const mounted: string[] = [];
158
+ const scheduled: string[] = [];
71
159
  const seenDeliveries = new Set<string>();
160
+ const timers = new Set<unknown>();
161
+ let stopped = false;
162
+
163
+ // Arm a cron trigger: compute the next UTC fire, sleep until it, fire the action, repeat.
164
+ const armCron = (trig: TriggerDecl): void => {
165
+ const spec = trig.spec;
166
+ if (!spec) {
167
+ ctx.host.log("warn", `trigger "${trig.id}": cron trigger has no spec, skipped`);
168
+ return;
169
+ }
170
+ let schedule;
171
+ try {
172
+ schedule = parseCron(spec);
173
+ } catch (e) {
174
+ app.log("error", `trigger "${trig.id}": invalid cron spec "${spec}": ${String(e)}`);
175
+ return;
176
+ }
177
+ if (!trig.action?.start && !trig.action?.message) {
178
+ // A timer that can never do anything is pure waste — don't arm it (unlike a webhook,
179
+ // which is request-driven and costs nothing while idle).
180
+ app.log("warn", `trigger "${trig.id}": cron trigger has no action.start/message; not scheduled`);
181
+ return;
182
+ }
183
+ if (trig.onMissed && trig.onMissed !== "skip") {
184
+ // once/all catch-up needs a durable last-fire timestamp the runtime does not persist;
185
+ // without it, honour the safe default (skip) and say so rather than silently dropping.
186
+ app.log("warn", `trigger "${trig.id}": onMissed="${trig.onMissed}" not supported without ` +
187
+ `durable trigger state; scheduling forward as "skip"`);
188
+ }
189
+ // Probe the first fire before recording the trigger as scheduled, so an impossible spec
190
+ // (e.g. Feb 30) is reported honestly and never shows up in describe()/startup logs.
191
+ if (!nextCronFire(schedule, new Date(sched.now()))) {
192
+ app.log("warn", `trigger "${trig.id}": cron spec "${spec}" never fires; not scheduled`);
193
+ return;
194
+ }
195
+ scheduled.push(`${trig.id}@${spec}`);
196
+
197
+ const arm = (): void => {
198
+ if (stopped) return;
199
+ // Capture the clock once so `next` and `delay` are derived from the same instant.
200
+ const nowMs = sched.now();
201
+ const next = nextCronFire(schedule, new Date(nowMs));
202
+ if (!next) return; // spec became unsatisfiable (defensive; the probe above already vetted it)
203
+ const remaining = Math.max(0, next.getTime() - nowMs);
204
+
205
+ // setTimeout delays are clamped to a 32-bit signed int (~24.8 days); a larger delay
206
+ // overflows and fires immediately. For far-future fires (monthly/annual specs), sleep in
207
+ // bounded chunks and re-arm — without firing — until the real deadline is within range.
208
+ if (remaining > MAX_TIMER_DELAY_MS) {
209
+ let chunk: unknown;
210
+ chunk = sched.setTimer(() => {
211
+ timers.delete(chunk);
212
+ if (stopped) return;
213
+ arm();
214
+ }, MAX_TIMER_DELAY_MS);
215
+ timers.add(chunk);
216
+ return;
217
+ }
218
+
219
+ // Declare `handle` before arming so the callback can reference it even if a scheduler
220
+ // double invokes it synchronously (avoids a TDZ ReferenceError on `timers.delete`).
221
+ let handle: unknown;
222
+ handle = sched.setTimer(() => {
223
+ timers.delete(handle);
224
+ if (stopped) return;
225
+ void (async () => {
226
+ try {
227
+ const res = await runTriggerAction(app, trig.action, {
228
+ ...emptyScope,
229
+ body: { firedAt: next.toISOString() },
230
+ });
231
+ app.log("info", `trigger "${trig.id}" fired`, { firedAt: next.toISOString(), ...res });
232
+ } catch (err) {
233
+ app.log("error", `trigger "${trig.id}": action failed`, { error: String(err) });
234
+ } finally {
235
+ arm(); // reschedule regardless of success (at-least-once, idempotent handlers)
236
+ }
237
+ })();
238
+ }, remaining);
239
+ timers.add(handle);
240
+ };
241
+ arm();
242
+ };
72
243
 
73
244
  for (const trig of (ctx.manifest.triggers ?? []) as TriggerDecl[]) {
245
+ if (trig.type === "cron") {
246
+ armCron(trig);
247
+ continue;
248
+ }
74
249
  if (trig.type !== "webhook") {
75
250
  ctx.host.log("warn", `trigger "${trig.id}": type "${trig.type}" not implemented, skipped`);
76
251
  continue;
@@ -117,27 +292,31 @@ export function mountTriggers(ctx: RuntimeContext, app: AppApi): TriggersHandle
117
292
  return json({ error: "body must be JSON" }, 400);
118
293
  }
119
294
 
120
- const message = trig.action?.message;
121
- if (!message) {
122
- app.log("warn", `trigger "${trig.id}": no action.message; nothing published`);
123
- return json({ ok: true, published: false });
295
+ if (!trig.action?.message && !trig.action?.start) {
296
+ app.log("warn", `trigger "${trig.id}": no action.message/start; nothing to do`);
297
+ return json({ ok: true, fired: false });
124
298
  }
125
- const correlationKey = evalCorrelation(trig.action?.correlationKey, {
299
+ const res = await runTriggerAction(app, trig.action, {
126
300
  body,
127
301
  headers,
128
302
  query: Object.fromEntries(req.query),
129
303
  });
130
- await app.engine.publishMessage({
131
- name: message,
132
- correlationKey,
133
- variables: (body && typeof body === "object" ? (body as Record<string, unknown>) : {}),
134
- });
135
- app.log("info", `trigger "${trig.id}" published`, { message, correlationKey });
136
- return json({ ok: true, published: true, message, correlationKey });
304
+ // `res.kind` is the honest verb (start | message); don't hard-code "published".
305
+ app.log("info", `trigger "${trig.id}" fired`, { ...res });
306
+ return json({ ok: true, fired: true, ...res });
137
307
  },
138
308
  });
139
309
  }
140
310
 
141
- ctx.host.log("info", "triggers mounted", { mounted });
142
- return { name: "triggers", routes, describe: () => ({ mounted }) };
311
+ ctx.host.log("info", "triggers mounted", { mounted, scheduled });
312
+ return {
313
+ name: "triggers",
314
+ routes,
315
+ describe: () => ({ mounted, scheduled }),
316
+ async stop() {
317
+ stopped = true;
318
+ for (const h of timers) sched.clearTimer(h);
319
+ timers.clear();
320
+ },
321
+ };
143
322
  }
@@ -180,7 +180,8 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
180
180
  if (flags.triggers) {
181
181
  const t = mountTriggers(ctx, api);
182
182
  routes.push(...t.routes);
183
- describe.triggers = t.describe();
183
+ mounted.push(t);
184
+ describe.triggers = t.describe?.();
184
185
  }
185
186
  if (security) describe.security = security.describe();
186
187
  if (data) describe.data = data.describe();