@protoboardai/cloud 0.0.0-stage → 0.2.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/src/kits.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Kit manifests (PB-867): what a purchasable kit contains, item by item,
3
+ * with the catalog part each item is and how many come in the box. The
4
+ * library keeps them in `library/kits/<kit>.json` (library/taxonomy.ts reads
5
+ * and checks them); the seed gives them to the service (`PUT /v1/kits/:kit`)
6
+ * and the service serves them (`GET /v1/kits`, `GET /v1/kits/:kit`, the
7
+ * discovery capability `x-kits`).
8
+ *
9
+ * A kit is a node of the taxonomy (`kit.<name>`, UHD keeps the node and
10
+ * leaves the contents to the library that publishes them). Its parts are
11
+ * filed under that node, so `GET /v1/parts?taxonomy=kit.<name>` finds them;
12
+ * the manifest adds what search cannot: quantities, the kit's own labels,
13
+ * series placements and the items that are not catalog parts.
14
+ *
15
+ * Pure, and free of `@deltarobotics/uhd` like the rest of the server.
16
+ */
17
+
18
+ export interface KitItem {
19
+ /** The catalog part; absent for an item the catalog does not model. */
20
+ part_id?: string;
21
+ /** The ProtoPart part the item came from (the kit was carried over from ProtoPart). */
22
+ protopart_id?: string;
23
+ /** The kit's own name for the item (its packing list). */
24
+ kit_label: string;
25
+ /** How many come in the kit. */
26
+ quantity: number;
27
+ /** Kit contents that are not circuit parts (cables, media, boxes). */
28
+ unmodeled?: boolean;
29
+ /** For a series part: the placement (ChildModuleRef overrides). */
30
+ overrides?: Record<string, unknown>;
31
+ /** For a series part: the ordering code the placement resolves to. */
32
+ mpn?: string;
33
+ note?: string;
34
+ tutorials?: { title: string; url: string }[];
35
+ }
36
+
37
+ export interface KitManifest {
38
+ /** The kit's taxonomy path, `kit.<name>`. */
39
+ kit: string;
40
+ name: string;
41
+ sku?: string;
42
+ vendor?: string;
43
+ docs_url?: string;
44
+ source_url?: string;
45
+ controller_part_id?: string | null;
46
+ notes?: string;
47
+ provenance?: string;
48
+ items: KitItem[];
49
+ }
50
+
51
+ /** One row of `GET /v1/kits`. */
52
+ export interface KitSummary {
53
+ kit: string;
54
+ name: string;
55
+ sku?: string;
56
+ vendor?: string;
57
+ /** Items in the manifest, and the distinct catalog parts they name. */
58
+ items: number;
59
+ parts: number;
60
+ setAt: string;
61
+ digest: string;
62
+ }
63
+
64
+ /** `GET /v1/kits/:kit`: the manifest as given, and the latest revision of each part it names (null: not in the catalog). */
65
+ export interface KitResult {
66
+ manifest: KitManifest;
67
+ setAt: string;
68
+ digest: string;
69
+ revisions: Record<string, number | null>;
70
+ }
71
+
72
+ export const KIT_PATH = /^kit\.[a-z][a-z0-9_]*$/;
73
+
74
+ /** What is wrong with a kit manifest body, as `{ at, message }` problems; empty when it is one. */
75
+ export function kitManifestProblems(body: unknown): { at?: string; message: string }[] {
76
+ const out: { at?: string; message: string }[] = [];
77
+ if (!body || typeof body !== "object" || Array.isArray(body)) return [{ message: "the body must be a kit manifest object" }];
78
+ const k = body as Record<string, unknown>;
79
+ if (typeof k.kit !== "string" || !KIT_PATH.test(k.kit)) out.push({ at: "kit", message: "kit must be a taxonomy path kit.<name>" });
80
+ if (typeof k.name !== "string" || !k.name.trim()) out.push({ at: "name", message: "name must be a non-empty string" });
81
+ for (const f of ["sku", "vendor", "docs_url", "source_url", "notes", "provenance"]) if (k[f] !== undefined && typeof k[f] !== "string") out.push({ at: f, message: `${f} must be a string` });
82
+ if (k.controller_part_id !== undefined && k.controller_part_id !== null && typeof k.controller_part_id !== "string") out.push({ at: "controller_part_id", message: "controller_part_id must be a part id or null" });
83
+ if (!Array.isArray(k.items) || !k.items.length) out.push({ at: "items", message: "items must be a non-empty array" });
84
+ else
85
+ k.items.forEach((raw, i) => {
86
+ const it = raw as Record<string, unknown> | null;
87
+ const at = `items[${i}]`;
88
+ if (!it || typeof it !== "object" || Array.isArray(it)) return out.push({ at, message: "an item is an object" });
89
+ if (typeof it.kit_label !== "string" || !it.kit_label.trim()) out.push({ at: `${at}.kit_label`, message: "kit_label must be a non-empty string" });
90
+ if (!Number.isInteger(it.quantity) || (it.quantity as number) < 1) out.push({ at: `${at}.quantity`, message: "quantity must be an integer >= 1" });
91
+ if (it.part_id !== undefined && (typeof it.part_id !== "string" || !it.part_id)) out.push({ at: `${at}.part_id`, message: "part_id must be a part id" });
92
+ if (it.part_id === undefined && it.unmodeled !== true && it.protopart_id === undefined) out.push({ at, message: "an item names a part_id, or is marked unmodeled" });
93
+ if (it.overrides !== undefined && (typeof it.overrides !== "object" || it.overrides === null || Array.isArray(it.overrides))) out.push({ at: `${at}.overrides`, message: "overrides must be an object" });
94
+ if (it.overrides !== undefined && it.part_id === undefined) out.push({ at: `${at}.overrides`, message: "overrides place a part: the item needs its part_id" });
95
+ return undefined;
96
+ });
97
+ return out;
98
+ }
99
+
100
+ /** The catalog parts a manifest names, once each, sorted. */
101
+ export const kitPartIds = (m: KitManifest): string[] => [...new Set(m.items.flatMap((i) => (i.part_id ? [i.part_id] : [])))].sort();
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The category taxonomy as the parts service holds it (PB-849). Pure, and
3
+ * free of `@deltarobotics/uhd` like the rest of the server: the documents
4
+ * are UHD's taxonomy format (a base taxonomy file and library extensions,
5
+ * UHD docs/taxonomy.md), which the seed reads from UHD and the library and
6
+ * sends with `PUT /v1/taxonomy`. test/taxonomy.test.ts checks these helpers
7
+ * agree with UHD's.
8
+ *
9
+ * Parts are indexed by the ancestors of their categories, so a filter on a
10
+ * path is an exact match and returns the whole subtree.
11
+ */
12
+
13
+ export interface TaxonomyNodeData {
14
+ name: string;
15
+ description?: string;
16
+ docs_url?: string;
17
+ links?: { label: string; url: string }[];
18
+ agent_notes?: string;
19
+ kit?: { sku: string; vendor: string; controller_part_id?: string; manifest?: string };
20
+ children?: Record<string, TaxonomyNodeData>;
21
+ }
22
+
23
+ /** What the service is given and stores: UHD's taxonomy document and the library's extensions. */
24
+ export interface ServiceTaxonomyDocument {
25
+ base: { id: string; version: string; delimiter: "."; description?: string; provenance?: Record<string, unknown>; aliases?: Record<string, string[]>; categories: Record<string, TaxonomyNodeData>; $schema?: string };
26
+ extensions: { library: string; taxonomy: { id: string; version: string }; description?: string; categories?: Record<string, TaxonomyNodeData>; $schema?: string }[];
27
+ }
28
+
29
+ /** One node of `GET /v1/taxonomy`: parts at or under it in `count`. */
30
+ export interface TaxonomyTreeNode {
31
+ id: string;
32
+ path: string;
33
+ name: string;
34
+ description?: string;
35
+ docsUrl?: string;
36
+ links?: { label: string; url: string }[];
37
+ agentNotes?: string;
38
+ kit?: { sku: string; vendor: string; controllerPartId?: string; manifest?: string };
39
+ /** The taxonomy id ("uhd") or the library of the extension that defines the node. */
40
+ source: string;
41
+ count: number;
42
+ children: TaxonomyTreeNode[];
43
+ }
44
+
45
+ const DELIM = ".";
46
+ const NODE_ID = /^[a-z][a-z0-9_]*$/;
47
+ const LIBRARY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
48
+ const SEMVER = /^\d+\.\d+\.\d+$/;
49
+
50
+ /** Each path and every ancestor of it, once, sorted (UHD `categoryAncestors`). */
51
+ export function categoryAncestors(paths: readonly unknown[] | undefined): string[] {
52
+ const out = new Set<string>();
53
+ for (const raw of paths ?? []) {
54
+ if (typeof raw !== "string") continue;
55
+ const ids = raw.split(DELIM).filter((s) => s.length > 0);
56
+ for (let i = 1; i <= ids.length; i++) out.add(ids.slice(0, i).join(DELIM));
57
+ }
58
+ return [...out].sort();
59
+ }
60
+
61
+ const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
62
+
63
+ /** Shape problems with a taxonomy the service is asked to store (UHD validates the content; this guards the service). */
64
+ export function taxonomyDocumentProblems(doc: unknown): { message: string; at?: string }[] {
65
+ const out: { message: string; at?: string }[] = [];
66
+ const nodes = (map: unknown, at: string, root: boolean, library?: string) => {
67
+ if (!isObject(map)) return void out.push({ message: "must be an object of nodes by id", at });
68
+ for (const [id, node] of Object.entries(map)) {
69
+ const okId = root && library !== undefined ? id === `x-${library}` : NODE_ID.test(id);
70
+ if (!okId) out.push({ message: library !== undefined && root ? `extension root must be x-${library}` : `node id "${id}" is not a lower-case id`, at });
71
+ if (!isObject(node) || typeof node.name !== "string" || node.name === "") out.push({ message: "a node needs a name", at: `${at}.${id}` });
72
+ else if (node.children !== undefined) nodes(node.children, `${at}.${id}.children`, false);
73
+ }
74
+ };
75
+ if (!isObject(doc)) return [{ message: "body must be { base, extensions }" }];
76
+ const base = doc.base;
77
+ if (!isObject(base) || typeof base.id !== "string" || typeof base.version !== "string" || !SEMVER.test(base.version) || base.delimiter !== DELIM)
78
+ out.push({ message: "base must be a taxonomy document with id, version (MAJOR.MINOR.PATCH) and delimiter \".\"", at: "base" });
79
+ else nodes(base.categories, "base.categories", true);
80
+ if (!Array.isArray(doc.extensions)) out.push({ message: "extensions must be an array", at: "extensions" });
81
+ else
82
+ doc.extensions.forEach((e, i) => {
83
+ if (!isObject(e) || typeof e.library !== "string" || !LIBRARY.test(e.library) || !isObject(e.taxonomy)) return void out.push({ message: "an extension needs library and taxonomy { id, version }", at: `extensions[${i}]` });
84
+ if (e.categories !== undefined) nodes(e.categories, `extensions[${i}].categories`, true, e.library);
85
+ });
86
+ return out;
87
+ }
88
+
89
+ /** Every node path the document defines. */
90
+ export function taxonomyPaths(doc: ServiceTaxonomyDocument): Set<string> {
91
+ const out = new Set<string>();
92
+ const walk = (map: Record<string, TaxonomyNodeData> | undefined, prefix: string) => {
93
+ for (const [id, n] of Object.entries(map ?? {})) {
94
+ const path = prefix ? `${prefix}${DELIM}${id}` : id;
95
+ out.add(path);
96
+ walk(n.children, path);
97
+ }
98
+ };
99
+ walk(doc.base.categories, "");
100
+ for (const e of doc.extensions) walk(e.categories, "");
101
+ return out;
102
+ }
103
+
104
+ /** The tree with each node's count (UHD `taxonomyTree`, with every count present). */
105
+ export function taxonomyTreeWithCounts(doc: ServiceTaxonomyDocument, counts: ReadonlyMap<string, number>): TaxonomyTreeNode[] {
106
+ const convert = (id: string, n: TaxonomyNodeData, prefix: string, source: string): TaxonomyTreeNode => {
107
+ const path = prefix ? `${prefix}${DELIM}${id}` : id;
108
+ return {
109
+ id,
110
+ path,
111
+ name: n.name,
112
+ ...(n.description !== undefined ? { description: n.description } : {}),
113
+ ...(n.docs_url !== undefined ? { docsUrl: n.docs_url } : {}),
114
+ ...(n.links?.length ? { links: n.links.map((l) => ({ label: l.label, url: l.url })) } : {}),
115
+ ...(n.agent_notes !== undefined ? { agentNotes: n.agent_notes } : {}),
116
+ ...(n.kit
117
+ ? {
118
+ kit: {
119
+ sku: n.kit.sku,
120
+ vendor: n.kit.vendor,
121
+ ...(n.kit.controller_part_id !== undefined ? { controllerPartId: n.kit.controller_part_id } : {}),
122
+ ...(n.kit.manifest !== undefined ? { manifest: n.kit.manifest } : {}),
123
+ },
124
+ }
125
+ : {}),
126
+ source,
127
+ count: counts.get(path) ?? 0,
128
+ children: Object.entries(n.children ?? {}).map(([cid, c]) => convert(cid, c, path, source)),
129
+ };
130
+ };
131
+ return [
132
+ ...Object.entries(doc.base.categories).map(([id, n]) => convert(id, n, "", doc.base.id)),
133
+ ...doc.extensions.flatMap((e) => Object.entries(e.categories ?? {}).map(([id, n]) => convert(id, n, "", e.library))),
134
+ ];
135
+ }