@sproutboat/wire 0.12.2 → 0.13.1

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,38 @@
1
+ /** Build at package/image preparation time, never on a tenant request. */
2
+ import { existsSync, mkdirSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ export const budgetExtension = resolve(
5
+ import.meta.dir,
6
+ `../native/d1-budget-${process.platform}-${process.arch}.${process.platform === "darwin" ? "dylib" : "so"}`,
7
+ );
8
+ if (import.meta.main) {
9
+ if (process.platform !== "darwin" && process.platform !== "linux")
10
+ throw new Error("SQL budget supports Linux and macOS");
11
+ mkdirSync(dirname(budgetExtension), { recursive: true });
12
+ const include =
13
+ process.env.SQLITE_INCLUDE ||
14
+ (process.platform === "darwin"
15
+ ? ["/opt/homebrew/opt/sqlite/include", "/usr/local/opt/sqlite/include"].find((p) =>
16
+ existsSync(`${p}/sqlite3ext.h`),
17
+ )
18
+ : undefined);
19
+ const child = Bun.spawnSync(
20
+ [
21
+ process.env.CC || "cc",
22
+ "-O2",
23
+ "-Wall",
24
+ "-Wextra",
25
+ "-Werror",
26
+ "-fPIC",
27
+ ...(process.platform === "darwin" ? ["-dynamiclib"] : ["-shared"]),
28
+ ...(include ? ["-I", include] : []),
29
+ resolve(import.meta.dir, "d1-budget.c"),
30
+ "-o",
31
+ budgetExtension,
32
+ ],
33
+ { stdout: "inherit", stderr: "inherit" },
34
+ );
35
+ if (child.exitCode !== 0)
36
+ throw new Error("SQL budget extension build failed; install a C compiler and SQLite development headers");
37
+ console.log(budgetExtension);
38
+ }
@@ -0,0 +1,67 @@
1
+ /* SQLite's public extension ABI. No Bun internals or additional database engine. */
2
+ #include <sqlite3ext.h>
3
+ #include <stdint.h>
4
+ #include <string.h>
5
+ #include <time.h>
6
+ SQLITE_EXTENSION_INIT1
7
+
8
+ typedef struct {
9
+ char key[65];
10
+ int initialized;
11
+ int64_t deadline;
12
+ } Budget;
13
+
14
+ static int64_t now_ns(void) {
15
+ struct timespec ts;
16
+ clock_gettime(CLOCK_MONOTONIC, &ts);
17
+ return (int64_t)ts.tv_sec * 1000000000 + ts.tv_nsec;
18
+ }
19
+ static int progress(void *context) {
20
+ Budget *budget = context;
21
+ if (budget->deadline && now_ns() >= budget->deadline) {
22
+ /* One-shot interruption lets SQLite and the host finish rollback/cleanup. */
23
+ budget->deadline = 0;
24
+ return 1;
25
+ }
26
+ return 0;
27
+ }
28
+ static void arm(sqlite3_context *context, int argc, sqlite3_value **argv) {
29
+ Budget *budget = sqlite3_user_data(context);
30
+ const unsigned char *key = argc == 2 ? sqlite3_value_text(argv[0]) : 0;
31
+ if (!key || sqlite3_value_bytes(argv[0]) != 64) {
32
+ sqlite3_result_error(context, "invalid budget capability", -1);
33
+ return;
34
+ }
35
+ if (!budget->initialized) {
36
+ memcpy(budget->key, key, 64);
37
+ budget->initialized = 1;
38
+ }
39
+ unsigned char mismatch = 0;
40
+ for (int i = 0; i < 64; i++) mismatch |= (unsigned char)budget->key[i] ^ key[i];
41
+ if (mismatch) {
42
+ sqlite3_result_error(context, "invalid budget capability", -1);
43
+ return;
44
+ }
45
+ int ms = sqlite3_value_int(argv[1]);
46
+ if (ms < 0 || ms > 30000) {
47
+ sqlite3_result_error(context, "invalid SQL execution budget", -1);
48
+ return;
49
+ }
50
+ budget->deadline = ms ? now_ns() + (int64_t)ms * 1000000 : 0;
51
+ sqlite3_result_int(context, 1);
52
+ }
53
+ #ifdef _WIN32
54
+ __declspec(dllexport)
55
+ #endif
56
+ int sqlite3_sproutbudget_init(sqlite3 *db, char **error, const sqlite3_api_routines *api) {
57
+ (void)error;
58
+ SQLITE_EXTENSION_INIT2(api);
59
+ Budget *budget = sqlite3_malloc(sizeof(Budget));
60
+ if (!budget) return SQLITE_NOMEM;
61
+ memset(budget, 0, sizeof(Budget));
62
+ int rc = sqlite3_create_function_v2(db, "sb_sql_budget", 2,
63
+ SQLITE_UTF8 | SQLITE_DIRECTONLY, budget, arm, 0, 0, sqlite3_free);
64
+ if (rc != SQLITE_OK) return rc;
65
+ sqlite3_progress_handler(db, 1000, progress, budget);
66
+ return SQLITE_OK;
67
+ }
@@ -0,0 +1,209 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createBroker, type Frame } from "./broker";
3
+ import { D1Pool } from "./d1-pool";
4
+
5
+ const slowSql =
6
+ "WITH RECURSIVE t(x) AS (VALUES(0) UNION ALL SELECT x+1 FROM t WHERE x<8000000) SELECT sum(x) AS total FROM t";
7
+ test("shared SQL leaves sibling KV and control timers responsive, preserves D1 transactions and session isolation", async () => {
8
+ const pool = new D1Pool(128, 16, 10_000, 10_000);
9
+ const a = createBroker({ d1Pool: pool, bindings: { d1: ["DB"] } });
10
+ const b = createBroker({ d1Pool: pool, bindings: { d1: ["DB"], kv: ["DATA"] } });
11
+ try {
12
+ expect(
13
+ (await a.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t(v INTEGER); INSERT INTO t VALUES(7)" })).ok,
14
+ ).toBe(true);
15
+ expect(
16
+ await b.dispatch({ op: "d1.query", db: "DB", sql: "SELECT * FROM t" }).then(
17
+ () => "unexpected success",
18
+ (e) => String(e),
19
+ ),
20
+ ).toContain("no such table");
21
+ await b.dispatch({ op: "kv.put", ns: "DATA", key: "test", value: "B" });
22
+ const heavy = a.dispatch({ op: "d1.query", db: "DB", sql: slowSql });
23
+ const start = performance.now();
24
+ await Bun.sleep(25);
25
+ expect((await b.dispatch({ op: "kv.get", ns: "DATA", key: "test" })).value).toBe("B");
26
+ expect(performance.now() - start).toBeLessThan(150);
27
+ expect((await heavy).ok).toBe(true);
28
+ expect(
29
+ await a
30
+ .dispatch({
31
+ op: "d1.batch",
32
+ db: "DB",
33
+ statements: [{ sql: "INSERT INTO t VALUES(8)" }, { sql: "INSERT INTO absent VALUES(9)" }],
34
+ })
35
+ .then(
36
+ () => "unexpected success",
37
+ (e) => String(e),
38
+ ),
39
+ ).toContain("no such table");
40
+ expect((await a.dispatch({ op: "d1.query", db: "DB", sql: "SELECT * FROM t" })).results).toEqual([{ v: 7 }]);
41
+ a.close();
42
+ expect((await b.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t(v); INSERT INTO t VALUES(11)" })).ok).toBe(
43
+ true,
44
+ );
45
+ expect((await b.dispatch({ op: "d1.query", db: "DB", sql: "SELECT * FROM t" })).results).toEqual([{ v: 11 }]);
46
+ } finally {
47
+ b.close();
48
+ pool.close();
49
+ }
50
+ }, 15_000);
51
+
52
+ test("SQL admission is bounded and queued deadlines never execute expired mutations", async () => {
53
+ const pool = new D1Pool(2, 2, 30, 10_000);
54
+ const session = pool.session();
55
+ const request = (sql: string) =>
56
+ session.execute({ key: "one", path: ":memory:", name: "DB", msg: { op: "d1.query", sql } });
57
+ try {
58
+ const active = request(slowSql);
59
+ const expired = request("CREATE TABLE must_not_exist(v)");
60
+ const rejected = expect(request("SELECT 1")).rejects.toThrow("capacity exceeded");
61
+ await expect(expired).rejects.toThrow("queue deadline exceeded");
62
+ await rejected;
63
+ await active;
64
+ const result = await request("SELECT count(*) AS n FROM sqlite_master WHERE name='must_not_exist'");
65
+ expect(result.results).toEqual([{ n: 0 }]);
66
+ } finally {
67
+ session.close();
68
+ pool.close();
69
+ }
70
+ }, 15_000);
71
+
72
+ test("SQL lanes rotate all waiting sessions instead of alternating only two noisy sessions", async () => {
73
+ const pool = new D1Pool(128, 16, 10_000, 10_000);
74
+ const sessions = [pool.session(), pool.session(), pool.session()];
75
+ const order: string[] = [];
76
+ const submit = (owner: number, sql: string, label: string) =>
77
+ sessions[owner]!.execute({
78
+ key: "same-lane",
79
+ path: ":memory:",
80
+ name: "DB",
81
+ msg: { op: "d1.query", sql },
82
+ }).then(() => {
83
+ order.push(label);
84
+ });
85
+ try {
86
+ const work = [submit(0, slowSql, "active")];
87
+ for (let owner = 0; owner < 3; owner++) {
88
+ work.push(submit(owner, "SELECT 1", `${owner}:1`), submit(owner, "SELECT 2", `${owner}:2`));
89
+ }
90
+ await Promise.all(work);
91
+ expect(order).toEqual(["active", "0:1", "1:1", "2:1", "0:2", "1:2", "2:2"]);
92
+ } finally {
93
+ for (const session of sessions) session.close();
94
+ pool.close();
95
+ }
96
+ }, 15_000);
97
+
98
+ test("unexpected SQL thread loss fails closed and notifies broker supervision", async () => {
99
+ const pool = new D1Pool(128, 16, 10_000, 10_000);
100
+ const session = pool.session();
101
+ const request = () =>
102
+ session.execute({ key: "failure", path: ":memory:", name: "DB", msg: { op: "d1.query", sql: "SELECT 1" } });
103
+ let signal!: () => void;
104
+ const failed = new Promise<void>((resolve) => {
105
+ signal = resolve;
106
+ });
107
+ pool.onFailure = signal;
108
+ try {
109
+ await request();
110
+ // SAFETY: fault injection into this test's own private worker, never a shared process.
111
+ // oxlint-disable-next-line anti-slop/no-chained-type-assertions, anti-slop/no-known-value-widening -- Deliberate private-state fault injection avoids adding a production test API.
112
+ const lanes = (pool as unknown as { lanes: Array<{ worker?: Worker }> }).lanes;
113
+ lanes.find((lane) => lane.worker)!.worker!.terminate();
114
+ await failed;
115
+ expect(
116
+ await request().then(
117
+ () => "unexpected success",
118
+ (e) => String(e),
119
+ ),
120
+ ).toContain("session closed");
121
+ } finally {
122
+ session.close();
123
+ pool.close();
124
+ }
125
+ }, 5000);
126
+
127
+ test("running SQL is interrupted, batch writes roll back, and the same lane recovers without replay", async () => {
128
+ const pool = new D1Pool(128, 16, 10_000, 50);
129
+ const session = pool.session();
130
+ const peer = pool.session();
131
+ const run = (msg: Frame) => session.execute({ key: "deadline", path: ":memory:", name: "DB", msg });
132
+ try {
133
+ await run({ op: "d1.exec", sql: "CREATE TABLE writes(v INTEGER)" });
134
+ const started = performance.now();
135
+ const interrupted = run({
136
+ op: "d1.batch",
137
+ statements: [{ sql: "INSERT INTO writes VALUES(1)" }, { sql: slowSql }, { sql: "INSERT INTO writes VALUES(2)" }],
138
+ }).then(
139
+ () => "unexpected success",
140
+ (error) => String(error),
141
+ );
142
+ const queuedPeer = peer.execute({
143
+ key: "deadline",
144
+ path: ":memory:",
145
+ name: "DB",
146
+ msg: { op: "d1.query", sql: "SELECT 42 AS n" },
147
+ });
148
+ expect(await interrupted).toContain("execution deadline exceeded");
149
+ expect((await queuedPeer).results).toEqual([{ n: 42 }]);
150
+ expect(performance.now() - started).toBeLessThan(500);
151
+ expect((await run({ op: "d1.query", sql: "SELECT * FROM writes" })).results).toEqual([]);
152
+ await run({ op: "d1.query", sql: "INSERT INTO writes VALUES(3)" });
153
+ expect((await run({ op: "d1.query", sql: "SELECT * FROM writes" })).results).toEqual([{ v: 3 }]);
154
+ expect(
155
+ await run({ op: "d1.query", sql: "SELECT sb_sql_budget(?, 30000)", params: ["0".repeat(64)] }).then(
156
+ () => "unexpected success",
157
+ (error) => String(error),
158
+ ),
159
+ ).toContain("invalid budget capability");
160
+ expect(
161
+ await run({ op: "d1.query", sql: "SELECT load_extension('untrusted')" }).then(
162
+ () => "unexpected success",
163
+ (error) => String(error),
164
+ ),
165
+ ).toContain("not authorized");
166
+ expect(
167
+ await run({ op: "d1.query", sql: slowSql }).then(
168
+ () => "unexpected success",
169
+ (error) => String(error),
170
+ ),
171
+ ).toContain("execution deadline exceeded");
172
+ } finally {
173
+ session.close();
174
+ peer.close();
175
+ pool.close();
176
+ }
177
+ }, 5000);
178
+
179
+ test("watchdog revokes a broker stuck in SQLite lock wait and does not commit or replay its write", async () => {
180
+ const { Database } = await import("bun:sqlite");
181
+ const { mkdtempSync, rmSync } = await import("node:fs");
182
+ const { tmpdir } = await import("node:os");
183
+ const { join } = await import("node:path");
184
+ const dir = mkdtempSync(join(tmpdir(), "sb-budget-lock-"));
185
+ const path = join(dir, "locked.sqlite");
186
+ const db = new Database(path);
187
+ db.exec("PRAGMA journal_mode=WAL; CREATE TABLE writes(v); BEGIN IMMEDIATE");
188
+ const script = `import {D1Pool} from ${JSON.stringify(join(import.meta.dir, "d1-pool.ts"))};
189
+ const pool=new D1Pool(128,16,10000,50);
190
+ pool.onFailure=()=>process.exit(1);
191
+ await pool.session().execute({key:${JSON.stringify(path)},path:${JSON.stringify(path)},name:"DB",msg:{op:"d1.exec",sql:"PRAGMA busy_timeout=600000; INSERT INTO writes VALUES(1)"}}).catch(()=>{});`;
192
+ const child = Bun.spawn([process.execPath, "-e", script], { stdout: "ignore", stderr: "pipe" });
193
+ const started = performance.now();
194
+ const timeout = setTimeout(() => child.kill(9), 4000);
195
+ try {
196
+ expect(await child.exited).toBe(1);
197
+ expect(performance.now() - started).toBeLessThan(2500);
198
+ db.exec("ROLLBACK");
199
+ expect(db.query("SELECT * FROM writes").all()).toEqual([]);
200
+ db.exec("INSERT INTO writes VALUES(2)");
201
+ expect(db.query("SELECT * FROM writes").all()).toEqual([{ v: 2 }]);
202
+ } finally {
203
+ clearTimeout(timeout);
204
+ child.kill(9);
205
+ await child.exited;
206
+ db.close();
207
+ rmSync(dir, { recursive: true, force: true });
208
+ }
209
+ }, 5000);
package/src/d1-pool.ts ADDED
@@ -0,0 +1,156 @@
1
+ import "./d1-sqlite";
2
+ import { randomBytes } from "node:crypto";
3
+ import type { D1Request } from "./d1";
4
+ import type { Frame } from "./broker";
5
+
6
+ type Job = {
7
+ request: D1Request;
8
+ resolve: (value: Frame) => void;
9
+ reject: (error: Error) => void;
10
+ timer: ReturnType<typeof setTimeout>;
11
+ };
12
+ type Lane = { worker?: Worker; active?: Job; queue: Job[]; owners: string[] };
13
+ /** Bounded SQL threads per broker process, created only when D1 is used.
14
+ * Database affinity preserves in-memory data and connection-local SQL state.
15
+ * Queues rotate sessions; SQLite interrupts long statements through its progress hook.
16
+ */
17
+ export class D1Pool {
18
+ /** Fatal SQL-thread loss revokes the hosting broker, never replaying uncertain SQL. */
19
+ onFailure?: (error: Error) => void;
20
+ private lanes: Lane[] = [
21
+ { queue: [], owners: [] },
22
+ { queue: [], owners: [] },
23
+ ];
24
+ private closed = false;
25
+ private sequence = 0;
26
+ private capability = "";
27
+ constructor(
28
+ private readonly queueLimit = 128,
29
+ private readonly ownerLimit = 16,
30
+ private readonly queueTimeoutMs = 10_000,
31
+ private readonly executionMs = Number(process.env.SPROUTBOAT_D1_EXECUTION_MS ?? 1000),
32
+ ) {
33
+ if (!Number.isSafeInteger(executionMs) || executionMs < 10 || executionMs > 30_000)
34
+ throw new Error("invalid D1 execution budget (10..30000 ms)");
35
+ }
36
+ session() {
37
+ const owner = String(++this.sequence);
38
+ let closed = false;
39
+ return {
40
+ execute: (request: Omit<D1Request, "owner">): Promise<Frame> => {
41
+ if (closed || this.closed) return Promise.reject(new Error("D1 session closed"));
42
+ // Stable affinity also covers resources shared by multiple sessions.
43
+ let hash = 2166136261;
44
+ for (let i = 0; i < request.key.length; i++) hash = Math.imul(hash ^ request.key.charCodeAt(i), 16777619);
45
+ const lane = this.lanes[(hash >>> 0) % this.lanes.length]!;
46
+ if (request.path === ":memory:") request = { ...request, key: `${owner}:${request.key}` };
47
+ const jobs = this.lanes.flatMap((l) => (l.active ? [...l.queue, l.active] : l.queue));
48
+ if (jobs.length >= this.queueLimit || jobs.filter((j) => j.request.owner === owner).length >= this.ownerLimit)
49
+ return Promise.reject(new Error("D1 capacity exceeded"));
50
+ return new Promise((resolve, reject) => {
51
+ const job: Job = {
52
+ request: { ...request, owner },
53
+ resolve,
54
+ reject,
55
+ timer: setTimeout(() => {
56
+ const index = lane.queue.indexOf(job);
57
+ if (index !== -1) {
58
+ lane.queue.splice(index, 1);
59
+ if (!lane.queue.some((j) => j.request.owner === owner))
60
+ lane.owners = lane.owners.filter((id) => id !== owner);
61
+ reject(new Error("D1 queue deadline exceeded"));
62
+ }
63
+ }, this.queueTimeoutMs),
64
+ };
65
+ if (!lane.owners.includes(owner)) lane.owners.push(owner);
66
+ lane.queue.push(job);
67
+ this.pump(lane);
68
+ });
69
+ },
70
+ close: () => {
71
+ if (closed) return;
72
+ closed = true;
73
+ for (const lane of this.lanes) {
74
+ for (const job of lane.queue.filter((j) => j.request.owner === owner)) {
75
+ clearTimeout(job.timer);
76
+ job.reject(new Error("D1 session closed"));
77
+ }
78
+ lane.queue = lane.queue.filter((j) => j.request.owner !== owner);
79
+ lane.owners = lane.owners.filter((id) => id !== owner);
80
+ // The active statement may already have committed. Never retry it.
81
+ lane.worker?.postMessage({ op: "release", owner });
82
+ }
83
+ },
84
+ };
85
+ }
86
+ private pump(lane: Lane) {
87
+ if (lane.active || !lane.queue.length || this.closed) return;
88
+ if (!lane.worker) {
89
+ const worker = new Worker(new URL("./d1-worker.ts", import.meta.url).href, { smol: true });
90
+ lane.worker = worker;
91
+ worker.onmessage = (event: MessageEvent<{ ok: boolean; value: Frame; error: string }>) => {
92
+ const job = lane.active;
93
+ if (!job) return;
94
+ clearTimeout(job.timer);
95
+ lane.active = undefined;
96
+ if (event.data.ok) job.resolve(event.data.value);
97
+ else job.reject(new Error(event.data.error));
98
+ this.pump(lane);
99
+ };
100
+ const failed = () => {
101
+ if (lane.worker !== worker) return;
102
+ lane.worker = undefined;
103
+ const jobs = lane.active ? [lane.active, ...lane.queue] : lane.queue;
104
+ lane.active = undefined;
105
+ lane.queue = [];
106
+ lane.owners = [];
107
+ for (const job of jobs) {
108
+ clearTimeout(job.timer);
109
+ job.reject(new Error("D1 worker lost; SQL outcome unknown"));
110
+ }
111
+ // Fail closed: lost in-memory databases must never silently become empty.
112
+ worker.terminate();
113
+ this.close();
114
+ this.onFailure?.(new Error("D1 worker lost; SQL outcome unknown"));
115
+ };
116
+ worker.addEventListener("error", failed);
117
+ worker.addEventListener("close", failed);
118
+ }
119
+ const owner = lane.owners.shift()!;
120
+ const index = lane.queue.findIndex((j) => j.request.owner === owner);
121
+ const job = lane.queue.splice(index, 1)[0]!;
122
+ if (lane.queue.some((j) => j.request.owner === owner)) lane.owners.push(owner);
123
+ clearTimeout(job.timer);
124
+ lane.active = job;
125
+ // Native progress interrupts VM work. This watchdog also bounds operations
126
+ // that do not poll progress (locks, native allocation, or stalled I/O).
127
+ // Revoke the broker instead of claiming an uncertain mutation rolled back.
128
+ job.timer = setTimeout(() => {
129
+ if (lane.active !== job) return;
130
+ this.close();
131
+ this.onFailure?.(new Error("D1 execution watchdog expired; SQL outcome unknown"));
132
+ }, this.executionMs + 1000);
133
+ this.capability ||= randomBytes(32).toString("hex");
134
+ lane.worker.postMessage({
135
+ op: "run",
136
+ executionMs: this.executionMs,
137
+ capability: this.capability,
138
+ request: job.request,
139
+ });
140
+ }
141
+ close() {
142
+ this.closed = true;
143
+ for (const lane of this.lanes) {
144
+ for (const job of lane.active ? [lane.active, ...lane.queue] : lane.queue) {
145
+ clearTimeout(job.timer);
146
+ job.reject(new Error("D1 pool closed; active SQL outcome unknown"));
147
+ }
148
+ lane.active = undefined;
149
+ lane.queue = [];
150
+ lane.owners = [];
151
+ const worker = lane.worker;
152
+ lane.worker = undefined;
153
+ worker?.terminate();
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,14 @@
1
+ /** Bun shares its SQLite library across threads: select it before any DB opens. */
2
+ import { Database } from "bun:sqlite";
3
+ import { existsSync } from "node:fs";
4
+ export function configureBudgetSQLite() {
5
+ if (process.platform !== "darwin") return;
6
+ const library =
7
+ process.env.SPROUTBOAT_D1_SQLITE_LIBRARY ||
8
+ ["/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", "/usr/local/opt/sqlite/lib/libsqlite3.dylib"].find(existsSync);
9
+ if (!library)
10
+ throw new Error("Shared SQL budgets need extension-capable SQLite on macOS; set SPROUTBOAT_D1_SQLITE_LIBRARY");
11
+ Database.setCustomSQLite(library);
12
+ }
13
+ // Also used as the package test preload, before dedicated-broker tests open DBs.
14
+ configureBudgetSQLite();
@@ -0,0 +1,22 @@
1
+ /** Private messages only: paths and identities are supplied by the broker. */
2
+ import { createD1Store, type D1Request } from "./d1";
3
+ declare var self: Worker;
4
+ let store: ReturnType<typeof createD1Store> | undefined;
5
+ self.onmessage = (
6
+ event: MessageEvent<
7
+ { op: "run"; executionMs: number; capability: string; request: D1Request } | { op: "release"; owner: string }
8
+ >,
9
+ ) => {
10
+ const message = event.data;
11
+ if (message.op === "release") {
12
+ store?.release(message.owner);
13
+ return;
14
+ }
15
+ try {
16
+ store ??= createD1Store(message.executionMs, message.capability);
17
+ postMessage({ ok: true, value: store.execute(message.request) });
18
+ } catch (error) {
19
+ postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
20
+ }
21
+ };
22
+ process.on("exit", () => store?.close());
package/src/d1.ts ADDED
@@ -0,0 +1,164 @@
1
+ /** D1's synchronous storage contract, shared by dedicated and SQL-worker modes. */
2
+ import { budgetExtension } from "./build-d1-budget";
3
+ import { Database } from "bun:sqlite";
4
+ import { existsSync, mkdirSync, rmSync, statSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import { isBoolean, isString, jsonObject, type JsonObject, type JsonValue } from "./json";
7
+ import type { Frame } from "./broker";
8
+
9
+ const isNumber = (value: JsonValue): value is number => Number.isFinite(value);
10
+ type SqlParam = string | number | null;
11
+ const str = (v: JsonValue | undefined) => (isString(v) ? v : String(v ?? ""));
12
+ const params = (v: JsonValue | undefined): SqlParam[] =>
13
+ Array.isArray(v)
14
+ ? v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? Number(p) : String(p)))
15
+ : [];
16
+ export type D1Request = { owner: string; key: string; path: string; name: string; msg: Frame };
17
+
18
+ export function createD1Store(executionMs = 0, capability = "") {
19
+ if (executionMs && !/^[a-f0-9]{64}$/.test(capability)) throw new Error("invalid host budget capability");
20
+ const connections = new Map<string, { db: Database; owners: Set<string> }>();
21
+ const run = (db: Database, sql: string, values: SqlParam[]) => {
22
+ const started = performance.now();
23
+ const results = db.query<JsonObject, SqlParam[]>(sql).all(...values);
24
+ const meta = db
25
+ .query<{ changes: number; last_row_id: number }, []>(
26
+ "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
27
+ )
28
+ .get();
29
+ return {
30
+ results,
31
+ meta: {
32
+ duration: performance.now() - started,
33
+ changes: meta?.changes ?? 0,
34
+ last_row_id: meta?.last_row_id ?? 0,
35
+ rows_read: results.length,
36
+ },
37
+ };
38
+ };
39
+ return {
40
+ execute({ owner, key, path, name, msg }: D1Request): Frame {
41
+ let entry = connections.get(key);
42
+ if (!entry) {
43
+ if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
44
+ const db = new Database(path, { create: true });
45
+ db.exec("PRAGMA journal_mode = WAL");
46
+ db.exec("PRAGMA synchronous = NORMAL");
47
+ if (executionMs) {
48
+ try {
49
+ db.loadExtension(
50
+ process.env.SPROUTBOAT_D1_BUDGET_EXTENSION || budgetExtension,
51
+ "sqlite3_sproutbudget_init",
52
+ );
53
+ db.query("SELECT sb_sql_budget(?, ?)").get(capability, 0);
54
+ } catch (error) {
55
+ db.close();
56
+ throw new Error(`SQL budget extension unavailable; run wire build:sql-budget: ${String(error)}`);
57
+ }
58
+ }
59
+ entry = { db, owners: new Set() };
60
+ connections.set(key, entry);
61
+ }
62
+ entry.owners.add(owner);
63
+ const db = entry.db;
64
+ const deadline = performance.now() + executionMs;
65
+ const guard = executionMs ? db.query("SELECT sb_sql_budget(?, ?)") : null;
66
+ guard?.get(capability, executionMs);
67
+ const disarm = () => {
68
+ try {
69
+ guard?.get(capability, 0);
70
+ } catch (error) {
71
+ // The expired one-shot callback can interrupt the cleanup SELECT itself.
72
+ if (error instanceof Error && "code" in error && error.code === "SQLITE_INTERRUPT") guard?.get(capability, 0);
73
+ else throw error;
74
+ }
75
+ };
76
+ try {
77
+ switch (msg.op) {
78
+ case "d1.query":
79
+ return { ok: true, ...run(db, str(msg.sql), params(msg.params)) };
80
+ case "d1.exec":
81
+ db.exec(str(msg.sql));
82
+ return { ok: true };
83
+ case "d1.batch": {
84
+ const statements = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
85
+ return {
86
+ ok: true,
87
+ results: db.transaction(() =>
88
+ statements.map((s) => {
89
+ if (executionMs && performance.now() >= deadline)
90
+ throw new Error("D1 execution deadline exceeded; batch rolled back");
91
+ return run(db, str(s.sql), params(s.params));
92
+ }),
93
+ )(),
94
+ };
95
+ }
96
+ case "d1.backup": {
97
+ if (path === ":memory:") throw new Error("d1 backup needs a file-backed database");
98
+ const requested = str(msg.name);
99
+ const file = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(requested)
100
+ ? requested
101
+ : `${name}-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
102
+ const dir = join(dirname(dirname(resolve(path))), "backups");
103
+ mkdirSync(dir, { recursive: true });
104
+ const dest = join(dir, file);
105
+ if (existsSync(dest)) rmSync(dest);
106
+ try {
107
+ db.run("VACUUM INTO ?", [dest]);
108
+ } catch (error) {
109
+ rmSync(dest, { force: true });
110
+ throw error;
111
+ }
112
+ const copy = new Database(dest, { readonly: true });
113
+ try {
114
+ if (executionMs) {
115
+ const remaining = Math.floor(deadline - performance.now());
116
+ if (remaining <= 0) throw new Error("D1 execution deadline exceeded during backup");
117
+ copy.loadExtension(
118
+ process.env.SPROUTBOAT_D1_BUDGET_EXTENSION || budgetExtension,
119
+ "sqlite3_sproutbudget_init",
120
+ );
121
+ copy.query("SELECT sb_sql_budget(?, ?)").get(capability, remaining);
122
+ }
123
+ if (
124
+ copy.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get()?.integrity_check !== "ok"
125
+ ) {
126
+ rmSync(dest, { force: true });
127
+ throw new Error("integrity check failed on the backup");
128
+ }
129
+ } catch (error) {
130
+ rmSync(dest, { force: true });
131
+ throw error;
132
+ } finally {
133
+ copy.close();
134
+ }
135
+ return { ok: true, path: dest, bytes: statSync(dest).size };
136
+ }
137
+ default:
138
+ throw new Error("unknown D1 operation");
139
+ }
140
+ } catch (error) {
141
+ if (error instanceof Error && "code" in error && error.code === "SQLITE_INTERRUPT")
142
+ throw new Error(
143
+ `D1 execution deadline exceeded; SQL was not retried${msg.op === "d1.exec" ? "; earlier exec statements may have committed" : ""}`,
144
+ );
145
+ throw error;
146
+ } finally {
147
+ disarm();
148
+ }
149
+ },
150
+ release(owner: string) {
151
+ for (const [key, entry] of connections) {
152
+ entry.owners.delete(owner);
153
+ if (!entry.owners.size) {
154
+ entry.db.close();
155
+ connections.delete(key);
156
+ }
157
+ }
158
+ },
159
+ close() {
160
+ for (const entry of connections.values()) entry.db.close();
161
+ connections.clear();
162
+ },
163
+ };
164
+ }