@foir/sdk 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +719 -27
- package/README.md +121 -2
- package/dist/auth/auth.d.ts +72 -1
- package/dist/auth/hosted.d.ts +71 -4
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/operations.d.ts +84 -0
- package/dist/auth/ops.d.ts +18 -0
- package/dist/auth.js +209 -47
- package/dist/client.js +101 -13
- package/dist/codegen/block-registry.d.ts +1 -1
- package/dist/codegen/block-scaffold.d.ts +1 -1
- package/dist/codegen/index.d.ts +1 -1
- package/dist/codegen/item-registry.d.ts +1 -1
- package/dist/codegen/item-scaffold.d.ts +1 -1
- package/dist/codegen/preset.d.ts +3 -2
- package/dist/content/content-renderer.d.ts +4 -5
- package/dist/content/index.d.ts +1 -1
- package/dist/content/richtext.d.ts +1 -1
- package/dist/data/builtins.d.ts +81 -13
- package/dist/data/client.d.ts +92 -9
- package/dist/data/decimal.d.ts +212 -0
- package/dist/data/document.d.ts +14 -1
- package/dist/data/index.d.ts +22 -10
- package/dist/data/manifest.d.ts +40 -4
- package/dist/data/schema.d.ts +18 -11
- package/dist/data/selection.d.ts +15 -0
- package/dist/data/subscriptions.d.ts +36 -10
- package/dist/data/transport.d.ts +363 -24
- package/dist/data/ws-transport.d.ts +8 -2
- package/dist/data.js +591 -64
- package/dist/graphql/cdn-route.d.ts +1 -1
- package/dist/graphql/foir-request.d.ts +1 -1
- package/dist/index.js +0 -9
- package/dist/live/coalesce.d.ts +57 -0
- package/dist/live/drift.d.ts +58 -0
- package/dist/live/events.d.ts +13 -0
- package/dist/live/headers.d.ts +53 -0
- package/dist/live/index.d.ts +54 -0
- package/dist/live/keys.d.ts +72 -0
- package/dist/live/manager.d.ts +77 -0
- package/dist/live/react/index.d.ts +143 -0
- package/dist/live/reconcile.d.ts +82 -0
- package/dist/live/resync.d.ts +59 -0
- package/dist/live/store.d.ts +134 -0
- package/dist/live/tagIndex.d.ts +29 -0
- package/dist/live/tanstack.d.ts +127 -0
- package/dist/live/writetoken.d.ts +86 -0
- package/dist/live-react.js +395 -0
- package/dist/live.js +845 -0
- package/dist/next/cacheTags.d.ts +245 -0
- package/dist/next/index.d.ts +7 -0
- package/dist/next.js +260 -34
- package/dist/push.js +163 -36
- package/dist/react.js +157 -33
- package/dist/realtime/index.d.ts +4 -5
- package/dist/server/events-client.d.ts +54 -4
- package/dist/server/index.d.ts +4 -2
- package/dist/server/operation-types.d.ts +86 -58
- package/dist/server/read-dispatch.d.ts +53 -0
- package/dist/server/require-scoped-token.d.ts +20 -1
- package/dist/server.js +602 -10
- package/dist/transport/foir-http.d.ts +168 -15
- 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.
|
|
@@ -55,16 +76,46 @@ var FoirRequestError = class extends Error {
|
|
|
55
76
|
return typeof seconds === "number" && seconds > 0 ? seconds * 1e3 : void 0;
|
|
56
77
|
}
|
|
57
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
|
+
}
|
|
58
95
|
function buildFoirHeaders(input) {
|
|
59
96
|
const headers = {
|
|
60
|
-
"Content-Type": "application/json"
|
|
61
|
-
"x-api-key": input.apiKey
|
|
97
|
+
"Content-Type": "application/json"
|
|
62
98
|
};
|
|
63
|
-
if (input.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
+
}
|
|
68
119
|
}
|
|
69
120
|
if (input.extra) {
|
|
70
121
|
for (const [key, value] of Object.entries(input.extra)) {
|
|
@@ -80,14 +131,48 @@ function foirApiEndpoint(apiUrl, path) {
|
|
|
80
131
|
}
|
|
81
132
|
return base + path;
|
|
82
133
|
}
|
|
83
|
-
|
|
134
|
+
var warnedDowngrade = null;
|
|
135
|
+
function reportAuthDowngrade(envelope) {
|
|
136
|
+
const downgrade = envelope.extensions?.auth;
|
|
137
|
+
if (!downgrade) {
|
|
138
|
+
warnedDowngrade = null;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (warnedDowngrade === downgrade.reason) return;
|
|
142
|
+
warnedDowngrade = downgrade.reason;
|
|
143
|
+
console.warn(
|
|
144
|
+
`[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.")
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
function refusePrunedDocument(json, options) {
|
|
148
|
+
const selections = json.extensions?.pruned?.selections;
|
|
149
|
+
if (!selections?.length) return;
|
|
150
|
+
const describe2 = (s) => `${s.path}${s.type ? ` on ${s.type}` : ""} (${s.reason})`;
|
|
151
|
+
const malformed = selections.filter((s) => MALFORMED_PRUNE_REASONS.has(s.reason));
|
|
152
|
+
if (malformed.length && options?.strict !== false) {
|
|
153
|
+
throw new FoirRequestError(
|
|
154
|
+
`[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.`,
|
|
155
|
+
{ prunedSelections: selections }
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
console.warn(
|
|
159
|
+
`[foir] selections stripped from this query: ${selections.map(describe2).join(", ")}`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
function answeredAnything(data) {
|
|
163
|
+
return typeof data === "object" && data !== null && Object.values(data).some((value) => value !== null && value !== void 0);
|
|
164
|
+
}
|
|
165
|
+
function readFoirEnvelope(json, options) {
|
|
166
|
+
reportAuthDowngrade(json);
|
|
167
|
+
refusePrunedDocument(json, options);
|
|
84
168
|
if (json.errors?.length) {
|
|
85
169
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
86
|
-
if (json.data) {
|
|
170
|
+
if (answeredAnything(json.data) && !options?.strict) {
|
|
87
171
|
console.warn(`[foir] GraphQL partial errors (data returned): ${msg}`);
|
|
88
172
|
} else {
|
|
89
173
|
throw new FoirRequestError(`[foir] GraphQL errors: ${msg}`, {
|
|
90
|
-
graphQLErrors: json.errors
|
|
174
|
+
graphQLErrors: json.errors,
|
|
175
|
+
partialData: json.data ?? void 0
|
|
91
176
|
});
|
|
92
177
|
}
|
|
93
178
|
}
|
|
@@ -128,6 +213,14 @@ async function hasFoirErrorCode(res, ...codes) {
|
|
|
128
213
|
}
|
|
129
214
|
|
|
130
215
|
// src/data/transport.ts
|
|
216
|
+
var FoirSchemaDriftError = class extends FoirRequestError {
|
|
217
|
+
drift;
|
|
218
|
+
constructor(message, drift) {
|
|
219
|
+
super(message);
|
|
220
|
+
this.name = "FoirSchemaDriftError";
|
|
221
|
+
this.drift = drift;
|
|
222
|
+
}
|
|
223
|
+
};
|
|
131
224
|
function foirDataEndpoint(apiUrl, path) {
|
|
132
225
|
return foirApiEndpoint(apiUrl, path);
|
|
133
226
|
}
|
|
@@ -136,10 +229,109 @@ async function resolveBearerToken(config) {
|
|
|
136
229
|
if (typeof source === "function") return await source() ?? void 0;
|
|
137
230
|
return source ?? void 0;
|
|
138
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
|
+
}
|
|
139
329
|
function foirRoute(input) {
|
|
140
330
|
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
141
331
|
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
332
|
+
if (input.orgId || input.orgUrl) return { lane: "origin", reason: "org" };
|
|
142
333
|
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
334
|
+
if (input.writeToken) return { lane: "origin", reason: "floor" };
|
|
143
335
|
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
144
336
|
return { lane: "shared" };
|
|
145
337
|
}
|
|
@@ -152,26 +344,22 @@ async function executeFoirDocument(config, plan) {
|
|
|
152
344
|
}
|
|
153
345
|
return data[plan.rootField];
|
|
154
346
|
}
|
|
155
|
-
async function executeFoirRawDocument(config, document, mutation, variables) {
|
|
156
|
-
return await runFoirDocument(config, document, mutation, variables);
|
|
347
|
+
async function executeFoirRawDocument(config, document, mutation, variables, options) {
|
|
348
|
+
return await runFoirDocument(config, document, mutation, variables, options);
|
|
157
349
|
}
|
|
158
|
-
async function runFoirDocument(config, document, mutation, variables) {
|
|
350
|
+
async function runFoirDocument(config, document, mutation, variables, options) {
|
|
159
351
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
160
352
|
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
161
|
-
const
|
|
162
|
-
const headers = buildFoirHeaders({
|
|
163
|
-
apiKey: config.apiKey,
|
|
164
|
-
draftChannel: config.channel === "draft",
|
|
165
|
-
bearerToken,
|
|
166
|
-
extra: config.headers
|
|
167
|
-
});
|
|
353
|
+
const { headers, key } = await resolveFoirAuth(config);
|
|
168
354
|
const hash = await documentHash(document);
|
|
169
|
-
const
|
|
170
|
-
apiKey:
|
|
171
|
-
bearerToken,
|
|
355
|
+
const sharedKey = key !== void 0 && foirRoute({
|
|
356
|
+
apiKey: key.apiKey,
|
|
357
|
+
bearerToken: key.bearerToken,
|
|
358
|
+
orgId: config.orgId,
|
|
359
|
+
orgUrl: config.orgUrl,
|
|
172
360
|
mutation,
|
|
173
361
|
channel: config.channel
|
|
174
|
-
});
|
|
362
|
+
}).lane === "shared" ? key.apiKey : void 0;
|
|
175
363
|
const post = (body) => doFetch(endpoint, {
|
|
176
364
|
method: "POST",
|
|
177
365
|
headers,
|
|
@@ -181,8 +369,8 @@ async function runFoirDocument(config, document, mutation, variables) {
|
|
|
181
369
|
const extensions = persistedQueryExtension(hash);
|
|
182
370
|
let res;
|
|
183
371
|
try {
|
|
184
|
-
if (
|
|
185
|
-
res = await doFetch(buildCdnUrl(endpoint,
|
|
372
|
+
if (sharedKey !== void 0) {
|
|
373
|
+
res = await doFetch(buildCdnUrl(endpoint, sharedKey, hash, variables), {
|
|
186
374
|
method: "GET",
|
|
187
375
|
headers,
|
|
188
376
|
...config.requestInit
|
|
@@ -208,7 +396,7 @@ async function runFoirDocument(config, document, mutation, variables) {
|
|
|
208
396
|
});
|
|
209
397
|
}
|
|
210
398
|
const json = await res.json();
|
|
211
|
-
return readFoirEnvelope(json);
|
|
399
|
+
return readFoirEnvelope(json, options);
|
|
212
400
|
}
|
|
213
401
|
async function readErrorBody(res) {
|
|
214
402
|
try {
|
|
@@ -220,19 +408,16 @@ async function readErrorBody(res) {
|
|
|
220
408
|
}
|
|
221
409
|
|
|
222
410
|
// src/data/manifest.ts
|
|
223
|
-
async function fetchFoirManifest(config) {
|
|
224
|
-
const
|
|
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)}`;
|
|
225
414
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
415
|
+
const { headers } = await resolveFoirAuth(config);
|
|
226
416
|
let res;
|
|
227
417
|
try {
|
|
228
418
|
res = await doFetch(url, {
|
|
229
419
|
method: "GET",
|
|
230
|
-
headers
|
|
231
|
-
apiKey: config.apiKey,
|
|
232
|
-
draftChannel: config.channel === "draft",
|
|
233
|
-
bearerToken: await resolveBearerToken(config),
|
|
234
|
-
extra: config.headers
|
|
235
|
-
}),
|
|
420
|
+
headers,
|
|
236
421
|
...config.requestInit
|
|
237
422
|
});
|
|
238
423
|
} catch (err) {
|
|
@@ -637,6 +822,13 @@ function planFoirModelCall(source, call) {
|
|
|
637
822
|
}
|
|
638
823
|
return planCall(source, opDef, `${call.model}.${call.op}`, operationName(model.typeName, call.op), call);
|
|
639
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
|
+
}
|
|
640
832
|
function planFoirNamespaceCall(source, call) {
|
|
641
833
|
const namespace = source.namespaces?.[call.namespace];
|
|
642
834
|
if (!namespace) {
|
|
@@ -794,6 +986,11 @@ var FoirSocketTransport = class {
|
|
|
794
986
|
keepAlive = null;
|
|
795
987
|
retryTimer = null;
|
|
796
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
|
+
}
|
|
797
994
|
this.config = config;
|
|
798
995
|
this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
|
|
799
996
|
}
|
|
@@ -865,12 +1062,7 @@ var FoirSocketTransport = class {
|
|
|
865
1062
|
async sendConnectionInit(socket) {
|
|
866
1063
|
this.connecting = false;
|
|
867
1064
|
try {
|
|
868
|
-
const headers =
|
|
869
|
-
apiKey: this.config.apiKey,
|
|
870
|
-
draftChannel: this.config.channel === "draft",
|
|
871
|
-
bearerToken: await resolveBearerToken(this.config),
|
|
872
|
-
extra: this.config.headers
|
|
873
|
-
});
|
|
1065
|
+
const { headers } = await resolveFoirAuth(this.config);
|
|
874
1066
|
const { "Content-Type": _contentType, ...params } = headers;
|
|
875
1067
|
const extra = await this.config.connectionParams?.() ?? {};
|
|
876
1068
|
this.send(socket, {
|
|
@@ -977,7 +1169,9 @@ var FoirSocketTransport = class {
|
|
|
977
1169
|
return;
|
|
978
1170
|
}
|
|
979
1171
|
case GQL.error: {
|
|
980
|
-
const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}
|
|
1172
|
+
const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`, {
|
|
1173
|
+
graphQLErrors: graphQLErrorItems(message.payload)
|
|
1174
|
+
});
|
|
981
1175
|
if (!message.id) {
|
|
982
1176
|
this.failAll(error);
|
|
983
1177
|
this.teardownSocket(true);
|
|
@@ -1088,6 +1282,21 @@ function defaultWebSocketFactory() {
|
|
|
1088
1282
|
}
|
|
1089
1283
|
return (url, protocols) => new Impl(url, protocols);
|
|
1090
1284
|
}
|
|
1285
|
+
function graphQLErrorItems(payload) {
|
|
1286
|
+
const list = Array.isArray(payload) ? payload : payload == null ? [] : [payload];
|
|
1287
|
+
const items = [];
|
|
1288
|
+
for (const entry of list) {
|
|
1289
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
1290
|
+
const { message, path, extensions } = entry;
|
|
1291
|
+
if (extensions === void 0 && path === void 0) continue;
|
|
1292
|
+
items.push({
|
|
1293
|
+
message: typeof message === "string" ? message : describe(entry),
|
|
1294
|
+
...Array.isArray(path) ? { path } : {},
|
|
1295
|
+
...typeof extensions === "object" && extensions !== null ? { extensions } : {}
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
return items.length > 0 ? items : void 0;
|
|
1299
|
+
}
|
|
1091
1300
|
function describe(payload) {
|
|
1092
1301
|
if (payload == null) return "no reason given";
|
|
1093
1302
|
if (typeof payload === "string") return payload;
|
|
@@ -1133,12 +1342,19 @@ var FoirSubscriptionRouter = class {
|
|
|
1133
1342
|
if (method !== "onChange") {
|
|
1134
1343
|
throw new FoirRequestError(`[foir] ${modelKey}.${method} is not a subscription`);
|
|
1135
1344
|
}
|
|
1345
|
+
const after = input.after;
|
|
1346
|
+
const args = [{ name: "modelKey", type: "String!" }];
|
|
1347
|
+
const variables = { modelKey };
|
|
1348
|
+
if (typeof after === "number") {
|
|
1349
|
+
args.push({ name: "after", type: "EventSeq" });
|
|
1350
|
+
variables.after = after;
|
|
1351
|
+
}
|
|
1136
1352
|
return this.open(
|
|
1137
1353
|
{
|
|
1138
1354
|
field: "recordChanged",
|
|
1139
1355
|
operationName: "FoirRecordChanged",
|
|
1140
|
-
args
|
|
1141
|
-
variables
|
|
1356
|
+
args,
|
|
1357
|
+
variables
|
|
1142
1358
|
},
|
|
1143
1359
|
input
|
|
1144
1360
|
);
|
|
@@ -1177,7 +1393,7 @@ var FoirSubscriptionRouter = class {
|
|
|
1177
1393
|
args: [{ name: "id", type: "String!" }],
|
|
1178
1394
|
variables: { id }
|
|
1179
1395
|
},
|
|
1180
|
-
handlers
|
|
1396
|
+
{ ...handlers, onData: (event) => handlers.onData(executionEventWithEnumStatus(event)) }
|
|
1181
1397
|
);
|
|
1182
1398
|
}
|
|
1183
1399
|
throw new FoirRequestError(`[foir] ns.${namespace}.${method} is not a subscription`);
|
|
@@ -1222,6 +1438,12 @@ var FoirSubscriptionRouter = class {
|
|
|
1222
1438
|
};
|
|
1223
1439
|
}
|
|
1224
1440
|
};
|
|
1441
|
+
function executionEventWithEnumStatus(event) {
|
|
1442
|
+
if (event === null || typeof event !== "object") return event;
|
|
1443
|
+
const status = event.status;
|
|
1444
|
+
if (typeof status !== "string") return event;
|
|
1445
|
+
return { ...event, status: status.toUpperCase() };
|
|
1446
|
+
}
|
|
1225
1447
|
function findSubscription(manifest, field) {
|
|
1226
1448
|
const found = manifest.subscriptions?.find((s) => s.field === field);
|
|
1227
1449
|
if (found) return found;
|
|
@@ -1369,6 +1591,9 @@ function recordsAt(value, path) {
|
|
|
1369
1591
|
}
|
|
1370
1592
|
|
|
1371
1593
|
// src/data/client.ts
|
|
1594
|
+
function graphql(document) {
|
|
1595
|
+
return document;
|
|
1596
|
+
}
|
|
1372
1597
|
var NON_MODEL_PROPS = /* @__PURE__ */ new Set([
|
|
1373
1598
|
"then",
|
|
1374
1599
|
"catch",
|
|
@@ -1444,7 +1669,8 @@ function splitQuery(query) {
|
|
|
1444
1669
|
return { args, include, omit, pick, depth };
|
|
1445
1670
|
}
|
|
1446
1671
|
var GENERATED_EXPORTS = [
|
|
1447
|
-
"
|
|
1672
|
+
"foirSurfaceVersion",
|
|
1673
|
+
"foirSurfaceHashes",
|
|
1448
1674
|
"foirSelections",
|
|
1449
1675
|
"foirNamespaceSelections",
|
|
1450
1676
|
"foirSelectionUnions"
|
|
@@ -1464,10 +1690,16 @@ function resolveGeneratedConfig(config) {
|
|
|
1464
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.`
|
|
1465
1691
|
);
|
|
1466
1692
|
}
|
|
1467
|
-
const version = generated["
|
|
1468
|
-
if (typeof 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")) {
|
|
1469
1701
|
throw new FoirRequestError(
|
|
1470
|
-
"[foir] createFoirClient: `generated.
|
|
1702
|
+
"[foir] createFoirClient: `generated.foirSurfaceHashes` is not an object of component -> hash. It is how a drift names what moved. Re-run `foir types`."
|
|
1471
1703
|
);
|
|
1472
1704
|
}
|
|
1473
1705
|
const selections = generated["foirSelections"];
|
|
@@ -1500,28 +1732,83 @@ function resolveGeneratedConfig(config) {
|
|
|
1500
1732
|
`[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
1733
|
);
|
|
1502
1734
|
}
|
|
1735
|
+
const subscriptions = generated["foirSubscriptions"];
|
|
1736
|
+
if (subscriptions !== void 0 && !Array.isArray(subscriptions)) {
|
|
1737
|
+
throw new FoirRequestError(
|
|
1738
|
+
"[foir] createFoirClient: `generated.foirSubscriptions` is not an array of subscription descriptors. Re-run `foir types`."
|
|
1739
|
+
);
|
|
1740
|
+
}
|
|
1503
1741
|
return {
|
|
1504
1742
|
...config,
|
|
1505
|
-
|
|
1743
|
+
surfaceVersion: config.surfaceVersion ?? version,
|
|
1744
|
+
surfaceHashes: config.surfaceHashes ?? hashes,
|
|
1506
1745
|
selections: config.selections ?? selections,
|
|
1507
1746
|
namespaceSelections: config.namespaceSelections ?? namespaceSelections,
|
|
1508
|
-
selectionUnions: config.selectionUnions ?? selectionUnions
|
|
1747
|
+
selectionUnions: config.selectionUnions ?? selectionUnions,
|
|
1748
|
+
subscriptions: config.subscriptions ?? subscriptions
|
|
1509
1749
|
};
|
|
1510
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
|
+
}
|
|
1511
1794
|
function announceFallback(config) {
|
|
1512
1795
|
if (config.generated !== void 0 || config.selections !== void 0) return;
|
|
1513
1796
|
if (config.announceManifestFallback === false) return;
|
|
1514
1797
|
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
|
|
1798
|
+
"[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
1799
|
);
|
|
1517
1800
|
}
|
|
1518
1801
|
function createFoirClient(clientConfig) {
|
|
1519
|
-
if (!clientConfig?.apiUrl
|
|
1520
|
-
throw new Error(
|
|
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
|
+
);
|
|
1521
1806
|
}
|
|
1807
|
+
assertFoirCredential(clientConfig);
|
|
1522
1808
|
const config = resolveGeneratedConfig(clientConfig);
|
|
1523
1809
|
announceFallback(config);
|
|
1524
1810
|
let driftChecked = false;
|
|
1811
|
+
let detectedDrift;
|
|
1525
1812
|
let manifestOnce;
|
|
1526
1813
|
const manifest = () => {
|
|
1527
1814
|
if (!manifestOnce) {
|
|
@@ -1543,34 +1830,45 @@ function createFoirClient(clientConfig) {
|
|
|
1543
1830
|
return manifest();
|
|
1544
1831
|
};
|
|
1545
1832
|
const maybeCheckDrift = () => {
|
|
1546
|
-
const generated = config.
|
|
1833
|
+
const generated = config.surfaceVersion;
|
|
1547
1834
|
if (driftChecked) return;
|
|
1548
1835
|
if (generated === void 0) return;
|
|
1549
1836
|
if (config.checkSchemaDrift === false) return;
|
|
1550
1837
|
driftChecked = true;
|
|
1551
1838
|
void manifest().then((served) => {
|
|
1552
|
-
if (served.
|
|
1839
|
+
if (!served.surfaceVersion || served.surfaceVersion === generated) return;
|
|
1553
1840
|
const drift = {
|
|
1554
1841
|
generated,
|
|
1555
|
-
server: served.
|
|
1556
|
-
channel: served.channel
|
|
1842
|
+
server: served.surfaceVersion,
|
|
1843
|
+
channel: served.channel,
|
|
1844
|
+
changed: changedSurfaceParts(config.surfaceHashes, served.surfaceHashes)
|
|
1557
1845
|
};
|
|
1846
|
+
detectedDrift = drift;
|
|
1558
1847
|
if (config.onSchemaDrift) {
|
|
1559
1848
|
config.onSchemaDrift(drift);
|
|
1560
1849
|
return;
|
|
1561
1850
|
}
|
|
1562
1851
|
console.warn(
|
|
1563
|
-
`[foir] schema drift:
|
|
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.`
|
|
1564
1853
|
);
|
|
1565
1854
|
}).catch(() => {
|
|
1566
1855
|
});
|
|
1567
1856
|
};
|
|
1857
|
+
const assertNoStaleSelections = (what) => {
|
|
1858
|
+
if (config.strictSchemaDrift === false || !detectedDrift) return;
|
|
1859
|
+
throw new FoirSchemaDriftError(
|
|
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\`.`,
|
|
1861
|
+
detectedDrift
|
|
1862
|
+
);
|
|
1863
|
+
};
|
|
1568
1864
|
const runModel = async (model, op, query) => {
|
|
1865
|
+
assertNoStaleSelections(`${model}.${op}`);
|
|
1569
1866
|
const { args, include, omit, pick, depth } = splitQuery(query);
|
|
1570
|
-
const
|
|
1867
|
+
const source = await selectionSource();
|
|
1868
|
+
const plan = planFoirModelCall(source, {
|
|
1571
1869
|
model,
|
|
1572
1870
|
op,
|
|
1573
|
-
args,
|
|
1871
|
+
args: withCreateIdempotencyKey(source, model, op, args),
|
|
1574
1872
|
include,
|
|
1575
1873
|
omit,
|
|
1576
1874
|
pick,
|
|
@@ -1582,6 +1880,7 @@ function createFoirClient(clientConfig) {
|
|
|
1582
1880
|
return result;
|
|
1583
1881
|
};
|
|
1584
1882
|
const runNamespace = async (namespace, op, query) => {
|
|
1883
|
+
assertNoStaleSelections(`ns.${namespace}.${op}`);
|
|
1585
1884
|
const { args, include, omit, pick, depth } = splitQuery(query);
|
|
1586
1885
|
const plan = planFoirNamespaceCall(await selectionSource(), {
|
|
1587
1886
|
namespace,
|
|
@@ -1597,11 +1896,18 @@ function createFoirClient(clientConfig) {
|
|
|
1597
1896
|
maybeCheckDrift();
|
|
1598
1897
|
return result;
|
|
1599
1898
|
};
|
|
1899
|
+
const subscriptionManifest = () => {
|
|
1900
|
+
if (!config.subscriptions) return manifest();
|
|
1901
|
+
return Promise.resolve({
|
|
1902
|
+
subscriptions: config.subscriptions,
|
|
1903
|
+
selectionUnions: config.selectionUnions
|
|
1904
|
+
});
|
|
1905
|
+
};
|
|
1600
1906
|
const subscriptions = /* @__PURE__ */ (() => {
|
|
1601
1907
|
const held = { router: null };
|
|
1602
1908
|
return {
|
|
1603
1909
|
get() {
|
|
1604
|
-
held.router ??= new FoirSubscriptionRouter(config,
|
|
1910
|
+
held.router ??= new FoirSubscriptionRouter(config, subscriptionManifest);
|
|
1605
1911
|
return held.router;
|
|
1606
1912
|
},
|
|
1607
1913
|
dispose() {
|
|
@@ -1629,7 +1935,7 @@ function createFoirClient(clientConfig) {
|
|
|
1629
1935
|
return opProxy({ namespace });
|
|
1630
1936
|
}
|
|
1631
1937
|
});
|
|
1632
|
-
const raw = async (document, variables) => {
|
|
1938
|
+
const raw = async (document, variables, options) => {
|
|
1633
1939
|
if (typeof document !== "string" || !document.trim()) {
|
|
1634
1940
|
throw new FoirRequestError("[foir] raw() takes a GraphQL document string");
|
|
1635
1941
|
}
|
|
@@ -1637,7 +1943,8 @@ function createFoirClient(clientConfig) {
|
|
|
1637
1943
|
config,
|
|
1638
1944
|
document,
|
|
1639
1945
|
isFoirMutationDocument(document),
|
|
1640
|
-
variables
|
|
1946
|
+
variables,
|
|
1947
|
+
options
|
|
1641
1948
|
);
|
|
1642
1949
|
maybeCheckDrift();
|
|
1643
1950
|
return result;
|
|
@@ -1666,16 +1973,236 @@ function createFoirClientFromEnv(env, overrides) {
|
|
|
1666
1973
|
channel: isFoirPreview(env) ? "draft" : "published"
|
|
1667
1974
|
});
|
|
1668
1975
|
}
|
|
1976
|
+
|
|
1977
|
+
// src/data/decimal.ts
|
|
1978
|
+
var DECIMAL_PATTERN = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
1979
|
+
var MAX_SCALE = 1e3;
|
|
1980
|
+
function pow10(n) {
|
|
1981
|
+
return 10n ** BigInt(n);
|
|
1982
|
+
}
|
|
1983
|
+
function divRound(numerator, denominator, rounding) {
|
|
1984
|
+
if (denominator === 0n) throw new RangeError("FoirDecimal: division by zero");
|
|
1985
|
+
const negative = numerator < 0n !== denominator < 0n;
|
|
1986
|
+
const n = numerator < 0n ? -numerator : numerator;
|
|
1987
|
+
const d = denominator < 0n ? -denominator : denominator;
|
|
1988
|
+
let quotient = n / d;
|
|
1989
|
+
const remainder = n % d;
|
|
1990
|
+
if (remainder !== 0n && rounding !== "down") {
|
|
1991
|
+
const doubled = remainder * 2n;
|
|
1992
|
+
if (doubled > d) {
|
|
1993
|
+
quotient += 1n;
|
|
1994
|
+
} else if (doubled === d) {
|
|
1995
|
+
if (rounding === "half-up" || quotient % 2n === 1n) quotient += 1n;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
return negative ? -quotient : quotient;
|
|
1999
|
+
}
|
|
2000
|
+
function parse(input) {
|
|
2001
|
+
const text = input.trim();
|
|
2002
|
+
if (!DECIMAL_PATTERN.test(text)) {
|
|
2003
|
+
throw new TypeError(
|
|
2004
|
+
`FoirDecimal: ${JSON.stringify(input)} is not a decimal. Expected a numeric string such as "19.99".`
|
|
2005
|
+
);
|
|
2006
|
+
}
|
|
2007
|
+
const [mantissa, exponentText] = text.split(/[eE]/);
|
|
2008
|
+
const exponent = exponentText ? Number(exponentText) : 0;
|
|
2009
|
+
const negative = mantissa.startsWith("-");
|
|
2010
|
+
const unsigned = negative || mantissa.startsWith("+") ? mantissa.slice(1) : mantissa;
|
|
2011
|
+
const [integerPart = "", fractionPart = ""] = unsigned.split(".");
|
|
2012
|
+
let units = BigInt(`${integerPart || "0"}${fractionPart}`);
|
|
2013
|
+
let scale = fractionPart.length - exponent;
|
|
2014
|
+
if (scale < 0) {
|
|
2015
|
+
units *= pow10(-scale);
|
|
2016
|
+
scale = 0;
|
|
2017
|
+
}
|
|
2018
|
+
if (scale > MAX_SCALE) {
|
|
2019
|
+
throw new RangeError(`FoirDecimal: scale ${scale} exceeds the maximum of ${MAX_SCALE}`);
|
|
2020
|
+
}
|
|
2021
|
+
return { units: negative ? -units : units, scale };
|
|
2022
|
+
}
|
|
2023
|
+
var FoirDecimal = class _FoirDecimal {
|
|
2024
|
+
// Not `readonly` only so `#of` can populate a fresh instance without going
|
|
2025
|
+
// back through the string parser. Never mutated after construction, and
|
|
2026
|
+
// `#`-private so immutability is not part of the public contract to keep.
|
|
2027
|
+
#units;
|
|
2028
|
+
#scale;
|
|
2029
|
+
constructor(value) {
|
|
2030
|
+
if (value instanceof _FoirDecimal) {
|
|
2031
|
+
this.#units = value.#units;
|
|
2032
|
+
this.#scale = value.#scale;
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
if (typeof value === "bigint") {
|
|
2036
|
+
this.#units = value;
|
|
2037
|
+
this.#scale = 0;
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
if (typeof value !== "string") {
|
|
2041
|
+
throw new TypeError(
|
|
2042
|
+
`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.`
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
const { units, scale } = parse(value);
|
|
2046
|
+
this.#units = units;
|
|
2047
|
+
this.#scale = scale;
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Build a decimal from a JavaScript number, accepting the precision that
|
|
2051
|
+
* number may already have lost.
|
|
2052
|
+
*
|
|
2053
|
+
* Deliberately not the constructor. It is for interop — a value read from a
|
|
2054
|
+
* third-party JSON API that emitted a float — and naming it at every call
|
|
2055
|
+
* site is the point. `fromNumber(0.1 + 0.2)` gives `0.30000000000000004`,
|
|
2056
|
+
* because that is genuinely the value it was handed.
|
|
2057
|
+
*/
|
|
2058
|
+
static fromNumber(value) {
|
|
2059
|
+
if (!Number.isFinite(value)) {
|
|
2060
|
+
throw new RangeError(`FoirDecimal: ${value} is not a finite number`);
|
|
2061
|
+
}
|
|
2062
|
+
return new _FoirDecimal(value.toPrecision(17));
|
|
2063
|
+
}
|
|
2064
|
+
/** `true` if `value` parses as a decimal. Does not throw. */
|
|
2065
|
+
static isDecimalString(value) {
|
|
2066
|
+
return typeof value === "string" && DECIMAL_PATTERN.test(value.trim());
|
|
2067
|
+
}
|
|
2068
|
+
/** Decimal places carried by this value. `19.90` has scale 2. */
|
|
2069
|
+
get scale() {
|
|
2070
|
+
return this.#scale;
|
|
2071
|
+
}
|
|
2072
|
+
/** Re-express at `scale`, rounding away any digits that no longer fit. */
|
|
2073
|
+
rescale(scale, rounding = "half-up") {
|
|
2074
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
|
|
2075
|
+
throw new RangeError(`FoirDecimal: scale must be an integer in 0..${MAX_SCALE}, got ${scale}`);
|
|
2076
|
+
}
|
|
2077
|
+
if (scale === this.#scale) return this;
|
|
2078
|
+
const units = scale > this.#scale ? this.#units * pow10(scale - this.#scale) : divRound(this.#units, pow10(this.#scale - scale), rounding);
|
|
2079
|
+
return _FoirDecimal.#of(units, scale);
|
|
2080
|
+
}
|
|
2081
|
+
add(other) {
|
|
2082
|
+
const [a, b, scale] = _FoirDecimal.#align(this, other);
|
|
2083
|
+
return _FoirDecimal.#of(a + b, scale);
|
|
2084
|
+
}
|
|
2085
|
+
sub(other) {
|
|
2086
|
+
const [a, b, scale] = _FoirDecimal.#align(this, other);
|
|
2087
|
+
return _FoirDecimal.#of(a - b, scale);
|
|
2088
|
+
}
|
|
2089
|
+
/** Exact: scales add, so `1.10 × 1.10` is `1.2100` rather than `1.21`. */
|
|
2090
|
+
mul(other) {
|
|
2091
|
+
const rhs = new _FoirDecimal(other);
|
|
2092
|
+
return _FoirDecimal.#of(this.#units * rhs.#units, this.#scale + rhs.#scale);
|
|
2093
|
+
}
|
|
2094
|
+
/** The only inexact operation — see {@link FoirDecimalDivOptions}. */
|
|
2095
|
+
div(other, options = {}) {
|
|
2096
|
+
const { scale = 20, rounding = "half-even" } = options;
|
|
2097
|
+
if (!Number.isInteger(scale) || scale < 0 || scale > MAX_SCALE) {
|
|
2098
|
+
throw new RangeError(`FoirDecimal: scale must be an integer in 0..${MAX_SCALE}, got ${scale}`);
|
|
2099
|
+
}
|
|
2100
|
+
const rhs = new _FoirDecimal(other);
|
|
2101
|
+
const numerator = this.#units * pow10(rhs.#scale + scale);
|
|
2102
|
+
const denominator = rhs.#units * pow10(this.#scale);
|
|
2103
|
+
return _FoirDecimal.#of(divRound(numerator, denominator, rounding), scale);
|
|
2104
|
+
}
|
|
2105
|
+
abs() {
|
|
2106
|
+
return this.#units < 0n ? _FoirDecimal.#of(-this.#units, this.#scale) : this;
|
|
2107
|
+
}
|
|
2108
|
+
neg() {
|
|
2109
|
+
return _FoirDecimal.#of(-this.#units, this.#scale);
|
|
2110
|
+
}
|
|
2111
|
+
isZero() {
|
|
2112
|
+
return this.#units === 0n;
|
|
2113
|
+
}
|
|
2114
|
+
isNegative() {
|
|
2115
|
+
return this.#units < 0n;
|
|
2116
|
+
}
|
|
2117
|
+
/** `-1`, `0` or `1`. Use this rather than `<`, which throws. */
|
|
2118
|
+
cmp(other) {
|
|
2119
|
+
const [a, b] = _FoirDecimal.#align(this, other);
|
|
2120
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
2121
|
+
}
|
|
2122
|
+
/** Numeric equality: `1.0` equals `1.00`. Scale is display, not identity. */
|
|
2123
|
+
equals(other) {
|
|
2124
|
+
return this.cmp(other) === 0;
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Fixed-place string, rounding `half-up` by default because this is the
|
|
2128
|
+
* display operation and that is what a person means by "to 2dp".
|
|
2129
|
+
*/
|
|
2130
|
+
toFixed(places, rounding = "half-up") {
|
|
2131
|
+
return this.rescale(places, rounding).toString();
|
|
2132
|
+
}
|
|
2133
|
+
/**
|
|
2134
|
+
* The canonical wire form, preserving scale. Never exponent notation, so the
|
|
2135
|
+
* output always parses back to an equal value.
|
|
2136
|
+
*/
|
|
2137
|
+
toString() {
|
|
2138
|
+
const negative = this.#units < 0n;
|
|
2139
|
+
const digits = (negative ? -this.#units : this.#units).toString();
|
|
2140
|
+
if (this.#scale === 0) return negative ? `-${digits}` : digits;
|
|
2141
|
+
const padded = digits.padStart(this.#scale + 1, "0");
|
|
2142
|
+
const integerPart = padded.slice(0, padded.length - this.#scale);
|
|
2143
|
+
const fractionPart = padded.slice(padded.length - this.#scale);
|
|
2144
|
+
return `${negative ? "-" : ""}${integerPart}.${fractionPart}`;
|
|
2145
|
+
}
|
|
2146
|
+
/** `JSON.stringify` emits the canonical string, not an object. Pinned by test. */
|
|
2147
|
+
toJSON() {
|
|
2148
|
+
return this.toString();
|
|
2149
|
+
}
|
|
2150
|
+
/**
|
|
2151
|
+
* Explicitly lossy — this is the boundary back into float, and naming it is
|
|
2152
|
+
* the point. Fine for a chart axis; never for money you will add up.
|
|
2153
|
+
*/
|
|
2154
|
+
toNumber() {
|
|
2155
|
+
return Number(this.toString());
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Answers the `'string'` hint so template literals work, and throws on every
|
|
2159
|
+
* other hint so `+`, `<` and `Number()` fail loudly instead of silently
|
|
2160
|
+
* concatenating or truncating.
|
|
2161
|
+
*/
|
|
2162
|
+
[Symbol.toPrimitive](hint) {
|
|
2163
|
+
if (hint === "string") return this.toString();
|
|
2164
|
+
throw new TypeError(
|
|
2165
|
+
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."
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
get [Symbol.toStringTag]() {
|
|
2169
|
+
return "FoirDecimal";
|
|
2170
|
+
}
|
|
2171
|
+
/** Skip parsing when the units are already known to be well-formed. */
|
|
2172
|
+
static #of(units, scale) {
|
|
2173
|
+
const instance = new _FoirDecimal(0n);
|
|
2174
|
+
instance.#units = units;
|
|
2175
|
+
instance.#scale = scale;
|
|
2176
|
+
return instance;
|
|
2177
|
+
}
|
|
2178
|
+
/** Bring two values to a common scale so their units can be compared. */
|
|
2179
|
+
static #align(left, right) {
|
|
2180
|
+
const rhs = new _FoirDecimal(right);
|
|
2181
|
+
const scale = Math.max(left.#scale, rhs.#scale);
|
|
2182
|
+
return [
|
|
2183
|
+
left.#units * pow10(scale - left.#scale),
|
|
2184
|
+
rhs.#units * pow10(scale - rhs.#scale),
|
|
2185
|
+
scale
|
|
2186
|
+
];
|
|
2187
|
+
}
|
|
2188
|
+
};
|
|
2189
|
+
function decimal(value) {
|
|
2190
|
+
return new FoirDecimal(value);
|
|
2191
|
+
}
|
|
1669
2192
|
export {
|
|
2193
|
+
FoirDecimal,
|
|
1670
2194
|
FoirRequestError,
|
|
2195
|
+
FoirSchemaDriftError,
|
|
1671
2196
|
FoirSocketTransport,
|
|
1672
2197
|
createFoirClient,
|
|
1673
2198
|
createFoirClientFromEnv,
|
|
1674
2199
|
createFoirSubjectRegistry,
|
|
2200
|
+
decimal,
|
|
1675
2201
|
fetchFoirManifest,
|
|
1676
2202
|
foirDataEndpoint,
|
|
1677
2203
|
foirRoute,
|
|
1678
2204
|
foirSocketEndpoint,
|
|
2205
|
+
graphql,
|
|
1679
2206
|
planFoirModelCall,
|
|
1680
2207
|
planFoirNamespaceCall,
|
|
1681
2208
|
printSubscriptionDocument,
|