@foir/sdk 0.8.0 → 0.10.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 (57) hide show
  1. package/CHANGELOG.md +550 -28
  2. package/README.md +111 -0
  3. package/dist/auth/auth.d.ts +72 -1
  4. package/dist/auth/hosted.d.ts +93 -4
  5. package/dist/auth/index.d.ts +4 -4
  6. package/dist/auth/operations.d.ts +84 -0
  7. package/dist/auth/ops.d.ts +10 -0
  8. package/dist/auth.js +138 -19
  9. package/dist/client.js +63 -6
  10. package/dist/codegen/block-registry.d.ts +1 -1
  11. package/dist/codegen/block-scaffold.d.ts +1 -1
  12. package/dist/codegen/index.d.ts +1 -1
  13. package/dist/codegen/item-registry.d.ts +1 -1
  14. package/dist/codegen/item-scaffold.d.ts +1 -1
  15. package/dist/content/content-renderer.d.ts +4 -5
  16. package/dist/content/index.d.ts +1 -1
  17. package/dist/content/richtext.d.ts +1 -1
  18. package/dist/data/builtins.d.ts +131 -12
  19. package/dist/data/client.d.ts +82 -1
  20. package/dist/data/decimal.d.ts +212 -0
  21. package/dist/data/document.d.ts +1 -1
  22. package/dist/data/index.d.ts +11 -9
  23. package/dist/data/manifest.d.ts +33 -6
  24. package/dist/data/schema.d.ts +2 -2
  25. package/dist/data/subscriptions.d.ts +36 -10
  26. package/dist/data/transport.d.ts +120 -3
  27. package/dist/data.js +377 -16
  28. package/dist/graphql/cdn-route.d.ts +1 -1
  29. package/dist/graphql/foir-request.d.ts +1 -1
  30. package/dist/live/coalesce.d.ts +57 -0
  31. package/dist/live/drift.d.ts +59 -0
  32. package/dist/live/events.d.ts +13 -0
  33. package/dist/live/headers.d.ts +53 -0
  34. package/dist/live/index.d.ts +54 -0
  35. package/dist/live/keys.d.ts +72 -0
  36. package/dist/live/manager.d.ts +77 -0
  37. package/dist/live/react/index.d.ts +143 -0
  38. package/dist/live/reconcile.d.ts +82 -0
  39. package/dist/live/resync.d.ts +59 -0
  40. package/dist/live/store.d.ts +134 -0
  41. package/dist/live/tagIndex.d.ts +29 -0
  42. package/dist/live/tanstack.d.ts +127 -0
  43. package/dist/live/writetoken.d.ts +86 -0
  44. package/dist/live-react.js +395 -0
  45. package/dist/live.js +845 -0
  46. package/dist/next/cacheTags.d.ts +245 -0
  47. package/dist/next/index.d.ts +7 -0
  48. package/dist/next.js +179 -6
  49. package/dist/push.js +81 -8
  50. package/dist/react.js +76 -5
  51. package/dist/realtime/index.d.ts +4 -5
  52. package/dist/server/events-client.d.ts +54 -4
  53. package/dist/server/index.d.ts +3 -2
  54. package/dist/server/require-scoped-token.d.ts +1 -1
  55. package/dist/server.js +454 -2
  56. package/dist/transport/foir-http.d.ts +113 -10
  57. package/package.json +10 -2
package/dist/data.js CHANGED
@@ -5,11 +5,30 @@ function isFoirPreview(env) {
5
5
  function resolveFoirApiKey(env) {
6
6
  return isFoirPreview(env) ? env.PLATFORM_PREVIEW_API_KEY ?? env.PLATFORM_API_KEY : env.PLATFORM_API_KEY;
7
7
  }
8
+ var MALFORMED_PRUNE_REASONS = /* @__PURE__ */ new Set([
9
+ "unknown_field",
10
+ "subselections_on_scalar",
11
+ "missing_subselections"
12
+ ]);
8
13
  var FoirRequestError = class extends Error {
9
14
  /** HTTP status code, when the request failed at the HTTP layer. */
10
15
  status;
11
16
  /** GraphQL errors, when the request failed with a no-data error envelope. */
12
17
  graphQLErrors;
18
+ /**
19
+ * The `data` the server did return, when a STRICT read refused a partial
20
+ * response. Present only on that path: without it, opting into strictness
21
+ * would trade a silent wrong answer for a thrown one that discards the answer,
22
+ * and a caller that wants to log what it nearly rendered could not.
23
+ */
24
+ partialData;
25
+ /**
26
+ * The selections the server stripped, when the refusal was a pruned document
27
+ * rather than a partial error. Same purpose as `partialData`: a caller that
28
+ * wants to log what it nearly rendered, or name the offending path in a
29
+ * developer-facing message, has it without re-parsing anything.
30
+ */
31
+ prunedSelections;
13
32
  cause;
14
33
  constructor(message, options) {
15
34
  super(message);
@@ -17,6 +36,8 @@ var FoirRequestError = class extends Error {
17
36
  this.cause = options?.cause;
18
37
  this.status = options?.status;
19
38
  this.graphQLErrors = options?.graphQLErrors;
39
+ this.partialData = options?.partialData;
40
+ this.prunedSelections = options?.prunedSelections;
20
41
  }
21
42
  /**
22
43
  * The code of the first error, if the response carried one.
@@ -66,6 +87,9 @@ function buildFoirHeaders(input) {
66
87
  if (input.bearerToken) {
67
88
  headers["Authorization"] = `Bearer ${input.bearerToken}`;
68
89
  }
90
+ if (input.orgId) {
91
+ headers["X-Org-Id"] = input.orgId;
92
+ }
69
93
  if (input.extra) {
70
94
  for (const [key, value] of Object.entries(input.extra)) {
71
95
  headers[key] = value;
@@ -80,14 +104,45 @@ function foirApiEndpoint(apiUrl, path) {
80
104
  }
81
105
  return base + path;
82
106
  }
83
- function readFoirEnvelope(json) {
107
+ var warnedDowngrade = null;
108
+ function reportAuthDowngrade(envelope) {
109
+ const downgrade = envelope.extensions?.auth;
110
+ if (!downgrade) {
111
+ warnedDowngrade = null;
112
+ return;
113
+ }
114
+ if (warnedDowngrade === downgrade.reason) return;
115
+ warnedDowngrade = downgrade.reason;
116
+ console.warn(
117
+ `[foir] your customer token was ${downgrade.customerToken} (${downgrade.reason}) \u2014 this request was served as ${downgrade.servedAs}, so auth-targeted content resolved to its anonymous variant. ` + (downgrade.reason === "expired" ? "Re-exchange the token and retry." : "Check which credential you are sending.")
118
+ );
119
+ }
120
+ function refusePrunedDocument(json, options) {
121
+ const selections = json.extensions?.pruned?.selections;
122
+ if (!selections?.length) return;
123
+ const describe2 = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
124
+ const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
125
+ if (malformed.length && options?.strict !== false) {
126
+ throw new FoirRequestError(
127
+ `[foir] the server could not serve part of this query and returned the rest without it: ${malformed.map(describe2).join(", ")}. Those selections do not exist on the schema this project is serving. Pass strict: false to get the partial answer instead of this error.`,
128
+ { prunedSelections: selections }
129
+ );
130
+ }
131
+ console.warn(
132
+ `[foir] selections stripped from this query: ${selections.map(describe2).join(", ")}`
133
+ );
134
+ }
135
+ function readFoirEnvelope(json, options) {
136
+ reportAuthDowngrade(json);
137
+ refusePrunedDocument(json, options);
84
138
  if (json.errors?.length) {
85
139
  const msg = json.errors.map((e) => e.message).join("; ");
86
- if (json.data) {
140
+ if (json.data && !options?.strict) {
87
141
  console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
88
142
  } else {
89
143
  throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
90
- graphQLErrors: json.errors
144
+ graphQLErrors: json.errors,
145
+ partialData: json.data ?? void 0
91
146
  });
92
147
  }
93
148
  }
@@ -128,6 +183,14 @@ async function hasFoirErrorCode(res, ...codes) {
128
183
  }
129
184
 
130
185
  // src/data/transport.ts
186
+ var FoirSchemaDriftError = class extends FoirRequestError {
187
+ drift;
188
+ constructor(message, drift) {
189
+ super(message);
190
+ this.name = "FoirSchemaDriftError";
191
+ this.drift = drift;
192
+ }
193
+ };
131
194
  function foirDataEndpoint(apiUrl, path) {
132
195
  return foirApiEndpoint(apiUrl, path);
133
196
  }
@@ -139,7 +202,9 @@ async function resolveBearerToken(config) {
139
202
  function foirRoute(input) {
140
203
  if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
141
204
  if (input.bearerToken) return { lane: "origin", reason: "bearer" };
205
+ if (input.orgId) return { lane: "origin", reason: "org" };
142
206
  if (input.mutation) return { lane: "origin", reason: "operation" };
207
+ if (input.writeToken) return { lane: "origin", reason: "floor" };
143
208
  if (input.channel === "draft") return { lane: "origin", reason: "channel" };
144
209
  return { lane: "shared" };
145
210
  }
@@ -152,10 +217,10 @@ async function executeFoirDocument(config, plan) {
152
217
  }
153
218
  return data[plan.rootField];
154
219
  }
155
- async function executeFoirRawDocument(config, document, mutation, variables) {
156
- return await runFoirDocument(config, document, mutation, variables);
220
+ async function executeFoirRawDocument(config, document, mutation, variables, options) {
221
+ return await runFoirDocument(config, document, mutation, variables, options);
157
222
  }
158
- async function runFoirDocument(config, document, mutation, variables) {
223
+ async function runFoirDocument(config, document, mutation, variables, options) {
159
224
  const doFetch = config.fetch ?? globalThis.fetch;
160
225
  const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
161
226
  const bearerToken = await resolveBearerToken(config);
@@ -163,12 +228,14 @@ async function runFoirDocument(config, document, mutation, variables) {
163
228
  apiKey: config.apiKey,
164
229
  draftChannel: config.channel === "draft",
165
230
  bearerToken,
231
+ orgId: config.orgId,
166
232
  extra: config.headers
167
233
  });
168
234
  const hash = await documentHash(document);
169
235
  const route = foirRoute({
170
236
  apiKey: config.apiKey,
171
237
  bearerToken,
238
+ orgId: config.orgId,
172
239
  mutation,
173
240
  channel: config.channel
174
241
  });
@@ -208,7 +275,7 @@ async function runFoirDocument(config, document, mutation, variables) {
208
275
  });
209
276
  }
210
277
  const json = await res.json();
211
- return readFoirEnvelope(json);
278
+ return readFoirEnvelope(json, options);
212
279
  }
213
280
  async function readErrorBody(res) {
214
281
  try {
@@ -231,6 +298,7 @@ async function fetchFoirManifest(config) {
231
298
  apiKey: config.apiKey,
232
299
  draftChannel: config.channel === "draft",
233
300
  bearerToken: await resolveBearerToken(config),
301
+ orgId: config.orgId,
234
302
  extra: config.headers
235
303
  }),
236
304
  ...config.requestInit
@@ -869,6 +937,7 @@ var FoirSocketTransport = class {
869
937
  apiKey: this.config.apiKey,
870
938
  draftChannel: this.config.channel === "draft",
871
939
  bearerToken: await resolveBearerToken(this.config),
940
+ orgId: this.config.orgId,
872
941
  extra: this.config.headers
873
942
  });
874
943
  const { "Content-Type": _contentType, ...params } = headers;
@@ -977,7 +1046,9 @@ var FoirSocketTransport = class {
977
1046
  return;
978
1047
  }
979
1048
  case GQL.error: {
980
- const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`);
1049
+ const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`, {
1050
+ graphQLErrors: graphQLErrorItems(message.payload)
1051
+ });
981
1052
  if (!message.id) {
982
1053
  this.failAll(error);
983
1054
  this.teardownSocket(true);
@@ -1088,6 +1159,21 @@ function defaultWebSocketFactory() {
1088
1159
  }
1089
1160
  return (url, protocols) => new Impl(url, protocols);
1090
1161
  }
1162
+ function graphQLErrorItems(payload) {
1163
+ const list = Array.isArray(payload) ? payload : payload == null ? [] : [payload];
1164
+ const items = [];
1165
+ for (const entry of list) {
1166
+ if (typeof entry !== "object" || entry === null) continue;
1167
+ const { message, path, extensions } = entry;
1168
+ if (extensions === void 0 && path === void 0) continue;
1169
+ items.push({
1170
+ message: typeof message === "string" ? message : describe(entry),
1171
+ ...Array.isArray(path) ? { path } : {},
1172
+ ...typeof extensions === "object" && extensions !== null ? { extensions } : {}
1173
+ });
1174
+ }
1175
+ return items.length > 0 ? items : void 0;
1176
+ }
1091
1177
  function describe(payload) {
1092
1178
  if (payload == null) return "no reason given";
1093
1179
  if (typeof payload === "string") return payload;
@@ -1133,12 +1219,19 @@ var FoirSubscriptionRouter = class {
1133
1219
  if (method !== "onChange") {
1134
1220
  throw new FoirRequestError(`[foir] ${modelKey}.${method} is not a subscription`);
1135
1221
  }
1222
+ const after = input.after;
1223
+ const args = [{ name: "modelKey", type: "String!" }];
1224
+ const variables = { modelKey };
1225
+ if (typeof after === "number") {
1226
+ args.push({ name: "after", type: "EventSeq" });
1227
+ variables.after = after;
1228
+ }
1136
1229
  return this.open(
1137
1230
  {
1138
1231
  field: "recordChanged",
1139
1232
  operationName: "FoirRecordChanged",
1140
- args: [{ name: "modelKey", type: "String!" }],
1141
- variables: { modelKey }
1233
+ args,
1234
+ variables
1142
1235
  },
1143
1236
  input
1144
1237
  );
@@ -1177,7 +1270,7 @@ var FoirSubscriptionRouter = class {
1177
1270
  args: [{ name: "id", type: "String!" }],
1178
1271
  variables: { id }
1179
1272
  },
1180
- handlers
1273
+ { ...handlers, onData: (event) => handlers.onData(executionEventWithEnumStatus(event)) }
1181
1274
  );
1182
1275
  }
1183
1276
  throw new FoirRequestError(`[foir] ns.${namespace}.${method} is not a subscription`);
@@ -1222,6 +1315,12 @@ var FoirSubscriptionRouter = class {
1222
1315
  };
1223
1316
  }
1224
1317
  };
1318
+ function executionEventWithEnumStatus(event) {
1319
+ if (event === null || typeof event !== "object") return event;
1320
+ const status = event.status;
1321
+ if (typeof status !== "string") return event;
1322
+ return { ...event, status: status.toUpperCase() };
1323
+ }
1225
1324
  function findSubscription(manifest, field) {
1226
1325
  const found = manifest.subscriptions?.find((s) => s.field === field);
1227
1326
  if (found) return found;
@@ -1369,6 +1468,9 @@ function recordsAt(value, path) {
1369
1468
  }
1370
1469
 
1371
1470
  // src/data/client.ts
1471
+ function graphql(document) {
1472
+ return document;
1473
+ }
1372
1474
  var NON_MODEL_PROPS = /* @__PURE__ */ new Set([
1373
1475
  "then",
1374
1476
  "catch",
@@ -1500,19 +1602,26 @@ function resolveGeneratedConfig(config) {
1500
1602
  `[foir] createFoirClient: \`generated.foirSelectionUnions["${name}"]\` has no \`members\`. A tree that names this union would print an empty selection set. Re-run \`foir types\`.`
1501
1603
  );
1502
1604
  }
1605
+ const subscriptions = generated["foirSubscriptions"];
1606
+ if (subscriptions !== void 0 && !Array.isArray(subscriptions)) {
1607
+ throw new FoirRequestError(
1608
+ "[foir] createFoirClient: `generated.foirSubscriptions` is not an array of subscription descriptors. Re-run `foir types`."
1609
+ );
1610
+ }
1503
1611
  return {
1504
1612
  ...config,
1505
1613
  schemaVersion: config.schemaVersion ?? version,
1506
1614
  selections: config.selections ?? selections,
1507
1615
  namespaceSelections: config.namespaceSelections ?? namespaceSelections,
1508
- selectionUnions: config.selectionUnions ?? selectionUnions
1616
+ selectionUnions: config.selectionUnions ?? selectionUnions,
1617
+ subscriptions: config.subscriptions ?? subscriptions
1509
1618
  };
1510
1619
  }
1511
1620
  function announceFallback(config) {
1512
1621
  if (config.generated !== void 0 || config.selections !== void 0) return;
1513
1622
  if (config.announceManifestFallback === false) return;
1514
1623
  console.info(
1515
- "[foir] no generated module passed, so this client fetches `GET /v1/manifest` once at first use and prints from the trees it publishes. That works \u2014 it is what makes a zero-codegen start possible \u2014 but it puts a round trip in front of the first query on every cold start. Run `foir types`, then `import * as generated from './foir.generated'` and pass `generated`. Silence this with `announceManifestFallback: false`."
1624
+ "[foir] no generated module passed, so this client fetches `GET /v1/manifest` once at first use and prints from the trees it publishes. That works \u2014 it is what makes a zero-codegen start possible \u2014 but it puts a round trip in front of the first query on every cold start. Run `foir types`, then `import * as generated from './foir.generated'` and pass `generated`. Silence this with `announceManifestFallback: false`.\n[foir] A subscribe-only client can now take that advice too: `foir types` emits `foirSubscriptions` alongside the model trees, so a generated client prints its subscribe document from the module and makes no request at all. Weigh the module against one fetch behind a socket that lives for the whole session \u2014 on a phone the fallback may still be the better trade."
1516
1625
  );
1517
1626
  }
1518
1627
  function createFoirClient(clientConfig) {
@@ -1522,6 +1631,7 @@ function createFoirClient(clientConfig) {
1522
1631
  const config = resolveGeneratedConfig(clientConfig);
1523
1632
  announceFallback(config);
1524
1633
  let driftChecked = false;
1634
+ let detectedDrift;
1525
1635
  let manifestOnce;
1526
1636
  const manifest = () => {
1527
1637
  if (!manifestOnce) {
@@ -1542,6 +1652,16 @@ function createFoirClient(clientConfig) {
1542
1652
  }
1543
1653
  return manifest();
1544
1654
  };
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
+ };
1545
1665
  const maybeCheckDrift = () => {
1546
1666
  const generated = config.schemaVersion;
1547
1667
  if (driftChecked) return;
@@ -1550,11 +1670,15 @@ function createFoirClient(clientConfig) {
1550
1670
  driftChecked = true;
1551
1671
  void manifest().then((served) => {
1552
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
+ }
1553
1676
  const drift = {
1554
1677
  generated,
1555
1678
  server: served.schemaVersion,
1556
1679
  channel: served.channel
1557
1680
  };
1681
+ detectedDrift = drift;
1558
1682
  if (config.onSchemaDrift) {
1559
1683
  config.onSchemaDrift(drift);
1560
1684
  return;
@@ -1565,7 +1689,15 @@ function createFoirClient(clientConfig) {
1565
1689
  }).catch(() => {
1566
1690
  });
1567
1691
  };
1692
+ const assertNoStaleSelections = (what) => {
1693
+ if (config.strictSchemaDrift === false || !detectedDrift) return;
1694
+ 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.`,
1696
+ detectedDrift
1697
+ );
1698
+ };
1568
1699
  const runModel = async (model, op, query) => {
1700
+ assertNoStaleSelections(`${model}.${op}`);
1569
1701
  const { args, include, omit, pick, depth } = splitQuery(query);
1570
1702
  const plan = planFoirModelCall(await selectionSource(), {
1571
1703
  model,
@@ -1582,6 +1714,7 @@ function createFoirClient(clientConfig) {
1582
1714
  return result;
1583
1715
  };
1584
1716
  const runNamespace = async (namespace, op, query) => {
1717
+ assertNoStaleSelections(`ns.${namespace}.${op}`);
1585
1718
  const { args, include, omit, pick, depth } = splitQuery(query);
1586
1719
  const plan = planFoirNamespaceCall(await selectionSource(), {
1587
1720
  namespace,
@@ -1597,11 +1730,18 @@ function createFoirClient(clientConfig) {
1597
1730
  maybeCheckDrift();
1598
1731
  return result;
1599
1732
  };
1733
+ const subscriptionManifest = () => {
1734
+ if (!config.subscriptions) return manifest();
1735
+ return Promise.resolve({
1736
+ subscriptions: config.subscriptions,
1737
+ selectionUnions: config.selectionUnions
1738
+ });
1739
+ };
1600
1740
  const subscriptions = /* @__PURE__ */ (() => {
1601
1741
  const held = { router: null };
1602
1742
  return {
1603
1743
  get() {
1604
- held.router ??= new FoirSubscriptionRouter(config, manifest);
1744
+ held.router ??= new FoirSubscriptionRouter(config, subscriptionManifest);
1605
1745
  return held.router;
1606
1746
  },
1607
1747
  dispose() {
@@ -1629,7 +1769,7 @@ function createFoirClient(clientConfig) {
1629
1769
  return opProxy({ namespace });
1630
1770
  }
1631
1771
  });
1632
- const raw = async (document, variables) => {
1772
+ const raw = async (document, variables, options) => {
1633
1773
  if (typeof document !== "string" || !document.trim()) {
1634
1774
  throw new FoirRequestError("[foir] raw() takes a GraphQL document string");
1635
1775
  }
@@ -1637,7 +1777,8 @@ function createFoirClient(clientConfig) {
1637
1777
  config,
1638
1778
  document,
1639
1779
  isFoirMutationDocument(document),
1640
- variables
1780
+ variables,
1781
+ options
1641
1782
  );
1642
1783
  maybeCheckDrift();
1643
1784
  return result;
@@ -1666,16 +1807,236 @@ function createFoirClientFromEnv(env, overrides) {
1666
1807
  channel: isFoirPreview(env) ? "draft" : "published"
1667
1808
  });
1668
1809
  }
1810
+
1811
+ // src/data/decimal.ts
1812
+ var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
1813
+ var MAX_SCALE = 1e3;
1814
+ function pow10(n) {
1815
+ return 10n ** BigInt(n);
1816
+ }
1817
+ function divRound(numerator, denominator, rounding) {
1818
+ if (denominator === 0n) throw new RangeError("FoirDecimal: division by zero");
1819
+ const negative = numerator < 0n !== denominator < 0n;
1820
+ const n = numerator < 0n ? -numerator : numerator;
1821
+ const d = denominator < 0n ? -denominator : denominator;
1822
+ let quotient = n / d;
1823
+ const remainder = n % d;
1824
+ if (remainder !== 0n && rounding !== "down") {
1825
+ const doubled = remainder * 2n;
1826
+ if (doubled > d) {
1827
+ quotient += 1n;
1828
+ } else if (doubled === d) {
1829
+ if (rounding === "half-up" || quotient % 2n === 1n) quotient += 1n;
1830
+ }
1831
+ }
1832
+ return negative ? -quotient : quotient;
1833
+ }
1834
+ function parse(input) {
1835
+ const text = input.trim();
1836
+ if (!DECIMAL_PATTERN.test(text)) {
1837
+ throw new TypeError(
1838
+ `FoirDecimal: ${JSON.stringify(input)} is not a decimal. Expected a numeric string such as "19.99".`
1839
+ );
1840
+ }
1841
+ const [mantissa, exponentText] = text.split(/[eE]/);
1842
+ const exponent = exponentText ? Number(exponentText) : 0;
1843
+ const negative = mantissa.startsWith("-");
1844
+ const unsigned = negative || mantissa.startsWith("+") ? mantissa.slice(1) : mantissa;
1845
+ const [integerPart = "", fractionPart = ""] = unsigned.split(".");
1846
+ let units = BigInt(`${integerPart || "0"}${fractionPart}`);
1847
+ let scale = fractionPart.length - exponent;
1848
+ if (scale < 0) {
1849
+ units *= pow10(-scale);
1850
+ scale = 0;
1851
+ }
1852
+ if (scale > MAX_SCALE) {
1853
+ throw new RangeError(`FoirDecimal: scale ${scale} exceeds the maximum of ${MAX_SCALE}`);
1854
+ }
1855
+ return { units: negative ? -units : units, scale };
1856
+ }
1857
+ var FoirDecimal = class _FoirDecimal {
1858
+ // Not `readonly` only so `#of` can populate a fresh instance without going
1859
+ // back through the string parser. Never mutated after construction, and
1860
+ // `#`-private so immutability is not part of the public contract to keep.
1861
+ #units;
1862
+ #scale;
1863
+ constructor(value) {
1864
+ if (value instanceof _FoirDecimal) {
1865
+ this.#units = value.#units;
1866
+ this.#scale = value.#scale;
1867
+ return;
1868
+ }
1869
+ if (typeof value === "bigint") {
1870
+ this.#units = value;
1871
+ this.#scale = 0;
1872
+ return;
1873
+ }
1874
+ if (typeof value !== "string") {
1875
+ throw new TypeError(
1876
+ `FoirDecimal: refusing to construct from ${typeof value}. A JavaScript number is a float and may already have lost precision \u2014 pass the string, or use FoirDecimal.fromNumber() to accept that loss explicitly.`
1877
+ );
1878
+ }
1879
+ const { units, scale } = parse(value);
1880
+ this.#units = units;
1881
+ this.#scale = scale;
1882
+ }
1883
+ /**
1884
+ * Build a decimal from a JavaScript number, accepting the precision that
1885
+ * number may already have lost.
1886
+ *
1887
+ * Deliberately not the constructor. It is for interop — a value read from a
1888
+ * third-party JSON API that emitted a float — and naming it at every call
1889
+ * site is the point. `fromNumber(0.1 + 0.2)` gives `0.30000000000000004`,
1890
+ * because that is genuinely the value it was handed.
1891
+ */
1892
+ static fromNumber(value) {
1893
+ if (!Number.isFinite(value)) {
1894
+ throw new RangeError(`FoirDecimal: ${value} is not a finite number`);
1895
+ }
1896
+ return new _FoirDecimal(value.toPrecision(17));
1897
+ }
1898
+ /** `true` if `value` parses as a decimal. Does not throw. */
1899
+ static isDecimalString(value) {
1900
+ return typeof value === "string" && DECIMAL_PATTERN.test(value.trim());
1901
+ }
1902
+ /** Decimal places carried by this value. `19.90` has scale 2. */
1903
+ get scale() {
1904
+ return this.#scale;
1905
+ }
1906
+ /** Re-express at `scale`, rounding away any digits that no longer fit. */
1907
+ rescale(scale, rounding = "half-up") {
1908
+ if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
1909
+ throw new RangeError(`FoirDecimal: scale must be an integer in 0..${MAX_SCALE}, got ${scale}`);
1910
+ }
1911
+ if (scale === this.#scale) return this;
1912
+ const units = scale > this.#scale ? this.#units * pow10(scale - this.#scale) : divRound(this.#units, pow10(this.#scale - scale), rounding);
1913
+ return _FoirDecimal.#of(units, scale);
1914
+ }
1915
+ add(other) {
1916
+ const [a, b, scale] = _FoirDecimal.#align(this, other);
1917
+ return _FoirDecimal.#of(a + b, scale);
1918
+ }
1919
+ sub(other) {
1920
+ const [a, b, scale] = _FoirDecimal.#align(this, other);
1921
+ return _FoirDecimal.#of(a - b, scale);
1922
+ }
1923
+ /** Exact: scales add, so `1.10 × 1.10` is `1.2100` rather than `1.21`. */
1924
+ mul(other) {
1925
+ const rhs = new _FoirDecimal(other);
1926
+ return _FoirDecimal.#of(this.#units * rhs.#units, this.#scale + rhs.#scale);
1927
+ }
1928
+ /** The only inexact operation — see {@link FoirDecimalDivOptions}. */
1929
+ div(other, options = {}) {
1930
+ const { scale = 20, rounding = "half-even" } = options;
1931
+ if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
1932
+ throw new RangeError(`FoirDecimal: scale must be an integer in 0..${MAX_SCALE}, got ${scale}`);
1933
+ }
1934
+ const rhs = new _FoirDecimal(other);
1935
+ const numerator = this.#units * pow10(rhs.#scale + scale);
1936
+ const denominator = rhs.#units * pow10(this.#scale);
1937
+ return _FoirDecimal.#of(divRound(numerator, denominator, rounding), scale);
1938
+ }
1939
+ abs() {
1940
+ return this.#units < 0n ? _FoirDecimal.#of(-this.#units, this.#scale) : this;
1941
+ }
1942
+ neg() {
1943
+ return _FoirDecimal.#of(-this.#units, this.#scale);
1944
+ }
1945
+ isZero() {
1946
+ return this.#units === 0n;
1947
+ }
1948
+ isNegative() {
1949
+ return this.#units < 0n;
1950
+ }
1951
+ /** `-1`, `0` or `1`. Use this rather than `<`, which throws. */
1952
+ cmp(other) {
1953
+ const [a, b] = _FoirDecimal.#align(this, other);
1954
+ return a < b ? -1 : a > b ? 1 : 0;
1955
+ }
1956
+ /** Numeric equality: `1.0` equals `1.00`. Scale is display, not identity. */
1957
+ equals(other) {
1958
+ return this.cmp(other) === 0;
1959
+ }
1960
+ /**
1961
+ * Fixed-place string, rounding `half-up` by default because this is the
1962
+ * display operation and that is what a person means by "to 2dp".
1963
+ */
1964
+ toFixed(places, rounding = "half-up") {
1965
+ return this.rescale(places, rounding).toString();
1966
+ }
1967
+ /**
1968
+ * The canonical wire form, preserving scale. Never exponent notation, so the
1969
+ * output always parses back to an equal value.
1970
+ */
1971
+ toString() {
1972
+ const negative = this.#units < 0n;
1973
+ const digits = (negative ? -this.#units : this.#units).toString();
1974
+ if (this.#scale === 0) return negative ? `-${digits}` : digits;
1975
+ const padded = digits.padStart(this.#scale + 1, "0");
1976
+ const integerPart = padded.slice(0, padded.length - this.#scale);
1977
+ const fractionPart = padded.slice(padded.length - this.#scale);
1978
+ return `${negative ? "-" : ""}${integerPart}.${fractionPart}`;
1979
+ }
1980
+ /** `JSON.stringify` emits the canonical string, not an object. Pinned by test. */
1981
+ toJSON() {
1982
+ return this.toString();
1983
+ }
1984
+ /**
1985
+ * Explicitly lossy — this is the boundary back into float, and naming it is
1986
+ * the point. Fine for a chart axis; never for money you will add up.
1987
+ */
1988
+ toNumber() {
1989
+ return Number(this.toString());
1990
+ }
1991
+ /**
1992
+ * Answers the `'string'` hint so template literals work, and throws on every
1993
+ * other hint so `+`, `<` and `Number()` fail loudly instead of silently
1994
+ * concatenating or truncating.
1995
+ */
1996
+ [Symbol.toPrimitive](hint) {
1997
+ if (hint === "string") return this.toString();
1998
+ throw new TypeError(
1999
+ hint === "number" ? "FoirDecimal: cannot be coerced to a number implicitly. Use .toNumber() to accept the precision loss, or .cmp() to compare." : "FoirDecimal: cannot be coerced implicitly. Use .add() / .sub() / .mul() / .div() for arithmetic, .cmp() to compare, or .toString() for text."
2000
+ );
2001
+ }
2002
+ get [Symbol.toStringTag]() {
2003
+ return "FoirDecimal";
2004
+ }
2005
+ /** Skip parsing when the units are already known to be well-formed. */
2006
+ static #of(units, scale) {
2007
+ const instance = new _FoirDecimal(0n);
2008
+ instance.#units = units;
2009
+ instance.#scale = scale;
2010
+ return instance;
2011
+ }
2012
+ /** Bring two values to a common scale so their units can be compared. */
2013
+ static #align(left, right) {
2014
+ const rhs = new _FoirDecimal(right);
2015
+ const scale = Math.max(left.#scale, rhs.#scale);
2016
+ return [
2017
+ left.#units * pow10(scale - left.#scale),
2018
+ rhs.#units * pow10(scale - rhs.#scale),
2019
+ scale
2020
+ ];
2021
+ }
2022
+ };
2023
+ function decimal(value) {
2024
+ return new FoirDecimal(value);
2025
+ }
1669
2026
  export {
2027
+ FoirDecimal,
1670
2028
  FoirRequestError,
2029
+ FoirSchemaDriftError,
1671
2030
  FoirSocketTransport,
1672
2031
  createFoirClient,
1673
2032
  createFoirClientFromEnv,
1674
2033
  createFoirSubjectRegistry,
2034
+ decimal,
1675
2035
  fetchFoirManifest,
1676
2036
  foirDataEndpoint,
1677
2037
  foirRoute,
1678
2038
  foirSocketEndpoint,
2039
+ graphql,
1679
2040
  planFoirModelCall,
1680
2041
  planFoirNamespaceCall,
1681
2042
  printSubscriptionDocument,
@@ -21,6 +21,6 @@ export { buildCdnUrl, documentHash, hasFoirErrorCode, NOT_SHAREABLE, PERSISTED_Q
21
21
  * `/graphql` when it does — but every condition checked here is one round trip
22
22
  * saved.
23
23
  */
24
- export declare function isCdnEligible(env: FoirEnv, documentText: string, bearerToken?: string): boolean;
24
+ export declare function isCdnEligible(env: FoirEnv, documentText: string, bearerToken?: string, orgId?: string): boolean;
25
25
  /** Print a document once, so eligibility and the URL agree on the same text. */
26
26
  export declare function printDocument<TData, TVariables>(document: TypedDocumentNode<TData, TVariables>): string;
@@ -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, } 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';
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/";