@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
@@ -1,122 +1,331 @@
1
- import { CHARTCOACH_DEFAULTS } from "./chartcoach-defaults";
2
1
  import { CatalogError } from "./errors";
3
- export { CHARTCOACH_DEFAULTS, type ChartCoachDefaults } from "./chartcoach-defaults";
4
-
5
- const defaultCatalogReleaseRootUrl = new URL(
6
- `catalog/releases/${CHARTCOACH_DEFAULTS.catalogVersion}/${CHARTCOACH_DEFAULTS.catalogDigest}/`,
7
- `${CHARTCOACH_DEFAULTS.catalogArtifactBaseUrl}/`,
8
- ).toString();
9
-
10
- export const DEFAULT_CATALOG = {
11
- version: CHARTCOACH_DEFAULTS.catalogVersion,
12
- digest: CHARTCOACH_DEFAULTS.catalogDigest,
13
- releaseRootUrl: defaultCatalogReleaseRootUrl,
14
- metadataUrl: new URL("metadata.json", defaultCatalogReleaseRootUrl).toString(),
15
- entriesUrl: new URL("entries.parquet", defaultCatalogReleaseRootUrl).toString(),
16
- manifestUrl: new URL("MANIFEST.md", defaultCatalogReleaseRootUrl).toString(),
17
- } as const;
18
-
19
- export type ArtifactKind = "manifest" | "entries" | "lancedb-index";
20
-
21
- export type ArtifactDescriptor = {
22
- kind: ArtifactKind;
23
- path: string;
24
- digest: string;
2
+ import { canonicalJson, compareUnicode, sha256Bytes } from "./identity";
3
+ import { isJsonNumber, isJsonObject, isJsonString, type JsonObject, type JsonValue } from "./json";
4
+
5
+ const CATALOG_ARTIFACT_BASE_URL = "https://artifacts.chartcoach.dev/";
6
+
7
+ const RELEASE_SCHEMA_VERSION = 1;
8
+
9
+ const REQUIRED_ARTIFACTS = ["MANIFEST.md", "entries.parquet"] as const;
10
+
11
+ const FORBIDDEN_PATH_CHARACTERS = new Set('<>:"\\|?*#%');
12
+
13
+ const WINDOWS_DEVICE_NAMES = new Set(["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"]);
14
+
15
+ export type ReleaseArtifact = Readonly<{
16
+ sha256: string;
25
17
  bytes: number;
26
- format?: string;
27
- rows?: number;
28
- [key: string]: unknown;
29
- };
18
+ }>;
30
19
 
31
- export type CatalogReleaseMetadata = {
32
- version: string;
20
+ export type CatalogRelease = Readonly<{
21
+ schema_version: 1;
33
22
  digest: string;
34
- artifacts: ArtifactDescriptor[];
35
- };
36
-
37
- export function parseCatalogReleaseMetadata(value: unknown): CatalogReleaseMetadata {
38
- if (!isRecord(value)) {
39
- throw new CatalogError("Catalog release metadata must be an object.");
40
- }
41
- const artifacts = value.artifacts;
42
- if (!Array.isArray(artifacts)) {
43
- throw new CatalogError("Catalog release metadata artifacts must be a list.");
44
- }
45
- const metadata = {
46
- version: requiredString(value, "version"),
47
- digest: requiredString(value, "digest"),
48
- artifacts: artifacts.map(parseArtifactDescriptor),
49
- };
50
- catalogArtifact(metadata, "manifest");
51
- catalogArtifact(metadata, "entries");
52
- return metadata;
53
- }
54
-
55
- export function catalogArtifactUrl(baseUrl: string | URL, descriptor: ArtifactDescriptor): string {
56
- return new URL(descriptor.path, baseUrl).toString();
57
- }
58
-
59
- export function catalogArtifact(
60
- metadata: CatalogReleaseMetadata,
61
- kind: ArtifactKind,
62
- ): ArtifactDescriptor {
63
- const descriptor = metadata.artifacts.find((item) => item.kind === kind);
64
- if (!descriptor) {
65
- throw new CatalogError(`Catalog release metadata is missing a ${kind} artifact.`);
66
- }
67
- return descriptor;
68
- }
69
-
70
- function parseArtifactDescriptor(value: unknown): ArtifactDescriptor {
71
- if (!isRecord(value)) {
72
- throw new CatalogError("Catalog artifact descriptor must be an object.");
73
- }
74
- const kind = value.kind;
75
- if (kind !== "manifest" && kind !== "entries" && kind !== "lancedb-index") {
76
- throw new CatalogError(`Unsupported catalog artifact kind: ${String(kind)}`);
77
- }
78
- const path = requiredString(value, "path");
79
- validateRelativePath(path);
80
- const descriptor: ArtifactDescriptor = {
81
- kind,
82
- path,
83
- digest: requiredString(value, "digest"),
84
- bytes: requiredInteger(value, "bytes"),
85
- };
86
- if (typeof value.format === "string") descriptor.format = value.format;
87
- if (typeof value.rows === "number" && Number.isInteger(value.rows)) {
88
- descriptor.rows = value.rows;
89
- }
90
- for (const [key, rawValue] of Object.entries(value)) {
91
- if (!["kind", "path", "digest", "bytes", "format", "rows"].includes(key)) {
92
- descriptor[key] = rawValue;
23
+ artifacts: Readonly<Record<string, ReleaseArtifact>>;
24
+ }>;
25
+
26
+ export function parseCatalogRelease(value: JsonValue): CatalogRelease {
27
+ const record = requireRecord(value, "Catalog release");
28
+ requireExactKeys(record, ["schema_version", "digest", "artifacts"], "Catalog release");
29
+ requireSchemaVersion(record);
30
+ const artifactRecords = requireRecord(record.artifacts, "Catalog artifacts");
31
+ const artifacts: Record<string, ReleaseArtifact> = Object.create(null);
32
+
33
+ for (const [path, artifact] of Object.entries(artifactRecords)
34
+ .map(([path, value]): [string, ReleaseArtifact] => [
35
+ requireRelativePath(path, "Catalog artifact path"),
36
+ parseReleaseArtifact(value),
37
+ ])
38
+ .sort(([left], [right]) => compareUnicode(left, right))) {
39
+ artifacts[path] = artifact;
40
+ }
41
+
42
+ validateArtifactPaths(Object.keys(artifacts));
43
+
44
+ for (const path of REQUIRED_ARTIFACTS) {
45
+ if (!Object.hasOwn(artifacts, path)) {
46
+ throw new CatalogError(`Catalog release is missing artifact: ${path}.`);
47
+ }
48
+ }
49
+
50
+ return Object.freeze({
51
+ schema_version: RELEASE_SCHEMA_VERSION,
52
+ digest: requiredSha256(record, "digest", "Catalog release"),
53
+ artifacts: Object.freeze(artifacts),
54
+ });
55
+ }
56
+
57
+ export function copyCatalogRelease(release: CatalogRelease): CatalogRelease {
58
+ return parseCatalogRelease({
59
+ schema_version: release.schema_version,
60
+ digest: release.digest,
61
+ artifacts: Object.fromEntries(
62
+ Object.entries(release.artifacts).map(([path, artifact]) => [
63
+ path,
64
+ { sha256: artifact.sha256, bytes: artifact.bytes },
65
+ ]),
66
+ ),
67
+ });
68
+ }
69
+
70
+ export async function assertReleaseDigest(
71
+ release: CatalogRelease,
72
+ expected = release.digest,
73
+ label = "Catalog release",
74
+ ): Promise<void> {
75
+ const actual = await catalogReleaseDigest(release);
76
+
77
+ if (release.digest !== actual) {
78
+ throw new CatalogError(`${label} digest ${release.digest} does not match its artifact set.`, {
79
+ code: "integrity",
80
+ details: { expected_digest: release.digest, actual_digest: actual },
81
+ });
82
+ }
83
+
84
+ if (release.digest !== expected) {
85
+ throw new CatalogError(`${label} digest ${release.digest} does not match ${expected}.`, {
86
+ code: "integrity",
87
+ details: { expected_digest: expected, actual_digest: release.digest },
88
+ });
89
+ }
90
+ }
91
+
92
+ export async function assertReleaseArtifactBytes(
93
+ path: string,
94
+ artifact: ReleaseArtifact,
95
+ data: ArrayBuffer | ArrayBufferView,
96
+ ): Promise<void> {
97
+ const bytes = bytesView(data);
98
+
99
+ if (bytes.byteLength !== artifact.bytes) {
100
+ throw new CatalogError(`Catalog artifact byte count mismatch: ${path}`, {
101
+ code: "integrity",
102
+ details: { path, expected_bytes: artifact.bytes, actual_bytes: bytes.byteLength },
103
+ });
104
+ }
105
+
106
+ const actual = await sha256Bytes(bytes);
107
+
108
+ if (actual !== artifact.sha256) {
109
+ throw new CatalogError(`Catalog artifact SHA-256 mismatch: ${path}`, {
110
+ code: "integrity",
111
+ details: { path, expected_sha256: artifact.sha256, actual_sha256: actual },
112
+ });
113
+ }
114
+ }
115
+
116
+ export function catalogUrl(): string {
117
+ return new URL("catalog.json", CATALOG_ARTIFACT_BASE_URL).toString();
118
+ }
119
+
120
+ export function releaseArtifact(release: CatalogRelease, path: string): ReleaseArtifact {
121
+ const artifact = release.artifacts[path];
122
+
123
+ if (artifact === undefined) {
124
+ throw new CatalogError(`Catalog release is missing artifact: ${path}.`, {
125
+ code: "integrity",
126
+ details: { path },
127
+ });
128
+ }
129
+
130
+ return artifact;
131
+ }
132
+
133
+ export function releaseArtifactUrl(releaseUrl: string | URL, path: string): string {
134
+ return new URL(path, releaseUrl).toString();
135
+ }
136
+
137
+ export function assertReleaseLocation(releaseUrl: string | URL, release: CatalogRelease): void {
138
+ const url = requireReleaseUrl(releaseUrl);
139
+
140
+ if (url.pathname.split("/").at(-1) !== "release.json") return;
141
+ const parent = url.pathname.split("/").at(-2);
142
+
143
+ if (parent && /^[a-f0-9]{64}$/.test(parent) && parent !== release.digest) {
144
+ throw new CatalogError("Catalog release digest does not match its digest-addressed location.", {
145
+ code: "integrity",
146
+ details: { expected_digest: parent, actual_digest: release.digest },
147
+ });
148
+ }
149
+ }
150
+
151
+ export function sanitizeReleaseUrl(value: string | URL): string {
152
+ const url = requireReleaseUrl(value);
153
+ url.username = "";
154
+ url.password = "";
155
+ url.search = "";
156
+ url.hash = "";
157
+
158
+ return url.toString();
159
+ }
160
+
161
+ function requireReleaseUrl(value: string | URL): URL {
162
+ try {
163
+ return new URL(value);
164
+ } catch {
165
+ throw new CatalogError("Catalog release URL must be absolute.");
166
+ }
167
+ }
168
+
169
+ function parseReleaseArtifact(value: JsonValue): ReleaseArtifact {
170
+ const record = requireRecord(value, "Catalog release artifact");
171
+ requireExactKeys(record, ["sha256", "bytes"], "Catalog release artifact");
172
+
173
+ return Object.freeze({
174
+ sha256: requiredSha256(record, "sha256", "Catalog release artifact"),
175
+ bytes: requiredInteger(record, "bytes", "Catalog release artifact"),
176
+ });
177
+ }
178
+
179
+ function validateArtifactPaths(paths: string[]): void {
180
+ const seen = new Map<string, string>();
181
+
182
+ for (const path of paths) {
183
+ const folded = asciiLower(path);
184
+ const previous = seen.get(folded);
185
+
186
+ if (previous !== undefined) {
187
+ throw new CatalogError(`Catalog artifact paths collide: ${previous}, ${path}.`);
188
+ }
189
+
190
+ seen.set(folded, path);
191
+ }
192
+
193
+ for (const [folded, path] of seen) {
194
+ const parts = folded.split("/");
195
+
196
+ if (folded === "release.json") {
197
+ throw new CatalogError(`Catalog artifact path is reserved: ${path}.`);
198
+ }
199
+
200
+ for (let length = 1; length < parts.length; length += 1) {
201
+ const ancestor = parts.slice(0, length).join("/");
202
+ const previous = seen.get(ancestor);
203
+
204
+ if (previous !== undefined) {
205
+ throw new CatalogError(`Catalog artifact paths collide: ${previous}, ${path}.`);
206
+ }
93
207
  }
94
208
  }
95
- return descriptor;
96
209
  }
97
210
 
98
- function validateRelativePath(path: string): void {
99
- if (path.startsWith("/") || path.split("/").includes("..")) {
100
- throw new CatalogError(`Catalog artifact path must be relative: ${path}`);
211
+ async function catalogReleaseDigest(release: CatalogRelease): Promise<string> {
212
+ return sha256Bytes(
213
+ new TextEncoder().encode(
214
+ canonicalJson({
215
+ schema_version: release.schema_version,
216
+ artifacts: release.artifacts,
217
+ }),
218
+ ),
219
+ );
220
+ }
221
+
222
+ function bytesView(data: ArrayBuffer | ArrayBufferView): Uint8Array {
223
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
224
+
225
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
226
+ }
227
+
228
+ function requireRecord(value: JsonValue, label: string): JsonObject {
229
+ if (!isJsonObject(value)) throw new CatalogError(`${label} must be an object.`);
230
+
231
+ return value;
232
+ }
233
+
234
+ function requireExactKeys(value: JsonObject, expected: readonly string[], label: string): void {
235
+ const actual = Object.keys(value);
236
+ const missing = expected.filter((key) => !actual.includes(key)).sort();
237
+ const unexpected = actual.filter((key) => !expected.includes(key)).sort();
238
+
239
+ if (missing.length > 0) {
240
+ throw new CatalogError(`${label} is missing fields: ${missing.join(", ")}.`);
241
+ }
242
+
243
+ if (unexpected.length > 0) {
244
+ throw new CatalogError(`${label} has unsupported fields: ${unexpected.join(", ")}.`);
245
+ }
246
+ }
247
+
248
+ function requireSchemaVersion(value: JsonObject): void {
249
+ const version = requiredInteger(value, "schema_version", "Catalog release");
250
+
251
+ if (version !== RELEASE_SCHEMA_VERSION) {
252
+ throw new CatalogError(`Catalog release schema_version must be ${RELEASE_SCHEMA_VERSION}.`);
101
253
  }
102
254
  }
103
255
 
104
- function requiredString(value: Record<string, unknown>, key: string): string {
256
+ function requireRelativePath(path: string, label: string): string {
257
+ const parts = path.split("/");
258
+
259
+ if (!pathIsAscii(path) || parts.some((part) => !isPortablePathSegment(part))) {
260
+ throw new CatalogError(`${label} must be relative: ${path}`);
261
+ }
262
+
263
+ return path;
264
+ }
265
+
266
+ function isPortablePathSegment(value: string): boolean {
267
+ if (
268
+ value === "" ||
269
+ value === "." ||
270
+ value === ".." ||
271
+ value.endsWith(".") ||
272
+ Array.from(value).some((character) => FORBIDDEN_PATH_CHARACTERS.has(character))
273
+ ) {
274
+ return false;
275
+ }
276
+
277
+ const basename = value.split(".", 1)[0]!.toUpperCase();
278
+
279
+ return !WINDOWS_DEVICE_NAMES.has(basename) && !/^(?:COM|LPT)[1-9]$/.test(basename);
280
+ }
281
+
282
+ function requiredString(value: JsonObject, key: string, label: string): string {
105
283
  const raw = value[key];
106
- if (typeof raw !== "string" || raw.length === 0) {
107
- throw new CatalogError(`Catalog release metadata ${key} must be a string.`);
284
+
285
+ if (!isJsonString(raw) || raw.length === 0) {
286
+ throw new CatalogError(`${label} ${key} must be a string.`);
108
287
  }
288
+
109
289
  return raw;
110
290
  }
111
291
 
112
- function requiredInteger(value: Record<string, unknown>, key: string): number {
292
+ function requiredInteger(value: JsonObject, key: string, label: string): number {
113
293
  const raw = value[key];
114
- if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
115
- throw new CatalogError(`Catalog release metadata ${key} must be a non-negative integer.`);
294
+
295
+ if (!isJsonNumber(raw) || !Number.isSafeInteger(raw) || raw < 0) {
296
+ throw new CatalogError(
297
+ `${label} ${key} must be an integer from 0 through ${Number.MAX_SAFE_INTEGER}.`,
298
+ );
116
299
  }
300
+
117
301
  return raw;
118
302
  }
119
303
 
120
- function isRecord(value: unknown): value is Record<string, unknown> {
121
- return Boolean(value && typeof value === "object" && !Array.isArray(value));
304
+ function requiredSha256(value: JsonObject, key: string, label: string): string {
305
+ const digest = requiredString(value, key, label);
306
+ requireSha256(digest, `${label} ${key}`);
307
+
308
+ return digest;
309
+ }
310
+
311
+ function requireSha256(value: string, label: string): void {
312
+ if (!/^[a-f0-9]{64}$/.test(value)) {
313
+ throw new CatalogError(`${label} must be a lowercase SHA-256 digest.`);
314
+ }
315
+ }
316
+
317
+ function pathIsAscii(value: string): boolean {
318
+ if (value.length === 0) return false;
319
+
320
+ for (const character of value) {
321
+ const code = character.charCodeAt(0);
322
+
323
+ if (code < 0x21 || code > 0x7e) return false;
324
+ }
325
+
326
+ return true;
327
+ }
328
+
329
+ function asciiLower(value: string): string {
330
+ return value.replace(/[A-Z]/g, (character) => character.toLowerCase());
122
331
  }
@@ -0,0 +1,198 @@
1
+ import { CatalogError } from "./errors";
2
+ import { catalogEntriesDigest, manifestDigest } from "./identity";
3
+ import type { Catalog } from "./model";
4
+ import type { ProfileMetadata } from "./profile";
5
+ import type { ReleaseProfile } from "./profile-layout";
6
+ import type { ManifestDefinition } from "./manifest";
7
+ import { catalogTableInfo, type TableInfo } from "./tables";
8
+
9
+ export type DescribeOptions = Readonly<{
10
+ profile?: string;
11
+ signal?: AbortSignal;
12
+ }>;
13
+
14
+ export type ProfileInfo = Readonly<{
15
+ name: string;
16
+ profile_schema_version: 1;
17
+ documents_version: 1;
18
+ embedding_functions: ProfileMetadata["embedding_functions"];
19
+ dimensions: number;
20
+ distance_metric: ProfileMetadata["distance_metric"];
21
+ python_requirements: ProfileMetadata["python_requirements"];
22
+ lancedb_version: string;
23
+ projection: ProfileMetadata["projection"];
24
+ }>;
25
+
26
+ export type CatalogInfo = Readonly<{
27
+ resolved_location: string | null;
28
+ release_digest: string | null;
29
+ entries_digest: string;
30
+ manifest_digest: string;
31
+ tables: readonly TableInfo[];
32
+ section_roles: readonly ManifestDefinition[];
33
+ label_families: readonly ManifestDefinition[];
34
+ profiles: readonly string[];
35
+ profile: ProfileInfo | null;
36
+ }>;
37
+
38
+ export type ProfileLoader = (
39
+ profile: ReleaseProfile,
40
+ signal?: AbortSignal,
41
+ ) => Promise<ProfileMetadata>;
42
+
43
+ export type DescriptionContext = Readonly<{
44
+ resolvedLocation: string | null;
45
+ releaseDigest: string | null;
46
+ profiles: readonly ReleaseProfile[];
47
+ profileLoader?: ProfileLoader;
48
+ profileCache: Map<string, ProfileMetadata>;
49
+ }>;
50
+
51
+ type CatalogIdentity = Readonly<{
52
+ entriesDigest: string;
53
+ manifestDigest: string;
54
+ }>;
55
+
56
+ const identityCache = new WeakMap<Catalog, CatalogIdentity>();
57
+
58
+ export async function describeCatalog(
59
+ catalog: Catalog,
60
+ context: DescriptionContext,
61
+ options: DescribeOptions = {},
62
+ ): Promise<CatalogInfo> {
63
+ if (Object.prototype.toString.call(options) !== "[object Object]") {
64
+ throw new CatalogError("Description options must be an object.");
65
+ }
66
+
67
+ const identity = await catalogIdentity(catalog);
68
+ const selected = selectProfile(context.profiles, options.profile);
69
+ let profile: ProfileInfo | null = null;
70
+
71
+ if (selected) {
72
+ options.signal?.throwIfAborted();
73
+ const metadata = await loadProfile(context, selected, options.signal);
74
+
75
+ if (metadata.entries_digest !== identity.entriesDigest) {
76
+ throw new CatalogError("Profile entries digest does not match the catalog entries.", {
77
+ code: "incompatible_profile",
78
+ details: { profile: selected.name },
79
+ });
80
+ }
81
+
82
+ if (metadata.manifest_digest !== identity.manifestDigest) {
83
+ throw new CatalogError("Profile manifest digest does not match MANIFEST.md.", {
84
+ code: "incompatible_profile",
85
+ details: { profile: selected.name },
86
+ });
87
+ }
88
+
89
+ if ((metadata.projection === null) !== (selected.projection === null)) {
90
+ throw new CatalogError("Profile projection metadata does not match the release inventory.", {
91
+ code: "incompatible_profile",
92
+ details: { profile: selected.name },
93
+ });
94
+ }
95
+
96
+ context.profileCache.set(selected.name, metadata);
97
+ profile = profileInfo(selected.name, metadata);
98
+ }
99
+
100
+ return Object.freeze({
101
+ resolved_location: context.resolvedLocation,
102
+ release_digest: context.releaseDigest,
103
+ entries_digest: identity.entriesDigest,
104
+ manifest_digest: identity.manifestDigest,
105
+ tables: catalogTableInfo(catalog),
106
+ section_roles: vocabulary(Object.values(catalog.manifest.sectionRoles)),
107
+ label_families: vocabulary(Object.values(catalog.manifest.labelFamilies)),
108
+ profiles: Object.freeze(context.profiles.map((item) => item.name)),
109
+ profile,
110
+ });
111
+ }
112
+
113
+ async function catalogIdentity(catalog: Catalog): Promise<CatalogIdentity> {
114
+ const cached = identityCache.get(catalog);
115
+
116
+ if (cached) return cached;
117
+
118
+ const [entriesDigest, catalogManifestDigest] = await Promise.all([
119
+ catalogEntriesDigest(catalog.guidelines),
120
+ manifestDigest(catalog.manifest.markdown),
121
+ ]);
122
+
123
+ const identity = Object.freeze({ entriesDigest, manifestDigest: catalogManifestDigest });
124
+ identityCache.set(catalog, identity);
125
+
126
+ return identity;
127
+ }
128
+
129
+ function selectProfile(
130
+ profiles: readonly ReleaseProfile[],
131
+ name: string | undefined,
132
+ ): ReleaseProfile | undefined {
133
+ if (name === undefined) return undefined;
134
+
135
+ if (!name) throw new CatalogError("Profile must be a non-empty string.");
136
+ const profile = profiles.find((item) => item.name === name);
137
+
138
+ if (profile) return profile;
139
+ throw new CatalogError(`Unknown profile: ${name}`, {
140
+ code: "lookup",
141
+ details: { profile: name, available: profiles.map((item) => item.name) },
142
+ hints: [
143
+ `Available profiles: ${profiles.map((item) => JSON.stringify(item.name)).join(", ") || "none"}.`,
144
+ ],
145
+ });
146
+ }
147
+
148
+ async function loadProfile(
149
+ context: DescriptionContext,
150
+ profile: ReleaseProfile,
151
+ signal?: AbortSignal,
152
+ ): Promise<ProfileMetadata> {
153
+ const cached = context.profileCache.get(profile.name);
154
+
155
+ if (cached) return cached;
156
+
157
+ if (!context.profileLoader) {
158
+ throw new CatalogError("Profile metadata loading is unavailable for caller-provided bytes.", {
159
+ code: "unavailable_capability",
160
+ details: { profile: profile.name },
161
+ hints: ["Use openCatalog with an HTTP or HTTPS release URL."],
162
+ });
163
+ }
164
+
165
+ return context.profileLoader(profile, signal);
166
+ }
167
+
168
+ function profileInfo(name: string, metadata: ProfileMetadata): ProfileInfo {
169
+ return Object.freeze({
170
+ name,
171
+ profile_schema_version: metadata.schema_version,
172
+ documents_version: metadata.documents_version,
173
+ embedding_functions: metadata.embedding_functions,
174
+ dimensions: metadata.dimensions,
175
+ distance_metric: metadata.distance_metric,
176
+ python_requirements: metadata.python_requirements,
177
+ lancedb_version: metadata.lancedb_version,
178
+ projection: metadata.projection,
179
+ });
180
+ }
181
+
182
+ function vocabulary(
183
+ definitions: readonly Readonly<{
184
+ name: string;
185
+ description: string;
186
+ examples: readonly string[];
187
+ }>[],
188
+ ): readonly ManifestDefinition[] {
189
+ return Object.freeze(
190
+ definitions.map((definition) =>
191
+ Object.freeze({
192
+ name: definition.name,
193
+ description: definition.description,
194
+ examples: Object.freeze([...definition.examples]),
195
+ }),
196
+ ),
197
+ );
198
+ }
@@ -1,6 +1,31 @@
1
+ import type { JsonObject } from "./json";
2
+
3
+ export type CatalogErrorCode =
4
+ | "lookup"
5
+ | "invalid_input"
6
+ | "integrity"
7
+ | "unavailable_capability"
8
+ | "incompatible_profile"
9
+ | "embedding_failure"
10
+ | "operation_failed"
11
+ | "response_too_large";
12
+
13
+ export type CatalogErrorOptions = Readonly<{
14
+ code?: CatalogErrorCode;
15
+ details?: JsonObject;
16
+ hints?: readonly string[];
17
+ }>;
18
+
1
19
  export class CatalogError extends Error {
2
- constructor(message: string) {
20
+ readonly code: CatalogErrorCode;
21
+ readonly details: Readonly<JsonObject>;
22
+ readonly hints: readonly string[];
23
+
24
+ constructor(message: string, options: CatalogErrorOptions = {}) {
3
25
  super(message);
4
26
  this.name = "CatalogError";
27
+ this.code = options.code ?? "invalid_input";
28
+ this.details = Object.freeze({ ...options.details });
29
+ this.hints = Object.freeze([...(options.hints ?? [])]);
5
30
  }
6
31
  }