@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/specifications.js
CHANGED
|
@@ -42,6 +42,11 @@ export function hasSpec(attributeId) {
|
|
|
42
42
|
* virtual attribute so a programme matches wherever it landed at ingest
|
|
43
43
|
* (compliance / sustainability / material-health / VOC buckets).
|
|
44
44
|
*
|
|
45
|
+
* The value may be a display name or alias ("GREENGUARD Gold", "LEED") — the
|
|
46
|
+
* server resolves it to its canonical programme token before matching — or a
|
|
47
|
+
* canonical token directly (e.g. `greenguard_certificate`,
|
|
48
|
+
* `has_greenguard_gold_certification`).
|
|
49
|
+
*
|
|
45
50
|
* @example
|
|
46
51
|
* // "find a GREENGUARD-certified alternative"
|
|
47
52
|
* client.search({ similarTo: { variantId: seed }, filters: [certificationProgramme("GREENGUARD Gold")] });
|
|
@@ -64,6 +69,10 @@ export function specCriterion(attributeId, value) {
|
|
|
64
69
|
* An `attribute:value` criterion string for `bySpec` that filters by
|
|
65
70
|
* certification PROGRAMME across all four certification buckets.
|
|
66
71
|
*
|
|
72
|
+
* The value may be a display name or alias ("GREENGUARD Gold", "LEED"), resolved
|
|
73
|
+
* server-side to its canonical programme token, or a canonical token directly
|
|
74
|
+
* (e.g. `greenguard_certificate`, `has_greenguard_gold_certification`).
|
|
75
|
+
*
|
|
67
76
|
* @example
|
|
68
77
|
* // every brand's GREENGUARD-Gold materials
|
|
69
78
|
* client.bySpec([certificationProgrammeCriterion("GREENGUARD Gold")]);
|
package/types.d.ts
CHANGED
|
@@ -12,9 +12,19 @@
|
|
|
12
12
|
* server value still type-checks, and objects that may grow fields are read
|
|
13
13
|
* structurally (unknown keys pass through untouched — the client never strips).
|
|
14
14
|
*/
|
|
15
|
+
import type { BySpecVerdict } from "./by-spec-types.js";
|
|
16
|
+
import type { MaterialDetail, MaterialTile } from "./material-types.js";
|
|
15
17
|
import type { SearchFilter, SearchSuggestion } from "./search-refinement-types.js";
|
|
16
18
|
/** A widened string-literal union: known members autocomplete, unknown values still type-check. */
|
|
17
19
|
export type OpenEnum<T extends string> = T | (string & {});
|
|
20
|
+
/**
|
|
21
|
+
* A JSON value, recursively — exactly what survives a `jsonb` round-trip
|
|
22
|
+
* unchanged. Used where a payload's SHAPE belongs to its producer but its values
|
|
23
|
+
* must still be JSON: `Date`, `bigint`, `undefined`, and functions are not.
|
|
24
|
+
*/
|
|
25
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
26
|
+
[key: string]: JsonValue;
|
|
27
|
+
};
|
|
18
28
|
/** One palette swatch of a material's main image. */
|
|
19
29
|
export interface PaletteSwatch {
|
|
20
30
|
hex: string;
|
|
@@ -48,60 +58,15 @@ export interface MaterialColour {
|
|
|
48
58
|
}
|
|
49
59
|
/** Where the served image came from. */
|
|
50
60
|
export type ImageSource = OpenEnum<"r2" | "origin">;
|
|
51
|
-
|
|
52
|
-
export interface MaterialTile {
|
|
53
|
-
variantId: string;
|
|
54
|
-
brand: string;
|
|
55
|
-
product: string;
|
|
56
|
-
variantName: string | null;
|
|
57
|
-
imageUrl: string | null;
|
|
58
|
-
imageSource: ImageSource | null;
|
|
59
|
-
category: string | null;
|
|
60
|
-
colour: MaterialColour;
|
|
61
|
-
}
|
|
62
|
-
/** A canonical attribute/term pair carried on a detail record. */
|
|
63
|
-
export interface MaterialFacet {
|
|
64
|
-
attributeId: string;
|
|
65
|
-
term: string;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* One dimensional configuration of a material. Numeric fields are millimetres
|
|
69
|
-
* (mass in kilograms); string fields are the source's own descriptors. The
|
|
70
|
-
* index signature preserves the corpus's long tail of rarer keys.
|
|
71
|
-
*/
|
|
72
|
-
export interface DimensionEntry {
|
|
73
|
-
label?: string;
|
|
74
|
-
tile_size?: string;
|
|
75
|
-
measurement_basis?: string;
|
|
76
|
-
width_mm?: number;
|
|
77
|
-
length_mm?: number;
|
|
78
|
-
height_mm?: number;
|
|
79
|
-
depth_mm?: number;
|
|
80
|
-
thickness_mm?: number;
|
|
81
|
-
diameter_mm?: number;
|
|
82
|
-
visible_projection_mm?: number;
|
|
83
|
-
weight_kg?: number;
|
|
84
|
-
[key: string]: unknown;
|
|
85
|
-
}
|
|
86
|
-
/** The record projection of a material — the tile plus detail-only fields. */
|
|
87
|
-
export interface MaterialDetail extends MaterialTile {
|
|
88
|
-
brandSlug: string;
|
|
89
|
-
productId: string;
|
|
90
|
-
color: string | null;
|
|
91
|
-
materialBankId: string | null;
|
|
92
|
-
installationImageUrl: string | null;
|
|
93
|
-
displayCategory: string | null;
|
|
94
|
-
categoryPath: string[];
|
|
95
|
-
palette: PaletteSwatch[];
|
|
96
|
-
facets: MaterialFacet[];
|
|
97
|
-
dimensions: DimensionEntry[] | null;
|
|
98
|
-
}
|
|
61
|
+
export type { DimensionEntry, MaterialAvailability, MaterialDetail, MaterialFacet, MaterialSuccessor, MaterialTile, } from "./material-types.js";
|
|
99
62
|
/** Per-signal scores, present on a result when `explain` was requested. */
|
|
100
63
|
export interface SearchResultSignals {
|
|
101
64
|
image: number | null;
|
|
102
65
|
category: number | null;
|
|
103
66
|
colour: number | null;
|
|
104
67
|
lexical: number | null;
|
|
68
|
+
/** Text→image RERANK cosine — non-null only for a seeded search carrying `text`. */
|
|
69
|
+
text: number | null;
|
|
105
70
|
}
|
|
106
71
|
/** An accepted/measured visual meaning — descriptive evidence, never a ranking cause. */
|
|
107
72
|
export interface VisualMeaning {
|
|
@@ -131,8 +96,21 @@ export interface SearchResultItem {
|
|
|
131
96
|
score: number;
|
|
132
97
|
signals?: SearchResultSignals;
|
|
133
98
|
retired?: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Present (true) only on quality-floor rejects returned under `includeBelowFloor`
|
|
101
|
+
* (match only) — the item failed the match image-quality floor and would be
|
|
102
|
+
* dropped by default. Appended after the kept results.
|
|
103
|
+
*/
|
|
104
|
+
belowFloor?: boolean;
|
|
134
105
|
siblingCount?: number;
|
|
135
106
|
visualMeanings?: VisualMeaning[];
|
|
107
|
+
/**
|
|
108
|
+
* The shared report card for this hit against the request's `filters` (required
|
|
109
|
+
* cells) and `preferences` (preferred cells). Present (full view only) whenever a
|
|
110
|
+
* spec filter or preference was given. Reuses the by-spec verdict shape — the
|
|
111
|
+
* shared CandidateVerdict mirror; see {@link MaterialVerdict}.
|
|
112
|
+
*/
|
|
113
|
+
verdict?: BySpecVerdict;
|
|
136
114
|
}
|
|
137
115
|
/** The lean `view:"card"` result item — identity + name + thumbnail + score. */
|
|
138
116
|
export interface SearchCardItem {
|
|
@@ -385,31 +363,7 @@ export interface ResolveAttributeResponse {
|
|
|
385
363
|
candidates: AttributeCandidate[];
|
|
386
364
|
}
|
|
387
365
|
export type * from "./catalog-discovery-types.js";
|
|
388
|
-
export
|
|
389
|
-
attributeId: string;
|
|
390
|
-
confidence: number | null;
|
|
391
|
-
sourceBasis: string | null;
|
|
392
|
-
}
|
|
393
|
-
export interface BySpecMatch {
|
|
394
|
-
variantId: string;
|
|
395
|
-
brand: string;
|
|
396
|
-
product: string;
|
|
397
|
-
variant: string;
|
|
398
|
-
color: string | null;
|
|
399
|
-
imageUrl: string | null;
|
|
400
|
-
hex: string | null;
|
|
401
|
-
matched: BySpecMatchedCell[];
|
|
402
|
-
minConfidence: number | null;
|
|
403
|
-
}
|
|
404
|
-
export interface BySpecResponse {
|
|
405
|
-
criteria: Array<{
|
|
406
|
-
attribute: string;
|
|
407
|
-
value: string;
|
|
408
|
-
}>;
|
|
409
|
-
totalMatches: number;
|
|
410
|
-
brandsSpanned: number;
|
|
411
|
-
matches: BySpecMatch[];
|
|
412
|
-
}
|
|
366
|
+
export type * from "./by-spec-types.js";
|
|
413
367
|
export interface WhoamiResponse {
|
|
414
368
|
principal: string;
|
|
415
369
|
scopes: string[];
|
|
@@ -453,6 +407,11 @@ export interface SearchInput {
|
|
|
453
407
|
};
|
|
454
408
|
mood?: string;
|
|
455
409
|
sort?: OpenEnum<"relevance" | "light" | "dark" | "vivid" | "muted" | "colourful">;
|
|
410
|
+
/**
|
|
411
|
+
* Seed a visual search from an existing variant's image (`variantId`) or a
|
|
412
|
+
* pre-computed image vector (`vector`). Only the `{ variantId }` form carries a
|
|
413
|
+
* seed variant, so it is the only form `verdicts` can diff a candidate against.
|
|
414
|
+
*/
|
|
456
415
|
similarTo?: {
|
|
457
416
|
variantId: string;
|
|
458
417
|
} | {
|
|
@@ -468,8 +427,16 @@ export interface SearchInput {
|
|
|
468
427
|
category?: number;
|
|
469
428
|
colour?: number;
|
|
470
429
|
lexical?: number;
|
|
430
|
+
/** Text rerank multiplier applied when a seeded search also carries `text` (default 0.4). */
|
|
431
|
+
text?: number;
|
|
471
432
|
};
|
|
472
433
|
filters?: SearchFilter[];
|
|
434
|
+
/**
|
|
435
|
+
* SOFT spec criteria (rank-never-exclude). Same shape as `filters`; a preference
|
|
436
|
+
* never removes a result — it adds a `preferred` cell to each hit's `verdict` and
|
|
437
|
+
* demotes a failed/missing preferred hit WITHIN its page only.
|
|
438
|
+
*/
|
|
439
|
+
preferences?: SearchFilter[];
|
|
473
440
|
limit?: number;
|
|
474
441
|
cursor?: string;
|
|
475
442
|
explain?: boolean;
|
|
@@ -478,6 +445,13 @@ export interface SearchInput {
|
|
|
478
445
|
count?: boolean;
|
|
479
446
|
/** Only return materials with at least one catalog image. Default false (full catalog). */
|
|
480
447
|
hasImages?: boolean;
|
|
448
|
+
/**
|
|
449
|
+
* Opt-in seed-relative verdict cells (MG-629). Only meaningful with
|
|
450
|
+
* `similarTo: { variantId }`: each hit's `verdict` gains `preferred` cells
|
|
451
|
+
* diffing its comparable attributes (numeric + a `colour` axis) against the
|
|
452
|
+
* seed's, with directional deviations. Inert on every other seed form.
|
|
453
|
+
*/
|
|
454
|
+
verdicts?: boolean;
|
|
481
455
|
/** Result projection — `"full"` (default) or the lean `"card"` grid form. */
|
|
482
456
|
view?: OpenEnum<"full" | "card">;
|
|
483
457
|
}
|
|
@@ -534,7 +508,19 @@ export interface MatchInput {
|
|
|
534
508
|
detectionLabel?: string;
|
|
535
509
|
materialFunction?: string;
|
|
536
510
|
detectionCategory?: string;
|
|
511
|
+
/**
|
|
512
|
+
* Opt-in: also return quality-floor rejects, flagged `belowFloor: true` and
|
|
513
|
+
* appended after the kept results. Absent/false = default behaviour;
|
|
514
|
+
* `droppedBelowFloor` is unchanged.
|
|
515
|
+
*/
|
|
516
|
+
includeBelowFloor?: boolean;
|
|
537
517
|
requestedType?: OpenEnum<"swatch" | "installation" | "flatplan">;
|
|
518
|
+
/** Hard spec gates forwarded verbatim to the inner search — term match or presence, each with an optional trust bar. */
|
|
519
|
+
filters?: SearchFilter[];
|
|
520
|
+
/** Soft criteria (rank-never-exclude). A miss demotes within the page but never removes a hit. */
|
|
521
|
+
preferences?: SearchFilter[];
|
|
522
|
+
/** Opt in to seed-relative verdict cells; inert unless the inner search carries a variant seed. */
|
|
523
|
+
verdicts?: boolean;
|
|
538
524
|
}
|
|
539
525
|
/** Options for a batch material read. */
|
|
540
526
|
export interface MaterialsBatchOptions {
|
package/validation.d.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's shared wire-validation primitives — the vocabulary every hand-rolled
|
|
3
|
+
* mirror of a canonical Zod schema is built from.
|
|
4
|
+
*
|
|
5
|
+
* The SDK publishes with ZERO runtime dependencies, so it cannot call
|
|
6
|
+
* `schema.parse()` on anything. Where a wire payload must be proven rather than
|
|
7
|
+
* trusted, it is checked with these helpers and the result is pinned against the
|
|
8
|
+
* real schema in `contract-parity.test.ts`. Each helper mirrors one Zod
|
|
9
|
+
* construct, named for it, so a reviewer can read the mirror against the schema
|
|
10
|
+
* line by line.
|
|
11
|
+
*
|
|
12
|
+
* Every helper takes the same three shapes: the value, a `what` path used in the
|
|
13
|
+
* diagnostic (`results[3].verdict.cells[0].state`), and a {@link ValidationFailure}
|
|
14
|
+
* owned by the calling module — so one payload's failures all read alike without
|
|
15
|
+
* this file knowing what a Match frame or a Run event is.
|
|
16
|
+
*/
|
|
17
|
+
import { MaterialGraphError } from "./errors.js";
|
|
18
|
+
import type { JsonValue } from "./types.js";
|
|
19
|
+
/**
|
|
20
|
+
* Builds the one error a validation run raises. The calling module supplies it,
|
|
21
|
+
* so the message names that payload and quotes its source.
|
|
22
|
+
*/
|
|
23
|
+
export type ValidationFailure = (reason: string) => MaterialGraphError;
|
|
24
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
25
|
+
/** A `z.object({…})`. */
|
|
26
|
+
export declare function requireRecord(value: unknown, what: string, fail: ValidationFailure): Record<string, unknown>;
|
|
27
|
+
/** A `z.string()`. */
|
|
28
|
+
export declare function requireString(value: unknown, what: string, fail: ValidationFailure): string;
|
|
29
|
+
/** A `z.string().min(1)`. */
|
|
30
|
+
export declare function requireNonEmptyString(value: unknown, what: string, fail: ValidationFailure): string;
|
|
31
|
+
/** A `z.string().optional()`: absent is fine, present-but-wrong is not. */
|
|
32
|
+
export declare function requireOptionalString(value: unknown, what: string, fail: ValidationFailure): void;
|
|
33
|
+
/** A `z.string().nullable()`. */
|
|
34
|
+
export declare function requireNullableString(value: unknown, what: string, fail: ValidationFailure): string | null;
|
|
35
|
+
/** A `z.string().min(1).nullable()`. */
|
|
36
|
+
export declare function requireNullableNonEmptyString(value: unknown, what: string, fail: ValidationFailure): string | null;
|
|
37
|
+
/** A `z.boolean()`. */
|
|
38
|
+
export declare function requireBoolean(value: unknown, what: string, fail: ValidationFailure): boolean;
|
|
39
|
+
/** A `z.boolean().optional()`. */
|
|
40
|
+
export declare function requireOptionalBoolean(value: unknown, what: string, fail: ValidationFailure): void;
|
|
41
|
+
/**
|
|
42
|
+
* A `z.number()`. Zod 4 rejects `NaN` AND `±Infinity` for it, and `1e999` parses
|
|
43
|
+
* out of JSON as `Infinity`, so finiteness is a reachable check.
|
|
44
|
+
*/
|
|
45
|
+
export declare function requireNumber(value: unknown, what: string, fail: ValidationFailure): number;
|
|
46
|
+
/** A `z.number().optional()`. */
|
|
47
|
+
export declare function requireOptionalNumber(value: unknown, what: string, fail: ValidationFailure): void;
|
|
48
|
+
/** A `z.number().nullable()`. */
|
|
49
|
+
export declare function requireNullableNumber(value: unknown, what: string, fail: ValidationFailure): number | null;
|
|
50
|
+
/**
|
|
51
|
+
* A `z.number().min(min).max(max)`. `NaN` and both infinities fail the
|
|
52
|
+
* comparisons, exactly as the bounded Zod schema rejects them.
|
|
53
|
+
*/
|
|
54
|
+
export declare function requireBoundedNumber(value: unknown, what: string, fail: ValidationFailure, min: number, max: number): number;
|
|
55
|
+
/** A `z.number().min(min).max(max).nullable()`. */
|
|
56
|
+
export declare function requireNullableBoundedNumber(value: unknown, what: string, fail: ValidationFailure, min: number, max: number): number | null;
|
|
57
|
+
/**
|
|
58
|
+
* A bare `z.number().int()` — integer, finite, and within the SAFE range, with
|
|
59
|
+
* NO lower bound.
|
|
60
|
+
*
|
|
61
|
+
* Measured against the pinned Zod (4.4.3), `.int()` is exactly
|
|
62
|
+
* `Number.isSafeInteger`: it accepts `0`, `-1`, and `±MAX_SAFE_INTEGER`, and
|
|
63
|
+
* rejects `1.5`, `±2**53`, `NaN`, and `±Infinity`.
|
|
64
|
+
*
|
|
65
|
+
* Two ways to get this wrong, both of which break parity in a different
|
|
66
|
+
* direction. Adding a `min: 0` REJECTS a negative the schema accepts — a client
|
|
67
|
+
* refusing a legitimate response. Using `Number.isInteger` ACCEPTS `2**53`,
|
|
68
|
+
* which the schema rejects — so `requireIntegerAtLeast` is not this helper with
|
|
69
|
+
* the bound dropped, and the safe-range test is why.
|
|
70
|
+
*/
|
|
71
|
+
export declare function requireSafeInteger(value: unknown, what: string, fail: ValidationFailure): number;
|
|
72
|
+
/** A `z.number().int().min(min)` — the integer constraint AND the lower bound. */
|
|
73
|
+
export declare function requireIntegerAtLeast(value: unknown, what: string, fail: ValidationFailure, min: number): number;
|
|
74
|
+
/** A `z.enum([…])` — CLOSED, exactly like the canonical member list. */
|
|
75
|
+
export declare function requireEnum(value: unknown, allowed: readonly string[], what: string, fail: ValidationFailure): string;
|
|
76
|
+
/** A `z.enum([…]).nullable()`. */
|
|
77
|
+
export declare function requireNullableEnum(value: unknown, allowed: readonly string[], what: string, fail: ValidationFailure): string | null;
|
|
78
|
+
/** A `z.array(…)`. */
|
|
79
|
+
export declare function requireArray(value: unknown, what: string, fail: ValidationFailure): unknown[];
|
|
80
|
+
/**
|
|
81
|
+
* A `z.array(…).default([])`: an OMITTED array is valid and materialises as
|
|
82
|
+
* `[]`; a present non-array is not.
|
|
83
|
+
*/
|
|
84
|
+
export declare function requireDefaultedArray(value: unknown, what: string, fail: ValidationFailure): unknown[];
|
|
85
|
+
/** A `z.array(z.string()).min(1)` — non-empty, and every element a string. */
|
|
86
|
+
export declare function requireNonEmptyStringArray(value: unknown, what: string, fail: ValidationFailure): string[];
|
|
87
|
+
/**
|
|
88
|
+
* A `z.json()` — a JSON value, recursively: `string | number | boolean | null`,
|
|
89
|
+
* an array of them, or an object of them, at ANY depth.
|
|
90
|
+
*
|
|
91
|
+
* WHY THIS EXISTS, given it can never fire over the wire. Every SDK response
|
|
92
|
+
* has already been through `JSON.parse`, so a `Date`, `bigint`, `undefined`, or
|
|
93
|
+
* function CANNOT be present by construction — this check is unreachable in
|
|
94
|
+
* practice. It is here for PARITY: the canonical `sourceImageAnalysisResultSchema`
|
|
95
|
+
* uses `z.json()` precisely because those four break differently on the way to
|
|
96
|
+
* `jsonb` (a `bigint` throws in `JSON.stringify`, an `undefined` is silently
|
|
97
|
+
* dropped, and a `Date` round-trips as an ISO *string* — a different type read
|
|
98
|
+
* back than written). The mirror has to reject what the schema rejects or the
|
|
99
|
+
* parity test is lying. Do not delete this as dead code.
|
|
100
|
+
*/
|
|
101
|
+
export declare function requireJsonValue(value: unknown, what: string, fail: ValidationFailure): JsonValue;
|
|
102
|
+
/**
|
|
103
|
+
* A `z.record(z.string(), z.json())` — a PLAIN JSON object of JSON values.
|
|
104
|
+
*
|
|
105
|
+
* Deliberately NOT `requireRecord` + walk the entries. That combination has a
|
|
106
|
+
* top-level hole: `Object.entries(new Date())` and `Object.entries(new Map())`
|
|
107
|
+
* are both `[]`, so there is nothing to recurse over and the value is accepted
|
|
108
|
+
* as `{}` — while `z.record(z.string(), z.json())` rejects both outright. A
|
|
109
|
+
* "non-array object" guard would close those two and still miss a class instance
|
|
110
|
+
* with real own properties, which canonical also rejects. Only a prototype test
|
|
111
|
+
* matches, and {@link requireJsonValue} already applies it at every depth — so
|
|
112
|
+
* this delegates there and then narrows to an object, rather than reimplementing
|
|
113
|
+
* the test and acquiring the same hole one level up.
|
|
114
|
+
*
|
|
115
|
+
* `requireRecord` is left alone: its other callers mirror `z.object({…})`, which
|
|
116
|
+
* genuinely does accept any record, and tightening a shared helper under them
|
|
117
|
+
* would be a change none of them asked for.
|
|
118
|
+
*/
|
|
119
|
+
export declare function requireJsonRecord(value: unknown, what: string, fail: ValidationFailure): Record<string, JsonValue>;
|
package/validation.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's shared wire-validation primitives — the vocabulary every hand-rolled
|
|
3
|
+
* mirror of a canonical Zod schema is built from.
|
|
4
|
+
*
|
|
5
|
+
* The SDK publishes with ZERO runtime dependencies, so it cannot call
|
|
6
|
+
* `schema.parse()` on anything. Where a wire payload must be proven rather than
|
|
7
|
+
* trusted, it is checked with these helpers and the result is pinned against the
|
|
8
|
+
* real schema in `contract-parity.test.ts`. Each helper mirrors one Zod
|
|
9
|
+
* construct, named for it, so a reviewer can read the mirror against the schema
|
|
10
|
+
* line by line.
|
|
11
|
+
*
|
|
12
|
+
* Every helper takes the same three shapes: the value, a `what` path used in the
|
|
13
|
+
* diagnostic (`results[3].verdict.cells[0].state`), and a {@link ValidationFailure}
|
|
14
|
+
* owned by the calling module — so one payload's failures all read alike without
|
|
15
|
+
* this file knowing what a Match frame or a Run event is.
|
|
16
|
+
*/
|
|
17
|
+
import { MaterialGraphError } from "./errors.js";
|
|
18
|
+
export function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
/** A `z.object({…})`. */
|
|
22
|
+
export function requireRecord(value, what, fail) {
|
|
23
|
+
if (!isRecord(value))
|
|
24
|
+
throw fail(`${what} is not a JSON object`);
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
/** A `z.string()`. */
|
|
28
|
+
export function requireString(value, what, fail) {
|
|
29
|
+
if (typeof value !== "string")
|
|
30
|
+
throw fail(`${what} is not a string`);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
/** A `z.string().min(1)`. */
|
|
34
|
+
export function requireNonEmptyString(value, what, fail) {
|
|
35
|
+
const text = requireString(value, what, fail);
|
|
36
|
+
if (text.length === 0)
|
|
37
|
+
throw fail(`${what} is an empty string`);
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
/** A `z.string().optional()`: absent is fine, present-but-wrong is not. */
|
|
41
|
+
export function requireOptionalString(value, what, fail) {
|
|
42
|
+
if (value === undefined)
|
|
43
|
+
return;
|
|
44
|
+
requireString(value, what, fail);
|
|
45
|
+
}
|
|
46
|
+
/** A `z.string().nullable()`. */
|
|
47
|
+
export function requireNullableString(value, what, fail) {
|
|
48
|
+
if (value === null)
|
|
49
|
+
return null;
|
|
50
|
+
return requireString(value, what, fail);
|
|
51
|
+
}
|
|
52
|
+
/** A `z.string().min(1).nullable()`. */
|
|
53
|
+
export function requireNullableNonEmptyString(value, what, fail) {
|
|
54
|
+
if (value === null)
|
|
55
|
+
return null;
|
|
56
|
+
return requireNonEmptyString(value, what, fail);
|
|
57
|
+
}
|
|
58
|
+
/** A `z.boolean()`. */
|
|
59
|
+
export function requireBoolean(value, what, fail) {
|
|
60
|
+
if (typeof value !== "boolean")
|
|
61
|
+
throw fail(`${what} is not a boolean`);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
/** A `z.boolean().optional()`. */
|
|
65
|
+
export function requireOptionalBoolean(value, what, fail) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return;
|
|
68
|
+
requireBoolean(value, what, fail);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A `z.number()`. Zod 4 rejects `NaN` AND `±Infinity` for it, and `1e999` parses
|
|
72
|
+
* out of JSON as `Infinity`, so finiteness is a reachable check.
|
|
73
|
+
*/
|
|
74
|
+
export function requireNumber(value, what, fail) {
|
|
75
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
76
|
+
throw fail(`${what} is not a finite number`);
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
/** A `z.number().optional()`. */
|
|
81
|
+
export function requireOptionalNumber(value, what, fail) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return;
|
|
84
|
+
requireNumber(value, what, fail);
|
|
85
|
+
}
|
|
86
|
+
/** A `z.number().nullable()`. */
|
|
87
|
+
export function requireNullableNumber(value, what, fail) {
|
|
88
|
+
if (value === null)
|
|
89
|
+
return null;
|
|
90
|
+
return requireNumber(value, what, fail);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A `z.number().min(min).max(max)`. `NaN` and both infinities fail the
|
|
94
|
+
* comparisons, exactly as the bounded Zod schema rejects them.
|
|
95
|
+
*/
|
|
96
|
+
export function requireBoundedNumber(value, what, fail, min, max) {
|
|
97
|
+
if (typeof value !== "number" || !(value >= min && value <= max)) {
|
|
98
|
+
throw fail(`${what} is not a number within [${min}, ${max}]`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
/** A `z.number().min(min).max(max).nullable()`. */
|
|
103
|
+
export function requireNullableBoundedNumber(value, what, fail, min, max) {
|
|
104
|
+
if (value === null)
|
|
105
|
+
return null;
|
|
106
|
+
return requireBoundedNumber(value, what, fail, min, max);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A bare `z.number().int()` — integer, finite, and within the SAFE range, with
|
|
110
|
+
* NO lower bound.
|
|
111
|
+
*
|
|
112
|
+
* Measured against the pinned Zod (4.4.3), `.int()` is exactly
|
|
113
|
+
* `Number.isSafeInteger`: it accepts `0`, `-1`, and `±MAX_SAFE_INTEGER`, and
|
|
114
|
+
* rejects `1.5`, `±2**53`, `NaN`, and `±Infinity`.
|
|
115
|
+
*
|
|
116
|
+
* Two ways to get this wrong, both of which break parity in a different
|
|
117
|
+
* direction. Adding a `min: 0` REJECTS a negative the schema accepts — a client
|
|
118
|
+
* refusing a legitimate response. Using `Number.isInteger` ACCEPTS `2**53`,
|
|
119
|
+
* which the schema rejects — so `requireIntegerAtLeast` is not this helper with
|
|
120
|
+
* the bound dropped, and the safe-range test is why.
|
|
121
|
+
*/
|
|
122
|
+
export function requireSafeInteger(value, what, fail) {
|
|
123
|
+
if (!Number.isSafeInteger(value))
|
|
124
|
+
throw fail(`${what} is not a safe integer`);
|
|
125
|
+
return requireNumber(value, what, fail);
|
|
126
|
+
}
|
|
127
|
+
/** A `z.number().int().min(min)` — the integer constraint AND the lower bound. */
|
|
128
|
+
export function requireIntegerAtLeast(value, what, fail, min) {
|
|
129
|
+
const numeric = requireNumber(value, what, fail);
|
|
130
|
+
if (!Number.isInteger(numeric))
|
|
131
|
+
throw fail(`${what} is not an integer`);
|
|
132
|
+
if (numeric < min)
|
|
133
|
+
throw fail(`${what} is below the minimum of ${min}`);
|
|
134
|
+
return numeric;
|
|
135
|
+
}
|
|
136
|
+
/** A `z.enum([…])` — CLOSED, exactly like the canonical member list. */
|
|
137
|
+
export function requireEnum(value, allowed, what, fail) {
|
|
138
|
+
const text = requireString(value, what, fail);
|
|
139
|
+
if (!allowed.includes(text)) {
|
|
140
|
+
throw fail(`${what} is not one of ${allowed.join(" | ")} (${text})`);
|
|
141
|
+
}
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
/** A `z.enum([…]).nullable()`. */
|
|
145
|
+
export function requireNullableEnum(value, allowed, what, fail) {
|
|
146
|
+
if (value === null)
|
|
147
|
+
return null;
|
|
148
|
+
return requireEnum(value, allowed, what, fail);
|
|
149
|
+
}
|
|
150
|
+
/** A `z.array(…)`. */
|
|
151
|
+
export function requireArray(value, what, fail) {
|
|
152
|
+
if (!Array.isArray(value))
|
|
153
|
+
throw fail(`${what} is not an array`);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* A `z.array(…).default([])`: an OMITTED array is valid and materialises as
|
|
158
|
+
* `[]`; a present non-array is not.
|
|
159
|
+
*/
|
|
160
|
+
export function requireDefaultedArray(value, what, fail) {
|
|
161
|
+
if (value === undefined)
|
|
162
|
+
return [];
|
|
163
|
+
return requireArray(value, what, fail);
|
|
164
|
+
}
|
|
165
|
+
/** A `z.array(z.string()).min(1)` — non-empty, and every element a string. */
|
|
166
|
+
export function requireNonEmptyStringArray(value, what, fail) {
|
|
167
|
+
const items = requireArray(value, what, fail);
|
|
168
|
+
if (items.length === 0)
|
|
169
|
+
throw fail(`${what} is empty`);
|
|
170
|
+
return items.map((item, index) => requireString(item, `${what}[${index}]`, fail));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* A `z.json()` — a JSON value, recursively: `string | number | boolean | null`,
|
|
174
|
+
* an array of them, or an object of them, at ANY depth.
|
|
175
|
+
*
|
|
176
|
+
* WHY THIS EXISTS, given it can never fire over the wire. Every SDK response
|
|
177
|
+
* has already been through `JSON.parse`, so a `Date`, `bigint`, `undefined`, or
|
|
178
|
+
* function CANNOT be present by construction — this check is unreachable in
|
|
179
|
+
* practice. It is here for PARITY: the canonical `sourceImageAnalysisResultSchema`
|
|
180
|
+
* uses `z.json()` precisely because those four break differently on the way to
|
|
181
|
+
* `jsonb` (a `bigint` throws in `JSON.stringify`, an `undefined` is silently
|
|
182
|
+
* dropped, and a `Date` round-trips as an ISO *string* — a different type read
|
|
183
|
+
* back than written). The mirror has to reject what the schema rejects or the
|
|
184
|
+
* parity test is lying. Do not delete this as dead code.
|
|
185
|
+
*/
|
|
186
|
+
export function requireJsonValue(value, what, fail) {
|
|
187
|
+
if (value === null)
|
|
188
|
+
return null;
|
|
189
|
+
if (typeof value === "string" || typeof value === "boolean")
|
|
190
|
+
return value;
|
|
191
|
+
if (typeof value === "number") {
|
|
192
|
+
// `z.json()`'s number leaf rejects NaN and ±Infinity, and `1e999` parses out
|
|
193
|
+
// of JSON as `Infinity` — so finiteness IS reachable, unlike the rest.
|
|
194
|
+
if (!Number.isFinite(value))
|
|
195
|
+
throw fail(`${what} is not a finite number`);
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
return value.map((item, index) => requireJsonValue(item, `${what}[${index}]`, fail));
|
|
200
|
+
}
|
|
201
|
+
if (isRecord(value)) {
|
|
202
|
+
// A PLAIN object only. `isRecord` alone is not enough: it answers true for a
|
|
203
|
+
// `Date`, a `Map`, and any class instance, none of which have own enumerable
|
|
204
|
+
// entries — so recursing over them would silently accept them as `{}` while
|
|
205
|
+
// `z.json()` rejects them outright. A `Date` reaching `jsonb` is exactly the
|
|
206
|
+
// failure this check exists to stop.
|
|
207
|
+
const prototype = Object.getPrototypeOf(value);
|
|
208
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
209
|
+
throw fail(`${what} is not a JSON value`);
|
|
210
|
+
}
|
|
211
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
212
|
+
key,
|
|
213
|
+
requireJsonValue(item, `${what}.${key}`, fail),
|
|
214
|
+
]));
|
|
215
|
+
}
|
|
216
|
+
throw fail(`${what} is not a JSON value`);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* A `z.record(z.string(), z.json())` — a PLAIN JSON object of JSON values.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately NOT `requireRecord` + walk the entries. That combination has a
|
|
222
|
+
* top-level hole: `Object.entries(new Date())` and `Object.entries(new Map())`
|
|
223
|
+
* are both `[]`, so there is nothing to recurse over and the value is accepted
|
|
224
|
+
* as `{}` — while `z.record(z.string(), z.json())` rejects both outright. A
|
|
225
|
+
* "non-array object" guard would close those two and still miss a class instance
|
|
226
|
+
* with real own properties, which canonical also rejects. Only a prototype test
|
|
227
|
+
* matches, and {@link requireJsonValue} already applies it at every depth — so
|
|
228
|
+
* this delegates there and then narrows to an object, rather than reimplementing
|
|
229
|
+
* the test and acquiring the same hole one level up.
|
|
230
|
+
*
|
|
231
|
+
* `requireRecord` is left alone: its other callers mirror `z.object({…})`, which
|
|
232
|
+
* genuinely does accept any record, and tightening a shared helper under them
|
|
233
|
+
* would be a change none of them asked for.
|
|
234
|
+
*/
|
|
235
|
+
export function requireJsonRecord(value, what, fail) {
|
|
236
|
+
const json = requireJsonValue(value, what, fail);
|
|
237
|
+
if (json === null || typeof json !== "object" || Array.isArray(json)) {
|
|
238
|
+
throw fail(`${what} is not a JSON object`);
|
|
239
|
+
}
|
|
240
|
+
return json;
|
|
241
|
+
}
|