@instruments/materialgraph 0.9.1 → 0.11.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 (64) hide show
  1. package/brands-client.d.ts +48 -0
  2. package/brands-client.js +45 -0
  3. package/browse-types.d.ts +147 -0
  4. package/browse-types.js +1 -0
  5. package/by-colour-types.d.ts +105 -5
  6. package/client-browse.d.ts +13 -3
  7. package/client-browse.js +12 -2
  8. package/client-exploration.d.ts +9 -0
  9. package/client-exploration.js +7 -0
  10. package/client-source-material-comments.d.ts +51 -0
  11. package/client-source-material-comments.js +40 -0
  12. package/client.d.ts +64 -71
  13. package/client.js +90 -78
  14. package/colour-aggregate-types.d.ts +58 -0
  15. package/colours-client.d.ts +8 -2
  16. package/colours-client.js +15 -2
  17. package/composition-client.d.ts +33 -0
  18. package/composition-client.js +27 -0
  19. package/composition-types.d.ts +46 -0
  20. package/coordinate-client.d.ts +23 -0
  21. package/coordinate-client.js +25 -0
  22. package/errors.js +24 -8
  23. package/exploration-types.d.ts +110 -0
  24. package/exploration-types.js +1 -0
  25. package/fan-deck-types.d.ts +42 -0
  26. package/fan-deck-types.js +9 -0
  27. package/http-transport.js +24 -5
  28. package/index.d.ts +7 -3
  29. package/index.js +2 -2
  30. package/match-stream-types.d.ts +1 -1
  31. package/match-stream-types.js +16 -3
  32. package/match-stream.js +14 -4
  33. package/material-types.d.ts +1 -1
  34. package/operations.d.ts +1 -1
  35. package/operations.js +187 -26
  36. package/package.json +1 -1
  37. package/palette-search-types.d.ts +31 -0
  38. package/resolve-client.d.ts +22 -3
  39. package/resolve-client.js +31 -4
  40. package/resolve-types.d.ts +70 -1
  41. package/run-events.d.ts +1 -1
  42. package/run-events.js +4 -1
  43. package/run-types.d.ts +132 -0
  44. package/runs.d.ts +35 -1
  45. package/runs.js +69 -5
  46. package/schedule-types.d.ts +128 -0
  47. package/schema-resource-types.d.ts +1 -1
  48. package/search-refinement-types.d.ts +39 -4
  49. package/search-request-types.d.ts +172 -0
  50. package/search-request-types.js +1 -0
  51. package/search-result-validation.js +21 -4
  52. package/slot-pool-types.d.ts +370 -0
  53. package/slot-pool-types.js +1 -0
  54. package/source-material-validation.d.ts +5 -1
  55. package/source-material-validation.js +114 -5
  56. package/spec-facet-types.d.ts +53 -0
  57. package/spec-facet-types.js +7 -0
  58. package/specifications.d.ts +26 -0
  59. package/specifications.js +32 -0
  60. package/sse.js +3 -1
  61. package/suggest-types.js +3 -1
  62. package/types.d.ts +176 -216
  63. package/validation.d.ts +12 -0
  64. package/validation.js +23 -0
@@ -11,12 +11,16 @@
11
11
  * nothing at run time. This is the run-time half, and it must ACCEPT and REJECT
12
12
  * exactly what the schema does — pinned by `source-material-parity.test.ts`.
13
13
  *
14
- * TWO CONTRACT PROPERTIES IT ENFORCES, both easy to lose in a refactor:
14
+ * THREE CONTRACT PROPERTIES IT ENFORCES, each easy to lose in a refactor:
15
15
  * 1. `analyses` is REQUIRED and an array on every file. `[]` means "nothing
16
16
  * computed yet"; an absent field is a broken response, not an empty one.
17
17
  * Preprocessing is manual today and automatic later — only the trigger
18
18
  * changes, never this contract.
19
- * 2. `result` is a JSON record all the way down (`z.json()`), not `unknown`.
19
+ * 2. `comments` is REQUIRED and an array on every file, and every root comment
20
+ * carries a REQUIRED `replies` array. Same distinction: "nobody said
21
+ * anything about this file" is a state the client draws, and an absent
22
+ * field is a malformed response.
23
+ * 3. `result` is a JSON record all the way down (`z.json()`), not `unknown`.
20
24
  * See `requireJsonValue` for why the schema is that strict and why this
21
25
  * mirror cannot actually fire over the wire.
22
26
  *
@@ -30,7 +34,7 @@
30
34
  * permanent contract, and promoting it later is additive.
31
35
  */
32
36
  import { MaterialGraphError } from "./errors.js";
33
- import { requireArray, requireEnum, requireSafeInteger, requireJsonRecord, requireNonEmptyString, requireRecord, requireString, } from "./validation.js";
37
+ import { requireArray, requireBoolean, requireEnum, requireSafeInteger, requireJsonRecord, requireNonEmptyString, requireNullableTrimmedBoundedString, requireRecord, requireString, requireTrimmedBoundedString, } from "./validation.js";
34
38
  /** `sourceImageAnalysisKindSchema` members, verbatim. */
35
39
  const ANALYSIS_KINDS = ["scene_match"];
36
40
  /** `sourceImageAnalysisStatusSchema` members, verbatim. */
@@ -61,12 +65,67 @@ function checkAnalysis(value, what, fail) {
61
65
  analysedAt: requireNonEmptyString(record.analysedAt, `${what}.analysedAt`, fail),
62
66
  };
63
67
  }
68
+ /** `sourceMaterialCommentAuthorSchema`'s `attribution` members, verbatim. */
69
+ const COMMENT_ATTRIBUTIONS = ["reported"];
70
+ /** The canonical cap on both author fields: `.trim().min(1).max(160)`. */
71
+ const AUTHOR_FIELD_MAX = 160;
72
+ /**
73
+ * A reply, and the shared half of a root comment.
74
+ *
75
+ * `body`, `commentId` and `createdAt` are `.min(1)` canonically — fields this
76
+ * endpoint introduces, where an empty value carries nothing a client could
77
+ * render. `author.organisation` is nullable, NOT optional: `null` is the answer
78
+ * for a note with no organisation, and an absent key is a malformed body.
79
+ *
80
+ * The author fields are `.trim().min(1).max(160)` canonically and are mirrored
81
+ * as exactly that. A plain non-empty check — which is what this used to do —
82
+ * disagreed with the schema in both directions: it accepted `" "`, which the
83
+ * schema rejects once trimmed, and accepted a name of any length, which the
84
+ * schema caps. A validator that is looser than the contract is a validator that
85
+ * lets the two drift without anything failing.
86
+ */
87
+ function checkCommentBase(record, what, fail) {
88
+ const author = requireRecord(record.author, `${what}.author`, fail);
89
+ const recordedBy = requireRecord(record.recordedBy, `${what}.recordedBy`, fail);
90
+ return {
91
+ commentId: requireNonEmptyString(record.commentId, `${what}.commentId`, fail),
92
+ author: {
93
+ name: requireTrimmedBoundedString(author.name, `${what}.author.name`, AUTHOR_FIELD_MAX, fail),
94
+ organisation: requireNullableTrimmedBoundedString(author.organisation, `${what}.author.organisation`, AUTHOR_FIELD_MAX, fail),
95
+ // Closed here, matching the canonical `z.literal("reported")`, while the
96
+ // published type is `OpenEnum` so a newer server value keeps
97
+ // type-checking — the same split the analysis enums make.
98
+ attribution: requireEnum(author.attribution, COMMENT_ATTRIBUTIONS, `${what}.author.attribution`, fail),
99
+ },
100
+ recordedBy: {
101
+ principalId: requireNonEmptyString(recordedBy.principalId, `${what}.recordedBy.principalId`, fail),
102
+ },
103
+ body: requireNonEmptyString(record.body, `${what}.body`, fail),
104
+ createdAt: requireNonEmptyString(record.createdAt, `${what}.createdAt`, fail),
105
+ };
106
+ }
107
+ function checkComment(value, what, fail) {
108
+ const record = requireRecord(value, what, fail);
109
+ // Same reasoning as `analyses`: no `.default([])` canonically, so an absent
110
+ // `replies` is malformed rather than empty.
111
+ const replies = requireArray(record.replies, `${what}.replies`, fail);
112
+ return {
113
+ ...checkCommentBase(record, what, fail),
114
+ replies: replies.map((reply, index) => checkCommentBase(requireRecord(reply, `${what}.replies[${index}]`, fail), `${what}.replies[${index}]`, fail)),
115
+ };
116
+ }
64
117
  function checkFile(value, what, fail) {
65
118
  const record = requireRecord(value, what, fail);
66
119
  // NOT `requireDefaultedArray`: the schema has no `.default([])`, so an absent
67
120
  // `analyses` is a malformed body rather than an empty one. Keeping that
68
- // distinction is the whole point of the always-an-array contract.
121
+ // distinction is the whole point of the always-an-array contract. `comments`
122
+ // is required for the same reason: "nobody has said anything" and "the server
123
+ // forgot to tell you" are different answers.
69
124
  const analyses = requireArray(record.analyses, `${what}.analyses`, fail);
125
+ const comments = requireArray(record.comments, `${what}.comments`, fail);
126
+ // Required, not defaulted: "you are seeing the whole thread" is a claim the
127
+ // server makes or does not, never one a client should infer from a length.
128
+ const commentsTruncated = requireBoolean(record.commentsTruncated, `${what}.commentsTruncated`, fail);
70
129
  // `requireString`, NOT `requireNonEmptyString`, for the fields this endpoint
71
130
  // INHERITS from the shared source-material contract (`sourceId`, `sha256`,
72
131
  // `url` — plus `batchId`/`createdAt` below). Those are plain `z.string()`
@@ -91,9 +150,13 @@ function checkFile(value, what, fail) {
91
150
  // fields is a canonical change to a schema shared with the upload path, and
92
151
  // belongs in its own ticket with this mirror following after.
93
152
  bytes: requireSafeInteger(record.bytes, `${what}.bytes`, fail),
94
- pages: record.pages === null ? null : requireSafeInteger(record.pages, `${what}.pages`, fail),
153
+ pages: record.pages === null
154
+ ? null
155
+ : requireSafeInteger(record.pages, `${what}.pages`, fail),
95
156
  url: requireString(record.url, `${what}.url`, fail),
96
157
  analyses: analyses.map((analysis, index) => checkAnalysis(analysis, `${what}.analyses[${index}]`, fail)),
158
+ comments: comments.map((comment, index) => checkComment(comment, `${what}.comments[${index}]`, fail)),
159
+ commentsTruncated,
97
160
  };
98
161
  }
99
162
  /** Validate a batch-detail body, returning it typed, or throw explaining why. */
@@ -107,3 +170,49 @@ export function checkSourceMaterialBatchDetail(value, batchId) {
107
170
  sources: sources.map((file, index) => checkFile(file, `body.sources[${index}]`, fail)),
108
171
  };
109
172
  }
173
+ function commentsFailure(verb, sourceId) {
174
+ return (reason) => MaterialGraphError.unexpected(`${verb} .../sources/${sourceId}/comments returned a malformed body: ${reason}`);
175
+ }
176
+ /** Validate a comment-thread body, returning it typed, or throw explaining why. */
177
+ export function checkSourceMaterialCommentThread(value, sourceId) {
178
+ const fail = commentsFailure("GET", sourceId);
179
+ const record = requireRecord(value, "body", fail);
180
+ const comments = requireArray(record.comments, "body.comments", fail);
181
+ return {
182
+ sourceId: requireString(record.sourceId, "body.sourceId", fail),
183
+ comments: comments.map((comment, index) => checkComment(comment, `body.comments[${index}]`, fail)),
184
+ truncated: requireBoolean(record.truncated, "body.truncated", fail),
185
+ };
186
+ }
187
+ /** `storedSourceMaterialCommentSchema`'s discriminator members, verbatim. */
188
+ const STORED_COMMENT_KINDS = ["root", "reply"];
189
+ /**
190
+ * Validate a stored comment, following its `kind` discriminator.
191
+ *
192
+ * The two branches are checked as the two DIFFERENT shapes they are: a root must
193
+ * carry `replies`, a reply must carry `parentCommentId` and must not be assumed
194
+ * to carry `replies` at all. Validating both as the root shape is exactly the
195
+ * defect this discriminated union was added to fix, and a mirror that did it
196
+ * would put the lie back on the client side.
197
+ */
198
+ function checkStoredComment(value, what, fail) {
199
+ const record = requireRecord(value, what, fail);
200
+ const kind = requireEnum(record.kind, STORED_COMMENT_KINDS, `${what}.kind`, fail);
201
+ if (kind === "reply") {
202
+ return {
203
+ kind: "reply",
204
+ ...checkCommentBase(record, what, fail),
205
+ parentCommentId: requireNonEmptyString(record.parentCommentId, `${what}.parentCommentId`, fail),
206
+ };
207
+ }
208
+ return { kind: "root", ...checkComment(record, what, fail) };
209
+ }
210
+ /** Validate a posted-comment body, returning it typed, or throw explaining why. */
211
+ export function checkPostSourceMaterialCommentResult(value, sourceId) {
212
+ const fail = commentsFailure("POST", sourceId);
213
+ const record = requireRecord(value, "body", fail);
214
+ return {
215
+ sourceId: requireString(record.sourceId, "body.sourceId", fail),
216
+ comment: checkStoredComment(record.comment, "body.comment", fail),
217
+ };
218
+ }
@@ -0,0 +1,53 @@
1
+ export interface SpecFacetValue {
2
+ value: string;
3
+ variants: number;
4
+ brands: number;
5
+ label?: string;
6
+ }
7
+ /**
8
+ * A NUMERIC facet's observed span and the coverage behind it.
9
+ *
10
+ * A range, not buckets — no bucketing scheme has been decided, and one invented
11
+ * on the wire would be treated as canonical by everyone who read it. Both bounds
12
+ * are INCLUSIVE, matching `specRange`.
13
+ *
14
+ * READ `variants` BEFORE TRUSTING A FILTER. It is how many variants carry a
15
+ * value that parses as a number, i.e. how much of the corpus a range can reach;
16
+ * a facet that exists says the attribute is queryable, not that it is populated.
17
+ * `unparseableVariants` is the honesty companion: values recorded here that are
18
+ * not numbers, which a range EXCLUDES rather than reading as zero.
19
+ */
20
+ export interface SpecFacetRange {
21
+ min: number;
22
+ max: number;
23
+ variants: number;
24
+ unparseableVariants: number;
25
+ }
26
+ export interface SpecFacet {
27
+ attributeId: string;
28
+ name: string;
29
+ isArray: boolean;
30
+ /**
31
+ * How this facet is queried. `token` — pick a value from `values` and send it
32
+ * as `attribute:value`. `numeric` — `values` is EMPTY and `range` carries the
33
+ * span; send inclusive bounds via `specRangeCriterion` instead.
34
+ */
35
+ kind: "token" | "numeric" | (string & {});
36
+ brands: number;
37
+ /** EMPTY for a `numeric` facet, which has no token vocabulary. */
38
+ values: SpecFacetValue[];
39
+ /** Present ONLY on a `numeric` facet. */
40
+ range?: SpecFacetRange;
41
+ description?: string;
42
+ }
43
+ export interface SpecFacetsListResponse {
44
+ mode: "facets";
45
+ count: number;
46
+ facets: SpecFacet[];
47
+ }
48
+ export interface SpecFacetsDistributionResponse {
49
+ mode: "distribution";
50
+ attributeId: string;
51
+ count: number;
52
+ values: SpecFacetValue[];
53
+ }
@@ -0,0 +1,7 @@
1
+ // ── Specification facets + attribute values ─────────────────────────────────
2
+ //
3
+ // Hand-maintained mirror of the `SpecificationFacet` OpenAPI schema (the source
4
+ // of truth on the wire); the `sdk-openapi-parity` test gates this against the
5
+ // generated spec. Split out of `types.ts` to keep that file under its growth
6
+ // baseline; re-exported from `types.ts` so importers see no change.
7
+ export {};
@@ -57,6 +57,32 @@ export declare function certificationProgramme(programme: string): Certification
57
57
  * client.bySpec([specCriterion("primary_material", "textile"), specCriterion("fire_classifications", "astm_e84_class_a")]);
58
58
  */
59
59
  export declare function specCriterion(attributeId: string, value: string): string;
60
+ /**
61
+ * An `attribute:min..max` RANGE criterion string for `bySpec`, for a
62
+ * NUMBER-valued spec (`light_reflectance_value`, `nrc_rating`,
63
+ * `slip_resistance_dcof`, …). Pass the result in `bySpec`'s `ranges` option.
64
+ *
65
+ * BOTH BOUNDS ARE INCLUSIVE — `{ min: 60 }` matches a value of exactly 60 —
66
+ * because spec language is written that way ("LRV of 60 or more", "DCOF ≥ 0.42")
67
+ * and because inclusive-both-ends means adjacent ranges overlap at the join
68
+ * rather than leaving a gap that hides products.
69
+ *
70
+ * At least one bound is REQUIRED: a range with neither is a presence check, and
71
+ * this throws rather than sending a criterion the server would have to refuse.
72
+ * Discover which attributes take a range — and the span they actually occupy —
73
+ * from `specificationFacets()`: those are the facets with `kind: "numeric"`.
74
+ *
75
+ * @example
76
+ * // light paints, every brand at once
77
+ * client.bySpec([], { ranges: [specRangeCriterion("light_reflectance_value", { min: 60 })] });
78
+ * @example
79
+ * // a mid-tone band
80
+ * specRangeCriterion("light_reflectance_value", { min: 30, max: 45 }); // "light_reflectance_value:30..45"
81
+ */
82
+ export declare function specRangeCriterion(attributeId: string, bounds: {
83
+ min?: number;
84
+ max?: number;
85
+ }): string;
60
86
  /**
61
87
  * An `attribute:value` criterion string for `bySpec` that filters by
62
88
  * certification PROGRAMME across all four certification buckets.
package/specifications.js CHANGED
@@ -65,6 +65,38 @@ export function certificationProgramme(programme) {
65
65
  export function specCriterion(attributeId, value) {
66
66
  return `${attributeId}:${value}`;
67
67
  }
68
+ /**
69
+ * An `attribute:min..max` RANGE criterion string for `bySpec`, for a
70
+ * NUMBER-valued spec (`light_reflectance_value`, `nrc_rating`,
71
+ * `slip_resistance_dcof`, …). Pass the result in `bySpec`'s `ranges` option.
72
+ *
73
+ * BOTH BOUNDS ARE INCLUSIVE — `{ min: 60 }` matches a value of exactly 60 —
74
+ * because spec language is written that way ("LRV of 60 or more", "DCOF ≥ 0.42")
75
+ * and because inclusive-both-ends means adjacent ranges overlap at the join
76
+ * rather than leaving a gap that hides products.
77
+ *
78
+ * At least one bound is REQUIRED: a range with neither is a presence check, and
79
+ * this throws rather than sending a criterion the server would have to refuse.
80
+ * Discover which attributes take a range — and the span they actually occupy —
81
+ * from `specificationFacets()`: those are the facets with `kind: "numeric"`.
82
+ *
83
+ * @example
84
+ * // light paints, every brand at once
85
+ * client.bySpec([], { ranges: [specRangeCriterion("light_reflectance_value", { min: 60 })] });
86
+ * @example
87
+ * // a mid-tone band
88
+ * specRangeCriterion("light_reflectance_value", { min: 30, max: 45 }); // "light_reflectance_value:30..45"
89
+ */
90
+ export function specRangeCriterion(attributeId, bounds) {
91
+ const { min, max } = bounds;
92
+ if (min === undefined && max === undefined) {
93
+ throw new Error(`specRangeCriterion("${attributeId}") needs a min, a max, or both — a range with neither would match every recorded value. Use hasSpec() for a presence check.`);
94
+ }
95
+ if (min !== undefined && max !== undefined && min > max) {
96
+ throw new Error(`specRangeCriterion("${attributeId}") is inverted (min ${min} > max ${max}) and can only ever match nothing.`);
97
+ }
98
+ return `${attributeId}:${min ?? ""}..${max ?? ""}`;
99
+ }
68
100
  /**
69
101
  * An `attribute:value` criterion string for `bySpec` that filters by
70
102
  * certification PROGRAMME across all four certification buckets.
package/sse.js CHANGED
@@ -40,7 +40,9 @@ export class SseDecoder {
40
40
  flush() {
41
41
  if (this.#buffer.length > 0) {
42
42
  // At end of stream a deferred trailing CR is a real terminator, not data.
43
- const line = this.#buffer.endsWith("\r") ? this.#buffer.slice(0, -1) : this.#buffer;
43
+ const line = this.#buffer.endsWith("\r")
44
+ ? this.#buffer.slice(0, -1)
45
+ : this.#buffer;
44
46
  this.#buffer = "";
45
47
  const frame = this.#consumeLine(line);
46
48
  if (frame)
package/suggest-types.js CHANGED
@@ -21,6 +21,8 @@
21
21
  * Undefined members are dropped by the transport.
22
22
  */
23
23
  export function suggestQuery(q, options) {
24
- const kind = Array.isArray(options?.kind) ? options.kind.join(",") : options?.kind;
24
+ const kind = Array.isArray(options?.kind)
25
+ ? options.kind.join(",")
26
+ : options?.kind;
25
27
  return { q, limit: options?.limit, kind };
26
28
  }