@sproutboat/wire 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/wire
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,15 +1,30 @@
1
1
  # `@sproutboat/wire`
2
2
 
3
- Single source of truth for the broker frame protocol: op dispatch, storage
4
- tables, queue and alarm delivery, plus the JSON validation utilities the
5
- protocol is built on.
6
-
7
- Current sources, moved verbatim: `sproutboat-cli/src/broker.ts`,
8
- `broker.test.ts`, `json.ts`. Contract tests move with the code; the CLI's
9
- `CONTRACTS.md` broker-wire and storage sections keep generating from here.
10
-
11
- Note: `broker.ts` imports `@sproutboat/assets`, deliberately declared
12
- nowhere here. Bun resolves transitive `file:` links from the consumer root,
13
- not the declaring package, so every consumer links all `@sproutboat/*`
14
- packages it needs at its own root. Add a real version range here at first
15
- npm publish.
3
+ Broker frame protocol and the JSON validation utilities it is built on.
4
+ Single source of truth shared by the CLI dev broker and the platform's
5
+ edge and supervisor.
6
+
7
+ ```sh
8
+ bun add @sproutboat/wire
9
+ ```
10
+
11
+ ```ts
12
+ import { createBroker } from "@sproutboat/wire";
13
+ import { parseJsonValue } from "@sproutboat/wire";
14
+ ```
15
+
16
+ ## API
17
+
18
+ - `createBroker(options)` opens a binding broker over a resource file:
19
+ KV, D1, R2, queues, Durable Object storage and alarms, Analytics Engine
20
+ points, secrets, outbound fetch. Frame envelope, both directions:
21
+ `[u32 LE length][payload]`.
22
+ - Frame helpers: `frameOf`, `encodeFrame`, `encodeV1`, `FRAME_V1`,
23
+ `cronMatches`, plus the `Broker`, `BrokerOptions`, `BrokerServer`,
24
+ `FetchLike` and `Frame` types.
25
+ - JSON boundary parsing: `parseJsonValue`, `jsonObject`, guards
26
+ (`isString`, `isBoolean`, `isSafeInteger`), `JsonValue` and `JsonObject`
27
+ types. Parse at the I/O boundary, then narrow; nothing downstream takes
28
+ an unparsed value.
29
+
30
+ See `src/broker.test.ts` for the protocol contract in executable form.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -105,6 +105,30 @@ test("D1: query / run / batch on a bound database, isolated per name", async ()
105
105
  await expect(b.dispatch({ op: "d1.query", db: "NOPE", sql: "SELECT 1", params: [] })).rejects.toThrow("not bound");
106
106
  });
107
107
 
108
+ test("D1: backup writes an integrity-checked snapshot, replayed from cache", async () => {
109
+ const dir = mkdtempSync(join(tmpdir(), "sb-broker-backup-"));
110
+ try {
111
+ const b = make({ db: join(dir, "store.sqlite"), bindings: { d1: ["DB"] } });
112
+ await b.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" });
113
+ await b.dispatch({ op: "d1.query", db: "DB", sql: "INSERT INTO t (name) VALUES (?)", params: ["ada"] });
114
+
115
+ const bk = await b.dispatch({ op: "d1.backup", db: "DB", name: "snap.sqlite" });
116
+ expect(bk.ok).toBe(true);
117
+ expect(String(bk.path).endsWith(join("backups", "snap.sqlite"))).toBe(true);
118
+ expect(Number(bk.bytes)).toBeGreaterThan(0);
119
+ expect(existsSync(String(bk.path))).toBe(true);
120
+
121
+ // the snapshot is a real, readable database with the row in it
122
+ const snap = new Database(String(bk.path), { readonly: true });
123
+ expect(snap.query("SELECT name FROM t").all()).toEqual([{ name: "ada" }]);
124
+ snap.close();
125
+
126
+ await expect(b.dispatch({ op: "d1.backup", db: "NOPE" })).rejects.toThrow("not bound");
127
+ } finally {
128
+ rmSync(dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+
108
132
  test("R2: put / get / head / list / delete on a bound bucket", async () => {
109
133
  const b = make({ bindings: { r2: ["ASSETS"] } });
110
134
  const put = await b.dispatch({
package/src/broker.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { Database, type Statement } from "bun:sqlite";
21
21
  import { createHash } from "node:crypto";
22
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
22
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
23
23
  import { dirname, join, normalize, resolve } from "node:path";
24
24
  import { parseArgs } from "node:util";
25
25
  import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
@@ -375,6 +375,34 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
375
375
  };
376
376
  };
377
377
 
378
+ // #164 — an online, integrity-checked snapshot of a D1 database via
379
+ // VACUUM INTO. Standalone (transport-embedded) is the primary target; the
380
+ // broker carries it too so one conformance suite covers both backends.
381
+ const d1Backup = (conn: Database, dbName: string, name: string) => {
382
+ const source = conn.filename;
383
+ if (!source || source === ":memory:") throw new Error("d1 backup needs a file-backed database");
384
+ // A caller-supplied name must be a plain basename; else use the default.
385
+ const file = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
386
+ ? name
387
+ : `${dbName}-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
388
+ const dir = join(dirname(dirname(resolve(source))), "backups");
389
+ mkdirSync(dir, { recursive: true });
390
+ const dest = join(dir, file);
391
+ if (existsSync(dest)) rmSync(dest); // VACUUM INTO refuses an existing target
392
+ conn.run("VACUUM INTO ?", [dest]);
393
+ const copy = new Database(dest, { readonly: true });
394
+ try {
395
+ const row = copy.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get();
396
+ if (row?.integrity_check !== "ok") {
397
+ rmSync(dest, { force: true });
398
+ throw new Error("integrity check failed on the backup");
399
+ }
400
+ } finally {
401
+ copy.close();
402
+ }
403
+ return { path: dest, bytes: statSync(dest).size };
404
+ };
405
+
378
406
  /** An object body as bytes, whichever storage class it came back in. */
379
407
  const r2Bytes = (body: string | Uint8Array): Uint8Array =>
380
408
  body instanceof Uint8Array ? body : new TextEncoder().encode(body);
@@ -553,6 +581,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
553
581
  conn.exec(str(msg.sql));
554
582
  return { ok: true };
555
583
  }
584
+ case "d1.backup": {
585
+ const name = requireD1(msg.db);
586
+ return { ok: true, ...d1Backup(d1(name), name, msg.name == null ? "" : str(msg.name)) };
587
+ }
556
588
 
557
589
  case "r2.put": {
558
590
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
@@ -864,6 +896,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
864
896
  "d1.query",
865
897
  "d1.exec",
866
898
  "d1.batch",
899
+ "d1.backup",
867
900
  "r2.put",
868
901
  "r2.delete",
869
902
  "do.storage.put",