@sproutboat/wire 0.7.0 → 0.9.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,24 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4a7fe62: Cache API: `caches.default` / `caches.open(name)` (baronunread/sproutboat#59), with `match()`/`put()`/`delete()` backed by both transports (broker's `cache.*` ops and the standalone embedded transport, so behavior matches whichever way the app deploys). Keyed on method+URL for v1 (no `Vary` support yet). `put()` throws for a non-GET request, matching the spec; a 206 status or an explicit `no-store`/`private` `Cache-Control` is a silent no-op rather than an error, matching Workers' own `caches.default`. TTL derives from `s-maxage` (preferred) or `max-age`; entries with neither have no expiry.
8
+
9
+ Deliberately synchronous, not Promise-returning: every other buffered-body method this runtime already ships (`text()`/`json()`/`arrayBuffer()`/`blob()`/`formData()`) is sync too, and this runtime's own coroutine machinery has a known, still-open memory-corruption issue tied specifically to nested `async function` calls accumulating across many requests (baronunread/sproutboat#168) — no reason to add more async surface here. `await caches.default.match(...)` still reads correctly: awaiting a non-promise value resolves to it immediately.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [6145afe]
14
+ - @sproutboat/assets@0.3.0
15
+
16
+ ## 0.8.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 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.
21
+
3
22
  ## 0.7.0
4
23
 
5
24
  ### 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.9.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,7 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@sproutboat/assets": "^0.2.0"
14
+ "@sproutboat/assets": "^0.3.0"
15
15
  },
16
16
  "exports": {
17
17
  ".": "./src/index.ts",
@@ -50,11 +50,87 @@ 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");
56
76
  });
57
77
 
78
+ test("cache.match on an empty cache is a plain miss", async () => {
79
+ const b = make();
80
+ expect(await b.dispatch({ op: "cache.match", key: "GET https://example.com/a" })).toEqual({
81
+ ok: true,
82
+ found: false,
83
+ });
84
+ });
85
+
86
+ test("cache: put then match round-trips status/headers/body, deletes work, ambient (no binding needed)", async () => {
87
+ const b = make();
88
+ const key = "GET https://example.com/a";
89
+ expect(
90
+ await b.dispatch({ op: "cache.put", key, status: 200, headers: { "content-type": "text/plain" }, body: "hi" }),
91
+ ).toEqual({ ok: true, stored: true });
92
+ expect(await b.dispatch({ op: "cache.match", key })).toEqual({
93
+ ok: true,
94
+ found: true,
95
+ status: 200,
96
+ headers: { "content-type": "text/plain" },
97
+ body: "hi",
98
+ });
99
+ expect(await b.dispatch({ op: "cache.delete", key })).toEqual({ ok: true, deleted: true });
100
+ expect(await b.dispatch({ op: "cache.match", key })).toEqual({ ok: true, found: false });
101
+ expect(await b.dispatch({ op: "cache.delete", key })).toEqual({ ok: true, deleted: false });
102
+ });
103
+
104
+ test("cache.put skips storage (silently) for a 206, no-store, or private response", async () => {
105
+ const b = make();
106
+ await b.dispatch({ op: "cache.put", key: "k1", status: 206, body: "partial" });
107
+ await b.dispatch({ op: "cache.put", key: "k2", status: 200, headers: { "cache-control": "no-store" }, body: "x" });
108
+ await b.dispatch({ op: "cache.put", key: "k3", status: 200, headers: { "cache-control": "private" }, body: "x" });
109
+ expect(await b.dispatch({ op: "cache.match", key: "k1" })).toEqual({ ok: true, found: false });
110
+ expect(await b.dispatch({ op: "cache.match", key: "k2" })).toEqual({ ok: true, found: false });
111
+ expect(await b.dispatch({ op: "cache.match", key: "k3" })).toEqual({ ok: true, found: false });
112
+ });
113
+
114
+ test("cache.put derives TTL from s-maxage over max-age; an expired entry reads as a miss", async () => {
115
+ const b = make();
116
+ await b.dispatch({
117
+ op: "cache.put",
118
+ key: "k",
119
+ status: 200,
120
+ headers: { "cache-control": "max-age=3600, s-maxage=0" },
121
+ body: "x",
122
+ });
123
+ // s-maxage=0 wins over max-age=3600 -> expires immediately.
124
+ expect(await b.dispatch({ op: "cache.match", key: "k" })).toEqual({ ok: true, found: false });
125
+ });
126
+
127
+ test("cache namespaces (caches.open) don't collide on the same key", async () => {
128
+ const b = make();
129
+ await b.dispatch({ op: "cache.put", name: "images", key: "k", status: 200, body: "img" });
130
+ expect(await b.dispatch({ op: "cache.match", name: "images", key: "k" })).toMatchObject({ found: true, body: "img" });
131
+ expect(await b.dispatch({ op: "cache.match", key: "k" })).toEqual({ ok: true, found: false });
132
+ });
133
+
58
134
  test("secrets resolve only when both bound and present", async () => {
59
135
  const b = make({ bindings: { secrets: ["API_KEY", "MISSING"] }, secrets: { API_KEY: "s3cr3t" } });
60
136
  expect(await b.dispatch({ op: "secret.get", name: "API_KEY" })).toEqual({ ok: true, value: "s3cr3t" });
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
@@ -329,6 +332,14 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
329
332
  "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
330
333
  "count INTEGER NOT NULL, PRIMARY KEY (name, key))",
331
334
  );
335
+ // #59 — caches.default: ambient, not a resource a project binds (no config
336
+ // key), so it lives in the per-deployment db like do_storage/ae rather than
337
+ // going through the account-level resource registry. Key on method+URL for
338
+ // v1, per the issue's own scoped-down ask; no Vary support yet.
339
+ db.exec(
340
+ "CREATE TABLE IF NOT EXISTS cache (name TEXT NOT NULL, cache_key TEXT NOT NULL, status INTEGER NOT NULL, " +
341
+ "headers_json TEXT NOT NULL, body TEXT NOT NULL, expires_at INTEGER, PRIMARY KEY (name, cache_key))",
342
+ );
332
343
 
333
344
  // #74 — one SQLite file per account-level resource id, opened on first use.
334
345
  const resourceDbs = new Map<string, Database>();
@@ -470,29 +481,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
470
481
  };
471
482
 
472
483
  type KvStmts = {
473
- get: Statement<{ value: string }, [string, string]>;
474
- put: Statement<unknown, [string, string, string]>;
484
+ get: Statement<{ value: string; expires_at: number | null }, [string, string]>;
485
+ put: Statement<unknown, [string, string, string, number | null]>;
475
486
  del: Statement<unknown, [string, string]>;
476
- list: Statement<{ key: string }, [string, string]>;
487
+ list: Statement<{ key: string }, [string, string, number]>;
477
488
  };
478
489
  const kvStmtCache = new Map<Database, KvStmts>();
479
490
  const kvStmts = (store: Database): KvStmts => {
480
491
  let stmts = kvStmtCache.get(store);
481
492
  if (!stmts) {
482
493
  stmts = {
483
- get: store.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
494
+ get: store.query<{ value: string; expires_at: number | null }, [string, string]>(
495
+ "SELECT value, expires_at FROM kv WHERE ns = ? AND key = ?",
496
+ ),
484
497
  put: store.query(
485
- "INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
498
+ "INSERT INTO kv (ns, key, value, expires_at) VALUES (?1, ?2, ?3, ?4) " +
499
+ "ON CONFLICT (ns, key) DO UPDATE SET value = ?3, expires_at = ?4",
486
500
  ),
487
501
  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",
502
+ // #58 — an expired row is filtered out here; a periodic sweep to hard-delete
503
+ // it is a separate concern (ponytail: rows accumulate until touched by a
504
+ // get/list/put on the same key/prefix, add a cron sweep if that matters).
505
+ list: store.query<{ key: string }, [string, string, number]>(
506
+ "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' AND (expires_at IS NULL OR expires_at > ?) ORDER BY key LIMIT 1000",
490
507
  ),
491
508
  };
492
509
  kvStmtCache.set(store, stmts);
493
510
  }
494
511
  return stmts;
495
512
  };
513
+ /** CF's own floor. Cloudflare rejects `expirationTtl < 60`; matched here so a
514
+ * handler ported from Workers gets the same validation error either place. */
515
+ const MIN_KV_TTL_SECONDS = 60;
516
+ /** `{ expirationTtl }` (seconds from now) or `{ expiration }` (absolute unix
517
+ * seconds) -> ms epoch, or `null` for no expiry. */
518
+ const kvExpiresAt = (msg: Frame): number | null => {
519
+ if (isNumber(msg.expirationTtl)) {
520
+ if (msg.expirationTtl < MIN_KV_TTL_SECONDS) throw new Error(`expirationTtl must be at least ${MIN_KV_TTL_SECONDS} seconds`);
521
+ return Date.now() + msg.expirationTtl * 1000;
522
+ }
523
+ if (isNumber(msg.expiration)) return msg.expiration * 1000;
524
+ return null;
525
+ };
496
526
 
497
527
  const bound =
498
528
  (list: string[], kind: string) =>
@@ -1190,12 +1220,18 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1190
1220
  return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
1191
1221
  case "kv.get": {
1192
1222
  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 };
1223
+ const stmts = kvStmts(store);
1224
+ const row = stmts.get.get(part, str(msg.key));
1225
+ if (!row) return { ok: true, found: false, value: null };
1226
+ if (row.expires_at != null && row.expires_at <= Date.now()) {
1227
+ stmts.del.run(part, str(msg.key));
1228
+ return { ok: true, found: false, value: null };
1229
+ }
1230
+ return { ok: true, found: true, value: row.value };
1195
1231
  }
1196
1232
  case "kv.put": {
1197
1233
  const { store, part } = storeFor("kv", requireKv(msg.ns));
1198
- kvStmts(store).put.run(part, str(msg.key), str(msg.value));
1234
+ kvStmts(store).put.run(part, str(msg.key), str(msg.value), kvExpiresAt(msg));
1199
1235
  return { ok: true };
1200
1236
  }
1201
1237
  case "kv.delete": {
@@ -1208,10 +1244,54 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1208
1244
  return {
1209
1245
  ok: true,
1210
1246
  keys: kvStmts(store)
1211
- .list.all(part, str(msg.prefix))
1247
+ .list.all(part, str(msg.prefix), Date.now())
1212
1248
  .map((r) => r.key),
1213
1249
  };
1214
1250
  }
1251
+ case "cache.match": {
1252
+ const name = str(msg.name) || "default";
1253
+ const key = str(msg.key);
1254
+ const row = db
1255
+ .query<
1256
+ { status: number; headers_json: string; body: string; expires_at: number | null },
1257
+ [string, string]
1258
+ >("SELECT status, headers_json, body, expires_at FROM cache WHERE name = ? AND cache_key = ?")
1259
+ .get(name, key);
1260
+ if (!row) return { ok: true, found: false };
1261
+ if (row.expires_at != null && row.expires_at <= Date.now()) {
1262
+ db.query("DELETE FROM cache WHERE name = ? AND cache_key = ?").run(name, key);
1263
+ return { ok: true, found: false };
1264
+ }
1265
+ return { ok: true, found: true, status: row.status, headers: jsonObject(parseJsonValue(row.headers_json)) ?? {}, body: row.body };
1266
+ }
1267
+ case "cache.put": {
1268
+ const name = str(msg.name) || "default";
1269
+ const key = str(msg.key);
1270
+ const status = Number(msg.status) || 200;
1271
+ const headers = jsonObject(msg.headers ?? null) ?? {};
1272
+ const cacheControl = str(headers["cache-control"] ?? headers["Cache-Control"] ?? "").toLowerCase();
1273
+ // Mirrors Workers' own `caches.default.put()`: a 206 or an explicit
1274
+ // no-store/private is a silent no-op, not an error — the handler's
1275
+ // response still goes to the client either way.
1276
+ if (status === 206 || cacheControl.includes("no-store") || cacheControl.includes("private")) {
1277
+ return { ok: true, stored: false };
1278
+ }
1279
+ const sMaxAge = /s-maxage=(\d+)/.exec(cacheControl);
1280
+ const maxAge = /max-age=(\d+)/.exec(cacheControl);
1281
+ const ttlSeconds = sMaxAge ? Number(sMaxAge[1]) : maxAge ? Number(maxAge[1]) : null;
1282
+ const expiresAt = ttlSeconds != null ? Date.now() + ttlSeconds * 1000 : null;
1283
+ db.query(
1284
+ "INSERT INTO cache (name, cache_key, status, headers_json, body, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
1285
+ "ON CONFLICT (name, cache_key) DO UPDATE SET status = ?3, headers_json = ?4, body = ?5, expires_at = ?6",
1286
+ ).run(name, key, status, JSON.stringify(headers), str(msg.body), expiresAt);
1287
+ return { ok: true, stored: true };
1288
+ }
1289
+ case "cache.delete": {
1290
+ const name = str(msg.name) || "default";
1291
+ const key = str(msg.key);
1292
+ const r = db.query("DELETE FROM cache WHERE name = ? AND cache_key = ?").run(name, key);
1293
+ return { ok: true, deleted: r.changes > 0 };
1294
+ }
1215
1295
  case "secret.get": {
1216
1296
  const name = str(msg.name);
1217
1297
  if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
@@ -1676,6 +1756,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1676
1756
  const REPLAYABLE = new Set([
1677
1757
  "kv.put",
1678
1758
  "kv.delete",
1759
+ "cache.put",
1760
+ "cache.delete",
1679
1761
  "queue.send",
1680
1762
  "queue.send_batch",
1681
1763
  "d1.query",