@sproutboat/runtime 0.24.0 → 0.26.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 +51 -0
- package/LTX_REPLICATION.md +19 -4
- package/package.json +1 -1
- package/src/kv-shim.test.js +124 -0
- package/src/native-fetch-prelude.js +121 -1
- package/src/transport-embedded.js +279 -14
- package/test/ltx-restore.ts +110 -0
- package/test/native-d1.ts +248 -5
- package/test/native-ltx.ts +60 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.26.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9b15539: KV matches workerd from compatibility date `2026-10-25`:
|
|
8
|
+
|
|
9
|
+
- `get`, `getWithMetadata`, `put`, `delete` and `list` return Promises, and errors reject them.
|
|
10
|
+
- `get(key, type)` takes `"text"`, `"json"`, `"arrayBuffer"` and `"stream"`, and `get([keys])` returns a `Map`.
|
|
11
|
+
- `getWithMetadata(key, type)` returns `{ value, metadata, cacheStatus }`.
|
|
12
|
+
- `put(key, value, { expiration, expirationTtl, metadata })` takes strings, bytes and `ReadableStream`s, and stores the metadata.
|
|
13
|
+
- `list({ prefix, limit, cursor })` returns `{ keys: [{ name, expiration?, metadata? }], list_complete, cursor?, cacheStatus }` and pages at up to 1,000 keys.
|
|
14
|
+
- 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.`
|
|
15
|
+
|
|
16
|
+
Earlier dates keep the synchronous shim. Two changes apply at every date:
|
|
17
|
+
|
|
18
|
+
- **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`.
|
|
19
|
+
- **Both transports store KV metadata in a new column.** It's added to existing stores when they open.
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 1c36119: Create the LTX page-index scratch file inside the database's replication spool
|
|
24
|
+
instead of libc's global temporary directory, fixing capture initialization when
|
|
25
|
+
a worker's filesystem sandbox permits writes only beneath its own state directory.
|
|
26
|
+
The scratch file is private and immediately unlinked. If a kill lands before
|
|
27
|
+
unlink, the next capture-lock owner removes the stale regular index file before
|
|
28
|
+
recovery. After unlink, closing the writer or killing the process releases it.
|
|
29
|
+
Its anonymous transient disk use is not counted by the
|
|
30
|
+
directory-walk quota. Capture retains its existing explicit opt-in and compatibility
|
|
31
|
+
date of 2026-10-22; capture-off apps and earlier dates keep their existing behavior.
|
|
32
|
+
- 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.
|
|
33
|
+
|
|
34
|
+
## 0.25.0
|
|
35
|
+
|
|
36
|
+
### Minor Changes
|
|
37
|
+
|
|
38
|
+
- ef40955: The storage quota is a hard bound when LTX capture is on. A D1 database under capture can't be capped by pinning its page count: a same-size `UPDATE`, or a `DELETE`, still adds a file to the replication spool, and the spool counts toward the app's quota. So while the app is over its quota, every write to a captured database is refused with `D1_ERROR: storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota: SQLITE_FULL`. That covers `exec()` and `batch()` too.
|
|
39
|
+
|
|
40
|
+
- Reads, read-only batches and transaction control still run.
|
|
41
|
+
- No LTX file is ever dropped to make room: the policy fails closed.
|
|
42
|
+
- Writes resume once the app is under its quota again, at the next rescan: for example after the operator raises the quota, or the app deletes R2 or KV data. The freeze holds across a restart.
|
|
43
|
+
|
|
44
|
+
This freezes statements after the quota scan; it is not a hard filesystem cap. The write that crosses the quota is accepted.
|
|
45
|
+
|
|
46
|
+
Databases without capture are unchanged: they are pinned at their size, so deletes still work.
|
|
47
|
+
- 95f6bd7: A storage quota for standalone sprouts and sproutd. An app's data directory (KV, D1, R2, Durable Object storage, queues) holds at most `SB_STORAGE_MAX_BYTES` bytes; sproutd sets it per app, and it defaults to 10 GiB. Each D1 database is also capped at 10 GB, as in D1. Past the quota:
|
|
48
|
+
|
|
49
|
+
- writes fail with an error that says so, such as `KV put() failed: storage quota exceeded (this app stores N bytes; the limit is M)`, or `D1_ERROR: database or disk is full: SQLITE_FULL` for D1;
|
|
50
|
+
- reads and deletes keep working, so an app can make room (for D1, `DELETE` and then `VACUUM`).
|
|
51
|
+
|
|
52
|
+
This is a check after a scan, not a hard filesystem cap with zero overshoot. The size is measured at most once a second, by walking the directory, and a write is checked against the last measurement. The write that crosses the limit is accepted (an R2 object, say), and so is anything else written within that second. Writes after it are refused.
|
|
53
|
+
|
|
3
54
|
## 0.24.0
|
|
4
55
|
|
|
5
56
|
### Minor Changes
|
package/LTX_REPLICATION.md
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
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
|
@@ -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
|
//
|
|
@@ -5185,7 +5305,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
5185
5305
|
|
|
5186
5306
|
for (let i = 0; i < (bindings.kv || []).length; i++) {
|
|
5187
5307
|
const ns = bindings.kv[i];
|
|
5188
|
-
target[ns] = {
|
|
5308
|
+
target[ns] = __sbKvStrict() ? __sbMakeKv(ns) : {
|
|
5189
5309
|
// #233 — Workers' get(key, type | { type }): "text" (default), "json",
|
|
5190
5310
|
// "arrayBuffer". A value put as bytes is stored one char per byte and
|
|
5191
5311
|
// flagged, so text decodes it as UTF-8 and arrayBuffer returns it
|