@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 +16 -0
- package/README.md +29 -2
- package/package.json +1 -1
- package/src/client-ip.test.js +66 -0
- package/src/native-fetch-prelude.js +118 -0
- package/src/transport-embedded.js +145 -3
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
|
-
|
|
4
|
-
|
|
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
|
@@ -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 =
|
|
323
|
-
if (
|
|
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
|
-
|
|
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);
|