@instruments/materialgraph 0.2.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.
- package/by-spec-types.d.ts +73 -0
- package/by-spec-types.js +7 -0
- package/client.d.ts +68 -23
- package/client.js +82 -25
- package/codes-client.d.ts +82 -0
- package/codes-client.js +73 -0
- package/codes-types.d.ts +154 -9
- package/colour-aggregate-types.d.ts +93 -0
- package/colour-aggregate-types.js +1 -0
- package/colours-client.d.ts +52 -0
- package/colours-client.js +39 -0
- package/composition-types.d.ts +228 -2
- package/http-transport.d.ts +19 -2
- package/http-transport.js +72 -2
- package/index.d.ts +7 -0
- package/index.js +3 -0
- package/match-stream-types.d.ts +150 -0
- package/match-stream-types.js +28 -0
- package/match-stream.d.ts +22 -0
- package/match-stream.js +280 -0
- package/material-block-types.d.ts +23 -2
- package/material-types.d.ts +92 -0
- package/material-types.js +1 -0
- package/ndjson.d.ts +46 -0
- package/ndjson.js +103 -0
- package/operations.d.ts +1 -1
- package/operations.js +6 -0
- package/package.json +1 -1
- package/run-types.d.ts +65 -1
- package/runs.d.ts +45 -3
- package/runs.js +82 -43
- package/schedule-types.d.ts +88 -0
- package/schedule-types.js +12 -0
- package/search-refinement-types.d.ts +13 -0
- package/search-result-validation.d.ts +27 -0
- package/search-result-validation.js +174 -0
- package/source-material-validation.d.ts +3 -0
- package/source-material-validation.js +109 -0
- package/spec-types.d.ts +4 -0
- package/spec-types.js +4 -0
- package/specifications.d.ts +9 -0
- package/specifications.js +9 -0
- package/types.d.ts +59 -73
- package/validation.d.ts +119 -0
- package/validation.js +241 -0
package/http-transport.d.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The shared low-level HTTP transport for {@link MaterialGraphClient}. It owns
|
|
3
3
|
* URL building, the bearer header, rate-limit capture, response reading, and the
|
|
4
4
|
* one error-mapping path — so the JSON read surface (`client.ts`), the multipart
|
|
5
|
-
* upload,
|
|
6
|
-
*
|
|
5
|
+
* upload, the SSE stream (`runs.ts`), and the NDJSON Match stream
|
|
6
|
+
* (`match-stream.ts`) all fail identically. Retry-free by design; a single
|
|
7
|
+
* failure surfaces as one {@link MaterialGraphError}.
|
|
7
8
|
*/
|
|
8
9
|
import { type RateLimit } from "./errors.js";
|
|
9
10
|
import { type SdkOperation } from "./operations.js";
|
|
@@ -15,6 +16,12 @@ export interface RequestOptions {
|
|
|
15
16
|
signal?: AbortSignal;
|
|
16
17
|
pathParams?: Readonly<Record<string, string>>;
|
|
17
18
|
}
|
|
19
|
+
export interface StreamRequestOptions extends RequestOptions {
|
|
20
|
+
/** The streaming media type to negotiate (`text/event-stream`, `application/x-ndjson`). */
|
|
21
|
+
accept: string;
|
|
22
|
+
/** Names this stream in the "returned no body" diagnostic, e.g. `"match stream"`. */
|
|
23
|
+
subject: string;
|
|
24
|
+
}
|
|
18
25
|
/** A read, status-checked HTTP response with its parsed body. */
|
|
19
26
|
export interface SentResponse {
|
|
20
27
|
response: Response;
|
|
@@ -51,6 +58,16 @@ export declare class HttpTransport {
|
|
|
51
58
|
* it consumes the whole body via `response.text()`.
|
|
52
59
|
*/
|
|
53
60
|
send(input: SendInput): Promise<SentResponse>;
|
|
61
|
+
/**
|
|
62
|
+
* Open a streaming operation and hand back its UNREAD byte body — the one
|
|
63
|
+
* entry point for every long-lived read (SSE run events, NDJSON Match
|
|
64
|
+
* progress). Everything up to the first byte behaves exactly like
|
|
65
|
+
* {@link send}: same URL building, same bearer header, same rate-limit
|
|
66
|
+
* capture, same {@link MaterialGraphError} mapping for a transport failure or
|
|
67
|
+
* a non-2xx (whose short error body IS read, since it is not a stream).
|
|
68
|
+
* Framing and cancellation belong to the caller.
|
|
69
|
+
*/
|
|
70
|
+
openStream(sdkOperation: SdkOperation, options: StreamRequestOptions): Promise<ReadableStream<Uint8Array>>;
|
|
54
71
|
/** Execute a registered JSON operation and return its typed body. */
|
|
55
72
|
requestJson<T>(sdkOperation: SdkOperation, options?: RequestOptions): Promise<T>;
|
|
56
73
|
}
|
package/http-transport.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* The shared low-level HTTP transport for {@link MaterialGraphClient}. It owns
|
|
3
3
|
* URL building, the bearer header, rate-limit capture, response reading, and the
|
|
4
4
|
* one error-mapping path — so the JSON read surface (`client.ts`), the multipart
|
|
5
|
-
* upload,
|
|
6
|
-
*
|
|
5
|
+
* upload, the SSE stream (`runs.ts`), and the NDJSON Match stream
|
|
6
|
+
* (`match-stream.ts`) all fail identically. Retry-free by design; a single
|
|
7
|
+
* failure surfaces as one {@link MaterialGraphError}.
|
|
7
8
|
*/
|
|
8
9
|
import { MaterialGraphError } from "./errors.js";
|
|
9
10
|
import { operationPath } from "./operations.js";
|
|
@@ -115,6 +116,75 @@ export class HttpTransport {
|
|
|
115
116
|
}
|
|
116
117
|
return { response, text, parsed };
|
|
117
118
|
}
|
|
119
|
+
/**
|
|
120
|
+
* Open a streaming operation and hand back its UNREAD byte body — the one
|
|
121
|
+
* entry point for every long-lived read (SSE run events, NDJSON Match
|
|
122
|
+
* progress). Everything up to the first byte behaves exactly like
|
|
123
|
+
* {@link send}: same URL building, same bearer header, same rate-limit
|
|
124
|
+
* capture, same {@link MaterialGraphError} mapping for a transport failure or
|
|
125
|
+
* a non-2xx (whose short error body IS read, since it is not a stream).
|
|
126
|
+
* Framing and cancellation belong to the caller.
|
|
127
|
+
*/
|
|
128
|
+
async openStream(sdkOperation, options) {
|
|
129
|
+
const url = this.buildUrl(operationPath(sdkOperation, options.pathParams), options.query);
|
|
130
|
+
const requestUrl = new URL(url);
|
|
131
|
+
const metadata = {
|
|
132
|
+
method: sdkOperation.method,
|
|
133
|
+
origin: requestUrl.origin,
|
|
134
|
+
path: requestUrl.pathname,
|
|
135
|
+
};
|
|
136
|
+
const hasBody = options.body !== undefined;
|
|
137
|
+
let response;
|
|
138
|
+
try {
|
|
139
|
+
response = await this.fetch(url, {
|
|
140
|
+
method: sdkOperation.method,
|
|
141
|
+
headers: this.authHeaders({
|
|
142
|
+
Accept: options.accept,
|
|
143
|
+
...(hasBody ? { "Content-Type": "application/json" } : {}),
|
|
144
|
+
}),
|
|
145
|
+
body: hasBody ? JSON.stringify(options.body) : undefined,
|
|
146
|
+
signal: options.signal,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (cause) {
|
|
150
|
+
throw MaterialGraphError.fromTransport({ ...metadata, cause, callerSignal: options.signal });
|
|
151
|
+
}
|
|
152
|
+
const rateLimit = parseRateLimit(response.headers);
|
|
153
|
+
this.#lastRateLimit = rateLimit;
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
let text = "";
|
|
156
|
+
try {
|
|
157
|
+
text = await response.text();
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// The status IS the error; a body that will not read adds nothing and
|
|
161
|
+
// must not mask it. Report the failure with an empty detail instead.
|
|
162
|
+
text = "";
|
|
163
|
+
}
|
|
164
|
+
let parsed;
|
|
165
|
+
if (text.length > 0) {
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(text);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
parsed = text;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
throw MaterialGraphError.fromResponse({
|
|
174
|
+
...metadata,
|
|
175
|
+
status: response.status,
|
|
176
|
+
body: parsed,
|
|
177
|
+
responseText: text,
|
|
178
|
+
rateLimit,
|
|
179
|
+
apiKey: this.apiKey,
|
|
180
|
+
queryValues: [...requestUrl.searchParams.values()],
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (response.body === null) {
|
|
184
|
+
throw MaterialGraphError.unexpected(`MaterialGraph ${options.subject} returned no body`);
|
|
185
|
+
}
|
|
186
|
+
return response.body;
|
|
187
|
+
}
|
|
118
188
|
/** Execute a registered JSON operation and return its typed body. */
|
|
119
189
|
async requestJson(sdkOperation, options = {}) {
|
|
120
190
|
const url = this.buildUrl(operationPath(sdkOperation, options.pathParams), options.query);
|
package/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { MATERIALGRAPH_API_VERSION, SDK_OPERATIONS } from "./operations.js";
|
|
|
4
4
|
export type { SdkOperation, SdkOperationId } from "./operations.js";
|
|
5
5
|
export { TERMINAL_RUN_EVENT_TYPES } from "./run-types.js";
|
|
6
6
|
export type * from "./run-types.js";
|
|
7
|
+
export type * from "./schedule-types.js";
|
|
7
8
|
export { VARY_AXES } from "./composition-types.js";
|
|
8
9
|
export type * from "./composition-types.js";
|
|
9
10
|
export { PROJECT_EVENT_KINDS } from "./project-types.js";
|
|
@@ -13,7 +14,13 @@ export type * from "./run-events.js";
|
|
|
13
14
|
export { MaterialGraphError } from "./errors.js";
|
|
14
15
|
export type { MaterialGraphErrorInit, MaterialGraphErrorKind, MaterialGraphRequestMetadata, ProblemDetail, RateLimit, SanitizedErrorValue, SanitizedIssue, SimpleErrorBody, } from "./errors.js";
|
|
15
16
|
export type * from "./types.js";
|
|
17
|
+
export { MATCH_IMAGE_TYPES, MATCH_MATERIAL_FUNCTIONS, MATCH_STREAM_EVENT_TYPES, MATCH_SURFACE_STATUSES, MATCH_WORKFLOW_STEPS, } from "./match-stream-types.js";
|
|
18
|
+
export type * from "./match-stream-types.js";
|
|
19
|
+
export { CodesClient } from "./codes-client.js";
|
|
16
20
|
export type * from "./codes-types.js";
|
|
21
|
+
export { ColoursClient } from "./colours-client.js";
|
|
22
|
+
export type { ColoursTransport } from "./colours-client.js";
|
|
23
|
+
export type * from "./colour-aggregate-types.js";
|
|
17
24
|
export type * from "./brand-types.js";
|
|
18
25
|
export type * from "./material-asset-types.js";
|
|
19
26
|
export type * from "./material-block-types.js";
|
package/index.js
CHANGED
|
@@ -5,5 +5,8 @@ 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
7
|
export { MaterialGraphError } from "./errors.js";
|
|
8
|
+
export { MATCH_IMAGE_TYPES, MATCH_MATERIAL_FUNCTIONS, MATCH_STREAM_EVENT_TYPES, MATCH_SURFACE_STATUSES, MATCH_WORKFLOW_STEPS, } from "./match-stream-types.js";
|
|
9
|
+
export { CodesClient } from "./codes-client.js";
|
|
10
|
+
export { ColoursClient } from "./colours-client.js";
|
|
8
11
|
export { CERTIFICATION_PROGRAMME_ATTRIBUTE_ID } from "./spec-types.js";
|
|
9
12
|
export { certificationProgramme, certificationProgrammeCriterion, hasSpec, specCriterion, specTerm, } from "./specifications.js";
|
|
@@ -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>;
|
package/match-stream.js
ADDED
|
@@ -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
|
+
}
|