@sproutboat/wire 0.14.6 → 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 +49 -0
- package/package.json +2 -2
- package/src/broker.test.ts +51 -5
- package/src/broker.ts +91 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
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
|
+
|
|
27
|
+
## 0.15.0
|
|
28
|
+
|
|
29
|
+
### Minor Changes
|
|
30
|
+
|
|
31
|
+
- 9b15539: KV matches workerd from compatibility date `2026-10-25`:
|
|
32
|
+
|
|
33
|
+
- `get`, `getWithMetadata`, `put`, `delete` and `list` return Promises, and errors reject them.
|
|
34
|
+
- `get(key, type)` takes `"text"`, `"json"`, `"arrayBuffer"` and `"stream"`, and `get([keys])` returns a `Map`.
|
|
35
|
+
- `getWithMetadata(key, type)` returns `{ value, metadata, cacheStatus }`.
|
|
36
|
+
- `put(key, value, { expiration, expirationTtl, metadata })` takes strings, bytes and `ReadableStream`s, and stores the metadata.
|
|
37
|
+
- `list({ prefix, limit, cursor })` returns `{ keys: [{ name, expiration?, metadata? }], list_complete, cursor?, cacheStatus }` and pages at up to 1,000 keys.
|
|
38
|
+
- Keys, values, expirations, metadata and list limits fail with KV's own errors. For example: `Key name cannot be empty.`, `KV PUT failed: 414 UTF-8 encoded length of 513 exceeds key length limit of 512.`, `KV PUT failed: 400 Invalid expiration_ttl of 30. Expiration TTL must be at least 60.`, and `KV PUT failed: 413 Metadata length of 1110 exceeds limit of 1024.`
|
|
39
|
+
|
|
40
|
+
Earlier dates keep the synchronous shim. Two changes apply at every date:
|
|
41
|
+
|
|
42
|
+
- **A `list()` prefix now matches exactly.** It used SQL `LIKE`, so it ignored case and read `_` and `%` as wildcards. For example, the prefix `a_` also listed `ab`, and `a` also listed `AB`.
|
|
43
|
+
- **Both transports store KV metadata in a new column.** It's added to existing stores when they open.
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [9b15539]
|
|
48
|
+
- Updated dependencies [1c36119]
|
|
49
|
+
- Updated dependencies [3cad1f0]
|
|
50
|
+
- @sproutboat/runtime@0.26.0
|
|
51
|
+
|
|
3
52
|
## 0.14.6
|
|
4
53
|
|
|
5
54
|
### Patch 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
|
@@ -114,7 +114,7 @@ test("#233: a KV value put as bytes is flagged binary and survives an existing s
|
|
|
114
114
|
const bytes = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
|
|
115
115
|
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: bytes, binary: true });
|
|
116
116
|
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "txt", value: "café" });
|
|
117
|
-
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).
|
|
117
|
+
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "bin" })).toMatchObject({ ok: true, found: true, value: bytes, binary: true });
|
|
118
118
|
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "txt" })).toMatchObject({ value: "café", binary: false });
|
|
119
119
|
// Overwriting a binary value with text clears the flag.
|
|
120
120
|
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "bin", value: "now text" });
|
|
@@ -126,12 +126,36 @@ test("KV put / get / list / delete, scoped to a bound namespace", async () => {
|
|
|
126
126
|
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toEqual({ ok: true, found: false, value: null });
|
|
127
127
|
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v" });
|
|
128
128
|
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "k2", value: "v2" });
|
|
129
|
-
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).
|
|
130
|
-
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "k" })).
|
|
129
|
+
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ ok: true, found: true, value: "v", binary: false });
|
|
130
|
+
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "k" })).toMatchObject({ ok: true, keys: ["k", "k2"] });
|
|
131
131
|
await b.dispatch({ op: "kv.delete", ns: "CACHE", key: "k" });
|
|
132
132
|
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("KV metadata, expiration and paged list (the shim from 2026-10-25); the prefix matches exactly", async () => {
|
|
136
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
137
|
+
const at = Math.floor(Date.now() / 1000) + 3600;
|
|
138
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "a", value: "1", metadata: '{"t":1}' });
|
|
139
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "b", value: "2", expiration: at });
|
|
140
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "c", value: "3" });
|
|
141
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "a_x", value: "4" });
|
|
142
|
+
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 });
|
|
144
|
+
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "b" })).toMatchObject({ metadata: null, expiration: at });
|
|
145
|
+
// LIKE matched "ab" for "a_" and "AB" for "a"; the prefix is now exact and case-sensitive.
|
|
146
|
+
expect((await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "a_" })).keys).toEqual(["a_x"]);
|
|
147
|
+
expect((await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "a" })).keys).toEqual(["a", "a_x"]);
|
|
148
|
+
const first = await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "", limit: 2, cursor: "" });
|
|
149
|
+
expect(first).toMatchObject({ keys: ["AB", "a"], list_complete: false, cursor: "a" });
|
|
150
|
+
expect(first.entries).toEqual([
|
|
151
|
+
{ name: "AB", expiration: null, metadata: null },
|
|
152
|
+
{ name: "a", expiration: null, metadata: '{"t":1}' },
|
|
153
|
+
]);
|
|
154
|
+
const rest = await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "", limit: 2, cursor: "a" });
|
|
155
|
+
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: "" });
|
|
157
|
+
});
|
|
158
|
+
|
|
135
159
|
test("KV expirationTtl under 60 seconds is rejected, matching CF", async () => {
|
|
136
160
|
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
137
161
|
await expect(b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v", expirationTtl: 59 })).rejects.toThrow(
|
|
@@ -149,7 +173,7 @@ test("a key past its expiration reads as absent and is omitted from list", async
|
|
|
149
173
|
found: false,
|
|
150
174
|
value: null,
|
|
151
175
|
});
|
|
152
|
-
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "" })).
|
|
176
|
+
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "" })).toMatchObject({ ok: true, keys: ["fresh"] });
|
|
153
177
|
});
|
|
154
178
|
|
|
155
179
|
test("an unbound KV namespace is rejected", async () => {
|
|
@@ -400,6 +424,28 @@ test("R2: put / get / head / list / delete on a bound bucket", async () => {
|
|
|
400
424
|
await expect(b.dispatch({ op: "r2.put", bucket: "NOPE", key: "x", body: "y" })).rejects.toThrow("not bound");
|
|
401
425
|
});
|
|
402
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
|
+
|
|
403
449
|
test("R2 direct transfer: streamed upload, metadata, one-use ticket and ranged download", async () => {
|
|
404
450
|
const root = mkdtempSync(join(tmpdir(), "sb-r2-transfer-"));
|
|
405
451
|
try {
|
|
@@ -883,7 +929,7 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
|
|
|
883
929
|
resourceDir,
|
|
884
930
|
bindings: { kv: ["SHORTENER"], resources: { SHORTENER: { kind: "kv", id: kvId } } },
|
|
885
931
|
});
|
|
886
|
-
expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).
|
|
932
|
+
expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).toMatchObject({
|
|
887
933
|
ok: true,
|
|
888
934
|
found: true,
|
|
889
935
|
binary: false,
|
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;
|
|
@@ -301,6 +302,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
301
302
|
// #233 — 1 when the value was put as bytes (stored one char per byte), so get()
|
|
302
303
|
// can hand back exact bytes or decoded text depending on the requested type.
|
|
303
304
|
if (!kvColumns.some((column) => column.name === "binary")) conn.exec("ALTER TABLE kv ADD COLUMN binary INTEGER NOT NULL DEFAULT 0");
|
|
305
|
+
// A key's metadata (put(key, value, { metadata })), as JSON text.
|
|
306
|
+
if (!kvColumns.some((column) => column.name === "metadata")) conn.exec("ALTER TABLE kv ADD COLUMN metadata TEXT");
|
|
304
307
|
// #56 — no `body` column: an object's bytes live in their own file under
|
|
305
308
|
// `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
|
|
306
309
|
// Doubling every object through SQLite's own page cache on top of the
|
|
@@ -315,6 +318,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
315
318
|
);
|
|
316
319
|
const r2Columns = conn.query<{ name: string }, []>("PRAGMA table_info(r2)").all();
|
|
317
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");
|
|
318
323
|
conn.exec(
|
|
319
324
|
"CREATE TABLE IF NOT EXISTS r2_upload (bucket TEXT NOT NULL, key TEXT NOT NULL, upload_id TEXT NOT NULL, " +
|
|
320
325
|
"uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
|
|
@@ -521,29 +526,31 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
521
526
|
};
|
|
522
527
|
|
|
523
528
|
type KvStmts = {
|
|
524
|
-
get: Statement<{ value: string; expires_at: number | null; binary: number }, [string, string]>;
|
|
525
|
-
put: Statement<unknown, [string, string, string, number | null, number]>;
|
|
529
|
+
get: Statement<{ value: string; expires_at: number | null; binary: number; metadata: string | null }, [string, string]>;
|
|
530
|
+
put: Statement<unknown, [string, string, string, number | null, number, string | null]>;
|
|
526
531
|
del: Statement<unknown, [string, string]>;
|
|
527
|
-
list: Statement<{ key: string }, [string, string, number]>;
|
|
532
|
+
list: Statement<{ key: string; expires_at: number | null; metadata: string | null }, [string, string, string, number, number]>;
|
|
528
533
|
};
|
|
529
534
|
const kvStmtCache = new Map<Database, KvStmts>();
|
|
530
535
|
const kvStmts = (store: Database): KvStmts => {
|
|
531
536
|
let stmts = kvStmtCache.get(store);
|
|
532
537
|
if (!stmts) {
|
|
533
538
|
stmts = {
|
|
534
|
-
get: store.query<{ value: string; expires_at: number | null; binary: number }, [string, string]>(
|
|
535
|
-
"SELECT value, expires_at, binary FROM kv WHERE ns = ? AND key = ?",
|
|
539
|
+
get: store.query<{ value: string; expires_at: number | null; binary: number; metadata: string | null }, [string, string]>(
|
|
540
|
+
"SELECT value, expires_at, binary, metadata FROM kv WHERE ns = ? AND key = ?",
|
|
536
541
|
),
|
|
537
542
|
put: store.query(
|
|
538
|
-
"INSERT INTO kv (ns, key, value, expires_at, binary) VALUES (?1, ?2, ?3, ?4, ?5) " +
|
|
539
|
-
"ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5",
|
|
543
|
+
"INSERT INTO kv (ns, key, value, expires_at, binary, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
|
|
544
|
+
"ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5, metadata = ?6",
|
|
540
545
|
),
|
|
541
546
|
del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
|
|
542
547
|
// #58 — an expired row is filtered out here; a periodic sweep to hard-delete
|
|
543
548
|
// it is a separate concern (ponytail: rows accumulate until touched by a
|
|
544
549
|
// get/list/put on the same key/prefix, add a cron sweep if that matters).
|
|
545
|
-
|
|
546
|
-
|
|
550
|
+
// The prefix matches exactly: LIKE ignored case and read "_" and "%" as wildcards.
|
|
551
|
+
list: store.query<{ key: string; expires_at: number | null; metadata: string | null }, [string, string, string, number, number]>(
|
|
552
|
+
"SELECT key, expires_at, metadata FROM kv WHERE ns = ?1 AND substr(key, 1, length(?2)) = ?2 AND key > ?3 " +
|
|
553
|
+
"AND (expires_at IS NULL OR expires_at > ?4) ORDER BY key LIMIT ?5",
|
|
547
554
|
),
|
|
548
555
|
};
|
|
549
556
|
kvStmtCache.set(store, stmts);
|
|
@@ -1091,6 +1098,38 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1091
1098
|
return r2Row(row);
|
|
1092
1099
|
};
|
|
1093
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
|
+
|
|
1094
1133
|
const r2Row = (r: R2Row) => ({
|
|
1095
1134
|
key: r.key,
|
|
1096
1135
|
size: r.size,
|
|
@@ -1098,6 +1137,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1098
1137
|
uploaded: r.uploaded,
|
|
1099
1138
|
httpMetadata: parseJsonValue(r.http_json),
|
|
1100
1139
|
customMetadata: parseJsonValue(r.custom_json),
|
|
1140
|
+
md5: r.md5 ?? null,
|
|
1101
1141
|
});
|
|
1102
1142
|
|
|
1103
1143
|
/**
|
|
@@ -1285,11 +1325,25 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1285
1325
|
stmts.del.run(part, str(msg.key));
|
|
1286
1326
|
return { ok: true, found: false, value: null };
|
|
1287
1327
|
}
|
|
1288
|
-
return {
|
|
1328
|
+
return {
|
|
1329
|
+
ok: true,
|
|
1330
|
+
found: true,
|
|
1331
|
+
value: row.value,
|
|
1332
|
+
binary: row.binary === 1,
|
|
1333
|
+
metadata: row.metadata,
|
|
1334
|
+
expiration: row.expires_at == null ? null : Math.floor(row.expires_at / 1000),
|
|
1335
|
+
};
|
|
1289
1336
|
}
|
|
1290
1337
|
case "kv.put": {
|
|
1291
1338
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1292
|
-
kvStmts(store).put.run(
|
|
1339
|
+
kvStmts(store).put.run(
|
|
1340
|
+
part,
|
|
1341
|
+
str(msg.key),
|
|
1342
|
+
str(msg.value),
|
|
1343
|
+
kvExpiresAt(msg),
|
|
1344
|
+
msg.binary === true ? 1 : 0,
|
|
1345
|
+
msg.metadata == null ? null : str(msg.metadata),
|
|
1346
|
+
);
|
|
1293
1347
|
return { ok: true };
|
|
1294
1348
|
}
|
|
1295
1349
|
case "kv.delete": {
|
|
@@ -1299,11 +1353,23 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1299
1353
|
}
|
|
1300
1354
|
case "kv.list": {
|
|
1301
1355
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1356
|
+
// With a limit (the shim from 2026-10-25): a page after the cursor (the previous page's
|
|
1357
|
+
// last key), with expirations and metadata. Without one: up to 1,000 keys, as before.
|
|
1358
|
+
const paged = isNumber(msg.limit);
|
|
1359
|
+
const limit = paged ? Math.max(0, Math.floor(Number(msg.limit))) : 1000;
|
|
1360
|
+
const rows = kvStmts(store).list.all(part, str(msg.prefix), paged ? str(msg.cursor) : "", Date.now(), paged ? limit + 1 : limit);
|
|
1361
|
+
const page = rows.slice(0, limit);
|
|
1362
|
+
const complete = !paged || rows.length <= limit;
|
|
1302
1363
|
return {
|
|
1303
1364
|
ok: true,
|
|
1304
|
-
keys:
|
|
1305
|
-
|
|
1306
|
-
|
|
1365
|
+
keys: page.map((r) => r.key),
|
|
1366
|
+
entries: page.map((r) => ({
|
|
1367
|
+
name: r.key,
|
|
1368
|
+
expiration: r.expires_at == null ? null : Math.floor(r.expires_at / 1000),
|
|
1369
|
+
metadata: r.metadata,
|
|
1370
|
+
})),
|
|
1371
|
+
list_complete: complete,
|
|
1372
|
+
cursor: complete ? "" : (page[page.length - 1]?.key ?? ""),
|
|
1307
1373
|
};
|
|
1308
1374
|
}
|
|
1309
1375
|
case "cache.match": {
|
|
@@ -1513,14 +1579,22 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1513
1579
|
if (row) r2DeleteBlob(store, bucket, key, row.blob_id);
|
|
1514
1580
|
return { ok: true };
|
|
1515
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
|
+
}
|
|
1516
1588
|
case "r2.list": {
|
|
1589
|
+
// The prefix matches exactly at every date: LIKE ignored case and read "_" and "%" as wildcards.
|
|
1517
1590
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
1518
1591
|
const prefix = str(msg.prefix);
|
|
1519
1592
|
const cursor = str(msg.cursor);
|
|
1520
|
-
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));
|
|
1521
1595
|
const rows = store
|
|
1522
1596
|
.query<R2Row, [string, string, string, number]>(
|
|
1523
|
-
"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",
|
|
1524
1598
|
)
|
|
1525
1599
|
.all(bucket, prefix, cursor, limit + 1);
|
|
1526
1600
|
const truncated = rows.length > limit;
|
|
@@ -1846,6 +1920,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1846
1920
|
"r2.multipart.abort",
|
|
1847
1921
|
"r2.transfer.create",
|
|
1848
1922
|
"r2.delete",
|
|
1923
|
+
"r2.md5",
|
|
1849
1924
|
"do.storage.put",
|
|
1850
1925
|
"do.storage.delete",
|
|
1851
1926
|
"do.storage.delete_all",
|