@cloudflare/workers-response-store 0.0.0 → 0.1.0-beta.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.
@@ -0,0 +1,34 @@
1
+ import { ResponseStoreBinding, ResponseStoreMutationResult, ResponseStorePurgeOptions, ResponseStorePutOptions, ResponseStoreRefreshOptions, RevalidationInput, RevalidationReason, RevalidationService, SerializableValue, WorkersResponseStore, WorkersResponseStoreEnv } from "./binding.js";
2
+ import { CacheMetadata } from "./metadata-do.js";
3
+ import { WorkerEntrypoint } from "cloudflare:workers";
4
+ //#region src/index.d.ts
5
+ export type ResponseStoreRevalidationContext<Env> = {
6
+ env: Env;
7
+ ctx: ExecutionContext;
8
+ };
9
+ export type ResponseStoreRevalidatorEntrypoint<Env = WorkersResponseStoreEnv> = new (ctx: ExecutionContext, env: Env) => WorkerEntrypoint<Env> & RevalidationService;
10
+ type WorkersResponseStoreDefinition<Env extends WorkersResponseStoreEnv = WorkersResponseStoreEnv> = WorkersResponseStore & {
11
+ entrypoints: {
12
+ CacheMetadata: typeof CacheMetadata;
13
+ ResponseStoreRevalidator: ResponseStoreRevalidatorEntrypoint<Env>;
14
+ ResponseStoreBinding: typeof ResponseStoreBinding;
15
+ };
16
+ };
17
+ type WorkersResponseStoreClientDefinition<Env> = WorkersResponseStore & {
18
+ entrypoints: {
19
+ ResponseStoreRevalidator: ResponseStoreRevalidatorEntrypoint<Env>;
20
+ ResponseStoreClient: new (ctx: ExecutionContext, env: Env) => WorkerEntrypoint<Env> & WorkersResponseStore;
21
+ };
22
+ };
23
+ export type ResponseStoreClientEntrypoint<Env = WorkersResponseStoreClientEnv> = new (ctx: ExecutionContext, env: Env) => WorkerEntrypoint<Env> & WorkersResponseStore;
24
+ export type WorkersResponseStoreOptions<Env> = {
25
+ regenerate(input: RevalidationInput, context: ResponseStoreRevalidationContext<Env>): Response | Promise<Response>;
26
+ };
27
+ export declare function createWorkersResponseStore<Env extends WorkersResponseStoreEnv = WorkersResponseStoreEnv>(options: WorkersResponseStoreOptions<Env>): WorkersResponseStoreDefinition<Env>;
28
+ export type WorkersResponseStoreClientEnv = {
29
+ RESPONSE_STORE: Service;
30
+ CF_VERSION_METADATA: WorkerVersionMetadata;
31
+ };
32
+ export declare function createWorkersResponseStoreClient<Env extends WorkersResponseStoreClientEnv = WorkersResponseStoreClientEnv>(options: WorkersResponseStoreOptions<Env>): WorkersResponseStoreClientDefinition<Env>;
33
+ //#endregion
34
+ export type { ResponseStoreMutationResult, ResponseStorePurgeOptions, ResponseStorePutOptions, ResponseStoreRefreshOptions, RevalidationInput, RevalidationReason, SerializableValue, WorkersResponseStore, WorkersResponseStoreEnv };
package/dist/index.js ADDED
@@ -0,0 +1,82 @@
1
+ import { ResponseStoreBinding, getWorkersResponseStore } from "./binding.js";
2
+ import { CacheMetadata } from "./metadata-do.js";
3
+ import { WorkerEntrypoint, exports } from "cloudflare:workers";
4
+ //#region src/index.ts
5
+ function createRevalidatorEntrypoint(options) {
6
+ return class ResponseStoreRevalidator extends WorkerEntrypoint {
7
+ async regenerate(input) {
8
+ return options.regenerate(input, {
9
+ env: this.env,
10
+ ctx: this.ctx
11
+ });
12
+ }
13
+ };
14
+ }
15
+ function createStoreFacade(getStore) {
16
+ return {
17
+ fetch: (request) => getStore().fetch(request),
18
+ getTagExpiration: (tags) => getStore().getTagExpiration(tags),
19
+ put: (request, response, options) => getStore().put(request, response, options),
20
+ refresh: (options) => getStore().refresh(options),
21
+ purge: (options) => getStore().purge(options)
22
+ };
23
+ }
24
+ function createWorkersResponseStore(options) {
25
+ const ResponseStoreRevalidator = createRevalidatorEntrypoint(options);
26
+ const getStore = () => getWorkersResponseStore({ exports });
27
+ return {
28
+ entrypoints: {
29
+ CacheMetadata,
30
+ ResponseStoreRevalidator,
31
+ ResponseStoreBinding
32
+ },
33
+ ...createStoreFacade(getStore)
34
+ };
35
+ }
36
+ function createWorkersResponseStoreClient(options) {
37
+ const ResponseStoreRevalidator = createRevalidatorEntrypoint(options);
38
+ class ResponseStoreClient extends WorkerEntrypoint {
39
+ get service() {
40
+ return this.env.RESPONSE_STORE;
41
+ }
42
+ getInvocation() {
43
+ const factory = Reflect.get(this.ctx.exports, "ResponseStoreRevalidator");
44
+ if (typeof factory !== "function") throw new Error("The ResponseStoreRevalidator entrypoint is not exported");
45
+ const versionId = this.env.CF_VERSION_METADATA.id;
46
+ if (!versionId) throw new Error("Workers Response Store requires the user Worker version ID");
47
+ return {
48
+ versionId,
49
+ revalidator: factory({ props: {} })
50
+ };
51
+ }
52
+ fetch(request) {
53
+ return this.service.read(request, this.getInvocation());
54
+ }
55
+ getTagExpiration(tags) {
56
+ return this.service.getTagExpiration(tags, this.getInvocation());
57
+ }
58
+ put(request, response, putOptions = {}) {
59
+ return this.service.put(request, response, putOptions, this.getInvocation());
60
+ }
61
+ refresh(refreshOptions) {
62
+ return this.service.refresh(refreshOptions, this.getInvocation());
63
+ }
64
+ purge(purgeOptions) {
65
+ return this.service.purge(purgeOptions, this.getInvocation());
66
+ }
67
+ }
68
+ const getClient = () => {
69
+ const factory = Reflect.get(exports, "ResponseStoreClient");
70
+ if (typeof factory !== "function") throw new Error("The ResponseStoreClient entrypoint is not exported");
71
+ return factory({ props: {} });
72
+ };
73
+ return {
74
+ entrypoints: {
75
+ ResponseStoreRevalidator,
76
+ ResponseStoreClient
77
+ },
78
+ ...createStoreFacade(getClient)
79
+ };
80
+ }
81
+ //#endregion
82
+ export { createWorkersResponseStore, createWorkersResponseStoreClient };
@@ -0,0 +1,65 @@
1
+ import { CandidateMetadata, PurgedEntry, ResponseStorePurgeOptions, ResponseStoreRefreshOptions, StoredEntry } from "./binding.js";
2
+ import { DurableObject } from "cloudflare:workers";
3
+ //#region src/metadata-do.d.ts
4
+ type RevalidationClaim = {
5
+ claimId: string;
6
+ objectKey: string;
7
+ revision: number;
8
+ };
9
+ type PublicationResult = {
10
+ entry: StoredEntry | null;
11
+ published: boolean;
12
+ };
13
+ type WriteReservation = {
14
+ objectKey: string;
15
+ revision: number;
16
+ };
17
+ type RefreshCandidate = {
18
+ entry: StoredEntry;
19
+ reservation?: Pick<WriteReservation, "objectKey" | "revision">;
20
+ };
21
+ export type CacheMetadataStub = DurableObjectStub & {
22
+ reserveWrite(keyHash: string, cacheKey: string, objectKeyPrefix: string, createdAt: number): Promise<WriteReservation>;
23
+ claimRevalidation(keyHash: string, activeRevision: number, cacheKey: string, objectKeyPrefix: string, now: number, leaseMs: number): Promise<RevalidationClaim | null>;
24
+ trackPendingObject(objectKey: string, createdAt: number): Promise<void>;
25
+ trackPendingObjects(objectKeys: string[], createdAt: number): Promise<void>;
26
+ releaseWrite(keyHash: string, objectKey: string, claimId?: string): Promise<void>;
27
+ finishPendingObjects(objectKeys: string[]): Promise<void>;
28
+ listExpiredPendingObjects(cutoff: number, limit: number): Promise<string[]>;
29
+ sweepExpiredPendingObjects(cutoff?: number): Promise<number>;
30
+ publish(keyHash: string, revision: number, metadata: CandidateMetadata, claimId?: string): Promise<PublicationResult>;
31
+ getEntry(keyHash: string): Promise<StoredEntry | null>;
32
+ getTagExpiration(tags: string[]): Promise<number>;
33
+ reserveRefresh(options: ResponseStoreRefreshOptions, objectKeyRoot: string, createdAt: number): Promise<RefreshCandidate[]>;
34
+ purgeMatching(options: ResponseStorePurgeOptions): Promise<PurgedEntry[]>;
35
+ inspect(): Promise<StoredEntry[]>;
36
+ };
37
+ type CacheMetadataEnv = {
38
+ CACHE_BODIES: R2Bucket;
39
+ };
40
+ export declare class CacheMetadata extends DurableObject<CacheMetadataEnv> {
41
+ private cleanupAlarmKnown;
42
+ constructor(ctx: DurableObjectState, env: CacheMetadataEnv);
43
+ private ensureCleanupAlarm;
44
+ private maintainCleanupAlarm;
45
+ private findMatchingEntryRows;
46
+ trackPendingObjects(objectKeys: string[], createdAt: number): Promise<void>;
47
+ trackPendingObject(objectKey: string, createdAt: number): Promise<void>;
48
+ releaseWrite(keyHash: string, objectKey: string, claimId?: string): void;
49
+ finishPendingObjects(objectKeys: string[]): void;
50
+ private deleteTrackedObjects;
51
+ listExpiredPendingObjects(cutoff: number, limit: number): string[];
52
+ sweepExpiredPendingObjects(cutoff?: number): Promise<number>;
53
+ alarm(): Promise<void>;
54
+ claimRevalidation(keyHash: string, activeRevision: number, cacheKey: string, objectKeyPrefix: string, now: number, leaseMs: number): Promise<RevalidationClaim | null>;
55
+ private reserveRevision;
56
+ reserveWrite(keyHash: string, cacheKey: string, objectKeyPrefix: string, createdAt: number): Promise<WriteReservation>;
57
+ publish(keyHash: string, revision: number, metadata: CandidateMetadata, claimId?: string): Promise<PublicationResult>;
58
+ getEntry(keyHash: string): StoredEntry | null;
59
+ private getTagInvalidationMaximum;
60
+ getTagExpiration(tags: string[]): number;
61
+ reserveRefresh(options: ResponseStoreRefreshOptions, objectKeyRoot: string, createdAt: number): Promise<RefreshCandidate[]>;
62
+ purgeMatching(options: ResponseStorePurgeOptions): Promise<PurgedEntry[]>;
63
+ inspect(): StoredEntry[];
64
+ }
65
+ //#endregion
@@ -0,0 +1,447 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ //#region src/metadata-do.ts
3
+ const MAX_SQL_PARAMETERS = 100;
4
+ const R2_DELETE_BATCH_SIZE = 1e3;
5
+ const ORPHAN_RETENTION_MS = 36e5;
6
+ const ORPHAN_CLEANUP_LIMIT = 100;
7
+ const ORPHAN_CLEANUP_RETRY_MS = 6e4;
8
+ function normalizeTags(tags) {
9
+ const normalized = tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean);
10
+ return [...new Set(normalized)];
11
+ }
12
+ function* batches(values, size) {
13
+ for (let offset = 0; offset < values.length; offset += size) yield values.slice(offset, offset + size);
14
+ }
15
+ function storedEntryFromRow(row) {
16
+ if (row.tombstoned || row.active_revision === null || row.object_key === null || row.response_headers === null || row.fresh_until === null || row.swr_until === null) return null;
17
+ return {
18
+ keyHash: row.key_hash,
19
+ cacheKey: row.cache_key,
20
+ activeRevision: row.active_revision,
21
+ latestRevision: row.latest_revision,
22
+ objectKey: row.object_key,
23
+ statusText: row.status_text ?? "",
24
+ responseHeaders: JSON.parse(row.response_headers),
25
+ freshUntil: row.fresh_until,
26
+ swrUntil: row.swr_until,
27
+ revalidator: row.revalidator_id === null ? null : {
28
+ id: row.revalidator_id,
29
+ args: JSON.parse(row.revalidator_args ?? "[]")
30
+ },
31
+ cacheTags: JSON.parse(row.cache_tags ?? "[]")
32
+ };
33
+ }
34
+ function storedEntriesFromRows(rows) {
35
+ const entries = [];
36
+ for (const row of rows) {
37
+ const entry = storedEntryFromRow(row);
38
+ if (entry) entries.push(entry);
39
+ }
40
+ return entries;
41
+ }
42
+ var CacheMetadata = class extends DurableObject {
43
+ cleanupAlarmKnown = false;
44
+ constructor(ctx, env) {
45
+ super(ctx, env);
46
+ ctx.blockConcurrencyWhile(async () => {
47
+ ctx.storage.sql.exec(`
48
+ CREATE TABLE IF NOT EXISTS entries (
49
+ key_hash TEXT PRIMARY KEY,
50
+ cache_key TEXT NOT NULL,
51
+ active_revision INTEGER,
52
+ latest_revision INTEGER NOT NULL,
53
+ object_key TEXT,
54
+ status_text TEXT,
55
+ response_headers TEXT,
56
+ fresh_until INTEGER,
57
+ swr_until INTEGER,
58
+ revalidator_id TEXT,
59
+ revalidator_args TEXT,
60
+ cache_tags TEXT,
61
+ tombstoned INTEGER NOT NULL DEFAULT 0
62
+ );
63
+ CREATE INDEX IF NOT EXISTS entries_cache_key ON entries(cache_key);
64
+ CREATE TABLE IF NOT EXISTS revalidation_claims (
65
+ key_hash TEXT PRIMARY KEY,
66
+ active_revision INTEGER NOT NULL,
67
+ revision INTEGER NOT NULL,
68
+ claim_id TEXT NOT NULL,
69
+ claimed_at INTEGER NOT NULL,
70
+ expires_at INTEGER NOT NULL
71
+ );
72
+ CREATE TABLE IF NOT EXISTS tag_invalidations (
73
+ tag TEXT PRIMARY KEY,
74
+ invalidated_at INTEGER NOT NULL,
75
+ invalidation_sequence INTEGER NOT NULL DEFAULT 0
76
+ ) WITHOUT ROWID;
77
+ CREATE TABLE IF NOT EXISTS metadata_schema_migrations (
78
+ version INTEGER PRIMARY KEY
79
+ );
80
+ CREATE TABLE IF NOT EXISTS metadata_state (
81
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
82
+ tag_invalidation_sequence INTEGER NOT NULL
83
+ );
84
+ INSERT OR IGNORE INTO metadata_state (singleton, tag_invalidation_sequence) VALUES (1, 0);
85
+ CREATE TABLE IF NOT EXISTS pending_objects (
86
+ object_key TEXT PRIMARY KEY,
87
+ invalidation_sequence INTEGER NOT NULL DEFAULT 0,
88
+ publishable INTEGER NOT NULL DEFAULT 1,
89
+ created_at INTEGER NOT NULL
90
+ );
91
+ CREATE INDEX IF NOT EXISTS pending_objects_created_at ON pending_objects(created_at);
92
+ `);
93
+ ctx.storage.transactionSync(() => {
94
+ const migrations = new Set(ctx.storage.sql.exec("SELECT version FROM metadata_schema_migrations WHERE version IN (2, 3)").toArray().map(({ version }) => version));
95
+ if (migrations.size === 2) return;
96
+ const schemas = ctx.storage.sql.exec(`SELECT name, sql FROM sqlite_schema
97
+ WHERE type = 'table' AND name IN ('tag_invalidations', 'pending_objects')`).toArray();
98
+ if (!migrations.has(2) && !schemas.find(({ name }) => name === "tag_invalidations")?.sql.includes("invalidation_sequence")) ctx.storage.sql.exec("ALTER TABLE tag_invalidations ADD COLUMN invalidation_sequence INTEGER NOT NULL DEFAULT 0");
99
+ if (!migrations.has(2) && !schemas.find(({ name }) => name === "pending_objects")?.sql.includes("invalidation_sequence")) ctx.storage.sql.exec("ALTER TABLE pending_objects ADD COLUMN invalidation_sequence INTEGER NOT NULL DEFAULT 0");
100
+ if (!migrations.has(2)) ctx.storage.sql.exec("INSERT INTO metadata_schema_migrations (version) VALUES (2)");
101
+ if (!migrations.has(3)) {
102
+ if (!schemas.find(({ name }) => name === "pending_objects")?.sql.includes("publishable")) ctx.storage.sql.exec("ALTER TABLE pending_objects ADD COLUMN publishable INTEGER NOT NULL DEFAULT 1");
103
+ ctx.storage.sql.exec("INSERT INTO metadata_schema_migrations (version) VALUES (3)");
104
+ }
105
+ });
106
+ });
107
+ }
108
+ async ensureCleanupAlarm(createdAt) {
109
+ if (this.cleanupAlarmKnown) return;
110
+ if (await this.ctx.storage.getAlarm() === null) await this.ctx.storage.setAlarm(createdAt + ORPHAN_RETENTION_MS);
111
+ this.cleanupAlarmKnown = true;
112
+ }
113
+ async maintainCleanupAlarm(createdAt) {
114
+ await this.ensureCleanupAlarm(createdAt).catch((error) => {
115
+ console.error(JSON.stringify({
116
+ message: "Workers Response Store cleanup alarm update failed",
117
+ error: error instanceof Error ? error.message : String(error)
118
+ }));
119
+ });
120
+ }
121
+ findMatchingEntryRows(options, includePending = false) {
122
+ const rows = this.ctx.storage.sql.exec(includePending ? `SELECT * FROM entries
123
+ WHERE (tombstoned = 0 AND active_revision IS NOT NULL)
124
+ OR active_revision IS NULL OR latest_revision > active_revision` : "SELECT * FROM entries WHERE tombstoned = 0 AND active_revision IS NOT NULL").toArray();
125
+ if (options.purgeEverything) return rows;
126
+ const tags = new Set(normalizeTags(options.tags ?? []));
127
+ const prefixes = options.pathPrefixes ?? [];
128
+ return rows.filter((row) => prefixes.some((prefix) => row.cache_key.startsWith(prefix)) || normalizeTags(JSON.parse(row.cache_tags ?? "[]")).some((tag) => tags.has(tag)));
129
+ }
130
+ async trackPendingObjects(objectKeys, createdAt) {
131
+ if (!objectKeys.length) return;
132
+ this.ctx.storage.transactionSync(() => {
133
+ for (const batch of batches(objectKeys, MAX_SQL_PARAMETERS / 2)) {
134
+ const values = batch.flatMap((objectKey) => [objectKey, createdAt]);
135
+ this.ctx.storage.sql.exec(`INSERT OR REPLACE INTO pending_objects
136
+ (object_key, created_at, publishable) VALUES ${batch.map(() => "(?, ?, 0)").join(", ")}`, ...values);
137
+ }
138
+ });
139
+ await this.ensureCleanupAlarm(createdAt);
140
+ }
141
+ trackPendingObject(objectKey, createdAt) {
142
+ return this.trackPendingObjects([objectKey], createdAt);
143
+ }
144
+ releaseWrite(keyHash, objectKey, claimId) {
145
+ this.ctx.storage.transactionSync(() => {
146
+ this.ctx.storage.sql.exec("UPDATE pending_objects SET created_at = 0, publishable = 0 WHERE object_key = ?", objectKey);
147
+ if (claimId) this.ctx.storage.sql.exec("DELETE FROM revalidation_claims WHERE key_hash = ? AND claim_id = ?", keyHash, claimId);
148
+ });
149
+ }
150
+ finishPendingObjects(objectKeys) {
151
+ if (!objectKeys.length) return;
152
+ this.ctx.storage.transactionSync(() => {
153
+ for (const batch of batches(objectKeys, MAX_SQL_PARAMETERS)) this.ctx.storage.sql.exec(`DELETE FROM pending_objects WHERE object_key IN (${batch.map(() => "?").join(", ")})`, ...batch);
154
+ });
155
+ }
156
+ async deleteTrackedObjects(objectKeys) {
157
+ for (const batch of batches(objectKeys, R2_DELETE_BATCH_SIZE)) try {
158
+ await this.env.CACHE_BODIES.delete(batch);
159
+ this.finishPendingObjects(batch);
160
+ } catch (error) {
161
+ for (const retryBatch of batches(batch, MAX_SQL_PARAMETERS)) this.ctx.storage.sql.exec(`INSERT OR REPLACE INTO pending_objects
162
+ (object_key, created_at, publishable) VALUES ${retryBatch.map(() => "(?, 0, 0)").join(", ")}`, ...retryBatch);
163
+ console.error(JSON.stringify({
164
+ message: "Workers Response Store R2 cleanup failed",
165
+ objectKeys: batch,
166
+ error: error instanceof Error ? error.message : String(error)
167
+ }));
168
+ try {
169
+ await this.ctx.storage.setAlarm(Date.now() + ORPHAN_CLEANUP_RETRY_MS);
170
+ this.cleanupAlarmKnown = true;
171
+ } catch (alarmError) {
172
+ console.error(JSON.stringify({
173
+ message: "Workers Response Store cleanup retry scheduling failed",
174
+ error: alarmError instanceof Error ? alarmError.message : String(alarmError)
175
+ }));
176
+ }
177
+ }
178
+ }
179
+ listExpiredPendingObjects(cutoff, limit) {
180
+ return this.ctx.storage.sql.exec(`SELECT pending_objects.object_key FROM pending_objects
181
+ LEFT JOIN entries
182
+ ON entries.object_key = pending_objects.object_key AND entries.tombstoned = 0
183
+ WHERE pending_objects.created_at <= ? AND entries.object_key IS NULL
184
+ ORDER BY pending_objects.created_at
185
+ LIMIT ?`, cutoff, limit).toArray().map((row) => row.object_key);
186
+ }
187
+ async sweepExpiredPendingObjects(cutoff = Date.now() - ORPHAN_RETENTION_MS) {
188
+ this.cleanupAlarmKnown = false;
189
+ const rows = this.ctx.storage.sql.exec(`SELECT pending_objects.object_key, pending_objects.created_at,
190
+ pending_objects.publishable,
191
+ entries.object_key IS NOT NULL AS active
192
+ FROM pending_objects
193
+ LEFT JOIN entries
194
+ ON entries.object_key = pending_objects.object_key AND entries.tombstoned = 0
195
+ ORDER BY pending_objects.created_at
196
+ LIMIT ?`, 101).toArray();
197
+ const batch = rows.slice(0, ORPHAN_CLEANUP_LIMIT);
198
+ const activeObjectKeys = batch.filter(({ active }) => active).map(({ object_key }) => object_key);
199
+ const expired = batch.filter(({ active, created_at }) => !active && created_at <= cutoff);
200
+ const reservations = expired.filter(({ publishable }) => publishable === 1);
201
+ const cleanupObjectKeys = expired.filter(({ publishable }) => publishable === 0).map(({ object_key }) => object_key);
202
+ const fencedAt = Date.now();
203
+ const expiredObjectKeys = expired.map(({ object_key }) => object_key);
204
+ this.finishPendingObjects(activeObjectKeys);
205
+ for (const reservationBatch of batches(reservations, 99)) this.ctx.storage.sql.exec(`UPDATE pending_objects SET created_at = ?, publishable = 0
206
+ WHERE object_key IN (${reservationBatch.map(() => "?").join(", ")})`, fencedAt, ...reservationBatch.map(({ object_key }) => object_key));
207
+ if (expiredObjectKeys.length) {
208
+ await this.env.CACHE_BODIES.delete(expiredObjectKeys);
209
+ this.finishPendingObjects(cleanupObjectKeys);
210
+ }
211
+ const next = batch.find(({ active, created_at }) => !active && created_at > cutoff);
212
+ if (!next && rows.length > ORPHAN_CLEANUP_LIMIT) {
213
+ await this.ctx.storage.setAlarm(Date.now());
214
+ this.cleanupAlarmKnown = true;
215
+ } else if (next || reservations.length) await this.ensureCleanupAlarm(Math.min(next?.created_at ?? Number.POSITIVE_INFINITY, fencedAt));
216
+ return expiredObjectKeys.length;
217
+ }
218
+ async alarm() {
219
+ try {
220
+ await this.sweepExpiredPendingObjects();
221
+ } catch (error) {
222
+ console.error(JSON.stringify({
223
+ message: "Workers Response Store orphan cleanup failed",
224
+ error: error instanceof Error ? error.message : String(error)
225
+ }));
226
+ await this.ctx.storage.setAlarm(Date.now() + ORPHAN_CLEANUP_RETRY_MS);
227
+ this.cleanupAlarmKnown = true;
228
+ }
229
+ }
230
+ async claimRevalidation(keyHash, activeRevision, cacheKey, objectKeyPrefix, now, leaseMs) {
231
+ const claim = this.ctx.storage.transactionSync(() => {
232
+ const entry = this.ctx.storage.sql.exec(`SELECT entries.active_revision, entries.latest_revision, entries.tombstoned,
233
+ revalidation_claims.active_revision AS claim_active_revision,
234
+ revalidation_claims.expires_at AS claim_expires_at
235
+ FROM entries
236
+ LEFT JOIN revalidation_claims ON revalidation_claims.key_hash = entries.key_hash
237
+ WHERE entries.key_hash = ? AND entries.cache_key = ?`, keyHash, cacheKey).toArray()[0];
238
+ if (entry?.tombstoned || entry?.active_revision !== activeRevision) return null;
239
+ if (entry.claim_active_revision === activeRevision && (entry.claim_expires_at ?? 0) > now) return null;
240
+ const revision = entry.latest_revision + 1;
241
+ const claimId = crypto.randomUUID();
242
+ const objectKey = `${objectKeyPrefix}/${revision}`;
243
+ this.ctx.storage.sql.exec("UPDATE entries SET latest_revision = ? WHERE key_hash = ? AND active_revision = ?", revision, keyHash, activeRevision);
244
+ this.ctx.storage.sql.exec(`INSERT OR REPLACE INTO revalidation_claims
245
+ (key_hash, active_revision, revision, claim_id, claimed_at, expires_at)
246
+ VALUES (?, ?, ?, ?, ?, ?)`, keyHash, activeRevision, revision, claimId, now, now + leaseMs);
247
+ this.ctx.storage.sql.exec(`INSERT OR REPLACE INTO pending_objects
248
+ (object_key, created_at, invalidation_sequence)
249
+ VALUES (?, ?, (SELECT tag_invalidation_sequence FROM metadata_state WHERE singleton = 1))`, objectKey, now);
250
+ return {
251
+ claimId,
252
+ objectKey,
253
+ revision
254
+ };
255
+ });
256
+ if (claim) await this.ensureCleanupAlarm(now);
257
+ return claim;
258
+ }
259
+ reserveRevision(keyHash, cacheKey, current) {
260
+ const revision = (current?.latest_revision ?? 0) + 1;
261
+ if (!current) this.ctx.storage.sql.exec("INSERT INTO entries (key_hash, cache_key, latest_revision, tombstoned) VALUES (?, ?, ?, 1)", keyHash, cacheKey, revision);
262
+ else this.ctx.storage.sql.exec("UPDATE entries SET cache_key = ?, latest_revision = ? WHERE key_hash = ?", cacheKey, revision, keyHash);
263
+ return revision;
264
+ }
265
+ async reserveWrite(keyHash, cacheKey, objectKeyPrefix, createdAt) {
266
+ const reservation = this.ctx.storage.transactionSync(() => {
267
+ const current = this.ctx.storage.sql.exec("SELECT active_revision, latest_revision FROM entries WHERE key_hash = ?", keyHash).toArray()[0];
268
+ const revision = this.reserveRevision(keyHash, cacheKey, current);
269
+ const objectKey = `${objectKeyPrefix}/${revision}`;
270
+ this.ctx.storage.sql.exec(`INSERT OR REPLACE INTO pending_objects
271
+ (object_key, created_at, invalidation_sequence)
272
+ VALUES (?, ?, (SELECT tag_invalidation_sequence FROM metadata_state WHERE singleton = 1))`, objectKey, createdAt);
273
+ return {
274
+ objectKey,
275
+ revision
276
+ };
277
+ });
278
+ await this.ensureCleanupAlarm(createdAt);
279
+ return reservation;
280
+ }
281
+ async publish(keyHash, revision, metadata, claimId) {
282
+ const { cleanupObjectKey, result } = this.ctx.storage.transactionSync(() => {
283
+ const current = this.ctx.storage.sql.exec(`SELECT entries.*,
284
+ revalidation_claims.active_revision AS claim_active_revision,
285
+ revalidation_claims.claim_id AS claim_id,
286
+ revalidation_claims.revision AS claim_revision,
287
+ pending_objects.invalidation_sequence AS pending_invalidation_sequence,
288
+ pending_objects.publishable AS pending_publishable,
289
+ metadata_state.tag_invalidation_sequence AS current_invalidation_sequence
290
+ FROM entries
291
+ CROSS JOIN metadata_state
292
+ LEFT JOIN pending_objects ON pending_objects.object_key = ?
293
+ LEFT JOIN revalidation_claims ON revalidation_claims.key_hash = entries.key_hash
294
+ WHERE entries.key_hash = ?`, metadata.objectKey, keyHash).toArray()[0];
295
+ if (!current || current.pending_invalidation_sequence === null || current.pending_invalidation_sequence === void 0 || current.pending_publishable !== 1 || revision > current.latest_revision || current.active_revision !== null && revision <= current.active_revision || claimId !== void 0 && (current.claim_id !== claimId || current.claim_revision !== revision || current.claim_active_revision !== current.active_revision) || metadata.fenceTags.length > 0 && current.current_invalidation_sequence > current.pending_invalidation_sequence && this.getTagInvalidationMaximum(metadata.fenceTags, "invalidation_sequence") > current.pending_invalidation_sequence) {
296
+ if (claimId) this.ctx.storage.sql.exec("DELETE FROM revalidation_claims WHERE key_hash = ? AND claim_id = ?", keyHash, claimId);
297
+ return {
298
+ cleanupObjectKey: current?.object_key === metadata.objectKey ? void 0 : metadata.objectKey,
299
+ result: {
300
+ entry: current ? storedEntryFromRow(current) : null,
301
+ published: false
302
+ }
303
+ };
304
+ }
305
+ const published = this.ctx.storage.sql.exec(`UPDATE entries SET
306
+ active_revision = ?, object_key = ?, status_text = ?, response_headers = ?,
307
+ fresh_until = ?, swr_until = ?,
308
+ revalidator_id = ?, revalidator_args = ?, cache_tags = ?, tombstoned = 0
309
+ WHERE key_hash = ? AND latest_revision >= ?
310
+ AND (active_revision IS NULL OR active_revision < ?)`, revision, metadata.objectKey, metadata.statusText, JSON.stringify(metadata.responseHeaders), metadata.freshUntil, metadata.swrUntil, metadata.revalidator?.id ?? null, metadata.revalidator ? JSON.stringify(metadata.revalidator.args) : null, JSON.stringify(metadata.cacheTags), keyHash, revision, revision).rowsWritten === 1;
311
+ if (published) this.ctx.storage.sql.exec("DELETE FROM pending_objects WHERE object_key = ?", metadata.objectKey);
312
+ if (published && current.object_key && current.object_key !== metadata.objectKey) this.ctx.storage.sql.exec(`INSERT OR IGNORE INTO pending_objects
313
+ (object_key, created_at, publishable) VALUES (?, ?, 0)`, current.object_key, Date.now());
314
+ if (claimId) this.ctx.storage.sql.exec("DELETE FROM revalidation_claims WHERE key_hash = ? AND claim_id = ?", keyHash, claimId);
315
+ const entry = {
316
+ keyHash,
317
+ cacheKey: current.cache_key,
318
+ activeRevision: revision,
319
+ latestRevision: current.latest_revision,
320
+ objectKey: metadata.objectKey,
321
+ statusText: metadata.statusText,
322
+ responseHeaders: metadata.responseHeaders,
323
+ freshUntil: metadata.freshUntil,
324
+ swrUntil: metadata.swrUntil,
325
+ revalidator: metadata.revalidator,
326
+ cacheTags: metadata.cacheTags
327
+ };
328
+ return {
329
+ cleanupObjectKey: published ? current.object_key && current.object_key !== metadata.objectKey ? current.object_key : void 0 : metadata.objectKey,
330
+ result: {
331
+ entry: published ? entry : null,
332
+ published
333
+ }
334
+ };
335
+ });
336
+ if (cleanupObjectKey) {
337
+ await this.maintainCleanupAlarm(Date.now());
338
+ await this.deleteTrackedObjects([cleanupObjectKey]);
339
+ }
340
+ return result;
341
+ }
342
+ getEntry(keyHash) {
343
+ const row = this.ctx.storage.sql.exec("SELECT * FROM entries WHERE key_hash = ?", keyHash).toArray()[0];
344
+ return row ? storedEntryFromRow(row) : null;
345
+ }
346
+ getTagInvalidationMaximum(tags, column) {
347
+ const normalized = normalizeTags(tags);
348
+ let expiration = 0;
349
+ for (const batch of batches(normalized, MAX_SQL_PARAMETERS)) {
350
+ const placeholders = batch.map(() => "?").join(", ");
351
+ const row = this.ctx.storage.sql.exec(`SELECT MAX(${column}) AS invalidated_at
352
+ FROM tag_invalidations WHERE tag IN (${placeholders})`, ...batch).one();
353
+ expiration = Math.max(expiration, row.invalidated_at ?? 0);
354
+ }
355
+ return expiration;
356
+ }
357
+ getTagExpiration(tags) {
358
+ return this.getTagInvalidationMaximum(tags, "invalidated_at");
359
+ }
360
+ async reserveRefresh(options, objectKeyRoot, createdAt) {
361
+ const candidates = this.ctx.storage.transactionSync(() => {
362
+ const matches = this.findMatchingEntryRows(options);
363
+ const reservations = matches.flatMap((row) => {
364
+ return storedEntryFromRow(row)?.revalidator ? [{
365
+ keyHash: row.key_hash,
366
+ objectKey: `${objectKeyRoot}/${row.key_hash}/${row.latest_revision + 1}`,
367
+ revision: row.latest_revision + 1
368
+ }] : [];
369
+ });
370
+ for (const batch of batches(reservations, MAX_SQL_PARAMETERS)) {
371
+ const keyHashes = batch.map(({ keyHash }) => keyHash);
372
+ this.ctx.storage.sql.exec(`UPDATE entries SET latest_revision = latest_revision + 1
373
+ WHERE key_hash IN (${keyHashes.map(() => "?").join(", ")})`, ...keyHashes);
374
+ }
375
+ for (const batch of batches(reservations, MAX_SQL_PARAMETERS / 2)) this.ctx.storage.sql.exec(`INSERT OR REPLACE INTO pending_objects
376
+ (object_key, created_at, invalidation_sequence) VALUES ${batch.map(() => "(?, ?, (SELECT tag_invalidation_sequence FROM metadata_state WHERE singleton = 1))").join(", ")}`, ...batch.flatMap(({ objectKey }) => [objectKey, createdAt]));
377
+ const byKey = new Map(reservations.map((reservation) => [reservation.keyHash, reservation]));
378
+ return matches.flatMap((row) => {
379
+ const entry = storedEntryFromRow(row);
380
+ if (!entry) return [];
381
+ const reservation = byKey.get(row.key_hash);
382
+ return [{
383
+ entry,
384
+ ...reservation ? { reservation: {
385
+ objectKey: reservation.objectKey,
386
+ revision: reservation.revision
387
+ } } : {}
388
+ }];
389
+ });
390
+ });
391
+ if (candidates.some(({ reservation }) => reservation)) await this.ensureCleanupAlarm(createdAt);
392
+ return candidates;
393
+ }
394
+ async purgeMatching(options) {
395
+ const invalidatedAt = Date.now();
396
+ const matches = this.ctx.storage.transactionSync(() => {
397
+ const matches = this.findMatchingEntryRows(options, true);
398
+ const tags = normalizeTags(options.tags ?? []);
399
+ if (tags.length) this.ctx.storage.sql.exec(`UPDATE metadata_state SET tag_invalidation_sequence = tag_invalidation_sequence + 1
400
+ WHERE singleton = 1`);
401
+ for (const batch of batches(tags, MAX_SQL_PARAMETERS / 2)) this.ctx.storage.sql.exec(`INSERT INTO tag_invalidations
402
+ (tag, invalidated_at, invalidation_sequence) VALUES ${batch.map(() => "(?, ?, (SELECT tag_invalidation_sequence FROM metadata_state WHERE singleton = 1))").join(", ")}
403
+ ON CONFLICT(tag) DO UPDATE SET invalidated_at =
404
+ MAX(tag_invalidations.invalidated_at, excluded.invalidated_at),
405
+ invalidation_sequence = MAX(
406
+ tag_invalidations.invalidation_sequence,
407
+ excluded.invalidation_sequence
408
+ )`, ...batch.flatMap((tag) => [tag, invalidatedAt]));
409
+ for (const batch of batches(matches, 99)) {
410
+ const keyHashes = batch.map((row) => row.key_hash);
411
+ const placeholders = keyHashes.map(() => "?").join(", ");
412
+ this.ctx.storage.sql.exec(`INSERT OR IGNORE INTO pending_objects (object_key, created_at, publishable)
413
+ SELECT object_key, ?, 0 FROM entries
414
+ WHERE key_hash IN (${placeholders}) AND object_key IS NOT NULL`, invalidatedAt, ...keyHashes);
415
+ this.ctx.storage.sql.exec(`UPDATE entries SET
416
+ latest_revision = latest_revision + 1,
417
+ active_revision = latest_revision + 1,
418
+ object_key = NULL,
419
+ status_text = NULL,
420
+ response_headers = NULL,
421
+ fresh_until = NULL,
422
+ swr_until = NULL,
423
+ revalidator_id = NULL,
424
+ revalidator_args = NULL,
425
+ cache_tags = NULL,
426
+ tombstoned = 1
427
+ WHERE key_hash IN (${placeholders})`, ...keyHashes);
428
+ this.ctx.storage.sql.exec(`DELETE FROM revalidation_claims WHERE key_hash IN (${placeholders})`, ...keyHashes);
429
+ }
430
+ return matches.flatMap((row) => row.object_key === null ? [] : [{
431
+ keyHash: row.key_hash,
432
+ cacheKey: row.cache_key,
433
+ objectKey: row.object_key
434
+ }]);
435
+ });
436
+ if (matches.length) {
437
+ await this.maintainCleanupAlarm(invalidatedAt);
438
+ await this.deleteTrackedObjects(matches.map((entry) => entry.objectKey));
439
+ }
440
+ return matches;
441
+ }
442
+ inspect() {
443
+ return storedEntriesFromRows(this.ctx.storage.sql.exec("SELECT * FROM entries ORDER BY cache_key").toArray());
444
+ }
445
+ };
446
+ //#endregion
447
+ export { CacheMetadata };
@@ -0,0 +1,8 @@
1
+ import { ResponseStoreBinding, ResponseStoreService } from "./binding.js";
2
+ import { CacheMetadata } from "./metadata-do.js";
3
+ //#region src/service.d.ts
4
+ declare const _default: {
5
+ fetch(): Response;
6
+ };
7
+ //#endregion
8
+ export { CacheMetadata, ResponseStoreBinding, ResponseStoreService, _default as default };
@@ -0,0 +1,8 @@
1
+ import { ResponseStoreBinding, ResponseStoreService } from "./binding.js";
2
+ import { CacheMetadata } from "./metadata-do.js";
3
+ //#region src/service.ts
4
+ var service_default = { fetch() {
5
+ return new Response("Use the ResponseStoreService service binding entrypoint.", { status: 404 });
6
+ } };
7
+ //#endregion
8
+ export { CacheMetadata, ResponseStoreBinding, ResponseStoreService, service_default as default };