@sproutboat/runtime 0.23.1 → 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,26 @@
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
+
3
24
  ## 0.23.1
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.23.1",
3
+ "version": "0.24.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,81 +34,121 @@ const caught = (fn) => {
34
34
  }
35
35
  return "did not throw";
36
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.";
37
48
 
38
49
  for (const date of ["2026-09-28", "2026-10-24"]) {
39
- test(`${date}: bytes bind as a BLOB, and the parameters after them are kept`, () => {
50
+ test(`${date}: bytes bind as a BLOB, and the parameters after them are kept`, async () => {
40
51
  const DB = fresh(date);
41
- DB.exec("CREATE TABLE t (a TEXT, b BLOB, c TEXT)");
42
- DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
43
- .bind("u8", new Uint8Array([0, 255, 7]), "after")
44
- .run();
45
- DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
46
- .bind("ab", new Uint8Array([1, 2]).buffer, "after")
47
- .run();
48
- DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
49
- .bind("view", new DataView(new Uint8Array([9, 8, 7]).buffer, 1, 2), "after")
50
- .run();
51
- expect(DB.prepare("SELECT a, hex(b) AS h, typeof(b) AS t, c FROM t ORDER BY rowid").all().results).toEqual([
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([
52
58
  { a: "u8", h: "00FF07", t: "blob", c: "after" },
53
59
  { a: "ab", h: "0102", t: "blob", c: "after" },
54
60
  { a: "view", h: "0807", t: "blob", c: "after" },
55
61
  ]);
56
62
  });
57
63
 
58
- test(`${date}: batch() is one transaction`, () => {
64
+ test(`${date}: batch() is one transaction`, async () => {
59
65
  const DB = fresh(date);
60
- DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
61
- expect(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")])).toThrow(
62
- /UNIQUE/,
63
- );
64
- expect(DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(0);
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);
65
69
  });
66
70
  }
67
71
 
68
- test("before 2026-10-24: lenient parameters, no foreign keys, BLOBs as one char per byte", () => {
72
+ test("before 2026-10-24: synchronous, lenient, no foreign keys, BLOBs as one char per byte", () => {
69
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 }]);
70
77
  // (bun:sqlite has always refused a wrong parameter count here; the embedded transport has not.)
71
78
  expect(DB.prepare("SELECT ?1 AS a").bind(undefined).first()).toEqual({ a: null });
72
- DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY); CREATE TABLE c (p INTEGER REFERENCES p(id))");
79
+ expect(DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY); CREATE TABLE c (p INTEGER REFERENCES p(id))").count).toBe(1);
73
80
  DB.prepare("INSERT INTO c VALUES (99)").run();
74
81
  expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toBe("\u0000ÿ");
82
+ expect(DB.prepare("SELECT 1 AS a").first("nope")).toBeUndefined();
75
83
  expect(caught(() => DB.prepare("SELEC 1").all())).toMatch(/^sproutboat d1\.query: /);
76
84
  });
77
85
 
78
- test("from 2026-10-24: D1's parameter rules", () => {
86
+ test("from 2026-10-24: D1's parameter rules", async () => {
79
87
  const DB = fresh("2026-10-24");
80
- expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe(
81
- "D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'",
82
- );
83
- expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe(
84
- "D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'",
85
- );
86
- expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe(
87
- "D1_TYPE_ERROR: Type 'bigint' not supported for value '10'",
88
- );
89
- expect(caught(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(
90
- "D1_ERROR: Wrong number of parameter bindings for SQL query.",
91
- );
92
- expect(caught(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(
93
- "D1_ERROR: Wrong number of parameter bindings for SQL query.",
94
- );
95
- expect(caught(() => DB.prepare("SELECT ?1").all())).toBe(
96
- "D1_ERROR: Wrong number of parameter bindings for SQL query.",
97
- );
98
- expect(DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({ a: "x", b: "x", c: 1 });
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 });
99
95
  });
100
96
 
101
- test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", () => {
97
+ test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", async () => {
102
98
  const DB = fresh("2026-10-24");
103
- DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY); CREATE TABLE c (p INTEGER REFERENCES p(id))");
104
- expect(caught(() => DB.prepare("INSERT INTO c VALUES (99)").run())).toBe(
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(
105
101
  "D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
106
102
  );
107
- DB.prepare("INSERT INTO p VALUES (1)").run();
108
- expect(caught(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
103
+ await DB.prepare("INSERT INTO p VALUES (1)").run();
104
+ expect(await failure(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
109
105
  "D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
110
106
  );
111
- expect(caught(() => DB.prepare("SELECT * FROM nope").all())).toBe("D1_ERROR: no such table: nope: SQLITE_ERROR");
112
- expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
113
- expect(DB.prepare("SELECT count(*) AS n FROM p").run().meta.changes).toBe(0);
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 }]);
114
154
  });
@@ -4869,7 +4869,62 @@ function __sbD1Result(r, strict) {
4869
4869
  if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
4870
4870
  return { results: __sbD1Objects(r.columns || [], __sbD1Rows(r)), success: true, meta: r.meta || {} };
4871
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
+ }
4872
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
+ }
4873
4928
  function stmt(sql, params) {
4874
4929
  const s = {
4875
4930
  __sql: sql,
@@ -4881,47 +4936,109 @@ function __sbMakeD1(dbName) {
4881
4936
  return stmt(sql, list);
4882
4937
  },
4883
4938
  all() {
4884
- const strict = __sbD1Strict();
4885
- return __sbD1Result(__sbD1Rpc("d1.query", { db: dbName, sql, params }, strict), strict);
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
+ });
4886
4944
  },
4887
4945
  run() {
4888
4946
  return s.all();
4889
4947
  },
4890
- raw() {
4891
- const rows = s.all().results;
4892
- const out = [];
4893
- for (let i = 0; i < rows.length; i++) {
4894
- const cols = [];
4895
- for (const k in rows[i]) cols.push(rows[i][k]);
4896
- 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;
4897
4958
  }
4898
- 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
+ });
4899
4963
  },
4900
4964
  first(column) {
4901
- const rows = s.all().results;
4902
- if (rows.length === 0) return null;
4903
- 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
+ });
4904
4978
  },
4905
4979
  };
4906
4980
  return s;
4907
4981
  }
4908
- 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 = {
4909
5018
  prepare(sql) {
4910
5019
  return stmt(String(sql), []);
4911
5020
  },
4912
5021
  batch(statements) {
4913
- const strict = __sbD1Strict();
4914
- const list = [];
4915
- for (let i = 0; i < (statements || []).length; i++)
4916
- list.push({ sql: statements[i].__sql, params: statements[i].__params });
4917
- const r = __sbD1Rpc("d1.batch", { db: dbName, statements: list }, strict);
4918
- const out = [];
4919
- for (let i = 0; i < (r.results || []).length; i++) out.push(__sbD1Result(r.results[i], strict));
4920
- return out;
5022
+ if (!__sbD1Strict()) return batch(statements);
5023
+ return __sbD1Promise(() => batch(statements));
4921
5024
  },
4922
5025
  exec(sql) {
4923
- __sbD1Rpc("d1.exec", { db: dbName, sql: String(sql) }, __sbD1Strict());
4924
- 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()"));
4925
5042
  },
4926
5043
  // Sproutboat extension (not in CF Workers D1): an online, integrity-checked
4927
5044
  // snapshot of this database. Both transports implement it: standalone writes
@@ -4932,6 +5049,7 @@ function __sbMakeD1(dbName) {
4932
5049
  return { path: r.path, bytes: r.bytes };
4933
5050
  },
4934
5051
  };
5052
+ return db;
4935
5053
  }
4936
5054
 
4937
5055
  // R2: a Cloudflare-shaped object. When `body` is present the sync accessors
@@ -1945,6 +1945,14 @@ size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* ctx, size_t offset, char
1945
1945
  }
1946
1946
  void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx->file) fclose(ctx->file); free(ctx); } }
1947
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
+ }
1948
1956
  // Run a script: one or more statements separated by semicolons. sqlite3_exec
1949
1957
  // handles the whole string, which prepare/step does not — it compiles the first
1950
1958
  // statement and silently ignores the rest, so a schema built from one exec call
@@ -1952,9 +1960,10 @@ void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx
1952
1960
  static char* sb_sql_script(const char* path, const char* sql) {
1953
1961
  sb_buf b = { 0, 0, 0 };
1954
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; }
1955
1964
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
1956
1965
  char* err = 0;
1957
- int script_rc = 0;
1966
+ int script_rc = 0, capture_failed = 0;
1958
1967
  if (sb_ltx_states[idx].db) {
1959
1968
  const char *next = sql;
1960
1969
  while (*next && !script_rc) {
@@ -1962,15 +1971,16 @@ static char* sb_sql_script(const char* path, const char* sql) {
1962
1971
  script_rc = sqlite3_prepare_v2(sb_dbs[idx], next, -1, &st, &tail);
1963
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; }
1964
1973
  if (st) sqlite3_finalize(st);
1965
- if (sb_ltx_after(idx)) script_rc = 10;
1974
+ if (sb_ltx_after(idx)) { script_rc = 10; capture_failed = 1; }
1966
1975
  if (!tail || tail <= next) break;
1967
1976
  next = tail;
1968
1977
  }
1969
1978
  } else script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
1970
1979
  if (script_rc != 0) {
1980
+ const char* m = capture_failed ? SB_LTX_FAILED : err ? err : sqlite3_errmsg(sb_dbs[idx]);
1971
1981
  sb_puts(&b, "{\"ok\":false,\"error\":");
1972
- sb_putjson_text(&b, err ? err : sqlite3_errmsg(sb_dbs[idx]), err ? strlen(err) : strlen(sqlite3_errmsg(sb_dbs[idx])));
1973
- sb_puts(&b, "}");
1982
+ sb_putjson_text(&b, m, strlen(m));
1983
+ sb_puts(&b, capture_failed ? ",\"code\":10}" : "}");
1974
1984
  return b.p;
1975
1985
  }
1976
1986
  sb_puts(&b, "{\"ok\":true}");
@@ -1995,14 +2005,6 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
1995
2005
  // D1 calls from compatibility date 2026-10-24 put this comment first: the statement must
1996
2006
  // get exactly as many parameters as it has, like D1.
1997
2007
  #define SB_D1_STRICT "/*sb-d1-strict*/"
1998
- // LTX capture (#141) failed after SQLite committed: the write is in the database but not in
1999
- // the replication log, and the database refuses writes until the process restarts. Said as
2000
- // such (SQLITE_IOERR), never as SQLite's own message, which still reads "not an error".
2001
- #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"
2002
- static int sb_db_capture_failed(const char* path) {
2003
- for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
2004
- return 0;
2005
- }
2006
2008
 
2007
2009
  // Run one statement. Returns malloc'd JSON:
2008
2010
  // {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
@@ -3584,6 +3586,10 @@ function __sbEmbeddedDispatch(msg) {
3584
3586
  // exec() takes a script; a single prepare would run only its first
3585
3587
  // statement and quietly drop the rest.
3586
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
+ }
3587
3593
  const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
3588
3594
  if (reply.ok === false) throw new Error("sqlite: " + reply.error);
3589
3595
  return { ok: true };
@@ -4138,6 +4144,18 @@ function __sbD1Release(path, savepoint, strict) {
4138
4144
  if (Number(r.code) !== 10) __sbD1Undo(path, savepoint);
4139
4145
  throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4140
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);
4158
+ }
4141
4159
  // END D1 OPS
4142
4160
 
4143
4161
  /**
package/test/native-d1.ts CHANGED
@@ -47,7 +47,8 @@ int main(void) {
47
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
48
  char* sql = strchr(line, '\\t'); if (!sql) return 2; *sql++ = 0;
49
49
  char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
50
- char* out = sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
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);
51
52
  }
52
53
  return 0;
53
54
  }
@@ -164,11 +165,21 @@ int main(void) {
164
165
  return r;
165
166
  };
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, "[]");
167
169
  const ops = new Function(
168
170
  "__sbSqlRaw",
169
171
  "__sbSql",
170
- `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open };`,
171
- )(sqlRaw, sql);
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");
172
183
  const db2 = join(work, "data/d1/OPS.sqlite");
173
184
  ops.__sbD1Run(db2, "CREATE TABLE p (id INTEGER PRIMARY KEY)", [], false);
174
185
  ops.__sbD1Run(db2, "CREATE TABLE c (id INTEGER PRIMARY KEY, p INTEGER REFERENCES p(id))", [], false);
@@ -251,11 +262,12 @@ int main(void) {
251
262
  const breaking = new Function(
252
263
  "__sbSqlRaw",
253
264
  "__sbSql",
265
+ "__sbSqlScriptRaw",
254
266
  `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
255
267
  )((path: string, text: string, params: string) => {
256
268
  if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
257
269
  return sqlRaw(path, text, params);
258
- }, sql);
270
+ }, sql, scriptRaw);
259
271
  const LTX_FAILED =
260
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";
261
273
  assert.throws(
@@ -272,6 +284,11 @@ int main(void) {
272
284
  /^Error: sqlite: LTX capture failed/,
273
285
  "old dates see the same failure",
274
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
+ );
275
292
  closeSync(toDriver);
276
293
  closeSync(fromDriver);
277
294
  await driver.exited;