@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 +32 -1
- package/dist/index.d.ts +1 -1
- package/dist/runtime/core/manifest.d.ts +19 -0
- package/dist/runtime/core/modules/cron.d.ts +27 -0
- package/dist/runtime/core/modules/cron.js +138 -0
- package/dist/runtime/core/modules/triggers.d.ts +43 -9
- package/dist/runtime/core/modules/triggers.js +181 -22
- package/dist/runtime/core/runtime.js +2 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/runtime/core/manifest.ts +23 -1
- package/src/runtime/core/modules/cron.test.ts +105 -0
- package/src/runtime/core/modules/cron.ts +150 -0
- package/src/runtime/core/modules/triggers.test.ts +269 -0
- package/src/runtime/core/modules/triggers.ts +207 -28
- package/src/runtime/core/runtime.ts +2 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { cronFiresBetween, cronMatches, nextCronFire, parseCron } from "./cron.ts";
|
|
4
|
+
|
|
5
|
+
const utc = (s: string) => new Date(s);
|
|
6
|
+
|
|
7
|
+
test("parseCron rejects malformed specs", () => {
|
|
8
|
+
assert.throws(() => parseCron("* * * *"), /expected 5 fields/);
|
|
9
|
+
assert.throws(() => parseCron("* * * * * *"), /expected 5 fields/);
|
|
10
|
+
assert.throws(() => parseCron("60 * * * *"), /out of range/);
|
|
11
|
+
assert.throws(() => parseCron("* 24 * * *"), /out of range/);
|
|
12
|
+
assert.throws(() => parseCron("* * 0 * *"), /out of range/);
|
|
13
|
+
assert.throws(() => parseCron("*/0 * * * *"), /bad step/);
|
|
14
|
+
assert.throws(() => parseCron("/5 * * * *"), /missing range before/);
|
|
15
|
+
assert.throws(() => parseCron("*/ * * * *"), /bad step/);
|
|
16
|
+
assert.throws(() => parseCron("5-3 * * * *"), /out of range/);
|
|
17
|
+
assert.throws(() => parseCron("1,,2 * * * *"), /empty term/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("parseCron handles wildcards, lists, ranges, steps and names", () => {
|
|
21
|
+
const daily = parseCron("0 6 * * *");
|
|
22
|
+
assert.equal(daily.minutes.has(0), true);
|
|
23
|
+
assert.equal(daily.hours.has(6), true);
|
|
24
|
+
assert.equal(daily.domWildcard, true);
|
|
25
|
+
assert.equal(daily.dowWildcard, true);
|
|
26
|
+
|
|
27
|
+
const s = parseCron("*/15 9-17 1,15 jan-mar mon-fri");
|
|
28
|
+
assert.deepEqual([...s.minutes].sort((a, b) => a - b), [0, 15, 30, 45]);
|
|
29
|
+
assert.deepEqual([...s.hours].sort((a, b) => a - b), [9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
|
30
|
+
assert.deepEqual([...s.daysOfMonth].sort((a, b) => a - b), [1, 15]);
|
|
31
|
+
assert.deepEqual([...s.months].sort((a, b) => a - b), [1, 2, 3]);
|
|
32
|
+
assert.deepEqual([...s.daysOfWeek].sort((a, b) => a - b), [1, 2, 3, 4, 5]);
|
|
33
|
+
assert.equal(s.domWildcard, false);
|
|
34
|
+
assert.equal(s.dowWildcard, false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("day-of-week 7 normalises to 0 (Sunday)", () => {
|
|
38
|
+
const s = parseCron("0 0 * * 7");
|
|
39
|
+
assert.equal(s.daysOfWeek.has(0), true);
|
|
40
|
+
assert.equal(s.daysOfWeek.has(7), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("cronMatches evaluates in UTC on minute boundaries", () => {
|
|
44
|
+
const s = parseCron("30 14 * * *");
|
|
45
|
+
assert.equal(cronMatches(s, utc("2026-08-01T14:30:00Z")), true);
|
|
46
|
+
assert.equal(cronMatches(s, utc("2026-08-01T14:31:00Z")), false);
|
|
47
|
+
assert.equal(cronMatches(s, utc("2026-08-01T13:30:00Z")), false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("standard cron day rule: both dom+dow restricted → OR", () => {
|
|
51
|
+
// 15th of the month OR any Monday.
|
|
52
|
+
const s = parseCron("0 0 15 * 1");
|
|
53
|
+
assert.equal(cronMatches(s, utc("2026-08-15T00:00:00Z")), true); // the 15th (a Saturday)
|
|
54
|
+
assert.equal(cronMatches(s, utc("2026-08-17T00:00:00Z")), true); // a Monday
|
|
55
|
+
assert.equal(cronMatches(s, utc("2026-08-18T00:00:00Z")), false); // Tuesday, not the 15th
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("only dom restricted → dow ignored (AND with wildcard)", () => {
|
|
59
|
+
const s = parseCron("0 0 1 * *");
|
|
60
|
+
assert.equal(cronMatches(s, utc("2026-08-01T00:00:00Z")), true);
|
|
61
|
+
assert.equal(cronMatches(s, utc("2026-08-02T00:00:00Z")), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a `*/n` day field counts as a wildcard (Vixie rule), so dow alone applies", () => {
|
|
65
|
+
// dom is `*/1` (a star field) + dow=Monday → only Mondays match, not every day.
|
|
66
|
+
const s = parseCron("0 0 */1 * 1");
|
|
67
|
+
assert.equal(s.domWildcard, true);
|
|
68
|
+
assert.equal(s.dowWildcard, false);
|
|
69
|
+
assert.equal(cronMatches(s, utc("2026-08-17T00:00:00Z")), true); // Monday
|
|
70
|
+
assert.equal(cronMatches(s, utc("2026-08-18T00:00:00Z")), false); // Tuesday
|
|
71
|
+
// An explicit full range is NOT a wildcard: `1-31` stays restricted → OR rule.
|
|
72
|
+
const r = parseCron("0 0 1-31 * 1");
|
|
73
|
+
assert.equal(r.domWildcard, false);
|
|
74
|
+
assert.equal(cronMatches(r, utc("2026-08-18T00:00:00Z")), true); // any dom 1-31
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("nextCronFire returns the first fire strictly after 'after'", () => {
|
|
78
|
+
const s = parseCron("0 6 * * *");
|
|
79
|
+
const next = nextCronFire(s, utc("2026-08-01T06:00:00Z"));
|
|
80
|
+
assert.equal(next?.toISOString(), "2026-08-02T06:00:00.000Z"); // strictly after, so next day
|
|
81
|
+
const next2 = nextCronFire(s, utc("2026-08-01T05:59:00Z"));
|
|
82
|
+
assert.equal(next2?.toISOString(), "2026-08-01T06:00:00.000Z");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("nextCronFire returns undefined for an impossible spec", () => {
|
|
86
|
+
const s = parseCron("0 0 30 2 *"); // Feb 30 never exists
|
|
87
|
+
assert.equal(nextCronFire(s, utc("2026-01-01T00:00:00Z")), undefined);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("cronFiresBetween enumerates due fires in a window", () => {
|
|
91
|
+
const s = parseCron("0 * * * *"); // top of every hour
|
|
92
|
+
const fires = cronFiresBetween(
|
|
93
|
+
s,
|
|
94
|
+
utc("2026-08-01T00:15:00Z"),
|
|
95
|
+
utc("2026-08-01T03:00:00Z"),
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
fires.map((d) => d.toISOString()),
|
|
99
|
+
[
|
|
100
|
+
"2026-08-01T01:00:00.000Z",
|
|
101
|
+
"2026-08-01T02:00:00.000Z",
|
|
102
|
+
"2026-08-01T03:00:00.000Z",
|
|
103
|
+
],
|
|
104
|
+
);
|
|
105
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
|
|
11
|
+
/** A parsed schedule: the allowed value set per field, plus whether dom/dow are wildcards. */
|
|
12
|
+
export interface CronSchedule {
|
|
13
|
+
minutes: Set<number>;
|
|
14
|
+
hours: Set<number>;
|
|
15
|
+
daysOfMonth: Set<number>;
|
|
16
|
+
months: Set<number>;
|
|
17
|
+
daysOfWeek: Set<number>;
|
|
18
|
+
/** dom / dow were `*` (unrestricted) — controls the OR-vs-AND day rule. */
|
|
19
|
+
domWildcard: boolean;
|
|
20
|
+
dowWildcard: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
24
|
+
const DOWS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
25
|
+
|
|
26
|
+
function nameToNumber(token: string, names: string[], offset: number): string {
|
|
27
|
+
const i = names.indexOf(token.toLowerCase());
|
|
28
|
+
return i >= 0 ? String(i + offset) : token;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Parse one field into the set of matching integers in [min,max]. */
|
|
32
|
+
function parseField(field: string, min: number, max: number, names?: string[]): Set<number> {
|
|
33
|
+
const out = new Set<number>();
|
|
34
|
+
for (const partRaw of field.split(",")) {
|
|
35
|
+
const part = partRaw.trim();
|
|
36
|
+
if (part === "") throw new Error(`cron: empty term in "${field}"`);
|
|
37
|
+
let step = 1;
|
|
38
|
+
let range = part;
|
|
39
|
+
const slash = part.indexOf("/");
|
|
40
|
+
if (slash >= 0) {
|
|
41
|
+
range = part.slice(0, slash);
|
|
42
|
+
if (range === "") throw new Error(`cron: missing range before "/" in "${part}"`);
|
|
43
|
+
const stepText = part.slice(slash + 1);
|
|
44
|
+
step = Number(stepText);
|
|
45
|
+
if (stepText === "" || !Number.isInteger(step) || step <= 0) {
|
|
46
|
+
throw new Error(`cron: bad step in "${part}"`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
let lo: number;
|
|
50
|
+
let hi: number;
|
|
51
|
+
if (range === "*") {
|
|
52
|
+
lo = min;
|
|
53
|
+
hi = max;
|
|
54
|
+
} else {
|
|
55
|
+
const dash = range.indexOf("-", range.startsWith("-") ? 1 : 0);
|
|
56
|
+
if (dash > 0) {
|
|
57
|
+
lo = Number(names ? nameToNumber(range.slice(0, dash), names, min) : range.slice(0, dash));
|
|
58
|
+
hi = Number(names ? nameToNumber(range.slice(dash + 1), names, min) : range.slice(dash + 1));
|
|
59
|
+
} else {
|
|
60
|
+
lo = hi = Number(names ? nameToNumber(range, names, min) : range);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi) || lo < min || hi > max || lo > hi) {
|
|
64
|
+
throw new Error(`cron: value out of range [${min},${max}] in "${part}"`);
|
|
65
|
+
}
|
|
66
|
+
for (let v = lo; v <= hi; v += step) out.add(v);
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Parse a 5-field crontab spec into a `CronSchedule`. Throws on a malformed spec. */
|
|
72
|
+
export function parseCron(spec: string): CronSchedule {
|
|
73
|
+
const fields = spec.trim().split(/\s+/);
|
|
74
|
+
if (fields.length !== 5) {
|
|
75
|
+
throw new Error(`cron: expected 5 fields, got ${fields.length} in "${spec}"`);
|
|
76
|
+
}
|
|
77
|
+
const [min, hr, dom, mon, dow] = fields;
|
|
78
|
+
// Normalise day-of-week 7 → 0 (both are Sunday in common cron dialects).
|
|
79
|
+
const daysOfWeek = parseField(dow, 0, 7, DOWS);
|
|
80
|
+
if (daysOfWeek.delete(7)) daysOfWeek.add(0);
|
|
81
|
+
return {
|
|
82
|
+
minutes: parseField(min, 0, 59),
|
|
83
|
+
hours: parseField(hr, 0, 23),
|
|
84
|
+
daysOfMonth: parseField(dom, 1, 31),
|
|
85
|
+
months: parseField(mon, 1, 12, MONTHS),
|
|
86
|
+
daysOfWeek,
|
|
87
|
+
// The OR-vs-AND day rule keys off whether dom/dow are "star" fields. Following Vixie
|
|
88
|
+
// cron, a field counts as a wildcard when it *starts* with `*` (so `*` and `*/n` both
|
|
89
|
+
// qualify); an explicit full range like `1-31` is a restricted field, not a wildcard.
|
|
90
|
+
domWildcard: dom.trim().startsWith("*"),
|
|
91
|
+
dowWildcard: dow.trim().startsWith("*"),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Does `date` (read in UTC) match the schedule? */
|
|
96
|
+
export function cronMatches(s: CronSchedule, date: Date): boolean {
|
|
97
|
+
if (!s.minutes.has(date.getUTCMinutes())) return false;
|
|
98
|
+
if (!s.hours.has(date.getUTCHours())) return false;
|
|
99
|
+
if (!s.months.has(date.getUTCMonth() + 1)) return false;
|
|
100
|
+
const domHit = s.daysOfMonth.has(date.getUTCDate());
|
|
101
|
+
const dowHit = s.daysOfWeek.has(date.getUTCDay());
|
|
102
|
+
// Standard cron day rule: both restricted → OR; otherwise the restricted one(s) must hit.
|
|
103
|
+
if (!s.domWildcard && !s.dowWildcard) return domHit || dowHit;
|
|
104
|
+
if (!s.domWildcard) return domHit;
|
|
105
|
+
if (!s.dowWildcard) return dowHit;
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Ceiling to the start of the next minute (UTC), so scheduling lands on minute boundaries. */
|
|
110
|
+
function nextMinute(after: Date): Date {
|
|
111
|
+
const d = new Date(after.getTime());
|
|
112
|
+
d.setUTCSeconds(0, 0);
|
|
113
|
+
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
|
114
|
+
return d;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The first fire strictly after `after`. Scans minute-by-minute (UTC) up to a bounded
|
|
119
|
+
* horizon; returns `undefined` if nothing matches within ~4 years (an impossible spec,
|
|
120
|
+
* e.g. Feb 30). The bound keeps a pathological spec from spinning forever.
|
|
121
|
+
*/
|
|
122
|
+
export function nextCronFire(s: CronSchedule, after: Date): Date | undefined {
|
|
123
|
+
let cur = nextMinute(after);
|
|
124
|
+
const limit = 4 * 366 * 24 * 60; // minutes in ~4 years
|
|
125
|
+
for (let i = 0; i < limit; i++) {
|
|
126
|
+
if (cronMatches(s, cur)) return cur;
|
|
127
|
+
cur = new Date(cur.getTime() + 60_000);
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The fire instants strictly between `after` and `until` (both exclusive on `after`,
|
|
134
|
+
* inclusive on `until`). Used by the `onMissed` catch-up policy to enumerate fires that
|
|
135
|
+
* were due while the app was down. Bounded to avoid unbounded output on a huge gap.
|
|
136
|
+
*/
|
|
137
|
+
export function cronFiresBetween(
|
|
138
|
+
s: CronSchedule,
|
|
139
|
+
after: Date,
|
|
140
|
+
until: Date,
|
|
141
|
+
max = 10_000,
|
|
142
|
+
): Date[] {
|
|
143
|
+
const fires: Date[] = [];
|
|
144
|
+
let next = nextCronFire(s, after);
|
|
145
|
+
while (next && next.getTime() <= until.getTime() && fires.length < max) {
|
|
146
|
+
fires.push(next);
|
|
147
|
+
next = nextCronFire(s, next);
|
|
148
|
+
}
|
|
149
|
+
return fires;
|
|
150
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import type { AppApi, RuntimeContext } from "../context.ts";
|
|
4
|
+
import type { HttpRequest } from "../host.ts";
|
|
5
|
+
import type { TriggerDecl } from "../manifest.ts";
|
|
6
|
+
import { makeRouter } from "../router.ts";
|
|
7
|
+
import { evalCorrelation, mountTriggers, runTriggerAction, type SchedulerDeps } from "./triggers.ts";
|
|
8
|
+
|
|
9
|
+
interface EngineCall {
|
|
10
|
+
kind: "start" | "message";
|
|
11
|
+
target: string;
|
|
12
|
+
variables?: Record<string, unknown>;
|
|
13
|
+
correlationKey?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fakeApp(): { app: AppApi; calls: EngineCall[]; logs: Array<{ level: string; msg: string }> } {
|
|
17
|
+
const calls: EngineCall[] = [];
|
|
18
|
+
const logs: Array<{ level: string; msg: string }> = [];
|
|
19
|
+
const app = {
|
|
20
|
+
env: () => undefined,
|
|
21
|
+
log: (level: string, msg: string) => logs.push({ level, msg }),
|
|
22
|
+
engine: {
|
|
23
|
+
createInstance: (input: { processDefinitionId: string; variables?: Record<string, unknown> }) => {
|
|
24
|
+
calls.push({ kind: "start", target: input.processDefinitionId, variables: input.variables });
|
|
25
|
+
return Promise.resolve({ processInstanceKey: "pi-1" });
|
|
26
|
+
},
|
|
27
|
+
publishMessage: (input: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
|
|
28
|
+
calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
} as unknown as AppApi;
|
|
33
|
+
return { app, calls, logs };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fakeCtx(triggers: TriggerDecl[]): { ctx: RuntimeContext; hostLogs: Array<{ level: string; msg: string }> } {
|
|
37
|
+
const hostLogs: Array<{ level: string; msg: string }> = [];
|
|
38
|
+
const ctx = {
|
|
39
|
+
root: "/app",
|
|
40
|
+
manifest: { schemaVersion: 1, id: "t", name: "T", triggers } as RuntimeContext["manifest"],
|
|
41
|
+
host: { log: (level: string, msg: string) => hostLogs.push({ level, msg }) },
|
|
42
|
+
} as unknown as RuntimeContext;
|
|
43
|
+
return { ctx, hostLogs };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A deterministic scheduler: timers fire only when the clock is advanced past their deadline. */
|
|
47
|
+
function fakeScheduler(startMs: number): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
|
|
48
|
+
let clock = startMs;
|
|
49
|
+
let seq = 0;
|
|
50
|
+
const timers = new Map<number, { at: number; fn: () => void }>();
|
|
51
|
+
return {
|
|
52
|
+
now: () => clock,
|
|
53
|
+
setTimer: (fn, delayMs) => {
|
|
54
|
+
const id = ++seq;
|
|
55
|
+
timers.set(id, { at: clock + delayMs, fn });
|
|
56
|
+
return id;
|
|
57
|
+
},
|
|
58
|
+
clearTimer: (h) => {
|
|
59
|
+
timers.delete(h as number);
|
|
60
|
+
},
|
|
61
|
+
pending: () => timers.size,
|
|
62
|
+
// Advance the clock, firing every timer whose deadline has passed (in order), letting
|
|
63
|
+
// microtasks (the async action) drain between fires.
|
|
64
|
+
advance: async (ms) => {
|
|
65
|
+
const target = clock + ms;
|
|
66
|
+
// Fire due timers one at a time; a timer's callback may schedule a new one.
|
|
67
|
+
// deno-lint-ignore no-constant-condition
|
|
68
|
+
while (true) {
|
|
69
|
+
let nextId = -1;
|
|
70
|
+
let nextAt = Infinity;
|
|
71
|
+
for (const [id, t] of timers) {
|
|
72
|
+
if (t.at <= target && t.at < nextAt) {
|
|
73
|
+
nextAt = t.at;
|
|
74
|
+
nextId = id;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (nextId < 0) break;
|
|
78
|
+
const t = timers.get(nextId)!;
|
|
79
|
+
timers.delete(nextId);
|
|
80
|
+
clock = t.at;
|
|
81
|
+
t.fn();
|
|
82
|
+
await Promise.resolve();
|
|
83
|
+
await Promise.resolve();
|
|
84
|
+
}
|
|
85
|
+
clock = target;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
test("evalCorrelation resolves literals and = body paths", () => {
|
|
91
|
+
const scope = { body: { taskId: 42 }, headers: {}, query: {} };
|
|
92
|
+
assert.equal(evalCorrelation("lit", scope), "lit");
|
|
93
|
+
assert.equal(evalCorrelation("= body.taskId", scope), "42");
|
|
94
|
+
assert.equal(evalCorrelation("= body.missing", scope), undefined);
|
|
95
|
+
assert.equal(evalCorrelation(undefined, scope), undefined);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("resolveExpr ignores inherited (prototype-chain) properties", () => {
|
|
99
|
+
const scope = { body: { id: "x" }, headers: {}, query: {} };
|
|
100
|
+
// `constructor`/`toString` live on Object.prototype, not the body — must not resolve.
|
|
101
|
+
assert.equal(evalCorrelation("= body.constructor", scope), undefined);
|
|
102
|
+
assert.equal(evalCorrelation("= body.toString", scope), undefined);
|
|
103
|
+
assert.equal(evalCorrelation("= body.__proto__", scope), undefined);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("evalCorrelation returns undefined for a non-scalar result", () => {
|
|
107
|
+
const scope = { body: { obj: { a: 1 }, arr: [1, 2], n: 7, flag: false }, headers: {}, query: {} };
|
|
108
|
+
assert.equal(evalCorrelation("= body.obj", scope), undefined);
|
|
109
|
+
assert.equal(evalCorrelation("= body.arr", scope), undefined);
|
|
110
|
+
assert.equal(evalCorrelation("= body.n", scope), "7");
|
|
111
|
+
assert.equal(evalCorrelation("= body.flag", scope), "false");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("runTriggerAction starts a process with action.variables", async () => {
|
|
115
|
+
const { app, calls } = fakeApp();
|
|
116
|
+
const res = await runTriggerAction(app, { start: "proc-1", variables: { a: 1 } }, {
|
|
117
|
+
body: { ignored: true },
|
|
118
|
+
headers: {},
|
|
119
|
+
query: {},
|
|
120
|
+
});
|
|
121
|
+
assert.equal(res.kind, "start");
|
|
122
|
+
assert.equal(res.target, "proc-1");
|
|
123
|
+
assert.deepEqual(calls, [{ kind: "start", target: "proc-1", variables: { a: 1 } }]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("runTriggerAction publishes a message, defaulting variables to the body", async () => {
|
|
127
|
+
const { app, calls } = fakeApp();
|
|
128
|
+
const res = await runTriggerAction(app, { message: "msg-1", correlationKey: "= body.id" }, {
|
|
129
|
+
body: { id: "k1", v: 9 },
|
|
130
|
+
headers: {},
|
|
131
|
+
query: {},
|
|
132
|
+
});
|
|
133
|
+
assert.equal(res.kind, "message");
|
|
134
|
+
assert.equal(res.correlationKey, "k1");
|
|
135
|
+
assert.deepEqual(calls, [
|
|
136
|
+
{ kind: "message", target: "msg-1", correlationKey: "k1", variables: { id: "k1", v: 9 } },
|
|
137
|
+
]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("runTriggerAction resolves a FEEL-string action.variables to an object", async () => {
|
|
141
|
+
const { app, calls } = fakeApp();
|
|
142
|
+
const res = await runTriggerAction(app, { start: "proc-2", variables: "= body.vars" }, {
|
|
143
|
+
body: { vars: { a: 1, b: "x" }, other: true },
|
|
144
|
+
headers: {},
|
|
145
|
+
query: {},
|
|
146
|
+
});
|
|
147
|
+
assert.equal(res.kind, "start");
|
|
148
|
+
assert.deepEqual(calls, [{ kind: "start", target: "proc-2", variables: { a: 1, b: "x" } }]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("runTriggerAction falls back to the body when a FEEL-string variables misses", async () => {
|
|
152
|
+
const { app, calls } = fakeApp();
|
|
153
|
+
await runTriggerAction(app, { message: "m", variables: "= body.nope" }, {
|
|
154
|
+
body: { k: 1 },
|
|
155
|
+
headers: {},
|
|
156
|
+
query: {},
|
|
157
|
+
});
|
|
158
|
+
assert.deepEqual(calls[0].variables, { k: 1 });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("cron trigger fires its action on schedule and reschedules", async () => {
|
|
162
|
+
const { app, calls } = fakeApp();
|
|
163
|
+
const { ctx } = fakeCtx([
|
|
164
|
+
{ id: "nightly", type: "cron", spec: "0 6 * * *", action: { start: "daily-report" } },
|
|
165
|
+
]);
|
|
166
|
+
// 2026-08-01T05:00:00Z — next 06:00 fire is 1h away.
|
|
167
|
+
const sched = fakeScheduler(Date.parse("2026-08-01T05:00:00Z"));
|
|
168
|
+
const handle = mountTriggers(ctx, app, sched);
|
|
169
|
+
assert.deepEqual(handle.describe?.(), { mounted: [], scheduled: ["nightly@0 6 * * *"] });
|
|
170
|
+
assert.equal(sched.pending(), 1);
|
|
171
|
+
|
|
172
|
+
await sched.advance(60 * 60 * 1000); // advance 1h → 06:00 fires
|
|
173
|
+
assert.equal(calls.length, 1);
|
|
174
|
+
assert.deepEqual(calls[0], { kind: "start", target: "daily-report", variables: { firedAt: "2026-08-01T06:00:00.000Z" } });
|
|
175
|
+
assert.equal(sched.pending(), 1); // rearmed for the next day
|
|
176
|
+
|
|
177
|
+
await sched.advance(24 * 60 * 60 * 1000); // next 06:00
|
|
178
|
+
assert.equal(calls.length, 2);
|
|
179
|
+
assert.equal(calls[1].variables?.firedAt, "2026-08-02T06:00:00.000Z");
|
|
180
|
+
|
|
181
|
+
await handle.stop();
|
|
182
|
+
assert.equal(sched.pending(), 0);
|
|
183
|
+
await sched.advance(48 * 60 * 60 * 1000); // no more fires after stop
|
|
184
|
+
assert.equal(calls.length, 2);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("cron trigger with onMissed once/all warns and degrades to skip", async () => {
|
|
188
|
+
const { app, logs } = fakeApp();
|
|
189
|
+
const { ctx } = fakeCtx([
|
|
190
|
+
{ id: "c", type: "cron", spec: "0 6 * * *", onMissed: "all", action: { start: "p" } },
|
|
191
|
+
]);
|
|
192
|
+
const sched = fakeScheduler(Date.parse("2026-08-01T05:00:00Z"));
|
|
193
|
+
mountTriggers(ctx, app, sched);
|
|
194
|
+
const warned = logs.find((l) => l.level === "warn" && /onMissed="all"/.test(l.msg));
|
|
195
|
+
assert.ok(warned, "expected a warning that onMissed=all degrades to skip");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("cron trigger with an invalid spec logs an error and does not schedule", () => {
|
|
199
|
+
const { app, logs } = fakeApp();
|
|
200
|
+
const { ctx } = fakeCtx([{ id: "bad", type: "cron", spec: "not a cron", action: { start: "p" } }]);
|
|
201
|
+
const sched = fakeScheduler(0);
|
|
202
|
+
const handle = mountTriggers(ctx, app, sched);
|
|
203
|
+
assert.equal(sched.pending(), 0);
|
|
204
|
+
assert.deepEqual(handle.describe?.(), { mounted: [], scheduled: [] });
|
|
205
|
+
assert.ok(logs.some((l) => l.level === "error" && /invalid cron spec/.test(l.msg)));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("cron trigger with no actionable action is not scheduled", () => {
|
|
209
|
+
const { app, logs } = fakeApp();
|
|
210
|
+
const { ctx } = fakeCtx([{ id: "noop", type: "cron", spec: "0 6 * * *" }]);
|
|
211
|
+
const sched = fakeScheduler(0);
|
|
212
|
+
const handle = mountTriggers(ctx, app, sched);
|
|
213
|
+
assert.equal(sched.pending(), 0);
|
|
214
|
+
assert.deepEqual(handle.describe?.(), { mounted: [], scheduled: [] });
|
|
215
|
+
assert.ok(logs.some((l) => l.level === "warn" && /no action\.start\/message/.test(l.msg)));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("cron trigger with an impossible spec warns and is not reported as scheduled", () => {
|
|
219
|
+
const { app, logs } = fakeApp();
|
|
220
|
+
const { ctx } = fakeCtx([{ id: "feb30", type: "cron", spec: "0 0 30 2 *", action: { start: "p" } }]);
|
|
221
|
+
const sched = fakeScheduler(Date.parse("2026-01-01T00:00:00Z"));
|
|
222
|
+
const handle = mountTriggers(ctx, app, sched);
|
|
223
|
+
assert.equal(sched.pending(), 0);
|
|
224
|
+
assert.deepEqual(handle.describe?.(), { mounted: [], scheduled: [] });
|
|
225
|
+
assert.ok(logs.some((l) => l.level === "warn" && /never fires/.test(l.msg)));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("cron trigger with a far-future fire chunks the wait instead of overflowing setTimeout", async () => {
|
|
229
|
+
const { app, calls } = fakeApp();
|
|
230
|
+
// Annual: Jan 1 00:00. Start just after so the next fire is ~1 year away (>> 24.8 days).
|
|
231
|
+
const { ctx } = fakeCtx([{ id: "annual", type: "cron", spec: "0 0 1 1 *", action: { start: "yr" } }]);
|
|
232
|
+
const start = Date.parse("2026-01-01T00:01:00Z");
|
|
233
|
+
const nextFire = Date.parse("2027-01-01T00:00:00Z");
|
|
234
|
+
const sched = fakeScheduler(start);
|
|
235
|
+
const handle = mountTriggers(ctx, app, sched);
|
|
236
|
+
assert.equal(sched.pending(), 1);
|
|
237
|
+
|
|
238
|
+
// Advance to just before the deadline: the wait was chunked (re-armed) and never fired.
|
|
239
|
+
await sched.advance(nextFire - start - 60_000);
|
|
240
|
+
assert.equal(calls.length, 0);
|
|
241
|
+
assert.equal(sched.pending(), 1);
|
|
242
|
+
|
|
243
|
+
// Cross the deadline: fires exactly once, on the intended instant, then re-arms.
|
|
244
|
+
await sched.advance(120_000);
|
|
245
|
+
assert.equal(calls.length, 1);
|
|
246
|
+
assert.equal(calls[0].variables?.firedAt, "2027-01-01T00:00:00.000Z");
|
|
247
|
+
await handle.stop();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("webhook trigger still routes and publishes", async () => {
|
|
251
|
+
const { app, calls } = fakeApp();
|
|
252
|
+
const { ctx } = fakeCtx([
|
|
253
|
+
{ id: "hook", type: "webhook", action: { message: "got-it", correlationKey: "= body.id" } },
|
|
254
|
+
]);
|
|
255
|
+
const handle = mountTriggers(ctx, app);
|
|
256
|
+
const router = makeRouter(handle.routes) as unknown as (r: HttpRequest) => Promise<{ status?: number; body?: string }>;
|
|
257
|
+
const res = await router({
|
|
258
|
+
method: "POST",
|
|
259
|
+
path: "/hooks/hook",
|
|
260
|
+
query: new URLSearchParams(""),
|
|
261
|
+
headers: new Headers(),
|
|
262
|
+
text: () => Promise.resolve(JSON.stringify({ id: "k9" })),
|
|
263
|
+
});
|
|
264
|
+
assert.equal(res.status ?? 200, 200);
|
|
265
|
+
assert.deepEqual(calls, [
|
|
266
|
+
{ kind: "message", target: "got-it", correlationKey: "k9", variables: { id: "k9" } },
|
|
267
|
+
]);
|
|
268
|
+
await handle.stop();
|
|
269
|
+
});
|