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