@foir/sdk 0.9.0 → 0.10.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 (57) hide show
  1. package/CHANGELOG.md +497 -27
  2. package/README.md +111 -0
  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 +10 -0
  8. package/dist/auth.js +127 -19
  9. package/dist/client.js +63 -6
  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/content/content-renderer.d.ts +4 -5
  16. package/dist/content/index.d.ts +1 -1
  17. package/dist/content/richtext.d.ts +1 -1
  18. package/dist/data/builtins.d.ts +64 -12
  19. package/dist/data/client.d.ts +82 -1
  20. package/dist/data/decimal.d.ts +212 -0
  21. package/dist/data/document.d.ts +1 -1
  22. package/dist/data/index.d.ts +10 -8
  23. package/dist/data/manifest.d.ts +9 -1
  24. package/dist/data/schema.d.ts +2 -2
  25. package/dist/data/subscriptions.d.ts +36 -10
  26. package/dist/data/transport.d.ts +120 -3
  27. package/dist/data.js +377 -16
  28. package/dist/graphql/cdn-route.d.ts +1 -1
  29. package/dist/graphql/foir-request.d.ts +1 -1
  30. package/dist/live/coalesce.d.ts +57 -0
  31. package/dist/live/drift.d.ts +59 -0
  32. package/dist/live/events.d.ts +13 -0
  33. package/dist/live/headers.d.ts +53 -0
  34. package/dist/live/index.d.ts +54 -0
  35. package/dist/live/keys.d.ts +72 -0
  36. package/dist/live/manager.d.ts +77 -0
  37. package/dist/live/react/index.d.ts +143 -0
  38. package/dist/live/reconcile.d.ts +82 -0
  39. package/dist/live/resync.d.ts +59 -0
  40. package/dist/live/store.d.ts +134 -0
  41. package/dist/live/tagIndex.d.ts +29 -0
  42. package/dist/live/tanstack.d.ts +127 -0
  43. package/dist/live/writetoken.d.ts +86 -0
  44. package/dist/live-react.js +395 -0
  45. package/dist/live.js +845 -0
  46. package/dist/next/cacheTags.d.ts +245 -0
  47. package/dist/next/index.d.ts +7 -0
  48. package/dist/next.js +179 -6
  49. package/dist/push.js +81 -8
  50. package/dist/react.js +76 -5
  51. package/dist/realtime/index.d.ts +4 -5
  52. package/dist/server/events-client.d.ts +54 -4
  53. package/dist/server/index.d.ts +3 -2
  54. package/dist/server/require-scoped-token.d.ts +1 -1
  55. package/dist/server.js +454 -2
  56. package/dist/transport/foir-http.d.ts +113 -10
  57. package/package.json +10 -2
package/CHANGELOG.md CHANGED
@@ -106,6 +106,477 @@ Changes on `dev` that have not been published to npm.
106
106
 
107
107
  ---
108
108
 
109
+ ## [0.10.0] — 2026-09-22
110
+
111
+ Two breaking changes under the pre-1.0 note, so a MINOR: `fetchHostedSession`
112
+ returns a result object rather than `HostedSession | null`, and
113
+ `operations.onExecution` delivers `status` as the enum name.
114
+
115
+ ### Added
116
+
117
+
118
+ - `composedFor` on `FoirManifest`: the organisation whose composition the
119
+ manifest describes, absent for the project's own surface.
120
+
121
+ A key confined to an organisation is served that organisation's surface by
122
+ `GET /v1/manifest` without naming it, so nothing in the payload, or in a
123
+ module generated from it, said whose definitions it carried. `foir types`
124
+ records it in the generated header and `foir explain` prints it. Optional and
125
+ additive: a project manifest is unchanged, and no export was added.
126
+
127
+ - A subscribing client no longer fetches `GET /v1/manifest`.
128
+
129
+ `foir types` now emits `foirSubscriptions` alongside the model trees, and
130
+ `createFoirClient` prints subscribe documents from it. Previously `generated`
131
+ carried model trees only, so passing the module was strictly *worse* for a
132
+ subscribe-only client: the bundle cost, and the round trip anyway.
133
+
134
+ `foirSubscriptions` is optional on the generated module and is deliberately
135
+ not part of the strict export check — a module generated before it existed
136
+ keeps working and keeps fetching, which is what it did before. Re-run
137
+ `foir types` to pick it up.
138
+
139
+ - `FoirSchemaDriftError` on `@foir/sdk/data`, and `strictSchemaDrift` on
140
+ `createFoirClient` to turn it off.
141
+
142
+ **Behaviour change.** Typed calls now THROW once schema drift is detected,
143
+ where they previously warned and served the stale shape. An app that upgrades
144
+ while holding a stale `foir.generated.ts` will start throwing on the second
145
+ and subsequent calls — re-run `foir types` as part of the upgrade. Pass
146
+ `strictSchemaDrift: false` to keep the old warn-and-serve behaviour.
147
+
148
+ The client already detects that its generated selection trees are older than
149
+ the schema the server is serving, and warns. The warning is easy to scroll
150
+ past, and what it is warning about is not a typing problem: the trees are what
151
+ the client PRINTS ITS QUERIES FROM, so a field added since generation is not
152
+ merely untyped, it is not requested. It arrives as absent data and reads as a
153
+ bug in the app.
154
+
155
+ That is the lax half of an asymmetry. An unknown ARGUMENT is refused
156
+ client-side before the server sees it; a missing field is silent. Set
157
+ `strictSchemaDrift: true` and typed calls throw `FoirSchemaDriftError` once
158
+ drift is detected, naming `foir types` as the fix.
159
+
160
+ Two deliberate exemptions. It never fails the FIRST call — drift is only known
161
+ once the manifest comes back, which happens off the response path after a call
162
+ has been served, and blocking every first call on a second round trip is the
163
+ cost the warning exists to avoid. And it never touches `raw()`, because a
164
+ hand-written document asks for exactly the fields it names and cannot silently
165
+ omit one.
166
+
167
+ On by default, because the failure it prevents is silent: a stale selection
168
+ omits a field rather than complaining, so an opt-in guard would only protect
169
+ the people who already suspected it. Opting out is written at the call site
170
+ where a reader can see it, rather than inferred from an environment variable.
171
+
172
+ - `graphql()` and `TypedFoirDocument` on `@foir/sdk/data`: a GraphQL document
173
+ that carries its own result and variable types.
174
+
175
+ ```ts
176
+ const RECENT = graphql<{ pages: { totalCount: number } }, { n: number }>(
177
+ 'query Recent($n: Int) { pages(first: $n) { totalCount } }',
178
+ );
179
+ const { pages } = await foir.raw(RECENT, { n: 5 }); // pages is typed
180
+ ```
181
+
182
+ `raw` returns `unknown`, so every call site hand-writes a result type that
183
+ nothing checks, and the sites that most need type safety are exactly the ones
184
+ the typed client could not express. Two real bugs came from a result type that
185
+ had drifted from the selection it claimed to describe. A branded document does
186
+ not make a bespoke selection type-checked against the schema, which is the
187
+ larger half and wants generated documents rather than a helper. What it removes
188
+ is the retyping: the assertion moves from every call site to one place beside
189
+ the document, and a wrong one is wrong where a reader is looking at the query.
190
+
191
+ Nothing changes for a caller passing a plain string. `raw('...')` still returns
192
+ `unknown` and `raw<T>('...')` still returns `T`. `graphql()` is a cast and
193
+ returns the string it was given, so a branded document can be logged, hashed or
194
+ sent anywhere a string goes.
195
+
196
+ - `FoirRawOptions` on `@foir/sdk/data`, and a third argument to `foir.raw()`:
197
+ `raw(document, variables, { strict: true })`.
198
+
199
+ A GraphQL response can carry `errors` and `data` together, and the SDK warns
200
+ and returns the data. That stays the default, and it is the right one for a
201
+ served page: a field the server could not resolve should not blank the whole
202
+ render. On a hand-written document it is also how a wrong selection stays
203
+ invisible — there is no generated selection to have caught a bad field name
204
+ first, so selecting something that does not exist returns everything else and
205
+ logs, and the hand-written result type asserts the shape was fine.
206
+
207
+ `strict: true` makes that a thrown `FoirRequestError` instead. The error
208
+ carries the data it refused on `partialData`, so opting in does not trade a
209
+ silent wrong answer for a thrown one that discards the answer. Nothing changes
210
+ for a caller that does not pass it.
211
+
212
+ - `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.
213
+ - **The operator's organization context, on `@foir/sdk/auth`** — `myOrganizations`
214
+ and `selectOrganization`, plus the types `MyOrganization` and
215
+ `SelectedOrganization`, and two entries on `AUTH_OPS`.
216
+
217
+ A Fóir project can be partitioned into organizations, one per tenant of a SaaS
218
+ built on Fóir. An *operator* is a customer who runs one or more of them.
219
+ `myOrganizations()` answers which ones this customer may stand in — from their
220
+ own membership rows, so it is not the project's inventory and cannot be used as
221
+ one — and `selectOrganization({ orgId })` says which. An empty `orgId` clears
222
+ the selection.
223
+
224
+ **Set once, not per call.** No read or write in this SDK takes an `orgId`
225
+ argument: the claim the row layer filters on is resolved server-side from the
226
+ caller's membership on every request. An application therefore cannot forget
227
+ to send it, cannot send another organization's, and cannot be made to cross
228
+ the boundary by a query it built wrong. These two functions are the entire
229
+ per-customer surface; everything downstream names no organization at all.
230
+ (The project credential's side of the same boundary is the client-level
231
+ `orgId` option below — a property of the client, still never of a call.)
232
+
233
+ Requires a server serving `myOrganizations` and `selectMyOrganization` in its
234
+ `auth` namespace. Against an older api-public both refuse, naming the op.
235
+
236
+ - **Acting inside an organization with the project credential** — `orgId` on
237
+ `FoirClientConfig` (`createFoirClient` / `createFoirClientFromEnv`), on
238
+ `FoirRequestOptions` (`foirRequest`) and on `AuthOpOptions` (the
239
+ `@foir/sdk/auth` ops), plus `'org'` on `FoirLaneReason`.
240
+
241
+ A multi-tenant app holding the PROJECT key has to choose which of its
242
+ organizations a request is for — after resolving `<sub>.theapp.com` to one,
243
+ or from the back-office. `orgId` sends `X-Org-Id` on every request the client
244
+ makes (HTTP, the manifest fetch and the subscription handshake alike). The
245
+ server resolves it against the credential's OWN project — an unknown,
246
+ suspended or foreign organization all read `NOT_FOUND` — and binds the
247
+ request to it; a credential already confined to an organization may only name
248
+ its own (`PERMISSION_DENIED`), so this cannot steer a storefront key. A read
249
+ naming an organization always takes the origin lane: the shared lane keys by
250
+ URL, where the header does not appear.
251
+
252
+ Pairs with the `organization` owner plane on a model: a record created on
253
+ such a model while acting in an organization belongs to it, and the project
254
+ key with no `orgId` still reads every organization's rows — which is how the
255
+ host→organization lookup is answered in the first place.
256
+
257
+ - **Reconcile on reconnect, on `@foir/sdk/live`** — `createFoirReconciler`,
258
+ `foirHeldRecordIds`, `foirTagsForGoneRecords`, and the types
259
+ `FoirReconciler`, `FoirReconcilerOptions` and `FoirReconcileResolveFn`. Plus a
260
+ `reconcile` prop on `<FoirLive>`.
261
+
262
+ Required, not optional, on any model whose records are partitioned per caller.
263
+ The server filters that stream with an inner join against the records the
264
+ caller can currently read, and records delete hard rather than being flagged —
265
+ so by the time the join runs for a deletion there is no row to match, and **no
266
+ event is emitted**. The same silence covers a record whose ownership moved
267
+ away. Resuming the cursor recovers neither: there was never an event to replay.
268
+
269
+ So the client names the ids it holds and asks which are still visible;
270
+ anything absent is gone. That cannot leak, because the caller supplies the ids
271
+ — an id it names is one it already had. The manager calls it on every
272
+ reconnect, after the stream is back rather than before, so nothing deleted
273
+ mid-reconcile falls between the two halves.
274
+
275
+ A resolve that throws ABANDONS the reconcile rather than reporting everything
276
+ as gone, so a network blink cannot evict a working cache.
277
+ - **Write-token primitives on `@foir/sdk/live`** — `readFoirWriteToken`,
278
+ `foirWriteTokenHeaders` and `FOIR_WRITE_TOKEN_HEADER`. Plus a `floor` reason
279
+ and a `writeToken` input on `foirRoute`.
280
+
281
+ Reading your own write without throwing the cache away. A mutation returns an
282
+ opaque token naming where it committed; a later read carries it; the server
283
+ serves a cached entry only if that entry resolved at or above the write.
284
+ `Cache-Control: no-cache` gets the same guarantee by discarding every warm
285
+ entry on the path, whether or not it was already fresh enough.
286
+
287
+ A token-bearing read is routed to the ORIGIN, with `floor` as the stated
288
+ reason. The shared edge has no view of the project clock, so it cannot tell
289
+ whether what it holds is new enough — and served from there the floor reads as
290
+ satisfied, which is worse than never asking for one.
291
+
292
+ Primitives rather than automatic behaviour: how long a token stands for, and
293
+ across which of a user's tabs, are application questions this package cannot
294
+ answer. Guessing gives either a floor that expires before it helps or one that
295
+ pins reads to the origin long after the write stopped mattering.
296
+
297
+ - **Cache-tag bridge on `@foir/sdk/next`** — `createFoirTaggingFetch`,
298
+ `foirCacheTags`, `readFoirCacheTags`, `foirTagsForRecordEvent`,
299
+ `revalidateFoirRecordEvent`, the constants `FOIR_SURROGATE_KEY_HEADER`,
300
+ `FOIR_NEXT_TAG_MAX_ITEMS`, `FOIR_NEXT_TAG_MAX_LENGTH` and `FOIR_UNTAGGED_TAG`,
301
+ and the types `FoirHeaderSource`, `FoirCacheTags`, `FoirCacheTagsOptions`,
302
+ `FoirTaggingReport`, `FoirTaggingFetchOptions` and `FoirRecordChange`.
303
+
304
+ The API already computes which records and collections every cacheable
305
+ response depends on — it has to, to invalidate its own cache — and ships that
306
+ set on `Surrogate-Key`. It already streams the events that make them stale.
307
+ Nothing joined the two. This is the join: tags in on the read, `revalidateTag`
308
+ out on the event, with a dependency graph computed by the process that
309
+ actually resolved the query rather than guessed at by a query-key convention.
310
+
311
+ `cacheTag` and `revalidateTag` are **injected**, never imported. `cacheTag` is
312
+ `unstable_cacheTag` on Next 15, needs `experimental.useCache`, and may be
313
+ renamed or gated differently in 16 — injecting it means the SDK inherits none
314
+ of that, and nothing under `packages/sdk/src` imports `next`.
315
+
316
+ Two behaviours worth knowing before you wire it up. **Over Next's 128-tag
317
+ limit the per-record tags are dropped entirely**, not truncated: Next's own
318
+ answer to overflow is a `console.warn` and a silent drop, which leaves the
319
+ dropped records stale until the entry's TTL with nothing to say which ones
320
+ they were. Over-invalidating on the coarse tags costs a refetch; truncating
321
+ loses invalidation permanently. And a response with **no** `Surrogate-Key` at
322
+ all — a draft read, a preview, a `NO_CACHE` model, a client `no-store`, a
323
+ traced read — reports `kind: 'untagged'` and is filed under `foir:untagged`,
324
+ because an entry cached with zero tags can never be invalidated at all.
325
+
326
+ Reads the injected `fetch` rather than the transport, which is unchanged.
327
+ - **Coalescing, backpressure and schema-drift invalidation on `@foir/sdk/live`**
328
+ — `createFoirCoalescer`, `createFoirDriftHandler`, `foirDriftIsActionable`,
329
+ plus `coalesceWindowMs` / `maxConcurrentRefetches` on `<FoirLive>` and an
330
+ `invalidate` seam on `applyFoirRecordChange`.
331
+
332
+ The invalidation graph is exact, and exact still means N invalidations for N
333
+ writes. A bulk import or a scheduled publish sweep fires a burst on one model,
334
+ and a client that refetched each one would spend the burst doing nothing else
335
+ — on the exact screen a burst is most likely to be watched from. Fifty
336
+ invalidations of one key inside the window are now one refetch, a burst
337
+ spanning many keys runs at most four at a time, and a key invalidated again
338
+ while its refetch is in flight is owed exactly one more, never one per event.
339
+
340
+ Schema drift is a different problem with the same remedy. A deploy that
341
+ renames a field leaves every cached entry perfectly current and quietly wrong:
342
+ no record changed, nothing is out of order, and the bytes describe a schema
343
+ that no longer exists — until the tab is reloaded, which on a long admin
344
+ session can be days. `createFoirDriftHandler` wires the `FoirSchemaDrift` the
345
+ data client already reports to a full clear.
346
+
347
+ - **`@foir/sdk/live/react`** — `<FoirLive>`, `useFoirLive`, `useFoirLiveStatus`.
348
+ One socket, refcounted per model: the first component to say
349
+ `useFoirLive('product')` opens the stream, the last to unmount closes it, and
350
+ N watchers of a model cost one subscription.
351
+
352
+ An **adapter**, not a store. You keep the query library you have and it owns
353
+ your data; what this owns is which entries a change invalidates. The query
354
+ client is structurally typed, so `@tanstack/react-query` is neither imported
355
+ nor pinned — the same reason the Next bridge takes `cacheTag` as a function.
356
+
357
+ Live is **opt-in per model**. Nothing subscribes because the provider is
358
+ mounted: the shared CDN lane is a major asset, and blanket liveness pushes
359
+ read traffic off it onto the origin.
360
+
361
+ Pagination is handled asymmetrically, because that is what the data supports.
362
+ `deleted` removes the row by id with **no fetch**; `created` exposes a
363
+ **count** rather than refetching, since a create cannot be spliced and
364
+ refetching the head page prepends under a reader mid-scroll; `updated`
365
+ refetches only the queries holding that record, matched on `rec:` rather than
366
+ the model. `totalCount` is flagged approximate rather than adjusted.
367
+
368
+ **Mutable sort keys are a documented limitation.** A query ordered by
369
+ `updatedAt` or a draggable `position` can have a record move between pages on
370
+ update. See the README.
371
+
372
+ Also on `@foir/sdk/live`: `createFoirLiveManager`, `applyFoirRecordChange`,
373
+ `foirQueryKeys`, `foirModelQueryKey`, `foirTagsForQueryInvalidation`,
374
+ `FOIR_QUERY_KEY_ROOT` and their types — the framework-free half of the same
375
+ adapter.
376
+
377
+ - **`after` on `foir.<model>.onChange`** (`@foir/sdk/data`), plus the
378
+ `FoirChangeSubscribeOptions` type. Pass the highest `seq` you have applied and
379
+ the stream replays what came after it from the durable log before continuing
380
+ live, so a message lost anywhere upstream is repaired by the next connect
381
+ rather than left as a permanently stale cache entry. Omitting it is the stream
382
+ as it has always been. `onChange` already took an object, so this is additive.
383
+
384
+ - **`@foir/sdk/live`** — a new subpath: the client half of Foir's invalidation
385
+ graph. `createFoirLiveStore`, `createFoirTagIndex`, `foirQueryKey`,
386
+ `foirStableKey`, `foirDecimalKey`, `readFoirLiveHeaders`,
387
+ `foirTagsForRecordChange`, `foirRecordTag`, `foirModelTag`,
388
+ `foirRequiresResync`, `foirResumeCursor`, the header and code constants, and
389
+ the accompanying types.
390
+
391
+ The API already computes which records and collections every cacheable
392
+ response depends on — it has to, to invalidate its own cache — and ships that
393
+ set on a response header, alongside the project's monotonic `seq`. It already
394
+ streams the change events that make those dependencies stale. This is the
395
+ join, on the client: a bidirectional tag index, a store that honours ordering
396
+ and completeness, and the mapping from one record change to the entries it
397
+ invalidates. Apollo, urql and TanStack Query all guess at that graph; here it
398
+ is computed by the process that actually resolved the query.
399
+
400
+ Framework-free and graphql-free, asserted as a property of the import graph
401
+ rather than promised in a comment.
402
+
403
+ **It is not a state-management library, and will not become one.** No local
404
+ mutation log, no offline mode, no conflict resolution, no optimistic
405
+ rollback. The server is authoritative: mutations go over the wire and the
406
+ invalidation comes back. That is a product boundary, not a phasing decision.
407
+
408
+ Three behaviours to know before building on it. The store holds the **raw
409
+ wire payload** and hydration happens at the read boundary — `FoirDecimal` is
410
+ a class with `#`-private fields, so `structuredClone` returns `{}` without
411
+ throwing and a structural deep-equal reports every decimal equal to every
412
+ other; raw JSON has neither problem, because a decimal there is a string.
413
+ Writes are **last-write-wins by `seq`**, so a slow refetch landing second
414
+ cannot walk the cache backwards. And a **hard resync fences requests already
415
+ in flight**: one issued before the resync arrives afterwards looking exactly
416
+ like a fresh answer, so `dedupe` hands the running function a fence to write
417
+ through.
418
+
419
+ Keys normalise, payloads never do. A decimal in query *variables* is
420
+ canonicalised to its minimal scale so `1.0` and `1.00` are one entry — but
421
+ never on the way into the store, where a trailing zero is significant for
422
+ money.
423
+
424
+ - **`FoirDecimal` and `decimal()` on `@foir/sdk/data`** — the exact numeric type
425
+ a `decimal` field arrives as. Also `FoirDecimalInput`, `FoirDecimalRounding`
426
+ and `FoirDecimalDivOptions`.
427
+
428
+ A `decimal` field travels as a string, because JSON has one number type and
429
+ every JavaScript consumer parses it into a double whatever the server meant.
430
+ Supabase is the worked example of getting this half right: Postgres stores
431
+ `numeric` exactly, PostgREST serialises it as a JSON number, and the precision
432
+ is gone inside `JSON.parse` before any application code runs. Exact storage
433
+ behind a lossy wire format buys nothing.
434
+
435
+ The string is therefore the transport and deliberately not what you are
436
+ handed, because `"19.99" + "2.00"` is `"19.992.00"` — silently. `FoirDecimal`
437
+ holds a scaled `bigint`, so arithmetic is exact by construction, and answers
438
+ only the `string` primitive hint: template literals work, while `+`, `<` and
439
+ `Number()` throw and name the method to use instead. TypeScript rejects those
440
+ at compile time already; the throw is for JavaScript callers and for values
441
+ arriving through `any`.
442
+
443
+ Scale is preserved for display (`19.90` stays `19.90`) while `equals` compares
444
+ values (`1.0` equals `1.00`). `JSON.stringify` emits the canonical string, not
445
+ an object — pinned by test, because leaving it implicit is Prisma's most common
446
+ support thread.
447
+
448
+ No dependency: `bigint` is ES2020 and this package targets ES2022. Prisma
449
+ hands out `Decimal.js` instances and prisma/prisma#16397 has run for years
450
+ because callers cannot supply a matching version — a type that crosses a
451
+ package boundary should not carry a third party's identity with it.
452
+
453
+ A `number` is refused by the constructor; `FoirDecimal.fromNumber()` accepts
454
+ one and names the precision loss at the call site.
455
+
456
+
457
+ - **`HostedSession.expiresIn`** — seconds until the bearer lapses, from the
458
+ endpoint's `expires_in`, which the exchange already received and discarded.
459
+
460
+ The hosted flow has no refresh token by design: the cookie is the durable
461
+ credential and the bearer is re-exchanged when it dies. A consumer that cannot
462
+ see the lifetime has to hardcode the platform's (coupling itself to a server
463
+ constant it cannot observe) or decode `exp` off the JWT — or, as happened,
464
+ treat a 15-minute bearer as good forever and have customers appear signed out
465
+ mid-session. `createFoirSessionClient` on the SPA path has read `expires_in`
466
+ and scheduled a proactive refresh all along; this closes the asymmetry.
467
+
468
+ `0` when the endpoint reported none. Treat that as "expires imminently", never
469
+ as "never expires".
470
+
471
+ - **`HostedSessionResult`** exported from `./auth`.
472
+
473
+ - **`FoirAuthDowngrade`** on `./data` and `./server`, plus `extensions` on the
474
+ response envelope — Foir now reports when it DISCARDED a customer token you
475
+ sent, and the SDK stops dropping that report on the floor.
476
+
477
+ A customer JWT is optional: one that fails to verify is not refused, the read
478
+ is served anonymously and returns `200`. So a lapsed token and a genuinely
479
+ anonymous request were indistinguishable — nothing threw, no refresh armed,
480
+ and auth-targeted content quietly resolved to its anonymous variant. The
481
+ failure surfaced as *wrong content*, never as an error.
482
+
483
+ ```json
484
+ { "data": { "…": "…" },
485
+ "extensions": { "auth": {
486
+ "customerToken": "rejected", "reason": "expired", "servedAs": "anonymous"
487
+ } } }
488
+ ```
489
+
490
+ The SDK warns once per episode (not once per request — a lapsed bearer affects
491
+ every one of them), naming the remedy, and re-arms after a clean response so a
492
+ second episode is reported as its own. The data is still returned: a downgrade
493
+ is a report, not an error.
494
+
495
+ - **`events.subscribe(params)` on `./server`** — the push form of the event
496
+ cursor `list()` and `stream()` already page through. The server backfills from
497
+ `after` and continues live on one stream, so there is no gap between catching
498
+ up and keeping up. The cursor advances as events arrive, so a dropped socket
499
+ resumes from the last event delivered rather than replaying the backfill; a
500
+ cursor older than the retention window ends the subscription with
501
+ `FoirEventCursorExpiredError` carrying `resumeFrom`, matching the 410 the HTTP
502
+ lane answers.
503
+
504
+ The GraphQL `events` subscription has been live on the public schema since the
505
+ events spine shipped, with no client able to reach it. It is an `sk_`-only
506
+ operator surface, so it lives on `./server` beside the rest of the events
507
+ client and inherits its `pk_` and `Authorization` refusals unchanged.
508
+
509
+ New exports: `FoirEventsSubscribeParams`, plus `FoirSubscription` and
510
+ `FoirSubscriptionHandlers` re-exported from `./server` so a consumer need not
511
+ import them from the browser-facing `./data`.
512
+
513
+ `wsUrl`, `webSocketImpl` and `reconnect` are accepted on
514
+ `FoirEventsClientConfig` for the socket, mirroring `./data`. Node 18/20 have no
515
+ global `WebSocket`, so a server process on those versions passes the `ws`
516
+ package's constructor.
517
+
518
+ ### Changed
519
+
520
+
521
+ - **`fetchHostedSession` returns a `HostedSessionResult` instead of
522
+ `HostedSession | null`.** BREAKING — every call site has to branch on
523
+ `outcome`.
524
+
525
+ It collapsed every failure to `null`: no cookie, a `401`, a `502`, a rate
526
+ limit, a DNS failure. Consumers have to act on that `null`, and the only
527
+ safe-looking action is to clear the session and redirect to the front door —
528
+ so one blip on `/session/token` signed out a customer whose 30-day cookie was
529
+ perfectly valid, and nothing recorded that it had happened. The route's own
530
+ rate limiter is deliberately fail-open for the opposite reason.
531
+
532
+ ```ts
533
+ // before
534
+ const session = await fetchHostedSession(request, config);
535
+ if (!session) return redirect(hostedLoginUrl(config));
536
+
537
+ // after
538
+ const result = await fetchHostedSession(request, config);
539
+ if (result.outcome === 'signed-out') return redirect(hostedLoginUrl(config));
540
+ if (result.outcome === 'unavailable') return keepExistingSession();
541
+ use(result.session);
542
+ ```
543
+
544
+ `outcome` says what to DO; `reason` says what happened. Only `401` and a
545
+ missing cookie are `signed-out`. A `403` reports as `unavailable` with reason
546
+ `forbidden` — it means the CSRF header did not arrive, which is a
547
+ misconfiguration rather than a blip, but signing the customer out over it
548
+ would still be wrong.
549
+
550
+ - **`foir.ns.operations.onExecution` delivers `status` as the
551
+ `OperationExecutionStatus` enum name.** BREAKING. `FoirOperationExecutionEvent.status`
552
+ is now `'PENDING' | 'DISPATCHED' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED' | 'TIMED_OUT'`
553
+ where it was a lowercase `string`.
554
+
555
+ `operationExecution` and `operationExecutions` return the enum name for a
556
+ run, and the socket carries the platform's lowercase value, so one consumer
557
+ held two spellings of the same run's status. The typed stream now matches the
558
+ query. The socket itself is unchanged: a subscriber on `FoirSocketTransport`
559
+ directly still receives the lowercase value the server sends.
560
+
561
+ ```ts
562
+ // before
563
+ onData: (event) => { if (event.status === 'completed') done(); }
564
+
565
+ // after
566
+ onData: (event) => { if (event.status === 'COMPLETED') done(); }
567
+ ```
568
+
569
+ ### Fixed
570
+
571
+
572
+ - **Subscription errors keep their `extensions`.** The socket transport reduced
573
+ every terminal `error` frame to its message and dropped the structured fields,
574
+ so `graphQLErrors` was always absent and a caller had no `code` to branch on —
575
+ while the HTTP lane carried them. This affected every subscription, not only
576
+ `events`. Both lanes now report the same shape.
577
+
578
+ ---
579
+
109
580
  ## [0.9.0] — 2026-08-20
110
581
 
111
582
  Everything additive, so a MINOR either way under the pre-1.0 note.
@@ -129,7 +600,7 @@ Everything additive, so a MINOR either way under the pre-1.0 note.
129
600
  `return redirect(hostedLogoutUrl(config, clientId, returnTo))`. `returnTo`
130
601
  must be absolute and on an origin registered for `clientId`.
131
602
 
132
- Requires the platform's `end-session` endpoint (TTS-1534). `HostedLoginConfig`
603
+ Requires the platform's `end-session` endpoint. `HostedLoginConfig`
133
604
  gains an optional `endSessionPath`, defaulting to `/session/end-session`.
134
605
 
135
606
  - **`Prose`, `Passage`, `ProseSpan` and `RecordAncestor` on `./data`.** The
@@ -166,13 +637,13 @@ note above.
166
637
  described are not in the schema:
167
638
 
168
639
  - `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
640
+ was the wrapper's envelope, removed — an item IS its value. No
170
641
  member carries any of the four, so every one was a field the types promised
171
642
  and the API never returned.
172
- - `MediaBlock` — `media` stopped being a primitive at TTS-1391. It was never
643
+ - `MediaBlock` — `media` stopped being a primitive. It was never
173
644
  in the proto FieldType enum, and it is the one primitive name a project had
174
645
  also used as a model key.
175
- - `TextBlock` — removed at TTS-1406. GraphQL requires a union member to be an
646
+ - `TextBlock` — removed. GraphQL requires a union member to be an
176
647
  object type and a text item's value is a bare scalar, which is the whole
177
648
  reason a wrapper existed. An inline model with a text field is how a text
178
649
  slot is authored.
@@ -195,7 +666,7 @@ note above.
195
666
  interface for every inline model — `ActHero` AND `ActHeroBlock` — where the
196
667
  wrapper was `FoirBlockMeta` plus the model's non-`_`-prefixed fields. On the
197
668
  Marketing fixture that was 20 of 47 interfaces describing a shape the platform
198
- stopped serving at TTS-1391.
669
+ stopped serving.
199
670
 
200
671
  A union branch is now `(<Model> & { __typename: '<Model>' })`: the model
201
672
  itself, intersected with the discriminant the union position guarantees. The
@@ -219,7 +690,7 @@ note above.
219
690
 
220
691
  - **BREAKING (`foir types`) — a manifest carrying a retired primitive is now
221
692
  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
693
+ being union members. A manifest listing one predates that, so
223
694
  the generator says so and names re-publishing as the fix rather than emitting
224
695
  a branch for a type the server never sends.
225
696
 
@@ -244,7 +715,7 @@ note above.
244
715
  project, so an exhaustive `switch` over a flexible field fell through every
245
716
  case.
246
717
  - **`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
718
+ platform relaxed them to `String` — non-null on the value object
248
719
  and nullable on the wrapper is a §5.3.2 response-shape conflict once the two
249
720
  are one type — so `img.url.startsWith(…)` could throw on a value the types
250
721
  said was present.
@@ -252,7 +723,7 @@ note above.
252
723
  `_targetId`, `_targetNaturalKey` and `record`.** `_id` was the ITEM's id and
253
724
  `_type` read `"reference"` on every reference ever stored; neither is in the
254
725
  schema. The target coordinates are what actually make a reference
255
- followable (TTS-1349/1371), and both identifier shapes are live — on the dev
726
+ followable, and both identifier shapes are live — on the dev
256
727
  database 101 of 193 stored references carry an id and 92 carry a natural key
257
728
  and no id.
258
729
 
@@ -269,7 +740,7 @@ note above.
269
740
  union, not `Block`.** `DEFAULT_UNIONS` in `@foir/sdk/codegen` was `['Block']`;
270
741
  it is `['Flexible']`.
271
742
 
272
- The platform stopped emitting a union named `Block` at TTS-1391 — a flexible
743
+ The platform stopped emitting a union named `Block` — a flexible
273
744
  field's members are the value types and inline models under their own names,
274
745
  and the project-wide union an unconstrained field resolves to is `Flexible`.
275
746
  So `foirCodegen()` on defaults could not have worked against any current
@@ -280,7 +751,7 @@ note above.
280
751
 
281
752
  It stayed invisible because the plugin tests built their own fixture schema
282
753
  declaring `union Block = RichtextBlock | ImageBlock | TextBlock` — the
283
- pre-TTS-1391 shape. The fixture is now the post-1391 shape, and
754
+ pre-rename shape. The fixture is now the post-rename shape, and
284
755
  `DEFAULT_UNIONS` is asserted against it directly.
285
756
 
286
757
  **Migration:** none if you pass `blockUnions`/`itemUnions` explicitly. If you
@@ -321,7 +792,7 @@ note above.
321
792
  `block` was never a platform concept — it was one project's NAME for a
322
793
  flexible field, and a modeller may equally call one `items`, `sections` or
323
794
  `modules`. The platform never infers anything from a field's name and the SDK
324
- should not teach otherwise (TTS-1391).
795
+ should not teach otherwise.
325
796
 
326
797
  Nothing is removed in this release; every old name still resolves and still
327
798
  behaves identically. Removal is a later, separately announced break.
@@ -860,7 +1331,7 @@ note above.
860
1331
  `FoirManifestSelection`, `FoirManifestSelectionField`,
861
1332
  `FoirManifestSelectionUnion`, `FoirManifestSelectionMember`.
862
1333
 
863
- - **The event-log client on `@foir/sdk/server`** (TTS-983) — `createEventsClient`,
1334
+ - **The event-log client on `@foir/sdk/server`** — `createEventsClient`,
864
1335
  cursor-resumable consumption of a project's durable event history over
865
1336
  `GET /v1/events`. `list()` returns one page; `stream()` pages forward to the
866
1337
  current end of the log and yields events, each carrying the `seq` that is the
@@ -873,7 +1344,7 @@ note above.
873
1344
  `FoirEventsClientConfig`, `FoirEventsListParams`, `FoirEventsStreamParams`.
874
1345
 
875
1346
  **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
1347
+ an OPERATOR surface (DECIDED 2026-08-04): reachable by an `sk_` key or
877
1348
  an admin session, never by a customer token, and `events:list.project` is not
878
1349
  mintable on a `pk_` key at all. `events.view` is plain tenant+project
879
1350
  containment while `record.view` carries owner/grantee/borrow terms on top, so a
@@ -1020,7 +1491,7 @@ actually happened.
1020
1491
  register / OTP / provider flows too; use this one for a backend-less SPA whose
1021
1492
  only credential is the Fóir-hosted session cookie.
1022
1493
 
1023
- - **New subpath `@foir/sdk/data`** — the typed data client (TTS-874).
1494
+ - **New subpath `@foir/sdk/data`** — the typed data client.
1024
1495
  `createFoirClient<FoirSchema>({ apiUrl, apiKey })` gives `foir.<model>.<op>(…)`,
1025
1496
  `foir.ns.<namespace>.<op>(…)` and an untyped `foir.raw(op)`. The client's surface is shaped by the credential: an op that
1026
1497
  is not in that key's manifest is not on the type, so `foir.page.create(…)`
@@ -1107,7 +1578,7 @@ on its own, which is the intended behaviour for a release that adds exports.
1107
1578
  - `@foir/sdk/auth`: `createFoirSessionClient`, plus the `FoirSessionClient`,
1108
1579
  `FoirSessionConfig` and `FoirSessionUser` types — Foir-hosted first-party
1109
1580
  sessions for backend-less SPAs, over the `fetchHostedSession` /
1110
- `hostedLoginUrl` hosted-login bridge that already shipped in 0.1.0 (EID-516).
1581
+ `hostedLoginUrl` hosted-login bridge that already shipped in 0.1.0.
1111
1582
 
1112
1583
  ## [0.3.0] — 2026-07-12
1113
1584
 
@@ -1115,10 +1586,10 @@ on its own, which is the intended behaviour for a release that adds exports.
1115
1586
 
1116
1587
  - **New subpath `@foir/sdk/react`** — drop-in React customer auth: `FoirProvider`,
1117
1588
  `SignIn`, `OAuthButtons`, `UserButton`, `useFoirAuth`, `useFoirSession`,
1118
- `useFoirUser` (EID-515).
1589
+ `useFoirUser`.
1119
1590
  - **New subpath `@foir/sdk/next`** — Next.js App Router BFF auth adapter: cookie
1120
1591
  session store, `createFoirMiddleware`, server-side sign-in/out actions and the
1121
- provider callback handler (EID-515).
1592
+ provider callback handler.
1122
1593
 
1123
1594
  ## [0.2.0] — 2026-07-10
1124
1595
 
@@ -1127,27 +1598,25 @@ on its own, which is the intended behaviour for a release that adds exports.
1127
1598
  - **New subpath `@foir/sdk/push`** — native Web Push (VAPID) enrolment:
1128
1599
  `enableWebPush`, `disableWebPush`, `isWebPushSupported`.
1129
1600
  - `@foir/sdk/server`: `requireScopedToken` — a one-line ingress guard for
1130
- operation BFF routes, over the existing `verifyScopedToken` (EID-498).
1601
+ operation BFF routes, over the existing `verifyScopedToken`.
1131
1602
 
1132
1603
  ## [0.1.0] — 2026-06-23
1133
1604
 
1134
1605
  First public release. Ten subpaths:
1135
1606
 
1136
1607
  - `@foir/sdk` — editor iframe client: `EditorProvider`, `useEditor`, the
1137
- host↔editor message protocol, and `createPlatformClient` (EID-88, EID-91).
1608
+ host↔editor message protocol, and `createPlatformClient`.
1138
1609
  - `@foir/sdk/server` — server-side platform helpers for config API backends:
1139
1610
  `createServerClient`, scoped-token verification, `verifyWebhookSignature`.
1140
1611
  - `@foir/sdk/client` — React-free GraphQL transport for SSR/edge (`foirRequest`).
1141
- - `@foir/sdk/media` — progressive media components and adapters (EID-94).
1612
+ - `@foir/sdk/media` — progressive media components and adapters.
1142
1613
  - `@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).
1614
+ injected typography tokens.
1615
+ - `@foir/sdk/auth` — customer auth flows, token lifecycle, hosted-login bridge.
1616
+ - `@foir/sdk/realtime` — `createCollabSession`, a thin y-websocket helper.
1148
1617
  - `@foir/sdk/codegen`, `@foir/sdk/codegen/block-registry`,
1149
1618
  `@foir/sdk/codegen/block-scaffold` — the `foirCodegen` preset and the
1150
- graphql-codegen plugins (EID-92).
1619
+ graphql-codegen plugins.
1151
1620
 
1152
1621
  ---
1153
1622
 
@@ -1170,7 +1639,8 @@ and the commit history.
1170
1639
  and are exhaustive for the public surface, which is what the export-surface
1171
1640
  guard exists to keep true.
1172
1641
 
1173
- [Unreleased]: https://github.com/foir-io/clients/compare/sdk-v0.5.0...dev
1642
+ [Unreleased]: https://github.com/foir-io/clients/compare/sdk-v0.10.0...dev
1643
+ [0.10.0]: https://github.com/foir-io/clients/compare/sdk-v0.9.0...sdk-v0.10.0
1174
1644
  [0.5.0]: https://github.com/foir-io/clients/compare/sdk-v0.4.0...sdk-v0.5.0
1175
1645
  [0.4.0]: https://github.com/foir-io/clients/compare/sdk-v0.3.0...sdk-v0.4.0
1176
1646
  [0.3.0]: https://github.com/foir-io/clients/compare/sdk-v0.2.0...sdk-v0.3.0