@dee-wan/cache 1.0.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dee-wan-cms
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # @dee-wan/cache
2
+
3
+ Prisma read cache for Cloudflare Workers and D1. A Durable Object coordinates invalidation, fill locks
4
+ and cached values, so a write through one Worker is seen by every other Worker on its next read.
5
+
6
+ The contract, including every invalidation rule and limit, is in
7
+ [docs/cache-contract.md](docs/cache-contract.md).
8
+
9
+ ## Generate the config
10
+
11
+ ```prisma
12
+ generator cache {
13
+ provider = "dee-wan-cache-generator"
14
+ output = "../src/generated/cache"
15
+ exclude = ["AuditLog"] // optional
16
+ }
17
+
18
+ /// @cache.skip
19
+ model Session {
20
+ id String @id
21
+ }
22
+ ```
23
+
24
+ Run `npx prisma generate` (or an npm script that runs `prisma generate`) to write `cache-config.ts`.
25
+ The provider name resolves through `node_modules/.bin`, so the Prisma binary must run with npm's `PATH`.
26
+ Every model is cacheable unless it carries `/// @cache.skip` or is listed in `exclude`. Reads that
27
+ include an opted-out model are not cached. Regenerate after upgrading this package: the extension
28
+ refuses a config of another format.
29
+
30
+ ## Wire it into a Worker
31
+
32
+ ```ts
33
+ import { PrismaD1 } from "@prisma/adapter-d1";
34
+ import { createCacheExtension } from "@dee-wan/cache";
35
+ import { createDurableObjectStore } from "@dee-wan/cache/cloudflare";
36
+ import { cacheConfig } from "./generated/cache/cache-config";
37
+ import { Prisma, PrismaClient } from "./generated/prisma";
38
+
39
+ export { CacheCoordinator } from "@dee-wan/cache/cloudflare";
40
+
41
+ export default {
42
+ async fetch(request, env, ctx) {
43
+ const prisma = new PrismaClient({ adapter: new PrismaD1(env.DB) }).$extends(
44
+ createCacheExtension({
45
+ config: cacheConfig,
46
+ decimal: Prisma.Decimal,
47
+ enabled: env.CACHE_ENABLED !== "false",
48
+ generation: env.CACHE_GENERATION,
49
+ scope: "public",
50
+ store: createDurableObjectStore(env.CACHE_COORDINATOR, { name: "main-db" }),
51
+ waitUntil: (promise) => ctx.waitUntil(promise),
52
+ }),
53
+ );
54
+ // ...
55
+ },
56
+ } satisfies ExportedHandler<Env>;
57
+ ```
58
+
59
+ ```jsonc
60
+ {
61
+ "durable_objects": { "bindings": [{ "name": "CACHE_COORDINATOR", "class_name": "CacheCoordinator" }] },
62
+ "migrations": [{ "tag": "cache-v1", "new_sqlite_classes": ["CacheCoordinator"] }]
63
+ }
64
+ ```
65
+
66
+ Prisma runs query extensions outermost-first in the order they were applied (verified on Prisma 6.19.3).
67
+ Apply per-caller extensions such as prisma-guard before the cache:
68
+
69
+ ```ts
70
+ const client = base.$extends(guardFor(caller)).$extends(createCacheExtension({ ...options, scope }));
71
+ ```
72
+
73
+ They then run on every read, including hits, and the arguments they rewrite (for example a tenant
74
+ filter) are part of the cache key. An extension applied after the cache is skipped on a hit. Use
75
+ `scope` for anything a caller changes about results without changing the arguments.
76
+
77
+ ## Options
78
+
79
+ Invalid options throw a `TypeError` at construction.
80
+
81
+ | Option | Default | Meaning |
82
+ |---|---|---|
83
+ | `config` | — | Generated config. |
84
+ | `store` | — | `createDurableObjectStore(namespace, { name, attempts?, locationHint? })`. |
85
+ | `decimal` | none | `Prisma.Decimal`. Required when the schema has Decimal fields. |
86
+ | `enabled` | `true` | `false`: reads and writes skip the coordinator. `$cacheInvalidate` still calls it. |
87
+ | `scope` | `""` | Part of every key. |
88
+ | `generation` | none | 1..256 chars, the same for every Worker bound to the coordinator. Changing it invalidates every entry once. |
89
+ | `excludeModels` | `[]` | Runtime opt-out on top of the generated config. |
90
+ | `shape` | fresh 3600 s, negative 600 s, soft ratio 0.8 | `{ default, byModel }`. TTLs must be at most 28,747,636 s. |
91
+ | `waitUntil` | none | Enables refresh after the soft TTL. |
92
+ | `leaseMs` | 60000 | Write lease, at most 600000. |
93
+ | `lockMs` | 30000 | Fill lock, at most 300000. |
94
+ | `fillPollDelaysMs` | 20, 40, 80, 160, 320, 640 | Waits while another request fills the same key. Readers stop waiting once the holder has held the lock longer than the whole budget. |
95
+ | `maxValueBytes` | 1500000 | Larger results are served but not cached. At most 1900000. |
96
+ | `onError` | none | Receives coordinator, serialization and write errors. |
97
+
98
+ `createDurableObjectStore(namespace, { name, attempts, locationHint })`:
99
+
100
+ - `name` (required) identifies the D1 database. Keys do not include the database, so two databases
101
+ sharing one coordinator name would share cached rows. Use one name per database.
102
+ - `attempts` (default 3, at most 10) is the total number of tries for retryable coordinator errors on
103
+ begin, end and invalidate.
104
+ - `locationHint` (`wnam`, `enam`, `sam`, `weur`, `eeur`, `apac`, `oc`, `afr`, `me`) places the
105
+ coordinator on first creation, best effort.
106
+
107
+ ## Raw SQL writes
108
+
109
+ Raw SQL does not pass through the extension. Wrap it:
110
+
111
+ ```ts
112
+ await prisma.$cacheRawWrite({ models: ["Post"] }, () => env.DB.prepare("UPDATE Post SET ...").run());
113
+ await prisma.$cacheInvalidate({ entities: [{ model: "Post", pk: [42] }] });
114
+ await prisma.$cacheInvalidate({ all: true });
115
+ ```
116
+
117
+ Models that reference the target through a foreign key are invalidated too. Unknown model names throw.
118
+
119
+ ## Behaviour to plan for
120
+
121
+ - Coordinator unreachable on a read: the read goes to D1 and nothing is cached.
122
+ - Coordinator unreachable or overloaded before a write: `CacheUnavailableError` is thrown and the write
123
+ does not run. Cache reads and writes share one coordinator, so heavy read load can refuse writes.
124
+ - Writes made while `enabled: false` are not tracked. Change `generation` when turning the cache back
125
+ on, or call `$cacheInvalidate({ all: true })`. Never run a disabled and an enabled version together.
126
+ - A new schema or package version invalidates every entry on first contact. Workers of different
127
+ versions bound to one coordinator keep invalidating each other until one version remains.
128
+ - Rolling back to a release with an older coordinator storage schema makes every write fail with
129
+ `CacheUnavailableError`. Roll forward.
130
+ - Reads inside batch `$transaction([...])` are not cached. Prisma's D1 adapter rejects interactive
131
+ transactions.
132
+ - Reads through D1 `withSession` replicas are not supported.
133
+ - One coordinator Durable Object has a soft limit of 1,000 requests per second. Measured on
134
+ 2026-09-17 from one client: about 1,030 cached reads per second at 21 ms p50 with no errors, against
135
+ about 340 per second at 215 ms reading D1 directly. Higher rates are not measured.
136
+ - Each cached hit costs one coordinator call; a miss costs two; a contended miss can cost up to seven;
137
+ each write costs two, plus retries. On the Workers Free plan, requests that make many Prisma calls
138
+ can reach the subrequest limit (see the contract, "Cost per Prisma call").
139
+ - Every write to a model invalidates that model's cached lists, counts and relation reads.
140
+ - Cached entries are capped at 1 GiB per coordinator; the entries closest to expiry are evicted first.
141
+ - Every cached read makes a round trip to the one coordinator location. Measured from Asia on
142
+ 2026-09-17: hits took 8–14 ms with the coordinator in `apac` and 171–210 ms with it in `weur`, slower
143
+ than reading a nearby D1 directly (48–103 ms). Place the coordinator near most traffic with
144
+ `locationHint`; users far from it can get slower reads than without the cache.
145
+
146
+ ## Runtime requirements
147
+
148
+ - Cloudflare Workers with a SQLite-backed Durable Object and D1. No `nodejs_compat` needed.
149
+ - Prisma 6.19.3 or newer 6.x with `@prisma/adapter-d1` (tested with 6.19.3).
150
+ - Generator: Node 20 or newer (CI runs it on 20, 22 and 24). Wrangler 4 and Miniflare need Node 22.
151
+
152
+ ## Development
153
+
154
+ ```sh
155
+ npm run build
156
+ npm run lint
157
+ npm run typecheck # builds, generates the test client, then checks src and tests
158
+ npm run test:unit # Node 22.5+ (node:sqlite)
159
+ npm run test:workerd
160
+ npm run test:consumer # packs, installs into a fresh Worker project, runs it on workerd
161
+ ```
162
+
163
+ Deployed smoke (only on a disposable account; the test Worker runs arbitrary SQL for callers holding
164
+ the token):
165
+
166
+ ```sh
167
+ npm run test:prepare
168
+ npx wrangler d1 create <name> # put the id into a copy of test/deployed/wrangler.example.jsonc
169
+ npx wrangler d1 execute <name> --remote --file test/workerd/.build/schema.sql
170
+ npx wrangler deploy -c <copy> --var SMOKE_TOKEN:<random>
171
+ CACHE_SMOKE_URL=https://<worker> CACHE_SMOKE_TOKEN=<random> npm run test:deployed
172
+ npx wrangler delete --name <worker> && npx wrangler d1 delete <name>
173
+ ```
174
+
175
+ `test/workerd/.build/schema.sql` is written by `npm run test:workerd`.
@@ -0,0 +1,21 @@
1
+ // src/core/fences.ts
2
+ var GLOBAL_FENCE = "g";
3
+ var modelFence = (model) => `m:${model}`;
4
+ var writeFence = (model) => `w:${model}`;
5
+ var entityFence = (model, pk) => `e:${model}:${pk}`;
6
+
7
+ // src/core/limits.ts
8
+ var MAX_LOCK_MS = 3e5;
9
+ var MAX_LEASE_MS = 6e5;
10
+ var MAX_VALUE_BYTES = 19e5;
11
+ var MAX_GENERATION_LENGTH = 256;
12
+ var MAX_COORDINATOR_GENERATION_LENGTH = 512;
13
+ var MAX_TTL_MS = 366 * 24 * 60 * 60 * 1e3;
14
+ var TTL_JITTER_RATIO = 0.1;
15
+ var MAX_SHAPE_TTL_SECONDS = Math.floor(MAX_TTL_MS / 1e3 / (1 + TTL_JITTER_RATIO));
16
+ var CONFIG_FORMAT = 2;
17
+ var PLANNER_VERSION = 2;
18
+
19
+ export { CONFIG_FORMAT, GLOBAL_FENCE, MAX_COORDINATOR_GENERATION_LENGTH, MAX_GENERATION_LENGTH, MAX_LEASE_MS, MAX_LOCK_MS, MAX_SHAPE_TTL_SECONDS, MAX_TTL_MS, MAX_VALUE_BYTES, PLANNER_VERSION, TTL_JITTER_RATIO, entityFence, modelFence, writeFence };
20
+ //# sourceMappingURL=chunk-OQRNVGQE.js.map
21
+ //# sourceMappingURL=chunk-OQRNVGQE.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/core/fences.ts","../src/core/limits.ts"],"names":[],"mappings":";AAAO,IAAM,YAAA,GAAe;AAErB,IAAM,UAAA,GAAa,CAAC,KAAA,KAA0B,CAAA,EAAA,EAAK,KAAK,CAAA;AAExD,IAAM,UAAA,GAAa,CAAC,KAAA,KAA0B,CAAA,EAAA,EAAK,KAAK,CAAA;AAExD,IAAM,cAAc,CAAC,KAAA,EAAe,OAAuB,CAAA,EAAA,EAAK,KAAK,IAAI,EAAE,CAAA;;;ACN3E,IAAM,WAAA,GAAc;AACpB,IAAM,YAAA,GAAe;AACrB,IAAM,eAAA,GAAkB;AACxB,IAAM,qBAAA,GAAwB;AAC9B,IAAM,iCAAA,GAAoC;AAC1C,IAAM,UAAA,GAAa,GAAA,GAAM,EAAA,GAAK,EAAA,GAAK,EAAA,GAAK;AACxC,IAAM,gBAAA,GAAmB;AACzB,IAAM,wBAAwB,IAAA,CAAK,KAAA,CAAM,UAAA,GAAa,GAAA,IAAQ,IAAI,gBAAA,CAAiB;AACnF,IAAM,aAAA,GAAgB;AACtB,IAAM,eAAA,GAAkB","file":"chunk-OQRNVGQE.js","sourcesContent":["export const GLOBAL_FENCE = \"g\";\n\nexport const modelFence = (model: string): string => `m:${model}`;\n\nexport const writeFence = (model: string): string => `w:${model}`;\n\nexport const entityFence = (model: string, pk: string): string => `e:${model}:${pk}`;\n","export const MAX_LOCK_MS = 300_000;\nexport const MAX_LEASE_MS = 600_000;\nexport const MAX_VALUE_BYTES = 1_900_000;\nexport const MAX_GENERATION_LENGTH = 256;\nexport const MAX_COORDINATOR_GENERATION_LENGTH = 512;\nexport const MAX_TTL_MS = 366 * 24 * 60 * 60 * 1000;\nexport const TTL_JITTER_RATIO = 0.1;\nexport const MAX_SHAPE_TTL_SECONDS = Math.floor(MAX_TTL_MS / 1000 / (1 + TTL_JITTER_RATIO));\nexport const CONFIG_FORMAT = 2;\nexport const PLANNER_VERSION = 2;\n"]}
@@ -0,0 +1,26 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { R as ReadRequest, a as ReadResponse, W as WriteRequest, b as WriteResult, c as ReleaseRequest, B as BeginWriteRequest, E as EndWriteRequest, C as CacheStore } from '../types-DwtU0KpX.js';
3
+
4
+ declare class CacheCoordinator<Env = unknown> extends DurableObject<Env> {
5
+ private readonly core;
6
+ constructor(ctx: DurableObjectState, env: Env);
7
+ read(request: ReadRequest): ReadResponse;
8
+ write(request: WriteRequest): WriteResult;
9
+ release(request: ReleaseRequest): void;
10
+ beginWrite(request: BeginWriteRequest): void;
11
+ endWrite(request: EndWriteRequest): void;
12
+ invalidate(fences: string[]): void;
13
+ }
14
+
15
+ declare const LOCATION_HINTS: readonly ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
16
+ type CoordinatorLocationHint = (typeof LOCATION_HINTS)[number];
17
+ interface DurableObjectStoreOptions {
18
+ attempts?: number;
19
+ locationHint?: CoordinatorLocationHint;
20
+ name: string;
21
+ sleep?: (ms: number) => Promise<void>;
22
+ }
23
+
24
+ declare function createDurableObjectStore<Env>(namespace: DurableObjectNamespace<CacheCoordinator<Env>>, options: DurableObjectStoreOptions): CacheStore;
25
+
26
+ export { CacheCoordinator, type CoordinatorLocationHint, type DurableObjectStoreOptions, createDurableObjectStore };
@@ -0,0 +1,406 @@
1
+ import { MAX_TTL_MS, MAX_VALUE_BYTES, MAX_COORDINATOR_GENERATION_LENGTH, MAX_LEASE_MS, MAX_LOCK_MS, GLOBAL_FENCE } from '../chunk-OQRNVGQE.js';
2
+ import { DurableObject } from 'cloudflare:workers';
3
+
4
+ // src/cloudflare/coordinator-core.ts
5
+ var FENCE_RETENTION_MS = 2 * MAX_LOCK_MS;
6
+ var SWEEP_LIMIT = 50;
7
+ var FENCE_COLLECT_LIMIT = 100;
8
+ var PURGE_LIMIT = 500;
9
+ var EVICTION_BATCH = 50;
10
+ var MAX_EVICTION_ROUNDS = 20;
11
+ var MAX_STORED_BYTES = 1024 * 1024 * 1024;
12
+ var MAX_KEY_LENGTH = 1024;
13
+ var MAX_TOKEN_LENGTH = 128;
14
+ var MAX_FENCE_LENGTH = 512;
15
+ var MAX_FENCES_PER_REQUEST = 5e3;
16
+ var MAX_BOUND_PARAMETERS = 90;
17
+ var MIGRATIONS = [
18
+ `
19
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value INTEGER NOT NULL);
20
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('sequence', 0);
21
+ CREATE TABLE IF NOT EXISTS fences (fence TEXT PRIMARY KEY, epoch INTEGER NOT NULL, updated_at INTEGER NOT NULL);
22
+ CREATE INDEX IF NOT EXISTS fences_by_updated ON fences (updated_at);
23
+ CREATE TABLE IF NOT EXISTS leases (token TEXT NOT NULL, fence TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY (token, fence));
24
+ CREATE INDEX IF NOT EXISTS leases_by_fence ON leases (fence, expires_at);
25
+ CREATE INDEX IF NOT EXISTS leases_by_expiry ON leases (expires_at);
26
+ CREATE TABLE IF NOT EXISTS locks (key TEXT PRIMARY KEY, token TEXT NOT NULL, expires_at INTEGER NOT NULL);
27
+ CREATE INDEX IF NOT EXISTS locks_by_expiry ON locks (expires_at);
28
+ CREATE TABLE IF NOT EXISTS entries (key TEXT PRIMARY KEY, value TEXT NOT NULL, snapshot TEXT NOT NULL, soft_expires_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
29
+ CREATE INDEX IF NOT EXISTS entries_by_expiry ON entries (expires_at);
30
+ CREATE TABLE IF NOT EXISTS entry_fences (fence TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY (fence, key));
31
+ CREATE INDEX IF NOT EXISTS entry_fences_by_key ON entry_fences (key);
32
+ `,
33
+ `
34
+ CREATE TABLE IF NOT EXISTS generation (id INTEGER PRIMARY KEY CHECK (id = 1), value TEXT NOT NULL);
35
+ `,
36
+ `
37
+ ALTER TABLE locks ADD COLUMN seq INTEGER NOT NULL DEFAULT 0;
38
+ ALTER TABLE locks ADD COLUMN kind TEXT NOT NULL DEFAULT 'fill';
39
+ ALTER TABLE locks ADD COLUMN acquired_at INTEGER NOT NULL DEFAULT 0;
40
+ ALTER TABLE entries ADD COLUMN size INTEGER NOT NULL DEFAULT 0;
41
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('gc_watermark', 0);
42
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('entry_bytes', 0);
43
+ CREATE TABLE IF NOT EXISTS gc_cursor (id INTEGER PRIMARY KEY CHECK (id = 1), updated_at INTEGER NOT NULL, fence TEXT NOT NULL);
44
+ INSERT OR IGNORE INTO gc_cursor (id, updated_at, fence) VALUES (1, -1, '');
45
+ CREATE INDEX IF NOT EXISTS fences_by_updated_fence ON fences (updated_at, fence);
46
+ `
47
+ ];
48
+ function chunks(items) {
49
+ const out = [];
50
+ for (let i = 0; i < items.length; i += MAX_BOUND_PARAMETERS) out.push(items.slice(i, i + MAX_BOUND_PARAMETERS));
51
+ return out;
52
+ }
53
+ var placeholders = (count) => Array.from({ length: count }, () => "?").join(",");
54
+ var clamp = (value, max) => Math.min(Math.max(Math.trunc(value), 1), max);
55
+ function assertRequest(condition, message) {
56
+ if (!condition) throw new TypeError(`cache coordinator: ${message}`);
57
+ }
58
+ var isKey = (value) => typeof value === "string" && value.length > 0 && value.length <= MAX_KEY_LENGTH;
59
+ var isFenceList = (value) => Array.isArray(value) && value.length <= MAX_FENCES_PER_REQUEST && value.every((fence) => typeof fence === "string" && fence.length > 0 && fence.length <= MAX_FENCE_LENGTH);
60
+ var isSnapshot = (value) => !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length <= MAX_FENCES_PER_REQUEST && Object.values(value).every((epoch) => Number.isSafeInteger(epoch));
61
+ var isGeneration = (value) => value === void 0 || typeof value === "string" && value.length > 0 && value.length <= MAX_COORDINATOR_GENERATION_LENGTH;
62
+ var isToken = (value) => typeof value === "string" && value.length > 0 && value.length <= MAX_TOKEN_LENGTH;
63
+ var isDuration = (value) => typeof value === "number" && Number.isFinite(value) && value > 0;
64
+ var byteLength = (text) => new TextEncoder().encode(text).byteLength;
65
+ function parseSnapshot(text) {
66
+ try {
67
+ const parsed = JSON.parse(text);
68
+ return isSnapshot(parsed) ? parsed : null;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ function migrate(sql, transact) {
74
+ transact(() => {
75
+ sql.exec("CREATE TABLE IF NOT EXISTS schema_version (id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL)");
76
+ const current = Number(sql.exec("SELECT version FROM schema_version WHERE id = 1").toArray()[0]?.version ?? 0);
77
+ if (current > MIGRATIONS.length) {
78
+ throw new Error(`cache coordinator: storage schema ${current} is newer than this code (${MIGRATIONS.length})`);
79
+ }
80
+ if (current === MIGRATIONS.length) return;
81
+ MIGRATIONS.slice(current).forEach((migration) => sql.exec(migration));
82
+ sql.exec("INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, ?)", MIGRATIONS.length);
83
+ });
84
+ }
85
+ function createCoordinatorCore(sql, newToken, transact, limits = {}) {
86
+ const maxStoredBytes = limits.maxStoredBytes ?? MAX_STORED_BYTES;
87
+ const rows = (query, ...bindings) => sql.exec(query, ...bindings).toArray();
88
+ const metaValue = (key) => Number(rows("SELECT value FROM meta WHERE key = ?", key)[0]?.value ?? 0);
89
+ function epochs(fences) {
90
+ const out = Object.fromEntries(fences.map((fence) => [fence, 0]));
91
+ for (const chunk of chunks([...new Set(fences)])) {
92
+ for (const row of rows(`SELECT fence, epoch FROM fences WHERE fence IN (${placeholders(chunk.length)})`, ...chunk)) {
93
+ out[String(row.fence)] = Number(row.epoch);
94
+ }
95
+ }
96
+ return out;
97
+ }
98
+ const matches = (snapshot, current) => {
99
+ const fences = Object.keys(snapshot);
100
+ return fences.length > 0 && fences.every((fence) => current[fence] === snapshot[fence]);
101
+ };
102
+ const pending = (fences, now) => chunks([...new Set(fences)]).some(
103
+ (chunk) => rows(`SELECT 1 FROM leases WHERE expires_at > ? AND fence IN (${placeholders(chunk.length)}) LIMIT 1`, now, ...chunk).length > 0
104
+ );
105
+ function writeLock(key, lockMs, now, kind) {
106
+ const token = newToken();
107
+ sql.exec(
108
+ "INSERT OR REPLACE INTO locks (key, token, expires_at, seq, kind, acquired_at) VALUES (?, ?, ?, ?, ?, ?)",
109
+ key,
110
+ token,
111
+ now + clamp(lockMs, MAX_LOCK_MS),
112
+ metaValue("sequence"),
113
+ kind,
114
+ now
115
+ );
116
+ return token;
117
+ }
118
+ function deleteEntries(keys) {
119
+ for (const chunk of chunks(keys)) {
120
+ const bytes = Number(rows(`SELECT COALESCE(SUM(size), 0) AS bytes FROM entries WHERE key IN (${placeholders(chunk.length)})`, ...chunk)[0]?.bytes ?? 0);
121
+ sql.exec(`DELETE FROM entry_fences WHERE key IN (${placeholders(chunk.length)})`, ...chunk);
122
+ sql.exec(`DELETE FROM entries WHERE key IN (${placeholders(chunk.length)})`, ...chunk);
123
+ if (bytes > 0) sql.exec("UPDATE meta SET value = MAX(0, value - ?) WHERE key = 'entry_bytes'", bytes);
124
+ }
125
+ }
126
+ function makeRoom(size) {
127
+ for (let round = 0; round < MAX_EVICTION_ROUNDS; round++) {
128
+ const overflow = metaValue("entry_bytes") + size - maxStoredBytes;
129
+ if (overflow <= 0) return true;
130
+ const candidates = rows("SELECT key, size FROM entries ORDER BY expires_at LIMIT ?", EVICTION_BATCH);
131
+ if (candidates.length === 0) break;
132
+ let freed = 0;
133
+ const victims = candidates.filter((row) => {
134
+ if (freed >= overflow) return false;
135
+ freed += Number(row.size);
136
+ return true;
137
+ });
138
+ deleteEntries(victims.map((row) => String(row.key)));
139
+ }
140
+ return metaValue("entry_bytes") + size <= maxStoredBytes;
141
+ }
142
+ function bump(fences, now) {
143
+ for (const fence of new Set(fences)) {
144
+ const next = Number(rows("UPDATE meta SET value = value + 1 WHERE key = 'sequence' RETURNING value")[0]?.value);
145
+ sql.exec(
146
+ "INSERT INTO fences (fence, epoch, updated_at) VALUES (?, ?, ?) ON CONFLICT (fence) DO UPDATE SET epoch = excluded.epoch, updated_at = excluded.updated_at",
147
+ fence,
148
+ next,
149
+ now
150
+ );
151
+ }
152
+ }
153
+ function purge(fences) {
154
+ let budget = PURGE_LIMIT;
155
+ for (const chunk of chunks([...new Set(fences)])) {
156
+ if (budget <= 0) return;
157
+ const keys = rows(
158
+ `SELECT DISTINCT key FROM entry_fences WHERE fence IN (${placeholders(chunk.length)}) LIMIT ?`,
159
+ ...chunk,
160
+ budget
161
+ );
162
+ deleteEntries(keys.map((row) => String(row.key)));
163
+ budget -= keys.length;
164
+ }
165
+ }
166
+ function collectFences(now) {
167
+ const cursor = rows("SELECT updated_at, fence FROM gc_cursor WHERE id = 1")[0];
168
+ const after = Number(cursor?.updated_at ?? -1);
169
+ const afterFence = String(cursor?.fence ?? "");
170
+ const batch = rows(
171
+ `SELECT fence, updated_at, epoch,
172
+ EXISTS (SELECT 1 FROM entry_fences WHERE entry_fences.fence = fences.fence) AS in_entries,
173
+ EXISTS (SELECT 1 FROM leases WHERE leases.fence = fences.fence) AS in_leases
174
+ FROM fences
175
+ WHERE (updated_at, fence) > (?, ?) AND updated_at <= ? AND fence >= ? AND fence < ?
176
+ ORDER BY updated_at, fence LIMIT ?`,
177
+ after,
178
+ afterFence,
179
+ now - FENCE_RETENTION_MS,
180
+ "e:",
181
+ "e;",
182
+ FENCE_COLLECT_LIMIT
183
+ );
184
+ const deletable = batch.filter((row) => Number(row.in_entries) === 0 && Number(row.in_leases) === 0);
185
+ for (const chunk of chunks(deletable.map((row) => String(row.fence)))) {
186
+ sql.exec(`DELETE FROM fences WHERE fence IN (${placeholders(chunk.length)})`, ...chunk);
187
+ }
188
+ const highestDeletedEpoch = Math.max(0, ...deletable.map((row) => Number(row.epoch)));
189
+ if (highestDeletedEpoch > 0) {
190
+ sql.exec("UPDATE meta SET value = MAX(value, ?) WHERE key = 'gc_watermark'", highestDeletedEpoch);
191
+ }
192
+ const last = batch[batch.length - 1];
193
+ const [nextAt, nextFence] = batch.length < FENCE_COLLECT_LIMIT || !last ? [-1, ""] : [Number(last.updated_at), String(last.fence)];
194
+ if (nextAt !== after || nextFence !== afterFence) {
195
+ sql.exec("UPDATE gc_cursor SET updated_at = ?, fence = ? WHERE id = 1", nextAt, nextFence);
196
+ }
197
+ }
198
+ function sweep(now) {
199
+ const expired = rows("SELECT key FROM entries WHERE expires_at <= ? LIMIT ?", now, SWEEP_LIMIT);
200
+ deleteEntries(expired.map((row) => String(row.key)));
201
+ sql.exec("DELETE FROM leases WHERE rowid IN (SELECT rowid FROM leases WHERE expires_at <= ? LIMIT ?)", now, SWEEP_LIMIT);
202
+ sql.exec("DELETE FROM locks WHERE rowid IN (SELECT rowid FROM locks WHERE expires_at <= ? LIMIT ?)", now, SWEEP_LIMIT);
203
+ collectFences(now);
204
+ }
205
+ function observeGeneration(generation, now) {
206
+ if (generation === void 0) return;
207
+ const current = rows("SELECT value FROM generation WHERE id = 1")[0]?.value;
208
+ if (current === generation) return;
209
+ bump([GLOBAL_FENCE], now);
210
+ sql.exec("INSERT OR REPLACE INTO generation (id, value) VALUES (1, ?)", generation);
211
+ }
212
+ function snapshotValid(snapshot) {
213
+ return snapshot !== null && matches(snapshot, epochs(Object.keys(snapshot)));
214
+ }
215
+ return {
216
+ beginWrite(request, now) {
217
+ assertRequest(
218
+ isFenceList(request.fences) && isDuration(request.leaseMs) && isGeneration(request.generation) && isToken(request.token),
219
+ "invalid beginWrite request"
220
+ );
221
+ transact(() => {
222
+ observeGeneration(request.generation, now);
223
+ bump(request.fences, now);
224
+ const expiresAt = now + clamp(request.leaseMs, MAX_LEASE_MS);
225
+ for (const fence of new Set(request.fences)) {
226
+ sql.exec("INSERT OR REPLACE INTO leases (token, fence, expires_at) VALUES (?, ?, ?)", request.token, fence, expiresAt);
227
+ }
228
+ });
229
+ },
230
+ endWrite(request, now) {
231
+ assertRequest(isFenceList(request.fences) && isToken(request.token), "invalid endWrite request");
232
+ transact(() => {
233
+ bump(request.fences, now);
234
+ sql.exec("DELETE FROM leases WHERE token = ?", request.token);
235
+ purge(request.fences);
236
+ sweep(now);
237
+ });
238
+ },
239
+ invalidate(fences, now) {
240
+ assertRequest(isFenceList(fences), "invalid invalidate request");
241
+ transact(() => {
242
+ bump(fences, now);
243
+ purge(fences);
244
+ sweep(now);
245
+ });
246
+ },
247
+ read(request, now) {
248
+ assertRequest(
249
+ isKey(request.key) && isFenceList(request.fences) && isDuration(request.lockMs) && isGeneration(request.generation),
250
+ "invalid read request"
251
+ );
252
+ return transact(() => {
253
+ observeGeneration(request.generation, now);
254
+ const entry = rows("SELECT value, snapshot, soft_expires_at, expires_at FROM entries WHERE key = ?", request.key)[0];
255
+ const lock = rows("SELECT expires_at, kind, acquired_at FROM locks WHERE key = ?", request.key)[0];
256
+ const lockLive = lock !== void 0 && Number(lock.expires_at) > now;
257
+ if (entry && Number(entry.expires_at) > now) {
258
+ const snapshot = parseSnapshot(String(entry.snapshot));
259
+ const current = epochs([...request.fences, ...Object.keys(snapshot ?? {})]);
260
+ if (snapshot && matches(snapshot, current)) {
261
+ const refreshable = request.refresh === true && now >= Number(entry.soft_expires_at) && !lockLive && !pending(request.fences, now);
262
+ return {
263
+ epochs: Object.fromEntries(request.fences.map((fence) => [fence, current[fence] ?? 0])),
264
+ kind: "hit",
265
+ refreshToken: refreshable ? writeLock(request.key, request.lockMs, now, "refresh") : null,
266
+ value: String(entry.value)
267
+ };
268
+ }
269
+ }
270
+ if (entry) deleteEntries([request.key]);
271
+ const isPending = pending(request.fences, now);
272
+ const heldByFill = lockLive && lock.kind !== "refresh";
273
+ const lockToken = isPending || heldByFill ? null : writeLock(request.key, request.lockMs, now, "fill");
274
+ return {
275
+ epochs: epochs(request.fences),
276
+ kind: "miss",
277
+ lockHeldForMs: heldByFill ? Math.max(0, now - Number(lock.acquired_at)) : null,
278
+ lockToken,
279
+ pending: isPending
280
+ };
281
+ });
282
+ },
283
+ release(request) {
284
+ assertRequest(isKey(request.key) && isToken(request.lockToken), "invalid release request");
285
+ transact(() => sql.exec("DELETE FROM locks WHERE key = ? AND token = ?", request.key, request.lockToken));
286
+ },
287
+ write(request, now) {
288
+ assertRequest(
289
+ typeof request.key === "string" && typeof request.lockToken === "string" && typeof request.value === "string" && isSnapshot(request.snapshot) && isDuration(request.ttlMs) && isDuration(request.softTtlMs),
290
+ "invalid write request"
291
+ );
292
+ return transact(() => {
293
+ const lock = rows("SELECT token, expires_at, seq FROM locks WHERE key = ?", request.key)[0];
294
+ if (!lock || lock.token !== request.lockToken || Number(lock.expires_at) <= now) return { ok: false, reason: "lock" };
295
+ sql.exec("DELETE FROM locks WHERE key = ?", request.key);
296
+ const snapshotText = JSON.stringify(request.snapshot);
297
+ const size = byteLength(request.value) + snapshotText.length + request.key.length;
298
+ const oversize = !isKey(request.key) || request.ttlMs > MAX_TTL_MS || byteLength(request.value) > MAX_VALUE_BYTES;
299
+ if (oversize) return { ok: false, reason: "invalid" };
300
+ const capturedMissing = Object.values(request.snapshot).some((epoch) => epoch === 0);
301
+ if (capturedMissing && Number(lock.seq) < metaValue("gc_watermark")) return { ok: false, reason: "fence" };
302
+ if (!snapshotValid(request.snapshot)) return { ok: false, reason: "fence" };
303
+ deleteEntries([request.key]);
304
+ if (!makeRoom(size)) return { ok: false, reason: "full" };
305
+ sql.exec(
306
+ "INSERT INTO entries (key, value, snapshot, soft_expires_at, expires_at, size) VALUES (?, ?, ?, ?, ?, ?)",
307
+ request.key,
308
+ request.value,
309
+ snapshotText,
310
+ now + Math.min(request.softTtlMs, request.ttlMs),
311
+ now + request.ttlMs,
312
+ size
313
+ );
314
+ sql.exec("UPDATE meta SET value = value + ? WHERE key = 'entry_bytes'", size);
315
+ for (const fence of Object.keys(request.snapshot)) {
316
+ sql.exec("INSERT INTO entry_fences (fence, key) VALUES (?, ?)", fence, request.key);
317
+ }
318
+ sweep(now);
319
+ return { ok: true };
320
+ });
321
+ }
322
+ };
323
+ }
324
+
325
+ // src/cloudflare/coordinator.ts
326
+ var CacheCoordinator = class extends DurableObject {
327
+ core;
328
+ constructor(ctx, env) {
329
+ super(ctx, env);
330
+ const transact = (run) => ctx.storage.transactionSync(run);
331
+ migrate(ctx.storage.sql, transact);
332
+ this.core = createCoordinatorCore(ctx.storage.sql, () => crypto.randomUUID(), transact);
333
+ }
334
+ read(request) {
335
+ return this.core.read(request, Date.now());
336
+ }
337
+ write(request) {
338
+ return this.core.write(request, Date.now());
339
+ }
340
+ release(request) {
341
+ this.core.release(request);
342
+ }
343
+ beginWrite(request) {
344
+ this.core.beginWrite(request, Date.now());
345
+ }
346
+ endWrite(request) {
347
+ this.core.endWrite(request, Date.now());
348
+ }
349
+ invalidate(fences) {
350
+ this.core.invalidate(fences, Date.now());
351
+ }
352
+ };
353
+
354
+ // src/cloudflare/retry.ts
355
+ var BASE_BACKOFF_MS = 50;
356
+ var isRetryable = (error) => !!error && typeof error === "object" && Reflect.get(error, "retryable") === true && Reflect.get(error, "overloaded") !== true;
357
+ async function retryTransient(call, attempts, sleep) {
358
+ for (let attempt = 0; ; attempt++) {
359
+ try {
360
+ return await call();
361
+ } catch (error) {
362
+ if (attempt + 1 >= attempts || !isRetryable(error)) throw error;
363
+ await sleep(BASE_BACKOFF_MS * Math.random() * 2 ** attempt);
364
+ }
365
+ }
366
+ }
367
+
368
+ // src/cloudflare/store-options.ts
369
+ var WRITE_PATH_ATTEMPTS = 3;
370
+ var MAX_COORDINATOR_NAME_LENGTH = 256;
371
+ var LOCATION_HINTS = ["wnam", "enam", "sam", "weur", "eeur", "apac", "oc", "afr", "me"];
372
+ var isLocationHint = (value) => LOCATION_HINTS.some((hint) => hint === value);
373
+ function resolveStoreOptions(options) {
374
+ const { attempts = WRITE_PATH_ATTEMPTS, locationHint, name } = options;
375
+ const problems = [
376
+ !(typeof name === "string" && name.length > 0 && name.length <= MAX_COORDINATOR_NAME_LENGTH) && `name must be 1..${MAX_COORDINATOR_NAME_LENGTH} characters and identify one D1 database`,
377
+ !(Number.isInteger(attempts) && attempts >= 1 && attempts <= 10) && "attempts must be an integer in 1..10",
378
+ locationHint !== void 0 && !isLocationHint(locationHint) && `locationHint must be one of ${LOCATION_HINTS.join(", ")}`
379
+ ].filter((problem) => typeof problem === "string");
380
+ if (problems.length > 0) throw new TypeError(`cache store: ${problems.join("; ")}`);
381
+ return {
382
+ attempts,
383
+ name,
384
+ sleep: options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))),
385
+ ...locationHint ? { locationHint } : {}
386
+ };
387
+ }
388
+
389
+ // src/cloudflare/store.ts
390
+ function createDurableObjectStore(namespace, options) {
391
+ const { attempts, locationHint, name, sleep } = resolveStoreOptions(options);
392
+ const stub = () => namespace.get(namespace.idFromName(name), locationHint ? { locationHint } : void 0);
393
+ const withRetry = (call) => retryTransient(() => call(stub()), attempts, sleep);
394
+ return {
395
+ beginWrite: (request) => withRetry(async (coordinator) => void await coordinator.beginWrite(request)),
396
+ endWrite: (request) => withRetry(async (coordinator) => void await coordinator.endWrite(request)),
397
+ invalidate: (fences) => withRetry(async (coordinator) => void await coordinator.invalidate(fences)),
398
+ read: async (request) => stub().read(request),
399
+ release: async (request) => void await stub().release(request),
400
+ write: async (request) => stub().write(request)
401
+ };
402
+ }
403
+
404
+ export { CacheCoordinator, createDurableObjectStore };
405
+ //# sourceMappingURL=index.js.map
406
+ //# sourceMappingURL=index.js.map