@chartcoach/catalog 0.1.7 → 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.
Files changed (72) hide show
  1. package/README.md +324 -38
  2. package/dist/duckdb-wasm.d.ts +8 -0
  3. package/dist/duckdb-wasm.js +22 -0
  4. package/dist/duckdb.d.ts +8 -0
  5. package/dist/duckdb.js +9 -0
  6. package/dist/index-cache-Cx1gIK6N.js +280 -0
  7. package/dist/index.d.ts +40 -8
  8. package/dist/index.js +22 -7
  9. package/dist/json-CBWjz9b4.js +29 -0
  10. package/dist/model-De0MF-zg.d.ts +255 -0
  11. package/dist/node.d.ts +15 -0
  12. package/dist/node.js +218 -0
  13. package/dist/open-Nnzkr_bE.d.ts +15 -0
  14. package/dist/open-Uu3-r8kp.js +1244 -0
  15. package/dist/registration-CgQGMUIH.d.ts +6 -0
  16. package/dist/registration-DXhS7vgr.js +41 -0
  17. package/dist/tables-DcgKnXwF.js +389 -0
  18. package/package.json +47 -18
  19. package/src/catalog/artifacts.ts +309 -100
  20. package/src/catalog/description.ts +198 -0
  21. package/src/catalog/errors.ts +26 -1
  22. package/src/catalog/identity.ts +69 -0
  23. package/src/catalog/json.ts +32 -0
  24. package/src/catalog/labels.ts +8 -6
  25. package/src/catalog/manifest.ts +81 -20
  26. package/src/catalog/markdown.ts +2 -2
  27. package/src/catalog/model.ts +222 -37
  28. package/src/catalog/open.ts +428 -0
  29. package/src/catalog/parquet.ts +164 -0
  30. package/src/catalog/profile-layout.ts +86 -0
  31. package/src/catalog/profile.ts +345 -0
  32. package/src/catalog/query.ts +125 -0
  33. package/src/catalog/read.ts +101 -0
  34. package/src/catalog/references.ts +336 -0
  35. package/src/catalog/registration.ts +74 -0
  36. package/src/catalog/tables.ts +250 -0
  37. package/src/catalog/wire.ts +38 -65
  38. package/src/duckdb-wasm.ts +33 -0
  39. package/src/duckdb.ts +18 -0
  40. package/src/index.ts +51 -20
  41. package/src/node/index-cache.ts +432 -0
  42. package/src/node.ts +347 -0
  43. package/dist/catalog/artifacts.d.ts +0 -28
  44. package/dist/catalog/artifacts.d.ts.map +0 -1
  45. package/dist/catalog/artifacts.js +0 -89
  46. package/dist/catalog/chartcoach-defaults.d.ts +0 -17
  47. package/dist/catalog/chartcoach-defaults.d.ts.map +0 -1
  48. package/dist/catalog/chartcoach-defaults.js +0 -8
  49. package/dist/catalog/errors.d.ts +0 -4
  50. package/dist/catalog/errors.d.ts.map +0 -1
  51. package/dist/catalog/errors.js +0 -6
  52. package/dist/catalog/labels.d.ts +0 -9
  53. package/dist/catalog/labels.d.ts.map +0 -1
  54. package/dist/catalog/labels.js +0 -23
  55. package/dist/catalog/load-parquet-core.d.ts +0 -15
  56. package/dist/catalog/load-parquet-core.d.ts.map +0 -1
  57. package/dist/catalog/load-parquet-core.js +0 -45
  58. package/dist/catalog/manifest.d.ts +0 -15
  59. package/dist/catalog/manifest.d.ts.map +0 -1
  60. package/dist/catalog/manifest.js +0 -143
  61. package/dist/catalog/markdown.d.ts +0 -3
  62. package/dist/catalog/markdown.d.ts.map +0 -1
  63. package/dist/catalog/markdown.js +0 -13
  64. package/dist/catalog/model.d.ts +0 -33
  65. package/dist/catalog/model.d.ts.map +0 -1
  66. package/dist/catalog/model.js +0 -67
  67. package/dist/catalog/wire.d.ts +0 -18
  68. package/dist/catalog/wire.d.ts.map +0 -1
  69. package/dist/catalog/wire.js +0 -78
  70. package/dist/index.d.ts.map +0 -1
  71. package/src/catalog/chartcoach-defaults.ts +0 -17
  72. package/src/catalog/load-parquet-core.ts +0 -78
@@ -0,0 +1,428 @@
1
+ import {
2
+ assertReleaseArtifactBytes,
3
+ assertReleaseDigest,
4
+ assertReleaseLocation,
5
+ catalogUrl,
6
+ parseCatalogRelease,
7
+ releaseArtifact,
8
+ releaseArtifactUrl,
9
+ type CatalogRelease,
10
+ type ReleaseArtifact,
11
+ } from "./artifacts";
12
+ import { CatalogError } from "./errors";
13
+ import type { ProfileLoader } from "./description";
14
+ import { parseJson, type JsonValue } from "./json";
15
+ import { loadCatalogWithProfileLoader } from "./parquet";
16
+ import type { Catalog } from "./model";
17
+ import { parseProfileMetadata } from "./profile";
18
+
19
+ export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
20
+
21
+ export interface ArtifactCache {
22
+ get(sha256: string, bytes: number): Promise<Uint8Array | undefined>;
23
+ put(sha256: string, bytes: Uint8Array): Promise<void>;
24
+ }
25
+
26
+ export type OpenCatalogOptions = {
27
+ fetch?: FetchLike;
28
+ signal?: AbortSignal;
29
+ cache?: ArtifactCache;
30
+ };
31
+
32
+ const CATALOG_REQUEST_TIMEOUT_MS = 15 * 60 * 1000;
33
+
34
+ const CATALOG_BODY_READ_ERROR = "Failed to read catalog resource body.";
35
+
36
+ const MAX_CATALOG_JSON_BYTES = 1024 * 1024;
37
+
38
+ const MAX_CORE_ARTIFACT_BYTES = 64 * 1024 * 1024;
39
+
40
+ const MAX_PROFILE_METADATA_BYTES = 65_536;
41
+
42
+ type CatalogDescriptor = {
43
+ kind: "catalog" | "release";
44
+ url: string;
45
+ };
46
+
47
+ export async function openCatalog(
48
+ location: string | URL = catalogUrl(),
49
+ options: OpenCatalogOptions = {},
50
+ ): Promise<Catalog> {
51
+ const fetch = options.fetch ?? globalThis.fetch;
52
+ const descriptor = requireDescriptorUrl(location, options.fetch !== undefined);
53
+
54
+ const release = parseCatalogRelease(
55
+ await fetchJson(
56
+ descriptor.url,
57
+ fetch,
58
+ options.signal,
59
+ descriptor.kind === "catalog" ? "no-cache" : undefined,
60
+ ),
61
+ );
62
+
63
+ await assertReleaseDigest(release);
64
+
65
+ if (descriptor.kind === "release") assertReleaseLocation(descriptor.url, release);
66
+
67
+ const artifactBase =
68
+ descriptor.kind === "catalog"
69
+ ? new URL(`catalog/releases/${release.digest}/`, descriptor.url).toString()
70
+ : new URL(".", descriptor.url).toString();
71
+
72
+ const releaseUrl =
73
+ descriptor.kind === "catalog"
74
+ ? new URL(`catalog/releases/${release.digest}/release.json`, descriptor.url).toString()
75
+ : descriptor.url;
76
+
77
+ return loadCatalogFromRelease(fetch, artifactBase, releaseUrl, release, options);
78
+ }
79
+
80
+ async function loadCatalogFromRelease(
81
+ fetch: FetchLike,
82
+ artifactBase: string,
83
+ releaseUrl: string,
84
+ release: CatalogRelease,
85
+ options: OpenCatalogOptions,
86
+ ): Promise<Catalog> {
87
+ const signal = options.signal;
88
+ const artifacts = new Map<string, Uint8Array>();
89
+
90
+ const loadArtifact = async (path: string, signal?: AbortSignal): Promise<Uint8Array> => {
91
+ const timeout = AbortSignal.timeout(CATALOG_REQUEST_TIMEOUT_MS);
92
+ signal = signal ? AbortSignal.any([signal, timeout]) : timeout;
93
+ signal?.throwIfAborted();
94
+ const descriptor = releaseArtifact(release, path);
95
+
96
+ const maximum =
97
+ path === "entries.parquet" || path === "MANIFEST.md" ? MAX_CORE_ARTIFACT_BYTES : 1024 ** 3;
98
+
99
+ if (descriptor.bytes > maximum)
100
+ throw new CatalogError(`Catalog artifact exceeds size limit: ${path}`);
101
+
102
+ const retain =
103
+ path === "entries.parquet" ||
104
+ path === "MANIFEST.md" ||
105
+ descriptor.bytes <= MAX_PROFILE_METADATA_BYTES;
106
+
107
+ const memory = artifacts.get(path);
108
+
109
+ if (memory) return memory.slice();
110
+ const cached = await options.cache?.get(descriptor.sha256, descriptor.bytes);
111
+
112
+ if (cached) {
113
+ try {
114
+ await assertReleaseArtifactBytes(path, descriptor, cached);
115
+ signal?.throwIfAborted();
116
+
117
+ if (retain) artifacts.set(path, cached.slice());
118
+
119
+ return cached.slice();
120
+ } catch (error) {
121
+ if (!(error instanceof CatalogError) || error.code !== "integrity") throw error;
122
+ }
123
+ }
124
+
125
+ const bytes = new Uint8Array(
126
+ await fetchReleaseArtifact(fetch, artifactBase, path, descriptor, signal),
127
+ );
128
+
129
+ await assertReleaseArtifactBytes(path, descriptor, bytes);
130
+ signal?.throwIfAborted();
131
+ await options.cache?.put(descriptor.sha256, bytes.slice());
132
+ signal?.throwIfAborted();
133
+
134
+ if (retain) artifacts.set(path, bytes);
135
+
136
+ return bytes.slice();
137
+ };
138
+
139
+ const controller = new AbortController();
140
+ const requestSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
141
+
142
+ try {
143
+ const [entries, manifest] = await Promise.all([
144
+ loadArtifact("entries.parquet", requestSignal),
145
+ loadArtifact("MANIFEST.md", requestSignal),
146
+ ]);
147
+
148
+ return loadCatalogWithProfileLoader(
149
+ { entries, manifest, release, releaseUrl },
150
+ profileLoader(loadArtifact, release),
151
+ loadArtifact,
152
+ );
153
+ } catch (error) {
154
+ controller.abort(error);
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ async function fetchJson(
160
+ url: string,
161
+ fetch: FetchLike,
162
+ signal?: AbortSignal,
163
+ cache?: "no-cache",
164
+ ): Promise<JsonValue> {
165
+ const timeout = AbortSignal.timeout(CATALOG_REQUEST_TIMEOUT_MS);
166
+ signal = signal ? AbortSignal.any([signal, timeout]) : timeout;
167
+ const response = await fetchCatalogResource(url, fetch, signal, cache);
168
+
169
+ const data = await readResponseBytes(
170
+ response,
171
+ MAX_CATALOG_JSON_BYTES,
172
+ "Catalog JSON exceeds size limit.",
173
+ signal,
174
+ );
175
+
176
+ try {
177
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
178
+
179
+ return parseJson(text);
180
+ } catch {
181
+ throw new CatalogError("Catalog JSON must contain valid UTF-8 JSON.");
182
+ }
183
+ }
184
+
185
+ async function fetchReleaseArtifact(
186
+ fetch: FetchLike,
187
+ artifactBase: string,
188
+ path: string,
189
+ artifact: ReleaseArtifact,
190
+ signal?: AbortSignal,
191
+ ): Promise<ArrayBuffer> {
192
+ const response = await fetchCatalogResource(
193
+ releaseArtifactUrl(artifactBase, path),
194
+ fetch,
195
+ signal,
196
+ );
197
+
198
+ return readArtifactBytes(response, path, artifact, signal);
199
+ }
200
+
201
+ async function readArtifactBytes(
202
+ response: Response,
203
+ path: string,
204
+ artifact: ReleaseArtifact,
205
+ signal?: AbortSignal,
206
+ ): Promise<ArrayBuffer> {
207
+ const maximum =
208
+ path === "entries.parquet" || path === "MANIFEST.md" ? MAX_CORE_ARTIFACT_BYTES : 1024 ** 3;
209
+
210
+ const absoluteLimitApplies = artifact.bytes > maximum;
211
+
212
+ return readResponseBytes(
213
+ response,
214
+ Math.min(artifact.bytes, maximum),
215
+ absoluteLimitApplies
216
+ ? `Catalog artifact exceeds size limit: ${path}`
217
+ : `Catalog artifact byte count mismatch: ${path}`,
218
+ signal,
219
+ );
220
+ }
221
+
222
+ async function readResponseBytes(
223
+ response: Response,
224
+ limit: number,
225
+ limitError: string,
226
+ signal?: AbortSignal,
227
+ ): Promise<ArrayBuffer> {
228
+ signal?.throwIfAborted();
229
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
230
+
231
+ try {
232
+ reader = response.body?.getReader();
233
+ } catch {
234
+ throw new CatalogError(CATALOG_BODY_READ_ERROR);
235
+ }
236
+
237
+ if (!reader) return new ArrayBuffer(0);
238
+ const activeReader = reader;
239
+
240
+ const abort = () => {
241
+ void activeReader.cancel(signal?.reason).catch(() => {});
242
+ };
243
+
244
+ signal?.addEventListener("abort", abort, { once: true });
245
+
246
+ if (signal?.aborted) abort();
247
+
248
+ const chunks: Uint8Array[] = [];
249
+ let size = 0;
250
+ let failure: Error | undefined;
251
+
252
+ while (!failure) {
253
+ try {
254
+ const result = await reader.read();
255
+ signal?.throwIfAborted();
256
+
257
+ if (result.done) break;
258
+ const { value } = result;
259
+ size += value.byteLength;
260
+
261
+ if (size > limit) {
262
+ void reader.cancel().catch(() => {});
263
+ failure = new CatalogError(limitError);
264
+ break;
265
+ }
266
+
267
+ chunks.push(value);
268
+ } catch {
269
+ failure = signal?.aborted ? abortReason(signal) : new CatalogError(CATALOG_BODY_READ_ERROR);
270
+ }
271
+ }
272
+
273
+ signal?.removeEventListener("abort", abort);
274
+
275
+ try {
276
+ reader.releaseLock();
277
+ } catch {
278
+ failure ??= new CatalogError(CATALOG_BODY_READ_ERROR);
279
+ }
280
+
281
+ if (failure) throw failure;
282
+
283
+ try {
284
+ const data = new Uint8Array(size);
285
+ let offset = 0;
286
+
287
+ for (const chunk of chunks) {
288
+ data.set(chunk, offset);
289
+ offset += chunk.byteLength;
290
+ }
291
+
292
+ return data.buffer;
293
+ } catch {
294
+ throw new CatalogError(CATALOG_BODY_READ_ERROR);
295
+ }
296
+ }
297
+
298
+ async function fetchCatalogResource(
299
+ url: string,
300
+ fetch: FetchLike,
301
+ signal?: AbortSignal,
302
+ cache?: "no-cache",
303
+ ): Promise<Response> {
304
+ const timeout = AbortSignal.timeout(CATALOG_REQUEST_TIMEOUT_MS);
305
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
306
+ const init = cache ? { signal: requestSignal, cache } : { signal: requestSignal };
307
+ let response: Response;
308
+
309
+ try {
310
+ response = await fetch(url, init);
311
+ } catch {
312
+ if (signal?.aborted) throw abortReason(signal);
313
+ throw new CatalogError("Failed to load catalog resource.");
314
+ }
315
+
316
+ let ok: boolean;
317
+ let status: number;
318
+
319
+ try {
320
+ ok = response.ok;
321
+ status = response.status;
322
+ } catch {
323
+ throw new CatalogError("Failed to load catalog resource.");
324
+ }
325
+
326
+ if (!ok) {
327
+ try {
328
+ await response.body?.cancel();
329
+ } catch {
330
+ // Preserve the HTTP failure when transport cleanup also fails.
331
+ }
332
+
333
+ const message = Number.isInteger(status)
334
+ ? `Failed to load catalog resource: HTTP ${status}.`
335
+ : "Failed to load catalog resource.";
336
+
337
+ throw new CatalogError(message);
338
+ }
339
+
340
+ return response;
341
+ }
342
+
343
+ function profileLoader(
344
+ loadArtifact: (path: string, signal?: AbortSignal) => Promise<Uint8Array>,
345
+ release: CatalogRelease,
346
+ ): ProfileLoader {
347
+ return async (profile, signal) => {
348
+ const path = profile.metadata;
349
+ const artifact = releaseArtifact(release, path);
350
+
351
+ if (artifact.bytes > MAX_PROFILE_METADATA_BYTES) {
352
+ throw new CatalogError("Profile metadata exceeds the 64 KiB limit.", {
353
+ code: "incompatible_profile",
354
+ details: { profile: profile.name },
355
+ });
356
+ }
357
+
358
+ let data: Uint8Array;
359
+
360
+ try {
361
+ data = await loadArtifact(path, signal);
362
+ } catch (error) {
363
+ if (signal?.aborted) throw abortReason(signal);
364
+ throw new CatalogError(
365
+ error instanceof CatalogError
366
+ ? error.message
367
+ : `Profile metadata could not be loaded: ${profile.name}.`,
368
+ {
369
+ code: "integrity",
370
+ details: { profile: profile.name },
371
+ },
372
+ );
373
+ }
374
+
375
+ let value: JsonValue;
376
+
377
+ try {
378
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(data);
379
+ value = parseJson(text);
380
+ } catch {
381
+ throw new CatalogError(`Profile metadata is not valid UTF-8 JSON: ${profile.name}.`, {
382
+ code: "incompatible_profile",
383
+ details: { profile: profile.name },
384
+ });
385
+ }
386
+
387
+ try {
388
+ return parseProfileMetadata(value);
389
+ } catch (error) {
390
+ if (error instanceof CatalogError) {
391
+ throw new CatalogError(error.message, {
392
+ code: "incompatible_profile",
393
+ details: { profile: profile.name },
394
+ });
395
+ }
396
+
397
+ throw error;
398
+ }
399
+ };
400
+ }
401
+
402
+ function abortReason(signal: AbortSignal): Error {
403
+ return signal.reason instanceof Error
404
+ ? signal.reason
405
+ : new DOMException("The operation was aborted.", "AbortError");
406
+ }
407
+
408
+ function requireDescriptorUrl(input: string | URL, customFetch: boolean): CatalogDescriptor {
409
+ const value = input.toString();
410
+ let url: URL;
411
+
412
+ try {
413
+ url = new URL(value);
414
+ } catch {
415
+ throw new CatalogError("Catalog location must be an absolute HTTP or HTTPS URL.");
416
+ }
417
+
418
+ if (!customFetch && url.protocol !== "http:" && url.protocol !== "https:") {
419
+ throw new CatalogError("Catalog location must use HTTP or HTTPS.");
420
+ }
421
+
422
+ const name = url.pathname.split("/").at(-1);
423
+
424
+ if (name === "catalog.json") return { kind: "catalog", url: url.toString() };
425
+
426
+ if (name === "release.json") return { kind: "release", url: url.toString() };
427
+ throw new CatalogError("Catalog location URL must name catalog.json or release.json.");
428
+ }
@@ -0,0 +1,164 @@
1
+ import { parquetReadObjects } from "hyparquet";
2
+ import { compressors } from "hyparquet-compressors";
3
+ import {
4
+ assertReleaseArtifactBytes,
5
+ assertReleaseDigest,
6
+ assertReleaseLocation,
7
+ copyCatalogRelease,
8
+ releaseArtifact,
9
+ sanitizeReleaseUrl,
10
+ type CatalogRelease,
11
+ } from "./artifacts";
12
+ import { CatalogError } from "./errors";
13
+ import type { ProfileLoader } from "./description";
14
+ import {
15
+ Catalog,
16
+ catalogWithRelease,
17
+ type CatalogReleaseContext,
18
+ type GuidelineInput,
19
+ } from "./model";
20
+ import type { JsonObject } from "./json";
21
+ import { parseCatalogManifest } from "./manifest";
22
+ import { releaseProfileNames } from "./profile-layout";
23
+ import { requireGuidelineFromWire } from "./wire";
24
+
25
+ type AsyncBuffer = {
26
+ byteLength: number;
27
+ slice(start: number, end?: number): ArrayBuffer | Promise<ArrayBuffer>;
28
+ };
29
+
30
+ type ParquetBytes = ArrayBuffer | ArrayBufferView;
31
+
32
+ type CatalogBytes = ParquetBytes | AsyncBuffer;
33
+
34
+ export type LoadCatalogDataInput = {
35
+ entries: CatalogBytes;
36
+ manifestText: string;
37
+ };
38
+
39
+ export type LoadCatalogInput = {
40
+ entries: ParquetBytes;
41
+ manifest: ParquetBytes;
42
+ release: CatalogRelease;
43
+ releaseUrl: string | URL;
44
+ };
45
+
46
+ const MAX_CORE_ARTIFACT_BYTES = 64 * 1024 * 1024;
47
+
48
+ function normalizeParquetBytes(bytes: ParquetBytes): ArrayBuffer {
49
+ if (bytes instanceof ArrayBuffer) return bytes;
50
+
51
+ // TypedArray/DataView may be a view into a larger ArrayBuffer (or SharedArrayBuffer).
52
+ // Copy to a standalone ArrayBuffer covering exactly the view range.
53
+ const u8 = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
54
+
55
+ return u8.slice().buffer;
56
+ }
57
+
58
+ function isParquetBytes(bytes: CatalogBytes): bytes is ParquetBytes {
59
+ return bytes instanceof ArrayBuffer || ArrayBuffer.isView(bytes);
60
+ }
61
+
62
+ export async function loadCatalog(input: LoadCatalogInput): Promise<Catalog> {
63
+ return loadCatalogWithProfileLoader(input);
64
+ }
65
+
66
+ export async function loadCatalogWithProfileLoader(
67
+ input: LoadCatalogInput,
68
+ profileLoader?: ProfileLoader,
69
+ artifactLoader?: CatalogReleaseContext["artifactLoader"],
70
+ ): Promise<Catalog> {
71
+ const release = copyCatalogRelease(input.release);
72
+ await assertReleaseDigest(release);
73
+ assertReleaseLocation(input.releaseUrl, release);
74
+ releaseProfileNames(release);
75
+ assertCoreArtifactSize(release, "entries.parquet", input.entries.byteLength);
76
+ assertCoreArtifactSize(release, "MANIFEST.md", input.manifest.byteLength);
77
+ await Promise.all([
78
+ assertReleaseArtifactBytes(
79
+ "entries.parquet",
80
+ releaseArtifact(release, "entries.parquet"),
81
+ input.entries,
82
+ ),
83
+ assertReleaseArtifactBytes(
84
+ "MANIFEST.md",
85
+ releaseArtifact(release, "MANIFEST.md"),
86
+ input.manifest,
87
+ ),
88
+ ]);
89
+ let manifestText: string;
90
+
91
+ try {
92
+ manifestText = new TextDecoder("utf-8", { fatal: true }).decode(
93
+ normalizeParquetBytes(input.manifest),
94
+ );
95
+ } catch {
96
+ throw new CatalogError("Catalog manifest must contain valid UTF-8.");
97
+ }
98
+
99
+ return parseCatalogData(
100
+ { entries: input.entries, manifestText },
101
+ {
102
+ release,
103
+ releaseUrl: sanitizeReleaseUrl(input.releaseUrl),
104
+ profileLoader,
105
+ artifactLoader: artifactLoader ?? suppliedArtifacts(input),
106
+ },
107
+ );
108
+ }
109
+
110
+ function suppliedArtifacts(input: LoadCatalogInput): CatalogReleaseContext["artifactLoader"] {
111
+ const entries = new Uint8Array(normalizeParquetBytes(input.entries)).slice();
112
+ const manifest = new Uint8Array(normalizeParquetBytes(input.manifest)).slice();
113
+
114
+ return async (path, signal) => {
115
+ signal?.throwIfAborted();
116
+
117
+ if (path === "entries.parquet") return entries.slice();
118
+
119
+ if (path === "MANIFEST.md") return manifest.slice();
120
+ throw new CatalogError(`Artifact bytes were not supplied: ${path}`, {
121
+ code: "unavailable_capability",
122
+ });
123
+ };
124
+ }
125
+
126
+ function assertCoreArtifactSize(
127
+ release: CatalogRelease,
128
+ path: "MANIFEST.md" | "entries.parquet",
129
+ receivedBytes: number,
130
+ ): void {
131
+ const recordedBytes = releaseArtifact(release, path).bytes;
132
+
133
+ if (recordedBytes > MAX_CORE_ARTIFACT_BYTES || receivedBytes > MAX_CORE_ARTIFACT_BYTES) {
134
+ throw new CatalogError(`Catalog artifact exceeds size limit: ${path}`);
135
+ }
136
+ }
137
+
138
+ export async function loadCatalogData(input: LoadCatalogDataInput): Promise<Catalog> {
139
+ return parseCatalogData(input);
140
+ }
141
+
142
+ async function parseCatalogData(
143
+ input: LoadCatalogDataInput,
144
+ releaseContext?: CatalogReleaseContext,
145
+ ): Promise<Catalog> {
146
+ const manifest = parseCatalogManifest(input.manifestText);
147
+
148
+ const normalizedFile = isParquetBytes(input.entries)
149
+ ? normalizeParquetBytes(input.entries)
150
+ : input.entries;
151
+
152
+ const rows: JsonObject[] = await parquetReadObjects({
153
+ file: normalizedFile,
154
+ compressors,
155
+ });
156
+
157
+ const guidelines: GuidelineInput[] = rows.map((row, index) =>
158
+ requireGuidelineFromWire(row, `parquet row ${index}`),
159
+ );
160
+
161
+ const catalog = new Catalog(guidelines, manifest);
162
+
163
+ return releaseContext ? catalogWithRelease(catalog, releaseContext) : catalog;
164
+ }
@@ -0,0 +1,86 @@
1
+ import type { CatalogRelease } from "./artifacts";
2
+ import { CatalogError } from "./errors";
3
+
4
+ const profileFiles = new Set([
5
+ "profile.json",
6
+ "index.tar.gz",
7
+ "documents.parquet",
8
+ "projection.parquet",
9
+ ]);
10
+
11
+ const profileIdPattern = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
12
+
13
+ const windowsDeviceNames = new Set(["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"]);
14
+
15
+ export type ReleaseProfile = Readonly<{
16
+ name: string;
17
+ metadata: string;
18
+ index: string;
19
+ documents: string | null;
20
+ projection: string | null;
21
+ }>;
22
+
23
+ export function releaseProfiles(release: CatalogRelease): readonly ReleaseProfile[] {
24
+ const profiles = new Map<string, Set<string>>();
25
+
26
+ for (const path of Object.keys(release.artifacts)) {
27
+ const parts = path.split("/");
28
+
29
+ if (parts[0] !== "profiles") continue;
30
+
31
+ if (parts.length < 3) {
32
+ throw new CatalogError(`Invalid profile artifact path: ${JSON.stringify(path)}.`);
33
+ }
34
+
35
+ const filename = parts.at(-1)!;
36
+
37
+ if (!profileFiles.has(filename)) {
38
+ throw new CatalogError(`Unknown profile artifact: ${JSON.stringify(path)}.`);
39
+ }
40
+
41
+ const profile = parts.slice(1, -1).join("/");
42
+ const artifacts = profiles.get(profile) ?? new Set<string>();
43
+ artifacts.add(filename);
44
+ profiles.set(profile, artifacts);
45
+ }
46
+
47
+ const result: ReleaseProfile[] = [];
48
+
49
+ for (const [profile, artifacts] of profiles) {
50
+ if (!isProfileId(profile)) {
51
+ throw new CatalogError("Profile ID must be a lowercase portable single-component name.");
52
+ }
53
+
54
+ if (!artifacts.has("profile.json")) {
55
+ throw new CatalogError(`Profile ${JSON.stringify(profile)} is missing profile.json.`);
56
+ }
57
+
58
+ if (!artifacts.has("index.tar.gz")) {
59
+ throw new CatalogError(`Profile ${JSON.stringify(profile)} is missing index.tar.gz.`);
60
+ }
61
+
62
+ const root = `profiles/${profile}`;
63
+ result.push(
64
+ Object.freeze({
65
+ name: profile,
66
+ metadata: `${root}/profile.json`,
67
+ index: `${root}/index.tar.gz`,
68
+ documents: artifacts.has("documents.parquet") ? `${root}/documents.parquet` : null,
69
+ projection: artifacts.has("projection.parquet") ? `${root}/projection.parquet` : null,
70
+ }),
71
+ );
72
+ }
73
+
74
+ return Object.freeze(result.sort((left, right) => (left.name < right.name ? -1 : 1)));
75
+ }
76
+
77
+ export function releaseProfileNames(release: CatalogRelease): readonly string[] {
78
+ return Object.freeze(releaseProfiles(release).map((profile) => profile.name));
79
+ }
80
+
81
+ function isProfileId(value: string): boolean {
82
+ if (!profileIdPattern.test(value) || value.includes("/")) return false;
83
+ const basename = value.split(".", 1)[0]!.toUpperCase();
84
+
85
+ return !windowsDeviceNames.has(basename) && !/^(?:COM|LPT)[1-9]$/.test(basename);
86
+ }