@foir/sdk 0.7.0 → 0.9.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 +857 -4
- package/README.md +46 -15
- package/dist/auth/auth.d.ts +104 -1
- package/dist/auth/hosted.d.ts +22 -0
- package/dist/auth/index.d.ts +13 -7
- package/dist/auth/operations.d.ts +85 -64
- package/dist/auth/ops.d.ts +99 -0
- package/dist/auth.js +786 -106
- package/dist/chunk-IGNLWT3H.js +35 -0
- package/dist/chunk-OHYVKTGB.js +45 -0
- package/dist/chunk-TAL5H6VA.js +39 -0
- package/dist/client.js +25 -31
- package/dist/codegen/block-registry.d.ts +13 -19
- package/dist/codegen/block-registry.js +3 -31
- package/dist/codegen/block-scaffold.d.ts +12 -21
- package/dist/codegen/block-scaffold.js +3 -41
- package/dist/codegen/index.d.ts +9 -4
- package/dist/codegen/index.js +7 -11
- package/dist/codegen/item-registry.d.ts +28 -0
- package/dist/codegen/item-registry.js +7 -0
- package/dist/codegen/item-scaffold.d.ts +29 -0
- package/dist/codegen/item-scaffold.js +7 -0
- package/dist/codegen/preset.d.ts +14 -7
- package/dist/codegen/shared.d.ts +20 -9
- package/dist/content/content-renderer.d.ts +90 -24
- package/dist/content/index.d.ts +13 -7
- package/dist/content/richtext.d.ts +66 -18
- package/dist/content.js +227 -80
- package/dist/data/builtins.d.ts +369 -92
- package/dist/data/client.d.ts +97 -25
- package/dist/data/document.d.ts +251 -0
- package/dist/data/index.d.ts +38 -19
- package/dist/data/manifest.d.ts +290 -8
- package/dist/data/schema.d.ts +78 -8
- package/dist/data/selection.d.ts +231 -37
- package/dist/data/subjects.d.ts +221 -0
- package/dist/data/subscription-document.d.ts +75 -0
- package/dist/data/subscriptions.d.ts +181 -0
- package/dist/data/transport.d.ts +255 -37
- package/dist/data/ws-transport.d.ts +197 -0
- package/dist/data.js +1419 -45
- package/dist/graphql/cdn-route.d.ts +12 -28
- package/dist/media/index.d.ts +6 -2
- package/dist/media/media.d.ts +2 -2
- package/dist/media/screen-capture.d.ts +81 -0
- package/dist/media.js +97 -0
- package/dist/next.js +663 -182
- package/dist/push.js +700 -90
- package/dist/react/FoirFeedback.d.ts +62 -0
- package/dist/react/feedback-notes.d.ts +101 -0
- package/dist/react/index.d.ts +7 -1
- package/dist/react.js +1272 -194
- package/dist/realtime.js +1 -1
- package/dist/server/events-client.d.ts +279 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server.js +298 -0
- package/dist/transport/foir-http.d.ts +23 -6
- package/dist/transport/persisted-query.d.ts +68 -0
- package/dist/transport/retry.d.ts +1 -1
- package/package.json +13 -1
- package/dist/chunk-ADO5FJNM.js +0 -30
package/dist/data.js
CHANGED
|
@@ -73,6 +73,13 @@ function buildFoirHeaders(input) {
|
|
|
73
73
|
}
|
|
74
74
|
return headers;
|
|
75
75
|
}
|
|
76
|
+
function foirApiEndpoint(apiUrl, path) {
|
|
77
|
+
const base = apiUrl.trim().replace(/\/+$/, "").replace(/\/graphql$/i, "");
|
|
78
|
+
if (!base) {
|
|
79
|
+
throw new FoirRequestError("[foir] apiUrl is empty \u2014 pass the Foir API origin");
|
|
80
|
+
}
|
|
81
|
+
return base + path;
|
|
82
|
+
}
|
|
76
83
|
function readFoirEnvelope(json) {
|
|
77
84
|
if (json.errors?.length) {
|
|
78
85
|
const msg = json.errors.map((e) => e.message).join("; ");
|
|
@@ -90,44 +97,105 @@ function readFoirEnvelope(json) {
|
|
|
90
97
|
return json.data;
|
|
91
98
|
}
|
|
92
99
|
|
|
100
|
+
// src/transport/persisted-query.ts
|
|
101
|
+
var FOIR_CDN_GRAPHQL_PATH = "/cdn/graphql";
|
|
102
|
+
function persistedQueryExtension(hash) {
|
|
103
|
+
return { persistedQuery: { version: 1, sha256Hash: hash } };
|
|
104
|
+
}
|
|
105
|
+
function buildCdnUrl(endpoint, apiKey, hash, variables) {
|
|
106
|
+
const url = new URL(endpoint);
|
|
107
|
+
url.pathname = url.pathname.replace(/\/graphql\/?$/, "") + FOIR_CDN_GRAPHQL_PATH;
|
|
108
|
+
url.searchParams.set("key", apiKey);
|
|
109
|
+
url.searchParams.set("extensions", JSON.stringify(persistedQueryExtension(hash)));
|
|
110
|
+
if (variables && Object.keys(variables).length > 0) {
|
|
111
|
+
url.searchParams.set("variables", JSON.stringify(variables));
|
|
112
|
+
}
|
|
113
|
+
return url.toString();
|
|
114
|
+
}
|
|
115
|
+
async function documentHash(documentText) {
|
|
116
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(documentText));
|
|
117
|
+
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
118
|
+
}
|
|
119
|
+
var PERSISTED_QUERY_NOT_FOUND = "PERSISTED_QUERY_NOT_FOUND";
|
|
120
|
+
var NOT_SHAREABLE = "NOT_SHAREABLE";
|
|
121
|
+
async function hasFoirErrorCode(res, ...codes) {
|
|
122
|
+
try {
|
|
123
|
+
const body = await res.clone().json();
|
|
124
|
+
return body.errors?.some((e) => e.extensions?.code !== void 0 && codes.includes(e.extensions.code)) ?? false;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
93
130
|
// src/data/transport.ts
|
|
94
131
|
function foirDataEndpoint(apiUrl, path) {
|
|
95
|
-
|
|
96
|
-
if (!base) {
|
|
97
|
-
throw new FoirRequestError("[foir] apiUrl is empty \u2014 pass the Foir API origin");
|
|
98
|
-
}
|
|
99
|
-
return base + path;
|
|
132
|
+
return foirApiEndpoint(apiUrl, path);
|
|
100
133
|
}
|
|
101
134
|
async function resolveBearerToken(config) {
|
|
102
135
|
const source = config.bearerToken;
|
|
103
136
|
if (typeof source === "function") return await source() ?? void 0;
|
|
104
137
|
return source ?? void 0;
|
|
105
138
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
function foirRoute(input) {
|
|
140
|
+
if (!input.apiKey.startsWith("pk_")) return { lane: "origin", reason: "key" };
|
|
141
|
+
if (input.bearerToken) return { lane: "origin", reason: "bearer" };
|
|
142
|
+
if (input.mutation) return { lane: "origin", reason: "operation" };
|
|
143
|
+
if (input.channel === "draft") return { lane: "origin", reason: "channel" };
|
|
144
|
+
return { lane: "shared" };
|
|
145
|
+
}
|
|
146
|
+
async function executeFoirDocument(config, plan) {
|
|
147
|
+
const data = await runFoirDocument(config, plan.document, plan.mutation, plan.variables);
|
|
148
|
+
if (!(plan.rootField in data)) {
|
|
149
|
+
throw new FoirRequestError(
|
|
150
|
+
`[foir] the response has no "${plan.rootField}" field` + (Object.keys(data).length ? ` (got ${Object.keys(data).join(", ")})` : "")
|
|
151
|
+
);
|
|
112
152
|
}
|
|
153
|
+
return data[plan.rootField];
|
|
154
|
+
}
|
|
155
|
+
async function executeFoirRawDocument(config, document, mutation, variables) {
|
|
156
|
+
return await runFoirDocument(config, document, mutation, variables);
|
|
113
157
|
}
|
|
114
|
-
async function
|
|
115
|
-
const url = foirDataEndpoint(config.apiUrl, "/v1/query");
|
|
158
|
+
async function runFoirDocument(config, document, mutation, variables) {
|
|
116
159
|
const doFetch = config.fetch ?? globalThis.fetch;
|
|
160
|
+
const endpoint = foirDataEndpoint(config.apiUrl, "/graphql");
|
|
161
|
+
const bearerToken = await resolveBearerToken(config);
|
|
117
162
|
const headers = buildFoirHeaders({
|
|
118
163
|
apiKey: config.apiKey,
|
|
119
164
|
draftChannel: config.channel === "draft",
|
|
120
|
-
bearerToken
|
|
165
|
+
bearerToken,
|
|
121
166
|
extra: config.headers
|
|
122
167
|
});
|
|
168
|
+
const hash = await documentHash(document);
|
|
169
|
+
const route = foirRoute({
|
|
170
|
+
apiKey: config.apiKey,
|
|
171
|
+
bearerToken,
|
|
172
|
+
mutation,
|
|
173
|
+
channel: config.channel
|
|
174
|
+
});
|
|
175
|
+
const post = (body) => doFetch(endpoint, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers,
|
|
178
|
+
body: JSON.stringify(body),
|
|
179
|
+
...config.requestInit
|
|
180
|
+
});
|
|
181
|
+
const extensions = persistedQueryExtension(hash);
|
|
123
182
|
let res;
|
|
124
183
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
184
|
+
if (route.lane === "shared") {
|
|
185
|
+
res = await doFetch(buildCdnUrl(endpoint, config.apiKey, hash, variables), {
|
|
186
|
+
method: "GET",
|
|
187
|
+
headers,
|
|
188
|
+
...config.requestInit
|
|
189
|
+
});
|
|
190
|
+
if (await hasFoirErrorCode(res, PERSISTED_QUERY_NOT_FOUND, NOT_SHAREABLE)) {
|
|
191
|
+
res = await post({ query: document, variables, extensions });
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
res = await post({ variables, extensions });
|
|
195
|
+
if (await hasFoirErrorCode(res, PERSISTED_QUERY_NOT_FOUND)) {
|
|
196
|
+
res = await post({ query: document, variables, extensions });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
131
199
|
} catch (err) {
|
|
132
200
|
throw new FoirRequestError("[foir] network error", { cause: err });
|
|
133
201
|
}
|
|
@@ -140,14 +208,15 @@ async function postFoirOp(config, body) {
|
|
|
140
208
|
});
|
|
141
209
|
}
|
|
142
210
|
const json = await res.json();
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
211
|
+
return readFoirEnvelope(json);
|
|
212
|
+
}
|
|
213
|
+
async function readErrorBody(res) {
|
|
214
|
+
try {
|
|
215
|
+
const json = await res.json();
|
|
216
|
+
return json?.errors?.length ? json.errors : void 0;
|
|
217
|
+
} catch {
|
|
218
|
+
return void 0;
|
|
149
219
|
}
|
|
150
|
-
return data[roots[0]];
|
|
151
220
|
}
|
|
152
221
|
|
|
153
222
|
// src/data/manifest.ts
|
|
@@ -178,6 +247,1127 @@ async function fetchFoirManifest(config) {
|
|
|
178
247
|
return await res.json();
|
|
179
248
|
}
|
|
180
249
|
|
|
250
|
+
// src/data/document.ts
|
|
251
|
+
function buildPathTrie(paths) {
|
|
252
|
+
const root = { self: false, children: /* @__PURE__ */ new Map() };
|
|
253
|
+
for (const path of paths) {
|
|
254
|
+
let node = root;
|
|
255
|
+
for (const segment of path.split(".")) {
|
|
256
|
+
if (!segment) continue;
|
|
257
|
+
let next = node.children.get(segment);
|
|
258
|
+
if (!next) {
|
|
259
|
+
next = { self: false, children: /* @__PURE__ */ new Map() };
|
|
260
|
+
node.children.set(segment, next);
|
|
261
|
+
}
|
|
262
|
+
node = next;
|
|
263
|
+
}
|
|
264
|
+
node.self = true;
|
|
265
|
+
}
|
|
266
|
+
return root;
|
|
267
|
+
}
|
|
268
|
+
function childFor(node, field) {
|
|
269
|
+
return node.children.get(field.key) ?? (field.alias ? node.children.get(field.alias) : void 0);
|
|
270
|
+
}
|
|
271
|
+
function segmentFor(node, field) {
|
|
272
|
+
return node.children.has(field.key) ? field.key : field.alias ?? field.key;
|
|
273
|
+
}
|
|
274
|
+
var PROTECTED_ROOT_FIELD = "_id";
|
|
275
|
+
var MAX_QUERY_COMPLEXITY = 1e4;
|
|
276
|
+
var MAX_EMITTED_FIELDS = 4 * MAX_QUERY_COMPLEXITY;
|
|
277
|
+
var newBudget = () => ({ fields: 0, emitted: 0, stopped: false });
|
|
278
|
+
function charge(ctx, n = 1) {
|
|
279
|
+
ctx.budget.fields += n;
|
|
280
|
+
ctx.budget.emitted += n;
|
|
281
|
+
}
|
|
282
|
+
function mayExpand(ctx, depth) {
|
|
283
|
+
if (depth <= 1) return false;
|
|
284
|
+
if (ctx.budget.emitted > MAX_EMITTED_FIELDS) {
|
|
285
|
+
ctx.budget.stopped = true;
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
function printFields(fields, ctx, omit, pick, isRoot, path, depth) {
|
|
291
|
+
const parts = [];
|
|
292
|
+
for (const field of fields) {
|
|
293
|
+
if (field.relation) {
|
|
294
|
+
const named = pick ? childFor(pick, field) : void 0;
|
|
295
|
+
if (named) {
|
|
296
|
+
const segment = segmentFor(pick, field);
|
|
297
|
+
throw new FoirRequestError(
|
|
298
|
+
`[foir] pick "${path}${segment}": "${segment}" is a relation, and relations are not part of the default document \u2014 no pick can select one. ` + (path ? (
|
|
299
|
+
// Nested: there is no `include` form that reaches here at all.
|
|
300
|
+
// `include` addresses the ROOT model's own relations, and (nested)
|
|
301
|
+
// a related model's — never one reached through a composite or a
|
|
302
|
+
// union member. Saying "use include" would send the caller to a
|
|
303
|
+
// call that refuses them a second time.
|
|
304
|
+
`A relation at this position cannot be reached by \`include\` either \u2014 that addresses the model's own relations, not one inside a composite or a union member. Read it through the field that owns it, or drop "${path}${segment}" from the pick`
|
|
305
|
+
) : `Use include: { ${segment}: true } instead`)
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const protectedField = isRoot && field.key === PROTECTED_ROOT_FIELD;
|
|
311
|
+
const omitChild = omit ? childFor(omit, field) : void 0;
|
|
312
|
+
if (omitChild?.self && !protectedField) continue;
|
|
313
|
+
let pickChild;
|
|
314
|
+
if (pick) {
|
|
315
|
+
pickChild = childFor(pick, field);
|
|
316
|
+
if (!pickChild && !protectedField) continue;
|
|
317
|
+
if (pickChild?.self) pickChild = void 0;
|
|
318
|
+
}
|
|
319
|
+
const head = field.alias ? `${field.alias}: ${field.key}` : field.key;
|
|
320
|
+
const childPath = `${path}${pick && childFor(pick, field) ? segmentFor(pick, field) : field.key}.`;
|
|
321
|
+
if (field.truncated) {
|
|
322
|
+
if (field.union && mayExpand(ctx, depth)) {
|
|
323
|
+
const body = printUnion(field.union, ctx, omitChild, pickChild, childPath, depth - 1);
|
|
324
|
+
if (body) {
|
|
325
|
+
charge(ctx);
|
|
326
|
+
parts.push(`${head} ${body}`);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
charge(ctx, 2);
|
|
331
|
+
parts.push(`${head} { __typename }`);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
if (field.model) {
|
|
335
|
+
const body = printModelTree(field.model, ctx, depth);
|
|
336
|
+
if (body) {
|
|
337
|
+
charge(ctx);
|
|
338
|
+
parts.push(`${head} { ${body} }`);
|
|
339
|
+
}
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (field.union) {
|
|
343
|
+
const body = printUnion(field.union, ctx, omitChild, pickChild, childPath, depth);
|
|
344
|
+
if (body) {
|
|
345
|
+
charge(ctx);
|
|
346
|
+
parts.push(`${head} ${body}`);
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (field.fields?.length) {
|
|
351
|
+
const body = printFields(field.fields, ctx, omitChild, pickChild, false, childPath, depth);
|
|
352
|
+
if (body) {
|
|
353
|
+
charge(ctx);
|
|
354
|
+
parts.push(`${head} { ${body} }`);
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
charge(ctx);
|
|
359
|
+
parts.push(head);
|
|
360
|
+
}
|
|
361
|
+
return parts.join(" ");
|
|
362
|
+
}
|
|
363
|
+
function printModelTree(modelKey, ctx, depth) {
|
|
364
|
+
const model = ctx.source.models[modelKey];
|
|
365
|
+
if (!model?.selection) {
|
|
366
|
+
throw new FoirRequestError(
|
|
367
|
+
`[foir] the result shape references the model "${modelKey}", which publishes no selection \u2014 re-run \`foir types\``
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
const narrowing = ctx.record?.modelKey === modelKey ? ctx.record : void 0;
|
|
371
|
+
const body = printFields(
|
|
372
|
+
model.selection.fields,
|
|
373
|
+
ctx,
|
|
374
|
+
narrowing?.omit,
|
|
375
|
+
narrowing?.pick,
|
|
376
|
+
true,
|
|
377
|
+
"",
|
|
378
|
+
depth
|
|
379
|
+
);
|
|
380
|
+
const includes = narrowing?.include ? printIncludes(ctx.source, model, modelKey, narrowing.include, ctx, depth) : "";
|
|
381
|
+
if (!body) {
|
|
382
|
+
return includes;
|
|
383
|
+
}
|
|
384
|
+
return includes ? `${body} ${includes}` : body;
|
|
385
|
+
}
|
|
386
|
+
function printFoirSelectionBody(fields, unions, models = {}) {
|
|
387
|
+
return printFields(
|
|
388
|
+
fields,
|
|
389
|
+
{ source: { models }, unions, budget: newBudget() },
|
|
390
|
+
void 0,
|
|
391
|
+
void 0,
|
|
392
|
+
false,
|
|
393
|
+
"",
|
|
394
|
+
// A subscription payload takes no caller options, so it prints at the
|
|
395
|
+
// default level: every stopping point degrades to `__typename` exactly as
|
|
396
|
+
// it did before depth existed.
|
|
397
|
+
DEFAULT_DEPTH
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
var NO_FIELDS = { self: false, children: /* @__PURE__ */ new Map() };
|
|
401
|
+
function addressesMembers(node, union) {
|
|
402
|
+
for (const member of union.members) {
|
|
403
|
+
if (node.children.has(member.typeName)) return true;
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
function printUnion(unionName, ctx, omit, pick, path, depth) {
|
|
408
|
+
const union = ctx.unions[unionName];
|
|
409
|
+
if (!union) {
|
|
410
|
+
throw new FoirRequestError(
|
|
411
|
+
`[foir] the selection tree names the union "${unionName}", which is not in selectionUnions \u2014 re-run \`foir types\`, or pass selectionUnions alongside selections`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
const perVariantPick = pick !== void 0 && addressesMembers(pick, union);
|
|
415
|
+
const perVariantOmit = omit !== void 0 && addressesMembers(omit, union);
|
|
416
|
+
charge(ctx);
|
|
417
|
+
const parts = ["__typename"];
|
|
418
|
+
const chargeBefore = ctx.budget.fields;
|
|
419
|
+
let widestMember = 0;
|
|
420
|
+
for (const member of union.members) {
|
|
421
|
+
let memberPick = pick;
|
|
422
|
+
if (perVariantPick) {
|
|
423
|
+
const named = pick.children.get(member.typeName);
|
|
424
|
+
memberPick = named === void 0 ? NO_FIELDS : named.self ? void 0 : named;
|
|
425
|
+
}
|
|
426
|
+
let memberOmit = omit;
|
|
427
|
+
if (perVariantOmit) {
|
|
428
|
+
const named = omit.children.get(member.typeName);
|
|
429
|
+
if (named?.self) continue;
|
|
430
|
+
memberOmit = named;
|
|
431
|
+
}
|
|
432
|
+
const memberPath = perVariantPick ? `${path}${member.typeName}.` : path;
|
|
433
|
+
const memberStart = ctx.budget.fields;
|
|
434
|
+
const body = printFields(member.fields, ctx, memberOmit, memberPick, false, memberPath, depth);
|
|
435
|
+
const memberCost = ctx.budget.fields - memberStart;
|
|
436
|
+
ctx.budget.fields = memberStart;
|
|
437
|
+
if (memberCost > widestMember) widestMember = memberCost;
|
|
438
|
+
if (body) parts.push(`... on ${member.typeName} { ${body} }`);
|
|
439
|
+
}
|
|
440
|
+
ctx.budget.fields = chargeBefore + widestMember;
|
|
441
|
+
return `{ ${parts.join(" ")} }`;
|
|
442
|
+
}
|
|
443
|
+
function printIncludes(source, model, modelKey, include, ctx, depth) {
|
|
444
|
+
const parts = [];
|
|
445
|
+
for (const [key, value] of Object.entries(include)) {
|
|
446
|
+
if (value === void 0 || value === false) continue;
|
|
447
|
+
const relation = model.relations?.find((r) => r.key === key);
|
|
448
|
+
if (!relation) {
|
|
449
|
+
throw new FoirRequestError(
|
|
450
|
+
`[foir] include "${key}": ${modelKey} has no such relation \u2014 re-run \`foir types\` if the model has changed`
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const nested = value && typeof value === "object" && "include" in value ? value["include"] : void 0;
|
|
454
|
+
const body = relation.targets?.length ? printPolymorphicRelation(source, relation, ctx, nested, depth) : printRelationTarget(source, relation.target ?? "", ctx, nested, relation.key, depth);
|
|
455
|
+
charge(ctx, relation.connection ? 1 + CONNECTION_WRAPPER_FIELDS : 1);
|
|
456
|
+
parts.push(`${key} ${relation.connection ? wrapRelationConnection(body) : `{ ${body} }`}`);
|
|
457
|
+
}
|
|
458
|
+
return parts.join(" ");
|
|
459
|
+
}
|
|
460
|
+
var CONNECTION_WRAPPER_FIELDS = 9;
|
|
461
|
+
function wrapRelationConnection(body) {
|
|
462
|
+
return `{ edges { cursor node { ${body} } } pageInfo { hasNextPage hasPreviousPage startCursor endCursor } totalCount }`;
|
|
463
|
+
}
|
|
464
|
+
function printRelationTarget(source, targetKey, ctx, nested, relationKey, depth) {
|
|
465
|
+
const target = source.models[targetKey];
|
|
466
|
+
if (!target?.selection) {
|
|
467
|
+
throw new FoirRequestError(
|
|
468
|
+
`[foir] include "${relationKey}": no selection published for its target model "${targetKey}" \u2014 re-run \`foir types\``
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const bare = { source, unions: ctx.unions, budget: ctx.budget };
|
|
472
|
+
const body = printFields(target.selection.fields, bare, void 0, void 0, true, "", depth);
|
|
473
|
+
const includes = nested ? printIncludes(source, target, targetKey, nested, bare, depth) : "";
|
|
474
|
+
return includes ? `${body} ${includes}` : body;
|
|
475
|
+
}
|
|
476
|
+
function printPolymorphicRelation(source, relation, ctx, nested, depth) {
|
|
477
|
+
charge(ctx);
|
|
478
|
+
const parts = ["__typename"];
|
|
479
|
+
const chargeBefore = ctx.budget.fields;
|
|
480
|
+
let widestTarget = 0;
|
|
481
|
+
for (const targetKey of relation.targets ?? []) {
|
|
482
|
+
const target = source.models[targetKey];
|
|
483
|
+
if (!target?.selection) {
|
|
484
|
+
throw new FoirRequestError(
|
|
485
|
+
`[foir] include "${relation.key}": no selection published for its target model "${targetKey}" \u2014 re-run \`foir types\``
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const targetStart = ctx.budget.fields;
|
|
489
|
+
const body = printRelationTarget(source, targetKey, ctx, nested, relation.key, depth);
|
|
490
|
+
const targetCost = ctx.budget.fields - targetStart;
|
|
491
|
+
ctx.budget.fields = targetStart;
|
|
492
|
+
if (targetCost > widestTarget) widestTarget = targetCost;
|
|
493
|
+
if (body) parts.push(`... on ${target.typeName} { ${body} }`);
|
|
494
|
+
}
|
|
495
|
+
ctx.budget.fields = chargeBefore + widestTarget;
|
|
496
|
+
return parts.join(" ");
|
|
497
|
+
}
|
|
498
|
+
var GRAPHQL_TYPE_REF = /^\[?\[?[_A-Za-z][_0-9A-Za-z]*!?\]?!?\]?!?$/;
|
|
499
|
+
function buildArguments(args, op, opLabel) {
|
|
500
|
+
const declared = op.args;
|
|
501
|
+
const definitions = [];
|
|
502
|
+
const call = [];
|
|
503
|
+
const variables = {};
|
|
504
|
+
for (const name of Object.keys(args).sort()) {
|
|
505
|
+
const value = args[name];
|
|
506
|
+
if (value === void 0) continue;
|
|
507
|
+
if (!declared?.includes(name)) {
|
|
508
|
+
throw new FoirRequestError(
|
|
509
|
+
`[foir] ${opLabel} does not take an argument "${name}" \u2014 it takes ${declared?.length ? declared.join(", ") : "no arguments"}`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
const type = op.argTypes?.[name];
|
|
513
|
+
if (!type) {
|
|
514
|
+
throw new FoirRequestError(
|
|
515
|
+
`[foir] ${opLabel}: the manifest publishes no type for the argument "${name}", so it cannot be sent as a variable \u2014 and inferring one from the value would guess wrong on every enum. Re-run \`foir types\` against a server that publishes argTypes, or use foir.raw() with your own variable definitions`
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
if (!GRAPHQL_TYPE_REF.test(type)) {
|
|
519
|
+
throw new FoirRequestError(
|
|
520
|
+
`[foir] ${opLabel}: the manifest gives the argument "${name}" the type "${type}", which is not a GraphQL type reference`
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
definitions.push(`$${name}: ${type}`);
|
|
524
|
+
call.push(`${name}: $${name}`);
|
|
525
|
+
variables[name] = toVariableValue(value, `${opLabel} argument "${name}"`);
|
|
526
|
+
}
|
|
527
|
+
return {
|
|
528
|
+
definitions: definitions.length ? `(${definitions.join(", ")})` : "",
|
|
529
|
+
call: call.length ? `(${call.join(", ")})` : "",
|
|
530
|
+
variables
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function toVariableValue(value, label) {
|
|
534
|
+
if (value === null) return null;
|
|
535
|
+
const kind = typeof value;
|
|
536
|
+
if (kind === "string" || kind === "boolean") return value;
|
|
537
|
+
if (kind === "number") {
|
|
538
|
+
if (!Number.isFinite(value)) {
|
|
539
|
+
throw new FoirRequestError(
|
|
540
|
+
`[foir] ${label} is ${String(value)}, which has no JSON form and cannot be sent`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return value;
|
|
544
|
+
}
|
|
545
|
+
if (kind === "bigint") {
|
|
546
|
+
const asNumber = Number(value);
|
|
547
|
+
if (!Number.isSafeInteger(asNumber)) {
|
|
548
|
+
throw new FoirRequestError(
|
|
549
|
+
`[foir] ${label} is the bigint ${String(value)}, which is outside the range JSON can carry exactly`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return asNumber;
|
|
553
|
+
}
|
|
554
|
+
if (value instanceof Date) {
|
|
555
|
+
if (Number.isNaN(value.getTime())) {
|
|
556
|
+
throw new FoirRequestError(`[foir] ${label} is an invalid Date`);
|
|
557
|
+
}
|
|
558
|
+
return value.toISOString();
|
|
559
|
+
}
|
|
560
|
+
if (Array.isArray(value)) {
|
|
561
|
+
return value.filter((item) => item !== void 0).map((item, index) => toVariableValue(item, `${label}[${index}]`));
|
|
562
|
+
}
|
|
563
|
+
if (kind === "object") {
|
|
564
|
+
const out = {};
|
|
565
|
+
for (const [key, child] of Object.entries(value)) {
|
|
566
|
+
if (child === void 0) continue;
|
|
567
|
+
out[key] = toVariableValue(child, `${label}.${key}`);
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
throw new FoirRequestError(
|
|
572
|
+
`[foir] ${label} is a ${kind}, which has no JSON form and cannot be sent`
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
function payloadRecordPaths(payload) {
|
|
576
|
+
if (payload.kind === "record") {
|
|
577
|
+
return payload.model ? [{ path: [], modelKey: payload.model }] : [];
|
|
578
|
+
}
|
|
579
|
+
const found = [];
|
|
580
|
+
const visit = (fields, prefix) => {
|
|
581
|
+
for (const field of fields ?? []) {
|
|
582
|
+
const at = [...prefix, field.alias ?? field.key];
|
|
583
|
+
if (field.model) found.push({ path: at, modelKey: field.model });
|
|
584
|
+
visit(field.fields, at);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
visit(payload.fields, []);
|
|
588
|
+
return found;
|
|
589
|
+
}
|
|
590
|
+
function printPayload(payload, ctx, root, opLabel, depth) {
|
|
591
|
+
switch (payload.kind) {
|
|
592
|
+
case "leaf":
|
|
593
|
+
return void 0;
|
|
594
|
+
case "record": {
|
|
595
|
+
if (!payload.model) {
|
|
596
|
+
throw new FoirRequestError(
|
|
597
|
+
`[foir] ${opLabel}: the result shape says it is a record but names no model \u2014 re-run \`foir types\``
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
return printModelTree(payload.model, ctx, depth);
|
|
601
|
+
}
|
|
602
|
+
case "union": {
|
|
603
|
+
if (!payload.union) {
|
|
604
|
+
throw new FoirRequestError(
|
|
605
|
+
`[foir] ${opLabel}: the result shape says it is a union but names none \u2014 re-run \`foir types\``
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
return printUnion(payload.union, ctx, root.omit, root.pick, "", depth).slice(2, -2);
|
|
609
|
+
}
|
|
610
|
+
case "object": {
|
|
611
|
+
if (!payload.fields?.length) {
|
|
612
|
+
throw new FoirRequestError(
|
|
613
|
+
`[foir] ${opLabel}: the result shape is an object with no fields \u2014 re-run \`foir types\``
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
return printFields(payload.fields, ctx, root.omit, root.pick, false, "", depth);
|
|
617
|
+
}
|
|
618
|
+
default:
|
|
619
|
+
throw new FoirRequestError(
|
|
620
|
+
`[foir] ${opLabel}: the result shape has the kind "${String(payload.kind)}", which this client does not know how to print \u2014 upgrade @foir/sdk`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
var DEFAULT_DEPTH = 1;
|
|
625
|
+
function planFoirModelCall(source, call) {
|
|
626
|
+
const model = source.models[call.model];
|
|
627
|
+
if (!model) {
|
|
628
|
+
throw new FoirRequestError(
|
|
629
|
+
`[foir] no model "${call.model}" in this credential's surface \u2014 re-run \`foir types\``
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
const opDef = model.ops?.[call.op];
|
|
633
|
+
if (!opDef) {
|
|
634
|
+
throw new FoirRequestError(
|
|
635
|
+
`[foir] ${call.model} has no op "${call.op}" for this credential \u2014 re-run \`foir types\``
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
return planCall(source, opDef, `${call.model}.${call.op}`, operationName(model.typeName, call.op), call);
|
|
639
|
+
}
|
|
640
|
+
function planFoirNamespaceCall(source, call) {
|
|
641
|
+
const namespace = source.namespaces?.[call.namespace];
|
|
642
|
+
if (!namespace) {
|
|
643
|
+
throw new FoirRequestError(
|
|
644
|
+
`[foir] no namespace "${call.namespace}" in this credential's surface \u2014 re-run \`foir types\``
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
const opDef = namespace.ops?.[call.op];
|
|
648
|
+
if (!opDef) {
|
|
649
|
+
throw new FoirRequestError(
|
|
650
|
+
`[foir] the namespace "${call.namespace}" has no op "${call.op}" for this credential \u2014 re-run \`foir types\``
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
return planCall(
|
|
654
|
+
source,
|
|
655
|
+
opDef,
|
|
656
|
+
`${call.namespace}.${call.op}`,
|
|
657
|
+
operationName(call.namespace, call.op),
|
|
658
|
+
call
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
function planCall(source, opDef, opLabel, name, call) {
|
|
662
|
+
const payload = opDef.payload;
|
|
663
|
+
if (!payload) {
|
|
664
|
+
throw new FoirRequestError(
|
|
665
|
+
`[foir] ${opLabel}: the manifest publishes no result shape for this op, so there is nothing to print \u2014 and synthesising one client-side is exactly the guess this client refuses to make. Re-run \`foir types\` against a server that publishes op payloads`
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
if (payload.error) {
|
|
669
|
+
throw new FoirRequestError(
|
|
670
|
+
`[foir] ${opLabel}: the server could not resolve a result shape for this op: ${payload.error}`
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
if (call.omit && call.pick) {
|
|
674
|
+
throw new FoirRequestError(
|
|
675
|
+
`[foir] ${opLabel}: "omit" and "pick" are mutually exclusive \u2014 pass one or the other`
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
if (call.omit?.includes(PROTECTED_ROOT_FIELD)) {
|
|
679
|
+
throw new FoirRequestError(
|
|
680
|
+
`[foir] omit "${PROTECTED_ROOT_FIELD}": "${PROTECTED_ROOT_FIELD}" is required for correctness and cannot be removed`
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
const depth = call.depth ?? DEFAULT_DEPTH;
|
|
684
|
+
if (!Number.isInteger(depth) || depth < DEFAULT_DEPTH) {
|
|
685
|
+
throw new FoirRequestError(
|
|
686
|
+
`[foir] ${opLabel}: depth must be a whole number of union levels, ${DEFAULT_DEPTH} or more \u2014 got ${String(call.depth)}. ${DEFAULT_DEPTH} is the complete-by-default document; each level above it expands one further union`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const omit = call.omit?.length ? buildPathTrie(call.omit) : void 0;
|
|
690
|
+
const pick = call.pick?.length ? buildPathTrie(call.pick) : void 0;
|
|
691
|
+
const include = call.include && Object.keys(call.include).length ? call.include : void 0;
|
|
692
|
+
const narrowed = Boolean(omit || pick || include);
|
|
693
|
+
const recordPaths = payloadRecordPaths(payload);
|
|
694
|
+
const records = new Set(recordPaths.map((entry) => entry.modelKey));
|
|
695
|
+
if (records.size > 1 && narrowed) {
|
|
696
|
+
throw new FoirRequestError(
|
|
697
|
+
`[foir] ${opLabel}: the result carries records of more than one model (${[...records].sort().join(", ")}), so "include"/"pick"/"omit" do not say which one they mean \u2014 call it without them and narrow with foir.raw()`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
const recordModel = records.size === 1 ? [...records][0] : void 0;
|
|
701
|
+
if (include && !recordModel) {
|
|
702
|
+
throw new FoirRequestError(
|
|
703
|
+
`[foir] ${opLabel}: "include" names relations of a record, and this op returns none`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const ctx = {
|
|
707
|
+
source,
|
|
708
|
+
unions: source.selectionUnions ?? {},
|
|
709
|
+
record: recordModel ? { modelKey: recordModel, omit, pick, include } : void 0,
|
|
710
|
+
budget: newBudget()
|
|
711
|
+
};
|
|
712
|
+
const root = recordModel ? {} : { omit, pick };
|
|
713
|
+
const selection = printPayload(payload, ctx, root, opLabel, depth);
|
|
714
|
+
if (selection === "") {
|
|
715
|
+
throw new FoirRequestError(
|
|
716
|
+
`[foir] ${opLabel}: the selection is empty after applying pick/omit \u2014 a GraphQL selection set cannot be empty`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
charge(ctx);
|
|
720
|
+
if (depth > DEFAULT_DEPTH && (ctx.budget.stopped || ctx.budget.fields > MAX_QUERY_COMPLEXITY)) {
|
|
721
|
+
const reason = ctx.budget.stopped ? `expands to more than ${MAX_EMITTED_FIELDS} fields to print, which is past what this client will build in one call` : `makes this document select ${ctx.budget.fields} fields, over the server's limit of ${MAX_QUERY_COMPLEXITY}`;
|
|
722
|
+
throw new FoirRequestError(
|
|
723
|
+
`[foir] ${opLabel}: depth ${depth} ${reason}. Each level re-expands every stopping point inside the expansion, so spend the level on less: narrow with "pick"/"omit" \u2014 including per-member, pick: { <field>: { on: { <MemberType>: [...] } } } \u2014 or ask for depth ${depth - 1}`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
const args = buildArguments(call.args, opDef, opLabel);
|
|
727
|
+
const keyword = opDef.mutation ? "mutation" : "query";
|
|
728
|
+
const body = selection === void 0 ? "" : ` { ${selection} }`;
|
|
729
|
+
return {
|
|
730
|
+
document: `${keyword} ${name}${args.definitions} { ${opDef.field}${args.call}${body} }`,
|
|
731
|
+
variables: args.variables,
|
|
732
|
+
mutation: opDef.mutation === true,
|
|
733
|
+
rootField: opDef.field,
|
|
734
|
+
fields: ctx.budget.fields,
|
|
735
|
+
records: recordPaths
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function operationName(scope, op) {
|
|
739
|
+
const parts = `${scope} ${op}`.split(/[^0-9A-Za-z]+/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1));
|
|
740
|
+
const name = parts.join("");
|
|
741
|
+
return /^[_A-Za-z]/.test(name) ? name : `Foir${name}`;
|
|
742
|
+
}
|
|
743
|
+
function isFoirMutationDocument(documentText) {
|
|
744
|
+
return /\bmutation\b/.test(documentText);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// src/data/subscription-document.ts
|
|
748
|
+
function printSubscriptionDocument(input) {
|
|
749
|
+
const args = input.args ?? [];
|
|
750
|
+
const declaration = args.length ? `(${args.map((a) => `$${a.name}: ${a.type}`).join(", ")})` : "";
|
|
751
|
+
const call = args.length ? `(${args.map((a) => `${a.name}: $${a.name}`).join(", ")})` : "";
|
|
752
|
+
const body = printFoirSelectionBody(input.selection.fields, input.unions ?? {});
|
|
753
|
+
if (!body) {
|
|
754
|
+
throw new Error(
|
|
755
|
+
`[foir] subscription \`${input.field}\` has an empty selection, so no valid document can be printed. The manifest's selectionError explains why; re-run \`foir types\` or check GET /v1/manifest.`
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return `subscription ${input.operationName}${declaration} { ${input.field}${call} { ${body} } }`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/data/ws-transport.ts
|
|
762
|
+
var GQL = {
|
|
763
|
+
connectionInit: "connection_init",
|
|
764
|
+
connectionAck: "connection_ack",
|
|
765
|
+
ping: "ping",
|
|
766
|
+
pong: "pong",
|
|
767
|
+
subscribe: "subscribe",
|
|
768
|
+
next: "next",
|
|
769
|
+
error: "error",
|
|
770
|
+
complete: "complete"
|
|
771
|
+
};
|
|
772
|
+
var SUBPROTOCOL = "graphql-transport-ws";
|
|
773
|
+
var OPEN_READY_STATE = 1;
|
|
774
|
+
var DEFAULT_RECONNECT = {
|
|
775
|
+
maxAttempts: Infinity,
|
|
776
|
+
initialDelayMs: 500,
|
|
777
|
+
maxDelayMs: 15e3,
|
|
778
|
+
jitter: 0.25
|
|
779
|
+
};
|
|
780
|
+
function foirSocketEndpoint(apiUrl) {
|
|
781
|
+
const trimmed = apiUrl.replace(/\/+$/, "").replace(/\/graphql$/, "");
|
|
782
|
+
return `${trimmed.replace(/^http/, "ws")}/graphql/ws`;
|
|
783
|
+
}
|
|
784
|
+
var FoirSocketTransport = class {
|
|
785
|
+
config;
|
|
786
|
+
policy;
|
|
787
|
+
registrations = /* @__PURE__ */ new Map();
|
|
788
|
+
socket = null;
|
|
789
|
+
acknowledged = false;
|
|
790
|
+
connecting = false;
|
|
791
|
+
attempt = 0;
|
|
792
|
+
nextId = 0;
|
|
793
|
+
disposed = false;
|
|
794
|
+
keepAlive = null;
|
|
795
|
+
retryTimer = null;
|
|
796
|
+
constructor(config) {
|
|
797
|
+
this.config = config;
|
|
798
|
+
this.policy = { ...DEFAULT_RECONNECT, ...config.reconnect ?? {} };
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* Start a subscription.
|
|
802
|
+
*
|
|
803
|
+
* Returns synchronously so a caller can hold the handle immediately, while
|
|
804
|
+
* the connect and the handshake proceed in the background. A subscription
|
|
805
|
+
* registered before the socket is acknowledged is sent the moment it is —
|
|
806
|
+
* which is the same path a reconnect takes, so the ordinary case and the
|
|
807
|
+
* recovery case exercise one piece of code.
|
|
808
|
+
*/
|
|
809
|
+
subscribe(request, handlers) {
|
|
810
|
+
if (this.disposed) {
|
|
811
|
+
throw new FoirRequestError("[foir] this client has been disposed; create a new one");
|
|
812
|
+
}
|
|
813
|
+
this.nextId += 1;
|
|
814
|
+
const id = String(this.nextId);
|
|
815
|
+
this.registrations.set(id, {
|
|
816
|
+
id,
|
|
817
|
+
request,
|
|
818
|
+
handlers,
|
|
819
|
+
sent: false,
|
|
820
|
+
established: false
|
|
821
|
+
});
|
|
822
|
+
this.ensureConnected();
|
|
823
|
+
this.flush();
|
|
824
|
+
return {
|
|
825
|
+
unsubscribe: () => this.cancel(id)
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
/** Close the socket and drop every subscription. */
|
|
829
|
+
dispose() {
|
|
830
|
+
this.disposed = true;
|
|
831
|
+
this.registrations.clear();
|
|
832
|
+
this.teardownSocket(true);
|
|
833
|
+
}
|
|
834
|
+
/** Live subscription count. Exposed for tests and diagnostics. */
|
|
835
|
+
get size() {
|
|
836
|
+
return this.registrations.size;
|
|
837
|
+
}
|
|
838
|
+
// -------------------------------------------------------------------------
|
|
839
|
+
// Connection
|
|
840
|
+
// -------------------------------------------------------------------------
|
|
841
|
+
ensureConnected() {
|
|
842
|
+
if (this.disposed || this.socket || this.connecting) return;
|
|
843
|
+
this.connecting = true;
|
|
844
|
+
const factory = this.config.webSocketImpl ?? defaultWebSocketFactory();
|
|
845
|
+
const url = this.config.wsUrl ?? foirSocketEndpoint(this.config.apiUrl);
|
|
846
|
+
let socket;
|
|
847
|
+
try {
|
|
848
|
+
socket = factory(url, SUBPROTOCOL);
|
|
849
|
+
} catch (cause) {
|
|
850
|
+
this.connecting = false;
|
|
851
|
+
this.scheduleReconnect(
|
|
852
|
+
new FoirRequestError(`[foir] could not open a WebSocket to ${url}: ${String(cause)}`)
|
|
853
|
+
);
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
this.socket = socket;
|
|
857
|
+
socket.onopen = () => {
|
|
858
|
+
void this.sendConnectionInit(socket);
|
|
859
|
+
};
|
|
860
|
+
socket.onmessage = (event) => this.receive(event.data);
|
|
861
|
+
socket.onerror = () => {
|
|
862
|
+
};
|
|
863
|
+
socket.onclose = () => this.handleClose();
|
|
864
|
+
}
|
|
865
|
+
async sendConnectionInit(socket) {
|
|
866
|
+
this.connecting = false;
|
|
867
|
+
try {
|
|
868
|
+
const headers = buildFoirHeaders({
|
|
869
|
+
apiKey: this.config.apiKey,
|
|
870
|
+
draftChannel: this.config.channel === "draft",
|
|
871
|
+
bearerToken: await resolveBearerToken(this.config),
|
|
872
|
+
extra: this.config.headers
|
|
873
|
+
});
|
|
874
|
+
const { "Content-Type": _contentType, ...params } = headers;
|
|
875
|
+
const extra = await this.config.connectionParams?.() ?? {};
|
|
876
|
+
this.send(socket, {
|
|
877
|
+
type: GQL.connectionInit,
|
|
878
|
+
payload: { ...params, ...extra }
|
|
879
|
+
});
|
|
880
|
+
} catch (cause) {
|
|
881
|
+
this.failAll(
|
|
882
|
+
new FoirRequestError(`[foir] could not resolve the subscription credential: ${String(cause)}`)
|
|
883
|
+
);
|
|
884
|
+
this.teardownSocket(true);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
handleClose() {
|
|
888
|
+
const wasAcknowledged = this.acknowledged;
|
|
889
|
+
this.teardownSocket(false);
|
|
890
|
+
if (this.disposed || this.registrations.size === 0) return;
|
|
891
|
+
for (const registration of this.registrations.values()) {
|
|
892
|
+
registration.sent = false;
|
|
893
|
+
}
|
|
894
|
+
this.scheduleReconnect(
|
|
895
|
+
new FoirRequestError(
|
|
896
|
+
wasAcknowledged ? "[foir] the subscription socket closed and could not be re-established" : "[foir] the subscription socket closed before the credential was accepted"
|
|
897
|
+
)
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
scheduleReconnect(error) {
|
|
901
|
+
if (this.disposed || this.registrations.size === 0) return;
|
|
902
|
+
if (this.attempt >= this.policy.maxAttempts) {
|
|
903
|
+
this.failAll(error);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const exponent = Math.min(this.attempt, 20);
|
|
907
|
+
const base = Math.min(
|
|
908
|
+
this.policy.initialDelayMs * 2 ** exponent,
|
|
909
|
+
this.policy.maxDelayMs
|
|
910
|
+
);
|
|
911
|
+
const delay = base + base * this.policy.jitter * Math.random();
|
|
912
|
+
this.attempt += 1;
|
|
913
|
+
this.retryTimer = setTimeout(() => {
|
|
914
|
+
this.retryTimer = null;
|
|
915
|
+
this.ensureConnected();
|
|
916
|
+
}, delay);
|
|
917
|
+
const timer = this.retryTimer;
|
|
918
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
919
|
+
}
|
|
920
|
+
teardownSocket(closeIt) {
|
|
921
|
+
if (this.keepAlive !== null) {
|
|
922
|
+
clearInterval(this.keepAlive);
|
|
923
|
+
this.keepAlive = null;
|
|
924
|
+
}
|
|
925
|
+
if (this.retryTimer !== null) {
|
|
926
|
+
clearTimeout(this.retryTimer);
|
|
927
|
+
this.retryTimer = null;
|
|
928
|
+
}
|
|
929
|
+
const socket = this.socket;
|
|
930
|
+
this.socket = null;
|
|
931
|
+
this.acknowledged = false;
|
|
932
|
+
this.connecting = false;
|
|
933
|
+
if (!socket) return;
|
|
934
|
+
socket.onopen = null;
|
|
935
|
+
socket.onmessage = null;
|
|
936
|
+
socket.onerror = null;
|
|
937
|
+
socket.onclose = null;
|
|
938
|
+
if (closeIt) {
|
|
939
|
+
try {
|
|
940
|
+
socket.close(1e3, "client disposed");
|
|
941
|
+
} catch {
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
// -------------------------------------------------------------------------
|
|
946
|
+
// Frames
|
|
947
|
+
// -------------------------------------------------------------------------
|
|
948
|
+
receive(data) {
|
|
949
|
+
if (typeof data !== "string") return;
|
|
950
|
+
let message;
|
|
951
|
+
try {
|
|
952
|
+
message = JSON.parse(data);
|
|
953
|
+
} catch {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
switch (message.type) {
|
|
957
|
+
case GQL.connectionAck:
|
|
958
|
+
this.acknowledged = true;
|
|
959
|
+
this.attempt = 0;
|
|
960
|
+
this.startKeepAlive();
|
|
961
|
+
this.flush();
|
|
962
|
+
return;
|
|
963
|
+
case GQL.ping:
|
|
964
|
+
if (this.socket) this.send(this.socket, { type: GQL.pong });
|
|
965
|
+
return;
|
|
966
|
+
case GQL.pong:
|
|
967
|
+
return;
|
|
968
|
+
case GQL.next: {
|
|
969
|
+
const registration = message.id ? this.registrations.get(message.id) : void 0;
|
|
970
|
+
if (!registration) return;
|
|
971
|
+
const payload = message.payload;
|
|
972
|
+
const root = payload?.data;
|
|
973
|
+
if (!root) return;
|
|
974
|
+
const keys = Object.keys(root);
|
|
975
|
+
if (keys.length !== 1) return;
|
|
976
|
+
registration.handlers.onData(root[keys[0]]);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
case GQL.error: {
|
|
980
|
+
const error = new FoirRequestError(`[foir] subscription refused: ${describe(message.payload)}`);
|
|
981
|
+
if (!message.id) {
|
|
982
|
+
this.failAll(error);
|
|
983
|
+
this.teardownSocket(true);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
const registration = this.registrations.get(message.id);
|
|
987
|
+
this.registrations.delete(message.id);
|
|
988
|
+
registration?.handlers.onError?.(error);
|
|
989
|
+
this.closeIfIdle();
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
case GQL.complete: {
|
|
993
|
+
if (!message.id) return;
|
|
994
|
+
const registration = this.registrations.get(message.id);
|
|
995
|
+
this.registrations.delete(message.id);
|
|
996
|
+
registration?.handlers.onComplete?.();
|
|
997
|
+
this.closeIfIdle();
|
|
998
|
+
return;
|
|
999
|
+
}
|
|
1000
|
+
default:
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Send every registration the current socket has not yet been told about.
|
|
1006
|
+
*
|
|
1007
|
+
* This IS the resubscribe path. A reconnect clears `sent` on every
|
|
1008
|
+
* registration and lands here, so a subscription re-established after a drop
|
|
1009
|
+
* takes the same code as one opened for the first time — there is no second
|
|
1010
|
+
* implementation that can be correct in one case and wrong in the other.
|
|
1011
|
+
*/
|
|
1012
|
+
flush() {
|
|
1013
|
+
const socket = this.socket;
|
|
1014
|
+
if (!socket || !this.acknowledged || socket.readyState !== OPEN_READY_STATE) return;
|
|
1015
|
+
for (const registration of this.registrations.values()) {
|
|
1016
|
+
if (registration.sent) continue;
|
|
1017
|
+
this.send(socket, {
|
|
1018
|
+
id: registration.id,
|
|
1019
|
+
type: GQL.subscribe,
|
|
1020
|
+
payload: registration.request
|
|
1021
|
+
});
|
|
1022
|
+
registration.sent = true;
|
|
1023
|
+
if (registration.established) {
|
|
1024
|
+
registration.handlers.onReconnect?.();
|
|
1025
|
+
} else {
|
|
1026
|
+
registration.established = true;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
cancel(id) {
|
|
1031
|
+
const registration = this.registrations.get(id);
|
|
1032
|
+
if (!registration) return;
|
|
1033
|
+
this.registrations.delete(id);
|
|
1034
|
+
if (this.socket && registration.sent && this.socket.readyState === OPEN_READY_STATE) {
|
|
1035
|
+
this.send(this.socket, { id, type: GQL.complete });
|
|
1036
|
+
}
|
|
1037
|
+
this.closeIfIdle();
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Close the socket once nothing is using it.
|
|
1041
|
+
*
|
|
1042
|
+
* A socket held open with no subscriptions is a connection the server has to
|
|
1043
|
+
* keep, a keepalive timer that keeps a serverless invocation alive, and
|
|
1044
|
+
* nothing in return.
|
|
1045
|
+
*/
|
|
1046
|
+
closeIfIdle() {
|
|
1047
|
+
if (this.registrations.size === 0) this.teardownSocket(true);
|
|
1048
|
+
}
|
|
1049
|
+
failAll(error) {
|
|
1050
|
+
const registrations = [...this.registrations.values()];
|
|
1051
|
+
this.registrations.clear();
|
|
1052
|
+
for (const registration of registrations) {
|
|
1053
|
+
registration.handlers.onError?.(error);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Client-side keepalive.
|
|
1058
|
+
*
|
|
1059
|
+
* The server pings on its own timer and this client answers, which is enough
|
|
1060
|
+
* for the server to know the client is alive. This is the other direction:
|
|
1061
|
+
* an idle subscription can sit for hours with no events, and a proxy that
|
|
1062
|
+
* times out idle sockets will drop it. A periodic `ping` keeps it warm, and
|
|
1063
|
+
* the pong that comes back proves the path is still there.
|
|
1064
|
+
*/
|
|
1065
|
+
startKeepAlive() {
|
|
1066
|
+
if (this.keepAlive !== null) clearInterval(this.keepAlive);
|
|
1067
|
+
this.keepAlive = setInterval(() => {
|
|
1068
|
+
const socket = this.socket;
|
|
1069
|
+
if (!socket || socket.readyState !== OPEN_READY_STATE) return;
|
|
1070
|
+
this.send(socket, { type: GQL.ping });
|
|
1071
|
+
}, 2e4);
|
|
1072
|
+
const timer = this.keepAlive;
|
|
1073
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
1074
|
+
}
|
|
1075
|
+
send(socket, message) {
|
|
1076
|
+
try {
|
|
1077
|
+
socket.send(JSON.stringify(message));
|
|
1078
|
+
} catch {
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
1082
|
+
function defaultWebSocketFactory() {
|
|
1083
|
+
const Impl = globalThis.WebSocket;
|
|
1084
|
+
if (typeof Impl !== "function") {
|
|
1085
|
+
throw new FoirRequestError(
|
|
1086
|
+
"[foir] no WebSocket implementation is available in this runtime. Pass one as `webSocketImpl` \u2014 e.g. `webSocketImpl: (url, protocols) => new WebSocket(url, protocols)` from the `ws` package on Node < 22."
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
return (url, protocols) => new Impl(url, protocols);
|
|
1090
|
+
}
|
|
1091
|
+
function describe(payload) {
|
|
1092
|
+
if (payload == null) return "no reason given";
|
|
1093
|
+
if (typeof payload === "string") return payload;
|
|
1094
|
+
if (Array.isArray(payload)) return payload.map(describe).join("; ");
|
|
1095
|
+
if (typeof payload === "object") {
|
|
1096
|
+
const message = payload.message;
|
|
1097
|
+
if (typeof message === "string") return message;
|
|
1098
|
+
}
|
|
1099
|
+
return JSON.stringify(payload);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// src/data/subscriptions.ts
|
|
1103
|
+
var MODEL_SUBSCRIPTION_METHODS = /* @__PURE__ */ new Set(["onChange"]);
|
|
1104
|
+
var NAMESPACE_SUBSCRIPTION_METHODS = {
|
|
1105
|
+
notifications: /* @__PURE__ */ new Set(["onNotification"]),
|
|
1106
|
+
operations: /* @__PURE__ */ new Set(["onJobProgress", "onExecution"])
|
|
1107
|
+
};
|
|
1108
|
+
function isModelSubscriptionMethod(name) {
|
|
1109
|
+
return MODEL_SUBSCRIPTION_METHODS.has(name);
|
|
1110
|
+
}
|
|
1111
|
+
function isNamespaceSubscriptionMethod(namespace, name) {
|
|
1112
|
+
return NAMESPACE_SUBSCRIPTION_METHODS[namespace]?.has(name) ?? false;
|
|
1113
|
+
}
|
|
1114
|
+
var FoirSubscriptionRouter = class {
|
|
1115
|
+
transport;
|
|
1116
|
+
loadManifest;
|
|
1117
|
+
/**
|
|
1118
|
+
* `loadManifest` is the client's own memoised descriptor loader, passed in
|
|
1119
|
+
* rather than rebuilt. One client is one credential and one descriptor; a
|
|
1120
|
+
* fetcher of our own would race the query path's for the same bytes and could
|
|
1121
|
+
* end up holding a different schema version than the documents the query path
|
|
1122
|
+
* is printing from.
|
|
1123
|
+
*/
|
|
1124
|
+
constructor(config, loadManifest) {
|
|
1125
|
+
this.transport = new FoirSocketTransport(config);
|
|
1126
|
+
this.loadManifest = loadManifest;
|
|
1127
|
+
}
|
|
1128
|
+
dispose() {
|
|
1129
|
+
this.transport.dispose();
|
|
1130
|
+
}
|
|
1131
|
+
/** `foir.<model>.onChange(...)`. */
|
|
1132
|
+
model(modelKey, method, input) {
|
|
1133
|
+
if (method !== "onChange") {
|
|
1134
|
+
throw new FoirRequestError(`[foir] ${modelKey}.${method} is not a subscription`);
|
|
1135
|
+
}
|
|
1136
|
+
return this.open(
|
|
1137
|
+
{
|
|
1138
|
+
field: "recordChanged",
|
|
1139
|
+
operationName: "FoirRecordChanged",
|
|
1140
|
+
args: [{ name: "modelKey", type: "String!" }],
|
|
1141
|
+
variables: { modelKey }
|
|
1142
|
+
},
|
|
1143
|
+
input
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
/** `foir.ns.<namespace>.on…(...)`. */
|
|
1147
|
+
namespace(namespace, method, input) {
|
|
1148
|
+
const handlers = input;
|
|
1149
|
+
if (namespace === "notifications" && method === "onNotification") {
|
|
1150
|
+
return this.open(
|
|
1151
|
+
{
|
|
1152
|
+
field: "notification",
|
|
1153
|
+
operationName: "FoirNotification",
|
|
1154
|
+
args: [],
|
|
1155
|
+
variables: {}
|
|
1156
|
+
},
|
|
1157
|
+
handlers
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
if (namespace === "operations" && method === "onJobProgress") {
|
|
1161
|
+
return this.open(
|
|
1162
|
+
{ field: "jobProgress", operationName: "FoirJobProgress", args: [], variables: {} },
|
|
1163
|
+
handlers
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
if (namespace === "operations" && method === "onExecution") {
|
|
1167
|
+
const id = input.id;
|
|
1168
|
+
if (typeof id !== "string" || id === "") {
|
|
1169
|
+
throw new FoirRequestError("[foir] onExecution requires an execution `id`");
|
|
1170
|
+
}
|
|
1171
|
+
const operation = typeof input.operation === "string" ? input.operation : void 0;
|
|
1172
|
+
return this.open(
|
|
1173
|
+
{
|
|
1174
|
+
field: "operationExecution",
|
|
1175
|
+
matchOperation: operation,
|
|
1176
|
+
operationName: "FoirOperationExecution",
|
|
1177
|
+
args: [{ name: "id", type: "String!" }],
|
|
1178
|
+
variables: { id }
|
|
1179
|
+
},
|
|
1180
|
+
handlers
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
throw new FoirRequestError(`[foir] ns.${namespace}.${method} is not a subscription`);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Resolve the route's document and subscribe.
|
|
1187
|
+
*
|
|
1188
|
+
* The handle is returned synchronously and the manifest fetch runs behind it,
|
|
1189
|
+
* so a caller can hold and cancel a subscription before the descriptor has
|
|
1190
|
+
* arrived. Cancelling in that window is honoured: `cancelled` is checked
|
|
1191
|
+
* after the await, and the inner handle is never created.
|
|
1192
|
+
*/
|
|
1193
|
+
open(route, handlers) {
|
|
1194
|
+
const state = {
|
|
1195
|
+
cancelled: false,
|
|
1196
|
+
inner: null
|
|
1197
|
+
};
|
|
1198
|
+
void this.loadManifest().then((manifest) => {
|
|
1199
|
+
if (state.cancelled) return;
|
|
1200
|
+
const entry = route.matchOperation ? matchTypedExecutionField(manifest, route.matchOperation) : findSubscription(manifest, route.field);
|
|
1201
|
+
const query = printSubscriptionDocument({
|
|
1202
|
+
field: entry.field,
|
|
1203
|
+
operationName: route.operationName,
|
|
1204
|
+
args: route.args,
|
|
1205
|
+
selection: requireSelection(entry),
|
|
1206
|
+
unions: manifest.selectionUnions
|
|
1207
|
+
});
|
|
1208
|
+
if (state.cancelled) return;
|
|
1209
|
+
state.inner = this.transport.subscribe({ query, variables: route.variables }, handlers);
|
|
1210
|
+
}).catch((cause) => {
|
|
1211
|
+
if (state.cancelled) return;
|
|
1212
|
+
handlers.onError?.(
|
|
1213
|
+
cause instanceof FoirRequestError ? cause : new FoirRequestError(`[foir] could not start the subscription: ${String(cause)}`)
|
|
1214
|
+
);
|
|
1215
|
+
});
|
|
1216
|
+
return {
|
|
1217
|
+
unsubscribe: () => {
|
|
1218
|
+
state.cancelled = true;
|
|
1219
|
+
state.inner?.unsubscribe();
|
|
1220
|
+
state.inner = null;
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
function findSubscription(manifest, field) {
|
|
1226
|
+
const found = manifest.subscriptions?.find((s) => s.field === field);
|
|
1227
|
+
if (found) return found;
|
|
1228
|
+
const available = (manifest.subscriptions ?? []).map((s) => s.field);
|
|
1229
|
+
if (available.length === 0) {
|
|
1230
|
+
throw new FoirRequestError(
|
|
1231
|
+
`[foir] this credential can open no subscriptions, so \`${field}\` is not available. Subscriptions need the matching read scope on the api key.`
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
throw new FoirRequestError(
|
|
1235
|
+
`[foir] \`${field}\` is not available to this credential. Available: ${available.join(", ")}.`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
function requireSelection(entry) {
|
|
1239
|
+
if (entry.selection) return entry.selection;
|
|
1240
|
+
throw new FoirRequestError(
|
|
1241
|
+
`[foir] the server published no selection for \`${entry.field}\`` + (entry.selectionError ? `: ${entry.selectionError}` : "") + ". Without one there is no document to send."
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
function matchTypedExecutionField(manifest, operationKey) {
|
|
1245
|
+
const wanted = normalise(`operation ${operationKey} execution`);
|
|
1246
|
+
const typed = (manifest.subscriptions ?? []).filter(
|
|
1247
|
+
(s) => s.field !== "operationExecution" && normalise(s.field) === wanted
|
|
1248
|
+
);
|
|
1249
|
+
if (typed.length === 1) return typed[0];
|
|
1250
|
+
const available = (manifest.subscriptions ?? []).filter((s) => s.field.startsWith("operation") && s.field.endsWith("Execution")).map((s) => s.field);
|
|
1251
|
+
throw new FoirRequestError(
|
|
1252
|
+
`[foir] no typed execution subscription for operation \`${operationKey}\`. ` + (available.length ? `Available: ${available.join(", ")}.` : "This credential reaches none \u2014 operations:read is required.") + " Omit `operation` to use the generic `operationExecution` stream."
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
function normalise(value) {
|
|
1256
|
+
return value.replace(/[^0-9a-zA-Z]+/g, "").toLowerCase();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// src/data/subjects.ts
|
|
1260
|
+
var keyOf = (modelKey, recordId) => `${modelKey}\0${recordId}`;
|
|
1261
|
+
function createFoirSubjectRegistry(options = {}) {
|
|
1262
|
+
const held = /* @__PURE__ */ new Map();
|
|
1263
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1264
|
+
const pinned = { subject: null };
|
|
1265
|
+
const latest = { key: null };
|
|
1266
|
+
const notify = () => {
|
|
1267
|
+
const failures = [];
|
|
1268
|
+
for (const listener of [...listeners]) {
|
|
1269
|
+
try {
|
|
1270
|
+
listener();
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
failures.push(err);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
if (failures.length === 0) return;
|
|
1276
|
+
throw new FoirRequestError(
|
|
1277
|
+
`[foir] ${failures.length} subject registry listener(s) threw. Every listener still ran; this is the first failure`,
|
|
1278
|
+
{ cause: failures[0] }
|
|
1279
|
+
);
|
|
1280
|
+
};
|
|
1281
|
+
const labelFor = (entry, subject) => {
|
|
1282
|
+
if (entry.label) return entry.label;
|
|
1283
|
+
if (!entry.record) return void 0;
|
|
1284
|
+
if (options.label) {
|
|
1285
|
+
const labelled = options.label(entry.record, subject);
|
|
1286
|
+
return labelled == null || labelled === "" ? void 0 : labelled;
|
|
1287
|
+
}
|
|
1288
|
+
const natural = entry.record["_naturalKey"];
|
|
1289
|
+
return typeof natural === "string" && natural !== "" ? natural : void 0;
|
|
1290
|
+
};
|
|
1291
|
+
const same = (a, b) => a !== void 0 && a.origin === b.origin && a.subject.label === b.subject.label && a.subject.modelKey === b.subject.modelKey && a.subject.recordId === b.subject.recordId;
|
|
1292
|
+
return {
|
|
1293
|
+
add(entry) {
|
|
1294
|
+
if (!entry.modelKey || !entry.recordId) {
|
|
1295
|
+
throw new FoirRequestError(
|
|
1296
|
+
"[foir] a subject needs both a modelKey and a recordId \u2014 an annotation anchored to half of one cannot be resolved back to a record"
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
const subject = { modelKey: entry.modelKey, recordId: entry.recordId };
|
|
1300
|
+
const label = labelFor(entry, subject);
|
|
1301
|
+
if (label !== void 0) subject.label = label;
|
|
1302
|
+
const next = { subject, origin: entry.origin ?? "record" };
|
|
1303
|
+
const key = keyOf(entry.modelKey, entry.recordId);
|
|
1304
|
+
if (latest.key === key && same(held.get(key), next)) return;
|
|
1305
|
+
held.delete(key);
|
|
1306
|
+
held.set(key, next);
|
|
1307
|
+
latest.key = key;
|
|
1308
|
+
notify();
|
|
1309
|
+
},
|
|
1310
|
+
fetched() {
|
|
1311
|
+
return [...held.values()].map((entry) => entry.subject);
|
|
1312
|
+
},
|
|
1313
|
+
subject() {
|
|
1314
|
+
if (pinned.subject) return pinned.subject;
|
|
1315
|
+
let answer;
|
|
1316
|
+
for (const entry of held.values()) {
|
|
1317
|
+
if (entry.origin === "record") answer = entry.subject;
|
|
1318
|
+
}
|
|
1319
|
+
return answer;
|
|
1320
|
+
},
|
|
1321
|
+
setSubject(subject) {
|
|
1322
|
+
pinned.subject = subject;
|
|
1323
|
+
notify();
|
|
1324
|
+
},
|
|
1325
|
+
clear() {
|
|
1326
|
+
held.clear();
|
|
1327
|
+
pinned.subject = null;
|
|
1328
|
+
latest.key = null;
|
|
1329
|
+
notify();
|
|
1330
|
+
},
|
|
1331
|
+
subscribe(listener) {
|
|
1332
|
+
listeners.add(listener);
|
|
1333
|
+
return () => {
|
|
1334
|
+
listeners.delete(listener);
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
function recordFoirSubjects(registry, plan, result) {
|
|
1340
|
+
if (!registry) return;
|
|
1341
|
+
const positions = plan.records;
|
|
1342
|
+
if (!positions?.length) return;
|
|
1343
|
+
try {
|
|
1344
|
+
for (const position of positions) {
|
|
1345
|
+
const origin = position.path.length === 0 ? "record" : "collection";
|
|
1346
|
+
for (const record of recordsAt(result, position.path)) {
|
|
1347
|
+
const recordId = record[PROTECTED_ROOT_FIELD];
|
|
1348
|
+
if (typeof recordId !== "string" || recordId === "") continue;
|
|
1349
|
+
registry.add({ modelKey: position.modelKey, recordId, origin, record });
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
throw new FoirRequestError(
|
|
1354
|
+
"[foir] the subject registry threw while recording what this call fetched. The request itself succeeded \u2014 the failure is in your `label` function or a `subscribe` listener. It is raised rather than swallowed because a subject that fails to record leaves the PREVIOUS one as the answer, and a widget then offers to annotate the wrong record",
|
|
1355
|
+
{ cause: err }
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
function recordsAt(value, path) {
|
|
1360
|
+
if (value === null || value === void 0 || typeof value !== "object") return [];
|
|
1361
|
+
if (Array.isArray(value)) {
|
|
1362
|
+
const out = [];
|
|
1363
|
+
for (const item of value) out.push(...recordsAt(item, path));
|
|
1364
|
+
return out;
|
|
1365
|
+
}
|
|
1366
|
+
const record = value;
|
|
1367
|
+
if (path.length === 0) return [record];
|
|
1368
|
+
return recordsAt(record[path[0]], path.slice(1));
|
|
1369
|
+
}
|
|
1370
|
+
|
|
181
1371
|
// src/data/client.ts
|
|
182
1372
|
var NON_MODEL_PROPS = /* @__PURE__ */ new Set([
|
|
183
1373
|
"then",
|
|
@@ -203,8 +1393,30 @@ function selectionPaths(value, verb, prefix = "") {
|
|
|
203
1393
|
out.push(path);
|
|
204
1394
|
continue;
|
|
205
1395
|
}
|
|
206
|
-
if (child && typeof child === "object"
|
|
207
|
-
|
|
1396
|
+
if (child && typeof child === "object") {
|
|
1397
|
+
const node = child;
|
|
1398
|
+
if (verb in node) {
|
|
1399
|
+
out.push(...selectionPaths(node[verb], verb, path));
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
if ("on" in node) {
|
|
1403
|
+
out.push(...variantPaths(node.on, path));
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
function variantPaths(value, prefix) {
|
|
1410
|
+
if (!value || typeof value !== "object") return [];
|
|
1411
|
+
const out = [];
|
|
1412
|
+
for (const [typeName, keys] of Object.entries(value)) {
|
|
1413
|
+
const memberPath = `${prefix}.${typeName}`;
|
|
1414
|
+
if (keys === true) {
|
|
1415
|
+
out.push(memberPath);
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
if (Array.isArray(keys)) {
|
|
1419
|
+
for (const key of keys) out.push(`${memberPath}.${String(key)}`);
|
|
208
1420
|
}
|
|
209
1421
|
}
|
|
210
1422
|
return out;
|
|
@@ -214,6 +1426,7 @@ function splitQuery(query) {
|
|
|
214
1426
|
let include;
|
|
215
1427
|
let omit;
|
|
216
1428
|
let pick;
|
|
1429
|
+
let depth;
|
|
217
1430
|
for (const [key, value] of Object.entries(query ?? {})) {
|
|
218
1431
|
if (value === void 0) continue;
|
|
219
1432
|
if (key === "include") {
|
|
@@ -222,52 +1435,192 @@ function splitQuery(query) {
|
|
|
222
1435
|
omit = selectionPaths(value, "omit");
|
|
223
1436
|
} else if (key === "pick") {
|
|
224
1437
|
pick = selectionPaths(value, "pick");
|
|
1438
|
+
} else if (key === "depth") {
|
|
1439
|
+
depth = value;
|
|
225
1440
|
} else {
|
|
226
1441
|
args[key] = value;
|
|
227
1442
|
}
|
|
228
1443
|
}
|
|
229
|
-
return { args, include, omit, pick };
|
|
1444
|
+
return { args, include, omit, pick, depth };
|
|
230
1445
|
}
|
|
231
|
-
|
|
232
|
-
|
|
1446
|
+
var GENERATED_EXPORTS = [
|
|
1447
|
+
"foirSchemaVersion",
|
|
1448
|
+
"foirSelections",
|
|
1449
|
+
"foirNamespaceSelections",
|
|
1450
|
+
"foirSelectionUnions"
|
|
1451
|
+
];
|
|
1452
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1453
|
+
function resolveGeneratedConfig(config) {
|
|
1454
|
+
const generated = config.generated;
|
|
1455
|
+
if (generated === void 0) return config;
|
|
1456
|
+
if (!isRecord(generated)) {
|
|
1457
|
+
throw new FoirRequestError(
|
|
1458
|
+
"[foir] createFoirClient: `generated` must be the module `foir types` emits \u2014 `import * as generated from './foir.generated'`. Omit it to read the selection trees from `GET /v1/manifest` instead."
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
const missing = GENERATED_EXPORTS.filter((name) => generated[name] == null);
|
|
1462
|
+
if (missing.length > 0) {
|
|
1463
|
+
throw new FoirRequestError(
|
|
1464
|
+
`[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
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
const version = generated["foirSchemaVersion"];
|
|
1468
|
+
if (typeof version !== "number" || !Number.isFinite(version)) {
|
|
1469
|
+
throw new FoirRequestError(
|
|
1470
|
+
"[foir] createFoirClient: `generated.foirSchemaVersion` is not a number. It is the manifest version the drift check compares against \u2014 re-run `foir types`."
|
|
1471
|
+
);
|
|
1472
|
+
}
|
|
1473
|
+
const selections = generated["foirSelections"];
|
|
1474
|
+
if (!isRecord(selections)) {
|
|
1475
|
+
throw new FoirRequestError(
|
|
1476
|
+
"[foir] createFoirClient: `generated.foirSelections` is not an object of model key -> selection model. Re-run `foir types`."
|
|
1477
|
+
);
|
|
1478
|
+
}
|
|
1479
|
+
for (const [key, model] of Object.entries(selections)) {
|
|
1480
|
+
if (isRecord(model) && typeof model["typeName"] === "string") continue;
|
|
1481
|
+
throw new FoirRequestError(
|
|
1482
|
+
`[foir] createFoirClient: \`generated.foirSelections["${key}"]\` is not a selection model \u2014 it has no \`typeName\`. The printer reads \`typeName\`, \`ops\` and \`selection\` off each entry, so a bare selection tree here prints nothing and every call would fall back to \`GET /v1/manifest\` as if the module were absent. Re-run \`foir types\` with a current @foir/cli.`
|
|
1483
|
+
);
|
|
1484
|
+
}
|
|
1485
|
+
const namespaceSelections = generated["foirNamespaceSelections"];
|
|
1486
|
+
if (!isRecord(namespaceSelections)) {
|
|
1487
|
+
throw new FoirRequestError(
|
|
1488
|
+
"[foir] createFoirClient: `generated.foirNamespaceSelections` is not an object of namespace -> ops. Re-run `foir types`."
|
|
1489
|
+
);
|
|
1490
|
+
}
|
|
1491
|
+
const selectionUnions = generated["foirSelectionUnions"];
|
|
1492
|
+
if (!isRecord(selectionUnions)) {
|
|
1493
|
+
throw new FoirRequestError(
|
|
1494
|
+
"[foir] createFoirClient: `generated.foirSelectionUnions` is not an object of union name -> expansion. Re-run `foir types`."
|
|
1495
|
+
);
|
|
1496
|
+
}
|
|
1497
|
+
for (const [name, union] of Object.entries(selectionUnions)) {
|
|
1498
|
+
if (isRecord(union) && Array.isArray(union["members"])) continue;
|
|
1499
|
+
throw new FoirRequestError(
|
|
1500
|
+
`[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
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
return {
|
|
1504
|
+
...config,
|
|
1505
|
+
schemaVersion: config.schemaVersion ?? version,
|
|
1506
|
+
selections: config.selections ?? selections,
|
|
1507
|
+
namespaceSelections: config.namespaceSelections ?? namespaceSelections,
|
|
1508
|
+
selectionUnions: config.selectionUnions ?? selectionUnions
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function announceFallback(config) {
|
|
1512
|
+
if (config.generated !== void 0 || config.selections !== void 0) return;
|
|
1513
|
+
if (config.announceManifestFallback === false) return;
|
|
1514
|
+
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`."
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
function createFoirClient(clientConfig) {
|
|
1519
|
+
if (!clientConfig?.apiUrl || !clientConfig?.apiKey) {
|
|
233
1520
|
throw new Error("[foir] createFoirClient needs both apiUrl and apiKey");
|
|
234
1521
|
}
|
|
1522
|
+
const config = resolveGeneratedConfig(clientConfig);
|
|
1523
|
+
announceFallback(config);
|
|
235
1524
|
let driftChecked = false;
|
|
1525
|
+
let manifestOnce;
|
|
1526
|
+
const manifest = () => {
|
|
1527
|
+
if (!manifestOnce) {
|
|
1528
|
+
manifestOnce = fetchFoirManifest(config).catch((err) => {
|
|
1529
|
+
manifestOnce = void 0;
|
|
1530
|
+
throw err;
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
return manifestOnce;
|
|
1534
|
+
};
|
|
1535
|
+
const selectionSource = async () => {
|
|
1536
|
+
if (config.selections) {
|
|
1537
|
+
return {
|
|
1538
|
+
models: config.selections,
|
|
1539
|
+
namespaces: config.namespaceSelections,
|
|
1540
|
+
selectionUnions: config.selectionUnions
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
return manifest();
|
|
1544
|
+
};
|
|
236
1545
|
const maybeCheckDrift = () => {
|
|
237
1546
|
const generated = config.schemaVersion;
|
|
238
1547
|
if (driftChecked) return;
|
|
239
1548
|
if (generated === void 0) return;
|
|
240
1549
|
if (config.checkSchemaDrift === false) return;
|
|
241
1550
|
driftChecked = true;
|
|
242
|
-
void
|
|
243
|
-
if (
|
|
1551
|
+
void manifest().then((served) => {
|
|
1552
|
+
if (served.schemaVersion === generated) return;
|
|
244
1553
|
const drift = {
|
|
245
1554
|
generated,
|
|
246
|
-
server:
|
|
247
|
-
channel:
|
|
1555
|
+
server: served.schemaVersion,
|
|
1556
|
+
channel: served.channel
|
|
248
1557
|
};
|
|
249
1558
|
if (config.onSchemaDrift) {
|
|
250
1559
|
config.onSchemaDrift(drift);
|
|
251
1560
|
return;
|
|
252
1561
|
}
|
|
253
1562
|
console.warn(
|
|
254
|
-
`[foir] schema drift: types were generated from schemaVersion ${drift.generated}, the server is serving ${drift.server} (${drift.channel}). Re-run \`foir types\` \u2014
|
|
1563
|
+
`[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.`
|
|
255
1564
|
);
|
|
256
1565
|
}).catch(() => {
|
|
257
1566
|
});
|
|
258
1567
|
};
|
|
259
|
-
const
|
|
260
|
-
const
|
|
1568
|
+
const runModel = async (model, op, query) => {
|
|
1569
|
+
const { args, include, omit, pick, depth } = splitQuery(query);
|
|
1570
|
+
const plan = planFoirModelCall(await selectionSource(), {
|
|
1571
|
+
model,
|
|
1572
|
+
op,
|
|
1573
|
+
args,
|
|
1574
|
+
include,
|
|
1575
|
+
omit,
|
|
1576
|
+
pick,
|
|
1577
|
+
depth
|
|
1578
|
+
});
|
|
1579
|
+
const result = await executeFoirDocument(config, plan);
|
|
1580
|
+
recordFoirSubjects(config.subjects, plan, result);
|
|
261
1581
|
maybeCheckDrift();
|
|
262
1582
|
return result;
|
|
263
1583
|
};
|
|
1584
|
+
const runNamespace = async (namespace, op, query) => {
|
|
1585
|
+
const { args, include, omit, pick, depth } = splitQuery(query);
|
|
1586
|
+
const plan = planFoirNamespaceCall(await selectionSource(), {
|
|
1587
|
+
namespace,
|
|
1588
|
+
op,
|
|
1589
|
+
args,
|
|
1590
|
+
include,
|
|
1591
|
+
omit,
|
|
1592
|
+
pick,
|
|
1593
|
+
depth
|
|
1594
|
+
});
|
|
1595
|
+
const result = await executeFoirDocument(config, plan);
|
|
1596
|
+
recordFoirSubjects(config.subjects, plan, result);
|
|
1597
|
+
maybeCheckDrift();
|
|
1598
|
+
return result;
|
|
1599
|
+
};
|
|
1600
|
+
const subscriptions = /* @__PURE__ */ (() => {
|
|
1601
|
+
const held = { router: null };
|
|
1602
|
+
return {
|
|
1603
|
+
get() {
|
|
1604
|
+
held.router ??= new FoirSubscriptionRouter(config, manifest);
|
|
1605
|
+
return held.router;
|
|
1606
|
+
},
|
|
1607
|
+
dispose() {
|
|
1608
|
+
held.router?.dispose();
|
|
1609
|
+
held.router = null;
|
|
1610
|
+
}
|
|
1611
|
+
};
|
|
1612
|
+
})();
|
|
264
1613
|
const opProxy = (address) => new Proxy({}, {
|
|
265
1614
|
get(_target, op) {
|
|
266
1615
|
if (typeof op !== "string" || NON_MODEL_PROPS.has(op)) return void 0;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
1616
|
+
if ("model" in address) {
|
|
1617
|
+
if (isModelSubscriptionMethod(op)) {
|
|
1618
|
+
return (input) => subscriptions.get().model(address.model, op, input ?? {});
|
|
1619
|
+
}
|
|
1620
|
+
} else if (isNamespaceSubscriptionMethod(address.namespace, op)) {
|
|
1621
|
+
return (input) => subscriptions.get().namespace(address.namespace, op, input ?? {});
|
|
1622
|
+
}
|
|
1623
|
+
return (query) => "model" in address ? runModel(address.model, op, query) : runNamespace(address.namespace, op, query);
|
|
271
1624
|
}
|
|
272
1625
|
});
|
|
273
1626
|
const nsProxy = new Proxy({}, {
|
|
@@ -276,11 +1629,24 @@ function createFoirClient(config) {
|
|
|
276
1629
|
return opProxy({ namespace });
|
|
277
1630
|
}
|
|
278
1631
|
});
|
|
279
|
-
const raw = (
|
|
1632
|
+
const raw = async (document, variables) => {
|
|
1633
|
+
if (typeof document !== "string" || !document.trim()) {
|
|
1634
|
+
throw new FoirRequestError("[foir] raw() takes a GraphQL document string");
|
|
1635
|
+
}
|
|
1636
|
+
const result = await executeFoirRawDocument(
|
|
1637
|
+
config,
|
|
1638
|
+
document,
|
|
1639
|
+
isFoirMutationDocument(document),
|
|
1640
|
+
variables
|
|
1641
|
+
);
|
|
1642
|
+
maybeCheckDrift();
|
|
1643
|
+
return result;
|
|
1644
|
+
};
|
|
280
1645
|
return new Proxy({}, {
|
|
281
1646
|
get(_target, prop) {
|
|
282
1647
|
if (prop === "ns") return nsProxy;
|
|
283
1648
|
if (prop === "raw") return raw;
|
|
1649
|
+
if (prop === "dispose") return () => subscriptions.dispose();
|
|
284
1650
|
if (typeof prop !== "string" || NON_MODEL_PROPS.has(prop)) return void 0;
|
|
285
1651
|
return opProxy({ model: prop });
|
|
286
1652
|
}
|
|
@@ -302,8 +1668,16 @@ function createFoirClientFromEnv(env, overrides) {
|
|
|
302
1668
|
}
|
|
303
1669
|
export {
|
|
304
1670
|
FoirRequestError,
|
|
1671
|
+
FoirSocketTransport,
|
|
305
1672
|
createFoirClient,
|
|
306
1673
|
createFoirClientFromEnv,
|
|
1674
|
+
createFoirSubjectRegistry,
|
|
307
1675
|
fetchFoirManifest,
|
|
308
|
-
foirDataEndpoint
|
|
1676
|
+
foirDataEndpoint,
|
|
1677
|
+
foirRoute,
|
|
1678
|
+
foirSocketEndpoint,
|
|
1679
|
+
planFoirModelCall,
|
|
1680
|
+
planFoirNamespaceCall,
|
|
1681
|
+
printSubscriptionDocument,
|
|
1682
|
+
recordFoirSubjects
|
|
309
1683
|
};
|