@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +353 -2
  2. package/README.md +10 -2
  3. package/dist/app/client.d.ts +371 -0
  4. package/dist/app/contract/index.d.ts +23 -0
  5. package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +1465 -0
  6. package/dist/app/contract/proto/apps/v1/discovery_pb.d.ts +678 -0
  7. package/dist/app/dispatch.d.ts +64 -0
  8. package/dist/app/errors.d.ts +50 -0
  9. package/dist/app/index.d.ts +40 -0
  10. package/dist/app/manifest.d.ts +56 -0
  11. package/dist/app/naming.d.ts +31 -0
  12. package/dist/app/record-key.d.ts +17 -0
  13. package/dist/app/ticket.d.ts +37 -0
  14. package/dist/app/vocabulary.generated.d.ts +20 -0
  15. package/dist/app-contract.js +124 -0
  16. package/dist/app.js +998 -0
  17. package/dist/auth/auth.d.ts +24 -6
  18. package/dist/auth/index.d.ts +2 -1
  19. package/dist/auth/ops.d.ts +8 -0
  20. package/dist/auth.js +230 -60
  21. package/dist/client.js +80 -15
  22. package/dist/codegen/preset.d.ts +3 -2
  23. package/dist/data/builtins.d.ts +17 -1
  24. package/dist/data/client.d.ts +10 -8
  25. package/dist/data/document.d.ts +13 -0
  26. package/dist/data/index.d.ts +15 -5
  27. package/dist/data/manifest.d.ts +31 -3
  28. package/dist/data/schema.d.ts +16 -9
  29. package/dist/data/selection.d.ts +15 -0
  30. package/dist/data/transport.d.ts +277 -23
  31. package/dist/data/ws-transport.d.ts +8 -2
  32. package/dist/data.js +313 -72
  33. package/dist/graphql/client.d.ts +1 -1
  34. package/dist/graphql/foir-request.d.ts +1 -1
  35. package/dist/index.js +0 -9
  36. package/dist/live/drift.d.ts +9 -10
  37. package/dist/live.js +1 -1
  38. package/dist/next.js +158 -37
  39. package/dist/placement/index.d.ts +129 -0
  40. package/dist/placement.js +144 -0
  41. package/dist/push.js +212 -50
  42. package/dist/react.js +158 -37
  43. package/dist/server/callback-client.d.ts +30 -9
  44. package/dist/server/continue-run.d.ts +56 -0
  45. package/dist/server/index.d.ts +6 -3
  46. package/dist/server/operation-types.d.ts +114 -65
  47. package/dist/server/read-dispatch.d.ts +53 -0
  48. package/dist/server/require-scoped-token.d.ts +71 -7
  49. package/dist/server/verify-scoped-token.d.ts +109 -3
  50. package/dist/server.js +566 -138
  51. package/dist/transport/foir-http.d.ts +103 -5
  52. package/package.json +24 -6
package/dist/data.js CHANGED
@@ -62,7 +62,12 @@ var FoirRequestError = class extends Error {
62
62
  * failed on the first attempt for an hour.
63
63
  */
64
64
  get retryable() {
65
- if (this.status === void 0) return true;
65
+ if (this.status === void 0) {
66
+ if (this.graphQLErrors?.length || this.prunedSelections?.length) {
67
+ return this.code === "RATE_LIMITED" || this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
68
+ }
69
+ return true;
70
+ }
66
71
  if (this.status === 429 || this.code === "RATE_LIMITED") return true;
67
72
  if (this.status >= 500) return true;
68
73
  return this.code === "UNAVAILABLE" || this.code === "TIMEOUT";
@@ -76,19 +81,50 @@ var FoirRequestError = class extends Error {
76
81
  return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
77
82
  }
78
83
  };
84
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
85
+ "x-api-key",
86
+ "authorization",
87
+ "x-foir-token",
88
+ "x-foir-context"
89
+ ]);
90
+ function refuseCredentialHeaders(extra) {
91
+ if (!extra) return;
92
+ for (const name of Object.keys(extra)) {
93
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
94
+ throw new FoirRequestError(
95
+ `[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.`
96
+ );
97
+ }
98
+ }
99
+ }
100
+ var FOIR_VISITOR_IP_HEADER = "X-Foir-Visitor-IP";
79
101
  function buildFoirHeaders(input) {
80
102
  const headers = {
81
- "Content-Type": "application/json",
82
- "x-api-key": input.apiKey
103
+ "Content-Type": "application/json"
83
104
  };
84
- if (input.draftChannel) {
85
- headers["X-Foir-Schema-Channel"] = "draft";
86
- }
87
- if (input.bearerToken) {
88
- headers["Authorization"] = `Bearer ${input.bearerToken}`;
89
- }
90
- if (input.orgId) {
91
- headers["X-Org-Id"] = input.orgId;
105
+ if (input.dispatchToken !== void 0) {
106
+ refuseCredentialHeaders(input.extra);
107
+ headers["X-Foir-Token"] = input.dispatchToken;
108
+ if (input.dispatchContext) {
109
+ headers["X-Foir-Context"] = input.dispatchContext;
110
+ }
111
+ } else {
112
+ headers["x-api-key"] = input.apiKey;
113
+ if (input.draftChannel) {
114
+ headers["X-Foir-Schema-Channel"] = "draft";
115
+ }
116
+ if (input.bearerToken) {
117
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
118
+ }
119
+ if (input.orgId) {
120
+ headers["X-Org-Id"] = input.orgId;
121
+ }
122
+ if (input.orgUrl) {
123
+ headers["X-Org-Url"] = input.orgUrl;
124
+ }
125
+ if (input.visitorAddress) {
126
+ headers[FOIR_VISITOR_IP_HEADER] = input.visitorAddress;
127
+ }
92
128
  }
93
129
  if (input.extra) {
94
130
  for (const [key, value] of Object.entries(input.extra)) {
@@ -104,6 +140,26 @@ function foirApiEndpoint(apiUrl, path) {
104
140
  }
105
141
  return base + path;
106
142
  }
143
+ var FoirAuthDowngradedError = class extends FoirRequestError {
144
+ downgrade;
145
+ constructor(downgrade) {
146
+ super(
147
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}), so the request was served as ${downgrade.servedAs}`,
148
+ { graphQLErrors: [{ message: "customer token rejected", extensions: { code: "AUTH_DOWNGRADED" } }] }
149
+ );
150
+ this.name = "FoirAuthDowngradedError";
151
+ this.downgrade = downgrade;
152
+ }
153
+ };
154
+ var FoirSessionExpiredError = class extends FoirRequestError {
155
+ constructor(options) {
156
+ super("[foir] the customer session has expired and could not be refreshed: sign in again", {
157
+ cause: options?.cause,
158
+ graphQLErrors: [{ message: "session expired", extensions: { code: "SESSION_EXPIRED" } }]
159
+ });
160
+ this.name = "FoirSessionExpiredError";
161
+ }
162
+ };
107
163
  var warnedDowngrade = null;
108
164
  function reportAuthDowngrade(envelope) {
109
165
  const downgrade = envelope.extensions?.auth;
@@ -132,12 +188,18 @@ function refusePrunedDocument(json, options) {
132
188
  `[foir] selections stripped from this query: ${selections.map(describe2).join(", ")}`
133
189
  );
134
190
  }
191
+ function answeredAnything(data) {
192
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
193
+ }
135
194
  function readFoirEnvelope(json, options) {
195
+ if (options?.rejectDowngrade && json.extensions?.auth) {
196
+ throw new FoirAuthDowngradedError(json.extensions.auth);
197
+ }
136
198
  reportAuthDowngrade(json);
137
199
  refusePrunedDocument(json, options);
138
200
  if (json.errors?.length) {
139
201
  const msg = json.errors.map((e) => e.message).join("; ");
140
- if (json.data && !options?.strict) {
202
+ if (answeredAnything(json.data) && !options?.strict) {
141
203
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
142
204
  } else {
143
205
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
@@ -194,15 +256,126 @@ var FoirSchemaDriftError = class extends FoirRequestError {
194
256
  function foirDataEndpoint(apiUrl, path) {
195
257
  return foirApiEndpoint(apiUrl, path);
196
258
  }
259
+ async function resolveVisitorAddress(config) {
260
+ const source = config.visitor;
261
+ const value = typeof source === "function" ? await source() : source;
262
+ const address = value?.trim();
263
+ return address ? address : void 0;
264
+ }
197
265
  async function resolveBearerToken(config) {
198
266
  const source = config.bearerToken;
199
267
  if (typeof source === "function") return await source() ?? void 0;
200
268
  return source ?? void 0;
201
269
  }
270
+ async function resolveDispatchToken(dispatch) {
271
+ const token = (await dispatch.token())?.trim();
272
+ if (!token) {
273
+ throw new FoirRequestError(
274
+ "[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`."
275
+ );
276
+ }
277
+ return token;
278
+ }
279
+ async function resolveFoirAuth(config) {
280
+ if (config.dispatch !== void 0) {
281
+ return {
282
+ headers: buildFoirHeaders({
283
+ dispatchToken: await resolveDispatchToken(config.dispatch),
284
+ dispatchContext: config.dispatch.context ?? void 0,
285
+ extra: config.headers
286
+ })
287
+ };
288
+ }
289
+ const bearerToken = await resolveBearerToken(config);
290
+ return {
291
+ headers: buildFoirHeaders({
292
+ apiKey: config.apiKey,
293
+ draftChannel: config.channel === "draft",
294
+ bearerToken,
295
+ orgId: config.orgId,
296
+ orgUrl: config.orgUrl,
297
+ visitorAddress: await resolveVisitorAddress(config),
298
+ extra: config.headers
299
+ }),
300
+ key: { apiKey: config.apiKey, bearerToken }
301
+ };
302
+ }
303
+ function assertFoirCredential(config) {
304
+ const loose = config;
305
+ const operation = generatedOperation(config.generated);
306
+ if (loose.dispatch === void 0) {
307
+ if (typeof loose.apiKey !== "string" || loose.apiKey === "") {
308
+ throw new FoirRequestError(
309
+ "[foir] createFoirClient needs both apiUrl and apiKey (or, inside an operation endpoint, apiUrl and dispatch)"
310
+ );
311
+ }
312
+ if (loose.visitor !== void 0 && !loose.apiKey.startsWith("sk_")) {
313
+ throw new FoirRequestError(
314
+ "[foir] createFoirClient: `visitor` names the visitor a server calls for, and only a secret key holding visitors:forward may send it. A public key already calls as its visitor, and cannot hold the permission. Drop `visitor`, or build this client from your server's secret key."
315
+ );
316
+ }
317
+ if (operation !== void 0) {
318
+ throw new FoirRequestError(
319
+ `[foir] createFoirClient: the module passed as \`generated\` was generated for operation "${operation}" (\`foir types --operation ${operation}\`), so its types are what that operation's dispatch token is served, and this client holds an api key, which is served its own surface. Generate the key's types with \`foir types\`, or build this client from the dispatch token inside the operation's endpoint.`
320
+ );
321
+ }
322
+ return;
323
+ }
324
+ const keySide = [
325
+ "apiKey",
326
+ "bearerToken",
327
+ "refreshBearer",
328
+ "orgId",
329
+ "orgUrl",
330
+ "visitor",
331
+ "channel",
332
+ "wsUrl",
333
+ "webSocketImpl",
334
+ "reconnect",
335
+ "connectionParams"
336
+ ].filter((name) => loose[name] !== void 0);
337
+ if (keySide.length > 0) {
338
+ throw new FoirRequestError(
339
+ `[foir] createFoirClient was given \`dispatch\` and ${keySide.map((n) => `\`${n}\``).join(", ")}. A dispatch token and an api key are different actors, and the dispatch token carries its own customer, organization and channel. Pass one credential: \`dispatch\` inside an operation endpoint, \`apiKey\` everywhere else.`
340
+ );
341
+ }
342
+ const dispatch = loose.dispatch;
343
+ if (!dispatch || typeof dispatch.token !== "function") {
344
+ throw new FoirRequestError(
345
+ "[foir] `dispatch.token` must be a function that returns the token, e.g. `() => token`, so a refreshed token is read on each request rather than one captured at construction. `requireScopedToken` returns one ready to pass."
346
+ );
347
+ }
348
+ refuseCredentialHeaders(config.headers);
349
+ if (operation === void 0) {
350
+ throw new FoirRequestError(
351
+ "[foir] createFoirClient: a client built from a dispatch token takes the module `foir types --operation <operation key>` generated for that operation, as `generated`. Its types are what the operation's token is served; a module generated for a key types calls the token is refused, and without one nothing says which operation this is."
352
+ );
353
+ }
354
+ const dispatched = contextOperation(dispatch.context);
355
+ if (dispatched !== void 0 && dispatched !== operation) {
356
+ throw new FoirRequestError(
357
+ `[foir] createFoirClient: the module passed as \`generated\` was generated for operation "${operation}", and this endpoint was dispatched for "${dispatched}". Generate this endpoint's types with \`foir types --operation ${dispatched}\`.`
358
+ );
359
+ }
360
+ }
361
+ function generatedOperation(generated) {
362
+ if (typeof generated !== "object" || generated === null) return void 0;
363
+ const credential = generated.foirCredential;
364
+ if (typeof credential !== "object" || credential === null) return void 0;
365
+ const operation = credential.operation;
366
+ return typeof operation === "string" && operation !== "" ? operation : void 0;
367
+ }
368
+ function contextOperation(context) {
369
+ for (const part of (context ?? "").split(";")) {
370
+ const [key, value] = part.split("=", 2);
371
+ if (key?.trim() === "operation" && value) return value.trim();
372
+ }
373
+ return void 0;
374
+ }
202
375
  function foirRoute(input) {
203
376
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
204
377
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
205
- if (input.orgId) return { lane: "origin", reason: "org" };
378
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
206
379
  if (input.mutation) return { lane: "origin", reason: "operation" };
207
380
  if (input.writeToken) return { lane: "origin", reason: "floor" };
208
381
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
@@ -220,25 +393,45 @@ async function executeFoirDocument(config, plan) {
220
393
  async function executeFoirRawDocument(config, document, mutation, variables, options) {
221
394
  return await runFoirDocument(config, document, mutation, variables, options);
222
395
  }
396
+ var renewing = /* @__PURE__ */ new WeakMap();
397
+ function renewBearerOnce(refresh) {
398
+ const running = renewing.get(refresh);
399
+ if (running) return running;
400
+ const next = refresh().finally(() => renewing.delete(refresh));
401
+ renewing.set(refresh, next);
402
+ return next;
403
+ }
223
404
  async function runFoirDocument(config, document, mutation, variables, options) {
405
+ try {
406
+ return await sendFoirDocument(config, document, mutation, variables, options);
407
+ } catch (err) {
408
+ const refresh = config.refreshBearer;
409
+ if (!(err instanceof FoirAuthDowngradedError) || !refresh || err.downgrade.reason === "wrong_project") {
410
+ throw err;
411
+ }
412
+ const token = err.downgrade.reason === "expired" ? await renewBearerOnce(refresh) : null;
413
+ if (!token) throw new FoirSessionExpiredError({ cause: err });
414
+ try {
415
+ return await sendFoirDocument({ ...config, bearerToken: token }, document, mutation, variables, options);
416
+ } catch (again) {
417
+ if (again instanceof FoirAuthDowngradedError) throw new FoirSessionExpiredError({ cause: again });
418
+ throw again;
419
+ }
420
+ }
421
+ }
422
+ async function sendFoirDocument(config, document, mutation, variables, options) {
224
423
  const doFetch = config.fetch ?? globalThis.fetch;
225
424
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
226
- const bearerToken = await resolveBearerToken(config);
227
- const headers = buildFoirHeaders({
228
- apiKey: config.apiKey,
229
- draftChannel: config.channel === "draft",
230
- bearerToken,
231
- orgId: config.orgId,
232
- extra: config.headers
233
- });
425
+ const { headers, key } = await resolveFoirAuth(config);
234
426
  const hash = await documentHash(document);
235
- const route = foirRoute({
236
- apiKey: config.apiKey,
237
- bearerToken,
427
+ const sharedKey = key !== void 0 && foirRoute({
428
+ apiKey: key.apiKey,
429
+ bearerToken: key.bearerToken,
238
430
  orgId: config.orgId,
431
+ orgUrl: config.orgUrl,
239
432
  mutation,
240
433
  channel: config.channel
241
- });
434
+ }).lane === "shared" ? key.apiKey : void 0;
242
435
  const post = (body) => doFetch(endpoint, {
243
436
  method: "POST",
244
437
  headers,
@@ -248,8 +441,8 @@ async function runFoirDocument(config, document, mutation, variables, options) {
248
441
  const extensions = persistedQueryExtension(hash);
249
442
  let res;
250
443
  try {
251
- if (route.lane === "shared") {
252
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
444
+ if (sharedKey !== void 0) {
445
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
253
446
  method: "GET",
254
447
  headers,
255
448
  ...config.requestInit
@@ -275,7 +468,7 @@ async function runFoirDocument(config, document, mutation, variables, options) {
275
468
  });
276
469
  }
277
470
  const json = await res.json();
278
- return readFoirEnvelope(json, options);
471
+ return readFoirEnvelope(json, { ...options, rejectDowngrade: Boolean(key?.bearerToken) });
279
472
  }
280
473
  async function readErrorBody(res) {
281
474
  try {
@@ -287,20 +480,16 @@ async function readErrorBody(res) {
287
480
  }
288
481
 
289
482
  // src/data/manifest.ts
290
- async function fetchFoirManifest(config) {
291
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
483
+ async function fetchFoirManifest(config, options = {}) {
484
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
485
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
292
486
  const doFetch = config.fetch ?? globalThis.fetch;
487
+ const { headers } = await resolveFoirAuth(config);
293
488
  let res;
294
489
  try {
295
490
  res = await doFetch(url, {
296
491
  method: "GET",
297
- headers: buildFoirHeaders({
298
- apiKey: config.apiKey,
299
- draftChannel: config.channel === "draft",
300
- bearerToken: await resolveBearerToken(config),
301
- orgId: config.orgId,
302
- extra: config.headers
303
- }),
492
+ headers,
304
493
  ...config.requestInit
305
494
  });
306
495
  } catch (err) {
@@ -705,6 +894,13 @@ function planFoirModelCall(source, call) {
705
894
  }
706
895
  return planCall(source, opDef, `${call.model}.${call.op}`, operationName(model.typeName, call.op), call);
707
896
  }
897
+ var CREATING_OPS = /* @__PURE__ */ new Set(["create", "submit"]);
898
+ function withCreateIdempotencyKey(source, model, op, args) {
899
+ if (!CREATING_OPS.has(op) || args.idempotencyKey !== void 0) return args;
900
+ const argTypes = source.models[model]?.ops?.[op]?.argTypes;
901
+ if (!argTypes || !("idempotencyKey" in argTypes)) return args;
902
+ return { ...args, idempotencyKey: globalThis.crypto.randomUUID() };
903
+ }
708
904
  function planFoirNamespaceCall(source, call) {
709
905
  const namespace = source.namespaces?.[call.namespace];
710
906
  if (!namespace) {
@@ -862,6 +1058,11 @@ var FoirSocketTransport = class {
862
1058
  keepAlive = null;
863
1059
  retryTimer = null;
864
1060
  constructor(config) {
1061
+ if (config.dispatch !== void 0) {
1062
+ throw new FoirRequestError(
1063
+ "[foir] a client built from a dispatch token cannot subscribe. The subscription socket authenticates an api key, an admin token or a customer bearer, and has no path for a dispatch token. Queries and mutations made as the operation go to /graphql and work as they are."
1064
+ );
1065
+ }
865
1066
  this.config = config;
866
1067
  this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
867
1068
  }
@@ -933,13 +1134,7 @@ var FoirSocketTransport = class {
933
1134
  async sendConnectionInit(socket) {
934
1135
  this.connecting = false;
935
1136
  try {
936
- const headers = buildFoirHeaders({
937
- apiKey: this.config.apiKey,
938
- draftChannel: this.config.channel === "draft",
939
- bearerToken: await resolveBearerToken(this.config),
940
- orgId: this.config.orgId,
941
- extra: this.config.headers
942
- });
1137
+ const { headers } = await resolveFoirAuth(this.config);
943
1138
  const { "Content-Type": _contentType, ...params } = headers;
944
1139
  const extra = await this.config.connectionParams?.() ?? {};
945
1140
  this.send(socket, {
@@ -1546,7 +1741,8 @@ function splitQuery(query) {
1546
1741
  return { args, include, omit, pick, depth };
1547
1742
  }
1548
1743
  var GENERATED_EXPORTS = [
1549
- "foirSchemaVersion",
1744
+ "foirSurfaceVersion",
1745
+ "foirSurfaceHashes",
1550
1746
  "foirSelections",
1551
1747
  "foirNamespaceSelections",
1552
1748
  "foirSelectionUnions"
@@ -1566,10 +1762,16 @@ function resolveGeneratedConfig(config) {
1566
1762
  `[foir] createFoirClient: the module passed as \`generated\` is missing ${missing.join(", ")}. \`foir types\` emits ${GENERATED_EXPORTS.join(", ")} together, so a module missing one is stale or hand-assembled \u2014 re-run \`foir types\`. Reading the rest and fetching \`GET /v1/manifest\` for the remainder would hide that; omit \`generated\` entirely to ask for the manifest deliberately.`
1567
1763
  );
1568
1764
  }
1569
- const version = generated["foirSchemaVersion"];
1570
- if (typeof version !== "number" || !Number.isFinite(version)) {
1765
+ const version = generated["foirSurfaceVersion"];
1766
+ if (typeof version !== "string" || version === "") {
1767
+ throw new FoirRequestError(
1768
+ "[foir] createFoirClient: `generated.foirSurfaceVersion` is not a surface version. It is what the drift check compares against the server. Re-run `foir types`."
1769
+ );
1770
+ }
1771
+ const hashes = generated["foirSurfaceHashes"];
1772
+ if (!isRecord(hashes) || Object.values(hashes).some((h) => typeof h !== "string")) {
1571
1773
  throw new FoirRequestError(
1572
- "[foir] createFoirClient: `generated.foirSchemaVersion` is not a number. It is the manifest version the drift check compares against \u2014 re-run `foir types`."
1774
+ "[foir] createFoirClient: `generated.foirSurfaceHashes` is not an object of component -> hash. It is how a drift names what moved. Re-run `foir types`."
1573
1775
  );
1574
1776
  }
1575
1777
  const selections = generated["foirSelections"];
@@ -1610,13 +1812,57 @@ function resolveGeneratedConfig(config) {
1610
1812
  }
1611
1813
  return {
1612
1814
  ...config,
1613
- schemaVersion: config.schemaVersion ?? version,
1815
+ surfaceVersion: config.surfaceVersion ?? version,
1816
+ surfaceHashes: config.surfaceHashes ?? hashes,
1614
1817
  selections: config.selections ?? selections,
1615
1818
  namespaceSelections: config.namespaceSelections ?? namespaceSelections,
1616
1819
  selectionUnions: config.selectionUnions ?? selectionUnions,
1617
1820
  subscriptions: config.subscriptions ?? subscriptions
1618
1821
  };
1619
1822
  }
1823
+ function changedSurfaceParts(generated, served) {
1824
+ if (!generated || !served) return [];
1825
+ const out = /* @__PURE__ */ new Set();
1826
+ for (const [key, hash] of Object.entries(served)) {
1827
+ if (generated[key] !== hash) out.add(key);
1828
+ }
1829
+ for (const key of Object.keys(generated)) {
1830
+ if (served[key] === void 0) out.add(key);
1831
+ }
1832
+ return [...out].sort();
1833
+ }
1834
+ var SURFACE_SECTIONS = {
1835
+ models: ["model", "models"],
1836
+ namespaces: ["namespace", "namespaces"],
1837
+ selectionUnions: ["union", "unions"]
1838
+ };
1839
+ var SURFACE_WHOLES = {
1840
+ subscriptions: "the subscriptions",
1841
+ composedFor: "the organization it is composed for"
1842
+ };
1843
+ function describeChanged(changed) {
1844
+ const named = /* @__PURE__ */ new Map();
1845
+ const wholes = [];
1846
+ for (const part of changed) {
1847
+ const dot = part.indexOf(".");
1848
+ const section = dot < 0 ? part : part.slice(0, dot);
1849
+ if (dot < 0 || !SURFACE_SECTIONS[section]) {
1850
+ wholes.push(SURFACE_WHOLES[part] ?? part);
1851
+ continue;
1852
+ }
1853
+ named.set(section, [...named.get(section) ?? [], `\`${part.slice(dot + 1)}\``]);
1854
+ }
1855
+ const phrases = [];
1856
+ for (const [section, [one, many]] of Object.entries(SURFACE_SECTIONS)) {
1857
+ const names = named.get(section);
1858
+ if (names?.length) phrases.push(`${names.length === 1 ? one : many} ${names.join(", ")}`);
1859
+ }
1860
+ return [...phrases, ...wholes].join("; ");
1861
+ }
1862
+ function describeDrift(drift) {
1863
+ const moved = describeChanged(drift.changed);
1864
+ return `the types were generated from surface ${drift.generated}, and the server is serving ${drift.server} (${drift.channel}).` + (moved ? ` Changed since: ${moved}.` : "");
1865
+ }
1620
1866
  function announceFallback(config) {
1621
1867
  if (config.generated !== void 0 || config.selections !== void 0) return;
1622
1868
  if (config.announceManifestFallback === false) return;
@@ -1625,9 +1871,12 @@ function announceFallback(config) {
1625
1871
  );
1626
1872
  }
1627
1873
  function createFoirClient(clientConfig) {
1628
- if (!clientConfig?.apiUrl || !clientConfig?.apiKey) {
1629
- throw new Error("[foir] createFoirClient needs both apiUrl and apiKey");
1874
+ if (!clientConfig?.apiUrl) {
1875
+ throw new Error(
1876
+ "[foir] createFoirClient needs both apiUrl and apiKey (or, inside an operation endpoint, apiUrl and dispatch)"
1877
+ );
1630
1878
  }
1879
+ assertFoirCredential(clientConfig);
1631
1880
  const config = resolveGeneratedConfig(clientConfig);
1632
1881
  announceFallback(config);
1633
1882
  let driftChecked = false;
@@ -1652,31 +1901,19 @@ function createFoirClient(clientConfig) {
1652
1901
  }
1653
1902
  return manifest();
1654
1903
  };
1655
- const sameSurface = (a, b) => {
1656
- const stable = (v) => {
1657
- if (Array.isArray(v)) return v.map(stable);
1658
- if (v && typeof v === "object") {
1659
- return Object.keys(v).sort().map((k) => [k, stable(v[k])]);
1660
- }
1661
- return v;
1662
- };
1663
- return JSON.stringify(stable(a)) === JSON.stringify(stable(b));
1664
- };
1665
1904
  const maybeCheckDrift = () => {
1666
- const generated = config.schemaVersion;
1905
+ const generated = config.surfaceVersion;
1667
1906
  if (driftChecked) return;
1668
1907
  if (generated === void 0) return;
1669
1908
  if (config.checkSchemaDrift === false) return;
1670
1909
  driftChecked = true;
1671
1910
  void manifest().then((served) => {
1672
- if (served.schemaVersion === generated) return;
1673
- if (sameSurface(config.selections, served.models) && sameSurface(config.namespaceSelections ?? {}, served.namespaces ?? {}) && sameSurface(config.selectionUnions ?? {}, served.selectionUnions ?? {})) {
1674
- return;
1675
- }
1911
+ if (!served.surfaceVersion || served.surfaceVersion === generated) return;
1676
1912
  const drift = {
1677
1913
  generated,
1678
- server: served.schemaVersion,
1679
- channel: served.channel
1914
+ server: served.surfaceVersion,
1915
+ channel: served.channel,
1916
+ changed: changedSurfaceParts(config.surfaceHashes, served.surfaceHashes)
1680
1917
  };
1681
1918
  detectedDrift = drift;
1682
1919
  if (config.onSchemaDrift) {
@@ -1684,7 +1921,7 @@ function createFoirClient(clientConfig) {
1684
1921
  return;
1685
1922
  }
1686
1923
  console.warn(
1687
- `[foir] schema drift: types were generated from schemaVersion ${drift.generated}, the server is serving ${drift.server} (${drift.channel}). Re-run \`foir types\` \u2014 the selection trees this client prints from are the ones that version published, so a new field is not merely untyped, it is not requested.`
1924
+ `[foir] schema drift: ${describeDrift(drift)} Re-run \`foir types\`: the selection trees this client prints from are the ones that surface published, so a new field is not merely untyped, it is not requested.`
1688
1925
  );
1689
1926
  }).catch(() => {
1690
1927
  });
@@ -1692,17 +1929,18 @@ function createFoirClient(clientConfig) {
1692
1929
  const assertNoStaleSelections = (what) => {
1693
1930
  if (config.strictSchemaDrift === false || !detectedDrift) return;
1694
1931
  throw new FoirSchemaDriftError(
1695
- `[foir] refusing ${what}: types were generated from schemaVersion ${detectedDrift.generated}, the server is serving ${detectedDrift.server} (${detectedDrift.channel}). The selection trees this client prints from are the ones that version published, so a field added since is not requested at all. Re-run \`foir types\`. Set \`strictSchemaDrift: false\` to warn instead.`,
1932
+ `[foir] refusing ${what}: ${describeDrift(detectedDrift)} The selection trees this client prints from are the ones that surface published, so a field added since is not requested at all. Re-run \`foir types\`.`,
1696
1933
  detectedDrift
1697
1934
  );
1698
1935
  };
1699
1936
  const runModel = async (model, op, query) => {
1700
1937
  assertNoStaleSelections(`${model}.${op}`);
1701
1938
  const { args, include, omit, pick, depth } = splitQuery(query);
1702
- const plan = planFoirModelCall(await selectionSource(), {
1939
+ const source = await selectionSource();
1940
+ const plan = planFoirModelCall(source, {
1703
1941
  model,
1704
1942
  op,
1705
- args,
1943
+ args: withCreateIdempotencyKey(source, model, op, args),
1706
1944
  include,
1707
1945
  omit,
1708
1946
  pick,
@@ -2024,9 +2262,12 @@ function decimal(value) {
2024
2262
  return new FoirDecimal(value);
2025
2263
  }
2026
2264
  export {
2265
+ FOIR_VISITOR_IP_HEADER,
2266
+ FoirAuthDowngradedError,
2027
2267
  FoirDecimal,
2028
2268
  FoirRequestError,
2029
2269
  FoirSchemaDriftError,
2270
+ FoirSessionExpiredError,
2030
2271
  FoirSocketTransport,
2031
2272
  createFoirClient,
2032
2273
  createFoirClientFromEnv,
@@ -6,7 +6,7 @@
6
6
  * Consumers that bring their own GraphQL client (Apollo/urql/react-query) use
7
7
  * the generated `TypedDocumentNode`s directly and replicate those two headers.
8
8
  */
9
- export { foirRequest, isFoirPreview, absolutiseFoirMediaUrl, FOIR_CDN_BASE, FoirRequestError, } from './foir-request';
9
+ export { foirRequest, isFoirPreview, absolutiseFoirMediaUrl, FOIR_CDN_BASE, FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, } from './foir-request';
10
10
  export type { FoirEnv, FoirRequestOptions } from './foir-request';
11
11
  /**
12
12
  * Failure classification. Branch on these rather than on `err.message`: the
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import type { TypedDocumentNode } from '@graphql-typed-document-node/core';
26
26
  import { type FoirEnv, type FoirRequestOptions } from '../transport/foir-http';
27
- export { FoirRequestError, isFoirPreview, isFoirRateLimited, isFoirUnauthenticated, isFoirForbidden, isFoirNotFound, readFoirRateLimit, type FoirEnv, type FoirErrorCode, type FoirErrorExtensions, type FoirGraphQLErrorItem, type FoirRateLimitSnapshot, type FoirRequestOptions, type FoirAuthDowngrade, } from '../transport/foir-http';
27
+ export { FoirRequestError, FoirAuthDowngradedError, FoirSessionExpiredError, isFoirPreview, isFoirRateLimited, isFoirUnauthenticated, isFoirForbidden, isFoirNotFound, readFoirRateLimit, type FoirEnv, type FoirErrorCode, type FoirErrorExtensions, type FoirGraphQLErrorItem, type FoirRateLimitSnapshot, type FoirRequestOptions, type FoirAuthDowngrade, } from '../transport/foir-http';
28
28
  export { withFoirRetry, foirRetryDelayMs, type FoirRetryPolicy, } from '../transport/retry';
29
29
  /** Default Foir CDN origin used to absolutise relative media URLs. */
30
30
  export declare const FOIR_CDN_BASE = "https://cdn.foir.dev/";
package/dist/index.js CHANGED
@@ -256,15 +256,6 @@ function EditorProvider({
256
256
  type: "request-operation",
257
257
  payload: { operationKey, input, requestId, mode: options?.mode ?? "sync" }
258
258
  });
259
- setTimeout(() => {
260
- if (pendingOperationsRef.current.has(requestId)) {
261
- pendingOperationsRef.current.delete(requestId);
262
- resolve({
263
- success: false,
264
- error: { code: "TIMEOUT", message: "Operation timed out" }
265
- });
266
- }
267
- }, 6e4);
268
259
  });
269
260
  },
270
261
  [postMessage]
@@ -15,7 +15,7 @@ import type { FoirSchemaDrift } from '../data/transport';
15
15
  * longer exists — until the tab is reloaded, which on a long-lived admin
16
16
  * session can be days.
17
17
  *
18
- * The SDK already detects this: the data client compares the manifest version
18
+ * The SDK already detects this: the data client compares the surface version
19
19
  * its generated types came from against the one the server is serving, and
20
20
  * reports `FoirSchemaDrift`. What was missing is anyone acting on it.
21
21
  *
@@ -36,16 +36,15 @@ export interface FoirDriftHandlerOptions {
36
36
  /**
37
37
  * Whether a reported drift is worth clearing the cache for.
38
38
  *
39
- * A server AHEAD of the generated types is the deploy case, and it is the one
40
- * that matters: entries resolved against the old shape are still being served
41
- * to code that was compiled for it.
39
+ * Any surface other than the one the types were generated from is. The versions
40
+ * are content hashes, so there is no ahead or behind: a deploy that added a
41
+ * field and a rollback that removed one are both a different surface, and in
42
+ * both the cache holds responses resolved against a shape the code no longer
43
+ * reads, which is the torn state this exists to remove.
42
44
  *
43
- * A server BEHIND is a rollback, or a client that reached an older replica
44
- * mid-deploy. Clearing there is defensible too, and it is deliberately included:
45
- * the cache holds responses from BOTH versions in that window, which is the
46
- * torn state this exists to remove. What is NOT actionable is equality — a
47
- * client reporting no drift at all, which the detector should not produce but
48
- * which costs nothing to refuse here rather than to clear the world for.
45
+ * What is NOT actionable is equality, or a version missing on either side: a
46
+ * report the detector should not produce, and one that costs nothing to refuse
47
+ * here rather than to clear the world for.
49
48
  */
50
49
  export declare function foirDriftIsActionable(drift: FoirSchemaDrift): boolean;
51
50
  /**
package/dist/live.js CHANGED
@@ -800,7 +800,7 @@ function createFoirCoalescer(options) {
800
800
 
801
801
  // src/live/drift.ts
802
802
  function foirDriftIsActionable(drift) {
803
- return Number.isFinite(drift.generated) && Number.isFinite(drift.server) && drift.generated !== drift.server;
803
+ return Boolean(drift.generated) && Boolean(drift.server) && drift.generated !== drift.server;
804
804
  }
805
805
  function createFoirDriftHandler(options) {
806
806
  return (drift) => {