cursedbelt-server 2.0.0 β†’ 3.0.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.
Files changed (82) hide show
  1. package/dist/server/bench/assert.d.ts +61 -0
  2. package/dist/server/bench/assert.js +117 -0
  3. package/dist/server/bench/budget.d.ts +130 -0
  4. package/dist/server/bench/budget.js +131 -0
  5. package/dist/server/bench/cpuBudget.d.ts +45 -0
  6. package/dist/server/bench/cpuBudget.js +34 -0
  7. package/dist/server/bench/cpuClock.d.ts +65 -0
  8. package/dist/server/bench/cpuClock.js +100 -0
  9. package/dist/server/bench/index.d.ts +40 -0
  10. package/dist/server/bench/index.js +40 -0
  11. package/dist/server/bench/recorder.d.ts +70 -0
  12. package/dist/server/bench/recorder.js +95 -0
  13. package/dist/server/bench/runBench.d.ts +61 -0
  14. package/dist/server/bench/runBench.js +61 -0
  15. package/dist/server/d1/backup.d.ts +110 -0
  16. package/dist/server/d1/backup.js +128 -0
  17. package/dist/server/d1/fakeD1.d.ts +41 -0
  18. package/dist/server/d1/fakeD1.js +185 -0
  19. package/dist/server/d1/index.d.ts +24 -0
  20. package/dist/server/d1/index.js +24 -0
  21. package/dist/server/d1/kysely.d.ts +56 -0
  22. package/dist/server/d1/kysely.js +138 -0
  23. package/dist/server/d1/limits.d.ts +56 -0
  24. package/dist/server/d1/limits.js +96 -0
  25. package/dist/server/d1/local.d.ts +31 -0
  26. package/dist/server/d1/local.js +135 -0
  27. package/dist/server/d1/remote.d.ts +59 -0
  28. package/dist/server/d1/remote.js +124 -0
  29. package/dist/server/d1/scheduling.d.ts +113 -0
  30. package/dist/server/d1/scheduling.js +164 -0
  31. package/dist/server/d1/types.d.ts +143 -0
  32. package/dist/server/d1/types.js +80 -0
  33. package/dist/server/d1/values.d.ts +50 -0
  34. package/dist/server/d1/values.js +124 -0
  35. package/dist/server/master-lock/guard.d.ts +10 -0
  36. package/dist/server/master-lock/guard.js +70 -19
  37. package/dist/server/master-lock/index.d.ts +1 -1
  38. package/dist/server/master-lock/index.js +1 -1
  39. package/dist/server/master-lock/lockPage.d.ts +1 -1
  40. package/dist/server/master-lock/lockPage.js +68 -3
  41. package/dist/server/master-lock/masterLock.d.ts +250 -76
  42. package/dist/server/master-lock/masterLock.js +426 -114
  43. package/dist/server/master-lock/principals.js +6 -1
  44. package/dist/server/master-lock/seed.d.ts +5 -1
  45. package/dist/server/master-lock/seed.js +18 -1
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/server/bench/assert.ts +192 -0
  49. package/src/server/bench/budget.spec.ts +126 -0
  50. package/src/server/bench/budget.ts +207 -0
  51. package/src/server/bench/cpuBudget.spec.ts +302 -0
  52. package/src/server/bench/cpuBudget.ts +81 -0
  53. package/src/server/bench/cpuClock.ts +119 -0
  54. package/src/server/bench/index.ts +81 -0
  55. package/src/server/bench/recorder.ts +163 -0
  56. package/src/server/bench/runBench.ts +110 -0
  57. package/src/server/d1/backup.spec.ts +121 -0
  58. package/src/server/d1/backup.ts +186 -0
  59. package/src/server/d1/fakeD1.ts +193 -0
  60. package/src/server/d1/index.ts +62 -0
  61. package/src/server/d1/kysely.spec.ts +145 -0
  62. package/src/server/d1/kysely.ts +169 -0
  63. package/src/server/d1/limits.spec.ts +90 -0
  64. package/src/server/d1/limits.ts +123 -0
  65. package/src/server/d1/local.ts +173 -0
  66. package/src/server/d1/remote.ts +182 -0
  67. package/src/server/d1/sameShape.spec.ts +279 -0
  68. package/src/server/d1/scheduling.spec.ts +120 -0
  69. package/src/server/d1/scheduling.ts +210 -0
  70. package/src/server/d1/types.ts +163 -0
  71. package/src/server/d1/values.ts +138 -0
  72. package/src/server/master-lock/accounts.spec.ts +308 -0
  73. package/src/server/master-lock/guard.spec.ts +69 -7
  74. package/src/server/master-lock/guard.ts +78 -20
  75. package/src/server/master-lock/index.ts +3 -0
  76. package/src/server/master-lock/lockPage.ts +70 -3
  77. package/src/server/master-lock/masterLock.spec.ts +56 -23
  78. package/src/server/master-lock/masterLock.ts +529 -151
  79. package/src/server/master-lock/principals.spec.ts +45 -15
  80. package/src/server/master-lock/principals.ts +6 -1
  81. package/src/server/master-lock/seed.spec.ts +7 -2
  82. package/src/server/master-lock/seed.ts +22 -2
@@ -0,0 +1,279 @@
1
+ /**
2
+ * πŸ”΄ The load-bearing test of the whole seam: the local and remote drivers return
3
+ * IDENTICAL shapes for the same query.
4
+ *
5
+ * Every case runs the same SQL through both drivers and compares the results to each
6
+ * other β€” not to a hand-written expectation. An expectation written by the same person who
7
+ * wrote the driver encodes their belief; comparing the two drivers encodes the property
8
+ * the fleet actually needs, which is that a ported call site cannot tell which side it is
9
+ * on.
10
+ *
11
+ * The remote driver runs against `createFakeD1Binding`, which deliberately reproduces the
12
+ * four places SQLite drivers disagree (BLOB wire shape, bind strictness, result wrapping,
13
+ * no interactive transaction). See the header of `./fakeD1` β€” if that fake ever drifts
14
+ * toward mirroring our driver, this file stops proving anything.
15
+ */
16
+
17
+ import { Database } from 'bun:sqlite';
18
+ import { beforeEach, describe, expect, test } from 'bun:test';
19
+ import { createFakeD1Binding } from './fakeD1';
20
+ import { createLocalD1 } from './local';
21
+ import { createRemoteD1 } from './remote';
22
+ import { D1BindError, type D1LikeBindable, type D1LikeDatabase } from './types';
23
+
24
+ const DDL = `CREATE TABLE t (
25
+ id INTEGER PRIMARY KEY,
26
+ int_col INTEGER,
27
+ real_col REAL,
28
+ text_col TEXT,
29
+ blob_col BLOB,
30
+ bool_col INTEGER,
31
+ null_col TEXT
32
+ )`;
33
+
34
+ /** Two databases with identical contents, one behind each driver. */
35
+ function pair(): { local: D1LikeDatabase; remote: D1LikeDatabase; seed: (sql: string) => void } {
36
+ const a = new Database(':memory:');
37
+ const b = new Database(':memory:');
38
+ a.run(DDL);
39
+ b.run(DDL);
40
+ return {
41
+ local: createLocalD1(a),
42
+ remote: createRemoteD1(createFakeD1Binding(b)),
43
+ seed: (sql: string) => {
44
+ a.run(sql);
45
+ b.run(sql);
46
+ },
47
+ };
48
+ }
49
+
50
+ /** Run the same statement on both drivers and return both answers. */
51
+ async function both<T>(
52
+ p: ReturnType<typeof pair>,
53
+ fn: (db: D1LikeDatabase) => Promise<T>,
54
+ ): Promise<{ local: T; remote: T }> {
55
+ return { local: await fn(p.local), remote: await fn(p.remote) };
56
+ }
57
+
58
+ describe('local and D1 drivers return identical shapes', () => {
59
+ let p: ReturnType<typeof pair>;
60
+ beforeEach(() => {
61
+ p = pair();
62
+ });
63
+
64
+ // ── the four places SQLite drivers disagree ──────────────────────────────────────
65
+
66
+ test('NULL β€” an absent column is null on both, not undefined and not omitted', async () => {
67
+ p.seed(`INSERT INTO t (id, int_col, null_col) VALUES (1, 7, NULL)`);
68
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t').all());
69
+
70
+ expect(local.results).toEqual(remote.results);
71
+ expect(local.results[0]).toHaveProperty('null_col', null);
72
+ // The column must be PRESENT and null β€” a driver that drops null columns changes
73
+ // `'null_col' in row` and every `??` downstream of it.
74
+ expect(Object.keys(local.results[0] as object)).toEqual(Object.keys(remote.results[0] as object));
75
+ });
76
+
77
+ test('BLOB β€” Uint8Array on both, where D1 natively hands back a number[]', async () => {
78
+ p.seed(`INSERT INTO t (id, blob_col) VALUES (1, X'00FF107F')`);
79
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT blob_col FROM t').all());
80
+
81
+ const lv = (local.results[0] as { blob_col: unknown }).blob_col;
82
+ const rv = (remote.results[0] as { blob_col: unknown }).blob_col;
83
+
84
+ expect(lv).toBeInstanceOf(Uint8Array);
85
+ expect(rv).toBeInstanceOf(Uint8Array);
86
+ expect(Array.from(rv as Uint8Array)).toEqual([0, 255, 16, 127]);
87
+ expect(lv).toEqual(rv);
88
+ });
89
+
90
+ test('BLOB β€” an EMPTY blob stays an empty Uint8Array, not null', async () => {
91
+ // The case a `value ?? null` normalizer gets wrong: an empty array is falsy-adjacent
92
+ // and an empty Uint8Array is not null.
93
+ p.seed(`INSERT INTO t (id, blob_col) VALUES (1, X'')`);
94
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT blob_col FROM t').all());
95
+ const lv = (local.results[0] as { blob_col: unknown }).blob_col;
96
+ expect(lv).toBeInstanceOf(Uint8Array);
97
+ expect((lv as Uint8Array).length).toBe(0);
98
+ expect(lv).toEqual((remote.results[0] as { blob_col: unknown }).blob_col);
99
+ });
100
+
101
+ test('integer vs float β€” REAL 1.0 and INTEGER 1 agree across drivers', async () => {
102
+ p.seed(`INSERT INTO t (id, int_col, real_col) VALUES (1, 1, 1.0)`);
103
+ p.seed(`INSERT INTO t (id, int_col, real_col) VALUES (2, 2, 2.5)`);
104
+ const { local, remote } = await both(p, (db) =>
105
+ db.prepare('SELECT int_col, real_col FROM t ORDER BY id').all(),
106
+ );
107
+
108
+ expect(local.results).toEqual(remote.results);
109
+ // SQLite stores 1.0 in a REAL column and both drivers surface it as the number 1.
110
+ // Asserted so a future driver change that started returning "1.0" is caught here.
111
+ expect(local.results[0]).toEqual({ int_col: 1, real_col: 1 });
112
+ expect(local.results[1]).toEqual({ int_col: 2, real_col: 2.5 });
113
+ });
114
+
115
+ test('boolean β€” bound as true/false, stored 1/0, read back identically on both', async () => {
116
+ const insert = (db: D1LikeDatabase, id: number, v: boolean) =>
117
+ db.prepare('INSERT INTO t (id, bool_col) VALUES (?, ?)').bind(id, v).run();
118
+
119
+ await insert(p.local, 1, true);
120
+ await insert(p.remote, 1, true);
121
+ await insert(p.local, 2, false);
122
+ await insert(p.remote, 2, false);
123
+
124
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT id, bool_col FROM t ORDER BY id').all());
125
+ expect(local.results).toEqual(remote.results);
126
+ expect(local.results).toEqual([
127
+ { id: 1, bool_col: 1 },
128
+ { id: 2, bool_col: 0 },
129
+ ]);
130
+ });
131
+
132
+ // ── the strictness that must match, or it is green here and red in production ─────
133
+
134
+ test('undefined bind is REFUSED by both, with the same error', async () => {
135
+ const attempt = async (db: D1LikeDatabase) => {
136
+ try {
137
+ await db
138
+ .prepare('INSERT INTO t (id, text_col) VALUES (?, ?)')
139
+ .bind(1, undefined as unknown as D1LikeBindable)
140
+ .run();
141
+ return 'no error';
142
+ } catch (e) {
143
+ return `${(e as Error).name}: ${(e as Error).message}`;
144
+ }
145
+ };
146
+ const local = await attempt(p.local);
147
+ const remote = await attempt(p.remote);
148
+
149
+ // πŸ”΄ `bun:sqlite` alone would have bound NULL here and said nothing, while D1 throws.
150
+ // That divergence is the defect; identical refusal is the fix.
151
+ expect(local).toBe(remote);
152
+ expect(local).toContain('D1BindError');
153
+ expect(local).toContain('pass null explicitly');
154
+ });
155
+
156
+ test('a Date bind is refused by both, naming the fix', async () => {
157
+ for (const db of [p.local, p.remote]) {
158
+ expect(() =>
159
+ db.prepare('INSERT INTO t (id, text_col) VALUES (?, ?)').bind(1, new Date() as unknown as D1LikeBindable),
160
+ ).toThrow(D1BindError);
161
+ }
162
+ });
163
+
164
+ test('an out-of-range bigint is refused rather than silently rounded', async () => {
165
+ // Measured: `bun:sqlite` returns 9007199254740992 for a stored 9007199254740993.
166
+ for (const db of [p.local, p.remote]) {
167
+ expect(() => db.prepare('INSERT INTO t (id, int_col) VALUES (1, ?)').bind(9007199254740993n)).toThrow(
168
+ D1BindError,
169
+ );
170
+ }
171
+ // …and one inside the range is accepted, as a number, on both.
172
+ const { local, remote } = await both(p, async (db) => {
173
+ await db.prepare('INSERT INTO t (id, int_col) VALUES (1, ?)').bind(42n).run();
174
+ return db.prepare('SELECT int_col FROM t').first();
175
+ });
176
+ expect(local).toEqual(remote);
177
+ expect(local).toEqual({ int_col: 42 });
178
+ });
179
+
180
+ // ── result and meta shapes ────────────────────────────────────────────────────────
181
+
182
+ test('first() on no rows is null on both, not undefined', async () => {
183
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t WHERE id = 999').first());
184
+ expect(local).toBeNull();
185
+ expect(remote).toBeNull();
186
+ });
187
+
188
+ test('all() on no rows is an empty results array on both', async () => {
189
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t WHERE id = 999').all());
190
+ expect(local.results).toEqual([]);
191
+ expect(remote.results).toEqual([]);
192
+ expect(local.success).toBe(remote.success);
193
+ });
194
+
195
+ test('first(column) returns the bare value on both', async () => {
196
+ p.seed(`INSERT INTO t (id, text_col) VALUES (1, 'hello')`);
197
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT text_col FROM t').first('text_col'));
198
+ expect(local).toBe('hello');
199
+ expect(remote).toBe('hello');
200
+ });
201
+
202
+ test('run() reports the same changes and last_row_id on both', async () => {
203
+ const { local, remote } = await both(p, (db) =>
204
+ db.prepare('INSERT INTO t (text_col) VALUES (?)').bind('x').run(),
205
+ );
206
+ expect(local.meta.changes).toBe(remote.meta.changes);
207
+ expect(local.meta.changes).toBe(1);
208
+ expect(local.meta.last_row_id).toBe(remote.meta.last_row_id);
209
+ expect(local.meta.last_row_id).toBe(1);
210
+ expect(local.results).toEqual(remote.results);
211
+ });
212
+
213
+ test('meta carries the same KEYS on both, so a caller can read it blind', async () => {
214
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t').all());
215
+ expect(Object.keys(local.meta).sort()).toEqual(Object.keys(remote.meta).sort());
216
+ });
217
+
218
+ test('a read reports last_row_id null on both, rather than a misleading 0', async () => {
219
+ p.seed(`INSERT INTO t (id, int_col) VALUES (1, 1)`);
220
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT * FROM t').all());
221
+ expect(local.meta.last_row_id).toBeNull();
222
+ expect(remote.meta.last_row_id).toBeNull();
223
+ });
224
+
225
+ test('raw() returns positional arrays that match across drivers', async () => {
226
+ p.seed(`INSERT INTO t (id, text_col, blob_col) VALUES (1, 'a', X'0102')`);
227
+ const { local, remote } = await both(p, (db) => db.prepare('SELECT id, text_col, blob_col FROM t').raw());
228
+ expect(local).toEqual(remote);
229
+ expect(local[0]?.[0]).toBe(1);
230
+ expect(local[0]?.[1]).toBe('a');
231
+ expect(local[0]?.[2]).toBeInstanceOf(Uint8Array);
232
+ });
233
+
234
+ test('a round-tripped blob bind survives identically on both', async () => {
235
+ const bytes = new Uint8Array([0, 1, 254, 255]);
236
+ const { local, remote } = await both(p, async (db) => {
237
+ await db.prepare('INSERT INTO t (id, blob_col) VALUES (1, ?)').bind(bytes).run();
238
+ return db.prepare('SELECT blob_col FROM t').first<{ blob_col: Uint8Array }>();
239
+ });
240
+ expect(local?.blob_col).toEqual(remote?.blob_col as Uint8Array);
241
+ expect(Array.from(local?.blob_col as Uint8Array)).toEqual([0, 1, 254, 255]);
242
+ });
243
+
244
+ // ── batch, which is the only transaction either side has ──────────────────────────
245
+
246
+ test('batch() runs every statement and returns one result each, on both', async () => {
247
+ const { local, remote } = await both(p, async (db) => {
248
+ const res = await db.batch([
249
+ db.prepare("INSERT INTO t (id, text_col) VALUES (1, 'a')"),
250
+ db.prepare("INSERT INTO t (id, text_col) VALUES (2, 'b')"),
251
+ ]);
252
+ const rows = await db.prepare('SELECT id, text_col FROM t ORDER BY id').all();
253
+ return { count: res.length, rows: rows.results };
254
+ });
255
+ expect(local).toEqual(remote);
256
+ expect(local.count).toBe(2);
257
+ expect(local.rows).toEqual([
258
+ { id: 1, text_col: 'a' },
259
+ { id: 2, text_col: 'b' },
260
+ ]);
261
+ });
262
+
263
+ test('batch() is atomic on both β€” a failing member rolls the whole batch back', async () => {
264
+ const attempt = async (db: D1LikeDatabase) => {
265
+ try {
266
+ await db.batch([
267
+ db.prepare("INSERT INTO t (id, text_col) VALUES (1, 'a')"),
268
+ // Duplicate primary key β€” fails, and must take the first insert with it.
269
+ db.prepare("INSERT INTO t (id, text_col) VALUES (1, 'b')"),
270
+ ]);
271
+ } catch {
272
+ /* expected */
273
+ }
274
+ return (await db.prepare('SELECT COUNT(*) AS n FROM t').first<{ n: number }>())?.n;
275
+ };
276
+ expect(await attempt(p.local)).toBe(0);
277
+ expect(await attempt(p.remote)).toBe(0);
278
+ });
279
+ });
@@ -0,0 +1,120 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ classifySchedule,
4
+ cronFieldCount,
5
+ jobsForCron,
6
+ type PortableJob,
7
+ toCronTriggers,
8
+ toFiveField,
9
+ } from './scheduling';
10
+
11
+ const job = (name: string, shape: PortableJob['shape']): PortableJob => ({
12
+ name,
13
+ shape,
14
+ run: async () => {},
15
+ });
16
+
17
+ describe('classifySchedule', () => {
18
+ test('a nightly backup is a Cron Trigger and costs nothing', () => {
19
+ const v = classifySchedule({ kind: 'periodic', cron: '0 3 * * *' });
20
+ expect(v.primitive).toBe('cron-trigger');
21
+ expect(v.cron).toBe('0 3 * * *');
22
+ expect(v.estimatedMonthlyUsd).toBe(0);
23
+ });
24
+
25
+ test('a 6-field expression with a fixed seconds column drops it losslessly', () => {
26
+ // `0 3 * * * *` and `3 * * *` fire at the same wall-clock moments.
27
+ const v = classifySchedule({ kind: 'periodic', cron: '0 3 * * * *' });
28
+ expect(v.primitive).toBe('cron-trigger');
29
+ expect(v.cron).toBe('3 * * * *');
30
+ });
31
+
32
+ test('πŸ”΄ a once-per-second job is UNPORTABLE, not silently rounded to a minute', () => {
33
+ // This repo already declares three jobs as `* * * * * *`. Rounding them up would
34
+ // make them 60Γ— less frequent in production and nowhere else.
35
+ const v = classifySchedule({ kind: 'periodic', cron: '* * * * * *' });
36
+ expect(v.primitive).toBe('unportable');
37
+ expect(v.cron).toBeNull();
38
+ expect(v.reason).toContain('one-minute floor');
39
+ });
40
+
41
+ test('a sub-minute STEP is unportable too', () => {
42
+ const v = classifySchedule({ kind: 'periodic', cron: '*/10 * * * * *' });
43
+ expect(v.primitive).toBe('unportable');
44
+ });
45
+
46
+ test('work fanned out from a request is a Queue, priced at two operations a message', () => {
47
+ // 1M operations are included; a delivered message costs one write and one read, so
48
+ // the free tier covers 500k messages a month, not 1M. Pricing it as one operation
49
+ // is the mistake that makes Queues look half price.
50
+ const free = classifySchedule({ kind: 'fanned-out-from-request', expectedPerDay: 16_000 });
51
+ expect(free.primitive).toBe('queue');
52
+ expect(free.estimatedMonthlyUsd).toBe(0); // 480k messages β†’ 960k ops, still included
53
+
54
+ const paid = classifySchedule({ kind: 'fanned-out-from-request', expectedPerDay: 100_000 });
55
+ // 3M messages β†’ 6M ops β†’ 5M billable β†’ $2.00
56
+ expect(paid.estimatedMonthlyUsd).toBeCloseTo(2.0, 4);
57
+ });
58
+
59
+ test('per-entity scheduling is a Durable Object Alarm', () => {
60
+ const v = classifySchedule({ kind: 'per-entity' });
61
+ expect(v.primitive).toBe('durable-object-alarm');
62
+ expect(v.reason).toContain('scan every row');
63
+ });
64
+ });
65
+
66
+ describe('cron helpers', () => {
67
+ test('cronFieldCount tolerates ragged whitespace', () => {
68
+ expect(cronFieldCount(' 0 3 * * * ')).toBe(5);
69
+ expect(cronFieldCount('0 0 3 * * *')).toBe(6);
70
+ });
71
+
72
+ test('toFiveField leaves a 5-field expression alone', () => {
73
+ expect(toFiveField('0 3 * * *')).toEqual({ cron: '0 3 * * *', lossless: true });
74
+ });
75
+ });
76
+
77
+ describe('toCronTriggers', () => {
78
+ test('renders the wrangler triggers block, de-duplicating shared expressions', () => {
79
+ const { crons, queues } = toCronTriggers([
80
+ job('backup', { kind: 'periodic', cron: '0 3 * * *' }),
81
+ job('metrics-flush', { kind: 'periodic', cron: '0 3 * * *' }),
82
+ job('thumbnails', { kind: 'fanned-out-from-request', expectedPerDay: 10 }),
83
+ ]);
84
+ expect(crons).toEqual(['0 3 * * *']);
85
+ expect(queues).toEqual(['thumbnails']);
86
+ });
87
+
88
+ test('πŸ”΄ THROWS on an unportable job rather than quietly omitting it', () => {
89
+ // A generated config that drops a job is a job that stops running in production
90
+ // while every local test still passes.
91
+ expect(() =>
92
+ toCronTriggers([job('poller', { kind: 'periodic', cron: '* * * * * *' })]),
93
+ ).toThrow(/cannot be expressed/);
94
+ });
95
+ });
96
+
97
+ describe('jobsForCron', () => {
98
+ test('πŸ”΄ both jobs sharing an expression are returned, not just the first', () => {
99
+ // Cloudflare dispatches by EXPRESSION, not by job name: two jobs on `0 3 * * *`
100
+ // arrive as ONE event. A handler that ran the first match would silently never run
101
+ // the second.
102
+ const jobs = [
103
+ job('backup', { kind: 'periodic', cron: '0 3 * * *' }),
104
+ job('metrics-flush', { kind: 'periodic', cron: '0 3 * * *' }),
105
+ job('hourly', { kind: 'periodic', cron: '0 * * * *' }),
106
+ ];
107
+ expect(jobsForCron(jobs, '0 3 * * *').map((j) => j.name)).toEqual(['backup', 'metrics-flush']);
108
+ expect(jobsForCron(jobs, '0 * * * *').map((j) => j.name)).toEqual(['hourly']);
109
+ });
110
+
111
+ test('a 6-field declaration is matched by its rendered 5-field expression', () => {
112
+ const jobs = [job('nightly', { kind: 'periodic', cron: '0 3 * * * *' })];
113
+ expect(jobsForCron(jobs, '3 * * * *').map((j) => j.name)).toEqual(['nightly']);
114
+ });
115
+
116
+ test('a queue job is never dispatched by a cron event', () => {
117
+ const jobs = [job('thumbs', { kind: 'fanned-out-from-request' })];
118
+ expect(jobsForCron(jobs, '0 3 * * *')).toEqual([]);
119
+ });
120
+ });
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `plainjob` β†’ the Workers answer, and the choice is measured rather than guessed.
3
+ *
4
+ * ## What `plainjob` actually is, and why a Worker cannot have it
5
+ *
6
+ * An in-process SQLite job queue that POLLS a table on a timer. A Worker has no background
7
+ * loop: it exists for the duration of a request or a scheduled event and then stops. There
8
+ * is no timer to hang a poller off, so the mechanism does not port β€” it is replaced.
9
+ *
10
+ * ## πŸ”΄ The measurement that makes this cheaper than the task file assumed
11
+ *
12
+ * Re-measured 2026-09-16 over `apps/` and `libs/`: all **12** `plainjob` files are in
13
+ * `libs/` (ten in `cursedbelt-server`, two in `cursedbelt-cc`), and **zero** are in
14
+ * `apps/`. No app declares a job through this engine yet. The original table read "12
15
+ * files using plainjob" as twelve call sites to port; they are one mechanism in one
16
+ * library, which is this seam. The port is a library change, not a fleet sweep.
17
+ *
18
+ * That also means the shape below is free to be the right one rather than the compatible
19
+ * one β€” there is no app-side caller to keep happy.
20
+ *
21
+ * ## The three primitives, and the rule for choosing
22
+ *
23
+ * | primitive | cost | when it is the answer |
24
+ * |---|---|---|
25
+ * | **Cron Triggers** | free | anything periodic at β‰₯ 1-minute cadence. Nightly backups and ingests β€” most of this fleet |
26
+ * | **Queues** | $0.40/M operations, 1M included | work fanned out FROM a request, where the response must not wait |
27
+ * | **Durable Object Alarms** | DO pricing | per-entity scheduling: one timer per row, set from the row's own lifecycle |
28
+ *
29
+ * πŸ”΄ **The hard discriminator is cadence, and it is a real cliff.** Cloudflare Cron
30
+ * Triggers take a **5-field** expression with a **one-minute floor**. `JobDef.cron` accepts
31
+ * a 6-field expression with a leading seconds column, and this repo already contains three
32
+ * jobs declared `'* * * * * *'` β€” once per second. Those cannot become Cron Triggers at
33
+ * all, and {@link classifySchedule} says so by name instead of silently rounding them up to
34
+ * a minute, which would change behaviour in production and nowhere else.
35
+ */
36
+
37
+ import type { D1LikeDatabase } from './types';
38
+
39
+ /** The Workers primitive a piece of scheduled work maps onto. */
40
+ export type WorkersPrimitive = 'cron-trigger' | 'queue' | 'durable-object-alarm' | 'unportable';
41
+
42
+ /** How a job is triggered β€” the input to the choice, not the choice itself. */
43
+ export type TriggerShape =
44
+ | { kind: 'periodic'; cron: string }
45
+ | { kind: 'fanned-out-from-request'; expectedPerDay?: number }
46
+ | { kind: 'per-entity' };
47
+
48
+ export interface ScheduleVerdict {
49
+ primitive: WorkersPrimitive;
50
+ /** Why β€” quoted into the port task, so the next reader sees the reasoning not the answer. */
51
+ reason: string;
52
+ /** The 5-field expression to put in `wrangler.toml`, when there is one. */
53
+ cron: string | null;
54
+ /** Monthly cost at the stated volume, in US dollars. */
55
+ estimatedMonthlyUsd: number;
56
+ }
57
+
58
+ /** Cloudflare Queues: $0.40 per million operations, first million free each month. */
59
+ const QUEUE_USD_PER_MILLION = 0.4;
60
+ const QUEUE_FREE_OPERATIONS = 1_000_000;
61
+
62
+ /**
63
+ * A cron expression with six fields carries a leading SECONDS column. Cloudflare has no
64
+ * such column and no cadence below one minute.
65
+ */
66
+ export function cronFieldCount(cron: string): number {
67
+ return cron.trim().split(/\s+/).filter(Boolean).length;
68
+ }
69
+
70
+ /**
71
+ * Drop the seconds column from a 6-field expression, reporting whether doing so changes
72
+ * when the job fires. `'0 3 * * * *'` is 3 a.m. daily either way; `'* * * * * *'` is not.
73
+ */
74
+ export function toFiveField(cron: string): { cron: string; lossless: boolean } {
75
+ const parts = cron.trim().split(/\s+/).filter(Boolean);
76
+ if (parts.length <= 5) return { cron: parts.join(' '), lossless: true };
77
+ const [seconds, ...rest] = parts;
78
+ // Only a fixed single second (`0`, `30`, …) survives the drop: it means "once, at that
79
+ // second of the matching minute", which a minute-granularity trigger reproduces. A
80
+ // wildcard or a step means sub-minute cadence, which it cannot.
81
+ const lossless = /^\d+$/.test(seconds ?? '');
82
+ return { cron: rest.join(' '), lossless };
83
+ }
84
+
85
+ /**
86
+ * Choose the Workers primitive for a piece of scheduled work.
87
+ *
88
+ * Returns `'unportable'` rather than a best guess when the cadence is below Cloudflare's
89
+ * floor β€” a job that silently became 60Γ— less frequent in production would be found by a
90
+ * user, not by a check.
91
+ */
92
+ export function classifySchedule(shape: TriggerShape): ScheduleVerdict {
93
+ switch (shape.kind) {
94
+ case 'periodic': {
95
+ const fields = cronFieldCount(shape.cron);
96
+ const { cron, lossless } = toFiveField(shape.cron);
97
+ if (fields > 5 && !lossless) {
98
+ return {
99
+ primitive: 'unportable',
100
+ reason:
101
+ `'${shape.cron}' fires below Cloudflare's one-minute floor. A Cron Trigger cannot express it. ` +
102
+ 'Either relax the cadence to β‰₯ 1 minute, or keep this job on a host with a real timer ' +
103
+ '(it is a poller, and a poller is usually a Queue consumer in disguise).',
104
+ cron: null,
105
+ estimatedMonthlyUsd: 0,
106
+ };
107
+ }
108
+ return {
109
+ primitive: 'cron-trigger',
110
+ reason:
111
+ 'Periodic at β‰₯ 1-minute cadence β€” a Cron Trigger, which is free and needs no table, no poll ' +
112
+ 'and no process. This is most of the fleet: nightly backups, ingests and metric flushes.',
113
+ cron,
114
+ estimatedMonthlyUsd: 0,
115
+ };
116
+ }
117
+
118
+ case 'fanned-out-from-request': {
119
+ const perMonth = (shape.expectedPerDay ?? 0) * 30;
120
+ // Each message is counted on write and on read, so a delivered message is two
121
+ // operations. Pricing it as one is the mistake that makes Queues look half price.
122
+ const operations = perMonth * 2;
123
+ const billable = Math.max(0, operations - QUEUE_FREE_OPERATIONS);
124
+ return {
125
+ primitive: 'queue',
126
+ reason:
127
+ 'Work fanned out from a request, where the response must not wait for it. A Queue decouples the ' +
128
+ 'two and retries on failure. 1M operations are included each month; a delivered message costs two ' +
129
+ '(one write, one read).',
130
+ cron: null,
131
+ estimatedMonthlyUsd: Number(((billable / 1_000_000) * QUEUE_USD_PER_MILLION).toFixed(4)),
132
+ };
133
+ }
134
+
135
+ case 'per-entity':
136
+ return {
137
+ primitive: 'durable-object-alarm',
138
+ reason:
139
+ 'One timer per entity, set from that entity’s own lifecycle β€” a reminder on a row, a lease that ' +
140
+ 'expires. A Cron Trigger would have to scan every row every minute to find the few that are due; ' +
141
+ 'an alarm is woken only for the one that is.',
142
+ cron: null,
143
+ // A DO is only billed for the requests and duration it actually uses; an idle
144
+ // alarm costs nothing until it fires. The real number depends on entity count,
145
+ // so a fabricated figure here would be worse than none.
146
+ estimatedMonthlyUsd: 0,
147
+ };
148
+ }
149
+ }
150
+
151
+ /** A scheduled job, declared once and readable by both the local runner and `wrangler`. */
152
+ export interface PortableJob {
153
+ name: string;
154
+ shape: TriggerShape;
155
+ /** The work. Takes the async seam, so the same handler runs on either side. */
156
+ run(ctx: PortableJobContext): Promise<void>;
157
+ }
158
+
159
+ export interface PortableJobContext {
160
+ db: D1LikeDatabase;
161
+ signal: AbortSignal;
162
+ log: { info(m: string): void; warn(m: string): void; error(m: string): void };
163
+ }
164
+
165
+ /**
166
+ * Render the `[triggers]` block of a `wrangler.toml` from a job list β€” and refuse to render
167
+ * one that silently drops a job.
168
+ *
169
+ * πŸ”΄ It throws on an unportable job rather than skipping it. A generated config that
170
+ * quietly omits a job is a job that stops running in production while every local test
171
+ * still passes, which is the failure mode this whole seam exists to prevent.
172
+ */
173
+ export function toCronTriggers(jobs: readonly PortableJob[]): { crons: string[]; queues: string[] } {
174
+ const crons: string[] = [];
175
+ const queues: string[] = [];
176
+ const unportable: string[] = [];
177
+
178
+ for (const job of jobs) {
179
+ const verdict = classifySchedule(job.shape);
180
+ if (verdict.primitive === 'unportable') {
181
+ unportable.push(` ${job.name}: ${verdict.reason}`);
182
+ } else if (verdict.primitive === 'cron-trigger' && verdict.cron) {
183
+ if (!crons.includes(verdict.cron)) crons.push(verdict.cron);
184
+ } else if (verdict.primitive === 'queue') {
185
+ queues.push(job.name);
186
+ }
187
+ }
188
+
189
+ if (unportable.length > 0) {
190
+ throw new Error(
191
+ `${unportable.length} job(s) cannot be expressed as a Cloudflare trigger:\n${unportable.join('\n')}\n` +
192
+ 'Fix the cadence or keep the job on a host β€” do not ship a config that omits it.',
193
+ );
194
+ }
195
+ return { crons, queues };
196
+ }
197
+
198
+ /**
199
+ * πŸ”΄ Cloudflare dispatches a Cron Trigger by EXPRESSION, not by job name β€” the
200
+ * `scheduled` handler is told which cron fired and nothing else. Two jobs sharing
201
+ * `0 3 * * *` arrive as one event, so the Worker has to fan back out to every job matching
202
+ * that expression. Forgetting this runs the first job and silently never runs the second.
203
+ */
204
+ export function jobsForCron(jobs: readonly PortableJob[], firedCron: string): PortableJob[] {
205
+ return jobs.filter((job) => {
206
+ if (job.shape.kind !== 'periodic') return false;
207
+ const verdict = classifySchedule(job.shape);
208
+ return verdict.primitive === 'cron-trigger' && verdict.cron === firedCron.trim();
209
+ });
210
+ }