@sproutboat/wire 0.15.0 → 0.16.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 +24 -0
- package/package.json +2 -2
- package/src/broker.test.ts +22 -0
- package/src/broker.ts +47 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.16.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9be9c74: R2 matches workerd from compatibility date `2026-10-26`:
|
|
8
|
+
|
|
9
|
+
- `get`, `put`, `head`, `delete`, `list` and `createMultipartUpload` return Promises, and errors reject them.
|
|
10
|
+
- An object's `etag` is the MD5 of its bytes, with `checksums.md5`. Objects written before keep their old etag.
|
|
11
|
+
- Objects have `uploaded` as a `Date`, `version`, `storageClass`, `range` and `writeHttpMetadata(headers)`. A body has `body` (a `ReadableStream`), `bodyUsed`, `text()`, `json()`, `arrayBuffer()` and `blob()`.
|
|
12
|
+
- `get(key, { range, onlyIf })` takes `{ offset, length }`, `{ suffix }` or a `Range` header, and `etagMatches`, `etagDoesNotMatch`, `uploadedBefore`, `uploadedAfter` or `If-*` headers. When the condition fails it returns the object without a body.
|
|
13
|
+
- `put(key, value, { onlyIf, httpMetadata })` takes a `ReadableStream` or a `Blob`, and `httpMetadata` as `Headers`. When the condition fails it returns `null`.
|
|
14
|
+
- `delete([keys])` deletes several keys.
|
|
15
|
+
- `list({ prefix, limit, cursor, delimiter, include })` pages at up to 1,000 entries, groups keys into `delimitedPrefixes`, and returns `httpMetadata` and `customMetadata` only when `include` asks for them.
|
|
16
|
+
- A value of the wrong type and a key longer than 1,024 bytes fail with R2's errors.
|
|
17
|
+
|
|
18
|
+
Earlier dates keep the synchronous shim. One change applies at every date: **a `list()` prefix now matches exactly.** It used SQL `LIKE`, so it ignored case and read `_` and `%` as wildcards. Both transports store an object's MD5 in a new column, added to existing stores when they open.
|
|
19
|
+
|
|
20
|
+
Against miniflare 4.20260730, `conformance/bindings/r2.js` in sproutd passes 48/48 at `2026-10-26` (16/48 before).
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- Updated dependencies [9be9c74]
|
|
25
|
+
- @sproutboat/runtime@0.27.0
|
|
26
|
+
|
|
3
27
|
## 0.15.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,6 +23,6 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@sproutboat/assets": "^0.3.0",
|
|
26
|
-
"@sproutboat/runtime": "^0.
|
|
26
|
+
"@sproutboat/runtime": "^0.27.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/broker.test.ts
CHANGED
|
@@ -424,6 +424,28 @@ test("R2: put / get / head / list / delete on a bound bucket", async () => {
|
|
|
424
424
|
await expect(b.dispatch({ op: "r2.put", bucket: "NOPE", key: "x", body: "y" })).rejects.toThrow("not bound");
|
|
425
425
|
});
|
|
426
426
|
|
|
427
|
+
test("R2: exact list prefix at every date; md5, paged list and delimiter for the shim from 2026-10-26", async () => {
|
|
428
|
+
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" });
|
|
430
|
+
// 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"]);
|
|
433
|
+
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");
|
|
436
|
+
// A stale etag (the object was written again since) records nothing.
|
|
437
|
+
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: "/" });
|
|
440
|
+
const first = await page("");
|
|
441
|
+
expect(arr(first.objects).map((o) => obj(o).key)).toEqual(["AB", "a_x", "ab"]);
|
|
442
|
+
expect(first).toMatchObject({ truncated: true, cursor: "ab", delimitedPrefixes: [] });
|
|
443
|
+
const second = await page("ab");
|
|
444
|
+
expect(arr(second.objects).map((o) => obj(o).key)).toEqual(["f"]);
|
|
445
|
+
expect(second).toMatchObject({ truncated: true, delimitedPrefixes: ["d/", "e/"] });
|
|
446
|
+
expect(await page(String(second.cursor))).toMatchObject({ objects: [{ key: "m" }], truncated: false, cursor: "" });
|
|
447
|
+
});
|
|
448
|
+
|
|
427
449
|
test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged download", async () => {
|
|
428
450
|
const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-"));
|
|
429
451
|
try {
|
package/src/broker.ts
CHANGED
|
@@ -150,6 +150,7 @@ type R2Row = {
|
|
|
150
150
|
http_json: string;
|
|
151
151
|
custom_json: string;
|
|
152
152
|
blob_id: string | null;
|
|
153
|
+
md5?: string | null;
|
|
153
154
|
};
|
|
154
155
|
type R2UploadRow = {
|
|
155
156
|
key: string;
|
|
@@ -317,6 +318,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
317
318
|
);
|
|
318
319
|
const r2Columns = conn.query<{ name: string }, []>("PRAGMA table_info(r2)").all();
|
|
319
320
|
if (!r2Columns.some((column) => column.name === "blob_id")) conn.exec("ALTER TABLE r2 ADD COLUMN blob_id TEXT");
|
|
321
|
+
// The MD5 of an object's bytes, its etag from compatibility date 2026-10-26 (r2.md5).
|
|
322
|
+
if (!r2Columns.some((column) => column.name === "md5")) conn.exec("ALTER TABLE r2 ADD COLUMN md5 TEXT");
|
|
320
323
|
conn.exec(
|
|
321
324
|
"CREATE TABLE IF NOT EXISTS r2_upload (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
|
|
322
325
|
"uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
|
|
@@ -1095,6 +1098,38 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1095
1098
|
return r2Row(row);
|
|
1096
1099
|
};
|
|
1097
1100
|
|
|
1101
|
+
// One page of r2.list for the shim from 2026-10-26: at most LIMIT entries after CURSOR,
|
|
1102
|
+
// keys under PREFIX, those holding DELIMITER after it grouped into delimitedPrefixes (a
|
|
1103
|
+
// group counts as one entry). Same rules as the embedded transport's __sbR2ListPage.
|
|
1104
|
+
// ponytail: with a delimiter every key after the cursor is read; an index on the
|
|
1105
|
+
// delimited prefix if buckets grow past ~100k keys.
|
|
1106
|
+
const r2ListPage = (store: Database, bucket: string, prefix: string, cursor: string, limit: number, delimiter: string) => {
|
|
1107
|
+
const rows = store
|
|
1108
|
+
.query<R2Row, [string, string, string]>(
|
|
1109
|
+
"SELECT * FROM r2 WHERE bucket = ?1 AND substr(key, 1, length(?2)) = ?2 AND key > ?3 ORDER BY key" +
|
|
1110
|
+
(delimiter ? "" : ` LIMIT ${limit + 1}`),
|
|
1111
|
+
)
|
|
1112
|
+
.all(bucket, prefix, cursor);
|
|
1113
|
+
const objects: ReturnType<typeof r2Row>[] = [];
|
|
1114
|
+
const delimitedPrefixes: string[] = [];
|
|
1115
|
+
let last = "";
|
|
1116
|
+
let truncated = false;
|
|
1117
|
+
for (const row of rows) {
|
|
1118
|
+
const at = delimiter ? row.key.indexOf(delimiter, prefix.length) : -1;
|
|
1119
|
+
const group = at < 0 ? "" : row.key.slice(0, at + delimiter.length);
|
|
1120
|
+
if (!group || delimitedPrefixes[delimitedPrefixes.length - 1] !== group) {
|
|
1121
|
+
if (objects.length + delimitedPrefixes.length >= limit) {
|
|
1122
|
+
truncated = true;
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
if (group) delimitedPrefixes.push(group);
|
|
1126
|
+
else objects.push(r2Row(row));
|
|
1127
|
+
}
|
|
1128
|
+
last = row.key;
|
|
1129
|
+
}
|
|
1130
|
+
return { ok: true, objects, delimitedPrefixes, truncated, cursor: truncated ? last : "" };
|
|
1131
|
+
};
|
|
1132
|
+
|
|
1098
1133
|
const r2Row = (r: R2Row) => ({
|
|
1099
1134
|
key: r.key,
|
|
1100
1135
|
size: r.size,
|
|
@@ -1102,6 +1137,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1102
1137
|
uploaded: r.uploaded,
|
|
1103
1138
|
httpMetadata: parseJsonValue(r.http_json),
|
|
1104
1139
|
customMetadata: parseJsonValue(r.custom_json),
|
|
1140
|
+
md5: r.md5 ?? null,
|
|
1105
1141
|
});
|
|
1106
1142
|
|
|
1107
1143
|
/**
|
|
@@ -1543,14 +1579,22 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1543
1579
|
if (row) r2DeleteBlob(store, bucket, key, row.blob_id);
|
|
1544
1580
|
return { ok: true };
|
|
1545
1581
|
}
|
|
1582
|
+
case "r2.md5": {
|
|
1583
|
+
// Recorded after the put that wrote these bytes (etag): the shim from 2026-10-26 hashes them.
|
|
1584
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
1585
|
+
store.query("UPDATE r2 SET md5 = ?1 WHERE bucket = ?2 AND key = ?3 AND etag = ?4").run(str(msg.md5), bucket, str(msg.key), str(msg.etag));
|
|
1586
|
+
return { ok: true };
|
|
1587
|
+
}
|
|
1546
1588
|
case "r2.list": {
|
|
1589
|
+
// The prefix matches exactly at every date: LIKE ignored case and read "_" and "%" as wildcards.
|
|
1547
1590
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
1548
1591
|
const prefix = str(msg.prefix);
|
|
1549
1592
|
const cursor = str(msg.cursor);
|
|
1550
|
-
const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
|
|
1593
|
+
const limit = Math.min(Math.max(Math.floor(Number(msg.limit)) || 1000, 1), 1000);
|
|
1594
|
+
if (msg.page === true) return r2ListPage(store, bucket, prefix, cursor, limit, str(msg.delimiter));
|
|
1551
1595
|
const rows = store
|
|
1552
1596
|
.query<R2Row, [string, string, string, number]>(
|
|
1553
|
-
"SELECT * FROM r2 WHERE bucket = ? AND key
|
|
1597
|
+
"SELECT * FROM r2 WHERE bucket = ?1 AND substr(key, 1, length(?2)) = ?2 AND key > ?3 ORDER BY key LIMIT ?4",
|
|
1554
1598
|
)
|
|
1555
1599
|
.all(bucket, prefix, cursor, limit + 1);
|
|
1556
1600
|
const truncated = rows.length > limit;
|
|
@@ -1876,6 +1920,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1876
1920
|
"r2.multipart.abort",
|
|
1877
1921
|
"r2.transfer.create",
|
|
1878
1922
|
"r2.delete",
|
|
1923
|
+
"r2.md5",
|
|
1879
1924
|
"do.storage.put",
|
|
1880
1925
|
"do.storage.delete",
|
|
1881
1926
|
"do.storage.delete_all",
|