@decocms/tanstack 7.6.0 → 7.7.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.7.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -23,9 +23,9 @@
23
23
  "lint:unused": "knip"
24
24
  },
25
25
  "dependencies": {
26
- "@decocms/blocks": "7.6.0",
27
- "@decocms/blocks-admin": "7.6.0",
28
- "@decocms/blocks-cli": "7.6.0",
26
+ "@decocms/blocks": "7.7.0",
27
+ "@decocms/blocks-admin": "7.7.0",
28
+ "@decocms/blocks-cli": "7.7.0",
29
29
  "@deco-cx/warp-node": "^0.3.16",
30
30
  "fast-json-patch": "^3.1.0",
31
31
  "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
  }
@@ -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;