@instruments/materialgraph 0.9.1 → 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 (64) hide show
  1. package/brands-client.d.ts +48 -0
  2. package/brands-client.js +45 -0
  3. package/browse-types.d.ts +147 -0
  4. package/browse-types.js +1 -0
  5. package/by-colour-types.d.ts +105 -5
  6. package/client-browse.d.ts +13 -3
  7. package/client-browse.js +12 -2
  8. package/client-exploration.d.ts +9 -0
  9. package/client-exploration.js +7 -0
  10. package/client-source-material-comments.d.ts +51 -0
  11. package/client-source-material-comments.js +40 -0
  12. package/client.d.ts +64 -71
  13. package/client.js +90 -78
  14. package/colour-aggregate-types.d.ts +58 -0
  15. package/colours-client.d.ts +8 -2
  16. package/colours-client.js +15 -2
  17. package/composition-client.d.ts +33 -0
  18. package/composition-client.js +27 -0
  19. package/composition-types.d.ts +46 -0
  20. package/coordinate-client.d.ts +23 -0
  21. package/coordinate-client.js +25 -0
  22. package/errors.js +24 -8
  23. package/exploration-types.d.ts +110 -0
  24. package/exploration-types.js +1 -0
  25. package/fan-deck-types.d.ts +42 -0
  26. package/fan-deck-types.js +9 -0
  27. package/http-transport.js +24 -5
  28. package/index.d.ts +7 -3
  29. package/index.js +2 -2
  30. package/match-stream-types.d.ts +1 -1
  31. package/match-stream-types.js +16 -3
  32. package/match-stream.js +14 -4
  33. package/material-types.d.ts +1 -1
  34. package/operations.d.ts +1 -1
  35. package/operations.js +187 -26
  36. package/package.json +1 -1
  37. package/palette-search-types.d.ts +31 -0
  38. package/resolve-client.d.ts +22 -3
  39. package/resolve-client.js +31 -4
  40. package/resolve-types.d.ts +70 -1
  41. package/run-events.d.ts +1 -1
  42. package/run-events.js +4 -1
  43. package/run-types.d.ts +132 -0
  44. package/runs.d.ts +35 -1
  45. package/runs.js +69 -5
  46. package/schedule-types.d.ts +128 -0
  47. package/schema-resource-types.d.ts +1 -1
  48. package/search-refinement-types.d.ts +39 -4
  49. package/search-request-types.d.ts +172 -0
  50. package/search-request-types.js +1 -0
  51. package/search-result-validation.js +21 -4
  52. package/slot-pool-types.d.ts +370 -0
  53. package/slot-pool-types.js +1 -0
  54. package/source-material-validation.d.ts +5 -1
  55. package/source-material-validation.js +114 -5
  56. package/spec-facet-types.d.ts +53 -0
  57. package/spec-facet-types.js +7 -0
  58. package/specifications.d.ts +26 -0
  59. package/specifications.js +32 -0
  60. package/sse.js +3 -1
  61. package/suggest-types.js +3 -1
  62. package/types.d.ts +176 -216
  63. package/validation.d.ts +12 -0
  64. package/validation.js +23 -0
package/types.d.ts CHANGED
@@ -1,35 +1,7 @@
1
- /**
2
- * Hand-mirrored wire types for the MaterialGraph public API (spec 0.5.0).
3
- *
4
- * These interfaces intentionally have NO runtime dependency on any
5
- * `@materialgraph/*` package — they are a standalone, publishable mirror of the
6
- * shapes in `apps/web/public/openapi.yaml` and the captured fixtures under
7
- * `docs/surface/fixtures/v0.5.0/`. The `contract-parity` test re-checks them
8
- * against the real Zod schemas so the mirror cannot silently drift.
9
- * TOLERANT-PARSING CONTRACT: the public surface declares open response enums —
10
- * "clients must tolerate unknown enum values and unknown fields". Every union
11
- * that mirrors a server enum is therefore widened with `(string & {})` so a new
12
- * server value still type-checks, and objects that may grow fields are read
13
- * structurally (unknown keys pass through untouched — the client never strips).
14
- *
15
- * READING CONTRACT — the three things callers get wrong, stated once here and
16
- * repeated on the types where each one bites:
17
- * 1. **Ids are opaque.** Round-trip them verbatim; never parse or construct
18
- * one. See `./identifiers.ts` for which strings are opaque tokens and which
19
- * are canonical vocabulary you discover.
20
- * 2. **Absent is not false.** An optional field that is missing means UNKNOWN,
21
- * not "known to be absent in reality" — most dangerously for booleans.
22
- * Values carry `confidence` and `sourceBasis`; report them as claims.
23
- * 3. **A page is not a corpus.** `results.length` is this page.
24
- * {@link CountedTotal} — with the `basis` it states — is the claim about the
25
- * match set. Follow the cursor for the rest, and never report a page size as
26
- * a total.
27
- */
28
- import type { ByColourSwatch } from "./by-colour-types.js";
29
1
  import type { BySpecVerdict } from "./by-spec-types.js";
30
2
  import type { Cursor, ProductId, VariantId } from "./identifiers.js";
31
3
  import type { MaterialDetail, MaterialTile } from "./material-types.js";
32
- import type { SearchFilter, SearchSuggestion } from "./search-refinement-types.js";
4
+ import type { SearchDisambiguation, SearchSuggestion } from "./search-refinement-types.js";
33
5
  /** A widened string-literal union: known members autocomplete, unknown values still type-check. */
34
6
  export type OpenEnum<T extends string> = T | (string & {});
35
7
  /**
@@ -104,6 +76,15 @@ export interface VisualMeaning {
104
76
  version?: string;
105
77
  appliedAt?: string;
106
78
  }
79
+ /** One member of a collapsed result's {@link SearchResultItem.anatomy}. */
80
+ export interface SearchAnatomyMember {
81
+ /** Opaque — round-trip verbatim. */
82
+ variantId: VariantId;
83
+ variantName: string | null;
84
+ color: string | null;
85
+ score: number;
86
+ signals: SearchResultSignals;
87
+ }
107
88
  /**
108
89
  * One material in a search / match result list.
109
90
  *
@@ -121,10 +102,14 @@ export interface SearchResultItem {
121
102
  brandSlug: string | null;
122
103
  productId: ProductId | null;
123
104
  /**
124
- * The variant's Material Bank SKU — the same `materialBankId` the material
125
- * record carries, so a match card can render straight from a result instead of
126
- * paying a per-result lookup. Populated on the MATCH lanes (`match()` and
127
- * `matchStream()` frames).
105
+ * The variant's Material Bank SKU — the ONE EXTERNAL identifier on a result,
106
+ * in deliberate contrast to the opaque `variantId` above. `variantId` is ours
107
+ * and means nothing anywhere else; this is the id you are expected to match
108
+ * against Material Bank's own catalogue, so a result list can be joined to a
109
+ * cart, an order or an analytics event without a per-result lookup.
110
+ *
111
+ * Populated on EVERY lane: `search()` (all four arms), `match()`, and
112
+ * `matchStream()` frames.
128
113
  *
129
114
  * ABSENT means no MB identity is known for the variant — never that the SKU
130
115
  * cannot be ordered. Presence is the orderability tell; absence is silence.
@@ -150,6 +135,38 @@ export interface SearchResultItem {
150
135
  hasImage: boolean;
151
136
  palette: PaletteSwatch[];
152
137
  colourMeta?: ColourMeta;
138
+ /**
139
+ * Present when this material's imagery carries nothing beyond ONE colour, so
140
+ * a surface should render that colour rather than the photograph — a paint's
141
+ * photo is a flat fill, and the colour we hold structured beats a JPEG of it.
142
+ *
143
+ * The hex is EXTRACTED FROM the product image (a visual likeness), never a
144
+ * manufacturer-declared colour specification; do not present it as one.
145
+ * ABSENT means the question was not established (unknown product type,
146
+ * unextracted or multi-swatch palette) and the image should be rendered — it
147
+ * NEVER asserts "this is not a coating".
148
+ */
149
+ colourChip?: {
150
+ hex: string;
151
+ };
152
+ /**
153
+ * EXPLAIN-only: the two identities a COLLAPSED result holds, which are not the
154
+ * same thing and are routinely confused.
155
+ *
156
+ * - `representative` — the RANKING identity. Its score is the group's cursor
157
+ * key and the item's `signals` describe IT, not the photograph on the card.
158
+ * Omitted when the face IS the representative.
159
+ * - the item's own `variantId` — the DISPLAY identity (the face).
160
+ *
161
+ * `siblingsTruncated` says the sibling list was capped: a product can hold
162
+ * hundreds of colourways, so absence of a sibling is never evidence it does
163
+ * not exist.
164
+ */
165
+ anatomy?: {
166
+ representative?: SearchAnatomyMember;
167
+ siblings?: SearchAnatomyMember[];
168
+ siblingsTruncated?: boolean;
169
+ };
153
170
  /** Fused score — range [0, sum of active weights]; see `maxScore`. */
154
171
  score: number;
155
172
  /**
@@ -189,6 +206,17 @@ export interface SearchResultItem {
189
206
  * dropped by default. Appended after the kept results.
190
207
  */
191
208
  belowFloor?: boolean;
209
+ /**
210
+ * Present (true) only on Match rows the colour probe admitted (match only):
211
+ * this material is as close in colour as the catalogue gets, and its image
212
+ * similarity is BELOW the match quality floor by design. It is on the page
213
+ * because nothing image-retrieved is nearer in colour, not because it cleared
214
+ * the floor — present it as a colour answer or suppress it knowingly. Absent
215
+ * (never `false`) on rows that came through image retrieval. Distinct from
216
+ * {@link SearchResultItem.belowFloor}, which marks rejects returned only under
217
+ * `includeBelowFloor`.
218
+ */
219
+ admittedOnColour?: boolean;
192
220
  siblingCount?: number;
193
221
  visualMeanings?: VisualMeaning[];
194
222
  /**
@@ -223,11 +251,17 @@ export interface SearchCardItem {
223
251
  * A feed total with its honesty basis — THE CLAIM ABOUT THE MATCH SET, which is
224
252
  * a different number from `results.length` (this page).
225
253
  *
226
- * `exact` — the true count (≤ the cap of 1,000, or a facet-MV read). Say "473
227
- * materials match".
254
+ * `exact` — the true count. Say "473 materials match".
228
255
  * `lowerBound` — a floor (cap hit, ANN-pool bound, or a degraded count). Say
229
256
  * "at least 1,000", never "1,000". Never a silent approximation.
230
257
  *
258
+ * The CAP differs by lane, and only `lowerBound` ever tells you one was hit. The
259
+ * SEARCH feed counts exactly to 1,000 and reports a floor beyond it, because it
260
+ * counts over attribute and tsquery universes where an unbounded count is a
261
+ * timeout. The BROWSE feed ({@link BrowseCounts}) is uncapped — a plain count
262
+ * over the same predicate its page scan ran, so a category of 66,000 reports
263
+ * 66,000 exactly. Read `basis`; never assume a ceiling.
264
+ *
231
265
  * A response with no `counts` at all states no total: `count: true` is opt-in,
232
266
  * and the absence of a total is not a total of zero.
233
267
  */
@@ -244,6 +278,54 @@ export interface SearchCounts {
244
278
  variants: CountedTotal;
245
279
  products?: CountedTotal;
246
280
  }
281
+ /**
282
+ * One value within a facet's counts: the CANONICAL token (send it back verbatim
283
+ * as a filter term), its display label, and how many variants in the counted
284
+ * set carry it.
285
+ */
286
+ export interface SearchFacetValue {
287
+ value: string;
288
+ label: string;
289
+ variants: number;
290
+ }
291
+ /**
292
+ * One facet's counts. `basis` is the honesty label, and — per the negotiated
293
+ * MG-1010 contract — its union is CLOSED: exactly `"exact"` or `"lowerBound"`,
294
+ * never widened, which is why it is not an {@link OpenEnum}. `exact` — each
295
+ * `variants` figure is a true count under the query's whole predicate
296
+ * (filter-driven queries only). `lowerBound` — counted over the ranked
297
+ * candidate pool (`SearchFacets.poolSize`), so each figure is a floor; a
298
+ * pool-counted value is NEVER `exact`. `truncated` is ALWAYS present: true
299
+ * when more distinct values existed beyond the reported list.
300
+ */
301
+ export interface SearchFacetCount {
302
+ values: SearchFacetValue[];
303
+ truncated: boolean;
304
+ basis: "exact" | "lowerBound";
305
+ }
306
+ /**
307
+ * A requested facet that was not counted, with its reason: `"unsupported"` —
308
+ * the id is known but facet counting does not cover it; `"timeout"` — the
309
+ * facet stage's own time budget expired, and the RESULTS are unaffected (the
310
+ * rail could not count; the page is intact). Render it as "couldn't count",
311
+ * never as zero.
312
+ */
313
+ export interface SearchAbsentFacet {
314
+ facetId: string;
315
+ reason: OpenEnum<"unsupported" | "timeout">;
316
+ }
317
+ /**
318
+ * Per-query facet counts (MG-1010) — present ONLY when the request sent
319
+ * `facetCounts`. `counts` is keyed by requested facet id; every requested
320
+ * facet not in `absent` has an entry, including empty ones. `poolSize` is
321
+ * present exactly when the counts are pool-derived.
322
+ */
323
+ export interface SearchFacets {
324
+ poolSize?: number;
325
+ requested: string[];
326
+ absent: SearchAbsentFacet[];
327
+ counts: Record<string, SearchFacetCount>;
328
+ }
247
329
  /** The single candidate source a query resolved to. */
248
330
  export type SearchMode = OpenEnum<"image" | "colour" | "text" | "lexical">;
249
331
  /** Rerank-term report (absent in colour mode — there colour IS the source). */
@@ -275,8 +357,42 @@ export interface SearchResponse {
275
357
  cursor?: Cursor;
276
358
  rerank?: SearchRerankReport;
277
359
  counts?: SearchCounts;
360
+ /**
361
+ * The `#rrggbb` colours the colour signal ACTUALLY ran on, after
362
+ * minor-proportion accents were dropped. Present whenever the request carried
363
+ * explicit `colours`. Compare against your request: a difference means the
364
+ * search that ran is not the search that was asked for.
365
+ */
366
+ effectiveColours?: string[];
367
+ /**
368
+ * Colours removed from the query for carrying a `weight` below the 5% accent
369
+ * threshold. EMPTY is the normal case. Non-empty and the dropped colours are
370
+ * absent from colour retrieval and ranking entirely — they did not merely
371
+ * rank low. To keep a small accent in the query, give it `role: "accent"`.
372
+ */
373
+ droppedColours?: string[];
374
+ /** Per-query facet counts — present ONLY when the request sent `facetCounts`. */
375
+ facets?: SearchFacets;
278
376
  note?: string;
279
377
  timings?: Record<string, number>;
378
+ /** A detected mood term overridden by a stronger colour reading. */
379
+ supersededMoodId?: string;
380
+ /**
381
+ * Present when the query had TWO defensible readings. Says which one ran and
382
+ * offers the other as a complete executable request.
383
+ */
384
+ disambiguation?: SearchDisambiguation;
385
+ /**
386
+ * The corpus this page AND its `counts` were drawn from — present ONLY when
387
+ * the request narrowed it. ABSENT means the whole graph: every material
388
+ * MaterialGraph knows, orderable or not. Read it alongside `counts`: a short
389
+ * page under a scope is a small SCOPED corpus, not a truncated one.
390
+ */
391
+ corpusScope?: {
392
+ scope: OpenEnum<"all" | "materialBankOrderable">;
393
+ /** What the scope admits, in one sentence, for a human reader. */
394
+ description: string;
395
+ };
280
396
  /** Echo of the resolved query input (raw image bytes/vector are never echoed). */
281
397
  query?: Record<string, unknown>;
282
398
  /** A `getVariantBrief` echo of the seed variant, present only in `variantId` seed mode. */
@@ -287,47 +403,6 @@ export interface SearchCardResponse extends Omit<SearchResponse, "view" | "resul
287
403
  view: OpenEnum<"card">;
288
404
  results: SearchCardItem[];
289
405
  }
290
- /** One item from the pure colour-scalar browse. */
291
- export interface BrowseResultItem {
292
- /** Opaque — round-trip verbatim. */
293
- variantId: VariantId;
294
- brand: string;
295
- product: string;
296
- brandSlug: string | null;
297
- productId: ProductId | null;
298
- color: string | null;
299
- variantName: string | null;
300
- imageUrl: string | null;
301
- scalars: {
302
- l: number | null;
303
- chroma: number | null;
304
- vibrancy: number | null;
305
- };
306
- retired?: boolean;
307
- /**
308
- * The row's own image palette — the FULL stored palette of the SAME image
309
- * `imageUrl` serves, uncapped and in stored order, each swatch with `oklab`
310
- * (see {@link ByColourSwatch}), so a card can render, sort or group colours
311
- * without a second call. A one-swatch palette means the extractor found one
312
- * colour (solid-colour materials are genuinely monochrome), not that the list
313
- * was trimmed. ABSENT when no palette is on record for that image: absent
314
- * means unknown, NOT that the material has no colours. These swatches come
315
- * from product imagery, not from the manufacturer.
316
- */
317
- palette?: ByColourSwatch[];
318
- }
319
- /**
320
- * A single page of the colour browse — the SDK's one genuinely cursor-paginated
321
- * surface. `results.length` is this page and the browse states no total at all,
322
- * so a corpus claim can only come from walking every page
323
- * (`client.browse.pages(...)` does the loop).
324
- */
325
- export interface BrowseResponse {
326
- /** THIS PAGE. */
327
- results: BrowseResultItem[];
328
- /** Opaque keyset for the next page. Absent = the walk is finished. */
329
- cursor?: Cursor;
330
- }
331
406
  export type ColourDictionaryId = OpenEnum<"colour_family" | "colour_mood" | "colour_undertone">;
332
407
  export interface ColourFacetTerm {
333
408
  id: string;
@@ -400,6 +475,22 @@ export interface CospecPairing {
400
475
  imageUrl: string | null;
401
476
  brandSlug: string;
402
477
  productId: ProductId;
478
+ /**
479
+ * How this companion relates to the seed — the distinction raw order evidence
480
+ * does not carry, and the one that decides what you may claim.
481
+ *
482
+ * - `companion` — a DIFFERENT BRAND entirely. This is the cross-brand claim
483
+ * the rail exists to make.
484
+ * - `brand_sibling` — the seed's own brand, a different product.
485
+ * - `colourway` — the seed's own product, restyled.
486
+ *
487
+ * **70.6% of edges are same-brand**, so a consumer that ignores this reports
488
+ * the seed's own family as cross-brand intelligence (MG-1007).
489
+ *
490
+ * Optional because responses captured before the field existed are genuinely
491
+ * in the wild; absent means "from an older version", never "unrelated".
492
+ */
493
+ relation?: "companion" | "brand_sibling" | "colourway";
403
494
  category: string | null;
404
495
  projectCount: number;
405
496
  customerCount: number;
@@ -543,121 +634,7 @@ export type SearchImageInput = {
543
634
  type: "base64";
544
635
  data: string;
545
636
  };
546
- /**
547
- * The POST `/search` JSON body — the `searchMaterials` contract. Every field is
548
- * optional; supply at least one signal (`text`, `colours`, `mood`, `similarTo`,
549
- * or `image`). Passed through to the server verbatim.
550
- */
551
- export interface SearchInput {
552
- text?: string;
553
- textModes?: Array<OpenEnum<"visual" | "lexical">>;
554
- colours?: ColourInput[];
555
- colourLogic?: OpenEnum<"all" | "any">;
556
- colourTolerance?: OpenEnum<"tight" | "normal" | "loose">;
557
- colourAppearance?: {
558
- lightness?: {
559
- min: number;
560
- max: number;
561
- };
562
- warmth?: {
563
- min: number;
564
- max: number;
565
- };
566
- };
567
- mood?: string;
568
- sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
569
- /**
570
- * Seed a visual search from an existing variant's image (`variantId`) or a
571
- * pre-computed image vector (`vector`). Only the `{ variantId }` form carries a
572
- * seed variant, so it is the only form `verdicts` can diff a candidate against.
573
- */
574
- similarTo?: {
575
- variantId: VariantId;
576
- } | {
577
- vector: string;
578
- };
579
- image?: SearchImageInput;
580
- category?: {
581
- boost?: string;
582
- filter?: string[];
583
- };
584
- weights?: {
585
- image?: number;
586
- category?: number;
587
- colour?: number;
588
- lexical?: number;
589
- /** Text rerank multiplier applied when a seeded search also carries `text` (default 0.4). */
590
- text?: number;
591
- };
592
- filters?: SearchFilter[];
593
- /**
594
- * SOFT spec criteria (rank-never-exclude). Same shape as `filters`; a preference
595
- * never removes a result — it adds a `preferred` cell to each hit's `verdict` and
596
- * demotes a failed/missing preferred hit WITHIN its page only.
597
- */
598
- preferences?: SearchFilter[];
599
- /** Page size — the size of ONE page, never a bound on the match set. */
600
- limit?: number;
601
- /** The previous response's `cursor`, passed back verbatim. Never synthesise one. */
602
- cursor?: Cursor;
603
- explain?: boolean;
604
- collapse?: OpenEnum<"none" | "product">;
605
- /**
606
- * Opt-in feed total: EXACT up to 1,000, `lowerBound` beyond / for ANN pools.
607
- * Without it the response states NO total — and a page size is not one.
608
- */
609
- count?: boolean;
610
- /** Only return materials with at least one catalog image. Default false (full catalog). */
611
- hasImages?: boolean;
612
- /**
613
- * Opt-in seed-relative verdict cells (MG-629). Only meaningful with
614
- * `similarTo: { variantId }`: each hit's `verdict` gains `preferred` cells
615
- * diffing its comparable attributes (numeric + a `colour` axis) against the
616
- * seed's, with directional deviations. Inert on every other seed form.
617
- */
618
- verdicts?: boolean;
619
- /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
620
- view?: OpenEnum<"full" | "card">;
621
- }
622
- /** Query-string params for the GET `/search` form. */
623
- export interface SearchGetParams {
624
- q?: string;
625
- colours?: string;
626
- logic?: OpenEnum<"all" | "any">;
627
- mood?: string;
628
- sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
629
- tol?: OpenEnum<"tight" | "normal" | "loose">;
630
- /** Seed the search from this variant's image. Opaque — pass it through verbatim. */
631
- variantId?: VariantId;
632
- limit?: number;
633
- /** "0" or "1" — include per-signal `signals` on each result. */
634
- explain?: OpenEnum<"0" | "1">;
635
- /** "true" — opt in to a `counts.variants` feed total. */
636
- count?: OpenEnum<"true">;
637
- /** "true" — only return materials with at least one catalog image. */
638
- hasImages?: OpenEnum<"true">;
639
- /** Result projection — `"full"` (default) or the lean `"card"` grid form. */
640
- view?: OpenEnum<"full" | "card">;
641
- }
642
- /** Params for the pure colour `search/browse` surface. */
643
- export interface BrowseParams {
644
- sort: OpenEnum<"light" | "dark" | "vivid" | "muted" | "colourful">;
645
- /**
646
- * The previous page's `cursor`, verbatim. Opaque keyset state — not an offset.
647
- * Changing `sort`, `limit` or `types` invalidates it.
648
- */
649
- cursor?: Cursor;
650
- /** Page size. */
651
- limit?: number;
652
- /**
653
- * Comma-separated taxonomy keys to restrict the browse to, at EITHER tier of
654
- * `/api/v1/taxonomy/categories`. A GROUP key (`tile_surfaces`) expands to
655
- * every product type under it, a TYPE key (`ceramic_porcelain_tile`) stands
656
- * for itself, and a mixed list is their deduplicated union. A key in neither
657
- * tier is a 400 listing the valid keys — never an empty page.
658
- */
659
- types?: string;
660
- }
637
+ export type { SearchGetParams, SearchInput } from "./search-request-types.js";
661
638
  /** Options for a batch material read. */
662
639
  export interface MaterialsBatchOptions {
663
640
  /** Projection depth. `tile` omits fields rather than reporting them absent. */
@@ -691,6 +668,13 @@ export interface ResolveAttributeOptions {
691
668
  /** Options for `search/specifications`. */
692
669
  export interface BySpecOptions {
693
670
  limit?: number;
671
+ /**
672
+ * Numeric range criteria as `attribute:min..max` strings, ANDed with the
673
+ * `spec` criteria. Build them with `specRangeCriterion` rather than by hand —
674
+ * both bounds are inclusive and at least one is required, and a hand-built
675
+ * token with neither is refused by the server rather than matching everything.
676
+ */
677
+ ranges?: string[];
694
678
  }
695
679
  export type * from "./demand-types.js";
696
680
  /** One quarter's tally. Sparse across a series — an absent quarter is not a zero. */
@@ -731,31 +715,7 @@ export interface ExtractedPalette {
731
715
  export interface ExtractPaletteResponse {
732
716
  palette: ExtractedPalette | null;
733
717
  }
734
- export interface SpecFacetValue {
735
- value: string;
736
- variants: number;
737
- brands: number;
738
- label?: string;
739
- }
740
- export interface SpecFacet {
741
- attributeId: string;
742
- name: string;
743
- isArray: boolean;
744
- brands: number;
745
- values: SpecFacetValue[];
746
- description?: string;
747
- }
748
- export interface SpecFacetsListResponse {
749
- mode: "facets";
750
- count: number;
751
- facets: SpecFacet[];
752
- }
753
- export interface SpecFacetsDistributionResponse {
754
- mode: "distribution";
755
- attributeId: string;
756
- count: number;
757
- values: SpecFacetValue[];
758
- }
718
+ export type * from "./spec-facet-types.js";
759
719
  export interface ApplicableSpecAttribute {
760
720
  id: string;
761
721
  name: string;
package/validation.d.ts CHANGED
@@ -34,6 +34,18 @@ export declare function requireOptionalString(value: unknown, what: string, fail
34
34
  export declare function requireNullableString(value: unknown, what: string, fail: ValidationFailure): string | null;
35
35
  /** A `z.string().min(1).nullable()`. */
36
36
  export declare function requireNullableNonEmptyString(value: unknown, what: string, fail: ValidationFailure): string | null;
37
+ /**
38
+ * A `z.string().trim().min(1).max(max)`.
39
+ *
40
+ * The `.trim()` is a TRANSFORM, so the schema's parsed output is the trimmed
41
+ * string and its length bounds apply AFTER trimming. A mirror that checked the
42
+ * raw length would disagree with the schema twice over: it would accept a
43
+ * whitespace-only value the schema rejects, and reject a padded value the schema
44
+ * accepts. Returns the trimmed string, which is what the schema returns.
45
+ */
46
+ export declare function requireTrimmedBoundedString(value: unknown, what: string, max: number, fail: ValidationFailure): string;
47
+ /** A `z.string().trim().min(1).max(max).nullable()`. */
48
+ export declare function requireNullableTrimmedBoundedString(value: unknown, what: string, max: number, fail: ValidationFailure): string | null;
37
49
  /** A `z.boolean()`. */
38
50
  export declare function requireBoolean(value: unknown, what: string, fail: ValidationFailure): boolean;
39
51
  /** A `z.boolean().optional()`. */
package/validation.js CHANGED
@@ -55,6 +55,29 @@ export function requireNullableNonEmptyString(value, what, fail) {
55
55
  return null;
56
56
  return requireNonEmptyString(value, what, fail);
57
57
  }
58
+ /**
59
+ * A `z.string().trim().min(1).max(max)`.
60
+ *
61
+ * The `.trim()` is a TRANSFORM, so the schema's parsed output is the trimmed
62
+ * string and its length bounds apply AFTER trimming. A mirror that checked the
63
+ * raw length would disagree with the schema twice over: it would accept a
64
+ * whitespace-only value the schema rejects, and reject a padded value the schema
65
+ * accepts. Returns the trimmed string, which is what the schema returns.
66
+ */
67
+ export function requireTrimmedBoundedString(value, what, max, fail) {
68
+ const text = requireString(value, what, fail).trim();
69
+ if (text.length === 0)
70
+ throw fail(`${what} is empty once trimmed`);
71
+ if (text.length > max)
72
+ throw fail(`${what} is longer than ${max} characters (${text.length})`);
73
+ return text;
74
+ }
75
+ /** A `z.string().trim().min(1).max(max).nullable()`. */
76
+ export function requireNullableTrimmedBoundedString(value, what, max, fail) {
77
+ if (value === null)
78
+ return null;
79
+ return requireTrimmedBoundedString(value, what, max, fail);
80
+ }
58
81
  /** A `z.boolean()`. */
59
82
  export function requireBoolean(value, what, fail) {
60
83
  if (typeof value !== "boolean")