@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/runs.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { MaterialGraphError } from "./errors.js";
|
|
9
9
|
import { HttpTransport } from "./http-transport.js";
|
|
10
10
|
import { findSdkOperation, operationPath } from "./operations.js";
|
|
11
|
+
import { checkSourceMaterialBatchDetail } from "./source-material-validation.js";
|
|
11
12
|
import { readSseFrames } from "./sse.js";
|
|
12
13
|
import { TERMINAL_RUN_EVENT_TYPES, } from "./run-types.js";
|
|
13
14
|
function runOperation(operationId) {
|
|
@@ -66,6 +67,25 @@ export async function uploadSourceMaterial(transport, input, signal) {
|
|
|
66
67
|
});
|
|
67
68
|
return parsed;
|
|
68
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Read back one uploaded batch (`GET /api/v1/source-material/{batchId}`): every
|
|
72
|
+
* file with a fetchable `url`, and every analysis computed over its bytes
|
|
73
|
+
* inlined as `analyses` — always an array, `[]` when nothing has been computed.
|
|
74
|
+
*
|
|
75
|
+
* A batch you do not own is indistinguishable from one that does not exist:
|
|
76
|
+
* both raise a 404, never a 403, so batch ids cannot be probed for existence.
|
|
77
|
+
*
|
|
78
|
+
* The body is VALIDATED, not trusted: `checkSourceMaterialBatchDetail` mirrors
|
|
79
|
+
* the server's Zod contract so a malformed response fails here with a path into
|
|
80
|
+
* the payload rather than as an undefined three frames away in caller code.
|
|
81
|
+
*/
|
|
82
|
+
export async function getSourceMaterialBatch(transport, batchId, signal) {
|
|
83
|
+
const body = await transport.requestJson(runOperation("get_source_material_batch"), {
|
|
84
|
+
pathParams: { batchId },
|
|
85
|
+
signal,
|
|
86
|
+
});
|
|
87
|
+
return checkSourceMaterialBatchDetail(body, batchId);
|
|
88
|
+
}
|
|
69
89
|
/** List invocable agents, workflows, and tools (`GET /api/v1/capabilities`). */
|
|
70
90
|
export function capabilities(transport, signal) {
|
|
71
91
|
return transport.requestJson(runOperation("list_public_capabilities"), {
|
|
@@ -86,10 +106,61 @@ export function getRun(transport, runId, signal) {
|
|
|
86
106
|
signal,
|
|
87
107
|
});
|
|
88
108
|
}
|
|
89
|
-
/**
|
|
90
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Export a completed compose Run's scheme as a finish schedule — one row per
|
|
111
|
+
* slot, every attribute value carrying its source and verification state
|
|
112
|
+
* (`GET /api/v1/runs/{runId}/schedule`).
|
|
113
|
+
*
|
|
114
|
+
* The `.xlsx` form of the same export is deliberately NOT an SDK method: this
|
|
115
|
+
* transport parses every response as text/JSON and has no binary read path, so
|
|
116
|
+
* a bytes-returning method would mean a second transport surface for one file.
|
|
117
|
+
* Fetch `GET /api/v1/runs/{runId}/schedule.xlsx` directly with the same bearer
|
|
118
|
+
* key when you want the workbook.
|
|
119
|
+
*/
|
|
120
|
+
export function getRunSchedule(transport, runId, options = {}) {
|
|
121
|
+
return transport.requestJson(runOperation("get_run_finish_schedule"), {
|
|
122
|
+
pathParams: { runId },
|
|
123
|
+
...(options.schemeId ? { query: { schemeId: options.schemeId } } : {}),
|
|
124
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Normalise `resumeRun`'s overloaded third argument.
|
|
129
|
+
*
|
|
130
|
+
* A bare `AbortSignal` stays supported because it is the published shape:
|
|
131
|
+
* reordering that parameter would break any consumer passing it positionally,
|
|
132
|
+
* and this ships with no version bump.
|
|
133
|
+
*
|
|
134
|
+
* Discriminated on `instanceof AbortSignal`, NOT on the presence of `stepId`.
|
|
135
|
+
* `{ signal }` with no `stepId` is a perfectly valid options object, and a
|
|
136
|
+
* key-presence test would misread it as a signal and silently drop the abort.
|
|
137
|
+
*/
|
|
138
|
+
function resumeOptions(value) {
|
|
139
|
+
if (value === undefined)
|
|
140
|
+
return {};
|
|
141
|
+
return value instanceof AbortSignal ? { signal: value } : value;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resume a suspended Run (`POST /api/v1/runs/{runId}/resume`).
|
|
145
|
+
*
|
|
146
|
+
* The third argument takes either an `AbortSignal` (legacy) or
|
|
147
|
+
* {@link ResumeRunOptions}.
|
|
148
|
+
*
|
|
149
|
+
* `stepId` is forwarded whenever it is PRESENT — `!== undefined`, not truthy.
|
|
150
|
+
* An empty string is a present-but-malformed value and must reach the server
|
|
151
|
+
* so its schema rejects it; treating it as absent would silently downgrade a
|
|
152
|
+
* guarded resume into an unguarded one, which is the exact failure this guard
|
|
153
|
+
* exists to prevent.
|
|
154
|
+
*
|
|
155
|
+
* Omitting it sends a body byte-identical to what this sent before. Note the
|
|
156
|
+
* wire cannot distinguish `{ stepId: undefined }` from no key at all —
|
|
157
|
+
* `JSON.stringify` drops both — so the distinction here is construction
|
|
158
|
+
* intent, kept so the serialised body is provably unchanged for old callers.
|
|
159
|
+
*/
|
|
160
|
+
export function resumeRun(transport, runId, resumeData, signalOrOptions) {
|
|
161
|
+
const { stepId, signal } = resumeOptions(signalOrOptions);
|
|
91
162
|
return transport.requestJson(runOperation("resume_public_run"), {
|
|
92
|
-
body: { resumeData },
|
|
163
|
+
body: { resumeData, ...(stepId !== undefined ? { stepId } : {}) },
|
|
93
164
|
pathParams: { runId },
|
|
94
165
|
signal,
|
|
95
166
|
});
|
|
@@ -127,46 +198,14 @@ function decodeRunEvent(data) {
|
|
|
127
198
|
* event); pass `after` to resume from the last observed sequence.
|
|
128
199
|
*/
|
|
129
200
|
export async function* streamRunEvents(transport, runId, options = {}) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
headers: transport.authHeaders({ Accept: "text/event-stream" }),
|
|
139
|
-
signal: options.signal,
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
catch (cause) {
|
|
143
|
-
throw MaterialGraphError.fromTransport({ ...metadata, cause, callerSignal: options.signal });
|
|
144
|
-
}
|
|
145
|
-
if (!response.ok) {
|
|
146
|
-
const text = await response.text().catch(() => "");
|
|
147
|
-
let parsed;
|
|
148
|
-
if (text.length > 0) {
|
|
149
|
-
try {
|
|
150
|
-
parsed = JSON.parse(text);
|
|
151
|
-
}
|
|
152
|
-
catch {
|
|
153
|
-
parsed = text;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
throw MaterialGraphError.fromResponse({
|
|
157
|
-
...metadata,
|
|
158
|
-
status: response.status,
|
|
159
|
-
body: parsed,
|
|
160
|
-
responseText: text,
|
|
161
|
-
rateLimit: transport.lastRateLimit,
|
|
162
|
-
apiKey: transport.apiKey,
|
|
163
|
-
queryValues: [...requestUrl.searchParams.values()],
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
if (response.body === null) {
|
|
167
|
-
throw MaterialGraphError.unexpected("MaterialGraph run-event stream returned no body");
|
|
168
|
-
}
|
|
169
|
-
for await (const frame of readSseFrames(response.body, options.signal)) {
|
|
201
|
+
const body = await transport.openStream(runOperation("list_public_run_events"), {
|
|
202
|
+
pathParams: { runId },
|
|
203
|
+
...(options.after === undefined ? {} : { query: { after: options.after } }),
|
|
204
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
205
|
+
accept: "text/event-stream",
|
|
206
|
+
subject: "run-event stream",
|
|
207
|
+
});
|
|
208
|
+
for await (const frame of readSseFrames(body, options.signal)) {
|
|
170
209
|
const event = decodeRunEvent(frame.data);
|
|
171
210
|
if (event)
|
|
172
211
|
yield event;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finish-schedule wire types (MG-616) — the hand-mirror of
|
|
3
|
+
* `@materialgraph/products/schemes/finish-schedule`. Kept in their own module
|
|
4
|
+
* rather than `types.ts`, which sits at its source-size baseline.
|
|
5
|
+
*
|
|
6
|
+
* A finish schedule is a completed compose scheme rendered as ONE ROW PER SLOT:
|
|
7
|
+
* the shape a specifier issues. The contract that makes it trustworthy is that
|
|
8
|
+
* every row carries the IDENTICAL, fixed column set, and every attribute cell
|
|
9
|
+
* states its own verification alongside its value. An unknown is an explicit
|
|
10
|
+
* `missing` cell with a null value — never a blank that reads as complete.
|
|
11
|
+
*/
|
|
12
|
+
/** One schedule column: the attribute and its header text. */
|
|
13
|
+
export interface FinishScheduleColumn {
|
|
14
|
+
attributeId: string;
|
|
15
|
+
/** The registry presentation label. */
|
|
16
|
+
label: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The six verification states a schedule cell can report, mirroring the shared
|
|
20
|
+
* verdict contract. On this public surface a `proposed` (pending-review) value
|
|
21
|
+
* is projected to `missing`, so it can never be told apart from a value that
|
|
22
|
+
* never existed.
|
|
23
|
+
*/
|
|
24
|
+
export type FinishScheduleCellState = "matched" | "deviated" | "failed" | "unverified" | "missing" | "not_evaluated";
|
|
25
|
+
/**
|
|
26
|
+
* One attribute cell of one row: the value AND how far it can be trusted.
|
|
27
|
+
*
|
|
28
|
+
* Columns the Run's criteria evaluated carry the real verdict state. Columns
|
|
29
|
+
* backfilled from the catalogue (nothing in the brief asked about them) only
|
|
30
|
+
* ever read `matched` or `missing`: they assert presence-with-trust, not that
|
|
31
|
+
* the brief was met.
|
|
32
|
+
*/
|
|
33
|
+
export interface FinishScheduleCell {
|
|
34
|
+
attributeId: string;
|
|
35
|
+
label: string;
|
|
36
|
+
/** The display value, or null when unknown. */
|
|
37
|
+
value: string | null;
|
|
38
|
+
state: FinishScheduleCellState;
|
|
39
|
+
confidence: number | null;
|
|
40
|
+
sourceBasis: string | null;
|
|
41
|
+
}
|
|
42
|
+
/** The rolled-up grade for one row, from its evaluated criterion cells only. */
|
|
43
|
+
export type FinishScheduleGrade = "meets_all" | "close" | "hard_no";
|
|
44
|
+
/** One row: one slot, one specified material, fully graded. */
|
|
45
|
+
export interface FinishScheduleRow {
|
|
46
|
+
slotId: string;
|
|
47
|
+
slotName: string;
|
|
48
|
+
/** The space NAME, or null for a spaceless slot. */
|
|
49
|
+
space: string | null;
|
|
50
|
+
element: string | null;
|
|
51
|
+
/** True when the designer pinned this pick rather than the engine choosing it. */
|
|
52
|
+
anchored: boolean;
|
|
53
|
+
brand: string;
|
|
54
|
+
product: string;
|
|
55
|
+
variant: string;
|
|
56
|
+
variantId: string;
|
|
57
|
+
/** Relative path to the public material record (`/materials/{variantId}`). */
|
|
58
|
+
recordUrl: string;
|
|
59
|
+
imageUrl: string | null;
|
|
60
|
+
hex: string | null;
|
|
61
|
+
/** Always the full, identically-ordered column set. */
|
|
62
|
+
attributes: FinishScheduleCell[];
|
|
63
|
+
fitNotes: string[];
|
|
64
|
+
grade: FinishScheduleGrade;
|
|
65
|
+
}
|
|
66
|
+
/** A whole composed scheme as an issuable finish schedule. */
|
|
67
|
+
export interface FinishSchedule {
|
|
68
|
+
runId: string;
|
|
69
|
+
schemeId: string;
|
|
70
|
+
title: string;
|
|
71
|
+
/** The compose contract version stamped on the Run, or `"unknown"`. */
|
|
72
|
+
composeVersion: string;
|
|
73
|
+
/** ISO timestamp the export was compiled at. */
|
|
74
|
+
generatedAt: string;
|
|
75
|
+
/** The stable column order every row's `attributes` array follows. */
|
|
76
|
+
columns: FinishScheduleColumn[];
|
|
77
|
+
rows: FinishScheduleRow[];
|
|
78
|
+
}
|
|
79
|
+
/** `GET /api/v1/runs/{runId}/schedule`. */
|
|
80
|
+
export interface FinishScheduleResponse {
|
|
81
|
+
schedule: FinishSchedule;
|
|
82
|
+
}
|
|
83
|
+
/** Options for {@link getRunSchedule}. */
|
|
84
|
+
export interface GetRunScheduleOptions {
|
|
85
|
+
/** Which composed scheme to export; defaults to the Run's first scheme. */
|
|
86
|
+
schemeId?: string;
|
|
87
|
+
signal?: AbortSignal;
|
|
88
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finish-schedule wire types (MG-616) — the hand-mirror of
|
|
3
|
+
* `@materialgraph/products/schemes/finish-schedule`. Kept in their own module
|
|
4
|
+
* rather than `types.ts`, which sits at its source-size baseline.
|
|
5
|
+
*
|
|
6
|
+
* A finish schedule is a completed compose scheme rendered as ONE ROW PER SLOT:
|
|
7
|
+
* the shape a specifier issues. The contract that makes it trustworthy is that
|
|
8
|
+
* every row carries the IDENTICAL, fixed column set, and every attribute cell
|
|
9
|
+
* states its own verification alongside its value. An unknown is an explicit
|
|
10
|
+
* `missing` cell with a null value — never a blank that reads as complete.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -13,12 +13,25 @@ import type { SearchInput } from "./types.js";
|
|
|
13
13
|
* supported for the virtual programme attribute. The typed `spec-types` mirror
|
|
14
14
|
* and the `specifications` builders produce values assignable to this type.
|
|
15
15
|
*/
|
|
16
|
+
/**
|
|
17
|
+
* Optional per-filter TRUST bar the matching value must clear. `minConfidence` is
|
|
18
|
+
* an explicit confidence floor; `sourceBasis` demands an EXACT source basis (closed
|
|
19
|
+
* enum — an unknown basis is rejected); `meetsRequirement` folds the attribute's
|
|
20
|
+
* registry confidence requirement server-side.
|
|
21
|
+
*/
|
|
22
|
+
export interface SearchFilterTrust {
|
|
23
|
+
minConfidence?: number;
|
|
24
|
+
sourceBasis?: "catalog_feed" | "declared" | "derived" | "tested";
|
|
25
|
+
meetsRequirement?: true;
|
|
26
|
+
}
|
|
16
27
|
export type SearchFilter = {
|
|
17
28
|
attributeId: string;
|
|
18
29
|
term: string;
|
|
30
|
+
trust?: SearchFilterTrust;
|
|
19
31
|
} | {
|
|
20
32
|
attributeId: string;
|
|
21
33
|
hasValue: true;
|
|
34
|
+
trust?: SearchFilterTrust;
|
|
22
35
|
};
|
|
23
36
|
/** One structured refinement returned with a search response. */
|
|
24
37
|
export interface SearchSuggestion {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type ValidationFailure } from "./validation.js";
|
|
2
|
+
/** `verdictStateSchema` — the six live cell states. */
|
|
3
|
+
export declare const VERDICT_STATES: readonly ["matched", "deviated", "failed", "unverified", "missing", "not_evaluated"];
|
|
4
|
+
/** `attributeVerdictSchema.strength`. */
|
|
5
|
+
export declare const VERDICT_STRENGTHS: readonly ["required", "preferred"];
|
|
6
|
+
/** `verdictBaselineSchema` discriminant — the four arms of what a cell was judged against. */
|
|
7
|
+
export declare const VERDICT_BASELINE_KINDS: readonly ["term", "presence", "seed", "image"];
|
|
8
|
+
/** The `image` baseline arm's own `source` enum. */
|
|
9
|
+
export declare const VERDICT_IMAGE_SOURCES: readonly ["variant", "vector"];
|
|
10
|
+
/** `verdictDeviationSchema.direction`. */
|
|
11
|
+
export declare const VERDICT_DEVIATION_DIRECTIONS: readonly ["above", "below", "other"];
|
|
12
|
+
/** `verdictTrustGapSchema` — why a real value did not clear the trust bar. */
|
|
13
|
+
export declare const VERDICT_TRUST_GAPS: readonly ["below_confidence", "basis_unmet", "proposed"];
|
|
14
|
+
/** `verdictGradeSchema` — the rolled-up card grade. */
|
|
15
|
+
export declare const VERDICT_GRADES: readonly ["meets_all", "close", "hard_no"];
|
|
16
|
+
/** `visualMeaningSchema.basis` — where a meaning came from. */
|
|
17
|
+
export declare const VISUAL_MEANING_BASES: readonly ["canonical_attribute", "image_colour_analysis"];
|
|
18
|
+
/**
|
|
19
|
+
* Prove one wire value is a {@link SearchResultItem}, raising through `fail` at
|
|
20
|
+
* the first field that does not match `searchResultItemSchema`. Checks the shape
|
|
21
|
+
* only — unknown keys ride through untouched, per the SDK's pass-through
|
|
22
|
+
* contract (nothing on the emit path parses, so stripping would invent a
|
|
23
|
+
* normalisation the wire never had).
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkSearchResultItem(value: unknown, what: string, fail: ValidationFailure): void;
|
|
26
|
+
/** Prove every entry of a `results[]` array is a {@link SearchResultItem}. */
|
|
27
|
+
export declare function checkSearchResultItems(items: readonly unknown[], what: string, fail: ValidationFailure): void;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { requireArray, requireBoolean, requireEnum, requireNonEmptyString, requireNonEmptyStringArray, requireNullableBoundedNumber, requireNullableEnum, requireNullableNonEmptyString, requireNullableNumber, requireNullableString, requireNumber, requireOptionalBoolean, requireOptionalNumber, requireOptionalString, requireRecord, requireString, requireIntegerAtLeast, } from "./validation.js";
|
|
2
|
+
// ── Closed enum members (canonical `z.enum` mirrors) ─────────────────────────
|
|
3
|
+
/** `verdictStateSchema` — the six live cell states. */
|
|
4
|
+
export const VERDICT_STATES = [
|
|
5
|
+
"matched",
|
|
6
|
+
"deviated",
|
|
7
|
+
"failed",
|
|
8
|
+
"unverified",
|
|
9
|
+
"missing",
|
|
10
|
+
"not_evaluated",
|
|
11
|
+
];
|
|
12
|
+
/** `attributeVerdictSchema.strength`. */
|
|
13
|
+
export const VERDICT_STRENGTHS = ["required", "preferred"];
|
|
14
|
+
/** `verdictBaselineSchema` discriminant — the four arms of what a cell was judged against. */
|
|
15
|
+
export const VERDICT_BASELINE_KINDS = ["term", "presence", "seed", "image"];
|
|
16
|
+
/** The `image` baseline arm's own `source` enum. */
|
|
17
|
+
export const VERDICT_IMAGE_SOURCES = ["variant", "vector"];
|
|
18
|
+
/** `verdictDeviationSchema.direction`. */
|
|
19
|
+
export const VERDICT_DEVIATION_DIRECTIONS = ["above", "below", "other"];
|
|
20
|
+
/** `verdictTrustGapSchema` — why a real value did not clear the trust bar. */
|
|
21
|
+
export const VERDICT_TRUST_GAPS = ["below_confidence", "basis_unmet", "proposed"];
|
|
22
|
+
/** `verdictGradeSchema` — the rolled-up card grade. */
|
|
23
|
+
export const VERDICT_GRADES = ["meets_all", "close", "hard_no"];
|
|
24
|
+
/** `visualMeaningSchema.basis` — where a meaning came from. */
|
|
25
|
+
export const VISUAL_MEANING_BASES = ["canonical_attribute", "image_colour_analysis"];
|
|
26
|
+
// ── Leaf checks ──────────────────────────────────────────────────────────────
|
|
27
|
+
/** `palette[]` — `{ hex, proportion, name: string.nullable().optional() }`. */
|
|
28
|
+
function checkPaletteSwatch(value, what, fail) {
|
|
29
|
+
const record = requireRecord(value, what, fail);
|
|
30
|
+
requireString(record.hex, `${what}.hex`, fail);
|
|
31
|
+
requireNumber(record.proportion, `${what}.proportion`, fail);
|
|
32
|
+
// `.nullable().optional()` — absent OR null OR a string.
|
|
33
|
+
if (record.name !== undefined)
|
|
34
|
+
requireNullableString(record.name, `${what}.name`, fail);
|
|
35
|
+
}
|
|
36
|
+
/** `colourMeta` — three independently optional strings. */
|
|
37
|
+
function checkColourMeta(value, what, fail) {
|
|
38
|
+
const record = requireRecord(value, what, fail);
|
|
39
|
+
requireOptionalString(record.mood, `${what}.mood`, fail);
|
|
40
|
+
requireOptionalString(record.dominantFamily, `${what}.dominantFamily`, fail);
|
|
41
|
+
requireOptionalString(record.dominantHex, `${what}.dominantHex`, fail);
|
|
42
|
+
}
|
|
43
|
+
/** `searchResultSignalsSchema` — five nullable numbers, all required keys. */
|
|
44
|
+
function checkSignals(value, what, fail) {
|
|
45
|
+
const record = requireRecord(value, what, fail);
|
|
46
|
+
for (const signal of ["image", "category", "colour", "lexical", "text"]) {
|
|
47
|
+
requireNullableNumber(record[signal], `${what}.${signal}`, fail);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** `visualMeaningSchema` — note the NON-EMPTY `values` array and the closed `basis`. */
|
|
51
|
+
function checkVisualMeaning(value, what, fail) {
|
|
52
|
+
const record = requireRecord(value, what, fail);
|
|
53
|
+
requireString(record.id, `${what}.id`, fail);
|
|
54
|
+
requireString(record.label, `${what}.label`, fail);
|
|
55
|
+
requireNonEmptyStringArray(record.values, `${what}.values`, fail);
|
|
56
|
+
requireEnum(record.basis, VISUAL_MEANING_BASES, `${what}.basis`, fail);
|
|
57
|
+
requireOptionalNumber(record.confidence, `${what}.confidence`, fail);
|
|
58
|
+
requireOptionalString(record.sourceBasis, `${what}.sourceBasis`, fail);
|
|
59
|
+
requireOptionalString(record.version, `${what}.version`, fail);
|
|
60
|
+
requireOptionalString(record.appliedAt, `${what}.appliedAt`, fail);
|
|
61
|
+
}
|
|
62
|
+
// ── Verdict subtree ──────────────────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* `verdictBaselineSchema` — a discriminated union on `kind`. An unknown
|
|
65
|
+
* discriminant is rejected, exactly as `z.discriminatedUnion` rejects it, and
|
|
66
|
+
* each arm carries only the fields it needs.
|
|
67
|
+
*/
|
|
68
|
+
function checkVerdictBaseline(value, what, fail) {
|
|
69
|
+
const record = requireRecord(value, what, fail);
|
|
70
|
+
const kind = requireEnum(record.kind, VERDICT_BASELINE_KINDS, `${what}.kind`, fail);
|
|
71
|
+
switch (kind) {
|
|
72
|
+
case "term":
|
|
73
|
+
requireNonEmptyString(record.term, `${what}.term`, fail);
|
|
74
|
+
return;
|
|
75
|
+
case "presence":
|
|
76
|
+
return;
|
|
77
|
+
case "seed":
|
|
78
|
+
requireNonEmptyString(record.variantId, `${what}.variantId`, fail);
|
|
79
|
+
return;
|
|
80
|
+
default:
|
|
81
|
+
// `image` — the like-this-picture arm.
|
|
82
|
+
requireEnum(record.source, VERDICT_IMAGE_SOURCES, `${what}.source`, fail);
|
|
83
|
+
requireNullableNonEmptyString(record.variantId, `${what}.variantId`, fail);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** `verdictDeviationSchema` — a named near-miss. */
|
|
87
|
+
function checkVerdictDeviation(value, what, fail) {
|
|
88
|
+
const record = requireRecord(value, what, fail);
|
|
89
|
+
requireEnum(record.direction, VERDICT_DEVIATION_DIRECTIONS, `${what}.direction`, fail);
|
|
90
|
+
requireNullableNumber(record.delta, `${what}.delta`, fail);
|
|
91
|
+
requireNullableString(record.unit, `${what}.unit`, fail);
|
|
92
|
+
requireNonEmptyString(record.label, `${what}.label`, fail);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* `attributeVerdictSchema` — nine fields plus the TWO cross-field invariants its
|
|
96
|
+
* `.superRefine()` enforces. Those are relational rather than field-wise, which
|
|
97
|
+
* is no obstacle to a hand-rolled validator: they are two conditionals, and the
|
|
98
|
+
* schema's own doc calls them out as the reason a malformed cell can never reach
|
|
99
|
+
* a consumer.
|
|
100
|
+
*/
|
|
101
|
+
function checkAttributeVerdict(value, what, fail) {
|
|
102
|
+
const record = requireRecord(value, what, fail);
|
|
103
|
+
requireNonEmptyString(record.attributeId, `${what}.attributeId`, fail);
|
|
104
|
+
const state = requireEnum(record.state, VERDICT_STATES, `${what}.state`, fail);
|
|
105
|
+
requireEnum(record.strength, VERDICT_STRENGTHS, `${what}.strength`, fail);
|
|
106
|
+
checkVerdictBaseline(record.baseline, `${what}.baseline`, fail);
|
|
107
|
+
requireNullableString(record.value, `${what}.value`, fail);
|
|
108
|
+
requireNullableBoundedNumber(record.confidence, `${what}.confidence`, fail, 0, 1);
|
|
109
|
+
requireNullableString(record.sourceBasis, `${what}.sourceBasis`, fail);
|
|
110
|
+
const deviation = record.deviation;
|
|
111
|
+
if (deviation !== null)
|
|
112
|
+
checkVerdictDeviation(deviation, `${what}.deviation`, fail);
|
|
113
|
+
const trustGap = requireNullableEnum(record.trustGap, VERDICT_TRUST_GAPS, `${what}.trustGap`, fail);
|
|
114
|
+
// superRefine invariant 1 — a deviation only describes a `deviated` cell.
|
|
115
|
+
if (deviation !== null && state !== "deviated") {
|
|
116
|
+
throw fail(`${what}.deviation is set on a cell whose state is "${state}", not "deviated"`);
|
|
117
|
+
}
|
|
118
|
+
// superRefine invariant 2 — a trust gap only explains an `unverified` cell.
|
|
119
|
+
if (trustGap !== null && state !== "unverified") {
|
|
120
|
+
throw fail(`${what}.trustGap is set on a cell whose state is "${state}", not "unverified"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** `candidateVerdictSchema` — the whole report card. */
|
|
124
|
+
function checkCandidateVerdict(value, what, fail) {
|
|
125
|
+
const record = requireRecord(value, what, fail);
|
|
126
|
+
const cells = requireArray(record.cells, `${what}.cells`, fail);
|
|
127
|
+
cells.forEach((cell, index) => checkAttributeVerdict(cell, `${what}.cells[${index}]`, fail));
|
|
128
|
+
requireEnum(record.grade, VERDICT_GRADES, `${what}.grade`, fail);
|
|
129
|
+
requireNullableBoundedNumber(record.minConfidence, `${what}.minConfidence`, fail, 0, 1);
|
|
130
|
+
}
|
|
131
|
+
// ── The item ─────────────────────────────────────────────────────────────────
|
|
132
|
+
/**
|
|
133
|
+
* Prove one wire value is a {@link SearchResultItem}, raising through `fail` at
|
|
134
|
+
* the first field that does not match `searchResultItemSchema`. Checks the shape
|
|
135
|
+
* only — unknown keys ride through untouched, per the SDK's pass-through
|
|
136
|
+
* contract (nothing on the emit path parses, so stripping would invent a
|
|
137
|
+
* normalisation the wire never had).
|
|
138
|
+
*/
|
|
139
|
+
export function checkSearchResultItem(value, what, fail) {
|
|
140
|
+
const record = requireRecord(value, what, fail);
|
|
141
|
+
requireString(record.variantId, `${what}.variantId`, fail);
|
|
142
|
+
requireString(record.brand, `${what}.brand`, fail);
|
|
143
|
+
requireString(record.product, `${what}.product`, fail);
|
|
144
|
+
requireNullableString(record.brandSlug, `${what}.brandSlug`, fail);
|
|
145
|
+
requireNullableString(record.productId, `${what}.productId`, fail);
|
|
146
|
+
requireNullableString(record.color, `${what}.color`, fail);
|
|
147
|
+
requireNullableString(record.variantName, `${what}.variantName`, fail);
|
|
148
|
+
requireNullableString(record.imageUrl, `${what}.imageUrl`, fail);
|
|
149
|
+
requireBoolean(record.hasImage, `${what}.hasImage`, fail);
|
|
150
|
+
requireNumber(record.score, `${what}.score`, fail);
|
|
151
|
+
const palette = requireArray(record.palette, `${what}.palette`, fail);
|
|
152
|
+
palette.forEach((swatch, index) => checkPaletteSwatch(swatch, `${what}.palette[${index}]`, fail));
|
|
153
|
+
if (record.colourMeta !== undefined) {
|
|
154
|
+
checkColourMeta(record.colourMeta, `${what}.colourMeta`, fail);
|
|
155
|
+
}
|
|
156
|
+
if (record.signals !== undefined)
|
|
157
|
+
checkSignals(record.signals, `${what}.signals`, fail);
|
|
158
|
+
requireOptionalBoolean(record.retired, `${what}.retired`, fail);
|
|
159
|
+
requireOptionalBoolean(record.belowFloor, `${what}.belowFloor`, fail);
|
|
160
|
+
if (record.siblingCount !== undefined) {
|
|
161
|
+
requireIntegerAtLeast(record.siblingCount, `${what}.siblingCount`, fail, 1);
|
|
162
|
+
}
|
|
163
|
+
if (record.visualMeanings !== undefined) {
|
|
164
|
+
const meanings = requireArray(record.visualMeanings, `${what}.visualMeanings`, fail);
|
|
165
|
+
meanings.forEach((meaning, index) => checkVisualMeaning(meaning, `${what}.visualMeanings[${index}]`, fail));
|
|
166
|
+
}
|
|
167
|
+
if (record.verdict !== undefined) {
|
|
168
|
+
checkCandidateVerdict(record.verdict, `${what}.verdict`, fail);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/** Prove every entry of a `results[]` array is a {@link SearchResultItem}. */
|
|
172
|
+
export function checkSearchResultItems(items, what, fail) {
|
|
173
|
+
items.forEach((item, index) => checkSearchResultItem(item, `${what}[${index}]`, fail));
|
|
174
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime validator for `GET /api/v1/source-material/{batchId}` — the
|
|
3
|
+
* hand-rolled, zero-runtime-dependency mirror of
|
|
4
|
+
* `sourceMaterialBatchDetailSchema` and its transitive closure
|
|
5
|
+
* (`sourceMaterialBatchDetailFileSchema`, `sourceImageAnalysisSchema`,
|
|
6
|
+
* `sourceImageAnalysisResultSchema`) in
|
|
7
|
+
* `packages/sources/src/source-material/contract.ts`.
|
|
8
|
+
*
|
|
9
|
+
* WHY IT EXISTS. `contract-parity.test.ts` proves the SDK's STATIC types match
|
|
10
|
+
* the canonical schemas; that is a compile-time proof about shapes and validates
|
|
11
|
+
* nothing at run time. This is the run-time half, and it must ACCEPT and REJECT
|
|
12
|
+
* exactly what the schema does — pinned by `source-material-parity.test.ts`.
|
|
13
|
+
*
|
|
14
|
+
* TWO CONTRACT PROPERTIES IT ENFORCES, both easy to lose in a refactor:
|
|
15
|
+
* 1. `analyses` is REQUIRED and an array on every file. `[]` means "nothing
|
|
16
|
+
* computed yet"; an absent field is a broken response, not an empty one.
|
|
17
|
+
* Preprocessing is manual today and automatic later — only the trigger
|
|
18
|
+
* changes, never this contract.
|
|
19
|
+
* 2. `result` is a JSON record all the way down (`z.json()`), not `unknown`.
|
|
20
|
+
* See `requireJsonValue` for why the schema is that strict and why this
|
|
21
|
+
* mirror cannot actually fire over the wire.
|
|
22
|
+
*
|
|
23
|
+
* ENUMS ARE CLOSED here, matching the canonical `z.enum(…)` members, while the
|
|
24
|
+
* published types are `OpenEnum` so a newer server value keeps type-checking.
|
|
25
|
+
* The validator follows the SCHEMA: the type stays wider than what is proven,
|
|
26
|
+
* which is imprecise but never a lie — the same choice
|
|
27
|
+
* `search-result-validation.ts` makes for `basis`.
|
|
28
|
+
*
|
|
29
|
+
* Package-internal (not re-exported from `index.ts`): a published symbol is a
|
|
30
|
+
* permanent contract, and promoting it later is additive.
|
|
31
|
+
*/
|
|
32
|
+
import { MaterialGraphError } from "./errors.js";
|
|
33
|
+
import { requireArray, requireEnum, requireSafeInteger, requireJsonRecord, requireNonEmptyString, requireRecord, requireString, } from "./validation.js";
|
|
34
|
+
/** `sourceImageAnalysisKindSchema` members, verbatim. */
|
|
35
|
+
const ANALYSIS_KINDS = ["scene_match"];
|
|
36
|
+
/** `sourceImageAnalysisStatusSchema` members, verbatim. */
|
|
37
|
+
const ANALYSIS_STATUSES = ["ok", "failed"];
|
|
38
|
+
/** `sourceMaterialRoleSchema` members, verbatim. */
|
|
39
|
+
const SOURCE_ROLES = [
|
|
40
|
+
"brief",
|
|
41
|
+
"reference_image",
|
|
42
|
+
"document",
|
|
43
|
+
"document_image",
|
|
44
|
+
"inspiration_image",
|
|
45
|
+
"unclassified",
|
|
46
|
+
];
|
|
47
|
+
function detailFailure(batchId) {
|
|
48
|
+
return (reason) => MaterialGraphError.unexpected(`GET /api/v1/source-material/${batchId} returned a malformed body: ${reason}`);
|
|
49
|
+
}
|
|
50
|
+
function checkAnalysis(value, what, fail) {
|
|
51
|
+
const record = requireRecord(value, what, fail);
|
|
52
|
+
// `model` and `analysedAt` ARE `.min(1)` canonically — fields this endpoint
|
|
53
|
+
// introduces, where an empty value is meaningless and `model` already cannot
|
|
54
|
+
// be written empty. Parity here is achieved by the schema being strict, never
|
|
55
|
+
// by this mirror freelancing past it.
|
|
56
|
+
return {
|
|
57
|
+
kind: requireEnum(record.kind, ANALYSIS_KINDS, `${what}.kind`, fail),
|
|
58
|
+
model: requireNonEmptyString(record.model, `${what}.model`, fail),
|
|
59
|
+
status: requireEnum(record.status, ANALYSIS_STATUSES, `${what}.status`, fail),
|
|
60
|
+
result: requireJsonRecord(record.result, `${what}.result`, fail),
|
|
61
|
+
analysedAt: requireNonEmptyString(record.analysedAt, `${what}.analysedAt`, fail),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function checkFile(value, what, fail) {
|
|
65
|
+
const record = requireRecord(value, what, fail);
|
|
66
|
+
// NOT `requireDefaultedArray`: the schema has no `.default([])`, so an absent
|
|
67
|
+
// `analyses` is a malformed body rather than an empty one. Keeping that
|
|
68
|
+
// distinction is the whole point of the always-an-array contract.
|
|
69
|
+
const analyses = requireArray(record.analyses, `${what}.analyses`, fail);
|
|
70
|
+
// `requireString`, NOT `requireNonEmptyString`, for the fields this endpoint
|
|
71
|
+
// INHERITS from the shared source-material contract (`sourceId`, `sha256`,
|
|
72
|
+
// `url` — plus `batchId`/`createdAt` below). Those are plain `z.string()`
|
|
73
|
+
// there and shared with the upload path; a client that rejected `""` would
|
|
74
|
+
// refuse a response the server can legitimately produce, and tightening them
|
|
75
|
+
// is a change to a contract this endpoint does not own.
|
|
76
|
+
return {
|
|
77
|
+
sourceId: requireString(record.sourceId, `${what}.sourceId`, fail),
|
|
78
|
+
role: requireEnum(record.role, SOURCE_ROLES, `${what}.role`, fail),
|
|
79
|
+
filename: requireString(record.filename, `${what}.filename`, fail),
|
|
80
|
+
mediaType: requireString(record.mediaType, `${what}.mediaType`, fail),
|
|
81
|
+
sha256: requireString(record.sha256, `${what}.sha256`, fail),
|
|
82
|
+
// `requireSafeInteger`, NOT `requireIntegerAtLeast(…, 0)`. Canonically these
|
|
83
|
+
// are a bare `z.number().int()` on the INHERITED
|
|
84
|
+
// `sourceMaterialFileReceiptSchema`, which carries no lower bound — so a
|
|
85
|
+
// negative is accepted there and must be accepted here.
|
|
86
|
+
//
|
|
87
|
+
// A negative `bytes` is nonsense and a negative `pages` more so, and this is
|
|
88
|
+
// still not the place to say so: a client validator holding an opinion the
|
|
89
|
+
// contract does not is how the two drift, and how a response the server may
|
|
90
|
+
// legitimately produce starts throwing at the boundary. Tightening those
|
|
91
|
+
// fields is a canonical change to a schema shared with the upload path, and
|
|
92
|
+
// belongs in its own ticket with this mirror following after.
|
|
93
|
+
bytes: requireSafeInteger(record.bytes, `${what}.bytes`, fail),
|
|
94
|
+
pages: record.pages === null ? null : requireSafeInteger(record.pages, `${what}.pages`, fail),
|
|
95
|
+
url: requireString(record.url, `${what}.url`, fail),
|
|
96
|
+
analyses: analyses.map((analysis, index) => checkAnalysis(analysis, `${what}.analyses[${index}]`, fail)),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** Validate a batch-detail body, returning it typed, or throw explaining why. */
|
|
100
|
+
export function checkSourceMaterialBatchDetail(value, batchId) {
|
|
101
|
+
const fail = detailFailure(batchId);
|
|
102
|
+
const record = requireRecord(value, "body", fail);
|
|
103
|
+
const sources = requireArray(record.sources, "body.sources", fail);
|
|
104
|
+
return {
|
|
105
|
+
batchId: requireString(record.batchId, "body.batchId", fail),
|
|
106
|
+
createdAt: requireString(record.createdAt, "body.createdAt", fail),
|
|
107
|
+
sources: sources.map((file, index) => checkFile(file, `body.sources[${index}]`, fail)),
|
|
108
|
+
};
|
|
109
|
+
}
|
package/spec-types.d.ts
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
* in the last). A caller shouldn't have to know which bucket a programme fell
|
|
31
31
|
* into, so this single virtual id matches the `programme` term across all four.
|
|
32
32
|
*
|
|
33
|
+
* The `term` may be a display name or alias ("GREENGUARD Gold", "LEED"), resolved
|
|
34
|
+
* server-side to its canonical programme token, or a canonical token directly
|
|
35
|
+
* (e.g. `greenguard_certificate`, `has_greenguard_gold_certification`).
|
|
36
|
+
*
|
|
33
37
|
* @example
|
|
34
38
|
* // "find a GREENGUARD-certified alternative"
|
|
35
39
|
* client.search({
|
package/spec-types.js
CHANGED
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
* in the last). A caller shouldn't have to know which bucket a programme fell
|
|
31
31
|
* into, so this single virtual id matches the `programme` term across all four.
|
|
32
32
|
*
|
|
33
|
+
* The `term` may be a display name or alias ("GREENGUARD Gold", "LEED"), resolved
|
|
34
|
+
* server-side to its canonical programme token, or a canonical token directly
|
|
35
|
+
* (e.g. `greenguard_certificate`, `has_greenguard_gold_certification`).
|
|
36
|
+
*
|
|
33
37
|
* @example
|
|
34
38
|
* // "find a GREENGUARD-certified alternative"
|
|
35
39
|
* client.search({
|
package/specifications.d.ts
CHANGED
|
@@ -38,6 +38,11 @@ export declare function hasSpec(attributeId: string): SpecPresenceFilter;
|
|
|
38
38
|
* virtual attribute so a programme matches wherever it landed at ingest
|
|
39
39
|
* (compliance / sustainability / material-health / VOC buckets).
|
|
40
40
|
*
|
|
41
|
+
* The value may be a display name or alias ("GREENGUARD Gold", "LEED") — the
|
|
42
|
+
* server resolves it to its canonical programme token before matching — or a
|
|
43
|
+
* canonical token directly (e.g. `greenguard_certificate`,
|
|
44
|
+
* `has_greenguard_gold_certification`).
|
|
45
|
+
*
|
|
41
46
|
* @example
|
|
42
47
|
* // "find a GREENGUARD-certified alternative"
|
|
43
48
|
* client.search({ similarTo: { variantId: seed }, filters: [certificationProgramme("GREENGUARD Gold")] });
|
|
@@ -56,6 +61,10 @@ export declare function specCriterion(attributeId: string, value: string): strin
|
|
|
56
61
|
* An `attribute:value` criterion string for `bySpec` that filters by
|
|
57
62
|
* certification PROGRAMME across all four certification buckets.
|
|
58
63
|
*
|
|
64
|
+
* The value may be a display name or alias ("GREENGUARD Gold", "LEED"), resolved
|
|
65
|
+
* server-side to its canonical programme token, or a canonical token directly
|
|
66
|
+
* (e.g. `greenguard_certificate`, `has_greenguard_gold_certification`).
|
|
67
|
+
*
|
|
59
68
|
* @example
|
|
60
69
|
* // every brand's GREENGUARD-Gold materials
|
|
61
70
|
* client.bySpec([certificationProgrammeCriterion("GREENGUARD Gold")]);
|