@foir/sdk 0.9.0 → 0.11.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 (63) hide show
  1. package/CHANGELOG.md +719 -27
  2. package/README.md +121 -2
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +71 -4
  5. package/dist/auth/index.d.ts +3 -3
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +18 -0
  8. package/dist/auth.js +209 -47
  9. package/dist/client.js +101 -13
  10. package/dist/codegen/block-registry.d.ts +1 -1
  11. package/dist/codegen/block-scaffold.d.ts +1 -1
  12. package/dist/codegen/index.d.ts +1 -1
  13. package/dist/codegen/item-registry.d.ts +1 -1
  14. package/dist/codegen/item-scaffold.d.ts +1 -1
  15. package/dist/codegen/preset.d.ts +3 -2
  16. package/dist/content/content-renderer.d.ts +4 -5
  17. package/dist/content/index.d.ts +1 -1
  18. package/dist/content/richtext.d.ts +1 -1
  19. package/dist/data/builtins.d.ts +81 -13
  20. package/dist/data/client.d.ts +92 -9
  21. package/dist/data/decimal.d.ts +212 -0
  22. package/dist/data/document.d.ts +14 -1
  23. package/dist/data/index.d.ts +22 -10
  24. package/dist/data/manifest.d.ts +40 -4
  25. package/dist/data/schema.d.ts +18 -11
  26. package/dist/data/selection.d.ts +15 -0
  27. package/dist/data/subscriptions.d.ts +36 -10
  28. package/dist/data/transport.d.ts +363 -24
  29. package/dist/data/ws-transport.d.ts +8 -2
  30. package/dist/data.js +591 -64
  31. package/dist/graphql/cdn-route.d.ts +1 -1
  32. package/dist/graphql/foir-request.d.ts +1 -1
  33. package/dist/index.js +0 -9
  34. package/dist/live/coalesce.d.ts +57 -0
  35. package/dist/live/drift.d.ts +58 -0
  36. package/dist/live/events.d.ts +13 -0
  37. package/dist/live/headers.d.ts +53 -0
  38. package/dist/live/index.d.ts +54 -0
  39. package/dist/live/keys.d.ts +72 -0
  40. package/dist/live/manager.d.ts +77 -0
  41. package/dist/live/react/index.d.ts +143 -0
  42. package/dist/live/reconcile.d.ts +82 -0
  43. package/dist/live/resync.d.ts +59 -0
  44. package/dist/live/store.d.ts +134 -0
  45. package/dist/live/tagIndex.d.ts +29 -0
  46. package/dist/live/tanstack.d.ts +127 -0
  47. package/dist/live/writetoken.d.ts +86 -0
  48. package/dist/live-react.js +395 -0
  49. package/dist/live.js +845 -0
  50. package/dist/next/cacheTags.d.ts +245 -0
  51. package/dist/next/index.d.ts +7 -0
  52. package/dist/next.js +260 -34
  53. package/dist/push.js +163 -36
  54. package/dist/react.js +157 -33
  55. package/dist/realtime/index.d.ts +4 -5
  56. package/dist/server/events-client.d.ts +54 -4
  57. package/dist/server/index.d.ts +4 -2
  58. package/dist/server/operation-types.d.ts +86 -58
  59. package/dist/server/read-dispatch.d.ts +53 -0
  60. package/dist/server/require-scoped-token.d.ts +20 -1
  61. package/dist/server.js +602 -10
  62. package/dist/transport/foir-http.d.ts +168 -15
  63. package/package.json +10 -2
package/CHANGELOG.md CHANGED
@@ -104,6 +104,699 @@ on `dev` would publish unreviewed work to npm.
104
104
 
105
105
  Changes on `dev` that have not been published to npm.
106
106
 
107
+ ## [0.11.0] — 2026-09-26
108
+
109
+ Breaking changes under the pre-1.0 note, so this release is a MINOR:
110
+ `OperationPayload` now describes the body the platform actually sends,
111
+ `FoirClientConfig` is a union of two credentials, a record write resolves to
112
+ `FoirWriteResult`, an option field's union is its declared values, and the
113
+ generated module, the drift check and `FoirSchemaDrift` are keyed on the
114
+ surface version instead of the schema counter.
115
+
116
+ ### Added
117
+
118
+ - The data client takes an operation's dispatch token as its credential, so an
119
+ operation endpoint calls Foir AS the operation and holds no key.
120
+
121
+ ```ts
122
+ import { createFoirClient } from '@foir/sdk/data';
123
+ import { readDispatch, requireScopedToken } from '@foir/sdk/server';
124
+ // foir types --operation checkout --out src/checkout.generated.ts
125
+ import * as generated from './checkout.generated';
126
+
127
+ export async function POST(request: Request) {
128
+ const { dispatch } = await requireScopedToken(request, {
129
+ expectedProjectId: process.env.FOIR_PROJECT_ID,
130
+ });
131
+ const { input } = await readDispatch<{ email: string }>(request);
132
+ const foir = createFoirClient({ apiUrl: process.env.PLATFORM_API_URL!, dispatch, generated });
133
+ // ...
134
+ }
135
+ ```
136
+
137
+ A dispatch client requires `generated`, and only a module generated for an
138
+ operation: `foir types --operation <key>` types the surface that operation's
139
+ token is served (`GET /v1/manifest?operation=<key>`) and exports
140
+ `foirCredential` naming it. A key client refuses such a module, a dispatch
141
+ client refuses a key's module, and a dispatch whose `X-Foir-Context` names
142
+ another operation than the module is refused, each at the type and at
143
+ construction. New on `@foir/sdk/data`: `FoirOperationCredential`,
144
+ `FoirKeyGeneratedModule` and `FoirOperationGeneratedModule`.
145
+
146
+ A client built with `dispatch` sends the token as `X-Foir-Token`, with the
147
+ dispatch's `X-Foir-Context`, and never sends `x-api-key`. api-public accepts
148
+ the token as a caller in its own right and builds the schema from the
149
+ capabilities the operation declared, so the generated client works unchanged
150
+ over it: queries, mutations and `execute<Op>`. `dispatch.token` is a provider
151
+ read before every request, not a string captured once. There is no key
152
+ fallback: a provider that yields nothing fails the request.
153
+
154
+ New on `@foir/sdk/data`: `FoirDispatchCredential`, `FoirKeyClientConfig` and
155
+ `FoirDispatchClientConfig`. New on `@foir/sdk/server`: `dispatch` on the
156
+ result of `requireScopedToken`, which is the token it verified as a ready
157
+ credential.
158
+
159
+ Two things a dispatch client does not do. It does not subscribe: the
160
+ subscription socket authenticates a key, an admin token or a customer bearer
161
+ and has no path for a dispatch token, so `onChange` and friends throw, naming
162
+ why. And it takes no key-side option (`apiKey`, `bearerToken`, `orgId`,
163
+ `channel`, the socket options), because the token carries its own customer,
164
+ organization and channel; each is a compile error and a runtime refusal.
165
+
166
+ - `readDispatch(request)` and `OperationPayloadError` on `@foir/sdk/server`.
167
+
168
+ An operation endpoint had no typed reader for its own dispatch, and the body
169
+ is the run, not the input: reading it as the input finds nothing and refuses
170
+ every call on a missing field. `readDispatch` parses the body into
171
+ `OperationPayload`, checks the fields that identify a dispatch, and refuses
172
+ anything else with `OperationPayloadError` (`status` 400), naming what is
173
+ wrong. It keeps every key the platform sent, typed or not. `input` is always
174
+ an object: a run started with no input, which the platform sends as `null`,
175
+ reads as `{}`.
176
+
177
+ - `FoirWriteResult<R>` on `@foir/sdk/data`: `{ record, replayed }`, what a
178
+ record write answers with.
179
+
180
+ - `fetchFoirManifest(config, { operation })` asks for the surface an
181
+ operation's dispatch token is served instead of the credential's own, which
182
+ is described to a key that can author the operation (`operations:write`).
183
+ `fetchFoirManifest` takes `FoirRequestConfig`, the credential and transport
184
+ half of a client config, so a manifest is fetched without a generated module;
185
+ `FoirManifestRequestOptions` and `FoirRequestConfig` are new on
186
+ `@foir/sdk/data`.
187
+
188
+ - `FoirRecordOwner` on `@foir/sdk/data`: `{ id, ownerId, ownerKind }`, what
189
+ `setOwner` answers with. An operation that declares `records:attribute:<model>`
190
+ hands a record to another customer with `foir.<model>.setOwner({ id, ownerId })`,
191
+ and names the owner of a record it creates with `create({ input, ownerId })`.
192
+ The answer carries the owner and not the record, because an operation acting
193
+ for the previous holder may no longer read it.
194
+
195
+ - `orgUrl` names the organization a client acts in by the address of the page
196
+ it is for, sent as `X-Org-Url`, beside `orgId`. An app serving many
197
+ organizations from its own addresses (a path each, a subdomain each, or a
198
+ domain an organization brought) passes the page URL with the publishable key
199
+ it already ships, and the server finds the organization whose registered
200
+ address is the longest prefix of that URL. Also on `FoirRequestOptions` and
201
+ the auth ops' options. A URL no organization's address covers answers
202
+ `NOT_FOUND` as an unknown `orgId` does, and a request naming one takes the
203
+ origin lane.
204
+
205
+ ```ts
206
+ const foir = createFoirClient({ apiUrl, apiKey, generated, orgUrl: window.location.href });
207
+ ```
208
+
209
+ The same answer is available up front as the `resolveOrganizationAddress(url)`
210
+ query.
211
+
212
+ ### Changed
213
+
214
+ - `requestOperation` (the editor SDK) no longer gives up after 60 seconds. A
215
+ blocking operation is now a direct call the platform ends at the operation's
216
+ own timeout, and the host answers every request with that outcome, so the
217
+ editor's own clock could only report as failed an operation that went on to
218
+ complete. The promise settles when the host answers.
219
+
220
+ - **Breaking (types).** `OperationPayload` matches the platform's
221
+ `buildOperationPayload` field for field. Code that read a field the platform
222
+ never sent now fails to compile, which is the point:
223
+
224
+ - `credentials` and `config` are removed. The platform stopped sending them
225
+ when scoped-token dispatch replaced credentials in the body; an app reads
226
+ its secrets through the dispatch token.
227
+ - `executionId` is required. It is always sent.
228
+ - `content`, `record`, `triggerContext` and `callback` are optional and are
229
+ absent, never `null`, when not sent. `content` is an object.
230
+ - `OperationRecordContext` is `Record<string, unknown>`. The platform forwards
231
+ the block as the caller wrote it and shapes none of it; the previous `data`
232
+ and `metadata` members were never guaranteed.
233
+ - `OperationTrigger` is `{ type: 'ui' | 'api' | 'lifecycle' | 'schedule' }`,
234
+ the platform's own four values. `fieldKey`, `fieldType` and the `field`
235
+ trigger were never sent; caller context arrives in `triggerContext`.
236
+ - `OperationContext.userId` is a string that is always present (empty for a
237
+ hook's run), and `locale` is removed.
238
+ - `OperationPayload` takes a type parameter for `input`.
239
+ - `callback` is documented as ASYNC only. A blocking (SYNC) dispatch carries
240
+ no callback: the endpoint's response body is the result. `callback` gains
241
+ `causationChain`.
242
+
243
+ - **Breaking (types).** `FoirClientConfig` is `FoirKeyClientConfig |
244
+ FoirDispatchClientConfig`. A config literal with an `apiKey` is unchanged.
245
+ Code that `extends FoirClientConfig` in an interface should extend
246
+ `FoirKeyClientConfig`, and code that reads `config.apiKey` off a
247
+ `FoirClientConfig` sees `string | undefined`.
248
+
249
+ - A client with `dispatch` refuses a credential header in `headers`
250
+ (`x-api-key`, `Authorization`, `X-Foir-Token`, `X-Foir-Context`), whatever its
251
+ case. A key client's `headers` are unchanged.
252
+
253
+ - `fetchFoirManifest` resolves the credential before the request rather than
254
+ inside it, so a `bearerToken` provider that throws surfaces its own error
255
+ instead of `[foir] network error`, as it already did on `/graphql`.
256
+
257
+ - **`create`, `update` and `rename` resolve to `FoirWriteResult`, not the
258
+ record.** The data API's `create<Model>`, `update<Model>` and
259
+ `rename<Model>` now return `<Model>WritePayload`, the record beside
260
+ `replayed`, and a typed call returns that payload as it came. `replayed` is
261
+ true when the call's `idempotencyKey` was already used by an earlier attempt
262
+ of the same write: nothing was written this time, and `record` is as the
263
+ first attempt left it. Read the record from `.record`:
264
+
265
+ ```ts
266
+ const { record, replayed } = await foir.order.update({ where, data, idempotencyKey });
267
+ if (replayed) {
268
+ // The first attempt's answer. Nothing was applied by this call.
269
+ }
270
+ ```
271
+
272
+ A generated schema types these ops as `{ kind: 'write' }`; re-run
273
+ `foir types`.
274
+
275
+ - `FoirUpdateManyPayload` carries `replayed`, with the same meaning.
276
+
277
+ - **An option field's generated union is the declared values.** The data API
278
+ serves an option value exactly as the config declares it (`orderSlip`, not
279
+ `ORDERSLIP`), so the union `foir types` prints from the manifest is the
280
+ config's own strings. Re-run `foir types`; a comparison against the
281
+ upper-cased names no longer type-checks, and never matched a stored value.
282
+
283
+ - **Schema drift compares the surface version, not the schema counter.**
284
+
285
+ `GET /v1/manifest` now serves `surfaceVersion`: a hash over exactly the
286
+ sections `foir types` reads, so it moves if and only if regenerating the types
287
+ would write a different file. Beside it, `surfaceHashes` carries the
288
+ components that version is folded from, one per model, namespace, union
289
+ expansion and the subscriptions. `schemaVersion` stays on the manifest and
290
+ keeps meaning what it always did server-side: the invalidation counter that
291
+ keys the ETag and the response cache.
292
+
293
+ The client used to pin generated types to that counter. Every write that
294
+ could change a response bumps it, so pushing an operation, a role or a key
295
+ reported drift, and under the strict default the next typed call threw,
296
+ though nothing the client prints had moved. A false alarm and a real one had
297
+ the same remedy, so nobody learned which they had. Now a drift is reported
298
+ only when the surface moved, and it names what moved: "Changed since: model
299
+ `page`." Every drift is real, so the message says `foir types` and no longer
300
+ suggests `strictSchemaDrift: false`, which is never the remedy.
301
+
302
+ What changes for a caller:
303
+
304
+ - The generated module exports `foirSurfaceVersion` and `foirSurfaceHashes`
305
+ in place of `foirSchemaVersion`, and `FoirGeneratedModule` requires them. A
306
+ module generated before this is refused at construction by name. **Re-run
307
+ `foir types`** with a current `@foir/cli` as part of the upgrade.
308
+ - `createFoirClient`'s `schemaVersion` option is now `surfaceVersion`, with
309
+ `surfaceHashes` beside it. Only a client wired field by field sets them;
310
+ one passing `generated` gets both from the module.
311
+ - `FoirSchemaDefinition.schemaVersion` is now `surfaceVersion: string`.
312
+ - `FoirSchemaDrift.generated` and `.server` are the two surface versions, as
313
+ strings, and `.changed` lists the components that moved (`models.page`).
314
+ An `onSchemaDrift` handler logging the numbers logs the hashes instead.
315
+ - `foirDriftIsActionable` (`@foir/sdk/live`) treats any difference as
316
+ actionable: a hash has no ahead or behind, and a rollback is another
317
+ surface like any other.
318
+ - `FoirManifest` gains `surfaceVersion` and `surfaceHashes`.
319
+
320
+ ---
321
+
322
+ ### Fixed
323
+
324
+ - A response in which no root field answered now throws `FoirRequestError`
325
+ with the server's errors. It used to count as partial data. Every
326
+ `execute<Op>` payload is nullable, so a failed operation run came back as
327
+ `{ r: null }` beside its error, and the typed call resolved to `null` with the
328
+ refusal only logged. The partial-errors default is unchanged wherever at least
329
+ one root field answered, and a null root with no error is still an answer.
330
+
331
+ ## [0.10.0] — 2026-09-22
332
+
333
+ Two breaking changes under the pre-1.0 note, so a MINOR: `fetchHostedSession`
334
+ returns a result object rather than `HostedSession | null`, and
335
+ `operations.onExecution` delivers `status` as the enum name.
336
+
337
+ ### Added
338
+
339
+
340
+ - `composedFor` on `FoirManifest`: the organisation whose composition the
341
+ manifest describes, absent for the project's own surface.
342
+
343
+ A key confined to an organisation is served that organisation's surface by
344
+ `GET /v1/manifest` without naming it, so nothing in the payload, or in a
345
+ module generated from it, said whose definitions it carried. `foir types`
346
+ records it in the generated header and `foir explain` prints it. Optional and
347
+ additive: a project manifest is unchanged, and no export was added.
348
+
349
+ - A subscribing client no longer fetches `GET /v1/manifest`.
350
+
351
+ `foir types` now emits `foirSubscriptions` alongside the model trees, and
352
+ `createFoirClient` prints subscribe documents from it. Previously `generated`
353
+ carried model trees only, so passing the module was strictly *worse* for a
354
+ subscribe-only client: the bundle cost, and the round trip anyway.
355
+
356
+ `foirSubscriptions` is optional on the generated module and is deliberately
357
+ not part of the strict export check — a module generated before it existed
358
+ keeps working and keeps fetching, which is what it did before. Re-run
359
+ `foir types` to pick it up.
360
+
361
+ - `FoirSchemaDriftError` on `@foir/sdk/data`, and `strictSchemaDrift` on
362
+ `createFoirClient` to turn it off.
363
+
364
+ **Behaviour change.** Typed calls now THROW once schema drift is detected,
365
+ where they previously warned and served the stale shape. An app that upgrades
366
+ while holding a stale `foir.generated.ts` will start throwing on the second
367
+ and subsequent calls — re-run `foir types` as part of the upgrade. Pass
368
+ `strictSchemaDrift: false` to keep the old warn-and-serve behaviour.
369
+
370
+ The client already detects that its generated selection trees are older than
371
+ the schema the server is serving, and warns. The warning is easy to scroll
372
+ past, and what it is warning about is not a typing problem: the trees are what
373
+ the client PRINTS ITS QUERIES FROM, so a field added since generation is not
374
+ merely untyped, it is not requested. It arrives as absent data and reads as a
375
+ bug in the app.
376
+
377
+ That is the lax half of an asymmetry. An unknown ARGUMENT is refused
378
+ client-side before the server sees it; a missing field is silent. Set
379
+ `strictSchemaDrift: true` and typed calls throw `FoirSchemaDriftError` once
380
+ drift is detected, naming `foir types` as the fix.
381
+
382
+ Two deliberate exemptions. It never fails the FIRST call — drift is only known
383
+ once the manifest comes back, which happens off the response path after a call
384
+ has been served, and blocking every first call on a second round trip is the
385
+ cost the warning exists to avoid. And it never touches `raw()`, because a
386
+ hand-written document asks for exactly the fields it names and cannot silently
387
+ omit one.
388
+
389
+ On by default, because the failure it prevents is silent: a stale selection
390
+ omits a field rather than complaining, so an opt-in guard would only protect
391
+ the people who already suspected it. Opting out is written at the call site
392
+ where a reader can see it, rather than inferred from an environment variable.
393
+
394
+ - `graphql()` and `TypedFoirDocument` on `@foir/sdk/data`: a GraphQL document
395
+ that carries its own result and variable types.
396
+
397
+ ```ts
398
+ const RECENT = graphql<{ pages: { totalCount: number } }, { n: number }>(
399
+ 'query Recent($n: Int) { pages(first: $n) { totalCount } }',
400
+ );
401
+ const { pages } = await foir.raw(RECENT, { n: 5 }); // pages is typed
402
+ ```
403
+
404
+ `raw` returns `unknown`, so every call site hand-writes a result type that
405
+ nothing checks, and the sites that most need type safety are exactly the ones
406
+ the typed client could not express. Two real bugs came from a result type that
407
+ had drifted from the selection it claimed to describe. A branded document does
408
+ not make a bespoke selection type-checked against the schema, which is the
409
+ larger half and wants generated documents rather than a helper. What it removes
410
+ is the retyping: the assertion moves from every call site to one place beside
411
+ the document, and a wrong one is wrong where a reader is looking at the query.
412
+
413
+ Nothing changes for a caller passing a plain string. `raw('...')` still returns
414
+ `unknown` and `raw<T>('...')` still returns `T`. `graphql()` is a cast and
415
+ returns the string it was given, so a branded document can be logged, hashed or
416
+ sent anywhere a string goes.
417
+
418
+ - `FoirRawOptions` on `@foir/sdk/data`, and a third argument to `foir.raw()`:
419
+ `raw(document, variables, { strict: true })`.
420
+
421
+ A GraphQL response can carry `errors` and `data` together, and the SDK warns
422
+ and returns the data. That stays the default, and it is the right one for a
423
+ served page: a field the server could not resolve should not blank the whole
424
+ render. On a hand-written document it is also how a wrong selection stays
425
+ invisible — there is no generated selection to have caught a bad field name
426
+ first, so selecting something that does not exist returns everything else and
427
+ logs, and the hand-written result type asserts the shape was fine.
428
+
429
+ `strict: true` makes that a thrown `FoirRequestError` instead. The error
430
+ carries the data it refused on `partialData`, so opting in does not trade a
431
+ silent wrong answer for a thrown one that discards the answer. Nothing changes
432
+ for a caller that does not pass it.
433
+
434
+ - `registerOrganization(env, { key, name }, { bearerToken })` on `@foir/sdk/auth`: a customer founds an organization of their own and becomes its first member, holding the role the project's `operatorSignupRole` setting names. Returns the same `MyOrganization` entry `myOrganizations` lists. Present on the wire only while the project's `operatorSignupEnabled` setting is on.
435
+ - **The operator's organization context, on `@foir/sdk/auth`** — `myOrganizations`
436
+ and `selectOrganization`, plus the types `MyOrganization` and
437
+ `SelectedOrganization`, and two entries on `AUTH_OPS`.
438
+
439
+ A Fóir project can be partitioned into organizations, one per tenant of a SaaS
440
+ built on Fóir. An *operator* is a customer who runs one or more of them.
441
+ `myOrganizations()` answers which ones this customer may stand in — from their
442
+ own membership rows, so it is not the project's inventory and cannot be used as
443
+ one — and `selectOrganization({ orgId })` says which. An empty `orgId` clears
444
+ the selection.
445
+
446
+ **Set once, not per call.** No read or write in this SDK takes an `orgId`
447
+ argument: the claim the row layer filters on is resolved server-side from the
448
+ caller's membership on every request. An application therefore cannot forget
449
+ to send it, cannot send another organization's, and cannot be made to cross
450
+ the boundary by a query it built wrong. These two functions are the entire
451
+ per-customer surface; everything downstream names no organization at all.
452
+ (The project credential's side of the same boundary is the client-level
453
+ `orgId` option below — a property of the client, still never of a call.)
454
+
455
+ Requires a server serving `myOrganizations` and `selectMyOrganization` in its
456
+ `auth` namespace. Against an older api-public both refuse, naming the op.
457
+
458
+ - **Acting inside an organization with the project credential** — `orgId` on
459
+ `FoirClientConfig` (`createFoirClient` / `createFoirClientFromEnv`), on
460
+ `FoirRequestOptions` (`foirRequest`) and on `AuthOpOptions` (the
461
+ `@foir/sdk/auth` ops), plus `'org'` on `FoirLaneReason`.
462
+
463
+ A multi-tenant app holding the PROJECT key has to choose which of its
464
+ organizations a request is for — after resolving `<sub>.theapp.com` to one,
465
+ or from the back-office. `orgId` sends `X-Org-Id` on every request the client
466
+ makes (HTTP, the manifest fetch and the subscription handshake alike). The
467
+ server resolves it against the credential's OWN project — an unknown,
468
+ suspended or foreign organization all read `NOT_FOUND` — and binds the
469
+ request to it; a credential already confined to an organization may only name
470
+ its own (`PERMISSION_DENIED`), so this cannot steer a storefront key. A read
471
+ naming an organization always takes the origin lane: the shared lane keys by
472
+ URL, where the header does not appear.
473
+
474
+ Pairs with the `organization` owner plane on a model: a record created on
475
+ such a model while acting in an organization belongs to it, and the project
476
+ key with no `orgId` still reads every organization's rows — which is how the
477
+ host→organization lookup is answered in the first place.
478
+
479
+ - **Reconcile on reconnect, on `@foir/sdk/live`** — `createFoirReconciler`,
480
+ `foirHeldRecordIds`, `foirTagsForGoneRecords`, and the types
481
+ `FoirReconciler`, `FoirReconcilerOptions` and `FoirReconcileResolveFn`. Plus a
482
+ `reconcile` prop on `<FoirLive>`.
483
+
484
+ Required, not optional, on any model whose records are partitioned per caller.
485
+ The server filters that stream with an inner join against the records the
486
+ caller can currently read, and records delete hard rather than being flagged —
487
+ so by the time the join runs for a deletion there is no row to match, and **no
488
+ event is emitted**. The same silence covers a record whose ownership moved
489
+ away. Resuming the cursor recovers neither: there was never an event to replay.
490
+
491
+ So the client names the ids it holds and asks which are still visible;
492
+ anything absent is gone. That cannot leak, because the caller supplies the ids
493
+ — an id it names is one it already had. The manager calls it on every
494
+ reconnect, after the stream is back rather than before, so nothing deleted
495
+ mid-reconcile falls between the two halves.
496
+
497
+ A resolve that throws ABANDONS the reconcile rather than reporting everything
498
+ as gone, so a network blink cannot evict a working cache.
499
+ - **Write-token primitives on `@foir/sdk/live`** — `readFoirWriteToken`,
500
+ `foirWriteTokenHeaders` and `FOIR_WRITE_TOKEN_HEADER`. Plus a `floor` reason
501
+ and a `writeToken` input on `foirRoute`.
502
+
503
+ Reading your own write without throwing the cache away. A mutation returns an
504
+ opaque token naming where it committed; a later read carries it; the server
505
+ serves a cached entry only if that entry resolved at or above the write.
506
+ `Cache-Control: no-cache` gets the same guarantee by discarding every warm
507
+ entry on the path, whether or not it was already fresh enough.
508
+
509
+ A token-bearing read is routed to the ORIGIN, with `floor` as the stated
510
+ reason. The shared edge has no view of the project clock, so it cannot tell
511
+ whether what it holds is new enough — and served from there the floor reads as
512
+ satisfied, which is worse than never asking for one.
513
+
514
+ Primitives rather than automatic behaviour: how long a token stands for, and
515
+ across which of a user's tabs, are application questions this package cannot
516
+ answer. Guessing gives either a floor that expires before it helps or one that
517
+ pins reads to the origin long after the write stopped mattering.
518
+
519
+ - **Cache-tag bridge on `@foir/sdk/next`** — `createFoirTaggingFetch`,
520
+ `foirCacheTags`, `readFoirCacheTags`, `foirTagsForRecordEvent`,
521
+ `revalidateFoirRecordEvent`, the constants `FOIR_SURROGATE_KEY_HEADER`,
522
+ `FOIR_NEXT_TAG_MAX_ITEMS`, `FOIR_NEXT_TAG_MAX_LENGTH` and `FOIR_UNTAGGED_TAG`,
523
+ and the types `FoirHeaderSource`, `FoirCacheTags`, `FoirCacheTagsOptions`,
524
+ `FoirTaggingReport`, `FoirTaggingFetchOptions` and `FoirRecordChange`.
525
+
526
+ The API already computes which records and collections every cacheable
527
+ response depends on — it has to, to invalidate its own cache — and ships that
528
+ set on `Surrogate-Key`. It already streams the events that make them stale.
529
+ Nothing joined the two. This is the join: tags in on the read, `revalidateTag`
530
+ out on the event, with a dependency graph computed by the process that
531
+ actually resolved the query rather than guessed at by a query-key convention.
532
+
533
+ `cacheTag` and `revalidateTag` are **injected**, never imported. `cacheTag` is
534
+ `unstable_cacheTag` on Next 15, needs `experimental.useCache`, and may be
535
+ renamed or gated differently in 16 — injecting it means the SDK inherits none
536
+ of that, and nothing under `packages/sdk/src` imports `next`.
537
+
538
+ Two behaviours worth knowing before you wire it up. **Over Next's 128-tag
539
+ limit the per-record tags are dropped entirely**, not truncated: Next's own
540
+ answer to overflow is a `console.warn` and a silent drop, which leaves the
541
+ dropped records stale until the entry's TTL with nothing to say which ones
542
+ they were. Over-invalidating on the coarse tags costs a refetch; truncating
543
+ loses invalidation permanently. And a response with **no** `Surrogate-Key` at
544
+ all — a draft read, a preview, a `NO_CACHE` model, a client `no-store`, a
545
+ traced read — reports `kind: 'untagged'` and is filed under `foir:untagged`,
546
+ because an entry cached with zero tags can never be invalidated at all.
547
+
548
+ Reads the injected `fetch` rather than the transport, which is unchanged.
549
+ - **Coalescing, backpressure and schema-drift invalidation on `@foir/sdk/live`**
550
+ — `createFoirCoalescer`, `createFoirDriftHandler`, `foirDriftIsActionable`,
551
+ plus `coalesceWindowMs` / `maxConcurrentRefetches` on `<FoirLive>` and an
552
+ `invalidate` seam on `applyFoirRecordChange`.
553
+
554
+ The invalidation graph is exact, and exact still means N invalidations for N
555
+ writes. A bulk import or a scheduled publish sweep fires a burst on one model,
556
+ and a client that refetched each one would spend the burst doing nothing else
557
+ — on the exact screen a burst is most likely to be watched from. Fifty
558
+ invalidations of one key inside the window are now one refetch, a burst
559
+ spanning many keys runs at most four at a time, and a key invalidated again
560
+ while its refetch is in flight is owed exactly one more, never one per event.
561
+
562
+ Schema drift is a different problem with the same remedy. A deploy that
563
+ renames a field leaves every cached entry perfectly current and quietly wrong:
564
+ no record changed, nothing is out of order, and the bytes describe a schema
565
+ that no longer exists — until the tab is reloaded, which on a long admin
566
+ session can be days. `createFoirDriftHandler` wires the `FoirSchemaDrift` the
567
+ data client already reports to a full clear.
568
+
569
+ - **`@foir/sdk/live/react`** — `<FoirLive>`, `useFoirLive`, `useFoirLiveStatus`.
570
+ One socket, refcounted per model: the first component to say
571
+ `useFoirLive('product')` opens the stream, the last to unmount closes it, and
572
+ N watchers of a model cost one subscription.
573
+
574
+ An **adapter**, not a store. You keep the query library you have and it owns
575
+ your data; what this owns is which entries a change invalidates. The query
576
+ client is structurally typed, so `@tanstack/react-query` is neither imported
577
+ nor pinned — the same reason the Next bridge takes `cacheTag` as a function.
578
+
579
+ Live is **opt-in per model**. Nothing subscribes because the provider is
580
+ mounted: the shared CDN lane is a major asset, and blanket liveness pushes
581
+ read traffic off it onto the origin.
582
+
583
+ Pagination is handled asymmetrically, because that is what the data supports.
584
+ `deleted` removes the row by id with **no fetch**; `created` exposes a
585
+ **count** rather than refetching, since a create cannot be spliced and
586
+ refetching the head page prepends under a reader mid-scroll; `updated`
587
+ refetches only the queries holding that record, matched on `rec:` rather than
588
+ the model. `totalCount` is flagged approximate rather than adjusted.
589
+
590
+ **Mutable sort keys are a documented limitation.** A query ordered by
591
+ `updatedAt` or a draggable `position` can have a record move between pages on
592
+ update. See the README.
593
+
594
+ Also on `@foir/sdk/live`: `createFoirLiveManager`, `applyFoirRecordChange`,
595
+ `foirQueryKeys`, `foirModelQueryKey`, `foirTagsForQueryInvalidation`,
596
+ `FOIR_QUERY_KEY_ROOT` and their types — the framework-free half of the same
597
+ adapter.
598
+
599
+ - **`after` on `foir.<model>.onChange`** (`@foir/sdk/data`), plus the
600
+ `FoirChangeSubscribeOptions` type. Pass the highest `seq` you have applied and
601
+ the stream replays what came after it from the durable log before continuing
602
+ live, so a message lost anywhere upstream is repaired by the next connect
603
+ rather than left as a permanently stale cache entry. Omitting it is the stream
604
+ as it has always been. `onChange` already took an object, so this is additive.
605
+
606
+ - **`@foir/sdk/live`** — a new subpath: the client half of Foir's invalidation
607
+ graph. `createFoirLiveStore`, `createFoirTagIndex`, `foirQueryKey`,
608
+ `foirStableKey`, `foirDecimalKey`, `readFoirLiveHeaders`,
609
+ `foirTagsForRecordChange`, `foirRecordTag`, `foirModelTag`,
610
+ `foirRequiresResync`, `foirResumeCursor`, the header and code constants, and
611
+ the accompanying types.
612
+
613
+ The API already computes which records and collections every cacheable
614
+ response depends on — it has to, to invalidate its own cache — and ships that
615
+ set on a response header, alongside the project's monotonic `seq`. It already
616
+ streams the change events that make those dependencies stale. This is the
617
+ join, on the client: a bidirectional tag index, a store that honours ordering
618
+ and completeness, and the mapping from one record change to the entries it
619
+ invalidates. Apollo, urql and TanStack Query all guess at that graph; here it
620
+ is computed by the process that actually resolved the query.
621
+
622
+ Framework-free and graphql-free, asserted as a property of the import graph
623
+ rather than promised in a comment.
624
+
625
+ **It is not a state-management library, and will not become one.** No local
626
+ mutation log, no offline mode, no conflict resolution, no optimistic
627
+ rollback. The server is authoritative: mutations go over the wire and the
628
+ invalidation comes back. That is a product boundary, not a phasing decision.
629
+
630
+ Three behaviours to know before building on it. The store holds the **raw
631
+ wire payload** and hydration happens at the read boundary — `FoirDecimal` is
632
+ a class with `#`-private fields, so `structuredClone` returns `{}` without
633
+ throwing and a structural deep-equal reports every decimal equal to every
634
+ other; raw JSON has neither problem, because a decimal there is a string.
635
+ Writes are **last-write-wins by `seq`**, so a slow refetch landing second
636
+ cannot walk the cache backwards. And a **hard resync fences requests already
637
+ in flight**: one issued before the resync arrives afterwards looking exactly
638
+ like a fresh answer, so `dedupe` hands the running function a fence to write
639
+ through.
640
+
641
+ Keys normalise, payloads never do. A decimal in query *variables* is
642
+ canonicalised to its minimal scale so `1.0` and `1.00` are one entry — but
643
+ never on the way into the store, where a trailing zero is significant for
644
+ money.
645
+
646
+ - **`FoirDecimal` and `decimal()` on `@foir/sdk/data`** — the exact numeric type
647
+ a `decimal` field arrives as. Also `FoirDecimalInput`, `FoirDecimalRounding`
648
+ and `FoirDecimalDivOptions`.
649
+
650
+ A `decimal` field travels as a string, because JSON has one number type and
651
+ every JavaScript consumer parses it into a double whatever the server meant.
652
+ Supabase is the worked example of getting this half right: Postgres stores
653
+ `numeric` exactly, PostgREST serialises it as a JSON number, and the precision
654
+ is gone inside `JSON.parse` before any application code runs. Exact storage
655
+ behind a lossy wire format buys nothing.
656
+
657
+ The string is therefore the transport and deliberately not what you are
658
+ handed, because `"19.99" + "2.00"` is `"19.992.00"` — silently. `FoirDecimal`
659
+ holds a scaled `bigint`, so arithmetic is exact by construction, and answers
660
+ only the `string` primitive hint: template literals work, while `+`, `<` and
661
+ `Number()` throw and name the method to use instead. TypeScript rejects those
662
+ at compile time already; the throw is for JavaScript callers and for values
663
+ arriving through `any`.
664
+
665
+ Scale is preserved for display (`19.90` stays `19.90`) while `equals` compares
666
+ values (`1.0` equals `1.00`). `JSON.stringify` emits the canonical string, not
667
+ an object — pinned by test, because leaving it implicit is Prisma's most common
668
+ support thread.
669
+
670
+ No dependency: `bigint` is ES2020 and this package targets ES2022. Prisma
671
+ hands out `Decimal.js` instances and prisma/prisma#16397 has run for years
672
+ because callers cannot supply a matching version — a type that crosses a
673
+ package boundary should not carry a third party's identity with it.
674
+
675
+ A `number` is refused by the constructor; `FoirDecimal.fromNumber()` accepts
676
+ one and names the precision loss at the call site.
677
+
678
+
679
+ - **`HostedSession.expiresIn`** — seconds until the bearer lapses, from the
680
+ endpoint's `expires_in`, which the exchange already received and discarded.
681
+
682
+ The hosted flow has no refresh token by design: the cookie is the durable
683
+ credential and the bearer is re-exchanged when it dies. A consumer that cannot
684
+ see the lifetime has to hardcode the platform's (coupling itself to a server
685
+ constant it cannot observe) or decode `exp` off the JWT — or, as happened,
686
+ treat a 15-minute bearer as good forever and have customers appear signed out
687
+ mid-session. `createFoirSessionClient` on the SPA path has read `expires_in`
688
+ and scheduled a proactive refresh all along; this closes the asymmetry.
689
+
690
+ `0` when the endpoint reported none. Treat that as "expires imminently", never
691
+ as "never expires".
692
+
693
+ - **`HostedSessionResult`** exported from `./auth`.
694
+
695
+ - **`FoirAuthDowngrade`** on `./data` and `./server`, plus `extensions` on the
696
+ response envelope — Foir now reports when it DISCARDED a customer token you
697
+ sent, and the SDK stops dropping that report on the floor.
698
+
699
+ A customer JWT is optional: one that fails to verify is not refused, the read
700
+ is served anonymously and returns `200`. So a lapsed token and a genuinely
701
+ anonymous request were indistinguishable — nothing threw, no refresh armed,
702
+ and auth-targeted content quietly resolved to its anonymous variant. The
703
+ failure surfaced as *wrong content*, never as an error.
704
+
705
+ ```json
706
+ { "data": { "…": "…" },
707
+ "extensions": { "auth": {
708
+ "customerToken": "rejected", "reason": "expired", "servedAs": "anonymous"
709
+ } } }
710
+ ```
711
+
712
+ The SDK warns once per episode (not once per request — a lapsed bearer affects
713
+ every one of them), naming the remedy, and re-arms after a clean response so a
714
+ second episode is reported as its own. The data is still returned: a downgrade
715
+ is a report, not an error.
716
+
717
+ - **`events.subscribe(params)` on `./server`** — the push form of the event
718
+ cursor `list()` and `stream()` already page through. The server backfills from
719
+ `after` and continues live on one stream, so there is no gap between catching
720
+ up and keeping up. The cursor advances as events arrive, so a dropped socket
721
+ resumes from the last event delivered rather than replaying the backfill; a
722
+ cursor older than the retention window ends the subscription with
723
+ `FoirEventCursorExpiredError` carrying `resumeFrom`, matching the 410 the HTTP
724
+ lane answers.
725
+
726
+ The GraphQL `events` subscription has been live on the public schema since the
727
+ events spine shipped, with no client able to reach it. It is an `sk_`-only
728
+ operator surface, so it lives on `./server` beside the rest of the events
729
+ client and inherits its `pk_` and `Authorization` refusals unchanged.
730
+
731
+ New exports: `FoirEventsSubscribeParams`, plus `FoirSubscription` and
732
+ `FoirSubscriptionHandlers` re-exported from `./server` so a consumer need not
733
+ import them from the browser-facing `./data`.
734
+
735
+ `wsUrl`, `webSocketImpl` and `reconnect` are accepted on
736
+ `FoirEventsClientConfig` for the socket, mirroring `./data`. Node 18/20 have no
737
+ global `WebSocket`, so a server process on those versions passes the `ws`
738
+ package's constructor.
739
+
740
+ ### Changed
741
+
742
+
743
+ - **`fetchHostedSession` returns a `HostedSessionResult` instead of
744
+ `HostedSession | null`.** BREAKING — every call site has to branch on
745
+ `outcome`.
746
+
747
+ It collapsed every failure to `null`: no cookie, a `401`, a `502`, a rate
748
+ limit, a DNS failure. Consumers have to act on that `null`, and the only
749
+ safe-looking action is to clear the session and redirect to the front door —
750
+ so one blip on `/session/token` signed out a customer whose 30-day cookie was
751
+ perfectly valid, and nothing recorded that it had happened. The route's own
752
+ rate limiter is deliberately fail-open for the opposite reason.
753
+
754
+ ```ts
755
+ // before
756
+ const session = await fetchHostedSession(request, config);
757
+ if (!session) return redirect(hostedLoginUrl(config));
758
+
759
+ // after
760
+ const result = await fetchHostedSession(request, config);
761
+ if (result.outcome === 'signed-out') return redirect(hostedLoginUrl(config));
762
+ if (result.outcome === 'unavailable') return keepExistingSession();
763
+ use(result.session);
764
+ ```
765
+
766
+ `outcome` says what to DO; `reason` says what happened. Only `401` and a
767
+ missing cookie are `signed-out`. A `403` reports as `unavailable` with reason
768
+ `forbidden` — it means the CSRF header did not arrive, which is a
769
+ misconfiguration rather than a blip, but signing the customer out over it
770
+ would still be wrong.
771
+
772
+ - **`foir.ns.operations.onExecution` delivers `status` as the
773
+ `OperationExecutionStatus` enum name.** BREAKING. `FoirOperationExecutionEvent.status`
774
+ is now `'PENDING' | 'DISPATCHED' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED' | 'TIMED_OUT'`
775
+ where it was a lowercase `string`.
776
+
777
+ `operationExecution` and `operationExecutions` return the enum name for a
778
+ run, and the socket carries the platform's lowercase value, so one consumer
779
+ held two spellings of the same run's status. The typed stream now matches the
780
+ query. The socket itself is unchanged: a subscriber on `FoirSocketTransport`
781
+ directly still receives the lowercase value the server sends.
782
+
783
+ ```ts
784
+ // before
785
+ onData: (event) => { if (event.status === 'completed') done(); }
786
+
787
+ // after
788
+ onData: (event) => { if (event.status === 'COMPLETED') done(); }
789
+ ```
790
+
791
+ ### Fixed
792
+
793
+
794
+ - **Subscription errors keep their `extensions`.** The socket transport reduced
795
+ every terminal `error` frame to its message and dropped the structured fields,
796
+ so `graphQLErrors` was always absent and a caller had no `code` to branch on —
797
+ while the HTTP lane carried them. This affected every subscription, not only
798
+ `events`. Both lanes now report the same shape.
799
+
107
800
  ---
108
801
 
109
802
  ## [0.9.0] — 2026-08-20
@@ -129,7 +822,7 @@ Everything additive, so a MINOR either way under the pre-1.0 note.
129
822
  `return redirect(hostedLogoutUrl(config, clientId, returnTo))`. `returnTo`
130
823
  must be absolute and on an origin registered for `clientId`.
131
824
 
132
- Requires the platform's `end-session` endpoint (TTS-1534). `HostedLoginConfig`
825
+ Requires the platform's `end-session` endpoint. `HostedLoginConfig`
133
826
  gains an optional `endSessionPath`, defaulting to `/session/end-session`.
134
827
 
135
828
  - **`Prose`, `Passage`, `ProseSpan` and `RecordAncestor` on `./data`.** The
@@ -166,13 +859,13 @@ note above.
166
859
  described are not in the schema:
167
860
 
168
861
  - `FoirBlockMeta` declared `_id`/`_key`/`_label`/`_type` on every member. That
169
- was the wrapper's envelope, removed at TTS-1391 — an item IS its value. No
862
+ was the wrapper's envelope, removed — an item IS its value. No
170
863
  member carries any of the four, so every one was a field the types promised
171
864
  and the API never returned.
172
- - `MediaBlock` — `media` stopped being a primitive at TTS-1391. It was never
865
+ - `MediaBlock` — `media` stopped being a primitive. It was never
173
866
  in the proto FieldType enum, and it is the one primitive name a project had
174
867
  also used as a model key.
175
- - `TextBlock` — removed at TTS-1406. GraphQL requires a union member to be an
868
+ - `TextBlock` — removed. GraphQL requires a union member to be an
176
869
  object type and a text item's value is a bare scalar, which is the whole
177
870
  reason a wrapper existed. An inline model with a text field is how a text
178
871
  slot is authored.
@@ -195,7 +888,7 @@ note above.
195
888
  interface for every inline model — `ActHero` AND `ActHeroBlock` — where the
196
889
  wrapper was `FoirBlockMeta` plus the model's non-`_`-prefixed fields. On the
197
890
  Marketing fixture that was 20 of 47 interfaces describing a shape the platform
198
- stopped serving at TTS-1391.
891
+ stopped serving.
199
892
 
200
893
  A union branch is now `(<Model> & { __typename: '<Model>' })`: the model
201
894
  itself, intersected with the discriminant the union position guarantees. The
@@ -219,7 +912,7 @@ note above.
219
912
 
220
913
  - **BREAKING (`foir types`) — a manifest carrying a retired primitive is now
221
914
  refused by name.** `text`, `media` and a `flexible` fallback member stopped
222
- being union members at TTS-1391/1406. A manifest listing one predates that, so
915
+ being union members. A manifest listing one predates that, so
223
916
  the generator says so and names re-publishing as the fix rather than emitting
224
917
  a branch for a type the server never sends.
225
918
 
@@ -244,7 +937,7 @@ note above.
244
937
  project, so an exhaustive `switch` over a flexible field fell through every
245
938
  case.
246
939
  - **`Image.url` and `Video.url` are nullable.** They were `string`. The
247
- platform relaxed them to `String` at TTS-1391 — non-null on the value object
940
+ platform relaxed them to `String` — non-null on the value object
248
941
  and nullable on the wrapper is a §5.3.2 response-shape conflict once the two
249
942
  are one type — so `img.url.startsWith(…)` could throw on a value the types
250
943
  said was present.
@@ -252,7 +945,7 @@ note above.
252
945
  `_targetId`, `_targetNaturalKey` and `record`.** `_id` was the ITEM's id and
253
946
  `_type` read `"reference"` on every reference ever stored; neither is in the
254
947
  schema. The target coordinates are what actually make a reference
255
- followable (TTS-1349/1371), and both identifier shapes are live — on the dev
948
+ followable, and both identifier shapes are live — on the dev
256
949
  database 101 of 193 stored references carry an id and 92 carry a natural key
257
950
  and no id.
258
951
 
@@ -269,7 +962,7 @@ note above.
269
962
  union, not `Block`.** `DEFAULT_UNIONS` in `@foir/sdk/codegen` was `['Block']`;
270
963
  it is `['Flexible']`.
271
964
 
272
- The platform stopped emitting a union named `Block` at TTS-1391 — a flexible
965
+ The platform stopped emitting a union named `Block` — a flexible
273
966
  field's members are the value types and inline models under their own names,
274
967
  and the project-wide union an unconstrained field resolves to is `Flexible`.
275
968
  So `foirCodegen()` on defaults could not have worked against any current
@@ -280,7 +973,7 @@ note above.
280
973
 
281
974
  It stayed invisible because the plugin tests built their own fixture schema
282
975
  declaring `union Block = RichtextBlock | ImageBlock | TextBlock` — the
283
- pre-TTS-1391 shape. The fixture is now the post-1391 shape, and
976
+ pre-rename shape. The fixture is now the post-rename shape, and
284
977
  `DEFAULT_UNIONS` is asserted against it directly.
285
978
 
286
979
  **Migration:** none if you pass `blockUnions`/`itemUnions` explicitly. If you
@@ -321,7 +1014,7 @@ note above.
321
1014
  `block` was never a platform concept — it was one project's NAME for a
322
1015
  flexible field, and a modeller may equally call one `items`, `sections` or
323
1016
  `modules`. The platform never infers anything from a field's name and the SDK
324
- should not teach otherwise (TTS-1391).
1017
+ should not teach otherwise.
325
1018
 
326
1019
  Nothing is removed in this release; every old name still resolves and still
327
1020
  behaves identically. Removal is a later, separately announced break.
@@ -860,7 +1553,7 @@ note above.
860
1553
  `FoirManifestSelection`, `FoirManifestSelectionField`,
861
1554
  `FoirManifestSelectionUnion`, `FoirManifestSelectionMember`.
862
1555
 
863
- - **The event-log client on `@foir/sdk/server`** (TTS-983) — `createEventsClient`,
1556
+ - **The event-log client on `@foir/sdk/server`** — `createEventsClient`,
864
1557
  cursor-resumable consumption of a project's durable event history over
865
1558
  `GET /v1/events`. `list()` returns one page; `stream()` pages forward to the
866
1559
  current end of the log and yields events, each carrying the `seq` that is the
@@ -873,7 +1566,7 @@ note above.
873
1566
  `FoirEventsClientConfig`, `FoirEventsListParams`, `FoirEventsStreamParams`.
874
1567
 
875
1568
  **On `@foir/sdk/server` and no other subpath, deliberately.** The event log is
876
- an OPERATOR surface (DECIDED 2026-08-04, TTS-981): reachable by an `sk_` key or
1569
+ an OPERATOR surface (DECIDED 2026-08-04): reachable by an `sk_` key or
877
1570
  an admin session, never by a customer token, and `events:list.project` is not
878
1571
  mintable on a `pk_` key at all. `events.view` is plain tenant+project
879
1572
  containment while `record.view` carries owner/grantee/borrow terms on top, so a
@@ -1020,7 +1713,7 @@ actually happened.
1020
1713
  register / OTP / provider flows too; use this one for a backend-less SPA whose
1021
1714
  only credential is the Fóir-hosted session cookie.
1022
1715
 
1023
- - **New subpath `@foir/sdk/data`** — the typed data client (TTS-874).
1716
+ - **New subpath `@foir/sdk/data`** — the typed data client.
1024
1717
  `createFoirClient<FoirSchema>({ apiUrl, apiKey })` gives `foir.<model>.<op>(…)`,
1025
1718
  `foir.ns.<namespace>.<op>(…)` and an untyped `foir.raw(op)`. The client's surface is shaped by the credential: an op that
1026
1719
  is not in that key's manifest is not on the type, so `foir.page.create(…)`
@@ -1107,7 +1800,7 @@ on its own, which is the intended behaviour for a release that adds exports.
1107
1800
  - `@foir/sdk/auth`: `createFoirSessionClient`, plus the `FoirSessionClient`,
1108
1801
  `FoirSessionConfig` and `FoirSessionUser` types — Foir-hosted first-party
1109
1802
  sessions for backend-less SPAs, over the `fetchHostedSession` /
1110
- `hostedLoginUrl` hosted-login bridge that already shipped in 0.1.0 (EID-516).
1803
+ `hostedLoginUrl` hosted-login bridge that already shipped in 0.1.0.
1111
1804
 
1112
1805
  ## [0.3.0] — 2026-07-12
1113
1806
 
@@ -1115,10 +1808,10 @@ on its own, which is the intended behaviour for a release that adds exports.
1115
1808
 
1116
1809
  - **New subpath `@foir/sdk/react`** — drop-in React customer auth: `FoirProvider`,
1117
1810
  `SignIn`, `OAuthButtons`, `UserButton`, `useFoirAuth`, `useFoirSession`,
1118
- `useFoirUser` (EID-515).
1811
+ `useFoirUser`.
1119
1812
  - **New subpath `@foir/sdk/next`** — Next.js App Router BFF auth adapter: cookie
1120
1813
  session store, `createFoirMiddleware`, server-side sign-in/out actions and the
1121
- provider callback handler (EID-515).
1814
+ provider callback handler.
1122
1815
 
1123
1816
  ## [0.2.0] — 2026-07-10
1124
1817
 
@@ -1127,27 +1820,25 @@ on its own, which is the intended behaviour for a release that adds exports.
1127
1820
  - **New subpath `@foir/sdk/push`** — native Web Push (VAPID) enrolment:
1128
1821
  `enableWebPush`, `disableWebPush`, `isWebPushSupported`.
1129
1822
  - `@foir/sdk/server`: `requireScopedToken` — a one-line ingress guard for
1130
- operation BFF routes, over the existing `verifyScopedToken` (EID-498).
1823
+ operation BFF routes, over the existing `verifyScopedToken`.
1131
1824
 
1132
1825
  ## [0.1.0] — 2026-06-23
1133
1826
 
1134
1827
  First public release. Ten subpaths:
1135
1828
 
1136
1829
  - `@foir/sdk` — editor iframe client: `EditorProvider`, `useEditor`, the
1137
- host↔editor message protocol, and `createPlatformClient` (EID-88, EID-91).
1830
+ host↔editor message protocol, and `createPlatformClient`.
1138
1831
  - `@foir/sdk/server` — server-side platform helpers for config API backends:
1139
1832
  `createServerClient`, scoped-token verification, `verifyWebhookSignature`.
1140
1833
  - `@foir/sdk/client` — React-free GraphQL transport for SSR/edge (`foirRequest`).
1141
- - `@foir/sdk/media` — progressive media components and adapters (EID-94).
1834
+ - `@foir/sdk/media` — progressive media components and adapters.
1142
1835
  - `@foir/sdk/content` — `createContentRenderer` and `RichTextRenderer` with
1143
- injected typography tokens (EID-93).
1144
- - `@foir/sdk/auth` — customer auth flows, token lifecycle, hosted-login bridge
1145
- (EID-95).
1146
- - `@foir/sdk/realtime` — `createCollabSession`, a thin y-websocket helper
1147
- (EID-103, EID-104).
1836
+ injected typography tokens.
1837
+ - `@foir/sdk/auth` — customer auth flows, token lifecycle, hosted-login bridge.
1838
+ - `@foir/sdk/realtime` — `createCollabSession`, a thin y-websocket helper.
1148
1839
  - `@foir/sdk/codegen`, `@foir/sdk/codegen/block-registry`,
1149
1840
  `@foir/sdk/codegen/block-scaffold` — the `foirCodegen` preset and the
1150
- graphql-codegen plugins (EID-92).
1841
+ graphql-codegen plugins.
1151
1842
 
1152
1843
  ---
1153
1844
 
@@ -1170,7 +1861,8 @@ and the commit history.
1170
1861
  and are exhaustive for the public surface, which is what the export-surface
1171
1862
  guard exists to keep true.
1172
1863
 
1173
- [Unreleased]: https://github.com/foir-io/clients/compare/sdk-v0.5.0...dev
1864
+ [Unreleased]: https://github.com/foir-io/clients/compare/sdk-v0.10.0...dev
1865
+ [0.10.0]: https://github.com/foir-io/clients/compare/sdk-v0.9.0...sdk-v0.10.0
1174
1866
  [0.5.0]: https://github.com/foir-io/clients/compare/sdk-v0.4.0...sdk-v0.5.0
1175
1867
  [0.4.0]: https://github.com/foir-io/clients/compare/sdk-v0.3.0...sdk-v0.4.0
1176
1868
  [0.3.0]: https://github.com/foir-io/clients/compare/sdk-v0.2.0...sdk-v0.3.0