@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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, PRIMARY KEY (ns, key))",
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]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
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) ON CONFLICT (ns, key) DO UPDATE SET value = ?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
- list: store.query<{ key: string }, [string, string]>(
489
- "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000",
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 row = kvStmts(store).get.get(part, str(msg.key));
1194
- return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false, value: null };
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
  }