@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.
- 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 +47 -18
- 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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { CatalogError } from "./errors";
|
|
2
|
+
import { isJsonNumber, isJsonObject, isJsonString, type JsonObject, type JsonValue } from "./json";
|
|
3
|
+
|
|
4
|
+
export type DistanceMetric = "cosine" | "l2" | "dot";
|
|
5
|
+
|
|
6
|
+
export type EmbeddingBinding = Readonly<{
|
|
7
|
+
name: string;
|
|
8
|
+
model: Readonly<JsonObject>;
|
|
9
|
+
source_column: "text";
|
|
10
|
+
vector_column: "vector";
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
export type ProjectionMetadata = Readonly<{
|
|
14
|
+
algorithm: "empty" | "linear" | "umap";
|
|
15
|
+
options: Readonly<JsonObject>;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
export type ProfileMetadata = Readonly<{
|
|
19
|
+
schema_version: 1;
|
|
20
|
+
documents_version: 1;
|
|
21
|
+
entries_digest: string;
|
|
22
|
+
manifest_digest: string;
|
|
23
|
+
embedding_functions: readonly [EmbeddingBinding];
|
|
24
|
+
dimensions: number;
|
|
25
|
+
distance_metric: DistanceMetric;
|
|
26
|
+
python_requirements: Readonly<Record<string, string>>;
|
|
27
|
+
lancedb_version: string;
|
|
28
|
+
projection: ProjectionMetadata | null;
|
|
29
|
+
}>;
|
|
30
|
+
|
|
31
|
+
const profileFields = [
|
|
32
|
+
"schema_version",
|
|
33
|
+
"documents_version",
|
|
34
|
+
"entries_digest",
|
|
35
|
+
"manifest_digest",
|
|
36
|
+
"embedding_functions",
|
|
37
|
+
"dimensions",
|
|
38
|
+
"distance_metric",
|
|
39
|
+
"python_requirements",
|
|
40
|
+
"lancedb_version",
|
|
41
|
+
"projection",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
const bindingFields = ["name", "model", "source_column", "vector_column"] as const;
|
|
45
|
+
|
|
46
|
+
const projectionFields = ["algorithm", "options"] as const;
|
|
47
|
+
|
|
48
|
+
const sha256Pattern = /^[a-f0-9]{64}$/;
|
|
49
|
+
|
|
50
|
+
const distributionPattern = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
51
|
+
|
|
52
|
+
const versionPattern = /^[A-Za-z0-9][A-Za-z0-9.!+_-]*$/;
|
|
53
|
+
|
|
54
|
+
const variablePattern = /^\$var:[^:]+$/;
|
|
55
|
+
|
|
56
|
+
const sensitiveNamePattern =
|
|
57
|
+
/(?:^|[_-])(?:access[_-]?key|api[_-]?key|authorization|client[_-]?secret|cookie|credential|password|private[_-]?key|secret|signature|token)(?:$|[_-])/i;
|
|
58
|
+
|
|
59
|
+
const sensitiveQueryNames = new Set([
|
|
60
|
+
"access_key",
|
|
61
|
+
"api_key",
|
|
62
|
+
"apikey",
|
|
63
|
+
"authorization",
|
|
64
|
+
"credential",
|
|
65
|
+
"password",
|
|
66
|
+
"secret",
|
|
67
|
+
"signature",
|
|
68
|
+
"sig",
|
|
69
|
+
"token",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
export function parseProfileMetadata(value: JsonValue): ProfileMetadata {
|
|
73
|
+
const record = requireObject(value, "Profile metadata");
|
|
74
|
+
requireExactFields(record, profileFields, "Profile metadata");
|
|
75
|
+
|
|
76
|
+
if (record.schema_version !== 1) {
|
|
77
|
+
throw new CatalogError("Profile schema_version must be 1.");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (record.documents_version !== 1) {
|
|
81
|
+
throw new CatalogError("Profile documents_version must be 1.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const entriesDigest = requireSha256(record.entries_digest, "Profile entries_digest");
|
|
85
|
+
const manifestDigest = requireSha256(record.manifest_digest, "Profile manifest_digest");
|
|
86
|
+
|
|
87
|
+
if (!Array.isArray(record.embedding_functions) || record.embedding_functions.length !== 1) {
|
|
88
|
+
throw new CatalogError("Profile must contain exactly one embedding binding.");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const binding = parseBinding(record.embedding_functions[0]!);
|
|
92
|
+
const dimensions = record.dimensions;
|
|
93
|
+
|
|
94
|
+
if (!isJsonNumber(dimensions) || !Number.isSafeInteger(dimensions) || dimensions < 1) {
|
|
95
|
+
throw new CatalogError("Profile dimensions must be a positive safe integer.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const distanceMetric = record.distance_metric;
|
|
99
|
+
|
|
100
|
+
if (distanceMetric !== "cosine" && distanceMetric !== "l2" && distanceMetric !== "dot") {
|
|
101
|
+
throw new CatalogError("Profile distance_metric must be cosine, l2, or dot.");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const pythonRequirements = parseRequirements(record.python_requirements);
|
|
105
|
+
const lancedbVersion = requireVersion(record.lancedb_version, "Profile lancedb_version");
|
|
106
|
+
const projection = record.projection === null ? null : parseProjection(record.projection);
|
|
107
|
+
|
|
108
|
+
return Object.freeze({
|
|
109
|
+
schema_version: 1,
|
|
110
|
+
documents_version: 1,
|
|
111
|
+
entries_digest: entriesDigest,
|
|
112
|
+
manifest_digest: manifestDigest,
|
|
113
|
+
embedding_functions: Object.freeze([binding] as const),
|
|
114
|
+
dimensions,
|
|
115
|
+
distance_metric: distanceMetric,
|
|
116
|
+
python_requirements: pythonRequirements,
|
|
117
|
+
lancedb_version: lancedbVersion,
|
|
118
|
+
projection,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseBinding(value: JsonValue): EmbeddingBinding {
|
|
123
|
+
const record = requireObject(value, "Profile embedding binding");
|
|
124
|
+
requireExactFields(record, bindingFields, "Profile embedding binding");
|
|
125
|
+
const name = record.name;
|
|
126
|
+
|
|
127
|
+
if (!isJsonString(name) || !name || name !== name.trim()) {
|
|
128
|
+
throw new CatalogError("Profile embedding name must be a non-empty string.");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (record.source_column !== "text" || record.vector_column !== "vector") {
|
|
132
|
+
throw new CatalogError("Profile embedding binding must map text to vector.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const model = requireObject(record.model, "Profile embedding model");
|
|
136
|
+
validateModel(model);
|
|
137
|
+
|
|
138
|
+
return Object.freeze({
|
|
139
|
+
name,
|
|
140
|
+
model: freezeJsonObject(model),
|
|
141
|
+
source_column: "text",
|
|
142
|
+
vector_column: "vector",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function parseProjection(value: JsonValue): ProjectionMetadata {
|
|
147
|
+
const record = requireObject(value, "Profile projection");
|
|
148
|
+
requireExactFields(record, projectionFields, "Profile projection");
|
|
149
|
+
const algorithm = record.algorithm;
|
|
150
|
+
|
|
151
|
+
if (algorithm !== "empty" && algorithm !== "linear" && algorithm !== "umap") {
|
|
152
|
+
throw new CatalogError("Profile projection algorithm is unsupported.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return Object.freeze({
|
|
156
|
+
algorithm,
|
|
157
|
+
options: freezeJsonObject(requireObject(record.options, "Profile projection options")),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function parseRequirements(value: JsonValue | undefined): Readonly<Record<string, string>> {
|
|
162
|
+
const record = requireObject(value, "Profile Python requirements");
|
|
163
|
+
const requirements: Record<string, string> = Object.create(null);
|
|
164
|
+
|
|
165
|
+
for (const [name, requirement] of Object.entries(record)) {
|
|
166
|
+
if (
|
|
167
|
+
!distributionPattern.test(name) ||
|
|
168
|
+
!isJsonString(requirement) ||
|
|
169
|
+
!versionPattern.test(requirement) ||
|
|
170
|
+
!/[0-9]/.test(requirement)
|
|
171
|
+
) {
|
|
172
|
+
throw new CatalogError(
|
|
173
|
+
"Profile python_requirements must map distribution names to exact versions.",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
requirements[name] = requirement;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return Object.freeze(requirements);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateModel(model: JsonObject): void {
|
|
184
|
+
for (const [key, value] of Object.entries(model)) {
|
|
185
|
+
const variables = variableValues(value);
|
|
186
|
+
|
|
187
|
+
if (
|
|
188
|
+
variables.some((variable) => !variablePattern.test(variable)) ||
|
|
189
|
+
(variables.length > 0 && !isJsonString(value))
|
|
190
|
+
) {
|
|
191
|
+
throw new CatalogError(
|
|
192
|
+
`Profile embedding setting ${JSON.stringify(key)} has an invalid variable.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (
|
|
197
|
+
sensitiveNamePattern.test(key) &&
|
|
198
|
+
value !== null &&
|
|
199
|
+
(!isJsonString(value) || !variablePattern.test(value))
|
|
200
|
+
) {
|
|
201
|
+
throw new CatalogError(
|
|
202
|
+
`Profile embedding setting ${JSON.stringify(key)} must use a registry variable.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (
|
|
207
|
+
key === "default_headers" &&
|
|
208
|
+
value !== null &&
|
|
209
|
+
(!isJsonObject(value) || Object.keys(value).length > 0)
|
|
210
|
+
) {
|
|
211
|
+
throw new CatalogError("Profile embedding default_headers must be empty.");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (containsSensitiveKey(value)) {
|
|
215
|
+
throw new CatalogError(
|
|
216
|
+
`Profile embedding setting ${JSON.stringify(key)} contains authentication data.`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
rejectCredentialUrls(value, key);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (Object.hasOwn(model, "trust_remote_code") && model.trust_remote_code !== false) {
|
|
224
|
+
throw new CatalogError("Profile embedding must disable remote model code.");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function containsSensitiveKey(value: JsonValue): boolean {
|
|
229
|
+
if (Array.isArray(value)) return value.some(containsSensitiveKey);
|
|
230
|
+
|
|
231
|
+
if (!isJsonObject(value)) return false;
|
|
232
|
+
|
|
233
|
+
return Object.entries(value).some(
|
|
234
|
+
([key, item]) => sensitiveNamePattern.test(key) || containsSensitiveKey(item),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function rejectCredentialUrls(value: JsonValue, path: string): void {
|
|
239
|
+
if (Array.isArray(value)) {
|
|
240
|
+
value.forEach((item, index) => rejectCredentialUrls(item, `${path}[${index}]`));
|
|
241
|
+
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (isJsonObject(value)) {
|
|
246
|
+
for (const [key, item] of Object.entries(value)) {
|
|
247
|
+
rejectCredentialUrls(item, `${path}.${key}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!isJsonString(value)) return;
|
|
254
|
+
let parsed: URL;
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
parsed = new URL(value);
|
|
258
|
+
} catch {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
|
|
263
|
+
|
|
264
|
+
if (parsed.username || parsed.password) {
|
|
265
|
+
throw new CatalogError(
|
|
266
|
+
`Profile embedding URL setting ${JSON.stringify(path)} contains credentials.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (
|
|
271
|
+
Array.from(parsed.searchParams.keys()).some(
|
|
272
|
+
(name) => sensitiveQueryNames.has(name.toLowerCase()) || sensitiveNamePattern.test(name),
|
|
273
|
+
)
|
|
274
|
+
) {
|
|
275
|
+
throw new CatalogError(
|
|
276
|
+
`Profile embedding URL setting ${JSON.stringify(path)} contains authentication parameters.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function variableValues(value: JsonValue): string[] {
|
|
282
|
+
if (isJsonString(value)) return value.startsWith("$var:") ? [value] : [];
|
|
283
|
+
|
|
284
|
+
if (Array.isArray(value)) return value.flatMap(variableValues);
|
|
285
|
+
|
|
286
|
+
if (!isJsonObject(value)) return [];
|
|
287
|
+
|
|
288
|
+
return Object.values(value).flatMap(variableValues);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function freezeJsonObject(value: JsonObject): Readonly<JsonObject> {
|
|
292
|
+
const result: JsonObject = Object.create(null);
|
|
293
|
+
|
|
294
|
+
for (const [key, item] of Object.entries(value)) {
|
|
295
|
+
result[key] = freezeJson(item);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return Object.freeze(result);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function freezeJson(value: JsonValue): JsonValue {
|
|
302
|
+
if (Object.prototype.toString.call(value) === "[object Number]" && !isJsonNumber(value)) {
|
|
303
|
+
throw new CatalogError("Profile metadata must contain finite JSON numbers.");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (Array.isArray(value)) return Object.freeze(value.map(freezeJson));
|
|
307
|
+
|
|
308
|
+
if (isJsonObject(value)) return freezeJsonObject(value);
|
|
309
|
+
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function requireObject(value: JsonValue | undefined, label: string): JsonObject {
|
|
314
|
+
if (!isJsonObject(value)) throw new CatalogError(`${label} must be a JSON object.`);
|
|
315
|
+
|
|
316
|
+
return value;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function requireExactFields(value: JsonObject, fields: readonly string[], label: string): void {
|
|
320
|
+
const actual = Object.keys(value);
|
|
321
|
+
const missing = fields.filter((field) => !Object.hasOwn(value, field));
|
|
322
|
+
const unknown = actual.filter((field) => !fields.includes(field));
|
|
323
|
+
|
|
324
|
+
if (missing.length > 0)
|
|
325
|
+
throw new CatalogError(`${label} is missing fields: ${missing.join(", ")}.`);
|
|
326
|
+
|
|
327
|
+
if (unknown.length > 0)
|
|
328
|
+
throw new CatalogError(`${label} has unsupported fields: ${unknown.join(", ")}.`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function requireSha256(value: JsonValue | undefined, label: string): string {
|
|
332
|
+
if (!isJsonString(value) || !sha256Pattern.test(value)) {
|
|
333
|
+
throw new CatalogError(`${label} must be a lowercase SHA-256 digest.`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return value;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function requireVersion(value: JsonValue | undefined, label: string): string {
|
|
340
|
+
if (!isJsonString(value) || !versionPattern.test(value) || !/[0-9]/.test(value)) {
|
|
341
|
+
throw new CatalogError(`${label} must be a non-empty exact version.`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { CatalogError } from "./errors";
|
|
2
|
+
import { isJsonString } from "./json";
|
|
3
|
+
import type { Catalog, Guideline } from "./model";
|
|
4
|
+
|
|
5
|
+
export type QueryOptions = Readonly<{
|
|
6
|
+
ids?: readonly string[];
|
|
7
|
+
labels?: readonly string[];
|
|
8
|
+
labelPrefixes?: readonly string[];
|
|
9
|
+
contains?: string;
|
|
10
|
+
limit?: number;
|
|
11
|
+
}>;
|
|
12
|
+
|
|
13
|
+
export type EntryCandidate = Readonly<{
|
|
14
|
+
id: string;
|
|
15
|
+
title: string;
|
|
16
|
+
description: string;
|
|
17
|
+
labels: readonly string[];
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
export function queryCatalog(
|
|
21
|
+
catalog: Catalog,
|
|
22
|
+
options: QueryOptions = {},
|
|
23
|
+
): readonly EntryCandidate[] {
|
|
24
|
+
if (Object.prototype.toString.call(options) !== "[object Object]") {
|
|
25
|
+
throw new CatalogError("Query options must be an object.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ids = stringArray(options.ids, "ids");
|
|
29
|
+
const labels = stringArray(options.labels, "labels");
|
|
30
|
+
const labelPrefixes = stringArray(options.labelPrefixes, "labelPrefixes");
|
|
31
|
+
const limit = options.limit ?? 50;
|
|
32
|
+
|
|
33
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
34
|
+
throw new CatalogError("Query limit must be at least 1.");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
validateLabels(catalog, labels);
|
|
38
|
+
validateLabelPrefixes(catalog, labelPrefixes);
|
|
39
|
+
const selected = ids.length > 0 ? ids.map((id) => catalog.require(id)) : [...catalog.guidelines];
|
|
40
|
+
let contains: string | undefined;
|
|
41
|
+
|
|
42
|
+
if (options.contains !== undefined) {
|
|
43
|
+
if (!isJsonString(options.contains)) {
|
|
44
|
+
throw new CatalogError("Query contains must be a string.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (options.contains.length > 0) {
|
|
48
|
+
contains = normalizeSearchText(options.contains);
|
|
49
|
+
|
|
50
|
+
if (!contains) {
|
|
51
|
+
throw new CatalogError(
|
|
52
|
+
"contains must not consist only of whitespace, hyphens, or underscores.",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return Object.freeze(
|
|
59
|
+
selected
|
|
60
|
+
.filter((guideline) => labels.every((label) => guideline.labels.includes(label)))
|
|
61
|
+
.filter((guideline) =>
|
|
62
|
+
labelPrefixes.every((prefix) => guideline.labels.some((label) => label.startsWith(prefix))),
|
|
63
|
+
)
|
|
64
|
+
.filter((guideline) => contains === undefined || matchesText(guideline, contains))
|
|
65
|
+
.slice(0, limit)
|
|
66
|
+
.map((guideline) =>
|
|
67
|
+
Object.freeze({
|
|
68
|
+
id: guideline.id,
|
|
69
|
+
title: guideline.title,
|
|
70
|
+
description: guideline.description,
|
|
71
|
+
labels: Object.freeze([...guideline.labels]),
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function validateLabels(catalog: Catalog, labels: readonly string[]): void {
|
|
78
|
+
const available = new Set(catalog.labels());
|
|
79
|
+
const missing = Array.from(new Set(labels.filter((label) => !available.has(label)))).sort();
|
|
80
|
+
|
|
81
|
+
if (missing.length === 0) return;
|
|
82
|
+
throw new CatalogError(`Unknown label(s): ${missing.join(", ")}`, {
|
|
83
|
+
code: "lookup",
|
|
84
|
+
details: { labels: missing },
|
|
85
|
+
hints: ["Call `await catalog.describe()` to inspect label families."],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function validateLabelPrefixes(catalog: Catalog, prefixes: readonly string[]): void {
|
|
90
|
+
const available = catalog.labels();
|
|
91
|
+
|
|
92
|
+
const missing = Array.from(
|
|
93
|
+
new Set(prefixes.filter((prefix) => !available.some((label) => label.startsWith(prefix)))),
|
|
94
|
+
).sort();
|
|
95
|
+
|
|
96
|
+
if (missing.length === 0) return;
|
|
97
|
+
throw new CatalogError(`No labels match prefix(es): ${missing.join(", ")}`, {
|
|
98
|
+
code: "lookup",
|
|
99
|
+
details: { label_prefixes: missing },
|
|
100
|
+
hints: ["Call `await catalog.describe()` to inspect label families."],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stringArray(value: readonly string[] | undefined, name: string): readonly string[] {
|
|
105
|
+
if (value === undefined) return Object.freeze([]);
|
|
106
|
+
|
|
107
|
+
if (!Array.isArray(value) || !value.every(isJsonString)) {
|
|
108
|
+
throw new CatalogError(`${name} must be an array of strings.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function matchesText(guideline: Guideline, contains: string): boolean {
|
|
115
|
+
return [guideline.id, guideline.title, guideline.description].some((value) =>
|
|
116
|
+
normalizeSearchText(value).includes(contains),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeSearchText(value: string): string {
|
|
121
|
+
return value
|
|
122
|
+
.toLowerCase()
|
|
123
|
+
.replace(/[-_\s]+/g, " ")
|
|
124
|
+
.trim();
|
|
125
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CatalogError } from "./errors";
|
|
2
|
+
import { isJsonString } from "./json";
|
|
3
|
+
import type { Catalog, GuidelineSection } from "./model";
|
|
4
|
+
import { sourceRecords, type FullSourceRecord, type MinimalSourceRecord } from "./references";
|
|
5
|
+
|
|
6
|
+
export type SourceDetail = "none" | "minimal" | "full";
|
|
7
|
+
|
|
8
|
+
export type ReadOptions = Readonly<{
|
|
9
|
+
ids: readonly string[];
|
|
10
|
+
roles?: readonly string[];
|
|
11
|
+
sourceDetail?: SourceDetail;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
export type GuidelineEntryRecord = Readonly<{
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description: string;
|
|
18
|
+
labels: readonly string[];
|
|
19
|
+
sections: readonly GuidelineSection[];
|
|
20
|
+
sources: readonly (MinimalSourceRecord | FullSourceRecord)[];
|
|
21
|
+
references?: readonly string[];
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
export function readCatalog(
|
|
25
|
+
catalog: Catalog,
|
|
26
|
+
options: ReadOptions,
|
|
27
|
+
): readonly GuidelineEntryRecord[] {
|
|
28
|
+
if (Object.prototype.toString.call(options) !== "[object Object]") {
|
|
29
|
+
throw new CatalogError("Read options must be an object.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!Array.isArray(options.ids) || !options.ids.every(isJsonString)) {
|
|
33
|
+
throw new CatalogError("ids must be an array of strings.");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const roles = options.roles ?? [];
|
|
37
|
+
|
|
38
|
+
if (!Array.isArray(roles) || !roles.every(isJsonString)) {
|
|
39
|
+
throw new CatalogError("roles must be an array of strings.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sourceDetail = options.sourceDetail ?? "minimal";
|
|
43
|
+
|
|
44
|
+
if (sourceDetail !== "none" && sourceDetail !== "minimal" && sourceDetail !== "full") {
|
|
45
|
+
throw new CatalogError(`Unknown source detail: ${JSON.stringify(sourceDetail)}.`, {
|
|
46
|
+
hints: ["Choose one of: none, minimal, full."],
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const missingRoles = Array.from(
|
|
51
|
+
new Set(roles.filter((role) => !Object.hasOwn(catalog.manifest.sectionRoles, role))),
|
|
52
|
+
).sort();
|
|
53
|
+
|
|
54
|
+
if (missingRoles.length > 0) {
|
|
55
|
+
throw new CatalogError(`Unknown section role(s): ${missingRoles.join(", ")}`, {
|
|
56
|
+
code: "lookup",
|
|
57
|
+
details: { roles: missingRoles },
|
|
58
|
+
hints: ["Call `await catalog.describe()` to inspect section roles."],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (options.ids.length === 0) return Object.freeze([]);
|
|
63
|
+
|
|
64
|
+
const roleSet = roles.length > 0 ? new Set(roles) : undefined;
|
|
65
|
+
|
|
66
|
+
return Object.freeze(
|
|
67
|
+
options.ids.map((id) => {
|
|
68
|
+
const guideline = catalog.require(id);
|
|
69
|
+
|
|
70
|
+
const sections = Object.freeze(
|
|
71
|
+
guideline.sections
|
|
72
|
+
.filter((section) => roleSet === undefined || roleSet.has(section.role))
|
|
73
|
+
.map((section) =>
|
|
74
|
+
Object.freeze({
|
|
75
|
+
role: section.role,
|
|
76
|
+
title: section.title,
|
|
77
|
+
content: section.content,
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const record = {
|
|
83
|
+
id: guideline.id,
|
|
84
|
+
title: guideline.title,
|
|
85
|
+
description: guideline.description,
|
|
86
|
+
labels: Object.freeze([...guideline.labels]),
|
|
87
|
+
sections,
|
|
88
|
+
sources:
|
|
89
|
+
sourceDetail === "none"
|
|
90
|
+
? Object.freeze([])
|
|
91
|
+
: sourceRecords(catalog, guideline.id, sourceDetail),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return Object.freeze(
|
|
95
|
+
sourceDetail === "full"
|
|
96
|
+
? { ...record, references: Object.freeze([...guideline.references]) }
|
|
97
|
+
: record,
|
|
98
|
+
);
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
}
|