@sproutboat/wire 0.4.0 → 0.6.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,34 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6998a15: R2 object bytes no longer live inside SQLite, on either transport (baronunread/sproutboat#56). Every `put()` used to bind the whole object into a `body` column, and every `get()` read it back out — doubling the object through SQLite's own page cache/WAL machinery on top of the buffer it already arrived in, which was the real driver of #56's "large uploads make memory skyrocket" complaint (the JSON-escaping half of that was already fixed in #63).
8
+
9
+ Objects now live in their own file under `r2-blobs/`, named by a hash of bucket+key (so unicode keys and directory traversal are non-issues):
10
+
11
+ - **Embedded (standalone):** `<data-dir>/r2-blobs/` — `sb_r2_put_c`/`sb_r2_get_c` write/read the file directly via C `fopen`/`fwrite`/`fread`, no SQLite blob column at all. `sb_r2_delete_blob` removes the file on delete.
12
+ - **Broker:** colocated with whichever store file already holds that bucket's metadata — the main `db` for a bare-string binding, the resource's own file under `resourceDir` for an account-level one (#74) — so blobs persist across a redeploy exactly when that metadata does. `:memory:` stores (tests, local dev) fall back to an in-memory map.
13
+
14
+ No migration: this is a schema change for new writes going forward, not a converter for objects already stored inline from before.
15
+
16
+ Verified end-to-end against both transports (kitchen-sink's full conformance suite, 24/24 standalone + 27/27 broker) and with new unit tests for file-backed persistence, the `:memory:` fallback, and a real binary round-trip over the v1 wire protocol.
17
+
18
+ **Known limitation found while verifying this, not fixed here:** a binary `put()` body (bytes ≥ 0x80) is corrupted on the way *in*, before it ever reaches storage — `porf_native_fetch_read_value` (the function every inline-C string read in the runtime goes through) always UTF-8-encodes a Latin-1-range string on read, which is correct for genuine text but wrong for opaque bytes coming off a raw HTTP body. This predates this change (the old SQLite-blob path had the identical corruption) and is not something the storage backend can fix — it needs an input-side counterpart to `#176`'s `x-sb-raw-body` marker, or real `ArrayBuffer`/`Uint8Array` body support (the upstream ask already filed as Draft G in `patches/UPSTREAM.md`).
19
+
20
+ ## 0.5.0
21
+
22
+ ### Minor Changes
23
+
24
+ - Rate limiter (baronunread/sproutboat#69): `env.<NAME>.limit({ key })` now
25
+ returns `{ success, resetAt }`. `resetAt` is epoch milliseconds for when the
26
+ fixed window rolls and the counter clears, returned on every call (hit or
27
+ miss), so a rejection can carry an accurate `Retry-After`:
28
+ `Math.ceil((resetAt - Date.now()) / 1000)`. The `ratelimit.check` op already
29
+ had `windowStart` and `period` in hand; it just stopped discarding them.
30
+ Additive: existing callers reading only `success` are unaffected.
31
+
3
32
  ## 0.4.0
4
33
 
5
34
  ### Minor Changes
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/baronunread/sproutboat-packages.git",
9
+ "directory": "packages/wire"
10
+ },
6
11
  "main": "./src/index.ts",
7
12
  "types": "./src/index.ts",
8
13
  "dependencies": {
@@ -1,6 +1,6 @@
1
1
  import { afterEach, expect, test } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import {
@@ -332,6 +332,87 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
332
332
  }
333
333
  });
334
334
 
335
+ // #56 — object bytes live in their own file next to the store's own SQLite
336
+ // file, not inside it, so a resource-bound bucket's blobs persist across a
337
+ // redeploy exactly like its metadata does, and a bare-string binding's blobs
338
+ // live beside its per-deployment db.
339
+ test("R2 blobs are file-backed and persist across brokers for a resource-bound bucket", async () => {
340
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-res-"));
341
+ try {
342
+ const resourceDir = join(root, "resources");
343
+ const bucketId = "r2_0123456789abcdef01234567";
344
+ mkdirSync(join(root, "dep-a"), { recursive: true });
345
+ mkdirSync(join(root, "dep-b"), { recursive: true });
346
+
347
+ const first = createBroker({
348
+ db: join(root, "dep-a", "state.sqlite"),
349
+ resourceDir,
350
+ bindings: { r2: ["UPLOADS"], resources: { UPLOADS: { kind: "r2", id: bucketId } } },
351
+ });
352
+ await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "a.txt", body: "hello" });
353
+ first.close();
354
+ // metadata in the per-resource file, bytes in their own file beside it —
355
+ // never inline in the sqlite row.
356
+ const dbFile = join(resourceDir, `${bucketId}.sqlite`);
357
+ expect(existsSync(dbFile)).toBe(true);
358
+ expect(readFileSync(dbFile, "utf8")).not.toContain("hello");
359
+ expect(existsSync(join(resourceDir, "r2-blobs"))).toBe(true);
360
+
361
+ const second = createBroker({
362
+ db: join(root, "dep-b", "state.sqlite"),
363
+ resourceDir,
364
+ bindings: { r2: ["FILES"], resources: { FILES: { kind: "r2", id: bucketId } } },
365
+ });
366
+ expect(await second.dispatch({ op: "r2.get", bucket: "FILES", key: "a.txt" })).toMatchObject({
367
+ ok: true,
368
+ found: true,
369
+ body: "hello",
370
+ });
371
+ second.close();
372
+ } finally {
373
+ rmSync(root, { recursive: true, force: true });
374
+ }
375
+ });
376
+
377
+ test("a bare-string R2 binding stores blobs beside the per-deployment db, not resourceDir", async () => {
378
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-bare-"));
379
+ try {
380
+ const resourceDir = join(root, "resources");
381
+ mkdirSync(join(root, "dep"), { recursive: true });
382
+ const b = createBroker({ db: join(root, "dep", "state.sqlite"), resourceDir, bindings: { r2: ["UPLOADS"] } });
383
+ const put = await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "bytes" });
384
+ b.close();
385
+ expect(existsSync(resourceDir)).toBe(false);
386
+ expect(existsSync(join(root, "dep", "r2-blobs"))).toBe(true);
387
+ expect(obj(put.object)).toMatchObject({ size: 5 });
388
+ } finally {
389
+ rmSync(root, { recursive: true, force: true });
390
+ }
391
+ });
392
+
393
+ // Binary round trip against a real file-backed store (not `:memory:`), the
394
+ // v1 path a live sprout actually uses (#63).
395
+ test("R2 v1 binary put/get round-trips through a file-backed blob", async () => {
396
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-v1-"));
397
+ try {
398
+ const b = createBroker({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] }, token: "tok" });
399
+ const server = listen(b, "127.0.0.1", 0);
400
+ try {
401
+ const body = new Uint8Array([0, 1, 2, 0x80, 0xff, 254, 253]);
402
+ const put = await v1(server, { v: 1, token: "tok", op: "r2.put", bucket: "UPLOADS", key: "k" }, body);
403
+ expect(obj(put.json.object)).toMatchObject({ size: body.length });
404
+ expect(existsSync(join(root, "r2-blobs"))).toBe(true);
405
+ const got = await v1(server, { v: 1, token: "tok", op: "r2.get", bucket: "UPLOADS", key: "k" });
406
+ expect(Buffer.from(got.bytes).equals(Buffer.from(body))).toBe(true);
407
+ } finally {
408
+ server.stop();
409
+ b.close();
410
+ }
411
+ } finally {
412
+ rmSync(root, { recursive: true, force: true });
413
+ }
414
+ });
415
+
335
416
  test("a bare-string binding still uses the per-broker db, not resourceDir", async () => {
336
417
  const root = mkdtempSync(join(tmpdir(), "sb-broker-bare-"));
337
418
  try {
@@ -745,7 +826,11 @@ test("ratelimit: fixed window per key, limit/period from the message (#69)", asy
745
826
  expect((await hit("a")).success).toBe(true); // 1
746
827
  expect((await hit("a")).success).toBe(true); // 2
747
828
  expect((await hit("a")).success).toBe(true); // 3
748
- expect((await hit("a")).success).toBe(false); // 4 — over
829
+ const over = await hit("a"); // 4 — over
830
+ expect(over.success).toBe(false);
831
+ // resetAt is epoch ms at the window edge, so it drives a Retry-After.
832
+ expect(over.resetAt).toBeGreaterThan(Date.now());
833
+ expect(over.resetAt).toBeLessThanOrEqual(Date.now() + 60_000);
749
834
  expect((await hit("b")).success).toBe(true); // a different key has its own count
750
835
 
751
836
  await expect(b.dispatch({ op: "ratelimit.check", name: "NOPE", key: "x", limit: 1, period: 60 })).rejects.toThrow(
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, rmSync, statSync } from "node:fs";
22
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } 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";
@@ -110,8 +110,6 @@ type D1Result = {
110
110
  };
111
111
  type R2Row = {
112
112
  key: string;
113
- /** TEXT from a v0 write, BLOB from a v1 one; sqlite hands each back as-is. */
114
- body: string | Uint8Array;
115
113
  size: number;
116
114
  etag: string;
117
115
  uploaded: string;
@@ -234,8 +232,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
234
232
  conn.exec(
235
233
  "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))",
236
234
  );
235
+ // #56 — no `body` column: an object's bytes live in their own file under
236
+ // `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
237
+ // Doubling every object through SQLite's own page cache on top of the
238
+ // buffer it already arrived in was the real cost driving #56's "large
239
+ // uploads make memory skyrocket" complaint, not just the JSON-escaping
240
+ // #63 already fixed.
237
241
  conn.exec(
238
- "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
242
+ "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, size INTEGER NOT NULL, " +
239
243
  "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
240
244
  "PRIMARY KEY (bucket, key))",
241
245
  );
@@ -414,15 +418,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
414
418
  return { path: dest, bytes: statSync(dest).size };
415
419
  };
416
420
 
417
- /** An object body as bytes, whichever storage class it came back in. */
418
- const r2Bytes = (body: string | Uint8Array): Uint8Array =>
419
- body instanceof Uint8Array ? body : new TextEncoder().encode(body);
420
-
421
- /** An object body as text, for the v0 reply shape that carries it in JSON.
422
- * A binary object stored by a v1 client cannot survive this, which is why
423
- * v1 exists — but returning a decoded string beats returning `{"0":...}`. */
424
- const r2Text = (body: string | Uint8Array): string =>
425
- body instanceof Uint8Array ? new TextDecoder().decode(body) : body;
421
+ /**
422
+ * #56 — an object's bytes never enter SQLite; they live in their own file,
423
+ * named by a hash of bucket+key (so unicode keys and directory traversal
424
+ * are both non-issues), colocated with whichever store file holds that
425
+ * bucket's metadata — the main `db` for a bare-string binding, the
426
+ * resource's own file under `resourceDir` for an account-level one (#74) —
427
+ * so blobs persist across a redeploy exactly when that metadata does.
428
+ * `:memory:` stores (tests, `db: ":memory:"`) fall back to an in-memory map.
429
+ */
430
+ const r2MemBlobs = new Map<string, Uint8Array>();
431
+ const r2BlobId = (bucketPart: string, key: string): string =>
432
+ createHash("sha256").update(`${bucketPart}\0${key}`).digest("hex");
433
+ const r2BlobDir = (store: Database): string | null => {
434
+ const file = store.filename;
435
+ return file && file !== ":memory:" ? join(dirname(resolve(file)), "r2-blobs") : null;
436
+ };
437
+ const r2BlobPath = (store: Database, bucketPart: string, key: string): string | null => {
438
+ const dir = r2BlobDir(store);
439
+ return dir ? join(dir, `${r2BlobId(bucketPart, key)}.blob`) : null;
440
+ };
441
+ const r2WriteBlob = (store: Database, bucketPart: string, key: string, bytes: Uint8Array): void => {
442
+ const path = r2BlobPath(store, bucketPart, key);
443
+ if (!path) {
444
+ r2MemBlobs.set(`${bucketPart}\0${key}`, bytes);
445
+ return;
446
+ }
447
+ mkdirSync(dirname(path), { recursive: true });
448
+ writeFileSync(path, bytes);
449
+ };
450
+ const r2ReadBlob = (store: Database, bucketPart: string, key: string): Uint8Array | null => {
451
+ const path = r2BlobPath(store, bucketPart, key);
452
+ if (!path) return r2MemBlobs.get(`${bucketPart}\0${key}`) ?? null;
453
+ return existsSync(path) ? readFileSync(path) : null;
454
+ };
455
+ const r2DeleteBlob = (store: Database, bucketPart: string, key: string): void => {
456
+ const path = r2BlobPath(store, bucketPart, key);
457
+ if (!path) {
458
+ r2MemBlobs.delete(`${bucketPart}\0${key}`);
459
+ return;
460
+ }
461
+ if (existsSync(path)) rmSync(path);
462
+ };
426
463
 
427
464
  const r2Row = (r: R2Row) => ({
428
465
  key: r.key,
@@ -612,45 +649,47 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
612
649
  "window_start = ?3 RETURNING count",
613
650
  )
614
651
  .get(name, msg.key == null ? "" : str(msg.key), windowStart);
615
- return { ok: true, success: (row?.count ?? 1) <= limit };
652
+ return { ok: true, success: (row?.count ?? 1) <= limit, resetAt: (windowStart + period) * 1000 };
616
653
  }
617
654
 
618
655
  case "r2.put": {
619
656
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
620
- const body = str(msg.body);
621
- const etag = createHash("sha256").update(body).digest("hex");
657
+ const key = str(msg.key);
658
+ const bytes = new TextEncoder().encode(str(msg.body));
659
+ const etag = createHash("sha256").update(bytes).digest("hex");
660
+ const uploaded = new Date().toISOString();
661
+ r2WriteBlob(store, bucket, key, bytes);
622
662
  store
623
663
  .query(
624
- "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
625
- "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
664
+ "INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7) " +
665
+ "ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7",
626
666
  )
627
667
  .run(
628
668
  bucket,
629
- str(msg.key),
630
- body,
631
- Buffer.byteLength(body),
669
+ key,
670
+ bytes.byteLength,
632
671
  etag,
633
- new Date().toISOString(),
672
+ uploaded,
634
673
  JSON.stringify(msg.httpMetadata ?? {}),
635
674
  JSON.stringify(msg.customMetadata ?? {}),
636
675
  );
637
- return {
638
- ok: true,
639
- object: { key: str(msg.key), size: Buffer.byteLength(body), etag, uploaded: new Date().toISOString() },
640
- };
676
+ return { ok: true, object: { key, size: bytes.byteLength, etag, uploaded } };
641
677
  }
642
678
  case "r2.get":
643
679
  case "r2.head": {
644
680
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
645
- const row = store
646
- .query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
647
- .get(bucket, str(msg.key));
681
+ const key = str(msg.key);
682
+ const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, key);
648
683
  if (!row) return { ok: true, found: false };
649
- return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? r2Text(row.body) : undefined };
684
+ const body =
685
+ msg.op === "r2.get" ? new TextDecoder().decode(r2ReadBlob(store, bucket, key) ?? new Uint8Array(0)) : undefined;
686
+ return { ok: true, found: true, object: r2Row(row), body };
650
687
  }
651
688
  case "r2.delete": {
652
689
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
653
- store.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
690
+ const key = str(msg.key);
691
+ store.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, key);
692
+ r2DeleteBlob(store, bucket, key);
654
693
  return { ok: true };
655
694
  }
656
695
  case "r2.list": {
@@ -832,38 +871,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
832
871
  */
833
872
  async function dispatchBinary(msg: Frame, binary: Uint8Array): Promise<{ reply: Frame; bytes?: Uint8Array }> {
834
873
  if (msg.op === "r2.put") {
835
- // The body arrived as bytes; store it as a blob rather than a string, so
836
- // a put costs one copy instead of an escape, a parse and a re-encode.
874
+ // The body arrived as bytes; write it straight to its own file rather
875
+ // than into SQLite (#56) — no escape, no parse, no re-encode, and no
876
+ // second full-object copy through the database's own page cache.
837
877
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
878
+ const key = str(msg.key);
838
879
  const etag = createHash("sha256").update(binary).digest("hex");
839
880
  const uploaded = new Date().toISOString();
881
+ r2WriteBlob(store, bucket, key, binary);
840
882
  store
841
883
  .query(
842
- "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
843
- "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
884
+ "INSERT INTO r2 (bucket, key, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7) " +
885
+ "ON CONFLICT (bucket, key) DO UPDATE SET size=?3, etag=?4, uploaded=?5, http_json=?6, custom_json=?7",
844
886
  )
845
887
  .run(
846
888
  bucket,
847
- str(msg.key),
848
- binary,
889
+ key,
849
890
  binary.byteLength,
850
891
  etag,
851
892
  uploaded,
852
893
  JSON.stringify(msg.httpMetadata ?? {}),
853
894
  JSON.stringify(msg.customMetadata ?? {}),
854
895
  );
855
- return {
856
- reply: { ok: true, object: { key: str(msg.key), size: binary.byteLength, etag, uploaded } },
857
- };
896
+ return { reply: { ok: true, object: { key, size: binary.byteLength, etag, uploaded } } };
858
897
  }
859
898
 
860
899
  if (msg.op === "r2.get") {
861
900
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
862
- const row = store
863
- .query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
864
- .get(bucket, str(msg.key));
901
+ const key = str(msg.key);
902
+ const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, key);
865
903
  if (!row) return { reply: { ok: true, found: false } };
866
- return { reply: { ok: true, found: true, object: r2Row(row) }, bytes: r2Bytes(row.body) };
904
+ return { reply: { ok: true, found: true, object: r2Row(row) }, bytes: r2ReadBlob(store, bucket, key) ?? new Uint8Array(0) };
867
905
  }
868
906
 
869
907
  if (msg.op === "assets.get") {