@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/wire.ts
CHANGED
|
@@ -1,93 +1,66 @@
|
|
|
1
1
|
import { CatalogError } from "./errors";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import { isJsonObject, type JsonObject, type JsonValue } from "./json";
|
|
3
|
+
import { isGuidelineInput, type GuidelineInput, type GuidelineSection } from "./model";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type CatalogRowWire = {
|
|
6
6
|
id: string;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
description: string;
|
|
12
|
-
labels: string[];
|
|
13
|
-
body: string;
|
|
14
|
-
sections: GuidelineSection[];
|
|
15
|
-
};
|
|
7
|
+
title: string;
|
|
8
|
+
description: string;
|
|
9
|
+
labels: string[];
|
|
10
|
+
sections: GuidelineSection[];
|
|
16
11
|
references: string[];
|
|
17
12
|
};
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
const catalogRowFields = [
|
|
15
|
+
"id",
|
|
16
|
+
"title",
|
|
17
|
+
"description",
|
|
18
|
+
"labels",
|
|
19
|
+
"sections",
|
|
20
|
+
"references",
|
|
21
|
+
] as const;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
25
|
-
}
|
|
23
|
+
const sectionFields = ["role", "title", "content"] as const;
|
|
26
24
|
|
|
27
|
-
function
|
|
28
|
-
|
|
29
|
-
isRecord(value) &&
|
|
30
|
-
typeof value.role === "string" &&
|
|
31
|
-
value.role.trim().length > 0 &&
|
|
32
|
-
typeof value.title === "string" &&
|
|
33
|
-
typeof value.content === "string"
|
|
34
|
-
);
|
|
35
|
-
}
|
|
25
|
+
function hasFields(value: JsonObject, fields: readonly string[]): boolean {
|
|
26
|
+
const keys = Object.keys(value);
|
|
36
27
|
|
|
37
|
-
|
|
38
|
-
return Array.isArray(value) && value.every(isGuidelineSection);
|
|
28
|
+
return keys.length === fields.length && fields.every((field) => Object.hasOwn(value, field));
|
|
39
29
|
}
|
|
40
30
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
title: section.title,
|
|
45
|
-
content: section.content,
|
|
46
|
-
}));
|
|
47
|
-
}
|
|
31
|
+
function isCatalogRowWire(value: JsonValue): value is CatalogRowWire {
|
|
32
|
+
if (!isJsonObject(value) || !hasFields(value, catalogRowFields)) return false;
|
|
33
|
+
const sections = value.sections;
|
|
48
34
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (!isRecord(value.guideline)) return false;
|
|
53
|
-
if (value.guideline.id !== value.id) return false;
|
|
54
|
-
if (typeof value.guideline.title !== "string") return false;
|
|
55
|
-
if (typeof value.guideline.description !== "string") return false;
|
|
56
|
-
if (typeof value.guideline.body !== "string") return false;
|
|
57
|
-
if (!isStringArray(value.guideline.labels)) return false;
|
|
58
|
-
if (!isSectionArray(value.guideline.sections)) return false;
|
|
59
|
-
if (!isStringArray(value.references)) return false;
|
|
60
|
-
return true;
|
|
35
|
+
if (!Array.isArray(sections) || !sections.every(sectionHasExactFields)) return false;
|
|
36
|
+
|
|
37
|
+
return isGuidelineInput(value);
|
|
61
38
|
}
|
|
62
39
|
|
|
63
|
-
|
|
40
|
+
function guidelineFromWire(value: JsonValue): GuidelineInput | null {
|
|
64
41
|
if (!isCatalogRowWire(value)) return null;
|
|
65
42
|
|
|
66
|
-
const
|
|
67
|
-
const sections = sectionsFromWire(guideline.sections);
|
|
68
|
-
let labels: string[];
|
|
69
|
-
try {
|
|
70
|
-
labels = guideline.labels.map((label) => normalizeLabel(label, "guideline label"));
|
|
71
|
-
} catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
43
|
+
const sections = value.sections.map((section) => ({ ...section }));
|
|
74
44
|
|
|
75
45
|
return {
|
|
76
|
-
id:
|
|
77
|
-
title:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
labels,
|
|
81
|
-
body: guideline.body,
|
|
46
|
+
id: value.id,
|
|
47
|
+
title: value.title,
|
|
48
|
+
description: value.description,
|
|
49
|
+
labels: [...value.labels],
|
|
82
50
|
sections,
|
|
83
|
-
references: [...references],
|
|
51
|
+
references: [...value.references],
|
|
84
52
|
};
|
|
85
53
|
}
|
|
86
54
|
|
|
87
|
-
export function requireGuidelineFromWire(value:
|
|
55
|
+
export function requireGuidelineFromWire(value: JsonValue, context?: string): GuidelineInput {
|
|
88
56
|
const guideline = guidelineFromWire(value);
|
|
57
|
+
|
|
89
58
|
if (guideline) return guideline;
|
|
90
59
|
|
|
91
60
|
const suffix = context ? ` (${context})` : "";
|
|
92
61
|
throw new CatalogError(`Invalid catalog row format${suffix}.`);
|
|
93
62
|
}
|
|
63
|
+
|
|
64
|
+
function sectionHasExactFields(value: JsonValue): value is GuidelineSection {
|
|
65
|
+
return isJsonObject(value) && hasFields(value, sectionFields);
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AsyncDuckDBConnection } from "@duckdb/duckdb-wasm";
|
|
2
|
+
import type { Catalog } from "./catalog/model";
|
|
3
|
+
import { registrationPlan, type RegisterCatalogOptions } from "./catalog/registration";
|
|
4
|
+
|
|
5
|
+
export type { RegisterCatalogOptions } from "./catalog/registration";
|
|
6
|
+
|
|
7
|
+
/** Create or replace canonical catalog tables in a caller-owned DuckDB-WASM connection. */
|
|
8
|
+
export async function registerCatalog(
|
|
9
|
+
connection: AsyncDuckDBConnection,
|
|
10
|
+
catalog: Catalog,
|
|
11
|
+
options: RegisterCatalogOptions = {},
|
|
12
|
+
): Promise<AsyncDuckDBConnection> {
|
|
13
|
+
for (const { sql, rows } of registrationPlan(catalog, options, "file")) {
|
|
14
|
+
const file = `chartcoach-${crypto.randomUUID()}.json`;
|
|
15
|
+
// DuckDB-WASM copies string parameters onto its fixed-size Emscripten stack.
|
|
16
|
+
// File buffers carry catalog payloads through heap memory instead.
|
|
17
|
+
await connection.bindings.registerFileBuffer(file, new TextEncoder().encode(rows));
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const statement = await connection.prepare(sql);
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
await statement.query(file);
|
|
24
|
+
} finally {
|
|
25
|
+
await statement.close();
|
|
26
|
+
}
|
|
27
|
+
} finally {
|
|
28
|
+
await connection.bindings.dropFile(file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return connection;
|
|
33
|
+
}
|
package/src/duckdb.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DuckDBConnection } from "@duckdb/node-api";
|
|
2
|
+
import type { Catalog } from "./catalog/model";
|
|
3
|
+
import { registrationPlan, type RegisterCatalogOptions } from "./catalog/registration";
|
|
4
|
+
|
|
5
|
+
export type { RegisterCatalogOptions } from "./catalog/registration";
|
|
6
|
+
|
|
7
|
+
/** Create or replace canonical catalog tables in a caller-owned DuckDB connection. */
|
|
8
|
+
export async function registerCatalog(
|
|
9
|
+
connection: DuckDBConnection,
|
|
10
|
+
catalog: Catalog,
|
|
11
|
+
options: RegisterCatalogOptions = {},
|
|
12
|
+
): Promise<DuckDBConnection> {
|
|
13
|
+
for (const { sql, rows } of registrationPlan(catalog, options, "parameter")) {
|
|
14
|
+
await connection.run(sql, [rows]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return connection;
|
|
18
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,32 +1,63 @@
|
|
|
1
1
|
export {
|
|
2
2
|
Catalog,
|
|
3
|
-
type
|
|
3
|
+
type ArtifactOptions,
|
|
4
4
|
type Guideline,
|
|
5
|
+
type GuidelineInput,
|
|
5
6
|
type GuidelineSection,
|
|
6
7
|
} from "./catalog/model";
|
|
7
|
-
|
|
8
|
+
|
|
9
|
+
export { type CatalogInfo, type DescribeOptions, type ProfileInfo } from "./catalog/description";
|
|
10
|
+
|
|
11
|
+
export type { CatalogTables, TableColumnInfo, TableInfo, TableName } from "./catalog/tables";
|
|
12
|
+
|
|
13
|
+
export { CatalogError, type CatalogErrorCode, type CatalogErrorOptions } from "./catalog/errors";
|
|
14
|
+
|
|
8
15
|
export {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
16
|
+
parseCatalogManifest,
|
|
17
|
+
type CatalogManifest,
|
|
18
|
+
type ManifestDefinition,
|
|
19
|
+
} from "./catalog/manifest";
|
|
20
|
+
|
|
21
|
+
export type { JsonObject, JsonValue } from "./catalog/json";
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
type CatalogRelease,
|
|
25
|
+
parseCatalogRelease,
|
|
26
|
+
type ReleaseArtifact,
|
|
18
27
|
} from "./catalog/artifacts";
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
openCatalog,
|
|
31
|
+
type ArtifactCache,
|
|
32
|
+
type FetchLike,
|
|
33
|
+
type OpenCatalogOptions,
|
|
34
|
+
} from "./catalog/open";
|
|
35
|
+
|
|
19
36
|
export {
|
|
20
37
|
loadCatalog,
|
|
21
|
-
|
|
22
|
-
type
|
|
38
|
+
loadCatalogData,
|
|
39
|
+
type LoadCatalogDataInput,
|
|
23
40
|
type LoadCatalogInput,
|
|
24
|
-
|
|
25
|
-
|
|
41
|
+
} from "./catalog/parquet";
|
|
42
|
+
|
|
43
|
+
export { type QueryOptions, type EntryCandidate } from "./catalog/query";
|
|
44
|
+
|
|
45
|
+
export { type ReadOptions, type GuidelineEntryRecord, type SourceDetail } from "./catalog/read";
|
|
46
|
+
|
|
26
47
|
export {
|
|
27
|
-
type
|
|
28
|
-
type
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
type CitationRecord,
|
|
49
|
+
type CitationSource,
|
|
50
|
+
type CiteOptions,
|
|
51
|
+
type FullSourceRecord,
|
|
52
|
+
type MinimalSourceRecord,
|
|
53
|
+
} from "./catalog/references";
|
|
54
|
+
|
|
32
55
|
export { toMarkdown } from "./catalog/markdown";
|
|
56
|
+
|
|
57
|
+
export {
|
|
58
|
+
parseProfileMetadata,
|
|
59
|
+
type DistanceMetric,
|
|
60
|
+
type EmbeddingBinding,
|
|
61
|
+
type ProfileMetadata,
|
|
62
|
+
type ProjectionMetadata,
|
|
63
|
+
} from "./catalog/profile";
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
realpath,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
writeFile,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { Transform, Writable } from "node:stream";
|
|
16
|
+
import { pipeline } from "node:stream/promises";
|
|
17
|
+
import { createGunzip } from "node:zlib";
|
|
18
|
+
import { Parser, type ReadEntry } from "tar";
|
|
19
|
+
import { CatalogError } from "../catalog/errors";
|
|
20
|
+
import { isJsonObject, isJsonString, parseJson } from "../catalog/json";
|
|
21
|
+
|
|
22
|
+
type Extraction = {
|
|
23
|
+
archive: () => Promise<string>;
|
|
24
|
+
digest: string;
|
|
25
|
+
cacheDirectory: string;
|
|
26
|
+
directory?: string;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const pending = new Map<string, Promise<string>>();
|
|
31
|
+
|
|
32
|
+
export async function extractIndex(options: Extraction): Promise<string> {
|
|
33
|
+
if (options.directory !== undefined) {
|
|
34
|
+
const target = resolve(options.directory);
|
|
35
|
+
const archive = await options.archive();
|
|
36
|
+
await mkdir(dirname(target), { recursive: true });
|
|
37
|
+
|
|
38
|
+
return unpack(archive, target, options.signal);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (process.platform === "win32")
|
|
42
|
+
throw new CatalogError("Protected shared index extraction is unavailable on this platform.", {
|
|
43
|
+
code: "unavailable_capability",
|
|
44
|
+
hints: ["Pass a new caller-owned directory to indexPath(...)."],
|
|
45
|
+
});
|
|
46
|
+
await mkdir(options.cacheDirectory, { recursive: true });
|
|
47
|
+
const cache = await realpath(options.cacheDirectory);
|
|
48
|
+
const root = join(cache, "indexes-v2", options.digest);
|
|
49
|
+
const previous = pending.get(root) ?? Promise.resolve("");
|
|
50
|
+
let active = false;
|
|
51
|
+
|
|
52
|
+
const operation = (async () => {
|
|
53
|
+
await previous.catch(() => "");
|
|
54
|
+
active = true;
|
|
55
|
+
options.signal?.throwIfAborted();
|
|
56
|
+
|
|
57
|
+
return cachedIndex(root, options);
|
|
58
|
+
})();
|
|
59
|
+
|
|
60
|
+
pending.set(root, operation);
|
|
61
|
+
|
|
62
|
+
const settled = () => {
|
|
63
|
+
if (pending.get(root) === operation) pending.delete(root);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
void operation.then(settled, settled);
|
|
67
|
+
|
|
68
|
+
return wait(operation, () => active, options.signal);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function cachedIndex(root: string, options: Extraction): Promise<string> {
|
|
72
|
+
await safeDirectory(dirname(root));
|
|
73
|
+
await safeDirectory(root);
|
|
74
|
+
const generations = join(root, "generations");
|
|
75
|
+
await safeDirectory(generations);
|
|
76
|
+
const current = await currentGeneration(root, options.digest, options.signal);
|
|
77
|
+
|
|
78
|
+
if (current) return current;
|
|
79
|
+
const archive = await options.archive();
|
|
80
|
+
const generation = randomUUID().replaceAll("-", "");
|
|
81
|
+
const staged = join(root, `.${generation}.tmp`);
|
|
82
|
+
const published = join(generations, generation);
|
|
83
|
+
const pointer = join(root, `.current.${generation}.tmp`);
|
|
84
|
+
let committed = false;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await unpack(archive, staged, options.signal);
|
|
88
|
+
await writeFile(join(staged, ".complete"), options.digest, { flag: "wx" });
|
|
89
|
+
options.signal?.throwIfAborted();
|
|
90
|
+
await rename(staged, published);
|
|
91
|
+
await seal(published, options.signal);
|
|
92
|
+
await writeFile(pointer, JSON.stringify({ digest: options.digest, generation }), {
|
|
93
|
+
flag: "wx",
|
|
94
|
+
});
|
|
95
|
+
options.signal?.throwIfAborted();
|
|
96
|
+
await rename(pointer, join(root, "current.json"));
|
|
97
|
+
committed = true;
|
|
98
|
+
|
|
99
|
+
return published;
|
|
100
|
+
} finally {
|
|
101
|
+
await rm(pointer, { force: true });
|
|
102
|
+
await discard(staged);
|
|
103
|
+
|
|
104
|
+
if (!committed) await discard(published);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function currentGeneration(
|
|
109
|
+
root: string,
|
|
110
|
+
digest: string,
|
|
111
|
+
signal?: AbortSignal,
|
|
112
|
+
): Promise<string | undefined> {
|
|
113
|
+
try {
|
|
114
|
+
const pointer = join(root, "current.json");
|
|
115
|
+
const status = await lstat(pointer);
|
|
116
|
+
|
|
117
|
+
if (!status.isFile() || status.size > 1024) return undefined;
|
|
118
|
+
const value = parseJson(await readFile(pointer, "utf8"));
|
|
119
|
+
|
|
120
|
+
if (!isJsonObject(value)) return undefined;
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
value.digest !== digest ||
|
|
124
|
+
!isJsonString(value.generation) ||
|
|
125
|
+
!/^[a-f0-9]{32}$/.test(value.generation)
|
|
126
|
+
)
|
|
127
|
+
return undefined;
|
|
128
|
+
const target = join(root, "generations", value.generation);
|
|
129
|
+
|
|
130
|
+
if (!(await validTree(target, signal))) return undefined;
|
|
131
|
+
const marker = join(target, ".complete");
|
|
132
|
+
const markerStatus = await lstat(marker);
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
!markerStatus.isFile() ||
|
|
136
|
+
markerStatus.size !== digest.length ||
|
|
137
|
+
(await readFile(marker, "utf8")) !== digest
|
|
138
|
+
)
|
|
139
|
+
return undefined;
|
|
140
|
+
|
|
141
|
+
if (!(await lstat(join(target, "documents.lance"))).isDirectory()) return undefined;
|
|
142
|
+
|
|
143
|
+
return target;
|
|
144
|
+
} catch (error) {
|
|
145
|
+
signal?.throwIfAborted();
|
|
146
|
+
|
|
147
|
+
if (error instanceof SyntaxError || (error instanceof Error && isMissing(error)))
|
|
148
|
+
return undefined;
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function validTree(root: string, signal?: AbortSignal): Promise<boolean> {
|
|
154
|
+
let count = 0;
|
|
155
|
+
|
|
156
|
+
const visit = async (path: string): Promise<boolean> => {
|
|
157
|
+
signal?.throwIfAborted();
|
|
158
|
+
const status = await lstat(path);
|
|
159
|
+
|
|
160
|
+
if (++count > 100_002 || (status.mode & 0o222) !== 0) return false;
|
|
161
|
+
|
|
162
|
+
if (status.isFile()) return true;
|
|
163
|
+
|
|
164
|
+
if (!status.isDirectory()) return false;
|
|
165
|
+
|
|
166
|
+
for (const name of await readdir(path)) if (!(await visit(join(path, name)))) return false;
|
|
167
|
+
|
|
168
|
+
return true;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const status = await lstat(root);
|
|
172
|
+
|
|
173
|
+
return status.isDirectory() && visit(root);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function unpack(archive: string, target: string, signal?: AbortSignal): Promise<string> {
|
|
177
|
+
signal?.throwIfAborted();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await mkdir(target);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST")
|
|
183
|
+
throw new CatalogError("Index directory already exists. Pass a new directory.");
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const members = new Map<string, { path: string; directory: boolean; explicit: boolean }>();
|
|
188
|
+
let count = 0;
|
|
189
|
+
let total = 0;
|
|
190
|
+
let expanded = 0;
|
|
191
|
+
let ended = false;
|
|
192
|
+
const cancellation = new AbortController();
|
|
193
|
+
|
|
194
|
+
const extractionSignal = signal
|
|
195
|
+
? AbortSignal.any([signal, cancellation.signal])
|
|
196
|
+
: cancellation.signal;
|
|
197
|
+
|
|
198
|
+
const jobs: Promise<void>[] = [];
|
|
199
|
+
|
|
200
|
+
const extractor = new Parser({
|
|
201
|
+
strict: true,
|
|
202
|
+
maxMetaEntrySize: 1024 * 1024,
|
|
203
|
+
// The outer gzip stream owns decompression and its byte limit.
|
|
204
|
+
maxDecompressionRatio: 0,
|
|
205
|
+
brotli: false,
|
|
206
|
+
zstd: false,
|
|
207
|
+
onReadEntry(entry) {
|
|
208
|
+
const job = writeEntry(entry);
|
|
209
|
+
jobs.push(job);
|
|
210
|
+
void job.catch((error) => cancellation.abort(error));
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
async function writeEntry(entry: ReadEntry): Promise<void> {
|
|
215
|
+
extractionSignal.throwIfAborted();
|
|
216
|
+
const directory = entry.type === "Directory";
|
|
217
|
+
|
|
218
|
+
if (!directory && entry.type !== "File")
|
|
219
|
+
throw integrity("Index archive must contain regular files and directories.");
|
|
220
|
+
|
|
221
|
+
if (++count > 100_000) throw integrity("Index archive contains too many members.");
|
|
222
|
+
|
|
223
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > 2 * 1024 ** 3)
|
|
224
|
+
throw integrity("Index archive member exceeds the 2 GiB limit.");
|
|
225
|
+
total += entry.size;
|
|
226
|
+
|
|
227
|
+
if (total > 8 * 1024 ** 3) throw integrity("Index archive expands beyond the 8 GiB limit.");
|
|
228
|
+
const parts = portablePath(entry.path, directory);
|
|
229
|
+
|
|
230
|
+
for (let length = 1; length <= parts.length; length += 1) {
|
|
231
|
+
const name = parts.slice(0, length).join("/");
|
|
232
|
+
const key = name.normalize("NFKC").toUpperCase();
|
|
233
|
+
const previous = members.get(key);
|
|
234
|
+
const leaf = length === parts.length;
|
|
235
|
+
|
|
236
|
+
if (
|
|
237
|
+
previous &&
|
|
238
|
+
(previous.path !== name ||
|
|
239
|
+
!previous.directory ||
|
|
240
|
+
(leaf && (!directory || previous.explicit)))
|
|
241
|
+
)
|
|
242
|
+
throw integrity("Index archive members collide.");
|
|
243
|
+
|
|
244
|
+
if (!previous && members.size >= 100_000)
|
|
245
|
+
throw integrity("Index archive contains too many filesystem entries.");
|
|
246
|
+
members.set(key, {
|
|
247
|
+
path: name,
|
|
248
|
+
directory: !leaf || directory,
|
|
249
|
+
explicit: leaf || previous?.explicit === true,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const output = join(target, ...parts);
|
|
254
|
+
|
|
255
|
+
if (directory) {
|
|
256
|
+
await mkdir(output, { recursive: true, mode: 0o700 });
|
|
257
|
+
entry.resume();
|
|
258
|
+
} else {
|
|
259
|
+
await mkdir(dirname(output), { recursive: true, mode: 0o700 });
|
|
260
|
+
await pipeline(entry, createWriteStream(output, { flags: "wx", mode: 0o600 }), {
|
|
261
|
+
signal: extractionSignal,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const destination = new Writable({
|
|
267
|
+
write(chunk: Buffer, _encoding, callback) {
|
|
268
|
+
// Parser retains chunks after tar EOF. Drain gzip separately so its
|
|
269
|
+
// checksum and decompression limit still cover the complete artifact.
|
|
270
|
+
if (ended) {
|
|
271
|
+
callback();
|
|
272
|
+
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
if (extractor.write(chunk)) callback();
|
|
278
|
+
else extractor.once("drain", callback);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
final(callback) {
|
|
284
|
+
extractor.once("end", callback);
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
extractor.end();
|
|
288
|
+
} catch (error) {
|
|
289
|
+
callback(error instanceof Error ? error : new Error(String(error)));
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
extractor.on("eof", () => {
|
|
295
|
+
ended = true;
|
|
296
|
+
});
|
|
297
|
+
extractor.on("error", (error: Error) => destination.destroy(error));
|
|
298
|
+
extractor.on("ignoredEntry", () =>
|
|
299
|
+
destination.destroy(integrity("Index archive contains an unsupported or oversized member.")),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const limit = new Transform({
|
|
303
|
+
transform(chunk: Buffer, _encoding, callback) {
|
|
304
|
+
expanded += chunk.length;
|
|
305
|
+
callback(
|
|
306
|
+
expanded > 8 * 1024 ** 3 + 100_000 * 1024
|
|
307
|
+
? integrity("Index archive decompression exceeds the size limit.")
|
|
308
|
+
: null,
|
|
309
|
+
chunk,
|
|
310
|
+
);
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
await pipeline(createReadStream(archive), createGunzip(), limit, destination, {
|
|
316
|
+
signal: extractionSignal,
|
|
317
|
+
});
|
|
318
|
+
await Promise.all(jobs);
|
|
319
|
+
|
|
320
|
+
if (!(await lstat(join(target, "documents.lance"))).isDirectory())
|
|
321
|
+
throw integrity("Index archive must contain the documents LanceDB table.");
|
|
322
|
+
signal?.throwIfAborted();
|
|
323
|
+
|
|
324
|
+
return target;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
cancellation.abort(error);
|
|
327
|
+
await Promise.allSettled(jobs);
|
|
328
|
+
await discard(target);
|
|
329
|
+
signal?.throwIfAborted();
|
|
330
|
+
|
|
331
|
+
if (cancellation.signal.reason instanceof CatalogError) throw cancellation.signal.reason;
|
|
332
|
+
|
|
333
|
+
if (error instanceof CatalogError) throw error;
|
|
334
|
+
throw integrity("Index archive could not be extracted as a complete LanceDB database.");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function portablePath(path: string, directory: boolean): string[] {
|
|
339
|
+
const normalized = directory && path.endsWith("/") ? path.slice(0, -1) : path;
|
|
340
|
+
const parts = normalized.split("/");
|
|
341
|
+
|
|
342
|
+
if (
|
|
343
|
+
Buffer.byteLength(normalized) > 4096 ||
|
|
344
|
+
parts.length > 64 ||
|
|
345
|
+
parts.some(
|
|
346
|
+
(part) =>
|
|
347
|
+
!part ||
|
|
348
|
+
Buffer.byteLength(part) > 255 ||
|
|
349
|
+
part === "." ||
|
|
350
|
+
part === ".." ||
|
|
351
|
+
/[\p{Cc}\\:<>"|?*]/u.test(part) ||
|
|
352
|
+
/[. ]$/.test(part) ||
|
|
353
|
+
/^(?:CON|PRN|AUX|NUL|CONIN\$|CONOUT\$|COM[1-9]|LPT[1-9])(?:\.|$)/i.test(
|
|
354
|
+
part.normalize("NFKC"),
|
|
355
|
+
),
|
|
356
|
+
) ||
|
|
357
|
+
parts[0]?.normalize("NFKC").toUpperCase() === ".COMPLETE"
|
|
358
|
+
)
|
|
359
|
+
throw integrity("Index archive contains an unsafe path.");
|
|
360
|
+
|
|
361
|
+
return parts;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function safeDirectory(path: string): Promise<void> {
|
|
365
|
+
try {
|
|
366
|
+
await mkdir(path);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!(await lstat(path)).isDirectory())
|
|
372
|
+
throw integrity("Index cache path must be a directory, not a link.");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function seal(root: string, signal?: AbortSignal): Promise<void> {
|
|
376
|
+
signal?.throwIfAborted();
|
|
377
|
+
const status = await lstat(root);
|
|
378
|
+
|
|
379
|
+
if (status.isDirectory()) {
|
|
380
|
+
for (const name of await readdir(root)) await seal(join(root, name), signal);
|
|
381
|
+
await chmod(root, 0o555);
|
|
382
|
+
} else await chmod(root, 0o444);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function discard(root: string): Promise<void> {
|
|
386
|
+
try {
|
|
387
|
+
const status = await lstat(root);
|
|
388
|
+
|
|
389
|
+
if (status.isDirectory()) {
|
|
390
|
+
await chmod(root, 0o700);
|
|
391
|
+
|
|
392
|
+
for (const name of await readdir(root)) await discard(join(root, name));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
await rm(root, { recursive: true, force: true });
|
|
396
|
+
} catch (error) {
|
|
397
|
+
if (!(error instanceof Error && isMissing(error))) throw error;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function isMissing(error: Error): boolean {
|
|
402
|
+
return "code" in error && error.code === "ENOENT";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function integrity(message: string): CatalogError {
|
|
406
|
+
return new CatalogError(message, { code: "integrity" });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function wait<T>(
|
|
410
|
+
operation: Promise<T>,
|
|
411
|
+
active: () => boolean,
|
|
412
|
+
signal?: AbortSignal,
|
|
413
|
+
): Promise<T> {
|
|
414
|
+
if (!signal) return operation;
|
|
415
|
+
signal.throwIfAborted();
|
|
416
|
+
let abort = () => {};
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
return await Promise.race([
|
|
420
|
+
operation,
|
|
421
|
+
new Promise<never>((_resolve, reject) => {
|
|
422
|
+
abort = () => {
|
|
423
|
+
if (!active()) reject(signal.reason);
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
427
|
+
}),
|
|
428
|
+
]);
|
|
429
|
+
} finally {
|
|
430
|
+
signal.removeEventListener("abort", abort);
|
|
431
|
+
}
|
|
432
|
+
}
|