@helipod/docstore-do-sqlite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,96 @@
1
+ # @helipod/docstore-do-sqlite
2
+
3
+ The MVCC `DocStore` over a **Cloudflare Durable Object's** embedded SQLite (`ctx.storage.sql`) — the
4
+ Cloudflare-native Tier 0 storage backend. Slice 2 of the DO-native host program
5
+ (`docs/superpowers/plans/2026-03-20-cloudflare-do-native-host-roadmap.md`).
6
+
7
+ DO-SQLite *is* SQLite, so the MVCC document-log implementation is reused **verbatim** from
8
+ [`@helipod/docstore-sqlite`](../docstore-sqlite) (`SqliteDocStore`). This package adds only the one
9
+ new thing: a `DatabaseAdapter` (`DoSqliteAdapter`) that drives the DO's synchronous SQL API.
10
+
11
+ ```ts
12
+ import { SqliteDocStore } from "@helipod/docstore-sqlite";
13
+ import { DoSqliteAdapter } from "@helipod/docstore-do-sqlite";
14
+
15
+ // inside a Durable Object (the Slice 3 host wires this up):
16
+ const adapter = new DoSqliteAdapter({
17
+ sql: ctx.storage.sql,
18
+ transactionSync: ctx.storage.transactionSync.bind(ctx.storage),
19
+ });
20
+ const store = new SqliteDocStore(adapter);
21
+ await store.setupSchema();
22
+ ```
23
+
24
+ ## Design notes
25
+
26
+ ### Injection, not import (neutrality)
27
+
28
+ The engine must never know it is on Cloudflare. So the adapter is **handed** the DO's SQL surface as
29
+ constructor input — `{ sql, transactionSync }` pulled off `ctx.storage` by the host — exactly as the
30
+ `node`/`bun`/`pg` adapters are constructed with their drivers. This package references **no**
31
+ Cloudflare type: the injected surface is declared as minimal structural interfaces (`SqlStorageLike`
32
+ etc.), which a real DO's typed `ctx.storage.sql` (`SqlStorage` from `@cloudflare/workers-types`)
33
+ satisfies by width — mirroring how `bun-adapter.ts` declares its `bun:sqlite` shape inline to avoid a
34
+ build dependency. Nothing above this leaf package imports a Cloudflare primitive.
35
+
36
+ ### The 10 GB ceiling → a typed error, not a crash
37
+
38
+ A Durable Object's embedded SQLite is capped at **10 GB per object**; a write past that limit
39
+ hard-fails with `SQLITE_FULL`. The adapter classifies **only** that failure into a typed
40
+ [`DatabaseFullError`](./src/errors.ts) (`code: "DATABASE_FULL"`, original error kept as `cause`) so a
41
+ host can catch it and react (shed the write, reshard, alert) instead of seeing an opaque driver throw.
42
+ Every other error — notably the `SQLITE_CONSTRAINT` that the conflict-strategy contract relies on to
43
+ reject a duplicate `(id, ts)` — propagates untouched.
44
+
45
+ Cloudflare does not document a stable error *code* for the limit, so `isDatabaseFullError` matches the
46
+ SQLite `SQLITE_FULL` result-code family and its canonical "database or disk is full" message,
47
+ deliberately narrow so it never mislabels an unrelated failure. Reaching 10 GB is not reachable in a
48
+ unit test, so the classifier is tested directly against a synthetic `SQLITE_FULL`-shaped error (and
49
+ proven to pass a constraint error through).
50
+
51
+ ### Three DO-SQLite deviations the adapter absorbs
52
+
53
+ 1. **No `BEGIN`/`COMMIT`/`SAVEPOINT` via `exec`.** DO-SQLite rejects transaction-control SQL; the only
54
+ atomic primitive is `ctx.storage.transactionSync(fn)`. `transaction()` delegates to it (it does
55
+ **not** emit `BEGIN`/`COMMIT` the way the node/bun adapters do). This is why `transactionSync` is a
56
+ required constructor input, not optional.
57
+ 2. **Values are `ArrayBuffer | string | number | null`.** BLOB columns read back as `ArrayBuffer`
58
+ (re-wrapped to `Uint8Array` on the read path, so the DocStore sees the same blobs as every other
59
+ adapter); INTEGER columns read back as `number` (there is no `setReadBigInts`/`safeIntegers` mode).
60
+ 3. **`bigint` is not a documented binding type.** The DocStore binds logical timestamps as `bigint`;
61
+ the bind path narrows them to `number` before `sql.exec`.
62
+
63
+ ### Why `number` timestamps are lossless here
64
+
65
+ Helipod's only INTEGER columns are logical timestamps (a per-store monotonic `MAX(ts)+1` counter
66
+ seeded at 1) and millisecond wall-clocks. Both stay far under `Number.MAX_SAFE_INTEGER` (2^53) in
67
+ every reachable state — a single DO would need ~9 quadrillion commits to overflow. Document ids and
68
+ index keys are BLOBs, never integers. The bind path throws loudly rather than silently truncate if a
69
+ value ever exceeds the safe range.
70
+
71
+ ## Test fidelity — what is (and is not) proven
72
+
73
+ This package passes the **full shared docstore conformance suite**
74
+ (`@helipod/docstore/test-support/conformance` — the identical contract SQLite/Postgres/PGlite run),
75
+ driving `SqliteDocStore` over `DoSqliteAdapter`.
76
+
77
+ **Fidelity level: API-shape conformance against a faithful in-process `SqlStorage` stand-in — NOT a
78
+ real Durable Object run.** The suite runs against `test/memory-sql-storage.ts`, a stand-in backed by
79
+ Node's built-in `node:sqlite` that reproduces the DO SQL surface the adapter actually depends on:
80
+
81
+ - a **synchronous** `exec(query, ...bindings)` returning a cursor with `toArray()` / `rowsWritten`;
82
+ - a `transactionSync(fn)` that is the sole atomicity primitive;
83
+ - BLOB results returned as **`ArrayBuffer`** (forcing the adapter's `ArrayBuffer → Uint8Array` wrap);
84
+ - INTEGER results returned as **`number`**;
85
+ - **rejection of `bigint` bindings** (forcing the adapter's `bigint → number` narrowing);
86
+ - `exec` refusing `BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT` (forcing use of `transactionSync`).
87
+
88
+ Because the stand-in enforces exactly the constraints a real DO imposes, a green suite proves the
89
+ adapter speaks the DO SQL contract correctly. It does **not** prove behavior inside a real DO
90
+ runtime (workerd) — SQLite version quirks, the real `SQLITE_FULL` message text, and 10 GB behavior are
91
+ unverified here.
92
+
93
+ **Follow-on (deferred, like the container smoke was for `serve`):** run this same conformance suite
94
+ inside a real Durable Object via `@cloudflare/vitest-pool-workers` (`runInDurableObject`) once the DO
95
+ host lands (Slice 3). That is the gold-standard gate; wiring `vitest-pool-workers` (workerd + wrangler
96
+ config + a separate vitest project) was out of scope for this leaf-adapter slice.
@@ -0,0 +1,129 @@
1
+ import { DatabaseAdapter, SqlValue, PreparedStatement } from '@helipod/docstore-sqlite';
2
+ export { DatabaseAdapter, PreparedStatement, RunResult, SqlRow, SqlValue, SqliteDocStore } from '@helipod/docstore-sqlite';
3
+
4
+ /**
5
+ * `DatabaseAdapter` backed by a **Durable Object's** embedded SQLite (`ctx.storage.sql`) — the
6
+ * Cloudflare-native Tier 0 storage backend (Slice 2 of the DO-native host program;
7
+ * `docs/superpowers/plans/2026-03-20-cloudflare-do-native-host-roadmap.md`). DO-SQLite's SQL API is
8
+ * **synchronous** (`sql.exec(query, ...bindings)` returns a cursor synchronously), which is a direct
9
+ * structural match for the existing synchronous `DatabaseAdapter` contract — an easier fit than the
10
+ * async Postgres adapter.
11
+ *
12
+ * ## Injection, not import (the neutrality invariant)
13
+ * The engine must NEVER know it is on Cloudflare. So this adapter takes the DO's SQL surface as
14
+ * CONSTRUCTOR INPUT — a `{ sql, transactionSync }` pair the DO host (Slice 3) pulls off its own
15
+ * `ctx.storage` and hands in — exactly as `docstore-sqlite`/`docstore-postgres` are constructed with
16
+ * their drivers. Nothing above this leaf ever references a Cloudflare type.
17
+ *
18
+ * The injected surface is declared here as minimal STRUCTURAL interfaces (`SqlStorageLike` /
19
+ * `SqlStorageCursorLike` / a `transactionSync` closure runner) — mirroring `bun-adapter.ts`, which
20
+ * declares its `bun:sqlite` shape inline "to avoid a build dependency". A real DO's typed
21
+ * `ctx.storage.sql` (`SqlStorage` from `@cloudflare/workers-types`) and `ctx.storage.transactionSync`
22
+ * structurally satisfy these, so the host wires them in with zero casts and this package needs no
23
+ * `@cloudflare/workers-types` dependency of its own.
24
+ *
25
+ * ## The three DO-SQLite deviations from a normal SQLite driver (all handled here)
26
+ * 1. **No `BEGIN`/`COMMIT`/`SAVEPOINT` via `exec`.** DO-SQLite rejects transaction-control SQL; the
27
+ * only way to get an atomic multi-statement unit is `ctx.storage.transactionSync(fn)`. So
28
+ * `transaction()` delegates to the injected `transactionSync` — it does NOT emit `BEGIN`/`COMMIT`
29
+ * the way `node-adapter`/`bun-adapter` do.
30
+ * 2. **Values are `ArrayBuffer | string | number | null`.** BLOB columns read back as `ArrayBuffer`
31
+ * (not `Uint8Array`), and INTEGER columns read back as `number` (there is no `bigint`/safe-integer
32
+ * read mode like `setReadBigInts`/`safeIntegers`). The read path re-wraps `ArrayBuffer → Uint8Array`
33
+ * so the DocStore sees the same `Uint8Array` blobs every other adapter yields; integers stay
34
+ * `number` and the DocStore's `asBigInt` widens them (see the precision note below).
35
+ * 3. **Bindings are `ArrayBuffer | ArrayBufferView | string | number | null` — `bigint` is NOT a
36
+ * documented binding type.** The DocStore binds logical timestamps as `bigint`; the bind path
37
+ * narrows those to `number` before they reach `sql.exec`.
38
+ *
39
+ * ## The `bigint`/`number` precision note (why `number` is safe here)
40
+ * Helipod's only INTEGER columns are logical timestamps (`ts`/`prev_ts`/`commit_ts` — a per-store
41
+ * monotonic `MAX(ts)+1` counter seeded at 1) and millisecond wall-clocks (`created_at`/`updated_at`).
42
+ * Both stay far under `Number.MAX_SAFE_INTEGER` (2^53): a single DO would need ~9 quadrillion commits
43
+ * to overflow, and ms-time overflows in year ~+287000. Document ids and index keys are BLOBs, never
44
+ * integers. So round-tripping timestamps through `number` is lossless in every reachable state — and
45
+ * the bind path throws loudly rather than silently truncate if a value ever exceeds the safe range.
46
+ *
47
+ * ## The 10 GB ceiling
48
+ * A write past a Durable Object's hard 10 GB limit fails with `SQLITE_FULL`; the adapter classifies
49
+ * that ONE failure into a typed `DatabaseFullError` (see `errors.ts`) instead of an opaque throw,
50
+ * while every other error (constraint violations, etc.) propagates untouched.
51
+ */
52
+
53
+ /** A DO-SQLite scalar, both as a binding and as a result cell:
54
+ * `ArrayBuffer | string | number | null` (Cloudflare's `SqlStorageValue`). We additionally accept
55
+ * `ArrayBufferView` as a binding, which the SQL API also takes for BLOBs. */
56
+ type DoSqlValue = ArrayBuffer | ArrayBufferView | string | number | null;
57
+ /** The synchronous cursor `SqlStorage.exec` returns. We only need `toArray()` (materialize rows)
58
+ * and `rowsWritten` (for `RunResult.changes`); the fuller DO cursor (`one`/`raw`/`next`/iteration/
59
+ * `columnNames`/`rowsRead`) is a structural superset and satisfies this by width. */
60
+ interface SqlStorageCursorLike {
61
+ toArray(): Record<string, DoSqlValue>[];
62
+ /** Rows actually written by the statement — DO-SQLite's counterpart to SQLite's `changes()`
63
+ * (0 for an `INSERT OR IGNORE` that ignored). */
64
+ readonly rowsWritten: number;
65
+ }
66
+ /** The narrow slice of Cloudflare's `SqlStorage` (`ctx.storage.sql`) this adapter drives. */
67
+ interface SqlStorageLike {
68
+ exec(query: string, ...bindings: DoSqlValue[]): SqlStorageCursorLike;
69
+ }
70
+ /** `ctx.storage.transactionSync` — runs `closure` inside ONE synchronous SQLite transaction, rolling
71
+ * back if it throws. This is DO-SQLite's ONLY transaction primitive (explicit `BEGIN`/`COMMIT` SQL
72
+ * is rejected), so the adapter cannot fabricate atomicity without it. */
73
+ type TransactionSyncFn = <T>(closure: () => T) => T;
74
+ interface DoSqliteOptions {
75
+ /** The DO's SQL handle — `ctx.storage.sql`. */
76
+ sql: SqlStorageLike;
77
+ /** The DO's synchronous transaction runner — `ctx.storage.transactionSync` (bind it to
78
+ * `ctx.storage`). Required: without it there is no way to make a multi-statement commit atomic. */
79
+ transactionSync: TransactionSyncFn;
80
+ }
81
+ declare class DoSqliteAdapter implements DatabaseAdapter {
82
+ private readonly sql;
83
+ private readonly transactionSyncFn;
84
+ constructor(options: DoSqliteOptions);
85
+ /** Issue one `sql.exec`, converting bindings and classifying a 10 GB `SQLITE_FULL` failure into a
86
+ * typed `DatabaseFullError`. The single chokepoint every `exec`/statement call funnels through, so
87
+ * the full-storage classification lives in exactly one place. */
88
+ execRaw(sqlText: string, params: readonly SqlValue[]): SqlStorageCursorLike;
89
+ exec(sql: string): void;
90
+ prepare(sql: string): PreparedStatement;
91
+ transaction<T>(fn: () => T): T;
92
+ close(): void;
93
+ }
94
+
95
+ /**
96
+ * The one storage-full failure mode a DO-SQLite adapter has that neither `node:sqlite` nor
97
+ * `bun:sqlite` share: a Durable Object's embedded SQLite is capped at **10 GB per object** and a
98
+ * write past that ceiling hard-fails with `SQLITE_FULL` (see
99
+ * `docs/dev/research/cloudflare-do-native-host.md`). Rather than let that surface as an opaque,
100
+ * driver-shaped throw, the adapter classifies it into this ONE typed error so a host (Slice 3) can
101
+ * catch it and react (shed the write, reshard, page an operator) — the same "typed, not a crash"
102
+ * discipline `docstore-postgres` applies to its `ReadOnlyStoreError`.
103
+ *
104
+ * Only a genuine capacity/disk-full failure is wrapped; a `SQLITE_CONSTRAINT` (e.g. the duplicate
105
+ * `(id, ts)` the conflict-strategy contract relies on rejecting) passes straight through untouched.
106
+ */
107
+ declare class DatabaseFullError extends Error {
108
+ /** The original driver error, preserved so the host never loses the underlying detail.
109
+ * `override` because ES2022's `Error` already declares an optional `cause`. */
110
+ readonly cause: unknown;
111
+ /** Stable, machine-checkable discriminant — a host can branch on this without instanceof across
112
+ * a bundling/isolate boundary. */
113
+ readonly code: "DATABASE_FULL";
114
+ constructor(message: string,
115
+ /** The original driver error, preserved so the host never loses the underlying detail.
116
+ * `override` because ES2022's `Error` already declares an optional `cause`. */
117
+ cause: unknown);
118
+ }
119
+ /**
120
+ * Does this driver error signal the DO-SQLite 10 GB ceiling (`SQLITE_FULL`)? Cloudflare does not
121
+ * document a stable error CODE for the limit (the SQL API just surfaces SQLite's own text), so we
122
+ * match on the SQLite `SQLITE_FULL` primary-result-code family and its canonical message —
123
+ * "database or disk is full" — while deliberately NOT matching `SQLITE_CONSTRAINT`/`SQLITE_BUSY`/etc.
124
+ * A false negative here degrades gracefully (the raw error still propagates, just untyped); a false
125
+ * positive would mislabel an unrelated failure, so the patterns are kept narrow and full-specific.
126
+ */
127
+ declare function isDatabaseFullError(err: unknown): boolean;
128
+
129
+ export { DatabaseFullError, DoSqliteAdapter, type DoSqliteOptions, type SqlStorageCursorLike, type SqlStorageLike, type TransactionSyncFn, isDatabaseFullError };
package/dist/index.js ADDED
@@ -0,0 +1,107 @@
1
+ // src/errors.ts
2
+ var DatabaseFullError = class extends Error {
3
+ constructor(message, cause) {
4
+ super(message);
5
+ this.cause = cause;
6
+ this.name = "DatabaseFullError";
7
+ }
8
+ cause;
9
+ /** Stable, machine-checkable discriminant — a host can branch on this without instanceof across
10
+ * a bundling/isolate boundary. */
11
+ code = "DATABASE_FULL";
12
+ };
13
+ function isDatabaseFullError(err) {
14
+ const code = err?.code;
15
+ if (typeof code === "string" && code.toUpperCase().includes("SQLITE_FULL")) return true;
16
+ const message = err?.message;
17
+ if (typeof message !== "string") return false;
18
+ return /\bSQLITE_FULL\b/i.test(message) || /database or disk is full/i.test(message);
19
+ }
20
+
21
+ // src/do-adapter.ts
22
+ function toDoBinding(v) {
23
+ if (typeof v === "bigint") {
24
+ if (v > 9007199254740991n || v < -9007199254740991n) {
25
+ throw new RangeError(
26
+ `[docstore-do-sqlite] integer ${v} exceeds Number.MAX_SAFE_INTEGER; DO-SQLite has no bigint binding type, so it cannot be bound without precision loss`
27
+ );
28
+ }
29
+ return Number(v);
30
+ }
31
+ return v;
32
+ }
33
+ function fromDoValue(v) {
34
+ if (v instanceof ArrayBuffer) return new Uint8Array(v);
35
+ if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
36
+ return v;
37
+ }
38
+ function rowFromDo(row) {
39
+ const out = {};
40
+ for (const key in row) out[key] = fromDoValue(row[key]);
41
+ return out;
42
+ }
43
+ var DoStatement = class {
44
+ constructor(adapter, sqlText) {
45
+ this.adapter = adapter;
46
+ this.sqlText = sqlText;
47
+ }
48
+ adapter;
49
+ sqlText;
50
+ run(...params) {
51
+ const cursor = this.adapter.execRaw(this.sqlText, params);
52
+ cursor.toArray();
53
+ return { changes: cursor.rowsWritten, lastInsertRowid: 0 };
54
+ }
55
+ get(...params) {
56
+ const rows = this.adapter.execRaw(this.sqlText, params).toArray();
57
+ return rows.length > 0 ? rowFromDo(rows[0]) : void 0;
58
+ }
59
+ all(...params) {
60
+ return this.adapter.execRaw(this.sqlText, params).toArray().map(rowFromDo);
61
+ }
62
+ };
63
+ var DoSqliteAdapter = class {
64
+ sql;
65
+ transactionSyncFn;
66
+ constructor(options) {
67
+ this.sql = options.sql;
68
+ this.transactionSyncFn = options.transactionSync;
69
+ }
70
+ /** Issue one `sql.exec`, converting bindings and classifying a 10 GB `SQLITE_FULL` failure into a
71
+ * typed `DatabaseFullError`. The single chokepoint every `exec`/statement call funnels through, so
72
+ * the full-storage classification lives in exactly one place. */
73
+ execRaw(sqlText, params) {
74
+ try {
75
+ return this.sql.exec(sqlText, ...params.map(toDoBinding));
76
+ } catch (err) {
77
+ if (isDatabaseFullError(err)) {
78
+ throw new DatabaseFullError(
79
+ "[docstore-do-sqlite] the Durable Object's 10 GB SQLite limit was reached (SQLITE_FULL)",
80
+ err
81
+ );
82
+ }
83
+ throw err;
84
+ }
85
+ }
86
+ exec(sql) {
87
+ this.execRaw(sql, []).toArray();
88
+ }
89
+ prepare(sql) {
90
+ return new DoStatement(this, sql);
91
+ }
92
+ transaction(fn) {
93
+ return this.transactionSyncFn(fn);
94
+ }
95
+ close() {
96
+ }
97
+ };
98
+
99
+ // src/index.ts
100
+ import { SqliteDocStore } from "@helipod/docstore-sqlite";
101
+ export {
102
+ DatabaseFullError,
103
+ DoSqliteAdapter,
104
+ SqliteDocStore,
105
+ isDatabaseFullError
106
+ };
107
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/do-adapter.ts","../src/index.ts"],"sourcesContent":["/**\n * The one storage-full failure mode a DO-SQLite adapter has that neither `node:sqlite` nor\n * `bun:sqlite` share: a Durable Object's embedded SQLite is capped at **10 GB per object** and a\n * write past that ceiling hard-fails with `SQLITE_FULL` (see\n * `docs/dev/research/cloudflare-do-native-host.md`). Rather than let that surface as an opaque,\n * driver-shaped throw, the adapter classifies it into this ONE typed error so a host (Slice 3) can\n * catch it and react (shed the write, reshard, page an operator) — the same \"typed, not a crash\"\n * discipline `docstore-postgres` applies to its `ReadOnlyStoreError`.\n *\n * Only a genuine capacity/disk-full failure is wrapped; a `SQLITE_CONSTRAINT` (e.g. the duplicate\n * `(id, ts)` the conflict-strategy contract relies on rejecting) passes straight through untouched.\n */\nexport class DatabaseFullError extends Error {\n /** Stable, machine-checkable discriminant — a host can branch on this without instanceof across\n * a bundling/isolate boundary. */\n readonly code = \"DATABASE_FULL\" as const;\n\n constructor(\n message: string,\n /** The original driver error, preserved so the host never loses the underlying detail.\n * `override` because ES2022's `Error` already declares an optional `cause`. */\n override readonly cause: unknown,\n ) {\n super(message);\n this.name = \"DatabaseFullError\";\n }\n}\n\n/**\n * Does this driver error signal the DO-SQLite 10 GB ceiling (`SQLITE_FULL`)? Cloudflare does not\n * document a stable error CODE for the limit (the SQL API just surfaces SQLite's own text), so we\n * match on the SQLite `SQLITE_FULL` primary-result-code family and its canonical message —\n * \"database or disk is full\" — while deliberately NOT matching `SQLITE_CONSTRAINT`/`SQLITE_BUSY`/etc.\n * A false negative here degrades gracefully (the raw error still propagates, just untyped); a false\n * positive would mislabel an unrelated failure, so the patterns are kept narrow and full-specific.\n */\nexport function isDatabaseFullError(err: unknown): boolean {\n const code = (err as { code?: unknown } | null)?.code;\n // SQLite exposes the primary result code as the string \"SQLITE_FULL\" on `err.code` in most\n // bindings; some (and the DO SQL API) only carry it in the message. Check both.\n if (typeof code === \"string\" && code.toUpperCase().includes(\"SQLITE_FULL\")) return true;\n const message = (err as { message?: unknown } | null)?.message;\n if (typeof message !== \"string\") return false;\n return /\\bSQLITE_FULL\\b/i.test(message) || /database or disk is full/i.test(message);\n}\n","/**\n * `DatabaseAdapter` backed by a **Durable Object's** embedded SQLite (`ctx.storage.sql`) — the\n * Cloudflare-native Tier 0 storage backend (Slice 2 of the DO-native host program;\n * `docs/superpowers/plans/2026-03-20-cloudflare-do-native-host-roadmap.md`). DO-SQLite's SQL API is\n * **synchronous** (`sql.exec(query, ...bindings)` returns a cursor synchronously), which is a direct\n * structural match for the existing synchronous `DatabaseAdapter` contract — an easier fit than the\n * async Postgres adapter.\n *\n * ## Injection, not import (the neutrality invariant)\n * The engine must NEVER know it is on Cloudflare. So this adapter takes the DO's SQL surface as\n * CONSTRUCTOR INPUT — a `{ sql, transactionSync }` pair the DO host (Slice 3) pulls off its own\n * `ctx.storage` and hands in — exactly as `docstore-sqlite`/`docstore-postgres` are constructed with\n * their drivers. Nothing above this leaf ever references a Cloudflare type.\n *\n * The injected surface is declared here as minimal STRUCTURAL interfaces (`SqlStorageLike` /\n * `SqlStorageCursorLike` / a `transactionSync` closure runner) — mirroring `bun-adapter.ts`, which\n * declares its `bun:sqlite` shape inline \"to avoid a build dependency\". A real DO's typed\n * `ctx.storage.sql` (`SqlStorage` from `@cloudflare/workers-types`) and `ctx.storage.transactionSync`\n * structurally satisfy these, so the host wires them in with zero casts and this package needs no\n * `@cloudflare/workers-types` dependency of its own.\n *\n * ## The three DO-SQLite deviations from a normal SQLite driver (all handled here)\n * 1. **No `BEGIN`/`COMMIT`/`SAVEPOINT` via `exec`.** DO-SQLite rejects transaction-control SQL; the\n * only way to get an atomic multi-statement unit is `ctx.storage.transactionSync(fn)`. So\n * `transaction()` delegates to the injected `transactionSync` — it does NOT emit `BEGIN`/`COMMIT`\n * the way `node-adapter`/`bun-adapter` do.\n * 2. **Values are `ArrayBuffer | string | number | null`.** BLOB columns read back as `ArrayBuffer`\n * (not `Uint8Array`), and INTEGER columns read back as `number` (there is no `bigint`/safe-integer\n * read mode like `setReadBigInts`/`safeIntegers`). The read path re-wraps `ArrayBuffer → Uint8Array`\n * so the DocStore sees the same `Uint8Array` blobs every other adapter yields; integers stay\n * `number` and the DocStore's `asBigInt` widens them (see the precision note below).\n * 3. **Bindings are `ArrayBuffer | ArrayBufferView | string | number | null` — `bigint` is NOT a\n * documented binding type.** The DocStore binds logical timestamps as `bigint`; the bind path\n * narrows those to `number` before they reach `sql.exec`.\n *\n * ## The `bigint`/`number` precision note (why `number` is safe here)\n * Helipod's only INTEGER columns are logical timestamps (`ts`/`prev_ts`/`commit_ts` — a per-store\n * monotonic `MAX(ts)+1` counter seeded at 1) and millisecond wall-clocks (`created_at`/`updated_at`).\n * Both stay far under `Number.MAX_SAFE_INTEGER` (2^53): a single DO would need ~9 quadrillion commits\n * to overflow, and ms-time overflows in year ~+287000. Document ids and index keys are BLOBs, never\n * integers. So round-tripping timestamps through `number` is lossless in every reachable state — and\n * the bind path throws loudly rather than silently truncate if a value ever exceeds the safe range.\n *\n * ## The 10 GB ceiling\n * A write past a Durable Object's hard 10 GB limit fails with `SQLITE_FULL`; the adapter classifies\n * that ONE failure into a typed `DatabaseFullError` (see `errors.ts`) instead of an opaque throw,\n * while every other error (constraint violations, etc.) propagates untouched.\n */\nimport type { DatabaseAdapter, PreparedStatement, RunResult, SqlRow, SqlValue } from \"@helipod/docstore-sqlite\";\nimport { DatabaseFullError, isDatabaseFullError } from \"./errors\";\n\n/** A DO-SQLite scalar, both as a binding and as a result cell:\n * `ArrayBuffer | string | number | null` (Cloudflare's `SqlStorageValue`). We additionally accept\n * `ArrayBufferView` as a binding, which the SQL API also takes for BLOBs. */\ntype DoSqlValue = ArrayBuffer | ArrayBufferView | string | number | null;\n\n/** The synchronous cursor `SqlStorage.exec` returns. We only need `toArray()` (materialize rows)\n * and `rowsWritten` (for `RunResult.changes`); the fuller DO cursor (`one`/`raw`/`next`/iteration/\n * `columnNames`/`rowsRead`) is a structural superset and satisfies this by width. */\nexport interface SqlStorageCursorLike {\n toArray(): Record<string, DoSqlValue>[];\n /** Rows actually written by the statement — DO-SQLite's counterpart to SQLite's `changes()`\n * (0 for an `INSERT OR IGNORE` that ignored). */\n readonly rowsWritten: number;\n}\n\n/** The narrow slice of Cloudflare's `SqlStorage` (`ctx.storage.sql`) this adapter drives. */\nexport interface SqlStorageLike {\n exec(query: string, ...bindings: DoSqlValue[]): SqlStorageCursorLike;\n}\n\n/** `ctx.storage.transactionSync` — runs `closure` inside ONE synchronous SQLite transaction, rolling\n * back if it throws. This is DO-SQLite's ONLY transaction primitive (explicit `BEGIN`/`COMMIT` SQL\n * is rejected), so the adapter cannot fabricate atomicity without it. */\nexport type TransactionSyncFn = <T>(closure: () => T) => T;\n\nexport interface DoSqliteOptions {\n /** The DO's SQL handle — `ctx.storage.sql`. */\n sql: SqlStorageLike;\n /** The DO's synchronous transaction runner — `ctx.storage.transactionSync` (bind it to\n * `ctx.storage`). Required: without it there is no way to make a multi-statement commit atomic. */\n transactionSync: TransactionSyncFn;\n}\n\n/** Narrow a DocStore-side binding (`SqlValue`) to what DO-SQLite's `exec` accepts. The only real work\n * is `bigint → number` (DO has no bigint binding type); `Uint8Array` blobs and everything else pass\n * through. Throws — rather than silently truncating — if a `bigint` exceeds the 2^53 safe range\n * (unreachable for Helipod's monotonic-counter timestamps; see the module note). */\nfunction toDoBinding(v: SqlValue): DoSqlValue {\n if (typeof v === \"bigint\") {\n if (v > 9007199254740991n || v < -9007199254740991n) {\n throw new RangeError(\n `[docstore-do-sqlite] integer ${v} exceeds Number.MAX_SAFE_INTEGER; DO-SQLite has no ` +\n `bigint binding type, so it cannot be bound without precision loss`,\n );\n }\n return Number(v);\n }\n return v;\n}\n\n/** Re-wrap a DO-SQLite result cell into the `SqlValue` shape the DocStore expects. The one\n * transformation that matters: BLOB columns come back as `ArrayBuffer`, but the DocStore reads them\n * as `Uint8Array` (`internal_id`/`key`) — so wrap `ArrayBuffer → Uint8Array`. Integers stay `number`\n * (the DocStore's `asBigInt` widens them); strings/null pass through. */\nfunction fromDoValue(v: DoSqlValue): SqlValue {\n if (v instanceof ArrayBuffer) return new Uint8Array(v);\n // A DO cursor only ever yields ArrayBuffer for blobs, but a faithful stand-in (or a future runtime)\n // could hand back an ArrayBufferView; normalize that too so downstream never sees a stray view.\n if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength);\n return v;\n}\n\nfunction rowFromDo(row: Record<string, DoSqlValue>): SqlRow {\n const out: SqlRow = {};\n for (const key in row) out[key] = fromDoValue(row[key]!);\n return out;\n}\n\n/** A `PreparedStatement` over DO-SQLite. DO-SQLite has no persistent prepared-statement handle on the\n * `SqlStorage` surface (and caches compiled SQL by text internally), so each `run`/`get`/`all` simply\n * re-issues `sql.exec(this.sql, ...bindings)`. The DocStore still caches these wrapper objects, so the\n * SQL text is built once per query — the same statement-cache ergonomics `node`/`bun` get. */\nclass DoStatement implements PreparedStatement {\n constructor(\n private readonly adapter: DoSqliteAdapter,\n private readonly sqlText: string,\n ) {}\n\n run(...params: SqlValue[]): RunResult {\n const cursor = this.adapter.execRaw(this.sqlText, params);\n // DO-SQLite writes are counted by `rowsWritten`; `lastInsertRowid` has no meaning on the\n // WITHOUT ROWID tables the DocStore uses (and the DocStore never reads it), so report 0.\n cursor.toArray(); // force execution so `rowsWritten` is populated even with no RETURNING rows\n return { changes: cursor.rowsWritten, lastInsertRowid: 0 };\n }\n\n get(...params: SqlValue[]): SqlRow | undefined {\n const rows = this.adapter.execRaw(this.sqlText, params).toArray();\n return rows.length > 0 ? rowFromDo(rows[0]!) : undefined;\n }\n\n all(...params: SqlValue[]): SqlRow[] {\n return this.adapter.execRaw(this.sqlText, params).toArray().map(rowFromDo);\n }\n}\n\nexport class DoSqliteAdapter implements DatabaseAdapter {\n private readonly sql: SqlStorageLike;\n private readonly transactionSyncFn: TransactionSyncFn;\n\n constructor(options: DoSqliteOptions) {\n this.sql = options.sql;\n this.transactionSyncFn = options.transactionSync;\n }\n\n /** Issue one `sql.exec`, converting bindings and classifying a 10 GB `SQLITE_FULL` failure into a\n * typed `DatabaseFullError`. The single chokepoint every `exec`/statement call funnels through, so\n * the full-storage classification lives in exactly one place. */\n execRaw(sqlText: string, params: readonly SqlValue[]): SqlStorageCursorLike {\n try {\n return this.sql.exec(sqlText, ...params.map(toDoBinding));\n } catch (err) {\n if (isDatabaseFullError(err)) {\n throw new DatabaseFullError(\n \"[docstore-do-sqlite] the Durable Object's 10 GB SQLite limit was reached (SQLITE_FULL)\",\n err,\n );\n }\n throw err;\n }\n }\n\n exec(sql: string): void {\n // `exec()` carries DDL (the multi-statement schema) and other no-result side effects. DO-SQLite's\n // `sql.exec` runs a multi-statement string in one call (bindings only apply to a single statement),\n // so no splitting is needed here. Materialize to surface any FULL error synchronously.\n this.execRaw(sql, []).toArray();\n }\n\n prepare(sql: string): PreparedStatement {\n return new DoStatement(this, sql);\n }\n\n transaction<T>(fn: () => T): T {\n // DO-SQLite's ONLY atomic primitive — explicit BEGIN/COMMIT SQL is rejected. A throw inside `fn`\n // propagates out of `transactionSync`, which rolls the whole transaction back (matching the\n // BEGIN/ROLLBACK contract the node/bun adapters implement by hand).\n return this.transactionSyncFn(fn);\n }\n\n close(): void {\n // A Durable Object's storage lifecycle is owned by the runtime, not the adapter: there is no\n // handle to close and no file to release (unlike node/bun's on-disk databases). The DocStore's\n // graceful-shutdown `close()` still flows here, so this is an intentional no-op.\n }\n}\n","/**\n * `@helipod/docstore-do-sqlite` — the MVCC `DocStore` over a **Durable Object's** embedded SQLite\n * (`ctx.storage.sql`), via a `DatabaseAdapter` that the DO host injects. The Cloudflare-native Tier 0\n * storage backend; the engine never imports a Cloudflare type — the adapter is handed the DO's SQL\n * surface as constructor input, exactly like the `node`/`bun`/`pg` adapters.\n *\n * The MVCC `DocStore` implementation itself is REUSED verbatim from `@helipod/docstore-sqlite`\n * (`SqliteDocStore`) — DO-SQLite is SQLite, so only the driver seam differs. Construct as:\n *\n * ```ts\n * import { SqliteDocStore } from \"@helipod/docstore-sqlite\";\n * import { DoSqliteAdapter } from \"@helipod/docstore-do-sqlite\";\n *\n * // inside a Durable Object:\n * const adapter = new DoSqliteAdapter({\n * sql: ctx.storage.sql,\n * transactionSync: ctx.storage.transactionSync.bind(ctx.storage),\n * });\n * const store = new SqliteDocStore(adapter);\n * await store.setupSchema();\n * ```\n */\nexport { DoSqliteAdapter } from \"./do-adapter\";\nexport type {\n DoSqliteOptions,\n SqlStorageLike,\n SqlStorageCursorLike,\n TransactionSyncFn,\n} from \"./do-adapter\";\nexport { DatabaseFullError, isDatabaseFullError } from \"./errors\";\n\n// Re-exported for ergonomics so a DO host can construct the whole store from one import. The MVCC\n// document-log logic lives in `@helipod/docstore-sqlite`; DO-SQLite reuses it unchanged.\nexport { SqliteDocStore } from \"@helipod/docstore-sqlite\";\nexport type { DatabaseAdapter, PreparedStatement, RunResult, SqlValue, SqlRow } from \"@helipod/docstore-sqlite\";\n"],"mappings":";AAYO,IAAM,oBAAN,cAAgC,MAAM;AAAA,EAK3C,YACE,SAGkB,OAClB;AACA,UAAM,OAAO;AAFK;AAGlB,SAAK,OAAO;AAAA,EACd;AAAA,EAJoB;AAAA;AAAA;AAAA,EANX,OAAO;AAWlB;AAUO,SAAS,oBAAoB,KAAuB;AACzD,QAAM,OAAQ,KAAmC;AAGjD,MAAI,OAAO,SAAS,YAAY,KAAK,YAAY,EAAE,SAAS,aAAa,EAAG,QAAO;AACnF,QAAM,UAAW,KAAsC;AACvD,MAAI,OAAO,YAAY,SAAU,QAAO;AACxC,SAAO,mBAAmB,KAAK,OAAO,KAAK,4BAA4B,KAAK,OAAO;AACrF;;;AC4CA,SAAS,YAAY,GAAyB;AAC5C,MAAI,OAAO,MAAM,UAAU;AACzB,QAAI,IAAI,qBAAqB,IAAI,CAAC,mBAAmB;AACnD,YAAM,IAAI;AAAA,QACR,gCAAgC,CAAC;AAAA,MAEnC;AAAA,IACF;AACA,WAAO,OAAO,CAAC;AAAA,EACjB;AACA,SAAO;AACT;AAMA,SAAS,YAAY,GAAyB;AAC5C,MAAI,aAAa,YAAa,QAAO,IAAI,WAAW,CAAC;AAGrD,MAAI,YAAY,OAAO,CAAC,EAAG,QAAO,IAAI,WAAW,EAAE,QAAQ,EAAE,YAAY,EAAE,UAAU;AACrF,SAAO;AACT;AAEA,SAAS,UAAU,KAAyC;AAC1D,QAAM,MAAc,CAAC;AACrB,aAAW,OAAO,IAAK,KAAI,GAAG,IAAI,YAAY,IAAI,GAAG,CAAE;AACvD,SAAO;AACT;AAMA,IAAM,cAAN,MAA+C;AAAA,EAC7C,YACmB,SACA,SACjB;AAFiB;AACA;AAAA,EAChB;AAAA,EAFgB;AAAA,EACA;AAAA,EAGnB,OAAO,QAA+B;AACpC,UAAM,SAAS,KAAK,QAAQ,QAAQ,KAAK,SAAS,MAAM;AAGxD,WAAO,QAAQ;AACf,WAAO,EAAE,SAAS,OAAO,aAAa,iBAAiB,EAAE;AAAA,EAC3D;AAAA,EAEA,OAAO,QAAwC;AAC7C,UAAM,OAAO,KAAK,QAAQ,QAAQ,KAAK,SAAS,MAAM,EAAE,QAAQ;AAChE,WAAO,KAAK,SAAS,IAAI,UAAU,KAAK,CAAC,CAAE,IAAI;AAAA,EACjD;AAAA,EAEA,OAAO,QAA8B;AACnC,WAAO,KAAK,QAAQ,QAAQ,KAAK,SAAS,MAAM,EAAE,QAAQ,EAAE,IAAI,SAAS;AAAA,EAC3E;AACF;AAEO,IAAM,kBAAN,MAAiD;AAAA,EACrC;AAAA,EACA;AAAA,EAEjB,YAAY,SAA0B;AACpC,SAAK,MAAM,QAAQ;AACnB,SAAK,oBAAoB,QAAQ;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA,EAKA,QAAQ,SAAiB,QAAmD;AAC1E,QAAI;AACF,aAAO,KAAK,IAAI,KAAK,SAAS,GAAG,OAAO,IAAI,WAAW,CAAC;AAAA,IAC1D,SAAS,KAAK;AACZ,UAAI,oBAAoB,GAAG,GAAG;AAC5B,cAAM,IAAI;AAAA,UACR;AAAA,UACA;AAAA,QACF;AAAA,MACF;AACA,YAAM;AAAA,IACR;AAAA,EACF;AAAA,EAEA,KAAK,KAAmB;AAItB,SAAK,QAAQ,KAAK,CAAC,CAAC,EAAE,QAAQ;AAAA,EAChC;AAAA,EAEA,QAAQ,KAAgC;AACtC,WAAO,IAAI,YAAY,MAAM,GAAG;AAAA,EAClC;AAAA,EAEA,YAAe,IAAgB;AAI7B,WAAO,KAAK,kBAAkB,EAAE;AAAA,EAClC;AAAA,EAEA,QAAc;AAAA,EAId;AACF;;;ACnKA,SAAS,sBAAsB;","names":[]}
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@helipod/docstore-do-sqlite",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "FSL-1.1-Apache-2.0",
6
+ "sideEffects": false,
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./test-support": {
16
+ "types": "./test/memory-sql-storage.ts",
17
+ "default": "./test/memory-sql-storage.ts"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "test/memory-sql-storage.ts"
23
+ ],
24
+ "scripts": {
25
+ "build": "tsup",
26
+ "test": "vitest run",
27
+ "typecheck": "tsc --noEmit",
28
+ "clean": "rm -rf dist .turbo"
29
+ },
30
+ "dependencies": {
31
+ "@helipod/docstore-sqlite": "0.1.0"
32
+ },
33
+ "devDependencies": {
34
+ "@helipod/docstore": "0.1.0",
35
+ "@helipod/id-codec": "0.1.0",
36
+ "@helipod/index-key-codec": "0.1.0",
37
+ "@helipod/values": "0.1.0",
38
+ "@types/node": "^22.10.5",
39
+ "tsup": "^8.3.5",
40
+ "typescript": "^5.7.2",
41
+ "vitest": "^2.1.8"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/helipod-sh/helipod.git",
49
+ "directory": "packages/docstore-do-sqlite"
50
+ },
51
+ "homepage": "https://github.com/helipod-sh/helipod"
52
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * A FAITHFUL in-process stand-in for a Durable Object's SQL surface (`ctx.storage.sql` +
3
+ * `ctx.storage.transactionSync`), backed by Node's built-in `node:sqlite`. It exists so the shared
4
+ * docstore conformance suite can run against `DoSqliteAdapter` under plain Node/vitest — WITHOUT a
5
+ * real Durable Object (which would need `@cloudflare/vitest-pool-workers`, workerd, and a wrangler
6
+ * config; deferred to the Slice 3 host, see this package's README).
7
+ *
8
+ * "Faithful" is the whole point: this stand-in deliberately reproduces the exact DO-SQLite CONSTRAINTS
9
+ * the adapter must cope with, so that a passing suite actually exercises the adapter's DO-specific
10
+ * code paths rather than papering over them. Specifically it:
11
+ * - is SYNCHRONOUS (`exec` returns a cursor synchronously; `transactionSync` runs its closure now);
12
+ * - returns BLOB columns as `ArrayBuffer` (not `Uint8Array`) — forcing the adapter's read-path wrap;
13
+ * - returns INTEGER columns as `number` — as DO-SQLite does (no bigint read mode);
14
+ * - REJECTS `bigint` bindings — DO-SQLite has no bigint binding type, so this proves the adapter
15
+ * narrows `bigint → number` before binding;
16
+ * - REJECTS `BEGIN`/`COMMIT`/`ROLLBACK`/`SAVEPOINT` through `exec` — as DO-SQLite does, proving the
17
+ * adapter routes atomicity through `transactionSync`, never through transaction-control SQL;
18
+ * - exposes `rowsWritten` on the cursor (DO-SQLite's counterpart to SQLite's `changes()`).
19
+ *
20
+ * What it does NOT reproduce: a real workerd SQLite build, the real `SQLITE_FULL` text at 10 GB, or
21
+ * hibernation. Those are the province of the deferred real-DO E2E.
22
+ */
23
+ import { createRequire } from "node:module";
24
+ import type { SqlStorageLike, SqlStorageCursorLike, TransactionSyncFn } from "../src/do-adapter";
25
+
26
+ // Load `node:sqlite` lazily via createRequire — same pattern the node adapter uses — so importing
27
+ // this module never eagerly resolves the (experimental) builtin unless a stand-in is constructed.
28
+ interface NodeStmt {
29
+ all(...params: unknown[]): Record<string, unknown>[];
30
+ }
31
+ interface NodeDb {
32
+ exec(sql: string): void;
33
+ prepare(sql: string): NodeStmt;
34
+ close(): void;
35
+ }
36
+
37
+ type DoSqlValue = ArrayBuffer | ArrayBufferView | string | number | null;
38
+
39
+ /** Re-shape a `node:sqlite` result cell to what a DO cursor would yield: BLOBs as `ArrayBuffer`
40
+ * (node:sqlite hands back a `Uint8Array`/`Buffer`), everything else unchanged. */
41
+ function toDoResultValue(v: unknown): DoSqlValue {
42
+ if (v instanceof ArrayBuffer) return v;
43
+ if (ArrayBuffer.isView(v)) {
44
+ // Copy out the exact byte window into a fresh, plain ArrayBuffer (node:sqlite yields a
45
+ // Uint8Array/Buffer for blobs; `.slice()` copies and its `.buffer` is a plain ArrayBuffer).
46
+ const bytes = new Uint8Array(v.buffer as ArrayBuffer, v.byteOffset, v.byteLength);
47
+ return bytes.slice().buffer;
48
+ }
49
+ if (typeof v === "bigint") return Number(v); // node:sqlite may widen; DO always yields number
50
+ return v as DoSqlValue;
51
+ }
52
+
53
+ function toDoRow(row: Record<string, unknown>): Record<string, DoSqlValue> {
54
+ const out: Record<string, DoSqlValue> = {};
55
+ for (const key in row) out[key] = toDoResultValue(row[key]);
56
+ return out;
57
+ }
58
+
59
+ class MemoryCursor implements SqlStorageCursorLike {
60
+ constructor(
61
+ private readonly rows: Record<string, DoSqlValue>[],
62
+ readonly rowsWritten: number,
63
+ ) {}
64
+ toArray(): Record<string, DoSqlValue>[] {
65
+ return this.rows;
66
+ }
67
+ }
68
+
69
+ // DO-SQLite forbids transaction-control statements through `exec`. Match on the leading keyword so
70
+ // the stand-in rejects them exactly as the real API does, forcing atomicity through transactionSync.
71
+ const TXN_CONTROL = /^\s*(begin|commit|rollback|savepoint|release)\b/i;
72
+
73
+ export class MemorySqlStorage implements SqlStorageLike {
74
+ private readonly db: NodeDb;
75
+
76
+ constructor() {
77
+ const nodeRequire = createRequire(import.meta.url);
78
+ const { DatabaseSync } = nodeRequire("node:sqlite") as { DatabaseSync: new (path: string) => NodeDb };
79
+ this.db = new DatabaseSync(":memory:");
80
+ this.db.exec("PRAGMA foreign_keys = ON;");
81
+ }
82
+
83
+ exec(query: string, ...bindings: DoSqlValue[]): SqlStorageCursorLike {
84
+ if (TXN_CONTROL.test(query)) {
85
+ throw new Error(
86
+ `[memory-sql-storage] DO-SQLite rejects transaction-control statements via exec(): ${query.trim().slice(0, 40)}` +
87
+ ` — use transactionSync() instead`,
88
+ );
89
+ }
90
+ for (const b of bindings) {
91
+ if (typeof b === "bigint") {
92
+ throw new TypeError("[memory-sql-storage] DO-SQLite has no bigint binding type");
93
+ }
94
+ }
95
+
96
+ // A multi-statement string (the schema DDL) is only ever passed with zero bindings. `node:sqlite`'s
97
+ // prepare() silently compiles just the FIRST statement of such a string, so route it through
98
+ // db.exec() (which runs them all) — mirroring DO-SQLite, where a multi-statement exec runs every
99
+ // statement and yields an empty result. Detection: an inner `;` (the DocStore's single prepared
100
+ // statements never contain one; the schema has many).
101
+ const trimmed = query.trim().replace(/;\s*$/, "");
102
+ if (bindings.length === 0 && trimmed.includes(";")) {
103
+ this.db.exec(query);
104
+ return new MemoryCursor([], 0);
105
+ }
106
+
107
+ const stmt = this.db.prepare(trimmed);
108
+ const rows = stmt.all(...bindings); // executes; returns [] for a write with no RETURNING
109
+ // `changes()` reflects the row count of the most recent INSERT/UPDATE/DELETE on this connection —
110
+ // DO-SQLite's `rowsWritten`. (Irrelevant/stale after a pure SELECT, which the adapter ignores.)
111
+ const changes = Number(this.db.prepare("SELECT changes() AS c").all()[0]!.c as number | bigint);
112
+ return new MemoryCursor(rows.map(toDoRow), changes);
113
+ }
114
+
115
+ /** `ctx.storage.transactionSync` — the sole atomicity primitive. Implemented with real
116
+ * BEGIN/COMMIT/ROLLBACK issued DIRECTLY on the underlying connection (bypassing our own `exec`
117
+ * guard, which is what forbids the adapter from doing this). No nesting is needed: the DocStore
118
+ * never nests `transaction()` calls. */
119
+ transactionSync: TransactionSyncFn = (closure) => {
120
+ this.db.exec("BEGIN");
121
+ try {
122
+ const result = closure();
123
+ this.db.exec("COMMIT");
124
+ return result;
125
+ } catch (e) {
126
+ this.db.exec("ROLLBACK");
127
+ throw e;
128
+ }
129
+ };
130
+
131
+ close(): void {
132
+ this.db.close();
133
+ }
134
+ }