@chartcoach/catalog 0.1.6 → 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/LICENSE +201 -21
- package/README.md +324 -38
- package/dist/duckdb-wasm.d.ts +8 -0
- package/dist/duckdb-wasm.js +22 -0
- package/dist/duckdb.d.ts +8 -0
- package/dist/duckdb.js +9 -0
- package/dist/index-cache-Cx1gIK6N.js +280 -0
- package/dist/index.d.ts +40 -8
- package/dist/index.js +22 -7
- package/dist/json-CBWjz9b4.js +29 -0
- package/dist/model-De0MF-zg.d.ts +255 -0
- package/dist/node.d.ts +15 -0
- package/dist/node.js +218 -0
- package/dist/open-Nnzkr_bE.d.ts +15 -0
- package/dist/open-Uu3-r8kp.js +1244 -0
- package/dist/registration-CgQGMUIH.d.ts +6 -0
- package/dist/registration-DXhS7vgr.js +41 -0
- package/dist/tables-DcgKnXwF.js +389 -0
- package/package.json +48 -19
- package/src/catalog/artifacts.ts +309 -100
- package/src/catalog/description.ts +198 -0
- package/src/catalog/errors.ts +26 -1
- package/src/catalog/identity.ts +69 -0
- package/src/catalog/json.ts +32 -0
- package/src/catalog/labels.ts +8 -6
- package/src/catalog/manifest.ts +81 -20
- package/src/catalog/markdown.ts +2 -2
- package/src/catalog/model.ts +222 -37
- package/src/catalog/open.ts +428 -0
- package/src/catalog/parquet.ts +164 -0
- package/src/catalog/profile-layout.ts +86 -0
- package/src/catalog/profile.ts +345 -0
- package/src/catalog/query.ts +125 -0
- package/src/catalog/read.ts +101 -0
- package/src/catalog/references.ts +336 -0
- package/src/catalog/registration.ts +74 -0
- package/src/catalog/tables.ts +250 -0
- package/src/catalog/wire.ts +38 -65
- package/src/duckdb-wasm.ts +33 -0
- package/src/duckdb.ts +18 -0
- package/src/index.ts +51 -20
- package/src/node/index-cache.ts +432 -0
- package/src/node.ts +347 -0
- package/dist/catalog/artifacts.d.ts +0 -28
- package/dist/catalog/artifacts.d.ts.map +0 -1
- package/dist/catalog/artifacts.js +0 -89
- package/dist/catalog/chartcoach-defaults.d.ts +0 -17
- package/dist/catalog/chartcoach-defaults.d.ts.map +0 -1
- package/dist/catalog/chartcoach-defaults.js +0 -8
- package/dist/catalog/errors.d.ts +0 -4
- package/dist/catalog/errors.d.ts.map +0 -1
- package/dist/catalog/errors.js +0 -6
- package/dist/catalog/labels.d.ts +0 -9
- package/dist/catalog/labels.d.ts.map +0 -1
- package/dist/catalog/labels.js +0 -23
- package/dist/catalog/load-parquet-core.d.ts +0 -15
- package/dist/catalog/load-parquet-core.d.ts.map +0 -1
- package/dist/catalog/load-parquet-core.js +0 -45
- package/dist/catalog/manifest.d.ts +0 -15
- package/dist/catalog/manifest.d.ts.map +0 -1
- package/dist/catalog/manifest.js +0 -143
- package/dist/catalog/markdown.d.ts +0 -3
- package/dist/catalog/markdown.d.ts.map +0 -1
- package/dist/catalog/markdown.js +0 -13
- package/dist/catalog/model.d.ts +0 -33
- package/dist/catalog/model.d.ts.map +0 -1
- package/dist/catalog/model.js +0 -67
- package/dist/catalog/wire.d.ts +0 -18
- package/dist/catalog/wire.d.ts.map +0 -1
- package/dist/catalog/wire.js +0 -78
- package/dist/index.d.ts.map +0 -1
- package/src/catalog/chartcoach-defaults.ts +0 -17
- package/src/catalog/load-parquet-core.ts +0 -78
package/src/catalog/artifacts.ts
CHANGED
|
@@ -1,122 +1,331 @@
|
|
|
1
|
-
import { CHARTCOACH_DEFAULTS } from "./chartcoach-defaults";
|
|
2
1
|
import { CatalogError } from "./errors";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
27
|
-
rows?: number;
|
|
28
|
-
[key: string]: unknown;
|
|
29
|
-
};
|
|
18
|
+
}>;
|
|
30
19
|
|
|
31
|
-
export type
|
|
32
|
-
|
|
20
|
+
export type CatalogRelease = Readonly<{
|
|
21
|
+
schema_version: 1;
|
|
33
22
|
digest: string;
|
|
34
|
-
artifacts:
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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
|
-
|
|
107
|
-
|
|
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:
|
|
292
|
+
function requiredInteger(value: JsonObject, key: string, label: string): number {
|
|
113
293
|
const raw = value[key];
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
121
|
-
|
|
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
|
+
}
|
package/src/catalog/errors.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|