@sproutboat/runtime 0.23.1 → 0.25.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 +41 -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 +197 -13
- package/test/native-d1.ts +97 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ef40955: The storage quota is a hard bound when LTX capture is on. A D1 database under capture can't be capped by pinning its page count: a same-size `UPDATE`, or a `DELETE`, still adds a file to the replication spool, and the spool counts toward the app's quota. So while the app is over its quota, every write to a captured database is refused with `D1_ERROR: storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota: SQLITE_FULL`. That covers `exec()` and `batch()` too.
|
|
8
|
+
|
|
9
|
+
- Reads, read-only batches and transaction control still run.
|
|
10
|
+
- No LTX file is ever dropped to make room: the policy fails closed.
|
|
11
|
+
- Writes resume once the app is under its quota again, at the next rescan: for example after the operator raises the quota, or the app deletes R2 or KV data. The freeze holds across a restart.
|
|
12
|
+
|
|
13
|
+
This freezes statements after the quota scan; it is not a hard filesystem cap. The write that crosses the quota is accepted.
|
|
14
|
+
|
|
15
|
+
Databases without capture are unchanged: they are pinned at their size, so deletes still work.
|
|
16
|
+
- 95f6bd7: A storage quota for standalone sprouts and sproutd. An app's data directory (KV, D1, R2, Durable Object storage, queues) holds at most `SB_STORAGE_MAX_BYTES` bytes; sproutd sets it per app, and it defaults to 10 GiB. Each D1 database is also capped at 10 GB, as in D1. Past the quota:
|
|
17
|
+
|
|
18
|
+
- writes fail with an error that says so, such as `KV put() failed: storage quota exceeded (this app stores N bytes; the limit is M)`, or `D1_ERROR: database or disk is full: SQLITE_FULL` for D1;
|
|
19
|
+
- reads and deletes keep working, so an app can make room (for D1, `DELETE` and then `VACUUM`).
|
|
20
|
+
|
|
21
|
+
This is a check after a scan, not a hard filesystem cap with zero overshoot. The size is measured at most once a second, by walking the directory, and a write is checked against the last measurement. The write that crosses the limit is accepted (an R2 object, say), and so is anything else written within that second. Writes after it are refused.
|
|
22
|
+
|
|
23
|
+
## 0.24.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- 9a5363e: D1 matches workerd from compatibility date `2026-10-24`:
|
|
28
|
+
|
|
29
|
+
- `all()`, `run()`, `first()`, `raw()`, `batch()` and `exec()` return Promises, and errors reject them. `bind()` stays synchronous and still throws `D1_TYPE_ERROR` at once.
|
|
30
|
+
- `meta` has D1's keys: `served_by`, `duration`, `changes`, `last_row_id`, `changed_db`, `size_after` (always 0 here), `rows_read` and `rows_written`.
|
|
31
|
+
- `raw({ columnNames: true })` puts the column names first, and keeps duplicate names.
|
|
32
|
+
- `first("name")` throws `D1_COLUMN_NOTFOUND` for a column the result doesn't have.
|
|
33
|
+
- Empty SQL and an empty `batch([])` throw `D1_ERROR: No SQL statements detected.`
|
|
34
|
+
- `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` and `RELEASE` are refused, as in D1. A batch is already one transaction.
|
|
35
|
+
- `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: ...`.
|
|
36
|
+
- `withSession()` returns a session with `prepare`, `batch` and `getBookmark()`, which returns `null`. `dump()` rejects; use `backup()` instead.
|
|
37
|
+
|
|
38
|
+
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.
|
|
39
|
+
|
|
40
|
+
Not yet like D1: `rows_read` counts the rows returned, not the rows scanned, and pragmas are not limited to D1's allow-list.
|
|
41
|
+
|
|
42
|
+
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.
|
|
43
|
+
|
|
3
44
|
## 0.23.1
|
|
4
45
|
|
|
5
46
|
### 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,19 @@ 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
|
+
// The storage quota, for a database under LTX capture: while the app is over its quota,
|
|
1953
|
+
// every write is refused (a same-size UPDATE or a DELETE still adds an LTX file to the
|
|
1954
|
+
// spool, which counts toward the quota). Reads and transaction control still run.
|
|
1955
|
+
#define SB_QUOTA_FULL "storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota"
|
|
1956
|
+
static unsigned char sb_quota_frozen[SB_MAX_DB];
|
|
1957
|
+
static int sb_db_capture_failed(const char* path) {
|
|
1958
|
+
for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
|
|
1959
|
+
return 0;
|
|
1960
|
+
}
|
|
1948
1961
|
// Run a script: one or more statements separated by semicolons. sqlite3_exec
|
|
1949
1962
|
// handles the whole string, which prepare/step does not — it compiles the first
|
|
1950
1963
|
// statement and silently ignores the rest, so a schema built from one exec call
|
|
@@ -1952,25 +1965,34 @@ void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx
|
|
|
1952
1965
|
static char* sb_sql_script(const char* path, const char* sql) {
|
|
1953
1966
|
sb_buf b = { 0, 0, 0 };
|
|
1954
1967
|
int idx = sb_db_for(path);
|
|
1968
|
+
if (idx < 0 && sb_db_capture_failed(path)) { sb_puts(&b, "{\"ok\":false,\"error\":\"" SB_LTX_FAILED "\",\"code\":10}"); return b.p; }
|
|
1955
1969
|
if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
|
|
1956
1970
|
char* err = 0;
|
|
1957
|
-
int script_rc = 0;
|
|
1971
|
+
int script_rc = 0, capture_failed = 0, quota_full = 0, failure_code = 0;
|
|
1972
|
+
char failure[512] = { 0 };
|
|
1958
1973
|
if (sb_ltx_states[idx].db) {
|
|
1959
1974
|
const char *next = sql;
|
|
1960
1975
|
while (*next && !script_rc) {
|
|
1961
1976
|
sqlite3_stmt *st = 0; const char *tail = 0;
|
|
1962
1977
|
script_rc = sqlite3_prepare_v2(sb_dbs[idx], next, -1, &st, &tail);
|
|
1978
|
+
if (!script_rc && st && sb_quota_frozen[idx] && !sqlite3_stmt_readonly(st)) { sqlite3_finalize(st); quota_full = 1; script_rc = 13; break; }
|
|
1963
1979
|
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; }
|
|
1980
|
+
// Keep the statement's error before capture runs statements of its own (it would
|
|
1981
|
+
// read "not an error" after them).
|
|
1982
|
+
if (script_rc && !failure[0]) { snprintf(failure, sizeof(failure), "%s", sqlite3_errmsg(sb_dbs[idx])); failure_code = sqlite3_extended_errcode(sb_dbs[idx]); }
|
|
1964
1983
|
if (st) sqlite3_finalize(st);
|
|
1965
|
-
if (sb_ltx_after(idx)) script_rc = 10;
|
|
1984
|
+
if (sb_ltx_after(idx)) { script_rc = 10; capture_failed = 1; }
|
|
1966
1985
|
if (!tail || tail <= next) break;
|
|
1967
1986
|
next = tail;
|
|
1968
1987
|
}
|
|
1969
1988
|
} else script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
|
|
1970
1989
|
if (script_rc != 0) {
|
|
1990
|
+
const char* m = quota_full ? SB_QUOTA_FULL : capture_failed ? SB_LTX_FAILED : failure[0] ? failure : err ? err : sqlite3_errmsg(sb_dbs[idx]);
|
|
1971
1991
|
sb_puts(&b, "{\"ok\":false,\"error\":");
|
|
1972
|
-
sb_putjson_text(&b,
|
|
1973
|
-
|
|
1992
|
+
sb_putjson_text(&b, m, strlen(m));
|
|
1993
|
+
char code[32];
|
|
1994
|
+
snprintf(code, sizeof(code), ",\"code\":%d}", quota_full ? 13 : capture_failed ? 10 : failure[0] ? failure_code : 0);
|
|
1995
|
+
sb_puts(&b, code);
|
|
1974
1996
|
return b.p;
|
|
1975
1997
|
}
|
|
1976
1998
|
sb_puts(&b, "{\"ok\":true}");
|
|
@@ -1995,14 +2017,6 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
|
|
|
1995
2017
|
// D1 calls from compatibility date 2026-10-24 put this comment first: the statement must
|
|
1996
2018
|
// get exactly as many parameters as it has, like D1.
|
|
1997
2019
|
#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
2020
|
|
|
2007
2021
|
// Run one statement. Returns malloc'd JSON:
|
|
2008
2022
|
// {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
|
|
@@ -2019,6 +2033,10 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
2019
2033
|
sqlite3_stmt* st = sb_stmt_get(idx, db, sql);
|
|
2020
2034
|
if (!st) return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
|
|
2021
2035
|
int bound = params && *params ? sb_bind_params(st, params) : 0;
|
|
2036
|
+
if (sb_quota_frozen[idx] && !sqlite3_stmt_readonly(st)) {
|
|
2037
|
+
sqlite3_reset(st);
|
|
2038
|
+
return sb_sql_error(db, SB_QUOTA_FULL, 13, 0);
|
|
2039
|
+
}
|
|
2022
2040
|
if (strict && bound != sqlite3_bind_parameter_count(st)) {
|
|
2023
2041
|
sqlite3_reset(st);
|
|
2024
2042
|
return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
|
|
@@ -2069,16 +2087,65 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
|
|
|
2069
2087
|
// Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
|
|
2070
2088
|
// now (not just on next reuse) so a SELECT stops holding its read transaction.
|
|
2071
2089
|
sqlite3_reset(st);
|
|
2090
|
+
// The statement's own error, kept before LTX capture runs: capture executes statements
|
|
2091
|
+
// of its own, after which SQLite reports "not an error".
|
|
2092
|
+
char failure[512] = { 0 };
|
|
2093
|
+
int failure_code = 0;
|
|
2094
|
+
if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
|
|
2095
|
+
snprintf(failure, sizeof(failure), "%s", sqlite3_errmsg(db));
|
|
2096
|
+
failure_code = sqlite3_extended_errcode(db);
|
|
2097
|
+
}
|
|
2072
2098
|
const int capture_failed = sb_ltx_after(idx) != 0;
|
|
2073
2099
|
if (capture_failed) rc = 10;
|
|
2074
2100
|
if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
|
|
2075
2101
|
free(b.p);
|
|
2076
2102
|
if (capture_failed) return sb_sql_error(db, SB_LTX_FAILED, 10, 0);
|
|
2077
|
-
return sb_sql_error(db,
|
|
2103
|
+
return sb_sql_error(db, failure, failure_code, 0);
|
|
2078
2104
|
}
|
|
2079
2105
|
return b.p;
|
|
2080
2106
|
}
|
|
2081
2107
|
|
|
2108
|
+
// The storage quota (docs: an app's data directory is capped): bytes in regular files under
|
|
2109
|
+
// dir, symlinks not followed. Bounded by depth; an unreadable entry counts as 0.
|
|
2110
|
+
static long long sb_dir_bytes(const char* dir, int depth) {
|
|
2111
|
+
DIR* d = opendir(dir);
|
|
2112
|
+
if (!d || depth > 16) { if (d) closedir(d); return 0; }
|
|
2113
|
+
long long total = 0;
|
|
2114
|
+
struct dirent* e;
|
|
2115
|
+
char path[4096];
|
|
2116
|
+
while ((e = readdir(d))) {
|
|
2117
|
+
if (!strcmp(e->d_name, ".") || !strcmp(e->d_name, "..")) continue;
|
|
2118
|
+
if (snprintf(path, sizeof(path), "%s/%s", dir, e->d_name) >= (int)sizeof(path)) continue;
|
|
2119
|
+
struct stat st;
|
|
2120
|
+
if (lstat(path, &st)) continue;
|
|
2121
|
+
if (S_ISREG(st.st_mode)) total += (long long)st.st_size;
|
|
2122
|
+
else if (S_ISDIR(st.st_mode)) total += sb_dir_bytes(path, depth + 1);
|
|
2123
|
+
}
|
|
2124
|
+
closedir(d);
|
|
2125
|
+
return total;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
// A database's page size without SQL: under LTX capture the authorizer refuses every
|
|
2129
|
+
// page_size pragma (the getter too), and capture records the size itself.
|
|
2130
|
+
static int sb_db_page_size(const char* path) {
|
|
2131
|
+
int idx = sb_db_for(path);
|
|
2132
|
+
if (idx < 0) return 0;
|
|
2133
|
+
if (sb_ltx_states[idx].db) return (int)sb_ltx_states[idx].page_size;
|
|
2134
|
+
sqlite3_stmt* st = 0;
|
|
2135
|
+
int size = 0;
|
|
2136
|
+
if (sqlite3_prepare_v2(sb_dbs[idx], "PRAGMA page_size", -1, &st, 0) == 0 && st && sqlite3_step(st) == SB_SQLITE_ROW) size = sqlite3_column_int(st, 0);
|
|
2137
|
+
if (st) sqlite3_finalize(st);
|
|
2138
|
+
return size;
|
|
2139
|
+
}
|
|
2140
|
+
// Freeze (refuse writes to) or thaw a database for the quota. Returns 1 when the database
|
|
2141
|
+
// is under LTX capture, 0 when not, -1 when it cannot be opened.
|
|
2142
|
+
static int sb_db_quota_freeze(const char* path, int on) {
|
|
2143
|
+
int idx = sb_db_for(path);
|
|
2144
|
+
if (idx < 0) return -1;
|
|
2145
|
+
sb_quota_frozen[idx] = on && sb_ltx_states[idx].db ? 1 : 0;
|
|
2146
|
+
return sb_ltx_states[idx].db ? 1 : 0;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2082
2149
|
// #164 — an online, consistent single-file snapshot of a database, via
|
|
2083
2150
|
// VACUUM INTO. Runs against the live db with no downtime (SQLite holds a read
|
|
2084
2151
|
// transaction for the copy) and writes a fresh, defragmented file with no WAL
|
|
@@ -3101,6 +3168,44 @@ function __sbSqlScriptRaw(path, sql) {
|
|
|
3101
3168
|
return res;
|
|
3102
3169
|
}
|
|
3103
3170
|
|
|
3171
|
+
// The bytes under a directory, as a decimal string (sb_dir_bytes).
|
|
3172
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
3173
|
+
function __sbDirBytesRaw(dir) {
|
|
3174
|
+
let res = "";
|
|
3175
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
3176
|
+
Porffor.c`
|
|
3177
|
+
const char* __d; size_t __dl; char* __do = 0;
|
|
3178
|
+
porf_native_fetch_read_value(dir, &__d, &__dl, &__do);
|
|
3179
|
+
char* __dir = (char*)malloc(__dl + 1); memcpy(__dir, __d, __dl); __dir[__dl] = 0;
|
|
3180
|
+
if (__do) free(__do);
|
|
3181
|
+
char __n[32]; int __k = snprintf(__n, sizeof(__n), "%lld", sb_dir_bytes(__dir, 0));
|
|
3182
|
+
free(__dir);
|
|
3183
|
+
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__n, (size_t)__k), 195);
|
|
3184
|
+
`;
|
|
3185
|
+
return res;
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
// sb_db_page_size and sb_db_quota_freeze, for the quota (a path and a "0"/"1" in, a number out).
|
|
3189
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
3190
|
+
function __sbD1DbRaw(path, what) {
|
|
3191
|
+
let res = "";
|
|
3192
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
3193
|
+
Porffor.c`
|
|
3194
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
3195
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
3196
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
3197
|
+
if (__po) free(__po);
|
|
3198
|
+
const char* __w; size_t __wl; char* __wo = 0;
|
|
3199
|
+
porf_native_fetch_read_value(what, &__w, &__wl, &__wo);
|
|
3200
|
+
int __r = __wl && __w[0] == 'p' ? sb_db_page_size(__path) : sb_db_quota_freeze(__path, __wl && __w[0] == 'f');
|
|
3201
|
+
if (__wo) free(__wo);
|
|
3202
|
+
free(__path);
|
|
3203
|
+
char __n[16]; int __k = snprintf(__n, sizeof(__n), "%d", __r);
|
|
3204
|
+
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__n, (size_t)__k), 195);
|
|
3205
|
+
`;
|
|
3206
|
+
return res;
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3104
3209
|
// #164 — VACUUM INTO a fresh file. Two strings in, JSON out. Same pattern as
|
|
3105
3210
|
// __sbSqlScriptRaw.
|
|
3106
3211
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
@@ -3304,6 +3409,64 @@ function __sbDir() {
|
|
|
3304
3409
|
}
|
|
3305
3410
|
return __sbDataDir;
|
|
3306
3411
|
}
|
|
3412
|
+
// The storage quota: an app's data directory holds at most SB_STORAGE_MAX_BYTES (sproutd
|
|
3413
|
+
// sets it per app; 10 GiB when unset, after D1's 10 GB per database). Past it, writes are
|
|
3414
|
+
// refused with an error that says so; reads and deletes still work, so an app can make
|
|
3415
|
+
// room. The size is measured at most once a second, and on a write's way in, so a write
|
|
3416
|
+
// can overshoot by what one second of writes adds. A D1 database is also capped at 10 GB.
|
|
3417
|
+
var __sbQuotaMax = -1;
|
|
3418
|
+
var __sbQuotaUsed = 0;
|
|
3419
|
+
var __sbQuotaAt = -1;
|
|
3420
|
+
function __sbQuotaLimit() {
|
|
3421
|
+
if (__sbQuotaMax < 0) {
|
|
3422
|
+
const v = Number(__sbEnv("SB_STORAGE_MAX_BYTES"));
|
|
3423
|
+
__sbQuotaMax = v > 0 ? v : 10737418240;
|
|
3424
|
+
}
|
|
3425
|
+
return __sbQuotaMax;
|
|
3426
|
+
}
|
|
3427
|
+
function __sbQuotaFull() {
|
|
3428
|
+
const now = Date.now();
|
|
3429
|
+
if (__sbQuotaAt < 0 || now - __sbQuotaAt >= 1000) {
|
|
3430
|
+
__sbQuotaUsed = Number(__sbDirBytesRaw(__sbDir())) || 0;
|
|
3431
|
+
__sbQuotaAt = now;
|
|
3432
|
+
}
|
|
3433
|
+
return __sbQuotaUsed >= __sbQuotaLimit();
|
|
3434
|
+
}
|
|
3435
|
+
function __sbQuotaCheck(what) {
|
|
3436
|
+
if (__sbQuotaFull())
|
|
3437
|
+
throw new Error(
|
|
3438
|
+
what + " failed: storage quota exceeded (this app stores " + __sbQuotaUsed + " bytes; the limit is " + __sbQuotaLimit() + ")",
|
|
3439
|
+
);
|
|
3440
|
+
}
|
|
3441
|
+
var __sbQuotaOps = {
|
|
3442
|
+
"kv.put": "KV put()",
|
|
3443
|
+
"do.storage.put": "Durable Object storage put()",
|
|
3444
|
+
"do.alarm.set": "Durable Object setAlarm()",
|
|
3445
|
+
"queue.send": "Queue send()",
|
|
3446
|
+
"queue.send_batch": "Queue sendBatch()",
|
|
3447
|
+
"cache.put": "Cache put()",
|
|
3448
|
+
"ae.write": "Analytics Engine writeDataPoint()",
|
|
3449
|
+
"r2.put": "R2 put()",
|
|
3450
|
+
"r2.multipart.create": "R2 createMultipartUpload()",
|
|
3451
|
+
"r2.multipart.complete": "R2 completeMultipartUpload()",
|
|
3452
|
+
};
|
|
3453
|
+
// D1 over the quota: pin each database at its current size (SQLITE_FULL on growth, so
|
|
3454
|
+
// reads and deletes still run); under it, cap it at 10 GB or the quota, whichever is less.
|
|
3455
|
+
// A database under LTX capture is frozen instead: every write is refused, because even a
|
|
3456
|
+
// DELETE adds a file to the replication spool, which counts toward the quota.
|
|
3457
|
+
var __sbD1Capped = {};
|
|
3458
|
+
function __sbD1Quota(path) {
|
|
3459
|
+
const full = __sbQuotaFull();
|
|
3460
|
+
if (__sbD1Capped[path] === full) return;
|
|
3461
|
+
__sbD1Capped[path] = full;
|
|
3462
|
+
const captured = Number(__sbD1DbRaw(path, full ? "freeze" : "thaw")) === 1;
|
|
3463
|
+
if (full && captured) return;
|
|
3464
|
+
const size = Number(__sbD1DbRaw(path, "page_size")) || 4096;
|
|
3465
|
+
const pages = full
|
|
3466
|
+
? Number(__sbSql(path, "PRAGMA page_count", []).rows[0][0])
|
|
3467
|
+
: Math.floor(Math.min(__sbQuotaLimit(), 10000000000) / size);
|
|
3468
|
+
__sbSql(path, "PRAGMA max_page_count = " + Math.max(1, pages), []);
|
|
3469
|
+
}
|
|
3307
3470
|
function __sbStore() {
|
|
3308
3471
|
return __sbDir() + "/store.sqlite";
|
|
3309
3472
|
}
|
|
@@ -3434,6 +3597,7 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3434
3597
|
__sbEnsureSchema();
|
|
3435
3598
|
const store = __sbStore();
|
|
3436
3599
|
const op = msg.op;
|
|
3600
|
+
if (__sbQuotaOps[op]) __sbQuotaCheck(__sbQuotaOps[op]);
|
|
3437
3601
|
|
|
3438
3602
|
if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
|
|
3439
3603
|
|
|
@@ -3584,12 +3748,18 @@ function __sbEmbeddedDispatch(msg) {
|
|
|
3584
3748
|
// exec() takes a script; a single prepare would run only its first
|
|
3585
3749
|
// statement and quietly drop the rest.
|
|
3586
3750
|
__sbD1Open(path, msg.strict === true);
|
|
3751
|
+
__sbD1Quota(path);
|
|
3752
|
+
if (msg.strict === true && Array.isArray(msg.lines)) {
|
|
3753
|
+
__sbD1ExecLines(path, msg.lines);
|
|
3754
|
+
return { ok: true };
|
|
3755
|
+
}
|
|
3587
3756
|
const reply = JSON.parse(__sbSqlScriptRaw(path, String(msg.sql)));
|
|
3588
3757
|
if (reply.ok === false) throw new Error("sqlite: " + reply.error);
|
|
3589
3758
|
return { ok: true };
|
|
3590
3759
|
}
|
|
3591
3760
|
const strict = msg.strict === true;
|
|
3592
3761
|
__sbD1Open(path, strict);
|
|
3762
|
+
__sbD1Quota(path);
|
|
3593
3763
|
if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
|
|
3594
3764
|
const one = __sbD1Run(path, msg.sql, msg.params, strict);
|
|
3595
3765
|
return { ok: true, results: one.results, columns: one.columns, rows: one.rows, meta: one.meta, success: true };
|
|
@@ -4138,6 +4308,18 @@ function __sbD1Release(path, savepoint, strict) {
|
|
|
4138
4308
|
if (Number(r.code) !== 10) __sbD1Undo(path, savepoint);
|
|
4139
4309
|
throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
|
|
4140
4310
|
}
|
|
4311
|
+
// exec() from 2026-10-24: line by line as workerd runs it, all or nothing.
|
|
4312
|
+
function __sbD1ExecLines(path, lines) {
|
|
4313
|
+
__sbD1Control(path, "SAVEPOINT sb_d1_exec", true);
|
|
4314
|
+
for (let i = 0; i < lines.length; i++) {
|
|
4315
|
+
const reply = JSON.parse(__sbSqlScriptRaw(path, String(lines[i])));
|
|
4316
|
+
if (reply.ok === false) {
|
|
4317
|
+
if (Number(reply.code) !== 10) __sbD1Undo(path, "sb_d1_exec");
|
|
4318
|
+
throw new Error("D1_EXEC_ERROR: Error in line " + (i + 1) + ": " + lines[i] + ": " + reply.error);
|
|
4319
|
+
}
|
|
4320
|
+
}
|
|
4321
|
+
__sbD1Release(path, "sb_d1_exec", true);
|
|
4322
|
+
}
|
|
4141
4323
|
// END D1 OPS
|
|
4142
4324
|
|
|
4143
4325
|
/**
|
|
@@ -4148,6 +4330,7 @@ function __sbD1Release(path, savepoint, strict) {
|
|
|
4148
4330
|
* itself does not know which backend it is on.
|
|
4149
4331
|
*/
|
|
4150
4332
|
globalThis.__sbR2Put = function (bucket, key, body, httpMetadata, customMetadata) {
|
|
4333
|
+
__sbQuotaCheck("R2 put()");
|
|
4151
4334
|
__sbEnsureSchema();
|
|
4152
4335
|
const reply = JSON.parse(
|
|
4153
4336
|
__sbR2PutRaw(
|
|
@@ -4177,6 +4360,7 @@ globalThis.__sbR2Get = function (bucket, key) {
|
|
|
4177
4360
|
};
|
|
4178
4361
|
|
|
4179
4362
|
globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
|
|
4363
|
+
__sbQuotaCheck("R2 uploadPart()");
|
|
4180
4364
|
__sbEnsureSchema();
|
|
4181
4365
|
const store = __sbStore();
|
|
4182
4366
|
const upload = __sbSql(
|
package/test/native-d1.ts
CHANGED
|
@@ -47,7 +47,10 @@ 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
|
+
if (!strncmp(line, ":db ", 4)) { int r = sql[0] == 'p' ? sb_db_page_size(line + 4) : sb_db_quota_freeze(line + 4, sql[0] == 'f'); printf("{\\"n\\":%d}\\n", r); fflush(stdout); continue; }
|
|
51
|
+
if (!strncmp(line, ":bytes ", 7)) { line[strcspn(line, "\\t")] = 0; printf("{\\"n\\":%lld}\\n", sb_dir_bytes(line + 7, 0)); fflush(stdout); continue; }
|
|
52
|
+
// ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
|
|
53
|
+
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
54
|
}
|
|
52
55
|
return 0;
|
|
53
56
|
}
|
|
@@ -164,11 +167,41 @@ int main(void) {
|
|
|
164
167
|
return r;
|
|
165
168
|
};
|
|
166
169
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
170
|
+
const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
|
|
167
171
|
const ops = new Function(
|
|
168
172
|
"__sbSqlRaw",
|
|
169
173
|
"__sbSql",
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
"__sbSqlScriptRaw",
|
|
175
|
+
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
|
|
176
|
+
)(sqlRaw, sql, scriptRaw);
|
|
177
|
+
// The storage quota, lifted from "The storage quota" to __sbStore, for DIR and LIMIT.
|
|
178
|
+
const quotaStart = transport.indexOf("// The storage quota: an app's data directory");
|
|
179
|
+
const quotaEnd = transport.indexOf("function __sbStore() {");
|
|
180
|
+
assert(quotaStart > 0 && quotaEnd > quotaStart, "quota block moved");
|
|
181
|
+
const liftQuota = (dir: string, limit: number) =>
|
|
182
|
+
new Function(
|
|
183
|
+
"__sbEnv",
|
|
184
|
+
"__sbDir",
|
|
185
|
+
"__sbDirBytesRaw",
|
|
186
|
+
"__sbD1DbRaw",
|
|
187
|
+
"__sbSql",
|
|
188
|
+
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull, expire() { __sbQuotaAt = -1; } };`,
|
|
189
|
+
)(
|
|
190
|
+
(name: string) => (name === "SB_STORAGE_MAX_BYTES" ? String(limit) : ""),
|
|
191
|
+
() => dir,
|
|
192
|
+
(d: string) => String(JSON.parse(sqlRaw(":bytes " + d, "", "")).n),
|
|
193
|
+
(path: string, what: string) => String(JSON.parse(sqlRaw(":db " + path, what, "")).n),
|
|
194
|
+
sql,
|
|
195
|
+
);
|
|
196
|
+
const dirBytes = (d: string) => Number(JSON.parse(sqlRaw(":bytes " + d, "", "")).n);
|
|
197
|
+
// exec() from 2026-10-24: line by line, all or nothing, the failing line named.
|
|
198
|
+
const ex = join(work, "data/d1/EXEC.sqlite");
|
|
199
|
+
ops.__sbD1ExecLines(ex, ["CREATE TABLE x (id INTEGER)", "INSERT INTO x VALUES (1); INSERT INTO x VALUES (2);"]);
|
|
200
|
+
assert.throws(
|
|
201
|
+
() => ops.__sbD1ExecLines(ex, ["INSERT INTO x VALUES (3);", "SELEC 2"]),
|
|
202
|
+
(e: Error) => e.message === 'D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error',
|
|
203
|
+
);
|
|
204
|
+
assert.equal(ops.__sbD1Run(ex, "SELECT count(*) AS n FROM x", [], false).results[0].n, 2, "exec rolled back");
|
|
172
205
|
const db2 = join(work, "data/d1/OPS.sqlite");
|
|
173
206
|
ops.__sbD1Run(db2, "CREATE TABLE p (id INTEGER PRIMARY KEY)", [], false);
|
|
174
207
|
ops.__sbD1Run(db2, "CREATE TABLE c (id INTEGER PRIMARY KEY, p INTEGER REFERENCES p(id))", [], false);
|
|
@@ -246,16 +279,45 @@ int main(void) {
|
|
|
246
279
|
true,
|
|
247
280
|
);
|
|
248
281
|
assert.equal(ltxFiles(), beforeBatch + 1, "a batch is one LTX commit");
|
|
282
|
+
// With capture on, an authorization failure says so (it read "not an error" once capture
|
|
283
|
+
// had run), and the quota works: its page size comes from capture, not from the
|
|
284
|
+
// page_size pragma the capture authorizer refuses.
|
|
285
|
+
assert.throws(() => ops.__sbD1Run(ltx, "PRAGMA page_size", [], true), (e: Error) => e.message === "D1_ERROR: not authorized: SQLITE_AUTH");
|
|
286
|
+
assert.throws(() => ops.__sbD1ExecLines(ltx, ["PRAGMA journal_mode = DELETE"]), (e: Error) => e.message === "D1_EXEC_ERROR: Error in line 1: PRAGMA journal_mode = DELETE: not authorized");
|
|
287
|
+
const data = join(work, "data");
|
|
288
|
+
const roomy = liftQuota(data, dirBytes(data) + (1 << 20));
|
|
289
|
+
roomy.__sbD1Quota(ltx);
|
|
290
|
+
ops.__sbD1Run(ltx, "INSERT INTO e VALUES (10, 'under the quota')", [], true);
|
|
291
|
+
assert.equal(ops.__sbD1Run(ltx, "SELECT v FROM e WHERE id = 10", [], true).rows[0][0], "under the quota");
|
|
292
|
+
// Over the quota with capture on, every write is refused, a same-size UPDATE and a DELETE
|
|
293
|
+
// too (each would add an LTX file to the spool), and the spool stops growing; reads and
|
|
294
|
+
// read-only batches still run. Under the quota again, writes resume.
|
|
295
|
+
const tight = liftQuota(data, 1);
|
|
296
|
+
tight.__sbD1Quota(ltx);
|
|
297
|
+
const spool = ltxFiles();
|
|
298
|
+
const QUOTA = "D1_ERROR: storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota: SQLITE_FULL";
|
|
299
|
+
for (const write of ["INSERT INTO e VALUES (11, 'x')", "UPDATE e SET v = 'y' WHERE id = 10", "DELETE FROM e WHERE id = 10"])
|
|
300
|
+
assert.throws(() => ops.__sbD1Run(ltx, write, [], true), (e: Error) => e.message === QUOTA, write);
|
|
301
|
+
assert.throws(() => ops.__sbD1ExecLines(ltx, ["UPDATE e SET v = 'z'"]), (e: Error) => e.message.startsWith("D1_EXEC_ERROR: Error in line 1: UPDATE e SET v = 'z': storage quota exceeded"));
|
|
302
|
+
assert.throws(() => ops.__sbD1Batch(ltx, [{ sql: "SELECT 1", params: [] }, { sql: "DELETE FROM e", params: [] }], true), (e: Error) => e.message === QUOTA);
|
|
303
|
+
assert.equal(ops.__sbD1Batch(ltx, [{ sql: "SELECT count(*) FROM e", params: [] }], true).length, 1, "a read-only batch runs");
|
|
304
|
+
assert.equal(ops.__sbD1Run(ltx, "SELECT v FROM e WHERE id = 10", [], true).rows[0][0], "under the quota");
|
|
305
|
+
assert.equal(ltxFiles(), spool, "no LTX file while over the quota");
|
|
306
|
+
const again = liftQuota(data, dirBytes(data) + (1 << 20));
|
|
307
|
+
again.__sbD1Quota(ltx);
|
|
308
|
+
ops.__sbD1Run(ltx, "UPDATE e SET v = 'thawed' WHERE id = 10", [], true);
|
|
309
|
+
assert.equal(ltxFiles(), spool + 1, "writes, and capture, resume under the quota");
|
|
249
310
|
// Break capture just before the batch's RELEASE.
|
|
250
311
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
251
312
|
const breaking = new Function(
|
|
252
313
|
"__sbSqlRaw",
|
|
253
314
|
"__sbSql",
|
|
315
|
+
"__sbSqlScriptRaw",
|
|
254
316
|
`${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
|
|
255
317
|
)((path: string, text: string, params: string) => {
|
|
256
318
|
if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
|
|
257
319
|
return sqlRaw(path, text, params);
|
|
258
|
-
}, sql);
|
|
320
|
+
}, sql, scriptRaw);
|
|
259
321
|
const LTX_FAILED =
|
|
260
322
|
"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
323
|
assert.throws(
|
|
@@ -272,6 +334,37 @@ int main(void) {
|
|
|
272
334
|
/^Error: sqlite: LTX capture failed/,
|
|
273
335
|
"old dates see the same failure",
|
|
274
336
|
);
|
|
337
|
+
assert.throws(
|
|
338
|
+
() => ops.__sbD1ExecLines(ltx, ["INSERT INTO e VALUES (5, 'e')"]),
|
|
339
|
+
(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",
|
|
340
|
+
"exec() after a capture failure",
|
|
341
|
+
);
|
|
342
|
+
const qdir = join(work, "quota");
|
|
343
|
+
const qdb = join(qdir, "d1/Q.sqlite");
|
|
344
|
+
const quota = liftQuota(qdir, 262144);
|
|
345
|
+
ops.__sbD1Run(qdb, "CREATE TABLE q (b BLOB)", [], true);
|
|
346
|
+
quota.__sbD1Quota(qdb);
|
|
347
|
+
quota.__sbQuotaCheck("KV put()");
|
|
348
|
+
// Fill to the limit: a database is capped at the quota, so growth past it fails with
|
|
349
|
+
// SQLITE_FULL; then the directory is over, a KV write is refused, and reads and deletes
|
|
350
|
+
// keep working. VACUUM gives the space back and writes resume.
|
|
351
|
+
let rows = 0;
|
|
352
|
+
assert.throws(() => {
|
|
353
|
+
for (; rows < 64; rows++) ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
|
|
354
|
+
}, (e: Error) => e.message === "D1_ERROR: database or disk is full: SQLITE_FULL");
|
|
355
|
+
assert(rows >= 2 && rows < 5, "filled to about 256 KiB: " + rows);
|
|
356
|
+
quota.expire();
|
|
357
|
+
assert.equal(quota.__sbQuotaFull(), true);
|
|
358
|
+
assert.throws(() => quota.__sbQuotaCheck("KV put()"), /^Error: KV put\(\) failed: storage quota exceeded \(this app stores \d+ bytes; the limit is 262144\)$/);
|
|
359
|
+
quota.__sbD1Quota(qdb);
|
|
360
|
+
assert.equal(ops.__sbD1Run(qdb, "SELECT count(*) FROM q", [], true).rows[0][0], rows, "reads still work");
|
|
361
|
+
ops.__sbD1Run(qdb, "DELETE FROM q", [], true);
|
|
362
|
+
ops.__sbD1Run(qdb, "VACUUM", [], true);
|
|
363
|
+
ops.__sbD1Run(qdb, "PRAGMA wal_checkpoint(TRUNCATE)", [], true);
|
|
364
|
+
quota.expire();
|
|
365
|
+
quota.__sbD1Quota(qdb);
|
|
366
|
+
quota.__sbQuotaCheck("KV put()");
|
|
367
|
+
ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
|
|
275
368
|
closeSync(toDriver);
|
|
276
369
|
closeSync(fromDriver);
|
|
277
370
|
await driver.exited;
|