@foir/sdk 0.17.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,121 @@ 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
+
197
+ ## [0.18.0] — 2026-10-02
198
+
199
+ Published under npm's `next` tag from `dev`, because it calls fields the
200
+ platform serves from the next promotion; `latest` moves to it then.
201
+
202
+ ### Changed
203
+
204
+ - **Breaking: an app finds a customer by identity through its install, not
205
+ through a model** (`@foir/sdk/app`). The customer sign-in an app's customers
206
+ come through is paired with the install as a whole, so the two reads that
207
+ walk it no longer name a model and have moved beside the other customer
208
+ reads:
209
+ - `records.customer.identities(model, customerId)` is now
210
+ `customers.identities(customerId)`.
211
+ - `customers.byIdentity(identity)` is new: the id of the customer who holds
212
+ an identity at the app's own system, or `null`. It replaces reading a
213
+ customer's record for its owner.
214
+
215
+ Both need the new capability `customers:read:identity` (or `customers:read`),
216
+ declared by the operation, and are refused at an install that pairs no
217
+ sign-in. Reading or writing a model is no longer what admits them.
218
+ - `install.config()` asks the platform for `customerSignIn`, a field a platform
219
+ older than this release does not have.
220
+
221
+ ### Added
222
+
223
+ - `install.config()` answers `customerSignIn`: the key of the customer sign-in
224
+ the install pairs, or `null` when it pairs none.
225
+
111
226
  ## [0.17.0] — 2026-10-02
112
227
 
113
228
  Published under npm's `next` tag from `dev`, because it reads a field 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*
@@ -103,6 +103,14 @@ export interface AppInstallConfig {
103
103
  * (`appCustomerEmailUnavailableOf`), so say so before one is.
104
104
  */
105
105
  customerEmailUnavailable: AppCustomerEmailUnavailableReason | null;
106
+ /**
107
+ * The customer sign-in this install pairs with the app, by key, for an app
108
+ * whose own system has customers; null when it pairs none. Every identity
109
+ * the app names is resolved through it, so while it is null nothing finds a
110
+ * customer by identity: `customers.byIdentity` and `customers.identities`
111
+ * are refused, and an operation that needs them is not registered.
112
+ */
113
+ customerSignIn: string | null;
106
114
  }
107
115
  /**
108
116
  * The status an install reports. Leaving out `subscriptionHealth`,
@@ -178,8 +186,24 @@ export interface AppNotification {
178
186
  /** Makes the send safe to repeat: the same key from the same operation tells the customer once. */
179
187
  idempotencyKey?: string;
180
188
  }
181
- 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 = {
182
200
  customerId: string;
201
+ email?: undefined;
202
+ } | {
203
+ email: string;
204
+ customerId?: undefined;
205
+ };
206
+ export type AppConsentChange = AppConsentSubject & {
183
207
  /** A notification category the project makes opt-in. */
184
208
  category: string;
185
209
  /** The channel the consent is for, as the project's preferences name it (for example `email`). */
@@ -207,7 +231,7 @@ export interface AppConsentChange {
207
231
  * email is written in it when the customer has stated none of their own.
208
232
  */
209
233
  locale?: string;
210
- }
234
+ };
211
235
  /** What the calling token is, and what it is served. Read from the platform, never off the token. */
212
236
  export interface AppWhoami {
213
237
  /** The app the token acts for, when it acts for one. */
@@ -378,12 +402,6 @@ export interface AppClient {
378
402
  * else is attached to them. Needs `customers:write` as well.
379
403
  */
380
404
  erase(model: string, identity: string): Promise<AppCustomerEraseResult>;
381
- /**
382
- * The other direction: the identities a customer the app names by id
383
- * signs in with there, most recently seen first. Empty when they sign in
384
- * with none. For an app acting on an event that names the Foir customer.
385
- */
386
- identities(model: string, customerId: string): Promise<string[]>;
387
405
  };
388
406
  };
389
407
  customers: {
@@ -415,6 +433,23 @@ export interface AppClient {
415
433
  * `customers:read`.
416
434
  */
417
435
  byEmail(email: string): Promise<AppCustomer | null>;
436
+ /**
437
+ * The id of the customer who holds this identity at the app's own system,
438
+ * or null when nobody does: for an app hearing from its system about one
439
+ * of its customers. The identity is the subject of the customer's sign-in
440
+ * with that system, and it is resolved through the customer sign-in the
441
+ * install pairs (`install.config().customerSignIn`) and no other. Never
442
+ * matched by email. Needs `customers:read:identity`, or `customers:read`.
443
+ */
444
+ byIdentity(identity: string): Promise<string | null>;
445
+ /**
446
+ * The other direction: the identities a customer the app names by id
447
+ * holds at the app's own system, most recently seen first, through the
448
+ * same pairing. Empty when they have never signed in that way. For an app
449
+ * acting on an event that names the Foir customer. Needs
450
+ * `customers:read:identity`, or `customers:read`.
451
+ */
452
+ identities(customerId: string): Promise<string[]>;
418
453
  /**
419
454
  * The customers at the install's level, a page at a time, newest first.
420
455
  * Needs `customers:read`: an operation declaring only
@@ -439,6 +474,10 @@ export interface AppClient {
439
474
  * been sent a link, or was sent one recently, and the preference turns on
440
475
  * when they follow it. The answer is the same whether or not the address
441
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`.
442
481
  */
443
482
  setConsent(change: AppConsentChange): Promise<{
444
483
  stale: boolean;
@@ -117,6 +117,14 @@ export declare type AppConfig = Message<"apps.v1.AppConfig"> & {
117
117
  * @generated from field: repeated apps.v1.Feature features = 13;
118
118
  */
119
119
  features: Feature[];
120
+
121
+ /**
122
+ * OPTIONAL. The app's own system has customers who sign in to it. See
123
+ * CustomerSignIn.
124
+ *
125
+ * @generated from field: apps.v1.CustomerSignIn customer_sign_in = 14;
126
+ */
127
+ customerSignIn?: CustomerSignIn | undefined;
120
128
  };
121
129
 
122
130
  /**
@@ -125,6 +133,42 @@ export declare type AppConfig = Message<"apps.v1.AppConfig"> & {
125
133
  */
126
134
  export declare const AppConfigSchema: GenMessage<AppConfig>;
127
135
 
136
+ /**
137
+ * The app's own system has customers, and the app names each by the subject of
138
+ * their sign-in with that system: a store's customer number, which is also who
139
+ * they are when they sign in to the project through the store's sign-in.
140
+ *
141
+ * Declaring it lets an install pair ONE of the project's customer sign-ins with
142
+ * the app (ConfirmInstallAppRequest.customer_sign_in): the operator says which
143
+ * sign-in the app's customers come through, and the platform resolves every
144
+ * identity the app names through that sign-in and no other. The pairing is the
145
+ * operator's, never inferred: the install knows the external account it
146
+ * connects, not which of the project's sign-ins its customers use, and a wrong
147
+ * pairing would attach one person's data to another.
148
+ *
149
+ * The pairing is optional. What needs it says so, and cannot be on without it:
150
+ * a source that names its customers by identity (SourceType.identity), a
151
+ * feature that finds customers through it (Feature.customer_sign_in), and an
152
+ * operation that asks who holds an identity (customers:read:identity).
153
+ *
154
+ * @generated from message apps.v1.CustomerSignIn
155
+ */
156
+ export declare type CustomerSignIn = Message<"apps.v1.CustomerSignIn"> & {
157
+ /**
158
+ * What the setup tells the operator they are choosing, in a sentence: which
159
+ * customers these are and what identifies them.
160
+ *
161
+ * @generated from field: string description = 1;
162
+ */
163
+ description: string;
164
+ };
165
+
166
+ /**
167
+ * Describes the message apps.v1.CustomerSignIn.
168
+ * Use `create(CustomerSignInSchema)` to create a new message.
169
+ */
170
+ export declare const CustomerSignInSchema: GenMessage<CustomerSignIn>;
171
+
128
172
  /**
129
173
  * A feature of the app: something the operator turns on or off as a whole at
130
174
  * setup, such as a store's preorders or its back-in-stock alerts.
@@ -180,6 +224,16 @@ export declare type Feature = Message<"apps.v1.Feature"> & {
180
224
  * @generated from field: repeated string sources = 5;
181
225
  */
182
226
  sources: string[];
227
+
228
+ /**
229
+ * The feature finds customers through the install's customer sign-in
230
+ * (AppConfig.customer_sign_in), which the manifest must declare. It cannot
231
+ * be on at an install that pairs none: the setup asks for the sign-in at the
232
+ * switch, and the platform refuses the switch without one.
233
+ *
234
+ * @generated from field: bool customer_sign_in = 6;
235
+ */
236
+ customerSignIn: boolean;
183
237
  };
184
238
 
185
239
  /**
@@ -586,10 +640,11 @@ export declare type SourceType = Message<"apps.v1.SourceType"> & {
586
640
  *
587
641
  * It is what lets a sync write customer-owned rows. The platform resolves the
588
642
  * value to a Foir customer through the customer sign-in the install pairs
589
- * with the source (SourceMapping.identity_provider), at the install's level:
590
- * the customer who signs in with that identity, or one made for it. Each
591
- * customer holds one row of the source, their profile, so the source has no
592
- * natural key; the customer is the key. Never matched by email.
643
+ * with the app (AppConfig.customer_sign_in, which the manifest must then
644
+ * declare), at the install's level: the customer who signs in with that
645
+ * identity, or one made for it. Each customer holds one row of the source,
646
+ * their profile, so the source has no natural key; the customer is the key.
647
+ * Never matched by email.
593
648
  *
594
649
  * @generated from field: string identity = 10;
595
650
  */
@@ -1429,6 +1484,18 @@ export enum ManifestCapability {
1429
1484
  */
1430
1485
  CUSTOMERS_READ_NAMED = 15,
1431
1486
 
1487
+ /**
1488
+ * The link between a customer and who they are at the app's own system, and
1489
+ * nothing else about them: the customer who holds an identity, and the
1490
+ * identities a customer the operation names by id holds, both through the
1491
+ * customer sign-in the install pairs with the app and no other. An operation
1492
+ * that declares customers:read holds it too; customers:read:named does not
1493
+ * reach it, nor it what that reaches.
1494
+ *
1495
+ * @generated from enum value: MANIFEST_CAPABILITY_CUSTOMERS_READ_IDENTITY = 16;
1496
+ */
1497
+ CUSTOMERS_READ_IDENTITY = 16,
1498
+
1432
1499
  /**
1433
1500
  * Reporting an ASYNC run's outcome.
1434
1501
  *
@@ -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';
@@ -7,7 +7,7 @@ export declare const APP_HOOK_EVENTS: readonly ["RECORD_CREATED", "RECORD_UPDATE
7
7
  /** An event a hook can name. */
8
8
  export type AppHookEvent = (typeof APP_HOOK_EVENTS)[number];
9
9
  /** The capabilities an operation declares as written. */
10
- export declare const APP_CAPABILITIES: readonly ["config:read", "secrets:read.app", "secrets:put.app", "secrets:list.app", "secrets:delete.app", "status:write", "files:read", "files:list", "files:write", "customers:write", "notifications:write", "notifications:consent", "customers:read", "customers:read:named", "operations:complete"];
10
+ export declare const APP_CAPABILITIES: readonly ["config:read", "secrets:read.app", "secrets:put.app", "secrets:list.app", "secrets:delete.app", "status:write", "files:read", "files:list", "files:write", "customers:write", "notifications:write", "notifications:consent", "customers:read", "customers:read:named", "customers:read:identity", "operations:complete"];
11
11
  /** A capability an operation declares as written. */
12
12
  export type AppCapability = (typeof APP_CAPABILITIES)[number];
13
13
  /**
@@ -8,30 +8,31 @@ var file_buf_validate_validate = /* @__PURE__ */ fileDesc("ChtidWYvdmFsaWRhdGUvd
8
8
 
9
9
  // ../proto-ts/src/apps/v1/apps_pb.js
10
10
  import { file_google_protobuf_descriptor as file_google_protobuf_descriptor2, file_google_protobuf_struct } from "@bufbuild/protobuf/wkt";
11
- var file_apps_v1_apps = /* @__PURE__ */ fileDesc2("ChJhcHBzL3YxL2FwcHMucHJvdG8SB2FwcHMudjEilgUKCUFwcENvbmZpZxI0CgRuYW1lGAEgASgJQia6SCNyIRhAMh1eW2Etel1bYS16MC05XSooLVthLXowLTldKykqJBIYCgd2ZXJzaW9uGAIgASgJQge6SARyAhABEiMKCG1ldGFkYXRhGAMgASgLMhEuYXBwcy52MS5NZXRhZGF0YRInCgptaWRkbGV3YXJlGAQgASgLMhMuYXBwcy52MS5NaWRkbGV3YXJlEikKC2NyZWRlbnRpYWxzGAUgASgLMhQuYXBwcy52MS5DcmVkZW50aWFscxIwCg9zZXR0aW5nc19zY2hlbWEYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EjAKB3NvdXJjZXMYByADKAsyHy5hcHBzLnYxLkFwcENvbmZpZy5Tb3VyY2VzRW50cnkSLAoFc2lua3MYCCADKAsyHS5hcHBzLnYxLkFwcENvbmZpZy5TaW5rc0VudHJ5EiYKCm9wZXJhdGlvbnMYCSADKAsyEi5hcHBzLnYxLk9wZXJhdGlvbhIcCgVob29rcxgKIAMoCzINLmFwcHMudjEuSG9vaxIXCgJ1aRgLIAEoCzILLmFwcHMudjEuVUkSIQoHaW5ib3VuZBgMIAEoCzIQLmFwcHMudjEuSW5ib3VuZBIiCghmZWF0dXJlcxgNIAMoCzIQLmFwcHMudjEuRmVhdHVyZRpDCgxTb3VyY2VzRW50cnkSCwoDa2V5GAEgASgJEiIKBXZhbHVlGAIgASgLMhMuYXBwcy52MS5Tb3VyY2VUeXBlOgI4ARpDCgpTaW5rc0VudHJ5EgsKA2tleRgBIAEoCRIkCgV2YWx1ZRgCIAEoCzIVLmFwcHMudjEuU2lua0NvbnRyYWN0OgI4ASKNAQoHRmVhdHVyZRIzCgNrZXkYASABKAlCJrpII3IhGEAyHV5bYS16XVthLXowLTldKigtW2EtejAtOV0rKSokEhYKBXRpdGxlGAIgASgJQge6SARyAhABEhMKC2Rlc2NyaXB0aW9uGAMgASgJEg8KB3NldHRpbmcYBCABKAkSDwoHc291cmNlcxgFIAMoCSJlCghNZXRhZGF0YRIUCgxkaXNwbGF5X25hbWUYASABKAkSEwoLZGVzY3JpcHRpb24YAiABKAkSDAoEaWNvbhgDIAEoCRIOCgZhdXRob3IYBCABKAkSEAoIaG9tZXBhZ2UYBSABKAkiLQoKTWlkZGxld2FyZRIfCgN1cmwYASABKAlCErpID3INMgteaHR0cHM6Ly8uKyLXAgoLQ3JlZGVudGlhbHMSLwoIc3RyYXRlZ3kYASABKA4yHS5hcHBzLnYxLkNyZWRlbnRpYWxzLlN0cmF0ZWd5EiwKBnNjaGVtYRgCIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3RIAIgBARIoCgVvYXV0aBgDIAEoCzIULmFwcHMudjEuT0F1dGhDb25maWdIAYgBASKpAQoIU3RyYXRlZ3kSGAoUU1RSQVRFR1lfVU5TUEVDSUZJRUQQABISCg5TVFJBVEVHWV9PQVVUSBABEhQKEFNUUkFURUdZX0FQSV9LRVkQAhIaChZTVFJBVEVHWV9TSEFSRURfU0VDUkVUEAMSFAoQU1RSQVRFR1lfU1NIX0tFWRAEEhEKDVNUUkFURUdZX05PTkUQBRIUChBTVFJBVEVHWV9NQU5BR0VEEAZCCQoHX3NjaGVtYUIICgZfb2F1dGgiTAoLT0F1dGhDb25maWcSHAoUYXV0aG9yaXplX3N0YXJ0X3BhdGgYASABKAkSHwoXYXV0aG9yaXplX2NhbGxiYWNrX3BhdGgYAiABKAkiVgoHSW5ib3VuZBIhCgd3ZWJob29rGAEgASgLMhAuYXBwcy52MS5XZWJob29rEigKB2FjY291bnQYAiABKAsyFy5hcHBzLnYxLkFjY291bnRXZWJob29rIhcKB1dlYmhvb2sSDAoEcGF0aBgBIAEoCSI/Cg5BY2NvdW50V2ViaG9vaxIMCgRwYXRoGAEgASgJEg8KB3NldHRpbmcYAiABKAkSDgoGc291cmNlGAMgASgJIt0CCgpTb3VyY2VUeXBlEg0KBWxhYmVsGAEgASgJEi8KBmZpZWxkcxgCIAMoCzIfLmFwcHMudjEuU291cmNlVHlwZS5GaWVsZHNFbnRyeRITCgtuYXR1cmFsX2tleRgDIAEoCRIQCghvcHRpb25hbBgEIAEoCBIWCg5wcm92aXNpb25fb25seRgFIAEoCBIrCglyZWZlcmVuY2UYBiABKAsyGC5hcHBzLnYxLlNvdXJjZVJlZmVyZW5jZRIkCgVvd25lchgHIAEoDjIVLmFwcHMudjEuU3RvcmFnZU93bmVyEhMKC3JlY29tbWVuZGVkGAggASgIEg4KBmxvb2t1cBgJIAMoCRIQCghpZGVudGl0eRgKIAEoCRpGCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSJgoFdmFsdWUYAiABKAsyFy5hcHBzLnYxLlN1Z2dlc3RlZEZpZWxkOgI4ASKqAgoPU291cmNlUmVmZXJlbmNlEhEKCWtleV9maWVsZBgBIAEoCRI0CgZmaWVsZHMYAiADKAsyJC5hcHBzLnYxLlNvdXJjZVJlZmVyZW5jZS5GaWVsZHNFbnRyeRIQCghlbmRwb2ludBgDIAEoCRINCgVob3N0cxgEIAMoCRIqCgdyZXF1ZXN0GAUgASgLMhkuYXBwcy52MS5SZWZlcmVuY2VSZXF1ZXN0EhoKEnJlc3BvbnNlX3RyYW5zZm9ybRgGIAEoCRIdChVjYWNoZV9tYXhfYWdlX3NlY29uZHMYByABKAUaRgoLRmllbGRzRW50cnkSCwoDa2V5GAEgASgJEiYKBXZhbHVlGAIgASgLMhcuYXBwcy52MS5TdWdnZXN0ZWRGaWVsZDoCOAEiswIKEFJlZmVyZW5jZVJlcXVlc3QSDgoGbWV0aG9kGAEgASgJEhUKDXBhdGhfdGVtcGxhdGUYAiABKAkSMwoFcXVlcnkYAyADKAsyJC5hcHBzLnYxLlJlZmVyZW5jZVJlcXVlc3QuUXVlcnlFbnRyeRI3CgdoZWFkZXJzGAQgAygLMiYuYXBwcy52MS5SZWZlcmVuY2VSZXF1ZXN0LkhlYWRlcnNFbnRyeRIVCg1ib2R5X3RlbXBsYXRlGAUgASgJEhUKDWJhdGNoX2NhcGFibGUYBiABKAgaLAoKUXVlcnlFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBGi4KDEhlYWRlcnNFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBIroBCgxTaW5rQ29udHJhY3QSDQoFbGFiZWwYASABKAkSMQoGZmllbGRzGAIgAygLMiEuYXBwcy52MS5TaW5rQ29udHJhY3QuRmllbGRzRW50cnkSEwoLbmF0dXJhbF9rZXkYAyABKAkSEAoIb3B0aW9uYWwYBCABKAgaQQoLRmllbGRzRW50cnkSCwoDa2V5GAEgASgJEiEKBXZhbHVlGAIgASgLMhIuYXBwcy52MS5TaW5rRmllbGQ6AjgBIpYCCg5TdWdnZXN0ZWRGaWVsZBIMCgR0eXBlGAEgASgJEhIKBWxhYmVsGAIgASgJSACIAQESFQoIcmVxdWlyZWQYAyABKAhIAYgBARIsCgZjb25maWcYBCABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SAKIAQESPQoTc3VwcG9ydGVkX2RpcmVjdGlvbhgFIAEoDjIbLmFwcHMudjEuRmllbGRTeW5jRGlyZWN0aW9uSAOIAQESFgoJcXVlcnlhYmxlGAYgASgISASIAQFCCAoGX2xhYmVsQgsKCV9yZXF1aXJlZEIJCgdfY29uZmlnQhYKFF9zdXBwb3J0ZWRfZGlyZWN0aW9uQgwKCl9xdWVyeWFibGUiWwoJU2lua0ZpZWxkEgwKBHR5cGUYASABKAkSEAoIc2VtYW50aWMYAiABKAkSEAoIcmVxdWlyZWQYAyABKAgSEgoFbGFiZWwYBCABKAlIAIgBAUIICgZfbGFiZWwimAQKCU9wZXJhdGlvbhI6CgNrZXkYASABKAlCLbpIKnIoGEAyJF4oX18pP1thLXpdW2EtejAtOV9dKigtW2EtejAtOV9dKykqJBIMCgRuYW1lGAIgASgJEhgKC2Rlc2NyaXB0aW9uGAMgASgJSACIAQESHQoIZW5kcG9pbnQYBCABKAlCC7pICHIGMgReLy4qEhQKDGNhcGFiaWxpdGllcxgFIAMoCRIXCgp0aW1lb3V0X21zGAYgASgFSAGIAQESMgoMcmV0cnlfcG9saWN5GAcgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgCiAEBEjIKDGlucHV0X3NjaGVtYRgIIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3RIA4gBARIzCg1vdXRwdXRfc2NoZW1hGAkgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgEiAEBEhsKDnN1cHBvcnRzX2FzeW5jGAogASgISAWIAQESGQoRcnVuX2FmdGVyX2luc3RhbGwYCyABKAgSHgoWcnVuc19vbl9tYXBwZWRfc291cmNlcxgMIAEoCEIOCgxfZGVzY3JpcHRpb25CDQoLX3RpbWVvdXRfbXNCDwoNX3JldHJ5X3BvbGljeUIPCg1faW5wdXRfc2NoZW1hQhAKDl9vdXRwdXRfc2NoZW1hQhEKD19zdXBwb3J0c19hc3luYyI9ChZNYW5pZmVzdENhcGFiaWxpdHlTcGVjEhAKCGRlY2xhcmVzGAEgASgJEhEKCXBlcl9tb2RlbBgCIAEoCCKmAQoESG9vaxIzCgNrZXkYASABKAlCJrpII3IhGEAyHV5bYS16XVthLXowLTldKigtW2EtejAtOV0rKSokEgwKBG5hbWUYAiABKAkSFgoFZXZlbnQYAyABKAlCB7pIBHICEAESJwoGZmlsdGVyGAQgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdBIaCglvcGVyYXRpb24YBSABKAlCB7pIBHICEAEiNwoCVUkSMQoKcGxhY2VtZW50cxgBIAMoCzIdLmFwcHMudjEuUGxhY2VtZW50RGVjbGFyYXRpb24itgQKFFBsYWNlbWVudERlY2xhcmF0aW9uEjMKA2tleRgBIAEoCUImukgjciEYQDIdXlthLXpdW2EtejAtOV0qKC1bYS16MC05XSspKiQSNAoGdGFyZ2V0GAIgASgOMiQuYXBwcy52MS5QbGFjZW1lbnREZWNsYXJhdGlvbi5UYXJnZXQSEgoFbW9kZWwYAyABKAlIAIgBARISCgVmaWVsZBgEIAEoCUgBiAEBEiEKGWZpZWxkX3NlbGVjdGVkX2F0X2luc3RhbGwYBSABKAgSEAoDdGFiGAYgASgJSAKIAQESFgoFdGl0bGUYByABKAlCB7pIBHICEAESHwoDdXJsGAggASgJQhK6SA9yDTILXmh0dHBzOi8vLisSHQoQcmVwbGFjZXNfZGVmYXVsdBgJIAEoCEgDiAEBEisKBWhpbnRzGAogASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgEiAEBEg4KBnNjb3BlcxgLIAMoCRIQCghzZXR0aW5ncxgMIAMoCSJ0CgZUYXJnZXQSFgoSVEFSR0VUX1VOU1BFQ0lGSUVEEAASFAoQVEFSR0VUX0ZVTExfUEFHRRABEhYKElRBUkdFVF9NQUlOX0VESVRPUhACEhIKDlRBUkdFVF9TSURFQkFSEAMSEAoMVEFSR0VUX0ZJRUxEEARCCAoGX21vZGVsQggKBl9maWVsZEIGCgRfdGFiQhMKEV9yZXBsYWNlc19kZWZhdWx0QggKBl9oaW50cyqsAQoSRmllbGRTeW5jRGlyZWN0aW9uEiQKIEZJRUxEX1NZTkNfRElSRUNUSU9OX1VOU1BFQ0lGSUVEEAASJAogRklFTERfU1lOQ19ESVJFQ1RJT05fU09VUkNFX09OTFkQARIiCh5GSUVMRF9TWU5DX0RJUkVDVElPTl9TSU5LX09OTFkQAhImCiJGSUVMRF9TWU5DX0RJUkVDVElPTl9CSURJUkVDVElPTkFMEAMqhAEKDFN0b3JhZ2VPd25lchIdChlTVE9SQUdFX09XTkVSX1VOU1BFQ0lGSUVEEAASGQoVU1RPUkFHRV9PV05FUl9QUk9KRUNUEAESHgoaU1RPUkFHRV9PV05FUl9PUkdBTklaQVRJT04QAhIaChZTVE9SQUdFX09XTkVSX0NVU1RPTUVSEAMqxAoKEk1hbmlmZXN0Q2FwYWJpbGl0eRIjCh9NQU5JRkVTVF9DQVBBQklMSVRZX1VOU1BFQ0lGSUVEEAASNgofTUFOSUZFU1RfQ0FQQUJJTElUWV9DT05GSUdfUkVBRBABGhGaph0NCgtjb25maWc6cmVhZBJACiRNQU5JRkVTVF9DQVBBQklMSVRZX1NFQ1JFVFNfUkVBRF9BUFAQAhoWmqYdEgoQc2VjcmV0czpyZWFkLmFwcBI+CiNNQU5JRkVTVF9DQVBBQklMSVRZX1NFQ1JFVFNfUFVUX0FQUBADGhWaph0RCg9zZWNyZXRzOnB1dC5hcHASQAokTUFOSUZFU1RfQ0FQQUJJTElUWV9TRUNSRVRTX0xJU1RfQVBQEAQaFpqmHRIKEHNlY3JldHM6bGlzdC5hcHASRAomTUFOSUZFU1RfQ0FQQUJJTElUWV9TRUNSRVRTX0RFTEVURV9BUFAQBRoYmqYdFAoSc2VjcmV0czpkZWxldGUuYXBwEjgKIE1BTklGRVNUX0NBUEFCSUxJVFlfU1RBVFVTX1dSSVRFEAYaEpqmHQ4KDHN0YXR1czp3cml0ZRI0Ch5NQU5JRkVTVF9DQVBBQklMSVRZX0ZJTEVTX1JFQUQQBxoQmqYdDAoKZmlsZXM6cmVhZBI0Ch5NQU5JRkVTVF9DQVBBQklMSVRZX0ZJTEVTX0xJU1QQCBoQmqYdDAoKZmlsZXM6bGlzdBI2Ch9NQU5JRkVTVF9DQVBBQklMSVRZX0ZJTEVTX1dSSVRFEAkaEZqmHQ0KC2ZpbGVzOndyaXRlEj4KI01BTklGRVNUX0NBUEFCSUxJVFlfQ1VTVE9NRVJTX1dSSVRFEAoaFZqmHREKD2N1c3RvbWVyczp3cml0ZRJGCidNQU5JRkVTVF9DQVBBQklMSVRZX05PVElGSUNBVElPTlNfV1JJVEUQCxoZmqYdFQoTbm90aWZpY2F0aW9uczp3cml0ZRJKCilNQU5JRkVTVF9DQVBBQklMSVRZX05PVElGSUNBVElPTlNfQ09OU0VOVBAMGhuaph0XChVub3RpZmljYXRpb25zOmNvbnNlbnQSPAoiTUFOSUZFU1RfQ0FQQUJJTElUWV9DVVNUT01FUlNfUkVBRBAOGhSaph0QCg5jdXN0b21lcnM6cmVhZBJICihNQU5JRkVTVF9DQVBBQklMSVRZX0NVU1RPTUVSU19SRUFEX05BTUVEEA8aGpqmHRYKFGN1c3RvbWVyczpyZWFkOm5hbWVkEkYKJ01BTklGRVNUX0NBUEFCSUxJVFlfT1BFUkFUSU9OU19DT01QTEVURRANGhmaph0VChNvcGVyYXRpb25zOmNvbXBsZXRlEjsKIE1BTklGRVNUX0NBUEFCSUxJVFlfUkVDT1JEU19SRUFEEBQaFZqmHREKDXJlY29yZHM6cmVhZDoQARI9CiFNQU5JRkVTVF9DQVBBQklMSVRZX1JFQ09SRFNfV1JJVEUQFRoWmqYdEgoOcmVjb3Jkczp3cml0ZToQARI/CiJNQU5JRkVTVF9DQVBBQklMSVRZX1JFQ09SRFNfREVMRVRFEBYaF5qmHRMKD3JlY29yZHM6ZGVsZXRlOhABEkEKI01BTklGRVNUX0NBUEFCSUxJVFlfUkVDT1JEU19QVUJMSVNIEBcaGJqmHRQKEHJlY29yZHM6cHVibGlzaDoQARJFCiVNQU5JRkVTVF9DQVBBQklMSVRZX1JFQ09SRFNfQVRUUklCVVRFEBgaGpqmHRYKEnJlY29yZHM6YXR0cmlidXRlOhABOnUKE21hbmlmZXN0X2NhcGFiaWxpdHkSIS5nb29nbGUucHJvdG9idWYuRW51bVZhbHVlT3B0aW9ucxjj1AMgASgLMh8uYXBwcy52MS5NYW5pZmVzdENhcGFiaWxpdHlTcGVjUhJtYW5pZmVzdENhcGFiaWxpdHlCNVozZ2l0aHViLmNvbS9laWRlc3R1ZGlvL2ZvaXIvZ2VuL3Byb3RvL2FwcHMvdjE7YXBwc3YxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_descriptor2, file_google_protobuf_struct]);
11
+ var file_apps_v1_apps = /* @__PURE__ */ fileDesc2("ChJhcHBzL3YxL2FwcHMucHJvdG8SB2FwcHMudjEiyQUKCUFwcENvbmZpZxI0CgRuYW1lGAEgASgJQia6SCNyIRhAMh1eW2Etel1bYS16MC05XSooLVthLXowLTldKykqJBIYCgd2ZXJzaW9uGAIgASgJQge6SARyAhABEiMKCG1ldGFkYXRhGAMgASgLMhEuYXBwcy52MS5NZXRhZGF0YRInCgptaWRkbGV3YXJlGAQgASgLMhMuYXBwcy52MS5NaWRkbGV3YXJlEikKC2NyZWRlbnRpYWxzGAUgASgLMhQuYXBwcy52MS5DcmVkZW50aWFscxIwCg9zZXR0aW5nc19zY2hlbWEYBiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EjAKB3NvdXJjZXMYByADKAsyHy5hcHBzLnYxLkFwcENvbmZpZy5Tb3VyY2VzRW50cnkSLAoFc2lua3MYCCADKAsyHS5hcHBzLnYxLkFwcENvbmZpZy5TaW5rc0VudHJ5EiYKCm9wZXJhdGlvbnMYCSADKAsyEi5hcHBzLnYxLk9wZXJhdGlvbhIcCgVob29rcxgKIAMoCzINLmFwcHMudjEuSG9vaxIXCgJ1aRgLIAEoCzILLmFwcHMudjEuVUkSIQoHaW5ib3VuZBgMIAEoCzIQLmFwcHMudjEuSW5ib3VuZBIiCghmZWF0dXJlcxgNIAMoCzIQLmFwcHMudjEuRmVhdHVyZRIxChBjdXN0b21lcl9zaWduX2luGA4gASgLMhcuYXBwcy52MS5DdXN0b21lclNpZ25JbhpDCgxTb3VyY2VzRW50cnkSCwoDa2V5GAEgASgJEiIKBXZhbHVlGAIgASgLMhMuYXBwcy52MS5Tb3VyY2VUeXBlOgI4ARpDCgpTaW5rc0VudHJ5EgsKA2tleRgBIAEoCRIkCgV2YWx1ZRgCIAEoCzIVLmFwcHMudjEuU2lua0NvbnRyYWN0OgI4ASIlCg5DdXN0b21lclNpZ25JbhITCgtkZXNjcmlwdGlvbhgBIAEoCSKnAQoHRmVhdHVyZRIzCgNrZXkYASABKAlCJrpII3IhGEAyHV5bYS16XVthLXowLTldKigtW2EtejAtOV0rKSokEhYKBXRpdGxlGAIgASgJQge6SARyAhABEhMKC2Rlc2NyaXB0aW9uGAMgASgJEg8KB3NldHRpbmcYBCABKAkSDwoHc291cmNlcxgFIAMoCRIYChBjdXN0b21lcl9zaWduX2luGAYgASgIImUKCE1ldGFkYXRhEhQKDGRpc3BsYXlfbmFtZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCRIMCgRpY29uGAMgASgJEg4KBmF1dGhvchgEIAEoCRIQCghob21lcGFnZRgFIAEoCSItCgpNaWRkbGV3YXJlEh8KA3VybBgBIAEoCUISukgPcg0yC15odHRwczovLy4rItcCCgtDcmVkZW50aWFscxIvCghzdHJhdGVneRgBIAEoDjIdLmFwcHMudjEuQ3JlZGVudGlhbHMuU3RyYXRlZ3kSLAoGc2NoZW1hGAIgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgAiAEBEigKBW9hdXRoGAMgASgLMhQuYXBwcy52MS5PQXV0aENvbmZpZ0gBiAEBIqkBCghTdHJhdGVneRIYChRTVFJBVEVHWV9VTlNQRUNJRklFRBAAEhIKDlNUUkFURUdZX09BVVRIEAESFAoQU1RSQVRFR1lfQVBJX0tFWRACEhoKFlNUUkFURUdZX1NIQVJFRF9TRUNSRVQQAxIUChBTVFJBVEVHWV9TU0hfS0VZEAQSEQoNU1RSQVRFR1lfTk9ORRAFEhQKEFNUUkFURUdZX01BTkFHRUQQBkIJCgdfc2NoZW1hQggKBl9vYXV0aCJMCgtPQXV0aENvbmZpZxIcChRhdXRob3JpemVfc3RhcnRfcGF0aBgBIAEoCRIfChdhdXRob3JpemVfY2FsbGJhY2tfcGF0aBgCIAEoCSJWCgdJbmJvdW5kEiEKB3dlYmhvb2sYASABKAsyEC5hcHBzLnYxLldlYmhvb2sSKAoHYWNjb3VudBgCIAEoCzIXLmFwcHMudjEuQWNjb3VudFdlYmhvb2siFwoHV2ViaG9vaxIMCgRwYXRoGAEgASgJIj8KDkFjY291bnRXZWJob29rEgwKBHBhdGgYASABKAkSDwoHc2V0dGluZxgCIAEoCRIOCgZzb3VyY2UYAyABKAki3QIKClNvdXJjZVR5cGUSDQoFbGFiZWwYASABKAkSLwoGZmllbGRzGAIgAygLMh8uYXBwcy52MS5Tb3VyY2VUeXBlLkZpZWxkc0VudHJ5EhMKC25hdHVyYWxfa2V5GAMgASgJEhAKCG9wdGlvbmFsGAQgASgIEhYKDnByb3Zpc2lvbl9vbmx5GAUgASgIEisKCXJlZmVyZW5jZRgGIAEoCzIYLmFwcHMudjEuU291cmNlUmVmZXJlbmNlEiQKBW93bmVyGAcgASgOMhUuYXBwcy52MS5TdG9yYWdlT3duZXISEwoLcmVjb21tZW5kZWQYCCABKAgSDgoGbG9va3VwGAkgAygJEhAKCGlkZW50aXR5GAogASgJGkYKC0ZpZWxkc0VudHJ5EgsKA2tleRgBIAEoCRImCgV2YWx1ZRgCIAEoCzIXLmFwcHMudjEuU3VnZ2VzdGVkRmllbGQ6AjgBIqoCCg9Tb3VyY2VSZWZlcmVuY2USEQoJa2V5X2ZpZWxkGAEgASgJEjQKBmZpZWxkcxgCIAMoCzIkLmFwcHMudjEuU291cmNlUmVmZXJlbmNlLkZpZWxkc0VudHJ5EhAKCGVuZHBvaW50GAMgASgJEg0KBWhvc3RzGAQgAygJEioKB3JlcXVlc3QYBSABKAsyGS5hcHBzLnYxLlJlZmVyZW5jZVJlcXVlc3QSGgoScmVzcG9uc2VfdHJhbnNmb3JtGAYgASgJEh0KFWNhY2hlX21heF9hZ2Vfc2Vjb25kcxgHIAEoBRpGCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSJgoFdmFsdWUYAiABKAsyFy5hcHBzLnYxLlN1Z2dlc3RlZEZpZWxkOgI4ASKzAgoQUmVmZXJlbmNlUmVxdWVzdBIOCgZtZXRob2QYASABKAkSFQoNcGF0aF90ZW1wbGF0ZRgCIAEoCRIzCgVxdWVyeRgDIAMoCzIkLmFwcHMudjEuUmVmZXJlbmNlUmVxdWVzdC5RdWVyeUVudHJ5EjcKB2hlYWRlcnMYBCADKAsyJi5hcHBzLnYxLlJlZmVyZW5jZVJlcXVlc3QuSGVhZGVyc0VudHJ5EhUKDWJvZHlfdGVtcGxhdGUYBSABKAkSFQoNYmF0Y2hfY2FwYWJsZRgGIAEoCBosCgpRdWVyeUVudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEaLgoMSGVhZGVyc0VudHJ5EgsKA2tleRgBIAEoCRINCgV2YWx1ZRgCIAEoCToCOAEiugEKDFNpbmtDb250cmFjdBINCgVsYWJlbBgBIAEoCRIxCgZmaWVsZHMYAiADKAsyIS5hcHBzLnYxLlNpbmtDb250cmFjdC5GaWVsZHNFbnRyeRITCgtuYXR1cmFsX2tleRgDIAEoCRIQCghvcHRpb25hbBgEIAEoCBpBCgtGaWVsZHNFbnRyeRILCgNrZXkYASABKAkSIQoFdmFsdWUYAiABKAsyEi5hcHBzLnYxLlNpbmtGaWVsZDoCOAEilgIKDlN1Z2dlc3RlZEZpZWxkEgwKBHR5cGUYASABKAkSEgoFbGFiZWwYAiABKAlIAIgBARIVCghyZXF1aXJlZBgDIAEoCEgBiAEBEiwKBmNvbmZpZxgEIAEoCzIXLmdvb2dsZS5wcm90b2J1Zi5TdHJ1Y3RIAogBARI9ChNzdXBwb3J0ZWRfZGlyZWN0aW9uGAUgASgOMhsuYXBwcy52MS5GaWVsZFN5bmNEaXJlY3Rpb25IA4gBARIWCglxdWVyeWFibGUYBiABKAhIBIgBAUIICgZfbGFiZWxCCwoJX3JlcXVpcmVkQgkKB19jb25maWdCFgoUX3N1cHBvcnRlZF9kaXJlY3Rpb25CDAoKX3F1ZXJ5YWJsZSJbCglTaW5rRmllbGQSDAoEdHlwZRgBIAEoCRIQCghzZW1hbnRpYxgCIAEoCRIQCghyZXF1aXJlZBgDIAEoCBISCgVsYWJlbBgEIAEoCUgAiAEBQggKBl9sYWJlbCKYBAoJT3BlcmF0aW9uEjoKA2tleRgBIAEoCUItukgqcigYQDIkXihfXyk/W2Etel1bYS16MC05X10qKC1bYS16MC05X10rKSokEgwKBG5hbWUYAiABKAkSGAoLZGVzY3JpcHRpb24YAyABKAlIAIgBARIdCghlbmRwb2ludBgEIAEoCUILukgIcgYyBF4vLioSFAoMY2FwYWJpbGl0aWVzGAUgAygJEhcKCnRpbWVvdXRfbXMYBiABKAVIAYgBARIyCgxyZXRyeV9wb2xpY3kYByABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SAKIAQESMgoMaW5wdXRfc2NoZW1hGAggASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdEgDiAEBEjMKDW91dHB1dF9zY2hlbWEYCSABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SASIAQESGwoOc3VwcG9ydHNfYXN5bmMYCiABKAhIBYgBARIZChFydW5fYWZ0ZXJfaW5zdGFsbBgLIAEoCBIeChZydW5zX29uX21hcHBlZF9zb3VyY2VzGAwgASgIQg4KDF9kZXNjcmlwdGlvbkINCgtfdGltZW91dF9tc0IPCg1fcmV0cnlfcG9saWN5Qg8KDV9pbnB1dF9zY2hlbWFCEAoOX291dHB1dF9zY2hlbWFCEQoPX3N1cHBvcnRzX2FzeW5jIj0KFk1hbmlmZXN0Q2FwYWJpbGl0eVNwZWMSEAoIZGVjbGFyZXMYASABKAkSEQoJcGVyX21vZGVsGAIgASgIIqYBCgRIb29rEjMKA2tleRgBIAEoCUImukgjciEYQDIdXlthLXpdW2EtejAtOV0qKC1bYS16MC05XSspKiQSDAoEbmFtZRgCIAEoCRIWCgVldmVudBgDIAEoCUIHukgEcgIQARInCgZmaWx0ZXIYBCABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0EhoKCW9wZXJhdGlvbhgFIAEoCUIHukgEcgIQASI3CgJVSRIxCgpwbGFjZW1lbnRzGAEgAygLMh0uYXBwcy52MS5QbGFjZW1lbnREZWNsYXJhdGlvbiK2BAoUUGxhY2VtZW50RGVjbGFyYXRpb24SMwoDa2V5GAEgASgJQia6SCNyIRhAMh1eW2Etel1bYS16MC05XSooLVthLXowLTldKykqJBI0CgZ0YXJnZXQYAiABKA4yJC5hcHBzLnYxLlBsYWNlbWVudERlY2xhcmF0aW9uLlRhcmdldBISCgVtb2RlbBgDIAEoCUgAiAEBEhIKBWZpZWxkGAQgASgJSAGIAQESIQoZZmllbGRfc2VsZWN0ZWRfYXRfaW5zdGFsbBgFIAEoCBIQCgN0YWIYBiABKAlIAogBARIWCgV0aXRsZRgHIAEoCUIHukgEcgIQARIfCgN1cmwYCCABKAlCErpID3INMgteaHR0cHM6Ly8uKxIdChByZXBsYWNlc19kZWZhdWx0GAkgASgISAOIAQESKwoFaGludHMYCiABKAsyFy5nb29nbGUucHJvdG9idWYuU3RydWN0SASIAQESDgoGc2NvcGVzGAsgAygJEhAKCHNldHRpbmdzGAwgAygJInQKBlRhcmdldBIWChJUQVJHRVRfVU5TUEVDSUZJRUQQABIUChBUQVJHRVRfRlVMTF9QQUdFEAESFgoSVEFSR0VUX01BSU5fRURJVE9SEAISEgoOVEFSR0VUX1NJREVCQVIQAxIQCgxUQVJHRVRfRklFTEQQBEIICgZfbW9kZWxCCAoGX2ZpZWxkQgYKBF90YWJCEwoRX3JlcGxhY2VzX2RlZmF1bHRCCAoGX2hpbnRzKqwBChJGaWVsZFN5bmNEaXJlY3Rpb24SJAogRklFTERfU1lOQ19ESVJFQ1RJT05fVU5TUEVDSUZJRUQQABIkCiBGSUVMRF9TWU5DX0RJUkVDVElPTl9TT1VSQ0VfT05MWRABEiIKHkZJRUxEX1NZTkNfRElSRUNUSU9OX1NJTktfT05MWRACEiYKIkZJRUxEX1NZTkNfRElSRUNUSU9OX0JJRElSRUNUSU9OQUwQAyqEAQoMU3RvcmFnZU93bmVyEh0KGVNUT1JBR0VfT1dORVJfVU5TUEVDSUZJRUQQABIZChVTVE9SQUdFX09XTkVSX1BST0pFQ1QQARIeChpTVE9SQUdFX09XTkVSX09SR0FOSVpBVElPThACEhoKFlNUT1JBR0VfT1dORVJfQ1VTVE9NRVIQAyqUCwoSTWFuaWZlc3RDYXBhYmlsaXR5EiMKH01BTklGRVNUX0NBUEFCSUxJVFlfVU5TUEVDSUZJRUQQABI2Ch9NQU5JRkVTVF9DQVBBQklMSVRZX0NPTkZJR19SRUFEEAEaEZqmHQ0KC2NvbmZpZzpyZWFkEkAKJE1BTklGRVNUX0NBUEFCSUxJVFlfU0VDUkVUU19SRUFEX0FQUBACGhaaph0SChBzZWNyZXRzOnJlYWQuYXBwEj4KI01BTklGRVNUX0NBUEFCSUxJVFlfU0VDUkVUU19QVVRfQVBQEAMaFZqmHREKD3NlY3JldHM6cHV0LmFwcBJACiRNQU5JRkVTVF9DQVBBQklMSVRZX1NFQ1JFVFNfTElTVF9BUFAQBBoWmqYdEgoQc2VjcmV0czpsaXN0LmFwcBJECiZNQU5JRkVTVF9DQVBBQklMSVRZX1NFQ1JFVFNfREVMRVRFX0FQUBAFGhiaph0UChJzZWNyZXRzOmRlbGV0ZS5hcHASOAogTUFOSUZFU1RfQ0FQQUJJTElUWV9TVEFUVVNfV1JJVEUQBhoSmqYdDgoMc3RhdHVzOndyaXRlEjQKHk1BTklGRVNUX0NBUEFCSUxJVFlfRklMRVNfUkVBRBAHGhCaph0MCgpmaWxlczpyZWFkEjQKHk1BTklGRVNUX0NBUEFCSUxJVFlfRklMRVNfTElTVBAIGhCaph0MCgpmaWxlczpsaXN0EjYKH01BTklGRVNUX0NBUEFCSUxJVFlfRklMRVNfV1JJVEUQCRoRmqYdDQoLZmlsZXM6d3JpdGUSPgojTUFOSUZFU1RfQ0FQQUJJTElUWV9DVVNUT01FUlNfV1JJVEUQChoVmqYdEQoPY3VzdG9tZXJzOndyaXRlEkYKJ01BTklGRVNUX0NBUEFCSUxJVFlfTk9USUZJQ0FUSU9OU19XUklURRALGhmaph0VChNub3RpZmljYXRpb25zOndyaXRlEkoKKU1BTklGRVNUX0NBUEFCSUxJVFlfTk9USUZJQ0FUSU9OU19DT05TRU5UEAwaG5qmHRcKFW5vdGlmaWNhdGlvbnM6Y29uc2VudBI8CiJNQU5JRkVTVF9DQVBBQklMSVRZX0NVU1RPTUVSU19SRUFEEA4aFJqmHRAKDmN1c3RvbWVyczpyZWFkEkgKKE1BTklGRVNUX0NBUEFCSUxJVFlfQ1VTVE9NRVJTX1JFQURfTkFNRUQQDxoamqYdFgoUY3VzdG9tZXJzOnJlYWQ6bmFtZWQSTgorTUFOSUZFU1RfQ0FQQUJJTElUWV9DVVNUT01FUlNfUkVBRF9JREVOVElUWRAQGh2aph0ZChdjdXN0b21lcnM6cmVhZDppZGVudGl0eRJGCidNQU5JRkVTVF9DQVBBQklMSVRZX09QRVJBVElPTlNfQ09NUExFVEUQDRoZmqYdFQoTb3BlcmF0aW9uczpjb21wbGV0ZRI7CiBNQU5JRkVTVF9DQVBBQklMSVRZX1JFQ09SRFNfUkVBRBAUGhWaph0RCg1yZWNvcmRzOnJlYWQ6EAESPQohTUFOSUZFU1RfQ0FQQUJJTElUWV9SRUNPUkRTX1dSSVRFEBUaFpqmHRIKDnJlY29yZHM6d3JpdGU6EAESPwoiTUFOSUZFU1RfQ0FQQUJJTElUWV9SRUNPUkRTX0RFTEVURRAWGheaph0TCg9yZWNvcmRzOmRlbGV0ZToQARJBCiNNQU5JRkVTVF9DQVBBQklMSVRZX1JFQ09SRFNfUFVCTElTSBAXGhiaph0UChByZWNvcmRzOnB1Ymxpc2g6EAESRQolTUFOSUZFU1RfQ0FQQUJJTElUWV9SRUNPUkRTX0FUVFJJQlVURRAYGhqaph0WChJyZWNvcmRzOmF0dHJpYnV0ZToQATp1ChNtYW5pZmVzdF9jYXBhYmlsaXR5EiEuZ29vZ2xlLnByb3RvYnVmLkVudW1WYWx1ZU9wdGlvbnMY49QDIAEoCzIfLmFwcHMudjEuTWFuaWZlc3RDYXBhYmlsaXR5U3BlY1ISbWFuaWZlc3RDYXBhYmlsaXR5QjVaM2dpdGh1Yi5jb20vZWlkZXN0dWRpby9mb2lyL2dlbi9wcm90by9hcHBzL3YxO2FwcHN2MWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_descriptor2, file_google_protobuf_struct]);
12
12
  var AppConfigSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 0);
13
- var FeatureSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 1);
14
- var MetadataSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 2);
15
- var MiddlewareSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 3);
16
- var CredentialsSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 4);
17
- var Credentials_StrategySchema = /* @__PURE__ */ enumDesc2(file_apps_v1_apps, 4, 0);
13
+ var CustomerSignInSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 1);
14
+ var FeatureSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 2);
15
+ var MetadataSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 3);
16
+ var MiddlewareSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 4);
17
+ var CredentialsSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 5);
18
+ var Credentials_StrategySchema = /* @__PURE__ */ enumDesc2(file_apps_v1_apps, 5, 0);
18
19
  var Credentials_Strategy = /* @__PURE__ */ tsEnum2(Credentials_StrategySchema);
19
- var OAuthConfigSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 5);
20
- var InboundSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 6);
21
- var WebhookSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 7);
22
- var AccountWebhookSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 8);
23
- var SourceTypeSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 9);
24
- var SourceReferenceSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 10);
25
- var ReferenceRequestSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 11);
26
- var SinkContractSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 12);
27
- var SuggestedFieldSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 13);
28
- var SinkFieldSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 14);
29
- var OperationSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 15);
30
- var ManifestCapabilitySpecSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 16);
31
- var HookSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 17);
32
- var UISchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 18);
33
- var PlacementDeclarationSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 19);
34
- var PlacementDeclaration_TargetSchema = /* @__PURE__ */ enumDesc2(file_apps_v1_apps, 19, 0);
20
+ var OAuthConfigSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 6);
21
+ var InboundSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 7);
22
+ var WebhookSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 8);
23
+ var AccountWebhookSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 9);
24
+ var SourceTypeSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 10);
25
+ var SourceReferenceSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 11);
26
+ var ReferenceRequestSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 12);
27
+ var SinkContractSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 13);
28
+ var SuggestedFieldSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 14);
29
+ var SinkFieldSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 15);
30
+ var OperationSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 16);
31
+ var ManifestCapabilitySpecSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 17);
32
+ var HookSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 18);
33
+ var UISchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 19);
34
+ var PlacementDeclarationSchema = /* @__PURE__ */ messageDesc2(file_apps_v1_apps, 20);
35
+ var PlacementDeclaration_TargetSchema = /* @__PURE__ */ enumDesc2(file_apps_v1_apps, 20, 0);
35
36
  var PlacementDeclaration_Target = /* @__PURE__ */ tsEnum2(PlacementDeclaration_TargetSchema);
36
37
  var FieldSyncDirectionSchema = /* @__PURE__ */ enumDesc2(file_apps_v1_apps, 0);
37
38
  var FieldSyncDirection = /* @__PURE__ */ tsEnum2(FieldSyncDirectionSchema);
@@ -75,6 +76,7 @@ export {
75
76
  CredentialsSchema,
76
77
  Credentials_Strategy,
77
78
  Credentials_StrategySchema,
79
+ CustomerSignInSchema,
78
80
  DiscoveredAccountSchema,
79
81
  DiscoveredCountSchema,
80
82
  DiscoveredFieldGroupSchema,
package/dist/app.js CHANGED
@@ -497,7 +497,7 @@ function createAppClient(config) {
497
497
  "integrationConfig",
498
498
  `query AppInstallConfig {
499
499
  integrationConfig {
500
- name enabled middlewareUrl targetModels webhookUrl config customerEmailUnavailable
500
+ name enabled middlewareUrl targetModels webhookUrl config customerEmailUnavailable customerSignIn
501
501
  sources { key model naturalKey mode fields { appField modelField direction } }
502
502
  sinks { key model naturalKey fields { appField modelField direction } }
503
503
  }
@@ -514,7 +514,8 @@ function createAppClient(config) {
514
514
  settings: got.config ?? {},
515
515
  sources: bindingsOf(got.sources),
516
516
  sinks: bindingsOf(got.sinks),
517
- customerEmailUnavailable: customerEmailUnavailableReasonOf(got.customerEmailUnavailable)
517
+ customerEmailUnavailable: customerEmailUnavailableReasonOf(got.customerEmailUnavailable),
518
+ customerSignIn: typeof got.customerSignIn === "string" && got.customerSignIn !== "" ? got.customerSignIn : null
518
519
  };
519
520
  },
520
521
  async credentials() {
@@ -732,16 +733,6 @@ function createAppClient(config) {
732
733
  customerDeleted: answer.customerDeleted === true,
733
734
  customerKeptBecause: answer.customerKeptBecause ?? null
734
735
  };
735
- },
736
- async identities(model, customerId) {
737
- if (customerId === "") refuse(`a customer's identities on ${model} were asked for with no customer`);
738
- const answer = await field(
739
- "customerIdentitiesById",
740
- `query AppCustomerIdentities($modelKey: String!, $customerId: ID!) { customerIdentitiesById(modelKey: $modelKey, customerId: $customerId) }`,
741
- { modelKey: model, customerId }
742
- );
743
- if (!Array.isArray(answer)) return refuse(`customerIdentitiesById reported no error and returned no list on ${model}`);
744
- return answer.filter((identity) => typeof identity === "string");
745
736
  }
746
737
  }
747
738
  },
@@ -776,6 +767,25 @@ function createAppClient(config) {
776
767
  );
777
768
  return customerOf(customer);
778
769
  },
770
+ async byIdentity(identity) {
771
+ if (identity === "") refuse("a customer was asked for with no identity");
772
+ const answer = await field(
773
+ "customerByIdentity",
774
+ `query AppCustomerByIdentity($identity: String!) { customerByIdentity(identity: $identity) }`,
775
+ { identity }
776
+ );
777
+ return typeof answer === "string" && answer !== "" ? answer : null;
778
+ },
779
+ async identities(customerId) {
780
+ if (customerId === "") refuse("a customer's identities were asked for with no customer");
781
+ const answer = await field(
782
+ "customerIdentitiesById",
783
+ `query AppCustomerIdentities($customerId: ID!) { customerIdentitiesById(customerId: $customerId) }`,
784
+ { customerId }
785
+ );
786
+ if (!Array.isArray(answer)) return refuse("customerIdentitiesById reported no error and returned no list");
787
+ return answer.filter((identity) => typeof identity === "string");
788
+ },
779
789
  async list(query = {}) {
780
790
  const search = query.search?.trim() ?? "";
781
791
  const page = await field(
@@ -825,9 +835,11 @@ function createAppClient(config) {
825
835
  if (sent !== true) refuse("sendNotification reported no error and did not say the notification was sent");
826
836
  },
827
837
  async setConsent(change) {
828
- 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");
829
841
  const input = {
830
- customerId: change.customerId,
842
+ [subject.by]: subject.value,
831
843
  category: change.category,
832
844
  channel: change.channel,
833
845
  enabled: change.enabled
@@ -876,6 +888,12 @@ function createAppClient(config) {
876
888
  }
877
889
  };
878
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
+ }
879
897
  function directionOf(raw) {
880
898
  const name = (raw ?? "").replace(/^FIELD_SYNC_DIRECTION_/, "");
881
899
  return name === "SOURCE_ONLY" || name === "BIDIRECTIONAL" ? name : "SINK_ONLY";
@@ -1137,6 +1155,7 @@ var APP_CAPABILITIES = [
1137
1155
  "notifications:consent",
1138
1156
  "customers:read",
1139
1157
  "customers:read:named",
1158
+ "customers:read:identity",
1140
1159
  "operations:complete"
1141
1160
  ];
1142
1161
  var APP_RECORD_CAPABILITY_PREFIXES = [
@@ -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.17.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": {