@sproutboat/runtime 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +88 -48
- package/src/native-fetch-prelude.js +142 -24
- package/src/transport-embedded.js +30 -12
- package/test/native-d1.ts +21 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.24.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9a5363e: D1 matches workerd from compatibility date `2026-10-24`:
|
|
8
|
+
|
|
9
|
+
- `all()`, `run()`, `first()`, `raw()`, `batch()` and `exec()` return Promises, and errors reject them. `bind()` stays synchronous and still throws `D1_TYPE_ERROR` at once.
|
|
10
|
+
- `meta` has D1's keys: `served_by`, `duration`, `changes`, `last_row_id`, `changed_db`, `size_after` (always 0 here), `rows_read` and `rows_written`.
|
|
11
|
+
- `raw({ columnNames: true })` puts the column names first, and keeps duplicate names.
|
|
12
|
+
- `first("name")` throws `D1_COLUMN_NOTFOUND` for a column the result doesn't have.
|
|
13
|
+
- Empty SQL and an empty `batch([])` throw `D1_ERROR: No SQL statements detected.`
|
|
14
|
+
- `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` and `RELEASE` are refused, as in D1. A batch is already one transaction.
|
|
15
|
+
- `exec()` runs line by line, all or nothing, and returns the number of lines. A failing line throws `D1_EXEC_ERROR: Error in line N: ...`.
|
|
16
|
+
- `withSession()` returns a session with `prepare`, `batch` and `getBookmark()`, which returns `null`. `dump()` rejects; use `backup()` instead.
|
|
17
|
+
|
|
18
|
+
Apps with an earlier `compatibility_date` keep the synchronous API unchanged. To opt in, move the date to `2026-10-24` or later and rebuild. `await` code needs no other change, but code that reads results without `await` does.
|
|
19
|
+
|
|
20
|
+
Not yet like D1: `rows_read` counts the rows returned, not the rows scanned, and pragmas are not limited to D1's allow-list.
|
|
21
|
+
|
|
22
|
+
With LTX capture on (from `2026-10-22`), a capture failure during a D1 call now reports `SQLITE_IOERR` with a message saying the write is not durable in the replication log, instead of SQLite's "not an error". Writes stay refused until restart.
|
|
23
|
+
|
|
3
24
|
## 0.23.1
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/package.json
CHANGED
package/src/d1-shim.test.js
CHANGED
|
@@ -34,81 +34,121 @@ const caught = (fn) => {
|
|
|
34
34
|
}
|
|
35
35
|
return "did not throw";
|
|
36
36
|
};
|
|
37
|
+
// The message a call fails with, synchronously or as a rejected Promise.
|
|
38
|
+
const failure = async (fn) => {
|
|
39
|
+
try {
|
|
40
|
+
await fn();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return error.message;
|
|
43
|
+
}
|
|
44
|
+
return "did not throw";
|
|
45
|
+
};
|
|
46
|
+
const NO_STATEMENTS = "D1_ERROR: No SQL statements detected.";
|
|
47
|
+
const WRONG_COUNT = "D1_ERROR: Wrong number of parameter bindings for SQL query.";
|
|
37
48
|
|
|
38
49
|
for (const date of ["2026-09-28", "2026-10-24"]) {
|
|
39
|
-
test(`${date}: bytes bind as a BLOB, and the parameters after them are kept`, () => {
|
|
50
|
+
test(`${date}: bytes bind as a BLOB, and the parameters after them are kept`, async () => {
|
|
40
51
|
const DB = fresh(date);
|
|
41
|
-
DB.exec("CREATE TABLE t (a TEXT, b BLOB, c TEXT)");
|
|
42
|
-
DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
.run();
|
|
48
|
-
DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
|
|
49
|
-
.bind("view", new DataView(new Uint8Array([9, 8, 7]).buffer, 1, 2), "after")
|
|
50
|
-
.run();
|
|
51
|
-
expect(DB.prepare("SELECT a, hex(b) AS h, typeof(b) AS t, c FROM t ORDER BY rowid").all().results).toEqual([
|
|
52
|
+
await DB.exec("CREATE TABLE t (a TEXT, b BLOB, c TEXT)");
|
|
53
|
+
const insert = DB.prepare("INSERT INTO t VALUES (?, ?, ?)");
|
|
54
|
+
await insert.bind("u8", new Uint8Array([0, 255, 7]), "after").run();
|
|
55
|
+
await insert.bind("ab", new Uint8Array([1, 2]).buffer, "after").run();
|
|
56
|
+
await insert.bind("view", new DataView(new Uint8Array([9, 8, 7]).buffer, 1, 2), "after").run();
|
|
57
|
+
expect((await DB.prepare("SELECT a, hex(b) AS h, typeof(b) AS t, c FROM t ORDER BY rowid").all()).results).toEqual([
|
|
52
58
|
{ a: "u8", h: "00FF07", t: "blob", c: "after" },
|
|
53
59
|
{ a: "ab", h: "0102", t: "blob", c: "after" },
|
|
54
60
|
{ a: "view", h: "0807", t: "blob", c: "after" },
|
|
55
61
|
]);
|
|
56
62
|
});
|
|
57
63
|
|
|
58
|
-
test(`${date}: batch() is one transaction`, () => {
|
|
64
|
+
test(`${date}: batch() is one transaction`, async () => {
|
|
59
65
|
const DB = fresh(date);
|
|
60
|
-
DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
61
|
-
expect(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")])).
|
|
62
|
-
|
|
63
|
-
);
|
|
64
|
-
expect(DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(0);
|
|
66
|
+
await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
67
|
+
expect(await failure(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")]))).toMatch(/UNIQUE/);
|
|
68
|
+
expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(0);
|
|
65
69
|
});
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
test("before 2026-10-24: lenient
|
|
72
|
+
test("before 2026-10-24: synchronous, lenient, no foreign keys, BLOBs as one char per byte", () => {
|
|
69
73
|
const DB = fresh("2026-09-28");
|
|
74
|
+
const result = DB.prepare("SELECT 1 AS a").all();
|
|
75
|
+
expect(result.then).toBeUndefined();
|
|
76
|
+
expect(result.results).toEqual([{ a: 1 }]);
|
|
70
77
|
// (bun:sqlite has always refused a wrong parameter count here; the embedded transport has not.)
|
|
71
78
|
expect(DB.prepare("SELECT ?1 AS a").bind(undefined).first()).toEqual({ a: null });
|
|
72
|
-
DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY); CREATE TABLE c (p INTEGER REFERENCES p(id))");
|
|
79
|
+
expect(DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY); CREATE TABLE c (p INTEGER REFERENCES p(id))").count).toBe(1);
|
|
73
80
|
DB.prepare("INSERT INTO c VALUES (99)").run();
|
|
74
81
|
expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toBe("\u0000ÿ");
|
|
82
|
+
expect(DB.prepare("SELECT 1 AS a").first("nope")).toBeUndefined();
|
|
75
83
|
expect(caught(() => DB.prepare("SELEC 1").all())).toMatch(/^sproutboat d1\.query: /);
|
|
76
84
|
});
|
|
77
85
|
|
|
78
|
-
test("from 2026-10-24: D1's parameter rules", () => {
|
|
86
|
+
test("from 2026-10-24: D1's parameter rules", async () => {
|
|
79
87
|
const DB = fresh("2026-10-24");
|
|
80
|
-
expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe(
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
expect(
|
|
84
|
-
|
|
85
|
-
);
|
|
86
|
-
expect(
|
|
87
|
-
"D1_TYPE_ERROR: Type 'bigint' not supported for value '10'",
|
|
88
|
-
);
|
|
89
|
-
expect(caught(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(
|
|
90
|
-
"D1_ERROR: Wrong number of parameter bindings for SQL query.",
|
|
91
|
-
);
|
|
92
|
-
expect(caught(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(
|
|
93
|
-
"D1_ERROR: Wrong number of parameter bindings for SQL query.",
|
|
94
|
-
);
|
|
95
|
-
expect(caught(() => DB.prepare("SELECT ?1").all())).toBe(
|
|
96
|
-
"D1_ERROR: Wrong number of parameter bindings for SQL query.",
|
|
97
|
-
);
|
|
98
|
-
expect(DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({ a: "x", b: "x", c: 1 });
|
|
88
|
+
expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe("D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'");
|
|
89
|
+
expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe("D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'");
|
|
90
|
+
expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe("D1_TYPE_ERROR: Type 'bigint' not supported for value '10'");
|
|
91
|
+
expect(await failure(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(WRONG_COUNT);
|
|
92
|
+
expect(await failure(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(WRONG_COUNT);
|
|
93
|
+
expect(await failure(() => DB.prepare("SELECT ?1").all())).toBe(WRONG_COUNT);
|
|
94
|
+
expect(await DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({ a: "x", b: "x", c: 1 });
|
|
99
95
|
});
|
|
100
96
|
|
|
101
|
-
test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", () => {
|
|
97
|
+
test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", async () => {
|
|
102
98
|
const DB = fresh("2026-10-24");
|
|
103
|
-
DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY)
|
|
104
|
-
expect(
|
|
99
|
+
await DB.exec("CREATE TABLE p (id INTEGER PRIMARY KEY)\nCREATE TABLE c (p INTEGER REFERENCES p(id))");
|
|
100
|
+
expect(await failure(() => DB.prepare("INSERT INTO c VALUES (99)").run())).toBe(
|
|
105
101
|
"D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
|
|
106
102
|
);
|
|
107
|
-
DB.prepare("INSERT INTO p VALUES (1)").run();
|
|
108
|
-
expect(
|
|
103
|
+
await DB.prepare("INSERT INTO p VALUES (1)").run();
|
|
104
|
+
expect(await failure(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
|
|
109
105
|
"D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
|
|
110
106
|
);
|
|
111
|
-
expect(
|
|
112
|
-
expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
|
|
113
|
-
|
|
107
|
+
expect(await failure(() => DB.prepare("SELECT * FROM nope").all())).toBe("D1_ERROR: no such table: nope: SQLITE_ERROR");
|
|
108
|
+
expect(await DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("from 2026-10-24: every query method returns a Promise", async () => {
|
|
112
|
+
const DB = fresh("2026-10-24");
|
|
113
|
+
const s = DB.prepare("SELECT 41 AS a");
|
|
114
|
+
for (const p of [s.all(), s.run(), s.first(), s.raw(), DB.batch([s]), DB.exec("SELECT 1"), DB.dump().catch(() => 0)]) expect(p).toBeInstanceOf(Promise);
|
|
115
|
+
expect(await s.first("a").then((a) => a + 1)).toBe(42);
|
|
116
|
+
expect(await Promise.all([s.first("a"), DB.prepare("SELECT 2 AS a").first("a")])).toEqual([41, 2]);
|
|
117
|
+
// Errors reject rather than throw: .catch() sees them.
|
|
118
|
+
expect(await DB.prepare("SELEC 1").all().catch((e) => e.message)).toMatch(/^D1_ERROR: near "SELEC": syntax error/);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("from 2026-10-24: results, meta, raw() and first() as in workerd", async () => {
|
|
122
|
+
const DB = fresh("2026-10-24");
|
|
123
|
+
await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
|
124
|
+
const inserted = await DB.prepare("INSERT INTO t (name) VALUES (?), (?)").bind("one", "two").run();
|
|
125
|
+
expect(Object.keys(inserted).sort()).toEqual(["meta", "results", "success"]);
|
|
126
|
+
expect(Object.keys(inserted.meta).sort()).toEqual(["changed_db", "changes", "duration", "last_row_id", "rows_read", "rows_written", "served_by", "size_after"]);
|
|
127
|
+
expect([inserted.meta.changes, inserted.meta.last_row_id, inserted.meta.changed_db, inserted.meta.rows_written]).toEqual([2, 2, true, 2]);
|
|
128
|
+
const read = await DB.prepare("SELECT id, name FROM t ORDER BY id").all();
|
|
129
|
+
expect([read.meta.changes, read.meta.changed_db, read.meta.rows_written]).toEqual([0, false, 0]);
|
|
130
|
+
expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw()).toEqual([[1, "one"], [2, "two"]]);
|
|
131
|
+
expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw({ columnNames: true })).toEqual([["id", "name"], [1, "one"], [2, "two"]]);
|
|
132
|
+
expect(await DB.prepare("SELECT id FROM t WHERE id > 9").raw({ columnNames: true })).toEqual([["id"]]);
|
|
133
|
+
expect(await DB.prepare("SELECT 1 AS a, 2 AS a").raw({ columnNames: true })).toEqual([["a", "a"], [1, 2]]);
|
|
134
|
+
expect((await DB.prepare("SELECT 1 AS a, 2 AS a").all()).results).toEqual([{ a: 2 }]);
|
|
135
|
+
expect(await failure(() => DB.prepare("SELECT id FROM t").first("nope"))).toBe("D1_COLUMN_NOTFOUND: Column not found (nope)");
|
|
136
|
+
expect(await DB.prepare("SELECT id FROM t WHERE id > 9").first("nope")).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("from 2026-10-24: empty SQL, transactions, exec() and sessions as in workerd", async () => {
|
|
140
|
+
const DB = fresh("2026-10-24");
|
|
141
|
+
expect(await failure(() => DB.prepare("").all())).toBe(NO_STATEMENTS);
|
|
142
|
+
expect(await failure(() => DB.batch([]))).toBe(NO_STATEMENTS);
|
|
143
|
+
expect(await failure(() => DB.prepare("BEGIN TRANSACTION").run())).toMatch(/^D1_ERROR: To execute a transaction, please use the state\.storage\.transaction\(\)/);
|
|
144
|
+
expect(await failure(() => DB.prepare("savepoint x").run())).toMatch(/^D1_ERROR: To execute a transaction/);
|
|
145
|
+
const done = await DB.exec("CREATE TABLE t (id INTEGER)\nINSERT INTO t VALUES (1);\n\nINSERT INTO t VALUES (2);\n");
|
|
146
|
+
expect([done.count, Number.isFinite(done.duration)]).toEqual([3, true]);
|
|
147
|
+
expect(await DB.exec("SELECT 1").then((r) => r.count)).toBe(1);
|
|
148
|
+
expect(await failure(() => DB.exec("INSERT INTO t VALUES (3);\nSELEC 2"))).toMatch(/^D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error/);
|
|
149
|
+
expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
|
|
150
|
+
const session = DB.withSession("first-unconstrained");
|
|
151
|
+
expect(await session.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
|
|
152
|
+
expect(session.getBookmark()).toBeNull();
|
|
153
|
+
expect(await session.batch([session.prepare("SELECT 1 AS a")]).then((r) => r[0].results)).toEqual([{ a: 1 }]);
|
|
114
154
|
});
|
|
@@ -4869,7 +4869,62 @@ function __sbD1Result(r, strict) {
|
|
|
4869
4869
|
if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
|
|
4870
4870
|
return { results: __sbD1Objects(r.columns || [], __sbD1Rows(r)), success: true, meta: r.meta || {} };
|
|
4871
4871
|
}
|
|
4872
|
+
function __sbD1Fail(message) {
|
|
4873
|
+
return new Error(message);
|
|
4874
|
+
}
|
|
4875
|
+
// From 2026-10-24: workerd's surface. Every query method returns a Promise, meta has D1's
|
|
4876
|
+
// keys, raw() takes { columnNames }, first(name) checks the column, empty SQL and
|
|
4877
|
+
// transaction statements are refused, exec() runs line by line, and withSession() exists.
|
|
4878
|
+
var __sbD1NoTransactions =
|
|
4879
|
+
"D1_ERROR: To execute a transaction, please use the state.storage.transaction() or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT statements. The JavaScript API is safer because it will automatically roll back on exceptions, and because it interacts correctly with Durable Objects' automatic atomic write coalescing.";
|
|
4880
|
+
function __sbD1Check(sql) {
|
|
4881
|
+
const text = String(sql).trim();
|
|
4882
|
+
if (!text) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
|
|
4883
|
+
if (/^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) throw __sbD1Fail(__sbD1NoTransactions);
|
|
4884
|
+
}
|
|
4885
|
+
function __sbD1Meta(meta, started) {
|
|
4886
|
+
const changes = Number(meta.changes) || 0;
|
|
4887
|
+
return {
|
|
4888
|
+
served_by: "sproutboat",
|
|
4889
|
+
duration: Date.now() - started,
|
|
4890
|
+
changes,
|
|
4891
|
+
last_row_id: Number(meta.last_row_id) || 0,
|
|
4892
|
+
changed_db: changes > 0,
|
|
4893
|
+
size_after: 0,
|
|
4894
|
+
rows_read: Number(meta.rows_read) || 0,
|
|
4895
|
+
rows_written: changes,
|
|
4896
|
+
};
|
|
4897
|
+
}
|
|
4898
|
+
// Columns and rows of a strict reply (an older broker answers with objects only).
|
|
4899
|
+
function __sbD1Table(r) {
|
|
4900
|
+
if (r.columns) return { columns: r.columns, rows: __sbD1Rows(r) };
|
|
4901
|
+
const objects = r.results || [];
|
|
4902
|
+
const columns = objects.length ? Object.keys(objects[0]) : [];
|
|
4903
|
+
const rows = [];
|
|
4904
|
+
for (let i = 0; i < objects.length; i++) {
|
|
4905
|
+
const row = [];
|
|
4906
|
+
for (let c = 0; c < columns.length; c++) row.push(objects[i][columns[c]]);
|
|
4907
|
+
rows.push(row);
|
|
4908
|
+
}
|
|
4909
|
+
return { columns, rows };
|
|
4910
|
+
}
|
|
4911
|
+
function __sbD1Promise(run) {
|
|
4912
|
+
try {
|
|
4913
|
+
return Promise.resolve(run());
|
|
4914
|
+
} catch (err) {
|
|
4915
|
+
return Promise.reject(err);
|
|
4916
|
+
}
|
|
4917
|
+
}
|
|
4872
4918
|
function __sbMakeD1(dbName) {
|
|
4919
|
+
// The synchronous core: the answer to one statement as columns, rows and meta.
|
|
4920
|
+
function query(sql, params, strict) {
|
|
4921
|
+
if (strict) __sbD1Check(sql);
|
|
4922
|
+
const started = Date.now();
|
|
4923
|
+
const r = __sbD1Rpc("d1.query", { db: dbName, sql, params }, strict);
|
|
4924
|
+
if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
|
|
4925
|
+
const table = __sbD1Table(r);
|
|
4926
|
+
return { columns: table.columns, rows: table.rows, meta: __sbD1Meta(r.meta || {}, started) };
|
|
4927
|
+
}
|
|
4873
4928
|
function stmt(sql, params) {
|
|
4874
4929
|
const s = {
|
|
4875
4930
|
__sql: sql,
|
|
@@ -4881,47 +4936,109 @@ function __sbMakeD1(dbName) {
|
|
|
4881
4936
|
return stmt(sql, list);
|
|
4882
4937
|
},
|
|
4883
4938
|
all() {
|
|
4884
|
-
|
|
4885
|
-
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
|
+
});
|
|
4886
4944
|
},
|
|
4887
4945
|
run() {
|
|
4888
4946
|
return s.all();
|
|
4889
4947
|
},
|
|
4890
|
-
raw() {
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
4894
|
-
|
|
4895
|
-
|
|
4896
|
-
|
|
4948
|
+
raw(options) {
|
|
4949
|
+
if (!__sbD1Strict()) {
|
|
4950
|
+
const rows = query(sql, params, false).results;
|
|
4951
|
+
const out = [];
|
|
4952
|
+
for (let i = 0; i < rows.length; i++) {
|
|
4953
|
+
const cols = [];
|
|
4954
|
+
for (const k in rows[i]) cols.push(rows[i][k]);
|
|
4955
|
+
out.push(cols);
|
|
4956
|
+
}
|
|
4957
|
+
return out;
|
|
4897
4958
|
}
|
|
4898
|
-
return
|
|
4959
|
+
return __sbD1Promise(() => {
|
|
4960
|
+
const t = query(sql, params, true);
|
|
4961
|
+
return options && options.columnNames ? [t.columns].concat(t.rows) : t.rows;
|
|
4962
|
+
});
|
|
4899
4963
|
},
|
|
4900
4964
|
first(column) {
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
|
|
4965
|
+
if (!__sbD1Strict()) {
|
|
4966
|
+
const rows = query(sql, params, false).results;
|
|
4967
|
+
if (rows.length === 0) return null;
|
|
4968
|
+
return column == null ? rows[0] : rows[0][column];
|
|
4969
|
+
}
|
|
4970
|
+
return __sbD1Promise(() => {
|
|
4971
|
+
const t = query(sql, params, true);
|
|
4972
|
+
if (column == null) return t.rows.length ? __sbD1Objects(t.columns, [t.rows[0]])[0] : null;
|
|
4973
|
+
const at = t.columns.indexOf(String(column));
|
|
4974
|
+
if (!t.rows.length) return null;
|
|
4975
|
+
if (at < 0) throw __sbD1Fail("D1_COLUMN_NOTFOUND: Column not found (" + column + ")");
|
|
4976
|
+
return t.rows[0][t.columns.lastIndexOf(String(column))];
|
|
4977
|
+
});
|
|
4904
4978
|
},
|
|
4905
4979
|
};
|
|
4906
4980
|
return s;
|
|
4907
4981
|
}
|
|
4908
|
-
|
|
4982
|
+
function batch(statements) {
|
|
4983
|
+
const strict = __sbD1Strict();
|
|
4984
|
+
const list = [];
|
|
4985
|
+
for (let i = 0; i < (statements || []).length; i++) {
|
|
4986
|
+
if (strict) __sbD1Check(statements[i].__sql);
|
|
4987
|
+
list.push({ sql: statements[i].__sql, params: statements[i].__params });
|
|
4988
|
+
}
|
|
4989
|
+
if (strict && !list.length) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
|
|
4990
|
+
const started = Date.now();
|
|
4991
|
+
const r = __sbD1Rpc("d1.batch", { db: dbName, statements: list }, strict);
|
|
4992
|
+
const out = [];
|
|
4993
|
+
for (let i = 0; i < (r.results || []).length; i++) {
|
|
4994
|
+
if (!strict) {
|
|
4995
|
+
out.push(__sbD1Result(r.results[i], false));
|
|
4996
|
+
continue;
|
|
4997
|
+
}
|
|
4998
|
+
const t = __sbD1Table(r.results[i]);
|
|
4999
|
+
out.push({ success: true, meta: __sbD1Meta(r.results[i].meta || {}, started), results: __sbD1Objects(t.columns, t.rows) });
|
|
5000
|
+
}
|
|
5001
|
+
return out;
|
|
5002
|
+
}
|
|
5003
|
+
function exec(sql) {
|
|
5004
|
+
if (!__sbD1Strict()) {
|
|
5005
|
+
__sbRpc("d1.exec", { db: dbName, sql: String(sql) });
|
|
5006
|
+
return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
|
|
5007
|
+
}
|
|
5008
|
+
// workerd runs exec() line by line and counts the lines.
|
|
5009
|
+
const started = Date.now();
|
|
5010
|
+
const all = String(sql).trim().split("\n");
|
|
5011
|
+
const lines = [];
|
|
5012
|
+
for (let i = 0; i < all.length; i++) if (all[i].trim()) lines.push(all[i]);
|
|
5013
|
+
if (!lines.length) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
|
|
5014
|
+
__sbD1Rpc("d1.exec", { db: dbName, sql: lines.join("\n"), lines }, true);
|
|
5015
|
+
return { count: lines.length, duration: Date.now() - started };
|
|
5016
|
+
}
|
|
5017
|
+
const db = {
|
|
4909
5018
|
prepare(sql) {
|
|
4910
5019
|
return stmt(String(sql), []);
|
|
4911
5020
|
},
|
|
4912
5021
|
batch(statements) {
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
for (let i = 0; i < (statements || []).length; i++)
|
|
4916
|
-
list.push({ sql: statements[i].__sql, params: statements[i].__params });
|
|
4917
|
-
const r = __sbD1Rpc("d1.batch", { db: dbName, statements: list }, strict);
|
|
4918
|
-
const out = [];
|
|
4919
|
-
for (let i = 0; i < (r.results || []).length; i++) out.push(__sbD1Result(r.results[i], strict));
|
|
4920
|
-
return out;
|
|
5022
|
+
if (!__sbD1Strict()) return batch(statements);
|
|
5023
|
+
return __sbD1Promise(() => batch(statements));
|
|
4921
5024
|
},
|
|
4922
5025
|
exec(sql) {
|
|
4923
|
-
|
|
4924
|
-
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()"));
|
|
4925
5042
|
},
|
|
4926
5043
|
// Sproutboat extension (not in CF Workers D1): an online, integrity-checked
|
|
4927
5044
|
// snapshot of this database. Both transports implement it: standalone writes
|
|
@@ -4932,6 +5049,7 @@ function __sbMakeD1(dbName) {
|
|
|
4932
5049
|
return { path: r.path, bytes: r.bytes };
|
|
4933
5050
|
},
|
|
4934
5051
|
};
|
|
5052
|
+
return db;
|
|
4935
5053
|
}
|
|
4936
5054
|
|
|
4937
5055
|
// R2: a Cloudflare-shaped object. When `body` is present the sync accessors
|
|
@@ -1945,6 +1945,14 @@ size_t sb_r2_transfer_download_read(sb_r2_transfer_ctx* ctx, size_t offset, char
|
|
|
1945
1945
|
}
|
|
1946
1946
|
void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx->file) fclose(ctx->file); free(ctx); } }
|
|
1947
1947
|
|
|
1948
|
+
// LTX capture (#141) failed after SQLite committed: the write is in the database but not in
|
|
1949
|
+
// the replication log, and the database refuses writes until the process restarts. Said as
|
|
1950
|
+
// such (SQLITE_IOERR), never as SQLite's own message, which still reads "not an error".
|
|
1951
|
+
#define SB_LTX_FAILED "LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart"
|
|
1952
|
+
static int sb_db_capture_failed(const char* path) {
|
|
1953
|
+
for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
|
|
1954
|
+
return 0;
|
|
1955
|
+
}
|
|
1948
1956
|
// Run a script: one or more statements separated by semicolons. sqlite3_exec
|
|
1949
1957
|
// handles the whole string, which prepare/step does not — it compiles the first
|
|
1950
1958
|
// statement and silently ignores the rest, so a schema built from one exec call
|
|
@@ -1952,9 +1960,10 @@ void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx
|
|
|
1952
1960
|
static char* sb_sql_script(const char* path, const char* sql) {
|
|
1953
1961
|
sb_buf b = { 0, 0, 0 };
|
|
1954
1962
|
int idx = sb_db_for(path);
|
|
1963
|
+
if (idx < 0 && sb_db_capture_failed(path)) { sb_puts(&b, "{\"ok\":false,\"error\":\"" SB_LTX_FAILED "\",\"code\":10}"); return b.p; }
|
|
1955
1964
|
if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
|
|
1956
1965
|
char* err = 0;
|
|
1957
|
-
int script_rc = 0;
|
|
1966
|
+
int script_rc = 0, capture_failed = 0;
|
|
1958
1967
|
if (sb_ltx_states[idx].db) {
|
|
1959
1968
|
const char *next = sql;
|
|
1960
1969
|
while (*next && !script_rc) {
|
|
@@ -1962,15 +1971,16 @@ static char* sb_sql_script(const char* path, const char* sql) {
|
|
|
1962
1971
|
script_rc = sqlite3_prepare_v2(sb_dbs[idx], next, -1, &st, &tail);
|
|
1963
1972
|
if (!script_rc && st) { do { script_rc = sqlite3_step(st); } while (script_rc == SB_SQLITE_ROW); if (script_rc == SB_SQLITE_DONE) script_rc = 0; }
|
|
1964
1973
|
if (st) sqlite3_finalize(st);
|
|
1965
|
-
if (sb_ltx_after(idx)) script_rc = 10;
|
|
1974
|
+
if (sb_ltx_after(idx)) { script_rc = 10; capture_failed = 1; }
|
|
1966
1975
|
if (!tail || tail <= next) break;
|
|
1967
1976
|
next = tail;
|
|
1968
1977
|
}
|
|
1969
1978
|
} else script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
|
|
1970
1979
|
if (script_rc != 0) {
|
|
1980
|
+
const char* m = capture_failed ? SB_LTX_FAILED : err ? err : sqlite3_errmsg(sb_dbs[idx]);
|
|
1971
1981
|
sb_puts(&b, "{\"ok\":false,\"error\":");
|
|
1972
|
-
sb_putjson_text(&b,
|
|
1973
|
-
sb_puts(&b, "}");
|
|
1982
|
+
sb_putjson_text(&b, m, strlen(m));
|
|
1983
|
+
sb_puts(&b, capture_failed ? ",\"code\":10}" : "}");
|
|
1974
1984
|
return b.p;
|
|
1975
1985
|
}
|
|
1976
1986
|
sb_puts(&b, "{\"ok\":true}");
|
|
@@ -1995,14 +2005,6 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
|
|
|
1995
2005
|
// D1 calls from compatibility date 2026-10-24 put this comment first: the statement must
|
|
1996
2006
|
// get exactly as many parameters as it has, like D1.
|
|
1997
2007
|
#define SB_D1_STRICT "/*sb-d1-strict*/"
|
|
1998
|
-
// LTX capture (#141) failed after SQLite committed: the write is in the database but not in
|
|
1999
|
-
// the replication log, and the database refuses writes until the process restarts. Said as
|
|
2000
|
-
// such (SQLITE_IOERR), never as SQLite's own message, which still reads "not an error".
|
|
2001
|
-
#define SB_LTX_FAILED "LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart"
|
|
2002
|
-
static int sb_db_capture_failed(const char* path) {
|
|
2003
|
-
for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
|
|
2004
|
-
return 0;
|
|
2005
|
-
}
|
|
2006
2008
|
|
|
2007
2009
|
// Run one statement. Returns malloc'd JSON:
|
|
2008
2010
|
// {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
|
|
@@ -3584,6 +3586,10 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3584
3586
|
// exec() takes a script; a single prepare would run only its first
|
|
3585
3587
|
// statement and quietly drop the rest.
|
|
3586
3588
|
__sbD1Open(path, msg.strict === true);
|
|
3589
|
+
if (msg.strict === true && Array.isArray(msg.lines)) {
|
|
3590
|
+
__sbD1ExecLines(path, msg.lines);
|
|
3591
|
+
return { ok: true };
|
|
3592
|
+
}
|
|
3587
3593
|
const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
|
|
3588
3594
|
if (reply.ok === false) throw new Error("sqlite: " + reply.error);
|
|
3589
3595
|
return { ok: true };
|
|
@@ -4138,6 +4144,18 @@ function __sbD1Release(path, savepoint, strict) {
|
|
|
4138
4144
|
if (Number(r.code) !== 10) __sbD1Undo(path, savepoint);
|
|
4139
4145
|
throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
|
|
4140
4146
|
}
|
|
4147
|
+
// exec() from 2026-10-24: line by line as workerd runs it, all or nothing.
|
|
4148
|
+
function __sbD1ExecLines(path, lines) {
|
|
4149
|
+
__sbD1Control(path, "SAVEPOINT sb_d1_exec", true);
|
|
4150
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4151
|
+
const reply = JSON.parse(__sbSqlScriptRaw(path, String(lines[i])));
|
|
4152
|
+
if (reply.ok === false) {
|
|
4153
|
+
if (Number(reply.code) !== 10) __sbD1Undo(path, "sb_d1_exec");
|
|
4154
|
+
throw new Error("D1_EXEC_ERROR: Error in line " + (i + 1) + ": " + lines[i] + ": " + reply.error);
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
__sbD1Release(path, "sb_d1_exec", true);
|
|
4158
|
+
}
|
|
4141
4159
|
// END D1 OPS
|
|
4142
4160
|
|
|
4143
4161
|
/**
|
package/test/native-d1.ts
CHANGED
|
@@ -47,7 +47,8 @@ int main(void) {
|
|
|
47
47
|
if (!strncmp(line, ":break ", 7)) { line[strcspn(line, "\\t")] = 0; char p[1024]; snprintf(p, sizeof(p), "%s.ltx/position.tmp", line + 7); mkdir(p, 0700); puts("{}"); fflush(stdout); continue; }
|
|
48
48
|
char* sql = strchr(line, '\\t'); if (!sql) return 2; *sql++ = 0;
|
|
49
49
|
char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
|
|
50
|
-
|
|
50
|
+
// ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
|
|
51
|
+
char* out = !strncmp(line, ":script ", 8) ? sb_sql_script(line + 8, sql) : sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
|
|
51
52
|
}
|
|
52
53
|
return 0;
|
|
53
54
|
}
|
|
@@ -164,11 +165,21 @@ int main(void) {
|
|
|
164
165
|
return r;
|
|
165
166
|
};
|
|
166
167
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
168
|
+
const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
|
|
167
169
|
const ops = new Function(
|
|
168
170
|
"__sbSqlRaw",
|
|
169
171
|
"__sbSql",
|
|
170
|
-
|
|
171
|
-
|
|
172
|
+
"__sbSqlScriptRaw",
|
|
173
|
+
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
|
|
174
|
+
)(sqlRaw, sql, scriptRaw);
|
|
175
|
+
// exec() from 2026-10-24: line by line, all or nothing, the failing line named.
|
|
176
|
+
const ex = join(work, "data/d1/EXEC.sqlite");
|
|
177
|
+
ops.__sbD1ExecLines(ex, ["CREATE TABLE x (id INTEGER)", "INSERT INTO x VALUES (1); INSERT INTO x VALUES (2);"]);
|
|
178
|
+
assert.throws(
|
|
179
|
+
() => ops.__sbD1ExecLines(ex, ["INSERT INTO x VALUES (3);", "SELEC 2"]),
|
|
180
|
+
(e: Error) => e.message === 'D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error',
|
|
181
|
+
);
|
|
182
|
+
assert.equal(ops.__sbD1Run(ex, "SELECT count(*) AS n FROM x", [], false).results[0].n, 2, "exec rolled back");
|
|
172
183
|
const db2 = join(work, "data/d1/OPS.sqlite");
|
|
173
184
|
ops.__sbD1Run(db2, "CREATE TABLE p (id INTEGER PRIMARY KEY)", [], false);
|
|
174
185
|
ops.__sbD1Run(db2, "CREATE TABLE c (id INTEGER PRIMARY KEY, p INTEGER REFERENCES p(id))", [], false);
|
|
@@ -251,11 +262,12 @@ int main(void) {
|
|
|
251
262
|
const breaking = new Function(
|
|
252
263
|
"__sbSqlRaw",
|
|
253
264
|
"__sbSql",
|
|
265
|
+
"__sbSqlScriptRaw",
|
|
254
266
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
|
|
255
267
|
)((path: string, text: string, params: string) => {
|
|
256
268
|
if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
|
|
257
269
|
return sqlRaw(path, text, params);
|
|
258
|
-
}, sql);
|
|
270
|
+
}, sql, scriptRaw);
|
|
259
271
|
const LTX_FAILED =
|
|
260
272
|
"D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR";
|
|
261
273
|
assert.throws(
|
|
@@ -272,6 +284,11 @@ int main(void) {
|
|
|
272
284
|
/^Error: sqlite: LTX capture failed/,
|
|
273
285
|
"old dates see the same failure",
|
|
274
286
|
);
|
|
287
|
+
assert.throws(
|
|
288
|
+
() => ops.__sbD1ExecLines(ltx, ["INSERT INTO e VALUES (5, 'e')"]),
|
|
289
|
+
(e: Error) => e.message === "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR",
|
|
290
|
+
"exec() after a capture failure",
|
|
291
|
+
);
|
|
275
292
|
closeSync(toDriver);
|
|
276
293
|
closeSync(fromDriver);
|
|
277
294
|
await driver.exited;
|