@sproutboat/runtime 0.22.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.
@@ -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
+ }