@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/dist/data.js CHANGED
@@ -76,19 +76,46 @@ var FoirRequestError = class extends Error {
76
76
  return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
77
77
  }
78
78
  };
79
+ var DISPATCH_RESERVED_HEADERS = /* @__PURE__ */ new Set([
80
+ "x-api-key",
81
+ "authorization",
82
+ "x-foir-token",
83
+ "x-foir-context"
84
+ ]);
85
+ function refuseCredentialHeaders(extra) {
86
+ if (!extra) return;
87
+ for (const name of Object.keys(extra)) {
88
+ if (DISPATCH_RESERVED_HEADERS.has(name.toLowerCase())) {
89
+ throw new FoirRequestError(
90
+ `[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.`
91
+ );
92
+ }
93
+ }
94
+ }
79
95
  function buildFoirHeaders(input) {
80
96
  const headers = {
81
- "Content-Type": "application/json",
82
- "x-api-key": input.apiKey
97
+ "Content-Type": "application/json"
83
98
  };
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;
99
+ if (input.dispatchToken !== void 0) {
100
+ refuseCredentialHeaders(input.extra);
101
+ headers["X-Foir-Token"] = input.dispatchToken;
102
+ if (input.dispatchContext) {
103
+ headers["X-Foir-Context"] = input.dispatchContext;
104
+ }
105
+ } else {
106
+ headers["x-api-key"] = input.apiKey;
107
+ if (input.draftChannel) {
108
+ headers["X-Foir-Schema-Channel"] = "draft";
109
+ }
110
+ if (input.bearerToken) {
111
+ headers["Authorization"] = `Bearer ${input.bearerToken}`;
112
+ }
113
+ if (input.orgId) {
114
+ headers["X-Org-Id"] = input.orgId;
115
+ }
116
+ if (input.orgUrl) {
117
+ headers["X-Org-Url"] = input.orgUrl;
118
+ }
92
119
  }
93
120
  if (input.extra) {
94
121
  for (const [key, value] of Object.entries(input.extra)) {
@@ -132,12 +159,15 @@ function refusePrunedDocument(json, options) {
132
159
  `[foir] selections stripped from this query: ${selections.map(describe2).join(", ")}`
133
160
  );
134
161
  }
162
+ function answeredAnything(data) {
163
+ return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
164
+ }
135
165
  function readFoirEnvelope(json, options) {
136
166
  reportAuthDowngrade(json);
137
167
  refusePrunedDocument(json, options);
138
168
  if (json.errors?.length) {
139
169
  const msg = json.errors.map((e) => e.message).join("; ");
140
- if (json.data && !options?.strict) {
170
+ if (answeredAnything(json.data) && !options?.strict) {
141
171
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
142
172
  } else {
143
173
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
@@ -199,10 +229,107 @@ async function resolveBearerToken(config) {
199
229
  if (typeof source === "function") return await source() ?? void 0;
200
230
  return source ?? void 0;
201
231
  }
232
+ async function resolveDispatchToken(dispatch) {
233
+ const token = (await dispatch.token())?.trim();
234
+ if (!token) {
235
+ throw new FoirRequestError(
236
+ "[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`."
237
+ );
238
+ }
239
+ return token;
240
+ }
241
+ async function resolveFoirAuth(config) {
242
+ if (config.dispatch !== void 0) {
243
+ return {
244
+ headers: buildFoirHeaders({
245
+ dispatchToken: await resolveDispatchToken(config.dispatch),
246
+ dispatchContext: config.dispatch.context ?? void 0,
247
+ extra: config.headers
248
+ })
249
+ };
250
+ }
251
+ const bearerToken = await resolveBearerToken(config);
252
+ return {
253
+ headers: buildFoirHeaders({
254
+ apiKey: config.apiKey,
255
+ draftChannel: config.channel === "draft",
256
+ bearerToken,
257
+ orgId: config.orgId,
258
+ orgUrl: config.orgUrl,
259
+ extra: config.headers
260
+ }),
261
+ key: { apiKey: config.apiKey, bearerToken }
262
+ };
263
+ }
264
+ function assertFoirCredential(config) {
265
+ const loose = config;
266
+ const operation = generatedOperation(config.generated);
267
+ if (loose.dispatch === void 0) {
268
+ if (typeof loose.apiKey !== "string" || loose.apiKey === "") {
269
+ throw new FoirRequestError(
270
+ "[foir] createFoirClient needs both apiUrl and apiKey (or, inside an operation endpoint, apiUrl and dispatch)"
271
+ );
272
+ }
273
+ if (operation !== void 0) {
274
+ throw new FoirRequestError(
275
+ `[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.`
276
+ );
277
+ }
278
+ return;
279
+ }
280
+ const keySide = [
281
+ "apiKey",
282
+ "bearerToken",
283
+ "orgId",
284
+ "orgUrl",
285
+ "channel",
286
+ "wsUrl",
287
+ "webSocketImpl",
288
+ "reconnect",
289
+ "connectionParams"
290
+ ].filter((name) => loose[name] !== void 0);
291
+ if (keySide.length > 0) {
292
+ throw new FoirRequestError(
293
+ `[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.`
294
+ );
295
+ }
296
+ const dispatch = loose.dispatch;
297
+ if (!dispatch || typeof dispatch.token !== "function") {
298
+ throw new FoirRequestError(
299
+ "[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."
300
+ );
301
+ }
302
+ refuseCredentialHeaders(config.headers);
303
+ if (operation === void 0) {
304
+ throw new FoirRequestError(
305
+ "[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."
306
+ );
307
+ }
308
+ const dispatched = contextOperation(dispatch.context);
309
+ if (dispatched !== void 0 && dispatched !== operation) {
310
+ throw new FoirRequestError(
311
+ `[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}\`.`
312
+ );
313
+ }
314
+ }
315
+ function generatedOperation(generated) {
316
+ if (typeof generated !== "object" || generated === null) return void 0;
317
+ const credential = generated.foirCredential;
318
+ if (typeof credential !== "object" || credential === null) return void 0;
319
+ const operation = credential.operation;
320
+ return typeof operation === "string" && operation !== "" ? operation : void 0;
321
+ }
322
+ function contextOperation(context) {
323
+ for (const part of (context ?? "").split(";")) {
324
+ const [key, value] = part.split("=", 2);
325
+ if (key?.trim() === "operation" && value) return value.trim();
326
+ }
327
+ return void 0;
328
+ }
202
329
  function foirRoute(input) {
203
330
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
204
331
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
205
- if (input.orgId) return { lane: "origin", reason: "org" };
332
+ if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
206
333
  if (input.mutation) return { lane: "origin", reason: "operation" };
207
334
  if (input.writeToken) return { lane: "origin", reason: "floor" };
208
335
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
@@ -223,22 +350,16 @@ async function executeFoirRawDocument(config, document, mutation, variables, opt
223
350
  async function runFoirDocument(config, document, mutation, variables, options) {
224
351
  const doFetch = config.fetch ?? globalThis.fetch;
225
352
  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
- });
353
+ const { headers, key } = await resolveFoirAuth(config);
234
354
  const hash = await documentHash(document);
235
- const route = foirRoute({
236
- apiKey: config.apiKey,
237
- bearerToken,
355
+ const sharedKey = key !== void 0 && foirRoute({
356
+ apiKey: key.apiKey,
357
+ bearerToken: key.bearerToken,
238
358
  orgId: config.orgId,
359
+ orgUrl: config.orgUrl,
239
360
  mutation,
240
361
  channel: config.channel
241
- });
362
+ }).lane === "shared" ? key.apiKey : void 0;
242
363
  const post = (body) => doFetch(endpoint, {
243
364
  method: "POST",
244
365
  headers,
@@ -248,8 +369,8 @@ async function runFoirDocument(config, document, mutation, variables, options) {
248
369
  const extensions = persistedQueryExtension(hash);
249
370
  let res;
250
371
  try {
251
- if (route.lane === "shared") {
252
- res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
372
+ if (sharedKey !== void 0) {
373
+ res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
253
374
  method: "GET",
254
375
  headers,
255
376
  ...config.requestInit
@@ -287,20 +408,16 @@ async function readErrorBody(res) {
287
408
  }
288
409
 
289
410
  // src/data/manifest.ts
290
- async function fetchFoirManifest(config) {
291
- const url = foirDataEndpoint(config.apiUrl, "/v1/manifest");
411
+ async function fetchFoirManifest(config, options = {}) {
412
+ const base = foirDataEndpoint(config.apiUrl, "/v1/manifest");
413
+ const url = options.operation === void 0 ? base : `${base}?operation=${encodeURIComponent(options.operation)}`;
292
414
  const doFetch = config.fetch ?? globalThis.fetch;
415
+ const { headers } = await resolveFoirAuth(config);
293
416
  let res;
294
417
  try {
295
418
  res = await doFetch(url, {
296
419
  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
- }),
420
+ headers,
304
421
  ...config.requestInit
305
422
  });
306
423
  } catch (err) {
@@ -705,6 +822,13 @@ function planFoirModelCall(source, call) {
705
822
  }
706
823
  return planCall(source, opDef, `${call.model}.${call.op}`, operationName(model.typeName, call.op), call);
707
824
  }
825
+ var CREATING_OPS = /* @__PURE__ */ new Set(["create", "submit"]);
826
+ function withCreateIdempotencyKey(source, model, op, args) {
827
+ if (!CREATING_OPS.has(op) || args.idempotencyKey !== void 0) return args;
828
+ const argTypes = source.models[model]?.ops?.[op]?.argTypes;
829
+ if (!argTypes || !("idempotencyKey" in argTypes)) return args;
830
+ return { ...args, idempotencyKey: globalThis.crypto.randomUUID() };
831
+ }
708
832
  function planFoirNamespaceCall(source, call) {
709
833
  const namespace = source.namespaces?.[call.namespace];
710
834
  if (!namespace) {
@@ -862,6 +986,11 @@ var FoirSocketTransport = class {
862
986
  keepAlive = null;
863
987
  retryTimer = null;
864
988
  constructor(config) {
989
+ if (config.dispatch !== void 0) {
990
+ throw new FoirRequestError(
991
+ "[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."
992
+ );
993
+ }
865
994
  this.config = config;
866
995
  this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
867
996
  }
@@ -933,13 +1062,7 @@ var FoirSocketTransport = class {
933
1062
  async sendConnectionInit(socket) {
934
1063
  this.connecting = false;
935
1064
  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
- });
1065
+ const { headers } = await resolveFoirAuth(this.config);
943
1066
  const { "Content-Type": _contentType, ...params } = headers;
944
1067
  const extra = await this.config.connectionParams?.() ?? {};
945
1068
  this.send(socket, {
@@ -1546,7 +1669,8 @@ function splitQuery(query) {
1546
1669
  return { args, include, omit, pick, depth };
1547
1670
  }
1548
1671
  var GENERATED_EXPORTS = [
1549
- "foirSchemaVersion",
1672
+ "foirSurfaceVersion",
1673
+ "foirSurfaceHashes",
1550
1674
  "foirSelections",
1551
1675
  "foirNamespaceSelections",
1552
1676
  "foirSelectionUnions"
@@ -1566,10 +1690,16 @@ function resolveGeneratedConfig(config) {
1566
1690
  `[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
1691
  );
1568
1692
  }
1569
- const version = generated["foirSchemaVersion"];
1570
- if (typeof version !== "number" || !Number.isFinite(version)) {
1693
+ const version = generated["foirSurfaceVersion"];
1694
+ if (typeof version !== "string" || version === "") {
1695
+ throw new FoirRequestError(
1696
+ "[foir] createFoirClient: `generated.foirSurfaceVersion` is not a surface version. It is what the drift check compares against the server. Re-run `foir types`."
1697
+ );
1698
+ }
1699
+ const hashes = generated["foirSurfaceHashes"];
1700
+ if (!isRecord(hashes) || Object.values(hashes).some((h) => typeof h !== "string")) {
1571
1701
  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`."
1702
+ "[foir] createFoirClient: `generated.foirSurfaceHashes` is not an object of component -> hash. It is how a drift names what moved. Re-run `foir types`."
1573
1703
  );
1574
1704
  }
1575
1705
  const selections = generated["foirSelections"];
@@ -1610,13 +1740,57 @@ function resolveGeneratedConfig(config) {
1610
1740
  }
1611
1741
  return {
1612
1742
  ...config,
1613
- schemaVersion: config.schemaVersion ?? version,
1743
+ surfaceVersion: config.surfaceVersion ?? version,
1744
+ surfaceHashes: config.surfaceHashes ?? hashes,
1614
1745
  selections: config.selections ?? selections,
1615
1746
  namespaceSelections: config.namespaceSelections ?? namespaceSelections,
1616
1747
  selectionUnions: config.selectionUnions ?? selectionUnions,
1617
1748
  subscriptions: config.subscriptions ?? subscriptions
1618
1749
  };
1619
1750
  }
1751
+ function changedSurfaceParts(generated, served) {
1752
+ if (!generated || !served) return [];
1753
+ const out = /* @__PURE__ */ new Set();
1754
+ for (const [key, hash] of Object.entries(served)) {
1755
+ if (generated[key] !== hash) out.add(key);
1756
+ }
1757
+ for (const key of Object.keys(generated)) {
1758
+ if (served[key] === void 0) out.add(key);
1759
+ }
1760
+ return [...out].sort();
1761
+ }
1762
+ var SURFACE_SECTIONS = {
1763
+ models: ["model", "models"],
1764
+ namespaces: ["namespace", "namespaces"],
1765
+ selectionUnions: ["union", "unions"]
1766
+ };
1767
+ var SURFACE_WHOLES = {
1768
+ subscriptions: "the subscriptions",
1769
+ composedFor: "the organization it is composed for"
1770
+ };
1771
+ function describeChanged(changed) {
1772
+ const named = /* @__PURE__ */ new Map();
1773
+ const wholes = [];
1774
+ for (const part of changed) {
1775
+ const dot = part.indexOf(".");
1776
+ const section = dot < 0 ? part : part.slice(0, dot);
1777
+ if (dot < 0 || !SURFACE_SECTIONS[section]) {
1778
+ wholes.push(SURFACE_WHOLES[part] ?? part);
1779
+ continue;
1780
+ }
1781
+ named.set(section, [...named.get(section) ?? [], `\`${part.slice(dot + 1)}\``]);
1782
+ }
1783
+ const phrases = [];
1784
+ for (const [section, [one, many]] of Object.entries(SURFACE_SECTIONS)) {
1785
+ const names = named.get(section);
1786
+ if (names?.length) phrases.push(`${names.length === 1 ? one : many} ${names.join(", ")}`);
1787
+ }
1788
+ return [...phrases, ...wholes].join("; ");
1789
+ }
1790
+ function describeDrift(drift) {
1791
+ const moved = describeChanged(drift.changed);
1792
+ return `the types were generated from surface ${drift.generated}, and the server is serving ${drift.server} (${drift.channel}).` + (moved ? ` Changed since: ${moved}.` : "");
1793
+ }
1620
1794
  function announceFallback(config) {
1621
1795
  if (config.generated !== void 0 || config.selections !== void 0) return;
1622
1796
  if (config.announceManifestFallback === false) return;
@@ -1625,9 +1799,12 @@ function announceFallback(config) {
1625
1799
  );
1626
1800
  }
1627
1801
  function createFoirClient(clientConfig) {
1628
- if (!clientConfig?.apiUrl || !clientConfig?.apiKey) {
1629
- throw new Error("[foir] createFoirClient needs both apiUrl and apiKey");
1802
+ if (!clientConfig?.apiUrl) {
1803
+ throw new Error(
1804
+ "[foir] createFoirClient needs both apiUrl and apiKey (or, inside an operation endpoint, apiUrl and dispatch)"
1805
+ );
1630
1806
  }
1807
+ assertFoirCredential(clientConfig);
1631
1808
  const config = resolveGeneratedConfig(clientConfig);
1632
1809
  announceFallback(config);
1633
1810
  let driftChecked = false;
@@ -1652,31 +1829,19 @@ function createFoirClient(clientConfig) {
1652
1829
  }
1653
1830
  return manifest();
1654
1831
  };
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
1832
  const maybeCheckDrift = () => {
1666
- const generated = config.schemaVersion;
1833
+ const generated = config.surfaceVersion;
1667
1834
  if (driftChecked) return;
1668
1835
  if (generated === void 0) return;
1669
1836
  if (config.checkSchemaDrift === false) return;
1670
1837
  driftChecked = true;
1671
1838
  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
- }
1839
+ if (!served.surfaceVersion || served.surfaceVersion === generated) return;
1676
1840
  const drift = {
1677
1841
  generated,
1678
- server: served.schemaVersion,
1679
- channel: served.channel
1842
+ server: served.surfaceVersion,
1843
+ channel: served.channel,
1844
+ changed: changedSurfaceParts(config.surfaceHashes, served.surfaceHashes)
1680
1845
  };
1681
1846
  detectedDrift = drift;
1682
1847
  if (config.onSchemaDrift) {
@@ -1684,7 +1849,7 @@ function createFoirClient(clientConfig) {
1684
1849
  return;
1685
1850
  }
1686
1851
  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.`
1852
+ `[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
1853
  );
1689
1854
  }).catch(() => {
1690
1855
  });
@@ -1692,17 +1857,18 @@ function createFoirClient(clientConfig) {
1692
1857
  const assertNoStaleSelections = (what) => {
1693
1858
  if (config.strictSchemaDrift === false || !detectedDrift) return;
1694
1859
  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.`,
1860
+ `[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
1861
  detectedDrift
1697
1862
  );
1698
1863
  };
1699
1864
  const runModel = async (model, op, query) => {
1700
1865
  assertNoStaleSelections(`${model}.${op}`);
1701
1866
  const { args, include, omit, pick, depth } = splitQuery(query);
1702
- const plan = planFoirModelCall(await selectionSource(), {
1867
+ const source = await selectionSource();
1868
+ const plan = planFoirModelCall(source, {
1703
1869
  model,
1704
1870
  op,
1705
- args,
1871
+ args: withCreateIdempotencyKey(source, model, op, args),
1706
1872
  include,
1707
1873
  omit,
1708
1874
  pick,
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) => {