@decocms/tanstack 7.78.2 → 7.79.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.78.2",
3
+ "version": "7.79.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -27,9 +27,9 @@
27
27
  "lint:unused": "knip"
28
28
  },
29
29
  "dependencies": {
30
- "@decocms/blocks": "7.78.2",
31
- "@decocms/blocks-admin": "7.78.2",
32
- "@decocms/blocks-cli": "7.78.2",
30
+ "@decocms/blocks": "7.79.0",
31
+ "@decocms/blocks-admin": "7.79.0",
32
+ "@decocms/blocks-cli": "7.79.0",
33
33
  "@deco-cx/warp-node": "^0.3.16",
34
34
  "fast-json-patch": "^3.1.0",
35
35
  "ws": "^8.18.0"
@@ -51,6 +51,8 @@ const G = globalThis as unknown as {
51
51
  kvHydrated?: boolean;
52
52
  kvHydration?: Promise<void> | null;
53
53
  kvLastPolledAt?: number;
54
+ dataVersion?: string | null;
55
+ dataVersionAt?: number;
54
56
  };
55
57
  };
56
58
  if (!G.__deco) G.__deco = {};
@@ -255,9 +257,62 @@ async function pollRevisionOnce(kv: KVNamespace, deploymentId: string): Promise<
255
257
  }
256
258
  }
257
259
 
260
+ /** KV key holding the preserved data-cache version. Writing a new value (the
261
+ * control-plane does, via the CF API) purges preserved loader/fetch data for
262
+ * every isolate within one poll — no deploy. */
263
+ export const DATA_CACHE_VERSION_KEY = "cache:data-version";
264
+
265
+ /** Deadline for one `cache:data-version` read. Calibration knob. */
266
+ export const DATA_VERSION_TIMEOUT_MS = 1_000;
267
+
268
+ /**
269
+ * Current `DATA_CACHE_VERSION_KEY` value, or "" without a `DECO_KV` binding.
270
+ * The first call per isolate awaits the read (so a cold isolate never reads
271
+ * purged keys); after that the cached value is returned and refreshed in the
272
+ * background every `POLL_INTERVAL_MS`. A failed or slow read (over
273
+ * `DATA_VERSION_TIMEOUT_MS`) keeps the last value. Only resolved strings are
274
+ * kept, never a pending read: a `DECO_KV.get` that never settles would otherwise
275
+ * hold every later request of the isolate.
276
+ */
277
+ export async function getDataCacheVersion(env: Env, ctx?: ExecutionContextLike): Promise<string> {
278
+ const kv = getKV(env);
279
+ if (!kv) return "";
280
+ const d = G.__deco!;
281
+ const read = (prev: string): Promise<string> => {
282
+ let timer: ReturnType<typeof setTimeout> | undefined;
283
+ return Promise.race([
284
+ kv.get(DATA_CACHE_VERSION_KEY).then(
285
+ (v) => v ?? "",
286
+ () => prev,
287
+ ),
288
+ new Promise<string>((resolve) => {
289
+ timer = setTimeout(() => resolve(prev), DATA_VERSION_TIMEOUT_MS);
290
+ }),
291
+ ]).finally(() => clearTimeout(timer));
292
+ };
293
+ if (d.dataVersion == null) {
294
+ d.dataVersionAt = Date.now();
295
+ d.dataVersion = await read("");
296
+ return d.dataVersion;
297
+ }
298
+ const current = d.dataVersion;
299
+ if (Date.now() - (d.dataVersionAt ?? 0) >= POLL_INTERVAL_MS) {
300
+ d.dataVersionAt = Date.now();
301
+ // Background refresh: requests keep the current value meanwhile.
302
+ const swap = read(current).then((v) => {
303
+ d.dataVersion = v;
304
+ });
305
+ if (ctx?.waitUntil) ctx.waitUntil(swap);
306
+ else void swap.catch(() => {});
307
+ }
308
+ return current;
309
+ }
310
+
258
311
  /** Test-only: reset the isolate-level hydration flags. */
259
312
  export function __resetKvHydrationStateForTests(): void {
260
313
  G.__deco!.kvHydrated = false;
261
314
  G.__deco!.kvHydration = null;
262
315
  G.__deco!.kvLastPolledAt = 0;
316
+ G.__deco!.dataVersion = null;
317
+ G.__deco!.dataVersionAt = 0;
263
318
  }
@@ -8,6 +8,15 @@ import {
8
8
  getCacheStorageContext,
9
9
  } from "@decocms/blocks/sdk/cacheStorage";
10
10
  import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { createFetchCache } from "@decocms/blocks/sdk/fetchCache";
12
+ import { __resetKvHydrationStateForTests, DATA_VERSION_TIMEOUT_MS } from "./kvHydration";
13
+
14
+ const FETCH_KNOBS = {
15
+ maxEntries: 10,
16
+ freshTtlMs: { success: 60_000, notFound: 1_000, serverError: 0 },
17
+ staleIfErrorMs: 60_000,
18
+ inflightBackstopMs: 5_000,
19
+ };
11
20
  import { createDecoWorkerEntry } from "./workerEntry";
12
21
 
13
22
  function makeKV(): CacheKVNamespace {
@@ -91,6 +100,123 @@ describe("worker cache storage injection", () => {
91
100
  await flush();
92
101
  });
93
102
 
103
+ // A fresh fetch cache per request: its memory tier can't answer, so a hit
104
+ // proves the shared (KV) entry was found under the same key.
105
+ function fetchWorker(opts: object = {}) {
106
+ const upstream = vi.fn(async () => Response.json({ ok: true }));
107
+ const worker = createDecoWorkerEntry(
108
+ {
109
+ fetch: async () => {
110
+ const cache = createFetchCache({ provider: "test", ...FETCH_KNOBS });
111
+ const body = await cache.fetchWithCache("https://upstream.test/x", upstream);
112
+ return Response.json(body, { headers: { "cache-control": "no-store" } });
113
+ },
114
+ },
115
+ { ...options, ...opts },
116
+ );
117
+ return { worker, upstream };
118
+ }
119
+
120
+ it("DECO_DATA_CACHE_ON_DEPLOY=preserve keeps fetch results across deploys and publishes", async () => {
121
+ const { worker, upstream } = fetchWorker();
122
+ const bindings = { ...env(), DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
123
+ await worker.fetch(request("/one"), bindings, ctx);
124
+ await flush();
125
+ // Distinct paths: only the fetch cache is under test, not the HTML cache.
126
+ await worker.fetch(request("/two"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
127
+ // A CMS publish (new decofile revision) keeps them too.
128
+ setBlocks({ published: { __resolveType: "x" } });
129
+ await worker.fetch(request("/three"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
130
+ expect(upstream).toHaveBeenCalledTimes(1);
131
+ // A new DECO_DATA_CACHE_PURGE value drops the preserved data once.
132
+ await worker.fetch(request("/four"), { ...bindings, DECO_DATA_CACHE_PURGE: "2026-09-22" }, ctx);
133
+ expect(upstream).toHaveBeenCalledTimes(2);
134
+ await flush();
135
+ });
136
+
137
+ it("loader results stay per-build even with preserve", async () => {
138
+ const upstream = vi.fn(async () => ({ n: 1 }));
139
+ const loader = createCachedLoader("worker-preserve-loader", upstream, {
140
+ policy: "no-cache",
141
+ maxAge: 60_000,
142
+ });
143
+ const worker = createDecoWorkerEntry(
144
+ {
145
+ fetch: async () =>
146
+ Response.json(await loader({}), { headers: { "cache-control": "no-store" } }),
147
+ },
148
+ options,
149
+ );
150
+ const bindings = { ...env(), DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
151
+ await worker.fetch(request("/a"), bindings, ctx);
152
+ await flush();
153
+ clearLoaderCache();
154
+ await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
155
+ expect(upstream).toHaveBeenCalledTimes(2);
156
+ await flush();
157
+ });
158
+
159
+ it("invalidate or unset keeps fetch results per build", async () => {
160
+ for (const extra of [{}, { DECO_DATA_CACHE_ON_DEPLOY: "invalidate" }, { DECO_DATA_CACHE_ON_DEPLOY: "yes" }]) {
161
+ const { worker, upstream } = fetchWorker();
162
+ const bindings = { ...env(), ...extra };
163
+ await worker.fetch(request("/a"), bindings, ctx);
164
+ await flush();
165
+ await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
166
+ expect(upstream).toHaveBeenCalledTimes(2);
167
+ await flush();
168
+ }
169
+ });
170
+
171
+ it("a new cache:data-version in DECO_KV purges preserved data without a deploy", async () => {
172
+ __resetKvHydrationStateForTests();
173
+ const { worker, upstream } = fetchWorker();
174
+ const DECO_KV = makeKV();
175
+ const bindings = { ...env(), DECO_KV, DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
176
+ await worker.fetch(request("/a"), bindings, ctx);
177
+ await flush();
178
+ await worker.fetch(request("/b"), bindings, ctx);
179
+ expect(upstream).toHaveBeenCalledTimes(1);
180
+ await DECO_KV.put("cache:data-version", "v2", { expirationTtl: 60 });
181
+ __resetKvHydrationStateForTests(); // skip the poll interval
182
+ await worker.fetch(request("/c"), bindings, ctx);
183
+ expect(upstream).toHaveBeenCalledTimes(2);
184
+ await flush();
185
+ });
186
+
187
+ it("a hung cache:data-version read falls back instead of holding the request", async () => {
188
+ vi.useFakeTimers();
189
+ try {
190
+ __resetKvHydrationStateForTests();
191
+ const { worker, upstream } = fetchWorker();
192
+ const DECO_KV = { ...makeKV(), get: () => new Promise<string | null>(() => {}) };
193
+ const bindings = { ...env(), DECO_KV, DECO_DATA_CACHE_ON_DEPLOY: "preserve" };
194
+ const pending = worker.fetch(request("/a"), bindings, ctx);
195
+ await vi.advanceTimersByTimeAsync(DATA_VERSION_TIMEOUT_MS);
196
+ expect((await pending).status).toBe(200);
197
+ expect(upstream).toHaveBeenCalledTimes(1);
198
+ } finally {
199
+ vi.useRealTimers();
200
+ await flush();
201
+ }
202
+ });
203
+
204
+ it("honours dataCacheOnDeployEnv renames and opt-out", async () => {
205
+ const run = async (opts: object, extra: Record<string, string>) => {
206
+ const { worker, upstream } = fetchWorker(opts);
207
+ const bindings = { ...env(), ...extra };
208
+ await worker.fetch(request("/a"), bindings, ctx);
209
+ await flush();
210
+ await worker.fetch(request("/b"), { ...bindings, BUILD_HASH: "build-B" }, ctx);
211
+ await flush();
212
+ return upstream.mock.calls.length;
213
+ };
214
+ expect(await run({ dataCacheOnDeployEnv: "KEEP" }, { KEEP: "preserve" })).toBe(1);
215
+ expect(
216
+ await run({ dataCacheOnDeployEnv: false }, { DECO_DATA_CACHE_ON_DEPLOY: "preserve" }),
217
+ ).toBe(2);
218
+ });
219
+
94
220
  it("stores public server-function POSTs by body and excludes unmarked responses", async () => {
95
221
  const origin = {
96
222
  fetch: vi.fn(
@@ -105,7 +105,7 @@ import {
105
105
  registerDraftOverride,
106
106
  requestCarriesDraft,
107
107
  } from "./draft";
108
- import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
108
+ import { ensureBlocksHydrated, getDataCacheVersion, maybePollRevision } from "./kvHydration";
109
109
  import { lookupExactRedirect } from "./kvRedirects";
110
110
  import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
111
111
  import { type SpeculationRulesConfig, setSpeculationRules } from "./speculationRules";
@@ -423,6 +423,33 @@ export interface DecoWorkerEntryOptions {
423
423
  */
424
424
  cacheVersionEnv?: string | false;
425
425
 
426
+ /**
427
+ * Environment variable choosing what a deploy does to the `createFetchCache`
428
+ * entries (`fetch:*`, raw upstream responses) in shared storage (e.g. KV):
429
+ *
430
+ * - `"invalidate"` (or unset — the default): keyed by build and CMS revision,
431
+ * a deploy or publish starts them cold. Today's behaviour.
432
+ * - `"preserve"`: keyed without build or CMS revision, they survive both.
433
+ * A raw response doesn't depend on the code or the decofile; the new
434
+ * code re-transforms it, so a deploy can't serve an old shape.
435
+ *
436
+ * `cachedLoader` results, HTML responses and sections stay per-build in both
437
+ * modes: they hold code output. Segment/device and geo stay in every key.
438
+ * Any other value falls back to `"invalidate"`.
439
+ *
440
+ * To drop preserved responses once, set `DECO_DATA_CACHE_PURGE` to a new value
441
+ * (any string, e.g. the date), or write a new `cache:data-version` in DECO_KV.
442
+ *
443
+ * @default "DECO_DATA_CACHE_ON_DEPLOY"
444
+ *
445
+ * @example
446
+ * ```jsonc
447
+ * // wrangler.jsonc
448
+ * "vars": { "DECO_DATA_CACHE_ON_DEPLOY": "preserve" }
449
+ * ```
450
+ */
451
+ dataCacheOnDeployEnv?: string | false;
452
+
426
453
  /**
427
454
  * Security headers appended to every SSR response (HTML pages).
428
455
  * Pass `false` to disable entirely.
@@ -1186,6 +1213,7 @@ export function createDecoWorkerEntry(
1186
1213
  redirects: redirectsOpt,
1187
1214
  previewShell: customPreviewShell,
1188
1215
  cacheVersionEnv = "BUILD_HASH",
1216
+ dataCacheOnDeployEnv = "DECO_DATA_CACHE_ON_DEPLOY",
1189
1217
  securityHeaders: securityHeadersOpt,
1190
1218
  csp: cspOpt,
1191
1219
  cspMode = "report-only",
@@ -1826,7 +1854,8 @@ export function createDecoWorkerEntry(
1826
1854
  // handlePurge (Bearer `purgeTokenEnv`). Per-isolate by nature — the isolate
1827
1855
  // that serves this request clears its cache; other live isolates keep theirs
1828
1856
  // until they're recycled or hit too. For a guaranteed global flush, redeploy
1829
- // (loader cache keys are build-hash versioned).
1857
+ // (loader cache keys are build-hash versioned; preserved `fetch:*` entries are
1858
+ // not — see `dataCacheOnDeployEnv`).
1830
1859
  async function handlePurgeLoaders(
1831
1860
  request: Request,
1832
1861
  env: Record<string, unknown>,
@@ -2305,24 +2334,36 @@ export function createDecoWorkerEntry(
2305
2334
  } catch {
2306
2335
  /* Missing storage is a cache miss, never a storefront failure. */
2307
2336
  }
2337
+ const preserveData = !!dataCacheOnDeployEnv && env[dataCacheOnDeployEnv] === "preserve";
2338
+ // Purge without a deploy: bump `cache:data-version` in DECO_KV.
2339
+ const kvDataVersion = preserveData && storage ? await getDataCacheVersion(env, ctx) : "";
2340
+ // Everything but the version is shared by the build and data scopes.
2341
+ // Revision is left out of `variant`: build-scoped stores add it below, while
2342
+ // preserved fetch data skips it — a raw upstream response does not depend on
2343
+ // the decofile (a publish that changes props changes the URL, hence the key).
2344
+ const variant = [
2345
+ segment
2346
+ ? hashSegment(segment)
2347
+ : isMobileUA(request.headers.get("user-agent") ?? "")
2348
+ ? "mobile"
2349
+ : "desktop",
2350
+ buildGeoCacheParam(
2351
+ (request as unknown as { cf?: Record<string, string> }).cf,
2352
+ effectiveGeoKey(),
2353
+ ),
2354
+ privateRequest ? crypto.randomUUID() : null,
2355
+ ];
2356
+ const scopeFor = (version: string, revision: string | null = getRevision()) =>
2357
+ JSON.stringify([url.origin, version, revision, ...variant]);
2308
2358
  bindCacheStorage({
2309
2359
  storage,
2310
2360
  disabled: privateRequest || isDevMode(),
2311
- scope: JSON.stringify([
2312
- new URL(request.url).origin,
2313
- getBuildHash(env),
2314
- getRevision(),
2315
- segment
2316
- ? hashSegment(segment)
2317
- : isMobileUA(request.headers.get("user-agent") ?? "")
2318
- ? "mobile"
2319
- : "desktop",
2320
- buildGeoCacheParam(
2321
- (request as unknown as { cf?: Record<string, string> }).cf,
2322
- effectiveGeoKey(),
2323
- ),
2324
- privateRequest ? crypto.randomUUID() : null,
2325
- ]),
2361
+ scope: scopeFor(getBuildHash(env)),
2362
+ // Only `createFetchCache` (`fetch:*`) uses dataScope. Preserve keys it without
2363
+ // build or CMS revision; invalidate keeps today's per-build key.
2364
+ dataScope: preserveData
2365
+ ? scopeFor(`data:${(env.DECO_DATA_CACHE_PURGE as string) ?? ""}:${kvDataVersion}`, null)
2366
+ : undefined,
2326
2367
  waitUntil: (work) => ctx.waitUntil(work),
2327
2368
  });
2328
2369
  }