@sproutboat/wire 0.17.0 → 0.18.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.
@@ -39,31 +39,59 @@ function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
39
39
 
40
40
  test("a stale queue acknowledgement cannot remove a newer delivery claim", async () => {
41
41
  const root = mkdtempSync(join(tmpdir(), "sb-claim-fence-"));
42
- const path = join(root,"state.sqlite");
42
+ const path = join(root, "state.sqlite");
43
43
  const first = Promise.withResolvers<void>();
44
44
  const second = Promise.withResolvers<void>();
45
45
  const finishFirst = Promise.withResolvers<Response>();
46
46
  const finishSecond = Promise.withResolvers<Response>();
47
47
  let enabled = false;
48
- const a = createBroker({db:path,bindings:{queues:["JOBS"]},sproutUrl:"http://127.0.0.1:1/",deliveryTimeoutMs:2_000,deliveryLeaseMs:3_000,fetchImpl:async()=>{first.resolve();return finishFirst.promise;}});
49
- const b = createBroker({db:path,bindings:{queues:["JOBS"]},sproutUrl:"http://127.0.0.1:1/",dispatchEnabled:()=>enabled,deliveryTimeoutMs:2_000,deliveryLeaseMs:3_000,fetchImpl:async()=>{second.resolve();return finishSecond.promise;}});
48
+ const a = createBroker({
49
+ db: path,
50
+ bindings: { queues: ["JOBS"] },
51
+ sproutUrl: "http://127.0.0.1:1/",
52
+ deliveryTimeoutMs: 2_000,
53
+ deliveryLeaseMs: 3_000,
54
+ fetchImpl: async () => {
55
+ first.resolve();
56
+ return finishFirst.promise;
57
+ },
58
+ });
59
+ const b = createBroker({
60
+ db: path,
61
+ bindings: { queues: ["JOBS"] },
62
+ sproutUrl: "http://127.0.0.1:1/",
63
+ dispatchEnabled: () => enabled,
64
+ deliveryTimeoutMs: 2_000,
65
+ deliveryLeaseMs: 3_000,
66
+ fetchImpl: async () => {
67
+ second.resolve();
68
+ return finishSecond.promise;
69
+ },
70
+ });
50
71
  const inspect = new Database(path);
51
72
  try {
52
- await a.dispatch({op:"queue.send",queue:"JOBS",body:"work"});
73
+ await a.dispatch({ op: "queue.send", queue: "JOBS", body: "work" });
53
74
  await first.promise;
54
- const row=inspect.query<{id:string;claim_token:string},[]>("SELECT id,claim_token FROM mq").get()!;
75
+ const row = inspect.query<{ id: string; claim_token: string }, []>("SELECT id,claim_token FROM mq").get()!;
55
76
  inspect.query("UPDATE mq SET visible_at=0").run();
56
- enabled=true;
77
+ enabled = true;
57
78
  await second.promise;
58
- expect(inspect.query<{claim_token:string},[]>("SELECT claim_token FROM mq").get()!.claim_token).not.toBe(row.claim_token);
59
- finishFirst.resolve(Response.json({ack:[row.id],retry:[]}));
79
+ expect(inspect.query<{ claim_token: string }, []>("SELECT claim_token FROM mq").get()!.claim_token).not.toBe(
80
+ row.claim_token,
81
+ );
82
+ finishFirst.resolve(Response.json({ ack: [row.id], retry: [] }));
60
83
  await Bun.sleep(30);
61
- expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM mq").get()!.count).toBe(1);
62
- finishSecond.resolve(Response.json({ack:[row.id],retry:[]}));
84
+ expect(inspect.query<{ count: number }, []>("SELECT count(*) AS count FROM mq").get()!.count).toBe(1);
85
+ finishSecond.resolve(Response.json({ ack: [row.id], retry: [] }));
63
86
  await Bun.sleep(30);
64
- expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM mq").get()!.count).toBe(0);
65
- } finally {a.close();b.close();inspect.close();rmSync(root,{recursive:true,force:true});}
66
- },5_000);
87
+ expect(inspect.query<{ count: number }, []>("SELECT count(*) AS count FROM mq").get()!.count).toBe(0);
88
+ } finally {
89
+ a.close();
90
+ b.close();
91
+ inspect.close();
92
+ rmSync(root, { recursive: true, force: true });
93
+ }
94
+ }, 5_000);
67
95
 
68
96
  test("ping round-trips", async () => {
69
97
  const b = make();
@@ -71,42 +99,70 @@ test("ping round-trips", async () => {
71
99
  });
72
100
 
73
101
  test("a new alarm replaces exhausted delivery state and delete cancels durable retries", async () => {
74
- const root=mkdtempSync(join(tmpdir(),"sb-alarm-replace-"));
75
- const path=join(root,"state.sqlite");
76
- const broker=createBroker({db:path,bindings:{do:[{binding:"COUNTER",className:"Counter"}]}});
77
- const inspect=new Database(path);
102
+ const root = mkdtempSync(join(tmpdir(), "sb-alarm-replace-"));
103
+ const path = join(root, "state.sqlite");
104
+ const broker = createBroker({ db: path, bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
105
+ const inspect = new Database(path);
78
106
  try {
79
107
  inspect.query("INSERT INTO do_alarm_delivery VALUES ('Counter','one',1,5,'old',0)").run();
80
- await broker.dispatch({op:"do.alarm.set",cls:"Counter",id:"one",at:Date.now()+60_000});
81
- expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM do_alarm_delivery").get()!.count).toBe(0);
82
- expect((await broker.dispatch({op:"do.alarm.get",cls:"Counter",id:"one"})).at).toBeGreaterThan(Date.now());
108
+ await broker.dispatch({ op: "do.alarm.set", cls: "Counter", id: "one", at: Date.now() + 60_000 });
109
+ expect(inspect.query<{ count: number }, []>("SELECT count(*) AS count FROM do_alarm_delivery").get()!.count).toBe(
110
+ 0,
111
+ );
112
+ expect((await broker.dispatch({ op: "do.alarm.get", cls: "Counter", id: "one" })).at).toBeGreaterThan(Date.now());
83
113
  inspect.query("INSERT INTO do_alarm_delivery VALUES ('Counter','one',1,1,'active',9999999999999)").run();
84
- expect((await broker.dispatch({op:"do.alarm.delete",cls:"Counter",id:"one"})).deleted).toBe(true);
85
- expect(inspect.query<{count:number},[]>("SELECT count(*) AS count FROM do_alarm_delivery").get()!.count).toBe(0);
86
- expect((await broker.dispatch({op:"do.alarm.get",cls:"Counter",id:"one"})).at).toBeNull();
87
- } finally {inspect.close();broker.close();rmSync(root,{recursive:true,force:true});}
114
+ expect((await broker.dispatch({ op: "do.alarm.delete", cls: "Counter", id: "one" })).deleted).toBe(true);
115
+ expect(inspect.query<{ count: number }, []>("SELECT count(*) AS count FROM do_alarm_delivery").get()!.count).toBe(
116
+ 0,
117
+ );
118
+ expect((await broker.dispatch({ op: "do.alarm.get", cls: "Counter", id: "one" })).at).toBeNull();
119
+ } finally {
120
+ inspect.close();
121
+ broker.close();
122
+ rmSync(root, { recursive: true, force: true });
123
+ }
88
124
  });
89
125
 
90
126
  test("oversized queue acknowledgement bodies are cancelled and retried", async () => {
91
- let cancelled=false;
92
- const delivered=Promise.withResolvers<void>();
93
- const root=mkdtempSync(join(tmpdir(),"sb-ack-bound-"));
94
- const path=join(root,"state.sqlite");
95
- const broker=createBroker({db:path,bindings:{queues:["JOBS"]},sproutUrl:"http://127.0.0.1:1/",fetchImpl:async()=>{
96
- delivered.resolve();
97
- return new Response(new ReadableStream({pull(controller){controller.enqueue(new Uint8Array(65_536));},cancel(){cancelled=true;}}));
98
- }});
99
- const inspect=new Database(path);
127
+ let cancelled = false;
128
+ const delivered = Promise.withResolvers<void>();
129
+ const root = mkdtempSync(join(tmpdir(), "sb-ack-bound-"));
130
+ const path = join(root, "state.sqlite");
131
+ const broker = createBroker({
132
+ db: path,
133
+ bindings: { queues: ["JOBS"] },
134
+ sproutUrl: "http://127.0.0.1:1/",
135
+ fetchImpl: async () => {
136
+ delivered.resolve();
137
+ return new Response(
138
+ new ReadableStream({
139
+ pull(controller) {
140
+ controller.enqueue(new Uint8Array(65_536));
141
+ },
142
+ cancel() {
143
+ cancelled = true;
144
+ },
145
+ }),
146
+ );
147
+ },
148
+ });
149
+ const inspect = new Database(path);
100
150
  try {
101
- await broker.dispatch({op:"queue.send",queue:"JOBS",body:"work"});
151
+ await broker.dispatch({ op: "queue.send", queue: "JOBS", body: "work" });
102
152
  await delivered.promise;
103
153
  await Bun.sleep(40);
104
154
  expect(cancelled).toBe(true);
105
- const row=inspect.query<{attempts:number;claim_token:string},[]>("SELECT attempts,claim_token FROM mq").get()!;
155
+ const row = inspect
156
+ .query<{ attempts: number; claim_token: string }, []>("SELECT attempts,claim_token FROM mq")
157
+ .get()!;
106
158
  expect(row.attempts).toBe(1);
107
159
  expect(row.claim_token).toBe("");
108
- } finally {inspect.close();broker.close();rmSync(root,{recursive:true,force:true});}
109
- },3_000);
160
+ } finally {
161
+ inspect.close();
162
+ broker.close();
163
+ rmSync(root, { recursive: true, force: true });
164
+ }
165
+ }, 3_000);
110
166
 
111
167
  test("#233: a KV value put as bytes is flagged binary and survives an existing store", async () => {
112
168
  const b = make({ bindings: { kv: ["CACHE"] } });
@@ -114,11 +170,19 @@ test("#233: a KV value put as bytes is flagged binary and survives an existing s
114
170
  const bytes = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
115
171
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: bytes, binary: true });
116
172
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "txt", value: "café" });
117
- expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({ ok: true, found: true, value: bytes, binary: true });
173
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({
174
+ ok: true,
175
+ found: true,
176
+ value: bytes,
177
+ binary: true,
178
+ });
118
179
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "txt" })).toMatchObject({ value: "café", binary: false });
119
180
  // Overwriting a binary value with text clears the flag.
120
181
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: "now text" });
121
- expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({ value: "now text", binary: false });
182
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({
183
+ value: "now text",
184
+ binary: false,
185
+ });
122
186
  });
123
187
 
124
188
  test("KV put / get / list / delete, scoped to a bound namespace", async () => {
@@ -126,7 +190,12 @@ test("KV put / get / list / delete, scoped to a bound namespace", async () => {
126
190
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: false, value: null });
127
191
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v" });
128
192
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k2", value: "v2" });
129
- expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ ok: true, found: true, value: "v", binary: false });
193
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({
194
+ ok: true,
195
+ found: true,
196
+ value: "v",
197
+ binary: false,
198
+ });
130
199
  expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "k" })).toMatchObject({ ok: true, keys: ["k", "k2"] });
131
200
  await b.dispatch({ op: "kv.delete", ns: "CACHE", key: "k" });
132
201
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
@@ -140,7 +209,10 @@ test("KV metadata, expiration and paged list (the shim from 2026-10-25); the pre
140
209
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "c", value: "3" });
141
210
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "a_x", value: "4" });
142
211
  await b.dispatch({ op: "kv.put", ns: "CACHE", key: "AB", value: "5" });
143
- expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "a" })).toMatchObject({ metadata: '{"t":1}', expiration: null });
212
+ expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "a" })).toMatchObject({
213
+ metadata: '{"t":1}',
214
+ expiration: null,
215
+ });
144
216
  expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "b" })).toMatchObject({ metadata: null, expiration: at });
145
217
  // LIKE matched "ab" for "a_" and "AB" for "a"; the prefix is now exact and case-sensitive.
146
218
  expect((await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "a_" })).keys).toEqual(["a_x"]);
@@ -153,7 +225,11 @@ test("KV metadata, expiration and paged list (the shim from 2026-10-25); the pre
153
225
  ]);
154
226
  const rest = await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "", limit: 2, cursor: "a" });
155
227
  expect(rest).toMatchObject({ keys: ["a_x", "b"], list_complete: false, cursor: "b" });
156
- expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "", limit: 2, cursor: "b" })).toMatchObject({ keys: ["c"], list_complete: true, cursor: "" });
228
+ expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "", limit: 2, cursor: "b" })).toMatchObject({
229
+ keys: ["c"],
230
+ list_complete: true,
231
+ cursor: "",
232
+ });
157
233
  });
158
234
 
159
235
  test("KV expirationTtl under 60 seconds is rejected, matching CF", async () => {
@@ -426,17 +502,33 @@ test("R2: put / get / head / list / delete on a bound bucket", async () => {
426
502
 
427
503
  test("R2: exact list prefix at every date; md5, paged list and delimiter for the shim from 2026-10-26", async () => {
428
504
  const b = make({ bindings: { r2: ["ASSETS"] } });
429
- for (const key of ["a_x", "ab", "AB", "d/1", "d/2", "e/1", "f"]) await b.dispatch({ op: "r2.put", bucket: "ASSETS", key, body: "v" });
505
+ for (const key of ["a_x", "ab", "AB", "d/1", "d/2", "e/1", "f"])
506
+ await b.dispatch({ op: "r2.put", bucket: "ASSETS", key, body: "v" });
430
507
  // LIKE listed "ab" for "a_" and "AB" for "a".
431
- expect(arr((await b.dispatch({ op: "r2.list", bucket: "ASSETS", prefix: "a_" })).objects).map((o) => obj(o).key)).toEqual(["a_x"]);
432
- expect(arr((await b.dispatch({ op: "r2.list", bucket: "ASSETS", prefix: "a" })).objects).map((o) => obj(o).key)).toEqual(["a_x", "ab"]);
508
+ expect(
509
+ arr((await b.dispatch({ op: "r2.list", bucket: "ASSETS", prefix: "a_" })).objects).map((o) => obj(o).key),
510
+ ).toEqual(["a_x"]);
511
+ expect(
512
+ arr((await b.dispatch({ op: "r2.list", bucket: "ASSETS", prefix: "a" })).objects).map((o) => obj(o).key),
513
+ ).toEqual(["a_x", "ab"]);
433
514
  const put = await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: "m", body: "hello" });
434
- await b.dispatch({ op: "r2.md5", bucket: "ASSETS", key: "m", etag: obj(put.object).etag, md5: "5d41402abc4b2a76b9719d911017c592" });
435
- expect(obj((await b.dispatch({ op: "r2.head", bucket: "ASSETS", key: "m" })).object).md5).toBe("5d41402abc4b2a76b9719d911017c592");
515
+ await b.dispatch({
516
+ op: "r2.md5",
517
+ bucket: "ASSETS",
518
+ key: "m",
519
+ etag: obj(put.object).etag,
520
+ md5: "5d41402abc4b2a76b9719d911017c592",
521
+ });
522
+ expect(obj((await b.dispatch({ op: "r2.head", bucket: "ASSETS", key: "m" })).object).md5).toBe(
523
+ "5d41402abc4b2a76b9719d911017c592",
524
+ );
436
525
  // A stale etag (the object was written again since) records nothing.
437
526
  await b.dispatch({ op: "r2.md5", bucket: "ASSETS", key: "m", etag: "stale", md5: "x" });
438
- expect(obj((await b.dispatch({ op: "r2.head", bucket: "ASSETS", key: "m" })).object).md5).toBe("5d41402abc4b2a76b9719d911017c592");
439
- const page = (cursor: string) => b.dispatch({ op: "r2.list", bucket: "ASSETS", page: true, prefix: "", cursor, limit: 3, delimiter: "/" });
527
+ expect(obj((await b.dispatch({ op: "r2.head", bucket: "ASSETS", key: "m" })).object).md5).toBe(
528
+ "5d41402abc4b2a76b9719d911017c592",
529
+ );
530
+ const page = (cursor: string) =>
531
+ b.dispatch({ op: "r2.list", bucket: "ASSETS", page: true, prefix: "", cursor, limit: 3, delimiter: "/" });
440
532
  const first = await page("");
441
533
  expect(arr(first.objects).map((o) => obj(o).key)).toEqual(["AB", "a_x", "ab"]);
442
534
  expect(first).toMatchObject({ truncated: true, cursor: "ab", delimitedPrefixes: [] });
@@ -451,14 +543,22 @@ test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged d
451
543
  try {
452
544
  const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
453
545
  const upload = await b.dispatch({
454
- op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "upload",
455
- maxBytes: 100, sha256: createHash("sha256").update("abcdef").digest("hex"),
546
+ op: "r2.transfer.create",
547
+ bucket: "UPLOADS",
548
+ key: "large.bin",
549
+ method: "upload",
550
+ maxBytes: 100,
551
+ sha256: createHash("sha256").update("abcdef").digest("hex"),
456
552
  httpMetadata: { contentType: "application/octet-stream" },
457
553
  });
458
554
  const url = `http://127.0.0.1${upload.url}`;
459
555
  const bytes = new TextEncoder().encode("abcdef");
460
556
  const body = new ReadableStream<Uint8Array>({
461
- pull(controller) { controller.enqueue(bytes.subarray(0, 3)); controller.enqueue(bytes.subarray(3)); controller.close(); },
557
+ pull(controller) {
558
+ controller.enqueue(bytes.subarray(0, 3));
559
+ controller.enqueue(bytes.subarray(3));
560
+ controller.close();
561
+ },
462
562
  });
463
563
  // SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
464
564
  const put = await b.transfer(new Request(url, { method: "PUT", body, duplex: "half" } as RequestInit));
@@ -467,7 +567,12 @@ test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged d
467
567
  const head = await b.dispatch({ op: "r2.head", bucket: "UPLOADS", key: "large.bin" });
468
568
  expect(obj(head.object).size).toBe(6);
469
569
 
470
- const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "download" });
570
+ const download = await b.dispatch({
571
+ op: "r2.transfer.create",
572
+ bucket: "UPLOADS",
573
+ key: "large.bin",
574
+ method: "download",
575
+ });
471
576
  const get = await b.transfer(new Request(`http://127.0.0.1${download.url}`, { headers: { range: "bytes=2-4" } }));
472
577
  expect(get.status).toBe(206);
473
578
  expect(get.headers.get("content-range")).toBe("bytes 2-4/6");
@@ -484,16 +589,32 @@ test("R2 direct transfer: invalid ranges and oversized bodies never replace an o
484
589
  try {
485
590
  const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
486
591
  await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "old" });
487
- const upload = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
592
+ const upload = await b.dispatch({
593
+ op: "r2.transfer.create",
594
+ bucket: "UPLOADS",
595
+ key: "k",
596
+ method: "upload",
597
+ maxBytes: 2,
598
+ });
488
599
  const url = `http://127.0.0.1${upload.url}`;
489
600
  expect((await b.transfer(new Request(url, { method: "PUT", body: "large" }))).status).toBe(413);
490
601
  expect((await b.transfer(new Request(url, { method: "PUT", body: "ok" }))).status).toBe(410);
491
- const retry = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
492
- expect((await b.transfer(new Request(`http://127.0.0.1${retry.url}`, { method: "PUT", body: "ok" }))).status).toBe(201);
602
+ const retry = await b.dispatch({
603
+ op: "r2.transfer.create",
604
+ bucket: "UPLOADS",
605
+ key: "k",
606
+ method: "upload",
607
+ maxBytes: 2,
608
+ });
609
+ expect((await b.transfer(new Request(`http://127.0.0.1${retry.url}`, { method: "PUT", body: "ok" }))).status).toBe(
610
+ 201,
611
+ );
493
612
  const after = await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "k" });
494
613
  expect(after.body).toBe("ok");
495
614
  const dl = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "download" });
496
- expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`, { headers: { range: "bytes=1-0" } }))).status).toBe(416);
615
+ expect(
616
+ (await b.transfer(new Request(`http://127.0.0.1${dl.url}`, { headers: { range: "bytes=1-0" } }))).status,
617
+ ).toBe(416);
497
618
  expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`))).status).toBe(200);
498
619
  } finally {
499
620
  rmSync(root, { recursive: true, force: true });
@@ -521,25 +642,46 @@ test("R2 direct transfer: account quota includes objects and shared in-flight re
521
642
  await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "existing", body: "four" });
522
643
  const resource = new Database(join(resourceDir, "uploads.sqlite"));
523
644
  try {
524
- resource.query("INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1, ?2, ?3, 1, 2, ?4)")
645
+ resource
646
+ .query("INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1, ?2, ?3, 1, 2, ?4)")
525
647
  .run("uploads", "partial", "upload-1", "part");
526
648
  } finally {
527
649
  resource.close();
528
650
  }
529
- await expect(first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 7 }))
530
- .rejects.toThrow("quota");
531
- const ticket = await first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 6 });
532
- await expect(second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 }))
533
- .rejects.toThrow("quota");
534
-
535
- expect((await first.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "five!" }))).status).toBe(201);
536
- const replacement = await second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 });
651
+ await expect(
652
+ first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 7 }),
653
+ ).rejects.toThrow("quota");
654
+ const ticket = await first.dispatch({
655
+ op: "r2.transfer.create",
656
+ bucket: "UPLOADS",
657
+ key: "large",
658
+ method: "upload",
659
+ maxBytes: 6,
660
+ });
661
+ await expect(
662
+ second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 }),
663
+ ).rejects.toThrow("quota");
664
+
665
+ expect(
666
+ (await first.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "five!" }))).status,
667
+ ).toBe(201);
668
+ const replacement = await second.dispatch({
669
+ op: "r2.transfer.create",
670
+ bucket: "UPLOADS",
671
+ key: "other",
672
+ method: "upload",
673
+ maxBytes: 1,
674
+ });
537
675
  expect(replacement.ok).toBe(true);
538
676
  const ledger = new Database(join(resourceDir, "r2-quota.sqlite"), { readonly: true });
539
677
  try {
540
- expect(ledger.query<{ tickets_issued: number; rejected_quota: number }, [string]>(
541
- "SELECT tickets_issued, rejected_quota FROM r2_transfer_metric WHERE owner = ?",
542
- ).get("owner-1")).toEqual({ tickets_issued: 2, rejected_quota: 2 });
678
+ expect(
679
+ ledger
680
+ .query<{ tickets_issued: number; rejected_quota: number }, [string]>(
681
+ "SELECT tickets_issued, rejected_quota FROM r2_transfer_metric WHERE owner = ?",
682
+ )
683
+ .get("owner-1"),
684
+ ).toEqual({ tickets_issued: 2, rejected_quota: 2 });
543
685
  } finally {
544
686
  ledger.close();
545
687
  }
@@ -561,15 +703,31 @@ test("R2 direct transfer: failed uploads release their reservation and retain th
561
703
  };
562
704
  const b = make(config);
563
705
  const ticket = await b.dispatch({
564
- op: "r2.transfer.create", bucket: "UPLOADS", key: "bad", method: "upload", maxBytes: 5,
706
+ op: "r2.transfer.create",
707
+ bucket: "UPLOADS",
708
+ key: "bad",
709
+ method: "upload",
710
+ maxBytes: 5,
565
711
  sha256: createHash("sha256").update("expected").digest("hex"),
566
712
  });
567
- expect((await b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "wrong" }))).status).toBe(422);
568
- expect((await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "retry", method: "upload", maxBytes: 5 })).ok).toBe(true);
569
-
570
- const floor = make({ ...config, db: join(root, "floor.sqlite"), r2QuotaBytes: 100, r2MinFreeBytes: 10, freeBytes: () => 11 });
571
- await expect(floor.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "no-space", method: "upload", maxBytes: 2 }))
572
- .rejects.toThrow("temporarily unavailable");
713
+ expect(
714
+ (await b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "wrong" }))).status,
715
+ ).toBe(422);
716
+ expect(
717
+ (await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "retry", method: "upload", maxBytes: 5 }))
718
+ .ok,
719
+ ).toBe(true);
720
+
721
+ const floor = make({
722
+ ...config,
723
+ db: join(root, "floor.sqlite"),
724
+ r2QuotaBytes: 100,
725
+ r2MinFreeBytes: 10,
726
+ freeBytes: () => 11,
727
+ });
728
+ await expect(
729
+ floor.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "no-space", method: "upload", maxBytes: 2 }),
730
+ ).rejects.toThrow("temporarily unavailable");
573
731
 
574
732
  let freeBytes = 20;
575
733
  const duringUpload = make({
@@ -579,15 +737,25 @@ test("R2 direct transfer: failed uploads release their reservation and retain th
579
737
  r2MinFreeBytes: 10,
580
738
  freeBytes: () => freeBytes,
581
739
  });
582
- const unknownLength = await duringUpload.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "during", method: "upload", maxBytes: 5 });
740
+ const unknownLength = await duringUpload.dispatch({
741
+ op: "r2.transfer.create",
742
+ bucket: "UPLOADS",
743
+ key: "during",
744
+ method: "upload",
745
+ maxBytes: 5,
746
+ });
583
747
  freeBytes = 10;
584
- expect((await duringUpload.transfer(new Request(`http://127.0.0.1${unknownLength.url}`, { method: "PUT", body: "x" }))).status).toBe(507);
748
+ expect(
749
+ (await duringUpload.transfer(new Request(`http://127.0.0.1${unknownLength.url}`, { method: "PUT", body: "x" })))
750
+ .status,
751
+ ).toBe(507);
585
752
 
586
753
  const abandoned = join(config.resourceDir, "r2-blobs", "a".repeat(24) + ".upload.tmp");
587
754
  mkdirSync(join(config.resourceDir, "r2-blobs"), { recursive: true });
588
755
  writeFileSync(abandoned, "partial");
589
- await expect(b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "quota-full", method: "upload", maxBytes: 1 }))
590
- .rejects.toThrow("quota");
756
+ await expect(
757
+ b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "quota-full", method: "upload", maxBytes: 1 }),
758
+ ).rejects.toThrow("quota");
591
759
  expect(existsSync(abandoned)).toBe(false);
592
760
  } finally {
593
761
  rmSync(root, { recursive: true, force: true });
@@ -599,7 +767,13 @@ test("R2 direct transfer: live HTTP listener accepts a chunked binary PUT", asyn
599
767
  const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
600
768
  const server = listenTransfers(b, "127.0.0.1", 0);
601
769
  try {
602
- const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "upload", maxBytes: 2 * 1024 * 1024 });
770
+ const ticket = await b.dispatch({
771
+ op: "r2.transfer.create",
772
+ bucket: "UPLOADS",
773
+ key: "stream.bin",
774
+ method: "upload",
775
+ maxBytes: 2 * 1024 * 1024,
776
+ });
603
777
  let chunks = 0;
604
778
  const body = new ReadableStream<Uint8Array>({
605
779
  pull(controller) {
@@ -608,10 +782,21 @@ test("R2 direct transfer: live HTTP listener accepts a chunked binary PUT", asyn
608
782
  },
609
783
  });
610
784
  // SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
611
- const uploaded = await fetch(`http://127.0.0.1:${server.port}${ticket.url}`, { method: "PUT", body, duplex: "half" } as RequestInit);
785
+ const uploaded = await fetch(`http://127.0.0.1:${server.port}${ticket.url}`, {
786
+ method: "PUT",
787
+ body,
788
+ duplex: "half",
789
+ } as RequestInit);
612
790
  expect(uploaded.status).toBe(201);
613
- const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "download" });
614
- const response = await fetch(`http://127.0.0.1:${server.port}${download.url}`, { headers: { range: "bytes=65535-65537" } });
791
+ const download = await b.dispatch({
792
+ op: "r2.transfer.create",
793
+ bucket: "UPLOADS",
794
+ key: "stream.bin",
795
+ method: "download",
796
+ });
797
+ const response = await fetch(`http://127.0.0.1:${server.port}${download.url}`, {
798
+ headers: { range: "bytes=65535-65537" },
799
+ });
615
800
  expect(response.status).toBe(206);
616
801
  expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 2]));
617
802
  } finally {
@@ -732,17 +917,40 @@ test("R2 direct overwrite also preserves the old generation on metadata failure"
732
917
  const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
733
918
  await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old" });
734
919
  const control = new Database(databasePath);
735
- control.exec("CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END");
920
+ control.exec(
921
+ "CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END",
922
+ );
736
923
  const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "object", method: "upload" });
737
- await expect(b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "new" })))
738
- .rejects.toThrow("forced metadata failure");
739
- expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({ found: true, body: "old" });
924
+ await expect(
925
+ b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "new" })),
926
+ ).rejects.toThrow("forced metadata failure");
927
+ expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({
928
+ found: true,
929
+ body: "old",
930
+ });
740
931
  control.close();
741
932
  } finally {
742
933
  rmSync(root, { recursive: true, force: true });
743
934
  }
744
935
  });
745
936
 
937
+ test("Durable Object storage list: exact prefix, start / startAfter / end, reverse, limit", async () => {
938
+ const b = make({ bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
939
+ for (const key of ["a", "b", "c", "d", "a_x", "A1", "aZ"])
940
+ await b.dispatch({ op: "do.storage.put", cls: "Counter", id: "r", key, value: "1" });
941
+ const keys = async (opts: Record<string, string | number | boolean | null>) => {
942
+ const entries = (await b.dispatch({ op: "do.storage.list", cls: "Counter", id: "r", prefix: "", ...opts }))
943
+ ?.entries;
944
+ return Array.isArray(entries) ? entries.map((e) => (Array.isArray(e) ? e[0] : null)) : null;
945
+ };
946
+ expect(await keys({ start: "b", end: "d" })).toEqual(["b", "c"]);
947
+ expect(await keys({ startAfter: "a_x" })).toEqual(["b", "c", "d"]);
948
+ expect(await keys({ reverse: true, limit: 2 })).toEqual(["d", "c"]);
949
+ // LIKE matched "_" as a wildcard and ignored case: "a_" used to list "aZ" and "A1" too.
950
+ expect(await keys({ prefix: "a_" })).toEqual(["a_x"]);
951
+ expect(await keys({ start: null, end: null })).toEqual(["A1", "a", "aZ", "a_x", "b", "c", "d"]);
952
+ });
953
+
746
954
  test("Durable Object storage: get / put / delete / list / deleteAll scoped to (class, id)", async () => {
747
955
  const b = make({ bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
748
956
  expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "a", key: "n" })).toMatchObject({ found: false });
@@ -1482,10 +1690,20 @@ test("TCP: large replies survive backpressure and stay ordered with a pipelined
1482
1690
  const second = encodeFrame({ ok: true, id: 2 });
1483
1691
  let calls = 0;
1484
1692
  const broker = make();
1485
- const server = listen({ ...broker, async handleFrame() {
1486
- if (calls++ === 0) { await Bun.sleep(10); return first; }
1487
- return second;
1488
- } }, "127.0.0.1", 0);
1693
+ const server = listen(
1694
+ {
1695
+ ...broker,
1696
+ async handleFrame() {
1697
+ if (calls++ === 0) {
1698
+ await Bun.sleep(10);
1699
+ return first;
1700
+ }
1701
+ return second;
1702
+ },
1703
+ },
1704
+ "127.0.0.1",
1705
+ 0,
1706
+ );
1489
1707
  const socket = createConnection({ host: "127.0.0.1", port: server.port });
1490
1708
  let resume: ReturnType<typeof setTimeout> | undefined;
1491
1709
  let next: ReturnType<typeof setTimeout> | undefined;
@@ -1497,7 +1715,8 @@ test("TCP: large replies survive backpressure and stay ordered with a pipelined
1497
1715
  timeout = setTimeout(() => reject(new Error("large broker reply stalled or truncated")), 3000);
1498
1716
  socket.on("error", reject);
1499
1717
  socket.on("data", (chunk: Buffer) => {
1500
- chunks.push(chunk); size += chunk.length;
1718
+ chunks.push(chunk);
1719
+ size += chunk.length;
1501
1720
  if (size >= first.length + second.length) done(Buffer.concat(chunks));
1502
1721
  });
1503
1722
  socket.on("connect", () => {
@@ -1512,8 +1731,11 @@ test("TCP: large replies survive backpressure and stay ordered with a pipelined
1512
1731
  expect(received.subarray(first.length).equals(second)).toBe(true);
1513
1732
  expect(calls).toBe(2);
1514
1733
  } finally {
1515
- clearTimeout(timeout); clearTimeout(next); clearTimeout(resume);
1516
- socket.destroy(); server.stop();
1734
+ clearTimeout(timeout);
1735
+ clearTimeout(next);
1736
+ clearTimeout(resume);
1737
+ socket.destroy();
1738
+ server.stop();
1517
1739
  }
1518
1740
  }, 10_000);
1519
1741
 
@@ -1523,7 +1745,12 @@ test("R2 direct download: If-None-Match supports wildcard, lists and weak compar
1523
1745
  try {
1524
1746
  const put = await broker.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "content" });
1525
1747
  const etag = `"${String(obj(put.object).etag)}"`;
1526
- const ticket = await broker.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "object", method: "download" });
1748
+ const ticket = await broker.dispatch({
1749
+ op: "r2.transfer.create",
1750
+ bucket: "UPLOADS",
1751
+ key: "object",
1752
+ method: "download",
1753
+ });
1527
1754
  const url = `http://127.0.0.1${ticket.url}`;
1528
1755
  for (const value of ["*", etag, `W/${etag}`, `"other", W/${etag}`]) {
1529
1756
  const response = await broker.transfer(new Request(url, { headers: { "if-none-match": value } }));
@@ -1535,7 +1762,10 @@ test("R2 direct download: If-None-Match supports wildcard, lists and weak compar
1535
1762
  expect(fresh.status).toBe(200);
1536
1763
  expect(await fresh.text()).toBe("content");
1537
1764
  expect((await broker.transfer(new Request(url))).status).toBe(410);
1538
- } finally { broker.close(); rmSync(dir, { recursive: true, force: true }); }
1765
+ } finally {
1766
+ broker.close();
1767
+ rmSync(dir, { recursive: true, force: true });
1768
+ }
1539
1769
  });
1540
1770
 
1541
1771
  test("listen(): a service.fetch body streamed in FRAME_V1_MORE frames reaches the upstream as it arrives", async () => {