@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.23.1",
3
+ "version": "0.25.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- .bind("u8", new Uint8Array([0, 255, 7]), "after")
44
- .run();
45
- DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
46
- .bind("ab", new Uint8Array([1, 2]).buffer, "after")
47
- .run();
48
- DB.prepare("INSERT INTO t VALUES (?, ?, ?)")
49
- .bind("view", new DataView(new Uint8Array([9, 8, 7]).buffer, 1, 2), "after")
50
- .run();
51
- expect(DB.prepare("SELECT a, hex(b) AS h, typeof(b) AS t, c FROM t ORDER BY rowid").all().results).toEqual([
52
+ 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)")])).toThrow(
62
- /UNIQUE/,
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 parameters, no foreign keys, BLOBs as one char per byte", () => {
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
- "D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'",
82
- );
83
- expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe(
84
- "D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'",
85
- );
86
- expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe(
87
- "D1_TYPE_ERROR: Type 'bigint' not supported for value '10'",
88
- );
89
- expect(caught(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(
90
- "D1_ERROR: Wrong number of parameter bindings for SQL query.",
91
- );
92
- expect(caught(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(
93
- "D1_ERROR: Wrong number of parameter bindings for SQL query.",
94
- );
95
- expect(caught(() => DB.prepare("SELECT ?1").all())).toBe(
96
- "D1_ERROR: Wrong number of parameter bindings for SQL query.",
97
- );
98
- expect(DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({ a: "x", b: "x", c: 1 });
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); CREATE TABLE c (p INTEGER REFERENCES p(id))");
104
- expect(caught(() => DB.prepare("INSERT INTO c VALUES (99)").run())).toBe(
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(caught(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
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(caught(() => DB.prepare("SELECT * FROM nope").all())).toBe("D1_ERROR: no such table: nope: SQLITE_ERROR");
112
- expect(DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
113
- expect(DB.prepare("SELECT count(*) AS n FROM p").run().meta.changes).toBe(0);
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
- const strict = __sbD1Strict();
4885
- return __sbD1Result(__sbD1Rpc("d1.query", { db: dbName, sql, params }, strict), strict);
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
- const rows = s.all().results;
4892
- const out = [];
4893
- for (let i = 0; i < rows.length; i++) {
4894
- const cols = [];
4895
- for (const k in rows[i]) cols.push(rows[i][k]);
4896
- out.push(cols);
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 out;
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
- const rows = s.all().results;
4902
- if (rows.length === 0) return null;
4903
- return column == null ? rows[0] : rows[0][column];
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
- return {
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
- const strict = __sbD1Strict();
4914
- const list = [];
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
- __sbD1Rpc("d1.exec", { db: dbName, sql: String(sql) }, __sbD1Strict());
4924
- return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
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, err ? err : sqlite3_errmsg(sb_dbs[idx]), err ? strlen(err) : strlen(sqlite3_errmsg(sb_dbs[idx])));
1973
- sb_puts(&b, "}");
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, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
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
- char* out = sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
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
- `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open };`,
171
- )(sqlRaw, sql);
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;