@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 +86 -0
- package/README.md +8 -1
- package/dist/app/client.d.ts +22 -2
- package/dist/app/index.d.ts +1 -1
- package/dist/app.js +10 -2
- package/dist/data/compatibility.d.ts +95 -0
- package/dist/data/document.d.ts +11 -1
- package/dist/data/transport.d.ts +49 -25
- package/dist/data.js +337 -7
- package/package.json +1 -1
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
|
-
|
|
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*
|
package/dist/app/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/data/document.d.ts
CHANGED
|
@@ -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. */
|
package/dist/data/transport.d.ts
CHANGED
|
@@ -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,
|