@foir/sdk 0.18.0 → 0.19.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 CHANGED
@@ -108,6 +108,92 @@ nobody asking for `@foir/sdk` gets it early. The push that carries the commit to
108
108
 
109
109
  Changes on `dev` that have not been published to npm.
110
110
 
111
+ ## [0.19.0] — 2026-10-02
112
+
113
+ Published under npm's `next` tag from `dev`. The data client's change needs no
114
+ platform release. `setConsent` by email calls a field the platform serves from
115
+ the next promotion; `latest` moves to this version then.
116
+
117
+ ### Changed
118
+
119
+ - **Breaking: schema drift by itself no longer refuses a typed call**
120
+ (`@foir/sdk/data`). What `strictSchemaDrift` refuses has changed; its name
121
+ and its default have not.
122
+
123
+ Before, a client built with a generated module refused every typed call,
124
+ with `FoirSchemaDriftError`, from the moment it saw the server serving another
125
+ surface version. That version is a hash over everything `foir types` reads,
126
+ so it moves whenever the surface grows. A field published on a model, or an
127
+ op the platform added to every model, made every deployed client refuse its
128
+ second typed call, though nothing it prints had changed.
129
+
130
+ Now the drift is reported once, exactly as before (`onSchemaDrift`, or the
131
+ `console.warn`), and it refuses nothing by itself. When the manifest arrives
132
+ the client compares its generated trees with the served ones, and refuses a
133
+ typed call only when something that call depends on is no longer served the
134
+ way the module describes it:
135
+
136
+ - its op is gone, or the op's root field or result kind changed;
137
+ - an argument the module declares is gone, or has a type the generated one no
138
+ longer fits, or the op now requires an argument the module does not know;
139
+ - a field its selection names is gone or changed shape, in the model the op
140
+ returns or in a union that model embeds;
141
+ - a relation the call includes changed target, became a list or a
142
+ connection, or its target model lost a field.
143
+
144
+ Anything added refuses nothing: a model, a namespace, an op, an optional
145
+ argument, a field, a relation, a union member, a subscription. An argument
146
+ that went from required to optional refuses nothing either.
147
+
148
+ `FoirSchemaDriftError`'s message now names what the refused call depends on
149
+ (`models.order.selection.status (no longer served)`), and still names
150
+ `foir types` as the remedy. The warning for a drift that broke nothing says
151
+ the surface has grown and that every typed call keeps working.
152
+
153
+ Unchanged: `strictSchemaDrift: false` turns refusal off entirely, the first
154
+ call on a client is never blocked on the manifest, `raw()` is never refused,
155
+ and `checkSchemaDrift: false` skips the fetch. `createFoirDriftHandler` in
156
+ `@foir/sdk/live` still resyncs on every drift, whether or not anything is
157
+ incompatible.
158
+
159
+ A client wired by hand with `surfaceVersion` and no `selections` prints from
160
+ the served manifest, so it has nothing older to compare and is no longer
161
+ refused on drift.
162
+
163
+ Migration: none for an app that only catches `FoirSchemaDriftError`. An app
164
+ that used the refusal to learn that its module was stale in ANY way should
165
+ read `onSchemaDrift`, which still fires for every drift, or gate on
166
+ `foir types --check` in CI.
167
+ - **Breaking: `FoirSchemaDrift` has a new required field, `incompatible`**
168
+ (`@foir/sdk/data`). Code that reads a drift is unaffected. Code that builds
169
+ one by hand, for example to test a handler given to `onSchemaDrift` or made by
170
+ `createFoirDriftHandler`, must add `incompatible: []`.
171
+ - **Breaking (types): `AppConsentChange` names its customer one way**
172
+ (`@foir/sdk/app`). It was an interface with a required `customerId`. It is
173
+ now a type that holds a `customerId` or an `email`, never both. A change
174
+ written as `{ customerId, ... }` is unaffected. An interface that extended
175
+ `AppConsentChange` no longer compiles: intersect with it instead.
176
+
177
+ ### Added
178
+
179
+ - `notifications.setConsent` takes `email` in place of `customerId`
180
+ (`@foir/sdk/app`), for an opt-in from somebody who may have no customer yet,
181
+ such as a sign-up form. The platform finds the customer holding the address
182
+ at the install's level, or makes one, together with the change, so a change
183
+ it refuses (a category the project does not have, a confirmation email the
184
+ project cannot send) leaves no customer behind. Finding or making the
185
+ customer first with `customers.ensure` did. The operation must declare
186
+ `customers:write` as well as `notifications:consent`. `AppConsentSubject` is
187
+ exported.
188
+
189
+ - `FoirSchemaDrift.incompatible`: what the generated module describes that the
190
+ server no longer serves that way, as sorted paths into the manifest
191
+ (`models.order.selection.status`, `models.order.ops.update`,
192
+ `namespaces.search.ops.searchRecords.args.query`,
193
+ `models.order.relations.customer`, `selectionUnions.Block.HeroBlock.title`).
194
+ Empty when the surface has only grown. `changed` keeps its meaning: what
195
+ moved, not what broke.
196
+
111
197
  ## [0.18.0] — 2026-10-02
112
198
 
113
199
  Published under npm's `next` tag from `dev`, because it calls fields the
package/README.md CHANGED
@@ -88,7 +88,14 @@ was generated from: a hash over exactly what `foir types` reads, which moves onl
88
88
  when regenerating would write a different file. Pushing an operation, a role or
89
89
  a key moves the server's invalidation counter and not the surface, so it is not
90
90
  drift. Publishing a field on a model the types include is, and the client
91
- refuses the next typed call with a message naming the model. The remedy is
91
+ reports it once, naming the model.
92
+
93
+ **Drift does not stop a call.** A surface that has grown still answers every
94
+ document the module prints. A typed call is refused, with `FoirSchemaDriftError`,
95
+ only when something it depends on is no longer served as generated: a field its
96
+ selection names, its op, one of its arguments, or the target of a relation it
97
+ includes. The message names that part, and `drift.incompatible` lists every
98
+ such part as a path (`models.order.selection.status`). The remedy is
92
99
  `foir types`; `strictSchemaDrift: false` never is.
93
100
 
94
101
  **Scalars are always complete**, so `post.title` is typed present and *is*
@@ -186,8 +186,24 @@ export interface AppNotification {
186
186
  /** Makes the send safe to repeat: the same key from the same operation tells the customer once. */
187
187
  idempotencyKey?: string;
188
188
  }
189
- export interface AppConsentChange {
189
+ /**
190
+ * Who a consent change is for: a customer whose id the app holds, or an
191
+ * address.
192
+ *
193
+ * By `email`, the customer holding the address at the operation's level is
194
+ * found, or made, together with the change, so a change the platform refuses
195
+ * leaves no customer behind. It is for an opt-in from somebody who may have no
196
+ * customer yet (a sign-up form), and the operation must declare
197
+ * `customers:write` as well as `notifications:consent`.
198
+ */
199
+ export type AppConsentSubject = {
190
200
  customerId: string;
201
+ email?: undefined;
202
+ } | {
203
+ email: string;
204
+ customerId?: undefined;
205
+ };
206
+ export type AppConsentChange = AppConsentSubject & {
191
207
  /** A notification category the project makes opt-in. */
192
208
  category: string;
193
209
  /** The channel the consent is for, as the project's preferences name it (for example `email`). */
@@ -215,7 +231,7 @@ export interface AppConsentChange {
215
231
  * email is written in it when the customer has stated none of their own.
216
232
  */
217
233
  locale?: string;
218
- }
234
+ };
219
235
  /** What the calling token is, and what it is served. Read from the platform, never off the token. */
220
236
  export interface AppWhoami {
221
237
  /** The app the token acts for, when it acts for one. */
@@ -458,6 +474,10 @@ export interface AppClient {
458
474
  * been sent a link, or was sent one recently, and the preference turns on
459
475
  * when they follow it. The answer is the same whether or not the address
460
476
  * already had a customer.
477
+ *
478
+ * Name the customer by `customerId`, or by `email` for somebody who may
479
+ * have no customer yet: the platform finds or makes them with the change,
480
+ * and that also needs `customers:write`.
461
481
  */
462
482
  setConsent(change: AppConsentChange): Promise<{
463
483
  stale: boolean;
@@ -21,7 +21,7 @@
21
21
  * The manifest's own messages are on `@foir/sdk/app/contract`.
22
22
  */
23
23
  export { createAppClient } from './client';
24
- export type { AppBinding, AppClient, AppClientConfig, AppConsentChange, AppCustomer, AppCustomerEraseResult, AppCustomerList, AppCustomerSaveResult, AppFieldMapping, AppInstallConfig, AppNotification, AppOperationStart, AppRecord, AppRecordList, AppStartedOperation, AppStatusReport, AppSyncDirection, AppUpsertResult, AppWhoami, } from './client';
24
+ export type { AppBinding, AppClient, AppClientConfig, AppConsentChange, AppConsentSubject, AppCustomer, AppCustomerEraseResult, AppCustomerList, AppCustomerSaveResult, AppFieldMapping, AppInstallConfig, AppNotification, AppOperationStart, AppRecord, AppRecordList, AppStartedOperation, AppStatusReport, AppSyncDirection, AppUpsertResult, AppWhoami, } from './client';
25
25
  export { appCustomerEmailUnavailableOf, appCustomerUnlinkedOf, appLookupConflictOf, isAppFieldWithheld } from './errors';
26
26
  export type { AppCustomerEmailUnavailableReason, AppLookupConflict } from './errors';
27
27
  export { appModelFieldName, appModelTypeName, appSelectorEnumValue } from './naming';
package/dist/app.js CHANGED
@@ -835,9 +835,11 @@ function createAppClient(config) {
835
835
  if (sent !== true) refuse("sendNotification reported no error and did not say the notification was sent");
836
836
  },
837
837
  async setConsent(change) {
838
- if (change.customerId === "" || change.category === "") refuse("a consent change needs a customer and a category");
838
+ const subject = consentSubject(change);
839
+ if (subject === null) return refuse("a consent change names its customer by customerId or by email, not both");
840
+ if (subject.value === "" || change.category === "") return refuse("a consent change needs a customer and a category");
839
841
  const input = {
840
- customerId: change.customerId,
842
+ [subject.by]: subject.value,
841
843
  category: change.category,
842
844
  channel: change.channel,
843
845
  enabled: change.enabled
@@ -886,6 +888,12 @@ function createAppClient(config) {
886
888
  }
887
889
  };
888
890
  }
891
+ function consentSubject(change) {
892
+ const loose = change;
893
+ if (loose.email !== void 0 && loose.customerId !== void 0) return null;
894
+ if (loose.email !== void 0) return { by: "email", value: loose.email.trim() };
895
+ return { by: "customerId", value: loose.customerId ?? "" };
896
+ }
889
897
  function directionOf(raw) {
890
898
  const name = (raw ?? "").replace(/^FIELD_SYNC_DIRECTION_/, "");
891
899
  return name === "SOURCE_ONLY" || name === "BIDIRECTIONAL" ? name : "SINK_ONLY";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Whether the surface being served can still answer what a generated module
3
+ * prints.
4
+ *
5
+ * A generated module records the surface version it came from, and that version
6
+ * is a hash over everything `foir types` reads. So it moves when a model gains
7
+ * a field, when a namespace gains an op, and when the platform adds a mutation
8
+ * to every model at once. None of those changes a document this client prints:
9
+ * the document names the fields and arguments the module knew about, and the
10
+ * server still has every one of them. Treating a moved version as a reason to
11
+ * refuse calls made every deployed client stop working the moment the surface
12
+ * grew, with nothing in its own project having changed.
13
+ *
14
+ * So the two are kept apart. A moved version is DRIFT, and it is reported. A
15
+ * call is refused only for an INCOMPATIBILITY: something that call prints, or
16
+ * sends, that the server no longer serves the way the module describes. The
17
+ * client holds both sides of that question once the manifest has arrived, since
18
+ * the generated trees and the served manifest are the same shape, and this file
19
+ * compares them.
20
+ *
21
+ * ---------------------------------------------------------------------------
22
+ * What "the same" means
23
+ * ---------------------------------------------------------------------------
24
+ *
25
+ * What `document.ts` reads, because a property the printer never reads cannot
26
+ * change a document:
27
+ *
28
+ * - an op: its root `field`, whether it is a `mutation`, its `args` and
29
+ * `argTypes`, and its `payload` (`kind`, `model`, `union`, `fields`);
30
+ * - a selection field: its `key`, and which of the printer's branches it
31
+ * takes (a relation, a stopping point, a `model` reference, a `union`
32
+ * reference, an object with `fields`, or a leaf);
33
+ * - a relation an `include` resolves: `target`, `targets` and `connection`,
34
+ * and one property the printer does not read, `list`, because it decides
35
+ * whether the same document answers with one record or an array;
36
+ * - a model's `typeName`, which names the fragment a polymorphic relation
37
+ * prints for it;
38
+ * - a union's members, by `typeName`, and each member's fields.
39
+ *
40
+ * `cost`, `nullable`, `deprecated` and the buckets the generator does not emit
41
+ * are not compared. `alias` is not compared either, and that one is a decision:
42
+ * an alias is a name this client gives a field in its OWN document, so the
43
+ * server never sees the manifest's opinion of it. A new union member whose
44
+ * field collides with an existing one makes the server publish aliases for
45
+ * both, and the module's document, which selects only the members it knew,
46
+ * is as valid as it was.
47
+ *
48
+ * The comparison runs one way. Everything the module names must still be
49
+ * served; whatever the server has added is never looked at, with one
50
+ * exception: an argument the served op REQUIRES and the module does not know,
51
+ * since a call that cannot supply it cannot succeed.
52
+ */
53
+ import { type FoirSelectionSource } from './document';
54
+ /** One thing the generated module describes that the server no longer serves that way. */
55
+ export interface FoirIncompatibility {
56
+ /**
57
+ * Where it sits, in the manifest's own keys:
58
+ * `models.order.selection.status`, `models.order.ops.update`,
59
+ * `namespaces.search.ops.searchRecords.args.query`.
60
+ */
61
+ path: string;
62
+ /** What happened to it, as a clause a message can put in brackets. */
63
+ reason: string;
64
+ }
65
+ /** The comparison, held by a client for as long as it lives. */
66
+ export interface FoirSurfaceComparison {
67
+ /** Every incompatible path, sorted. Empty when the surface has only grown. */
68
+ readonly incompatible: readonly string[];
69
+ /** What `foir.<model>.<op>()` depends on that is incompatible. */
70
+ modelCall(model: string, op: string, include: unknown): readonly FoirIncompatibility[];
71
+ /** What `foir.ns.<namespace>.<op>()` depends on that is incompatible. */
72
+ namespaceCall(namespace: string, op: string, include: unknown): readonly FoirIncompatibility[];
73
+ }
74
+ /**
75
+ * Compare a generated module's trees with the manifest being served.
76
+ *
77
+ * Called once, when the manifest arrives, and off the response path. What it
78
+ * returns answers each later call from maps built here, so a call pays for a
79
+ * lookup and, when it has an `include`, a walk of the relations it names.
80
+ *
81
+ * How far a break reaches is decided per call rather than per model. A call
82
+ * depends on its op, on every tree its result prints (the model's own, the
83
+ * unions inside it, and so on down), and on the relations THIS call includes
84
+ * with the trees of their targets. A model whose relation points at a broken
85
+ * model is not refused for it until a call includes that relation, because
86
+ * until then the broken tree is not in the document. Refusing on the whole
87
+ * relation graph would let one field removed from one model stop every call in
88
+ * a project whose models all reach each other, which is most projects.
89
+ *
90
+ * It is not narrowed by `pick` or `omit`. A call that omits the one field that
91
+ * is gone is still refused: resolving a caller's paths against two trees, with
92
+ * aliases and union members in play, is the printer's work done a second time,
93
+ * and regenerating is the remedy either way.
94
+ */
95
+ export declare function compareFoirSurfaces(generated: FoirSelectionSource, served: FoirSelectionSource): FoirSurfaceComparison;
@@ -51,7 +51,7 @@
51
51
  * inferring a literal would reinstate the defect above under a condition no
52
52
  * caller can see.
53
53
  */
54
- import type { FoirManifestOp, FoirManifestRelation, FoirManifestSelection, FoirManifestSelectionField, FoirManifestSelectionUnion } from './manifest';
54
+ import type { FoirManifestOp, FoirManifestOpPayload, FoirManifestRelation, FoirManifestSelection, FoirManifestSelectionField, FoirManifestSelectionUnion } from './manifest';
55
55
  /**
56
56
  * One model, as the printer needs it.
57
57
  *
@@ -145,6 +145,16 @@ export interface FoirRecordPath {
145
145
  /** Model key whose published `selection` is printed at that position. */
146
146
  modelKey: string;
147
147
  }
148
+ /**
149
+ * Every record position in a payload, in the order the tree declares them.
150
+ *
151
+ * ONE walk, and everything that needs to know where the records are reads it:
152
+ * `planCall` takes the model SET from it to decide where `pick`/`omit`/`include`
153
+ * land, and the plan carries the positions so the transport's caller can find
154
+ * the records in the response. A second walk would be correct on the day it was
155
+ * written and wrong on the day the payload format grew a case.
156
+ */
157
+ export declare function payloadRecordPaths(payload: FoirManifestOpPayload): readonly FoirRecordPath[];
148
158
  /** One printed operation, and what the transport needs to route it. */
149
159
  export interface FoirDocumentPlan {
150
160
  /** The document text. Also the thing that gets hashed for APQ. */
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foir/sdk",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Official Foir SDK — editor iframe client + server-side platform helpers",
5
5
  "type": "module",
6
6
  "publishConfig": {