@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.
@@ -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 capabilities the operation
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
- * capabilities, and it reads the organization and the customer from the
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 calls once drift is detected, instead of serving the stale
423
- * shape. **On by default**; pass `false` to opt out.
424
- *
425
- * The asymmetry this closes: an unknown ARGUMENT is refused client-side
426
- * before the server sees it, while a field the server has and the generated
427
- * selection does not is simply not requested. The second is the common case
428
- * when a schema evolves, and it surfaces as absent data rather than as a
429
- * tooling problem — so it is the one that needs to be loud.
430
- *
431
- * It is the default rather than an opt-in because the thing it prevents is
432
- * SILENT. An opt-in guard against a silent failure only protects the people
433
- * who already suspected the failure existed, and the warning it replaces is
434
- * one more line to scroll past.
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 asks for exactly the
445
- * fields it names, so it cannot silently omit one — raw is immune to this
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 for a drift. The version it compares
450
- * is the SURFACE the types were generated from, which moves only when
451
- * regenerating would write a different file, so every drift it reports is a
452
- * real one and the fix is `foir types`. The opt-out exists for an app that
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 }) // serve stale, warn only
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` once drift is detected.
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)} Re-run \`foir types\`: the selection trees this client prints from are the ones that surface published, so a new field is not merely untyped, it is not requested.`
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 assertNoStaleSelections = (what) => {
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)} The selection trees this client prints from are the ones that surface published, so a field added since is not requested at all. Re-run \`foir types\`.`,
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
- assertNoStaleSelections(`${model}.${op}`);
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
- assertNoStaleSelections(`ns.${namespace}.${op}`);
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 the scopes your placement declares that this
9
- * operator holds. Your page runs no login of its own.
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
- /** What the token carries. */
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
- /** What your placement declares that this operator does not hold, and why. */
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: PlacementGrant;
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
- /** Whether the token carries this scope, to hide or grey out what the operator cannot do. */
142
- can(scope: string): boolean;
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 {};