@fourtwelvelabs/fetch-contentful 0.4.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,170 @@
1
1
  # @fourtwelvelabs/fetch-contentful
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4712d22: Added three ways to stay under (or get past) Contentful's `QUERY_TOO_BIG` limit, and to opt into Automatic Persisted Queries.
8
+
9
+ **Every outgoing query is minified by default.** `graphql`'s `print()` is a pretty-printer — every level of nesting costs two bytes of indentation before a single field has been named, and Contentful's size limit counts that whitespace like anything else. Queries are now re-tokenized and rejoined with the least whitespace that keeps them distinct (the same idea as GQLMin), which is usually the single largest win available without touching a query's shape. Set `minifyQuery: false` to opt out — for example to read a request off the wire while debugging. `FetchContentfulError.query` and the annotated caret display always reflect whatever text was actually sent, minified or not.
10
+
11
+ **A new `autoSplitOnSize` option (default `true`) adds a static-size pass on top of existing query splitting.** Today's `autoSplitNestedCollections` and `@split` decide what to split by shape — every nested `*Collection` field, or whatever's explicitly annotated — regardless of whether the query actually needs it. The new pass instead measures the (minified) query exactly as it will be sent and, only if it's still over `maxQuerySize` (7500 bytes by default), peels off the single largest remaining field — one-to-one references included, not just collections — and re-measures, repeating until it fits or nothing splittable is left. A query that still doesn't fit at that point is sent as-is; Contentful's own `QUERY_TOO_BIG` is the accurate error to see, since there's nothing left for this pass to remove.
12
+
13
+ **A new `automaticPersistedQueries` option (default `false`) implements Contentful's APQ support.** The optimistic first attempt sends only a SHA-256 hash of the query; only the first time Contentful reports that hash unknown does the query text get sent (once), registering it for every request after. This is what actually raises Contentful's own size ceiling from 8 KB to 16 KB — `maxQuerySize` defaults to 15500 instead of 7500 whenever it's on — and it's off by default because it requires the Premium plan or above: on any other plan it would silently double every request's round trips for no benefit.
14
+
15
+ ## 1.0.0
16
+
17
+ ### Major Changes
18
+
19
+ - **1.0.0 — the API is stable.**
20
+
21
+ Everything the README documents is now covered by semver: `fetchContentful`,
22
+ `createFetchContentful`, every option on `FetchContentfulOptions`, the shape
23
+ of `FetchContentfulError`, and the exported helpers and types. Breaking
24
+ changes to any of them require a major release from here on.
25
+
26
+ Two things are deliberately *not* part of that promise:
27
+
28
+ - The `@split` directive's internal marker aliases (`_splitSysId`,
29
+ `_splitTypename`) and the generated subquery text. These are implementation
30
+ detail of query splitting, visible only if you inspect requests on the wire.
31
+ - The exact wording of error messages. `error.code`, `error.status`,
32
+ `error.errors` and `error.query` are stable; the prose is not.
33
+
34
+ This release also clears out the last deprecation — see the `token` removal
35
+ above — so 1.0 starts with no options that are documented one way and behave
36
+ another.
37
+ - **Breaking:** the deprecated `token` option is removed. Use `deliveryToken`
38
+ and `previewToken`.
39
+
40
+ A single `token` could not serve both modes. It was applied to whichever mode
41
+ a call happened to be in, so a factory configured with one sent a *delivery*
42
+ token to any call that passed `preview: true` — authenticating against the
43
+ Preview API with a credential that cannot read drafts. The two-token form has
44
+ been the documented way to configure this since 0.4.0; this release stops
45
+ carrying the version that could silently authenticate against the wrong API.
46
+
47
+ ```diff
48
+ export const fetchContentful = createFetchContentful({
49
+ space: 'abc123',
50
+ - token: process.env.CONTENTFUL_ACCESS_TOKEN,
51
+ + deliveryToken: process.env.CONTENTFUL_ACCESS_TOKEN,
52
+ + previewToken: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN,
53
+ });
54
+ ```
55
+
56
+ If you only ever fetch published content, `deliveryToken` alone is enough —
57
+ `previewToken` is required only for calls that pass `preview: true`, and the
58
+ `CONFIG` error names exactly which one is missing.
59
+
60
+ Passing `token` is now an unknown option: it is not silently ignored in a way
61
+ that sends an unauthenticated request, it simply is not a token, so the call
62
+ fails the same way passing none does.
63
+
64
+ `EnvSettings.token`, the matching deprecated alias on `readEnvSettings()`, is
65
+ removed with it. Read `deliveryToken` instead — same value, and it says which
66
+ of the two tokens it is.
67
+ - ffb17c0: Unpublished and deleted references no longer reject the call, and no longer
68
+ leave `null` holes in your collections.
69
+
70
+ Contentful's GraphQL API does not omit a link it cannot resolve — a reference
71
+ whose target was deleted, or (on the Delivery API) never published. It returns
72
+ `null` **in that position**, on an HTTP 200, with an `UNRESOLVABLE_LINK` object
73
+ in the `errors` array:
74
+
75
+ ```jsonc
76
+ {
77
+ "data": { "page": { "sectionsCollection": { "items": [{ "heading": "One" }, null] } } },
78
+ "errors": [{ "extensions": { "contentful": { "code": "UNRESOLVABLE_LINK", … } } }]
79
+ }
80
+ ```
81
+
82
+ This library rejected on any non-empty `errors` array, so a single entry
83
+ unpublished by an editor failed the *entire* fetch — discarding a response that
84
+ was otherwise complete and correct. That is now the one GraphQL error that does
85
+ not reject on its own. Any other error still does, and one riding along with an
86
+ unresolvable link takes the whole list with it into `error.errors`.
87
+
88
+ Left alone, the response then hands you `(Section | null)[]`, where the obvious
89
+ `sections.map((s) => s.heading)` throws — not in development, where everything
90
+ is published, but on the afternoon someone unpublishes one thing. So the null
91
+ positions are now **dropped by default**, in the data and in the return type:
92
+
93
+ ```ts
94
+ const page = await fetchContentful<PageQuery>(QUERY);
95
+ page.sections.map((section) => section.heading); // Section[], no guard needed
96
+ ```
97
+
98
+ Change it with the new `unresolvableLinks` option:
99
+
100
+ | Value | Data | `items` type |
101
+ | --- | --- | --- |
102
+ | `'omit'` *(default)* | Null positions dropped | `Section[]` |
103
+ | `'null'` | Contentful's response, untouched | `(Section \| null)[]` |
104
+ | `'error'` | Rejects with a `GRAPHQL` error | — |
105
+
106
+ `'error'` restores the pre-1.0 behavior exactly.
107
+
108
+ Because dropping a null silently hides a real editorial mistake, `onUnresolvableLink`
109
+ is called once per request that carried one, with the errors Contentful sent —
110
+ each holding the `linkId`, `field` and `type` of the missing reference. Anything
111
+ it throws is swallowed: it runs inside the retry loop, so a failing logger would
112
+ otherwise look like a failed request and send the query again.
113
+
114
+ ```ts
115
+ export const fetchContentful = createFetchContentful({
116
+ onUnresolvableLink: (errors) => logger.warn({ errors }, 'broken Contentful reference'),
117
+ });
118
+ ```
119
+
120
+ Two limits worth knowing. A *one-to-one* reference has no position to drop, so
121
+ an unresolvable one stays `null` on its field in every mode but `'error'`. And
122
+ the Preview API resolves drafts, so the same query can legitimately return
123
+ different array lengths in preview and production — usually the explanation when
124
+ a page looks right in Draft Mode and short in production.
125
+
126
+ Also exported: `omitUnresolvedLinks`, `isUnresolvableLinkError`,
127
+ `partitionUnresolvableLinks`, and the type-level `OmitUnresolvedLinks<T>` /
128
+ `UnresolvableLinkMode`.
129
+
130
+ ### Minor Changes
131
+
132
+ - ffb17c0: Errors from Contentful now show the query, with a caret under the line the
133
+ error points at.
134
+
135
+ Contentful reports `line` and `column` for most rejected queries, but those
136
+ coordinates are into the query **as sent** — after fragments are inlined,
137
+ `preview`/`locale` arguments injected, and split fields hoisted into
138
+ generated subqueries. Line 6 of a document nobody has ever seen is not a
139
+ clue, so the query now comes along with the message:
140
+
141
+ ```
142
+ Contentful returned GraphQL errors: Cannot query field "titel" on type "Page".
143
+
144
+ 2 | pageCollection(where: {slug: $slug}, limit: 1) {
145
+ 3 | items {
146
+ > 4 | titel
147
+ | ^ Cannot query field "titel" on type "Page".
148
+ 5 | }
149
+ ```
150
+
151
+ The marked line is colored when stderr is a terminal, and left as plain text
152
+ when it is not, so log pipelines still get something they can store.
153
+ `NO_COLOR` and `FORCE_COLOR` are honored. No dependency was added.
154
+
155
+ Two related changes come with it:
156
+
157
+ - A non-2xx response is no longer reported by status alone. Contentful
158
+ answers an invalid query with HTTP 400 and the same `errors` array a 200
159
+ would have carried, so those messages (and their locations) now reach the
160
+ error instead of being dropped on the floor. 5xx bodies are still ignored —
161
+ they explain nothing and the request is on its way to a retry.
162
+ - `FetchContentfulError` carries the `query` that failed.
163
+
164
+ Set `annotateQueryOnError: false` to keep messages to a single line. The
165
+ formatter is exported as `annotateQuery(query, errors, options)` for custom
166
+ reporting.
167
+
3
168
  ## 0.4.1
4
169
 
5
170
  ### Patch Changes
package/README.md CHANGED
@@ -3,10 +3,12 @@
3
3
  A foolproof, type-safe GraphQL fetching utility for Contentful. One function in, shaped data out:
4
4
 
5
5
  - **Automatic query splitting** — nested reference collections (and `@split`-annotated one-to-one references) are broken into separate subqueries, fetched in id batches, and stitched back into the original response shape. Fully recursive, so deeply nested queries never hit Contentful's query complexity limit.
6
+ - **Automatic query minification** — every outgoing query has its insignificant whitespace stripped before it's sent, which is usually the single biggest lever against Contentful's `QUERY_TOO_BIG` limit. Combined with a static-size pass (`autoSplitOnSize`, on by default) that splits off more fields only when a query still measures too big, and optional support for Automatic Persisted Queries. See [Staying under the query size limit](#staying-under-the-query-size-limit).
6
7
  - **Automatic retries** — transient failures (network errors, 408/429/5xx) retry with exponential backoff + jitter, honoring `Retry-After`. Configurable, default 5.
7
- - **Response shaping (built in)** — before the promise resolves, every `fooCollection.items` in the response becomes `foo` (a plain array), at runtime *and* in the return type. No separate call needed.
8
- - **Single-root unwrapping (built in)** — when a query has one root field, the promise resolves with that field's contents directly (`data` *is* the array/entry, not `{ siteSettings: ... }`). Multi-root queries stay wrapped.
8
+ - **Response shaping (built in)** — before the promise resolves, every `fooCollection.items` in the response becomes `foo` (a plain array), at runtime _and_ in the return type. No separate call needed.
9
+ - **Single-root unwrapping (built in)** — when a query has one root field, the promise resolves with that field's contents directly (`data` _is_ the array/entry, not `{ siteSettings: ... }`). Multi-root queries stay wrapped.
9
10
  - **No preview/locale boilerplate** — `preview` and `locale` are injected as arguments onto your query's root fields automatically (Contentful cascades them to every nested field), so queries never need to declare or thread them. The locale defaults to `en-US`.
11
+ - **No null holes in collections (built in)** — an unpublished or deleted reference comes back from Contentful as a `null` _inside_ `items`. Those positions are dropped before the promise resolves, at runtime and in the return type, so `items.map(...)` is safe. Configurable via `unresolvableLinks`.
10
12
  - **All-or-nothing promise** — resolves only when every request in the tree succeeded; rejects with a typed `FetchContentfulError` if anything fails.
11
13
  - **Locale awareness** — locales are fetched from Contentful once and cached in-module, so validation costs nothing per fetch.
12
14
  - **Inferred types, optionally** — pass a [gql.tada](https://gql-tada.0no.co) or graphql-codegen document and the result and variable types come from the query itself. One command (`tada-init`) sets gql.tada up against your space. See [Typed queries with gql.tada](#typed-queries-with-gqltada).
@@ -30,24 +32,24 @@ If `space` or the appropriate token can't be resolved, the promise rejects with
30
32
 
31
33
  ### Environment variables
32
34
 
33
- | Setting | Neutral name | Next.js client-safe name |
34
- | --- | --- | --- |
35
- | `space` | `CONTENTFUL_SPACE_ID` | `NEXT_PUBLIC_CONTENTFUL_SPACE_ID` |
36
- | `environment` | `CONTENTFUL_ENVIRONMENT` | `NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT` |
37
- | `deliveryToken` | `CONTENTFUL_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN` |
38
- | `previewToken` | `CONTENTFUL_PREVIEW_ACCESS_TOKEN` | **none, by design** |
35
+ | Setting | Neutral name | Next.js client-safe name |
36
+ | --------------- | --------------------------------- | ------------------------------------- |
37
+ | `space` | `CONTENTFUL_SPACE_ID` | `NEXT_PUBLIC_CONTENTFUL_SPACE_ID` |
38
+ | `environment` | `CONTENTFUL_ENVIRONMENT` | `NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT` |
39
+ | `deliveryToken` | `CONTENTFUL_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN` |
40
+ | `previewToken` | `CONTENTFUL_PREVIEW_ACCESS_TOKEN` | **none, by design** |
39
41
 
40
42
  There is no environment variable for the locale: it defaults to **`en-US`**, and a project that wants a different one sets it once with `createFetchContentful` (below).
41
43
 
42
44
  The library reads the already-populated `process.env`; loading `.env` / `.env.local` files from disk is your platform's job (Next.js, Vite, dotenv all do this), so precedence between those files always matches your framework's rules.
43
45
 
44
- **In a Next.js project you don't need both names.** Set only the `NEXT_PUBLIC_` variant and it works everywhere: the server reads it like any other env var (the prefix only controls *client* exposure), and client bundles get it because the library reads these variables with literal `process.env.NEXT_PUBLIC_...` accesses — the exact pattern Next's build-time inliner string-replaces. Server-only projects (or any other framework) should use the neutral names. This applies to the space, the environment and the delivery token; the preview token has no client-safe name, for the reason below.
46
+ **In a Next.js project you don't need both names.** Set only the `NEXT_PUBLIC_` variant and it works everywhere: the server reads it like any other env var (the prefix only controls _client_ exposure), and client bundles get it because the library reads these variables with literal `process.env.NEXT_PUBLIC_...` accesses — the exact pattern Next's build-time inliner string-replaces. Server-only projects (or any other framework) should use the neutral names. This applies to the space, the environment and the delivery token; the preview token has no client-safe name, for the reason below.
45
47
 
46
48
  ### The preview token is never read from a `NEXT_PUBLIC_` variable
47
49
 
48
50
  Next.js exposes `NEXT_PUBLIC_` variables to the browser by string-replacing literal `process.env.NEXT_PUBLIC_…` accesses at build time. This library contains such accesses, so if it read the preview token from a prefixed name, setting that variable would bake the token into your public JavaScript — whether or not any client code ever asked for preview content. Importing the library would be enough.
49
51
 
50
- A delivery token is read-only and commonly public. A **preview token reads unpublished content**, so it is resolved only from the unprefixed `CONTENTFUL_PREVIEW_ACCESS_TOKEN`. In Next.js that name is still readable on the server — the prefix only controls *client* exposure — so server-side preview (Draft Mode, route handlers, server components) works unchanged.
52
+ A delivery token is read-only and commonly public. A **preview token reads unpublished content**, so it is resolved only from the unprefixed `CONTENTFUL_PREVIEW_ACCESS_TOKEN`. In Next.js that name is still readable on the server — the prefix only controls _client_ exposure — so server-side preview (Draft Mode, route handlers, server components) works unchanged.
51
53
 
52
54
  If you have decided that your app genuinely needs preview in the browser, pass `previewToken` explicitly. Shipping it is then your deliberate choice rather than something the library did on your behalf.
53
55
 
@@ -60,9 +62,9 @@ For utility-specific configuration, create a configured instance once and import
60
62
  import { createFetchContentful } from '@fourtwelvelabs/fetch-contentful';
61
63
 
62
64
  export const fetchContentful = createFetchContentful({
63
- space: 'abc123', // or leave unset to use env vars
64
- locale: 'de-DE', // override the 'en-US' default for the whole project
65
- retries: 3,
65
+ space: 'abc123', // or leave unset to use env vars
66
+ locale: 'de-DE', // override the 'en-US' default for the whole project
67
+ retries: 3
66
68
  });
67
69
 
68
70
  // Configure both tokens once, and every call picks the right one:
@@ -73,10 +75,11 @@ export const fetchContentful = createFetchContentful({
73
75
  ```ts
74
76
  // anywhere else
75
77
  import { fetchContentful } from '@/lib/contentful';
78
+
76
79
  const data = await fetchContentful<PagesQuery>(QUERY);
77
80
  ```
78
81
 
79
- Per-call options override factory defaults (top-level shallow merge). One caveat: `shapeResponseData: false` and `unwrapRootField: false` change the return *type* only when written on the call itself, so set those per call rather than as factory defaults.
82
+ Per-call options override factory defaults (top-level shallow merge). One caveat: `shapeResponseData: false`, `unwrapRootField: false` and `unresolvableLinks: 'null'` change the return _type_ only when written on the call itself, so set those per call rather than as factory defaults. Set on the factory they still take effect at runtime — the type just assumes the default.
80
83
 
81
84
  ## Quick start
82
85
 
@@ -107,7 +110,7 @@ const data = await fetchContentful<PagesQuery>(
107
110
  }
108
111
  }
109
112
  `,
110
- { variables: { limit: 10 }, preview: true, locale: 'en-US' },
113
+ { variables: { limit: 10 }, preview: true, locale: 'en-US' }
111
114
  );
112
115
 
113
116
  // Shaped and unwrapped: the single root field's contents come back directly.
@@ -137,14 +140,14 @@ import type { introspection } from './contentful-env.d.ts';
137
140
 
138
141
  export const graphql = initGraphQLTada<{
139
142
  introspection: introspection; // your space's content model, as types
140
- scalars: ContentfulScalars; // DateTime → string, JSON → JsonValue, …
143
+ scalars: ContentfulScalars; // DateTime → string, JSON → JsonValue, …
141
144
  }>();
142
145
 
143
146
  export { readFragment } from 'gql.tada';
144
147
  export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
145
148
  ```
146
149
 
147
- `graphql` is a tagged template function doing two jobs at once: at runtime it parses your query into a GraphQL document, and at compile time it infers that query's result and variable types from the schema. It can't be an export of this package — it has to be bound to *your* space — which is why the command generates it.
150
+ `graphql` is a tagged template function doing two jobs at once: at runtime it parses your query into a GraphQL document, and at compile time it infers that query's result and variable types from the schema. It can't be an export of this package — it has to be bound to _your_ space — which is why the command generates it.
148
151
 
149
152
  Restart the TypeScript server so `@0no-co/graphqlsp` loads (it's what gives you autocomplete and field validation inside the backticks). Then import `graphql` from wherever `tada-init` put it, and queries type themselves:
150
153
 
@@ -158,7 +161,11 @@ const PageQuery = graphql(`
158
161
  pageCollection(where: { slug: $slug }, limit: 1) {
159
162
  items {
160
163
  title
161
- sectionsCollection { items { heading } }
164
+ sectionsCollection {
165
+ items {
166
+ heading
167
+ }
168
+ }
162
169
  }
163
170
  }
164
171
  }
@@ -198,7 +205,7 @@ import { draftMode } from 'next/headers';
198
205
  const { isEnabled } = await draftMode();
199
206
  const data = await fetchContentful<PageQuery>(PAGE_QUERY, {
200
207
  preview: isEnabled,
201
- cache: isEnabled ? 'no-store' : undefined,
208
+ cache: isEnabled ? 'no-store' : undefined
202
209
  });
203
210
  ```
204
211
 
@@ -216,33 +223,45 @@ export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
216
223
  ```ts
217
224
  fetchContentful(query, {
218
225
  // Contentful targeting
219
- space: 'abc123', // default: NEXT_PUBLIC_CONTENTFUL_SPACE
220
- environment: 'master', // default: NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'
221
- preview: false, // default: false
222
- locale: 'en-US', // default: 'en-US'. null sends no locale at all
223
- validateLocale: true, // reject with LOCALE if locale isn't configured
224
- deliveryToken: '...', // CDA token, used when preview is false
225
- previewToken: '...', // CPA token, used when preview is true
226
+ space: 'abc123', // default: NEXT_PUBLIC_CONTENTFUL_SPACE
227
+ environment: 'master', // default: NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'
228
+ preview: false, // default: false
229
+ locale: 'en-US', // default: 'en-US'. null sends no locale at all
230
+ validateLocale: true, // reject with LOCALE if locale isn't configured
231
+ deliveryToken: '...', // CDA token, used when preview is false
232
+ previewToken: '...', // CPA token, used when preview is true
226
233
 
227
234
  // Request behavior
228
235
  variables: { slug: 'home' },
229
- retries: 5, // retry count after the initial attempt
230
- retryDelayMs: 250, // base backoff delay
231
- maxRetryDelayMs: 8000, // backoff ceiling
232
- signal: controller.signal, // AbortSignal, forwarded to fetch
233
- cache: 'no-store', // RequestCache, forwarded to fetch
234
- next: { revalidate: 60 }, // Next.js App Router fetch extensions
235
- fetch: customFetch, // injectable fetch (testing / polyfills)
236
+ retries: 5, // retry count after the initial attempt
237
+ retryDelayMs: 250, // base backoff delay
238
+ maxRetryDelayMs: 8000, // backoff ceiling
239
+ signal: controller.signal, // AbortSignal, forwarded to fetch
240
+ cache: 'no-store', // RequestCache, forwarded to fetch
241
+ next: { revalidate: 60 }, // Next.js App Router fetch extensions
242
+ fetch: customFetch, // injectable fetch (testing / polyfills)
236
243
 
237
244
  // Response handling
238
- shapeResponseData: true, // false = skip collection shaping
239
- unwrapRootField: true, // false = keep single-root responses wrapped
245
+ shapeResponseData: true, // false = skip collection shaping
246
+ unwrapRootField: true, // false = keep single-root responses wrapped
247
+ unresolvableLinks: 'omit', // 'omit' | 'null' | 'error' — broken references
248
+
249
+ // Diagnostics
250
+ annotateQueryOnError: true, // false = keep error messages to one line
251
+ onUnresolvableLink: (errors) => {}, // notified when a broken reference is tolerated
240
252
 
241
253
  // Query rewriting
242
- autoInjectArgs: true, // inject preview/locale args on root fields
254
+ autoInjectArgs: true, // inject preview/locale args on root fields
243
255
  autoSplitNestedCollections: true, // auto-detect non-root *Collection fields
244
- // (@split works regardless — see below)
245
- splitBatchSize: 50, // parent ids per subquery
256
+ // (@split works regardless — see below)
257
+ splitBatchSize: 50, // parent ids per subquery
258
+
259
+ // Query size
260
+ minifyQuery: true, // strip insignificant whitespace before sending
261
+ autoSplitOnSize: true, // split further if the query still measures too big
262
+ maxQuerySize: 7500, // byte budget that triggers autoSplitOnSize
263
+ // (15500 by default when automaticPersistedQueries is on — see below)
264
+ automaticPersistedQueries: false // requires the Premium plan or above
246
265
  });
247
266
  ```
248
267
 
@@ -286,7 +305,9 @@ Note that pagination metadata (`total`, `skip`, `limit`) selected alongside `ite
286
305
  query {
287
306
  pagedResults: pageCollection(limit: 10, skip: 20) {
288
307
  total
289
- items { title }
308
+ items {
309
+ title
310
+ }
290
311
  }
291
312
  }
292
313
  # resolves as data.pagedResults: { total: number; items: [...] }
@@ -296,6 +317,63 @@ The generic you pass (`fetchContentful<PagesQuery>`) should always describe the
296
317
 
297
318
  The standalone `shapeData` / `ShapeCollections` exports exist only for advanced cases where you're transforming Contentful data that came from somewhere else; you never need them when using `fetchContentful`.
298
319
 
320
+ ## Unpublished and deleted references
321
+
322
+ Contentful's GraphQL API does not omit a reference it cannot resolve — it returns `null` **in that position**:
323
+
324
+ ```jsonc
325
+ // what Contentful sends when sections[1] was unpublished
326
+ {
327
+ "data": { "page": { "sectionsCollection": { "items": [{ "heading": "One" }, null] } } },
328
+ "errors": [{ "message": "Query execution error. Link to entry 'xyz' … cannot be resolved",
329
+ "extensions": { "contentful": { "code": "UNRESOLVABLE_LINK", … } } }]
330
+ }
331
+ ```
332
+
333
+ Note the HTTP status is **200** and the rest of the data is exactly what you asked for. Only the missing entry is missing.
334
+
335
+ That leaves `items` typed `(Section | null)[]`, and the natural `sections.map((s) => s.heading)` throws — not in development, where everything is published, but on the afternoon an editor unpublishes one thing. So by default this library **drops those positions** and resolves with the entries that exist:
336
+
337
+ ```ts
338
+ const page = await fetchContentful<PageQuery>(QUERY);
339
+ page.sections.map((section) => section.heading); // Section[], no null check needed
340
+ ```
341
+
342
+ The return type is narrowed to match, so TypeScript agrees with the runtime rather than forcing a guard that can never fire.
343
+
344
+ ### Choosing a different behavior
345
+
346
+ | `unresolvableLinks` | Data | `items` type |
347
+ | -------------------- | -------------------------------- | --------------------- |
348
+ | `'omit'` _(default)_ | Null positions dropped | `Section[]` |
349
+ | `'null'` | Contentful's response, untouched | `(Section \| null)[]` |
350
+ | `'error'` | Rejects with a `GRAPHQL` error | — |
351
+
352
+ `'error'` is what this library did before 1.0. It is the strictest option and the one to reach for if a broken reference should stop a build rather than quietly shorten a list.
353
+
354
+ ### Keep the broken references visible
355
+
356
+ Dropping a null silently hides a real editorial mistake, so pair any non-`'error'` mode with a reporter:
357
+
358
+ ```ts
359
+ export const fetchContentful = createFetchContentful({
360
+ onUnresolvableLink: (errors) => {
361
+ for (const error of errors) {
362
+ const { linkId, field, type } = error.extensions.contentful.details;
363
+ logger.warn({ linkId, field, type }, 'Contentful reference could not be resolved');
364
+ }
365
+ }
366
+ });
367
+ ```
368
+
369
+ It is called once per request that carried a broken link, never in `'error'` mode (the call rejects with those same errors instead), and anything it throws is swallowed — a logger must not be able to fail, or silently retry, a fetch that otherwise succeeded.
370
+
371
+ ### Two things this does not do
372
+
373
+ **A one-to-one reference stays `null`.** `page.hero` has no position to drop, only a field with no value, so it remains `null` in every mode but `'error'` and still needs a check.
374
+
375
+ **Preview and production legitimately disagree.** The Preview API resolves drafts, so a link that is a hole in production is a real entry in preview. The same query can return different array lengths in the two modes — which is usually the explanation when a page looks right in Draft Mode and short in production.
376
+
299
377
  ## How splitting works
300
378
 
301
379
  Contentful rejects queries whose complexity exceeds its limit — usually caused by nested reference fields multiplying. `fetch-contentful` avoids this transparently:
@@ -312,9 +390,12 @@ query {
312
390
  pageCollection {
313
391
  items {
314
392
  title
315
- hero @split { # fetched in a second, cheaper query
393
+ hero @split {
394
+ # fetched in a second, cheaper query
316
395
  headline
317
- image { url }
396
+ image {
397
+ url
398
+ }
318
399
  }
319
400
  }
320
401
  }
@@ -335,7 +416,7 @@ Turn auto-splitting off and annotate only the fields that actually blow the comp
335
416
 
336
417
  ```ts
337
418
  const data = await fetchContentful(QUERY, {
338
- autoSplitNestedCollections: false,
419
+ autoSplitNestedCollections: false
339
420
  });
340
421
  ```
341
422
 
@@ -345,14 +426,24 @@ query {
345
426
  items {
346
427
  title
347
428
  # Small and cheap: fetched inline, in the same request.
348
- seo { description }
349
- tagsCollection(limit: 5) { items { name } }
429
+ seo {
430
+ description
431
+ }
432
+ tagsCollection(limit: 5) {
433
+ items {
434
+ name
435
+ }
436
+ }
350
437
 
351
438
  # The expensive one: fetched separately, batched by parent id.
352
439
  sectionsCollection(limit: 50) @split {
353
440
  items {
354
441
  heading
355
- mediaCollection(limit: 10) { items { url } }
442
+ mediaCollection(limit: 10) {
443
+ items {
444
+ url
445
+ }
446
+ }
356
447
  }
357
448
  }
358
449
  }
@@ -368,14 +459,43 @@ That sends two requests instead of the four auto mode would. Splits still nest r
368
459
 
369
460
  ```graphql
370
461
  query {
371
- pageCollection @split { # ✗ CONFIG error
372
- items { title }
462
+ pageCollection @split {
463
+ # CONFIG error
464
+ items {
465
+ title
466
+ }
373
467
  }
374
468
  }
375
469
  ```
376
470
 
377
471
  A root field has no parent entry to stitch its result back onto, so there is nothing for the directive to do. Reach for `limit` and `skip` on the field itself instead. (Before 0.2.1 the directive was silently stripped here — if an existing query relies on that, remove the directive.)
378
472
 
473
+ ## Staying under the query size limit
474
+
475
+ Separately from the complexity limit that query splitting exists for, Contentful also rejects a query whose **text** — whitespace and newlines included — exceeds [8 KB](https://www.contentful.com/developers/docs/references/graphql/overview/#query-size-limits) with a `QUERY_TOO_BIG` error. Three settings work together to avoid it, all on by default except the last:
476
+
477
+ 1. **`minifyQuery` (default `true`)** — every outgoing query is re-tokenized and rejoined with the least whitespace that keeps it valid, the same effect a tool like [GQLMin](https://github.com/drwpow/gqlmin) has. `graphql`'s pretty-printer spends two bytes of indentation per level of nesting before naming a single field, so this is usually the single biggest lever available, and it changes nothing about what the query asks for. Disable it only to read a request off the wire while debugging — `FetchContentfulError.query` and the annotated caret display always show whatever text was actually sent, so turning it off does not lose any diagnostic power.
478
+
479
+ 2. **`autoSplitOnSize` (default `true`)** — a static-size pass that runs after ordinary query splitting. `autoSplitNestedCollections` and `@split` decide what to split by a query's *shape*: every nested `*Collection` field, or whatever's explicitly annotated, whether or not the query actually needs the help. This pass instead measures the query exactly as it will be sent and, only when it's still over `maxQuerySize`, peels off the single largest remaining field — a one-to-one reference works exactly as well as a collection — and re-measures, repeating until it fits or nothing splittable is left. A query that still doesn't fit at that point is sent as-is: Contentful's own `QUERY_TOO_BIG` is the accurate error to see, since there's nothing left for this library to remove.
480
+
481
+ ```ts
482
+ const data = await fetchContentful(QUERY, {
483
+ maxQuerySize: 6000 // lower the default 7500-byte budget
484
+ });
485
+ ```
486
+
487
+ 3. **`automaticPersistedQueries` (default `false`)** — implements Contentful's [Automatic Persisted Queries](https://www.contentful.com/developers/docs/references/graphql/automatic-persisted-queries/) support, adopted from Apollo's spec. The optimistic first attempt sends only a SHA-256 hash of the query in `extensions`, with no `query` field at all; only the first time Contentful reports that hash unknown does the query text get sent (once, alongside the hash, which registers it). Every request after that — from this process or any other client — hits on the hash alone. This is what actually raises Contentful's ceiling from 8 KB to 16 KB, which is why `maxQuerySize` defaults to `15500` instead of `7500` whenever this is on.
488
+
489
+ **Requires the Premium plan or above.** On any other plan every request would silently cost two round trips forever instead of one, which is why this defaults to `false` rather than detecting the plan automatically — there is no API to check it from the client.
490
+
491
+ ```ts
492
+ const data = await fetchContentful(QUERY, {
493
+ automaticPersistedQueries: true
494
+ });
495
+ ```
496
+
497
+ None of this touches `autoSplitNestedCollections` or `@split`, which remain about the complexity limit, not the size limit — a query can be simple enough to never need splitting and still be big enough (long field lists, deep `where` filters) to trip `QUERY_TOO_BIG`, which is exactly the case `autoSplitOnSize` exists for.
498
+
379
499
  ## Error handling
380
500
 
381
501
  Every rejection is a `FetchContentfulError`:
@@ -387,32 +507,82 @@ try {
387
507
  await fetchContentful(QUERY);
388
508
  } catch (error) {
389
509
  if (isFetchContentfulError(error)) {
390
- error.code; // 'CONFIG' | 'NETWORK' | 'HTTP' | 'GRAPHQL' | 'STITCH' | 'LOCALE'
510
+ error.code; // 'CONFIG' | 'NETWORK' | 'HTTP' | 'GRAPHQL' | 'STITCH' | 'LOCALE'
391
511
  error.status; // HTTP status, when applicable
392
512
  error.errors; // GraphQL errors, when applicable
513
+ error.query; // the query as sent, when a request was made
393
514
  }
394
515
  }
395
516
  ```
396
517
 
518
+ A GraphQL response that reports **only** unresolvable links is the one thing that does not reject — see [Unpublished and deleted references](#unpublished-and-deleted-references). Any other error in the `errors` array still does, and if one rides along with an unresolvable link the whole list reaches `error.errors`.
519
+
520
+ ### Rejected queries are printed back with the bad line marked
521
+
522
+ Contentful reports a `line` and `column` for most queries it rejects — into
523
+ the query **as sent**, which is not the query you wrote: fragments have been
524
+ inlined, `preview`/`locale` arguments injected, and `@split` fields hoisted
525
+ into generated subqueries. So the query comes along with the message:
526
+
527
+ ```text
528
+ Contentful returned GraphQL errors: Cannot query field "titel" on type "Page".
529
+
530
+ 2 | pageCollection(where: {slug: $slug}, limit: 1) {
531
+ 3 | items {
532
+ > 4 | titel
533
+ | ^ Cannot query field "titel" on type "Page".
534
+ 5 | }
535
+ ```
536
+
537
+ The marked line is colored when stderr is a terminal and plain when it is
538
+ not, so a log pipeline still receives text it can store. `NO_COLOR` and
539
+ `FORCE_COLOR` are honored.
540
+
541
+ This also applies to the HTTP 400 Contentful returns for a query it cannot
542
+ validate: the `errors` in that response body reach `error.errors` and the
543
+ message, rather than being reported as a bare status.
544
+
545
+ Pass `annotateQueryOnError: false` to keep messages to a single line. The
546
+ query is still on the error either way, and the formatter is exported if you
547
+ would rather render it yourself:
548
+
549
+ ```ts
550
+ import { annotateQuery, isFetchContentfulError } from '@fourtwelvelabs/fetch-contentful';
551
+
552
+ if (isFetchContentfulError(error) && error.query && error.errors) {
553
+ logger.debug(annotateQuery(error.query, error.errors, { contextLines: 5 }));
554
+ }
555
+ ```
556
+
557
+ `contextLines` defaults to `3`; pass `Infinity` to print the whole query, and
558
+ `color: true | false` to override the terminal detection.
559
+
397
560
  ## Other exports
398
561
 
399
562
  ```ts
400
563
  import {
564
+ annotateQuery, // render a query with a caret under a reported error
565
+ clearLocaleCache, // reset the cache (tests, revalidation hooks)
401
566
  createFetchContentful, // project-level defaults (see Configuration)
402
- getLocales, // cached locale lookup (space/env/preview keyed)
403
- clearLocaleCache, // reset the cache (tests, revalidation hooks)
404
- shapeData, // the runtime collection shaper (already applied by fetchContentful)
405
- unwrapSingleRoot, // the runtime single-root unwrapper (already applied too)
406
- inlineFragments, // fragment inliner (advanced AST use)
407
- injectRootArgs, // the preview/locale argument injector (advanced AST use)
567
+ getLocales, // cached locale lookup (space/env/preview keyed)
568
+ injectRootArgs, // the preview/locale argument injector (advanced AST use)
569
+ inlineFragments, // fragment inliner (advanced AST use)
570
+ isUnresolvableLinkError, // classify one GraphQL error as a broken link
571
+ minifyQuery, // the whitespace minifier (already applied by fetchContentful)
572
+ omitUnresolvedLinks, // the runtime null-position dropper (applied too)
573
+ partitionUnresolvableLinks, // split an errors array into broken links vs the rest
574
+ shapeData, // the runtime collection shaper (already applied by fetchContentful)
575
+ unwrapSingleRoot, // the runtime single-root unwrapper (already applied too)
576
+ type FetchContentfulOptions,
577
+ type OmitUnresolvedLinks,
578
+ type ShapeCollections,
579
+ type UnresolvableLinkMode
408
580
  } from '@fourtwelvelabs/fetch-contentful';
409
- import type { ShapeCollections, FetchContentfulOptions } from '@fourtwelvelabs/fetch-contentful';
410
-
411
581
  // Type-only subpath, for wiring gql.tada up by hand (see docs/tada.md).
412
582
  import type { ContentfulScalars, JsonValue } from '@fourtwelvelabs/fetch-contentful/tada';
413
583
  ```
414
584
 
415
- `ShapeCollections<T>` and `UnwrapSingleRoot<T>` are the type-level twins of `shapeData` and `unwrapSingleRoot`. Neither is needed alongside `fetchContentful` (shaping is built into it) — they're for shaping Contentful-shaped data that arrived some other way.
585
+ `ShapeCollections<T>`, `UnwrapSingleRoot<T>` and `OmitUnresolvedLinks<T>` are the type-level twins of `shapeData`, `unwrapSingleRoot` and `omitUnresolvedLinks`. Neither is needed alongside `fetchContentful` (shaping is built into it) — they're for shaping Contentful-shaped data that arrived some other way.
416
586
 
417
587
  ## Limitations
418
588
 
@@ -420,6 +590,7 @@ import type { ContentfulScalars, JsonValue } from '@fourtwelvelabs/fetch-content
420
590
  - Splitting relies on Contentful's naming conventions: a parent of type `BlogPost` must be re-queryable via `blogPostCollection`. Custom schemas that break this convention will fail with a `STITCH` error rather than return wrong data.
421
591
  - Root-level collections are never split (there is no parent to stitch onto); only nested ones are.
422
592
  - `@split`/auto-split fields inside `... on Entry { ... }` fragments are treated as unconditional, since `Entry` is an interface and never matches a concrete `__typename`.
593
+ - `autoSplitOnSize` picks candidates by measured size, not by name, so it can choose a field whose parent type does not follow Contentful's `blogPostCollection` naming convention — the same `STITCH` error above, just reached automatically rather than by writing `@split` yourself.
423
594
 
424
595
  ## Development
425
596