@sproutboat/wire 0.5.0 → 0.7.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,50 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 493e4e3: Add short-lived R2 direct-transfer tickets for broker-backed sprouts. A
8
+ loopback HTTP listener streams PUT bodies to file-backed R2 storage, and serves
9
+ GET and HEAD requests with single-range support without placing object bytes
10
+ in a binding frame.
11
+
12
+ Tickets are random bearer capabilities with a byte limit, expiry, one-use
13
+ claim, and optional SHA-256 verification. Native standalone direct transfers
14
+ remain unavailable until the native HTTP ingress can stream request bodies.
15
+
16
+ For broker deployments, ticket issuance now reserves account-level capacity
17
+ across every R2 resource. Reservations survive redeploys, expired and orphaned
18
+ temporary uploads are reclaimed, and the broker retains a configurable free
19
+ disk floor with capacity and rejected-transfer counters for the dashboard.
20
+ - 5acb93c: Add Cloudflare-shaped resumable R2 multipart uploads. Parts are persisted
21
+ independently, and standalone completion assembles the final object through a
22
+ fixed 64 KiB native buffer to keep memory bounded by part size rather than total
23
+ object size.
24
+
25
+ Unfinished multipart uploads expire after seven days and their persisted part
26
+ files are removed automatically.
27
+
28
+ Store completed objects as immutable blob generations so a failed overwrite
29
+ cannot expose new bytes under the previous metadata.
30
+
31
+ ## 0.6.0
32
+
33
+ ### Minor Changes
34
+
35
+ - 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).
36
+
37
+ 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):
38
+
39
+ - **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.
40
+ - **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.
41
+
42
+ No migration: this is a schema change for new writes going forward, not a converter for objects already stored inline from before.
43
+
44
+ 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.
45
+
46
+ **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`).
47
+
3
48
  ## 0.5.0
4
49
 
5
50
  ### Minor Changes
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.5.0",
3
+ "version": "0.7.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 {
@@ -9,6 +9,7 @@ import {
9
9
  encodeFrame,
10
10
  encodeV1,
11
11
  listen,
12
+ listenTransfers,
12
13
  type Broker,
13
14
  type Frame,
14
15
  type FetchLike,
@@ -159,6 +160,180 @@ test("R2: put / get / head / list / delete on a bound bucket", async () => {
159
160
  await expect(b.dispatch({ op: "r2.put", bucket: "NOPE", key: "x", body: "y" })).rejects.toThrow("not bound");
160
161
  });
161
162
 
163
+ test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged download", async () => {
164
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-"));
165
+ try {
166
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
167
+ const upload = await b.dispatch({
168
+ op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "upload",
169
+ maxBytes: 100, sha256: createHash("sha256").update("abcdef").digest("hex"),
170
+ httpMetadata: { contentType: "application/octet-stream" },
171
+ });
172
+ const url = `http://127.0.0.1${upload.url}`;
173
+ const bytes = new TextEncoder().encode("abcdef");
174
+ const body = new ReadableStream<Uint8Array>({
175
+ pull(controller) { controller.enqueue(bytes.subarray(0, 3)); controller.enqueue(bytes.subarray(3)); controller.close(); },
176
+ });
177
+ // SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
178
+ const put = await b.transfer(new Request(url, { method: "PUT", body, duplex: "half" } as RequestInit));
179
+ expect(put.status).toBe(201);
180
+ expect(await b.transfer(new Request(url, { method: "PUT", body: "again" }))).toMatchObject({ status: 410 });
181
+ const head = await b.dispatch({ op: "r2.head", bucket: "UPLOADS", key: "large.bin" });
182
+ expect(obj(head.object).size).toBe(6);
183
+
184
+ const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "download" });
185
+ const get = await b.transfer(new Request(`http://127.0.0.1${download.url}`, { headers: { range: "bytes=2-4" } }));
186
+ expect(get.status).toBe(206);
187
+ expect(get.headers.get("content-range")).toBe("bytes 2-4/6");
188
+ expect(get.headers.get("content-type")).toBe("application/octet-stream");
189
+ expect(await get.text()).toBe("cde");
190
+ expect((await b.transfer(new Request(`http://127.0.0.1${download.url}`))).status).toBe(410);
191
+ } finally {
192
+ rmSync(root, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ test("R2 direct transfer: invalid ranges and oversized bodies never replace an object", async () => {
197
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-errors-"));
198
+ try {
199
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
200
+ await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "old" });
201
+ const upload = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
202
+ const url = `http://127.0.0.1${upload.url}`;
203
+ expect((await b.transfer(new Request(url, { method: "PUT", body: "large" }))).status).toBe(413);
204
+ expect((await b.transfer(new Request(url, { method: "PUT", body: "ok" }))).status).toBe(410);
205
+ const retry = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
206
+ expect((await b.transfer(new Request(`http://127.0.0.1${retry.url}`, { method: "PUT", body: "ok" }))).status).toBe(201);
207
+ const after = await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "k" });
208
+ expect(after.body).toBe("ok");
209
+ const dl = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "download" });
210
+ expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`, { headers: { range: "bytes=1-0" } }))).status).toBe(416);
211
+ expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`))).status).toBe(200);
212
+ } finally {
213
+ rmSync(root, { recursive: true, force: true });
214
+ }
215
+ });
216
+
217
+ test("R2 direct transfer: account quota includes objects and shared in-flight reservations", async () => {
218
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-quota-"));
219
+ try {
220
+ const resourceDir = join(root, "resources");
221
+ const bindings = {
222
+ r2: ["UPLOADS"],
223
+ resources: { UPLOADS: { kind: "r2" as const, id: "uploads" } },
224
+ };
225
+ const config = {
226
+ resourceDir,
227
+ ownerId: "owner-1",
228
+ r2QuotaBytes: 12,
229
+ r2ResourceIds: ["uploads"],
230
+ bindings,
231
+ };
232
+ const first = make({ ...config, db: join(root, "first.sqlite") });
233
+ const second = make({ ...config, db: join(root, "second.sqlite") });
234
+
235
+ await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "existing", body: "four" });
236
+ const resource = new Database(join(resourceDir, "uploads.sqlite"));
237
+ try {
238
+ resource.query("INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1, ?2, ?3, 1, 2, ?4)")
239
+ .run("uploads", "partial", "upload-1", "part");
240
+ } finally {
241
+ resource.close();
242
+ }
243
+ await expect(first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 7 }))
244
+ .rejects.toThrow("quota");
245
+ const ticket = await first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 6 });
246
+ await expect(second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 }))
247
+ .rejects.toThrow("quota");
248
+
249
+ expect((await first.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "five!" }))).status).toBe(201);
250
+ const replacement = await second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 });
251
+ expect(replacement.ok).toBe(true);
252
+ const ledger = new Database(join(resourceDir, "r2-quota.sqlite"), { readonly: true });
253
+ try {
254
+ expect(ledger.query<{ tickets_issued: number; rejected_quota: number }, [string]>(
255
+ "SELECT tickets_issued, rejected_quota FROM r2_transfer_metric WHERE owner = ?",
256
+ ).get("owner-1")).toEqual({ tickets_issued: 2, rejected_quota: 2 });
257
+ } finally {
258
+ ledger.close();
259
+ }
260
+ } finally {
261
+ rmSync(root, { recursive: true, force: true });
262
+ }
263
+ });
264
+
265
+ test("R2 direct transfer: failed uploads release their reservation and retain the disk floor", async () => {
266
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-quota-release-"));
267
+ try {
268
+ const config = {
269
+ db: join(root, "state.sqlite"),
270
+ resourceDir: join(root, "resources"),
271
+ ownerId: "owner-1",
272
+ r2QuotaBytes: 5,
273
+ r2ResourceIds: ["uploads"],
274
+ bindings: { r2: ["UPLOADS"], resources: { UPLOADS: { kind: "r2" as const, id: "uploads" } } },
275
+ };
276
+ const b = make(config);
277
+ const ticket = await b.dispatch({
278
+ op: "r2.transfer.create", bucket: "UPLOADS", key: "bad", method: "upload", maxBytes: 5,
279
+ sha256: createHash("sha256").update("expected").digest("hex"),
280
+ });
281
+ expect((await b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "wrong" }))).status).toBe(422);
282
+ expect((await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "retry", method: "upload", maxBytes: 5 })).ok).toBe(true);
283
+
284
+ const floor = make({ ...config, db: join(root, "floor.sqlite"), r2QuotaBytes: 100, r2MinFreeBytes: 10, freeBytes: () => 11 });
285
+ await expect(floor.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "no-space", method: "upload", maxBytes: 2 }))
286
+ .rejects.toThrow("temporarily unavailable");
287
+
288
+ let freeBytes = 20;
289
+ const duringUpload = make({
290
+ ...config,
291
+ db: join(root, "during-upload.sqlite"),
292
+ r2QuotaBytes: 100,
293
+ r2MinFreeBytes: 10,
294
+ freeBytes: () => freeBytes,
295
+ });
296
+ const unknownLength = await duringUpload.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "during", method: "upload", maxBytes: 5 });
297
+ freeBytes = 10;
298
+ expect((await duringUpload.transfer(new Request(`http://127.0.0.1${unknownLength.url}`, { method: "PUT", body: "x" }))).status).toBe(507);
299
+
300
+ const abandoned = join(config.resourceDir, "r2-blobs", "a".repeat(24) + ".upload.tmp");
301
+ mkdirSync(join(config.resourceDir, "r2-blobs"), { recursive: true });
302
+ writeFileSync(abandoned, "partial");
303
+ await expect(b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "quota-full", method: "upload", maxBytes: 1 }))
304
+ .rejects.toThrow("quota");
305
+ expect(existsSync(abandoned)).toBe(false);
306
+ } finally {
307
+ rmSync(root, { recursive: true, force: true });
308
+ }
309
+ });
310
+
311
+ test("R2 direct transfer: live HTTP listener accepts a chunked binary PUT", async () => {
312
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-http-"));
313
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
314
+ const server = listenTransfers(b, "127.0.0.1", 0);
315
+ try {
316
+ const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "upload", maxBytes: 2 * 1024 * 1024 });
317
+ let chunks = 0;
318
+ const body = new ReadableStream<Uint8Array>({
319
+ pull(controller) {
320
+ if (chunks++ < 16) controller.enqueue(new Uint8Array(64 * 1024).fill(chunks));
321
+ else controller.close();
322
+ },
323
+ });
324
+ // SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
325
+ const uploaded = await fetch(`http://127.0.0.1:${server.port}${ticket.url}`, { method: "PUT", body, duplex: "half" } as RequestInit);
326
+ expect(uploaded.status).toBe(201);
327
+ const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "download" });
328
+ const response = await fetch(`http://127.0.0.1:${server.port}${download.url}`, { headers: { range: "bytes=65535-65537" } });
329
+ expect(response.status).toBe(206);
330
+ expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 2]));
331
+ } finally {
332
+ server.stop();
333
+ rmSync(root, { recursive: true, force: true });
334
+ }
335
+ });
336
+
162
337
  test("R2: list paginates with a cursor", async () => {
163
338
  const b = make({ bindings: { r2: ["ASSETS"] } });
164
339
  for (let i = 0; i < 5; i++) await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: `k${i}`, body: "x" });
@@ -169,6 +344,119 @@ test("R2: list paginates with a cursor", async () => {
169
344
  expect(arr(p2.objects).map((o) => obj(o).key)).toEqual(["k2", "k3"]);
170
345
  });
171
346
 
347
+ test("R2 multipart: binary parts complete without joining the full object in the broker heap", async () => {
348
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-multipart-"));
349
+ try {
350
+ const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] }, token: "tok" });
351
+ const created = await b.handleFrame(
352
+ encodeV1({
353
+ v: 1,
354
+ id: 1,
355
+ token: "tok",
356
+ op: "r2.multipart.create",
357
+ bucket: "UPLOADS",
358
+ key: "archive.bin",
359
+ customMetadata: { source: "test" },
360
+ }),
361
+ );
362
+ const uploadId = String(decodeV1Frame(created).json.uploadId);
363
+ const first = new Uint8Array([0, 1, 2, 255]);
364
+ const second = new Uint8Array([4, 5]);
365
+ const part1 = decodeV1Frame(
366
+ await b.handleFrame(
367
+ encodeV1(
368
+ {
369
+ v: 1,
370
+ id: 2,
371
+ token: "tok",
372
+ op: "r2.multipart.put",
373
+ bucket: "UPLOADS",
374
+ key: "archive.bin",
375
+ uploadId,
376
+ partNumber: 1,
377
+ },
378
+ first,
379
+ ),
380
+ ),
381
+ ).json;
382
+ const part2 = decodeV1Frame(
383
+ await b.handleFrame(
384
+ encodeV1(
385
+ {
386
+ v: 1,
387
+ id: 3,
388
+ token: "tok",
389
+ op: "r2.multipart.put",
390
+ bucket: "UPLOADS",
391
+ key: "archive.bin",
392
+ uploadId,
393
+ partNumber: 2,
394
+ },
395
+ second,
396
+ ),
397
+ ),
398
+ ).json;
399
+ const completed = await b.dispatch({
400
+ op: "r2.multipart.complete",
401
+ bucket: "UPLOADS",
402
+ key: "archive.bin",
403
+ uploadId,
404
+ parts: [obj(part1.part), obj(part2.part)],
405
+ });
406
+ expect(obj(completed.object)).toMatchObject({ key: "archive.bin", size: 6, customMetadata: { source: "test" } });
407
+
408
+ const got = decodeV1Frame(
409
+ await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "r2.get", bucket: "UPLOADS", key: "archive.bin" })),
410
+ );
411
+ expect(Buffer.from(got.bytes).equals(Buffer.from([...first, ...second]))).toBe(true);
412
+ expect(existsSync(join(root, "r2-blobs"))).toBe(true);
413
+ } finally {
414
+ rmSync(root, { recursive: true, force: true });
415
+ }
416
+ });
417
+
418
+ test("R2 overwrite keeps the committed generation visible when metadata update fails", async () => {
419
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-atomic-"));
420
+ try {
421
+ const databasePath = join(root, "state.sqlite");
422
+ const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
423
+ await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old-bytes" });
424
+ const control = new Database(databasePath);
425
+ control.exec(
426
+ "CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END",
427
+ );
428
+ await expect(
429
+ b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "new-and-longer-bytes" }),
430
+ ).rejects.toThrow("forced metadata failure");
431
+ expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({
432
+ found: true,
433
+ body: "old-bytes",
434
+ object: { size: 9 },
435
+ });
436
+ control.close();
437
+ } finally {
438
+ rmSync(root, { recursive: true, force: true });
439
+ }
440
+ });
441
+
442
+ test("R2 direct overwrite also preserves the old generation on metadata failure", async () => {
443
+ const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-atomic-"));
444
+ try {
445
+ const databasePath = join(root, "state.sqlite");
446
+ const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
447
+ await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old" });
448
+ const control = new Database(databasePath);
449
+ control.exec("CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END");
450
+ const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "object", method: "upload" });
451
+ await expect(b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "new" })))
452
+ .rejects.toThrow("forced metadata failure");
453
+ expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({ found: true, body: "old" });
454
+ control.close();
455
+ } finally {
456
+ rmSync(root, { recursive: true, force: true });
457
+ }
458
+ });
459
+
172
460
  test("Durable Object storage: get / put / delete / list / deleteAll scoped to (class, id)", async () => {
173
461
  const b = make({ bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
174
462
  expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "a", key: "n" })).toMatchObject({ found: false });
@@ -332,6 +620,87 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
332
620
  }
333
621
  });
334
622
 
623
+ // #56 — object bytes live in their own file next to the store's own SQLite
624
+ // file, not inside it, so a resource-bound bucket's blobs persist across a
625
+ // redeploy exactly like its metadata does, and a bare-string binding's blobs
626
+ // live beside its per-deployment db.
627
+ test("R2 blobs are file-backed and persist across brokers for a resource-bound bucket", async () => {
628
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-res-"));
629
+ try {
630
+ const resourceDir = join(root, "resources");
631
+ const bucketId = "r2_0123456789abcdef01234567";
632
+ mkdirSync(join(root, "dep-a"), { recursive: true });
633
+ mkdirSync(join(root, "dep-b"), { recursive: true });
634
+
635
+ const first = createBroker({
636
+ db: join(root, "dep-a", "state.sqlite"),
637
+ resourceDir,
638
+ bindings: { r2: ["UPLOADS"], resources: { UPLOADS: { kind: "r2", id: bucketId } } },
639
+ });
640
+ await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "a.txt", body: "hello" });
641
+ first.close();
642
+ // metadata in the per-resource file, bytes in their own file beside it —
643
+ // never inline in the sqlite row.
644
+ const dbFile = join(resourceDir, `${bucketId}.sqlite`);
645
+ expect(existsSync(dbFile)).toBe(true);
646
+ expect(readFileSync(dbFile, "utf8")).not.toContain("hello");
647
+ expect(existsSync(join(resourceDir, "r2-blobs"))).toBe(true);
648
+
649
+ const second = createBroker({
650
+ db: join(root, "dep-b", "state.sqlite"),
651
+ resourceDir,
652
+ bindings: { r2: ["FILES"], resources: { FILES: { kind: "r2", id: bucketId } } },
653
+ });
654
+ expect(await second.dispatch({ op: "r2.get", bucket: "FILES", key: "a.txt" })).toMatchObject({
655
+ ok: true,
656
+ found: true,
657
+ body: "hello",
658
+ });
659
+ second.close();
660
+ } finally {
661
+ rmSync(root, { recursive: true, force: true });
662
+ }
663
+ });
664
+
665
+ test("a bare-string R2 binding stores blobs beside the per-deployment db, not resourceDir", async () => {
666
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-bare-"));
667
+ try {
668
+ const resourceDir = join(root, "resources");
669
+ mkdirSync(join(root, "dep"), { recursive: true });
670
+ const b = createBroker({ db: join(root, "dep", "state.sqlite"), resourceDir, bindings: { r2: ["UPLOADS"] } });
671
+ const put = await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "bytes" });
672
+ b.close();
673
+ expect(existsSync(resourceDir)).toBe(false);
674
+ expect(existsSync(join(root, "dep", "r2-blobs"))).toBe(true);
675
+ expect(obj(put.object)).toMatchObject({ size: 5 });
676
+ } finally {
677
+ rmSync(root, { recursive: true, force: true });
678
+ }
679
+ });
680
+
681
+ // Binary round trip against a real file-backed store (not `:memory:`), the
682
+ // v1 path a live sprout actually uses (#63).
683
+ test("R2 v1 binary put/get round-trips through a file-backed blob", async () => {
684
+ const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-v1-"));
685
+ try {
686
+ const b = createBroker({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] }, token: "tok" });
687
+ const server = listen(b, "127.0.0.1", 0);
688
+ try {
689
+ const body = new Uint8Array([0, 1, 2, 0x80, 0xff, 254, 253]);
690
+ const put = await v1(server, { v: 1, token: "tok", op: "r2.put", bucket: "UPLOADS", key: "k" }, body);
691
+ expect(obj(put.json.object)).toMatchObject({ size: body.length });
692
+ expect(existsSync(join(root, "r2-blobs"))).toBe(true);
693
+ const got = await v1(server, { v: 1, token: "tok", op: "r2.get", bucket: "UPLOADS", key: "k" });
694
+ expect(Buffer.from(got.bytes).equals(Buffer.from(body))).toBe(true);
695
+ } finally {
696
+ server.stop();
697
+ b.close();
698
+ }
699
+ } finally {
700
+ rmSync(root, { recursive: true, force: true });
701
+ }
702
+ });
703
+
335
704
  test("a bare-string binding still uses the per-broker db, not resourceDir", async () => {
336
705
  const root = mkdtempSync(join(tmpdir(), "sb-broker-bare-"));
337
706
  try {
@@ -633,6 +1002,19 @@ async function v1(
633
1002
  };
634
1003
  }
635
1004
 
1005
+ type V1Reply = { json: JsonObject; bytes: Uint8Array };
1006
+
1007
+ function decodeV1Frame(frame: Buffer): V1Reply {
1008
+ const payloadLength = frame.readUInt32LE(0);
1009
+ const payload = frame.subarray(4, 4 + payloadLength);
1010
+ expect(payload[0]).toBe(1);
1011
+ const jsonLength = payload.readUInt32LE(1);
1012
+ return {
1013
+ json: obj(parseJsonValue(payload.subarray(5, 5 + jsonLength).toString("utf8"))),
1014
+ bytes: new Uint8Array(payload.subarray(5 + jsonLength)),
1015
+ };
1016
+ }
1017
+
636
1018
  test("v1: an object body round-trips as bytes, not as escaped JSON", async () => {
637
1019
  const b = make({ bindings: { r2: ["UP"] }, token: "tok" });
638
1020
  const server = listen(b, "127.0.0.1", 0);