@sproutboat/runtime 0.23.0 → 0.24.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/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9a5363e: D1 matches workerd from compatibility date `2026-10-24`:
8
+
9
+ - `all()`, `run()`, `first()`, `raw()`, `batch()` and `exec()` return Promises, and errors reject them. `bind()` stays synchronous and still throws `D1_TYPE_ERROR` at once.
10
+ - `meta` has D1's keys: `served_by`, `duration`, `changes`, `last_row_id`, `changed_db`, `size_after` (always 0 here), `rows_read` and `rows_written`.
11
+ - `raw({ columnNames: true })` puts the column names first, and keeps duplicate names.
12
+ - `first("name")` throws `D1_COLUMN_NOTFOUND` for a column the result doesn't have.
13
+ - Empty SQL and an empty `batch([])` throw `D1_ERROR: No SQL statements detected.`
14
+ - `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` and `RELEASE` are refused, as in D1. A batch is already one transaction.
15
+ - `exec()` runs line by line, all or nothing, and returns the number of lines. A failing line throws `D1_EXEC_ERROR: Error in line N: ...`.
16
+ - `withSession()` returns a session with `prepare`, `batch` and `getBookmark()`, which returns `null`. `dump()` rejects; use `backup()` instead.
17
+
18
+ Apps with an earlier `compatibility_date` keep the synchronous API unchanged. To opt in, move the date to `2026-10-24` or later and rebuild. `await` code needs no other change, but code that reads results without `await` does.
19
+
20
+ Not yet like D1: `rows_read` counts the rows returned, not the rows scanned, and pragmas are not limited to D1's allow-list.
21
+
22
+ With LTX capture on (from `2026-10-22`), a capture failure during a D1 call now reports `SQLITE_IOERR` with a message saying the write is not durable in the replication log, instead of SQLite's "not an error". Writes stay refused until restart.
23
+
24
+ ## 0.23.1
25
+
26
+ ### Patch Changes
27
+
28
+ - 7a7fd34: D1 correctness fixes, in the standalone (embedded) transport and the broker:
29
+
30
+ - **Bytes are no longer lost.** Binding a `Uint8Array`, another typed array, a `DataView` or an `ArrayBuffer` stored NULL in a standalone sprout, along with every parameter after it. It now stores a BLOB, at every compatibility date. On earlier dates a BLOB still reads back as before: as one character per byte.
31
+ - **`batch()` is one transaction** in a standalone sprout, as it already was through the broker and is in D1: if a statement fails, the ones before it are rolled back. This applies at every compatibility date.
32
+ - **From compatibility date `2026-10-24`**, D1 follows workerd's rules:
33
+ - a parameter must be `null`, a number, a string, a boolean or bytes, or the call throws `D1_TYPE_ERROR` (before, `undefined` and objects became NULL);
34
+ - the number of parameters must match the statement (`D1_ERROR: Wrong number of parameter bindings for SQL query.`);
35
+ - foreign keys are enforced (`PRAGMA foreign_keys = ON`);
36
+ - errors read `D1_ERROR: <message>: SQLITE_<CODE>`;
37
+ - a BLOB reads back as an array of byte values.
38
+
39
+ Apps with an earlier `compatibility_date` keep the old behaviour. To opt in, move the date to `2026-10-24` or later and rebuild.
40
+ - `meta.changes` is 0 for a statement that only reads. It used to repeat the previous write's count.
41
+
3
42
  ## 0.23.0
4
43
 
5
44
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,154 @@
1
+ // The D1 shim (`env.DB`) over the broker's D1 store (the production platform's path),
2
+ // at a compatibility date before and after 2026-10-24. The embedded transport's half is
3
+ // test/native-d1.ts.
4
+ import { expect, test } from "bun:test";
5
+ import { readFileSync } from "node:fs";
6
+ import { createD1Store } from "../../wire/src/d1";
7
+
8
+ const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
9
+ let store;
10
+ // oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
11
+ const { makeD1 } = new Function(
12
+ "Porffor",
13
+ "__testCall",
14
+ `${source}\n__sbCall = __testCall; return { makeD1: __sbMakeD1 };`,
15
+ )({ c: () => "" }, (json) => {
16
+ const msg = JSON.parse(json);
17
+ try {
18
+ return JSON.stringify(store.execute({ owner: "app", key: "app/DB", path: ":memory:", name: "DB", msg }));
19
+ } catch (error) {
20
+ return JSON.stringify({ ok: false, error: error.message });
21
+ }
22
+ });
23
+ const fresh = (date) => {
24
+ globalThis.__sbCompat = date;
25
+ store?.close();
26
+ store = createD1Store();
27
+ return makeD1("DB");
28
+ };
29
+ const caught = (fn) => {
30
+ try {
31
+ fn();
32
+ } catch (error) {
33
+ return error.message;
34
+ }
35
+ return "did not throw";
36
+ };
37
+ // The message a call fails with, synchronously or as a rejected Promise.
38
+ const failure = async (fn) => {
39
+ try {
40
+ await fn();
41
+ } catch (error) {
42
+ return error.message;
43
+ }
44
+ return "did not throw";
45
+ };
46
+ const NO_STATEMENTS = "D1_ERROR: No SQL statements detected.";
47
+ const WRONG_COUNT = "D1_ERROR: Wrong number of parameter bindings for SQL query.";
48
+
49
+ for (const date of ["2026-09-28", "2026-10-24"]) {
50
+ test(`${date}: bytes bind as a BLOB, and the parameters after them are kept`, async () => {
51
+ const DB = fresh(date);
52
+ await DB.exec("CREATE TABLE t (a TEXT, b BLOB, c TEXT)");
53
+ const insert = DB.prepare("INSERT INTO t VALUES (?, ?, ?)");
54
+ await insert.bind("u8", new Uint8Array([0, 255, 7]), "after").run();
55
+ await insert.bind("ab", new Uint8Array([1, 2]).buffer, "after").run();
56
+ await insert.bind("view", new DataView(new Uint8Array([9, 8, 7]).buffer, 1, 2), "after").run();
57
+ expect((await DB.prepare("SELECT a, hex(b) AS h, typeof(b) AS t, c FROM t ORDER BY rowid").all()).results).toEqual([
58
+ { a: "u8", h: "00FF07", t: "blob", c: "after" },
59
+ { a: "ab", h: "0102", t: "blob", c: "after" },
60
+ { a: "view", h: "0807", t: "blob", c: "after" },
61
+ ]);
62
+ });
63
+
64
+ test(`${date}: batch() is one transaction`, async () => {
65
+ const DB = fresh(date);
66
+ await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
67
+ expect(await failure(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")]))).toMatch(/UNIQUE/);
68
+ expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(0);
69
+ });
70
+ }
71
+
72
+ test("before 2026-10-24: synchronous, lenient, no foreign keys, BLOBs as one char per byte", () => {
73
+ const DB = fresh("2026-09-28");
74
+ const result = DB.prepare("SELECT 1 AS a").all();
75
+ expect(result.then).toBeUndefined();
76
+ expect(result.results).toEqual([{ a: 1 }]);
77
+ // (bun:sqlite has always refused a wrong parameter count here; the embedded transport has not.)
78
+ expect(DB.prepare("SELECT ?1 AS a").bind(undefined).first()).toEqual({ a: null });
79
+ expect(DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY); CREATE TABLE c (p INTEGER REFERENCES p(id))").count).toBe(1);
80
+ DB.prepare("INSERT INTO c VALUES (99)").run();
81
+ expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toBe("\u0000ÿ");
82
+ expect(DB.prepare("SELECT 1 AS a").first("nope")).toBeUndefined();
83
+ expect(caught(() => DB.prepare("SELEC 1").all())).toMatch(/^sproutboat d1\.query: /);
84
+ });
85
+
86
+ test("from 2026-10-24: D1's parameter rules", async () => {
87
+ const DB = fresh("2026-10-24");
88
+ expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe("D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'");
89
+ expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe("D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'");
90
+ expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe("D1_TYPE_ERROR: Type 'bigint' not supported for value '10'");
91
+ expect(await failure(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(WRONG_COUNT);
92
+ expect(await failure(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(WRONG_COUNT);
93
+ expect(await failure(() => DB.prepare("SELECT ?1").all())).toBe(WRONG_COUNT);
94
+ expect(await DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({ a: "x", b: "x", c: 1 });
95
+ });
96
+
97
+ test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", async () => {
98
+ const DB = fresh("2026-10-24");
99
+ await DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY)\nCREATE TABLE c (p INTEGER REFERENCES p(id))");
100
+ expect(await failure(() => DB.prepare("INSERT INTO c VALUES (99)").run())).toBe(
101
+ "D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
102
+ );
103
+ await DB.prepare("INSERT INTO p VALUES (1)").run();
104
+ expect(await failure(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
105
+ "D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
106
+ );
107
+ expect(await failure(() => DB.prepare("SELECT * FROM nope").all())).toBe("D1_ERROR: no such table: nope: SQLITE_ERROR");
108
+ expect(await DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
109
+ });
110
+
111
+ test("from 2026-10-24: every query method returns a Promise", async () => {
112
+ const DB = fresh("2026-10-24");
113
+ const s = DB.prepare("SELECT 41 AS a");
114
+ for (const p of [s.all(), s.run(), s.first(), s.raw(), DB.batch([s]), DB.exec("SELECT 1"), DB.dump().catch(() => 0)]) expect(p).toBeInstanceOf(Promise);
115
+ expect(await s.first("a").then((a) => a + 1)).toBe(42);
116
+ expect(await Promise.all([s.first("a"), DB.prepare("SELECT 2 AS a").first("a")])).toEqual([41, 2]);
117
+ // Errors reject rather than throw: .catch() sees them.
118
+ expect(await DB.prepare("SELEC 1").all().catch((e) => e.message)).toMatch(/^D1_ERROR: near "SELEC": syntax error/);
119
+ });
120
+
121
+ test("from 2026-10-24: results, meta, raw() and first() as in workerd", async () => {
122
+ const DB = fresh("2026-10-24");
123
+ await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
124
+ const inserted = await DB.prepare("INSERT INTO t (name) VALUES (?), (?)").bind("one", "two").run();
125
+ expect(Object.keys(inserted).sort()).toEqual(["meta", "results", "success"]);
126
+ expect(Object.keys(inserted.meta).sort()).toEqual(["changed_db", "changes", "duration", "last_row_id", "rows_read", "rows_written", "served_by", "size_after"]);
127
+ expect([inserted.meta.changes, inserted.meta.last_row_id, inserted.meta.changed_db, inserted.meta.rows_written]).toEqual([2, 2, true, 2]);
128
+ const read = await DB.prepare("SELECT id, name FROM t ORDER BY id").all();
129
+ expect([read.meta.changes, read.meta.changed_db, read.meta.rows_written]).toEqual([0, false, 0]);
130
+ expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw()).toEqual([[1, "one"], [2, "two"]]);
131
+ expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw({ columnNames: true })).toEqual([["id", "name"], [1, "one"], [2, "two"]]);
132
+ expect(await DB.prepare("SELECT id FROM t WHERE id > 9").raw({ columnNames: true })).toEqual([["id"]]);
133
+ expect(await DB.prepare("SELECT 1 AS a, 2 AS a").raw({ columnNames: true })).toEqual([["a", "a"], [1, 2]]);
134
+ expect((await DB.prepare("SELECT 1 AS a, 2 AS a").all()).results).toEqual([{ a: 2 }]);
135
+ expect(await failure(() => DB.prepare("SELECT id FROM t").first("nope"))).toBe("D1_COLUMN_NOTFOUND: Column not found (nope)");
136
+ expect(await DB.prepare("SELECT id FROM t WHERE id > 9").first("nope")).toBeNull();
137
+ });
138
+
139
+ test("from 2026-10-24: empty SQL, transactions, exec() and sessions as in workerd", async () => {
140
+ const DB = fresh("2026-10-24");
141
+ expect(await failure(() => DB.prepare("").all())).toBe(NO_STATEMENTS);
142
+ expect(await failure(() => DB.batch([]))).toBe(NO_STATEMENTS);
143
+ expect(await failure(() => DB.prepare("BEGIN TRANSACTION").run())).toMatch(/^D1_ERROR: To execute a transaction, please use the state\.storage\.transaction\(\)/);
144
+ expect(await failure(() => DB.prepare("savepoint x").run())).toMatch(/^D1_ERROR: To execute a transaction/);
145
+ const done = await DB.exec("CREATE TABLE t (id INTEGER)\nINSERT INTO t VALUES (1);\n\nINSERT INTO t VALUES (2);\n");
146
+ expect([done.count, Number.isFinite(done.duration)]).toEqual([3, true]);
147
+ expect(await DB.exec("SELECT 1").then((r) => r.count)).toBe(1);
148
+ expect(await failure(() => DB.exec("INSERT INTO t VALUES (3);\nSELEC 2"))).toMatch(/^D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error/);
149
+ expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
150
+ const session = DB.withSession("first-unconstrained");
151
+ expect(await session.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
152
+ expect(session.getBookmark()).toBeNull();
153
+ expect(await session.batch([session.prepare("SELECT 1 AS a")]).then((r) => r[0].results)).toEqual([{ a: 1 }]);
154
+ });
@@ -4809,56 +4809,236 @@ function __sbRpc(op, extra) {
4809
4809
 
4810
4810
  // D1: a Cloudflare-shaped `env.<DB>` (prepare / bind / all / run / raw / first,
4811
4811
  // plus batch and exec). Every call is one broker roundtrip.
4812
+ //
4813
+ // Bytes (an ArrayBuffer or a typed array) bind as a BLOB at every compatibility date:
4814
+ // before, they were stored as NULL. From compatibility date 2026-10-24 D1 calls are
4815
+ // strict, as in workerd: a parameter must be null, a number, a string, a boolean or
4816
+ // bytes (D1_TYPE_ERROR otherwise), the count must match the statement, foreign keys
4817
+ // are enforced, errors read "D1_ERROR: ...", and a BLOB comes back as an array of
4818
+ // byte values. Earlier dates keep the old behaviour.
4819
+ function __sbD1Strict() {
4820
+ return globalThis.__sbCompat >= "2026-10-24";
4821
+ }
4822
+ function __sbD1Param(value, strict) {
4823
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
4824
+ const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
4825
+ let hex = "";
4826
+ for (let i = 0; i < bytes.length; i++) hex += (bytes[i] < 16 ? "0" : "") + bytes[i].toString(16);
4827
+ return { $blob: hex };
4828
+ }
4829
+ if (!strict || value === null || value === true || value === false || Object(value) instanceof Number || __sbIsStr(value)) return value;
4830
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- workerd's D1_TYPE_ERROR names the value's typeof
4831
+ throw new Error("D1_TYPE_ERROR: Type '" + typeof value + "' not supported for value '" + String(value) + "'");
4832
+ }
4833
+ function __sbD1Rpc(op, extra, strict) {
4834
+ if (!strict) return __sbRpc(op, extra);
4835
+ extra.strict = true;
4836
+ try {
4837
+ return __sbRpc(op, extra);
4838
+ } catch (err) {
4839
+ // "sproutboat d1.query: D1_ERROR: ..." -> "D1_ERROR: ...", as workerd words it.
4840
+ const message = String((err && err.message) || err);
4841
+ const at = message.indexOf("D1_");
4842
+ throw new Error(at > 0 ? message.slice(at) : message);
4843
+ }
4844
+ }
4845
+ // Strict replies carry columns and rows; build workerd's result objects from them.
4846
+ function __sbD1Rows(r) {
4847
+ const rows = r.rows || [];
4848
+ for (let i = 0; i < rows.length; i++)
4849
+ for (let c = 0; c < rows[i].length; c++) {
4850
+ const cell = rows[i][c];
4851
+ if (__sbIsObj(cell) && __sbIsStr(cell.$blob)) {
4852
+ const out = [];
4853
+ for (let k = 0; k + 1 < cell.$blob.length; k += 2) out.push(parseInt(cell.$blob.slice(k, k + 2), 16));
4854
+ rows[i][c] = out;
4855
+ }
4856
+ }
4857
+ return rows;
4858
+ }
4859
+ function __sbD1Objects(columns, rows) {
4860
+ const out = [];
4861
+ for (let i = 0; i < rows.length; i++) {
4862
+ const row = {};
4863
+ for (let c = 0; c < columns.length; c++) row[columns[c]] = rows[i][c];
4864
+ out.push(row);
4865
+ }
4866
+ return out;
4867
+ }
4868
+ function __sbD1Result(r, strict) {
4869
+ if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
4870
+ return { results: __sbD1Objects(r.columns || [], __sbD1Rows(r)), success: true, meta: r.meta || {} };
4871
+ }
4872
+ function __sbD1Fail(message) {
4873
+ return new Error(message);
4874
+ }
4875
+ // From 2026-10-24: workerd's surface. Every query method returns a Promise, meta has D1's
4876
+ // keys, raw() takes { columnNames }, first(name) checks the column, empty SQL and
4877
+ // transaction statements are refused, exec() runs line by line, and withSession() exists.
4878
+ var __sbD1NoTransactions =
4879
+ "D1_ERROR: To execute a transaction, please use the state.storage.transaction() or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT statements. The JavaScript API is safer because it will automatically roll back on exceptions, and because it interacts correctly with Durable Objects' automatic atomic write coalescing.";
4880
+ function __sbD1Check(sql) {
4881
+ const text = String(sql).trim();
4882
+ if (!text) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
4883
+ if (/^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) throw __sbD1Fail(__sbD1NoTransactions);
4884
+ }
4885
+ function __sbD1Meta(meta, started) {
4886
+ const changes = Number(meta.changes) || 0;
4887
+ return {
4888
+ served_by: "sproutboat",
4889
+ duration: Date.now() - started,
4890
+ changes,
4891
+ last_row_id: Number(meta.last_row_id) || 0,
4892
+ changed_db: changes > 0,
4893
+ size_after: 0,
4894
+ rows_read: Number(meta.rows_read) || 0,
4895
+ rows_written: changes,
4896
+ };
4897
+ }
4898
+ // Columns and rows of a strict reply (an older broker answers with objects only).
4899
+ function __sbD1Table(r) {
4900
+ if (r.columns) return { columns: r.columns, rows: __sbD1Rows(r) };
4901
+ const objects = r.results || [];
4902
+ const columns = objects.length ? Object.keys(objects[0]) : [];
4903
+ const rows = [];
4904
+ for (let i = 0; i < objects.length; i++) {
4905
+ const row = [];
4906
+ for (let c = 0; c < columns.length; c++) row.push(objects[i][columns[c]]);
4907
+ rows.push(row);
4908
+ }
4909
+ return { columns, rows };
4910
+ }
4911
+ function __sbD1Promise(run) {
4912
+ try {
4913
+ return Promise.resolve(run());
4914
+ } catch (err) {
4915
+ return Promise.reject(err);
4916
+ }
4917
+ }
4812
4918
  function __sbMakeD1(dbName) {
4919
+ // The synchronous core: the answer to one statement as columns, rows and meta.
4920
+ function query(sql, params, strict) {
4921
+ if (strict) __sbD1Check(sql);
4922
+ const started = Date.now();
4923
+ const r = __sbD1Rpc("d1.query", { db: dbName, sql, params }, strict);
4924
+ if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
4925
+ const table = __sbD1Table(r);
4926
+ return { columns: table.columns, rows: table.rows, meta: __sbD1Meta(r.meta || {}, started) };
4927
+ }
4813
4928
  function stmt(sql, params) {
4814
4929
  const s = {
4815
4930
  __sql: sql,
4816
4931
  __params: params,
4817
4932
  bind() {
4818
- return stmt(sql, Array.prototype.slice.call(arguments));
4933
+ const strict = __sbD1Strict();
4934
+ const list = [];
4935
+ for (let i = 0; i < arguments.length; i++) list.push(__sbD1Param(arguments[i], strict));
4936
+ return stmt(sql, list);
4819
4937
  },
4820
4938
  all() {
4821
- const r = __sbRpc("d1.query", { db: dbName, sql, params });
4822
- return { results: r.results || [], success: true, meta: r.meta || {} };
4939
+ if (!__sbD1Strict()) return query(sql, params, false);
4940
+ return __sbD1Promise(() => {
4941
+ const t = query(sql, params, true);
4942
+ return { success: true, meta: t.meta, results: __sbD1Objects(t.columns, t.rows) };
4943
+ });
4823
4944
  },
4824
4945
  run() {
4825
4946
  return s.all();
4826
4947
  },
4827
- raw() {
4828
- const rows = s.all().results;
4829
- const out = [];
4830
- for (let i = 0; i < rows.length; i++) {
4831
- const cols = [];
4832
- for (const k in rows[i]) cols.push(rows[i][k]);
4833
- out.push(cols);
4948
+ raw(options) {
4949
+ if (!__sbD1Strict()) {
4950
+ const rows = query(sql, params, false).results;
4951
+ const out = [];
4952
+ for (let i = 0; i < rows.length; i++) {
4953
+ const cols = [];
4954
+ for (const k in rows[i]) cols.push(rows[i][k]);
4955
+ out.push(cols);
4956
+ }
4957
+ return out;
4834
4958
  }
4835
- return out;
4959
+ return __sbD1Promise(() => {
4960
+ const t = query(sql, params, true);
4961
+ return options && options.columnNames ? [t.columns].concat(t.rows) : t.rows;
4962
+ });
4836
4963
  },
4837
4964
  first(column) {
4838
- const rows = s.all().results;
4839
- if (rows.length === 0) return null;
4840
- return column == null ? rows[0] : rows[0][column];
4965
+ if (!__sbD1Strict()) {
4966
+ const rows = query(sql, params, false).results;
4967
+ if (rows.length === 0) return null;
4968
+ return column == null ? rows[0] : rows[0][column];
4969
+ }
4970
+ return __sbD1Promise(() => {
4971
+ const t = query(sql, params, true);
4972
+ if (column == null) return t.rows.length ? __sbD1Objects(t.columns, [t.rows[0]])[0] : null;
4973
+ const at = t.columns.indexOf(String(column));
4974
+ if (!t.rows.length) return null;
4975
+ if (at < 0) throw __sbD1Fail("D1_COLUMN_NOTFOUND: Column not found (" + column + ")");
4976
+ return t.rows[0][t.columns.lastIndexOf(String(column))];
4977
+ });
4841
4978
  },
4842
4979
  };
4843
4980
  return s;
4844
4981
  }
4845
- return {
4982
+ function batch(statements) {
4983
+ const strict = __sbD1Strict();
4984
+ const list = [];
4985
+ for (let i = 0; i < (statements || []).length; i++) {
4986
+ if (strict) __sbD1Check(statements[i].__sql);
4987
+ list.push({ sql: statements[i].__sql, params: statements[i].__params });
4988
+ }
4989
+ if (strict && !list.length) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
4990
+ const started = Date.now();
4991
+ const r = __sbD1Rpc("d1.batch", { db: dbName, statements: list }, strict);
4992
+ const out = [];
4993
+ for (let i = 0; i < (r.results || []).length; i++) {
4994
+ if (!strict) {
4995
+ out.push(__sbD1Result(r.results[i], false));
4996
+ continue;
4997
+ }
4998
+ const t = __sbD1Table(r.results[i]);
4999
+ out.push({ success: true, meta: __sbD1Meta(r.results[i].meta || {}, started), results: __sbD1Objects(t.columns, t.rows) });
5000
+ }
5001
+ return out;
5002
+ }
5003
+ function exec(sql) {
5004
+ if (!__sbD1Strict()) {
5005
+ __sbRpc("d1.exec", { db: dbName, sql: String(sql) });
5006
+ return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
5007
+ }
5008
+ // workerd runs exec() line by line and counts the lines.
5009
+ const started = Date.now();
5010
+ const all = String(sql).trim().split("\n");
5011
+ const lines = [];
5012
+ for (let i = 0; i < all.length; i++) if (all[i].trim()) lines.push(all[i]);
5013
+ if (!lines.length) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
5014
+ __sbD1Rpc("d1.exec", { db: dbName, sql: lines.join("\n"), lines }, true);
5015
+ return { count: lines.length, duration: Date.now() - started };
5016
+ }
5017
+ const db = {
4846
5018
  prepare(sql) {
4847
5019
  return stmt(String(sql), []);
4848
5020
  },
4849
5021
  batch(statements) {
4850
- const list = [];
4851
- for (let i = 0; i < (statements || []).length; i++)
4852
- list.push({ sql: statements[i].__sql, params: statements[i].__params });
4853
- const r = __sbRpc("d1.batch", { db: dbName, statements: list });
4854
- const out = [];
4855
- for (let i = 0; i < (r.results || []).length; i++)
4856
- out.push({ results: r.results[i].results || [], success: true, meta: r.results[i].meta || {} });
4857
- return out;
5022
+ if (!__sbD1Strict()) return batch(statements);
5023
+ return __sbD1Promise(() => batch(statements));
4858
5024
  },
4859
5025
  exec(sql) {
4860
- __sbRpc("d1.exec", { db: dbName, sql: String(sql) });
4861
- return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
5026
+ if (!__sbD1Strict()) return exec(sql);
5027
+ return __sbD1Promise(() => exec(sql));
5028
+ },
5029
+ // workerd's D1 sessions (read replication). One database here, so a session is the
5030
+ // database itself, and there is never a bookmark to return.
5031
+ withSession() {
5032
+ return {
5033
+ prepare: db.prepare,
5034
+ batch: db.batch,
5035
+ getBookmark() {
5036
+ return null;
5037
+ },
5038
+ };
5039
+ },
5040
+ dump() {
5041
+ return Promise.reject(__sbD1Fail("D1_DUMP_ERROR: dump() is not supported; use backup()"));
4862
5042
  },
4863
5043
  // Sproutboat extension (not in CF Workers D1): an online, integrity-checked
4864
5044
  // snapshot of this database. Both transports implement it: standalone writes
@@ -4869,6 +5049,7 @@ function __sbMakeD1(dbName) {
4869
5049
  return { path: r.path, bytes: r.bytes };
4870
5050
  },
4871
5051
  };
5052
+ return db;
4872
5053
  }
4873
5054
 
4874
5055
  // R2: a Cloudflare-shaped object. When `body` is present the sync accessors
@@ -57,6 +57,10 @@ extern int sqlite3_changes(sqlite3*);
57
57
  extern int64_t sqlite3_last_insert_rowid(sqlite3*);
58
58
  extern const char* sqlite3_errmsg(sqlite3*);
59
59
  extern int sqlite3_close(sqlite3*);
60
+ extern int sqlite3_bind_parameter_count(sqlite3_stmt*);
61
+ extern int sqlite3_stmt_readonly(sqlite3_stmt*);
62
+ extern int sqlite3_extended_errcode(sqlite3*);
63
+ extern int sqlite3_error_offset(sqlite3*);
60
64
 
61
65
  // baronunread/sproutboat#176 — defined in render.js, alongside
62
66
  // porf_native_fetch_read_value (which every inline-C block below already
@@ -1293,6 +1297,19 @@ static void sb_putjson_text(sb_buf* b, const char* s, size_t n) {
1293
1297
  // binds as NULL rather than guessing.
1294
1298
  static const char* sb_skip_ws(const char* p) { while (*p == ' ' || *p == '\n' || *p == '\t' || *p == '\r') p++; return p; }
1295
1299
 
1300
+ static int sb_hex_nibble(char c) { return c >= 'a' ? c - 'a' + 10 : c >= 'A' ? c - 'A' + 10 : c - '0'; }
1301
+ // Past one JSON object (strings and nesting respected), or to the end of the text.
1302
+ static const char* sb_json_skip_object(const char* p) {
1303
+ int depth = 0, quoted = 0;
1304
+ for (; *p; p++) {
1305
+ if (quoted) { if (*p == '\\' && p[1]) p++; else if (*p == '"') quoted = 0; continue; }
1306
+ if (*p == '"') quoted = 1;
1307
+ else if (*p == '{' || *p == '[') depth++;
1308
+ else if ((*p == '}' || *p == ']') && --depth == 0) return p + 1;
1309
+ }
1310
+ return p;
1311
+ }
1312
+
1296
1313
  static int sb_bind_params(sqlite3_stmt* st, const char* json) {
1297
1314
  const char* p = sb_skip_ws(json);
1298
1315
  if (*p != '[') return 0;
@@ -1338,6 +1355,25 @@ static int sb_bind_params(sqlite3_stmt* st, const char* json) {
1338
1355
  free(out);
1339
1356
  continue;
1340
1357
  }
1358
+ // {"$blob":"HEX"}: the bytes of a D1 bind (an ArrayBuffer or a typed array), bound
1359
+ // as a BLOB. Any other object binds NULL. Before this an object stopped the parse,
1360
+ // so a Uint8Array and every parameter after it were silently stored as NULL.
1361
+ if (*p == '{') {
1362
+ const char* end = sb_json_skip_object(p);
1363
+ const char* hex = strstr(p, "\"$blob\":\"");
1364
+ if (hex && hex < end) {
1365
+ hex += 9;
1366
+ size_t n = 0;
1367
+ while (hex + 2 * n < end && hex[2 * n] != '"' && hex[2 * n + 1] != '"') n++;
1368
+ unsigned char* bytes = (unsigned char*)malloc(n ? n : 1);
1369
+ for (size_t i = 0; i < n; i++)
1370
+ bytes[i] = (unsigned char)(sb_hex_nibble(hex[2 * i]) << 4 | sb_hex_nibble(hex[2 * i + 1]));
1371
+ sqlite3_bind_blob(st, index++, bytes, (int)n, SB_SQLITE_TRANSIENT);
1372
+ free(bytes);
1373
+ } else sqlite3_bind_null(st, index++);
1374
+ p = end;
1375
+ continue;
1376
+ }
1341
1377
  // number (or true/false, bound as 1/0)
1342
1378
  if (*p == 't') { sqlite3_bind_double(st, index++, 1); p += 4; continue; }
1343
1379
  if (*p == 'f') { sqlite3_bind_double(st, index++, 0); p += 5; continue; }
@@ -1349,7 +1385,7 @@ static int sb_bind_params(sqlite3_stmt* st, const char* json) {
1349
1385
  p = endp;
1350
1386
  }
1351
1387
  }
1352
- return 0;
1388
+ return index - 1; // how many were bound
1353
1389
  }
1354
1390
 
1355
1391
  // --- R2 bodies, without the JSON detour or the SQLite detour ----------------
@@ -1909,6 +1945,14 @@ size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* ctx, size_t offset, char
1909
1945
  }
1910
1946
  void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx->file) fclose(ctx->file); free(ctx); } }
1911
1947
 
1948
+ // LTX capture (#141) failed after SQLite committed: the write is in the database but not in
1949
+ // the replication log, and the database refuses writes until the process restarts. Said as
1950
+ // such (SQLITE_IOERR), never as SQLite's own message, which still reads "not an error".
1951
+ #define SB_LTX_FAILED "LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart"
1952
+ static int sb_db_capture_failed(const char* path) {
1953
+ for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
1954
+ return 0;
1955
+ }
1912
1956
  // Run a script: one or more statements separated by semicolons. sqlite3_exec
1913
1957
  // handles the whole string, which prepare/step does not — it compiles the first
1914
1958
  // statement and silently ignores the rest, so a schema built from one exec call
@@ -1916,9 +1960,10 @@ void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx
1916
1960
  static char* sb_sql_script(const char* path, const char* sql) {
1917
1961
  sb_buf b = { 0, 0, 0 };
1918
1962
  int idx = sb_db_for(path);
1963
+ if (idx < 0 && sb_db_capture_failed(path)) { sb_puts(&b, "{\"ok\":false,\"error\":\"" SB_LTX_FAILED "\",\"code\":10}"); return b.p; }
1919
1964
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
1920
1965
  char* err = 0;
1921
- int script_rc = 0;
1966
+ int script_rc = 0, capture_failed = 0;
1922
1967
  if (sb_ltx_states[idx].db) {
1923
1968
  const char *next = sql;
1924
1969
  while (*next && !script_rc) {
@@ -1926,38 +1971,60 @@ static char* sb_sql_script(const char* path, const char* sql) {
1926
1971
  script_rc = sqlite3_prepare_v2(sb_dbs[idx], next, -1, &st, &tail);
1927
1972
  if (!script_rc && st) { do { script_rc = sqlite3_step(st); } while (script_rc == SB_SQLITE_ROW); if (script_rc == SB_SQLITE_DONE) script_rc = 0; }
1928
1973
  if (st) sqlite3_finalize(st);
1929
- if (sb_ltx_after(idx)) script_rc = 10;
1974
+ if (sb_ltx_after(idx)) { script_rc = 10; capture_failed = 1; }
1930
1975
  if (!tail || tail <= next) break;
1931
1976
  next = tail;
1932
1977
  }
1933
1978
  } else script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
1934
1979
  if (script_rc != 0) {
1980
+ const char* m = capture_failed ? SB_LTX_FAILED : err ? err : sqlite3_errmsg(sb_dbs[idx]);
1935
1981
  sb_puts(&b, "{\"ok\":false,\"error\":");
1936
- sb_putjson_text(&b, err ? err : sqlite3_errmsg(sb_dbs[idx]), err ? strlen(err) : strlen(sqlite3_errmsg(sb_dbs[idx])));
1937
- sb_puts(&b, "}");
1982
+ sb_putjson_text(&b, m, strlen(m));
1983
+ sb_puts(&b, capture_failed ? ",\"code\":10}" : "}");
1938
1984
  return b.p;
1939
1985
  }
1940
1986
  sb_puts(&b, "{\"ok\":true}");
1941
1987
  return b.p;
1942
1988
  }
1943
1989
 
1990
+ // An error reply with SQLite's extended result code and, when SQLite knows it, the byte
1991
+ // offset of the error in the SQL (D1 reports both). A message of our own comes with its
1992
+ // own code (code < 0: take SQLite's).
1993
+ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip) {
1994
+ sb_buf e = { 0, 0, 0 };
1995
+ const char* m = message ? message : sqlite3_errmsg(db);
1996
+ sb_puts(&e, "{\"ok\":false,\"error\":");
1997
+ sb_putjson_text(&e, m, strlen(m));
1998
+ char tail[64];
1999
+ int offset = message ? -1 : sqlite3_error_offset(db);
2000
+ int k = snprintf(tail, sizeof(tail), ",\"code\":%d,\"offset\":%d}", code >= 0 ? code : sqlite3_extended_errcode(db), offset >= skip ? offset - skip : -1);
2001
+ sb_put(&e, tail, (size_t)k);
2002
+ return e.p;
2003
+ }
2004
+
2005
+ // D1 calls from compatibility date 2026-10-24 put this comment first: the statement must
2006
+ // get exactly as many parameters as it has, like D1.
2007
+ #define SB_D1_STRICT "/*sb-d1-strict*/"
2008
+
1944
2009
  // Run one statement. Returns malloc'd JSON:
1945
2010
  // {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
1946
- // or {"ok":false,"error":"..."}.
2011
+ // or {"ok":false,"error":"...","code":n,"offset":n}. In a D1 database (a path under
2012
+ // /d1/) a BLOB comes back as {"$blob":"HEX"}; elsewhere as one char per byte.
1947
2013
  static char* sb_sql_run(const char* path, const char* sql, const char* params) {
1948
2014
  sb_buf b = { 0, 0, 0 };
1949
2015
  int idx = sb_db_for(path);
2016
+ if (idx < 0 && sb_db_capture_failed(path)) return sb_sql_error(0, SB_LTX_FAILED, 10, 0);
1950
2017
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
1951
2018
  sqlite3* db = sb_dbs[idx];
2019
+ const int strict = strncmp(sql, SB_D1_STRICT, sizeof(SB_D1_STRICT) - 1) == 0;
2020
+ const int d1 = strstr(path, "/d1/") != 0;
1952
2021
  sqlite3_stmt* st = sb_stmt_get(idx, db, sql);
1953
- if (!st) {
1954
- sb_puts(&b, "{\"ok\":false,\"error\":");
1955
- const char* m = sqlite3_errmsg(db);
1956
- sb_putjson_text(&b, m, strlen(m));
1957
- sb_puts(&b, "}");
1958
- return b.p;
2022
+ if (!st) return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2023
+ int bound = params && *params ? sb_bind_params(st, params) : 0;
2024
+ if (strict && bound != sqlite3_bind_parameter_count(st)) {
2025
+ sqlite3_reset(st);
2026
+ return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
1959
2027
  }
1960
- if (params && *params) sb_bind_params(st, params);
1961
2028
 
1962
2029
  sb_puts(&b, "{\"ok\":true,\"cols\":[");
1963
2030
  int ncol = sqlite3_column_count(st);
@@ -1984,27 +2051,32 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
1984
2051
  }
1985
2052
  const unsigned char* txt = sqlite3_column_text(st, i);
1986
2053
  int n = sqlite3_column_bytes(st, i);
1987
- // TEXT is UTF-8 and decodes (#189); a BLOB stays one char per byte.
1988
- if (t == 4) sb_putjson(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
2054
+ // TEXT is UTF-8 and decodes (#189); a BLOB stays one char per byte, or is hex in D1.
2055
+ if (t == 4 && d1) {
2056
+ static const char* digits = "0123456789abcdef";
2057
+ const unsigned char* bytes = (const unsigned char*)sqlite3_column_blob(st, i);
2058
+ sb_puts(&b, "{\"$blob\":\"");
2059
+ for (int j = 0; j < n; j++) { char pair[2] = { digits[bytes[j] >> 4], digits[bytes[j] & 15] }; sb_put(&b, pair, 2); }
2060
+ sb_puts(&b, "\"}");
2061
+ }
2062
+ else if (t == 4) sb_putjson(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
1989
2063
  else sb_putjson_text(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
1990
2064
  }
1991
2065
  sb_puts(&b, "]");
1992
2066
  }
1993
2067
  char tail[96];
1994
- int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
2068
+ // A read-only statement changed nothing: sqlite3_changes still holds the last write's count.
2069
+ int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
1995
2070
  sb_put(&b, tail, (size_t)k);
1996
2071
  // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
1997
2072
  // now (not just on next reuse) so a SELECT stops holding its read transaction.
1998
2073
  sqlite3_reset(st);
1999
- if (sb_ltx_after(idx)) rc = 10;
2074
+ const int capture_failed = sb_ltx_after(idx) != 0;
2075
+ if (capture_failed) rc = 10;
2000
2076
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
2001
2077
  free(b.p);
2002
- sb_buf e = { 0, 0, 0 };
2003
- sb_puts(&e, "{\"ok\":false,\"error\":");
2004
- const char* m = sqlite3_errmsg(db);
2005
- sb_putjson_text(&e, m, strlen(m));
2006
- sb_puts(&e, "}");
2007
- return e.p;
2078
+ if (capture_failed) return sb_sql_error(db, SB_LTX_FAILED, 10, 0);
2079
+ return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2008
2080
  }
2009
2081
  return b.p;
2010
2082
  }
@@ -3513,19 +3585,20 @@ function __sbEmbeddedDispatch(msg) {
3513
3585
  if (op === "d1.exec") {
3514
3586
  // exec() takes a script; a single prepare would run only its first
3515
3587
  // statement and quietly drop the rest.
3588
+ __sbD1Open(path, msg.strict === true);
3589
+ if (msg.strict === true && Array.isArray(msg.lines)) {
3590
+ __sbD1ExecLines(path, msg.lines);
3591
+ return { ok: true };
3592
+ }
3516
3593
  const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
3517
3594
  if (reply.ok === false) throw new Error("sqlite: " + reply.error);
3518
3595
  return { ok: true };
3519
3596
  }
3520
- if (op === "d1.batch") {
3521
- const results = [];
3522
- const list = msg.statements || [];
3523
- for (let i = 0; i < list.length; i++)
3524
- results.push(__sbD1Run(path, list[i].sql, list[i].params));
3525
- return { ok: true, results };
3526
- }
3527
- const one = __sbD1Run(path, msg.sql, msg.params);
3528
- return { ok: true, results: one.results, meta: one.meta, success: true };
3597
+ const strict = msg.strict === true;
3598
+ __sbD1Open(path, strict);
3599
+ if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
3600
+ const one = __sbD1Run(path, msg.sql, msg.params, strict);
3601
+ return { ok: true, results: one.results, columns: one.columns, rows: one.rows, meta: one.meta, success: true };
3529
3602
  }
3530
3603
 
3531
3604
  if (op === "d1.backup") {
@@ -3979,16 +4052,111 @@ function __sbEmbeddedDispatch(msg) {
3979
4052
  }
3980
4053
 
3981
4054
  /** One D1 statement, shaped like the broker's d1Run. */
3982
- function __sbD1Run(path, sql, params) {
3983
- const r = __sbSql(path, String(sql), params || []);
4055
+ // BEGIN D1 OPS (lifted by test/native-d1.ts)
4056
+ // D1 calls from compatibility date 2026-10-24 come with msg.strict (the shim sets it):
4057
+ // exact parameter counts, foreign keys enforced, D1's error text, and rows as arrays with
4058
+ // their column names (the shim builds the objects, so duplicate names survive in raw()).
4059
+ // A BLOB comes from C as {"$blob":"HEX"}; strict calls get it so, older ones get the one
4060
+ // char per byte string they always had.
4061
+ var __sbD1Opened = {};
4062
+ function __sbD1Open(path, strict) {
4063
+ if (__sbD1Opened[path] === strict) return;
4064
+ __sbD1Opened[path] = strict;
4065
+ __sbSql(path, strict ? "PRAGMA foreign_keys = ON" : "PRAGMA foreign_keys = OFF", []);
4066
+ }
4067
+ var __sbSqliteCodes = ["", "SQLITE_ERROR", "SQLITE_INTERNAL", "SQLITE_PERM", "SQLITE_ABORT", "SQLITE_BUSY",
4068
+ "SQLITE_LOCKED", "SQLITE_NOMEM", "SQLITE_READONLY", "SQLITE_INTERRUPT", "SQLITE_IOERR", "SQLITE_CORRUPT",
4069
+ "SQLITE_NOTFOUND", "SQLITE_FULL", "SQLITE_CANTOPEN", "SQLITE_PROTOCOL", "SQLITE_EMPTY", "SQLITE_SCHEMA",
4070
+ "SQLITE_TOOBIG", "SQLITE_CONSTRAINT", "SQLITE_MISMATCH", "SQLITE_MISUSE", "SQLITE_NOLFS", "SQLITE_AUTH",
4071
+ "SQLITE_FORMAT", "SQLITE_RANGE", "SQLITE_NOTADB"];
4072
+ var __sbSqliteConstraints = { 275: "CHECK", 531: "COMMITHOOK", 787: "FOREIGNKEY", 1043: "FUNCTION",
4073
+ 1299: "NOTNULL", 1555: "PRIMARYKEY", 1811: "TRIGGER", 2067: "UNIQUE", 2323: "VTAB", 2579: "ROWID",
4074
+ 2835: "PINNED", 3091: "DATATYPE" };
4075
+ function __sbD1Error(reply) {
4076
+ const code = Number(reply.code) || 0;
4077
+ const base = __sbSqliteCodes[code & 255] || "";
4078
+ let text = "D1_ERROR: " + reply.error;
4079
+ if (reply.offset >= 0) text += " at offset " + reply.offset;
4080
+ if (base) {
4081
+ text += ": " + base;
4082
+ if (__sbSqliteConstraints[code]) text += " (extended: SQLITE_CONSTRAINT_" + __sbSqliteConstraints[code] + ")";
4083
+ }
4084
+ return new Error(text);
4085
+ }
4086
+ function __sbD1Bytes(cell) {
4087
+ let out = "";
4088
+ const hex = String(cell.$blob);
4089
+ for (let i = 0; i + 1 < hex.length; i += 2) out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
4090
+ return out;
4091
+ }
4092
+ function __sbD1Run(path, sql, params, strict) {
4093
+ const r = JSON.parse(__sbSqlRaw(path, (strict ? "/*sb-d1-strict*/" : "") + String(sql), JSON.stringify(params || [])));
4094
+ if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4095
+ const meta = { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length };
4096
+ if (strict) return { columns: r.cols, rows: r.rows, meta };
3984
4097
  const results = [];
3985
4098
  for (let i = 0; i < r.rows.length; i++) {
3986
4099
  const row = {};
3987
- for (let c = 0; c < r.cols.length; c++) row[r.cols[c]] = r.rows[i][c];
4100
+ for (let c = 0; c < r.cols.length; c++) {
4101
+ const cell = r.rows[i][c];
4102
+ row[r.cols[c]] = cell !== null && Object(cell) === cell ? __sbD1Bytes(cell) : cell;
4103
+ }
3988
4104
  results.push(row);
3989
4105
  }
3990
- return { results, meta: { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length } };
4106
+ return { results, meta };
4107
+ }
4108
+ // One statement of our own (SAVEPOINT, RELEASE, ...), failing in the caller's terms.
4109
+ function __sbD1Control(path, sql, strict) {
4110
+ const r = JSON.parse(__sbSqlRaw(path, sql, "[]"));
4111
+ if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4112
+ }
4113
+ // Undo after a failure, best effort: the error the caller sees is the first one. If LTX
4114
+ // capture failed, SQLite may already have committed; that error says so, and the database
4115
+ // stays closed to writes (the capture latch), so a cleanup failure must not replace it.
4116
+ function __sbD1Undo(path, savepoint) {
4117
+ try {
4118
+ __sbD1Control(path, "ROLLBACK TO " + savepoint, false);
4119
+ __sbD1Control(path, "RELEASE " + savepoint, false);
4120
+ } catch (ignored) {
4121
+ return ignored;
4122
+ }
4123
+ return null;
4124
+ }
4125
+ // One transaction, like D1: a statement that fails undoes the ones before it.
4126
+ function __sbD1Batch(path, list, strict) {
4127
+ const results = [];
4128
+ __sbD1Control(path, "SAVEPOINT sb_d1_batch", strict);
4129
+ try {
4130
+ for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params, strict));
4131
+ } catch (err) {
4132
+ __sbD1Undo(path, "sb_d1_batch");
4133
+ throw err;
4134
+ }
4135
+ __sbD1Release(path, "sb_d1_batch", strict);
4136
+ return results;
4137
+ }
4138
+ // RELEASE of our outermost savepoint is the commit. A deferred constraint can fail it and
4139
+ // leave the transaction open: undo it. A capture failure (code 10) comes after SQLite has
4140
+ // committed: nothing to undo, and that error is the one to report.
4141
+ function __sbD1Release(path, savepoint, strict) {
4142
+ const r = JSON.parse(__sbSqlRaw(path, "RELEASE " + savepoint, "[]"));
4143
+ if (r.ok !== false) return;
4144
+ if (Number(r.code) !== 10) __sbD1Undo(path, savepoint);
4145
+ throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4146
+ }
4147
+ // exec() from 2026-10-24: line by line as workerd runs it, all or nothing.
4148
+ function __sbD1ExecLines(path, lines) {
4149
+ __sbD1Control(path, "SAVEPOINT sb_d1_exec", true);
4150
+ for (let i = 0; i < lines.length; i++) {
4151
+ const reply = JSON.parse(__sbSqlScriptRaw(path, String(lines[i])));
4152
+ if (reply.ok === false) {
4153
+ if (Number(reply.code) !== 10) __sbD1Undo(path, "sb_d1_exec");
4154
+ throw new Error("D1_EXEC_ERROR: Error in line " + (i + 1) + ": " + lines[i] + ": " + reply.error);
4155
+ }
4156
+ }
4157
+ __sbD1Release(path, "sb_d1_exec", true);
3991
4158
  }
4159
+ // END D1 OPS
3992
4160
 
3993
4161
  /**
3994
4162
  * R2 object bodies, out of band (#56).
@@ -0,0 +1,300 @@
1
+ import assert from "node:assert/strict";
2
+ import { closeSync, openSync, readFileSync, readdirSync, readSync, writeSync } from "node:fs";
3
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ // The embedded transport's SQL runner (sb_sql_run and sb_bind_params), compiled from the
8
+ // transport's own inline C against the system SQLite, the way native-ltx.ts does. Only
9
+ // the C up to the D1 backup code is needed; the few runtime symbols it names are stubbed.
10
+ const transport = await readFile(new URL("../src/transport-embedded.js", import.meta.url), "utf8");
11
+ const start = transport.indexOf("Porffor.c`\n") + "Porffor.c`\n".length;
12
+ const end = transport.indexOf("// #164 — an online");
13
+ assert(start > 20 && end > start, "transport C block markers moved");
14
+ const work = await mkdtemp(join(tmpdir(), "sb-native-d1-"));
15
+ try {
16
+ await writeFile(
17
+ join(work, "bearssl.h"),
18
+ `typedef struct { int x; } br_sha256_context;
19
+ static void br_sha256_init(br_sha256_context* c) { (void)c; }
20
+ static void br_sha256_update(br_sha256_context* c, const void* d, unsigned long n) { (void)c; (void)d; (void)n; }
21
+ static void br_sha256_out(const br_sha256_context* c, void* o) { (void)c; (void)o; }
22
+ `,
23
+ );
24
+ await writeFile(join(work, "block.c"), transport.slice(start, end));
25
+ await writeFile(
26
+ join(work, "driver.c"),
27
+ `#include <stdint.h>
28
+ #include <stdio.h>
29
+ #include <stdlib.h>
30
+ #include <string.h>
31
+ #include <unistd.h>
32
+ #include <fcntl.h>
33
+ #include <time.h>
34
+ #include <sys/stat.h>
35
+ typedef struct { double val; int type; } jsval;
36
+ typedef uint32_t u32;
37
+ static uint32_t porf_native_fetch_alloc_bytestring(const char* d, size_t n) { (void)d; (void)n; return 0; }
38
+ #include "block.c"
39
+ const char* sb_standalone_app_name(void) { return "test"; }
40
+ // One call per line: PATH \\t SQL \\t PARAMS_JSON; one reply line each. ":break PATH" makes
41
+ // PATH's next LTX capture fail (native-ltx.ts's :break-position).
42
+ int main(void) {
43
+ sb_ltx_compat = 1; // __sbLtxConfigure at compatibility date 2026-10-22 or later
44
+ char* line = 0; size_t cap = 0;
45
+ while (getline(&line, &cap, stdin) > 0) {
46
+ line[strcspn(line, "\\n")] = 0;
47
+ if (!strncmp(line, ":break ", 7)) { line[strcspn(line, "\\t")] = 0; char p[1024]; snprintf(p, sizeof(p), "%s.ltx/position.tmp", line + 7); mkdir(p, 0700); puts("{}"); fflush(stdout); continue; }
48
+ char* sql = strchr(line, '\\t'); if (!sql) return 2; *sql++ = 0;
49
+ char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
50
+ // ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
51
+ char* out = !strncmp(line, ":script ", 8) ? sb_sql_script(line + 8, sql) : sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
52
+ }
53
+ return 0;
54
+ }
55
+ `,
56
+ );
57
+ const binary = join(work, "driver");
58
+ const compile = Bun.spawnSync(["cc", "-O1", "-w", "-I", work, join(work, "driver.c"), "-lsqlite3", "-o", binary]);
59
+ assert.equal(compile.exitCode, 0, compile.stderr.toString());
60
+
61
+ const d1 = join(work, "data/d1/DB.sqlite");
62
+ const store = join(work, "data/store.sqlite");
63
+ const run = (calls: [string, string, unknown[]][]) => {
64
+ const input = calls.map(([path, sql, params]) => `${path}\t${sql}\t${JSON.stringify(params)}\n`).join("");
65
+ const p = Bun.spawnSync([binary], { stdin: Buffer.from(input) });
66
+ assert.equal(p.exitCode, 0, p.stderr.toString());
67
+ return p.stdout
68
+ .toString()
69
+ .trim()
70
+ .split("\n")
71
+ .map((l) => JSON.parse(l));
72
+ };
73
+ const blob = (bytes: number[]) => ({ $blob: Buffer.from(bytes).toString("hex") });
74
+
75
+ // A Uint8Array bind used to stop the parameter parse: it, and every parameter after it,
76
+ // were stored as NULL (data loss).
77
+ const [, , stored, other] = run([
78
+ [d1, "CREATE TABLE t (a TEXT, b BLOB, c TEXT)", []],
79
+ [d1, "INSERT INTO t VALUES (?1, ?2, ?3)", ["k", blob([0, 255, 127, 0x22]), "after"]],
80
+ [d1, "SELECT a, b, typeof(b) AS bt, length(b) AS bl, c FROM t", []],
81
+ [store, "SELECT x'00ff' AS b", []],
82
+ ]);
83
+ assert.deepEqual(
84
+ stored.rows,
85
+ [["k", { $blob: "00ff7f22" }, "blob", 4, "after"]],
86
+ "blob stored as a BLOB, later parameters kept",
87
+ );
88
+ assert.equal(other.rows[0][0], String(other.rows[0][0]), "outside D1 a BLOB is still one char per byte, not hex");
89
+
90
+ // An object that is not a blob binds NULL and the parse carries on (a "}" in a string included).
91
+ const [, kept] = run([
92
+ [d1, "INSERT INTO t VALUES (?1, ?2, ?3)", [{ a: "}{" }, 5, "x"]],
93
+ [d1, "SELECT a, b, c FROM t WHERE c = 'x'", []],
94
+ ]);
95
+ assert.deepEqual(kept.rows, [[null, 5, "x"]]);
96
+
97
+ // Strict D1 calls (compatibility date 2026-10-24) check the parameter count.
98
+ const [few, many, right, reuse] = run([
99
+ [d1, "/*sb-d1-strict*/SELECT ?1, ?2", [1]],
100
+ [d1, "/*sb-d1-strict*/SELECT ?1", [1, 2]],
101
+ [d1, "/*sb-d1-strict*/SELECT ?1 AS a", [1]],
102
+ [d1, "/*sb-d1-strict*/SELECT ?1 AS a, ?1 AS b, ?2 AS c", ["x", "y"]],
103
+ ]);
104
+ assert.equal(few.ok, false);
105
+ assert.equal(few.error, "Wrong number of parameter bindings for SQL query.");
106
+ assert.equal(many.ok, false);
107
+ assert.deepEqual(right.rows, [[1]]);
108
+ assert.deepEqual(reuse.rows, [["x", "x", "y"]]);
109
+ const [loose] = run([[d1, "SELECT ?1 AS a, ?2 AS b", [1]]]);
110
+ assert.deepEqual(loose.rows, [[1, null]], "without the marker a missing parameter is NULL, as before");
111
+
112
+ // Errors carry SQLite's extended code and the offset in the caller's SQL.
113
+ const [syntax, unique] = run([
114
+ [d1, "/*sb-d1-strict*/SELEC 1", []],
115
+ [d1, "CREATE TABLE u (id INTEGER PRIMARY KEY); INSERT INTO u VALUES (1)", []],
116
+ ]);
117
+ assert.equal(syntax.ok, false);
118
+ assert.equal(syntax.code, 1);
119
+ assert.equal(syntax.offset, 0);
120
+ assert.equal(unique.ok, true);
121
+ const [, dup] = run([
122
+ [d1, "INSERT INTO u VALUES (1)", []],
123
+ [d1, "INSERT INTO u VALUES (1)", []],
124
+ ]);
125
+ assert.equal(dup.ok, false);
126
+ assert.equal(dup.code, 1555, "SQLITE_CONSTRAINT_PRIMARYKEY");
127
+
128
+ // A read reports no changes, not the last write's.
129
+ const [write, read] = run([
130
+ [d1, "INSERT INTO t (a) VALUES ('z')", []],
131
+ [d1, "SELECT count(*) FROM t", []],
132
+ ]);
133
+ assert.equal(write.changes, 1);
134
+ assert.equal(read.changes, 0);
135
+ // The transport's D1 ops (JS), on the same C: lifted from BEGIN/END D1 OPS.
136
+ const opsStart = transport.indexOf("// BEGIN D1 OPS");
137
+ const opsEnd = transport.indexOf("// END D1 OPS");
138
+ assert(opsStart > 0 && opsEnd > opsStart, "D1 ops markers moved");
139
+ // One long-lived driver, so a SAVEPOINT spans calls as it does in a worker: FIFOs give
140
+ // the lifted (synchronous) ops a synchronous line protocol.
141
+ const fifoIn = join(work, "in"),
142
+ fifoOut = join(work, "out");
143
+ assert.equal(Bun.spawnSync(["mkfifo", fifoIn, fifoOut]).exitCode, 0);
144
+ const driver = Bun.spawn(["sh", "-c", `exec "${binary}" < "${fifoIn}" > "${fifoOut}"`], {
145
+ env: { ...process.env, SB_LTX_ENABLE: "1", SB_LTX_DATABASE: "LTX" },
146
+ });
147
+ const toDriver = openSync(fifoIn, "w"),
148
+ fromDriver = openSync(fifoOut, "r");
149
+ let pending = "";
150
+ const sqlRaw = (path: string, sql: string, params: string) => {
151
+ writeSync(toDriver, `${path}\t${sql}\t${params}\n`);
152
+ const chunk = Buffer.alloc(65536);
153
+ while (!pending.includes("\n")) {
154
+ const n = readSync(fromDriver, chunk, 0, chunk.length, null);
155
+ if (n <= 0) throw new Error("driver exited");
156
+ pending += chunk.toString("utf8", 0, n);
157
+ }
158
+ const line = pending.slice(0, pending.indexOf("\n"));
159
+ pending = pending.slice(line.length + 1);
160
+ return line;
161
+ };
162
+ const sql = (path: string, text: string, params: unknown[]) => {
163
+ const r = JSON.parse(sqlRaw(path, text, JSON.stringify(params)));
164
+ if (r.ok === false) throw new Error("sqlite: " + r.error);
165
+ return r;
166
+ };
167
+ // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
168
+ const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
169
+ const ops = new Function(
170
+ "__sbSqlRaw",
171
+ "__sbSql",
172
+ "__sbSqlScriptRaw",
173
+ `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
174
+ )(sqlRaw, sql, scriptRaw);
175
+ // exec() from 2026-10-24: line by line, all or nothing, the failing line named.
176
+ const ex = join(work, "data/d1/EXEC.sqlite");
177
+ ops.__sbD1ExecLines(ex, ["CREATE TABLE x (id INTEGER)", "INSERT INTO x VALUES (1); INSERT INTO x VALUES (2);"]);
178
+ assert.throws(
179
+ () => ops.__sbD1ExecLines(ex, ["INSERT INTO x VALUES (3);", "SELEC 2"]),
180
+ (e: Error) => e.message === 'D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error',
181
+ );
182
+ assert.equal(ops.__sbD1Run(ex, "SELECT count(*) AS n FROM x", [], false).results[0].n, 2, "exec rolled back");
183
+ const db2 = join(work, "data/d1/OPS.sqlite");
184
+ ops.__sbD1Run(db2, "CREATE TABLE p (id INTEGER PRIMARY KEY)", [], false);
185
+ ops.__sbD1Run(db2, "CREATE TABLE c (id INTEGER PRIMARY KEY, p INTEGER REFERENCES p(id))", [], false);
186
+ // batch() is one transaction: the failing second insert undoes the first.
187
+ assert.throws(
188
+ () =>
189
+ ops.__sbD1Batch(
190
+ db2,
191
+ [
192
+ { sql: "INSERT INTO p VALUES (5)", params: [] },
193
+ { sql: "INSERT INTO p VALUES (5)", params: [] },
194
+ ],
195
+ false,
196
+ ),
197
+ /UNIQUE/,
198
+ );
199
+ assert.equal(ops.__sbD1Run(db2, "SELECT count(*) AS n FROM p", [], false).results[0].n, 0, "batch rolled back");
200
+ assert.equal(
201
+ ops.__sbD1Batch(
202
+ db2,
203
+ [
204
+ { sql: "INSERT INTO p VALUES (1)", params: [] },
205
+ { sql: "INSERT INTO p VALUES (2)", params: [] },
206
+ ],
207
+ false,
208
+ ).length,
209
+ 2,
210
+ );
211
+ // Old dates: no foreign keys, a BLOB read as one char per byte, errors as before.
212
+ ops.__sbD1Open(db2, false);
213
+ ops.__sbD1Run(db2, "INSERT INTO c VALUES (1, 99)", [], false);
214
+ const oldBlob = ops.__sbD1Run(db2, "SELECT ?1 AS b", [{ $blob: "00ff" }], false).results[0].b;
215
+ assert.equal(oldBlob, "\u0000ÿ");
216
+ assert.throws(() => ops.__sbD1Run(db2, "SELEC 1", [], false), /^Error: sqlite: near "SELEC": syntax error$/);
217
+ // Strict (2026-10-24): foreign keys on, rows with columns, D1's error text.
218
+ ops.__sbD1Open(db2, true);
219
+ assert.throws(
220
+ () => ops.__sbD1Run(db2, "INSERT INTO c VALUES (2, 98)", [], true),
221
+ (e: Error) =>
222
+ e.message ===
223
+ "D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
224
+ );
225
+ const strictRow = ops.__sbD1Run(db2, "SELECT 1 AS a, ?1 AS a", [{ $blob: "0a" }], true);
226
+ assert.deepEqual([strictRow.columns, strictRow.rows], [["a", "a"], [[1, { $blob: "0a" }]]]);
227
+ assert.throws(
228
+ () => ops.__sbD1Run(db2, "SELEC 1", [], true),
229
+ (e: Error) => e.message === 'D1_ERROR: near "SELEC": syntax error at offset 0: SQLITE_ERROR',
230
+ );
231
+ assert.throws(
232
+ () => ops.__sbD1Run(db2, "INSERT INTO p VALUES (1)", [], true),
233
+ (e: Error) =>
234
+ e.message ===
235
+ "D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
236
+ );
237
+ assert.throws(
238
+ () => ops.__sbD1Run(db2, "SELECT ?1", [], true),
239
+ (e: Error) => e.message === "D1_ERROR: Wrong number of parameter bindings for SQL query.",
240
+ );
241
+ // LTX capture on (SB_LTX_ENABLE, database LTX): a batch is one commit, and a capture
242
+ // that fails at RELEASE (after SQLite committed) surfaces as itself: SQLITE_IOERR with a
243
+ // message that says so, never "no such savepoint" from the cleanup, and the database then
244
+ // refuses writes until restart.
245
+ const ltx = join(work, "data/d1/LTX.sqlite");
246
+ const generation = () => join(ltx + ".ltx", readFileSync(ltx + ".ltx/position", "utf8").split("\n")[1]);
247
+ const ltxFiles = () => readdirSync(generation()).filter((n) => n.endsWith(".ltx")).length;
248
+ ops.__sbD1Open(ltx, true);
249
+ ops.__sbD1Run(ltx, "CREATE TABLE e (id INTEGER PRIMARY KEY, v TEXT)", [], true);
250
+ const beforeBatch = ltxFiles();
251
+ ops.__sbD1Batch(
252
+ ltx,
253
+ [
254
+ { sql: "INSERT INTO e VALUES (1, 'a')", params: [] },
255
+ { sql: "INSERT INTO e VALUES (2, 'b')", params: [] },
256
+ ],
257
+ true,
258
+ );
259
+ assert.equal(ltxFiles(), beforeBatch + 1, "a batch is one LTX commit");
260
+ // Break capture just before the batch's RELEASE.
261
+ // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
262
+ const breaking = new Function(
263
+ "__sbSqlRaw",
264
+ "__sbSql",
265
+ "__sbSqlScriptRaw",
266
+ `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
267
+ )((path: string, text: string, params: string) => {
268
+ if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
269
+ return sqlRaw(path, text, params);
270
+ }, sql, scriptRaw);
271
+ const LTX_FAILED =
272
+ "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR";
273
+ assert.throws(
274
+ () => breaking.__sbD1Batch(ltx, [{ sql: "INSERT INTO e VALUES (3, 'c')", params: [] }], true),
275
+ (e: Error) => e.message === LTX_FAILED,
276
+ );
277
+ assert.throws(
278
+ () => ops.__sbD1Run(ltx, "INSERT INTO e VALUES (4, 'd')", [], true),
279
+ (e: Error) => e.message === LTX_FAILED,
280
+ "writes refused after a capture failure",
281
+ );
282
+ assert.throws(
283
+ () => ops.__sbD1Run(ltx, "SELECT 1", [], false),
284
+ /^Error: sqlite: LTX capture failed/,
285
+ "old dates see the same failure",
286
+ );
287
+ assert.throws(
288
+ () => ops.__sbD1ExecLines(ltx, ["INSERT INTO e VALUES (5, 'e')"]),
289
+ (e: Error) => e.message === "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR",
290
+ "exec() after a capture failure",
291
+ );
292
+ closeSync(toDriver);
293
+ closeSync(fromDriver);
294
+ await driver.exited;
295
+ console.log(
296
+ "native d1: sb_sql_run and the D1 ops pass (blob binds, atomic batch, strict counts, foreign keys, error text)",
297
+ );
298
+ } finally {
299
+ await rm(work, { recursive: true, force: true });
300
+ }
@@ -247,6 +247,65 @@ try {
247
247
  assert.deepEqual(await readdir(join(root, epoch)), finishedBefore, "idle restart must not rewrite a base");
248
248
  assert.equal((await readdir(root)).filter((n) => n.startsWith("epoch-")).length, 1);
249
249
  }
250
+ // A D1 batch() (__sbD1Batch) is a SAVEPOINT around its statements, each its own call,
251
+ // then RELEASE: one transaction, so one LTX file with the next TXID. A rolled-back batch
252
+ // (ROLLBACK TO, then RELEASE) commits the pages it touched back unchanged: at most one
253
+ // more LTX file, and the restored database has none of its rows.
254
+ {
255
+ const batchPath = join(work, "batch.sqlite");
256
+ await run(batchPath, "CREATE TABLE b(id INTEGER PRIMARY KEY,value TEXT);\n");
257
+ const batchRoot = batchPath + ".ltx";
258
+ const batchEpoch = (await readdir(batchRoot)).find((n) => n.startsWith("epoch-"));
259
+ assert(batchEpoch);
260
+ const ltxFiles = async () => (await readdir(join(batchRoot, batchEpoch))).filter((n) => n.endsWith(".ltx")).sort();
261
+ const before = await ltxFiles();
262
+ const lastTxid = BigInt("0x" + before[before.length - 1].split("-")[0]);
263
+ const replies = await run(
264
+ batchPath,
265
+ "SAVEPOINT sb_d1_batch;\nINSERT INTO b VALUES(1,'one');\nINSERT INTO b VALUES(2,'two');\nRELEASE sb_d1_batch;\n",
266
+ );
267
+ assert(
268
+ replies.every((n) => n.startsWith("0 ")),
269
+ replies.join("\n"),
270
+ );
271
+ const after = await ltxFiles();
272
+ const added = after.filter((n) => !before.includes(n));
273
+ assert.equal(added.length, 1, "a batch is one LTX file: " + added.join(", "));
274
+ const [minTxid, maxTxid] = added[0]
275
+ .replace(".ltx", "")
276
+ .split("-")
277
+ .map((h) => BigInt("0x" + h));
278
+ assert.equal(minTxid, lastTxid + 1n);
279
+ assert.equal(maxTxid, lastTxid + 1n);
280
+ const rolledBack = await run(
281
+ batchPath,
282
+ "SAVEPOINT sb_d1_batch;\nINSERT INTO b VALUES(3,'three');\nROLLBACK TO sb_d1_batch;\nRELEASE sb_d1_batch;\n",
283
+ );
284
+ assert(
285
+ rolledBack.every((n) => n.startsWith("0 ")),
286
+ rolledBack.join("\n"),
287
+ );
288
+ const afterRollback = await ltxFiles();
289
+ assert(afterRollback.length <= after.length + 1, "a rolled-back batch writes at most one LTX file");
290
+ // If it writes one, it holds page 1 only (SQLite bumps the header of the page it had
291
+ // dirtied: the file change counter and version-valid-for number, bytes 24-27 and 92-95,
292
+ // so the pre- and post-apply checksums differ); the rows are checked below.
293
+ for (const name of afterRollback.filter((n) => !after.includes(n))) {
294
+ const b = new Uint8Array(await readFile(join(batchRoot, batchEpoch, name)));
295
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
296
+ assert.equal(v.getUint32(100), 1, "first page of the rolled-back batch's LTX file");
297
+ assert.equal(v.getUint32(106 + 4 + v.getUint32(106)), 0, "the rolled-back batch's LTX file holds one page");
298
+ }
299
+ const batchRestore = join(work, "batch-restored.sqlite");
300
+ await restore(join(batchRoot, batchEpoch), batchRestore);
301
+ assert.deepEqual(await readFile(batchRestore), await readFile(batchPath));
302
+ const check = new Database(batchRestore);
303
+ assert.deepEqual(check.query("SELECT id, value FROM b ORDER BY id").all(), [
304
+ { id: 1, value: "one" },
305
+ { id: 2, value: "two" },
306
+ ]);
307
+ check.close();
308
+ }
250
309
  // Size alone is not enough: an offline committed change must invalidate a clean marker.
251
310
  const offline = new Database(path);
252
311
  offline.exec("UPDATE t SET value='changed-offline' WHERE id=1");