@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.
- package/CHANGELOG.md +719 -27
- package/README.md +121 -2
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +71 -4
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +18 -0
- package/dist/auth.js +209 -47
- package/dist/client.js +101 -13
- package/dist/codegen/block-registry.d.ts +1 -1
- package/dist/codegen/block-scaffold.d.ts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/item-registry.d.ts +1 -1
- package/dist/codegen/item-scaffold.d.ts +1 -1
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/content/content-renderer.d.ts +4 -5
- package/dist/content/index.d.ts +1 -1
- package/dist/content/richtext.d.ts +1 -1
- package/dist/data/builtins.d.ts +81 -13
- package/dist/data/client.d.ts +92 -9
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +14 -1
- package/dist/data/index.d.ts +22 -10
- package/dist/data/manifest.d.ts +40 -4
- package/dist/data/schema.d.ts +18 -11
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +363 -24
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +591 -64
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +58 -0
- package/dist/live/events.d.ts +13 -0
- package/dist/live/headers.d.ts +53 -0
- package/dist/live/index.d.ts +54 -0
- package/dist/live/keys.d.ts +72 -0
- package/dist/live/manager.d.ts +77 -0
- package/dist/live/react/index.d.ts +143 -0
- package/dist/live/reconcile.d.ts +82 -0
- package/dist/live/resync.d.ts +59 -0
- package/dist/live/store.d.ts +134 -0
- package/dist/live/tagIndex.d.ts +29 -0
- package/dist/live/tanstack.d.ts +127 -0
- package/dist/live/writetoken.d.ts +86 -0
- package/dist/live-react.js +395 -0
- package/dist/live.js +845 -0
- package/dist/next/cacheTags.d.ts +245 -0
- package/dist/next/index.d.ts +7 -0
- package/dist/next.js +260 -34
- package/dist/push.js +163 -36
- package/dist/react.js +157 -33
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +4 -2
- package/dist/server/operation-types.d.ts +86 -58
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +20 -1
- package/dist/server.js +602 -10
- package/dist/transport/foir-http.d.ts +168 -15
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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`
|
|
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-
|
|
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
|
|
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`**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1834
|
+
- `@foir/sdk/media` — progressive media components and adapters.
|
|
1142
1835
|
- `@foir/sdk/content` — `createContentRenderer` and `RichTextRenderer` with
|
|
1143
|
-
injected typography tokens
|
|
1144
|
-
- `@foir/sdk/auth` — customer auth flows, token lifecycle, hosted-login bridge
|
|
1145
|
-
|
|
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
|
|
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.
|
|
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
|