@instruments/materialgraph 0.1.0 → 0.3.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 (56) hide show
  1. package/by-spec-types.d.ts +73 -0
  2. package/by-spec-types.js +7 -0
  3. package/client.d.ts +186 -7
  4. package/client.js +242 -125
  5. package/codes-client.d.ts +82 -0
  6. package/codes-client.js +73 -0
  7. package/codes-types.d.ts +238 -0
  8. package/codes-types.js +1 -0
  9. package/colour-aggregate-types.d.ts +93 -0
  10. package/colour-aggregate-types.js +1 -0
  11. package/colours-client.d.ts +52 -0
  12. package/colours-client.js +39 -0
  13. package/composition-types.d.ts +343 -0
  14. package/composition-types.js +10 -0
  15. package/http-transport.d.ts +74 -0
  16. package/http-transport.js +201 -0
  17. package/index.d.ts +19 -0
  18. package/index.js +9 -0
  19. package/match-stream-types.d.ts +150 -0
  20. package/match-stream-types.js +28 -0
  21. package/match-stream.d.ts +22 -0
  22. package/match-stream.js +280 -0
  23. package/material-block-types.d.ts +23 -2
  24. package/material-types.d.ts +92 -0
  25. package/material-types.js +1 -0
  26. package/ndjson.d.ts +46 -0
  27. package/ndjson.js +103 -0
  28. package/operations.d.ts +6 -2
  29. package/operations.js +30 -2
  30. package/package.json +1 -1
  31. package/project-types.d.ts +67 -0
  32. package/project-types.js +16 -0
  33. package/projects.d.ts +13 -0
  34. package/projects.js +46 -0
  35. package/run-events.d.ts +82 -0
  36. package/run-events.js +29 -0
  37. package/run-types.d.ts +266 -0
  38. package/run-types.js +6 -0
  39. package/runs.d.ts +84 -0
  40. package/runs.js +253 -0
  41. package/schedule-types.d.ts +88 -0
  42. package/schedule-types.js +12 -0
  43. package/search-refinement-types.d.ts +26 -1
  44. package/search-result-validation.d.ts +27 -0
  45. package/search-result-validation.js +174 -0
  46. package/source-material-validation.d.ts +3 -0
  47. package/source-material-validation.js +109 -0
  48. package/spec-types.d.ts +82 -0
  49. package/spec-types.js +44 -0
  50. package/specifications.d.ts +72 -0
  51. package/specifications.js +82 -0
  52. package/sse.d.ts +35 -0
  53. package/sse.js +147 -0
  54. package/types.d.ts +204 -77
  55. package/validation.d.ts +119 -0
  56. package/validation.js +241 -0
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The Material Match PROGRESS-STREAM contracts — the wire vocabulary of
3
+ * {@link MaterialGraphClient.matchStream} (`POST /api/v1/search/match` under
4
+ * `Accept: application/x-ndjson`). One JSON object per line; the run ends on a
5
+ * `complete` or an `error` event.
6
+ *
7
+ * These shapes are HAND-MIRRORED from `matchStreamEventSchema` in
8
+ * `@materialgraph/products` — the SDK publishes with zero runtime dependencies,
9
+ * so it cannot import the Zod schema itself. `contract-parity.test.ts` re-checks
10
+ * the mirror against the real schema, deriving the enum members FROM the
11
+ * canonical schemas so a server-side addition fails that test rather than
12
+ * silently widening the client.
13
+ *
14
+ * Every enum here is CLOSED, exactly as the canonical `z.enum(…)` members are.
15
+ * The value lists are exported so a consumer can build an exhaustive switch or a
16
+ * UI legend from the same source the validator uses.
17
+ */
18
+ import type { MatchCategoryInference, SearchResultItem } from "./types.js";
19
+ /** How the workflow classified the uploaded image. */
20
+ export declare const MATCH_IMAGE_TYPES: readonly ["swatch", "installation", "flatplan"];
21
+ export type MatchImageType = (typeof MATCH_IMAGE_TYPES)[number];
22
+ /** What a detected surface is FOR — the element the material would serve. */
23
+ export declare const MATCH_MATERIAL_FUNCTIONS: readonly ["flooring", "wall_surface", "ceiling", "partition", "upholstery", "millwork", "countertop", "accent", "sample"];
24
+ export type MatchMaterialFunction = (typeof MATCH_MATERIAL_FUNCTIONS)[number];
25
+ /** The workflow's coarse progress phase, narrated by a `step` event. */
26
+ export declare const MATCH_WORKFLOW_STEPS: readonly ["classifying", "decomposing", "matching", "complete"];
27
+ export type MatchWorkflowStep = (typeof MATCH_WORKFLOW_STEPS)[number];
28
+ /** Where one surface stands in the run. */
29
+ export declare const MATCH_SURFACE_STATUSES: readonly ["pending", "matching", "complete", "error"];
30
+ export type MatchSurfaceStatus = (typeof MATCH_SURFACE_STATUSES)[number];
31
+ /** Every frame type the stream can carry — the union's discriminant values. */
32
+ export declare const MATCH_STREAM_EVENT_TYPES: readonly ["step", "classified", "surfaces", "surface", "heartbeat", "complete", "error"];
33
+ export type MatchStreamEventType = (typeof MATCH_STREAM_EVENT_TYPES)[number];
34
+ /** Where a surface sits in the image, in Gemini-style 0–1000 image space. */
35
+ export interface MatchBoundingBox {
36
+ x0: number;
37
+ y0: number;
38
+ x1: number;
39
+ y1: number;
40
+ }
41
+ /** One surface the decompose step found in the image. */
42
+ export interface MatchDetectedSurface {
43
+ id: string;
44
+ label: string;
45
+ materialFunction: MatchMaterialFunction;
46
+ /** The detector's catalog category answer, when it named one. */
47
+ categoryHint?: string;
48
+ /** Detector confidence, always within [0, 1]. */
49
+ confidence: number;
50
+ description?: string;
51
+ boundingBox: MatchBoundingBox;
52
+ /** JPEG data-URI crop used for Match retrieval — the thumbnail to show. */
53
+ cropDataUri?: string;
54
+ }
55
+ /**
56
+ * One surface's matching state and its ranked results. The same `surface.id`
57
+ * arrives repeatedly as the run progresses (`matching` → `complete` / `error`);
58
+ * render by replacing on id rather than appending.
59
+ *
60
+ * `results` and `inferredCategories` are always present on a yielded event: the
61
+ * canonical schema defaults each to `[]`, and the reader fills them when the
62
+ * server omits them, so neither ever arrives as `undefined`.
63
+ */
64
+ export interface MatchSurfaceResult {
65
+ surface: MatchDetectedSurface;
66
+ status: MatchSurfaceStatus;
67
+ results: SearchResultItem[];
68
+ inferredCategories: MatchCategoryInference[];
69
+ /** Present only on `status: "error"` — why this one surface failed. */
70
+ error?: string;
71
+ }
72
+ /** The terminal Match workflow result — every surface in its final state. */
73
+ export interface MatchWorkflowResult {
74
+ imageType: MatchImageType;
75
+ surfaces: MatchSurfaceResult[];
76
+ /** Present for swatch / single-surface runs that skip decompose. */
77
+ results?: SearchResultItem[];
78
+ inferredCategories?: MatchCategoryInference[];
79
+ /**
80
+ * How the run ended, counted rather than inferred. `surfacesFailed > 0` with
81
+ * no results is a broken run; zero failures with no results is a genuine
82
+ * "nothing above the floor". Do not fold `surfaces[]` to tell those apart.
83
+ */
84
+ totals: MatchRunTotals;
85
+ }
86
+ /** Progress narration for the phase the workflow just entered. */
87
+ export interface MatchStreamStepEvent {
88
+ type: "step";
89
+ step: MatchWorkflowStep;
90
+ message: string;
91
+ }
92
+ /** The image type the classify step settled on. */
93
+ export interface MatchStreamClassifiedEvent {
94
+ type: "classified";
95
+ imageType: MatchImageType;
96
+ }
97
+ /** Every surface the decompose step found, before any of them has matched. */
98
+ export interface MatchStreamSurfacesEvent {
99
+ type: "surfaces";
100
+ surfaces: MatchDetectedSurface[];
101
+ }
102
+ /**
103
+ * The run's tally at the moment a `surface` event was emitted, so a consumer
104
+ * reads the server's arithmetic rather than reimplementing it.
105
+ *
106
+ * A FAILED surface is the case this exists to settle: failing is a way of
107
+ * RESOLVING. An errored surface counts in `surfacesResolved` (it will never be
108
+ * reported again), counts again in `surfacesFailed`, and adds nothing to
109
+ * `matchesSoFar`. `surfacesResolved === surfacesDetected` is therefore the
110
+ * correct "the run is done" test, and never stalls at 3/4 on a failure.
111
+ */
112
+ export interface MatchRunTotals {
113
+ /** Surfaces the decompose step found. The denominator; fixed for the run. */
114
+ surfacesDetected: number;
115
+ /** Surfaces that reached a terminal state — `complete` OR `error`. */
116
+ surfacesResolved: number;
117
+ /** Of those resolved, how many ended in `error`. Always ≤ `surfacesResolved`. */
118
+ surfacesFailed: number;
119
+ /** Result items accumulated across every surface resolved so far. */
120
+ matchesSoFar: number;
121
+ }
122
+ /** One surface's state changed — the frame that makes Match progressive. */
123
+ export interface MatchStreamSurfaceEvent {
124
+ type: "surface";
125
+ surface: MatchSurfaceResult;
126
+ /** The run's tally as of this frame. Always present on a `surface` event. */
127
+ totals: MatchRunTotals;
128
+ }
129
+ /**
130
+ * CONNECTION LIVENESS ONLY — never progress. Proxies and load balancers kill
131
+ * idle connections, so the server sends this when the stream has been quiet, and
132
+ * a client can tell a slow run from a dropped one. It carries no payload, and no
133
+ * consumer should treat it as a state change: do not advance a step, reset a
134
+ * no-progress watchdog, or count it towards anything.
135
+ */
136
+ export interface MatchStreamHeartbeatEvent {
137
+ type: "heartbeat";
138
+ }
139
+ /** Terminal: the whole run finished and carries its assembled result. */
140
+ export interface MatchStreamCompleteEvent {
141
+ type: "complete";
142
+ result: MatchWorkflowResult;
143
+ }
144
+ /** Terminal: the run failed as a whole (a single surface failing is not this). */
145
+ export interface MatchStreamErrorEvent {
146
+ type: "error";
147
+ message: string;
148
+ }
149
+ /** One frame of the Match progress stream, discriminated by `type`. */
150
+ export type MatchStreamEvent = MatchStreamStepEvent | MatchStreamClassifiedEvent | MatchStreamSurfacesEvent | MatchStreamSurfaceEvent | MatchStreamHeartbeatEvent | MatchStreamCompleteEvent | MatchStreamErrorEvent;
@@ -0,0 +1,28 @@
1
+ /** How the workflow classified the uploaded image. */
2
+ export const MATCH_IMAGE_TYPES = ["swatch", "installation", "flatplan"];
3
+ /** What a detected surface is FOR — the element the material would serve. */
4
+ export const MATCH_MATERIAL_FUNCTIONS = [
5
+ "flooring",
6
+ "wall_surface",
7
+ "ceiling",
8
+ "partition",
9
+ "upholstery",
10
+ "millwork",
11
+ "countertop",
12
+ "accent",
13
+ "sample",
14
+ ];
15
+ /** The workflow's coarse progress phase, narrated by a `step` event. */
16
+ export const MATCH_WORKFLOW_STEPS = ["classifying", "decomposing", "matching", "complete"];
17
+ /** Where one surface stands in the run. */
18
+ export const MATCH_SURFACE_STATUSES = ["pending", "matching", "complete", "error"];
19
+ /** Every frame type the stream can carry — the union's discriminant values. */
20
+ export const MATCH_STREAM_EVENT_TYPES = [
21
+ "step",
22
+ "classified",
23
+ "surfaces",
24
+ "surface",
25
+ "heartbeat",
26
+ "complete",
27
+ "error",
28
+ ];
@@ -0,0 +1,22 @@
1
+ import type { HttpTransport } from "./http-transport.js";
2
+ import { type MatchStreamEvent } from "./match-stream-types.js";
3
+ import type { MatchInput } from "./types.js";
4
+ /**
5
+ * Decode one NDJSON line into a validated {@link MatchStreamEvent}, raising a
6
+ * {@link MaterialGraphError} on anything the contract does not allow. Exported
7
+ * for `contract-parity.test.ts`, which re-checks it against the real Zod schema;
8
+ * it is not part of the published surface.
9
+ */
10
+ export declare function decodeMatchStreamEvent(line: string): MatchStreamEvent;
11
+ /**
12
+ * Stream Match progress as an async iterator. Iterates until the run reaches a
13
+ * `complete` or an `error` event, and raises if the connection ends before one
14
+ * arrives.
15
+ *
16
+ * `signal` genuinely cancels: it is linked to the AbortController that owns the
17
+ * in-flight request, so aborting (or simply `break`ing out of the loop) tears
18
+ * down the HTTP connection rather than merely stopping the yields. A
19
+ * caller-requested abort ends the iteration CLEANLY — the caller already knows
20
+ * it cancelled, so the truncated-stream error is not raised for it.
21
+ */
22
+ export declare function matchStream(transport: HttpTransport, input: MatchInput, signal?: AbortSignal): AsyncGenerator<MatchStreamEvent, void, unknown>;
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The Material Match PROGRESS STREAM — the streaming twin of the buffered
3
+ * `match()` read. `POST /api/v1/search/match` with `Accept:
4
+ * application/x-ndjson` runs classify → decompose → one match per surface and
5
+ * emits an event per line as each surface resolves, so a `/match` UI can fill in
6
+ * progressively instead of showing a spinner until the whole run lands.
7
+ *
8
+ * FAIL-FAST BY CONTRACT. Every non-blank line is validated before it is yielded;
9
+ * a line that is not JSON, is not an object, carries an unknown `type`, an
10
+ * out-of-range number, a value outside a closed enum, or a field of the wrong
11
+ * kind raises a {@link MaterialGraphError} rather than being skipped. A stream
12
+ * that ends without a `complete` or an `error` event was truncated, and raises
13
+ * too — a short run and a dropped connection must never look alike. There is no
14
+ * retry or reconnect.
15
+ *
16
+ * HEARTBEATS ARE NOT PROGRESS. A scene match goes quiet for long stretches
17
+ * between surfaces, and proxies kill idle connections, so the server sends a
18
+ * payload-free `{ type: "heartbeat" }` when the stream has been silent. It is
19
+ * yielded like any other event — the connection being alive is worth knowing —
20
+ * but it carries nothing and marks no state change: never advance a step, reset
21
+ * a no-progress watchdog, or count anything on one. It is not terminal, and none
22
+ * arrives after `complete` or `error`.
23
+ *
24
+ * ACCEPTANCE PARITY WITH THE CANONICAL SCHEMA. The SDK cannot import
25
+ * `matchStreamEventSchema` at runtime (it publishes with zero dependencies), so
26
+ * this validator mirrors it by hand and `contract-parity.test.ts` proves the two
27
+ * ACCEPT and REJECT the same frames — with the enum members derived from the
28
+ * canonical schemas, so a server-side addition fails that test.
29
+ *
30
+ * OUTPUT IS PASS-THROUGH, NOT `parse` OUTPUT — a deliberate divergence. Zod
31
+ * objects strip unknown keys, so `matchStreamEventSchema.parse(frame)` DROPS any
32
+ * field the schema does not name. This reader keeps the frame as sent. Three
33
+ * reasons:
34
+ * 1. Nothing on the emit path parses. The route stringifies the workflow's own
35
+ * objects (`createMatchNdjsonStream`), so `parse` output is not what the wire
36
+ * carries; reshaping to match it would invent a normalisation that never
37
+ * happened.
38
+ * 2. Stripping needs the COMPLETE key inventory of `searchResultItemSchema`,
39
+ * whose closure reaches `candidateVerdictSchema` in `@materialgraph/contracts`
40
+ * — a recursive hand-mirror that would have to stay in step forever, and a
41
+ * wider surface than the validation it would serve.
42
+ * 3. An additive server field then reaches consumers without an SDK release,
43
+ * which matters while new EVENT types still require one (MG-658).
44
+ * The ONE exception is `.default([])`: an omitted `results` / `inferredCategories`
45
+ * is FILLED to `[]`, because {@link MatchSurfaceResult} types both as always
46
+ * present and the SDK must not hand back a type it has falsified.
47
+ *
48
+ * VALIDATION DEPTH — FULL. The frame envelope, every closed enum, every bounded
49
+ * number, every non-negative integer in a `surface` event's `totals`, every
50
+ * optional string, `MatchCategoryInference` items, and every
51
+ * `SearchResultItem` in `results[]` are checked exhaustively. Item validation is
52
+ * the shared `checkSearchResultItem` (`search-result-validation.ts`), which
53
+ * mirrors `searchResultItemSchema` and its whole closure down to
54
+ * `candidateVerdictSchema` — including both of its `.superRefine()` cross-field
55
+ * invariants.
56
+ */
57
+ import { MaterialGraphError } from "./errors.js";
58
+ import { findSdkOperation } from "./operations.js";
59
+ import { MATCH_IMAGE_TYPES, MATCH_MATERIAL_FUNCTIONS, MATCH_STREAM_EVENT_TYPES, MATCH_SURFACE_STATUSES, MATCH_WORKFLOW_STEPS, } from "./match-stream-types.js";
60
+ import { readNdjsonLines } from "./ndjson.js";
61
+ import { checkSearchResultItems } from "./search-result-validation.js";
62
+ import { requireArray, requireBoundedNumber, requireDefaultedArray, requireEnum, requireIntegerAtLeast, requireNumber, requireOptionalString, requireRecord, requireString, } from "./validation.js";
63
+ /** The NDJSON media type the Match route negotiates the scene workflow on. */
64
+ const MATCH_STREAM_ACCEPT = "application/x-ndjson";
65
+ /** How much of an offending frame a diagnostic quotes (crops can be megabytes). */
66
+ const MAX_FRAME_PREVIEW = 200;
67
+ const BOUNDING_BOX_CORNERS = Object.freeze(["x0", "y0", "x1", "y1"]);
68
+ function operation(operationId) {
69
+ const found = findSdkOperation(operationId);
70
+ if (!found)
71
+ throw MaterialGraphError.unexpected(`Unknown SDK operation: ${operationId}`);
72
+ return found;
73
+ }
74
+ /**
75
+ * The failure shape every check in this module raises: one message naming the
76
+ * offending path and quoting a bounded slice of the frame.
77
+ */
78
+ function frameFailure(line) {
79
+ const preview = line.length > MAX_FRAME_PREVIEW ? `${line.slice(0, MAX_FRAME_PREVIEW)}…` : line;
80
+ return (reason) => MaterialGraphError.unexpected(`MaterialGraph match stream sent a malformed NDJSON frame: ${reason}. Frame: ${preview}`);
81
+ }
82
+ /** Check one `MatchCategoryInference` item. */
83
+ function checkCategoryInference(value, what, fail) {
84
+ const record = requireRecord(value, what, fail);
85
+ requireString(record.productTypeId, `${what}.productTypeId`, fail);
86
+ requireString(record.displayName, `${what}.displayName`, fail);
87
+ requireNumber(record.boost, `${what}.boost`, fail);
88
+ }
89
+ function checkCategoryInferences(items, what, fail) {
90
+ items.forEach((item, index) => checkCategoryInference(item, `${what}[${index}]`, fail));
91
+ }
92
+ /** Check one detected surface — identity, function, confidence, and its box. */
93
+ function checkDetectedSurface(value, what, fail) {
94
+ const record = requireRecord(value, what, fail);
95
+ requireString(record.id, `${what}.id`, fail);
96
+ requireString(record.label, `${what}.label`, fail);
97
+ requireEnum(record.materialFunction, MATCH_MATERIAL_FUNCTIONS, `${what}.materialFunction`, fail);
98
+ requireOptionalString(record.categoryHint, `${what}.categoryHint`, fail);
99
+ requireOptionalString(record.description, `${what}.description`, fail);
100
+ requireOptionalString(record.cropDataUri, `${what}.cropDataUri`, fail);
101
+ requireBoundedNumber(record.confidence, `${what}.confidence`, fail, 0, 1);
102
+ const box = requireRecord(record.boundingBox, `${what}.boundingBox`, fail);
103
+ for (const corner of BOUNDING_BOX_CORNERS) {
104
+ requireNumber(box[corner], `${what}.boundingBox.${corner}`, fail);
105
+ }
106
+ }
107
+ /**
108
+ * Check one surface's matching state and fill the canonical `.default([])`
109
+ * arrays, so a consumer never meets `undefined` where the type promises an array.
110
+ */
111
+ function readSurfaceResult(value, what, fail) {
112
+ const record = requireRecord(value, what, fail);
113
+ checkDetectedSurface(record.surface, `${what}.surface`, fail);
114
+ requireEnum(record.status, MATCH_SURFACE_STATUSES, `${what}.status`, fail);
115
+ requireOptionalString(record.error, `${what}.error`, fail);
116
+ const results = requireDefaultedArray(record.results, `${what}.results`, fail);
117
+ checkSearchResultItems(results, `${what}.results`, fail);
118
+ const inferredCategories = requireDefaultedArray(record.inferredCategories, `${what}.inferredCategories`, fail);
119
+ checkCategoryInferences(inferredCategories, `${what}.inferredCategories`, fail);
120
+ return { ...record, results, inferredCategories };
121
+ }
122
+ /**
123
+ * `matchRunTotalsSchema` — four non-negative integers plus the TWO cross-field
124
+ * invariants its `.superRefine()` enforces. Relational rather than field-wise,
125
+ * which is no obstacle here: they are two comparisons, and without them a frame
126
+ * claiming "7 of 2 surfaces done" would reach a consumer that trusted the tally.
127
+ *
128
+ * `matchesSoFar` is deliberately unbounded against the surface counts — one
129
+ * surface can carry many results and another none, so no ratio is principled.
130
+ */
131
+ function checkRunTotals(value, what, fail) {
132
+ const record = requireRecord(value, what, fail);
133
+ const detected = requireIntegerAtLeast(record.surfacesDetected, `${what}.surfacesDetected`, fail, 0);
134
+ const resolved = requireIntegerAtLeast(record.surfacesResolved, `${what}.surfacesResolved`, fail, 0);
135
+ const failed = requireIntegerAtLeast(record.surfacesFailed, `${what}.surfacesFailed`, fail, 0);
136
+ requireIntegerAtLeast(record.matchesSoFar, `${what}.matchesSoFar`, fail, 0);
137
+ // superRefine invariant 1 — a failed surface is also a resolved one.
138
+ if (failed > resolved) {
139
+ throw fail(`${what}.surfacesFailed (${failed}) exceeds ${what}.surfacesResolved (${resolved}) — a failed surface is also a resolved one`);
140
+ }
141
+ // superRefine invariant 2 — a run cannot resolve more surfaces than it found.
142
+ if (resolved > detected) {
143
+ throw fail(`${what}.surfacesResolved (${resolved}) exceeds ${what}.surfacesDetected (${detected}) — a run cannot resolve more surfaces than it found`);
144
+ }
145
+ }
146
+ /** Check the terminal workflow result — image type, surfaces, optional roll-ups. */
147
+ function readWorkflowResult(value, what, fail) {
148
+ const record = requireRecord(value, what, fail);
149
+ requireEnum(record.imageType, MATCH_IMAGE_TYPES, `${what}.imageType`, fail);
150
+ const surfaces = requireArray(record.surfaces, `${what}.surfaces`, fail).map((surface, index) => readSurfaceResult(surface, `${what}.surfaces[${index}]`, fail));
151
+ // These two are `.optional()` on the workflow result, NOT `.default([])`:
152
+ // absent stays absent, matching the swatch path that skips decompose.
153
+ if (record.results !== undefined) {
154
+ checkSearchResultItems(requireArray(record.results, `${what}.results`, fail), `${what}.results`, fail);
155
+ }
156
+ if (record.inferredCategories !== undefined) {
157
+ checkCategoryInferences(requireArray(record.inferredCategories, `${what}.inferredCategories`, fail), `${what}.inferredCategories`, fail);
158
+ }
159
+ return { ...record, surfaces };
160
+ }
161
+ /**
162
+ * Validate one frame against the canonical contract and apply the schema's
163
+ * `.default([])` fills, returning the event as a plain record. Unknown keys ride
164
+ * through untouched — see the pass-through note in the module header.
165
+ */
166
+ function readFrame(value, fail) {
167
+ const record = requireRecord(value, "the frame", fail);
168
+ const type = requireString(record.type, "`type`", fail);
169
+ switch (type) {
170
+ case "step":
171
+ requireEnum(record.step, MATCH_WORKFLOW_STEPS, "`step`", fail);
172
+ requireString(record.message, "`message`", fail);
173
+ return record;
174
+ case "classified":
175
+ requireEnum(record.imageType, MATCH_IMAGE_TYPES, "`imageType`", fail);
176
+ return record;
177
+ case "surfaces": {
178
+ const surfaces = requireArray(record.surfaces, "`surfaces`", fail);
179
+ surfaces.forEach((surface, index) => checkDetectedSurface(surface, `surfaces[${index}]`, fail));
180
+ return record;
181
+ }
182
+ case "surface": {
183
+ // Payload before tally, so a malformed surface reports itself rather than
184
+ // being masked by whatever the totals looked like.
185
+ const surface = readSurfaceResult(record.surface, "`surface`", fail);
186
+ checkRunTotals(record.totals, "`totals`", fail);
187
+ return { ...record, surface };
188
+ }
189
+ case "heartbeat":
190
+ // Payload-free by contract — there is nothing else to check.
191
+ return record;
192
+ case "complete":
193
+ return { ...record, result: readWorkflowResult(record.result, "`result`", fail) };
194
+ case "error":
195
+ requireString(record.message, "`message`", fail);
196
+ return record;
197
+ default:
198
+ throw fail(`\`type\` is not a known Match stream event (${type})`);
199
+ }
200
+ }
201
+ /**
202
+ * The single narrowing boundary of this module. {@link readFrame} has already
203
+ * proven every field the union's shape depends on; re-checking the discriminant
204
+ * here keeps the assertion from being vacuous.
205
+ */
206
+ function assertMatchStreamEvent(frame, fail) {
207
+ const record = requireRecord(frame, "the frame", fail);
208
+ requireEnum(record.type, MATCH_STREAM_EVENT_TYPES, "`type`", fail);
209
+ }
210
+ /**
211
+ * Decode one NDJSON line into a validated {@link MatchStreamEvent}, raising a
212
+ * {@link MaterialGraphError} on anything the contract does not allow. Exported
213
+ * for `contract-parity.test.ts`, which re-checks it against the real Zod schema;
214
+ * it is not part of the published surface.
215
+ */
216
+ export function decodeMatchStreamEvent(line) {
217
+ const fail = frameFailure(line);
218
+ let parsed;
219
+ try {
220
+ parsed = JSON.parse(line);
221
+ }
222
+ catch (cause) {
223
+ throw fail(`it is not valid JSON (${String(cause)})`);
224
+ }
225
+ const frame = readFrame(parsed, fail);
226
+ assertMatchStreamEvent(frame, fail);
227
+ return frame;
228
+ }
229
+ function isTerminal(event) {
230
+ return event.type === "complete" || event.type === "error";
231
+ }
232
+ /**
233
+ * Stream Match progress as an async iterator. Iterates until the run reaches a
234
+ * `complete` or an `error` event, and raises if the connection ends before one
235
+ * arrives.
236
+ *
237
+ * `signal` genuinely cancels: it is linked to the AbortController that owns the
238
+ * in-flight request, so aborting (or simply `break`ing out of the loop) tears
239
+ * down the HTTP connection rather than merely stopping the yields. A
240
+ * caller-requested abort ends the iteration CLEANLY — the caller already knows
241
+ * it cancelled, so the truncated-stream error is not raised for it.
242
+ */
243
+ export async function* matchStream(transport, input, signal) {
244
+ if (signal?.aborted === true)
245
+ return;
246
+ // Our own controller, so abandoning the iterator cancels the request too.
247
+ const controller = new AbortController();
248
+ const forwardAbort = () => controller.abort();
249
+ signal?.addEventListener("abort", forwardAbort, { once: true });
250
+ try {
251
+ let body;
252
+ try {
253
+ body = await transport.openStream(operation("match_material"), {
254
+ body: input,
255
+ signal: controller.signal,
256
+ accept: MATCH_STREAM_ACCEPT,
257
+ subject: "match stream",
258
+ });
259
+ }
260
+ catch (error) {
261
+ // Cancelled before the response headers landed — the caller's own stop.
262
+ if (controller.signal.aborted)
263
+ return;
264
+ throw error;
265
+ }
266
+ for await (const line of readNdjsonLines(body, controller.signal)) {
267
+ const event = decodeMatchStreamEvent(line);
268
+ yield event;
269
+ if (isTerminal(event))
270
+ return;
271
+ }
272
+ if (!controller.signal.aborted) {
273
+ throw MaterialGraphError.unexpected("MaterialGraph match stream ended without a `complete` or `error` event — the connection was truncated");
274
+ }
275
+ }
276
+ finally {
277
+ signal?.removeEventListener("abort", forwardAbort);
278
+ controller.abort();
279
+ }
280
+ }
@@ -1,6 +1,27 @@
1
1
  import type { OpenEnum } from "./types.js";
2
- export type MaterialBlockStatus = OpenEnum<"populated" | "partial" | "pending" | "not_applicable">;
3
- export type MaterialBlockFieldStatus = OpenEnum<"verified" | "partial" | "missing" | "not_applicable">;
2
+ /**
3
+ * Coverage status, shared by blocks and fields.
4
+ *
5
+ * `verified` — determined. For a field a value is known, INCLUDING `false`, `0`
6
+ * and `[]`, which are answers meaning "we looked and there is none". For a
7
+ * block, every field is determined.
8
+ * `partial` — determined for some fields but not all.
9
+ * `missing` — not determined yet. The work queue, and the only status that
10
+ * appears in `missingAttributeIds`.
11
+ * `not_applicable` — cannot apply to this product type. Never work, never a gap.
12
+ *
13
+ * BREAKING in this release: blocks previously reported `populated`/`pending`
14
+ * while fields reported `verified`/`missing` — two spellings of one concept
15
+ * that disagreed with each other on the same variant. Map `populated ->
16
+ * verified` and `pending -> missing`. Both types are retained as aliases so
17
+ * existing code keeps compiling; they now describe the same set.
18
+ *
19
+ * The authoritative list is served at runtime by
20
+ * `get-schema { kind: "coverageStatuses" }`.
21
+ */
22
+ export type MaterialCoverageStatus = OpenEnum<"verified" | "partial" | "missing" | "not_applicable">;
23
+ export type MaterialBlockStatus = MaterialCoverageStatus;
24
+ export type MaterialBlockFieldStatus = MaterialCoverageStatus;
4
25
  export type MaterialBlockFieldValue = string | number | boolean | unknown[] | Record<string, unknown> | null;
5
26
  export interface MaterialBlockProjectionField {
6
27
  attributeId: string;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Hand-mirrored wire types for the material projections — the grid-card tile,
3
+ * the detail record, and the availability/successor block they carry.
4
+ *
5
+ * Extracted from `./types.ts` (which re-exports everything here) so that module
6
+ * stays within the repository's source-size budget. Same contract, same
7
+ * tolerant-parsing rules: response enums are OPEN, so `MaterialAvailability` is
8
+ * widened — a new server state still type-checks.
9
+ */
10
+ import type { ImageSource, MaterialColour, OpenEnum, PaletteSwatch } from "./types.js";
11
+ /**
12
+ * Whether a material can still be bought.
13
+ *
14
+ * - `available` — listed in the catalog of record.
15
+ * - `discontinued` — WITHDRAWN from that catalog. This is the source's own
16
+ * listing state, not a reviewed manufacturer-discontinuation claim; do not
17
+ * present it as the manufacturer having discontinued the product.
18
+ * - `superseded` — a reviewed product relationship names a successor and the
19
+ * material is no longer listed. See `MaterialDetail.supersededBy`.
20
+ * - `unknown` — never checked. A real answer, not an error.
21
+ */
22
+ export type MaterialAvailability = OpenEnum<"available" | "discontinued" | "superseded" | "unknown">;
23
+ /**
24
+ * The variant a reviewed product relationship names as replacing another.
25
+ * `availability` is the successor's OWN state, resolved ONE HOP only — a
26
+ * successor that has itself been superseded reports `discontinued`/`unknown`
27
+ * here; follow `variantId` for the next link in the chain.
28
+ */
29
+ export interface MaterialSuccessor {
30
+ variantId: string;
31
+ brandSlug: string;
32
+ productId: string;
33
+ brand: string;
34
+ product: string;
35
+ variant: string;
36
+ color: string | null;
37
+ availability: MaterialAvailability;
38
+ }
39
+ /**
40
+ * The grid-card projection of a material. Tiles carry `availability` but not the
41
+ * successor record — read the detail projection for that.
42
+ */
43
+ export interface MaterialTile {
44
+ variantId: string;
45
+ brand: string;
46
+ product: string;
47
+ variantName: string | null;
48
+ imageUrl: string | null;
49
+ imageSource: ImageSource | null;
50
+ category: string | null;
51
+ colour: MaterialColour;
52
+ availability: MaterialAvailability;
53
+ }
54
+ /** A canonical attribute/term pair carried on a detail record. */
55
+ export interface MaterialFacet {
56
+ attributeId: string;
57
+ term: string;
58
+ }
59
+ /**
60
+ * One dimensional configuration of a material. Numeric fields are millimetres
61
+ * (mass in kilograms); string fields are the source's own descriptors. The
62
+ * index signature preserves the corpus's long tail of rarer keys.
63
+ */
64
+ export interface DimensionEntry {
65
+ label?: string;
66
+ tile_size?: string;
67
+ measurement_basis?: string;
68
+ width_mm?: number;
69
+ length_mm?: number;
70
+ height_mm?: number;
71
+ depth_mm?: number;
72
+ thickness_mm?: number;
73
+ diameter_mm?: number;
74
+ visible_projection_mm?: number;
75
+ weight_kg?: number;
76
+ [key: string]: unknown;
77
+ }
78
+ /** The record projection of a material — the tile plus detail-only fields. */
79
+ export interface MaterialDetail extends MaterialTile {
80
+ brandSlug: string;
81
+ productId: string;
82
+ color: string | null;
83
+ materialBankId: string | null;
84
+ installationImageUrl: string | null;
85
+ displayCategory: string | null;
86
+ categoryPath: string[];
87
+ palette: PaletteSwatch[];
88
+ facets: MaterialFacet[];
89
+ dimensions: DimensionEntry[] | null;
90
+ /** The declared successor, or null when no reviewed relationship names one. */
91
+ supersededBy: MaterialSuccessor | null;
92
+ }
@@ -0,0 +1 @@
1
+ export {};
package/ndjson.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * A minimal, dependency-free NDJSON (newline-delimited JSON) line reader — the
3
+ * primitive behind {@link MaterialGraphClient.matchStream}. Hand-rolled so the
4
+ * SDK stays zero-runtime-dep and works unchanged inside Node route handlers,
5
+ * where `fetch` yields a byte `ReadableStream`.
6
+ *
7
+ * Deliberately a SIBLING of `sse.ts`, not a reuse of it. NDJSON and SSE agree on
8
+ * nothing but "text arrives in chunks": SSE frames are `field: value` lines
9
+ * terminated by a BLANK line, with comment lines and multi-line `data:`
10
+ * accumulation, whereas an NDJSON frame is exactly one line of JSON. Feeding
11
+ * NDJSON through the SSE tokenizer would read `{"type":"step",…}` as a field
12
+ * named `{"type"` and silently discard every frame. The two also want OPPOSITE
13
+ * end-of-stream policies: SSE dispatches a trailing partial frame as valid,
14
+ * while a truncated NDJSON line must surface as an error (enforced by the
15
+ * caller, which parses whatever `flush` returns rather than dropping it).
16
+ *
17
+ * Line endings: the reader splits on `\n` only, per the NDJSON contract. A `\r`
18
+ * left by a CRLF-normalising proxy stays as trailing whitespace on the line,
19
+ * which `JSON.parse` ignores.
20
+ */
21
+ /**
22
+ * A push-based NDJSON line assembler. Feed it decoded text with {@link push};
23
+ * it returns the lines that text COMPLETED, terminators removed. Any partial
24
+ * trailing line is held in an internal buffer until a later chunk terminates it,
25
+ * or until {@link flush} surrenders it at end of stream.
26
+ */
27
+ export declare class NdjsonDecoder {
28
+ #private;
29
+ /** Absorb one decoded chunk and return the lines it completed, in order. */
30
+ push(text: string): string[];
31
+ /**
32
+ * Surrender the unterminated remainder at end of stream — a well-formed
33
+ * NDJSON stream's final line need not carry a trailing newline. The remainder
34
+ * is RETURNED, never dropped: a truncated frame is the caller's error to
35
+ * raise, not this decoder's fragment to discard.
36
+ */
37
+ flush(): string[];
38
+ }
39
+ /**
40
+ * Iterate the NDJSON lines of a byte stream. Only lines with non-whitespace
41
+ * content are yielded — a blank separator line carries no frame and must never
42
+ * reach `JSON.parse`, where it would become an empty object. Stops cleanly when
43
+ * the stream ends or `signal` aborts (mid-read abort rejections are swallowed
44
+ * once the signal is set).
45
+ */
46
+ export declare function readNdjsonLines(body: ReadableStream<Uint8Array>, signal?: AbortSignal): AsyncGenerator<string, void, unknown>;