@sproutboat/runtime 0.2.0 → 0.3.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,21 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Standalone runtime: client IP, D1 online backup, and a prepared-statement cache.
8
+
9
+ - `request.cf.clientIp` is populated from the connection's remote address
10
+ (`x-sb-remote-addr`, appended by the server and stripped from client input),
11
+ with `SB_TRUSTED_PROXIES` resolving `X-Forwarded-For` behind a reverse proxy.
12
+ IPv4-mapped IPv6 peers are folded to dotted form. (baronunread/sproutboat#163)
13
+ - `env.<D1>.backup(name?)` — a Sproutboat extension: an online, integrity-checked
14
+ single-file snapshot via `VACUUM INTO`, on both the embedded and broker
15
+ transports. New `d1.backup` op. (baronunread/sproutboat#164)
16
+ - The embedded transport caches prepared statements per database (FIFO, 32/db)
17
+ instead of recompiling the SQL on every binding op. (baronunread/sproutboat#155)
18
+
3
19
  ## 0.2.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,33 @@
1
1
  # `@sproutboat/runtime`
2
2
 
3
- Single source of truth for the sprout runtime: the binding/trigger wrapper,
4
- handler source validation, both transports (broker and embedded), and the
3
+ The sprout runtime as a unit: the binding and trigger wrapper, handler
4
+ source validation, both transports (broker and embedded), and the
5
5
  native-fetch prelude. These move as one because `wrap.ts` locates the
6
6
  prelude and transports by file URL beside itself.
7
+
8
+ ```sh
9
+ bun add @sproutboat/runtime
10
+ ```
11
+
12
+ ```ts
13
+ import { preludePath, transportPath, wrapNativeFetchHandler } from "@sproutboat/runtime";
14
+ import { validateHttpSyncSource } from "@sproutboat/runtime";
15
+ ```
16
+
17
+ ## API
18
+
19
+ - `wrapNativeFetchHandler(...)` turns a user's `export default { fetch }`
20
+ into a native-fetch module with bindings, triggers, and env installed.
21
+ `Bindings`, `EMPTY_BINDINGS`, `readVarsFromEnv`, `readBindingsFromEnv`,
22
+ and `neutraliseExports` travel with it.
23
+ - `preludePath` / `transportPath(transport)` locate the prelude and the
24
+ `broker` / `embedded` transports on disk. The prelude is read as text
25
+ and prepended before compilation, never imported.
26
+ - `TRANSPORT_MARKER`, `BASELINE_COMPATIBILITY_DATE`, `Transport` type.
27
+ - `validateHttpSyncSource(source)` checks a bundled handler module
28
+ (`SourceValidation`), used by project checks on both sides.
29
+
30
+ The prelude shims the Web API surface handlers expect (URL, Response,
31
+ crypto random values, binding shims, trigger dispatch) and is identical
32
+ for both transports, which is what lets one conformance suite hold them
33
+ honest.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,66 @@
1
+ // #163 — the client-IP resolution helpers live inline in native-fetch-prelude.js
2
+ // (the prelude is prepended as text and cannot import), so this test lifts that
3
+ // one pure-JS block out and exercises it directly. If the block moves or its
4
+ // boundary markers change, this fails loudly rather than silently testing nothing.
5
+ import { expect, test } from "bun:test";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const src = readFileSync(fileURLToPath(new URL("./native-fetch-prelude.js", import.meta.url)), "utf8");
10
+ const start = src.indexOf("function __sbParseHex(");
11
+ const end = src.indexOf("globalThis.__sbEntry");
12
+ if (start === -1 || end === -1 || end < start) throw new Error("client-ip helper block not found in prelude");
13
+
14
+ let env = {};
15
+ // oxlint-disable-next-line no-function-constructor -- lifting a pure block out of the text-only prelude for test
16
+ const load = new Function(
17
+ "__envMap",
18
+ `${src.slice(start, end)}
19
+ const __sbEnv = (k) => __envMap[k];
20
+ return { __sbNormalizeIp, __sbIpInCidr, __sbIpTrusted, __sbClientIp, __sbSplitList };`,
21
+ );
22
+ const H = load(new Proxy({}, { get: (_, k) => env[k] }));
23
+
24
+ const req = (headers) => ({ headers: { get: (k) => headers[k.toLowerCase()] ?? null } });
25
+
26
+ test("__sbNormalizeIp folds IPv4-mapped IPv6 to dotted quad", () => {
27
+ expect(H.__sbNormalizeIp("0000:0000:0000:0000:0000:ffff:7f00:0001")).toBe("127.0.0.1");
28
+ expect(H.__sbNormalizeIp("::ffff:198.51.100.9")).toBe("198.51.100.9");
29
+ expect(H.__sbNormalizeIp("203.0.113.7")).toBe("203.0.113.7");
30
+ expect(H.__sbNormalizeIp("2001:db8::1")).toBe("2001:db8::1"); // real IPv6 untouched
31
+ expect(H.__sbNormalizeIp("")).toBe("");
32
+ });
33
+
34
+ test("__sbIpInCidr does IPv4 range math without sign errors above 2^31", () => {
35
+ expect(H.__sbIpInCidr("10.1.2.3", "10.0.0.0/8")).toBe(true);
36
+ expect(H.__sbIpInCidr("11.0.0.1", "10.0.0.0/8")).toBe(false);
37
+ expect(H.__sbIpInCidr("203.0.113.7", "203.0.113.7")).toBe(true); // bare IP
38
+ expect(H.__sbIpInCidr("192.168.1.1", "0.0.0.0/0")).toBe(true);
39
+ // 224.x is > 2^31 as a uint32 — the divide-not-mask path.
40
+ expect(H.__sbIpInCidr("224.0.0.5", "224.0.0.0/24")).toBe(true);
41
+ expect(H.__sbIpInCidr("224.0.1.5", "224.0.0.0/24")).toBe(false);
42
+ });
43
+
44
+ test("__sbClientIp: no trusted proxies means the peer wins, XFF ignored", () => {
45
+ env = {};
46
+ const r = req({ "x-sb-remote-addr": "::ffff:203.0.113.7", "x-forwarded-for": "1.2.3.4" });
47
+ expect(H.__sbClientIp(r)).toBe("203.0.113.7");
48
+ });
49
+
50
+ test("__sbClientIp: a trusted peer resolves the rightmost untrusted XFF hop", () => {
51
+ env = { SB_TRUSTED_PROXIES: "127.0.0.0/8, 10.0.0.0/8" };
52
+ expect(H.__sbClientIp(req({ "x-sb-remote-addr": "127.0.0.1", "x-forwarded-for": "203.0.113.7, 10.1.2.3" }))).toBe(
53
+ "203.0.113.7",
54
+ );
55
+ // every hop trusted → fall back to the peer
56
+ expect(H.__sbClientIp(req({ "x-sb-remote-addr": "127.0.0.1", "x-forwarded-for": "10.9.9.9" }))).toBe("127.0.0.1");
57
+ // mapped-v6 entry in the chain is normalised
58
+ expect(
59
+ H.__sbClientIp(req({ "x-sb-remote-addr": "10.0.0.1", "x-forwarded-for": "::ffff:198.51.100.9, 10.1.2.3" })),
60
+ ).toBe("198.51.100.9");
61
+ });
62
+
63
+ test("__sbClientIp: an untrusted peer cannot use XFF even if it sends one", () => {
64
+ env = { SB_TRUSTED_PROXIES: "10.0.0.0/8" };
65
+ expect(H.__sbClientIp(req({ "x-sb-remote-addr": "198.51.100.9", "x-forwarded-for": "1.1.1.1" }))).toBe("198.51.100.9");
66
+ });
@@ -421,6 +421,14 @@ function __sbMakeD1(dbName) {
421
421
  __sbRpc("d1.exec", { db: dbName, sql: String(sql) });
422
422
  return { count: (String(sql).match(/;/g) || []).length, duration: 0 };
423
423
  },
424
+ // Sproutboat extension (not in CF Workers D1): an online, integrity-checked
425
+ // snapshot of this database. Standalone only for now — the broker transport
426
+ // returns an error until hosted backup (#139) covers it. `name` lands under
427
+ // `<data-dir>/backups/`; omit it for a timestamped default. See #164.
428
+ backup(name) {
429
+ const r = __sbRpc("d1.backup", { db: dbName, name: name == null ? "" : String(name) });
430
+ return { path: r.path, bytes: r.bytes };
431
+ },
424
432
  };
425
433
  }
426
434
 
@@ -844,9 +852,119 @@ function __sbTriggerAuthed(request) {
844
852
  return request.headers.get("x-sb-token") === want;
845
853
  }
846
854
 
855
+ // #163 — the connection's remote address, for `request.cf.clientIp`.
856
+ //
857
+ // `x-sb-remote-addr` is the TCP peer, appended by the server (see
858
+ // patches/UPSTREAM.md #163) and stripped from anything a client sends, so it
859
+ // cannot be forged. With no proxy in front, that is the client. Behind one, set
860
+ // `SB_TRUSTED_PROXIES` to a comma-separated list of trusted CIDRs (or bare IPs):
861
+ // when the peer is trusted, the client is the rightmost `x-forwarded-for` entry
862
+ // that is not itself a trusted hop.
863
+ //
864
+ // ponytail: IPv4 CIDR ranges + exact-string match (which covers a bare IPv6).
865
+ // An IPv6 *prefix* in SB_TRUSTED_PROXIES matches nothing — widen __sbIpInCidr if
866
+ // a deployment ever fronts its sprout with an IPv6 proxy range.
867
+ function __sbParseHex(s) {
868
+ let n = 0;
869
+ for (let i = 0; i < s.length; i++) {
870
+ const c = s.charCodeAt(i);
871
+ let d = -1;
872
+ if (c >= 48 && c <= 57) d = c - 48;
873
+ else if (c >= 97 && c <= 102) d = c - 87;
874
+ else if (c >= 65 && c <= 70) d = c - 55;
875
+ if (d < 0) return -1;
876
+ n = n * 16 + d;
877
+ }
878
+ return n;
879
+ }
880
+ // uWS hands back IPv4-mapped IPv6 for IPv4 clients on a dual-stack socket
881
+ // (`::ffff:1.2.3.4`, or fully expanded `0:0:0:0:0:ffff:0102:0304`). Fold those
882
+ // to the dotted IPv4 so CIDR matching and `clientIp` see a plain address.
883
+ function __sbNormalizeIp(ip) {
884
+ const s = String(ip || "");
885
+ if (s.indexOf(":") === -1) return s;
886
+ const low = s.toLowerCase();
887
+ const dotted = low.indexOf("::ffff:");
888
+ if (dotted === 0 && low.indexOf(".") !== -1) return low.slice(7);
889
+ const g = low.split(":");
890
+ if (g.length === 8) {
891
+ let mapped = __sbParseHex(g[5]) === 0xffff;
892
+ for (let i = 0; i < 5; i++) if (__sbParseHex(g[i] || "0") !== 0) mapped = false;
893
+ if (mapped) {
894
+ const hi = __sbParseHex(g[6] || "0");
895
+ const lo = __sbParseHex(g[7] || "0");
896
+ if (hi >= 0 && lo >= 0) {
897
+ return ((hi / 256) | 0) + "." + (hi & 0xff) + "." + ((lo / 256) | 0) + "." + (lo & 0xff);
898
+ }
899
+ }
900
+ }
901
+ return s;
902
+ }
903
+ function __sbTrim(s) {
904
+ let a = 0;
905
+ let b = s.length;
906
+ while (a < b && (s.charCodeAt(a) === 32 || s.charCodeAt(a) === 9)) a++;
907
+ while (b > a && (s.charCodeAt(b - 1) === 32 || s.charCodeAt(b - 1) === 9)) b--;
908
+ return s.slice(a, b);
909
+ }
910
+ function __sbSplitList(raw) {
911
+ const out = [];
912
+ const parts = String(raw || "").split(",");
913
+ for (let i = 0; i < parts.length; i++) {
914
+ const v = __sbTrim(parts[i]);
915
+ if (v) out.push(v);
916
+ }
917
+ return out;
918
+ }
919
+ function __sbIpToLong(ip) {
920
+ const p = String(ip).split(".");
921
+ if (p.length !== 4) return -1;
922
+ let n = 0;
923
+ for (let i = 0; i < 4; i++) {
924
+ if (p[i] === "" || p[i].length > 3) return -1;
925
+ const o = Number(p[i]);
926
+ if (!(o >= 0 && o <= 255) || o !== (o | 0)) return -1;
927
+ n = n * 256 + o;
928
+ }
929
+ return n;
930
+ }
931
+ function __sbIpInCidr(ip, cidr) {
932
+ const slash = cidr.indexOf("/");
933
+ if (slash === -1) return ip === cidr; // bare IP: exact match, covers IPv6
934
+ const addr = __sbIpToLong(ip);
935
+ const base = __sbIpToLong(cidr.slice(0, slash));
936
+ const bits = Number(cidr.slice(slash + 1));
937
+ if (addr < 0 || base < 0 || !(bits >= 0 && bits <= 32)) return false;
938
+ if (bits === 0) return true;
939
+ if (bits === 32) return addr === base;
940
+ // Divide rather than mask: a 32-bit `&` in JS is signed and would miscompare
941
+ // addresses above 2^31.
942
+ const size = Math.pow(2, 32 - bits);
943
+ return Math.floor(addr / size) === Math.floor(base / size);
944
+ }
945
+ function __sbIpTrusted(ip, list) {
946
+ for (let i = 0; i < list.length; i++) if (__sbIpInCidr(ip, list[i])) return true;
947
+ return false;
948
+ }
949
+ function __sbClientIp(request) {
950
+ const peer = __sbNormalizeIp(request.headers.get("x-sb-remote-addr") || "");
951
+ const trusted = __sbSplitList(__sbEnv("SB_TRUSTED_PROXIES"));
952
+ if (!trusted.length || !__sbIpTrusted(peer, trusted)) return peer;
953
+ const xff = __sbSplitList(request.headers.get("x-forwarded-for"));
954
+ for (let i = xff.length - 1; i >= 0; i--) {
955
+ const hop = __sbNormalizeIp(xff[i]);
956
+ if (!__sbIpTrusted(hop, trusted)) return hop;
957
+ }
958
+ return peer;
959
+ }
960
+
847
961
  globalThis.__sbEntry = function (handlers, request) {
848
962
  const trigger = request.headers.get("x-sb-trigger");
849
963
  if (!trigger) {
964
+ // #163 — expose the resolved client IP the Workers way, before the handler runs.
965
+ const __cf = request.cf || {};
966
+ __cf.clientIp = __sbClientIp(request);
967
+ request.cf = __cf;
850
968
  // #28 — per-invocation CPU time. One fetch turn per process (serial), so the
851
969
  // process CPU delta across the handler is this invocation's CPU.
852
970
  // ponytail: serial-turn assumption; revisit if the profile ever allows
@@ -35,6 +35,7 @@ extern int sqlite3_prepare_v2(sqlite3*, const char*, int, sqlite3_stmt**, const
35
35
  extern int sqlite3_step(sqlite3_stmt*);
36
36
  extern int sqlite3_finalize(sqlite3_stmt*);
37
37
  extern int sqlite3_reset(sqlite3_stmt*);
38
+ extern int sqlite3_clear_bindings(sqlite3_stmt*);
38
39
  extern int sqlite3_column_count(sqlite3_stmt*);
39
40
  extern int sqlite3_column_type(sqlite3_stmt*, int);
40
41
  extern const unsigned char* sqlite3_column_text(sqlite3_stmt*, int);
@@ -50,6 +51,7 @@ extern const void* sqlite3_column_blob(sqlite3_stmt*, int);
50
51
  extern int sqlite3_changes(sqlite3*);
51
52
  extern int64_t sqlite3_last_insert_rowid(sqlite3*);
52
53
  extern const char* sqlite3_errmsg(sqlite3*);
54
+ extern int sqlite3_close(sqlite3*);
53
55
 
54
56
  #define SB_SQLITE_ROW 100
55
57
  #define SB_SQLITE_DONE 101
@@ -103,6 +105,42 @@ static int sb_db_for(const char* path) {
103
105
  return sb_db_count++;
104
106
  }
105
107
 
108
+ // #155 — a prepared-statement cache per database. Every binding op (KV, D1, DO
109
+ // storage, queue, AE) funnels through sb_sql_run, which used to prepare and
110
+ // finalize on every call — measured at ~0.6 ms/op, that compile step was most
111
+ // of an op's cost. Keyed by exact SQL text; reset and re-bound on reuse.
112
+ //
113
+ // ponytail: FIFO eviction over 32 slots, linear scan, no lock (the runtime
114
+ // serves one turn at a time). Move to LRU + a hash if a real app keeps >32 hot
115
+ // statements per db, or if execution ever overlaps. Entries are never finalized
116
+ // on shutdown — sb_db_for never closes a handle, the process owns it for life.
117
+ #define SB_STMT_CACHE 32
118
+ typedef struct { char* sql; sqlite3_stmt* st; } sb_cached_stmt;
119
+ static sb_cached_stmt sb_stmt_cache[SB_MAX_DB][SB_STMT_CACHE];
120
+ static int sb_stmt_fifo[SB_MAX_DB];
121
+
122
+ static sqlite3_stmt* sb_stmt_get(int idx, sqlite3* db, const char* sql) {
123
+ sb_cached_stmt* slots = sb_stmt_cache[idx];
124
+ for (int i = 0; i < SB_STMT_CACHE; i++) {
125
+ if (slots[i].sql && strcmp(slots[i].sql, sql) == 0) {
126
+ sqlite3_reset(slots[i].st);
127
+ sqlite3_clear_bindings(slots[i].st);
128
+ return slots[i].st;
129
+ }
130
+ }
131
+ sqlite3_stmt* st = 0;
132
+ if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) return 0;
133
+ int slot = sb_stmt_fifo[idx];
134
+ sb_stmt_fifo[idx] = (slot + 1) % SB_STMT_CACHE;
135
+ if (slots[slot].st) sqlite3_finalize(slots[slot].st);
136
+ free(slots[slot].sql);
137
+ size_t n = strlen(sql);
138
+ slots[slot].sql = (char*)malloc(n + 1);
139
+ if (slots[slot].sql) memcpy(slots[slot].sql, sql, n + 1);
140
+ slots[slot].st = st;
141
+ return st;
142
+ }
143
+
106
144
  // --- a growable output buffer, for building the reply JSON ------------------
107
145
  typedef struct { char* p; size_t len; size_t cap; } sb_buf;
108
146
  static void sb_buf_need(sb_buf* b, size_t extra) {
@@ -319,8 +357,8 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
319
357
  int idx = sb_db_for(path);
320
358
  if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
321
359
  sqlite3* db = sb_dbs[idx];
322
- sqlite3_stmt* st = 0;
323
- if (sqlite3_prepare_v2(db, sql, -1, &st, 0) != 0 || !st) {
360
+ sqlite3_stmt* st = sb_stmt_get(idx, db, sql);
361
+ if (!st) {
324
362
  sb_puts(&b, "{\"ok\":false,\"error\":");
325
363
  const char* m = sqlite3_errmsg(db);
326
364
  sb_putjson(&b, m, strlen(m));
@@ -361,7 +399,9 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
361
399
  char tail[96];
362
400
  int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
363
401
  sb_put(&b, tail, (size_t)k);
364
- sqlite3_finalize(st);
402
+ // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
403
+ // now (not just on next reuse) so a SELECT stops holding its read transaction.
404
+ sqlite3_reset(st);
365
405
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
366
406
  free(b.p);
367
407
  sb_buf e = { 0, 0, 0 };
@@ -374,6 +414,67 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
374
414
  return b.p;
375
415
  }
376
416
 
417
+ // #164 — an online, consistent single-file snapshot of a database, via
418
+ // VACUUM INTO. Runs against the live db with no downtime (SQLite holds a read
419
+ // transaction for the copy) and writes a fresh, defragmented file with no WAL
420
+ // sidecars — exactly what you copy off the box. The copy is then reopened and
421
+ // PRAGMA integrity_check'd, so a bad backup fails here, not on restore.
422
+ // Returns {"ok":true,"bytes":n} or {"ok":false,"error":"..."}.
423
+ static char* sb_sql_backup(const char* src_path, const char* dest_path) {
424
+ sb_buf b = { 0, 0, 0 };
425
+ int idx = sb_db_for(src_path);
426
+ if (idx < 0) { sb_puts(&b, "{\"ok\":false,\"error\":\"cannot open database\"}"); return b.p; }
427
+ sqlite3* db = sb_dbs[idx];
428
+
429
+ sb_mkdirs(dest_path);
430
+ unlink(dest_path); // VACUUM INTO refuses to overwrite an existing file
431
+
432
+ sqlite3_stmt* st = 0;
433
+ if (sqlite3_prepare_v2(db, "VACUUM INTO ?1", -1, &st, 0) != 0 || !st) {
434
+ sb_puts(&b, "{\"ok\":false,\"error\":");
435
+ const char* m = sqlite3_errmsg(db);
436
+ sb_putjson(&b, m, strlen(m));
437
+ sb_puts(&b, "}");
438
+ return b.p;
439
+ }
440
+ sqlite3_bind_text(st, 1, dest_path, -1, SB_SQLITE_TRANSIENT);
441
+ int rc = sqlite3_step(st);
442
+ sqlite3_finalize(st);
443
+ if (rc != SB_SQLITE_DONE) {
444
+ sb_puts(&b, "{\"ok\":false,\"error\":");
445
+ const char* m = sqlite3_errmsg(db);
446
+ sb_putjson(&b, m, strlen(m));
447
+ sb_puts(&b, "}");
448
+ return b.p;
449
+ }
450
+
451
+ sqlite3* chk = 0;
452
+ int ok = 0;
453
+ if (sqlite3_open(dest_path, &chk) == 0 && chk) {
454
+ sqlite3_stmt* cs = 0;
455
+ if (sqlite3_prepare_v2(chk, "PRAGMA integrity_check", -1, &cs, 0) == 0 && cs) {
456
+ if (sqlite3_step(cs) == SB_SQLITE_ROW) {
457
+ const unsigned char* r = sqlite3_column_text(cs, 0);
458
+ ok = r && strcmp((const char*)r, "ok") == 0;
459
+ }
460
+ sqlite3_finalize(cs);
461
+ }
462
+ }
463
+ if (chk) sqlite3_close(chk);
464
+ if (!ok) {
465
+ unlink(dest_path);
466
+ sb_puts(&b, "{\"ok\":false,\"error\":\"integrity check failed on the backup\"}");
467
+ return b.p;
468
+ }
469
+
470
+ struct stat sbuf;
471
+ long long bytes = stat(dest_path, &sbuf) == 0 ? (long long)sbuf.st_size : -1;
472
+ char head[64];
473
+ int k = snprintf(head, sizeof(head), "{\"ok\":true,\"bytes\":%lld}", bytes);
474
+ sb_put(&b, head, (size_t)k);
475
+ return b.p;
476
+ }
477
+
377
478
  // --- outbound HTTP + HTTPS ---------------------------------------------------
378
479
  // Plain sockets for http, BearSSL for https, with the Mozilla root set compiled
379
480
  // in (see src/bearssl.ts). Both directions share the request builder and the
@@ -894,6 +995,33 @@ function __sbSqlScriptRaw(path, sql) {
894
995
  return res;
895
996
  }
896
997
 
998
+ // #164 — VACUUM INTO a fresh file. Two strings in, JSON out. Same pattern as
999
+ // __sbSqlScriptRaw.
1000
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
1001
+ function __sbD1BackupRaw(src, dest) {
1002
+ let res = "";
1003
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
1004
+ Porffor.c`
1005
+ const char* __s; size_t __sl; char* __so = 0;
1006
+ porf_native_fetch_read_value(src, &__s, &__sl, &__so);
1007
+ char* __src = (char*)malloc(__sl + 1); memcpy(__src, __s, __sl); __src[__sl] = 0;
1008
+ if (__so) free(__so);
1009
+
1010
+ const char* __d; size_t __dl; char* __do = 0;
1011
+ porf_native_fetch_read_value(dest, &__d, &__dl, &__do);
1012
+ char* __dest = (char*)malloc(__dl + 1); memcpy(__dest, __d, __dl); __dest[__dl] = 0;
1013
+ if (__do) free(__do);
1014
+
1015
+ char* __out = sb_sql_backup(__src, __dest);
1016
+ free(__src); free(__dest);
1017
+ if (__out) {
1018
+ res = porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195);
1019
+ free(__out);
1020
+ }
1021
+ `;
1022
+ return res;
1023
+ }
1024
+
897
1025
  // Outbound HTTP. String params so C can read each directly; the JS side has
898
1026
  // already split the URL and enforced the allowlist. `tlsFlag` is "1" or "0" —
899
1027
  // a string like the rest, so the marshalling stays uniform.
@@ -1199,6 +1327,20 @@ function __sbEmbeddedDispatch(msg) {
1199
1327
  return { ok: true, results: one.results, meta: one.meta, success: true };
1200
1328
  }
1201
1329
 
1330
+ if (op === "d1.backup") {
1331
+ const src = __sbD1Path(String(msg.db));
1332
+ // A caller-supplied name must be a plain basename; anything else falls back
1333
+ // to the timestamped default, so nothing can climb out of backups/.
1334
+ const given = String(msg.name || "");
1335
+ const name = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(given)
1336
+ ? given
1337
+ : String(msg.db) + "-" + new Date().toISOString().replace(/[:.]/g, "-") + ".sqlite";
1338
+ const dest = __sbDir() + "/backups/" + name;
1339
+ const reply = JSON.parse(__sbD1BackupRaw(src, dest));
1340
+ if (reply.ok === false) throw new Error("d1 backup: " + reply.error);
1341
+ return { ok: true, path: dest, bytes: reply.bytes };
1342
+ }
1343
+
1202
1344
  if (op === "r2.put") {
1203
1345
  const body = String(msg.body == null ? "" : msg.body);
1204
1346
  const etag = __sbHex(16);