@foir/sdk 0.10.0 → 0.12.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 (52) hide show
  1. package/CHANGELOG.md +353 -2
  2. package/README.md +10 -2
  3. package/dist/app/client.d.ts +371 -0
  4. package/dist/app/contract/index.d.ts +23 -0
  5. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  6. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  7. package/dist/app/dispatch.d.ts +64 -0
  8. package/dist/app/errors.d.ts +50 -0
  9. package/dist/app/index.d.ts +40 -0
  10. package/dist/app/manifest.d.ts +56 -0
  11. package/dist/app/naming.d.ts +31 -0
  12. package/dist/app/record-key.d.ts +17 -0
  13. package/dist/app/ticket.d.ts +37 -0
  14. package/dist/app/vocabulary.generated.d.ts +20 -0
  15. package/dist/app-contract.js +124 -0
  16. package/dist/app.js +998 -0
  17. package/dist/auth/auth.d.ts +24 -6
  18. package/dist/auth/index.d.ts +2 -1
  19. package/dist/auth/ops.d.ts +8 -0
  20. package/dist/auth.js +230 -60
  21. package/dist/client.js +80 -15
  22. package/dist/codegen/preset.d.ts +3 -2
  23. package/dist/data/builtins.d.ts +17 -1
  24. package/dist/data/client.d.ts +10 -8
  25. package/dist/data/document.d.ts +13 -0
  26. package/dist/data/index.d.ts +15 -5
  27. package/dist/data/manifest.d.ts +31 -3
  28. package/dist/data/schema.d.ts +16 -9
  29. package/dist/data/selection.d.ts +15 -0
  30. package/dist/data/transport.d.ts +277 -23
  31. package/dist/data/ws-transport.d.ts +8 -2
  32. package/dist/data.js +313 -72
  33. package/dist/graphql/client.d.ts +1 -1
  34. package/dist/graphql/foir-request.d.ts +1 -1
  35. package/dist/index.js +0 -9
  36. package/dist/live/drift.d.ts +9 -10
  37. package/dist/live.js +1 -1
  38. package/dist/next.js +158 -37
  39. package/dist/placement/index.d.ts +129 -0
  40. package/dist/placement.js +144 -0
  41. package/dist/push.js +212 -50
  42. package/dist/react.js +158 -37
  43. package/dist/server/callback-client.d.ts +30 -9
  44. package/dist/server/continue-run.d.ts +56 -0
  45. package/dist/server/index.d.ts +6 -3
  46. package/dist/server/operation-types.d.ts +114 -65
  47. package/dist/server/read-dispatch.d.ts +53 -0
  48. package/dist/server/require-scoped-token.d.ts +71 -7
  49. package/dist/server/verify-scoped-token.d.ts +109 -3
  50. package/dist/server.js +566 -138
  51. package/dist/transport/foir-http.d.ts +103 -5
  52. package/package.json +24 -6
package/CHANGELOG.md CHANGED
@@ -95,8 +95,12 @@ move or drop that `.npmrc`, tag by hand:
95
95
  git tag sdk-v<version> && git push origin sdk-v<version>
96
96
  ```
97
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.
98
+ Where the tagged commit is decides the npm tag it goes out under. A tag on a
99
+ commit `main` holds publishes as `latest`. A tag on a commit only `dev` holds
100
+ publishes as `next`: use it when a release needs something production does not
101
+ serve until the next promotion, so the version can be installed by name and
102
+ nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
103
+ `main` moves `latest` onto it.
100
104
 
101
105
  ---
102
106
 
@@ -104,8 +108,355 @@ on `dev` would publish unreviewed work to npm.
104
108
 
105
109
  Changes on `dev` that have not been published to npm.
106
110
 
111
+ ## [0.12.0] — 2026-10-01
112
+
113
+ Breaking changes under the pre-1.0 note, so this release is a MINOR. Published
114
+ under npm's `next` tag from `dev`, because it calls parts of the platform that
115
+ production serves from the next promotion; `latest` moves to it then.
116
+
117
+ ### Added
118
+
119
+ - **`@foir/sdk/app`**, a new subpath: everything an app built on Foir asks of
120
+ the platform, as one client. An app is a service the platform dispatches to,
121
+ holding the token each dispatch arrives with.
122
+ - `createAppClient({ apiUrl, dispatch })` calls the data API with that token:
123
+ `whoami`, `install.config`, `install.credentials`,
124
+ `install.writeCredentials`, `install.reportStatus`, `records.upsert`,
125
+ `read`, `delete`, `refresh`, `rename`, `updateMany` and `aggregate`,
126
+ `records.customer.save`, `read`, `erase` and `identities`,
127
+ `customers.ensure`, `notifications.send` and `notifications.setConsent`, and
128
+ `request` for a document of your own. Nothing is checked before a call: what
129
+ the token may do is the platform's to hold it to, and a refusal is a
130
+ `FoirRequestError` carrying the platform's answer.
131
+ - `appLookupConflictOf`, `appCustomerUnlinkedOf` and `isAppFieldWithheld` read
132
+ the refusals an app acts on, as data.
133
+ - `readAppDispatch` and `readCredentialProbe` read the two bodies the platform
134
+ posts to an app, and `redeemExchangeTicket` trades an OAuth exchange ticket
135
+ for the token that writes the install's credentials.
136
+ - `validateAppManifest({ apiUrl, apiKey, manifest })` asks the platform
137
+ whether it would accept a manifest, as a dry run, and answers with every
138
+ issue and where it is. Any key may ask, a publishable one included.
139
+ - `APP_CAPABILITIES`, `APP_RECORD_CAPABILITY_PREFIXES` and `APP_HOOK_EVENTS`
140
+ are the capabilities an operation may declare and the events a hook may
141
+ name, with their types, generated from the platform's contract.
142
+ - `appModelTypeName`, `appModelFieldName`, `appSelectorEnumValue` and
143
+ `isSelectableFieldKey` name a model's fields on the data API from its key,
144
+ and `RECORD_KEY_RULE` and `recordKeyRefusal` state what a record key may be.
145
+ - **`@foir/sdk/app/contract`**, a new subpath: the manifest and discovery
146
+ contracts as the messages the platform decodes them with (`AppConfigSchema`,
147
+ `DiscoveryResultSchema` and what they are built from). Building a manifest
148
+ from them means a key the platform does not accept cannot be written. It
149
+ needs `@bufbuild/protobuf`, an optional peer dependency nothing else in the
150
+ package asks for.
151
+ - **`createScopedTokenVerifier`** in `@foir/sdk/server`, for a long-lived
152
+ service: it keeps the platform's signing keys, refreshes them in the
153
+ background, keeps serving the ones it holds through an outage, and looks
154
+ again at most once per cooldown when a token names a key it has not seen.
155
+ `requireScopedToken` takes it as `verifier`. `ScopedTokenUnavailableError` is
156
+ what it throws when it has no keys to judge a token by.
157
+ - **`requireScopedToken` answers more about who is calling.** The ingress it
158
+ resolves with gains `orgId` (the organisation level the token was minted at,
159
+ `null` when it states none), `customerId`, `expiresAt` and `tokenId`.
160
+ `ScopedTokenError` gains `reason` (`missing`, `invalid`, `forbidden` or
161
+ `unavailable`), and its `status` may now be 503: keys that cannot be fetched
162
+ are this service's outage, which the platform retries, and not a verdict on
163
+ the token. `FOIR_TOKEN_HEADER` and `FOIR_CONTEXT_HEADER` name the two dispatch
164
+ headers.
165
+ - **`createCallbackClient` needs no signing secret.** It signs with the run's
166
+ own key, `callback.signingKey`, which the platform sends with every ASYNC
167
+ dispatch, so `signingSecret` is now optional and is the key used only when
168
+ passed. It takes `apiUrl`, used when a dispatch names no address to report
169
+ to, `retry`, and `signal`. A call the platform did not answer, or answered
170
+ with a 5xx or a 429, is tried again, with the same signed bytes.
171
+ - **`@foir/sdk/placement`**, a new subpath for an app's own admin console shown
172
+ inside Foir's as a full-page placement. `connectPlacement` tells the console
173
+ the page is ready, takes the token it posts into the frame (only from the
174
+ consoles you list), keeps it fresh, and gives you `request`, `can` and
175
+ `onSignedOut`. `placementFrameAncestors` builds the `frame-ancestors` value a
176
+ page must send to be shown at all, and `FOIR_CONSOLE_ORIGINS` names the
177
+ consoles per environment. `PLACEMENT_MESSAGE` and the message types are the
178
+ protocol the console speaks. Additive: no existing export changes.
179
+ - **`continueRun`** in `@foir/sdk/server`, for an ASYNC operation whose work is
180
+ longer than one call: it answers the dispatch with a continuation (a cursor,
181
+ and optionally a delay and progress), and the platform queues the next slice
182
+ with a fresh callback, handing it the cursor. `CONTINUATION_MEDIA_TYPE`,
183
+ `MAX_CONTINUATION_CURSOR_BYTES` and `MAX_CONTINUATION_DELAY_MS` state the
184
+ contract, `Continuation` is its type, and `OperationContinuationBlock` is the
185
+ envelope block a slice reads its cursor from (`readDispatch` validates it).
186
+ Additive: no existing export changes.
187
+ - **`FoirAuthDowngradedError`** and **`FoirSessionExpiredError`**, exported from
188
+ `@foir/sdk/data`, `@foir/sdk/server`, `@foir/sdk/auth` and `@foir/sdk/client`,
189
+ and **`refreshSessionOnce`** in `@foir/sdk/auth`. `requireAuth` takes an
190
+ optional `env`, and the data client's config takes `refreshBearer`.
191
+ - **`visitor`** on the data client: the address of the visitor a server
192
+ rendering pages is calling for, or a function resolving it per request, sent
193
+ as `X-Foir-Visitor-IP` so the public API's per-visitor limit applies to that
194
+ visitor. From a secret key holding `visitors:forward`; a client built from a
195
+ public key is refused it. The SDK reads no address off a request: pass the one
196
+ your host guarantees. `FoirVisitorSource` and `FOIR_VISITOR_IP_HEADER` are
197
+ exported from `@foir/sdk/data`.
198
+
199
+ ### Changed
200
+
201
+ - **Breaking: a scoped token's app is everything after the second `|` of its
202
+ subject.** `verifyScopedToken` required exactly three `|`-separated parts,
203
+ none empty, so it refused a token whose app name holds a `|`, and one minted
204
+ for a project and no app. The platform splits on the first two: both now
205
+ verify, and `appName` is `''` for a token that names no app.
206
+ - **Breaking: `createCallbackClient` throws `FoirRequestError`**, where it threw
207
+ a plain `Error`, so a caller can tell a refusal (`status`, `graphQLErrors`)
208
+ from a call that got no answer. It also throws when it is built with nothing
209
+ to sign with, or with nowhere to report to.
210
+ - **Breaking: a refusal in a 200 is not retryable.**
211
+ `FoirRequestError.retryable` was true for any error with no HTTP status, which
212
+ is the shape of a network fault and also of a GraphQL refusal answered with a
213
+ 200. A refusal is now retryable only when its code is `RATE_LIMITED`,
214
+ `UNAVAILABLE` or `TIMEOUT`; a stripped selection never is.
215
+ - **Breaking: a signed-in request is no longer handed the signed-out answer.**
216
+ A bearer the server cannot verify is not refused: the request is served as
217
+ nobody, and the response says so in `extensions.auth`. The SDK used to warn
218
+ and return that answer as though it were the signed-in one.
219
+ - `authedOp` and `authedRequest` now treat it like a 401. They refresh the
220
+ session once, retry once, and fail with `FoirSessionExpiredError` when it
221
+ cannot be renewed. A token this environment never issued fails the same way,
222
+ without a refresh.
223
+ - Requests that find the session lapsed together share one refresh, because a
224
+ refresh token rotates when it is spent.
225
+ - The data client, for a request that sent a bearer, fails with
226
+ `FoirAuthDowngradedError`. Give it `refreshBearer` and it renews the bearer
227
+ once and asks again, failing with `FoirSessionExpiredError` when the session
228
+ is over.
229
+ - A request that sent no bearer is untouched.
230
+ - **A refresh that names no customer keeps the session's identity.**
231
+ `saveSession` used to write the customer id and email unconditionally, so a
232
+ refresh answered without them left a valid token beside an empty identity, and
233
+ `requireAuth` turned the session away. Given `env`, `requireAuth` now reads a
234
+ missing identity once from `currentUser` rather than failing.
235
+
236
+ ## [0.11.0] — 2026-09-26
237
+
238
+ Breaking changes under the pre-1.0 note, so this release is a MINOR:
239
+ `OperationPayload` now describes the body the platform actually sends,
240
+ `FoirClientConfig` is a union of two credentials, a record write resolves to
241
+ `FoirWriteResult`, an option field's union is its declared values, and the
242
+ generated module, the drift check and `FoirSchemaDrift` are keyed on the
243
+ surface version instead of the schema counter.
244
+
245
+ ### Added
246
+
247
+ - The data client takes an operation's dispatch token as its credential, so an
248
+ operation endpoint calls Foir AS the operation and holds no key.
249
+
250
+ ```ts
251
+ import { createFoirClient } from '@foir/sdk/data';
252
+ import { readDispatch, requireScopedToken } from '@foir/sdk/server';
253
+ // foir types --operation checkout --out src/checkout.generated.ts
254
+ import * as generated from './checkout.generated';
255
+
256
+ export async function POST(request: Request) {
257
+ const { dispatch } = await requireScopedToken(request, {
258
+ expectedProjectId: process.env.FOIR_PROJECT_ID,
259
+ });
260
+ const { input } = await readDispatch<{ email: string }>(request);
261
+ const foir = createFoirClient({ apiUrl: process.env.PLATFORM_API_URL!, dispatch, generated });
262
+ // ...
263
+ }
264
+ ```
265
+
266
+ A dispatch client requires `generated`, and only a module generated for an
267
+ operation: `foir types --operation <key>` types the surface that operation's
268
+ token is served (`GET /v1/manifest?operation=<key>`) and exports
269
+ `foirCredential` naming it. A key client refuses such a module, a dispatch
270
+ client refuses a key's module, and a dispatch whose `X-Foir-Context` names
271
+ another operation than the module is refused, each at the type and at
272
+ construction. New on `@foir/sdk/data`: `FoirOperationCredential`,
273
+ `FoirKeyGeneratedModule` and `FoirOperationGeneratedModule`.
274
+
275
+ A client built with `dispatch` sends the token as `X-Foir-Token`, with the
276
+ dispatch's `X-Foir-Context`, and never sends `x-api-key`. api-public accepts
277
+ the token as a caller in its own right and builds the schema from the
278
+ capabilities the operation declared, so the generated client works unchanged
279
+ over it: queries, mutations and `execute<Op>`. `dispatch.token` is a provider
280
+ read before every request, not a string captured once. There is no key
281
+ fallback: a provider that yields nothing fails the request.
282
+
283
+ New on `@foir/sdk/data`: `FoirDispatchCredential`, `FoirKeyClientConfig` and
284
+ `FoirDispatchClientConfig`. New on `@foir/sdk/server`: `dispatch` on the
285
+ result of `requireScopedToken`, which is the token it verified as a ready
286
+ credential.
287
+
288
+ Two things a dispatch client does not do. It does not subscribe: the
289
+ subscription socket authenticates a key, an admin token or a customer bearer
290
+ and has no path for a dispatch token, so `onChange` and friends throw, naming
291
+ why. And it takes no key-side option (`apiKey`, `bearerToken`, `orgId`,
292
+ `channel`, the socket options), because the token carries its own customer,
293
+ organization and channel; each is a compile error and a runtime refusal.
294
+
295
+ - `readDispatch(request)` and `OperationPayloadError` on `@foir/sdk/server`.
296
+
297
+ An operation endpoint had no typed reader for its own dispatch, and the body
298
+ is the run, not the input: reading it as the input finds nothing and refuses
299
+ every call on a missing field. `readDispatch` parses the body into
300
+ `OperationPayload`, checks the fields that identify a dispatch, and refuses
301
+ anything else with `OperationPayloadError` (`status` 400), naming what is
302
+ wrong. It keeps every key the platform sent, typed or not. `input` is always
303
+ an object: a run started with no input, which the platform sends as `null`,
304
+ reads as `{}`.
305
+
306
+ - `FoirWriteResult<R>` on `@foir/sdk/data`: `{ record, replayed }`, what a
307
+ record write answers with.
308
+
309
+ - `fetchFoirManifest(config, { operation })` asks for the surface an
310
+ operation's dispatch token is served instead of the credential's own, which
311
+ is described to a key that can author the operation (`operations:write`).
312
+ `fetchFoirManifest` takes `FoirRequestConfig`, the credential and transport
313
+ half of a client config, so a manifest is fetched without a generated module;
314
+ `FoirManifestRequestOptions` and `FoirRequestConfig` are new on
315
+ `@foir/sdk/data`.
316
+
317
+ - `FoirRecordOwner` on `@foir/sdk/data`: `{ id, ownerId, ownerKind }`, what
318
+ `setOwner` answers with. An operation that declares `records:attribute:<model>`
319
+ hands a record to another customer with `foir.<model>.setOwner({ id, ownerId })`,
320
+ and names the owner of a record it creates with `create({ input, ownerId })`.
321
+ The answer carries the owner and not the record, because an operation acting
322
+ for the previous holder may no longer read it.
323
+
324
+ - `orgUrl` names the organization a client acts in by the address of the page
325
+ it is for, sent as `X-Org-Url`, beside `orgId`. An app serving many
326
+ organizations from its own addresses (a path each, a subdomain each, or a
327
+ domain an organization brought) passes the page URL with the publishable key
328
+ it already ships, and the server finds the organization whose registered
329
+ address is the longest prefix of that URL. Also on `FoirRequestOptions` and
330
+ the auth ops' options. A URL no organization's address covers answers
331
+ `NOT_FOUND` as an unknown `orgId` does, and a request naming one takes the
332
+ origin lane.
333
+
334
+ ```ts
335
+ const foir = createFoirClient({ apiUrl, apiKey, generated, orgUrl: window.location.href });
336
+ ```
337
+
338
+ The same answer is available up front as the `resolveOrganizationAddress(url)`
339
+ query.
340
+
341
+ ### Changed
342
+
343
+ - `requestOperation` (the editor SDK) no longer gives up after 60 seconds. A
344
+ blocking operation is now a direct call the platform ends at the operation's
345
+ own timeout, and the host answers every request with that outcome, so the
346
+ editor's own clock could only report as failed an operation that went on to
347
+ complete. The promise settles when the host answers.
348
+
349
+ - **Breaking (types).** `OperationPayload` matches the platform's
350
+ `buildOperationPayload` field for field. Code that read a field the platform
351
+ never sent now fails to compile, which is the point:
352
+
353
+ - `credentials` and `config` are removed. The platform stopped sending them
354
+ when scoped-token dispatch replaced credentials in the body; an app reads
355
+ its secrets through the dispatch token.
356
+ - `executionId` is required. It is always sent.
357
+ - `content`, `record`, `triggerContext` and `callback` are optional and are
358
+ absent, never `null`, when not sent. `content` is an object.
359
+ - `OperationRecordContext` is `Record<string, unknown>`. The platform forwards
360
+ the block as the caller wrote it and shapes none of it; the previous `data`
361
+ and `metadata` members were never guaranteed.
362
+ - `OperationTrigger` is `{ type: 'ui' | 'api' | 'lifecycle' | 'schedule' }`,
363
+ the platform's own four values. `fieldKey`, `fieldType` and the `field`
364
+ trigger were never sent; caller context arrives in `triggerContext`.
365
+ - `OperationContext.userId` is a string that is always present (empty for a
366
+ hook's run), and `locale` is removed.
367
+ - `OperationPayload` takes a type parameter for `input`.
368
+ - `callback` is documented as ASYNC only. A blocking (SYNC) dispatch carries
369
+ no callback: the endpoint's response body is the result. `callback` gains
370
+ `causationChain`.
371
+
372
+ - **Breaking (types).** `FoirClientConfig` is `FoirKeyClientConfig |
373
+ FoirDispatchClientConfig`. A config literal with an `apiKey` is unchanged.
374
+ Code that `extends FoirClientConfig` in an interface should extend
375
+ `FoirKeyClientConfig`, and code that reads `config.apiKey` off a
376
+ `FoirClientConfig` sees `string | undefined`.
377
+
378
+ - A client with `dispatch` refuses a credential header in `headers`
379
+ (`x-api-key`, `Authorization`, `X-Foir-Token`, `X-Foir-Context`), whatever its
380
+ case. A key client's `headers` are unchanged.
381
+
382
+ - `fetchFoirManifest` resolves the credential before the request rather than
383
+ inside it, so a `bearerToken` provider that throws surfaces its own error
384
+ instead of `[foir] network error`, as it already did on `/graphql`.
385
+
386
+ - **`create`, `update` and `rename` resolve to `FoirWriteResult`, not the
387
+ record.** The data API's `create<Model>`, `update<Model>` and
388
+ `rename<Model>` now return `<Model>WritePayload`, the record beside
389
+ `replayed`, and a typed call returns that payload as it came. `replayed` is
390
+ true when the call's `idempotencyKey` was already used by an earlier attempt
391
+ of the same write: nothing was written this time, and `record` is as the
392
+ first attempt left it. Read the record from `.record`:
393
+
394
+ ```ts
395
+ const { record, replayed } = await foir.order.update({ where, data, idempotencyKey });
396
+ if (replayed) {
397
+ // The first attempt's answer. Nothing was applied by this call.
398
+ }
399
+ ```
400
+
401
+ A generated schema types these ops as `{ kind: 'write' }`; re-run
402
+ `foir types`.
403
+
404
+ - `FoirUpdateManyPayload` carries `replayed`, with the same meaning.
405
+
406
+ - **An option field's generated union is the declared values.** The data API
407
+ serves an option value exactly as the config declares it (`orderSlip`, not
408
+ `ORDERSLIP`), so the union `foir types` prints from the manifest is the
409
+ config's own strings. Re-run `foir types`; a comparison against the
410
+ upper-cased names no longer type-checks, and never matched a stored value.
411
+
412
+ - **Schema drift compares the surface version, not the schema counter.**
413
+
414
+ `GET /v1/manifest` now serves `surfaceVersion`: a hash over exactly the
415
+ sections `foir types` reads, so it moves if and only if regenerating the types
416
+ would write a different file. Beside it, `surfaceHashes` carries the
417
+ components that version is folded from, one per model, namespace, union
418
+ expansion and the subscriptions. `schemaVersion` stays on the manifest and
419
+ keeps meaning what it always did server-side: the invalidation counter that
420
+ keys the ETag and the response cache.
421
+
422
+ The client used to pin generated types to that counter. Every write that
423
+ could change a response bumps it, so pushing an operation, a role or a key
424
+ reported drift, and under the strict default the next typed call threw,
425
+ though nothing the client prints had moved. A false alarm and a real one had
426
+ the same remedy, so nobody learned which they had. Now a drift is reported
427
+ only when the surface moved, and it names what moved: "Changed since: model
428
+ `page`." Every drift is real, so the message says `foir types` and no longer
429
+ suggests `strictSchemaDrift: false`, which is never the remedy.
430
+
431
+ What changes for a caller:
432
+
433
+ - The generated module exports `foirSurfaceVersion` and `foirSurfaceHashes`
434
+ in place of `foirSchemaVersion`, and `FoirGeneratedModule` requires them. A
435
+ module generated before this is refused at construction by name. **Re-run
436
+ `foir types`** with a current `@foir/cli` as part of the upgrade.
437
+ - `createFoirClient`'s `schemaVersion` option is now `surfaceVersion`, with
438
+ `surfaceHashes` beside it. Only a client wired field by field sets them;
439
+ one passing `generated` gets both from the module.
440
+ - `FoirSchemaDefinition.schemaVersion` is now `surfaceVersion: string`.
441
+ - `FoirSchemaDrift.generated` and `.server` are the two surface versions, as
442
+ strings, and `.changed` lists the components that moved (`models.page`).
443
+ An `onSchemaDrift` handler logging the numbers logs the hashes instead.
444
+ - `foirDriftIsActionable` (`@foir/sdk/live`) treats any difference as
445
+ actionable: a hash has no ahead or behind, and a rollback is another
446
+ surface like any other.
447
+ - `FoirManifest` gains `surfaceVersion` and `surfaceHashes`.
448
+
107
449
  ---
108
450
 
451
+ ### Fixed
452
+
453
+ - A response in which no root field answered now throws `FoirRequestError`
454
+ with the server's errors. It used to count as partial data. Every
455
+ `execute<Op>` payload is nullable, so a failed operation run came back as
456
+ `{ r: null }` beside its error, and the typed call resolved to `null` with the
457
+ refusal only logged. The partial-errors default is unchanged wherever at least
458
+ one root field answered, and a null root with no error is still an answer.
459
+
109
460
  ## [0.10.0] — 2026-09-22
110
461
 
111
462
  Two breaking changes under the pre-1.0 note, so a MINOR: `fetchHostedSession`
package/README.md CHANGED
@@ -79,9 +79,17 @@ and an artifact added to the generator later costs your call site nothing. A
79
79
  module missing one of those exports is a compile error naming it, not a quiet
80
80
  degrade: pass it whole, or do not pass it.
81
81
 
82
- The four fields it replaces (`schemaVersion`, `selections`,
82
+ The fields it replaces (`surfaceVersion`, `surfaceHashes`, `selections`,
83
83
  `namespaceSelections`, `selectionUnions`) still work and still win where both
84
- are given, so an existing client keeps compiling and behaving exactly as it did.
84
+ are given.
85
+
86
+ **Drift is keyed on the surface.** The module records the `surfaceVersion` it
87
+ was generated from: a hash over exactly what `foir types` reads, which moves only
88
+ when regenerating would write a different file. Pushing an operation, a role or
89
+ a key moves the server's invalidation counter and not the surface, so it is not
90
+ drift. Publishing a field on a model the types include is, and the client
91
+ refuses the next typed call with a message naming the model. The remedy is
92
+ `foir types`; `strictSchemaDrift: false` never is.
85
93
 
86
94
  **Scalars are always complete**, so `post.title` is typed present and *is*
87
95
  present — there is no partial-`select` footgun. `include` adds a relation key,