@sproutboat/runtime 0.24.0 → 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,25 @@
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
+
3
23
  ## 0.24.0
4
24
 
5
25
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1949,6 +1949,11 @@ void sb_r2_transfer_download_close(sb_r2_transfer_ctx* ctx) { if (ctx) { if (ctx
1949
1949
  // the replication log, and the database refuses writes until the process restarts. Said as
1950
1950
  // such (SQLITE_IOERR), never as SQLite's own message, which still reads "not an error".
1951
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];
1952
1957
  static int sb_db_capture_failed(const char* path) {
1953
1958
  for (int i = 0; i < sb_db_count; i++) if (strcmp(sb_db_names[i], path) == 0) return sb_ltx_states[i].failed;
1954
1959
  return 0;
@@ -1963,13 +1968,18 @@ static char* sb_sql_script(const char* path, const char* sql) {
1963
1968
  if (idx < 0 && sb_db_capture_failed(path)) { sb_puts(&b, "{\"ok\":false,\"error\":\"" SB_LTX_FAILED "\",\"code\":10}"); return b.p; }
1964
1969
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
1965
1970
  char* err = 0;
1966
- int script_rc = 0, capture_failed = 0;
1971
+ int script_rc = 0, capture_failed = 0, quota_full = 0, failure_code = 0;
1972
+ char failure[512] = { 0 };
1967
1973
  if (sb_ltx_states[idx].db) {
1968
1974
  const char *next = sql;
1969
1975
  while (*next && !script_rc) {
1970
1976
  sqlite3_stmt *st = 0; const char *tail = 0;
1971
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; }
1972
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]); }
1973
1983
  if (st) sqlite3_finalize(st);
1974
1984
  if (sb_ltx_after(idx)) { script_rc = 10; capture_failed = 1; }
1975
1985
  if (!tail || tail <= next) break;
@@ -1977,10 +1987,12 @@ static char* sb_sql_script(const char* path, const char* sql) {
1977
1987
  }
1978
1988
  } else script_rc = sqlite3_exec(sb_dbs[idx], sql, 0, 0, &err);
1979
1989
  if (script_rc != 0) {
1980
- const char* m = capture_failed ? SB_LTX_FAILED : err ? err : sqlite3_errmsg(sb_dbs[idx]);
1990
+ const char* m = quota_full ? SB_QUOTA_FULL : capture_failed ? SB_LTX_FAILED : failure[0] ? failure : err ? err : sqlite3_errmsg(sb_dbs[idx]);
1981
1991
  sb_puts(&b, "{\"ok\":false,\"error\":");
1982
1992
  sb_putjson_text(&b, m, strlen(m));
1983
- sb_puts(&b, capture_failed ? ",\"code\":10}" : "}");
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);
1984
1996
  return b.p;
1985
1997
  }
1986
1998
  sb_puts(&b, "{\"ok\":true}");
@@ -2021,6 +2033,10 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2021
2033
  sqlite3_stmt* st = sb_stmt_get(idx, db, sql);
2022
2034
  if (!st) return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2023
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
+ }
2024
2040
  if (strict && bound != sqlite3_bind_parameter_count(st)) {
2025
2041
  sqlite3_reset(st);
2026
2042
  return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
@@ -2071,16 +2087,65 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2071
2087
  // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
2072
2088
  // now (not just on next reuse) so a SELECT stops holding its read transaction.
2073
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
+ }
2074
2098
  const int capture_failed = sb_ltx_after(idx) != 0;
2075
2099
  if (capture_failed) rc = 10;
2076
2100
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
2077
2101
  free(b.p);
2078
2102
  if (capture_failed) return sb_sql_error(db, SB_LTX_FAILED, 10, 0);
2079
- return sb_sql_error(db, 0, -1, strict ? (int)sizeof(SB_D1_STRICT) - 1 : 0);
2103
+ return sb_sql_error(db, failure, failure_code, 0);
2080
2104
  }
2081
2105
  return b.p;
2082
2106
  }
2083
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
+
2084
2149
  // #164 — an online, consistent single-file snapshot of a database, via
2085
2150
  // VACUUM INTO. Runs against the live db with no downtime (SQLite holds a read
2086
2151
  // transaction for the copy) and writes a fresh, defragmented file with no WAL
@@ -3103,6 +3168,44 @@ function __sbSqlScriptRaw(path, sql) {
3103
3168
  return res;
3104
3169
  }
3105
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
+
3106
3209
  // #164 — VACUUM INTO a fresh file. Two strings in, JSON out. Same pattern as
3107
3210
  // __sbSqlScriptRaw.
3108
3211
  // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
@@ -3306,6 +3409,64 @@ function __sbDir() {
3306
3409
  }
3307
3410
  return __sbDataDir;
3308
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
+ }
3309
3470
  function __sbStore() {
3310
3471
  return __sbDir() + "/store.sqlite";
3311
3472
  }
@@ -3436,6 +3597,7 @@ function __sbEmbeddedDispatch(msg) {
3436
3597
  __sbEnsureSchema();
3437
3598
  const store = __sbStore();
3438
3599
  const op = msg.op;
3600
+ if (__sbQuotaOps[op]) __sbQuotaCheck(__sbQuotaOps[op]);
3439
3601
 
3440
3602
  if (op === "ping") return { ok: true, op: "pong", echo: msg.msg };
3441
3603
 
@@ -3586,6 +3748,7 @@ function __sbEmbeddedDispatch(msg) {
3586
3748
  // exec() takes a script; a single prepare would run only its first
3587
3749
  // statement and quietly drop the rest.
3588
3750
  __sbD1Open(path, msg.strict === true);
3751
+ __sbD1Quota(path);
3589
3752
  if (msg.strict === true && Array.isArray(msg.lines)) {
3590
3753
  __sbD1ExecLines(path, msg.lines);
3591
3754
  return { ok: true };
@@ -3596,6 +3759,7 @@ function __sbEmbeddedDispatch(msg) {
3596
3759
  }
3597
3760
  const strict = msg.strict === true;
3598
3761
  __sbD1Open(path, strict);
3762
+ __sbD1Quota(path);
3599
3763
  if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
3600
3764
  const one = __sbD1Run(path, msg.sql, msg.params, strict);
3601
3765
  return { ok: true, results: one.results, columns: one.columns, rows: one.rows, meta: one.meta, success: true };
@@ -4166,6 +4330,7 @@ function __sbD1ExecLines(path, lines) {
4166
4330
  * itself does not know which backend it is on.
4167
4331
  */
4168
4332
  globalThis.__sbR2Put = function (bucket, key, body, httpMetadata, customMetadata) {
4333
+ __sbQuotaCheck("R2 put()");
4169
4334
  __sbEnsureSchema();
4170
4335
  const reply = JSON.parse(
4171
4336
  __sbR2PutRaw(
@@ -4195,6 +4360,7 @@ globalThis.__sbR2Get = function (bucket, key) {
4195
4360
  };
4196
4361
 
4197
4362
  globalThis.__sbR2MultipartPut = function (bucket, key, uploadId, partNumber, body) {
4363
+ __sbQuotaCheck("R2 uploadPart()");
4198
4364
  __sbEnsureSchema();
4199
4365
  const store = __sbStore();
4200
4366
  const upload = __sbSql(
package/test/native-d1.ts CHANGED
@@ -47,6 +47,8 @@ int main(void) {
47
47
  if (!strncmp(line, ":break ", 7)) { line[strcspn(line, "\\t")] = 0; char p[1024]; snprintf(p, sizeof(p), "%s.ltx/position.tmp", line + 7); mkdir(p, 0700); puts("{}"); fflush(stdout); continue; }
48
48
  char* sql = strchr(line, '\\t'); if (!sql) return 2; *sql++ = 0;
49
49
  char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
50
+ 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; }
50
52
  // ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
51
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);
52
54
  }
@@ -172,6 +174,26 @@ int main(void) {
172
174
  "__sbSqlScriptRaw",
173
175
  `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
174
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);
175
197
  // exec() from 2026-10-24: line by line, all or nothing, the failing line named.
176
198
  const ex = join(work, "data/d1/EXEC.sqlite");
177
199
  ops.__sbD1ExecLines(ex, ["CREATE TABLE x (id INTEGER)", "INSERT INTO x VALUES (1); INSERT INTO x VALUES (2);"]);
@@ -257,6 +279,34 @@ int main(void) {
257
279
  true,
258
280
  );
259
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");
260
310
  // Break capture just before the batch's RELEASE.
261
311
  // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
262
312
  const breaking = new Function(
@@ -289,6 +339,32 @@ int main(void) {
289
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",
290
340
  "exec() after a capture failure",
291
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);
292
368
  closeSync(toDriver);
293
369
  closeSync(fromDriver);
294
370
  await driver.exited;