@sproutboat/wire 0.14.6 → 0.15.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 +25 -0
- package/package.json +2 -2
- package/src/broker.test.ts +29 -5
- package/src/broker.ts +44 -14
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.15.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
|
+
- Updated dependencies [9b15539]
|
|
24
|
+
- Updated dependencies [1c36119]
|
|
25
|
+
- Updated dependencies [3cad1f0]
|
|
26
|
+
- @sproutboat/runtime@0.26.0
|
|
27
|
+
|
|
3
28
|
## 0.14.6
|
|
4
29
|
|
|
5
30
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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.26.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 () => {
|
|
@@ -883,7 +907,7 @@ test("resource-backed KV persists across brokers and is shared by id", async ()
|
|
|
883
907
|
resourceDir,
|
|
884
908
|
bindings: { kv: ["SHORTENER"], resources: { SHORTENER: { kind: "kv", id: kvId } } },
|
|
885
909
|
});
|
|
886
|
-
expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).
|
|
910
|
+
expect(await second.dispatch({ op: "kv.get", ns: "SHORTENER", key: "a" })).toMatchObject({
|
|
887
911
|
ok: true,
|
|
888
912
|
found: true,
|
|
889
913
|
binary: false,
|
package/src/broker.ts
CHANGED
|
@@ -301,6 +301,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
301
301
|
// #233 — 1 when the value was put as bytes (stored one char per byte), so get()
|
|
302
302
|
// can hand back exact bytes or decoded text depending on the requested type.
|
|
303
303
|
if (!kvColumns.some((column) => column.name === "binary")) conn.exec("ALTER TABLE kv ADD COLUMN binary INTEGER NOT NULL DEFAULT 0");
|
|
304
|
+
// A key's metadata (put(key, value, { metadata })), as JSON text.
|
|
305
|
+
if (!kvColumns.some((column) => column.name === "metadata")) conn.exec("ALTER TABLE kv ADD COLUMN metadata TEXT");
|
|
304
306
|
// #56 — no `body` column: an object's bytes live in their own file under
|
|
305
307
|
// `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
|
|
306
308
|
// Doubling every object through SQLite's own page cache on top of the
|
|
@@ -521,29 +523,31 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
521
523
|
};
|
|
522
524
|
|
|
523
525
|
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]>;
|
|
526
|
+
get: Statement<{ value: string; expires_at: number | null; binary: number; metadata: string | null }, [string, string]>;
|
|
527
|
+
put: Statement<unknown, [string, string, string, number | null, number, string | null]>;
|
|
526
528
|
del: Statement<unknown, [string, string]>;
|
|
527
|
-
list: Statement<{ key: string }, [string, string, number]>;
|
|
529
|
+
list: Statement<{ key: string; expires_at: number | null; metadata: string | null }, [string, string, string, number, number]>;
|
|
528
530
|
};
|
|
529
531
|
const kvStmtCache = new Map<Database, KvStmts>();
|
|
530
532
|
const kvStmts = (store: Database): KvStmts => {
|
|
531
533
|
let stmts = kvStmtCache.get(store);
|
|
532
534
|
if (!stmts) {
|
|
533
535
|
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 = ?",
|
|
536
|
+
get: store.query<{ value: string; expires_at: number | null; binary: number; metadata: string | null }, [string, string]>(
|
|
537
|
+
"SELECT value, expires_at, binary, metadata FROM kv WHERE ns = ? AND key = ?",
|
|
536
538
|
),
|
|
537
539
|
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",
|
|
540
|
+
"INSERT INTO kv (ns, key, value, expires_at, binary, metadata) VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
|
|
541
|
+
"ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4, binary = ?5, metadata = ?6",
|
|
540
542
|
),
|
|
541
543
|
del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
|
|
542
544
|
// #58 — an expired row is filtered out here; a periodic sweep to hard-delete
|
|
543
545
|
// it is a separate concern (ponytail: rows accumulate until touched by a
|
|
544
546
|
// get/list/put on the same key/prefix, add a cron sweep if that matters).
|
|
545
|
-
|
|
546
|
-
|
|
547
|
+
// The prefix matches exactly: LIKE ignored case and read "_" and "%" as wildcards.
|
|
548
|
+
list: store.query<{ key: string; expires_at: number | null; metadata: string | null }, [string, string, string, number, number]>(
|
|
549
|
+
"SELECT key, expires_at, metadata FROM kv WHERE ns = ?1 AND substr(key, 1, length(?2)) = ?2 AND key > ?3 " +
|
|
550
|
+
"AND (expires_at IS NULL OR expires_at > ?4) ORDER BY key LIMIT ?5",
|
|
547
551
|
),
|
|
548
552
|
};
|
|
549
553
|
kvStmtCache.set(store, stmts);
|
|
@@ -1285,11 +1289,25 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1285
1289
|
stmts.del.run(part, str(msg.key));
|
|
1286
1290
|
return { ok: true, found: false, value: null };
|
|
1287
1291
|
}
|
|
1288
|
-
return {
|
|
1292
|
+
return {
|
|
1293
|
+
ok: true,
|
|
1294
|
+
found: true,
|
|
1295
|
+
value: row.value,
|
|
1296
|
+
binary: row.binary === 1,
|
|
1297
|
+
metadata: row.metadata,
|
|
1298
|
+
expiration: row.expires_at == null ? null : Math.floor(row.expires_at / 1000),
|
|
1299
|
+
};
|
|
1289
1300
|
}
|
|
1290
1301
|
case "kv.put": {
|
|
1291
1302
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1292
|
-
kvStmts(store).put.run(
|
|
1303
|
+
kvStmts(store).put.run(
|
|
1304
|
+
part,
|
|
1305
|
+
str(msg.key),
|
|
1306
|
+
str(msg.value),
|
|
1307
|
+
kvExpiresAt(msg),
|
|
1308
|
+
msg.binary === true ? 1 : 0,
|
|
1309
|
+
msg.metadata == null ? null : str(msg.metadata),
|
|
1310
|
+
);
|
|
1293
1311
|
return { ok: true };
|
|
1294
1312
|
}
|
|
1295
1313
|
case "kv.delete": {
|
|
@@ -1299,11 +1317,23 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1299
1317
|
}
|
|
1300
1318
|
case "kv.list": {
|
|
1301
1319
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1320
|
+
// With a limit (the shim from 2026-10-25): a page after the cursor (the previous page's
|
|
1321
|
+
// last key), with expirations and metadata. Without one: up to 1,000 keys, as before.
|
|
1322
|
+
const paged = isNumber(msg.limit);
|
|
1323
|
+
const limit = paged ? Math.max(0, Math.floor(Number(msg.limit))) : 1000;
|
|
1324
|
+
const rows = kvStmts(store).list.all(part, str(msg.prefix), paged ? str(msg.cursor) : "", Date.now(), paged ? limit + 1 : limit);
|
|
1325
|
+
const page = rows.slice(0, limit);
|
|
1326
|
+
const complete = !paged || rows.length <= limit;
|
|
1302
1327
|
return {
|
|
1303
1328
|
ok: true,
|
|
1304
|
-
keys:
|
|
1305
|
-
|
|
1306
|
-
|
|
1329
|
+
keys: page.map((r) => r.key),
|
|
1330
|
+
entries: page.map((r) => ({
|
|
1331
|
+
name: r.key,
|
|
1332
|
+
expiration: r.expires_at == null ? null : Math.floor(r.expires_at / 1000),
|
|
1333
|
+
metadata: r.metadata,
|
|
1334
|
+
})),
|
|
1335
|
+
list_complete: complete,
|
|
1336
|
+
cursor: complete ? "" : (page[page.length - 1]?.key ?? ""),
|
|
1307
1337
|
};
|
|
1308
1338
|
}
|
|
1309
1339
|
case "cache.match": {
|