@foir/sdk 0.7.0 → 0.9.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +857 -4
  2. package/README.md +46 -15
  3. package/dist/auth/auth.d.ts +104 -1
  4. package/dist/auth/hosted.d.ts +22 -0
  5. package/dist/auth/index.d.ts +13 -7
  6. package/dist/auth/operations.d.ts +85 -64
  7. package/dist/auth/ops.d.ts +99 -0
  8. package/dist/auth.js +786 -106
  9. package/dist/chunk-IGNLWT3H.js +35 -0
  10. package/dist/chunk-OHYVKTGB.js +45 -0
  11. package/dist/chunk-TAL5H6VA.js +39 -0
  12. package/dist/client.js +25 -31
  13. package/dist/codegen/block-registry.d.ts +13 -19
  14. package/dist/codegen/block-registry.js +3 -31
  15. package/dist/codegen/block-scaffold.d.ts +12 -21
  16. package/dist/codegen/block-scaffold.js +3 -41
  17. package/dist/codegen/index.d.ts +9 -4
  18. package/dist/codegen/index.js +7 -11
  19. package/dist/codegen/item-registry.d.ts +28 -0
  20. package/dist/codegen/item-registry.js +7 -0
  21. package/dist/codegen/item-scaffold.d.ts +29 -0
  22. package/dist/codegen/item-scaffold.js +7 -0
  23. package/dist/codegen/preset.d.ts +14 -7
  24. package/dist/codegen/shared.d.ts +20 -9
  25. package/dist/content/content-renderer.d.ts +90 -24
  26. package/dist/content/index.d.ts +13 -7
  27. package/dist/content/richtext.d.ts +66 -18
  28. package/dist/content.js +227 -80
  29. package/dist/data/builtins.d.ts +369 -92
  30. package/dist/data/client.d.ts +97 -25
  31. package/dist/data/document.d.ts +251 -0
  32. package/dist/data/index.d.ts +38 -19
  33. package/dist/data/manifest.d.ts +290 -8
  34. package/dist/data/schema.d.ts +78 -8
  35. package/dist/data/selection.d.ts +231 -37
  36. package/dist/data/subjects.d.ts +221 -0
  37. package/dist/data/subscription-document.d.ts +75 -0
  38. package/dist/data/subscriptions.d.ts +181 -0
  39. package/dist/data/transport.d.ts +255 -37
  40. package/dist/data/ws-transport.d.ts +197 -0
  41. package/dist/data.js +1419 -45
  42. package/dist/graphql/cdn-route.d.ts +12 -28
  43. package/dist/media/index.d.ts +6 -2
  44. package/dist/media/media.d.ts +2 -2
  45. package/dist/media/screen-capture.d.ts +81 -0
  46. package/dist/media.js +97 -0
  47. package/dist/next.js +663 -182
  48. package/dist/push.js +700 -90
  49. package/dist/react/FoirFeedback.d.ts +62 -0
  50. package/dist/react/feedback-notes.d.ts +101 -0
  51. package/dist/react/index.d.ts +7 -1
  52. package/dist/react.js +1272 -194
  53. package/dist/realtime.js +1 -1
  54. package/dist/server/events-client.d.ts +279 -0
  55. package/dist/server/index.d.ts +4 -0
  56. package/dist/server.js +298 -0
  57. package/dist/transport/foir-http.d.ts +23 -6
  58. package/dist/transport/persisted-query.d.ts +68 -0
  59. package/dist/transport/retry.d.ts +1 -1
  60. package/package.json +13 -1
  61. package/dist/chunk-ADO5FJNM.js +0 -30
@@ -20,9 +20,9 @@ export interface FoirManifestScalar {
20
20
  /**
21
21
  * GraphQL named type: a scalar (`String`, `Float`, `Boolean`, `ID`,
22
22
  * `DateTime`, `JSON`), a generated enum (then `enumValues` is set), or one of
23
- * the closed set of platform-owned value objects (`ImageValue`, `VideoValue`,
24
- * `FileValue`, `MediaValue`, `RichtextValue`, `GeoPointValue`,
25
- * `ReferenceValue`, `CollabInfo`) — see `builtins.ts`.
23
+ * the closed set of platform-owned value objects (`Image`, `Video`,
24
+ * `FileValue`, `MediaValue`, `Richtext`, `GeoPoint`,
25
+ * `Reference`, `CollabInfo`) — see `builtins.ts`.
26
26
  */
27
27
  type: string;
28
28
  /**
@@ -72,8 +72,89 @@ export interface FoirManifestOp {
72
72
  /** GraphQL root field this op compiles to, e.g. `tillyBlocks`. */
73
73
  field: string;
74
74
  args?: string[];
75
+ /**
76
+ * Each name in `args` mapped to the GraphQL type the root field declares for
77
+ * it — `'String!'`, `'[PageOrderByInput]'`, `'SortDirection'` — spelled
78
+ * exactly as a variable definition needs it.
79
+ *
80
+ * This is what lets the printer emit `query FoirQuery($orderBy: [PageOrderByInput])`
81
+ * and put the value in `variables`, where the executor coerces it. Without
82
+ * the type a printer has to infer a LITERAL from the JavaScript value, which
83
+ * is exact for strings, numbers, booleans and objects and ambiguous for
84
+ * enums — the client holds the string `'DESC'` and `orderBy: { createdAt: DESC }`
85
+ * needs it unquoted.
86
+ *
87
+ * An argument named in `args` with no entry here has no type this projection
88
+ * could read. The printer REFUSES such a call: inferring the literal form
89
+ * from the value is the defect this field exists to remove, so falling back
90
+ * to it would reintroduce it under a condition nobody can see.
91
+ */
92
+ argTypes?: Record<string, string>;
75
93
  /** True when the field lives on the Mutation root. */
76
94
  mutation?: boolean;
95
+ /**
96
+ * The resolved shape of this op's RESULT.
97
+ *
98
+ * The model's own `selection` answers for `findUnique` and for nothing else:
99
+ * `findMany` returns a connection OVER the model, `search` returns a wrapper
100
+ * CARRYING it, `delete` returns `Boolean!`, and `myAccess` returns a payload
101
+ * that is not the model in any sense. A consumer that bridged that gap by
102
+ * knowing from the OP NAME which shape to print was carrying a schema under
103
+ * another name.
104
+ *
105
+ * Absent is not "no selection". A consumer must refuse the op rather than
106
+ * synthesise a shape for it.
107
+ */
108
+ payload?: FoirManifestOpPayload;
109
+ }
110
+ /**
111
+ * The resolved shape of one op's result — four kinds, and the printer handles
112
+ * all four rather than defaulting.
113
+ */
114
+ export interface FoirManifestOpPayload {
115
+ /**
116
+ * - `record` — the payload IS the model named by `model`; print that model's
117
+ * published `selection` as the root field's selection set.
118
+ * - `object` — `fields` is the resolved selection set. A field carrying
119
+ * `model` expands to that model's `selection`, which is how the connection
120
+ * and search-result wrappers keep the record's tree in one place instead of
121
+ * copying it per op.
122
+ * - `union` — print one inline fragment per member of `selectionUnions[union]`.
123
+ * - `leaf` — `Boolean!`, `Int`, an enum. NO selection set: emitting `{}` is a
124
+ * parse error, which is why this is a stated kind rather than empty `fields`.
125
+ */
126
+ kind: 'record' | 'object' | 'union' | 'leaf';
127
+ /** Model key, for kind `record`. */
128
+ model?: string;
129
+ /** `selectionUnions` key, for kind `union`. */
130
+ union?: string;
131
+ /** The resolved selection, for kind `object`. */
132
+ fields?: FoirManifestSelectionField[];
133
+ /** What this payload emits when printed whole, in `FoirManifestSelection.cost`'s unit. */
134
+ cost?: number;
135
+ /**
136
+ * Why this payload could not be resolved. Set INSTEAD of the shape fields,
137
+ * never beside them — an op carrying an error has no printable shape and a
138
+ * consumer must refuse it.
139
+ */
140
+ error?: string;
141
+ }
142
+ /**
143
+ * A platform-owned structural field: a prose wrapper, or the hierarchy walk's
144
+ * `ancestors`. Nested and always returned, like a composite — but there is no
145
+ * model behind it, so the entry names its TYPE (`Prose`, `ProseCta`,
146
+ * `RecordAncestor`) and the shape is `builtins.ts`'s.
147
+ *
148
+ * ABSENT on a server that predates the bucket, where these fields landed in
149
+ * `unclassifiedFields` and dropped out of the selection entirely. Widen on
150
+ * absent, as everywhere else in this file.
151
+ */
152
+ export interface FoirManifestStructural {
153
+ key: string;
154
+ type: string;
155
+ list: boolean;
156
+ nullable: boolean;
157
+ deprecated?: boolean;
77
158
  }
78
159
  /** A built field the projection has no bucket for. Reported rather than dropped. */
79
160
  export interface FoirManifestUnclassifiedField {
@@ -95,19 +176,62 @@ export interface FoirManifestModel {
95
176
  * survived pruning — hence optional, not `{}`.
96
177
  */
97
178
  ops?: Record<string, FoirManifestOp>;
179
+ /**
180
+ * The resolved complete-by-default selection for this model. Present on a
181
+ * server that publishes selection trees; absent otherwise.
182
+ *
183
+ * It hangs off the MODEL rather than each op because every op whose payload
184
+ * is this model shares one selection root.
185
+ */
186
+ selection?: FoirManifestSelection;
187
+ /** Why `selection` is absent, when the server could not resolve one. */
188
+ selectionError?: string;
98
189
  scalars: FoirManifestScalar[];
99
190
  relations: FoirManifestRelation[];
100
191
  composites: FoirManifestComposite[];
192
+ /** Platform-owned structural fields — prose wrappers, `ancestors`. */
193
+ structurals?: FoirManifestStructural[];
101
194
  /**
102
- * Block-union field key → member model keys. Members that are built-in block
103
- * primitives appear under their lowercase token (`text`, `image`, `video`,
104
- * `file`, `richtext`, `media`, `reference`, `flexible`) and have NO entry in
105
- * `models` — they are platform-owned, identical in every project, and typed
106
- * statically by `builtins.ts`.
195
+ * Union field key → member model keys. Members that are platform primitives
196
+ * appear under their lowercase token (`richtext`, `image`, `video`, `file`,
197
+ * `reference`, `geopoint`) and have NO entry in `models` — they are
198
+ * platform-owned, identical in every project, and typed statically by
199
+ * `builtins.ts`.
107
200
  */
108
201
  unions?: Record<string, string[]>;
109
202
  /** Op names of this model's typed lookups. Each also appears in `ops`. */
110
203
  lookups?: string[];
204
+ /**
205
+ * The model's `<Type>OrderByInput`, projected — the vocabulary of the
206
+ * `orderBy` argument.
207
+ *
208
+ * `fields` are the entry keys a sort element may carry; `directions` are the
209
+ * value names of the enum those entries are typed as (`ASC` / `DESC`). Both
210
+ * are read off the built input object server-side, so neither can disagree
211
+ * with what the executor coerces a caller's `orderBy` against.
212
+ *
213
+ * IT IS PUBLISHED BECAUSE NOTHING ELSE HERE ANSWERS FOR IT, and `scalars` —
214
+ * the field that looks like it should — is wrong in both directions at once:
215
+ *
216
+ * - wrong SET: `hero` (`Image`), `body` (`Richtext`), `_segments`
217
+ * (`JSON`) and every soft-deleted field are scalars and none is sortable;
218
+ * - wrong NAMES: the platform-managed columns are `_id` / `_createdAt` /
219
+ * `_updatedAt` / `_naturalKey` on the record type and `id` / `createdAt` /
220
+ * `updatedAt` / `naturalKey` in `OrderByInput`. A consumer deriving sort
221
+ * keys from the scalar list emits `_createdAt`, which the server refuses.
222
+ *
223
+ * `ops[op].argTypes.orderBy` names the input type (`[PageOrderByInput]`) and
224
+ * stops there: enough to print a variable definition, not enough to type a
225
+ * call site.
226
+ *
227
+ * ABSENT means "this projection has nothing to say" — an embedded model, or a
228
+ * server that predates the field. A consumer must WIDEN on absent, never read
229
+ * it as "nothing is sortable".
230
+ */
231
+ orderBy?: {
232
+ fields: string[];
233
+ directions: string[];
234
+ };
111
235
  unclassifiedFields?: FoirManifestUnclassifiedField[];
112
236
  cacheControl?: {
113
237
  scope: string;
@@ -133,6 +257,20 @@ export interface FoirManifestNamespace {
133
257
  execute?: string[];
134
258
  /** `operations` only: operation keys reachable as `query.<key>`. */
135
259
  query?: string[];
260
+ /**
261
+ * Each of those root fields described the way a model op is described:
262
+ * arguments with their types, and the resolved shape of the result.
263
+ *
264
+ * Keyed by the token the op is ADDRESSED by — the root field name for every
265
+ * namespace but `operations`, where it is `execute.<key>` / `query.<key>` for
266
+ * the per-operation surfaces and the plain field name for the bookkeeping
267
+ * ones. The lists above stay authoritative for REACHABILITY; this is the
268
+ * description of what those names do.
269
+ *
270
+ * Because an entry is a `FoirManifestOp`, whatever prints a model op prints a
271
+ * namespace op — one code path, not two.
272
+ */
273
+ ops?: Record<string, FoirManifestOp>;
136
274
  }
137
275
  export interface FoirManifest {
138
276
  schemaVersion: number;
@@ -149,7 +287,151 @@ export interface FoirManifest {
149
287
  unclassified?: {
150
288
  queries?: string[];
151
289
  mutations?: string[];
290
+ /**
291
+ * Subscription root fields the server declined to state the reachability
292
+ * of — today only `events`, whose scope is charged against the resolved
293
+ * data-plane authority rather than the api key's own scope set. Reported so
294
+ * the gap is visible; nothing is generated from it.
295
+ */
296
+ subscriptions?: string[];
152
297
  };
298
+ /**
299
+ * Each union's expansion, once, keyed by union type name. A selection field
300
+ * references one by name rather than inlining it — seven models reaching one
301
+ * 56-member block union inlined it seven times and made the manifest 6.9x
302
+ * larger, 85% of it duplication.
303
+ *
304
+ * Absent on a server that predates selection trees, which is why every
305
+ * consumer must treat it as optional rather than assume the shape.
306
+ */
307
+ selectionUnions?: Record<string, FoirManifestSelectionUnion>;
308
+ /**
309
+ * Subscription root fields this credential may open over `/graphql/ws`.
310
+ *
311
+ * Unlike `models[].ops`, this list is NOT derived from the caller's schema
312
+ * variant: the server copies the whole Subscription root into every variant
313
+ * (the transport is out of band), so the variant admits all of them to
314
+ * everybody. The list is projected from the same table the socket charges at
315
+ * subscribe time, which is what makes it safe to generate against — a field
316
+ * that is here will be accepted, and one that is absent will be refused.
317
+ */
318
+ subscriptions?: FoirManifestSubscription[];
319
+ }
320
+ /**
321
+ * One subscription root field, with the tree a document is printed from.
322
+ */
323
+ export interface FoirManifestSubscription {
324
+ /** The Subscription root field name. */
325
+ field: string;
326
+ /** Argument names, sorted. */
327
+ args?: string[];
328
+ /**
329
+ * Each name in `args` mapped to the GraphQL type the field declares for it —
330
+ * `'String!'` — spelled exactly as a variable definition needs it, and for
331
+ * the same reason `FoirManifestOp.argTypes` exists: a subscribe frame carries
332
+ * a `variables` object like any other operation, so without the type a
333
+ * consumer would have to infer a literal form from the value it holds.
334
+ *
335
+ * It also decides the variable NAME. The socket resolves an argument-gated
336
+ * subscription's scope by reading `variables[<arg>]` BY NAME, so
337
+ * `subscription S($m: String!) { recordChanged(modelKey: $m) … }` opens and is
338
+ * then refused for a scope it cannot complete. Declaring `$<arg>` — which is
339
+ * what a printer driven by this map does — is what keeps the two in step.
340
+ */
341
+ argTypes?: Record<string, string>;
342
+ /** GraphQL named type of the event payload. */
343
+ type: string;
344
+ /**
345
+ * The scope the socket charges. Empty exactly when `argScope` is set. Carried
346
+ * so a client can say WHICH capability a refusal was about instead of
347
+ * reporting a bare failure.
348
+ */
349
+ scope?: string;
350
+ /** Set when the required scope is completed by a subscribe argument. */
351
+ argScope?: FoirManifestSubscriptionArgScope;
352
+ /** Resolved complete-by-default selection for the payload type. */
353
+ selection?: FoirManifestSelection;
354
+ /** Why `selection` is absent. Absent is not "no fields". */
355
+ selectionError?: string;
356
+ }
357
+ /**
358
+ * A subscription whose authorization depends on a value supplied at subscribe
359
+ * time.
360
+ *
361
+ * `recordChanged` is the one instance: the socket charges
362
+ * `records:read:<modelKey>` against the `modelKey` variable, so the field has
363
+ * no static yes/no. What is statically true is which values this credential's
364
+ * scopes admit, and that is what `values` carries.
365
+ */
366
+ export interface FoirManifestSubscriptionArgScope {
367
+ /** The subscribe argument whose value completes the scope. */
368
+ arg: string;
369
+ /** What the argument's value is appended to. */
370
+ prefix: string;
371
+ /**
372
+ * The argument values this credential admits, sorted. The ADDRESSABLE set,
373
+ * not a proof of exhaustiveness — a key holding the two-part `records:read`
374
+ * satisfies the socket for any string at all.
375
+ */
376
+ values: string[];
377
+ }
378
+ /**
379
+ * A resolved complete-by-default selection: what to ask for, already expanded.
380
+ *
381
+ * The server resolves this once per schema version — unions expanded per
382
+ * member, response-shape aliases assigned, cycles broken, field budget
383
+ * counted — so printing a document is string building rather than a schema
384
+ * walk. Nothing here requires the consumer to know a field's GraphQL type.
385
+ */
386
+ export interface FoirManifestSelection {
387
+ fields: FoirManifestSelectionField[];
388
+ /**
389
+ * Fields this tree emits when printed whole, relations excluded. Same unit
390
+ * the executor's complexity budget is denominated in.
391
+ */
392
+ cost: number;
393
+ }
394
+ export interface FoirManifestSelectionField {
395
+ key: string;
396
+ /**
397
+ * The response key to emit instead of `key`, set only where a union member's
398
+ * field name collides with an incompatible shape on another member. A
399
+ * consumer prints `alias: key` and types the result under `alias`.
400
+ */
401
+ alias?: string;
402
+ /** Nested selection for a composite or a builtin value object. */
403
+ fields?: FoirManifestSelectionField[];
404
+ /**
405
+ * Names a model whose published `selection` IS this field's selection.
406
+ *
407
+ * It appears where a record turns up inside a payload the caller did not
408
+ * address the record through: a connection's `node`, a search result's
409
+ * `record`. A REFERENCE rather than an inlined copy, so seven ops over one
410
+ * model do not carry seven copies of its tree — and so a caller's `omit`
411
+ * reaches the same tree whichever op it came through.
412
+ */
413
+ model?: string;
414
+ /** Names an entry in `FoirManifest.selectionUnions`. */
415
+ union?: string;
416
+ /**
417
+ * `include` governs this field, so it is NOT part of the default document.
418
+ * Emit it only when the caller asks for it.
419
+ */
420
+ relation?: boolean;
421
+ /**
422
+ * The cycle or depth rule stopped expansion here. Still selectable, but only
423
+ * as `__typename` — print that rather than an empty body, which is a parse
424
+ * error.
425
+ */
426
+ truncated?: boolean;
427
+ }
428
+ export interface FoirManifestSelectionUnion {
429
+ /** Sorted by type name, so the expansion is stable across runs. */
430
+ members: FoirManifestSelectionMember[];
431
+ }
432
+ export interface FoirManifestSelectionMember {
433
+ typeName: string;
434
+ fields: FoirManifestSelectionField[];
153
435
  }
154
436
  /**
155
437
  * Fetch the manifest for the credential in `config`.
@@ -11,9 +11,20 @@
11
11
  * What the generator emits
12
12
  * ---------------------------------------------------------------------------
13
13
  *
14
- * A single module, `foir.generated.ts`. A `.ts` and not a `.d.ts` for exactly
15
- * one reason: `foirSchemaVersion` has to survive to runtime for drift
16
- * detection. Everything else in the file is types and erases to nothing.
14
+ * A single module, `foir.generated.ts`, PASSED WHOLE:
15
+ *
16
+ * ```ts
17
+ * import * as generated from './foir.generated';
18
+ * const foir = createFoirClient({ apiUrl, apiKey, generated });
19
+ * ```
20
+ *
21
+ * A `.ts` and not a `.d.ts` because the file has a runtime half — the selection
22
+ * trees, `foirSchemaVersion`, and `foirSchemaType`, which is what carries
23
+ * `FoirSchema` across the type/value boundary so the client can infer it (a
24
+ * namespace import carries values; an interface erases). `FoirGeneratedModule`
25
+ * in `transport.ts` is the shape those exports have to satisfy, and it requires
26
+ * all of them: there is no case where a subset is wanted, and accepting one
27
+ * quietly meant a client that fetched the manifest on every cold start instead.
17
28
  *
18
29
  * ```ts
19
30
  * // foir.generated.ts — DO NOT EDIT. Generated by `foir types`.
@@ -23,14 +34,26 @@
23
34
  * export const foirSchemaVersion = 317;
24
35
  * export const foirSchemaChannel = 'published';
25
36
  *
26
- * export interface TillyBlock {
37
+ * // `<D>` ONLY on the types between the record and a union stopping point —
38
+ * // see rule 10. Everything else is emitted without it.
39
+ * export interface TillyBlock<D extends FoirDepthLevel = 1> {
27
40
  * _id: string;
28
41
  * _createdAt: string;
29
42
  * text: string | null;
30
43
  * // Composite/polymorphic fields are emitted as a STATIC discriminated
31
44
  * // union on `__typename`. The client never infers these; it only has to
32
45
  * // avoid destroying them. See `selection.ts`.
33
- * body: Array<HeroBlock | TextBlock> | null;
46
+ * body: Array<HeroBlock<D> | TextBlock> | null;
47
+ * }
48
+ *
49
+ * export interface HeroBlock<D extends FoirDepthLevel = 1> {
50
+ * __typename: 'HeroBlock';
51
+ * headline: string;
52
+ * // The stopping point: a union reached from inside a union member is
53
+ * // already on the op compiler's path, so it degrades — unless the caller
54
+ * // bought a level. Mirrors the printer's `depth - 1` at this position.
55
+ * body: Array<(D extends 1 ? TillyBlockBodyDegraded
56
+ * : TillyBlockBody<FoirDepthBelow[D]>) | null> | null;
34
57
  * }
35
58
  *
36
59
  * export interface TillyBlockFindUniqueArgs {
@@ -54,6 +77,8 @@
54
77
  * tilly_block: {
55
78
  * typeName: 'TillyBlock';
56
79
  * fields: TillyBlock;
80
+ * // Rule 10. Absent on a model with no stopping point, which is most.
81
+ * atDepth: { 2: TillyBlock<2>; 3: TillyBlock<3>; 4: TillyBlock<4> };
57
82
  * relations: {
58
83
  * parentBlockId: { target: 'tilly_block'; list: false; nullable: true };
59
84
  * };
@@ -80,6 +105,10 @@
80
105
  * };
81
106
  * };
82
107
  * }
108
+ *
109
+ * // The type carrier. `FoirSchema` is an interface and erases, so this is the
110
+ * // only thing a namespace import can infer it from. Runtime value: undefined.
111
+ * export const foirSchemaType = undefined as unknown as FoirSchema;
83
112
  * ```
84
113
  *
85
114
  * ---------------------------------------------------------------------------
@@ -87,9 +116,9 @@
87
116
  * ---------------------------------------------------------------------------
88
117
  *
89
118
  * 1. **Model keys are the manifest's model keys verbatim** (`tilly_block`, not
90
- * `tillyBlock`). They are what `POST /v1/query` takes in `model`, and the
91
- * client forwards the property name straight onto the wire. Renaming them
92
- * for aesthetics breaks the request.
119
+ * `tillyBlock`). They are the key the client looks the model's selection
120
+ * tree up by, and the key a refusal names when there is no tree to look up.
121
+ * Renaming them for aesthetics breaks the lookup.
93
122
  *
94
123
  * 2. **Op names are the manifest's op names verbatim** (`findUnique`,
95
124
  * `findMany`, `search`, …) — the canonical names, NOT the GraphQL root
@@ -144,6 +173,27 @@
144
173
  * (as the sketch above does) is the safe shape; if the generator ever
145
174
  * factors these out for readability, they have to be `type X = {…}`.
146
175
  * `__tests__/fixture-schema.ts` is the compiling reference.
176
+ *
177
+ * 10. **The `<D>` level parameter goes on the path to a stopping point, and
178
+ * nowhere else.** A union reached from inside a union member is already on
179
+ * the op compiler's path, so it degrades to `__typename` — and `depth: 2`
180
+ * buys one such level back, on the wire AND in the type. The parameter is
181
+ * what carries the caller's level from the record down to that position,
182
+ * so it belongs on exactly the interfaces, union aliases and member
183
+ * projections between the two, and the model names its shape at each level
184
+ * under `atDepth`.
185
+ *
186
+ * Both halves of "and nowhere else" matter. A parameter on a type that
187
+ * reaches no stopping point is a generic instantiation at every reference
188
+ * that can only ever resolve to the default — measured at 7 interfaces of
189
+ * 50 on the live dev project, which is why the change costs +1.6% types
190
+ * rather than a multiple. And `= 1` on every one of them is what makes a
191
+ * call passing no `depth` resolve to the type it always did; a parameter
192
+ * without a default would be a breaking change to every generated name.
193
+ *
194
+ * The generator computes the set (`depthReach`); it is not a judgement
195
+ * call. See `builtins.ts` for `FoirDepthLevel` / `FoirDepthBelow`, and
196
+ * `selection.ts` for the single lookup that reads `atDepth`.
147
197
  */
148
198
  /**
149
199
  * A model's complete scalar shape. Deliberately loose — the generator writes a
@@ -191,6 +241,26 @@ export interface FoirModelDefinition {
191
241
  readonly typeName: string;
192
242
  /** Complete scalar shape. See contract rule 4. */
193
243
  readonly fields: FoirFieldsShape;
244
+ /**
245
+ * The same shape at a depth ABOVE the default, keyed by the level a caller
246
+ * would pass as `depth`.
247
+ *
248
+ * Present only on models whose tree reaches a union stopping point — a
249
+ * position the op compiler refuses to expand because the union is already on
250
+ * its path. `depth: 2` expands one such level on the wire, and the entry
251
+ * under `2` is the model type describing what then arrives.
252
+ *
253
+ * OPTIONAL, and absent is the common case. A model with no stopping point
254
+ * reads identically at every depth, so generating an entry for it would emit
255
+ * a second name for one type. A LEVEL with no entry — a depth past what the
256
+ * generator emitted, or a `depth` that is not a literal at the call site —
257
+ * falls back to `fields`, which under-promises: the caller gets the values
258
+ * and narrows by hand, exactly as they did before this key existed. That is
259
+ * the safe direction, and the reason absence never has to be an error.
260
+ *
261
+ * Contract rule 9 applies: emit it as an object type LITERAL.
262
+ */
263
+ readonly atDepth?: Readonly<Record<number, FoirFieldsShape>>;
194
264
  readonly relations: Readonly<Record<string, FoirRelationDefinition>>;
195
265
  readonly ops: Readonly<Record<string, FoirOpDefinition>>;
196
266
  }