@instruments/materialgraph 0.12.0 → 0.12.1

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/browse-types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ByColourSwatch } from "./by-colour-types.js";
1
2
  /**
2
3
  * Wire types for the browse lane — GET `/api/v1/search/browse`.
3
4
  *
@@ -12,7 +13,7 @@
12
13
  * Nothing about the client's surface changed: `index.ts` re-exports these
13
14
  * exactly as before.
14
15
  */
15
- import type { ByColourSwatch } from "./by-colour-types.js";
16
+ import type { CorpusScope, CorpusScopeReceipt } from "./corpus-scope-types.js";
16
17
  import type { Cursor, ProductId, VariantId } from "./identifiers.js";
17
18
  import type { CountedTotal, OpenEnum } from "./types.js";
18
19
  /** One item from the pure colour-scalar browse. */
@@ -88,11 +89,7 @@ export interface BrowseResponse {
88
89
  * narrowed it. ABSENT means the whole graph: every material MaterialGraph
89
90
  * knows, orderable or not.
90
91
  */
91
- corpusScope?: {
92
- scope: OpenEnum<"all" | "materialBankOrderable">;
93
- /** What the scope admits, in one sentence, for a human reader. */
94
- description: string;
95
- };
92
+ corpusScope?: CorpusScopeReceipt;
96
93
  }
97
94
  /** Params for the pure colour `search/browse` surface. */
98
95
  export interface BrowseParams {
@@ -143,5 +140,5 @@ export interface BrowseParams {
143
140
  * scoped page is still a full page and `cursor` still means another eligible
144
141
  * row exists.
145
142
  */
146
- corpusScope?: OpenEnum<"all" | "materialBankOrderable">;
143
+ corpusScope?: CorpusScope;
147
144
  }
@@ -6,6 +6,7 @@
6
6
  * budget. Same tolerant-parsing contract as the rest of the mirror — response
7
7
  * enums are widened so a new server value still type-checks.
8
8
  */
9
+ import type { CorpusScopeReceipt } from "./corpus-scope-types.js";
9
10
  import type { VariantId } from "./identifiers.js";
10
11
  import type { OklabTriple, OpenEnum, PaletteSwatch } from "./types.js";
11
12
  /** Coarse group + fine type of a colour match's primary product type. */
@@ -186,11 +187,7 @@ export interface ByColourResponse {
186
187
  * counts describe the SCOPED corpus too, because the scope was applied inside
187
188
  * retrieval rather than over the page it produced.
188
189
  */
189
- corpusScope?: {
190
- scope: OpenEnum<"all" | "materialBankOrderable">;
191
- /** What the scope admits, in one sentence, for a human reader. */
192
- description: string;
193
- };
190
+ corpusScope?: CorpusScopeReceipt;
194
191
  }
195
192
  /** Params for `search/colours`. */
196
193
  export interface ByColourParams {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The corpus scope vocabulary, declared ONCE for the whole client.
3
+ *
4
+ * ── Why this file exists ──
5
+ * Until MG-1168 the union was spelled inline at six places across the SDK, and
6
+ * five of them had gone stale: `designShopOrderable` shipped in MG-1144 and
7
+ * reached only the search request type, so a TypeScript consumer could send the
8
+ * scope to `/search` and not to `/browse` — against a server that had accepted
9
+ * it on both since the day it landed. The narrowed RECEIPT types were worse
10
+ * than the request ones, because a caller could not even name the scope it got
11
+ * back.
12
+ *
13
+ * That is the third hop of the reachability checklist failing quietly: server
14
+ * correct, published client unable to reach the surface. One alias means the
15
+ * next member is one edit here rather than six edits nobody is reminded to
16
+ * make. `corpus-scope-parity.test.ts` in `apps/web/config` fails if this file
17
+ * falls behind `CORPUS_SCOPES`.
18
+ *
19
+ * ── Why `OpenEnum` ──
20
+ * The server may add a scope before a consumer upgrades. `OpenEnum` keeps the
21
+ * known members as autocomplete while still accepting a string the server knows
22
+ * and this build does not, so a new scope is not a compile error in a client
23
+ * that simply has not been republished yet.
24
+ */
25
+ import { type OpenEnum } from "./types.js";
26
+ /**
27
+ * Every corpus a caller may ask for.
28
+ *
29
+ * `"all"` (and absence) is the WHOLE GRAPH — every material MaterialGraph
30
+ * knows, orderable or not. Absence is never a narrower default.
31
+ *
32
+ * Three members gate on CHANNEL membership, which is what "can someone buy
33
+ * this" means: `"materialBankNaOrderable"` (Material Bank North America),
34
+ * `"materialBankEuOrderable"` (Material Bank Europe) and
35
+ * `"designShopOrderable"` (DesignShop, with the products DesignShop hides from
36
+ * browse excluded).
37
+ *
38
+ * `"materialBankOrderable"` is NOT one of them, despite the name. It selects
39
+ * materials carrying a Material Bank identity — PROVENANCE, not a storefront —
40
+ * and admits about 98% of the graph. Use it to exclude the crawl-sourced brands
41
+ * that were never in the Material Bank feed; if you mean orderable, use one of
42
+ * the three above.
43
+ */
44
+ export type CorpusScope = OpenEnum<"all" | "designShopOrderable" | "materialBankEuOrderable" | "materialBankNaOrderable" | "materialBankOrderable">;
45
+ /**
46
+ * The corpus a response AND its counts were drawn from. Present only when a
47
+ * narrowing scope applied — absence means the whole graph. Read it alongside
48
+ * the counts: a short page under a scope is a small SCOPED corpus, never a
49
+ * truncated one.
50
+ */
51
+ export interface CorpusScopeReceipt {
52
+ scope: CorpusScope;
53
+ /** What the scope admits, in one sentence, for a human reader. */
54
+ description: string;
55
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The corpus scope vocabulary, declared ONCE for the whole client.
3
+ *
4
+ * ── Why this file exists ──
5
+ * Until MG-1168 the union was spelled inline at six places across the SDK, and
6
+ * five of them had gone stale: `designShopOrderable` shipped in MG-1144 and
7
+ * reached only the search request type, so a TypeScript consumer could send the
8
+ * scope to `/search` and not to `/browse` — against a server that had accepted
9
+ * it on both since the day it landed. The narrowed RECEIPT types were worse
10
+ * than the request ones, because a caller could not even name the scope it got
11
+ * back.
12
+ *
13
+ * That is the third hop of the reachability checklist failing quietly: server
14
+ * correct, published client unable to reach the surface. One alias means the
15
+ * next member is one edit here rather than six edits nobody is reminded to
16
+ * make. `corpus-scope-parity.test.ts` in `apps/web/config` fails if this file
17
+ * falls behind `CORPUS_SCOPES`.
18
+ *
19
+ * ── Why `OpenEnum` ──
20
+ * The server may add a scope before a consumer upgrades. `OpenEnum` keeps the
21
+ * known members as autocomplete while still accepting a string the server knows
22
+ * and this build does not, so a new scope is not a compile error in a client
23
+ * that simply has not been republished yet.
24
+ */
25
+ import {} from "./types.js";
package/errors.d.ts CHANGED
@@ -17,6 +17,12 @@ export interface ProblemDetail {
17
17
  suggestions?: string[];
18
18
  doc_uri?: string;
19
19
  issues?: SanitizedIssue[];
20
+ /**
21
+ * MaterialGraph's documented RFC 7807 extension members, sanitized by the
22
+ * same rules as every other nested value here. Absent when the response did
23
+ * not carry them. See {@link MATERIALGRAPH_PROBLEM_EXTENSIONS}.
24
+ */
25
+ readonly [extension: string]: unknown;
20
26
  }
21
27
  /** Normalized residual `{ error }` body. Unknown response fields are discarded. */
22
28
  export interface SimpleErrorBody {
@@ -61,6 +67,29 @@ interface TransportErrorInput extends MaterialGraphRequestMetadata {
61
67
  cause: unknown;
62
68
  callerSignal?: AbortSignal;
63
69
  }
70
+ /**
71
+ * MaterialGraph's documented problem EXTENSION members — the only non-standard
72
+ * keys this client retains.
73
+ *
74
+ * ── Why an allow-list and not "keep everything" ──
75
+ * Dropping unknown members is a deliberate decision here, pinned by a test that
76
+ * sends `unknown_top_level: "must-not-be-retained"` and asserts it is gone. A
77
+ * client should not echo arbitrary server-supplied keys into an object the
78
+ * caller trusts. That decision stands.
79
+ *
80
+ * ── Why not "drop everything", which is what it used to do ──
81
+ * MaterialGraph sends these members precisely so a caller can BRANCH on a
82
+ * refusal rather than parse its prose, and dropping them silently forced the
83
+ * opposite. A consumer reported it on 2026-08-21: they received
84
+ * `populated_alternatives` on the wire, found it absent from the thrown error,
85
+ * and were left matching a regex against `detail` — the exact coupling the
86
+ * field was added to remove. All ten members below were unreachable.
87
+ *
88
+ * So: the server's own contract is retained, and nothing else is. Adding a
89
+ * member to a problem response means adding it here too, and
90
+ * `problem-extension-parity.test.ts` fails when the two disagree.
91
+ */
92
+ export declare const MATERIALGRAPH_PROBLEM_EXTENSIONS: readonly ["content_length", "did_you_mean", "empty_brands", "limit_bytes", "missing_env", "populated_alternatives", "unknown_attributes", "unknown_brands", "valid_group_keys", "valid_type_keys"];
64
93
  /** The one error class thrown by all SDK transport and HTTP failures. */
65
94
  export declare class MaterialGraphError extends Error {
66
95
  readonly kind: MaterialGraphErrorKind;
package/errors.js CHANGED
@@ -1,5 +1,40 @@
1
1
  /** Error and rate-limit contracts for the retry-free MaterialGraph SDK. */
2
2
  const RETRIABLE_STATUS = new Set([502, 503, 504]);
3
+ /**
4
+ * MaterialGraph's documented problem EXTENSION members — the only non-standard
5
+ * keys this client retains.
6
+ *
7
+ * ── Why an allow-list and not "keep everything" ──
8
+ * Dropping unknown members is a deliberate decision here, pinned by a test that
9
+ * sends `unknown_top_level: "must-not-be-retained"` and asserts it is gone. A
10
+ * client should not echo arbitrary server-supplied keys into an object the
11
+ * caller trusts. That decision stands.
12
+ *
13
+ * ── Why not "drop everything", which is what it used to do ──
14
+ * MaterialGraph sends these members precisely so a caller can BRANCH on a
15
+ * refusal rather than parse its prose, and dropping them silently forced the
16
+ * opposite. A consumer reported it on 2026-08-21: they received
17
+ * `populated_alternatives` on the wire, found it absent from the thrown error,
18
+ * and were left matching a regex against `detail` — the exact coupling the
19
+ * field was added to remove. All ten members below were unreachable.
20
+ *
21
+ * So: the server's own contract is retained, and nothing else is. Adding a
22
+ * member to a problem response means adding it here too, and
23
+ * `problem-extension-parity.test.ts` fails when the two disagree.
24
+ */
25
+ export const MATERIALGRAPH_PROBLEM_EXTENSIONS = [
26
+ "content_length",
27
+ "did_you_mean",
28
+ "empty_brands",
29
+ "limit_bytes",
30
+ "missing_env",
31
+ "populated_alternatives",
32
+ "unknown_attributes",
33
+ "unknown_brands",
34
+ "valid_group_keys",
35
+ "valid_type_keys",
36
+ ];
37
+ const PROBLEM_EXTENSION_KEYS = new Set(MATERIALGRAPH_PROBLEM_EXTENSIONS);
3
38
  const MAX_UNEXPECTED_TEXT = 768;
4
39
  const MAX_EXTENSION_DEPTH = 8;
5
40
  const MAX_EXTENSION_ITEMS = 100;
@@ -89,7 +124,12 @@ function normalizeProblem(body, secrets) {
89
124
  }
90
125
  const suggestions = sanitizeSuggestions(body.suggestions, secrets);
91
126
  const issues = sanitizeIssues(body.issues, secrets);
127
+ // Extensions FIRST so a malformed one can never shadow a standard member
128
+ // below. `sanitizeRecord` is the existing redaction and depth/item caps —
129
+ // this is that machinery finally being reached, not a second sanitizer.
130
+ const extensions = sanitizeRecord(Object.fromEntries(Object.entries(body).filter(([key]) => PROBLEM_EXTENSION_KEYS.has(key))), secrets, 0);
92
131
  return {
132
+ ...extensions,
93
133
  type: safeString(body.type, secrets),
94
134
  title: safeString(body.title, secrets),
95
135
  status: body.status,
@@ -1,3 +1,4 @@
1
+ import type { CorpusScope, CorpusScopeReceipt } from "./corpus-scope-types.js";
1
2
  import type { VariantId } from "./identifiers.js";
2
3
  export type GraphOperator = "colourways" | "same_category" | "still_listed" | "looks_like" | "same_colour" | "pairs_with" | "often_specified_with" | "substitutes_for" | "more_durable" | "outdoor_suitable" | "lower_cost" | "more_sustainable";
3
4
  export interface GraphOperatorAvailability {
@@ -14,7 +15,7 @@ export interface GraphOperatorEvidence {
14
15
  }
15
16
  export interface CandidateReadInput {
16
17
  operator: "looks_like";
17
- corpusScope?: "all" | "materialBankOrderable";
18
+ corpusScope?: CorpusScope;
18
19
  limit?: number;
19
20
  excludeVariantIds?: VariantId[];
20
21
  }
@@ -62,10 +63,7 @@ export interface CandidateSeedEvidence {
62
63
  }
63
64
  export interface CandidateReadReceipt {
64
65
  operator: "looks_like";
65
- corpusScope: {
66
- scope: "all" | "materialBankOrderable";
67
- description: string;
68
- } | null;
66
+ corpusScope: CorpusScopeReceipt | null;
69
67
  requestedLimit: number;
70
68
  returnedCount: number;
71
69
  exclusions: {
package/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { PROJECT_EVENT_KINDS } from "./project-types.js";
14
14
  export type * from "./project-types.js";
15
15
  export { COMPOSITION_PHASE_EVENT_TYPES, COMPOSITION_PROGRAMME_EVENT_TYPES, COMPOSITION_REVIEW_CRITERIA_EVENT_TYPES, parseRunEvent, } from "./run-events.js";
16
16
  export type * from "./run-events.js";
17
- export { MaterialGraphError } from "./errors.js";
17
+ export { MATERIALGRAPH_PROBLEM_EXTENSIONS, MaterialGraphError, } from "./errors.js";
18
18
  export type { MaterialGraphErrorInit, MaterialGraphErrorKind, MaterialGraphRequestMetadata, ProblemDetail, RateLimit, SanitizedErrorValue, SanitizedIssue, SimpleErrorBody, } from "./errors.js";
19
19
  export type * from "./types.js";
20
20
  export type * from "./match-types.js";
package/index.js CHANGED
@@ -4,7 +4,7 @@ export { TERMINAL_RUN_EVENT_TYPES } from "./run-types.js";
4
4
  export { VARY_AXES } from "./composition-types.js";
5
5
  export { PROJECT_EVENT_KINDS } from "./project-types.js";
6
6
  export { COMPOSITION_PHASE_EVENT_TYPES, COMPOSITION_PROGRAMME_EVENT_TYPES, COMPOSITION_REVIEW_CRITERIA_EVENT_TYPES, parseRunEvent, } from "./run-events.js";
7
- export { MaterialGraphError } from "./errors.js";
7
+ export { MATERIALGRAPH_PROBLEM_EXTENSIONS, MaterialGraphError, } from "./errors.js";
8
8
  export { MATCH_IMAGE_TYPES, MATCH_OUTCOMES, MATCH_MATERIAL_FUNCTIONS, MATCH_STREAM_EVENT_TYPES, MATCH_SURFACE_STATUSES, MATCH_WORKFLOW_STEPS, } from "./match-stream-types.js";
9
9
  export { CodesClient } from "./codes-client.js";
10
10
  export { ColoursClient } from "./colours-client.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@instruments/materialgraph",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -28,14 +28,21 @@ export interface SearchFilterTrust {
28
28
  minConfidence?: number;
29
29
  /**
30
30
  * Demand an EXACT source basis (closed enum — an unknown basis is rejected).
31
- * Roughly strongest to weakest as evidence:
32
- * - `tested` — a measured result. The strongest claim available.
31
+ * Strongest to weakest as evidence:
33
32
  * - `declared` — the manufacturer stated it. Attributable, not independent.
34
33
  * - `catalog_feed` — it arrived in a catalog feed. Trusted at ingest, unreviewed.
35
34
  * - `derived` — MaterialGraph inferred it. Ours, not the manufacturer's — never
36
35
  * present a `derived` value as something the brand claims.
36
+ *
37
+ * There is NO `tested` basis. This docblock advertised one as "the strongest
38
+ * claim available" through 0.12.0, and the union listed `declared` twice in
39
+ * its place — so a caller reading the documentation would write a value the
40
+ * server rejects with a 400, and the type would not stop them because the
41
+ * fourth arm was a duplicate rather than the value described.
42
+ * `packages/schema/src/source-basis.test.ts` asserts `tested` parses false;
43
+ * its absence is deliberate, not an omission.
37
44
  */
38
- sourceBasis?: "catalog_feed" | "declared" | "derived" | "declared";
45
+ sourceBasis?: "catalog_feed" | "declared" | "derived";
39
46
  /** Fold the attribute's own registry confidence requirement in, server-side. */
40
47
  meetsRequirement?: true;
41
48
  }
@@ -49,6 +56,22 @@ export type SearchFilter = {
49
56
  attributeId: string;
50
57
  term: string;
51
58
  trust?: SearchFilterTrust;
59
+ }
60
+ /**
61
+ * TERM SET — the attribute must equal any ONE of these. OR within the entry;
62
+ * AND still holds between entries, so a filter rail composes the same way
63
+ * whatever it is filtering on.
64
+ *
65
+ * The server has accepted this since the term-set arm shipped, and a consumer
66
+ * has been sending it successfully on the wire — but it was missing from this
67
+ * union through 0.12.0, so multi-value filtering type-checked nowhere while
68
+ * working everywhere. Brand multi-select is the common case:
69
+ * `{attributeId: "brand", terms: ["kvadrat", "interface"]}`.
70
+ */
71
+ | {
72
+ attributeId: string;
73
+ terms: string[];
74
+ trust?: SearchFilterTrust;
52
75
  } | {
53
76
  attributeId: string;
54
77
  hasValue: true;
@@ -5,6 +5,7 @@
5
5
  * repo's source-size gate; every name here is re-exported from `types.ts`, so
6
6
  * no import path changes.
7
7
  */
8
+ import type { CorpusScope } from "./corpus-scope-types.js";
8
9
  import type { Cursor, VariantId } from "./identifiers.js";
9
10
  import type { SearchFilter } from "./search-refinement-types.js";
10
11
  import type { ColourInput, OpenEnum, SearchImageInput } from "./types.js";
@@ -126,10 +127,17 @@ export interface SearchInput {
126
127
  * **ABSENT means the WHOLE GRAPH** — every material MaterialGraph knows,
127
128
  * orderable or not. Absence is never a narrower default, so a caller that
128
129
  * says nothing gets materials it may not be able to buy.
129
- * `"materialBankOrderable"` restricts retrieval to materials carrying a
130
- * Material Bank identity. `"designShopOrderable"` restricts it to what a
131
- * consumer can buy on DesignShop today — about 16,200 materials, with the
132
- * products DesignShop hides from browse excluded.
130
+ * Three scopes gate on CHANNEL membership, which is what "can someone buy
131
+ * this" means: `"materialBankNaOrderable"` (Material Bank North America,
132
+ * ~277,000 materials), `"materialBankEuOrderable"` (Material Bank Europe,
133
+ * ~65,000) and `"designShopOrderable"` (DesignShop, ~34,000, with the
134
+ * products DesignShop hides from browse excluded).
135
+ *
136
+ * `"materialBankOrderable"` is NOT one of them, despite the name. It selects
137
+ * materials carrying a Material Bank identity — a PROVENANCE signal, not a
138
+ * storefront — and admits about 98% of the graph. Use it to exclude the
139
+ * crawl-sourced brands that were never in the Material Bank feed; if you mean
140
+ * orderable, use one of the three above.
133
141
  *
134
142
  * A scope answers what is BUYABLE. It never answers what is SUITABLE: scope
135
143
  * on channel, then refine on sector. A trade-only material can be right for a
@@ -142,7 +150,7 @@ export interface SearchInput {
142
150
  * shortens the page exactly when the query drifts toward the corpus beyond
143
151
  * the Material Bank feed, which is unpredictable from outside.
144
152
  */
145
- corpusScope?: OpenEnum<"all" | "materialBankOrderable" | "designShopOrderable">;
153
+ corpusScope?: CorpusScope;
146
154
  /**
147
155
  * OPS/AUDIT escape hatch — do NOT use for normal search. Default behaviour
148
156
  * (absent or false) excludes sampling-artifact products: multi-swatch sample
package/types.d.ts CHANGED
@@ -1,4 +1,32 @@
1
1
  import type { BySpecVerdict } from "./by-spec-types.js";
2
+ /**
3
+ * Hand-mirrored wire types for the MaterialGraph public API (spec 0.5.0).
4
+ *
5
+ * These interfaces intentionally have NO runtime dependency on any
6
+ * `@materialgraph/*` package — they are a standalone, publishable mirror of the
7
+ * shapes in `apps/web/public/openapi.yaml` and the captured fixtures under
8
+ * `docs/surface/fixtures/v0.5.0/`. The `contract-parity` test re-checks them
9
+ * against the real Zod schemas so the mirror cannot silently drift.
10
+ * TOLERANT-PARSING CONTRACT: the public surface declares open response enums —
11
+ * "clients must tolerate unknown enum values and unknown fields". Every union
12
+ * that mirrors a server enum is therefore widened with `(string & {})` so a new
13
+ * server value still type-checks, and objects that may grow fields are read
14
+ * structurally (unknown keys pass through untouched — the client never strips).
15
+ *
16
+ * READING CONTRACT — the three things callers get wrong, stated once here and
17
+ * repeated on the types where each one bites:
18
+ * 1. **Ids are opaque.** Round-trip them verbatim; never parse or construct
19
+ * one. See `./identifiers.ts` for which strings are opaque tokens and which
20
+ * are canonical vocabulary you discover.
21
+ * 2. **Absent is not false.** An optional field that is missing means UNKNOWN,
22
+ * not "known to be absent in reality" — most dangerously for booleans.
23
+ * Values carry `confidence` and `sourceBasis`; report them as claims.
24
+ * 3. **A page is not a corpus.** `results.length` is this page.
25
+ * {@link CountedTotal} — with the `basis` it states — is the claim about the
26
+ * match set. Follow the cursor for the rest, and never report a page size as
27
+ * a total.
28
+ */
29
+ import type { CorpusScopeReceipt } from "./corpus-scope-types.js";
2
30
  import type { Cursor, ProductId, VariantId } from "./identifiers.js";
3
31
  import type { MaterialDetail, MaterialTile } from "./material-types.js";
4
32
  import type { SearchDisambiguation, SearchSuggestion } from "./search-refinement-types.js";
@@ -388,11 +416,7 @@ export interface SearchResponse {
388
416
  * MaterialGraph knows, orderable or not. Read it alongside `counts`: a short
389
417
  * page under a scope is a small SCOPED corpus, not a truncated one.
390
418
  */
391
- corpusScope?: {
392
- scope: OpenEnum<"all" | "materialBankOrderable">;
393
- /** What the scope admits, in one sentence, for a human reader. */
394
- description: string;
395
- };
419
+ corpusScope?: CorpusScopeReceipt;
396
420
  /** Echo of the resolved query input (raw image bytes/vector are never echoed). */
397
421
  query?: Record<string, unknown>;
398
422
  /** A `getVariantBrief` echo of the seed variant, present only in `variantId` seed mode. */