@decocms/tanstack 7.6.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.6.0",
3
+ "version": "7.8.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -14,7 +14,8 @@
14
14
  "./vite": "./src/vite/plugin.js",
15
15
  "./daemon": "./src/daemon/index.ts",
16
16
  "./sdk/createInvoke": "./src/sdk/createInvoke.ts",
17
- "./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts"
17
+ "./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts",
18
+ "./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts"
18
19
  },
19
20
  "scripts": {
20
21
  "build": "tsc",
@@ -23,9 +24,9 @@
23
24
  "lint:unused": "knip"
24
25
  },
25
26
  "dependencies": {
26
- "@decocms/blocks": "7.6.0",
27
- "@decocms/blocks-admin": "7.6.0",
28
- "@decocms/blocks-cli": "7.6.0",
27
+ "@decocms/blocks": "7.8.0",
28
+ "@decocms/blocks-admin": "7.8.0",
29
+ "@decocms/blocks-cli": "7.8.0",
29
30
  "@deco-cx/warp-node": "^0.3.16",
30
31
  "fast-json-patch": "^3.1.0",
31
32
  "ws": "^8.18.0"
@@ -1,7 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { computeRevision, KV_KEYS, type KVNamespace } from "@decocms/blocks/cms";
2
+ import { computeRevision, type KVNamespace, revisionKey, snapshotKey } from "@decocms/blocks/cms";
3
3
  import { KVBlockSource } from "./kvBlockSource";
4
4
 
5
+ const ID = "sha-abc123";
6
+ const SNAP = snapshotKey(ID);
7
+ const REV = revisionKey(ID);
8
+
5
9
  function makeKV(initial: Record<string, string> = {}): KVNamespace {
6
10
  const store = new Map<string, string>(Object.entries(initial));
7
11
  return {
@@ -19,7 +23,17 @@ function makeKV(initial: Record<string, string> = {}): KVNamespace {
19
23
 
20
24
  describe("KVBlockSource.loadSnapshot", () => {
21
25
  it("returns null when the snapshot key is missing", async () => {
22
- const src = new KVBlockSource(makeKV());
26
+ const src = new KVBlockSource(makeKV(), ID);
27
+ await expect(src.loadSnapshot()).resolves.toBeNull();
28
+ });
29
+
30
+ it("reads the key for its own deployment id, not another", async () => {
31
+ const blocks = { Site: { name: "x" } };
32
+ // Store under a DIFFERENT deployment id — this source must not see it.
33
+ const src = new KVBlockSource(
34
+ makeKV({ [snapshotKey("other-sha")]: JSON.stringify(blocks) }),
35
+ ID,
36
+ );
23
37
  await expect(src.loadSnapshot()).resolves.toBeNull();
24
38
  });
25
39
 
@@ -27,16 +41,17 @@ describe("KVBlockSource.loadSnapshot", () => {
27
41
  const blocks = { Site: { name: "x" } };
28
42
  const src = new KVBlockSource(
29
43
  makeKV({
30
- [KV_KEYS.SNAPSHOT]: JSON.stringify(blocks),
31
- [KV_KEYS.REVISION]: "stored-rev",
44
+ [SNAP]: JSON.stringify(blocks),
45
+ [REV]: "stored-rev",
32
46
  }),
47
+ ID,
33
48
  );
34
49
  await expect(src.loadSnapshot()).resolves.toEqual({ blocks, revision: "stored-rev" });
35
50
  });
36
51
 
37
52
  it("recomputes the revision when only the snapshot is stored", async () => {
38
53
  const blocks = { Site: { name: "x" } };
39
- const src = new KVBlockSource(makeKV({ [KV_KEYS.SNAPSHOT]: JSON.stringify(blocks) }));
54
+ const src = new KVBlockSource(makeKV({ [SNAP]: JSON.stringify(blocks) }), ID);
40
55
  await expect(src.loadSnapshot()).resolves.toEqual({
41
56
  blocks,
42
57
  revision: computeRevision(blocks),
@@ -44,24 +59,24 @@ describe("KVBlockSource.loadSnapshot", () => {
44
59
  });
45
60
 
46
61
  it("throws on a non-object snapshot (treated as KV-unavailable by callers)", async () => {
47
- const src = new KVBlockSource(makeKV({ [KV_KEYS.SNAPSHOT]: "[1,2,3]" }));
62
+ const src = new KVBlockSource(makeKV({ [SNAP]: "[1,2,3]" }), ID);
48
63
  await expect(src.loadSnapshot()).rejects.toThrow();
49
64
  });
50
65
 
51
66
  it("throws on malformed JSON", async () => {
52
- const src = new KVBlockSource(makeKV({ [KV_KEYS.SNAPSHOT]: "{not json" }));
67
+ const src = new KVBlockSource(makeKV({ [SNAP]: "{not json" }), ID);
53
68
  await expect(src.loadSnapshot()).rejects.toThrow();
54
69
  });
55
70
  });
56
71
 
57
72
  describe("KVBlockSource.getRevision", () => {
58
73
  it("returns the stored revision", async () => {
59
- const src = new KVBlockSource(makeKV({ [KV_KEYS.REVISION]: "r1" }));
74
+ const src = new KVBlockSource(makeKV({ [REV]: "r1" }), ID);
60
75
  await expect(src.getRevision()).resolves.toBe("r1");
61
76
  });
62
77
 
63
78
  it("returns null when absent", async () => {
64
- const src = new KVBlockSource(makeKV());
79
+ const src = new KVBlockSource(makeKV(), ID);
65
80
  await expect(src.getRevision()).resolves.toBeNull();
66
81
  });
67
82
  });
@@ -1,10 +1,13 @@
1
1
  /**
2
- * KVBlockSource — a `BlockSource` backed by a Cloudflare KV namespace.
2
+ * KVBlockSource — a `BlockSource` backed by a Cloudflare KV namespace, scoped
3
+ * to a single **deployment id**.
3
4
  *
4
- * Reads the whole decofile snapshot (`decofile:current`) and its revision
5
- * (`index:revision`) from KV. Used by the runtime hydration path
5
+ * Reads the whole decofile snapshot (`decofile:<id>`) and its revision
6
+ * (`index:revision:<id>`) from KV. Used by the runtime hydration path
6
7
  * (`src/sdk/kvHydration.ts`) on cold start and during revision polling, and by
7
8
  * the write-through path (`src/admin/decofile.ts`) which writes the same keys.
9
+ * Keying by deployment id means each running version only ever sees its own
10
+ * content — a rolling deploy can't feed new content to still-live old code.
8
11
  *
9
12
  * This class is intentionally thin — error handling (KV outages, JSON parse
10
13
  * failures) is the caller's responsibility so the framework can fall back to
@@ -15,15 +18,26 @@ import {
15
18
  type BlockSnapshot,
16
19
  type BlockSource,
17
20
  computeRevision,
18
- KV_KEYS,
19
21
  type KVNamespace,
22
+ revisionKey,
23
+ snapshotKey,
20
24
  } from "@decocms/blocks/cms";
21
25
 
22
26
  export class KVBlockSource implements BlockSource {
23
- constructor(private readonly kv: KVNamespace) {}
27
+ private readonly snapshotKey: string;
28
+ private readonly revisionKey: string;
29
+
30
+ constructor(
31
+ private readonly kv: KVNamespace,
32
+ /** Deployment id (git commit sha) this source is scoped to. */
33
+ deploymentId: string,
34
+ ) {
35
+ this.snapshotKey = snapshotKey(deploymentId);
36
+ this.revisionKey = revisionKey(deploymentId);
37
+ }
24
38
 
25
39
  /**
26
- * Read and parse the full decofile snapshot from KV.
40
+ * Read and parse this deployment's full decofile snapshot from KV.
27
41
  *
28
42
  * Returns `null` when no snapshot is present (key missing) so the caller
29
43
  * keeps whatever blocks are already in memory (the bundled fallback).
@@ -34,21 +48,21 @@ export class KVBlockSource implements BlockSource {
34
48
  * unavailable" and falls back.
35
49
  */
36
50
  async loadSnapshot(): Promise<BlockSnapshot | null> {
37
- const raw = await this.kv.get(KV_KEYS.SNAPSHOT);
51
+ const raw = await this.kv.get(this.snapshotKey);
38
52
  if (raw === null) return null;
39
53
 
40
54
  const parsed = JSON.parse(raw) as unknown;
41
55
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
42
- throw new Error(`[CMS/KV] ${KV_KEYS.SNAPSHOT} is not a JSON object`);
56
+ throw new Error(`[CMS/KV] ${this.snapshotKey} is not a JSON object`);
43
57
  }
44
58
  const blocks = parsed as Record<string, unknown>;
45
59
 
46
- const storedRevision = await this.kv.get(KV_KEYS.REVISION);
60
+ const storedRevision = await this.kv.get(this.revisionKey);
47
61
  return { blocks, revision: storedRevision ?? computeRevision(blocks) };
48
62
  }
49
63
 
50
64
  /** Cheap revision probe for change detection (no full snapshot transfer). */
51
65
  getRevision(): Promise<string | null> {
52
- return this.kv.get(KV_KEYS.REVISION);
66
+ return this.kv.get(this.revisionKey);
53
67
  }
54
68
  }
@@ -340,35 +340,6 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
340
340
  return normalizeUrlsInObject(enriched);
341
341
  });
342
342
 
343
- // ---------------------------------------------------------------------------
344
- // Pre-wrapped deferred section loader for IntersectionObserver-based rendering
345
- // ---------------------------------------------------------------------------
346
-
347
- /**
348
- * Convenience wrapper around `loadDeferredSection` that matches the
349
- * `loadDeferredSectionFn` prop signature of `DecoPageRenderer`.
350
- *
351
- * Pass this directly to `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`
352
- * to enable IntersectionObserver-based lazy loading of deferred sections.
353
- */
354
- export const deferredSectionLoader = async ({
355
- component,
356
- rawProps,
357
- pagePath,
358
- pageUrl,
359
- index,
360
- }: {
361
- component: string;
362
- rawProps?: Record<string, unknown>;
363
- pagePath: string;
364
- pageUrl?: string;
365
- index?: number;
366
- }): Promise<ResolvedSection | null> => {
367
- return loadDeferredSection({
368
- data: { component, rawProps, pagePath, pageUrl, index },
369
- });
370
- };
371
-
372
343
  // ---------------------------------------------------------------------------
373
344
  // Default pending component — shown during SPA navigation while loader runs
374
345
  // ---------------------------------------------------------------------------
@@ -10,12 +10,12 @@ export {
10
10
  type CmsRouteOptions,
11
11
  cmsHomeRouteConfig,
12
12
  cmsRouteConfig,
13
- deferredSectionLoader,
14
13
  loadCmsHomePage,
15
14
  loadCmsPage,
16
15
  loadDeferredSection,
17
16
  setSectionChunkMap,
18
17
  } from "./cmsRoute";
18
+ export { deferredSectionLoader } from "../sdk/deferredSectionLoader";
19
19
  export { CmsPage, NotFoundPage } from "./components";
20
20
  export {
21
21
  resolveSiteGlobals,
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Contract test for the public `deferredSectionLoader` wrapper
3
+ * (`@decocms/tanstack/sdk/deferredSectionLoader`).
4
+ *
5
+ * Before 7.7 this wrapper was unreachable from any public subpath, so
6
+ * migrated sites (lebiscuit, miess, granadobr, casaevideo) each carried a
7
+ * byte-identical local shim wrapping the public `loadDeferredSection`
8
+ * export. The cases below are derived from how those sites call it: it is
9
+ * passed verbatim as `<DecoPageRenderer loadDeferredSectionFn={...} />`,
10
+ * so it must (a) accept the renderer's flat argument object, (b) wrap it
11
+ * into the server function's `{ data }` envelope, and (c) pass the result
12
+ * (section or null) straight through.
13
+ */
14
+ import type { ComponentProps } from "react";
15
+ import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
16
+
17
+ vi.mock("../routes/cmsRoute", () => ({
18
+ loadDeferredSection: vi.fn(),
19
+ }));
20
+
21
+ import type { DecoPageRenderer } from "../hooks/DecoPageRenderer";
22
+ import { loadDeferredSection } from "../routes/cmsRoute";
23
+ import { deferredSectionLoader } from "./deferredSectionLoader";
24
+
25
+ const mockedLoad = loadDeferredSection as unknown as ReturnType<typeof vi.fn>;
26
+
27
+ describe("deferredSectionLoader", () => {
28
+ beforeEach(() => {
29
+ mockedLoad.mockReset();
30
+ });
31
+
32
+ it("wraps the flat argument object into the server function's { data } envelope", async () => {
33
+ const section = { component: "site/sections/Newsletter.tsx", props: {} };
34
+ mockedLoad.mockResolvedValue(section);
35
+
36
+ const result = await deferredSectionLoader({
37
+ component: "site/sections/Newsletter.tsx",
38
+ rawProps: { title: "Hi" },
39
+ pagePath: "/",
40
+ pageUrl: "https://example.com/?utm=x",
41
+ index: 4,
42
+ });
43
+
44
+ expect(mockedLoad).toHaveBeenCalledTimes(1);
45
+ expect(mockedLoad).toHaveBeenCalledWith({
46
+ data: {
47
+ component: "site/sections/Newsletter.tsx",
48
+ rawProps: { title: "Hi" },
49
+ pagePath: "/",
50
+ pageUrl: "https://example.com/?utm=x",
51
+ index: 4,
52
+ },
53
+ });
54
+ expect(result).toBe(section);
55
+ });
56
+
57
+ it("accepts the minimal call shape (component + pagePath only)", async () => {
58
+ mockedLoad.mockResolvedValue(null);
59
+
60
+ await deferredSectionLoader({
61
+ component: "site/sections/Footer.tsx",
62
+ pagePath: "/collections/sale",
63
+ });
64
+
65
+ expect(mockedLoad).toHaveBeenCalledWith({
66
+ data: {
67
+ component: "site/sections/Footer.tsx",
68
+ rawProps: undefined,
69
+ pagePath: "/collections/sale",
70
+ pageUrl: undefined,
71
+ index: undefined,
72
+ },
73
+ });
74
+ });
75
+
76
+ it("passes a null resolution (cache miss / unresolvable section) straight through", async () => {
77
+ mockedLoad.mockResolvedValue(null);
78
+
79
+ const result = await deferredSectionLoader({
80
+ component: "site/sections/Missing.tsx",
81
+ pagePath: "/",
82
+ index: 0,
83
+ });
84
+
85
+ expect(result).toBeNull();
86
+ });
87
+
88
+ it("matches DecoPageRenderer's loadDeferredSectionFn prop signature", () => {
89
+ // The whole point of the export: sites pass it verbatim as
90
+ // `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`.
91
+ type LoadFn = NonNullable<ComponentProps<typeof DecoPageRenderer>["loadDeferredSectionFn"]>;
92
+ expectTypeOf(deferredSectionLoader).toExtend<LoadFn>();
93
+ });
94
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pre-wrapped deferred section loader for IntersectionObserver-based
3
+ * rendering.
4
+ *
5
+ * Convenience wrapper around `loadDeferredSection` (the POST server
6
+ * function defined in `../routes/cmsRoute.ts`) that matches the
7
+ * `loadDeferredSectionFn` prop signature of `DecoPageRenderer`.
8
+ *
9
+ * Pass this directly to
10
+ * `<DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />`
11
+ * to enable IntersectionObserver-based lazy loading of deferred sections.
12
+ *
13
+ * Publicly exported at `@decocms/tanstack/sdk/deferredSectionLoader`.
14
+ * Before 7.7 this wrapper existed only inside the package (exported from
15
+ * the internal `./routes` barrel but reachable from no public subpath), so
16
+ * migrated sites each carried a byte-identical local shim re-implementing
17
+ * it against the public `loadDeferredSection` export. Those shims can be
18
+ * deleted in favor of this subpath.
19
+ *
20
+ * @example Site's `src/routes/$.tsx`:
21
+ * ```tsx
22
+ * import { deferredSectionLoader } from "@decocms/tanstack/sdk/deferredSectionLoader";
23
+ *
24
+ * <DecoPageRenderer loadDeferredSectionFn={deferredSectionLoader} />
25
+ * ```
26
+ */
27
+ import type { ResolvedSection } from "@decocms/blocks/cms";
28
+ import { loadDeferredSection } from "../routes/cmsRoute";
29
+
30
+ export const deferredSectionLoader = async ({
31
+ component,
32
+ rawProps,
33
+ pagePath,
34
+ pageUrl,
35
+ index,
36
+ }: {
37
+ component: string;
38
+ rawProps?: Record<string, unknown>;
39
+ pagePath: string;
40
+ pageUrl?: string;
41
+ index?: number;
42
+ }): Promise<ResolvedSection | null> => {
43
+ return loadDeferredSection({
44
+ data: { component, rawProps, pagePath, pageUrl, index },
45
+ });
46
+ };
@@ -1,14 +1,28 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import { computeRevision, getRevision, KV_KEYS, type KVNamespace, loadBlocks, setBlocks } from "@decocms/blocks/cms";
2
+ import {
3
+ computeRevision,
4
+ getRevision,
5
+ type KVNamespace,
6
+ loadBlocks,
7
+ revisionKey,
8
+ setBlocks,
9
+ snapshotKey,
10
+ } from "@decocms/blocks/cms";
3
11
  import {
4
12
  __resetKvHydrationStateForTests,
5
13
  ensureBlocksHydrated,
14
+ getDeploymentId,
6
15
  isFastDeployEnabled,
7
16
  maybePollRevision,
8
17
  } from "./kvHydration";
9
18
 
10
19
  const BUNDLED = { Site: { name: "bundled" } };
11
20
 
21
+ /** Deployment id used across the hydration tests. */
22
+ const ID = "sha-deadbeef";
23
+ const SNAP = snapshotKey(ID);
24
+ const REV = revisionKey(ID);
25
+
12
26
  /** KV stub that counts get() calls so we can assert throttling / single-load. */
13
27
  function makeKV(initial: Record<string, string> = {}) {
14
28
  const store = new Map<string, string>(Object.entries(initial));
@@ -32,11 +46,16 @@ function makeKV(initial: Record<string, string> = {}) {
32
46
 
33
47
  function snapshotEnv(blocks: Record<string, unknown>, extra: Record<string, unknown> = {}) {
34
48
  const { kv, ...rest } = makeKV({
35
- [KV_KEYS.SNAPSHOT]: JSON.stringify(blocks),
36
- [KV_KEYS.REVISION]: computeRevision(blocks),
49
+ [SNAP]: JSON.stringify(blocks),
50
+ [REV]: computeRevision(blocks),
37
51
  });
38
- // DECO_FAST_DEPLOY="1" is the explicit opt-in required alongside the binding.
39
- return { env: { DECO_KV: kv, DECO_FAST_DEPLOY: "1", ...extra }, kv, ...rest };
52
+ // DECO_FAST_DEPLOY="1" is the explicit opt-in required alongside the binding;
53
+ // DECO_DEPLOYMENT_ID scopes the read to this deployment's keyed snapshot.
54
+ return {
55
+ env: { DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID, ...extra },
56
+ kv,
57
+ ...rest,
58
+ };
40
59
  }
41
60
 
42
61
  /** Collects ctx.waitUntil promises so tests can await background polls. */
@@ -110,10 +129,19 @@ describe("ensureBlocksHydrated", () => {
110
129
  expect(getCalls()).toBe(2);
111
130
  });
112
131
 
113
- it("keeps the bundled snapshot when the snapshot key is absent", async () => {
114
- const { kv } = makeKV({ [KV_KEYS.REVISION]: "r" }); // no SNAPSHOT
115
- await ensureBlocksHydrated({ DECO_KV: kv });
132
+ it("keeps the bundled snapshot when this deployment's snapshot key is absent", async () => {
133
+ const { kv } = makeKV({ [REV]: "r" }); // revision present, but no SNAPSHOT
134
+ await ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID });
135
+ expect(loadBlocks()).toEqual(BUNDLED);
136
+ });
137
+
138
+ it("keeps bundled (never touches KV) when no deployment id resolves", async () => {
139
+ const kvBlocks = { Site: { name: "from-kv" } };
140
+ const { kv, getCalls } = makeKV({ [SNAP]: JSON.stringify(kvBlocks) });
141
+ // Fast-deploy enabled + bound, but no DECO_DEPLOYMENT_ID / BUILD_HASH.
142
+ await ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1" });
116
143
  expect(loadBlocks()).toEqual(BUNDLED);
144
+ expect(getCalls()).toBe(0);
117
145
  });
118
146
 
119
147
  it("falls back to bundled (and does not throw) when KV errors", async () => {
@@ -123,11 +151,27 @@ describe("ensureBlocksHydrated", () => {
123
151
  delete: () => Promise.resolve(),
124
152
  };
125
153
  vi.spyOn(console, "warn").mockImplementation(() => {});
126
- await expect(ensureBlocksHydrated({ DECO_KV: kv })).resolves.toBeUndefined();
154
+ await expect(
155
+ ensureBlocksHydrated({ DECO_KV: kv, DECO_FAST_DEPLOY: "1", DECO_DEPLOYMENT_ID: ID }),
156
+ ).resolves.toBeUndefined();
127
157
  expect(loadBlocks()).toEqual(BUNDLED);
128
158
  });
129
159
  });
130
160
 
161
+ describe("getDeploymentId", () => {
162
+ it("prefers DECO_DEPLOYMENT_ID", () => {
163
+ expect(getDeploymentId({ DECO_DEPLOYMENT_ID: "a", BUILD_HASH: "b" })).toBe("a");
164
+ });
165
+
166
+ it("falls back to BUILD_HASH", () => {
167
+ expect(getDeploymentId({ BUILD_HASH: "b" })).toBe("b");
168
+ });
169
+
170
+ it("is null when nothing resolves", () => {
171
+ expect(getDeploymentId({})).toBeNull();
172
+ });
173
+ });
174
+
131
175
  describe("maybePollRevision", () => {
132
176
  it("is a no-op before cold-start hydration has run", async () => {
133
177
  const { env, getCalls } = snapshotEnv({ Site: { name: "x" } });
@@ -145,8 +189,8 @@ describe("maybePollRevision", () => {
145
189
 
146
190
  // Simulate a publish from another isolate: KV now holds v2.
147
191
  const updated = { Site: { name: "v2" }, "pages-x": { path: "/x" } };
148
- store.set(KV_KEYS.SNAPSHOT, JSON.stringify(updated));
149
- store.set(KV_KEYS.REVISION, computeRevision(updated));
192
+ store.set(SNAP, JSON.stringify(updated));
193
+ store.set(REV, computeRevision(updated));
150
194
 
151
195
  const { ctx, settle } = makeCtx();
152
196
  maybePollRevision(env, ctx);
@@ -24,12 +24,12 @@
24
24
  * `DecofileProvider` pattern from the deco-cx/deco Fresh runtime.
25
25
  */
26
26
 
27
- import { getRevision, setBlocks } from "@decocms/blocks/cms";
27
+ import { getDeploymentId, getRevision, setBlocks } from "@decocms/blocks/cms";
28
28
  import { KVBlockSource } from "../cms/kvBlockSource";
29
29
  import type { KVNamespace } from "@decocms/blocks/cms";
30
30
  import { setSpanAttribute } from "@decocms/blocks/sdk/observability";
31
31
 
32
- /** How often (ms) an isolate re-probes `index:revision`. */
32
+ /** How often (ms) an isolate re-probes its `index:revision:<id>`. */
33
33
  export const POLL_INTERVAL_MS = 10_000;
34
34
 
35
35
  /** KV binding name expected on the Worker `env`. */
@@ -39,6 +39,11 @@ export const KV_BINDING = "DECO_KV";
39
39
  * binding must also be present. */
40
40
  export const FAST_DEPLOY_ENV = "DECO_FAST_DEPLOY";
41
41
 
42
+ // Deployment-id resolution lives in `@decocms/blocks/cms` (next to the key
43
+ // builders it feeds) so the runtime read path and the admin write-through
44
+ // resolve it identically. Re-exported for the existing call sites/tests.
45
+ export { getDeploymentId };
46
+
42
47
  // globalThis-backed state so all Vite server-function split-module copies share
43
48
  // the same hydration flags (same pattern as `loader.ts`).
44
49
  const G = globalThis as unknown as {
@@ -105,9 +110,18 @@ export function ensureBlocksHydrated(env: Env, _ctx?: ExecutionContextLike): Pro
105
110
  const kv = getKV(env);
106
111
  if (!kv) return Promise.resolve();
107
112
 
113
+ // No resolvable deployment id ⇒ keep the bundled snapshot (this build's own
114
+ // content). Never read another deployment's key.
115
+ const deploymentId = getDeploymentId(env);
116
+ if (!deploymentId) {
117
+ setSpanAttribute("deco.block.source", "bundled");
118
+ G.__deco!.kvHydrated = true;
119
+ return Promise.resolve();
120
+ }
121
+
108
122
  const load = (async () => {
109
123
  try {
110
- const snapshot = await new KVBlockSource(kv).loadSnapshot();
124
+ const snapshot = await new KVBlockSource(kv, deploymentId).loadSnapshot();
111
125
  if (snapshot) {
112
126
  setBlocks(snapshot.blocks);
113
127
  setSpanAttribute("deco.block.source", "kv");
@@ -144,16 +158,19 @@ export function maybePollRevision(env: Env, ctx?: ExecutionContextLike): void {
144
158
  const kv = getKV(env);
145
159
  if (!kv) return;
146
160
 
147
- const poll = pollRevisionOnce(kv);
161
+ const deploymentId = getDeploymentId(env);
162
+ if (!deploymentId) return; // bundled-only; nothing to poll
163
+
164
+ const poll = pollRevisionOnce(kv, deploymentId);
148
165
  // Prefer waitUntil so the work outlives the response; fall back to a
149
166
  // fire-and-forget promise (dev / tests) with its rejection swallowed.
150
167
  if (ctx?.waitUntil) ctx.waitUntil(poll);
151
168
  else void poll.catch(() => {});
152
169
  }
153
170
 
154
- async function pollRevisionOnce(kv: KVNamespace): Promise<void> {
171
+ async function pollRevisionOnce(kv: KVNamespace, deploymentId: string): Promise<void> {
155
172
  try {
156
- const source = new KVBlockSource(kv);
173
+ const source = new KVBlockSource(kv, deploymentId);
157
174
  const remoteRevision = await source.getRevision();
158
175
  if (!remoteRevision || remoteRevision === getRevision()) return;
159
176
 
@@ -122,7 +122,7 @@ function appendResourceHints(resp: Response): void {
122
122
 
123
123
  /**
124
124
  * Minimal ExecutionContext interface compatible with Cloudflare Workers.
125
- * Defined here so deco-start doesn't need @cloudflare/workers-types.
125
+ * Defined here so blocks doesn't need @cloudflare/workers-types.
126
126
  */
127
127
  interface WorkerExecutionContext {
128
128
  waitUntil(promise: Promise<unknown>): void;