@sproutboat/wire 0.9.2 → 0.10.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,19 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 276fd90: New `doDb` option and `--do-db` flag: Durable Object storage and alarms can live in their own SQLite file instead of the per-deployment database (baronunread/sproutboat#207). The platform points it at a per-project file so an object's state survives redeploys, as on Workers. Without it, behaviour is unchanged.
8
+
9
+ ## 0.9.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 5dba469: Binary data survives KV and outbound `fetch()` bodies (baronunread/sproutboat#233, and the request half of #232).
14
+ - **KV:** `put()` of an `ArrayBuffer` or any typed array stores its bytes, flagged binary, instead of `String(view)` (`"0,1,2,..."`). `get(key, type | { type })` supports `"text"`, `"json"` and `"arrayBuffer"` as in Workers: a binary value read as text decodes as UTF-8, and a text value read as `arrayBuffer` is its UTF-8 encoding. `"stream"` throws a clear error. Existing stores gain the `binary` column on first use.
15
+ - **`fetch()` and service-binding request bodies:** an `ArrayBuffer` or view is sent as its exact bytes in both transports, instead of `"0,1,2,..."`. The standalone HTTP client also stopped truncating bodies at the first NUL byte.
16
+
3
17
  ## 0.9.2
4
18
 
5
19
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -39,12 +39,25 @@ test("ping round-trips", async () => {
39
39
  expect(await b.dispatch({ op: "ping", msg: "hi" })).toMatchObject({ ok: true, op: "pong", echo: "hi" });
40
40
  });
41
41
 
42
+ test("#233: a KV value put as bytes is flagged binary and survives an existing store", async () => {
43
+ const b = make({ bindings: { kv: ["CACHE"] } });
44
+ // One char per byte, as the runtime sends a put of an ArrayBuffer/view.
45
+ const bytes = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
46
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: bytes, binary: true });
47
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "txt", value: "café" });
48
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toEqual({ ok: true, found: true, value: bytes, binary: true });
49
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "txt" })).toMatchObject({ value: "café", binary: false });
50
+ // Overwriting a binary value with text clears the flag.
51
+ await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: "now text" });
52
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({ value: "now text", binary: false });
53
+ });
54
+
42
55
  test("KV put / get / list / delete, scoped to a bound namespace", async () => {
43
56
  const b = make({ bindings: { kv: ["CACHE"] } });
44
57
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: false, value: null });
45
58
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v" });
46
59
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k2", value: "v2" });
47
- expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: true, value: "v" });
60
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: true, value: "v", binary: false });
48
61
  expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "k" })).toEqual({ ok: true, keys: ["k", "k2"] });
49
62
  await b.dispatch({ op: "kv.delete", ns: "CACHE", key: "k" });
50
63
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
@@ -154,6 +167,21 @@ test("fetch is gated by the exact-host allowlist", async () => {
154
167
  expect(calls).toEqual(["https://api.example.com/x"]);
155
168
  });
156
169
 
170
+ test("#232: a binary fetch request body is sent as its bytes, a text body as UTF-8", async () => {
171
+ const seen: Uint8Array[] = [];
172
+ const fetchImpl: FetchLike = async (_url, init) => {
173
+ seen.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
174
+ return new Response("ok");
175
+ };
176
+ const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
177
+ // The runtime sends a binary body one char per byte, flagged bodyBinary.
178
+ const raw = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
179
+ await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: raw, bodyBinary: true });
180
+ await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: "café" });
181
+ expect(Array.from(seen[0])).toEqual(Array.from({ length: 256 }, (_, i) => i));
182
+ expect(Array.from(seen[1])).toEqual([99, 97, 102, 195, 169]);
183
+ });
184
+
157
185
  test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded text", async () => {
158
186
  const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
159
187
  const fetchImpl: FetchLike = async (url) =>
@@ -678,6 +706,40 @@ test("cronMatches: fields, steps, ranges, lists (UTC)", () => {
678
706
  // #74 — a binding wired to an account-level resource stores in its own file
679
707
  // under resourceDir, keyed by id, so the data outlives a "redeploy" (a new
680
708
  // broker with a fresh per-deployment db but the same resourceDir).
709
+ test("#207: Durable Object storage and alarms in doDb outlive a redeploy", async () => {
710
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-do-"));
711
+ try {
712
+ mkdirSync(join(root, "dep-a"), { recursive: true });
713
+ mkdirSync(join(root, "dep-b"), { recursive: true });
714
+ const doDb = join(root, "do_0123456789abcdef01234567.sqlite");
715
+ const bindings = { do: [{ binding: "COUNTER", className: "Counter" }] };
716
+
717
+ const first = createBroker({ db: join(root, "dep-a", "state.sqlite"), doDb, bindings });
718
+ await first.dispatch({ op: "do.storage.put", cls: "Counter", id: "global", key: "n", value: "41" });
719
+ await first.dispatch({ op: "do.alarm.set", cls: "Counter", id: "global", at: 1234 });
720
+ first.close();
721
+
722
+ // A redeploy: new per-deployment state.sqlite, same per-project doDb.
723
+ const second = createBroker({ db: join(root, "dep-b", "state.sqlite"), doDb, bindings });
724
+ expect(await second.dispatch({ op: "do.storage.get", cls: "Counter", id: "global", key: "n" })).toEqual({
725
+ ok: true,
726
+ found: true,
727
+ value: "41",
728
+ });
729
+ expect(await second.dispatch({ op: "do.alarm.get", cls: "Counter", id: "global" })).toMatchObject({ at: 1234 });
730
+ second.close();
731
+
732
+ // Without doDb the state stays per deployment, as before.
733
+ const third = createBroker({ db: join(root, "dep-a", "state.sqlite"), bindings });
734
+ expect(await third.dispatch({ op: "do.storage.get", cls: "Counter", id: "global", key: "n" })).toMatchObject({
735
+ found: false,
736
+ });
737
+ third.close();
738
+ } finally {
739
+ rmSync(root, { recursive: true, force: true });
740
+ }
741
+ });
742
+
681
743
  test("resource-backed KV persists across brokers and is shared by id", async () => {
682
744
  const root = mkdtempSync(join(tmpdir(), "sb-broker-res-"));
683
745
  try {
@@ -705,6 +767,7 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
705
767
  expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).toEqual({
706
768
  ok: true,
707
769
  found: true,
770
+ binary: false,
708
771
  value: "1",
709
772
  });
710
773
  second.close();
package/src/broker.ts CHANGED
@@ -85,6 +85,12 @@ export type BrokerOptions = {
85
85
  * data outlives a redeploy.
86
86
  */
87
87
  resourceDir?: string;
88
+ /**
89
+ * #207 — SQLite file for Durable Object storage and alarms. The supervisor
90
+ * points this at a per-project path, so an object's state outlives a
91
+ * redeploy as it does on Workers. Defaults to the per-broker `db`.
92
+ */
93
+ doDb?: string;
88
94
  /** Account whose R2 resources this broker may mutate. */
89
95
  ownerId?: string;
90
96
  /** Account-wide R2 byte ceiling. Zero disables the ceiling for local development. */
@@ -269,6 +275,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
269
275
  );
270
276
  const kvColumns = conn.query<{ name: string }, []>("PRAGMA table_info(kv)").all();
271
277
  if (!kvColumns.some((column) => column.name === "expires_at")) conn.exec("ALTER TABLE kv ADD COLUMN expires_at INTEGER");
278
+ // #233 — 1 when the value was put as bytes (stored one char per byte), so get()
279
+ // can hand back exact bytes or decoded text depending on the requested type.
280
+ if (!kvColumns.some((column) => column.name === "binary")) conn.exec("ALTER TABLE kv ADD COLUMN binary INTEGER NOT NULL DEFAULT 0");
272
281
  // #56 — no `body` column: an object's bytes live in their own file under
273
282
  // `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
274
283
  // Doubling every object through SQLite's own page cache on top of the
@@ -309,9 +318,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
309
318
  };
310
319
 
311
320
  const db = openStore(dbPath);
312
- // Durable Object storage and Analytics Engine rows stay in the per-broker db —
313
- // neither is an account-level resource (#74).
314
- db.exec(
321
+ // #207 — Durable Object state lives in its own file when the supervisor gives
322
+ // one (per project, stable across deploys); otherwise in the per-broker db.
323
+ const doDb = opts.doDb ? new Database(opts.doDb, { create: true }) : db;
324
+ if (doDb !== db) {
325
+ doDb.exec("PRAGMA journal_mode = WAL");
326
+ doDb.exec("PRAGMA synchronous = NORMAL");
327
+ }
328
+ doDb.exec(
315
329
  "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
316
330
  "PRIMARY KEY (cls, id, key))",
317
331
  );
@@ -321,12 +335,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
321
335
  );
322
336
  // #125 — at most one pending alarm per object, which is the Workers rule: a
323
337
  // later setAlarm replaces the earlier one rather than queueing beside it.
324
- db.exec(
338
+ doDb.exec(
325
339
  "CREATE TABLE IF NOT EXISTS do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, " +
326
340
  "attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
327
341
  );
328
342
  // Alarm polling reads the earliest due objects across classes.
329
- db.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
343
+ doDb.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
330
344
  // #69 — fixed-window rate-limiter counters, per binding name + key.
331
345
  db.exec(
332
346
  "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
@@ -481,8 +495,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
481
495
  };
482
496
 
483
497
  type KvStmts = {
484
- get: Statement<{ value: string; expires_at: number | null }, [string, string]>;
485
- put: Statement<unknown, [string, string, string, number | null]>;
498
+ get: Statement<{ value: string; expires_at: number | null; binary: number }, [string, string]>;
499
+ put: Statement<unknown, [string, string, string, number | null, number]>;
486
500
  del: Statement<unknown, [string, string]>;
487
501
  list: Statement<{ key: string }, [string, string, number]>;
488
502
  };
@@ -491,12 +505,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
491
505
  let stmts = kvStmtCache.get(store);
492
506
  if (!stmts) {
493
507
  stmts = {
494
- get: store.query<{ value: string; expires_at: number | null }, [string, string]>(
495
- "SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?",
508
+ get: store.query<{ value: string; expires_at: number | null; binary: number }, [string, string]>(
509
+ "SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?",
496
510
  ),
497
511
  put: store.query(
498
- "INSERT INTO kv (ns, key, value, expires_at) VALUES (?1, ?2, ?3, ?4) " +
499
- "ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4",
512
+ "INSERT INTO kv (ns, key, value, expires_at, binary) VALUES (?1, ?2, ?3, ?4, ?5) " +
513
+ "ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5",
500
514
  ),
501
515
  del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
502
516
  // #58 — an expired row is filtered out here; a periodic sweep to hard-delete
@@ -1149,6 +1163,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1149
1163
  type Upstream = { reply: Frame; bytes: Buffer };
1150
1164
  const asTextFrame = (u: Upstream) => ({ ...u.reply, body: new TextDecoder().decode(u.bytes) });
1151
1165
 
1166
+ /** #232 — a binary request body arrives one char per byte; send those bytes, not their UTF-8. */
1167
+ const upstreamBody = (msg: Frame): string | Uint8Array<ArrayBuffer> | undefined =>
1168
+ msg.body == null
1169
+ ? undefined
1170
+ : msg.bodyBinary === true
1171
+ ? new Uint8Array(Buffer.from(str(msg.body), "latin1"))
1172
+ : str(msg.body);
1173
+
1152
1174
  async function proxyFetch(msg: Frame): Promise<Upstream> {
1153
1175
  let url: URL;
1154
1176
  try {
@@ -1169,7 +1191,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1169
1191
  const res = await doFetch(url, {
1170
1192
  method,
1171
1193
  headers,
1172
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
1194
+ body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1173
1195
  redirect: "manual",
1174
1196
  });
1175
1197
  const outHeaders: Array<[string, string]> = [];
@@ -1215,7 +1237,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1215
1237
  const res = await doFetch(target, {
1216
1238
  method,
1217
1239
  headers,
1218
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
1240
+ body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1219
1241
  redirect: "manual",
1220
1242
  });
1221
1243
  const outHeaders: Array<[string, string]> = [];
@@ -1236,11 +1258,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1236
1258
  stmts.del.run(part, str(msg.key));
1237
1259
  return { ok: true, found: false, value: null };
1238
1260
  }
1239
- return { ok: true, found: true, value: row.value };
1261
+ return { ok: true, found: true, value: row.value, binary: row.binary === 1 };
1240
1262
  }
1241
1263
  case "kv.put": {
1242
1264
  const { store, part } = storeFor("kv", requireKv(msg.ns));
1243
- kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg));
1265
+ kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg), msg.binary === true ? 1 : 0);
1244
1266
  return { ok: true };
1245
1267
  }
1246
1268
  case "kv.delete": {
@@ -1553,7 +1575,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1553
1575
 
1554
1576
  case "do.storage.get": {
1555
1577
  const cls = requireDoClass(msg.cls);
1556
- const row = db
1578
+ const row = doDb
1557
1579
  .query<{ value: string }, [string, string, string]>(
1558
1580
  "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
1559
1581
  )
@@ -1561,24 +1583,24 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1561
1583
  return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
1562
1584
  }
1563
1585
  case "do.storage.put":
1564
- db.query(
1586
+ doDb.query(
1565
1587
  "INSERT INTO do_storage (cls, id, key, value) VALUES (?1,?2,?3,?4) " +
1566
1588
  "ON CONFLICT (cls, id, key) DO UPDATE SET value = ?4",
1567
1589
  ).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
1568
1590
  return { ok: true };
1569
1591
  case "do.storage.delete": {
1570
- const r = db
1592
+ const r = doDb
1571
1593
  .query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?")
1572
1594
  .run(requireDoClass(msg.cls), str(msg.id), str(msg.key));
1573
1595
  return { ok: true, deleted: r.changes > 0 };
1574
1596
  }
1575
1597
  case "do.storage.delete_all":
1576
- db.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1598
+ doDb.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1577
1599
  return { ok: true };
1578
1600
  case "do.storage.list": {
1579
1601
  const cls = requireDoClass(msg.cls);
1580
1602
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
1581
- const rows = db
1603
+ const rows = doDb
1582
1604
  .query<{ key: string; value: string }, [string, string, string, number]>(
1583
1605
  "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
1584
1606
  )
@@ -1589,19 +1611,19 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1589
1611
  // #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
1590
1612
  // for that object, and attempts resets because this is a fresh schedule.
1591
1613
  case "do.alarm.set":
1592
- db.query(
1614
+ doDb.query(
1593
1615
  "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) " +
1594
1616
  "ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
1595
1617
  ).run(requireDoClass(msg.cls), str(msg.id), Math.trunc(Number(msg.at) || 0));
1596
1618
  return { ok: true };
1597
1619
  case "do.alarm.get": {
1598
- const row = db
1620
+ const row = doDb
1599
1621
  .query<{ at: number }, [string, string]>("SELECT at FROM do_alarm WHERE cls = ? AND id = ?")
1600
1622
  .get(requireDoClass(msg.cls), str(msg.id));
1601
1623
  return { ok: true, at: row ? row.at : null };
1602
1624
  }
1603
1625
  case "do.alarm.delete": {
1604
- const r = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1626
+ const r = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1605
1627
  return { ok: true, deleted: r.changes > 0 };
1606
1628
  }
1607
1629
 
@@ -1919,14 +1941,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1919
1941
  function fireAlarmsOnce(): void {
1920
1942
  if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0) return;
1921
1943
  const now = Date.now();
1922
- const due = db
1944
+ const due = doDb
1923
1945
  .query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
1924
1946
  "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
1925
1947
  )
1926
1948
  .all(now);
1927
1949
  if (due.length === 0) return;
1928
- const claim = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
1929
- const rearm = db.query(
1950
+ const claim = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
1951
+ const rearm = doDb.query(
1930
1952
  "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
1931
1953
  );
1932
1954
  for (const row of due) {
@@ -1969,6 +1991,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1969
1991
  for (const t of timers) clearInterval(t);
1970
1992
  for (const conn of d1Conns.values()) conn.close();
1971
1993
  for (const conn of resourceDbs.values()) conn.close();
1994
+ if (doDb !== db) doDb.close();
1972
1995
  db.close();
1973
1996
  },
1974
1997
  };
@@ -2075,6 +2098,7 @@ if (import.meta.main) {
2075
2098
  db: { type: "string" },
2076
2099
  "data-dir": { type: "string" },
2077
2100
  "resource-dir": { type: "string" },
2101
+ "do-db": { type: "string" },
2078
2102
  "owner-id": { type: "string" },
2079
2103
  "r2-quota-bytes": { type: "string" },
2080
2104
  "r2-resource-ids": { type: "string" },
@@ -2127,6 +2151,7 @@ if (import.meta.main) {
2127
2151
  db: values.db,
2128
2152
  dataDir: values["data-dir"],
2129
2153
  resourceDir: values["resource-dir"],
2154
+ doDb: values["do-db"],
2130
2155
  ownerId: values["owner-id"],
2131
2156
  r2QuotaBytes: Number(values["r2-quota-bytes"] ?? process.env.SPROUTBOAT_R2_QUOTA_BYTES) || undefined,
2132
2157
  r2ResourceIds: values["r2-resource-ids"] ? String(values["r2-resource-ids"]).split(",").filter(Boolean) : undefined,