@sproutboat/wire 0.6.0 → 0.8.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 +34 -0
- package/package.json +1 -1
- package/src/broker.test.ts +321 -0
- package/src/broker.ts +838 -61
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7287d9b: KV: native key expiration (baronunread/sproutboat#58). `env.KV.put(key, value, { expirationTtl })` / `{ expiration }` now match the Cloudflare Workers contract: `expirationTtl` is seconds from now (rejected below 60, matching CF), `expiration` is an absolute unix-seconds timestamp. An expired key reads as absent from `get()` and is omitted from `list()`; the row is hard-deleted lazily the next time it's touched by a get. No new transport op — both `expirationTtl` and `expiration` are optional fields on the existing `kv.put` frame, so an older broker/prelude pair that ignores them keeps working exactly as before.
|
|
8
|
+
|
|
9
|
+
## 0.7.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 493e4e3: Add short-lived R2 direct-transfer tickets for broker-backed sprouts. A
|
|
14
|
+
loopback HTTP listener streams PUT bodies to file-backed R2 storage, and serves
|
|
15
|
+
GET and HEAD requests with single-range support without placing object bytes
|
|
16
|
+
in a binding frame.
|
|
17
|
+
|
|
18
|
+
Tickets are random bearer capabilities with a byte limit, expiry, one-use
|
|
19
|
+
claim, and optional SHA-256 verification. Native standalone direct transfers
|
|
20
|
+
remain unavailable until the native HTTP ingress can stream request bodies.
|
|
21
|
+
|
|
22
|
+
For broker deployments, ticket issuance now reserves account-level capacity
|
|
23
|
+
across every R2 resource. Reservations survive redeploys, expired and orphaned
|
|
24
|
+
temporary uploads are reclaimed, and the broker retains a configurable free
|
|
25
|
+
disk floor with capacity and rejected-transfer counters for the dashboard.
|
|
26
|
+
- 5acb93c: Add Cloudflare-shaped resumable R2 multipart uploads. Parts are persisted
|
|
27
|
+
independently, and standalone completion assembles the final object through a
|
|
28
|
+
fixed 64 KiB native buffer to keep memory bounded by part size rather than total
|
|
29
|
+
object size.
|
|
30
|
+
|
|
31
|
+
Unfinished multipart uploads expire after seven days and their persisted part
|
|
32
|
+
files are removed automatically.
|
|
33
|
+
|
|
34
|
+
Store completed objects as immutable blob generations so a failed overwrite
|
|
35
|
+
cannot expose new bytes under the previous metadata.
|
|
36
|
+
|
|
3
37
|
## 0.6.0
|
|
4
38
|
|
|
5
39
|
### Minor Changes
|
package/package.json
CHANGED
package/src/broker.test.ts
CHANGED
|
@@ -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,
|
|
@@ -49,6 +50,26 @@ test("KV put / get / list / delete, scoped to a bound namespace", async () => {
|
|
|
49
50
|
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
|
|
50
51
|
});
|
|
51
52
|
|
|
53
|
+
test("KV expirationTtl under 60 seconds is rejected, matching CF", async () => {
|
|
54
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
55
|
+
await expect(b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v", expirationTtl: 59 })).rejects.toThrow(
|
|
56
|
+
"at least 60 seconds",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a key past its expiration reads as absent and is omitted from list", async () => {
|
|
61
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
62
|
+
const past = Math.floor((Date.now() - 1000) / 1000);
|
|
63
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "expired", value: "v", expiration: past });
|
|
64
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "fresh", value: "v", expirationTtl: 3600 });
|
|
65
|
+
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "expired" })).toEqual({
|
|
66
|
+
ok: true,
|
|
67
|
+
found: false,
|
|
68
|
+
value: null,
|
|
69
|
+
});
|
|
70
|
+
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "" })).toEqual({ ok: true, keys: ["fresh"] });
|
|
71
|
+
});
|
|
72
|
+
|
|
52
73
|
test("an unbound KV namespace is rejected", async () => {
|
|
53
74
|
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
54
75
|
await expect(b.dispatch({ op: "kv.put", ns: "OTHER", key: "k", value: "v" })).rejects.toThrow("not bound");
|
|
@@ -159,6 +180,180 @@ test("R2: put / get / head / list / delete on a bound bucket", async () => {
|
|
|
159
180
|
await expect(b.dispatch({ op: "r2.put", bucket: "NOPE", key: "x", body: "y" })).rejects.toThrow("not bound");
|
|
160
181
|
});
|
|
161
182
|
|
|
183
|
+
test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged download", async () => {
|
|
184
|
+
const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-"));
|
|
185
|
+
try {
|
|
186
|
+
const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
|
|
187
|
+
const upload = await b.dispatch({
|
|
188
|
+
op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "upload",
|
|
189
|
+
maxBytes: 100, sha256: createHash("sha256").update("abcdef").digest("hex"),
|
|
190
|
+
httpMetadata: { contentType: "application/octet-stream" },
|
|
191
|
+
});
|
|
192
|
+
const url = `http://127.0.0.1${upload.url}`;
|
|
193
|
+
const bytes = new TextEncoder().encode("abcdef");
|
|
194
|
+
const body = new ReadableStream<Uint8Array>({
|
|
195
|
+
pull(controller) { controller.enqueue(bytes.subarray(0, 3)); controller.enqueue(bytes.subarray(3)); controller.close(); },
|
|
196
|
+
});
|
|
197
|
+
// SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
|
|
198
|
+
const put = await b.transfer(new Request(url, { method: "PUT", body, duplex: "half" } as RequestInit));
|
|
199
|
+
expect(put.status).toBe(201);
|
|
200
|
+
expect(await b.transfer(new Request(url, { method: "PUT", body: "again" }))).toMatchObject({ status: 410 });
|
|
201
|
+
const head = await b.dispatch({ op: "r2.head", bucket: "UPLOADS", key: "large.bin" });
|
|
202
|
+
expect(obj(head.object).size).toBe(6);
|
|
203
|
+
|
|
204
|
+
const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large.bin", method: "download" });
|
|
205
|
+
const get = await b.transfer(new Request(`http://127.0.0.1${download.url}`, { headers: { range: "bytes=2-4" } }));
|
|
206
|
+
expect(get.status).toBe(206);
|
|
207
|
+
expect(get.headers.get("content-range")).toBe("bytes 2-4/6");
|
|
208
|
+
expect(get.headers.get("content-type")).toBe("application/octet-stream");
|
|
209
|
+
expect(await get.text()).toBe("cde");
|
|
210
|
+
expect((await b.transfer(new Request(`http://127.0.0.1${download.url}`))).status).toBe(410);
|
|
211
|
+
} finally {
|
|
212
|
+
rmSync(root, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("R2 direct transfer: invalid ranges and oversized bodies never replace an object", async () => {
|
|
217
|
+
const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-errors-"));
|
|
218
|
+
try {
|
|
219
|
+
const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
|
|
220
|
+
await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "k", body: "old" });
|
|
221
|
+
const upload = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
|
|
222
|
+
const url = `http://127.0.0.1${upload.url}`;
|
|
223
|
+
expect((await b.transfer(new Request(url, { method: "PUT", body: "large" }))).status).toBe(413);
|
|
224
|
+
expect((await b.transfer(new Request(url, { method: "PUT", body: "ok" }))).status).toBe(410);
|
|
225
|
+
const retry = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "upload", maxBytes: 2 });
|
|
226
|
+
expect((await b.transfer(new Request(`http://127.0.0.1${retry.url}`, { method: "PUT", body: "ok" }))).status).toBe(201);
|
|
227
|
+
const after = await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "k" });
|
|
228
|
+
expect(after.body).toBe("ok");
|
|
229
|
+
const dl = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "k", method: "download" });
|
|
230
|
+
expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`, { headers: { range: "bytes=1-0" } }))).status).toBe(416);
|
|
231
|
+
expect((await b.transfer(new Request(`http://127.0.0.1${dl.url}`))).status).toBe(200);
|
|
232
|
+
} finally {
|
|
233
|
+
rmSync(root, { recursive: true, force: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("R2 direct transfer: account quota includes objects and shared in-flight reservations", async () => {
|
|
238
|
+
const root = mkdtempSync(join(tmpdir(), "sb-r2-quota-"));
|
|
239
|
+
try {
|
|
240
|
+
const resourceDir = join(root, "resources");
|
|
241
|
+
const bindings = {
|
|
242
|
+
r2: ["UPLOADS"],
|
|
243
|
+
resources: { UPLOADS: { kind: "r2" as const, id: "uploads" } },
|
|
244
|
+
};
|
|
245
|
+
const config = {
|
|
246
|
+
resourceDir,
|
|
247
|
+
ownerId: "owner-1",
|
|
248
|
+
r2QuotaBytes: 12,
|
|
249
|
+
r2ResourceIds: ["uploads"],
|
|
250
|
+
bindings,
|
|
251
|
+
};
|
|
252
|
+
const first = make({ ...config, db: join(root, "first.sqlite") });
|
|
253
|
+
const second = make({ ...config, db: join(root, "second.sqlite") });
|
|
254
|
+
|
|
255
|
+
await first.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "existing", body: "four" });
|
|
256
|
+
const resource = new Database(join(resourceDir, "uploads.sqlite"));
|
|
257
|
+
try {
|
|
258
|
+
resource.query("INSERT INTO r2_part (bucket, key, upload_id, part_number, size, etag) VALUES (?1, ?2, ?3, 1, 2, ?4)")
|
|
259
|
+
.run("uploads", "partial", "upload-1", "part");
|
|
260
|
+
} finally {
|
|
261
|
+
resource.close();
|
|
262
|
+
}
|
|
263
|
+
await expect(first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 7 }))
|
|
264
|
+
.rejects.toThrow("quota");
|
|
265
|
+
const ticket = await first.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "large", method: "upload", maxBytes: 6 });
|
|
266
|
+
await expect(second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 }))
|
|
267
|
+
.rejects.toThrow("quota");
|
|
268
|
+
|
|
269
|
+
expect((await first.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "five!" }))).status).toBe(201);
|
|
270
|
+
const replacement = await second.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "other", method: "upload", maxBytes: 1 });
|
|
271
|
+
expect(replacement.ok).toBe(true);
|
|
272
|
+
const ledger = new Database(join(resourceDir, "r2-quota.sqlite"), { readonly: true });
|
|
273
|
+
try {
|
|
274
|
+
expect(ledger.query<{ tickets_issued: number; rejected_quota: number }, [string]>(
|
|
275
|
+
"SELECT tickets_issued, rejected_quota FROM r2_transfer_metric WHERE owner = ?",
|
|
276
|
+
).get("owner-1")).toEqual({ tickets_issued: 2, rejected_quota: 2 });
|
|
277
|
+
} finally {
|
|
278
|
+
ledger.close();
|
|
279
|
+
}
|
|
280
|
+
} finally {
|
|
281
|
+
rmSync(root, { recursive: true, force: true });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("R2 direct transfer: failed uploads release their reservation and retain the disk floor", async () => {
|
|
286
|
+
const root = mkdtempSync(join(tmpdir(), "sb-r2-quota-release-"));
|
|
287
|
+
try {
|
|
288
|
+
const config = {
|
|
289
|
+
db: join(root, "state.sqlite"),
|
|
290
|
+
resourceDir: join(root, "resources"),
|
|
291
|
+
ownerId: "owner-1",
|
|
292
|
+
r2QuotaBytes: 5,
|
|
293
|
+
r2ResourceIds: ["uploads"],
|
|
294
|
+
bindings: { r2: ["UPLOADS"], resources: { UPLOADS: { kind: "r2" as const, id: "uploads" } } },
|
|
295
|
+
};
|
|
296
|
+
const b = make(config);
|
|
297
|
+
const ticket = await b.dispatch({
|
|
298
|
+
op: "r2.transfer.create", bucket: "UPLOADS", key: "bad", method: "upload", maxBytes: 5,
|
|
299
|
+
sha256: createHash("sha256").update("expected").digest("hex"),
|
|
300
|
+
});
|
|
301
|
+
expect((await b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "wrong" }))).status).toBe(422);
|
|
302
|
+
expect((await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "retry", method: "upload", maxBytes: 5 })).ok).toBe(true);
|
|
303
|
+
|
|
304
|
+
const floor = make({ ...config, db: join(root, "floor.sqlite"), r2QuotaBytes: 100, r2MinFreeBytes: 10, freeBytes: () => 11 });
|
|
305
|
+
await expect(floor.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "no-space", method: "upload", maxBytes: 2 }))
|
|
306
|
+
.rejects.toThrow("temporarily unavailable");
|
|
307
|
+
|
|
308
|
+
let freeBytes = 20;
|
|
309
|
+
const duringUpload = make({
|
|
310
|
+
...config,
|
|
311
|
+
db: join(root, "during-upload.sqlite"),
|
|
312
|
+
r2QuotaBytes: 100,
|
|
313
|
+
r2MinFreeBytes: 10,
|
|
314
|
+
freeBytes: () => freeBytes,
|
|
315
|
+
});
|
|
316
|
+
const unknownLength = await duringUpload.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "during", method: "upload", maxBytes: 5 });
|
|
317
|
+
freeBytes = 10;
|
|
318
|
+
expect((await duringUpload.transfer(new Request(`http://127.0.0.1${unknownLength.url}`, { method: "PUT", body: "x" }))).status).toBe(507);
|
|
319
|
+
|
|
320
|
+
const abandoned = join(config.resourceDir, "r2-blobs", "a".repeat(24) + ".upload.tmp");
|
|
321
|
+
mkdirSync(join(config.resourceDir, "r2-blobs"), { recursive: true });
|
|
322
|
+
writeFileSync(abandoned, "partial");
|
|
323
|
+
await expect(b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "quota-full", method: "upload", maxBytes: 1 }))
|
|
324
|
+
.rejects.toThrow("quota");
|
|
325
|
+
expect(existsSync(abandoned)).toBe(false);
|
|
326
|
+
} finally {
|
|
327
|
+
rmSync(root, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("R2 direct transfer: live HTTP listener accepts a chunked binary PUT", async () => {
|
|
332
|
+
const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-http-"));
|
|
333
|
+
const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] } });
|
|
334
|
+
const server = listenTransfers(b, "127.0.0.1", 0);
|
|
335
|
+
try {
|
|
336
|
+
const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "upload", maxBytes: 2 * 1024 * 1024 });
|
|
337
|
+
let chunks = 0;
|
|
338
|
+
const body = new ReadableStream<Uint8Array>({
|
|
339
|
+
pull(controller) {
|
|
340
|
+
if (chunks++ < 16) controller.enqueue(new Uint8Array(64 * 1024).fill(chunks));
|
|
341
|
+
else controller.close();
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
// SAFETY: Bun accepts duplex for streamed bodies; the bundled DOM type omits this runtime field.
|
|
345
|
+
const uploaded = await fetch(`http://127.0.0.1:${server.port}${ticket.url}`, { method: "PUT", body, duplex: "half" } as RequestInit);
|
|
346
|
+
expect(uploaded.status).toBe(201);
|
|
347
|
+
const download = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "stream.bin", method: "download" });
|
|
348
|
+
const response = await fetch(`http://127.0.0.1:${server.port}${download.url}`, { headers: { range: "bytes=65535-65537" } });
|
|
349
|
+
expect(response.status).toBe(206);
|
|
350
|
+
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([1, 2, 2]));
|
|
351
|
+
} finally {
|
|
352
|
+
server.stop();
|
|
353
|
+
rmSync(root, { recursive: true, force: true });
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
162
357
|
test("R2: list paginates with a cursor", async () => {
|
|
163
358
|
const b = make({ bindings: { r2: ["ASSETS"] } });
|
|
164
359
|
for (let i = 0; i < 5; i++) await b.dispatch({ op: "r2.put", bucket: "ASSETS", key: `k${i}`, body: "x" });
|
|
@@ -169,6 +364,119 @@ test("R2: list paginates with a cursor", async () => {
|
|
|
169
364
|
expect(arr(p2.objects).map((o) => obj(o).key)).toEqual(["k2", "k3"]);
|
|
170
365
|
});
|
|
171
366
|
|
|
367
|
+
test("R2 multipart: binary parts complete without joining the full object in the broker heap", async () => {
|
|
368
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-multipart-"));
|
|
369
|
+
try {
|
|
370
|
+
const b = make({ db: join(root, "state.sqlite"), bindings: { r2: ["UPLOADS"] }, token: "tok" });
|
|
371
|
+
const created = await b.handleFrame(
|
|
372
|
+
encodeV1({
|
|
373
|
+
v: 1,
|
|
374
|
+
id: 1,
|
|
375
|
+
token: "tok",
|
|
376
|
+
op: "r2.multipart.create",
|
|
377
|
+
bucket: "UPLOADS",
|
|
378
|
+
key: "archive.bin",
|
|
379
|
+
customMetadata: { source: "test" },
|
|
380
|
+
}),
|
|
381
|
+
);
|
|
382
|
+
const uploadId = String(decodeV1Frame(created).json.uploadId);
|
|
383
|
+
const first = new Uint8Array([0, 1, 2, 255]);
|
|
384
|
+
const second = new Uint8Array([4, 5]);
|
|
385
|
+
const part1 = decodeV1Frame(
|
|
386
|
+
await b.handleFrame(
|
|
387
|
+
encodeV1(
|
|
388
|
+
{
|
|
389
|
+
v: 1,
|
|
390
|
+
id: 2,
|
|
391
|
+
token: "tok",
|
|
392
|
+
op: "r2.multipart.put",
|
|
393
|
+
bucket: "UPLOADS",
|
|
394
|
+
key: "archive.bin",
|
|
395
|
+
uploadId,
|
|
396
|
+
partNumber: 1,
|
|
397
|
+
},
|
|
398
|
+
first,
|
|
399
|
+
),
|
|
400
|
+
),
|
|
401
|
+
).json;
|
|
402
|
+
const part2 = decodeV1Frame(
|
|
403
|
+
await b.handleFrame(
|
|
404
|
+
encodeV1(
|
|
405
|
+
{
|
|
406
|
+
v: 1,
|
|
407
|
+
id: 3,
|
|
408
|
+
token: "tok",
|
|
409
|
+
op: "r2.multipart.put",
|
|
410
|
+
bucket: "UPLOADS",
|
|
411
|
+
key: "archive.bin",
|
|
412
|
+
uploadId,
|
|
413
|
+
partNumber: 2,
|
|
414
|
+
},
|
|
415
|
+
second,
|
|
416
|
+
),
|
|
417
|
+
),
|
|
418
|
+
).json;
|
|
419
|
+
const completed = await b.dispatch({
|
|
420
|
+
op: "r2.multipart.complete",
|
|
421
|
+
bucket: "UPLOADS",
|
|
422
|
+
key: "archive.bin",
|
|
423
|
+
uploadId,
|
|
424
|
+
parts: [obj(part1.part), obj(part2.part)],
|
|
425
|
+
});
|
|
426
|
+
expect(obj(completed.object)).toMatchObject({ key: "archive.bin", size: 6, customMetadata: { source: "test" } });
|
|
427
|
+
|
|
428
|
+
const got = decodeV1Frame(
|
|
429
|
+
await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "r2.get", bucket: "UPLOADS", key: "archive.bin" })),
|
|
430
|
+
);
|
|
431
|
+
expect(Buffer.from(got.bytes).equals(Buffer.from([...first, ...second]))).toBe(true);
|
|
432
|
+
expect(existsSync(join(root, "r2-blobs"))).toBe(true);
|
|
433
|
+
} finally {
|
|
434
|
+
rmSync(root, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("R2 overwrite keeps the committed generation visible when metadata update fails", async () => {
|
|
439
|
+
const root = mkdtempSync(join(tmpdir(), "sb-broker-r2-atomic-"));
|
|
440
|
+
try {
|
|
441
|
+
const databasePath = join(root, "state.sqlite");
|
|
442
|
+
const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
|
|
443
|
+
await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old-bytes" });
|
|
444
|
+
const control = new Database(databasePath);
|
|
445
|
+
control.exec(
|
|
446
|
+
"CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END",
|
|
447
|
+
);
|
|
448
|
+
await expect(
|
|
449
|
+
b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "new-and-longer-bytes" }),
|
|
450
|
+
).rejects.toThrow("forced metadata failure");
|
|
451
|
+
expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({
|
|
452
|
+
found: true,
|
|
453
|
+
body: "old-bytes",
|
|
454
|
+
object: { size: 9 },
|
|
455
|
+
});
|
|
456
|
+
control.close();
|
|
457
|
+
} finally {
|
|
458
|
+
rmSync(root, { recursive: true, force: true });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("R2 direct overwrite also preserves the old generation on metadata failure", async () => {
|
|
463
|
+
const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-atomic-"));
|
|
464
|
+
try {
|
|
465
|
+
const databasePath = join(root, "state.sqlite");
|
|
466
|
+
const b = make({ db: databasePath, bindings: { r2: ["UPLOADS"] } });
|
|
467
|
+
await b.dispatch({ op: "r2.put", bucket: "UPLOADS", key: "object", body: "old" });
|
|
468
|
+
const control = new Database(databasePath);
|
|
469
|
+
control.exec("CREATE TRIGGER fail_r2_metadata BEFORE UPDATE ON r2 BEGIN SELECT RAISE(ABORT, 'forced metadata failure'); END");
|
|
470
|
+
const ticket = await b.dispatch({ op: "r2.transfer.create", bucket: "UPLOADS", key: "object", method: "upload" });
|
|
471
|
+
await expect(b.transfer(new Request(`http://127.0.0.1${ticket.url}`, { method: "PUT", body: "new" })))
|
|
472
|
+
.rejects.toThrow("forced metadata failure");
|
|
473
|
+
expect(await b.dispatch({ op: "r2.get", bucket: "UPLOADS", key: "object" })).toMatchObject({ found: true, body: "old" });
|
|
474
|
+
control.close();
|
|
475
|
+
} finally {
|
|
476
|
+
rmSync(root, { recursive: true, force: true });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
172
480
|
test("Durable Object storage: get / put / delete / list / deleteAll scoped to (class, id)", async () => {
|
|
173
481
|
const b = make({ bindings: { do: [{ binding: "COUNTER", className: "Counter" }] } });
|
|
174
482
|
expect(await b.dispatch({ op: "do.storage.get", cls: "Counter", id: "a", key: "n" })).toMatchObject({ found: false });
|
|
@@ -714,6 +1022,19 @@ async function v1(
|
|
|
714
1022
|
};
|
|
715
1023
|
}
|
|
716
1024
|
|
|
1025
|
+
type V1Reply = { json: JsonObject; bytes: Uint8Array };
|
|
1026
|
+
|
|
1027
|
+
function decodeV1Frame(frame: Buffer): V1Reply {
|
|
1028
|
+
const payloadLength = frame.readUInt32LE(0);
|
|
1029
|
+
const payload = frame.subarray(4, 4 + payloadLength);
|
|
1030
|
+
expect(payload[0]).toBe(1);
|
|
1031
|
+
const jsonLength = payload.readUInt32LE(1);
|
|
1032
|
+
return {
|
|
1033
|
+
json: obj(parseJsonValue(payload.subarray(5, 5 + jsonLength).toString("utf8"))),
|
|
1034
|
+
bytes: new Uint8Array(payload.subarray(5 + jsonLength)),
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
|
|
717
1038
|
test("v1: an object body round-trips as bytes, not as escaped JSON", async () => {
|
|
718
1039
|
const b = make({ bindings: { r2: ["UP"] }, token: "tok" });
|
|
719
1040
|
const server = listen(b, "127.0.0.1", 0);
|