cursedbelt-server 1.1.0 → 2.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.
Files changed (81) 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/sync/http.d.ts +20 -3
  36. package/dist/server/sync/http.js +20 -14
  37. package/dist/server/sync/index.d.ts +10 -2
  38. package/dist/server/sync/index.js +9 -1
  39. package/dist/server/sync/planner.d.ts +38 -8
  40. package/dist/server/sync/planner.js +32 -8
  41. package/dist/server/sync/signal.d.ts +161 -0
  42. package/dist/server/sync/signal.js +348 -0
  43. package/dist/server/sync/timer.d.ts +63 -19
  44. package/dist/server/sync/timer.js +104 -45
  45. package/dist/server/sync/types.d.ts +0 -2
  46. package/package.json +21 -3
  47. package/src/leafSubpathsImportNothing.spec.ts +15 -3
  48. package/src/noTimerDialsAPeer.spec.ts +469 -0
  49. package/src/server/bench/assert.ts +192 -0
  50. package/src/server/bench/budget.spec.ts +126 -0
  51. package/src/server/bench/budget.ts +207 -0
  52. package/src/server/bench/cpuBudget.spec.ts +302 -0
  53. package/src/server/bench/cpuBudget.ts +81 -0
  54. package/src/server/bench/cpuClock.ts +119 -0
  55. package/src/server/bench/index.ts +81 -0
  56. package/src/server/bench/recorder.ts +163 -0
  57. package/src/server/bench/runBench.ts +110 -0
  58. package/src/server/d1/backup.spec.ts +121 -0
  59. package/src/server/d1/backup.ts +186 -0
  60. package/src/server/d1/fakeD1.ts +193 -0
  61. package/src/server/d1/index.ts +62 -0
  62. package/src/server/d1/kysely.spec.ts +145 -0
  63. package/src/server/d1/kysely.ts +169 -0
  64. package/src/server/d1/limits.spec.ts +90 -0
  65. package/src/server/d1/limits.ts +123 -0
  66. package/src/server/d1/local.ts +173 -0
  67. package/src/server/d1/remote.ts +182 -0
  68. package/src/server/d1/sameShape.spec.ts +279 -0
  69. package/src/server/d1/scheduling.spec.ts +120 -0
  70. package/src/server/d1/scheduling.ts +210 -0
  71. package/src/server/d1/types.ts +163 -0
  72. package/src/server/d1/values.ts +138 -0
  73. package/src/server/sync/http.ts +31 -16
  74. package/src/server/sync/index.ts +23 -1
  75. package/src/server/sync/planner.spec.ts +33 -16
  76. package/src/server/sync/planner.ts +48 -11
  77. package/src/server/sync/signal.spec.ts +306 -0
  78. package/src/server/sync/signal.ts +422 -0
  79. package/src/server/sync/timer.spec.ts +97 -16
  80. package/src/server/sync/timer.ts +124 -47
  81. package/src/server/sync/types.ts +0 -2
@@ -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
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The async database seam every app crosses to reach SQLite — locally on `bun:sqlite`,
3
+ * remotely on Cloudflare D1.
4
+ *
5
+ * ## 🔴 It is async on BOTH sides, and that is the entire point
6
+ *
7
+ * `bun:sqlite` is synchronous: `db.query(sql).all()` returns rows. D1 is
8
+ * `await db.prepare(sql).all()`. No adapter, no proxy and no `Proxy` trap makes a
9
+ * synchronous call site await — so the port is a real edit at every call site, and the
10
+ * only question this seam answers is whether that edit has to happen TWICE.
11
+ *
12
+ * A seam that is sync locally and async remotely passes every test on this machine and
13
+ * fails in production. So {@link D1LikeDatabase} is the same async shape on both sides,
14
+ * the local driver is the one that changes, and a ported call site runs unmodified
15
+ * against either.
16
+ *
17
+ * ## 🔴 The local driver is as STRICT as the remote one, never as lenient as Bun
18
+ *
19
+ * The same argument applies one level down, and it is where the real bugs live. Measured
20
+ * against `bun:sqlite` on 2026-09-16, four things differ between the two drivers, and two
21
+ * of them are silent-here / throw-there:
22
+ *
23
+ * | value | `bun:sqlite` | D1 (`workerd`) |
24
+ * |----------------------|-------------------------------|-------------------------|
25
+ * | `undefined` bind | 🔴 silently binds NULL | throws |
26
+ * | `boolean` bind | 🔴 silently coerces to 0/1 | throws |
27
+ * | BLOB column read | `Uint8Array` | `number[]` |
28
+ * | `.run()` result | `{changes, lastInsertRowid}` | `{success, meta, …}` |
29
+ *
30
+ * Lenient-locally is the same defect as sync-locally wearing a different costume: it is
31
+ * green on this Mac and red in the Worker. `normalizeBind` in `./values` therefore makes
32
+ * the LOCAL driver refuse what D1 refuses, and normalizes what both accept to one shape.
33
+ * The one deliberate exception is `boolean`, which is coerced to 0/1 on both sides rather
34
+ * than refused on both: SQLite has no boolean type, so 0/1 is not a guess about intent,
35
+ * and refusing it would break every natural `where('disabled', '=', false)` in the fleet.
36
+ * `undefined` is refused, because "missing argument" and "intentional NULL" are genuinely
37
+ * different and only the caller knows which was meant.
38
+ *
39
+ * ## Transactions are refused on both sides, and `batch()` is the answer
40
+ *
41
+ * D1 has no interactive transaction — there is no `BEGIN`/`COMMIT` over HTTP, only
42
+ * {@link D1LikeDatabase.batch}, which is atomic and runs as one round trip. A local
43
+ * driver that happily honoured `BEGIN` would be the sync-vs-async trap a third time, so
44
+ * {@link D1UnsupportedError} is thrown by both. Wrap the statements in `batch()` instead;
45
+ * it is atomic on D1 and wrapped in a real `bun:sqlite` transaction locally, so the
46
+ * guarantee is the same.
47
+ *
48
+ * The shape below deliberately mirrors Cloudflare's own `D1Database` rather than
49
+ * inventing a vocabulary: the remote driver is then close to a pass-through, and the
50
+ * thing an app author reads in Cloudflare's docs is the thing they have.
51
+ */
52
+
53
+ /** A row as it comes back from either driver — column name to normalized value. */
54
+ export type D1LikeRow = Record<string, D1LikeValue>;
55
+
56
+ /**
57
+ * Every value either driver can RETURN, after normalization. Note there is no `boolean`
58
+ * and no `bigint`: SQLite stores neither, and a driver that invented one on read would
59
+ * disagree with the other driver on the way back in.
60
+ */
61
+ export type D1LikeValue = string | number | null | Uint8Array;
62
+
63
+ /**
64
+ * Every value either driver accepts as a BOUND parameter. `boolean` is accepted and
65
+ * coerced to 0/1; `bigint` is accepted within the safe-integer range and refused outside
66
+ * it (where it could not survive the round trip anyway). `undefined` is absent on
67
+ * purpose — see the header.
68
+ */
69
+ export type D1LikeBindable = string | number | boolean | bigint | null | Uint8Array | ArrayBuffer;
70
+
71
+ /**
72
+ * What a statement reports about its own execution. Mirrors D1's `meta`, with the fields
73
+ * the local driver can honestly fill. A field the local driver cannot know is `null`
74
+ * rather than a plausible-looking zero — a fabricated `rows_read` of 0 would read as a
75
+ * measurement in `192`'s CPU accounting rather than as an absence.
76
+ */
77
+ export interface D1LikeMeta {
78
+ /** Rows changed by the statement. `0` for a read. */
79
+ changes: number;
80
+ /** Rowid of the last insert, or `null` when the statement inserted nothing. */
81
+ last_row_id: number | null;
82
+ /** Wall-clock milliseconds the driver spent on the statement. */
83
+ duration: number;
84
+ /** D1 only — rows the query engine read. `null` locally, where nothing counts them. */
85
+ rows_read: number | null;
86
+ /** D1 only — rows the query engine wrote. `null` locally. */
87
+ rows_written: number | null;
88
+ }
89
+
90
+ /** The result of `.all()` / `.run()` on either driver. */
91
+ export interface D1LikeResult<T = D1LikeRow> {
92
+ results: T[];
93
+ success: true;
94
+ meta: D1LikeMeta;
95
+ }
96
+
97
+ /**
98
+ * A prepared statement. `bind()` returns a NEW statement rather than mutating this one,
99
+ * which is D1's own contract and what makes a statement safe to keep and re-bind.
100
+ */
101
+ export interface D1LikeStatement {
102
+ bind(...values: D1LikeBindable[]): D1LikeStatement;
103
+ /** The first row, or `null` when the query matched nothing. */
104
+ first<T = D1LikeRow>(): Promise<T | null>;
105
+ /** One column of the first row, or `null` when the query matched nothing. */
106
+ first<V = D1LikeValue>(column: string): Promise<V | null>;
107
+ all<T = D1LikeRow>(): Promise<D1LikeResult<T>>;
108
+ run(): Promise<D1LikeResult<never>>;
109
+ /** Rows as positional arrays rather than objects — the cheap shape for bulk reads. */
110
+ raw<V = D1LikeValue>(): Promise<V[][]>;
111
+ }
112
+
113
+ /**
114
+ * The database handle an app holds. One of these is constructed per request on a Worker
115
+ * and once per process locally; nothing below this line knows which it got.
116
+ */
117
+ export interface D1LikeDatabase {
118
+ prepare(sql: string): D1LikeStatement;
119
+ /**
120
+ * Run several statements atomically as ONE round trip. This is the transaction
121
+ * primitive — see the header for why there is no `begin()`.
122
+ */
123
+ batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]>;
124
+ /**
125
+ * Run one or more statements for their side effects, with no bound parameters —
126
+ * schema migrations, `PRAGMA`s, DDL. Not for anything carrying user input.
127
+ */
128
+ exec(sql: string): Promise<{ count: number; duration: number }>;
129
+ /** Which side of the seam this handle is. Lets a caller branch where it genuinely must. */
130
+ readonly flavor: 'local' | 'd1';
131
+ }
132
+
133
+ /** Thrown when a bound parameter is of a type D1 will not accept. */
134
+ export class D1BindError extends TypeError {
135
+ readonly index: number;
136
+ constructor(index: number, message: string) {
137
+ super(`parameter ${index + 1}: ${message}`);
138
+ this.name = 'D1BindError';
139
+ this.index = index;
140
+ }
141
+ }
142
+
143
+ /** Thrown for an operation D1 cannot perform, so the local driver refuses it too. */
144
+ export class D1UnsupportedError extends Error {
145
+ constructor(what: string, instead: string) {
146
+ super(`${what} is not supported on D1 — ${instead}`);
147
+ this.name = 'D1UnsupportedError';
148
+ }
149
+ }
150
+
151
+ /** Thrown when a statement would exceed one of D1's hard limits. See `./limits`. */
152
+ export class D1LimitError extends RangeError {
153
+ readonly limit: string;
154
+ readonly actual: number;
155
+ readonly max: number;
156
+ constructor(limit: string, actual: number, max: number, remedy: string) {
157
+ super(`D1 limit '${limit}' exceeded: ${actual} > ${max}. ${remedy}`);
158
+ this.name = 'D1LimitError';
159
+ this.limit = limit;
160
+ this.actual = actual;
161
+ this.max = max;
162
+ }
163
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The one place that decides what a bound parameter and a returned column MEAN, shared by
3
+ * both drivers.
4
+ *
5
+ * 🔴 **Both drivers call these, and that is why the identical-shapes test can pass.** If
6
+ * the local driver normalized its own way and the remote driver normalized its own way,
7
+ * the test in `sameShape.spec.ts` would be asserting that two independent implementations
8
+ * happen to agree today — which is a coincidence with a maintenance schedule. Here there
9
+ * is one implementation of the meaning and two implementations of the transport.
10
+ *
11
+ * Every rule below was measured against `bun:sqlite` on 2026-09-16 (`Bun 1.3`), not
12
+ * recalled: the probe wrote each type into a `CREATE TABLE t (v)` column and read it back.
13
+ */
14
+
15
+ import { D1BindError, type D1LikeBindable, type D1LikeMeta, type D1LikeValue } from './types';
16
+
17
+ /**
18
+ * The largest integer a JS number holds exactly. A `bigint` beyond this cannot survive the
19
+ * round trip through either driver — measured, `bun:sqlite` returns `9007199254740992` for
20
+ * a stored `9007199254740993` — so it is refused rather than silently rounded.
21
+ */
22
+ const SAFE = BigInt(Number.MAX_SAFE_INTEGER);
23
+
24
+ /**
25
+ * Normalize one bound parameter to something BOTH drivers accept, or throw.
26
+ *
27
+ * The local driver is deliberately made as strict as D1 here. `bun:sqlite` accepts
28
+ * `undefined` (binding NULL) and `boolean` (coercing to 0/1) where D1 throws; a local
29
+ * driver that inherited that leniency would go green on this Mac and red in the Worker,
30
+ * which is the same defect as a sync seam.
31
+ */
32
+ export function normalizeBind(value: unknown, index: number): D1LikeBindable {
33
+ if (value === null) return null;
34
+
35
+ switch (typeof value) {
36
+ case 'string':
37
+ case 'number':
38
+ // 🔴 NaN and ±Infinity have no SQLite representation. `bun:sqlite` stores NaN as
39
+ // NULL; D1 rejects it. Refusing both is the only answer that reads the same.
40
+ if (typeof value === 'number' && !Number.isFinite(value)) {
41
+ throw new D1BindError(index, `${value} has no SQLite representation — store null, or a string`);
42
+ }
43
+ return value;
44
+
45
+ // SQLite has no boolean type: 0/1 is the storage, not a guess about intent. Coerced
46
+ // on BOTH sides so the two agree, rather than refused on both, which would break
47
+ // every natural `where('disabled', '=', false)` in the fleet.
48
+ case 'boolean':
49
+ return value ? 1 : 0;
50
+
51
+ case 'bigint':
52
+ if (value > SAFE || value < -SAFE) {
53
+ throw new D1BindError(
54
+ index,
55
+ `bigint ${value} is outside the safe-integer range and cannot round-trip — store it as TEXT`,
56
+ );
57
+ }
58
+ return Number(value);
59
+
60
+ case 'undefined':
61
+ // 🔴 The measured trap. `bun:sqlite` binds NULL and says nothing; D1 throws.
62
+ // "Missing argument" and "intentional NULL" are different, and only the caller
63
+ // knows which was meant — so the caller says so.
64
+ throw new D1BindError(index, 'undefined — pass null explicitly if you mean SQL NULL');
65
+
66
+ case 'object':
67
+ if (value instanceof Uint8Array) return value;
68
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
69
+ if (value instanceof Date) {
70
+ // Common enough to deserve its own sentence rather than "not supported".
71
+ throw new D1BindError(index, 'Date — store an ISO string (`d.toISOString()`) or an epoch number');
72
+ }
73
+ throw new D1BindError(index, `${value.constructor?.name ?? 'object'} — bind a string, number, null or Uint8Array`);
74
+
75
+ default:
76
+ throw new D1BindError(index, `${typeof value} is not bindable`);
77
+ }
78
+ }
79
+
80
+ /** Normalize a whole parameter list, reporting the offending position on failure. */
81
+ export const normalizeBinds = (values: readonly unknown[]): D1LikeBindable[] =>
82
+ values.map((v, i) => normalizeBind(v, i));
83
+
84
+ /**
85
+ * Normalize one column value coming BACK from a driver.
86
+ *
87
+ * The measured disagreement is BLOBs: `bun:sqlite` returns a `Uint8Array`, D1 returns a
88
+ * plain `number[]` (it crosses the wire as JSON). `Uint8Array` wins on both — it is the
89
+ * type every consumer of a blob actually wants, and `Array.isArray` is a reliable way to
90
+ * spot D1's shape because no other column type returns an array.
91
+ */
92
+ export function normalizeValue(value: unknown): D1LikeValue {
93
+ if (value === null || value === undefined) return null;
94
+ if (value instanceof Uint8Array) return value;
95
+ if (value instanceof ArrayBuffer) return new Uint8Array(value);
96
+ if (Array.isArray(value)) return Uint8Array.from(value as number[]);
97
+ if (typeof value === 'bigint') return Number(value);
98
+ if (typeof value === 'boolean') return value ? 1 : 0;
99
+ if (typeof value === 'string' || typeof value === 'number') return value;
100
+ // Nothing else can come out of either driver; if it does, that is a driver change we
101
+ // want to hear about loudly rather than pass through as an opaque object.
102
+ throw new TypeError(`unexpected column value of type ${typeof value} from the database driver`);
103
+ }
104
+
105
+ /** Normalize a row object, preserving column order. */
106
+ export function normalizeRow<T>(row: Record<string, unknown>): T {
107
+ const out: Record<string, D1LikeValue> = {};
108
+ for (const key of Object.keys(row)) out[key] = normalizeValue(row[key]);
109
+ return out as T;
110
+ }
111
+
112
+ /** Normalize a whole result set. */
113
+ export const normalizeRows = <T>(rows: readonly Record<string, unknown>[]): T[] =>
114
+ rows.map((r) => normalizeRow<T>(r));
115
+
116
+ /**
117
+ * Build a {@link D1LikeMeta}. `rows_read`/`rows_written` default to `null` — the local
118
+ * driver genuinely cannot count them, and a plausible-looking `0` would be read as a
119
+ * measurement by `192`'s CPU accounting rather than as an absence.
120
+ */
121
+ export function makeMeta(partial: {
122
+ changes?: number;
123
+ last_row_id?: number | bigint | null;
124
+ duration: number;
125
+ rows_read?: number | null;
126
+ rows_written?: number | null;
127
+ }): D1LikeMeta {
128
+ const raw = partial.last_row_id ?? null;
129
+ return {
130
+ changes: partial.changes ?? 0,
131
+ // A rowid of 0 means "nothing was inserted" in both drivers; report it as absent
132
+ // so a caller can `?? ` it rather than having to know that 0 is a sentinel.
133
+ last_row_id: raw === null || Number(raw) === 0 ? null : Number(raw),
134
+ duration: partial.duration,
135
+ rows_read: partial.rows_read ?? null,
136
+ rows_written: partial.rows_written ?? null,
137
+ };
138
+ }
@@ -12,6 +12,7 @@ import { Hono } from "hono";
12
12
  import type { OpLog } from "./opLog";
13
13
  import type { ApplyContext, ApplyOne, ApplyReport, PeerInfo, RemoteApi, SyncOp } from "./types";
14
14
  import { createSyncApplier } from "./engine";
15
+ import { type SyncSignalHub, signalResponse } from "./signal";
15
16
 
16
17
  const noStore = { "cache-control": "no-store" } as const;
17
18
  const SYNC_PAGE = 500;
@@ -65,8 +66,6 @@ export interface ReceiverHooks {
65
66
  /** Every authenticated hit — the only evidence a non-dialing half has that it is
66
67
  * in step (vault's `lastPeerContactAt` lesson). */
67
68
  onPeerContact?: (peerId: string) => void;
68
- /** The "Sync now was pressed HERE" stamp the dialer polls. */
69
- readRequest?: () => number | null;
70
69
  }
71
70
 
72
71
  export interface ReceiverOptions {
@@ -98,9 +97,27 @@ export interface ReceiverOptions {
98
97
  */
99
98
  afterBatch?: (report: ApplyReport) => void | Promise<void>;
100
99
  hooks?: ReceiverHooks;
100
+ /**
101
+ * Mount `GET /signal` — the long-lived stream that replaced the dialer's
102
+ * 20-second `GET /requested` probe. Omitted ⇒ the route does not exist, the
103
+ * orch-companion idiom: nothing to probe, nothing to authenticate against.
104
+ *
105
+ * Call {@link SyncSignalHub.announce} after a local write on THIS half and the
106
+ * dialing half reconciles within the round trip. See `./signal.ts`.
107
+ */
108
+ signal?: SyncSignalHub;
101
109
  }
102
110
 
103
- /** Build the receiver: GET /info, GET /pull, POST /push, GET /requested. */
111
+ /**
112
+ * Build the receiver: GET /info, GET /pull, POST /push.
113
+ *
114
+ * 🔴 There was a fourth route, `GET /requested`, and it is gone (2026-09-15). It
115
+ * served a single integer — "was Sync now pressed here?" — and existed only to be
116
+ * asked, every 20 seconds, by a dialer that otherwise had nothing to say. That made
117
+ * it **68 % of `vault`'s entire traffic**. A receiver with news now says so over
118
+ * {@link import("./signal")} instead of waiting to be asked; deleting the route is
119
+ * what stops a stale consumer from keeping the poll alive against a new build.
120
+ */
104
121
  export function createSyncReceiver(options: ReceiverOptions): Hono {
105
122
  const { log, verifyToken } = options;
106
123
  if ((options.applyOne === undefined) === (options.beginBatch === undefined)) {
@@ -135,9 +152,17 @@ export function createSyncReceiver(options: ReceiverOptions): Hono {
135
152
  return c.json(body, 200, noStore);
136
153
  });
137
154
 
138
- api.get("/requested", (c) =>
139
- c.json({ requestedAt: options.hooks?.readRequest?.() ?? null }, 200, noStore),
140
- );
155
+ const signal = options.signal;
156
+ if (signal !== undefined) {
157
+ api.get("/signal", (c) =>
158
+ signalResponse(signal, {
159
+ // The catch-up frame: a client reconnecting after a sleep learns the
160
+ // current head without a round trip of its own.
161
+ initial: { head: log.head(), instanceId: log.instanceId() },
162
+ signal: c.req.raw.signal,
163
+ }),
164
+ );
165
+ }
141
166
 
142
167
  api.get("/pull", (c) => {
143
168
  const afterRaw = c.req.query("after");
@@ -302,15 +327,5 @@ export function createHttpRemote(options: {
302
327
  if (!r.ok && r.status !== 409) throw new Error(`sync/push: ${r.status}`);
303
328
  return await readJson<ApplyReport>(r, "sync/push");
304
329
  },
305
- async requestedAt() {
306
- try {
307
- const r = await request("/requested");
308
- if (!r.ok) return null;
309
- const body = await readJson<{ requestedAt?: unknown }>(r, "sync/requested");
310
- return typeof body.requestedAt === "number" ? body.requestedAt : null;
311
- } catch {
312
- return null;
313
- }
314
- },
315
330
  };
316
331
  }
@@ -63,7 +63,22 @@ export {
63
63
  planNextSync,
64
64
  type SyncLoopConfig,
65
65
  type SyncLoopState,
66
+ type SyncPlan,
66
67
  } from "./planner";
68
+ export {
69
+ SIGNAL_SSE_HEADERS,
70
+ connectSyncSignal,
71
+ createSyncSignalHub,
72
+ encodeNewsFrame,
73
+ parseNewsFrame,
74
+ signalResponse,
75
+ signalStream,
76
+ type SignalClient,
77
+ type SignalClientOptions,
78
+ type SignalStreamOptions,
79
+ type SyncNews,
80
+ type SyncSignalHub,
81
+ } from "./signal";
67
82
  export {
68
83
  OFFLINE_STATUS,
69
84
  PEER_CONTACT_WRITE_MS,
@@ -72,7 +87,14 @@ export {
72
87
  type SyncStatusReporter,
73
88
  type SyncStatusStore,
74
89
  } from "./status";
75
- export { REQUEST_POLL_MS, startSyncTimer, type SyncTimerDeps, type SyncTimerHandle } from "./timer";
90
+ /**
91
+ * 🔴 `REQUEST_POLL_MS` was exported from here until 2026-09-15 and is GONE — that
92
+ * removal is why this package went to 2.0.0. It was a 20-second dial at a configured
93
+ * peer, and being *public API* is what made it dangerous: one more consumer and
94
+ * deleting it would have been a breaking change rather than an edit. `./timer`'s
95
+ * header has the ruling and the measurement; `./signal` is what replaced it.
96
+ */
97
+ export { startSyncTimer, type SyncTimerDeps, type SyncTimerHandle, type WakeReason } from "./timer";
76
98
  export { createSyncAlarm, type AlarmOutcome, type SyncAlarm, type SyncAlarmOptions } from "./alarm";
77
99
  export {
78
100
  DEFAULT_HOLD_MS,