@fourtwelvelabs/fetch-contentful 0.4.1 → 1.0.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,158 @@
1
1
  # @fourtwelvelabs/fetch-contentful
2
2
 
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - **1.0.0 — the API is stable.**
8
+
9
+ Everything the README documents is now covered by semver: `fetchContentful`,
10
+ `createFetchContentful`, every option on `FetchContentfulOptions`, the shape
11
+ of `FetchContentfulError`, and the exported helpers and types. Breaking
12
+ changes to any of them require a major release from here on.
13
+
14
+ Two things are deliberately *not* part of that promise:
15
+
16
+ - The `@split` directive's internal marker aliases (`_splitSysId`,
17
+ `_splitTypename`) and the generated subquery text. These are implementation
18
+ detail of query splitting, visible only if you inspect requests on the wire.
19
+ - The exact wording of error messages. `error.code`, `error.status`,
20
+ `error.errors` and `error.query` are stable; the prose is not.
21
+
22
+ This release also clears out the last deprecation — see the `token` removal
23
+ above — so 1.0 starts with no options that are documented one way and behave
24
+ another.
25
+ - **Breaking:** the deprecated `token` option is removed. Use `deliveryToken`
26
+ and `previewToken`.
27
+
28
+ A single `token` could not serve both modes. It was applied to whichever mode
29
+ a call happened to be in, so a factory configured with one sent a *delivery*
30
+ token to any call that passed `preview: true` — authenticating against the
31
+ Preview API with a credential that cannot read drafts. The two-token form has
32
+ been the documented way to configure this since 0.4.0; this release stops
33
+ carrying the version that could silently authenticate against the wrong API.
34
+
35
+ ```diff
36
+ export const fetchContentful = createFetchContentful({
37
+ space: 'abc123',
38
+ - token: process.env.CONTENTFUL_ACCESS_TOKEN,
39
+ + deliveryToken: process.env.CONTENTFUL_ACCESS_TOKEN,
40
+ + previewToken: process.env.CONTENTFUL_PREVIEW_ACCESS_TOKEN,
41
+ });
42
+ ```
43
+
44
+ If you only ever fetch published content, `deliveryToken` alone is enough —
45
+ `previewToken` is required only for calls that pass `preview: true`, and the
46
+ `CONFIG` error names exactly which one is missing.
47
+
48
+ Passing `token` is now an unknown option: it is not silently ignored in a way
49
+ that sends an unauthenticated request, it simply is not a token, so the call
50
+ fails the same way passing none does.
51
+
52
+ `EnvSettings.token`, the matching deprecated alias on `readEnvSettings()`, is
53
+ removed with it. Read `deliveryToken` instead — same value, and it says which
54
+ of the two tokens it is.
55
+ - ffb17c0: Unpublished and deleted references no longer reject the call, and no longer
56
+ leave `null` holes in your collections.
57
+
58
+ Contentful's GraphQL API does not omit a link it cannot resolve — a reference
59
+ whose target was deleted, or (on the Delivery API) never published. It returns
60
+ `null` **in that position**, on an HTTP 200, with an `UNRESOLVABLE_LINK` object
61
+ in the `errors` array:
62
+
63
+ ```jsonc
64
+ {
65
+ "data": { "page": { "sectionsCollection": { "items": [{ "heading": "One" }, null] } } },
66
+ "errors": [{ "extensions": { "contentful": { "code": "UNRESOLVABLE_LINK", … } } }]
67
+ }
68
+ ```
69
+
70
+ This library rejected on any non-empty `errors` array, so a single entry
71
+ unpublished by an editor failed the *entire* fetch — discarding a response that
72
+ was otherwise complete and correct. That is now the one GraphQL error that does
73
+ not reject on its own. Any other error still does, and one riding along with an
74
+ unresolvable link takes the whole list with it into `error.errors`.
75
+
76
+ Left alone, the response then hands you `(Section | null)[]`, where the obvious
77
+ `sections.map((s) => s.heading)` throws — not in development, where everything
78
+ is published, but on the afternoon someone unpublishes one thing. So the null
79
+ positions are now **dropped by default**, in the data and in the return type:
80
+
81
+ ```ts
82
+ const page = await fetchContentful<PageQuery>(QUERY);
83
+ page.sections.map((section) => section.heading); // Section[], no guard needed
84
+ ```
85
+
86
+ Change it with the new `unresolvableLinks` option:
87
+
88
+ | Value | Data | `items` type |
89
+ | --- | --- | --- |
90
+ | `'omit'` *(default)* | Null positions dropped | `Section[]` |
91
+ | `'null'` | Contentful's response, untouched | `(Section \| null)[]` |
92
+ | `'error'` | Rejects with a `GRAPHQL` error | — |
93
+
94
+ `'error'` restores the pre-1.0 behavior exactly.
95
+
96
+ Because dropping a null silently hides a real editorial mistake, `onUnresolvableLink`
97
+ is called once per request that carried one, with the errors Contentful sent —
98
+ each holding the `linkId`, `field` and `type` of the missing reference. Anything
99
+ it throws is swallowed: it runs inside the retry loop, so a failing logger would
100
+ otherwise look like a failed request and send the query again.
101
+
102
+ ```ts
103
+ export const fetchContentful = createFetchContentful({
104
+ onUnresolvableLink: (errors) => logger.warn({ errors }, 'broken Contentful reference'),
105
+ });
106
+ ```
107
+
108
+ Two limits worth knowing. A *one-to-one* reference has no position to drop, so
109
+ an unresolvable one stays `null` on its field in every mode but `'error'`. And
110
+ the Preview API resolves drafts, so the same query can legitimately return
111
+ different array lengths in preview and production — usually the explanation when
112
+ a page looks right in Draft Mode and short in production.
113
+
114
+ Also exported: `omitUnresolvedLinks`, `isUnresolvableLinkError`,
115
+ `partitionUnresolvableLinks`, and the type-level `OmitUnresolvedLinks<T>` /
116
+ `UnresolvableLinkMode`.
117
+
118
+ ### Minor Changes
119
+
120
+ - ffb17c0: Errors from Contentful now show the query, with a caret under the line the
121
+ error points at.
122
+
123
+ Contentful reports `line` and `column` for most rejected queries, but those
124
+ coordinates are into the query **as sent** — after fragments are inlined,
125
+ `preview`/`locale` arguments injected, and split fields hoisted into
126
+ generated subqueries. Line 6 of a document nobody has ever seen is not a
127
+ clue, so the query now comes along with the message:
128
+
129
+ ```
130
+ Contentful returned GraphQL errors: Cannot query field "titel" on type "Page".
131
+
132
+ 2 | pageCollection(where: {slug: $slug}, limit: 1) {
133
+ 3 | items {
134
+ > 4 | titel
135
+ | ^ Cannot query field "titel" on type "Page".
136
+ 5 | }
137
+ ```
138
+
139
+ The marked line is colored when stderr is a terminal, and left as plain text
140
+ when it is not, so log pipelines still get something they can store.
141
+ `NO_COLOR` and `FORCE_COLOR` are honored. No dependency was added.
142
+
143
+ Two related changes come with it:
144
+
145
+ - A non-2xx response is no longer reported by status alone. Contentful
146
+ answers an invalid query with HTTP 400 and the same `errors` array a 200
147
+ would have carried, so those messages (and their locations) now reach the
148
+ error instead of being dropped on the floor. 5xx bodies are still ignored —
149
+ they explain nothing and the request is on its way to a retry.
150
+ - `FetchContentfulError` carries the `query` that failed.
151
+
152
+ Set `annotateQueryOnError: false` to keep messages to a single line. The
153
+ formatter is exported as `annotateQuery(query, errors, options)` for custom
154
+ reporting.
155
+
3
156
  ## 0.4.1
4
157
 
5
158
  ### Patch Changes
package/README.md CHANGED
@@ -4,9 +4,10 @@ A foolproof, type-safe GraphQL fetching utility for Contentful. One function in,
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
6
  - **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.
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.
9
9
  - **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`.
10
+ - **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
11
  - **All-or-nothing promise** — resolves only when every request in the tree succeeded; rejects with a typed `FetchContentfulError` if anything fails.
11
12
  - **Locale awareness** — locales are fetched from Contentful once and cached in-module, so validation costs nothing per fetch.
12
13
  - **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 +31,24 @@ If `space` or the appropriate token can't be resolved, the promise rejects with
30
31
 
31
32
  ### Environment variables
32
33
 
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** |
34
+ | Setting | Neutral name | Next.js client-safe name |
35
+ | --------------- | --------------------------------- | ------------------------------------- |
36
+ | `space` | `CONTENTFUL_SPACE_ID` | `NEXT_PUBLIC_CONTENTFUL_SPACE_ID` |
37
+ | `environment` | `CONTENTFUL_ENVIRONMENT` | `NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT` |
38
+ | `deliveryToken` | `CONTENTFUL_ACCESS_TOKEN` | `NEXT_PUBLIC_CONTENTFUL_ACCESS_TOKEN` |
39
+ | `previewToken` | `CONTENTFUL_PREVIEW_ACCESS_TOKEN` | **none, by design** |
39
40
 
40
41
  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
42
 
42
43
  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
44
 
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.
45
+ **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
46
 
46
47
  ### The preview token is never read from a `NEXT_PUBLIC_` variable
47
48
 
48
49
  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
50
 
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.
51
+ 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
52
 
52
53
  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
54
 
@@ -60,9 +61,9 @@ For utility-specific configuration, create a configured instance once and import
60
61
  import { createFetchContentful } from '@fourtwelvelabs/fetch-contentful';
61
62
 
62
63
  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,
64
+ space: 'abc123', // or leave unset to use env vars
65
+ locale: 'de-DE', // override the 'en-US' default for the whole project
66
+ retries: 3
66
67
  });
67
68
 
68
69
  // Configure both tokens once, and every call picks the right one:
@@ -73,10 +74,11 @@ export const fetchContentful = createFetchContentful({
73
74
  ```ts
74
75
  // anywhere else
75
76
  import { fetchContentful } from '@/lib/contentful';
77
+
76
78
  const data = await fetchContentful<PagesQuery>(QUERY);
77
79
  ```
78
80
 
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.
81
+ 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
82
 
81
83
  ## Quick start
82
84
 
@@ -107,7 +109,7 @@ const data = await fetchContentful<PagesQuery>(
107
109
  }
108
110
  }
109
111
  `,
110
- { variables: { limit: 10 }, preview: true, locale: 'en-US' },
112
+ { variables: { limit: 10 }, preview: true, locale: 'en-US' }
111
113
  );
112
114
 
113
115
  // Shaped and unwrapped: the single root field's contents come back directly.
@@ -137,14 +139,14 @@ import type { introspection } from './contentful-env.d.ts';
137
139
 
138
140
  export const graphql = initGraphQLTada<{
139
141
  introspection: introspection; // your space's content model, as types
140
- scalars: ContentfulScalars; // DateTime → string, JSON → JsonValue, …
142
+ scalars: ContentfulScalars; // DateTime → string, JSON → JsonValue, …
141
143
  }>();
142
144
 
143
145
  export { readFragment } from 'gql.tada';
144
146
  export type { FragmentOf, ResultOf, VariablesOf } from 'gql.tada';
145
147
  ```
146
148
 
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.
149
+ `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
150
 
149
151
  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
152
 
@@ -158,7 +160,11 @@ const PageQuery = graphql(`
158
160
  pageCollection(where: { slug: $slug }, limit: 1) {
159
161
  items {
160
162
  title
161
- sectionsCollection { items { heading } }
163
+ sectionsCollection {
164
+ items {
165
+ heading
166
+ }
167
+ }
162
168
  }
163
169
  }
164
170
  }
@@ -198,7 +204,7 @@ import { draftMode } from 'next/headers';
198
204
  const { isEnabled } = await draftMode();
199
205
  const data = await fetchContentful<PageQuery>(PAGE_QUERY, {
200
206
  preview: isEnabled,
201
- cache: isEnabled ? 'no-store' : undefined,
207
+ cache: isEnabled ? 'no-store' : undefined
202
208
  });
203
209
  ```
204
210
 
@@ -216,33 +222,38 @@ export const getStaticProps: GetStaticProps = async ({ preview = false }) => {
216
222
  ```ts
217
223
  fetchContentful(query, {
218
224
  // 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
225
+ space: 'abc123', // default: NEXT_PUBLIC_CONTENTFUL_SPACE
226
+ environment: 'master', // default: NEXT_PUBLIC_CONTENTFUL_ENVIRONMENT ?? 'master'
227
+ preview: false, // default: false
228
+ locale: 'en-US', // default: 'en-US'. null sends no locale at all
229
+ validateLocale: true, // reject with LOCALE if locale isn't configured
230
+ deliveryToken: '...', // CDA token, used when preview is false
231
+ previewToken: '...', // CPA token, used when preview is true
226
232
 
227
233
  // Request behavior
228
234
  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)
235
+ retries: 5, // retry count after the initial attempt
236
+ retryDelayMs: 250, // base backoff delay
237
+ maxRetryDelayMs: 8000, // backoff ceiling
238
+ signal: controller.signal, // AbortSignal, forwarded to fetch
239
+ cache: 'no-store', // RequestCache, forwarded to fetch
240
+ next: { revalidate: 60 }, // Next.js App Router fetch extensions
241
+ fetch: customFetch, // injectable fetch (testing / polyfills)
236
242
 
237
243
  // Response handling
238
- shapeResponseData: true, // false = skip collection shaping
239
- unwrapRootField: true, // false = keep single-root responses wrapped
244
+ shapeResponseData: true, // false = skip collection shaping
245
+ unwrapRootField: true, // false = keep single-root responses wrapped
246
+ unresolvableLinks: 'omit', // 'omit' | 'null' | 'error' — broken references
247
+
248
+ // Diagnostics
249
+ annotateQueryOnError: true, // false = keep error messages to one line
250
+ onUnresolvableLink: (errors) => {}, // notified when a broken reference is tolerated
240
251
 
241
252
  // Query rewriting
242
- autoInjectArgs: true, // inject preview/locale args on root fields
253
+ autoInjectArgs: true, // inject preview/locale args on root fields
243
254
  autoSplitNestedCollections: true, // auto-detect non-root *Collection fields
244
- // (@split works regardless — see below)
245
- splitBatchSize: 50, // parent ids per subquery
255
+ // (@split works regardless — see below)
256
+ splitBatchSize: 50 // parent ids per subquery
246
257
  });
247
258
  ```
248
259
 
@@ -286,7 +297,9 @@ Note that pagination metadata (`total`, `skip`, `limit`) selected alongside `ite
286
297
  query {
287
298
  pagedResults: pageCollection(limit: 10, skip: 20) {
288
299
  total
289
- items { title }
300
+ items {
301
+ title
302
+ }
290
303
  }
291
304
  }
292
305
  # resolves as data.pagedResults: { total: number; items: [...] }
@@ -296,6 +309,63 @@ The generic you pass (`fetchContentful<PagesQuery>`) should always describe the
296
309
 
297
310
  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
311
 
312
+ ## Unpublished and deleted references
313
+
314
+ Contentful's GraphQL API does not omit a reference it cannot resolve — it returns `null` **in that position**:
315
+
316
+ ```jsonc
317
+ // what Contentful sends when sections[1] was unpublished
318
+ {
319
+ "data": { "page": { "sectionsCollection": { "items": [{ "heading": "One" }, null] } } },
320
+ "errors": [{ "message": "Query execution error. Link to entry 'xyz' … cannot be resolved",
321
+ "extensions": { "contentful": { "code": "UNRESOLVABLE_LINK", … } } }]
322
+ }
323
+ ```
324
+
325
+ Note the HTTP status is **200** and the rest of the data is exactly what you asked for. Only the missing entry is missing.
326
+
327
+ 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:
328
+
329
+ ```ts
330
+ const page = await fetchContentful<PageQuery>(QUERY);
331
+ page.sections.map((section) => section.heading); // Section[], no null check needed
332
+ ```
333
+
334
+ The return type is narrowed to match, so TypeScript agrees with the runtime rather than forcing a guard that can never fire.
335
+
336
+ ### Choosing a different behavior
337
+
338
+ | `unresolvableLinks` | Data | `items` type |
339
+ | -------------------- | -------------------------------- | --------------------- |
340
+ | `'omit'` _(default)_ | Null positions dropped | `Section[]` |
341
+ | `'null'` | Contentful's response, untouched | `(Section \| null)[]` |
342
+ | `'error'` | Rejects with a `GRAPHQL` error | — |
343
+
344
+ `'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.
345
+
346
+ ### Keep the broken references visible
347
+
348
+ Dropping a null silently hides a real editorial mistake, so pair any non-`'error'` mode with a reporter:
349
+
350
+ ```ts
351
+ export const fetchContentful = createFetchContentful({
352
+ onUnresolvableLink: (errors) => {
353
+ for (const error of errors) {
354
+ const { linkId, field, type } = error.extensions.contentful.details;
355
+ logger.warn({ linkId, field, type }, 'Contentful reference could not be resolved');
356
+ }
357
+ }
358
+ });
359
+ ```
360
+
361
+ 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.
362
+
363
+ ### Two things this does not do
364
+
365
+ **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.
366
+
367
+ **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.
368
+
299
369
  ## How splitting works
300
370
 
301
371
  Contentful rejects queries whose complexity exceeds its limit — usually caused by nested reference fields multiplying. `fetch-contentful` avoids this transparently:
@@ -312,9 +382,12 @@ query {
312
382
  pageCollection {
313
383
  items {
314
384
  title
315
- hero @split { # fetched in a second, cheaper query
385
+ hero @split {
386
+ # fetched in a second, cheaper query
316
387
  headline
317
- image { url }
388
+ image {
389
+ url
390
+ }
318
391
  }
319
392
  }
320
393
  }
@@ -335,7 +408,7 @@ Turn auto-splitting off and annotate only the fields that actually blow the comp
335
408
 
336
409
  ```ts
337
410
  const data = await fetchContentful(QUERY, {
338
- autoSplitNestedCollections: false,
411
+ autoSplitNestedCollections: false
339
412
  });
340
413
  ```
341
414
 
@@ -345,14 +418,24 @@ query {
345
418
  items {
346
419
  title
347
420
  # Small and cheap: fetched inline, in the same request.
348
- seo { description }
349
- tagsCollection(limit: 5) { items { name } }
421
+ seo {
422
+ description
423
+ }
424
+ tagsCollection(limit: 5) {
425
+ items {
426
+ name
427
+ }
428
+ }
350
429
 
351
430
  # The expensive one: fetched separately, batched by parent id.
352
431
  sectionsCollection(limit: 50) @split {
353
432
  items {
354
433
  heading
355
- mediaCollection(limit: 10) { items { url } }
434
+ mediaCollection(limit: 10) {
435
+ items {
436
+ url
437
+ }
438
+ }
356
439
  }
357
440
  }
358
441
  }
@@ -368,8 +451,11 @@ That sends two requests instead of the four auto mode would. Splits still nest r
368
451
 
369
452
  ```graphql
370
453
  query {
371
- pageCollection @split { # ✗ CONFIG error
372
- items { title }
454
+ pageCollection @split {
455
+ # CONFIG error
456
+ items {
457
+ title
458
+ }
373
459
  }
374
460
  }
375
461
  ```
@@ -387,32 +473,81 @@ try {
387
473
  await fetchContentful(QUERY);
388
474
  } catch (error) {
389
475
  if (isFetchContentfulError(error)) {
390
- error.code; // 'CONFIG' | 'NETWORK' | 'HTTP' | 'GRAPHQL' | 'STITCH' | 'LOCALE'
476
+ error.code; // 'CONFIG' | 'NETWORK' | 'HTTP' | 'GRAPHQL' | 'STITCH' | 'LOCALE'
391
477
  error.status; // HTTP status, when applicable
392
478
  error.errors; // GraphQL errors, when applicable
479
+ error.query; // the query as sent, when a request was made
393
480
  }
394
481
  }
395
482
  ```
396
483
 
484
+ 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`.
485
+
486
+ ### Rejected queries are printed back with the bad line marked
487
+
488
+ Contentful reports a `line` and `column` for most queries it rejects — into
489
+ the query **as sent**, which is not the query you wrote: fragments have been
490
+ inlined, `preview`/`locale` arguments injected, and `@split` fields hoisted
491
+ into generated subqueries. So the query comes along with the message:
492
+
493
+ ```text
494
+ Contentful returned GraphQL errors: Cannot query field "titel" on type "Page".
495
+
496
+ 2 | pageCollection(where: {slug: $slug}, limit: 1) {
497
+ 3 | items {
498
+ > 4 | titel
499
+ | ^ Cannot query field "titel" on type "Page".
500
+ 5 | }
501
+ ```
502
+
503
+ The marked line is colored when stderr is a terminal and plain when it is
504
+ not, so a log pipeline still receives text it can store. `NO_COLOR` and
505
+ `FORCE_COLOR` are honored.
506
+
507
+ This also applies to the HTTP 400 Contentful returns for a query it cannot
508
+ validate: the `errors` in that response body reach `error.errors` and the
509
+ message, rather than being reported as a bare status.
510
+
511
+ Pass `annotateQueryOnError: false` to keep messages to a single line. The
512
+ query is still on the error either way, and the formatter is exported if you
513
+ would rather render it yourself:
514
+
515
+ ```ts
516
+ import { annotateQuery, isFetchContentfulError } from '@fourtwelvelabs/fetch-contentful';
517
+
518
+ if (isFetchContentfulError(error) && error.query && error.errors) {
519
+ logger.debug(annotateQuery(error.query, error.errors, { contextLines: 5 }));
520
+ }
521
+ ```
522
+
523
+ `contextLines` defaults to `3`; pass `Infinity` to print the whole query, and
524
+ `color: true | false` to override the terminal detection.
525
+
397
526
  ## Other exports
398
527
 
399
528
  ```ts
400
529
  import {
530
+ annotateQuery, // render a query with a caret under a reported error
531
+ clearLocaleCache, // reset the cache (tests, revalidation hooks)
401
532
  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)
533
+ getLocales, // cached locale lookup (space/env/preview keyed)
534
+ injectRootArgs, // the preview/locale argument injector (advanced AST use)
535
+ inlineFragments, // fragment inliner (advanced AST use)
536
+ isUnresolvableLinkError, // classify one GraphQL error as a broken link
537
+ omitUnresolvedLinks, // the runtime null-position dropper (applied too)
538
+ partitionUnresolvableLinks, // split an errors array into broken links vs the rest
539
+ shapeData, // the runtime collection shaper (already applied by fetchContentful)
540
+ unwrapSingleRoot, // the runtime single-root unwrapper (already applied too)
541
+ type FetchContentfulOptions,
542
+ type OmitUnresolvedLinks,
543
+ type ShapeCollections,
544
+ type UnresolvableLinkMode
408
545
  } from '@fourtwelvelabs/fetch-contentful';
409
- import type { ShapeCollections, FetchContentfulOptions } from '@fourtwelvelabs/fetch-contentful';
410
-
411
546
  // Type-only subpath, for wiring gql.tada up by hand (see docs/tada.md).
412
547
  import type { ContentfulScalars, JsonValue } from '@fourtwelvelabs/fetch-contentful/tada';
413
548
  ```
414
549
 
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.
550
+ `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
551
 
417
552
  ## Limitations
418
553