@graphcommerce/graphql 10.1.0-canary.43 → 10.1.0-canary.44

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Change Log
2
2
 
3
+ ## 10.1.0-canary.44
4
+
5
+ ### Patch Changes
6
+
7
+ - [#2662](https://github.com/graphcommerce-org/graphcommerce/pull/2662) [`54e167f`](https://github.com/graphcommerce-org/graphcommerce/commit/54e167f13b883fbe24e954c6c093097c0815263f) - Publishing content now pushes a renew signal through the Next.js incremental cache, so every server invalidates at once instead of each polling `cdn/spaces/me` on a 60 second interval — `storyblok.cacheVersionTtl` therefore defaults to `3600` as a failsafe. ([@paales](https://github.com/paales))
8
+
3
9
  ## 10.1.0-canary.43
4
10
 
5
11
  ## 10.1.0-canary.42
package/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './generated/types'
7
7
  export * from './config'
8
8
  export * from './utils/getPreviewData'
9
9
  export * from './utils/cachePolicy'
10
+ export * from './utils/renewSignal'
10
11
  export * from './components/PrivateQueryMask/PrivateQueryMask'
11
12
  export * from './hooks/usePrivateQueryContext'
12
13
  export * from './hooks/usePrivateQuery'
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@graphcommerce/graphql",
3
3
  "homepage": "https://www.graphcommerce.org/",
4
4
  "repository": "github:graphcommerce-org/graphcommerce",
5
- "version": "10.1.0-canary.43",
5
+ "version": "10.1.0-canary.44",
6
6
  "sideEffects": false,
7
7
  "main": "index.ts",
8
8
  "prettier": "@graphcommerce/prettier-config-pwa",
@@ -28,12 +28,12 @@
28
28
  "rxjs": "^7.8.2"
29
29
  },
30
30
  "peerDependencies": {
31
- "@graphcommerce/eslint-config-pwa": "^10.1.0-canary.43",
32
- "@graphcommerce/graphql-codegen-near-operation-file": "10.1.0-canary.43",
33
- "@graphcommerce/graphql-codegen-relay-optimizer-plugin": "10.1.0-canary.43",
34
- "@graphcommerce/next-config": "^10.1.0-canary.43",
35
- "@graphcommerce/prettier-config-pwa": "^10.1.0-canary.43",
36
- "@graphcommerce/typescript-config-pwa": "^10.1.0-canary.43",
31
+ "@graphcommerce/eslint-config-pwa": "^10.1.0-canary.44",
32
+ "@graphcommerce/graphql-codegen-near-operation-file": "10.1.0-canary.44",
33
+ "@graphcommerce/graphql-codegen-relay-optimizer-plugin": "10.1.0-canary.44",
34
+ "@graphcommerce/next-config": "^10.1.0-canary.44",
35
+ "@graphcommerce/prettier-config-pwa": "^10.1.0-canary.44",
36
+ "@graphcommerce/typescript-config-pwa": "^10.1.0-canary.44",
37
37
  "@graphql-mesh/plugin-http-details-extensions": "*",
38
38
  "react": "^19.2.0",
39
39
  "react-dom": "^19.2.0"
@@ -0,0 +1,121 @@
1
+ /**
2
+ * A deployment-wide "content was published" signal, carried through Next.js' incremental cache so
3
+ * it reaches every server that shares a `cacheHandler`.
4
+ *
5
+ * Caches that are built per process — an SSR Apollo client's `InMemoryCache`, a CMS client's pinned
6
+ * cache-version — are invalidated by a webhook that only ever reaches one server. This is how the
7
+ * other servers find out, without any of them polling an upstream API to ask whether anything
8
+ * changed.
9
+ *
10
+ * Two Next.js internals are involved. `globalThis.__incrementalCache` is set per incoming request
11
+ * before route handling, so it is available inside an API route and inside ISR regeneration, but
12
+ * *not* in middleware or the edge runtime, which construct their own instance. The entry is written
13
+ * as `kind: 'FETCH'` with `fetchCache: true`, the one path that stores an arbitrary key verbatim
14
+ * instead of running it through `normalizePagePath()`.
15
+ *
16
+ * Both are unstable API. If either changes shape, reads and writes fail closed: the signal stays at
17
+ * its last known value and callers fall back to whatever time-based failsafe they have. The call
18
+ * shape does drift — `revalidate` became `cacheControl` between Next 14 and 15 — so it needs
19
+ * re-testing per major.
20
+ *
21
+ * Deployments on Next's default `FileSystemCache` must set `cacheMaxMemorySize: 0`; its in-memory
22
+ * LRU sits in front of the shared layer and would answer every read from the writing process' own
23
+ * memory. Custom cache handlers have no such layer.
24
+ */
25
+ const RENEW_SIGNAL_KEY = 'gc:signal:content-renew'
26
+ const RENEW_SIGNAL_POLL_MS = 1000
27
+ const RENEW_SIGNAL_REVALIDATE = 60 * 60 * 24 * 30
28
+
29
+ type IncrementalCacheLike = {
30
+ get: (key: string, ctx: unknown) => Promise<{ value?: { data?: { body?: string } } } | null>
31
+ set: (key: string, data: unknown, ctx: unknown) => Promise<void>
32
+ }
33
+
34
+ /** Undefined outside a Next.js request, in the browser bundle, and on the edge runtime. */
35
+ function incrementalCache(): IncrementalCacheLike | undefined {
36
+ return (globalThis as { __incrementalCache?: IncrementalCacheLike }).__incrementalCache
37
+ }
38
+
39
+ /** `undefined` until the first read completes — distinct from "read, and nothing was published". */
40
+ let signalValue: number | undefined
41
+ let signalReadAt = 0
42
+ let inFlight: Promise<number | undefined> | undefined
43
+
44
+ /**
45
+ * Publish the signal. Call this from the webhook that learns content changed.
46
+ *
47
+ * The value is a millisecond timestamp: only its ordering matters, never its absolute value.
48
+ */
49
+ export async function publishRenewSignal(): Promise<void> {
50
+ const cache = incrementalCache()
51
+ if (!cache) return
52
+ try {
53
+ await cache.set(
54
+ RENEW_SIGNAL_KEY,
55
+ {
56
+ kind: 'FETCH',
57
+ data: { headers: {}, body: String(Date.now()), url: '', status: 200 },
58
+ tags: [],
59
+ revalidate: RENEW_SIGNAL_REVALIDATE,
60
+ },
61
+ { fetchCache: true, tags: [] },
62
+ )
63
+ } catch {
64
+ // Unsupported or unwritable cache handler.
65
+ }
66
+ }
67
+
68
+ async function readRenewSignal(): Promise<number | undefined> {
69
+ const cache = incrementalCache()
70
+ if (!cache) return signalValue
71
+ try {
72
+ const entry = await cache.get(RENEW_SIGNAL_KEY, {
73
+ kind: 'FETCH',
74
+ tags: [],
75
+ softTags: [],
76
+ revalidate: RENEW_SIGNAL_REVALIDATE,
77
+ })
78
+ const value = Number(entry?.value?.data?.body)
79
+ // An absent entry means nothing has been published yet, which is a known state, not an unknown
80
+ // one — record it so cold-start callers stop treating the signal as unreadable.
81
+ signalValue = Number.isFinite(value) && value > 0 ? value : (signalValue ?? 0)
82
+ } catch {
83
+ // A cache handler that cannot serve this entry. Leave the signal as it was.
84
+ }
85
+ return signalValue
86
+ }
87
+
88
+ /**
89
+ * Read the signal, at most once per {@link RENEW_SIGNAL_POLL_MS}. Never an upstream API request.
90
+ *
91
+ * Concurrent callers share one read: on a fresh pod every in-flight `getStaticProps` hits this at
92
+ * once, and they should not each open their own.
93
+ */
94
+ export async function refreshRenewSignal(): Promise<number | undefined> {
95
+ const now = Date.now()
96
+ if (signalValue !== undefined && now - signalReadAt < RENEW_SIGNAL_POLL_MS) return signalValue
97
+ if (!inFlight) {
98
+ signalReadAt = now
99
+ inFlight = readRenewSignal().finally(() => {
100
+ inFlight = undefined
101
+ })
102
+ }
103
+ return inFlight
104
+ }
105
+
106
+ /**
107
+ * Last known signal value, or `undefined` when it has not been read yet.
108
+ *
109
+ * For callers that cannot await one: `graphqlSsrClient()` is synchronous and called as
110
+ * `const client = graphqlSsrClient(context)` throughout `getStaticProps`, so making the read
111
+ * awaited would mean making every consumer async. It gets the value from the previous refresh and
112
+ * schedules the next one, bounding staleness at one poll interval plus one call.
113
+ *
114
+ * `undefined` must not be read as "nothing published" — on a fresh pod, which is every pod right
115
+ * after a deploy, that would silently skip the first invalidation. Callers treat it as unknown and
116
+ * fall back to their own failsafe.
117
+ */
118
+ export function renewSignal(): number | undefined {
119
+ void refreshRenewSignal()
120
+ return signalValue
121
+ }