@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.
Files changed (45) hide show
  1. package/by-spec-types.d.ts +73 -0
  2. package/by-spec-types.js +7 -0
  3. package/client.d.ts +68 -23
  4. package/client.js +82 -25
  5. package/codes-client.d.ts +82 -0
  6. package/codes-client.js +73 -0
  7. package/codes-types.d.ts +154 -9
  8. package/colour-aggregate-types.d.ts +93 -0
  9. package/colour-aggregate-types.js +1 -0
  10. package/colours-client.d.ts +52 -0
  11. package/colours-client.js +39 -0
  12. package/composition-types.d.ts +228 -2
  13. package/http-transport.d.ts +19 -2
  14. package/http-transport.js +72 -2
  15. package/index.d.ts +7 -0
  16. package/index.js +3 -0
  17. package/match-stream-types.d.ts +150 -0
  18. package/match-stream-types.js +28 -0
  19. package/match-stream.d.ts +22 -0
  20. package/match-stream.js +280 -0
  21. package/material-block-types.d.ts +23 -2
  22. package/material-types.d.ts +92 -0
  23. package/material-types.js +1 -0
  24. package/ndjson.d.ts +46 -0
  25. package/ndjson.js +103 -0
  26. package/operations.d.ts +1 -1
  27. package/operations.js +6 -0
  28. package/package.json +1 -1
  29. package/run-types.d.ts +65 -1
  30. package/runs.d.ts +45 -3
  31. package/runs.js +82 -43
  32. package/schedule-types.d.ts +88 -0
  33. package/schedule-types.js +12 -0
  34. package/search-refinement-types.d.ts +13 -0
  35. package/search-result-validation.d.ts +27 -0
  36. package/search-result-validation.js +174 -0
  37. package/source-material-validation.d.ts +3 -0
  38. package/source-material-validation.js +109 -0
  39. package/spec-types.d.ts +4 -0
  40. package/spec-types.js +4 -0
  41. package/specifications.d.ts +9 -0
  42. package/specifications.js +9 -0
  43. package/types.d.ts +59 -73
  44. package/validation.d.ts +119 -0
  45. 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
- /** Resume a suspended Run (`POST /api/v1/runs/{runId}/resume`). */
90
- export function resumeRun(transport, runId, resumeData, signal) {
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 operation = runOperation("list_public_run_events");
131
- const url = transport.buildUrl(operationPath(operation, { runId }), options.after === undefined ? undefined : { after: options.after });
132
- const requestUrl = new URL(url);
133
- const metadata = { method: "GET", origin: requestUrl.origin, path: requestUrl.pathname };
134
- let response;
135
- try {
136
- response = await transport.fetch(url, {
137
- method: "GET",
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,3 @@
1
+ import type { SourceMaterialBatchDetail } from "./run-types.js";
2
+ /** Validate a batch-detail body, returning it typed, or throw explaining why. */
3
+ export declare function checkSourceMaterialBatchDetail(value: unknown, batchId: string): SourceMaterialBatchDetail;
@@ -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({
@@ -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")]);