@sproutboat/wire 0.17.0 → 0.19.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 +70 -0
- package/package.json +2 -2
- package/src/broker.test.ts +370 -97
- package/src/broker.ts +348 -170
- package/src/d1.ts +19 -2
package/src/broker.test.ts
CHANGED
|
@@ -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({
|
|
49
|
-
|
|
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(
|
|
59
|
-
|
|
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 {
|
|
66
|
-
|
|
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(
|
|
82
|
-
|
|
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(
|
|
86
|
-
|
|
87
|
-
|
|
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({
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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 {
|
|
109
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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"])
|
|
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(
|
|
432
|
-
|
|
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({
|
|
435
|
-
|
|
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(
|
|
439
|
-
|
|
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",
|
|
455
|
-
|
|
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) {
|
|
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({
|
|
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({
|
|
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({
|
|
492
|
-
|
|
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(
|
|
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
|
|
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(
|
|
530
|
-
.
|
|
531
|
-
|
|
532
|
-
await
|
|
533
|
-
.
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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(
|
|
541
|
-
|
|
542
|
-
|
|
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",
|
|
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(
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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({
|
|
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(
|
|
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(
|
|
590
|
-
.
|
|
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({
|
|
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}`, {
|
|
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({
|
|
614
|
-
|
|
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(
|
|
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(
|
|
738
|
-
.
|
|
739
|
-
|
|
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 });
|
|
@@ -871,6 +1079,49 @@ test("cronMatches: fields, steps, ranges, lists (UTC)", () => {
|
|
|
871
1079
|
// #74 — a binding wired to an account-level resource stores in its own file
|
|
872
1080
|
// under resourceDir, keyed by id, so the data outlives a "redeploy" (a new
|
|
873
1081
|
// broker with a fresh per-deployment db but the same resourceDir).
|
|
1082
|
+
test("Durable Object SQL: one database per object, beside its project's doDb, D1-strict replies", async () => {
|
|
1083
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-do-sql-"));
|
|
1084
|
+
try {
|
|
1085
|
+
for (const dep of ["dep", "dep2", "dep3"]) mkdirSync(join(root, dep));
|
|
1086
|
+
const bindings = { do: [{ binding: "SQL", className: "SqlObject" }] };
|
|
1087
|
+
const a = "a".repeat(64);
|
|
1088
|
+
const b2 = "b".repeat(64);
|
|
1089
|
+
const sql = (b: ReturnType<typeof createBroker>, file: string, text: string, params: JsonValue[] = []) =>
|
|
1090
|
+
b.dispatch({ op: "do.sql", cls: "SqlObject", file, sql: text, params });
|
|
1091
|
+
const one = createBroker({ db: join(root, "dep", "state.sqlite"), doDb: join(root, "do_p1.sqlite"), bindings });
|
|
1092
|
+
await sql(one, a, "CREATE TABLE t (v, b BLOB)");
|
|
1093
|
+
expect(await sql(one, a, "INSERT INTO t VALUES (?, ?), (?, ?)", [1, { $blob: "0102" }, 2, null])).toMatchObject({
|
|
1094
|
+
meta: { changes: 2 },
|
|
1095
|
+
});
|
|
1096
|
+
expect(await sql(one, a, "SELECT v, b FROM t ORDER BY v")).toMatchObject({
|
|
1097
|
+
columns: ["v", "b"],
|
|
1098
|
+
rows: [
|
|
1099
|
+
[1, { $blob: "0102" }],
|
|
1100
|
+
[2, null],
|
|
1101
|
+
],
|
|
1102
|
+
});
|
|
1103
|
+
// Another object has its own database.
|
|
1104
|
+
await expect(sql(one, b2, "SELECT v FROM t")).rejects.toThrow("D1_ERROR: no such table: t");
|
|
1105
|
+
await expect(sql(one, a, "SELEKT 1")).rejects.toThrow('near "SELEKT": syntax error');
|
|
1106
|
+
await expect(sql(one, "../x", "SELECT 1")).rejects.toThrow("invalid Durable Object SQL database");
|
|
1107
|
+
await expect(one.dispatch({ op: "do.sql", cls: "Nope", file: a, sql: "SELECT 1", params: [] })).rejects.toThrow(
|
|
1108
|
+
"not bound",
|
|
1109
|
+
);
|
|
1110
|
+
one.close();
|
|
1111
|
+
expect(existsSync(join(root, "do_p1.do-sql", "SqlObject", `${a}.sqlite`))).toBe(true);
|
|
1112
|
+
|
|
1113
|
+
// A redeploy keeps it; another project with the same class and id does not see it.
|
|
1114
|
+
const again = createBroker({ db: join(root, "dep2", "state.sqlite"), doDb: join(root, "do_p1.sqlite"), bindings });
|
|
1115
|
+
expect((await sql(again, a, "SELECT count(*) AS n FROM t")).rows).toEqual([[2]]);
|
|
1116
|
+
again.close();
|
|
1117
|
+
const other = createBroker({ db: join(root, "dep3", "state.sqlite"), doDb: join(root, "do_p2.sqlite"), bindings });
|
|
1118
|
+
await expect(sql(other, a, "SELECT v FROM t")).rejects.toThrow("no such table: t");
|
|
1119
|
+
other.close();
|
|
1120
|
+
} finally {
|
|
1121
|
+
rmSync(root, { recursive: true, force: true });
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
874
1125
|
test("#207: Durable Object storage and alarms in doDb outlive a redeploy", async () => {
|
|
875
1126
|
const root = mkdtempSync(join(tmpdir(), "sb-broker-do-"));
|
|
876
1127
|
try {
|
|
@@ -1482,10 +1733,20 @@ test("TCP: large replies survive backpressure and stay ordered with a pipelined
|
|
|
1482
1733
|
const second = encodeFrame({ ok: true, id: 2 });
|
|
1483
1734
|
let calls = 0;
|
|
1484
1735
|
const broker = make();
|
|
1485
|
-
const server = listen(
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1736
|
+
const server = listen(
|
|
1737
|
+
{
|
|
1738
|
+
...broker,
|
|
1739
|
+
async handleFrame() {
|
|
1740
|
+
if (calls++ === 0) {
|
|
1741
|
+
await Bun.sleep(10);
|
|
1742
|
+
return first;
|
|
1743
|
+
}
|
|
1744
|
+
return second;
|
|
1745
|
+
},
|
|
1746
|
+
},
|
|
1747
|
+
"127.0.0.1",
|
|
1748
|
+
0,
|
|
1749
|
+
);
|
|
1489
1750
|
const socket = createConnection({ host: "127.0.0.1", port: server.port });
|
|
1490
1751
|
let resume: ReturnType<typeof setTimeout> | undefined;
|
|
1491
1752
|
let next: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -1497,7 +1758,8 @@ test("TCP: large replies survive backpressure and stay ordered with a pipelined
|
|
|
1497
1758
|
timeout = setTimeout(() => reject(new Error("large broker reply stalled or truncated")), 3000);
|
|
1498
1759
|
socket.on("error", reject);
|
|
1499
1760
|
socket.on("data", (chunk: Buffer) => {
|
|
1500
|
-
chunks.push(chunk);
|
|
1761
|
+
chunks.push(chunk);
|
|
1762
|
+
size += chunk.length;
|
|
1501
1763
|
if (size >= first.length + second.length) done(Buffer.concat(chunks));
|
|
1502
1764
|
});
|
|
1503
1765
|
socket.on("connect", () => {
|
|
@@ -1512,8 +1774,11 @@ test("TCP: large replies survive backpressure and stay ordered with a pipelined
|
|
|
1512
1774
|
expect(received.subarray(first.length).equals(second)).toBe(true);
|
|
1513
1775
|
expect(calls).toBe(2);
|
|
1514
1776
|
} finally {
|
|
1515
|
-
clearTimeout(timeout);
|
|
1516
|
-
|
|
1777
|
+
clearTimeout(timeout);
|
|
1778
|
+
clearTimeout(next);
|
|
1779
|
+
clearTimeout(resume);
|
|
1780
|
+
socket.destroy();
|
|
1781
|
+
server.stop();
|
|
1517
1782
|
}
|
|
1518
1783
|
}, 10_000);
|
|
1519
1784
|
|
|
@@ -1523,7 +1788,12 @@ test("R2 direct download: If-None-Match supports wildcard, lists and weak compar
|
|
|
1523
1788
|
try {
|
|
1524
1789
|
const put = await broker.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "content" });
|
|
1525
1790
|
const etag = `"${String(obj(put.object).etag)}"`;
|
|
1526
|
-
const ticket = await broker.dispatch({
|
|
1791
|
+
const ticket = await broker.dispatch({
|
|
1792
|
+
op: "r2.transfer.create",
|
|
1793
|
+
bucket: "UPLOADS",
|
|
1794
|
+
key: "object",
|
|
1795
|
+
method: "download",
|
|
1796
|
+
});
|
|
1527
1797
|
const url = `http://127.0.0.1${ticket.url}`;
|
|
1528
1798
|
for (const value of ["*", etag, `W/${etag}`, `"other", W/${etag}`]) {
|
|
1529
1799
|
const response = await broker.transfer(new Request(url, { headers: { "if-none-match": value } }));
|
|
@@ -1535,7 +1805,10 @@ test("R2 direct download: If-None-Match supports wildcard, lists and weak compar
|
|
|
1535
1805
|
expect(fresh.status).toBe(200);
|
|
1536
1806
|
expect(await fresh.text()).toBe("content");
|
|
1537
1807
|
expect((await broker.transfer(new Request(url))).status).toBe(410);
|
|
1538
|
-
} finally {
|
|
1808
|
+
} finally {
|
|
1809
|
+
broker.close();
|
|
1810
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1811
|
+
}
|
|
1539
1812
|
});
|
|
1540
1813
|
|
|
1541
1814
|
test("listen(): a service.fetch body streamed in FRAME_V1_MORE frames reaches the upstream as it arrives", async () => {
|