@foir/sdk 0.5.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +1126 -0
  2. package/README.md +94 -0
  3. package/dist/auth/auth.d.ts +243 -0
  4. package/dist/auth/hosted.d.ts +40 -0
  5. package/dist/auth/index.d.ts +21 -0
  6. package/dist/auth/operations.d.ts +144 -0
  7. package/dist/auth/ops.d.ts +99 -0
  8. package/dist/auth/session-client.d.ts +76 -0
  9. package/dist/auth-session.js +137 -0
  10. package/dist/auth.js +973 -92
  11. package/dist/chunk-IGNLWT3H.js +35 -0
  12. package/dist/chunk-OHYVKTGB.js +45 -0
  13. package/dist/chunk-TAL5H6VA.js +39 -0
  14. package/dist/client/platform-client.d.ts +29 -0
  15. package/dist/client.js +275 -37
  16. package/dist/codegen/block-registry.d.ts +13 -23
  17. package/dist/codegen/block-registry.js +3 -31
  18. package/dist/codegen/block-scaffold.d.ts +12 -25
  19. package/dist/codegen/block-scaffold.js +3 -41
  20. package/dist/codegen/index.d.ts +12 -61
  21. package/dist/codegen/index.js +7 -11
  22. package/dist/codegen/item-registry.d.ts +28 -0
  23. package/dist/codegen/item-registry.js +7 -0
  24. package/dist/codegen/item-scaffold.d.ts +29 -0
  25. package/dist/codegen/item-scaffold.js +7 -0
  26. package/dist/codegen/preset.d.ts +67 -0
  27. package/dist/codegen/shared.d.ts +36 -0
  28. package/dist/components/EditorProvider.d.ts +36 -0
  29. package/dist/content/content-renderer.d.ts +113 -0
  30. package/dist/content/index.d.ts +18 -0
  31. package/dist/content/richtext.d.ts +118 -0
  32. package/dist/content.js +227 -80
  33. package/dist/data/builtins.d.ts +506 -0
  34. package/dist/data/client.d.ts +166 -0
  35. package/dist/data/document.d.ts +251 -0
  36. package/dist/data/index.d.ts +73 -0
  37. package/dist/data/manifest.d.ts +424 -0
  38. package/dist/data/schema.d.ts +313 -0
  39. package/dist/data/selection.d.ts +524 -0
  40. package/dist/data/subjects.d.ts +221 -0
  41. package/dist/data/subscription-document.d.ts +75 -0
  42. package/dist/data/subscriptions.d.ts +181 -0
  43. package/dist/data/transport.d.ts +325 -0
  44. package/dist/data/ws-transport.d.ts +197 -0
  45. package/dist/data.js +1683 -0
  46. package/dist/graphql/cdn-route.d.ts +26 -0
  47. package/dist/graphql/client.d.ts +30 -0
  48. package/dist/graphql/content-client.d.ts +66 -0
  49. package/dist/graphql/foir-request.d.ts +47 -0
  50. package/dist/hooks/useAutoResize.d.ts +22 -0
  51. package/dist/hooks/useEditor.d.ts +16 -0
  52. package/dist/index.d.ts +10 -394
  53. package/dist/media/BlurhashCanvas.d.ts +10 -0
  54. package/dist/media/ProgressiveMedia.d.ts +56 -0
  55. package/dist/media/index.d.ts +18 -0
  56. package/dist/media/media.d.ts +139 -0
  57. package/dist/media/screen-capture.d.ts +81 -0
  58. package/dist/media/useProgressiveLoad.d.ts +23 -0
  59. package/dist/media.js +102 -0
  60. package/dist/next/cookieAdapter.d.ts +26 -0
  61. package/dist/next/index.d.ts +15 -0
  62. package/dist/next/middleware.d.ts +49 -0
  63. package/dist/next/session.d.ts +53 -0
  64. package/dist/next.js +763 -78
  65. package/dist/protocol/constants.d.ts +6 -0
  66. package/dist/protocol/messages.d.ts +155 -0
  67. package/dist/protocol/validation.d.ts +8 -0
  68. package/dist/push/index.d.ts +14 -0
  69. package/dist/push/operations.d.ts +22 -0
  70. package/dist/push/web-push.d.ts +39 -0
  71. package/dist/push.js +892 -78
  72. package/dist/react/FoirFeedback.d.ts +62 -0
  73. package/dist/react/FoirProvider.d.ts +116 -0
  74. package/dist/react/OAuthButtons.d.ts +20 -0
  75. package/dist/react/SignIn.d.ts +22 -0
  76. package/dist/react/UserButton.d.ts +13 -0
  77. package/dist/react/feedback-notes.d.ts +101 -0
  78. package/dist/react/index.d.ts +25 -0
  79. package/dist/react/localStorageAdapter.d.ts +10 -0
  80. package/dist/react.js +1373 -91
  81. package/dist/{realtime.d.ts → realtime/index.d.ts} +35 -16
  82. package/dist/realtime.js +1 -1
  83. package/dist/server/callback-client.d.ts +92 -0
  84. package/dist/server/events-client.d.ts +279 -0
  85. package/dist/server/index.d.ts +12 -0
  86. package/dist/server/operation-types.d.ts +134 -0
  87. package/dist/server/require-scoped-token.d.ts +76 -0
  88. package/dist/server/server-client.d.ts +34 -0
  89. package/dist/server/types.d.ts +54 -0
  90. package/dist/server/verify-scoped-token.d.ts +100 -0
  91. package/dist/server/webhook-helpers.d.ts +36 -0
  92. package/dist/server.js +298 -0
  93. package/dist/transport/foir-http.d.ts +234 -0
  94. package/dist/transport/persisted-query.d.ts +68 -0
  95. package/dist/transport/retry.d.ts +61 -0
  96. package/dist/types/index.d.ts +124 -0
  97. package/package.json +35 -13
  98. package/dist/auth.d.ts +0 -382
  99. package/dist/chunk-ADO5FJNM.js +0 -30
  100. package/dist/client.d.ts +0 -135
  101. package/dist/content.d.ts +0 -96
  102. package/dist/media.d.ts +0 -234
  103. package/dist/next.d.ts +0 -178
  104. package/dist/push.d.ts +0 -109
  105. package/dist/react.d.ts +0 -319
  106. package/dist/server.d.ts +0 -534
  107. package/dist/shared-DkWlEd2Z.d.ts +0 -18
package/CHANGELOG.md ADDED
@@ -0,0 +1,1126 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@foir/sdk`.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+ Versioning follows the policy below.
7
+
8
+ ---
9
+
10
+ ## Semver policy
11
+
12
+ `@foir/sdk` is published to npm and installed by people outside this repo. Its
13
+ **public API is the set of named exports reachable through the subpaths in
14
+ `package.json#exports`** — nothing else. Anything reachable only by deep-importing
15
+ `@foir/sdk/dist/...` is private and may change in any release without notice.
16
+
17
+ That surface is pinned in [`public-exports.snapshot.json`](./public-exports.snapshot.json)
18
+ and enforced on every build by `scripts/assert-public-exports.mjs`. If a change
19
+ reds that guard, it belongs in this file.
20
+
21
+ ### Pre-1.0 note — breaking changes bump the MINOR
22
+
23
+ While the version is `0.x`, a breaking change bumps the **minor** (`0.5.0` →
24
+ `0.6.0`), not the major. This is not laziness about semver, it is what actually
25
+ protects consumers: npm resolves `^0.5.0` to `>=0.5.0 <0.6.0`, so for a `0.x`
26
+ package the minor — not the major — is the boundary an existing `pnpm install`
27
+ will not cross on its own. Once `1.0.0` ships, breaking changes move to the
28
+ major and this note goes away.
29
+
30
+ Read "MAJOR" below as "minor, while we are pre-1.0".
31
+
32
+ ### MAJOR — breaking
33
+
34
+ A change is breaking if code that compiled and ran against the previous version
35
+ can stop compiling, stop resolving, or start behaving differently:
36
+
37
+ - **Removing a named export** from any published subpath.
38
+ - **Renaming a named export.** A rename is a removal plus an addition; shipping
39
+ the old name as a deprecated alias for at least one minor is preferred.
40
+ - **Removing a subpath** from `package.json#exports`, or repointing one at a
41
+ module with a different surface.
42
+ - **Demoting a value export to type-only** (`export { X }` → `export type { X }`).
43
+ The type-checker stays happy at the re-export site and the runtime import
44
+ starts throwing, which is the worst possible failure shape.
45
+ - **Narrowing a type**: making an optional parameter or property required,
46
+ removing a member from a union or an enum, tightening a parameter's accepted
47
+ type, widening a return type, or adding a required field to an options object.
48
+ Rule of thumb — narrowing what we *accept* or widening what we *return* is
49
+ breaking; the reverse is not.
50
+ - **Changing the runtime behaviour of an existing call**: different network
51
+ endpoint, different request or response shape on the wire, different default
52
+ value, throwing where it used to return, returning where it used to throw, or
53
+ a change in what gets persisted (cookie name, storage key, header name).
54
+ - **Raising the floor**: a higher minimum Node version, a higher required
55
+ version of a peer dependency, or making an optional peer dependency required.
56
+
57
+ Every breaking change needs a `### Removed` or `### Changed` entry below that
58
+ **names each affected symbol** and states the migration.
59
+
60
+ ### MINOR — additive
61
+
62
+ - A new subpath, a new named export, or a new optional parameter/property.
63
+ - Widening what an existing call accepts, or narrowing what it returns.
64
+ - Promoting a type-only export to a value export.
65
+ - A new optional peer dependency.
66
+ - Deprecating something (the deprecation itself is additive; the eventual
67
+ removal is the breaking part).
68
+
69
+ Additive changes still red the export-surface guard — deliberately, so the
70
+ snapshot cannot rot into a rubber stamp. Refresh it with
71
+ `pnpm --filter @foir/sdk run snapshot:exports` and commit the result.
72
+
73
+ ### PATCH — fixes
74
+
75
+ - A bug fix that moves behaviour **toward** what the types and docs already
76
+ promised. If a caller could reasonably have been depending on the buggy
77
+ behaviour, it is a MAJOR, not a PATCH.
78
+ - Internal refactors, build/bundling changes, dependency bumps and performance
79
+ work with no observable change to the public surface.
80
+ - Documentation, comments and JSDoc.
81
+
82
+ ### Releasing
83
+
84
+ Bump `packages/sdk/package.json#version`, move `Unreleased` into a dated
85
+ section here, then push a `sdk-v<version>` tag — the tag, not the commit,
86
+ triggers `.github/workflows/sdk-publish.yml`.
87
+
88
+ `pnpm --filter @foir/sdk release:minor` does the bump and the tag together, but
89
+ only because `packages/sdk/.npmrc` sets `tag-version-prefix=sdk-v`. Without it
90
+ `pnpm version` tags `v<version>`, which does **not** match the workflow's
91
+ `sdk-v*` trigger — the bump would land and no publish would ever run. If you
92
+ move or drop that `.npmrc`, tag by hand:
93
+
94
+ ```sh
95
+ git tag sdk-v<version> && git push origin sdk-v<version>
96
+ ```
97
+
98
+ The tag must point at a commit on `main`: `main` is what is released, and a tag
99
+ on `dev` would publish unreviewed work to npm.
100
+
101
+ ---
102
+
103
+ ## [Unreleased]
104
+
105
+ Changes on `dev` that have not been published to npm.
106
+
107
+ **The next release is BREAKING** — see `foir.raw()` under "Changed". A MINOR,
108
+ per the pre-1.0 note above.
109
+
110
+ ### Removed
111
+
112
+ - **BREAKING — `FoirBlockMeta`, `MediaBlock`, `TextBlock` and `FlexibleBlock`
113
+ are gone from `./data`.** They have no replacement because the things they
114
+ described are not in the schema:
115
+
116
+ - `FoirBlockMeta` declared `_id`/`_key`/`_label`/`_type` on every member. That
117
+ was the wrapper's envelope, removed at TTS-1391 — an item IS its value. No
118
+ member carries any of the four, so every one was a field the types promised
119
+ and the API never returned.
120
+ - `MediaBlock` — `media` stopped being a primitive at TTS-1391. It was never
121
+ in the proto FieldType enum, and it is the one primitive name a project had
122
+ also used as a model key.
123
+ - `TextBlock` — removed at TTS-1406. GraphQL requires a union member to be an
124
+ object type and a text item's value is a bare scalar, which is the whole
125
+ reason a wrapper existed. An inline model with a text field is how a text
126
+ slot is authored.
127
+ - `FlexibleBlock` — there is no fallback member. An item whose `_type` names
128
+ nothing is nulled out of its list and reported, which is already what a
129
+ stored null reads as.
130
+
131
+ **Migration:** delete the import. Code narrowing on `__typename ===
132
+ 'MediaBlock' | 'TextBlock' | 'FlexibleBlock'` was matching nothing, so the
133
+ branch was already dead. Code reading `_id`/`_key`/`_label`/`_type` off a
134
+ member was reading `undefined` through a type that said `string | null`.
135
+
136
+ Nothing generates them either: `foir types` emitted `FoirBlockMeta` and the
137
+ three tokens, and no longer does — see the CLI entry below.
138
+
139
+ ### Changed
140
+
141
+ - **BREAKING (`foir types`) — a flexible union member is the model's own type,
142
+ and the per-model wrapper is gone.** `foir types` used to emit a second
143
+ interface for every inline model — `ActHero` AND `ActHeroBlock` — where the
144
+ wrapper was `FoirBlockMeta` plus the model's non-`_`-prefixed fields. On the
145
+ Marketing fixture that was 20 of 47 interfaces describing a shape the platform
146
+ stopped serving at TTS-1391.
147
+
148
+ A union branch is now `(<Model> & { __typename: '<Model>' })`: the model
149
+ itself, intersected with the discriminant the union position guarantees. The
150
+ intersection is not a wrapper — it adds no field the server does not send. It
151
+ is there because the op compiler selects `__typename` on a union's own
152
+ selection set and nowhere else, so the model interface cannot declare it
153
+ outright, and because `@foir/sdk/data`'s narrowing surface is written as
154
+ `U extends { __typename: infer T }` — an optional key does not satisfy that,
155
+ and `pick: { on: … }` would silently stop working.
156
+
157
+ Where a model is reached BOTH as a member and as a model, the two answers now
158
+ live on one declaration keyed on `C`: `PageContent<D, C>` degrades `sections`
159
+ at `C extends true` and expands it otherwise, which is exactly what
160
+ `PageContentBlock` and `PageContent` said separately before.
161
+
162
+ **Migration:** re-run `foir types`. Narrow on `ActHero` rather than
163
+ `ActHeroBlock`; `_type`, `_key` and `_label` are gone with the envelope, and
164
+ `__typename` is the discriminant. Aliased response keys are unchanged — they
165
+ are a wire contract published in the manifest, so `imageBlockHeight` stays
166
+ `imageBlockHeight` until the platform republishes it.
167
+
168
+ - **BREAKING (`foir types`) — a manifest carrying a retired primitive is now
169
+ refused by name.** `text`, `media` and a `flexible` fallback member stopped
170
+ being union members at TTS-1391/1406. A manifest listing one predates that, so
171
+ the generator says so and names re-publishing as the fix rather than emitting
172
+ a branch for a type the server never sends.
173
+
174
+ - **BREAKING — the primitive members are the value types, under the schema's own
175
+ names.** `ImageValue` and `ImageBlock` are one type called `Image`, and the
176
+ same for the rest:
177
+
178
+ | was | now |
179
+ | --- | --- |
180
+ | `ImageValue` · `ImageBlock` | `Image` |
181
+ | `VideoValue` · `VideoBlock` | `Video` |
182
+ | `RichtextValue` · `RichtextBlock` | `Richtext` |
183
+ | `GeoPointValue` | `GeoPoint` |
184
+ | `ReferenceValue` · `ReferenceBlock` | `Reference` |
185
+ | `FileValue` · `FileBlock` | `FileValue` |
186
+
187
+ Every old name is kept as a deprecated alias of the new one, so imports keep
188
+ resolving. What changes underneath them:
189
+
190
+ - **`__typename` is now on each type**, optional, carrying the schema's name —
191
+ `'Image'`, not `'ImageBlock'`. The old literals matched nothing in a current
192
+ project, so an exhaustive `switch` over a flexible field fell through every
193
+ case.
194
+ - **`Image.url` and `Video.url` are nullable.** They were `string`. The
195
+ platform relaxed them to `String` at TTS-1391 — non-null on the value object
196
+ and nullable on the wrapper is a §5.3.2 response-shape conflict once the two
197
+ are one type — so `img.url.startsWith(…)` could throw on a value the types
198
+ said was present.
199
+ - **`Reference` loses `_id` and `_type`, and gains `_targetModelKey`,
200
+ `_targetId`, `_targetNaturalKey` and `record`.** `_id` was the ITEM's id and
201
+ `_type` read `"reference"` on every reference ever stored; neither is in the
202
+ schema. The target coordinates are what actually make a reference
203
+ followable (TTS-1349/1371), and both identifier shapes are live — on the dev
204
+ database 101 of 193 stored references carry an id and 92 carry a natural key
205
+ and no id.
206
+
207
+ **Migration:** narrow on the new `__typename` literals; treat `url` as
208
+ nullable; replace `ref._id` with `ref._targetId ?? ref._targetNaturalKey` and
209
+ `ref._type` with `ref.__typename`.
210
+
211
+ **Why this was wrong for so long:** `builtins.ts` is the one part of the typed
212
+ data surface that is not generated per project, and nothing compared it to a
213
+ real schema. `src/data/__tests__/builtins.test-d.ts` now pins every member
214
+ against graphql-codegen output taken from a live project.
215
+
216
+ - **BREAKING (behaviour) — the codegen plugins now default to the `Flexible`
217
+ union, not `Block`.** `DEFAULT_UNIONS` in `@foir/sdk/codegen` was `['Block']`;
218
+ it is `['Flexible']`.
219
+
220
+ The platform stopped emitting a union named `Block` at TTS-1391 — a flexible
221
+ field's members are the value types and inline models under their own names,
222
+ and the project-wide union an unconstrained field resolves to is `Flexible`.
223
+ So `foirCodegen()` on defaults could not have worked against any current
224
+ project: `collectItemMembers` refuses rather than emitting an empty registry,
225
+ so it threw `[foir codegen] no union members found. Looked for union(s):
226
+ Block`. This is listed as breaking because it changes a default, but the
227
+ default it replaces had no working configuration.
228
+
229
+ It stayed invisible because the plugin tests built their own fixture schema
230
+ declaring `union Block = RichtextBlock | ImageBlock | TextBlock` — the
231
+ pre-TTS-1391 shape. The fixture is now the post-1391 shape, and
232
+ `DEFAULT_UNIONS` is asserted against it directly.
233
+
234
+ **Migration:** none if you pass `blockUnions`/`itemUnions` explicitly. If you
235
+ relied on the default and codegen was failing, it now resolves. If your
236
+ project genuinely has a union named `Block`, pass `itemUnions: ['Block']`.
237
+
238
+ - **`createContentRenderer` passes `item` to every component, and accepts
239
+ `items` as well as `blocks`.** `props.block` is still passed on every render
240
+ and `blocks=` is still accepted, so scaffolded components and existing call
241
+ sites are unaffected. Passing both `items` and `blocks` is a type error rather
242
+ than a silent precedence rule.
243
+
244
+ ### Added
245
+
246
+ - **`@foir/sdk/codegen/item-registry` and `@foir/sdk/codegen/item-scaffold`** —
247
+ the two graphql-codegen plugins under names that do not claim `block` is a
248
+ platform concept. `@foir/sdk/codegen/block-registry` and `/block-scaffold`
249
+ re-export them unchanged and keep working.
250
+
251
+ - **`ItemLike`, `ItemComponent`, `ItemComponentProps`, `ItemRegistry`** on
252
+ `./content`, and **`ItemPluginConfig`**, **`ItemRegistryConfig`**,
253
+ **`ItemScaffoldConfig`** on the codegen subpaths. Each is the canonical name
254
+ for the `Block*` type of the same shape.
255
+
256
+ - **`foirCodegen({ itemUnions })` / `foirScaffold({ itemUnions })`** — the
257
+ canonical spelling of `blockUnions`, which is still honoured when `itemUnions`
258
+ is unset.
259
+
260
+ ### Deprecated
261
+
262
+ - **Every `Block*` spelling in the SDK.** `BlockLike`, `BlockComponent`,
263
+ `BlockComponentProps`, `BlockRegistry`, `BlockPluginConfig`,
264
+ `BlockRegistryConfig`, `BlockScaffoldConfig`, `collectBlockMembers`, the
265
+ `blockUnions` option, the `blocks` prop on `createContentRenderer`, the
266
+ `props.block` field, and the `./codegen/block-registry` and
267
+ `./codegen/block-scaffold` subpaths.
268
+
269
+ `block` was never a platform concept — it was one project's NAME for a
270
+ flexible field, and a modeller may equally call one `items`, `sections` or
271
+ `modules`. The platform never infers anything from a field's name and the SDK
272
+ should not teach otherwise (TTS-1391).
273
+
274
+ Nothing is removed in this release; every old name still resolves and still
275
+ behaves identically. Removal is a later, separately announced break.
276
+
277
+ - **BREAKING (generated types) — a union the compiler stopped in front of one
278
+ level BELOW a block union member is now typed as the degraded union, where it
279
+ used to be typed as the whole one.** Same direction as the composite entry
280
+ below: the type promised more than arrives, so code reading through it
281
+ compiled and got `undefined`.
282
+
283
+ The entry below fixed the composites cut at a union member's OWN fields, which
284
+ are emitted per position and could simply be degraded there. A cut can also
285
+ land one level deeper. `ProductDetailsAccordionBlock.items` is a composite the
286
+ server did NOT cut, so it expands and is typed with `Detail`'s own interface —
287
+ and `Detail.body` IS cut inside that subtree, because reaching it means
288
+ `Block` is already on the compiler's path.
289
+
290
+ `Detail` is one interface referenced from a cut position and an uncut one
291
+ (`product_details_accordion.items` at the model position), so it could not
292
+ simply be degraded: that would have been wrong at the other reference. The
293
+ answer now travels with the REFERENCE, as a second parameter threaded the way
294
+ `<D>` already is:
295
+
296
+ ```ts
297
+ export interface Detail<D extends FoirDepthLevel = 1, C extends boolean = false> {
298
+ label: string | null;
299
+ body: Array<(C extends true ? … : DetailBody<D>) | null> | null;
300
+ }
301
+
302
+ // inside the union member — cut
303
+ export interface ProductDetailsAccordionBlock<D extends FoirDepthLevel = 1> … {
304
+ items: Array<Detail<D, true> | null> | null;
305
+ }
306
+ // at the model position — whole
307
+ export interface ProductDetailsAccordion<D extends FoirDepthLevel = 1, C extends boolean = false> {
308
+ items: Array<Detail<D, C> | null> | null;
309
+ }
310
+ ```
311
+
312
+ **Nothing a caller writes ever passes `C`.** It defaults to `false`, so every
313
+ existing reference resolves exactly as it did; the position it came from is
314
+ what sets it.
315
+
316
+ On the live dev project **one position narrows**, and the full recursive
317
+ census is what says so: ten `truncated` positions anywhere in `models[*]
318
+ .selection` or `selectionUnions`, of which six were already correct, three
319
+ were fixed by the entry below, and this is the tenth. Re-run after the change,
320
+ the count of over-promising positions is **zero of ten**.
321
+
322
+ | position | was | is |
323
+ | --- | --- | --- |
324
+ | `ProductDetailsAccordionBlock.items[].body` | `Array<DetailBody \| null> \| null` | `Array<DetailBodyDegraded \| null> \| null` |
325
+
326
+ **`depth` DOES help here**, which is the difference from the composite case.
327
+ The position carries a union name, so `printFields` expands it when the budget
328
+ allows and the type follows: at `depth: 2` the same read resolves to the whole
329
+ `DetailBody` union and narrows on `__typename` with no cast. Verified against
330
+ deployed dev — at the level where the member arrives expanded, all ten live
331
+ `body` values are `{ "__typename": "RichtextBlock" }` and nothing else; one
332
+ level further, every one of them is a whole `RichtextBlock` carrying its
333
+ `html` and `json`.
334
+
335
+ **The wire is untouched.** Types only: 192 documents compared across five
336
+ manifests (the four committed fixtures and a live capture) at every level
337
+ 1–4, all byte-identical, and every emitted `foirSelections` /
338
+ `foirNamespaceSelections` / `foirSelectionUnions` const unchanged byte for
339
+ byte. **Regenerate with `foir types` to get the narrowing.**
340
+
341
+ A manifest with no selection trees is unaffected, as before: no `truncated`
342
+ means no cut, no interface gains the parameter, and the generated file is what
343
+ it was.
344
+
345
+ - **BREAKING (generated types) — a composite the compiler stopped in front of
346
+ inside a block union member is now typed `FoirDegraded`, where it used to be
347
+ typed whole.** The DEFAULT result type narrows at those positions, and code
348
+ reading through one stops compiling. That is the point: it compiles today
349
+ against a value that never arrives.
350
+
351
+ The op compiler never expands a type already on the current path, and the path
352
+ includes the union the composite was reached through. `foir types` was deciding
353
+ truncation from the composite GRAPH alone, which cannot see that edge — so a
354
+ composite reached from inside a union member got the target's full interface
355
+ for a position the server sends `{ "__typename": "PageContent" }` at. A caller
356
+ wrote `block.pageContent.sections[0].anchor`, it type-checked, and it was
357
+ `undefined` at runtime with no null check to have skipped.
358
+
359
+ On the live dev project (50 models, one 56-member `Block` union) **four
360
+ positions narrow**, and they are the whole population — the manifest's own
361
+ `truncated` flag is now what decides, so this is not an estimate:
362
+
363
+ | position | was | is |
364
+ | --- | --- | --- |
365
+ | `ProductBlock.pageContent` | `PageContent` | `FoirDegraded<'PageContent'>` |
366
+ | `PageBlock.pageContent` | `PageContent` | `FoirDegraded<'PageContent'>` |
367
+ | `CollectionBlock.pageContent` | `PageContent` | `FoirDegraded<'PageContent'>` |
368
+ | `PageContentBlock.sections` | `Array<Section \| null> \| null` | `Array<FoirDegraded<'Section'> \| null> \| null` |
369
+
370
+ **What a caller does about it.** The values were never arriving, so there is no
371
+ code to preserve — only a read to move:
372
+
373
+ ```ts
374
+ // was: compiled, `sections` undefined at runtime
375
+ const anchor = block.pageContent.sections[0].anchor;
376
+
377
+ // now: `__typename` is all that arrives, and the type says so
378
+ if (block.pageContent?.__typename === 'PageContent') { /* … */ }
379
+ ```
380
+
381
+ To actually get the content, address the record it belongs to in a second call
382
+ (`foir.page.findUnique({ … })`), or reach it through the position where it is
383
+ NOT cut — the same composite read from the model rather than from inside a
384
+ block still arrives whole.
385
+
386
+ **`depth` does not help here, deliberately.** A UNION stopping point is buyable
387
+ a level at a time (see `depth` under "Added"); a COMPOSITE one has no union to
388
+ expand, so `printFields` prints `{ __typename }` for it at every level. Typing
389
+ it behind `<D>` would promise a level that never arrives, which is the defect
390
+ being fixed rather than a fix for it. Measured on the live project: the number
391
+ of expanded union positions rises 1 → 2 → 3 → 4 with `depth`, and the four
392
+ composite cuts are merely replicated by that expansion, never opened.
393
+
394
+ **The wire is untouched.** This is a types-only change: the document printed
395
+ for a call passing no `depth` is byte-identical — verified across 119 default
396
+ documents (73 ops of the live manifest, 39 of the `orderby` fixture, 7 of the
397
+ `jacket` one), and the emitted `foirSelections` runtime const is unchanged
398
+ byte for byte. **Regenerate with `foir types` to get the narrowing**; an
399
+ existing generated file keeps its old, over-promising types.
400
+
401
+ A manifest with no selection trees — a server predating `selectionUnions` —
402
+ keeps widening at these positions, unchanged. Absence of the flag must not read
403
+ as "cut", or an old server would silently degrade every composite in every
404
+ block, which is a worse lie than the one being fixed. `compositeCycleMembers`
405
+ is still what cuts a composite CYCLE there, from the graph alone.
406
+
407
+ - **`@foir/sdk/data` prints a GraphQL document and executes it over `/graphql`**,
408
+ the same endpoint every other consumer of the API uses — one execution surface,
409
+ one executor, one set of scope checks, one response cache. The public API is
410
+ unchanged: `foir.page.findMany({ where, include, omit })` is the same call it
411
+ always was. What changed is what goes on the wire. The client loads that op's
412
+ selection tree (published by `GET /v1/manifest` as `models.<key>.selection`),
413
+ applies `omit`/`pick`/`include`, prints a document, hashes it and sends the
414
+ hash.
415
+
416
+ Still **no `graphql` dependency** — the tree arrives pre-resolved, so printing
417
+ it is string building. The import-graph assertion that pins this is unchanged
418
+ and still passes.
419
+
420
+ What this buys, immediately: a block-bearing model reads complete-by-default.
421
+ `page`, `product` and `collection` need no caller-supplied `omit` to be read at
422
+ all. A document is the only form that can alias two union members which
423
+ disagree on a field's response shape, and the manifest publishes those aliases,
424
+ so those models return 200 on a bare `findMany()`.
425
+
426
+ Two new config fields carry the runtime half of `foir types`:
427
+
428
+ ```ts
429
+ createFoirClient<FoirSchema>({
430
+ apiUrl, apiKey,
431
+ selections: foirSelections,
432
+ selectionUnions: foirSelectionUnions,
433
+ });
434
+ ```
435
+
436
+ Both are optional. Without them the client fetches `GET /v1/manifest` once at
437
+ first use and reads the same trees off it — a FALLBACK, so a zero-codegen start
438
+ still works, at the cost of a round trip in front of the first query on every
439
+ cold edge invocation.
440
+
441
+ Reads take the shared, cacheable lane where they can:
442
+ `GET /cdn/graphql?key=pk_…` with the persisted-query hash in the URL when the
443
+ call is a query, on a `pk_` key, with no customer bearer, on the published
444
+ channel — and `POST /graphql` otherwise. The server's gate refuses on five
445
+ conditions and the client decides all five before the request: the fifth,
446
+ "the credential is in the URL", it satisfies by construction. The server is
447
+ still the authority — it refuses an ineligible read by name, and the refusal
448
+ is retried on `/graphql`.
449
+
450
+ - **BREAKING — `foir.raw()` takes a GraphQL document.** A document string and an
451
+ optional variables object, where it used to take a JSON op
452
+ (`{ model, op, args }`):
453
+
454
+ ```ts
455
+ // before
456
+ await foir.raw({ model: 'page', op: 'findMany', args: { first: 5 } });
457
+ // after
458
+ await foir.raw('query ($n: Int) { pages(first: $n) { totalCount } }', { n: 5 });
459
+ ```
460
+
461
+ It returns the whole `data` object rather than a single root field, because a
462
+ hand-written document may legitimately have several. As a document it reaches
463
+ everything the credential can reach — including the two dozen root fields no
464
+ model and no namespace claims (`designTokens`, `record`, `locales`, the
465
+ customer-auth mutations), which is the point of an escape hatch and is more
466
+ than the old shape could address.
467
+
468
+ - **Every op prints, and there is one wire format rather than two.** The
469
+ descriptor now publishes a `payload` per op — the model it IS, the model it
470
+ CARRIES, its own resolved fields, or an explicit `leaf` — so every shape the
471
+ client can name is a shape it can print. That covers the 18 the client could
472
+ previously only name: every `foir.ns.*` op, plus `myAccess` and the other model
473
+ ops whose result is not the model. They execute over `/graphql` like everything
474
+ else, and nothing is kept beside it that a call could quietly land on.
475
+
476
+ This also removes the last place the client knew something the descriptor did
477
+ not tell it. A connection was wrapped in `edges { cursor node { … } }` from a
478
+ table of op names compiled into the client; it is now printed from the wrapper
479
+ the manifest published, so a server that changes that shape does not need a
480
+ client release to match.
481
+
482
+ **An op the manifest describes no payload for is refused, by name.** It is not
483
+ synthesised and there is no second protocol to fall back to.
484
+
485
+ - **Arguments are sent as GraphQL variables, not printed as literals.** An op's
486
+ published `argTypes` becomes a real variable definition —
487
+ `query PageFindMany($orderBy: [PageOrderByInput])` — and the value travels in
488
+ `variables`, where the executor coerces it against the declared input type.
489
+
490
+ This is the fix for enum arguments. A printer working from argument names
491
+ alone has to infer each value's literal form from the JavaScript value, which
492
+ is exact for strings, numbers, booleans and objects and ambiguous for enums:
493
+ `orderBy: { createdAt: DESC }` needs `DESC` unquoted and the client holds the
494
+ string `'DESC'`. On one real project that was `orderBy` on every `findMany`
495
+ plus `where` on any enum field. Those calls used to be refused by the server,
496
+ by name; they work now.
497
+
498
+ Second-order, and worth having on its own: the document text no longer varies
499
+ with the argument VALUE, so the persisted-query registry holds one entry per
500
+ op instead of one per argument tuple. Argument names are emitted sorted for
501
+ the same reason.
502
+
503
+ **An argument the manifest publishes no type for is refused.** Falling back to
504
+ inferring a literal would reinstate the ambiguity under a condition no caller
505
+ can see.
506
+
507
+ - **`@foir/sdk/auth` prints its documents too.** The flows used the same JSON
508
+ lane the data client did; they now print from `namespaces.auth.ops.<op>` on
509
+ the caller's own descriptor and execute over `/graphql`. Same functions, same
510
+ arguments, same return values. The descriptor is fetched once per credential
511
+ and memoised, so a `currentUser()` on every render does not refetch it.
512
+
513
+ - **Schema drift now means "regenerate", not "be aware".** The trees the client
514
+ prints from are the ones the generated version published, so a field added
515
+ since is not merely untyped — it is not requested. The warning says so.
516
+
517
+ ### Removed
518
+
519
+ - **`FoirRawOp` from `@foir/sdk/data`** — the JSON op shape (`{ model, op, args }`)
520
+ `foir.raw()` used to take. There is no replacement type because a call is a
521
+ GraphQL document now: `foir.raw()` takes a document string and variables. See
522
+ `foir.raw()` under "Changed" above for the migration.
523
+ - **`foirOpResultKind` and `FoirResultKind` from `@foir/sdk/data`** — a table
524
+ mapping op NAMES to how their result wrapped the model, which is exactly the
525
+ schema knowledge the client is not allowed to hold. The manifest publishes
526
+ each op's result shape, so the table has nothing left to answer. Neither
527
+ symbol has appeared in a published release.
528
+ - **`customerResendVerificationEmail` from `@foir/sdk/auth`** — and the
529
+ `AUTH_OPS.customerResendVerificationEmail` entry with it. **It never sent an
530
+ email.** The resolver behind it checked that the request carried a customer id,
531
+ returned `true`, and called no RPC; there is no customer verification-code
532
+ column and no customer verification email in the action catalogue for it to
533
+ have used. A caller got a success response every time and nothing was
534
+ delivered, which is worse than an error, because there is no signal to act on.
535
+
536
+ **Use the OTP flow for mailbox proof:** `customerRequestOTP(env, email)` emails
537
+ a code, and `customerLoginOTP(env, { email, otp })` redeems it — activating a
538
+ pending account on redemption and returning a session, so possession of the
539
+ mailbox is proven by the same call that signs the customer in. That is a live,
540
+ delivered path today; `customerVerifyEmail` remains for redeeming a token that
541
+ arrived by some other route.
542
+
543
+ Like the two entries above, this symbol has not appeared in a published
544
+ release — the whole `@foir/sdk/auth` surface it belongs to is unreleased — so
545
+ no installed version loses a function and there is nothing to migrate off.
546
+
547
+ ### Added
548
+
549
+ - **`<FoirFeedback>` — a feedback widget anchored to the record, not the page.**
550
+ On `./react`, beside `<SignIn>` and `<UserButton>`.
551
+
552
+ ```tsx
553
+ const subjects = createFoirSubjectRegistry({ label: (r) => r.title as string });
554
+ const foir = createFoirClient({ apiUrl, apiKey, generated, subjects });
555
+
556
+ <FoirFeedback client={foir} subjects={subjects} />
557
+ ```
558
+
559
+ That is the whole wiring. There is no route table and no per-page
560
+ configuration: the subject is the last record the client addressed by name, so
561
+ a comment attaches to the record and survives a redesign, a URL change and a
562
+ locale variant. Nothing in the component reads `window.location`.
563
+
564
+ A page whose data all came out of lists has no subject, and the widget says so
565
+ rather than picking one of the twelve or falling back to the address bar. Pass
566
+ `subject` to name one yourself, or `subject={null}` to state there is nothing
567
+ here — which beats whatever the registry still holds from the previous page.
568
+
569
+ The visitor sees a human label or nothing at all: the model key and the record
570
+ id are what the note is filed against and never appear on screen.
571
+
572
+ Opening the panel reads `noteConfig`, which the API charges `notes:write` and
573
+ refuses to a caller with no customer session, so one probe settles whether the
574
+ visitor is signed in, whether their role may write notes, and whether
575
+ screenshot capture may be offered. A missing scope reads like a bad API key and
576
+ is not one, so it is reported as what it is: the signed-in customer's ROLE is
577
+ short `notes:write`, which a new project's default customer role does not
578
+ carry.
579
+
580
+ Screenshot capture is offered only when the project allows it and the browser
581
+ can do it, and is opt-in per submission — the browser's own picker is the
582
+ consent gate. A still is uploaded through the ordinary
583
+ `createFileUpload` → PUT → `confirmFileUpload` flow, so the mime gate, the
584
+ plan's per-file limit and the storage allowance all apply; an upload that fails
585
+ fails the whole submission rather than quietly sending a note without the
586
+ picture.
587
+
588
+ Props: `client`, `subjects`, `subject`, `label`, `triggerLabel`, `title`,
589
+ `placeholder`, `defaultOpen`, `onSignIn`, `onSubmitted`, `onError`,
590
+ `className`. Passing `className` turns the built-in inline styles off entirely;
591
+ the `data-foir-feedback-*` attributes are the styling hooks.
592
+
593
+ New exports on `./react`: `FoirFeedback`, `FoirFeedbackProps`,
594
+ `FoirFeedbackClient`.
595
+
596
+ - **Screen capture on `./media`.** `isFoirScreenCaptureSupported()` says whether
597
+ the browser can do it at all; `captureFoirScreen()` captures one still of a
598
+ surface the reader picks, downscaled to 1600px and encoded as JPEG at 0.7. It
599
+ answers `null` when the picker is dismissed, because that is a decision rather
600
+ than a failure, and it stops every track before returning on every path. The
601
+ blob and the data URL are one encode read two ways, so the size the upload
602
+ declares is the size the bytes are.
603
+
604
+ New exports on `./media`: `captureFoirScreen`, `isFoirScreenCaptureSupported`,
605
+ `FoirScreenCapture`, `FoirScreenCaptureOptions`.
606
+
607
+ - **Note attachments and note config on `./data`.** `FoirCustomerNote` gains
608
+ `attachments`, `FoirCreateMyNoteArgs` gains an optional `attachmentFileIds`,
609
+ and `FoirNoteConfig` is the shape `foir.ns.notes.noteConfig()` answers with.
610
+
611
+ New exports on `./data`: `FoirCustomerNoteAttachment`, `FoirNoteConfig`.
612
+
613
+ - **A subject registry — the records a client fetched, so an annotation can
614
+ anchor to the record instead of to a URL.** `createFoirSubjectRegistry()`
615
+ builds one; passing it as `subjects` on the client config records what each
616
+ call fetched.
617
+
618
+ ```ts
619
+ const subjects = createFoirSubjectRegistry({ label: (r) => r.title as string });
620
+ const foir = createFoirClient({ apiUrl, apiKey, generated, subjects });
621
+
622
+ await foir.article.findUnique({ naturalKey: 'about-us' });
623
+ subjects.subject(); // { modelKey: 'article', recordId: 'kx7p…', label: 'About Us' }
624
+ ```
625
+
626
+ `subject()` answers with the last record the caller addressed **by name**; a
627
+ record that only appeared inside a list is recorded but never promoted, so a
628
+ page whose data all came out of lists answers `undefined` rather than picking
629
+ one. That distinction is read off the op's published payload — a `record`
630
+ payload IS one record — not inferred from result counts or field names. The
631
+ rest of the surface is `fetched()`, `setSubject()`, `clear()`, `subscribe()`
632
+ and `add()`, the last for seeding a record read on the server or through
633
+ `foir.raw()`, neither of which the data path can see.
634
+
635
+ **Off unless you build one, and there is no flag that turns it on.** A list of
636
+ the records a page fetched names content the reader may never have seen
637
+ rendered, so it exists only when the host constructs it and the host holds the
638
+ only reference. Build one per client, never at module scope — a shared registry
639
+ on a multi-tenant edge isolate is shared across tenants.
640
+
641
+ A failing `label` function or `subscribe` listener raises rather than being
642
+ swallowed, in a message that says the request itself succeeded: a subject that
643
+ silently fails to record leaves the PREVIOUS one standing, and a widget then
644
+ files feedback against the wrong page.
645
+
646
+ `FoirDocumentPlan` also now carries an optional `records` — where the records a
647
+ document selects sit in the response, and which model each is — derived from
648
+ the same payload walk that decides where `pick`/`omit` land. Optional so a
649
+ hand-built plan need not invent positions.
650
+
651
+ New exports on `./data`: `createFoirSubjectRegistry`, `recordFoirSubjects`,
652
+ `FoirSubject`, `FoirSubjectEntry`, `FoirSubjectOptions`, `FoirSubjectOrigin`,
653
+ `FoirSubjectRegistry`, `FoirRecordPath`.
654
+
655
+ - **Types for the customer notes surface.** `foir.ns.notes.myNotes()` and
656
+ `foir.ns.notes.createMyNote()` already worked — `notes` is a namespace the
657
+ manifest publishes like any other — but their shapes had to be written out by
658
+ hand at each call site. They are platform-owned and identical in every project,
659
+ so they now ship: `FoirCustomerNote`, `FoirMyNotesArgs`, `FoirMyNotesResult`,
660
+ `FoirCreateMyNoteArgs`, `FoirNoteSubjectType`.
661
+
662
+ ```ts
663
+ const mine = (await foir.ns.notes.myNotes({ first: 20 })) as FoirMyNotesResult;
664
+ ```
665
+
666
+ `FoirNoteSubjectType` is the closed set the platform's write path enforces
667
+ (`record`, `file`, `model`, `operation`, `schedule`, `customer`) and is never a
668
+ model key — a note on a record is `'record'` plus the key on
669
+ `subjectModelKey`. Like every mirrored platform table in `builtins.ts`, a kind
670
+ added upstream widens this union in a minor release.
671
+
672
+ - **`depth` on a model or namespace call — a per-request budget for nested block
673
+ content.** A block union's members can themselves carry block unions, and the
674
+ server refuses to expand a union already on the current path, so the
675
+ complete-by-default document stops one level in. Those stopping points now
676
+ publish the union they stopped in front of, and `depth` spends a level on one:
677
+
678
+ ```ts
679
+ // Default: a stopping point arrives as `{ __typename }`, exactly as before.
680
+ await foir.product.findMany({ first: 5 });
681
+
682
+ // One further union expansion — the nested blocks' own fields arrive.
683
+ await foir.product.findMany({ first: 5, depth: 2 });
684
+ ```
685
+
686
+ It is a **budget**, not a setting: the field count is exponential per level.
687
+ On one real project the same op measures 1,203 fields at the default, 4,308 at
688
+ `depth: 2` and 13,623 at `depth: 3`, against a server ceiling of 10,000 — so
689
+ the third level is reachable there only alongside `pick`/`omit`. A call whose
690
+ document would cross the ceiling is refused **client-side**, with the
691
+ arithmetic in the message and the ceiling named, rather than being sent for the
692
+ server to answer with a complexity number the caller cannot act on.
693
+
694
+ Additive in the strict sense: **the document printed for a call that does not
695
+ pass `depth` is byte-identical to the one printed before this change** —
696
+ verified across all 73 ops of a live manifest, and asserted in
697
+ `document.test.ts`. `FoirDocumentPlan` also now carries an optional `fields`,
698
+ the count the server's complexity ceiling is denominated in — optional so a
699
+ hand-built plan need not invent a number, and always set on one this SDK
700
+ printed.
701
+
702
+ **The result type follows the level.** A stopping point the budget reaches is
703
+ typed as the expanded union rather than `FoirDegraded`, so the value is read
704
+ with a `__typename` narrowing and no cast:
705
+
706
+ ```ts
707
+ const page = await foir.product.findMany({ first: 5, depth: 2 });
708
+ const block = page.edges[0].node.pageContent?.sections?.[0]?.blocks?.[0];
709
+ if (block?.__typename === 'SectionBlock') {
710
+ const nested = block.blocks?.[0]; // the expanded union, not `{ __typename }`
711
+ if (nested?.__typename === 'ProductMainBlock') nested.enableShopPay; // reads
712
+ }
713
+ ```
714
+
715
+ `foir types` gives the composites between the record and a stopping point a
716
+ `<D extends FoirDepthLevel = 1>` parameter and names the model's shape at each
717
+ level under a new optional `FoirModelDefinition.atDepth`; the client picks one
718
+ with a single lookup. **Regenerate to get it** — an existing generated file
719
+ keeps working and keeps typing stopping points as `FoirDegraded` at every
720
+ level, which is what it did before.
721
+
722
+ The default is untouched, in the same strict sense the document is: `D`
723
+ defaults to `1`, so **the result type of a call that does not pass `depth` is
724
+ the type it was before this change**. A `depth` that is not a literal at the
725
+ call site (a variable typed `number`), or a level past the four `atDepth`
726
+ names, falls back to that same default shape — the values still arrive, the
727
+ type just declines to describe them, which is the direction that cannot
728
+ mislead.
729
+
730
+ - **`FoirDepthLevel` and `FoirDepthBelow` on `@foir/sdk/data`** — the two type
731
+ exports the generated file needs to declare its `<D>` parameter (see `depth`
732
+ above). Platform-owned rather than emitted per project because `FoirDepthBelow`
733
+ mirrors the printer's `depth - 1` at an expanded stopping point, and one
734
+ definition beside it is how the two stay agreeing. Named exports, so the
735
+ export-surface snapshot moves 372 → 374.
736
+
737
+ - **`generated` on `createFoirClient` and `createFoirClientFromEnv`, with the
738
+ `FoirGeneratedModule` type** — the generated module passed whole, which is now
739
+ the documented way to build a client:
740
+
741
+ ```ts
742
+ import { createFoirClient } from '@foir/sdk/data';
743
+ import * as generated from './foir.generated';
744
+
745
+ const foir = createFoirClient({ apiUrl, apiKey, generated });
746
+ ```
747
+
748
+ One import, no per-export knowledge at the call site, and `FoirSchema` inferred
749
+ from the module rather than restated as `createFoirClient<FoirSchema>`. An
750
+ artifact added to `foir types` later then costs a call site nothing.
751
+
752
+ **Additive.** `schemaVersion`, `selections`, `namespaceSelections` and
753
+ `selectionUnions` are unchanged, still accepted, and still win where both are
754
+ given — a client written against them compiles and behaves exactly as before.
755
+
756
+ What the module form removes is a failure that had no symptom. Passing four of
757
+ the five exports produced a client that silently fetched `GET /v1/manifest` on
758
+ every cold start and answered identically, so a `foirSelections` emitted in a
759
+ shape the printer could not read went unnoticed. A `generated` module is now
760
+ checked where it is passed: a missing export is a compile error naming it and a
761
+ refusal at construction, and so is a `foirSelections` entry that is a bare
762
+ selection tree rather than a selection model.
763
+
764
+ Needs a file from a current `foir types`: the inference reads `foirSchemaType`,
765
+ a type carrier the generator now emits, because a namespace import carries
766
+ values and `FoirSchema` is an interface. An older generated file, or one from a
767
+ server that publishes no selection trees, does not type-check as a
768
+ `FoirGeneratedModule` — re-run `foir types`, or keep using the individual
769
+ fields, which is what that file's own header now tells you to do.
770
+ - **`announceManifestFallback` on `createFoirClient`** — set `false` to silence
771
+ the one-line `console.info` a client prints when it is built with neither
772
+ `generated` nor `selections` and will therefore read its selection trees from
773
+ `GET /v1/manifest`.
774
+
775
+ The fallback itself is unchanged and stays supported: it is what makes a
776
+ zero-codegen start work. What is new is that it announces itself, because a
777
+ fallback nobody can see is indistinguishable from the wiring mistake above.
778
+ - **`planFoirNamespaceCall` on `@foir/sdk/data`**, with `FoirNamespaceCall`,
779
+ `FoirNamespaceSelections` and `FoirSelectionNamespace`. A namespace op is a
780
+ `FoirManifestOp` carrying a payload exactly as a model op is, so the function
781
+ that prints `foir.page.findMany()` prints `foir.ns.search.searchRecords()` —
782
+ one printer rather than two that agree by inspection.
783
+ - **`namespaceSelections` on `createFoirClient`** — the namespace half of what
784
+ `foir types` emits, beside `selections`. Optional, like `selections`: without
785
+ it the client reads the same descriptor from `GET /v1/manifest` at first use.
786
+ - **`FoirManifestOpPayload` on `@foir/sdk/data`** — an op's published result
787
+ shape, in four kinds (`record`, `object`, `union`, `leaf`). `leaf` is a stated
788
+ kind rather than an absent field list because a leaf takes NO selection set
789
+ and printing `{}` after one is a parse error.
790
+ - **Selection-tree types on `@foir/sdk/data`** — `FoirManifestSelection`,
791
+ `FoirManifestSelectionField`, `FoirManifestSelectionMember` and
792
+ `FoirManifestSelectionUnion`, the resolved complete-by-default selections
793
+ `GET /v1/manifest` now publishes alongside the field buckets.
794
+
795
+ They are what `foir types` emits as a runtime value, so a client can print a
796
+ GraphQL document without a schema and without recomputing a selection in a
797
+ second language. Every field on them is optional on `FoirManifest` and
798
+ `FoirManifestModel`, because a server that predates trees omits them and a
799
+ consumer must not assume the shape.
800
+ - **`foirSelections` / `foirNamespaceSelections` / `foirSelectionUnions` on
801
+ `@foir/sdk/data`** — the runtime contract `foir types` emits against, and the
802
+ printer that reads it: `planFoirModelCall`, `planFoirNamespaceCall`,
803
+ `foirRoute`, and the types `FoirSelections`, `FoirNamespaceSelections`,
804
+ `FoirSelectionUnions`, `FoirSelectionModel`, `FoirSelectionNamespace`,
805
+ `FoirSelectionSource`, `FoirCall`, `FoirNamespaceCall`, `FoirDocumentPlan`,
806
+ `FoirRouteDecision`, `FoirRouteInput`, `FoirLane`, `FoirLaneReason`. Plus the
807
+ manifest's own per-op and selection types — `FoirManifestOpPayload`,
808
+ `FoirManifestSelection`, `FoirManifestSelectionField`,
809
+ `FoirManifestSelectionUnion`, `FoirManifestSelectionMember`.
810
+
811
+ - **The event-log client on `@foir/sdk/server`** (TTS-983) — `createEventsClient`,
812
+ cursor-resumable consumption of a project's durable event history over
813
+ `GET /v1/events`. `list()` returns one page; `stream()` pages forward to the
814
+ current end of the log and yields events, each carrying the `seq` that is the
815
+ durable cursor.
816
+
817
+ Also `FoirEventCursorExpiredError` and `isFoirEventCursorExpired` for the
818
+ retention refusal, the constants `FOIR_EVENTS_PATH`, `FOIR_EVENTS_SCOPE` and
819
+ `FOIR_EVENTS_RETENTION_DAYS`, and the types `FoirEvent`, `FoirEventActor`,
820
+ `FoirEventPage`, `FoirEventRetention`, `FoirEventsClient`,
821
+ `FoirEventsClientConfig`, `FoirEventsListParams`, `FoirEventsStreamParams`.
822
+
823
+ **On `@foir/sdk/server` and no other subpath, deliberately.** The event log is
824
+ an OPERATOR surface (DECIDED 2026-08-04, TTS-981): reachable by an `sk_` key or
825
+ an admin session, never by a customer token, and `events:list.project` is not
826
+ mintable on a `pk_` key at all. `events.view` is plain tenant+project
827
+ containment while `record.view` carries owner/grantee/borrow terms on top, so a
828
+ containment-only read over the log describes rows whose records the same caller
829
+ could not read — and each row's `actor` names which *other* customer acted.
830
+ `createEventsClient` therefore throws on a `pk_` key and on an `Authorization`
831
+ header rather than letting either reach the wire, and an import-graph test
832
+ fails if the module becomes reachable from a subpath that ships to a browser.
833
+
834
+ Requires foir-io/platform#757 to be deployed. Against an api-public without it,
835
+ `GET /v1/events` 404s.
836
+
837
+ - **The package error taxonomy on `@foir/sdk/server`** — `FoirRequestError`,
838
+ `isFoirRateLimited`, `isFoirUnauthenticated`, `isFoirForbidden`,
839
+ `isFoirNotFound`, and the `FoirErrorCode` / `FoirErrorExtensions` /
840
+ `FoirGraphQLErrorItem` types, re-exported from `@foir/sdk/client` so an
841
+ events-client consumer classifies failures without importing a second subpath.
842
+ Same classes, not copies.
843
+
844
+ - **The whole auth surface on `@foir/sdk/auth`.** Password management, email
845
+ verification and account lifecycle were reachable NOWHERE in the package — a
846
+ storefront doing "forgot password" had to drop to hand-written GraphQL, which
847
+ is the entire cost of GraphQL paid for one screen. Ten root fields closed:
848
+
849
+ `getAuthConfig`, `getDefaultAuthProvider`, `currentUser`, `myProviderToken`,
850
+ `customerLogout`, `customerRequestPasswordReset`, `customerResetPassword`,
851
+ `customerUpdatePassword`, `customerVerifyEmail`, `deleteMyAccount`.
852
+
853
+ Plus the session-layer pairings for the flows that need one:
854
+ `resetPasswordAndSignIn` (redeem the emailed token and persist the session it
855
+ mints — the peer of `loginWithPassword` for the branch where the customer
856
+ cannot log in), `signOut` (revoke server-side, then clear locally, and clear
857
+ locally even if the revoke fails), and `authedOp` (`authedRequest`'s twin for
858
+ auth ops: refresh once, retry once, on a 401-shaped failure).
859
+
860
+ And the vocabulary: `AUTH_OPS`, `AuthOpName`, `AuthOpOptions`, with the result
861
+ types `AuthConfig`, `AuthConfigProvider`, `DefaultAuthProvider`, `CurrentUser`,
862
+ `ProviderToken`, `DeleteAccountResult`.
863
+
864
+ Requires an api-public that publishes the full `auth` namespace in
865
+ `GET /v1/manifest`. Against an older one every auth call is refused with
866
+ `operation "…" is not available in namespace "auth" for this credential`, so
867
+ **the platform change deploys first**.
868
+
869
+ ### Removed
870
+
871
+ - **The nine auth operation documents on `@foir/sdk/auth`** —
872
+ `CustomerLoginDocument`, `CustomerRegisterDocument`,
873
+ `CustomerRefreshTokenDocument`, `CustomerRequestOTPDocument`,
874
+ `CustomerLoginOTPDocument`, `CustomerLoginWithProviderDocument`,
875
+ `CustomerProviderCallbackDocument`, `CustomerProviderVerifyOTPDocument`,
876
+ `AuthProvidersDocument`.
877
+
878
+ **Breaking, so the next release is a MINOR** per the pre-1.0 note above.
879
+
880
+ *Why.* They were a second wire protocol for one substrate inside one package:
881
+ hand-written GraphQL posted to `/graphql` while every other typed call in the
882
+ SDK went through the op transport. That split is what produced the gap above —
883
+ the hand-written set covered nine root fields and nobody noticed the other
884
+ eleven were unreachable. The flows now address the server's generated `auth`
885
+ namespace, so `@foir/sdk/auth` carries no transport of its own.
886
+
887
+ *Migration.* Nothing that called a FUNCTION changes: `customerLogin(env, …)`,
888
+ `getAuthProviders(env)`, `loginWithPassword(adapter, env, …)`, `refreshSession`,
889
+ `requireAuth`, `authedRequest` and every React hook in `@foir/sdk/react` keep
890
+ their exact signatures. Only code importing a `*Document` const is affected,
891
+ and only if it was executing the document itself through its own GraphQL
892
+ client. Two options:
893
+
894
+ - call the SDK function instead (`customerLogin(env, vars)` rather than running
895
+ `CustomerLoginDocument` through Apollo/urql), which is what the exports were
896
+ for; or
897
+ - keep your own client and write the document in your own codebase — these are
898
+ fixed-shape platform operations, so the text does not vary by project.
899
+
900
+ `authedRequest` is unchanged and still takes a `TypedDocumentNode`: it exists
901
+ for the project's OWN operations, whose shape the SDK cannot know.
902
+
903
+ - **`graphql` from `@foir/sdk/auth`'s path.** The removed documents were
904
+ `parse()`d at module load, which no bundler could tree-shake, and that is the
905
+ reason `@foir/sdk/auth/session` had to exist as a separate graphql-free door.
906
+ The subpath stays — it is a published surface and consumers import it — but it
907
+ is no longer the only graphql-free way into this package.
908
+
909
+ ---
910
+
911
+ ## [0.7.0] — 2026-08-08
912
+
913
+ Everything additive, so a MINOR per the pre-1.0 note.
914
+
915
+ Ships 0.6.0 with it. That version was cut in the changelog and in
916
+ `package.json` on 2026-07-27 but no `sdk-v0.6.0` tag was ever pushed, and the
917
+ tag is what publishes, so npm went straight from 0.5.0 to here. The 0.6.0
918
+ section below is left as written rather than folded in, so the history says what
919
+ actually happened.
920
+
921
+ ### Added
922
+
923
+ - **Failure classification on `@foir/sdk/client`** — `isFoirRateLimited`,
924
+ `isFoirUnauthenticated`, `isFoirForbidden`, `isFoirNotFound`, plus `code`,
925
+ `requestId`, `retryable` and `retryAfterMs` on `FoirRequestError`, and the
926
+ `FoirErrorCode` / `FoirErrorExtensions` types behind them.
927
+
928
+ `FoirGraphQLErrorItem` now carries `extensions`, which is what makes any of
929
+ this possible: the platform has been sending `extensions.code` on every
930
+ rejection and the SDK's error type had nowhere to put it, so the only way to
931
+ tell one failure from another was to match the message.
932
+
933
+ - **Retry, on by default, in `createFoirContentClient`** — `withFoirRetry` and
934
+ `foirRetryDelayMs` are exported for callers running their own loop.
935
+
936
+ It covers no-response, `5xx`, and **`429`**. The last one is the reason this
937
+ is in the SDK rather than in a docs snippet. A 429 sits among the 4xx statuses
938
+ that are permanent, so the rule everyone writes — retry 5xx, never retry 4xx —
939
+ excludes the one failure in that family that clears on its own. A storefront
940
+ wrote exactly that rule; when its key saturated, every read failed on the
941
+ first attempt for an hour and the site served empty pages while the API was
942
+ healthy.
943
+
944
+ A server-stated `Retry-After` wins over backoff, capped by `maxDelayMs`
945
+ (default 20s) so an hour-long window does not block a render. Opt out with
946
+ `retry: { attempts: 1 }`.
947
+
948
+ - **`onRateLimit`** on `createFoirContentClient` and on `FoirRequestOptions`,
949
+ with `readFoirRateLimit` and the `FoirRateLimitSnapshot` type. Called with the
950
+ `X-RateLimit-*` headers of every response, so an app can log that a key is
951
+ approaching its ceiling instead of finding out when it hits one.
952
+
953
+ - **New subpath `@foir/sdk/auth/session`** — `createFoirSessionClient` and its
954
+ types (`FoirSessionClient`, `FoirSessionConfig`, `FoirSessionUser`) on their
955
+ own, without the rest of `./auth`.
956
+
957
+ Purely additive: `./auth` keeps exporting all four, unchanged, and nothing is
958
+ moved or renamed. What changes is the cost of reaching them. `./auth` also
959
+ exports the customer-auth GraphQL documents, which build their
960
+ TypedDocumentNodes with a top-level `parse()` that no bundler can treat as
961
+ pure — so a browser app importing only `createFoirSessionClient` was shipping
962
+ the whole `graphql` package to get it. `src/auth/session-client.ts` imports
963
+ nothing at all, and this subpath bundles it alone.
964
+
965
+ Like `./data`, it adds **no dependency and no peer dependency**:
966
+ `dist/auth-session.js` has no imports. An app on this subpath needs `graphql`
967
+ neither installed nor bundled. Use `./auth` when you want the login /
968
+ register / OTP / provider flows too; use this one for a backend-less SPA whose
969
+ only credential is the Fóir-hosted session cookie.
970
+
971
+ - **New subpath `@foir/sdk/data`** — the typed data client (TTS-874).
972
+ `createFoirClient<FoirSchema>({ apiUrl, apiKey })` gives `foir.<model>.<op>(…)`,
973
+ `foir.ns.<namespace>.<op>(…)` and an untyped `foir.raw(op)`. The client's surface is shaped by the credential: an op that
974
+ is not in that key's manifest is not on the type, so `foir.page.create(…)`
975
+ under a `pk_` key is a compile error rather than a 400.
976
+
977
+ Selection is complete-by-default — every scalar always arrives, so the model
978
+ type is unconditionally true — with `include` to opt into relations and
979
+ `omit` to drop a heavy field. `_id` is not omittable, matching the server.
980
+
981
+ **This subpath has no `graphql` dependency**: it imports neither `graphql`,
982
+ `@graphql-typed-document-node/core` nor `@graphql-codegen/*`, and adds no new
983
+ peer dependency, required or optional. `dist/data.js` has no imports at all.
984
+
985
+ New exports on `./data`: `createFoirClient`, `createFoirClientFromEnv`,
986
+ `fetchFoirManifest`, `foirDataEndpoint`, `FoirRequestError`, and the type
987
+ surface (`FoirSchemaDefinition`, `FoirModelDefinition`,
988
+ `FoirRelationDefinition`, `FoirOpDefinition`, `FoirReturnDefinition`,
989
+ `FoirNamespaceDefinition`, `FoirNamespaceOpDefinition`, `FoirFieldsShape`,
990
+ `FoirConnection`, `FoirClient`, `FoirClientCore`, `FoirClientConfig`,
991
+ `FoirModelClient`, `FoirNamespaceClient`, `FoirModelOperation`,
992
+ `FoirNamespaceOperation`, `FoirQuery`, `FoirInclude`, `FoirOmittableKey`,
993
+ `FoirRecordResult`, `FoirOpResult`, `FoirRawOp`, `FoirSchemaDrift`,
994
+ `FoirManifest` + its members, and `FoirEnv` / `FoirGraphQLErrorItem` /
995
+ `FoirTokenProvider` re-exported for `./data`-only consumers).
996
+
997
+ Additive, so a MINOR bump at release time. No existing subpath's surface
998
+ changed — the export snapshot diff for this change is one new subpath and
999
+ nothing else.
1000
+
1001
+ ### Changed
1002
+
1003
+ - Internal only, no public surface change: the `graphql`-free half of
1004
+ `src/graphql/foir-request.ts` moved to `src/transport/foir-http.ts` so
1005
+ `./data` and `./client` share one header builder and one `FoirRequestError`
1006
+ rather than forking the transport. `@foir/sdk/client` re-exports every symbol
1007
+ it exported before, unchanged, and `FoirTokenProvider` is now declared in the
1008
+ shared module and re-exported from its old home.
1009
+
1010
+ ---
1011
+
1012
+ ## [0.6.0] — 2026-07-27
1013
+
1014
+ First release since the public surface was pinned. Everything in it is
1015
+ additive, so per the pre-1.0 note this is a MINOR: `^0.5.0` will not pick it up
1016
+ on its own, which is the intended behaviour for a release that adds exports.
1017
+
1018
+ ### Added
1019
+
1020
+ - `@foir/sdk/auth`: `customerSignupContext` and `customerJoinWaitlist`, plus the
1021
+ `SignupContext`, `SignupMode` and `WaitlistJoinResult` types — waitlist and
1022
+ restricted-signup flows for projects that are not open-registration.
1023
+ - A pinned public-export surface: `public-exports.snapshot.json` plus
1024
+ `scripts/assert-public-exports.mjs`, wired into `pnpm build`. Deleting a named
1025
+ export previously passed the full SDK CI lane — 125 tests and the export-map
1026
+ guard all green — because nothing checked the contents of the declared files,
1027
+ only that they existed.
1028
+ - This changelog, and the semver policy above.
1029
+
1030
+ ### Changed
1031
+
1032
+ - Type declarations are emitted by a dedicated `tsc --emitDeclarationOnly` pass
1033
+ instead of `tsup`'s built-in `dts`, whose parallel rollup workers
1034
+ intermittently failed to flush a subpath's `.d.ts`; consumers saw
1035
+ "Could not find a declaration file for module '@foir/sdk/realtime'".
1036
+ `scripts/assert-export-types.mjs` now fails the build if any file the export
1037
+ map promises is missing. No change to the public surface.
1038
+ - The test suite pins `NODE_ENV=test` so an inherited `NODE_ENV=production`
1039
+ cannot load React's production build and break every render test. Internal.
1040
+
1041
+ ---
1042
+
1043
+ ## [0.5.0] — 2026-07-15
1044
+
1045
+ ### Added
1046
+
1047
+ - `@foir/sdk/client`: `createFoirContentClient` — forwards the customer bearer
1048
+ token on content reads, so authenticated and segment-targeted content works
1049
+ without hand-rolling the header.
1050
+
1051
+ ## [0.4.0] — 2026-07-12
1052
+
1053
+ ### Added
1054
+
1055
+ - `@foir/sdk/auth`: `createFoirSessionClient`, plus the `FoirSessionClient`,
1056
+ `FoirSessionConfig` and `FoirSessionUser` types — Foir-hosted first-party
1057
+ sessions for backend-less SPAs, over the `fetchHostedSession` /
1058
+ `hostedLoginUrl` hosted-login bridge that already shipped in 0.1.0 (EID-516).
1059
+
1060
+ ## [0.3.0] — 2026-07-12
1061
+
1062
+ ### Added
1063
+
1064
+ - **New subpath `@foir/sdk/react`** — drop-in React customer auth: `FoirProvider`,
1065
+ `SignIn`, `OAuthButtons`, `UserButton`, `useFoirAuth`, `useFoirSession`,
1066
+ `useFoirUser` (EID-515).
1067
+ - **New subpath `@foir/sdk/next`** — Next.js App Router BFF auth adapter: cookie
1068
+ session store, `createFoirMiddleware`, server-side sign-in/out actions and the
1069
+ provider callback handler (EID-515).
1070
+
1071
+ ## [0.2.0] — 2026-07-10
1072
+
1073
+ ### Added
1074
+
1075
+ - **New subpath `@foir/sdk/push`** — native Web Push (VAPID) enrolment:
1076
+ `enableWebPush`, `disableWebPush`, `isWebPushSupported`.
1077
+ - `@foir/sdk/server`: `requireScopedToken` — a one-line ingress guard for
1078
+ operation BFF routes, over the existing `verifyScopedToken` (EID-498).
1079
+
1080
+ ## [0.1.0] — 2026-06-23
1081
+
1082
+ First public release. Ten subpaths:
1083
+
1084
+ - `@foir/sdk` — editor iframe client: `EditorProvider`, `useEditor`, the
1085
+ host↔editor message protocol, and `createPlatformClient` (EID-88, EID-91).
1086
+ - `@foir/sdk/server` — server-side platform helpers for config API backends:
1087
+ `createServerClient`, scoped-token verification, `verifyWebhookSignature`.
1088
+ - `@foir/sdk/client` — React-free GraphQL transport for SSR/edge (`foirRequest`).
1089
+ - `@foir/sdk/media` — progressive media components and adapters (EID-94).
1090
+ - `@foir/sdk/content` — `createContentRenderer` and `RichTextRenderer` with
1091
+ injected typography tokens (EID-93).
1092
+ - `@foir/sdk/auth` — customer auth flows, token lifecycle, hosted-login bridge
1093
+ (EID-95).
1094
+ - `@foir/sdk/realtime` — `createCollabSession`, a thin y-websocket helper
1095
+ (EID-103, EID-104).
1096
+ - `@foir/sdk/codegen`, `@foir/sdk/codegen/block-registry`,
1097
+ `@foir/sdk/codegen/block-scaffold` — the `foirCodegen` preset and the
1098
+ graphql-codegen plugins (EID-92).
1099
+
1100
+ ---
1101
+
1102
+ ### A note on the backfilled entries
1103
+
1104
+ `@foir/sdk` shipped 0.1.0 through 0.5.0 with no changelog. The five sections
1105
+ above were reconstructed after the fact from sources that can be re-checked:
1106
+ the `sdk-v*` git tags, the `packages/sdk/package.json#exports` map read at each
1107
+ of those tags, the commits between consecutive tags that touch `packages/sdk/`,
1108
+ and the npm registry's publish timestamps (`npm view @foir/sdk time`), which is
1109
+ where the dates come from.
1110
+
1111
+ They are **headline-level, not exhaustive**. They record subpath additions and
1112
+ the named feature of each release; they do not claim to list every symbol added,
1113
+ because no per-symbol record was kept at the time and reconstructing one would
1114
+ mean guessing. Nothing above is asserted that could not be read out of the tags
1115
+ and the commit history.
1116
+
1117
+ `Unreleased` and every section after it are authored at the time of the change
1118
+ and are exhaustive for the public surface, which is what the export-surface
1119
+ guard exists to keep true.
1120
+
1121
+ [Unreleased]: https://github.com/foir-io/clients/compare/sdk-v0.5.0...dev
1122
+ [0.5.0]: https://github.com/foir-io/clients/compare/sdk-v0.4.0...sdk-v0.5.0
1123
+ [0.4.0]: https://github.com/foir-io/clients/compare/sdk-v0.3.0...sdk-v0.4.0
1124
+ [0.3.0]: https://github.com/foir-io/clients/compare/sdk-v0.2.0...sdk-v0.3.0
1125
+ [0.2.0]: https://github.com/foir-io/clients/compare/sdk-v0.1.0...sdk-v0.2.0
1126
+ [0.1.0]: https://github.com/foir-io/clients/releases/tag/sdk-v0.1.0