@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 +18 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +114 -0
- package/src/native-fetch-prelude.js +70 -7
- package/src/transport-embedded.js +182 -32
- package/test/native-d1.ts +283 -0
- package/test/native-ltx.ts +59 -0
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
|
@@ -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
|
-
|
|
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
|
|
4822
|
-
return {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2003
|
-
|
|
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
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
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
|
-
|
|
3983
|
-
|
|
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++)
|
|
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
|
|
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
|
+
}
|
package/test/native-ltx.ts
CHANGED
|
@@ -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");
|