@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 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 four fields it replaces (`schemaVersion`, `selections`,
82
+ The fields it replaces (`surfaceVersion`, `surfaceHashes`, `selections`,
83
83
  `namespaceSelections`, `selectionUnions`) still work and still win where both
84
- are given, so an existing client keeps compiling and behaving exactly as it did.
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,
@@ -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.draftChannel) {
112
- headers["X-Foir-Schema-Channel"] = "draft";
113
- }
114
- if (input.bearerToken) {
115
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
116
- }
117
- if (input.orgId) {
118
- headers["X-Org-Id"] = input.orgId;
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 bearerToken = await resolveBearerToken(config);
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 route = foirRoute({
827
- apiKey: config.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 (route.lane === "shared") {
843
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
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 url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
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: buildFoirHeaders({
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.draftChannel) {
124
- headers["X-Foir-Schema-Channel"] = "draft";
125
- }
126
- if (input.bearerToken) {
127
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
128
- }
129
- if (input.orgId) {
130
- headers["X-Org-Id"] = input.orgId;
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",
@@ -21,9 +21,10 @@ export interface FoirCodegenOptions {
21
21
  */
22
22
  apiKey?: string;
23
23
  /**
24
- * Preview (`sk_`) key with `drafts:read`. Reads the DRAFT schema channel so
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 — a `pk_` key only sees the published schema.
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/`. */
@@ -423,10 +423,26 @@ export interface FoirResourceAccess {
423
423
  public: boolean;
424
424
  visibility: FoirVisibility;
425
425
  }
426
- /** `updateMany` — how many rows moved, and which. */
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
@@ -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 (`schemaVersion`, `selections`, `namespaceSelections`,
27
- * `selectionUnions`) still work, and still win where both are given.
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 `foirSchemaVersion`. Passing
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 FoirGeneratedModule } from './transport';
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 the `sk_` `PLATFORM_PREVIEW_API_KEY` and the
234
- * draft schema channel, exactly as `foirRequest` does.
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<FoirClientConfig, 'apiUrl' | 'apiKey' | 'channel' | 'generated'> & {
245
- generated?: FoirGeneratedModule<S>;
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 {};