@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 +20 -0
- package/package.json +1 -1
- package/src/transport-embedded.js +170 -4
- package/test/native-d1.ts +76 -0
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
|
@@ -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
|
-
|
|
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,
|
|
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;
|