@human-synthesis/norns 0.0.16 → 0.1.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.
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Trace runner (K-18): execute Action `examples` against a sandboxed
3
+ * in-memory SQLite and report per-case pass/fail with step values.
4
+ *
5
+ * The traced artifact is the *generated* code, not a re-interpretation of
6
+ * the spec: the generated tree is compiled (Civet → JS) into a scratch
7
+ * dir with imports rewritten to resolvable paths, then imported and run.
8
+ *
9
+ * Conventions:
10
+ * - `$name` input values are fixture handles: a row with that id is
11
+ * seeded in the action's entity table; if `name` is one of the
12
+ * entity's states, the row starts in that state. Owner/ref fields
13
+ * are seeded to the trace user so `owner` policies pass.
14
+ * - `expect` keys are checked against the entity row after the run,
15
+ * falling back to the action's return value.
16
+ * - external calls (`container.resolve(...)`) hit `opts.fixtures` when
17
+ * provided, otherwise a recording stub — every call is reported.
18
+ */
19
+
20
+ import { mkdirSync, readFileSync, readdirSync, rmSync, existsSync, writeFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
23
+ import { pathToFileURL } from 'node:url';
24
+
25
+ import { actionEntity } from './emit-units.js';
26
+ import { normalizeField } from './emit-schema.js';
27
+ import { generateApp } from './generate.js';
28
+ import { loadSpecs } from './validate.js';
29
+
30
+ const require = createRequire(import.meta.url);
31
+
32
+ export const TRACE_USER = { id: 'trace-user', roles: ['admin'] };
33
+
34
+ /* ------------------------------------------------------------------ */
35
+ /* Scratch tree: compile generated Civet to importable JS */
36
+ /* ------------------------------------------------------------------ */
37
+
38
+ const BARE = {
39
+ '@human-synthesis/norns/server': new URL('../server/index.js', import.meta.url).href,
40
+ '@human-synthesis/norns/kernel': new URL('./index.js', import.meta.url).href
41
+ };
42
+
43
+ function resolveBare(spec) {
44
+ if (BARE[spec]) return BARE[spec];
45
+ return import.meta.resolve(spec);
46
+ }
47
+
48
+ function rewriteImports(js, { scratch, appRoot, compileCivet }) {
49
+ return js.replace(/(from\s+)(['"])([^'"]+)\2/g, (whole, from, q, spec) => {
50
+ if (spec.startsWith('.')) return `${from}${q}${spec.replace(/\.c$/, '.js')}${q}`;
51
+ if (spec.startsWith('$lib/')) {
52
+ const target = join(scratch, 'lib', spec.slice('$lib/'.length).replace(/\.c$/, '.js'));
53
+ return `${from}${q}${pathToFileURL(target).href}${q}`;
54
+ }
55
+ if (spec.startsWith('$custom/')) {
56
+ const rel = spec.slice('$custom/'.length);
57
+ const target = join(scratch, 'custom', rel.replace(/\.c$/, '.js'));
58
+ if (!existsSync(target)) {
59
+ const src = join(appRoot, 'src', rel);
60
+ mkdirSync(dirname(target), { recursive: true });
61
+ if (existsSync(src)) {
62
+ writeFileSync(
63
+ target,
64
+ rewriteImports(compileCivet(readFileSync(src, 'utf-8')), { scratch, appRoot, compileCivet })
65
+ );
66
+ } else {
67
+ writeFileSync(
68
+ target,
69
+ `export default () => { throw new Error(${JSON.stringify(`missing custom body: src/${rel}`)}) }\n`
70
+ );
71
+ }
72
+ }
73
+ return `${from}${q}${pathToFileURL(target).href}${q}`;
74
+ }
75
+ return `${from}${q}${resolveBare(spec)}${q}`;
76
+ });
77
+ }
78
+
79
+ /** Compile `lib/**` of the generated tree into `<scratch>/lib` as JS. */
80
+ function buildScratch(genRoot, scratch, appRoot) {
81
+ const { compile } = require('@danielx/civet');
82
+ const compileCivet = (src) => compile(src, { sync: true, js: true });
83
+ const libRoot = join(genRoot, 'lib');
84
+ if (!existsSync(libRoot)) return;
85
+ for (const moduleName of readdirSync(libRoot)) {
86
+ const dir = join(libRoot, moduleName);
87
+ for (const file of readdirSync(dir)) {
88
+ if (!file.endsWith('.c')) continue;
89
+ const js = rewriteImports(compileCivet(readFileSync(join(dir, file), 'utf-8')), {
90
+ scratch,
91
+ appRoot,
92
+ compileCivet
93
+ });
94
+ const out = join(scratch, 'lib', moduleName, file.replace(/\.c$/, '.js'));
95
+ mkdirSync(dirname(out), { recursive: true });
96
+ writeFileSync(out, js);
97
+ }
98
+ }
99
+ }
100
+
101
+ /* ------------------------------------------------------------------ */
102
+ /* Sandbox database */
103
+ /* ------------------------------------------------------------------ */
104
+
105
+ const sqlLit = (v) =>
106
+ typeof v === 'number' ? String(v) : typeof v === 'boolean' ? (v ? '1' : '0') : `'${String(v).replaceAll("'", "''")}'`;
107
+
108
+ async function tableDDL(tables) {
109
+ const { getTableConfig } = await import('drizzle-orm/sqlite-core');
110
+ return tables.map((table) => {
111
+ const cfg = getTableConfig(table);
112
+ const cols = cfg.columns.map((c) => {
113
+ let s = `"${c.name}" ${c.getSQLType()}`;
114
+ if (c.primary) s += ' PRIMARY KEY';
115
+ else if (c.notNull) s += ' NOT NULL';
116
+ if (c.hasDefault && c.default !== undefined) s += ` DEFAULT ${sqlLit(c.default)}`;
117
+ return s;
118
+ });
119
+ return `CREATE TABLE "${cfg.name}" (${cols.join(', ')})`;
120
+ });
121
+ }
122
+
123
+ /** Collect every sqliteTable export across the compiled schema modules. */
124
+ async function loadTables(scratch, specs) {
125
+ const tables = {};
126
+ for (const moduleName of Object.keys(specs.modules)) {
127
+ const file = join(scratch, 'lib', moduleName, 'schema.js');
128
+ if (!existsSync(file)) continue;
129
+ const mod = await import(pathToFileURL(file).href);
130
+ for (const [entity] of Object.entries(specs.modules[moduleName].entities ?? {})) {
131
+ if (mod[entity]) tables[`${moduleName}.${entity}`] = mod[entity];
132
+ }
133
+ }
134
+ return tables;
135
+ }
136
+
137
+ const SEED_VALUES = {
138
+ text: 'trace',
139
+ email: 'trace@example.com',
140
+ url: 'https://example.com',
141
+ file: 'trace.bin',
142
+ int: 0,
143
+ money: 0,
144
+ number: 0,
145
+ bool: false,
146
+ json: {}
147
+ };
148
+
149
+ function seedRow(id, entitySpec) {
150
+ const row = { id };
151
+ const owner = entitySpec.owner;
152
+ for (const [field, def0] of Object.entries(entitySpec.fields ?? {})) {
153
+ const def = normalizeField(def0);
154
+ if (def.optional) continue;
155
+ if (field === owner || def.type === 'ref') row[field] = TRACE_USER.id;
156
+ else if (def.type === 'date' || def.type === 'datetime') row[field] = new Date(0);
157
+ else row[field] = def.default ?? SEED_VALUES[def.type] ?? 'trace';
158
+ }
159
+ const states = Object.keys(entitySpec.status ?? {});
160
+ const name = id.startsWith('$') ? id.slice(1) : null;
161
+ if (name && states.includes(name)) row.status = name;
162
+ return row;
163
+ }
164
+
165
+ /* ------------------------------------------------------------------ */
166
+ /* Runner */
167
+ /* ------------------------------------------------------------------ */
168
+
169
+ function recordingContainer(db, { fixtures = {}, events, calls }) {
170
+ return {
171
+ resolve(name) {
172
+ if (name === 'db') return db;
173
+ if (name === 'events') {
174
+ return {
175
+ emit: async (evt, payload) => {
176
+ events.push({ name: evt, payload });
177
+ }
178
+ };
179
+ }
180
+ if (name in fixtures) return fixtures[name];
181
+ return async (...args) => {
182
+ calls.push({ name, args });
183
+ };
184
+ }
185
+ };
186
+ }
187
+
188
+ const looseEq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
189
+
190
+ /**
191
+ * Run every Action example in the app's specs.
192
+ *
193
+ * @param {string} [dir] specs directory, defaults to `<cwd>/specs`
194
+ * @param {{ out?: string, fixtures?: Record<string, *>, user?: * }} [opts]
195
+ * @returns {Promise<{ version: string, pass: number, fail: number, cases: * }>}
196
+ */
197
+ export async function traceApp(dir, opts = {}) {
198
+ const specs = loadSpecs(dir);
199
+ const appRoot = dirname(specs.dir);
200
+ const genRoot = opts.out ?? join(appRoot, '.norns', 'generated');
201
+ generateApp(dir, { out: opts.out });
202
+
203
+ const scratch = join(appRoot, '.norns', 'cache', 'trace', `${specs.version.slice(0, 8)}-${Date.now()}`);
204
+ const user = opts.user ?? TRACE_USER;
205
+ const cases = [];
206
+
207
+ try {
208
+ buildScratch(genRoot, scratch, appRoot);
209
+ const tables = await loadTables(scratch, specs);
210
+ const ddl = await tableDDL(Object.values(tables));
211
+ const { betterSqlite } = await import('../server/db.js');
212
+ const { sql, eq } = await import('drizzle-orm');
213
+
214
+ for (const [moduleName, moduleSpec] of Object.entries(specs.modules)) {
215
+ const actionsFile = join(scratch, 'lib', moduleName, 'actions.js');
216
+ const actionNames = Object.entries(moduleSpec.actions ?? {})
217
+ .filter(([, a]) => (a.examples ?? []).length > 0)
218
+ .map(([n]) => n)
219
+ .sort();
220
+ if (actionNames.length === 0 || !existsSync(actionsFile)) continue;
221
+ const mod = await import(pathToFileURL(actionsFile).href);
222
+
223
+ for (const name of actionNames) {
224
+ const actionSpec = moduleSpec.actions[name];
225
+ const address = `${moduleName}.Action.${name}`;
226
+ const target = actionEntity(moduleName, actionSpec, specs);
227
+
228
+ for (const [index, example] of (actionSpec.examples ?? []).entries()) {
229
+ const events = [];
230
+ const calls = [];
231
+ const record = { address, index, input: example.input ?? {}, expect: example.expect ?? {}, events, calls };
232
+ cases.push(record);
233
+ try {
234
+ const db = await betterSqlite(':memory:');
235
+ for (const stmt of ddl) await db.run(sql.raw(stmt));
236
+
237
+ // seed every `$handle` input value into its entity table
238
+ let seededId = null;
239
+ for (const [key, value] of Object.entries(example.input ?? {})) {
240
+ if (typeof value !== 'string' || !value.startsWith('$')) continue;
241
+ const ref = actionSpec.input?.[key];
242
+ const entity = typeof ref === 'string' ? ref.replace(/\?$/, '').split('.')[0] : target?.entity;
243
+ const entityModule = target && target.entity === entity ? target.module : moduleName;
244
+ const table = tables[`${entityModule}.${entity}`];
245
+ const entitySpec = specs.modules[entityModule]?.entities?.[entity];
246
+ if (!table || !entitySpec) continue;
247
+ await db.insert(table).values(seedRow(value, entitySpec));
248
+ if (ref?.endsWith('.id')) seededId = { table, value };
249
+ }
250
+
251
+ const container = recordingContainer(db, { fixtures: opts.fixtures, events, calls });
252
+ record.result = await mod[name].run({ input: example.input ?? {}, container, user });
253
+
254
+ if (seededId && target) {
255
+ record.row = (
256
+ await db.select().from(seededId.table).where(eq(seededId.table.id, seededId.value)).limit(1)
257
+ )[0];
258
+ }
259
+
260
+ record.pass = Object.entries(record.expect).every(
261
+ ([k, want]) => looseEq(record.row?.[k], want) || looseEq(record.result?.[k], want)
262
+ );
263
+ } catch (e) {
264
+ record.pass = false;
265
+ record.error = e?.body?.message ?? e?.message ?? String(e);
266
+ record.status = e?.status;
267
+ }
268
+ }
269
+ }
270
+ }
271
+ } finally {
272
+ rmSync(scratch, { recursive: true, force: true });
273
+ }
274
+
275
+ const pass = cases.filter((c) => c.pass).length;
276
+ return { version: specs.version, pass, fail: cases.length - pass, cases };
277
+ }
@@ -0,0 +1,92 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { APP_SPEC, readSpecs } from '@human-synthesis/norns-tron/spec';
5
+
6
+ import { indexUnits, listUnits } from './address.js';
7
+ import { APP_SCHEMA, MODULE_SCHEMA, UNIT_SCHEMAS, schemaIssues } from './meta.js';
8
+ import { refineSpecs } from './refine.js';
9
+
10
+ /**
11
+ * @typedef {{ level: 'error' | 'warning', address: string, message: string }} Issue
12
+ * @typedef {{
13
+ * dir: string,
14
+ * app: *,
15
+ * modules: Record<string, *>,
16
+ * hashes: Record<string, string>,
17
+ * version: string
18
+ * }} LoadedSpecs
19
+ */
20
+
21
+ /**
22
+ * Load a `specs/` directory. Throws when the directory is missing —
23
+ * everything past this point can assume specs exist.
24
+ *
25
+ * @param {string} [dir] defaults to `<cwd>/specs`
26
+ * @returns {LoadedSpecs}
27
+ */
28
+ export function loadSpecs(dir = resolve(process.cwd(), 'specs')) {
29
+ const abs = resolve(dir);
30
+ if (!existsSync(abs)) {
31
+ throw new Error(`norns: no specs directory at ${abs} (expected specs/*.tron)`);
32
+ }
33
+ return { dir: abs, ...readSpecs(abs) };
34
+ }
35
+
36
+ /**
37
+ * Validate a specs directory and return every issue found: structural
38
+ * checks, per-kind meta-schemas, uid uniqueness. Cross-unit refinements
39
+ * (K-06) register here as they land.
40
+ *
41
+ * @param {string} [dir]
42
+ * @returns {{ ok: boolean, version: string, modules: string[], issues: Issue[] }}
43
+ */
44
+ export function validateSpecs(dir) {
45
+ const specs = loadSpecs(dir);
46
+ /** @type {Issue[]} */
47
+ const issues = [];
48
+
49
+ if (specs.app === null) {
50
+ issues.push({
51
+ level: 'error',
52
+ address: APP_SPEC,
53
+ message: `missing ${APP_SPEC}.tron — every app needs an app spec`
54
+ });
55
+ }
56
+
57
+ for (const [name, value] of Object.entries(specs.modules)) {
58
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
59
+ issues.push({
60
+ level: 'error',
61
+ address: name,
62
+ message: `${name}.tron must be an object, got ${Array.isArray(value) ? 'array' : typeof value}`
63
+ });
64
+ continue;
65
+ }
66
+ if (value.module !== name) {
67
+ issues.push({
68
+ level: 'error',
69
+ address: name,
70
+ message: `module field ${JSON.stringify(value.module)} does not match file name "${name}"`
71
+ });
72
+ }
73
+ issues.push(...schemaIssues(MODULE_SCHEMA, value, name));
74
+ for (const unit of listUnits(name, value)) {
75
+ issues.push(...schemaIssues(UNIT_SCHEMAS[unit.kind], unit.value, unit.address));
76
+ }
77
+ }
78
+
79
+ if (specs.app !== null) {
80
+ issues.push(...schemaIssues(APP_SCHEMA, specs.app, APP_SPEC));
81
+ }
82
+
83
+ issues.push(...indexUnits(specs.modules).issues);
84
+ issues.push(...refineSpecs(specs));
85
+
86
+ return {
87
+ ok: !issues.some((i) => i.level === 'error'),
88
+ version: specs.version,
89
+ modules: Object.keys(specs.modules),
90
+ issues
91
+ };
92
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Browser side of the live-query bridge (R-11). Generated pages with
3
+ * `live: true` queries call `liveQueries` from an `$effect`; the server
4
+ * counterpart streams refresh signals from `/_norns/live` (see
5
+ * `src/server/live.js`).
6
+ *
7
+ * No SvelteKit import here — the caller passes `invalidate` from
8
+ * `$app/navigation` so this module stays framework-neutral and testable.
9
+ */
10
+
11
+ /** SvelteKit `depends`/`invalidate` key for a query address. */
12
+ export const dependsKey = (address) => `norns:${address}`;
13
+
14
+ /**
15
+ * Subscribe to refresh signals and invalidate the given query addresses
16
+ * when they change. Returns a cleanup function (safe for `$effect`).
17
+ *
18
+ * @param {string[]} addresses query addresses this page depends on
19
+ * @param {(key: string) => *} invalidate `invalidate` from `$app/navigation`
20
+ * @param {{ path?: string, EventSource?: typeof EventSource }} [opts]
21
+ * @returns {() => void}
22
+ */
23
+ export function liveQueries(addresses, invalidate, opts = {}) {
24
+ const ES = opts.EventSource ?? globalThis.EventSource;
25
+ if (typeof ES !== 'function') return () => {};
26
+
27
+ const wanted = new Set(addresses);
28
+ const source = new ES(opts.path ?? '/_norns/live');
29
+ source.onmessage = (e) => {
30
+ let payload;
31
+ try {
32
+ payload = JSON.parse(e.data);
33
+ } catch {
34
+ return;
35
+ }
36
+ for (const address of Array.isArray(payload?.queries) ? payload.queries : []) {
37
+ if (wanted.has(address)) invalidate(dependsKey(address));
38
+ }
39
+ };
40
+ return () => source.close();
41
+ }
42
+
43
+ /**
44
+ * Call a `transport: remote` action endpoint
45
+ * (`module.Action.name` → POST `/api/<module>/<name>`).
46
+ *
47
+ * @param {string} address action address
48
+ * @param {*} [input]
49
+ * @param {{ fetch?: typeof fetch }} [opts]
50
+ */
51
+ export async function remoteCall(address, input, opts = {}) {
52
+ const parts = String(address).split('.');
53
+ if (parts.length !== 3 || parts[1] !== 'Action') {
54
+ throw new Error(`remoteCall: "${address}" is not an Action address`);
55
+ }
56
+ const f = opts.fetch ?? globalThis.fetch;
57
+ const res = await f(`/api/${parts[0]}/${parts[2]}`, {
58
+ method: 'POST',
59
+ headers: { 'content-type': 'application/json' },
60
+ body: JSON.stringify(input ?? {})
61
+ });
62
+ if (!res.ok) {
63
+ let detail = '';
64
+ try {
65
+ detail = (await res.json())?.message ?? '';
66
+ } catch {
67
+ /* body may not be JSON */
68
+ }
69
+ throw new Error(`remoteCall ${address}: ${res.status}${detail ? ` — ${detail}` : ''}`);
70
+ }
71
+ return res.json();
72
+ }
@@ -1,8 +1,12 @@
1
1
  import { sequence } from '@sveltejs/kit/hooks';
2
2
  import { Container } from './container.js';
3
3
  import { contextHandle } from './handle/context.js';
4
+ import { authHandle } from './handle/auth.js';
4
5
  import { errorHandle } from './handle/error.js';
5
6
  import { setSerializer } from './route.js';
7
+ import { createEvents, registerTriggers } from './events.js';
8
+ import { createLive } from './live.js';
9
+ import { scheduledHandler, startCronShim } from './cron.js';
6
10
 
7
11
  /**
8
12
  * Create a fresh root container with no features registered. Useful for tests
@@ -34,16 +38,42 @@ export function createApp() {
34
38
  * Each `module.c` must default-export a function `(app) -> ...` that calls
35
39
  * `app.bind(...)` / `app.single(...)` / `app.migrations(...)`.
36
40
  *
41
+ * Spec-first extras:
42
+ * - `triggers` — generated trigger tables (`lib/<m>/triggers.c` exports),
43
+ * nested arrays are flattened. Event triggers are wired into the bus;
44
+ * cron ones are served by the returned `scheduled` handler.
45
+ * - `queue` — Cloudflare Queues producer binding; `emit` enqueues instead of
46
+ * dispatching in-process.
47
+ * - `cronShim: true` — local minute-timer for cron triggers (`norns dev`).
48
+ * - `auth` — a better-auth-shaped instance (`.handler(request)` +
49
+ * `.api.getSession({ headers })`); requests under `authBasePath`
50
+ * (default `/api/auth`) are handed to it, every other request gets
51
+ * `event.locals.user` / `event.locals.session` and scope bindings.
52
+ * - `room` — the `ROOM` Durable Object namespace binding (Workers prod);
53
+ * live-query publishes and the `/_norns/live` stream go through it. Omit
54
+ * in dev: signals ride the in-process bus.
55
+ * - an `events` singleton is bound automatically unless a feature bound one,
56
+ * and a `live` bridge singleton likewise (actions with `refresh` lists
57
+ * publish through it).
58
+ *
37
59
  * @param {{
38
60
  * features?: Record<string, FeatureModule>,
39
61
  * extraHandle?: import('@sveltejs/kit').Handle | import('@sveltejs/kit').Handle[],
40
62
  * handleError?: import('@sveltejs/kit').HandleServerError,
41
- * serializer?: import('./route.js').Serializer | null
63
+ * serializer?: import('./route.js').Serializer | null,
64
+ * triggers?: *[],
65
+ * queue?: { send(body: *): Promise<void> | void },
66
+ * cronShim?: boolean,
67
+ * room?: *,
68
+ * auth?: { handler(request: Request): Promise<Response> | Response, api: { getSession(input: *): Promise<*> } },
69
+ * authBasePath?: string
42
70
  * }} [opts]
43
71
  * @returns {Promise<{
44
72
  * container: Container,
45
73
  * handle: import('@sveltejs/kit').Handle,
46
- * handleError: import('@sveltejs/kit').HandleServerError
74
+ * handleError: import('@sveltejs/kit').HandleServerError,
75
+ * scheduled: (event: *) => Promise<void>,
76
+ * stopCronShim: () => void
47
77
  * }>}
48
78
  */
49
79
  export async function boot(opts = {}) {
@@ -73,8 +103,35 @@ export async function boot(opts = {}) {
73
103
  : [opts.extraHandle]
74
104
  : [];
75
105
 
76
- const handle = sequence(contextHandle(container), ...extras);
106
+ if (!container.has('events')) {
107
+ container.single('events', () => createEvents(opts.queue ? { queue: opts.queue } : {}));
108
+ }
109
+ if (!container.has('live')) {
110
+ container.single('live', () =>
111
+ createLive({ events: container.resolve('events'), room: opts.room })
112
+ );
113
+ }
114
+
115
+ const triggers = (opts.triggers ?? []).flat(Infinity);
116
+ if (triggers.length > 0) {
117
+ registerTriggers(container, triggers.filter((t) => !t.schedule));
118
+ }
119
+ const stopCronShim = opts.cronShim ? startCronShim(container, triggers) : () => {};
120
+
121
+ // contextHandle must come first so authHandle can bind user/session into
122
+ // the per-request scope it creates.
123
+ const handle = sequence(
124
+ contextHandle(container),
125
+ ...(opts.auth ? [authHandle(opts.auth, { basePath: opts.authBasePath })] : []),
126
+ ...extras
127
+ );
77
128
  const handleError = opts.handleError ?? errorHandle();
78
129
 
79
- return { container, handle, handleError };
130
+ return {
131
+ container,
132
+ handle,
133
+ handleError,
134
+ scheduled: scheduledHandler(container, triggers),
135
+ stopCronShim
136
+ };
80
137
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cron triggers: Workers `scheduled` adapter + local timer shim for
3
+ * `norns dev`. Triggers are the generated tables (`[{ on, action, schedule? }]`).
4
+ */
5
+
6
+ /**
7
+ * Match a 5-field cron expression (min hour dom mon dow) against a Date.
8
+ * Supports `*`, `n`, `a-b`, `*​/n`, `a-b/n`, and comma lists; dow 0/7 = Sunday.
9
+ *
10
+ * @param {string} expr
11
+ * @param {Date} date
12
+ */
13
+ export function cronMatches(expr, date) {
14
+ const fields = expr.trim().split(/\s+/);
15
+ if (fields.length !== 5) return false;
16
+ const values = [
17
+ date.getUTCMinutes(),
18
+ date.getUTCHours(),
19
+ date.getUTCDate(),
20
+ date.getUTCMonth() + 1,
21
+ date.getUTCDay()
22
+ ];
23
+ return fields.every((field, i) => fieldMatches(field, values[i], i === 4));
24
+ }
25
+
26
+ function fieldMatches(field, value, isDow) {
27
+ return field.split(',').some((part) => {
28
+ const [range, stepStr] = part.split('/');
29
+ const step = stepStr === undefined ? 1 : Number(stepStr);
30
+ if (!Number.isInteger(step) || step < 1) return false;
31
+ let lo;
32
+ let hi;
33
+ if (range === '*') {
34
+ lo = 0;
35
+ hi = Infinity;
36
+ } else if (range.includes('-')) {
37
+ [lo, hi] = range.split('-').map(Number);
38
+ } else {
39
+ lo = hi = Number(range);
40
+ if (isDow && lo === 7) lo = hi = 0;
41
+ }
42
+ if (!Number.isInteger(lo) || (hi !== Infinity && !Number.isInteger(hi))) return false;
43
+ return value >= lo && value <= hi && (value - (lo === 0 || range === '*' ? 0 : lo)) % step === 0;
44
+ });
45
+ }
46
+
47
+ /** @param {{ schedule?: string }[]} triggers */
48
+ export function cronTriggers(triggers) {
49
+ return triggers.filter((t) => typeof t.schedule === 'string');
50
+ }
51
+
52
+ async function runTrigger(container, trigger) {
53
+ await trigger.action.run({ input: {}, container });
54
+ }
55
+
56
+ /**
57
+ * Cloudflare Workers `scheduled` handler. Runs every cron trigger whose
58
+ * schedule equals `event.cron` (how Workers routes multi-cron Workers), or —
59
+ * when `event.cron` is absent — whose schedule matches the event time.
60
+ *
61
+ * @param {*} container
62
+ * @param {{ schedule?: string, action: { run(ctx: *): * } }[]} triggers
63
+ */
64
+ export function scheduledHandler(container, triggers) {
65
+ const crons = cronTriggers(triggers);
66
+ return async (event) => {
67
+ const due = event?.cron
68
+ ? crons.filter((t) => t.schedule === event.cron)
69
+ : crons.filter((t) => cronMatches(t.schedule, new Date(event?.scheduledTime ?? Date.now())));
70
+ for (const t of due) await runTrigger(container, t);
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Local dev shim: checks once per minute (aligned to the minute) and runs
76
+ * matching cron triggers. Returns a stop function.
77
+ *
78
+ * @param {*} container
79
+ * @param {{ schedule?: string, action: { run(ctx: *): * } }[]} triggers
80
+ * @param {{ onError?: (err: *, trigger: *) => void }} [opts]
81
+ */
82
+ export function startCronShim(container, triggers, { onError } = {}) {
83
+ const crons = cronTriggers(triggers);
84
+ if (crons.length === 0) return () => {};
85
+ let timer;
86
+ const tick = async () => {
87
+ const now = new Date();
88
+ for (const t of crons.filter((t) => cronMatches(t.schedule, now))) {
89
+ try {
90
+ await runTrigger(container, t);
91
+ } catch (err) {
92
+ onError?.(err, t);
93
+ }
94
+ }
95
+ };
96
+ const arm = () => {
97
+ const msToMinute = 60_000 - (Date.now() % 60_000);
98
+ timer = setTimeout(async () => {
99
+ await tick();
100
+ arm();
101
+ }, msToMinute);
102
+ };
103
+ arm();
104
+ return () => clearTimeout(timer);
105
+ }