@foir/sdk 0.10.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 +222 -0
- package/README.md +10 -2
- package/dist/auth/ops.d.ts +8 -0
- package/dist/auth.js +91 -37
- package/dist/client.js +44 -13
- 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 +13 -3
- 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 +244 -22
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +236 -70
- package/dist/index.js +0 -9
- package/dist/live/drift.d.ts +9 -10
- package/dist/live.js +1 -1
- package/dist/next.js +88 -35
- package/dist/push.js +91 -37
- package/dist/react.js +88 -35
- package/dist/server/index.d.ts +1 -0
- 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 +19 -0
- package/dist/server.js +162 -22
- package/dist/transport/foir-http.d.ts +55 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -104,8 +104,230 @@ 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
|
+
|
|
107
320
|
---
|
|
108
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
|
+
|
|
109
331
|
## [0.10.0] — 2026-09-22
|
|
110
332
|
|
|
111
333
|
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,
|
package/dist/auth/ops.d.ts
CHANGED
|
@@ -81,6 +81,14 @@ export interface AuthOpOptions {
|
|
|
81
81
|
* already confined to an organization may only name its own.
|
|
82
82
|
*/
|
|
83
83
|
orgId?: string;
|
|
84
|
+
/**
|
|
85
|
+
* The organization the call acts in, named by the address of the page it is
|
|
86
|
+
* for, sent as `X-Org-Url`: same contract as `FoirClientConfig.orgUrl`. A
|
|
87
|
+
* storefront on a path per organization signs its customers in on those
|
|
88
|
+
* pages, and names the organization the way every other request from them
|
|
89
|
+
* does.
|
|
90
|
+
*/
|
|
91
|
+
orgUrl?: string;
|
|
84
92
|
/**
|
|
85
93
|
* `fetch` implementation. Defaults to `globalThis.fetch`. Injectable for
|
|
86
94
|
* tests and for runtimes that hand you a bound `fetch` (Hydrogen/Oxygen).
|
package/dist/auth.js
CHANGED
|
@@ -103,19 +103,46 @@ function readFoirRetryAfterMs(headers) {
|
|
|
103
103
|
const seconds = Number(headers.get("Retry-After"));
|
|
104
104
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
105
105
|
}
|
|
106
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
107
|
+
"x-api-key",
|
|
108
|
+
"authorization",
|
|
109
|
+
"x-foir-token",
|
|
110
|
+
"x-foir-context"
|
|
111
|
+
]);
|
|
112
|
+
function refuseCredentialHeaders(extra) {
|
|
113
|
+
if (!extra) return;
|
|
114
|
+
for (const name of Object.keys(extra)) {
|
|
115
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
116
|
+
throw new FoirRequestError(
|
|
117
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
106
122
|
function buildFoirHeaders(input) {
|
|
107
123
|
const headers = {
|
|
108
|
-
"Content-Type": "application/json"
|
|
109
|
-
"x-api-key": input.apiKey
|
|
124
|
+
"Content-Type": "application/json"
|
|
110
125
|
};
|
|
111
|
-
if (input.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
headers["
|
|
126
|
+
if (input.dispatchToken !== void 0) {
|
|
127
|
+
refuseCredentialHeaders(input.extra);
|
|
128
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
129
|
+
if (input.dispatchContext) {
|
|
130
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
headers["x-api-key"] = input.apiKey;
|
|
134
|
+
if (input.draftChannel) {
|
|
135
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
136
|
+
}
|
|
137
|
+
if (input.bearerToken) {
|
|
138
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
139
|
+
}
|
|
140
|
+
if (input.orgId) {
|
|
141
|
+
headers["X-Org-Id"] = input.orgId;
|
|
142
|
+
}
|
|
143
|
+
if (input.orgUrl) {
|
|
144
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
145
|
+
}
|
|
119
146
|
}
|
|
120
147
|
if (input.extra) {
|
|
121
148
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -159,12 +186,15 @@ function refusePrunedDocument(json, options) {
|
|
|
159
186
|
`[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
|
|
160
187
|
);
|
|
161
188
|
}
|
|
189
|
+
function answeredAnything(data) {
|
|
190
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
191
|
+
}
|
|
162
192
|
function readFoirEnvelope(json, options) {
|
|
163
193
|
reportAuthDowngrade(json);
|
|
164
194
|
refusePrunedDocument(json, options);
|
|
165
195
|
if (json.errors?.length) {
|
|
166
196
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
167
|
-
if (json.data && !options?.strict) {
|
|
197
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
168
198
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
169
199
|
} else {
|
|
170
200
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
@@ -232,12 +262,13 @@ async function foirRequest(env, document, variables, options) {
|
|
|
232
262
|
apiKey,
|
|
233
263
|
draftChannel: isFoirPreview(env),
|
|
234
264
|
bearerToken: options?.bearerToken,
|
|
235
|
-
orgId: options?.orgId
|
|
265
|
+
orgId: options?.orgId,
|
|
266
|
+
orgUrl: options?.orgUrl
|
|
236
267
|
});
|
|
237
268
|
const documentText = print2(document);
|
|
238
269
|
let res;
|
|
239
270
|
try {
|
|
240
|
-
if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId)) {
|
|
271
|
+
if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
|
|
241
272
|
const hash = await documentHash(documentText);
|
|
242
273
|
let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
|
|
243
274
|
method: "GET",
|
|
@@ -793,10 +824,42 @@ async function resolveBearerToken(config) {
|
|
|
793
824
|
if (typeof source === "function") return await source() ?? void 0;
|
|
794
825
|
return source ?? void 0;
|
|
795
826
|
}
|
|
827
|
+
async function resolveDispatchToken(dispatch) {
|
|
828
|
+
const token = (await dispatch.token())?.trim();
|
|
829
|
+
if (!token) {
|
|
830
|
+
throw new FoirRequestError(
|
|
831
|
+
"[foir] the dispatch token provider returned no token. A client built from a dispatch calls Foir as that operation and holds no other credential. Build it from the token the endpoint was handed: `(await requireScopedToken(request)).dispatch`."
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return token;
|
|
835
|
+
}
|
|
836
|
+
async function resolveFoirAuth(config) {
|
|
837
|
+
if (config.dispatch !== void 0) {
|
|
838
|
+
return {
|
|
839
|
+
headers: buildFoirHeaders({
|
|
840
|
+
dispatchToken: await resolveDispatchToken(config.dispatch),
|
|
841
|
+
dispatchContext: config.dispatch.context ?? void 0,
|
|
842
|
+
extra: config.headers
|
|
843
|
+
})
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
const bearerToken = await resolveBearerToken(config);
|
|
847
|
+
return {
|
|
848
|
+
headers: buildFoirHeaders({
|
|
849
|
+
apiKey: config.apiKey,
|
|
850
|
+
draftChannel: config.channel === "draft",
|
|
851
|
+
bearerToken,
|
|
852
|
+
orgId: config.orgId,
|
|
853
|
+
orgUrl: config.orgUrl,
|
|
854
|
+
extra: config.headers
|
|
855
|
+
}),
|
|
856
|
+
key: { apiKey: config.apiKey, bearerToken }
|
|
857
|
+
};
|
|
858
|
+
}
|
|
796
859
|
function foirRoute(input) {
|
|
797
860
|
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
798
861
|
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
799
|
-
if (input.orgId) return { lane: "origin", reason: "org" };
|
|
862
|
+
if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
|
|
800
863
|
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
801
864
|
if (input.writeToken) return { lane: "origin", reason: "floor" };
|
|
802
865
|
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
@@ -814,22 +877,16 @@ async function executeFoirDocument(config, plan) {
|
|
|
814
877
|
async function runFoirDocument(config, document, mutation, variables, options) {
|
|
815
878
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
816
879
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
817
|
-
const
|
|
818
|
-
const headers = buildFoirHeaders({
|
|
819
|
-
apiKey: config.apiKey,
|
|
820
|
-
draftChannel: config.channel === "draft",
|
|
821
|
-
bearerToken,
|
|
822
|
-
orgId: config.orgId,
|
|
823
|
-
extra: config.headers
|
|
824
|
-
});
|
|
880
|
+
const { headers, key } = await resolveFoirAuth(config);
|
|
825
881
|
const hash = await documentHash(document);
|
|
826
|
-
const
|
|
827
|
-
apiKey:
|
|
828
|
-
bearerToken,
|
|
882
|
+
const sharedKey = key !== void 0 && foirRoute({
|
|
883
|
+
apiKey: key.apiKey,
|
|
884
|
+
bearerToken: key.bearerToken,
|
|
829
885
|
orgId: config.orgId,
|
|
886
|
+
orgUrl: config.orgUrl,
|
|
830
887
|
mutation,
|
|
831
888
|
channel: config.channel
|
|
832
|
-
});
|
|
889
|
+
}).lane === "shared" ? key.apiKey : void 0;
|
|
833
890
|
const post = (body) => doFetch(endpoint, {
|
|
834
891
|
method: "POST",
|
|
835
892
|
headers,
|
|
@@ -839,8 +896,8 @@ async function runFoirDocument(config, document, mutation, variables, options) {
|
|
|
839
896
|
const extensions = persistedQueryExtension(hash);
|
|
840
897
|
let res;
|
|
841
898
|
try {
|
|
842
|
-
if (
|
|
843
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
899
|
+
if (sharedKey !== void 0) {
|
|
900
|
+
res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
|
|
844
901
|
method: "GET",
|
|
845
902
|
headers,
|
|
846
903
|
...config.requestInit
|
|
@@ -878,20 +935,16 @@ async function readErrorBody(res) {
|
|
|
878
935
|
}
|
|
879
936
|
|
|
880
937
|
// src/data/manifest.ts
|
|
881
|
-
async function fetchFoirManifest(config) {
|
|
882
|
-
const
|
|
938
|
+
async function fetchFoirManifest(config, options = {}) {
|
|
939
|
+
const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
|
|
940
|
+
const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
|
|
883
941
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
942
|
+
const { headers } = await resolveFoirAuth(config);
|
|
884
943
|
let res;
|
|
885
944
|
try {
|
|
886
945
|
res = await doFetch(url, {
|
|
887
946
|
method: "GET",
|
|
888
|
-
headers
|
|
889
|
-
apiKey: config.apiKey,
|
|
890
|
-
draftChannel: config.channel === "draft",
|
|
891
|
-
bearerToken: await resolveBearerToken(config),
|
|
892
|
-
orgId: config.orgId,
|
|
893
|
-
extra: config.headers
|
|
894
|
-
}),
|
|
947
|
+
headers,
|
|
895
948
|
...config.requestInit
|
|
896
949
|
});
|
|
897
950
|
} catch (err) {
|
|
@@ -963,6 +1016,7 @@ function authClientConfig(env, options) {
|
|
|
963
1016
|
channel: isFoirPreview(env) ? "draft" : "published",
|
|
964
1017
|
bearerToken: options?.bearerToken,
|
|
965
1018
|
orgId: options?.orgId,
|
|
1019
|
+
orgUrl: options?.orgUrl,
|
|
966
1020
|
headers: options?.headers,
|
|
967
1021
|
fetch: options?.fetch
|
|
968
1022
|
};
|
package/dist/client.js
CHANGED
|
@@ -115,19 +115,46 @@ function readFoirRetryAfterMs(headers) {
|
|
|
115
115
|
const seconds = Number(headers.get("Retry-After"));
|
|
116
116
|
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
|
|
117
117
|
}
|
|
118
|
+
var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
|
|
119
|
+
"x-api-key",
|
|
120
|
+
"authorization",
|
|
121
|
+
"x-foir-token",
|
|
122
|
+
"x-foir-context"
|
|
123
|
+
]);
|
|
124
|
+
function refuseCredentialHeaders(extra) {
|
|
125
|
+
if (!extra) return;
|
|
126
|
+
for (const name of Object.keys(extra)) {
|
|
127
|
+
if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
|
|
128
|
+
throw new FoirRequestError(
|
|
129
|
+
`[foir] a client built from a dispatch token cannot send "${name}" in \`headers\`. The dispatch token is the credential, and an api key or a bearer beside it would make the request two actors at once. Build a separate client for the other credential.`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
118
134
|
function buildFoirHeaders(input) {
|
|
119
135
|
const headers = {
|
|
120
|
-
"Content-Type": "application/json"
|
|
121
|
-
"x-api-key": input.apiKey
|
|
136
|
+
"Content-Type": "application/json"
|
|
122
137
|
};
|
|
123
|
-
if (input.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
headers["
|
|
138
|
+
if (input.dispatchToken !== void 0) {
|
|
139
|
+
refuseCredentialHeaders(input.extra);
|
|
140
|
+
headers["X-Foir-Token"] = input.dispatchToken;
|
|
141
|
+
if (input.dispatchContext) {
|
|
142
|
+
headers["X-Foir-Context"] = input.dispatchContext;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
headers["x-api-key"] = input.apiKey;
|
|
146
|
+
if (input.draftChannel) {
|
|
147
|
+
headers["X-Foir-Schema-Channel"] = "draft";
|
|
148
|
+
}
|
|
149
|
+
if (input.bearerToken) {
|
|
150
|
+
headers["Authorization"] = `Bearer ${input.bearerToken}`;
|
|
151
|
+
}
|
|
152
|
+
if (input.orgId) {
|
|
153
|
+
headers["X-Org-Id"] = input.orgId;
|
|
154
|
+
}
|
|
155
|
+
if (input.orgUrl) {
|
|
156
|
+
headers["X-Org-Url"] = input.orgUrl;
|
|
157
|
+
}
|
|
131
158
|
}
|
|
132
159
|
if (input.extra) {
|
|
133
160
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -164,12 +191,15 @@ function refusePrunedDocument(json, options) {
|
|
|
164
191
|
`[foir] selections stripped from this query: ${selections.map(describe).join(", ")}`
|
|
165
192
|
);
|
|
166
193
|
}
|
|
194
|
+
function answeredAnything(data) {
|
|
195
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
196
|
+
}
|
|
167
197
|
function readFoirEnvelope(json, options) {
|
|
168
198
|
reportAuthDowngrade(json);
|
|
169
199
|
refusePrunedDocument(json, options);
|
|
170
200
|
if (json.errors?.length) {
|
|
171
201
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
172
|
-
if (json.data && !options?.strict) {
|
|
202
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
173
203
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
174
204
|
} else {
|
|
175
205
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
@@ -271,12 +301,13 @@ async function foirRequest(env, document, variables, options) {
|
|
|
271
301
|
apiKey,
|
|
272
302
|
draftChannel: isFoirPreview(env),
|
|
273
303
|
bearerToken: options?.bearerToken,
|
|
274
|
-
orgId: options?.orgId
|
|
304
|
+
orgId: options?.orgId,
|
|
305
|
+
orgUrl: options?.orgUrl
|
|
275
306
|
});
|
|
276
307
|
const documentText = print2(document);
|
|
277
308
|
let res;
|
|
278
309
|
try {
|
|
279
|
-
if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId)) {
|
|
310
|
+
if (isCdnEligible(env, documentText, options?.bearerToken, options?.orgId ?? options?.orgUrl)) {
|
|
280
311
|
const hash = await documentHash(documentText);
|
|
281
312
|
let shared = await fetch(buildCdnUrl(endpoint, apiKey, hash, variables), {
|
|
282
313
|
method: "GET",
|
package/dist/codegen/preset.d.ts
CHANGED
|
@@ -21,9 +21,10 @@ export interface FoirCodegenOptions {
|
|
|
21
21
|
*/
|
|
22
22
|
apiKey?: string;
|
|
23
23
|
/**
|
|
24
|
-
* Preview
|
|
24
|
+
* Preview key holding `drafts:read`. Reads the DRAFT schema channel so
|
|
25
25
|
* freshly pushed (un-promoted) model changes are visible to codegen.
|
|
26
|
-
* Strongly preferred
|
|
26
|
+
* Strongly preferred: a key without `drafts:read` only sees the published
|
|
27
|
+
* schema.
|
|
27
28
|
*/
|
|
28
29
|
previewKey?: string;
|
|
29
30
|
/** Output directory for the generated client types. Default `./app/gql/foir/`. */
|
package/dist/data/builtins.d.ts
CHANGED
|
@@ -423,10 +423,26 @@ export interface FoirResourceAccess {
|
|
|
423
423
|
public: boolean;
|
|
424
424
|
visibility: FoirVisibility;
|
|
425
425
|
}
|
|
426
|
-
/**
|
|
426
|
+
/**
|
|
427
|
+
* `updateMany`: how many rows moved, and which. `replayed` is true when the
|
|
428
|
+
* call's `idempotencyKey` was already used by an earlier attempt of the same
|
|
429
|
+
* update: nothing moved this time, and `count` and `ids` are that attempt's.
|
|
430
|
+
*/
|
|
427
431
|
export interface FoirUpdateManyPayload {
|
|
428
432
|
count: number;
|
|
429
433
|
ids: string[];
|
|
434
|
+
replayed: boolean;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* `setOwner`: who the record belongs to after an operation handed it to
|
|
438
|
+
* another customer. It carries the owner and not the record, because the
|
|
439
|
+
* operation may have been acting for the previous holder, who can no longer
|
|
440
|
+
* read it. `ownerKind` is always `'customer'` from this op.
|
|
441
|
+
*/
|
|
442
|
+
export interface FoirRecordOwner {
|
|
443
|
+
id: string;
|
|
444
|
+
ownerId: string;
|
|
445
|
+
ownerKind: string;
|
|
430
446
|
}
|
|
431
447
|
/**
|
|
432
448
|
* What a customer's note can be ABOUT: the customer-plane subset of the closed
|
package/dist/data/client.d.ts
CHANGED
|
@@ -23,8 +23,9 @@
|
|
|
23
23
|
* than named one at a time at every call site — see `resolveGeneratedConfig`
|
|
24
24
|
* below for why that indirection is worth having.
|
|
25
25
|
*
|
|
26
|
-
* The individual fields (`
|
|
27
|
-
* `selectionUnions`) still work, and still win where
|
|
26
|
+
* The individual fields (`surfaceVersion`, `surfaceHashes`, `selections`,
|
|
27
|
+
* `namespaceSelections`, `selectionUnions`) still work, and still win where
|
|
28
|
+
* both are given.
|
|
28
29
|
*
|
|
29
30
|
* The call is the same call it always was. What changed underneath is what goes
|
|
30
31
|
* on the wire: the client loads that op's selection tree, applies
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
* ---------------------------------------------------------------------------
|
|
48
49
|
*
|
|
49
50
|
* The trees ARE runtime data — that is the one thing the generated module has to
|
|
50
|
-
* carry across the type/value boundary, alongside `
|
|
51
|
+
* carry across the type/value boundary, alongside `foirSurfaceVersion`. Passing
|
|
51
52
|
* them in costs nothing and is the intended path.
|
|
52
53
|
*
|
|
53
54
|
* When they are absent the client fetches `GET /v1/manifest` once, at first use,
|
|
@@ -75,7 +76,7 @@
|
|
|
75
76
|
import type { FoirModelDefinition, FoirNamespaceOpDefinition, FoirOpDefinition, FoirSchemaDefinition } from './schema';
|
|
76
77
|
import type { FoirModelOperation, FoirNamespaceOperation } from './selection';
|
|
77
78
|
import { type FoirEnv } from '../transport/foir-http';
|
|
78
|
-
import { type FoirClientConfig, type
|
|
79
|
+
import { type FoirClientConfig, type FoirKeyGeneratedModule, type FoirKeyClientConfig } from './transport';
|
|
79
80
|
import { type FoirModelSubscriptions, type FoirNamespaceSubscriptions } from './subscriptions';
|
|
80
81
|
/**
|
|
81
82
|
* The ops available on one model for this credential, plus its subscriptions.
|
|
@@ -230,8 +231,9 @@ export declare function createFoirClient<S extends FoirSchemaDefinition = FoirSc
|
|
|
230
231
|
* `createFoirClient` from the `PLATFORM_API_URL` / `PLATFORM_API_KEY` /
|
|
231
232
|
* `FOIR_PREVIEW` convention `@foir/sdk/client` already uses, so a storefront
|
|
232
233
|
* that has an env wired for GraphQL can add the data client without a second
|
|
233
|
-
* set of variables. Preview picks
|
|
234
|
-
* draft schema channel,
|
|
234
|
+
* set of variables. Preview picks `PLATFORM_PREVIEW_API_KEY`, the preview
|
|
235
|
+
* environment's own key holding `drafts:read`, and the draft schema channel,
|
|
236
|
+
* exactly as `foirRequest` does.
|
|
235
237
|
*
|
|
236
238
|
* ```ts
|
|
237
239
|
* import * as generated from './foir.generated';
|
|
@@ -241,7 +243,7 @@ export declare function createFoirClient<S extends FoirSchemaDefinition = FoirSc
|
|
|
241
243
|
* `generated` is spelled out in the overrides type rather than left inside the
|
|
242
244
|
* `Omit<…>` so `S` is inferred from it here exactly as it is on `createFoirClient`.
|
|
243
245
|
*/
|
|
244
|
-
export declare function createFoirClientFromEnv<S extends FoirSchemaDefinition = FoirSchemaDefinition>(env: FoirEnv, overrides?: Omit<
|
|
245
|
-
generated?:
|
|
246
|
+
export declare function createFoirClientFromEnv<S extends FoirSchemaDefinition = FoirSchemaDefinition>(env: FoirEnv, overrides?: Omit<FoirKeyClientConfig, 'apiUrl' | 'apiKey' | 'channel' | 'generated'> & {
|
|
247
|
+
generated?: FoirKeyGeneratedModule<S>;
|
|
246
248
|
}): FoirClient<S>;
|
|
247
249
|
export {};
|