@sproutboat/runtime 0.25.0 → 0.27.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 CHANGED
@@ -1,5 +1,55 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.27.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
+ ## 0.26.0
23
+
24
+ ### Minor Changes
25
+
26
+ - 9b15539: KV matches workerd from compatibility date `2026-10-25`:
27
+
28
+ - `get`, `getWithMetadata`, `put`, `delete` and `list` return Promises, and errors reject them.
29
+ - `get(key, type)` takes `"text"`, `"json"`, `"arrayBuffer"` and `"stream"`, and `get([keys])` returns a `Map`.
30
+ - `getWithMetadata(key, type)` returns `{ value, metadata, cacheStatus }`.
31
+ - `put(key, value, { expiration, expirationTtl, metadata })` takes strings, bytes and `ReadableStream`s, and stores the metadata.
32
+ - `list({ prefix, limit, cursor })` returns `{ keys: [{ name, expiration?, metadata? }], list_complete, cursor?, cacheStatus }` and pages at up to 1,000 keys.
33
+ - 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.`
34
+
35
+ Earlier dates keep the synchronous shim. Two changes apply at every date:
36
+
37
+ - **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`.
38
+ - **Both transports store KV metadata in a new column.** It's added to existing stores when they open.
39
+
40
+ ### Patch Changes
41
+
42
+ - 1c36119: Create the LTX page-index scratch file inside the database's replication spool
43
+ instead of libc's global temporary directory, fixing capture initialization when
44
+ a worker's filesystem sandbox permits writes only beneath its own state directory.
45
+ The scratch file is private and immediately unlinked. If a kill lands before
46
+ unlink, the next capture-lock owner removes the stale regular index file before
47
+ recovery. After unlink, closing the writer or killing the process releases it.
48
+ Its anonymous transient disk use is not counted by the
49
+ directory-walk quota. Capture retains its existing explicit opt-in and compatibility
50
+ date of 2026-10-22; capture-off apps and earlier dates keep their existing behavior.
51
+ - 3cad1f0: The storage quota counts a D1 database's WAL file, which can hold space the app has already freed. While the app is over its quota, the runtime now checkpoints and truncates each D1 database's WAL before measuring again, at most once a second. So `DELETE` then `VACUUM` is enough to get back under the quota, and the app no longer has to run a checkpoint itself. Databases under LTX capture are left alone: capture owns their checkpoints, and they stay frozen until the quota is raised or other data is deleted.
52
+
3
53
  ## 0.25.0
4
54
 
5
55
  ### Minor Changes
@@ -53,7 +53,18 @@ for this opted-in binding. Use the base LTX file for replication.
53
53
 
54
54
  Files live under `<database-path>.ltx/epoch-XXXXXX/`. Completed names are
55
55
  `<minTXID as 16 hex digits>-<maxTXID as 16 hex digits>.ltx`.
56
- The encoder streams one page at a time and spools index records with `tmpfile()`.
56
+ The encoder streams one page at a time. It creates its index scratch file with
57
+ `mkstemp()` beneath `<database-path>.ltx/`, immediately unlinks the name, and uses
58
+ the open descriptor until the writer closes. This needs no access to a global
59
+ temporary directory, which a sandboxed worker may not have. The scratch file is
60
+ private (0600). A kill between creation and unlink can leave a named index file.
61
+ On startup, after acquiring the capture lock and before recovery, the owner removes
62
+ only regular root-level files matching the reserved `index-` plus six alphanumeric
63
+ characters. It does not follow symlinks or remove directories or unrelated names.
64
+ After unlink, process death releases the anonymous scratch space automatically.
65
+ Its transient disk usage grows with the index records and is not visible to the
66
+ directory-walk storage quota after unlink. Allow disk headroom for that scratch
67
+ space as well as the LTX output; the quota is not a hard filesystem limit.
57
68
 
58
69
  For every LTX file:
59
70
 
@@ -78,7 +89,10 @@ applications must use idempotent transaction identifiers for retries.
78
89
  ## Qualification status
79
90
 
80
91
  Capture is tested against the exact inline C through `bun run test:native-ltx`,
81
- including independent BigInt CRC and index verification, a 102-file chain,
92
+ with global `tmpfile()` access deliberately denied in the driver so the capture
93
+ path cannot depend on it. This portable regression does not itself qualify the
94
+ Linux Landlock/seccomp sandbox; sproutd's capture-on lifecycle smoke must do that.
95
+ The tests cover independent BigInt CRC and index verification, a 102-file chain,
82
96
  byte-exact restore, integrity checking, oversized transaction rollback, database truncation, explicit rollback and late position-write failure latching.
83
97
  The workspace prototype additionally tests the compiled Porffor executable,
84
98
  including external writer exclusion and invariant PRAGMA rejection.
@@ -103,8 +117,9 @@ time. It also checks the last LTX file. Five native restarts each at 4.2 MB and
103
117
  The 51.1 MB verification took 399 to 446 ms on macOS arm64 (plus about 13 ms to
104
118
  health readiness); this is not a 9 ms cold-start claim for large databases.
105
119
 
106
- The regression SIGKILLs at ten durability boundaries, including both sides of
107
- clean-marker publication, and verifies matching-prefix resume and fallback cases.
120
+ The regression SIGKILLs at eleven durability boundaries, including the interval
121
+ between index creation and unlink and both sides of clean-marker publication,
122
+ and verifies scratch cleanup, matching-prefix resume and fallback cases.
108
123
  It also recovers a committed tail beyond the durable cursor. The full Porffor executable passed 200 seeded random SIGKILL iterations on macOS
109
124
  arm64: 1,675 acknowledged writes retained, 88 same-generation resumes and 112
110
125
  fresh-base recoveries. Every finished chain passed file/database CRC verification,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,124 @@
1
+ // The KV shim (`env.KV`) at compatibility dates before and from 2026-10-25, over an
2
+ // in-memory transport with the kv.* contract both transports implement (broker.test.ts
3
+ // covers the broker's side; sproutd's conformance/bindings/kv.js the whole path).
4
+ import { expect, test } from "bun:test";
5
+ import { readFileSync } from "node:fs";
6
+
7
+ const source = readFileSync(new URL("./native-fetch-prelude.js", import.meta.url), "utf8");
8
+ let rows;
9
+ const call = (json) => {
10
+ const msg = JSON.parse(json);
11
+ const reply = (() => {
12
+ if (msg.op === "kv.get") {
13
+ const r = rows.get(msg.key);
14
+ return r ? { ok: true, found: true, value: r.value, binary: r.binary, metadata: r.metadata ?? null, expiration: r.expiration ?? null } : { ok: true, found: false, value: null };
15
+ }
16
+ if (msg.op === "kv.put") {
17
+ rows.set(msg.key, { value: msg.value, binary: msg.binary === true, metadata: msg.metadata, expiration: msg.expiration ?? (msg.expirationTtl ? Math.floor(Date.now() / 1000) + msg.expirationTtl : undefined) });
18
+ return { ok: true };
19
+ }
20
+ if (msg.op === "kv.delete") {
21
+ rows.delete(msg.key);
22
+ return { ok: true };
23
+ }
24
+ if (msg.op === "kv.list") {
25
+ const keys = [...rows.keys()].filter((k) => k.startsWith(msg.prefix) && (!msg.cursor || k > msg.cursor)).sort();
26
+ const limit = msg.limit ?? keys.length;
27
+ const page = keys.slice(0, limit);
28
+ const complete = keys.length <= limit;
29
+ return {
30
+ ok: true,
31
+ keys: page,
32
+ entries: page.map((k) => ({ name: k, expiration: rows.get(k).expiration ?? null, metadata: rows.get(k).metadata ?? null })),
33
+ list_complete: complete,
34
+ cursor: complete ? "" : page[page.length - 1],
35
+ };
36
+ }
37
+ return { ok: false, error: "unknown op" };
38
+ })();
39
+ return JSON.stringify(reply);
40
+ };
41
+ // oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
42
+ const { makeEnv } = new Function(
43
+ "Porffor",
44
+ "__testCall",
45
+ `${source}\n__sbCall = __testCall; return { makeEnv: (target) => { __sbInstallBindings(target, { kv: ["KV"] }); return target; } };`,
46
+ )({ c: () => "" }, call);
47
+ const fresh = (date) => {
48
+ globalThis.__sbCompat = date;
49
+ rows = new Map();
50
+ return makeEnv({}).KV;
51
+ };
52
+ const failure = async (fn) => {
53
+ try {
54
+ await fn();
55
+ } catch (error) {
56
+ return `${error.name}: ${error.message}`;
57
+ }
58
+ return "did not throw";
59
+ };
60
+
61
+ test("before 2026-10-25: the synchronous shim, unchanged", () => {
62
+ const KV = fresh("2026-10-24");
63
+ expect(KV.put("a", "v")).toBeUndefined();
64
+ expect(KV.get("a")).toBe("v");
65
+ expect(KV.list("a")).toEqual(["a"]);
66
+ expect(KV.getWithMetadata).toBeUndefined();
67
+ });
68
+
69
+ test("from 2026-10-25: Promises, metadata, typed gets and get(keys)", async () => {
70
+ const KV = fresh("2026-10-25");
71
+ expect(KV.put("a", "v", { metadata: { n: 1 } })).toBeInstanceOf(Promise);
72
+ await KV.put("a", '{"x":1}', { metadata: { n: 1 } });
73
+ expect(await KV.get("a", "json")).toEqual({ x: 1 });
74
+ expect(await KV.get("a", { type: "text" })).toBe('{"x":1}');
75
+ expect(await KV.getWithMetadata("a", "json")).toEqual({ value: { x: 1 }, metadata: { n: 1 }, cacheStatus: null });
76
+ expect(await KV.getWithMetadata("missing")).toEqual({ value: null, metadata: null, cacheStatus: null });
77
+ await KV.put("b", new Uint8Array([0, 255]));
78
+ expect(Array.from(new Uint8Array(await KV.get("b", "arrayBuffer")))).toEqual([0, 255]);
79
+ // (ReadableStream values and the "stream" type use the prelude's own streams: checked end to
80
+ // end by sproutd's conformance/bindings/kv.js, not here against Bun's.)
81
+ const many = await KV.get(["a", "missing"]);
82
+ expect(many).toBeInstanceOf(Map);
83
+ expect([...many.entries()]).toEqual([["a", '{"x":1}'], ["missing", null]]);
84
+ expect(await KV.delete("a")).toBeUndefined();
85
+ expect(await KV.get("a")).toBeNull();
86
+ });
87
+
88
+ test("from 2026-10-25: KV's errors for keys, values, expiration and metadata", async () => {
89
+ const KV = fresh("2026-10-25");
90
+ expect(await failure(() => KV.put("", "v"))).toBe("TypeError: Key name cannot be empty.");
91
+ expect(await failure(() => KV.put("..", "v"))).toBe('TypeError: ".." is not allowed as a key name.');
92
+ expect(await failure(() => KV.put("k".repeat(513), "v"))).toBe("Error: KV PUT failed: 414 UTF-8 encoded length of 513 exceeds key length limit of 512.");
93
+ expect(await failure(() => KV.get("k".repeat(513)))).toBe("Error: KV GET failed: 414 UTF-8 encoded length of 513 exceeds key length limit of 512.");
94
+ expect(await failure(() => KV.put("a", { a: 1 }))).toBe("TypeError: KV put() accepts only strings, ArrayBuffers, ArrayBufferViews, and ReadableStreams as values.");
95
+ expect(await failure(() => KV.put("a", "v", { expirationTtl: 30 }))).toBe("Error: KV PUT failed: 400 Invalid expiration_ttl of 30. Expiration TTL must be at least 60.");
96
+ expect(await failure(() => KV.put("a", "v", { expirationTtl: "soon" }))).toBe("Error: KV PUT failed: 400 Invalid expiration_ttl of 0. Please specify integer greater than 0.");
97
+ expect(await failure(() => KV.put("a", "v", { expiration: 1000 }))).toBe(
98
+ "Error: KV PUT failed: 400 Invalid expiration of 1000. Please specify integer greater than the current number of seconds since the UNIX epoch.",
99
+ );
100
+ expect(await failure(() => KV.put("a", "v", { metadata: { big: "x".repeat(1100) } }))).toBe("Error: KV PUT failed: 413 Metadata length of 1110 exceeds limit of 1024.");
101
+ expect(await KV.get("missing", "nope")).toBeNull();
102
+ await KV.put("there", "v");
103
+ expect(await failure(() => KV.get("there", "nope"))).toBe('TypeError: Unknown response type. Possible types are "text", "arrayBuffer", "json", and "stream".');
104
+ expect(await failure(() => KV.list({ limit: 1001 }))).toBe("Error: KV GET failed: 400 Invalid key_count_limit of 1001. Please specify an integer less than 1000.");
105
+ // A failing get rejects rather than throws.
106
+ const rejected = KV.get("");
107
+ expect(rejected).toBeInstanceOf(Promise);
108
+ expect(await rejected.catch((e) => e.message)).toBe("Key name cannot be empty.");
109
+ });
110
+
111
+ test("from 2026-10-25: list() pages with limit and cursor, with expiration and metadata", async () => {
112
+ const KV = fresh("2026-10-25");
113
+ for (const k of ["b", "a", "c"]) await KV.put(k, "v", k === "a" ? { metadata: { t: 1 }, expirationTtl: 3600 } : undefined);
114
+ const first = await KV.list({ limit: 2 });
115
+ expect(first.keys.map((k) => k.name)).toEqual(["a", "b"]);
116
+ expect(first.keys[0].metadata).toEqual({ t: 1 });
117
+ expect(Number.isInteger(first.keys[0].expiration)).toBe(true);
118
+ expect("expiration" in first.keys[1]).toBe(false);
119
+ expect(first.list_complete).toBe(false);
120
+ const rest = await KV.list({ limit: 2, cursor: first.cursor });
121
+ expect(rest.keys.map((k) => k.name)).toEqual(["c"]);
122
+ expect(Object.keys(rest).sort()).toEqual(["cacheStatus", "keys", "list_complete"]);
123
+ expect(await KV.list({ limit: 0 })).toEqual({ keys: [], list_complete: true, cacheStatus: null });
124
+ });
@@ -4807,6 +4807,126 @@ function __sbRpc(op, extra) {
4807
4807
  return reply;
4808
4808
  }
4809
4809
 
4810
+ // KV from compatibility date 2026-10-25: workerd's surface. Every method returns a
4811
+ // Promise; keys and options are checked with KV's errors; get() takes a list of keys and
4812
+ // the "stream" type; getWithMetadata(); put() takes a ReadableStream and metadata; list()
4813
+ // pages with limit and cursor and returns each key's expiration and metadata. Earlier
4814
+ // dates keep the synchronous shim below.
4815
+ function __sbKvStrict() {
4816
+ return globalThis.__sbCompat >= "2026-10-25";
4817
+ }
4818
+ function __sbKvKey(key, verb) {
4819
+ const name = String(key);
4820
+ if (name === "") throw new TypeError("Key name cannot be empty.");
4821
+ if (name === "." || name === "..") throw new TypeError('"' + name + '" is not allowed as a key name.');
4822
+ const length = new TextEncoder().encode(name).length;
4823
+ if (length > 512) throw new Error("KV " + verb + " failed: 414 UTF-8 encoded length of " + length + " exceeds key length limit of 512.");
4824
+ return name;
4825
+ }
4826
+ function __sbKvType(typeOrOptions) {
4827
+ return typeOrOptions == null ? "text" : __sbIsStr(typeOrOptions) ? typeOrOptions : typeOrOptions.type || "text";
4828
+ }
4829
+ // An unknown type fails only when there is a value to decode: a missing key is null
4830
+ // whatever the type, as in workerd.
4831
+ function __sbKvDecode(r, type) {
4832
+ if (type !== "text" && type !== "json" && type !== "arrayBuffer" && type !== "stream")
4833
+ throw new TypeError('Unknown response type. Possible types are "text", "arrayBuffer", "json", and "stream".');
4834
+ if (type === "arrayBuffer") return __sbBufFrom(r.binary ? r.value : __sbToBytes(r.value));
4835
+ if (type === "stream") return new Response(__sbBufFrom(r.binary ? r.value : __sbToBytes(r.value))).body;
4836
+ const text = r.binary ? __sbFromUtf8(r.value) : r.value;
4837
+ return type === "json" ? JSON.parse(text) : text;
4838
+ }
4839
+ function __sbKvMetadata(text) {
4840
+ return text == null ? null : JSON.parse(text);
4841
+ }
4842
+ function __sbMakeKv(ns) {
4843
+ const one = (key, type) => {
4844
+ const r = __sbRpc("kv.get", { ns, key: __sbKvKey(key, "GET") });
4845
+ return r.found ? { value: __sbKvDecode(r, type), metadata: __sbKvMetadata(r.metadata) } : { value: null, metadata: null };
4846
+ };
4847
+ const attempt = (run) => {
4848
+ try {
4849
+ return Promise.resolve(run());
4850
+ } catch (err) {
4851
+ return Promise.reject(err);
4852
+ }
4853
+ };
4854
+ return {
4855
+ get(key, typeOrOptions) {
4856
+ return attempt(() => {
4857
+ const type = __sbKvType(typeOrOptions);
4858
+ if (!Array.isArray(key)) return one(key, type).value;
4859
+ const out = new Map();
4860
+ for (let i = 0; i < key.length; i++) out.set(String(key[i]), one(key[i], type).value);
4861
+ return out;
4862
+ });
4863
+ },
4864
+ getWithMetadata(key, typeOrOptions) {
4865
+ return attempt(() => {
4866
+ const r = one(key, __sbKvType(typeOrOptions));
4867
+ return { value: r.value, metadata: r.metadata, cacheStatus: null };
4868
+ });
4869
+ },
4870
+ async put(key, value, options) {
4871
+ const msg = { ns, key: __sbKvKey(key, "PUT"), value: "" };
4872
+ value = __sbUnwrapBody(value);
4873
+ if (value != null && __sbIsFn(value.getReader)) value = new Uint8Array(await new Response(value).arrayBuffer());
4874
+ if (value != null && !__sbIsStr(value) && value.byteLength !== undefined) {
4875
+ const bytes = value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value);
4876
+ msg.value = __sbToBytes(bytes);
4877
+ msg.binary = true;
4878
+ } else if (__sbIsStr(value) || value === Number(value) || value === true || value === false) msg.value = String(value);
4879
+ else throw new TypeError("KV put() accepts only strings, ArrayBuffers, ArrayBufferViews, and ReadableStreams as values.");
4880
+ const o = options || {};
4881
+ if (o.expirationTtl != null) {
4882
+ const ttl = Math.floor(Number(o.expirationTtl)) || 0;
4883
+ if (ttl <= 0) throw new Error("KV PUT failed: 400 Invalid expiration_ttl of " + ttl + ". Please specify integer greater than 0.");
4884
+ if (ttl < 60) throw new Error("KV PUT failed: 400 Invalid expiration_ttl of " + ttl + ". Expiration TTL must be at least 60.");
4885
+ msg.expirationTtl = ttl;
4886
+ } else if (o.expiration != null) {
4887
+ const at = Math.floor(Number(o.expiration)) || 0;
4888
+ if (at <= Math.floor(Date.now() / 1000))
4889
+ throw new Error("KV PUT failed: 400 Invalid expiration of " + at + ". Please specify integer greater than the current number of seconds since the UNIX epoch.");
4890
+ msg.expiration = at;
4891
+ }
4892
+ if (o.metadata !== undefined) {
4893
+ const json = JSON.stringify(o.metadata);
4894
+ const length = new TextEncoder().encode(json).length;
4895
+ if (length > 1024) throw new Error("KV PUT failed: 413 Metadata length of " + length + " exceeds limit of 1024.");
4896
+ msg.metadata = json;
4897
+ }
4898
+ __sbRpc("kv.put", msg);
4899
+ },
4900
+ delete(key) {
4901
+ return attempt(() => {
4902
+ __sbRpc("kv.delete", { ns, key: __sbKvKey(key, "DELETE") });
4903
+ });
4904
+ },
4905
+ list(options) {
4906
+ return attempt(() => {
4907
+ const o = options || {};
4908
+ const limit = o.limit == null ? 1000 : Math.floor(Number(o.limit));
4909
+ if (!(limit >= 0) || limit > 1000)
4910
+ throw new Error("KV GET failed: 400 Invalid key_count_limit of " + o.limit + ". Please specify an integer less than 1000.");
4911
+ if (limit === 0) return { keys: [], list_complete: true, cacheStatus: null };
4912
+ const r = __sbRpc("kv.list", { ns, prefix: o.prefix == null ? "" : String(o.prefix), limit, cursor: o.cursor == null ? "" : String(o.cursor) });
4913
+ const keys = [];
4914
+ const entries = r.entries || [];
4915
+ for (let i = 0; i < entries.length; i++) {
4916
+ const k = { name: entries[i].name };
4917
+ if (entries[i].expiration != null) k.expiration = entries[i].expiration;
4918
+ if (entries[i].metadata != null) k.metadata = __sbKvMetadata(entries[i].metadata);
4919
+ keys.push(k);
4920
+ }
4921
+ const out = { keys, list_complete: r.list_complete !== false };
4922
+ if (!out.list_complete) out.cursor = r.cursor;
4923
+ out.cacheStatus = null;
4924
+ return out;
4925
+ });
4926
+ },
4927
+ };
4928
+ }
4929
+
4810
4930
  // D1: a Cloudflare-shaped `env.<DB>` (prepare / bind / all / run / raw / first,
4811
4931
  // plus batch and exec). Every call is one broker roundtrip.
4812
4932
  //
@@ -5118,6 +5238,261 @@ function __sbR2Multipart(bucket, key, uploadId) {
5118
5238
  };
5119
5239
  }
5120
5240
 
5241
+ // R2 from compatibility date 2026-10-26: workerd's surface. Every method returns a
5242
+ // Promise; an object's etag is the MD5 of its bytes (sha256 before; objects written
5243
+ // earlier keep theirs), with `checksums.md5`, `uploaded` as a Date and `range`; get()
5244
+ // takes `range` and `onlyIf`, put() takes `onlyIf`, a ReadableStream or a Blob and
5245
+ // httpMetadata as Headers; delete() takes a list of keys; list() pages with limit and
5246
+ // cursor, groups by delimiter and returns metadata only when `include` asks for it.
5247
+ // Earlier dates keep the synchronous shim below.
5248
+ function __sbR2Strict() {
5249
+ return globalThis.__sbCompat >= "2026-10-26";
5250
+ }
5251
+ function __sbR2Key(key, verb) {
5252
+ const name = String(key);
5253
+ if (new TextEncoder().encode(name).length > 1024) throw new Error(verb + ": The specified object name is not valid. (10020)");
5254
+ return name;
5255
+ }
5256
+ function __sbR2HexOf(bytes) {
5257
+ let out = "";
5258
+ for (let i = 0; i < bytes.length; i++) {
5259
+ const b = bytes.charCodeAt(i) & 0xff;
5260
+ out += (b < 16 ? "0" : "") + b.toString(16);
5261
+ }
5262
+ return out;
5263
+ }
5264
+ // R2's httpMetadata, in workerd's key order, from an object or a Headers.
5265
+ function __sbR2Http(h) {
5266
+ if (h == null) return {};
5267
+ const src = h instanceof Headers
5268
+ ? {
5269
+ contentType: h.get("content-type"),
5270
+ contentLanguage: h.get("content-language"),
5271
+ contentDisposition: h.get("content-disposition"),
5272
+ contentEncoding: h.get("content-encoding"),
5273
+ cacheControl: h.get("cache-control"),
5274
+ cacheExpiry: h.get("expires"),
5275
+ }
5276
+ : h;
5277
+ const out = {};
5278
+ const names = ["contentType", "contentLanguage", "contentDisposition", "contentEncoding", "cacheControl", "cacheExpiry"];
5279
+ for (let i = 0; i < names.length; i++) if (src[names[i]] != null) out[names[i]] = src[names[i]];
5280
+ return out;
5281
+ }
5282
+ function __sbR2EtagIn(list, etag) {
5283
+ const items = Array.isArray(list) ? list : String(list).split(",");
5284
+ for (let i = 0; i < items.length; i++) {
5285
+ const e = String(items[i]).trim().replace(/^W\//, "").replace(/^"|"$/g, "");
5286
+ if (e === "*" || e === etag) return true;
5287
+ }
5288
+ return false;
5289
+ }
5290
+ // Whether an object passes onlyIf (an object or a Headers with If-* headers).
5291
+ function __sbR2OnlyIf(cond, etag, uploaded) {
5292
+ if (cond == null) return true;
5293
+ const c = cond instanceof Headers
5294
+ ? {
5295
+ etagMatches: cond.get("if-match"),
5296
+ etagDoesNotMatch: cond.get("if-none-match"),
5297
+ uploadedBefore: cond.get("if-unmodified-since"),
5298
+ uploadedAfter: cond.get("if-modified-since"),
5299
+ }
5300
+ : cond;
5301
+ const at = new Date(uploaded).getTime();
5302
+ if (c.etagMatches != null && !__sbR2EtagIn(c.etagMatches, etag)) return false;
5303
+ if (c.etagDoesNotMatch != null && __sbR2EtagIn(c.etagDoesNotMatch, etag)) return false;
5304
+ if (c.uploadedBefore != null && !(at < new Date(c.uploadedBefore).getTime())) return false;
5305
+ if (c.uploadedAfter != null && !(at > new Date(c.uploadedAfter).getTime())) return false;
5306
+ return true;
5307
+ }
5308
+ // {offset, length} of a range (an object or a Headers with Range) on an object of SIZE.
5309
+ function __sbR2Range(range, size) {
5310
+ let offset = 0, length = size;
5311
+ if (range instanceof Headers) {
5312
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(range.get("range") || "").trim());
5313
+ if (!m) return { offset, length };
5314
+ if (m[1] === "") range = { suffix: Number(m[2]) };
5315
+ else range = m[2] === "" ? { offset: Number(m[1]) } : { offset: Number(m[1]), length: Number(m[2]) - Number(m[1]) + 1 };
5316
+ }
5317
+ if (range.suffix != null) {
5318
+ length = Math.min(Number(range.suffix), size);
5319
+ offset = size - length;
5320
+ } else {
5321
+ offset = Math.min(Number(range.offset || 0), size);
5322
+ length = range.length == null ? size - offset : Math.min(Number(range.length), size - offset);
5323
+ }
5324
+ return { offset, length };
5325
+ }
5326
+ // An R2Object (or an R2ObjectBody, given BYTES) as workerd returns it.
5327
+ function __sbR2StrictObject(meta, bytes, range, include) {
5328
+ const etag = meta.md5 || meta.etag;
5329
+ const md5 = meta.md5;
5330
+ const obj = {
5331
+ key: meta.key,
5332
+ version: meta.etag,
5333
+ size: meta.size,
5334
+ etag,
5335
+ httpEtag: '"' + etag + '"',
5336
+ checksums: {
5337
+ md5: md5 ? __sbBufFrom(__sbR2Bytes(md5)) : undefined,
5338
+ toJSON() {
5339
+ return { md5: md5 || undefined, sha1: undefined, sha256: undefined, sha384: undefined, sha512: undefined };
5340
+ },
5341
+ },
5342
+ uploaded: new Date(meta.uploaded),
5343
+ httpMetadata: include === false ? {} : __sbR2Http(meta.httpMetadata),
5344
+ customMetadata: include === false ? {} : meta.customMetadata || {},
5345
+ storageClass: "",
5346
+ range,
5347
+ writeHttpMetadata(headers) {
5348
+ const h = obj.httpMetadata;
5349
+ if (h.contentType != null) headers.set("content-type", h.contentType);
5350
+ if (h.contentLanguage != null) headers.set("content-language", h.contentLanguage);
5351
+ if (h.contentDisposition != null) headers.set("content-disposition", h.contentDisposition);
5352
+ if (h.contentEncoding != null) headers.set("content-encoding", h.contentEncoding);
5353
+ if (h.cacheControl != null) headers.set("cache-control", h.cacheControl);
5354
+ if (h.cacheExpiry != null) headers.set("expires", new Date(h.cacheExpiry).toUTCString());
5355
+ },
5356
+ };
5357
+ if (bytes == null) return obj;
5358
+ let used = false;
5359
+ const take = () => {
5360
+ if (used) throw new TypeError("Body has already been used. It can only be used once. Use tee() first if you need to read it twice.");
5361
+ used = true;
5362
+ return bytes;
5363
+ };
5364
+ obj.body = new Response(__sbBufFrom(bytes)).body;
5365
+ Object.defineProperty(obj, "bodyUsed", { get: () => used, enumerable: true });
5366
+ obj.text = async () => __sbFromUtf8(take());
5367
+ obj.json = async () => JSON.parse(__sbFromUtf8(take()));
5368
+ obj.arrayBuffer = async () => __sbBufFrom(take());
5369
+ obj.blob = async () => new Blob([__sbBufFrom(take())], { type: obj.httpMetadata.contentType || "" });
5370
+ return obj;
5371
+ }
5372
+ // A hex string's bytes, one char per byte.
5373
+ function __sbR2Bytes(hex) {
5374
+ let out = "";
5375
+ for (let i = 0; i + 1 < hex.length; i += 2) out += String.fromCharCode(parseInt(hex.slice(i, i + 2), 16));
5376
+ return out;
5377
+ }
5378
+ function __sbMakeR2(name) {
5379
+ const attempt = (run) => {
5380
+ try {
5381
+ return Promise.resolve(run());
5382
+ } catch (err) {
5383
+ return Promise.reject(err);
5384
+ }
5385
+ };
5386
+ const head = (key) => {
5387
+ const r = __sbRpc("r2.head", { bucket: name, key });
5388
+ return r.found ? r.object : null;
5389
+ };
5390
+ const upload = (key, uploadId) => {
5391
+ const inner = __sbR2Multipart(name, key, uploadId);
5392
+ return {
5393
+ key,
5394
+ uploadId,
5395
+ uploadPart: (partNumber, value) => attempt(() => inner.uploadPart(partNumber, value)),
5396
+ // ponytail: a multipart object's etag stays the transport's sha256; workerd's is
5397
+ // MD5-of-part-MD5s-N. Add when an app compares multipart etags.
5398
+ complete: (parts) => attempt(() => __sbR2StrictObject(__sbRpc("r2.multipart.complete", { bucket: name, key, uploadId, parts: parts || [] }).object, null)),
5399
+ abort: () => attempt(() => inner.abort()),
5400
+ };
5401
+ };
5402
+ return {
5403
+ head(key) {
5404
+ return attempt(() => {
5405
+ const meta = head(__sbR2Key(key, "head"));
5406
+ return meta ? __sbR2StrictObject(meta, null, { offset: 0, length: meta.size }) : null;
5407
+ });
5408
+ },
5409
+ get(key, options) {
5410
+ return attempt(() => {
5411
+ const o = options || {};
5412
+ const r = __sbR2Get(name, __sbR2Key(key, "get"));
5413
+ if (!r.found) return null;
5414
+ const meta = r.object;
5415
+ if (!__sbR2OnlyIf(o.onlyIf, meta.md5 || meta.etag, meta.uploaded)) return __sbR2StrictObject(meta, null, { offset: 0, length: meta.size });
5416
+ const bytes = r.body == null ? "" : r.body;
5417
+ const range = o.range == null ? { offset: 0, length: meta.size } : __sbR2Range(o.range, meta.size);
5418
+ return __sbR2StrictObject(meta, bytes.slice(range.offset, range.offset + range.length), range);
5419
+ });
5420
+ },
5421
+ async put(key, value, options) {
5422
+ const objectKey = __sbR2Key(key, "put");
5423
+ const o = options || {};
5424
+ value = __sbUnwrapBody(value);
5425
+ if (value instanceof Blob) value = await value.arrayBuffer();
5426
+ else if (value != null && __sbIsFn(value.getReader)) value = await new Response(value).arrayBuffer();
5427
+ let bytes;
5428
+ if (value == null) bytes = "";
5429
+ else if (__sbIsStr(value)) bytes = __sbToBytes(value);
5430
+ else if (value.byteLength !== undefined)
5431
+ bytes = __sbToBytes(value.buffer !== undefined ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value));
5432
+ else
5433
+ throw new TypeError("Failed to execute 'put' on 'R2Bucket': parameter 2 is not of type 'ReadableStream or ArrayBuffer or ArrayBufferView or string or Blob'.");
5434
+ if (o.onlyIf != null) {
5435
+ // ponytail: check, then write; one worker per app, so nothing writes in between.
5436
+ const current = head(objectKey);
5437
+ if (current && !__sbR2OnlyIf(o.onlyIf, current.md5 || current.etag, current.uploaded)) return null;
5438
+ if (!current && o.onlyIf.etagMatches != null) return null;
5439
+ }
5440
+ const http = __sbR2Http(o.httpMetadata);
5441
+ const custom = o.customMetadata || {};
5442
+ const meta = __sbR2Put(name, objectKey, bytes, http, custom).object;
5443
+ meta.md5 = __sbR2HexOf(__sbDigestRaw("5", bytes));
5444
+ __sbRpc("r2.md5", { bucket: name, key: objectKey, etag: meta.etag, md5: meta.md5 });
5445
+ meta.httpMetadata = http;
5446
+ meta.customMetadata = custom;
5447
+ return __sbR2StrictObject(meta, null, undefined);
5448
+ },
5449
+ delete(keys) {
5450
+ return attempt(() => {
5451
+ const list = Array.isArray(keys) ? keys : [keys];
5452
+ for (let i = 0; i < list.length; i++) __sbRpc("r2.delete", { bucket: name, key: __sbR2Key(list[i], "delete") });
5453
+ });
5454
+ },
5455
+ list(options) {
5456
+ return attempt(() => {
5457
+ const o = options || {};
5458
+ const r = __sbRpc("r2.list", {
5459
+ bucket: name,
5460
+ page: true,
5461
+ prefix: o.prefix == null ? "" : String(o.prefix),
5462
+ cursor: o.cursor == null ? "" : String(o.cursor),
5463
+ limit: o.limit == null ? 1000 : Number(o.limit),
5464
+ delimiter: o.delimiter == null ? "" : String(o.delimiter),
5465
+ });
5466
+ const include = o.include || [];
5467
+ const objects = [];
5468
+ for (let j = 0; j < (r.objects || []).length; j++) {
5469
+ const obj = __sbR2StrictObject(r.objects[j], null, undefined);
5470
+ if (include.indexOf("httpMetadata") < 0) obj.httpMetadata = {};
5471
+ if (include.indexOf("customMetadata") < 0) obj.customMetadata = {};
5472
+ objects.push(obj);
5473
+ }
5474
+ return { objects, truncated: !!r.truncated, cursor: r.truncated ? r.cursor : undefined, delimitedPrefixes: r.delimitedPrefixes || [] };
5475
+ });
5476
+ },
5477
+ createMultipartUpload(key, options) {
5478
+ return attempt(() => {
5479
+ const objectKey = __sbR2Key(key, "createMultipartUpload");
5480
+ const o = options || {};
5481
+ const r = __sbRpc("r2.multipart.create", {
5482
+ bucket: name,
5483
+ key: objectKey,
5484
+ httpMetadata: __sbR2Http(o.httpMetadata),
5485
+ customMetadata: o.customMetadata || {},
5486
+ });
5487
+ return upload(objectKey, r.uploadId);
5488
+ });
5489
+ },
5490
+ resumeMultipartUpload(key, uploadId) {
5491
+ return upload(String(key), String(uploadId));
5492
+ },
5493
+ };
5494
+ }
5495
+
5121
5496
  // Asset rules use the same parsed manifest in both transports. Matching mirrors
5122
5497
  // @sproutboat/assets; parity tests cover the portable native implementation.
5123
5498
  function __sbAssetRedirect(rule, path) {
@@ -5185,7 +5560,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5185
5560
 
5186
5561
  for (let i = 0; i < (bindings.kv || []).length; i++) {
5187
5562
  const ns = bindings.kv[i];
5188
- target[ns] = {
5563
+ target[ns] = __sbKvStrict() ? __sbMakeKv(ns) : {
5189
5564
  // #233 — Workers' get(key, type | { type }): "text" (default), "json",
5190
5565
  // "arrayBuffer". A value put as bytes is stored one char per byte and
5191
5566
  // flagged, so text decodes it as UTF-8 and arrayBuffer returns it
@@ -5244,7 +5619,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
5244
5619
 
5245
5620
  for (let i = 0; i < (bindings.r2 || []).length; i++) {
5246
5621
  const name = bindings.r2[i];
5247
- target[name] = {
5622
+ target[name] = __sbR2Strict() ? __sbMakeR2(name) : {
5248
5623
  put(key, value, options) {
5249
5624
  value = __sbUnwrapBody(value);
5250
5625
  const o = options || {};