@sproutboat/runtime 0.23.0 → 0.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.23.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7a7fd34: D1 correctness fixes, in the standalone (embedded) transport and the broker:
8
+
9
+ - **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.
10
+ - **`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.
11
+ - **From compatibility date `2026-10-24`**, D1 follows workerd's rules:
12
+ - 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);
13
+ - the number of parameters must match the statement (`D1_ERROR: Wrong number of parameter bindings for SQL query.`);
14
+ - foreign keys are enforced (`PRAGMA foreign_keys = ON`);
15
+ - errors read `D1_ERROR: <message>: SQLITE_<CODE>`;
16
+ - a BLOB reads back as an array of byte values.
17
+
18
+ Apps with an earlier `compatibility_date` keep the old behaviour. To opt in, move the date to `2026-10-24` or later and rebuild.
19
+ - `meta.changes` is 0 for a statement that only reads. It used to repeat the previous write's count.
20
+
3
21
  ## 0.23.0
4
22
 
5
23
  ### 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.23.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,114 @@
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
+
38
+ 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`, () => {
40
+ 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
+ { a: "u8", h: "00FF07", t: "blob", c: "after" },
53
+ { a: "ab", h: "0102", t: "blob", c: "after" },
54
+ { a: "view", h: "0807", t: "blob", c: "after" },
55
+ ]);
56
+ });
57
+
58
+ test(`${date}: batch() is one transaction`, () => {
59
+ 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);
65
+ });
66
+ }
67
+
68
+ test("before 2026-10-24: lenient parameters, no foreign keys, BLOBs as one char per byte", () => {
69
+ const DB = fresh("2026-09-28");
70
+ // (bun:sqlite has always refused a wrong parameter count here; the embedded transport has not.)
71
+ 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))");
73
+ DB.prepare("INSERT INTO c VALUES (99)").run();
74
+ expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toBe("\u0000ÿ");
75
+ expect(caught(() => DB.prepare("SELEC 1").all())).toMatch(/^sproutboat d1\.query: /);
76
+ });
77
+
78
+ test("from 2026-10-24: D1's parameter rules", () => {
79
+ 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 });
99
+ });
100
+
101
+ test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", () => {
102
+ 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(
105
+ "D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
106
+ );
107
+ DB.prepare("INSERT INTO p VALUES (1)").run();
108
+ expect(caught(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
109
+ "D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
110
+ );
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);
114
+ });
@@ -4809,17 +4809,80 @@ 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
+ }
4812
4872
  function __sbMakeD1(dbName) {
4813
4873
  function stmt(sql, params) {
4814
4874
  const s = {
4815
4875
  __sql: sql,
4816
4876
  __params: params,
4817
4877
  bind() {
4818
- return stmt(sql, Array.prototype.slice.call(arguments));
4878
+ const strict = __sbD1Strict();
4879
+ const list = [];
4880
+ for (let i = 0; i < arguments.length; i++) list.push(__sbD1Param(arguments[i], strict));
4881
+ return stmt(sql, list);
4819
4882
  },
4820
4883
  all() {
4821
- const r = __sbRpc("d1.query", { db: dbName, sql, params });
4822
- return { results: r.results || [], success: true, meta: r.meta || {} };
4884
+ const strict = __sbD1Strict();
4885
+ return __sbD1Result(__sbD1Rpc("d1.query", { db: dbName, sql, params }, strict), strict);
4823
4886
  },
4824
4887
  run() {
4825
4888
  return s.all();
@@ -4847,17 +4910,17 @@ function __sbMakeD1(dbName) {
4847
4910
  return stmt(String(sql), []);
4848
4911
  },
4849
4912
  batch(statements) {
4913
+ const strict = __sbD1Strict();
4850
4914
  const list = [];
4851
4915
  for (let i = 0; i < (statements || []).length; i++)
4852
4916
  list.push({ sql: statements[i].__sql, params: statements[i].__params });
4853
- const r = __sbRpc("d1.batch", { db: dbName, statements: list });
4917
+ const r = __sbD1Rpc("d1.batch", { db: dbName, statements: list }, strict);
4854
4918
  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 || {} });
4919
+ for (let i = 0; i < (r.results || []).length; i++) out.push(__sbD1Result(r.results[i], strict));
4857
4920
  return out;
4858
4921
  },
4859
4922
  exec(sql) {
4860
- __sbRpc("d1.exec", { db: dbName, sql: String(sql) });
4923
+ __sbD1Rpc("d1.exec", { db: dbName, sql: String(sql) }, __sbD1Strict());
4861
4924
  return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
4862
4925
  },
4863
4926
  // Sproutboat extension (not in CF Workers D1): an online, integrity-checked
@@ -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 ----------------
@@ -1941,23 +1977,52 @@ static char* sb_sql_script(const char* path, const char* sql) {
1941
1977
  return b.p;
1942
1978
  }
1943
1979
 
1980
+ // An error reply with SQLite's extended result code and, when SQLite knows it, the byte
1981
+ // offset of the error in the SQL (D1 reports both). A message of our own comes with its
1982
+ // own code (code < 0: take SQLite's).
1983
+ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip) {
1984
+ sb_buf e = { 0, 0, 0 };
1985
+ const char* m = message ? message : sqlite3_errmsg(db);
1986
+ sb_puts(&e, "{\"ok\":false,\"error\":");
1987
+ sb_putjson_text(&e, m, strlen(m));
1988
+ char tail[64];
1989
+ int offset = message ? -1 : sqlite3_error_offset(db);
1990
+ int k = snprintf(tail, sizeof(tail), ",\"code\":%d,\"offset\":%d}", code >= 0 ? code : sqlite3_extended_errcode(db), offset >= skip ? offset - skip : -1);
1991
+ sb_put(&e, tail, (size_t)k);
1992
+ return e.p;
1993
+ }
1994
+
1995
+ // D1 calls from compatibility date 2026-10-24 put this comment first: the statement must
1996
+ // get exactly as many parameters as it has, like D1.
1997
+ #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
+
1944
2007
  // Run one statement. Returns malloc'd JSON:
1945
2008
  // {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
1946
- // or {"ok":false,"error":"..."}.
2009
+ // or {"ok":false,"error":"...","code":n,"offset":n}. In a D1 database (a path under
2010
+ // /d1/) a BLOB comes back as {"$blob":"HEX"}; elsewhere as one char per byte.
1947
2011
  static char* sb_sql_run(const char* path, const char* sql, const char* params) {
1948
2012
  sb_buf b = { 0, 0, 0 };
1949
2013
  int idx = sb_db_for(path);
2014
+ if (idx < 0 && sb_db_capture_failed(path)) return sb_sql_error(0, SB_LTX_FAILED, 10, 0);
1950
2015
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
1951
2016
  sqlite3* db = sb_dbs[idx];
2017
+ const int strict = strncmp(sql, SB_D1_STRICT, sizeof(SB_D1_STRICT) - 1) == 0;
2018
+ const int d1 = strstr(path, "/d1/") != 0;
1952
2019
  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;
2020
+ if (!st) return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2021
+ int bound = params && *params ? sb_bind_params(st, params) : 0;
2022
+ if (strict && bound != sqlite3_bind_parameter_count(st)) {
2023
+ sqlite3_reset(st);
2024
+ return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
1959
2025
  }
1960
- if (params && *params) sb_bind_params(st, params);
1961
2026
 
1962
2027
  sb_puts(&b, "{\"ok\":true,\"cols\":[");
1963
2028
  int ncol = sqlite3_column_count(st);
@@ -1984,27 +2049,32 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
1984
2049
  }
1985
2050
  const unsigned char* txt = sqlite3_column_text(st, i);
1986
2051
  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);
2052
+ // TEXT is UTF-8 and decodes (#189); a BLOB stays one char per byte, or is hex in D1.
2053
+ if (t == 4 && d1) {
2054
+ static const char* digits = "0123456789abcdef";
2055
+ const unsigned char* bytes = (const unsigned char*)sqlite3_column_blob(st, i);
2056
+ sb_puts(&b, "{\"$blob\":\"");
2057
+ for (int j = 0; j < n; j++) { char pair[2] = { digits[bytes[j] >> 4], digits[bytes[j] & 15] }; sb_put(&b, pair, 2); }
2058
+ sb_puts(&b, "\"}");
2059
+ }
2060
+ else if (t == 4) sb_putjson(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
1989
2061
  else sb_putjson_text(&b, txt ? (const char*)txt : "", txt ? (size_t)n : 0);
1990
2062
  }
1991
2063
  sb_puts(&b, "]");
1992
2064
  }
1993
2065
  char tail[96];
1994
- int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
2066
+ // A read-only statement changed nothing: sqlite3_changes still holds the last write's count.
2067
+ 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
2068
  sb_put(&b, tail, (size_t)k);
1996
2069
  // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
1997
2070
  // now (not just on next reuse) so a SELECT stops holding its read transaction.
1998
2071
  sqlite3_reset(st);
1999
- if (sb_ltx_after(idx)) rc = 10;
2072
+ const int capture_failed = sb_ltx_after(idx) != 0;
2073
+ if (capture_failed) rc = 10;
2000
2074
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
2001
2075
  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;
2076
+ if (capture_failed) return sb_sql_error(db, SB_LTX_FAILED, 10, 0);
2077
+ return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2008
2078
  }
2009
2079
  return b.p;
2010
2080
  }
@@ -3513,19 +3583,16 @@ function __sbEmbeddedDispatch(msg) {
3513
3583
  if (op === "d1.exec") {
3514
3584
  // exec() takes a script; a single prepare would run only its first
3515
3585
  // statement and quietly drop the rest.
3586
+ __sbD1Open(path, msg.strict === true);
3516
3587
  const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
3517
3588
  if (reply.ok === false) throw new Error("sqlite: " + reply.error);
3518
3589
  return { ok: true };
3519
3590
  }
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 };
3591
+ const strict = msg.strict === true;
3592
+ __sbD1Open(path, strict);
3593
+ if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
3594
+ const one = __sbD1Run(path, msg.sql, msg.params, strict);
3595
+ return { ok: true, results: one.results, columns: one.columns, rows: one.rows, meta: one.meta, success: true };
3529
3596
  }
3530
3597
 
3531
3598
  if (op === "d1.backup") {
@@ -3979,16 +4046,99 @@ function __sbEmbeddedDispatch(msg) {
3979
4046
  }
3980
4047
 
3981
4048
  /** One D1 statement, shaped like the broker's d1Run. */
3982
- function __sbD1Run(path, sql, params) {
3983
- const r = __sbSql(path, String(sql), params || []);
4049
+ // BEGIN D1 OPS (lifted by test/native-d1.ts)
4050
+ // D1 calls from compatibility date 2026-10-24 come with msg.strict (the shim sets it):
4051
+ // exact parameter counts, foreign keys enforced, D1's error text, and rows as arrays with
4052
+ // their column names (the shim builds the objects, so duplicate names survive in raw()).
4053
+ // A BLOB comes from C as {"$blob":"HEX"}; strict calls get it so, older ones get the one
4054
+ // char per byte string they always had.
4055
+ var __sbD1Opened = {};
4056
+ function __sbD1Open(path, strict) {
4057
+ if (__sbD1Opened[path] === strict) return;
4058
+ __sbD1Opened[path] = strict;
4059
+ __sbSql(path, strict ? "PRAGMA foreign_keys = ON" : "PRAGMA foreign_keys = OFF", []);
4060
+ }
4061
+ var __sbSqliteCodes = ["", "SQLITE_ERROR", "SQLITE_INTERNAL", "SQLITE_PERM", "SQLITE_ABORT", "SQLITE_BUSY",
4062
+ "SQLITE_LOCKED", "SQLITE_NOMEM", "SQLITE_READONLY", "SQLITE_INTERRUPT", "SQLITE_IOERR", "SQLITE_CORRUPT",
4063
+ "SQLITE_NOTFOUND", "SQLITE_FULL", "SQLITE_CANTOPEN", "SQLITE_PROTOCOL", "SQLITE_EMPTY", "SQLITE_SCHEMA",
4064
+ "SQLITE_TOOBIG", "SQLITE_CONSTRAINT", "SQLITE_MISMATCH", "SQLITE_MISUSE", "SQLITE_NOLFS", "SQLITE_AUTH",
4065
+ "SQLITE_FORMAT", "SQLITE_RANGE", "SQLITE_NOTADB"];
4066
+ var __sbSqliteConstraints = { 275: "CHECK", 531: "COMMITHOOK", 787: "FOREIGNKEY", 1043: "FUNCTION",
4067
+ 1299: "NOTNULL", 1555: "PRIMARYKEY", 1811: "TRIGGER", 2067: "UNIQUE", 2323: "VTAB", 2579: "ROWID",
4068
+ 2835: "PINNED", 3091: "DATATYPE" };
4069
+ function __sbD1Error(reply) {
4070
+ const code = Number(reply.code) || 0;
4071
+ const base = __sbSqliteCodes[code & 255] || "";
4072
+ let text = "D1_ERROR: " + reply.error;
4073
+ if (reply.offset >= 0) text += " at offset " + reply.offset;
4074
+ if (base) {
4075
+ text += ": " + base;
4076
+ if (__sbSqliteConstraints[code]) text += " (extended: SQLITE_CONSTRAINT_" + __sbSqliteConstraints[code] + ")";
4077
+ }
4078
+ return new Error(text);
4079
+ }
4080
+ function __sbD1Bytes(cell) {
4081
+ let out = "";
4082
+ const hex = String(cell.$blob);
4083
+ for (let i = 0; i + 1 < hex.length; i += 2) out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
4084
+ return out;
4085
+ }
4086
+ function __sbD1Run(path, sql, params, strict) {
4087
+ const r = JSON.parse(__sbSqlRaw(path, (strict ? "/*sb-d1-strict*/" : "") + String(sql), JSON.stringify(params || [])));
4088
+ if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4089
+ const meta = { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length };
4090
+ if (strict) return { columns: r.cols, rows: r.rows, meta };
3984
4091
  const results = [];
3985
4092
  for (let i = 0; i < r.rows.length; i++) {
3986
4093
  const row = {};
3987
- for (let c = 0; c < r.cols.length; c++) row[r.cols[c]] = r.rows[i][c];
4094
+ for (let c = 0; c < r.cols.length; c++) {
4095
+ const cell = r.rows[i][c];
4096
+ row[r.cols[c]] = cell !== null && Object(cell) === cell ? __sbD1Bytes(cell) : cell;
4097
+ }
3988
4098
  results.push(row);
3989
4099
  }
3990
- return { results, meta: { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length } };
4100
+ return { results, meta };
3991
4101
  }
4102
+ // One statement of our own (SAVEPOINT, RELEASE, ...), failing in the caller's terms.
4103
+ function __sbD1Control(path, sql, strict) {
4104
+ const r = JSON.parse(__sbSqlRaw(path, sql, "[]"));
4105
+ if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4106
+ }
4107
+ // Undo after a failure, best effort: the error the caller sees is the first one. If LTX
4108
+ // capture failed, SQLite may already have committed; that error says so, and the database
4109
+ // stays closed to writes (the capture latch), so a cleanup failure must not replace it.
4110
+ function __sbD1Undo(path, savepoint) {
4111
+ try {
4112
+ __sbD1Control(path, "ROLLBACK TO " + savepoint, false);
4113
+ __sbD1Control(path, "RELEASE " + savepoint, false);
4114
+ } catch (ignored) {
4115
+ return ignored;
4116
+ }
4117
+ return null;
4118
+ }
4119
+ // One transaction, like D1: a statement that fails undoes the ones before it.
4120
+ function __sbD1Batch(path, list, strict) {
4121
+ const results = [];
4122
+ __sbD1Control(path, "SAVEPOINT sb_d1_batch", strict);
4123
+ try {
4124
+ for (let i = 0; i < list.length; i++) results.push(__sbD1Run(path, list[i].sql, list[i].params, strict));
4125
+ } catch (err) {
4126
+ __sbD1Undo(path, "sb_d1_batch");
4127
+ throw err;
4128
+ }
4129
+ __sbD1Release(path, "sb_d1_batch", strict);
4130
+ return results;
4131
+ }
4132
+ // RELEASE of our outermost savepoint is the commit. A deferred constraint can fail it and
4133
+ // leave the transaction open: undo it. A capture failure (code 10) comes after SQLite has
4134
+ // committed: nothing to undo, and that error is the one to report.
4135
+ function __sbD1Release(path, savepoint, strict) {
4136
+ const r = JSON.parse(__sbSqlRaw(path, "RELEASE " + savepoint, "[]"));
4137
+ if (r.ok !== false) return;
4138
+ if (Number(r.code) !== 10) __sbD1Undo(path, savepoint);
4139
+ throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
4140
+ }
4141
+ // END D1 OPS
3992
4142
 
3993
4143
  /**
3994
4144
  * R2 object bodies, out of band (#56).
@@ -0,0 +1,283 @@
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
+ char* out = sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
51
+ }
52
+ return 0;
53
+ }
54
+ `,
55
+ );
56
+ const binary = join(work, "driver");
57
+ const compile = Bun.spawnSync(["cc", "-O1", "-w", "-I", work, join(work, "driver.c"), "-lsqlite3", "-o", binary]);
58
+ assert.equal(compile.exitCode, 0, compile.stderr.toString());
59
+
60
+ const d1 = join(work, "data/d1/DB.sqlite");
61
+ const store = join(work, "data/store.sqlite");
62
+ const run = (calls: [string, string, unknown[]][]) => {
63
+ const input = calls.map(([path, sql, params]) => `${path}\t${sql}\t${JSON.stringify(params)}\n`).join("");
64
+ const p = Bun.spawnSync([binary], { stdin: Buffer.from(input) });
65
+ assert.equal(p.exitCode, 0, p.stderr.toString());
66
+ return p.stdout
67
+ .toString()
68
+ .trim()
69
+ .split("\n")
70
+ .map((l) => JSON.parse(l));
71
+ };
72
+ const blob = (bytes: number[]) => ({ $blob: Buffer.from(bytes).toString("hex") });
73
+
74
+ // A Uint8Array bind used to stop the parameter parse: it, and every parameter after it,
75
+ // were stored as NULL (data loss).
76
+ const [, , stored, other] = run([
77
+ [d1, "CREATE TABLE t (a TEXT, b BLOB, c TEXT)", []],
78
+ [d1, "INSERT INTO t VALUES (?1, ?2, ?3)", ["k", blob([0, 255, 127, 0x22]), "after"]],
79
+ [d1, "SELECT a, b, typeof(b) AS bt, length(b) AS bl, c FROM t", []],
80
+ [store, "SELECT x'00ff' AS b", []],
81
+ ]);
82
+ assert.deepEqual(
83
+ stored.rows,
84
+ [["k", { $blob: "00ff7f22" }, "blob", 4, "after"]],
85
+ "blob stored as a BLOB, later parameters kept",
86
+ );
87
+ assert.equal(other.rows[0][0], String(other.rows[0][0]), "outside D1 a BLOB is still one char per byte, not hex");
88
+
89
+ // An object that is not a blob binds NULL and the parse carries on (a "}" in a string included).
90
+ const [, kept] = run([
91
+ [d1, "INSERT INTO t VALUES (?1, ?2, ?3)", [{ a: "}{" }, 5, "x"]],
92
+ [d1, "SELECT a, b, c FROM t WHERE c = 'x'", []],
93
+ ]);
94
+ assert.deepEqual(kept.rows, [[null, 5, "x"]]);
95
+
96
+ // Strict D1 calls (compatibility date 2026-10-24) check the parameter count.
97
+ const [few, many, right, reuse] = run([
98
+ [d1, "/*sb-d1-strict*/SELECT ?1, ?2", [1]],
99
+ [d1, "/*sb-d1-strict*/SELECT ?1", [1, 2]],
100
+ [d1, "/*sb-d1-strict*/SELECT ?1 AS a", [1]],
101
+ [d1, "/*sb-d1-strict*/SELECT ?1 AS a, ?1 AS b, ?2 AS c", ["x", "y"]],
102
+ ]);
103
+ assert.equal(few.ok, false);
104
+ assert.equal(few.error, "Wrong number of parameter bindings for SQL query.");
105
+ assert.equal(many.ok, false);
106
+ assert.deepEqual(right.rows, [[1]]);
107
+ assert.deepEqual(reuse.rows, [["x", "x", "y"]]);
108
+ const [loose] = run([[d1, "SELECT ?1 AS a, ?2 AS b", [1]]]);
109
+ assert.deepEqual(loose.rows, [[1, null]], "without the marker a missing parameter is NULL, as before");
110
+
111
+ // Errors carry SQLite's extended code and the offset in the caller's SQL.
112
+ const [syntax, unique] = run([
113
+ [d1, "/*sb-d1-strict*/SELEC 1", []],
114
+ [d1, "CREATE TABLE u (id INTEGER PRIMARY KEY); INSERT INTO u VALUES (1)", []],
115
+ ]);
116
+ assert.equal(syntax.ok, false);
117
+ assert.equal(syntax.code, 1);
118
+ assert.equal(syntax.offset, 0);
119
+ assert.equal(unique.ok, true);
120
+ const [, dup] = run([
121
+ [d1, "INSERT INTO u VALUES (1)", []],
122
+ [d1, "INSERT INTO u VALUES (1)", []],
123
+ ]);
124
+ assert.equal(dup.ok, false);
125
+ assert.equal(dup.code, 1555, "SQLITE_CONSTRAINT_PRIMARYKEY");
126
+
127
+ // A read reports no changes, not the last write's.
128
+ const [write, read] = run([
129
+ [d1, "INSERT INTO t (a) VALUES ('z')", []],
130
+ [d1, "SELECT count(*) FROM t", []],
131
+ ]);
132
+ assert.equal(write.changes, 1);
133
+ assert.equal(read.changes, 0);
134
+ // The transport's D1 ops (JS), on the same C: lifted from BEGIN/END D1 OPS.
135
+ const opsStart = transport.indexOf("// BEGIN D1 OPS");
136
+ const opsEnd = transport.indexOf("// END D1 OPS");
137
+ assert(opsStart > 0 && opsEnd > opsStart, "D1 ops markers moved");
138
+ // One long-lived driver, so a SAVEPOINT spans calls as it does in a worker: FIFOs give
139
+ // the lifted (synchronous) ops a synchronous line protocol.
140
+ const fifoIn = join(work, "in"),
141
+ fifoOut = join(work, "out");
142
+ assert.equal(Bun.spawnSync(["mkfifo", fifoIn, fifoOut]).exitCode, 0);
143
+ const driver = Bun.spawn(["sh", "-c", `exec "${binary}" < "${fifoIn}" > "${fifoOut}"`], {
144
+ env: { ...process.env, SB_LTX_ENABLE: "1", SB_LTX_DATABASE: "LTX" },
145
+ });
146
+ const toDriver = openSync(fifoIn, "w"),
147
+ fromDriver = openSync(fifoOut, "r");
148
+ let pending = "";
149
+ const sqlRaw = (path: string, sql: string, params: string) => {
150
+ writeSync(toDriver, `${path}\t${sql}\t${params}\n`);
151
+ const chunk = Buffer.alloc(65536);
152
+ while (!pending.includes("\n")) {
153
+ const n = readSync(fromDriver, chunk, 0, chunk.length, null);
154
+ if (n <= 0) throw new Error("driver exited");
155
+ pending += chunk.toString("utf8", 0, n);
156
+ }
157
+ const line = pending.slice(0, pending.indexOf("\n"));
158
+ pending = pending.slice(line.length + 1);
159
+ return line;
160
+ };
161
+ const sql = (path: string, text: string, params: unknown[]) => {
162
+ const r = JSON.parse(sqlRaw(path, text, JSON.stringify(params)));
163
+ if (r.ok === false) throw new Error("sqlite: " + r.error);
164
+ return r;
165
+ };
166
+ // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
167
+ const ops = new Function(
168
+ "__sbSqlRaw",
169
+ "__sbSql",
170
+ `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open };`,
171
+ )(sqlRaw, sql);
172
+ const db2 = join(work, "data/d1/OPS.sqlite");
173
+ ops.__sbD1Run(db2, "CREATE TABLE p (id INTEGER PRIMARY KEY)", [], false);
174
+ ops.__sbD1Run(db2, "CREATE TABLE c (id INTEGER PRIMARY KEY, p INTEGER REFERENCES p(id))", [], false);
175
+ // batch() is one transaction: the failing second insert undoes the first.
176
+ assert.throws(
177
+ () =>
178
+ ops.__sbD1Batch(
179
+ db2,
180
+ [
181
+ { sql: "INSERT INTO p VALUES (5)", params: [] },
182
+ { sql: "INSERT INTO p VALUES (5)", params: [] },
183
+ ],
184
+ false,
185
+ ),
186
+ /UNIQUE/,
187
+ );
188
+ assert.equal(ops.__sbD1Run(db2, "SELECT count(*) AS n FROM p", [], false).results[0].n, 0, "batch rolled back");
189
+ assert.equal(
190
+ ops.__sbD1Batch(
191
+ db2,
192
+ [
193
+ { sql: "INSERT INTO p VALUES (1)", params: [] },
194
+ { sql: "INSERT INTO p VALUES (2)", params: [] },
195
+ ],
196
+ false,
197
+ ).length,
198
+ 2,
199
+ );
200
+ // Old dates: no foreign keys, a BLOB read as one char per byte, errors as before.
201
+ ops.__sbD1Open(db2, false);
202
+ ops.__sbD1Run(db2, "INSERT INTO c VALUES (1, 99)", [], false);
203
+ const oldBlob = ops.__sbD1Run(db2, "SELECT ?1 AS b", [{ $blob: "00ff" }], false).results[0].b;
204
+ assert.equal(oldBlob, "\u0000ÿ");
205
+ assert.throws(() => ops.__sbD1Run(db2, "SELEC 1", [], false), /^Error: sqlite: near "SELEC": syntax error$/);
206
+ // Strict (2026-10-24): foreign keys on, rows with columns, D1's error text.
207
+ ops.__sbD1Open(db2, true);
208
+ assert.throws(
209
+ () => ops.__sbD1Run(db2, "INSERT INTO c VALUES (2, 98)", [], true),
210
+ (e: Error) =>
211
+ e.message ===
212
+ "D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
213
+ );
214
+ const strictRow = ops.__sbD1Run(db2, "SELECT 1 AS a, ?1 AS a", [{ $blob: "0a" }], true);
215
+ assert.deepEqual([strictRow.columns, strictRow.rows], [["a", "a"], [[1, { $blob: "0a" }]]]);
216
+ assert.throws(
217
+ () => ops.__sbD1Run(db2, "SELEC 1", [], true),
218
+ (e: Error) => e.message === 'D1_ERROR: near "SELEC": syntax error at offset 0: SQLITE_ERROR',
219
+ );
220
+ assert.throws(
221
+ () => ops.__sbD1Run(db2, "INSERT INTO p VALUES (1)", [], true),
222
+ (e: Error) =>
223
+ e.message ===
224
+ "D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
225
+ );
226
+ assert.throws(
227
+ () => ops.__sbD1Run(db2, "SELECT ?1", [], true),
228
+ (e: Error) => e.message === "D1_ERROR: Wrong number of parameter bindings for SQL query.",
229
+ );
230
+ // LTX capture on (SB_LTX_ENABLE, database LTX): a batch is one commit, and a capture
231
+ // that fails at RELEASE (after SQLite committed) surfaces as itself: SQLITE_IOERR with a
232
+ // message that says so, never "no such savepoint" from the cleanup, and the database then
233
+ // refuses writes until restart.
234
+ const ltx = join(work, "data/d1/LTX.sqlite");
235
+ const generation = () => join(ltx + ".ltx", readFileSync(ltx + ".ltx/position", "utf8").split("\n")[1]);
236
+ const ltxFiles = () => readdirSync(generation()).filter((n) => n.endsWith(".ltx")).length;
237
+ ops.__sbD1Open(ltx, true);
238
+ ops.__sbD1Run(ltx, "CREATE TABLE e (id INTEGER PRIMARY KEY, v TEXT)", [], true);
239
+ const beforeBatch = ltxFiles();
240
+ ops.__sbD1Batch(
241
+ ltx,
242
+ [
243
+ { sql: "INSERT INTO e VALUES (1, 'a')", params: [] },
244
+ { sql: "INSERT INTO e VALUES (2, 'b')", params: [] },
245
+ ],
246
+ true,
247
+ );
248
+ assert.equal(ltxFiles(), beforeBatch + 1, "a batch is one LTX commit");
249
+ // Break capture just before the batch's RELEASE.
250
+ // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
251
+ const breaking = new Function(
252
+ "__sbSqlRaw",
253
+ "__sbSql",
254
+ `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
255
+ )((path: string, text: string, params: string) => {
256
+ if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
257
+ return sqlRaw(path, text, params);
258
+ }, sql);
259
+ const LTX_FAILED =
260
+ "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
+ assert.throws(
262
+ () => breaking.__sbD1Batch(ltx, [{ sql: "INSERT INTO e VALUES (3, 'c')", params: [] }], true),
263
+ (e: Error) => e.message === LTX_FAILED,
264
+ );
265
+ assert.throws(
266
+ () => ops.__sbD1Run(ltx, "INSERT INTO e VALUES (4, 'd')", [], true),
267
+ (e: Error) => e.message === LTX_FAILED,
268
+ "writes refused after a capture failure",
269
+ );
270
+ assert.throws(
271
+ () => ops.__sbD1Run(ltx, "SELECT 1", [], false),
272
+ /^Error: sqlite: LTX capture failed/,
273
+ "old dates see the same failure",
274
+ );
275
+ closeSync(toDriver);
276
+ closeSync(fromDriver);
277
+ await driver.exited;
278
+ console.log(
279
+ "native d1: sb_sql_run and the D1 ops pass (blob binds, atomic batch, strict counts, foreign keys, error text)",
280
+ );
281
+ } finally {
282
+ await rm(work, { recursive: true, force: true });
283
+ }
@@ -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");