@foir/sdk 0.18.0 → 0.20.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 +365 -0
- package/README.md +8 -1
- package/dist/app/client.d.ts +129 -21
- package/dist/app/contract/proto/apps/v1/apps_pb.d.ts +223 -211
- package/dist/app/errors.d.ts +2 -2
- package/dist/app/index.d.ts +4 -4
- package/dist/app/manifest.d.ts +1 -1
- package/dist/app/vocabulary.generated.d.ts +13 -10
- package/dist/app-contract.js +27 -31
- package/dist/app.js +90 -25
- package/dist/auth/access.d.ts +106 -0
- package/dist/auth/grant-table.generated.d.ts +13 -0
- package/dist/auth/grants.d.ts +38 -0
- package/dist/auth/index.d.ts +2 -0
- package/dist/auth/operations.d.ts +6 -1
- package/dist/auth/ops.d.ts +1 -0
- package/dist/auth.js +269 -1
- package/dist/data/builtins.d.ts +59 -3
- package/dist/data/compatibility.d.ts +95 -0
- package/dist/data/document.d.ts +11 -1
- package/dist/data/index.d.ts +1 -1
- package/dist/data/transport.d.ts +51 -27
- package/dist/data.js +337 -7
- package/dist/next.js +3 -0
- package/dist/placement/index.d.ts +40 -8
- package/dist/placement.js +224 -2
- package/dist/push.js +3 -0
- package/dist/react/FoirProvider.d.ts +40 -1
- package/dist/react/feedback-notes.d.ts +1 -1
- package/dist/react/index.d.ts +5 -4
- package/dist/react.js +347 -3
- package/dist/server/events-client.d.ts +5 -5
- package/dist/server/index.d.ts +1 -1
- package/dist/server/older-claims.generated.d.ts +8 -0
- package/dist/server/require-scoped-token.d.ts +1 -1
- package/dist/server/verify-scoped-token.d.ts +36 -17
- package/dist/server.js +267 -5
- package/package.json +6 -5
package/dist/data/transport.d.ts
CHANGED
|
@@ -155,10 +155,10 @@ export type FoirOperationGeneratedModule<S extends FoirSchemaDefinition = FoirSc
|
|
|
155
155
|
* const foir = createFoirClient({ apiUrl, dispatch, generated });
|
|
156
156
|
* ```
|
|
157
157
|
*
|
|
158
|
-
* The endpoint calls Foir AS the operation, with the
|
|
158
|
+
* The endpoint calls Foir AS the operation, with the grants the operation
|
|
159
159
|
* declared, and holds no key of its own. api-public accepts the token as a
|
|
160
160
|
* caller in its own right: it builds the schema overlay from the token's
|
|
161
|
-
*
|
|
161
|
+
* grants, and it reads the organization and the customer from the
|
|
162
162
|
* token's claims. So nothing a key client configures beside its key
|
|
163
163
|
* (`bearerToken`, `orgId`, `channel`) means anything here, and none of it is
|
|
164
164
|
* accepted.
|
|
@@ -419,19 +419,33 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
|
|
|
419
419
|
/** Called instead of `console.warn` when drift is detected. */
|
|
420
420
|
onSchemaDrift?: (drift: FoirSchemaDrift) => void;
|
|
421
421
|
/**
|
|
422
|
-
* Refuse typed
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
422
|
+
* Refuse a typed call that depends on something the server no longer serves
|
|
423
|
+
* the way the generated module describes. **On by default**; pass `false` to
|
|
424
|
+
* opt out.
|
|
425
|
+
*
|
|
426
|
+
* A drift by itself refuses nothing. The surface version moves whenever the
|
|
427
|
+
* surface grows (a field on a model, an op on a namespace, a mutation the
|
|
428
|
+
* platform adds to every model), and a document printed from the older trees
|
|
429
|
+
* is still answered in full. So once the manifest has arrived the client
|
|
430
|
+
* compares its generated trees with the served ones, and refuses only the
|
|
431
|
+
* calls whose own parts changed:
|
|
432
|
+
*
|
|
433
|
+
* - the op is gone, or its root field or result kind changed;
|
|
434
|
+
* - an argument the module declares is gone or has a type the generated
|
|
435
|
+
* one no longer fits, or the op now requires an argument the module does
|
|
436
|
+
* not know;
|
|
437
|
+
* - a field the call's selection names is gone or has another shape, in the
|
|
438
|
+
* model the op returns, in a union that model's tree embeds, or in the
|
|
439
|
+
* target of a relation the call includes.
|
|
440
|
+
*
|
|
441
|
+
* Anything ADDED refuses nothing: a model, a namespace, an op, an optional
|
|
442
|
+
* argument, a field, a relation, a union member, a subscription.
|
|
443
|
+
*
|
|
444
|
+
* It is the default because the alternative is worse for the caller. A call
|
|
445
|
+
* that depends on a removed field is answered with a server error naming the
|
|
446
|
+
* field, and one whose relation changed from a record to a list is answered
|
|
447
|
+
* in a shape the types do not describe. Neither says the generated module is
|
|
448
|
+
* the thing to fix. `FoirSchemaDriftError` does, and names the part.
|
|
435
449
|
*
|
|
436
450
|
* Two things it deliberately does not do.
|
|
437
451
|
*
|
|
@@ -441,20 +455,16 @@ export interface FoirKeyClientConfig<S extends FoirSchemaDefinition = FoirSchema
|
|
|
441
455
|
* block every first call on a second round trip, which is the cost the
|
|
442
456
|
* warning was designed to avoid.
|
|
443
457
|
*
|
|
444
|
-
* It does not touch `raw`. A hand-written document
|
|
445
|
-
*
|
|
446
|
-
* class of bug by construction, and refusing it would punish the escape
|
|
447
|
-
* hatch people reach for precisely because it is immune.
|
|
458
|
+
* It does not touch `raw`. A hand-written document is not printed from the
|
|
459
|
+
* generated trees, so nothing compared here says anything about it.
|
|
448
460
|
*
|
|
449
|
-
* Turning it off is never the remedy
|
|
450
|
-
* is
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
* knowingly ships a stale module for a while, and it says so at the call
|
|
454
|
-
* site, where a reader can see it:
|
|
461
|
+
* Turning it off is never the remedy. A refusal names something that really
|
|
462
|
+
* is no longer served as generated, and the fix is `foir types`. The opt-out
|
|
463
|
+
* exists for an app that would rather send the call and handle the server's
|
|
464
|
+
* answer, and it says so at the call site, where a reader can see it:
|
|
455
465
|
*
|
|
456
466
|
* ```ts
|
|
457
|
-
* createFoirClient({ strictSchemaDrift: false }) //
|
|
467
|
+
* createFoirClient({ strictSchemaDrift: false }) // never refuse, warn only
|
|
458
468
|
* ```
|
|
459
469
|
*/
|
|
460
470
|
strictSchemaDrift?: boolean;
|
|
@@ -578,9 +588,23 @@ export interface FoirSchemaDrift {
|
|
|
578
588
|
* too. Empty only when the module was wired without `surfaceHashes`.
|
|
579
589
|
*/
|
|
580
590
|
changed: readonly string[];
|
|
591
|
+
/**
|
|
592
|
+
* What the generated module describes that the server no longer serves that
|
|
593
|
+
* way, as paths into the manifest, sorted:
|
|
594
|
+
* `models.order.selection.status`, `models.order.ops.update`,
|
|
595
|
+
* `namespaces.search.ops.searchRecords.args.query`,
|
|
596
|
+
* `models.order.relations.customer`, `selectionUnions.Block.HeroBlock.title`.
|
|
597
|
+
*
|
|
598
|
+
* `changed` says what moved; this says what BROKE. Empty when the surface
|
|
599
|
+
* only grew, which is the usual drift, and then no typed call is refused.
|
|
600
|
+
* Also empty for a client that prints from the served manifest rather than
|
|
601
|
+
* from generated trees, since it has nothing older to compare.
|
|
602
|
+
*/
|
|
603
|
+
incompatible: readonly string[];
|
|
581
604
|
}
|
|
582
605
|
/**
|
|
583
|
-
* Thrown by a typed call under `strictSchemaDrift`
|
|
606
|
+
* Thrown by a typed call under `strictSchemaDrift` when the call depends on
|
|
607
|
+
* something in `drift.incompatible`. The message names which.
|
|
584
608
|
*
|
|
585
609
|
* A subclass rather than a plain `FoirRequestError` so an app can tell "your
|
|
586
610
|
* generated module is stale" from "this request failed", which want different
|
package/dist/data.js
CHANGED
|
@@ -1008,6 +1008,304 @@ function isFoirMutationDocument(documentText) {
|
|
|
1008
1008
|
return /\bmutation\b/.test(documentText);
|
|
1009
1009
|
}
|
|
1010
1010
|
|
|
1011
|
+
// src/data/compatibility.ts
|
|
1012
|
+
var newPart = () => ({ broken: [], prints: /* @__PURE__ */ new Set() });
|
|
1013
|
+
var GONE = "no longer served";
|
|
1014
|
+
function shapeOf(field) {
|
|
1015
|
+
if (field.relation) return "a relation";
|
|
1016
|
+
if (field.truncated) {
|
|
1017
|
+
return field.union ? `a stopping point before the union ${field.union}` : "a stopping point";
|
|
1018
|
+
}
|
|
1019
|
+
if (field.model) return `the model ${field.model}`;
|
|
1020
|
+
if (field.union) return `the union ${field.union}`;
|
|
1021
|
+
return field.fields?.length ? "an object" : "a leaf";
|
|
1022
|
+
}
|
|
1023
|
+
function compareFields(generated, served, path, part) {
|
|
1024
|
+
const byKey = new Map((served ?? []).map((field) => [field.key, field]));
|
|
1025
|
+
for (const field of generated) {
|
|
1026
|
+
if (field.relation) continue;
|
|
1027
|
+
const at = `${path}.${field.key}`;
|
|
1028
|
+
const now = byKey.get(field.key);
|
|
1029
|
+
if (!now) {
|
|
1030
|
+
part.broken.push({ path: at, reason: GONE });
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const was = shapeOf(field);
|
|
1034
|
+
const is = shapeOf(now);
|
|
1035
|
+
if (field.truncated) {
|
|
1036
|
+
const composite = !now.relation && is !== "a leaf";
|
|
1037
|
+
if (!composite || field.union && now.union !== field.union) {
|
|
1038
|
+
part.broken.push({ path: at, reason: `generated as ${was}, served as ${is}` });
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
if (field.union) part.prints.add(`u:${field.union}`);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
if (was !== is) {
|
|
1045
|
+
part.broken.push({ path: at, reason: `generated as ${was}, served as ${is}` });
|
|
1046
|
+
continue;
|
|
1047
|
+
}
|
|
1048
|
+
if (field.model) part.prints.add(`m:${field.model}`);
|
|
1049
|
+
else if (field.union) part.prints.add(`u:${field.union}`);
|
|
1050
|
+
else if (field.fields?.length) compareFields(field.fields, now.fields, at, part);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
function variableFits(variable, position) {
|
|
1054
|
+
if (position.endsWith("!")) {
|
|
1055
|
+
return variable.endsWith("!") && variableFits(variable.slice(0, -1), position.slice(0, -1));
|
|
1056
|
+
}
|
|
1057
|
+
if (variable.endsWith("!")) return variableFits(variable.slice(0, -1), position);
|
|
1058
|
+
if (position.startsWith("[")) {
|
|
1059
|
+
return variable.startsWith("[") && variableFits(variable.slice(1, -1), position.slice(1, -1));
|
|
1060
|
+
}
|
|
1061
|
+
return variable === position;
|
|
1062
|
+
}
|
|
1063
|
+
function compareOp(generated, served, at) {
|
|
1064
|
+
const part = newPart();
|
|
1065
|
+
if (!served) {
|
|
1066
|
+
part.broken.push({ path: at, reason: GONE });
|
|
1067
|
+
return part;
|
|
1068
|
+
}
|
|
1069
|
+
if (generated.field !== served.field) {
|
|
1070
|
+
part.broken.push({
|
|
1071
|
+
path: `${at}.field`,
|
|
1072
|
+
reason: `root field ${generated.field}, served as ${served.field}`
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
if (Boolean(generated.mutation) !== Boolean(served.mutation)) {
|
|
1076
|
+
part.broken.push({
|
|
1077
|
+
path: `${at}.mutation`,
|
|
1078
|
+
reason: served.mutation ? "now a mutation" : "no longer a mutation"
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
const declared = generated.args ?? [];
|
|
1082
|
+
for (const name of declared) {
|
|
1083
|
+
const type = generated.argTypes?.[name];
|
|
1084
|
+
if (!type) continue;
|
|
1085
|
+
if (!served.args?.includes(name)) {
|
|
1086
|
+
part.broken.push({ path: `${at}.args.${name}`, reason: "no longer accepted" });
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
const now2 = served.argTypes?.[name];
|
|
1090
|
+
if (!now2 || !variableFits(type, now2)) {
|
|
1091
|
+
part.broken.push({
|
|
1092
|
+
path: `${at}.args.${name}`,
|
|
1093
|
+
reason: `type ${type}, served as ${now2 ?? "no published type"}`
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
for (const name of served.args ?? []) {
|
|
1098
|
+
if (declared.includes(name)) continue;
|
|
1099
|
+
if (served.argTypes?.[name]?.endsWith("!")) {
|
|
1100
|
+
part.broken.push({ path: `${at}.args.${name}`, reason: "a new required argument" });
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
const payload = generated.payload;
|
|
1104
|
+
if (!payload || payload.error) return part;
|
|
1105
|
+
const now = served.payload;
|
|
1106
|
+
if (!now || now.error) {
|
|
1107
|
+
part.broken.push({ path: `${at}.payload`, reason: "no result shape is served" });
|
|
1108
|
+
return part;
|
|
1109
|
+
}
|
|
1110
|
+
if (payload.kind !== now.kind) {
|
|
1111
|
+
part.broken.push({
|
|
1112
|
+
path: `${at}.payload`,
|
|
1113
|
+
reason: `result kind ${payload.kind}, served as ${String(now.kind)}`
|
|
1114
|
+
});
|
|
1115
|
+
return part;
|
|
1116
|
+
}
|
|
1117
|
+
if (payload.kind === "record" && payload.model) {
|
|
1118
|
+
if (payload.model !== now.model) {
|
|
1119
|
+
part.broken.push({
|
|
1120
|
+
path: `${at}.payload`,
|
|
1121
|
+
reason: `returns the model ${payload.model}, served as ${now.model ?? "none"}`
|
|
1122
|
+
});
|
|
1123
|
+
} else {
|
|
1124
|
+
part.prints.add(`m:${payload.model}`);
|
|
1125
|
+
}
|
|
1126
|
+
} else if (payload.kind === "union" && payload.union) {
|
|
1127
|
+
if (payload.union !== now.union) {
|
|
1128
|
+
part.broken.push({
|
|
1129
|
+
path: `${at}.payload`,
|
|
1130
|
+
reason: `returns the union ${payload.union}, served as ${now.union ?? "none"}`
|
|
1131
|
+
});
|
|
1132
|
+
} else {
|
|
1133
|
+
part.prints.add(`u:${payload.union}`);
|
|
1134
|
+
}
|
|
1135
|
+
} else if (payload.kind === "object") {
|
|
1136
|
+
compareFields(payload.fields ?? [], now.fields, `${at}.payload`, part);
|
|
1137
|
+
}
|
|
1138
|
+
return part;
|
|
1139
|
+
}
|
|
1140
|
+
function compareRelation(generated, served, at) {
|
|
1141
|
+
if (!served) return { path: at, reason: GONE };
|
|
1142
|
+
const targets = generated.targets ?? [];
|
|
1143
|
+
const nowTargets = served.targets ?? [];
|
|
1144
|
+
if (targets.length) {
|
|
1145
|
+
const dropped = targets.filter((target) => !nowTargets.includes(target));
|
|
1146
|
+
if (dropped.length) {
|
|
1147
|
+
return { path: at, reason: `no longer targets ${dropped.join(", ")}` };
|
|
1148
|
+
}
|
|
1149
|
+
} else if (nowTargets.length || (generated.target ?? "") !== (served.target ?? "")) {
|
|
1150
|
+
return {
|
|
1151
|
+
path: at,
|
|
1152
|
+
reason: `targets ${generated.target ?? "nothing"}, served as ${nowTargets.length ? nowTargets.join(" | ") : served.target ?? "nothing"}`
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
if (Boolean(generated.connection) !== Boolean(served.connection)) {
|
|
1156
|
+
return {
|
|
1157
|
+
path: at,
|
|
1158
|
+
reason: served.connection ? "now a connection" : "no longer a connection"
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
if (Boolean(generated.list) !== Boolean(served.list)) {
|
|
1162
|
+
return { path: at, reason: served.list ? "now a list" : "no longer a list" };
|
|
1163
|
+
}
|
|
1164
|
+
return void 0;
|
|
1165
|
+
}
|
|
1166
|
+
var NOTHING = [];
|
|
1167
|
+
function compareFoirSurfaces(generated, served) {
|
|
1168
|
+
const servedModels = served.models ?? {};
|
|
1169
|
+
const servedNamespaces = served.namespaces ?? {};
|
|
1170
|
+
const servedUnions = served.selectionUnions ?? {};
|
|
1171
|
+
const trees = /* @__PURE__ */ new Map();
|
|
1172
|
+
const ops = /* @__PURE__ */ new Map();
|
|
1173
|
+
const relations = /* @__PURE__ */ new Map();
|
|
1174
|
+
for (const [key, model] of Object.entries(generated.models ?? {})) {
|
|
1175
|
+
const at = `models.${key}`;
|
|
1176
|
+
const now = servedModels[key];
|
|
1177
|
+
const tree = newPart();
|
|
1178
|
+
trees.set(`m:${key}`, tree);
|
|
1179
|
+
if (!now) {
|
|
1180
|
+
const gone = { path: at, reason: GONE };
|
|
1181
|
+
tree.broken.push(gone);
|
|
1182
|
+
for (const op of Object.keys(model.ops ?? {})) {
|
|
1183
|
+
ops.set(`${at}.ops.${op}`, { broken: [gone], prints: /* @__PURE__ */ new Set() });
|
|
1184
|
+
}
|
|
1185
|
+
for (const relation of model.relations ?? []) {
|
|
1186
|
+
relations.set(`${key} ${relation.key}`, gone);
|
|
1187
|
+
}
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1190
|
+
if (model.typeName !== now.typeName) {
|
|
1191
|
+
tree.broken.push({
|
|
1192
|
+
path: `${at}.typeName`,
|
|
1193
|
+
reason: `type ${model.typeName}, served as ${now.typeName}`
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
if (model.selection) {
|
|
1197
|
+
if (!now.selection) tree.broken.push({ path: `${at}.selection`, reason: GONE });
|
|
1198
|
+
else compareFields(model.selection.fields, now.selection.fields, `${at}.selection`, tree);
|
|
1199
|
+
}
|
|
1200
|
+
for (const [op, def] of Object.entries(model.ops ?? {})) {
|
|
1201
|
+
ops.set(`${at}.ops.${op}`, compareOp(def, now.ops?.[op], `${at}.ops.${op}`));
|
|
1202
|
+
}
|
|
1203
|
+
for (const relation of model.relations ?? []) {
|
|
1204
|
+
const broken = compareRelation(
|
|
1205
|
+
relation,
|
|
1206
|
+
now.relations?.find((r) => r.key === relation.key),
|
|
1207
|
+
`${at}.relations.${relation.key}`
|
|
1208
|
+
);
|
|
1209
|
+
if (broken) relations.set(`${key} ${relation.key}`, broken);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
for (const [name, namespace] of Object.entries(generated.namespaces ?? {})) {
|
|
1213
|
+
const at = `namespaces.${name}`;
|
|
1214
|
+
const now = servedNamespaces[name];
|
|
1215
|
+
const gone = now ? void 0 : { path: at, reason: GONE };
|
|
1216
|
+
for (const [op, def] of Object.entries(namespace.ops ?? {})) {
|
|
1217
|
+
ops.set(
|
|
1218
|
+
`${at}.ops.${op}`,
|
|
1219
|
+
gone ? { broken: [gone], prints: /* @__PURE__ */ new Set() } : compareOp(def, now?.ops?.[op], `${at}.ops.${op}`)
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
for (const [name, union] of Object.entries(generated.selectionUnions ?? {})) {
|
|
1224
|
+
const at = `selectionUnions.${name}`;
|
|
1225
|
+
const now = servedUnions[name];
|
|
1226
|
+
const tree = newPart();
|
|
1227
|
+
trees.set(`u:${name}`, tree);
|
|
1228
|
+
if (!now) {
|
|
1229
|
+
tree.broken.push({ path: at, reason: GONE });
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
for (const member of union.members) {
|
|
1233
|
+
const nowMember = now.members?.find((m) => m.typeName === member.typeName);
|
|
1234
|
+
if (!nowMember) {
|
|
1235
|
+
tree.broken.push({ path: `${at}.${member.typeName}`, reason: "no longer a member" });
|
|
1236
|
+
continue;
|
|
1237
|
+
}
|
|
1238
|
+
compareFields(member.fields, nowMember.fields, `${at}.${member.typeName}`, tree);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const paths = /* @__PURE__ */ new Set();
|
|
1242
|
+
for (const part of [...trees.values(), ...ops.values()]) {
|
|
1243
|
+
for (const broken of part.broken) paths.add(broken.path);
|
|
1244
|
+
}
|
|
1245
|
+
for (const broken of relations.values()) paths.add(broken.path);
|
|
1246
|
+
const incompatible = [...paths].sort();
|
|
1247
|
+
const reached = /* @__PURE__ */ new Map();
|
|
1248
|
+
const reach = (start) => {
|
|
1249
|
+
const held = reached.get(start);
|
|
1250
|
+
if (held) return held;
|
|
1251
|
+
const out = [];
|
|
1252
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1253
|
+
const pending = [start];
|
|
1254
|
+
while (pending.length) {
|
|
1255
|
+
const id = pending.pop();
|
|
1256
|
+
if (seen.has(id)) continue;
|
|
1257
|
+
seen.add(id);
|
|
1258
|
+
const tree = trees.get(id);
|
|
1259
|
+
if (!tree) continue;
|
|
1260
|
+
out.push(...tree.broken);
|
|
1261
|
+
pending.push(...tree.prints);
|
|
1262
|
+
}
|
|
1263
|
+
reached.set(start, out);
|
|
1264
|
+
return out;
|
|
1265
|
+
};
|
|
1266
|
+
const included = (modelKey, include, out) => {
|
|
1267
|
+
if (!include || typeof include !== "object") return;
|
|
1268
|
+
const model = generated.models[modelKey];
|
|
1269
|
+
if (!model) return;
|
|
1270
|
+
for (const [key, value] of Object.entries(include)) {
|
|
1271
|
+
if (value === void 0 || value === false) continue;
|
|
1272
|
+
const relation = model.relations?.find((r) => r.key === key);
|
|
1273
|
+
if (!relation) continue;
|
|
1274
|
+
const broken = relations.get(`${modelKey} ${key}`);
|
|
1275
|
+
if (broken) out.push(broken);
|
|
1276
|
+
const nested = value && typeof value === "object" ? value["include"] : void 0;
|
|
1277
|
+
const targets = relation.targets?.length ? relation.targets : [relation.target ?? ""];
|
|
1278
|
+
for (const target of targets) {
|
|
1279
|
+
out.push(...reach(`m:${target}`));
|
|
1280
|
+
included(target, nested, out);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
};
|
|
1284
|
+
const call = (address, def, include) => {
|
|
1285
|
+
if (incompatible.length === 0) return NOTHING;
|
|
1286
|
+
const op = ops.get(address);
|
|
1287
|
+
if (!op || !def) return NOTHING;
|
|
1288
|
+
const out = [...op.broken];
|
|
1289
|
+
for (const id of op.prints) out.push(...reach(id));
|
|
1290
|
+
if (include && def.payload) {
|
|
1291
|
+
const records = new Set(payloadRecordPaths(def.payload).map((entry) => entry.modelKey));
|
|
1292
|
+
if (records.size === 1) included([...records][0], include, out);
|
|
1293
|
+
}
|
|
1294
|
+
if (out.length === 0) return NOTHING;
|
|
1295
|
+
const unique = new Map(out.map((broken) => [broken.path, broken]));
|
|
1296
|
+
return [...unique.values()].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
1297
|
+
};
|
|
1298
|
+
return {
|
|
1299
|
+
incompatible,
|
|
1300
|
+
modelCall: (model, op, include) => call(`models.${model}.ops.${op}`, generated.models[model]?.ops?.[op], include),
|
|
1301
|
+
namespaceCall: (namespace, op, include) => call(
|
|
1302
|
+
`namespaces.${namespace}.ops.${op}`,
|
|
1303
|
+
generated.namespaces?.[namespace]?.ops?.[op],
|
|
1304
|
+
include
|
|
1305
|
+
)
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1011
1309
|
// src/data/subscription-document.ts
|
|
1012
1310
|
function printSubscriptionDocument(input) {
|
|
1013
1311
|
const args = input.args ?? [];
|
|
@@ -1863,6 +2161,15 @@ function describeDrift(drift) {
|
|
|
1863
2161
|
const moved = describeChanged(drift.changed);
|
|
1864
2162
|
return `the types were generated from surface ${drift.generated}, and the server is serving ${drift.server} (${drift.channel}).` + (moved ? ` Changed since: ${moved}.` : "");
|
|
1865
2163
|
}
|
|
2164
|
+
var NAMED_INCOMPATIBLE_PARTS = 5;
|
|
2165
|
+
function nameParts(parts) {
|
|
2166
|
+
const named = parts.slice(0, NAMED_INCOMPATIBLE_PARTS).join(", ");
|
|
2167
|
+
const rest = parts.length - NAMED_INCOMPATIBLE_PARTS;
|
|
2168
|
+
return rest > 0 ? `${named} and ${rest} more` : named;
|
|
2169
|
+
}
|
|
2170
|
+
function describeIncompatible(parts) {
|
|
2171
|
+
return nameParts(parts.map((part) => `${part.path} (${part.reason})`));
|
|
2172
|
+
}
|
|
1866
2173
|
function announceFallback(config) {
|
|
1867
2174
|
if (config.generated !== void 0 || config.selections !== void 0) return;
|
|
1868
2175
|
if (config.announceManifestFallback === false) return;
|
|
@@ -1881,6 +2188,7 @@ function createFoirClient(clientConfig) {
|
|
|
1881
2188
|
announceFallback(config);
|
|
1882
2189
|
let driftChecked = false;
|
|
1883
2190
|
let detectedDrift;
|
|
2191
|
+
let servedComparison;
|
|
1884
2192
|
let manifestOnce;
|
|
1885
2193
|
const manifest = () => {
|
|
1886
2194
|
if (!manifestOnce) {
|
|
@@ -1909,32 +2217,51 @@ function createFoirClient(clientConfig) {
|
|
|
1909
2217
|
driftChecked = true;
|
|
1910
2218
|
void manifest().then((served) => {
|
|
1911
2219
|
if (!served.surfaceVersion || served.surfaceVersion === generated) return;
|
|
2220
|
+
const comparison = config.selections ? compareFoirSurfaces(
|
|
2221
|
+
{
|
|
2222
|
+
models: config.selections,
|
|
2223
|
+
namespaces: config.namespaceSelections,
|
|
2224
|
+
selectionUnions: config.selectionUnions
|
|
2225
|
+
},
|
|
2226
|
+
served
|
|
2227
|
+
) : void 0;
|
|
1912
2228
|
const drift = {
|
|
1913
2229
|
generated,
|
|
1914
2230
|
server: served.surfaceVersion,
|
|
1915
2231
|
channel: served.channel,
|
|
1916
|
-
changed: changedSurfaceParts(config.surfaceHashes, served.surfaceHashes)
|
|
2232
|
+
changed: changedSurfaceParts(config.surfaceHashes, served.surfaceHashes),
|
|
2233
|
+
incompatible: comparison?.incompatible ?? []
|
|
1917
2234
|
};
|
|
1918
2235
|
detectedDrift = drift;
|
|
2236
|
+
servedComparison = comparison;
|
|
1919
2237
|
if (config.onSchemaDrift) {
|
|
1920
2238
|
config.onSchemaDrift(drift);
|
|
1921
2239
|
return;
|
|
1922
2240
|
}
|
|
2241
|
+
if (drift.incompatible.length === 0) {
|
|
2242
|
+
console.warn(
|
|
2243
|
+
`[foir] schema drift: ${describeDrift(drift)} Everything the generated module describes is still served as generated, so every typed call keeps working. The surface has grown: re-run \`foir types\` to pick up what is new, because a field added since is not merely untyped, it is not requested.`
|
|
2244
|
+
);
|
|
2245
|
+
return;
|
|
2246
|
+
}
|
|
2247
|
+
const those = drift.incompatible.length === 1 ? "it" : "them";
|
|
1923
2248
|
console.warn(
|
|
1924
|
-
`[foir] schema drift: ${describeDrift(drift)}
|
|
2249
|
+
`[foir] schema drift: ${describeDrift(drift)} No longer served as the generated module describes: ${nameParts(drift.incompatible)}. ` + (config.strictSchemaDrift === false ? `Typed calls that depend on ${those} are still sent, and can fail or answer in another shape. ` : `Typed calls that depend on ${those} are refused. `) + `Re-run \`foir types\`.`
|
|
1925
2250
|
);
|
|
1926
2251
|
}).catch(() => {
|
|
1927
2252
|
});
|
|
1928
2253
|
};
|
|
1929
|
-
const
|
|
1930
|
-
if (config.strictSchemaDrift === false || !detectedDrift) return;
|
|
2254
|
+
const assertStillServed = (what, dependsOn) => {
|
|
2255
|
+
if (config.strictSchemaDrift === false || !detectedDrift || !servedComparison) return;
|
|
2256
|
+
const broken = dependsOn(servedComparison);
|
|
2257
|
+
if (broken.length === 0) return;
|
|
1931
2258
|
throw new FoirSchemaDriftError(
|
|
1932
|
-
`[foir] refusing ${what}: ${describeDrift(detectedDrift)}
|
|
2259
|
+
`[foir] refusing ${what}: ${describeDrift(detectedDrift)} This call depends on ${describeIncompatible(broken)}, which the server no longer serves as the generated module describes. Re-run \`foir types\`.`,
|
|
1933
2260
|
detectedDrift
|
|
1934
2261
|
);
|
|
1935
2262
|
};
|
|
1936
2263
|
const runModel = async (model, op, query) => {
|
|
1937
|
-
|
|
2264
|
+
assertStillServed(`${model}.${op}`, (served) => served.modelCall(model, op, query?.include));
|
|
1938
2265
|
const { args, include, omit, pick, depth } = splitQuery(query);
|
|
1939
2266
|
const source = await selectionSource();
|
|
1940
2267
|
const plan = planFoirModelCall(source, {
|
|
@@ -1952,7 +2279,10 @@ function createFoirClient(clientConfig) {
|
|
|
1952
2279
|
return result;
|
|
1953
2280
|
};
|
|
1954
2281
|
const runNamespace = async (namespace, op, query) => {
|
|
1955
|
-
|
|
2282
|
+
assertStillServed(
|
|
2283
|
+
`ns.${namespace}.${op}`,
|
|
2284
|
+
(served) => served.namespaceCall(namespace, op, query?.include)
|
|
2285
|
+
);
|
|
1956
2286
|
const { args, include, omit, pick, depth } = splitQuery(query);
|
|
1957
2287
|
const plan = planFoirNamespaceCall(await selectionSource(), {
|
|
1958
2288
|
namespace,
|
package/dist/next.js
CHANGED
|
@@ -921,6 +921,9 @@ var AUTH_OPS = {
|
|
|
921
921
|
// Post-credential self reads.
|
|
922
922
|
currentUser: "currentUser",
|
|
923
923
|
myProviderToken: "myProviderToken",
|
|
924
|
+
// What the calling credential may do, and for a signed-in customer the roles
|
|
925
|
+
// they hold. `./access` reads it.
|
|
926
|
+
whoami: "whoami",
|
|
924
927
|
// Methods.
|
|
925
928
|
customerLogin: "customerLogin",
|
|
926
929
|
customerRegister: "customerRegister",
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* reaches a cross-site frame, so the console hands the page a short-lived token
|
|
6
6
|
* instead, by message, to your placement's origin only. The token is the
|
|
7
7
|
* operator looking at the page, acting through your app at the level your app
|
|
8
|
-
* is installed at, carrying
|
|
9
|
-
*
|
|
8
|
+
* is installed at, carrying what your placement declares and this operator
|
|
9
|
+
* holds, as grants in the permission catalogue's names. Your page runs no
|
|
10
|
+
* login of its own.
|
|
10
11
|
*
|
|
11
12
|
* ```ts
|
|
12
13
|
* import { connectPlacement, FOIR_CONSOLE_ORIGINS } from '@foir/sdk/placement';
|
|
@@ -15,7 +16,7 @@
|
|
|
15
16
|
* apiUrl: 'https://api.foir.dev',
|
|
16
17
|
* consoleOrigins: FOIR_CONSOLE_ORIGINS.production,
|
|
17
18
|
* });
|
|
18
|
-
* if (session.can('records:write:preorder')) showEditButtons();
|
|
19
|
+
* if (session.can('records:write.all:preorder')) showEditButtons();
|
|
19
20
|
* const data = await session.request('{ preorders { items { id } } }');
|
|
20
21
|
* ```
|
|
21
22
|
*
|
|
@@ -52,14 +53,38 @@ export interface PlacementGrant {
|
|
|
52
53
|
expiresAt: string;
|
|
53
54
|
/** The organisation the page acts at: your app's install. Empty for the project. */
|
|
54
55
|
orgId: string;
|
|
55
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* What the token carries: the grants your placement declares that this
|
|
58
|
+
* operator holds, in the permission catalogue's names. A grant the operator
|
|
59
|
+
* holds part of is carried as that part, so ask with `can` and not by
|
|
60
|
+
* looking for a spelling here.
|
|
61
|
+
*/
|
|
62
|
+
grants: string[];
|
|
63
|
+
/** Each grant your placement declares that the token does not carry whole, and why. */
|
|
64
|
+
withheldGrants: {
|
|
65
|
+
grant: string;
|
|
66
|
+
reason: string;
|
|
67
|
+
}[];
|
|
68
|
+
/**
|
|
69
|
+
* @deprecated Read `grants`. The same list under the name it had before a
|
|
70
|
+
* placement declared grants.
|
|
71
|
+
*/
|
|
56
72
|
grantedScopes: string[];
|
|
57
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* @deprecated Read `withheldGrants`. The same list under the name it had
|
|
75
|
+
* before a placement declared grants.
|
|
76
|
+
*/
|
|
58
77
|
withheld: {
|
|
59
78
|
scope: string;
|
|
60
79
|
reason: string;
|
|
61
80
|
}[];
|
|
62
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* What a console sends with a token. A console of the release before sends
|
|
84
|
+
* only `grantedScopes` and `withheld`, and the session reads `grants` from
|
|
85
|
+
* those.
|
|
86
|
+
*/
|
|
87
|
+
type PlacementGrantMessage = Pick<PlacementGrant, 'accessToken' | 'expiresAt' | 'orgId'> & Partial<Pick<PlacementGrant, 'grants' | 'withheldGrants' | 'grantedScopes' | 'withheld'>>;
|
|
63
88
|
/** How the console looks, for an embedded page to match. */
|
|
64
89
|
export interface PlacementAppearance {
|
|
65
90
|
/**
|
|
@@ -71,7 +96,7 @@ export interface PlacementAppearance {
|
|
|
71
96
|
/** A message the console sends an embedded page. */
|
|
72
97
|
export type PlacementHostMessage = {
|
|
73
98
|
type: typeof PLACEMENT_MESSAGE.token;
|
|
74
|
-
grant:
|
|
99
|
+
grant: PlacementGrantMessage;
|
|
75
100
|
} | {
|
|
76
101
|
type: typeof PLACEMENT_MESSAGE.refused;
|
|
77
102
|
reason: string;
|
|
@@ -138,8 +163,14 @@ export interface PlacementSession {
|
|
|
138
163
|
* older than this). Kept current as the operator changes it.
|
|
139
164
|
*/
|
|
140
165
|
readonly appearance: PlacementAppearance | null;
|
|
141
|
-
/**
|
|
142
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Whether the token covers this grant, to hide or grey out what the operator
|
|
168
|
+
* cannot do. It answers as Foir's API would: a grant for every model covers
|
|
169
|
+
* the same grant for one, and one for every owner's rows covers a person's
|
|
170
|
+
* own. `can('records:read.all:order')` is true for a token that carries
|
|
171
|
+
* `records:read.all`.
|
|
172
|
+
*/
|
|
173
|
+
can(grant: string): boolean;
|
|
143
174
|
/** A GraphQL request to Foir's API as the operator, through your app. */
|
|
144
175
|
request<TResult = unknown>(document: string | {
|
|
145
176
|
toString(): string;
|
|
@@ -156,3 +187,4 @@ export interface PlacementSession {
|
|
|
156
187
|
* console gives no token, or when none arrives in time.
|
|
157
188
|
*/
|
|
158
189
|
export declare function connectPlacement(options: ConnectPlacementOptions): Promise<PlacementSession>;
|
|
190
|
+
export {};
|