@protoboardai/cloud 0.0.0-stage → 0.2.1

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/client.ts ADDED
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Typed client for the parts service API v1, for the stack
3
+ * (`protoboard parts search|add|update`) and the publishing scripts.
4
+ *
5
+ * Everything it downloads is verified before it is returned: an envelope
6
+ * against its digest and its definition digest, a blob against its address.
7
+ * Node 22+ (global fetch, node:crypto, node:fs); no other dependencies.
8
+ *
9
+ * import { PartsClient } from "@protoboardai/cloud/client";
10
+ * const cloud = new PartsClient({ baseUrl: "http://localhost:8787" });
11
+ * const { revisions } = await cloud.getClosure("meps-neon-2207-v2-1950kv", 1);
12
+ */
13
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import type { KitManifest, KitResult, KitSummary } from "./kits.js";
16
+ import type { ApiError, Closure, Discovery, Offer, PartSummary, PublicOffer, RelevanceSeed, RelevanceSeeds, RelevanceSeedsResult, RevisionRow, SearchQuery, SearchResult, ServiceTaxonomyDocument, TaxonomyResult, UsageReport } from "./api.js";
17
+ import {
18
+ ENVELOPE_SCHEMAS,
19
+ SUPPORTED_UHD_SCHEMA,
20
+ definitionDigest,
21
+ envelopeDigest,
22
+ isRedistributable,
23
+ isSafePath,
24
+ sha256Hex,
25
+ stableStringify,
26
+ uhdSchemaSupported,
27
+ type EnvelopeArtifact,
28
+ type EvidenceRef,
29
+ type PartRef,
30
+ type PartRevisionDraft,
31
+ type PartRevisionEnvelope,
32
+ type UhdSchemaRange,
33
+ } from "./envelope.js";
34
+
35
+ export type * from "./api.js";
36
+ export { PUBLIC_OFFER_FIELDS, SEARCH_SORTS } from "./api.js";
37
+ export * from "./envelope.js";
38
+
39
+ export interface ClientOptions {
40
+ /** Service root, e.g. http://localhost:8787 (the `/v1` prefix is added, unless discovery names another API root). */
41
+ baseUrl: string;
42
+ /**
43
+ * Publish token, for write calls (and reads, when no `readToken` is given:
44
+ * the same service's gated reads accept it). Pass it from the environment;
45
+ * never store it in project files.
46
+ */
47
+ token?: string;
48
+ /**
49
+ * Bearer token sent with every read: for a library whose reads need one
50
+ * (discovery `auth.read: "bearer"`), or a signed-in user's ID token for
51
+ * Protoboard Cloud's online services (full revisions, files, offers). From
52
+ * the environment or a credential store; never from project files.
53
+ */
54
+ readToken?: string;
55
+ /**
56
+ * Find the API root and the envelope identifiers from the library's
57
+ * uhd-library/v1 discovery document before the first call
58
+ * (`discoverLibrary`). Off: the API root is `<baseUrl>/v1`.
59
+ */
60
+ discover?: boolean;
61
+ /** Envelope identifiers accepted besides ENVELOPE_SCHEMAS (discovery adds the library's own). */
62
+ envelopeSchemas?: string[];
63
+ /** Replaces global fetch (tests, proxies). */
64
+ fetch?: typeof fetch;
65
+ /** UHD schema versions this consumer can read; envelopes outside it are refused. Default SUPPORTED_UHD_SCHEMA. */
66
+ uhdSchema?: UhdSchemaRange;
67
+ }
68
+
69
+ /** A failed call: the service's error code, or INTEGRITY / NETWORK from the client itself. */
70
+ export class CloudError extends Error {
71
+ constructor(
72
+ readonly status: number,
73
+ readonly code: string,
74
+ message: string,
75
+ readonly details: Record<string, unknown> = {},
76
+ ) {
77
+ super(message);
78
+ this.name = "CloudError";
79
+ }
80
+ }
81
+
82
+ export interface PublishResult {
83
+ /** `source-added`: the revision was published before without definition source, and now has it (protocol § 4.6.1). */
84
+ status: "published" | "unchanged" | "source-added";
85
+ envelope: PartRevisionEnvelope;
86
+ /** Blobs uploaded by this call (the service already had the others). */
87
+ uploaded: string[];
88
+ }
89
+
90
+ /** A file read back for upload: by blob address. */
91
+ export type BlobSource = (sha256: string) => Uint8Array | Promise<Uint8Array>;
92
+
93
+ /** What a downloaded envelope must be: the revision asked for and, when pinned, its digest. */
94
+ export interface ExpectedRevision extends PartRef {
95
+ /** The envelope digest a project pinned (`sha256:…`). */
96
+ digest?: string;
97
+ }
98
+
99
+ /**
100
+ * Check a downloaded envelope: its schema, a UHD schema this consumer reads,
101
+ * its digests, and what was asked for. Throws CloudError INTEGRITY, or
102
+ * UHD_SCHEMA_UNSUPPORTED for a definition written against a UHD outside `range`.
103
+ */
104
+ export function verifyEnvelope(env: PartRevisionEnvelope, expect?: ExpectedRevision, range: UhdSchemaRange = SUPPORTED_UHD_SCHEMA, schemas: readonly string[] = ENVELOPE_SCHEMAS): PartRevisionEnvelope {
105
+ const bad = (m: string) => new CloudError(0, "INTEGRITY", `${env?.partId}@${env?.revision}: ${m}`);
106
+ if (!env || typeof env !== "object" || !env.definition || typeof env.definition !== "object") throw bad("not an envelope");
107
+ if (!schemas.includes(env.schema)) throw bad(`schema is ${JSON.stringify(env.schema)}, expected ${schemas.join(" or ")}`);
108
+ if (expect && (env.partId !== expect.partId || env.revision !== expect.revision)) throw bad(`asked for ${expect.partId}@${expect.revision}`);
109
+ if (definitionDigest(env.definition) !== env.definitionDigest) throw bad("definition does not match definitionDigest");
110
+ if (envelopeDigest(env) !== env.digest) throw bad("envelope does not match its digest");
111
+ if (expect?.digest !== undefined && env.digest !== expect.digest) throw bad(`digest is ${env.digest}, pinned ${expect.digest}`);
112
+ if (typeof env.uhdSchema !== "string" || !uhdSchemaSupported(env.uhdSchema, range))
113
+ throw new CloudError(0, "UHD_SCHEMA_UNSUPPORTED", `${env.partId}@${env.revision} is written against UHD ${env.uhdSchema}; this client reads >=${range.min} <${range.below}`, {
114
+ uhdSchema: env.uhdSchema,
115
+ supported: range,
116
+ });
117
+ return env;
118
+ }
119
+
120
+ /**
121
+ * Check a closure is complete and self-consistent: the root is present, each
122
+ * revision once, every dependency of every revision present at its pinned
123
+ * revision and listed before it, nothing unreachable from the root. Conflicts
124
+ * are recomputed from the revisions rather than taken from the server.
125
+ */
126
+ export function verifyClosure(c: Closure, root: PartRef, range: UhdSchemaRange = SUPPORTED_UHD_SCHEMA, schemas: readonly string[] = ENVELOPE_SCHEMAS): Closure {
127
+ const bad = (m: string) => new CloudError(0, "INTEGRITY", `closure of ${root.partId}@${root.revision}: ${m}`);
128
+ if (!c || !c.root || !Array.isArray(c.revisions)) throw bad("not a closure");
129
+ if (c.root.partId !== root.partId || c.root.revision !== root.revision) throw bad(`root is ${c.root.partId}@${c.root.revision}`);
130
+ const key = (r: PartRef) => `${r.partId}@${r.revision}`;
131
+ const position = new Map<string, number>();
132
+ c.revisions.forEach((env, i) => {
133
+ verifyEnvelope(env, undefined, range, schemas);
134
+ if (position.has(key(env))) throw bad(`${key(env)} listed twice`);
135
+ position.set(key(env), i);
136
+ });
137
+ if (!position.has(key(root))) throw bad("the root revision is missing");
138
+ for (const [i, env] of c.revisions.entries())
139
+ for (const d of env.dependencies) {
140
+ const at = position.get(key(d));
141
+ if (at === undefined) throw bad(`${key(env)} pins ${key(d)}, which is missing`);
142
+ if (at >= i) throw bad(`${key(d)} is listed after ${key(env)}, which pins it`);
143
+ }
144
+ const byKey = new Map(c.revisions.map((env) => [key(env), env]));
145
+ const reached = new Set<string>();
146
+ const walk = (k: string) => {
147
+ if (reached.has(k)) return;
148
+ reached.add(k);
149
+ for (const d of byKey.get(k)!.dependencies) walk(key(d));
150
+ };
151
+ walk(key(root));
152
+ const extra = [...byKey.keys()].filter((k) => !reached.has(k));
153
+ if (extra.length) throw bad(`${extra.join(", ")} not reachable from the root`);
154
+ const revisionsOf = new Map<string, number[]>();
155
+ for (const env of c.revisions) revisionsOf.set(env.partId, [...(revisionsOf.get(env.partId) ?? []), env.revision]);
156
+ const conflicts = [...revisionsOf.entries()].filter(([, r]) => r.length > 1).map(([partId, r]) => ({ partId, revisions: r.sort((a, b) => a - b) }));
157
+ return { root: c.root, revisions: c.revisions, conflicts };
158
+ }
159
+
160
+ /** `?k=v&…` for the defined, non-empty members of a query. */
161
+ function queryString(query: object): string {
162
+ const params = new URLSearchParams();
163
+ for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== "") params.set(k, String(v));
164
+ const qs = params.toString();
165
+ return qs ? `?${qs}` : "";
166
+ }
167
+
168
+ /** What `discoverLibrary` found: the API root and, when the library publishes one, its discovery document. */
169
+ export interface LibraryLocation {
170
+ /** API root, without a trailing slash. */
171
+ api: string;
172
+ document?: Discovery;
173
+ }
174
+
175
+ /**
176
+ * Find a uhd-library/v1 library's API root (UHD docs/library-protocol.md § 2):
177
+ * `<baseUrl>/.well-known/uhd-library`, then `<baseUrl>/v1`. A library without
178
+ * a discovery document (a service older than the protocol) is assumed at
179
+ * `<baseUrl>/v1`. Only an unreachable host is an error (NETWORK). The
180
+ * discovery document never needs credentials, so none are sent.
181
+ */
182
+ export async function discoverLibrary(baseUrl: string, options: { fetch?: typeof fetch } = {}): Promise<LibraryLocation> {
183
+ const base = baseUrl.replace(/\/+$/, "");
184
+ const doFetch = options.fetch ?? fetch;
185
+ for (const url of [`${base}/.well-known/uhd-library`, `${base}/v1`]) {
186
+ let res: Response;
187
+ try {
188
+ res = await doFetch(url, { headers: { accept: "application/json" } });
189
+ } catch (e) {
190
+ throw new CloudError(0, "NETWORK", `GET ${url}: ${(e as Error).message}`);
191
+ }
192
+ if (!res.ok) continue;
193
+ const doc = (await res.json().catch(() => undefined)) as Discovery | undefined;
194
+ if (doc?.protocol !== "uhd-library/v1") continue;
195
+ return { api: new URL(doc.api ?? url, url).href.replace(/\/+$/, ""), document: doc };
196
+ }
197
+ return { api: `${base}/v1` };
198
+ }
199
+
200
+ /** How often a call is tried again after a 429, and the longest Retry-After it waits for (seconds). */
201
+ const RETRIES_429 = 2;
202
+ const MAX_RETRY_AFTER_S = 10;
203
+
204
+ export class PartsClient {
205
+ readonly baseUrl: string;
206
+ private readonly token?: string;
207
+ private readonly readToken?: string;
208
+ private readonly doFetch: typeof fetch;
209
+ private readonly uhdSchema: UhdSchemaRange;
210
+ private schemas: string[];
211
+ private api?: string;
212
+ private located?: Promise<LibraryLocation>;
213
+ /** The library's discovery document, once a call has found it (`discover: true`). */
214
+ library?: Discovery;
215
+
216
+ constructor(options: ClientOptions) {
217
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
218
+ this.token = options.token;
219
+ this.readToken = options.readToken;
220
+ this.doFetch = options.fetch ?? fetch;
221
+ this.uhdSchema = options.uhdSchema ?? SUPPORTED_UHD_SCHEMA;
222
+ this.schemas = [...new Set([...ENVELOPE_SCHEMAS, ...(options.envelopeSchemas ?? [])])];
223
+ if (!options.discover) this.api = `${this.baseUrl}/v1`;
224
+ }
225
+
226
+ /** The API root: `<baseUrl>/v1`, or what discovery found. */
227
+ async apiRoot(): Promise<string> {
228
+ if (this.api) return this.api;
229
+ this.located ??= discoverLibrary(this.baseUrl, { fetch: this.doFetch });
230
+ try {
231
+ const where = await this.located;
232
+ this.library = where.document;
233
+ this.schemas = [...new Set([...this.schemas, ...(where.document?.envelopeSchemas ?? [])])];
234
+ this.api = where.api;
235
+ return where.api;
236
+ } catch (e) {
237
+ this.located = undefined; // try again on the next call
238
+ throw e;
239
+ }
240
+ }
241
+
242
+ private async call(method: string, path: string, init: { body?: unknown; raw?: Uint8Array; auth?: boolean } = {}): Promise<Response> {
243
+ const headers: Record<string, string> = {};
244
+ if (init.auth) {
245
+ if (!this.token) throw new CloudError(401, "AUTH_REQUIRED", "this call needs a publish token (ClientOptions.token)");
246
+ headers.authorization = `Bearer ${this.token}`;
247
+ } else if (this.readToken ?? this.token) headers.authorization = `Bearer ${this.readToken ?? this.token}`;
248
+ const api = await this.apiRoot();
249
+ let body: RequestInit["body"];
250
+ if (init.raw) {
251
+ headers["content-type"] = "application/octet-stream";
252
+ body = init.raw as unknown as RequestInit["body"];
253
+ } else if (init.body !== undefined) {
254
+ headers["content-type"] = "application/json";
255
+ body = JSON.stringify(init.body);
256
+ }
257
+ let res: Response;
258
+ for (let attempt = 0; ; attempt++) {
259
+ try {
260
+ res = await this.doFetch(`${api}${path}`, { method, headers, body });
261
+ } catch (e) {
262
+ throw new CloudError(0, "NETWORK", `${method} ${api}${path}: ${(e as Error).message}`);
263
+ }
264
+ // over a per-minute cap (429 with a short Retry-After): wait it out, twice at most; a longer wait is the caller's to report
265
+ const wait = res.status === 429 ? Number(res.headers.get("retry-after")) : NaN;
266
+ if (!(attempt < RETRIES_429 && Number.isFinite(wait) && wait >= 0 && wait <= MAX_RETRY_AFTER_S)) break;
267
+ await res.body?.cancel().catch(() => {});
268
+ await new Promise((r) => setTimeout(r, wait * 1000));
269
+ }
270
+ if (!res.ok) {
271
+ const err = ((await res.json().catch(() => undefined)) as { error?: ApiError } | undefined)?.error;
272
+ const { code, message, ...details } = err ?? { code: `HTTP_${res.status}`, message: res.statusText };
273
+ throw new CloudError(res.status, code, message, details);
274
+ }
275
+ return res;
276
+ }
277
+
278
+ private async json<T>(method: string, path: string, init?: { body?: unknown; auth?: boolean }): Promise<T> {
279
+ return (await (await this.call(method, path, init)).json()) as T;
280
+ }
281
+
282
+ /** The uhd-library/v1 discovery document at the API root (with this service's counts, from Protoboard Cloud). */
283
+ discover(): Promise<Discovery> {
284
+ return this.json("GET", "");
285
+ }
286
+
287
+ /**
288
+ * Parts whose recommended revision matches; every word of `q` must match;
289
+ * `taxonomy` (or `category`) takes the subtree. Pass the result's
290
+ * `nextCursor` as `cursor` for the next page; `facets` adds counts over
291
+ * every match. `sort` orders the results on a library that declares the
292
+ * `x-sort` capability (`sorts()`); another library ignores it.
293
+ */
294
+ search(query: SearchQuery = {}): Promise<SearchResult> {
295
+ return this.json("GET", `/parts${queryString(query)}`);
296
+ }
297
+
298
+ /**
299
+ * The orders this library's search takes in `sort` (capability `x-sort`),
300
+ * from its discovery document; empty for a library that does not sort.
301
+ */
302
+ async sorts(): Promise<{ values: string[]; default?: string }> {
303
+ await this.apiRoot();
304
+ const doc = (this.library ??= await this.discover().catch(() => undefined));
305
+ return doc?.capabilities?.includes("x-sort") && Array.isArray(doc.sort?.values) ? { values: [...doc.sort.values], default: doc.sort.default } : { values: [] };
306
+ }
307
+
308
+ /** The taxonomy the service uses, with part counts over the query's filters (the taxonomy filter ignored). */
309
+ getTaxonomy(query: Omit<SearchQuery, "taxonomy" | "category" | "limit" | "offset" | "cursor" | "facets"> = {}): Promise<TaxonomyResult> {
310
+ return this.json("GET", `/taxonomy${queryString(query)}`);
311
+ }
312
+
313
+ /** Give the service its taxonomy (needs the publish token). Unchanged when it already has this one. */
314
+ setTaxonomy(doc: ServiceTaxonomyDocument): Promise<{ status: "set" | "unchanged"; digest: string; id: string; version: string }> {
315
+ return this.json("PUT", "/taxonomy", { body: doc, auth: true });
316
+ }
317
+
318
+ /** The kits the service holds (capability `x-kits`). */
319
+ async listKits(): Promise<KitSummary[]> {
320
+ return (await this.json<{ kits: KitSummary[] }>("GET", "/kits")).kits;
321
+ }
322
+
323
+ /** One kit's manifest, with the latest revision of each part it names (null: not in the catalog). */
324
+ getKit(kit: string): Promise<KitResult> {
325
+ return this.json("GET", `/kits/${encodeURIComponent(kit)}`);
326
+ }
327
+
328
+ /** Give the service a kit's manifest (needs the publish token). Unchanged when it already has this one. */
329
+ setKit(manifest: KitManifest): Promise<{ status: "set" | "unchanged"; digest: string; kit: string }> {
330
+ return this.json("PUT", `/kits/${encodeURIComponent(manifest.kit)}`, { body: manifest, auth: true });
331
+ }
332
+
333
+ getPart(partId: string): Promise<{ part: PartSummary; revisions: RevisionRow[] }> {
334
+ return this.json("GET", `/parts/${encodeURIComponent(partId)}`);
335
+ }
336
+
337
+ async listRevisions(partId: string): Promise<RevisionRow[]> {
338
+ return (await this.json<{ revisions: RevisionRow[] }>("GET", `/parts/${encodeURIComponent(partId)}/revisions`)).revisions;
339
+ }
340
+
341
+ /** One exact revision, verified; with `digest`, also against a pinned envelope digest. */
342
+ async getRevision(partId: string, revision: number, options: { digest?: string } = {}): Promise<PartRevisionEnvelope> {
343
+ const env = await this.json<PartRevisionEnvelope>("GET", `/parts/${encodeURIComponent(partId)}/revisions/${revision}`);
344
+ return verifyEnvelope(env, { partId, revision, ...(options.digest !== undefined ? { digest: options.digest } : {}) }, this.uhdSchema, this.schemas);
345
+ }
346
+
347
+ /**
348
+ * A revision and every transitive dependency at its pinned revision,
349
+ * dependencies first, each envelope verified and the closure checked
350
+ * complete (`verifyClosure`). A part reached at two revisions is a
351
+ * conflict (a project holds one definition per id): it throws
352
+ * CLOSURE_CONFLICT unless `allowConflicts`.
353
+ */
354
+ async getClosure(partId: string, revision: number, options: { allowConflicts?: boolean } = {}): Promise<Closure> {
355
+ const raw = await this.json<Closure>("GET", `/parts/${encodeURIComponent(partId)}/revisions/${revision}/closure`);
356
+ const c = verifyClosure(raw, { partId, revision }, this.uhdSchema, this.schemas);
357
+ if (c.conflicts.length && !options.allowConflicts) {
358
+ throw new CloudError(409, "CLOSURE_CONFLICT", `dependencies pin different revisions of ${c.conflicts.map((x) => `${x.partId} (${x.revisions.join(", ")})`).join("; ")}`, { conflicts: c.conflicts });
359
+ }
360
+ return c;
361
+ }
362
+
363
+ /** Blob bytes, verified against the address (and `size` when given). */
364
+ async downloadBlob(sha256: string, expect: { size?: number } = {}): Promise<Uint8Array> {
365
+ const bytes = new Uint8Array(await (await this.call("GET", `/blobs/${sha256}`)).arrayBuffer());
366
+ const got = sha256Hex(bytes);
367
+ if (got !== sha256) throw new CloudError(0, "INTEGRITY", `blob ${sha256} downloaded as ${got}`);
368
+ if (expect.size !== undefined && bytes.length !== expect.size) throw new CloudError(0, "INTEGRITY", `blob ${sha256} is ${bytes.length} bytes, expected ${expect.size}`);
369
+ return bytes;
370
+ }
371
+
372
+ /**
373
+ * Write a revision's files under `dir`, each at its envelope path, every one
374
+ * verified. Artifacts always; evidence files with `evidence: true`. With
375
+ * `redistributableOnly`, only files whose terms say `redistributable`
376
+ * (what the redistributable view serves, and all a copy for others may
377
+ * hold).
378
+ */
379
+ async downloadFiles(env: PartRevisionEnvelope, dir: string, options: { evidence?: boolean; redistributableOnly?: boolean } = {}): Promise<{ path: string; file: string; size: number }[]> {
380
+ const files: (EnvelopeArtifact | EvidenceRef)[] = [...env.artifacts, ...(options.evidence ? env.evidence.filter((e) => e.path) : [])].filter((f) => !options.redistributableOnly || isRedistributable(f));
381
+ const out: { path: string; file: string; size: number }[] = [];
382
+ for (const f of files) {
383
+ if (!isSafePath(f.path!)) throw new CloudError(0, "INTEGRITY", `unsafe path ${f.path}`);
384
+ const bytes = await this.downloadBlob(f.sha256!, { size: f.size });
385
+ const file = join(dir, ...f.path!.split("/"));
386
+ mkdirSync(dirname(file), { recursive: true });
387
+ writeFileSync(`${file}.part`, bytes);
388
+ renameSync(`${file}.part`, file);
389
+ out.push({ path: f.path!, file, size: bytes.length });
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * A revision's definition source (protocol § 4.6), each file verified
396
+ * against its address and size: path → text. Empty when the revision
397
+ * ships none. Whether the text is allowed (the lint) and evaluates to the
398
+ * definition digest is the caller's to check.
399
+ */
400
+ async downloadSource(env: PartRevisionEnvelope): Promise<{ path: string; text: string; sha256: string }[]> {
401
+ const out: { path: string; text: string; sha256: string }[] = [];
402
+ for (const f of env.source?.files ?? []) {
403
+ if (!isSafePath(f.path)) throw new CloudError(0, "INTEGRITY", `unsafe source path ${f.path}`);
404
+ const bytes = await this.downloadBlob(f.sha256, { size: f.size });
405
+ out.push({ path: f.path, text: new TextDecoder("utf-8", { fatal: true }).decode(bytes), sha256: f.sha256 });
406
+ }
407
+ return out;
408
+ }
409
+
410
+ /**
411
+ * Publish a revision: upload the blobs the service lacks (read through
412
+ * `files`), then the envelope. Republishing identical content returns
413
+ * "unchanged"; different content under an existing revision throws
414
+ * REVISION_IMMUTABLE (409).
415
+ */
416
+ async publish(draft: PartRevisionDraft, files: BlobSource): Promise<PublishResult> {
417
+ const wanted = [...new Set([...draft.artifacts.map((a) => a.sha256), ...draft.evidence.flatMap((e) => (e.sha256 ? [e.sha256] : [])), ...(draft.source?.files ?? []).map((f) => f.sha256)])];
418
+ const { missing } = wanted.length ? await this.json<{ missing: string[] }>("POST", "/blobs/missing", { body: { sha256: wanted } }) : { missing: [] };
419
+ for (const sha of missing) {
420
+ const bytes = await files(sha);
421
+ if (sha256Hex(bytes) !== sha) throw new CloudError(0, "INTEGRITY", `local file for blob ${sha} has changed since the draft was made`);
422
+ await this.call("PUT", `/blobs/${sha}`, { raw: bytes, auth: true });
423
+ }
424
+ const r = await this.json<{ status: PublishResult["status"]; envelope: PartRevisionEnvelope }>("PUT", `/parts/${encodeURIComponent(draft.partId)}/revisions/${draft.revision}`, {
425
+ body: draft,
426
+ auth: true,
427
+ });
428
+ return { ...r, uploaded: missing };
429
+ }
430
+
431
+ /** Upload one blob (needs the publish token); unchanged when the service has it. */
432
+ async uploadBlob(bytes: Uint8Array): Promise<{ sha256: string; size: number }> {
433
+ const sha = sha256Hex(bytes);
434
+ return (await this.call("PUT", `/blobs/${sha}`, { raw: bytes, auth: true })).json() as Promise<{ sha256: string; size: number }>;
435
+ }
436
+
437
+ /** Mark a revision (or, without `revision`, the whole part) deprecated. It stays downloadable. */
438
+ deprecate(partId: string, d: { revision?: number; reason: string; replacedBy?: PartRef }): Promise<{ part: PartSummary; revisions: RevisionRow[] }> {
439
+ return this.json("POST", `/parts/${encodeURIComponent(partId)}/deprecate`, { body: d, auth: true });
440
+ }
441
+
442
+ /**
443
+ * A part's supplier offers, newest first: prices by source, as users are
444
+ * served them (who sells it, the supplier's code, price breaks, currency,
445
+ * stock, pack and minimum order, the link, when it was observed). With
446
+ * `mpn`, only those for that ordering code (one value of a series part).
447
+ */
448
+ async listOffers(partId: string, filter: { mpn?: string } = {}): Promise<PublicOffer[]> {
449
+ return (await this.json<{ offers: PublicOffer[] }>("GET", `/parts/${encodeURIComponent(partId)}/offers${queryString({ mpn: filter.mpn })}`)).offers;
450
+ }
451
+
452
+ /** The whole offer records, with where and how each was observed: publishers only. */
453
+ async listOffersInternal(partId: string, filter: { mpn?: string } = {}): Promise<Offer[]> {
454
+ return (await this.json<{ offers: Offer[] }>("GET", `/parts/${encodeURIComponent(partId)}/offers${queryString({ mpn: filter.mpn, view: "internal" })}`, { auth: true })).offers;
455
+ }
456
+
457
+ /** The relevance seeds the library holds (publishers only). */
458
+ async listSeeds(): Promise<RelevanceSeed[]> {
459
+ return (await this.json<{ seeds: RelevanceSeed[] }>("GET", "/relevance", { auth: true })).seeds;
460
+ }
461
+
462
+ /** Set every relevance seed (publishers only); parts left out are cleared. The same seeds again change nothing. */
463
+ setSeeds(seeds: RelevanceSeeds): Promise<RelevanceSeedsResult> {
464
+ return this.json("PUT", "/relevance", { body: { seeds }, auth: true });
465
+ }
466
+
467
+ /** The most added parts over a period (publishers only): the last `days` (30), or `from`..`to` (UTC days). */
468
+ usage(query: { days?: number; from?: string; to?: string; limit?: number } = {}): Promise<UsageReport> {
469
+ return this.json("GET", `/usage${queryString(query)}`, { auth: true });
470
+ }
471
+
472
+ async addOffer(partId: string, offer: Omit<Offer, "id" | "partId">): Promise<Offer> {
473
+ return (await this.json<{ offer: Offer }>("POST", `/parts/${encodeURIComponent(partId)}/offers`, { body: offer, auth: true })).offer;
474
+ }
475
+ }
476
+
477
+ /** The vendored definition file's bytes (`parts/catalog/<id>.json`); its sha256 is `envelope.definitionDigest`. */
478
+ export const vendoredDefinition = (env: PartRevisionEnvelope): string => stableStringify(env.definition);
479
+