@sproutboat/wire 0.7.0 → 0.8.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 +6 -0
- package/package.json +1 -1
- package/src/broker.test.ts +20 -0
- package/src/broker.ts +40 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7287d9b: KV: native key expiration (baronunread/sproutboat#58). `env.KV.put(key, value, { expirationTtl })` / `{ expiration }` now match the Cloudflare Workers contract: `expirationTtl` is seconds from now (rejected below 60, matching CF), `expiration` is an absolute unix-seconds timestamp. An expired key reads as absent from `get()` and is omitted from `list()`; the row is hard-deleted lazily the next time it's touched by a get. No new transport op — both `expirationTtl` and `expiration` are optional fields on the existing `kv.put` frame, so an older broker/prelude pair that ignores them keeps working exactly as before.
|
|
8
|
+
|
|
3
9
|
## 0.7.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
package/src/broker.test.ts
CHANGED
|
@@ -50,6 +50,26 @@ test("KV put / get / list / delete, scoped to a bound namespace", async () => {
|
|
|
50
50
|
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "k" })).toMatchObject({ found: false });
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
test("KV expirationTtl under 60 seconds is rejected, matching CF", async () => {
|
|
54
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
55
|
+
await expect(b.dispatch({ op: "kv.put", ns: "CACHE", key: "k", value: "v", expirationTtl: 59 })).rejects.toThrow(
|
|
56
|
+
"at least 60 seconds",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a key past its expiration reads as absent and is omitted from list", async () => {
|
|
61
|
+
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
62
|
+
const past = Math.floor((Date.now() - 1000) / 1000);
|
|
63
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "expired", value: "v", expiration: past });
|
|
64
|
+
await b.dispatch({ op: "kv.put", ns: "CACHE", key: "fresh", value: "v", expirationTtl: 3600 });
|
|
65
|
+
expect(await b.dispatch({ op: "kv.get", ns: "CACHE", key: "expired" })).toEqual({
|
|
66
|
+
ok: true,
|
|
67
|
+
found: false,
|
|
68
|
+
value: null,
|
|
69
|
+
});
|
|
70
|
+
expect(await b.dispatch({ op: "kv.list", ns: "CACHE", prefix: "" })).toEqual({ ok: true, keys: ["fresh"] });
|
|
71
|
+
});
|
|
72
|
+
|
|
53
73
|
test("an unbound KV namespace is rejected", async () => {
|
|
54
74
|
const b = make({ bindings: { kv: ["CACHE"] } });
|
|
55
75
|
await expect(b.dispatch({ op: "kv.put", ns: "OTHER", key: "k", value: "v" })).rejects.toThrow("not bound");
|
package/src/broker.ts
CHANGED
|
@@ -264,8 +264,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
264
264
|
conn.exec("PRAGMA journal_mode = WAL");
|
|
265
265
|
conn.exec("PRAGMA synchronous = NORMAL");
|
|
266
266
|
conn.exec(
|
|
267
|
-
"CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
|
|
267
|
+
"CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
|
|
268
|
+
"expires_at INTEGER, PRIMARY KEY (ns, key))",
|
|
268
269
|
);
|
|
270
|
+
const kvColumns = conn.query<{ name: string }, []>("PRAGMA table_info(kv)").all();
|
|
271
|
+
if (!kvColumns.some((column) => column.name === "expires_at")) conn.exec("ALTER TABLE kv ADD COLUMN expires_at INTEGER");
|
|
269
272
|
// #56 — no `body` column: an object's bytes live in their own file under
|
|
270
273
|
// `r2Dir` (or an in-memory map for `:memory:`), never in the SQLite row.
|
|
271
274
|
// Doubling every object through SQLite's own page cache on top of the
|
|
@@ -470,29 +473,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
470
473
|
};
|
|
471
474
|
|
|
472
475
|
type KvStmts = {
|
|
473
|
-
get: Statement<{ value: string }, [string, string]>;
|
|
474
|
-
put: Statement<unknown, [string, string, string]>;
|
|
476
|
+
get: Statement<{ value: string; expires_at: number | null }, [string, string]>;
|
|
477
|
+
put: Statement<unknown, [string, string, string, number | null]>;
|
|
475
478
|
del: Statement<unknown, [string, string]>;
|
|
476
|
-
list: Statement<{ key: string }, [string, string]>;
|
|
479
|
+
list: Statement<{ key: string }, [string, string, number]>;
|
|
477
480
|
};
|
|
478
481
|
const kvStmtCache = new Map<Database, KvStmts>();
|
|
479
482
|
const kvStmts = (store: Database): KvStmts => {
|
|
480
483
|
let stmts = kvStmtCache.get(store);
|
|
481
484
|
if (!stmts) {
|
|
482
485
|
stmts = {
|
|
483
|
-
get: store.query<{ value: string }, [string, string]>(
|
|
486
|
+
get: store.query<{ value: string; expires_at: number | null }, [string, string]>(
|
|
487
|
+
"SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?",
|
|
488
|
+
),
|
|
484
489
|
put: store.query(
|
|
485
|
-
"INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3
|
|
490
|
+
"INSERT INTO kv (ns, key, value, expires_at) VALUES (?1, ?2, ?3, ?4) " +
|
|
491
|
+
"ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4",
|
|
486
492
|
),
|
|
487
493
|
del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
|
|
488
|
-
|
|
489
|
-
|
|
494
|
+
// #58 — an expired row is filtered out here; a periodic sweep to hard-delete
|
|
495
|
+
// it is a separate concern (ponytail: rows accumulate until touched by a
|
|
496
|
+
// get/list/put on the same key/prefix, add a cron sweep if that matters).
|
|
497
|
+
list: store.query<{ key: string }, [string, string, number]>(
|
|
498
|
+
"SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' AND (expires_at IS NULL OR expires_at > ?) ORDER BY key LIMIT 1000",
|
|
490
499
|
),
|
|
491
500
|
};
|
|
492
501
|
kvStmtCache.set(store, stmts);
|
|
493
502
|
}
|
|
494
503
|
return stmts;
|
|
495
504
|
};
|
|
505
|
+
/** CF's own floor. Cloudflare rejects `expirationTtl < 60`; matched here so a
|
|
506
|
+
* handler ported from Workers gets the same validation error either place. */
|
|
507
|
+
const MIN_KV_TTL_SECONDS = 60;
|
|
508
|
+
/** `{ expirationTtl }` (seconds from now) or `{ expiration }` (absolute unix
|
|
509
|
+
* seconds) -> ms epoch, or `null` for no expiry. */
|
|
510
|
+
const kvExpiresAt = (msg: Frame): number | null => {
|
|
511
|
+
if (isNumber(msg.expirationTtl)) {
|
|
512
|
+
if (msg.expirationTtl < MIN_KV_TTL_SECONDS) throw new Error(`expirationTtl must be at least ${MIN_KV_TTL_SECONDS} seconds`);
|
|
513
|
+
return Date.now() + msg.expirationTtl * 1000;
|
|
514
|
+
}
|
|
515
|
+
if (isNumber(msg.expiration)) return msg.expiration * 1000;
|
|
516
|
+
return null;
|
|
517
|
+
};
|
|
496
518
|
|
|
497
519
|
const bound =
|
|
498
520
|
(list: string[], kind: string) =>
|
|
@@ -1190,12 +1212,18 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1190
1212
|
return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
|
|
1191
1213
|
case "kv.get": {
|
|
1192
1214
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1215
|
+
const stmts = kvStmts(store);
|
|
1216
|
+
const row = stmts.get.get(part, str(msg.key));
|
|
1217
|
+
if (!row) return { ok: true, found: false, value: null };
|
|
1218
|
+
if (row.expires_at != null && row.expires_at <= Date.now()) {
|
|
1219
|
+
stmts.del.run(part, str(msg.key));
|
|
1220
|
+
return { ok: true, found: false, value: null };
|
|
1221
|
+
}
|
|
1222
|
+
return { ok: true, found: true, value: row.value };
|
|
1195
1223
|
}
|
|
1196
1224
|
case "kv.put": {
|
|
1197
1225
|
const { store, part } = storeFor("kv", requireKv(msg.ns));
|
|
1198
|
-
kvStmts(store).put.run(part, str(msg.key), str(msg.value));
|
|
1226
|
+
kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg));
|
|
1199
1227
|
return { ok: true };
|
|
1200
1228
|
}
|
|
1201
1229
|
case "kv.delete": {
|
|
@@ -1208,7 +1236,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
1208
1236
|
return {
|
|
1209
1237
|
ok: true,
|
|
1210
1238
|
keys: kvStmts(store)
|
|
1211
|
-
.list.all(part, str(msg.prefix))
|
|
1239
|
+
.list.all(part, str(msg.prefix), Date.now())
|
|
1212
1240
|
.map((r) => r.key),
|
|
1213
1241
|
};
|
|
1214
1242
|
}
|