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