@instruments/materialgraph 0.10.0 → 0.11.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 (42) hide show
  1. package/browse-types.d.ts +147 -0
  2. package/browse-types.js +1 -0
  3. package/by-colour-types.d.ts +56 -0
  4. package/client-browse.d.ts +13 -3
  5. package/client-browse.js +12 -2
  6. package/client-exploration.d.ts +9 -0
  7. package/client-exploration.js +7 -0
  8. package/client-source-material-comments.d.ts +51 -0
  9. package/client-source-material-comments.js +40 -0
  10. package/client.d.ts +28 -22
  11. package/client.js +33 -24
  12. package/colour-aggregate-types.d.ts +58 -0
  13. package/colours-client.d.ts +8 -2
  14. package/colours-client.js +15 -2
  15. package/composition-client.d.ts +33 -0
  16. package/composition-client.js +27 -0
  17. package/composition-types.d.ts +46 -0
  18. package/coordinate-client.d.ts +23 -0
  19. package/coordinate-client.js +25 -0
  20. package/exploration-types.d.ts +110 -0
  21. package/exploration-types.js +1 -0
  22. package/fan-deck-types.d.ts +4 -1
  23. package/index.d.ts +4 -1
  24. package/operations.d.ts +1 -1
  25. package/operations.js +25 -0
  26. package/package.json +1 -1
  27. package/palette-search-types.d.ts +31 -0
  28. package/run-types.d.ts +132 -0
  29. package/runs.d.ts +35 -1
  30. package/runs.js +61 -1
  31. package/schedule-types.d.ts +128 -0
  32. package/schema-resource-types.d.ts +1 -1
  33. package/search-refinement-types.d.ts +28 -1
  34. package/search-request-types.d.ts +172 -0
  35. package/search-request-types.js +1 -0
  36. package/slot-pool-types.d.ts +370 -0
  37. package/slot-pool-types.js +1 -0
  38. package/source-material-validation.d.ts +5 -1
  39. package/source-material-validation.js +111 -4
  40. package/types.d.ts +149 -199
  41. package/validation.d.ts +12 -0
  42. package/validation.js +23 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Hand-mirrored wire types for the slot-pool surface
3
+ * (`POST /api/v1/schemes/pools`, operation `slot_pools`, MG-1301). Like the rest
4
+ * of the SDK these interfaces carry NO runtime dependency on any
5
+ * `@materialgraph/*` package — they mirror the `SlotPoolsRequest` /
6
+ * `SlotPoolsResponse` shapes in `apps/web/public/openapi.yaml`.
7
+ *
8
+ * THE QUESTION IT ANSWERS: "for each of these slots, what should I be choosing
9
+ * between?" One call, up to 24 slots, one ranked and gated pool each. There is
10
+ * no project and no run — the request is self-contained and the ownership of the
11
+ * work stays with you.
12
+ *
13
+ * THREE THINGS TO READ CORRECTLY, because getting them wrong turns a careful
14
+ * answer into a false one:
15
+ * 1. A pool with a `shortfall` of kind `search-failed` was NOT answered. The
16
+ * other pools in the same response were. Never read that empty pool as
17
+ * "nothing matched".
18
+ * 2. `not_evaluated` and `missing` are not `failed`. The first means the engine
19
+ * did not check the criterion at the tier that ran; the second means the
20
+ * material holds no accepted value for it. Only `failed` means a value
21
+ * exists and does not satisfy the ask.
22
+ * 3. `eligible: false` does not mean "bad". It means this candidate cannot be
23
+ * placed automatically — read `flags` for which of the reasons applies.
24
+ *
25
+ * TOLERANT-PARSING CONTRACT: response enums are widened with {@link OpenEnum}
26
+ * so a new server value still type-checks; objects are read structurally.
27
+ */
28
+ import type { VariantId } from "./identifiers.js";
29
+ import type { OpenEnum } from "./types.js";
30
+ /**
31
+ * One requirement or preference. Request-side, so this is a STRICT union.
32
+ * `attribute` asks for a canonical term, `attribute-presence` merely that the
33
+ * attribute is recorded, `attribute-numeric` a one-sided bound. `unresolved`
34
+ * carries brief language nothing bound to — it is never a filter and never
35
+ * blamed for a coverage shortfall.
36
+ */
37
+ export type SlotPoolCriterion = {
38
+ kind: "attribute";
39
+ attributeId: string;
40
+ term: string;
41
+ raw: string;
42
+ } | {
43
+ kind: "attribute-presence";
44
+ attributeId: string;
45
+ raw: string;
46
+ } | {
47
+ kind: "attribute-numeric";
48
+ attributeId: string;
49
+ comparator: "at-least" | "at-most";
50
+ value: number;
51
+ unit?: string;
52
+ raw: string;
53
+ } | {
54
+ kind: "unresolved";
55
+ raw: string;
56
+ suggestion?: {
57
+ attributeId: string;
58
+ term?: string;
59
+ };
60
+ coverageNote?: string;
61
+ };
62
+ /** A colour cue for a slot. Colours rerank within the retrieved set; they never admit. */
63
+ export interface SlotPoolColourCue {
64
+ /** `#rrggbb`. */
65
+ hex: string;
66
+ name?: string;
67
+ }
68
+ /**
69
+ * What to do with a candidate the catalogue no longer lists. Neither value ever
70
+ * marks such a candidate eligible — the policy decides only whether it is
71
+ * returned at all.
72
+ */
73
+ export type SlotPoolAvailabilityPolicy = "flag-unavailable" | "exclude-unavailable";
74
+ /** One slot of work. Self-contained: nothing in it identifies a project. */
75
+ export interface SlotPoolTask {
76
+ /**
77
+ * YOUR key for this task, opaque to MaterialGraph and returned verbatim on the
78
+ * matching pool. This is how you correlate 24 results; it is never parsed,
79
+ * normalised or reused as a server id. Must be unique within a request.
80
+ */
81
+ taskKey: string;
82
+ /**
83
+ * The catalog category. OMITTING IT yields an empty pool with a `no-category`
84
+ * shortfall — category is the only cross-kind gate, and retrieval will not
85
+ * rank an ungated corpus.
86
+ */
87
+ category?: string;
88
+ /** A short VISUAL description of the material wanted — not a keyword list. */
89
+ intent?: string;
90
+ required?: SlotPoolCriterion[];
91
+ preferred?: SlotPoolCriterion[];
92
+ colours?: SlotPoolColourCue[];
93
+ /** Rank this task around an already-chosen variant. Ids are opaque tokens. */
94
+ anchorSeed?: {
95
+ variantId: VariantId;
96
+ };
97
+ exclude?: {
98
+ variantIds?: VariantId[];
99
+ brandSlugs?: string[];
100
+ };
101
+ availability?: SlotPoolAvailabilityPolicy;
102
+ /** Candidates returned for this task. Max 48, default 12. */
103
+ limit?: number;
104
+ }
105
+ /** Body for `POST /api/v1/schemes/pools` ({@link MaterialGraphClient.slotPools}). */
106
+ export interface SlotPoolsRequest {
107
+ /** Up to 24 tasks. Each is independent: one failing costs that one. */
108
+ tasks: SlotPoolTask[];
109
+ /**
110
+ * Include the co-specification evidence among the returned candidates
111
+ * (default true). One bounded read — never one request per candidate.
112
+ */
113
+ coSpecification?: boolean;
114
+ }
115
+ /** The six live verdict states, plus the two legacy tokens older rows carry. */
116
+ export type SlotPoolVerdictState = OpenEnum<"matched" | "deviated" | "failed" | "unverified" | "missing" | "not_evaluated">;
117
+ /** One cell of a candidate's report card against one criterion. */
118
+ export interface SlotPoolVerdictCell {
119
+ attributeId: string;
120
+ state: SlotPoolVerdictState;
121
+ strength: OpenEnum<"required" | "preferred">;
122
+ baseline: {
123
+ kind: "term";
124
+ term: string;
125
+ } | {
126
+ kind: "presence";
127
+ } | {
128
+ kind: "seed";
129
+ variantId: VariantId;
130
+ } | {
131
+ kind: "image";
132
+ source: OpenEnum<"variant" | "vector">;
133
+ variantId: VariantId | null;
134
+ };
135
+ /** The candidate's OWN accepted value, or null when it holds none. */
136
+ value: string | null;
137
+ deviation: {
138
+ direction: OpenEnum<"above" | "below" | "other">;
139
+ delta: number | null;
140
+ unit: string | null;
141
+ label: string;
142
+ } | null;
143
+ confidence: number | null;
144
+ /**
145
+ * WHERE the value came from — it ranks the SOURCE, not the accuracy. It says
146
+ * nothing about whether the value is right.
147
+ */
148
+ sourceBasis: string | null;
149
+ trustGap: OpenEnum<"below_confidence" | "basis_unmet" | "proposed"> | null;
150
+ }
151
+ /** Why a candidate is labelled. An eligible candidate carries none of these. */
152
+ export type SlotPoolFlag = OpenEnum<"below-floor" | "required-not-applied" | "discontinued" | "superseded" | "unverified-value">;
153
+ export type SlotPoolAvailability = OpenEnum<"available" | "discontinued" | "superseded" | "unknown">;
154
+ /** The declared successor of a withdrawn material, one hop only. */
155
+ export interface SlotPoolSuccessor {
156
+ variantId: VariantId;
157
+ brandSlug: string;
158
+ productId: string;
159
+ brand: string;
160
+ product: string;
161
+ variant: string;
162
+ color: string | null;
163
+ availability: SlotPoolAvailability;
164
+ }
165
+ /**
166
+ * The features a composer scores on. Colours are EXTRACTED FROM PRODUCT IMAGERY
167
+ * — visual likeness, never a declared colour specification. An empty `palette`
168
+ * or `attributes` means nothing is on record, never that the material has none.
169
+ */
170
+ export interface SlotPoolCandidateFeatures {
171
+ colour: {
172
+ dominantHex: string | null;
173
+ hsl: {
174
+ h: number;
175
+ s: number;
176
+ l: number;
177
+ } | null;
178
+ oklab: {
179
+ l: number;
180
+ a: number;
181
+ b: number;
182
+ } | null;
183
+ chroma: number | null;
184
+ vibrancy: number | null;
185
+ sourceBasis?: string;
186
+ };
187
+ palette: {
188
+ hex: string;
189
+ proportion: number;
190
+ name?: string | null;
191
+ oklab?: {
192
+ l: number;
193
+ a: number;
194
+ b: number;
195
+ };
196
+ }[];
197
+ attributes: {
198
+ attributeId: string;
199
+ term: string;
200
+ }[];
201
+ dimensions: Record<string, unknown>[] | null;
202
+ }
203
+ export interface SlotPoolCandidate {
204
+ variantId: VariantId;
205
+ productId: string;
206
+ brandSlug: string;
207
+ brand: string;
208
+ product: string;
209
+ variant: string;
210
+ category: string | null;
211
+ materialBankId: string | null;
212
+ imageUrl: string | null;
213
+ availability: SlotPoolAvailability;
214
+ supersededBy: SlotPoolSuccessor | null;
215
+ /**
216
+ * True only when every required criterion was applied, the catalogue still
217
+ * lists the material, and no exclusion touched it. Anything else needs a
218
+ * human decision — read `flags` for which reason.
219
+ */
220
+ eligible: boolean;
221
+ flags: SlotPoolFlag[];
222
+ score: {
223
+ /** Normalised to [0,1] against the best score on THIS pool's page. */
224
+ retrieval: number;
225
+ /** `unverified` + `not_evaluated` cells. A soft penalty, not a failure count. */
226
+ unknownCount: number;
227
+ verdictCounts: {
228
+ matched: number;
229
+ deviated: number;
230
+ failed: number;
231
+ unverified: number;
232
+ missing: number;
233
+ notEvaluated: number;
234
+ };
235
+ };
236
+ verdict: {
237
+ cells: SlotPoolVerdictCell[];
238
+ grade: OpenEnum<"meets_all" | "close" | "hard_no">;
239
+ minConfidence: number | null;
240
+ };
241
+ features: SlotPoolCandidateFeatures;
242
+ }
243
+ /**
244
+ * Why a pool is short. Four different facts, and none of them is "nothing in
245
+ * the catalogue matches your brief".
246
+ */
247
+ export type SlotPoolShortfall = {
248
+ kind: "no-category";
249
+ } | {
250
+ kind: "category-coverage";
251
+ category: string;
252
+ attributeIds: string[];
253
+ gaps?: ({
254
+ level: "attribute";
255
+ attributeId: string;
256
+ } | {
257
+ level: "value";
258
+ attributeId: string;
259
+ term: string;
260
+ })[];
261
+ } | {
262
+ kind: "search-failed";
263
+ detail: string;
264
+ } | {
265
+ kind: "no-match";
266
+ tier: string;
267
+ consideredCount: number;
268
+ };
269
+ export interface SlotPool {
270
+ /** Your own key, verbatim. */
271
+ taskKey: string;
272
+ category: string | null;
273
+ /** The relaxation rung the returned candidates were drawn at. */
274
+ retrievalTier: OpenEnum<"strict" | "presence" | "category-only">;
275
+ /**
276
+ * HOW the candidates were retrieved. DERIVED from the retrieval plan and the
277
+ * pool's receipt — MaterialGraph does not report an arm per candidate.
278
+ *
279
+ * It is constant across the relaxation rungs, so it cannot tell you how far
280
+ * the ladder went. `retrievalTier` beside it names the rung the pool settled
281
+ * on: the arm says how, the tier says how strictly.
282
+ */
283
+ arm: OpenEnum<"visual-text" | "image-similarity" | "none">;
284
+ candidates: SlotPoolCandidate[];
285
+ /** How much retrieval scored before this task's own `limit` trimmed the page. */
286
+ consideredCount: number;
287
+ returnedCount: number;
288
+ excludedCount: number;
289
+ unavailableDroppedCount: number;
290
+ /**
291
+ * The criteria nothing on this page could be graded on — a statement about
292
+ * catalogue COVERAGE, never about the candidates.
293
+ */
294
+ unknownCoverage: {
295
+ evaluatedCriteria: number;
296
+ /** Every returned candidate graded `not_evaluated` on these. */
297
+ uncheckedCriteria: string[];
298
+ /** Every returned candidate graded `missing` on these. */
299
+ unheldCriteria: string[];
300
+ };
301
+ shortfall: SlotPoolShortfall | null;
302
+ seededFrom: {
303
+ variantId: VariantId;
304
+ } | null;
305
+ warnings: string[];
306
+ }
307
+ /**
308
+ * The sparse pairing graph among the returned candidates. Each unordered pair
309
+ * appears ONCE and the relation is symmetric. An empty `edges` under a null
310
+ * `datasetSessionId` means the dataset is absent, NOT that these materials are
311
+ * never specified together.
312
+ */
313
+ export interface SlotPoolCoSpecification {
314
+ edges: {
315
+ from: VariantId;
316
+ to: VariantId;
317
+ projectCount: number;
318
+ }[];
319
+ coverage: {
320
+ candidateCount: number;
321
+ coveredCount: number;
322
+ truncated: boolean;
323
+ datasetSessionId: string | null;
324
+ window: string;
325
+ signal: string;
326
+ };
327
+ }
328
+ /** The `200` body from `POST /api/v1/schemes/pools`. */
329
+ export interface SlotPoolsResponse {
330
+ versions: {
331
+ contract: string;
332
+ retrieval: string;
333
+ evaluator: string;
334
+ dataset: {
335
+ coSpecificationSessionId: string | null;
336
+ };
337
+ };
338
+ /** One pool per task, in request order. Correlate by `taskKey`, not by index. */
339
+ pools: SlotPool[];
340
+ coSpecification: SlotPoolCoSpecification | null;
341
+ summary: {
342
+ tasks: number;
343
+ eligibleCandidates: number;
344
+ flaggedCandidates: number;
345
+ tasksWithEligibleCandidates: number;
346
+ tasksWithoutEligibleCandidates: number;
347
+ /** Non-zero means some tasks were NOT answered; their pools say so. */
348
+ searchFailed: number;
349
+ noCategory: number;
350
+ categoryCoverage: number;
351
+ noMatch: number;
352
+ };
353
+ /**
354
+ * CALL VOLUME — how many of each bounded operation the request performed.
355
+ *
356
+ * Never a cost or token figure. Slot retrieval is database and embedding work
357
+ * and does not go through the model-call path that carries token and spend
358
+ * accounting, so no such figure exists for it. Do not render these as usage
359
+ * or spend.
360
+ */
361
+ calls: {
362
+ searchesRun: number;
363
+ searchesFailed: number;
364
+ candidatesConsidered: number;
365
+ candidatesReturned: number;
366
+ hydrationBatches: number;
367
+ coSpecificationQueries: number;
368
+ };
369
+ warnings: string[];
370
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,3 +1,7 @@
1
- import type { SourceMaterialBatchDetail } from "./run-types.js";
1
+ import type { PostSourceMaterialCommentResult, SourceMaterialBatchDetail, SourceMaterialCommentThread } from "./run-types.js";
2
2
  /** Validate a batch-detail body, returning it typed, or throw explaining why. */
3
3
  export declare function checkSourceMaterialBatchDetail(value: unknown, batchId: string): SourceMaterialBatchDetail;
4
+ /** Validate a comment-thread body, returning it typed, or throw explaining why. */
5
+ export declare function checkSourceMaterialCommentThread(value: unknown, sourceId: string): SourceMaterialCommentThread;
6
+ /** Validate a posted-comment body, returning it typed, or throw explaining why. */
7
+ export declare function checkPostSourceMaterialCommentResult(value: unknown, sourceId: string): PostSourceMaterialCommentResult;
@@ -11,12 +11,16 @@
11
11
  * nothing at run time. This is the run-time half, and it must ACCEPT and REJECT
12
12
  * exactly what the schema does — pinned by `source-material-parity.test.ts`.
13
13
  *
14
- * TWO CONTRACT PROPERTIES IT ENFORCES, both easy to lose in a refactor:
14
+ * THREE CONTRACT PROPERTIES IT ENFORCES, each easy to lose in a refactor:
15
15
  * 1. `analyses` is REQUIRED and an array on every file. `[]` means "nothing
16
16
  * computed yet"; an absent field is a broken response, not an empty one.
17
17
  * Preprocessing is manual today and automatic later — only the trigger
18
18
  * changes, never this contract.
19
- * 2. `result` is a JSON record all the way down (`z.json()`), not `unknown`.
19
+ * 2. `comments` is REQUIRED and an array on every file, and every root comment
20
+ * carries a REQUIRED `replies` array. Same distinction: "nobody said
21
+ * anything about this file" is a state the client draws, and an absent
22
+ * field is a malformed response.
23
+ * 3. `result` is a JSON record all the way down (`z.json()`), not `unknown`.
20
24
  * See `requireJsonValue` for why the schema is that strict and why this
21
25
  * mirror cannot actually fire over the wire.
22
26
  *
@@ -30,7 +34,7 @@
30
34
  * permanent contract, and promoting it later is additive.
31
35
  */
32
36
  import { MaterialGraphError } from "./errors.js";
33
- import { requireArray, requireEnum, requireSafeInteger, requireJsonRecord, requireNonEmptyString, requireRecord, requireString, } from "./validation.js";
37
+ import { requireArray, requireBoolean, requireEnum, requireSafeInteger, requireJsonRecord, requireNonEmptyString, requireNullableTrimmedBoundedString, requireRecord, requireString, requireTrimmedBoundedString, } from "./validation.js";
34
38
  /** `sourceImageAnalysisKindSchema` members, verbatim. */
35
39
  const ANALYSIS_KINDS = ["scene_match"];
36
40
  /** `sourceImageAnalysisStatusSchema` members, verbatim. */
@@ -61,12 +65,67 @@ function checkAnalysis(value, what, fail) {
61
65
  analysedAt: requireNonEmptyString(record.analysedAt, `${what}.analysedAt`, fail),
62
66
  };
63
67
  }
68
+ /** `sourceMaterialCommentAuthorSchema`'s `attribution` members, verbatim. */
69
+ const COMMENT_ATTRIBUTIONS = ["reported"];
70
+ /** The canonical cap on both author fields: `.trim().min(1).max(160)`. */
71
+ const AUTHOR_FIELD_MAX = 160;
72
+ /**
73
+ * A reply, and the shared half of a root comment.
74
+ *
75
+ * `body`, `commentId` and `createdAt` are `.min(1)` canonically — fields this
76
+ * endpoint introduces, where an empty value carries nothing a client could
77
+ * render. `author.organisation` is nullable, NOT optional: `null` is the answer
78
+ * for a note with no organisation, and an absent key is a malformed body.
79
+ *
80
+ * The author fields are `.trim().min(1).max(160)` canonically and are mirrored
81
+ * as exactly that. A plain non-empty check — which is what this used to do —
82
+ * disagreed with the schema in both directions: it accepted `" "`, which the
83
+ * schema rejects once trimmed, and accepted a name of any length, which the
84
+ * schema caps. A validator that is looser than the contract is a validator that
85
+ * lets the two drift without anything failing.
86
+ */
87
+ function checkCommentBase(record, what, fail) {
88
+ const author = requireRecord(record.author, `${what}.author`, fail);
89
+ const recordedBy = requireRecord(record.recordedBy, `${what}.recordedBy`, fail);
90
+ return {
91
+ commentId: requireNonEmptyString(record.commentId, `${what}.commentId`, fail),
92
+ author: {
93
+ name: requireTrimmedBoundedString(author.name, `${what}.author.name`, AUTHOR_FIELD_MAX, fail),
94
+ organisation: requireNullableTrimmedBoundedString(author.organisation, `${what}.author.organisation`, AUTHOR_FIELD_MAX, fail),
95
+ // Closed here, matching the canonical `z.literal("reported")`, while the
96
+ // published type is `OpenEnum` so a newer server value keeps
97
+ // type-checking — the same split the analysis enums make.
98
+ attribution: requireEnum(author.attribution, COMMENT_ATTRIBUTIONS, `${what}.author.attribution`, fail),
99
+ },
100
+ recordedBy: {
101
+ principalId: requireNonEmptyString(recordedBy.principalId, `${what}.recordedBy.principalId`, fail),
102
+ },
103
+ body: requireNonEmptyString(record.body, `${what}.body`, fail),
104
+ createdAt: requireNonEmptyString(record.createdAt, `${what}.createdAt`, fail),
105
+ };
106
+ }
107
+ function checkComment(value, what, fail) {
108
+ const record = requireRecord(value, what, fail);
109
+ // Same reasoning as `analyses`: no `.default([])` canonically, so an absent
110
+ // `replies` is malformed rather than empty.
111
+ const replies = requireArray(record.replies, `${what}.replies`, fail);
112
+ return {
113
+ ...checkCommentBase(record, what, fail),
114
+ replies: replies.map((reply, index) => checkCommentBase(requireRecord(reply, `${what}.replies[${index}]`, fail), `${what}.replies[${index}]`, fail)),
115
+ };
116
+ }
64
117
  function checkFile(value, what, fail) {
65
118
  const record = requireRecord(value, what, fail);
66
119
  // NOT `requireDefaultedArray`: the schema has no `.default([])`, so an absent
67
120
  // `analyses` is a malformed body rather than an empty one. Keeping that
68
- // distinction is the whole point of the always-an-array contract.
121
+ // distinction is the whole point of the always-an-array contract. `comments`
122
+ // is required for the same reason: "nobody has said anything" and "the server
123
+ // forgot to tell you" are different answers.
69
124
  const analyses = requireArray(record.analyses, `${what}.analyses`, fail);
125
+ const comments = requireArray(record.comments, `${what}.comments`, fail);
126
+ // Required, not defaulted: "you are seeing the whole thread" is a claim the
127
+ // server makes or does not, never one a client should infer from a length.
128
+ const commentsTruncated = requireBoolean(record.commentsTruncated, `${what}.commentsTruncated`, fail);
70
129
  // `requireString`, NOT `requireNonEmptyString`, for the fields this endpoint
71
130
  // INHERITS from the shared source-material contract (`sourceId`, `sha256`,
72
131
  // `url` — plus `batchId`/`createdAt` below). Those are plain `z.string()`
@@ -96,6 +155,8 @@ function checkFile(value, what, fail) {
96
155
  : requireSafeInteger(record.pages, `${what}.pages`, fail),
97
156
  url: requireString(record.url, `${what}.url`, fail),
98
157
  analyses: analyses.map((analysis, index) => checkAnalysis(analysis, `${what}.analyses[${index}]`, fail)),
158
+ comments: comments.map((comment, index) => checkComment(comment, `${what}.comments[${index}]`, fail)),
159
+ commentsTruncated,
99
160
  };
100
161
  }
101
162
  /** Validate a batch-detail body, returning it typed, or throw explaining why. */
@@ -109,3 +170,49 @@ export function checkSourceMaterialBatchDetail(value, batchId) {
109
170
  sources: sources.map((file, index) => checkFile(file, `body.sources[${index}]`, fail)),
110
171
  };
111
172
  }
173
+ function commentsFailure(verb, sourceId) {
174
+ return (reason) => MaterialGraphError.unexpected(`${verb} .../sources/${sourceId}/comments returned a malformed body: ${reason}`);
175
+ }
176
+ /** Validate a comment-thread body, returning it typed, or throw explaining why. */
177
+ export function checkSourceMaterialCommentThread(value, sourceId) {
178
+ const fail = commentsFailure("GET", sourceId);
179
+ const record = requireRecord(value, "body", fail);
180
+ const comments = requireArray(record.comments, "body.comments", fail);
181
+ return {
182
+ sourceId: requireString(record.sourceId, "body.sourceId", fail),
183
+ comments: comments.map((comment, index) => checkComment(comment, `body.comments[${index}]`, fail)),
184
+ truncated: requireBoolean(record.truncated, "body.truncated", fail),
185
+ };
186
+ }
187
+ /** `storedSourceMaterialCommentSchema`'s discriminator members, verbatim. */
188
+ const STORED_COMMENT_KINDS = ["root", "reply"];
189
+ /**
190
+ * Validate a stored comment, following its `kind` discriminator.
191
+ *
192
+ * The two branches are checked as the two DIFFERENT shapes they are: a root must
193
+ * carry `replies`, a reply must carry `parentCommentId` and must not be assumed
194
+ * to carry `replies` at all. Validating both as the root shape is exactly the
195
+ * defect this discriminated union was added to fix, and a mirror that did it
196
+ * would put the lie back on the client side.
197
+ */
198
+ function checkStoredComment(value, what, fail) {
199
+ const record = requireRecord(value, what, fail);
200
+ const kind = requireEnum(record.kind, STORED_COMMENT_KINDS, `${what}.kind`, fail);
201
+ if (kind === "reply") {
202
+ return {
203
+ kind: "reply",
204
+ ...checkCommentBase(record, what, fail),
205
+ parentCommentId: requireNonEmptyString(record.parentCommentId, `${what}.parentCommentId`, fail),
206
+ };
207
+ }
208
+ return { kind: "root", ...checkComment(record, what, fail) };
209
+ }
210
+ /** Validate a posted-comment body, returning it typed, or throw explaining why. */
211
+ export function checkPostSourceMaterialCommentResult(value, sourceId) {
212
+ const fail = commentsFailure("POST", sourceId);
213
+ const record = requireRecord(value, "body", fail);
214
+ return {
215
+ sourceId: requireString(record.sourceId, "body.sourceId", fail),
216
+ comment: checkStoredComment(record.comment, "body.comment", fail),
217
+ };
218
+ }