@zenera/rag 1.1.3 → 1.1.5
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 +68 -9
- package/dist/command.js +290 -32
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/schema/build.d.ts +2 -0
- package/dist/schema/build.js +62 -34
- package/dist/schema/entities.d.ts +2 -0
- package/dist/schema/entities.js +2 -1
- package/dist/schema/files.d.ts +17 -2
- package/dist/schema/files.js +36 -7
- package/dist/schema/lookup.d.ts +44 -0
- package/dist/schema/lookup.js +83 -0
- package/dist/schema/match.d.ts +36 -0
- package/dist/schema/match.js +85 -0
- package/dist/schema/progress.d.ts +26 -0
- package/dist/schema/progress.js +316 -0
- package/dist/schema/spec.d.ts +5 -0
- package/dist/schema/spec.js +34 -10
- package/dist/schema/subgraph.d.ts +6 -0
- package/dist/schema/subgraph.js +27 -0
- package/dist/schema/tools.js +140 -28
- package/package.json +3 -3
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// A build that says what it is doing
|
|
7
|
+
//
|
|
8
|
+
// Indexing a large document is minutes of silence with a directory slowly
|
|
9
|
+
// filling up, and the directory is the only thing a second person — or a second
|
|
10
|
+
// process, or an agent reading the tree — ever sees. So the build writes two
|
|
11
|
+
// files into it and keeps them true:
|
|
12
|
+
//
|
|
13
|
+
// .lock who is building this, right now. Gone when nothing is.
|
|
14
|
+
// README.md a progress report while it runs, and a description of what the
|
|
15
|
+
// index holds once it does not.
|
|
16
|
+
//
|
|
17
|
+
// Nothing in either file is an absolute path. A project's `assets/` directory
|
|
18
|
+
// is mounted at `/assets` inside an agent's sandbox, so an index built there is
|
|
19
|
+
// read under a name this process never sees; a host path would be a lie there.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const LOCK_FILE = '.lock';
|
|
22
|
+
export const README_FILE = 'README.md';
|
|
23
|
+
/** The floor on how often README.md is rewritten. */
|
|
24
|
+
const INTERVAL_MS = 5000;
|
|
25
|
+
const PHASES = {
|
|
26
|
+
reading: 'reading the documents',
|
|
27
|
+
graph: 'building the graph',
|
|
28
|
+
embedding: 'embedding',
|
|
29
|
+
writing: 'writing the store',
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Takes the directory, or refuses it. Two builds writing one index would
|
|
33
|
+
* interleave their LanceDB writes and leave a store neither of them describes.
|
|
34
|
+
*/
|
|
35
|
+
export function beginBuild(plan) {
|
|
36
|
+
const documents = plan.files.map((file) => basename(file));
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
const lock = {
|
|
39
|
+
pid: process.pid,
|
|
40
|
+
host: hostname(),
|
|
41
|
+
startedAt: new Date(started).toISOString(),
|
|
42
|
+
indexer: plan.indexer,
|
|
43
|
+
embedding: plan.embedding,
|
|
44
|
+
documents,
|
|
45
|
+
};
|
|
46
|
+
mkdirSync(plan.dir, { recursive: true });
|
|
47
|
+
claim(join(plan.dir, LOCK_FILE), lock);
|
|
48
|
+
let phase = 'reading';
|
|
49
|
+
let counts;
|
|
50
|
+
let done = 0;
|
|
51
|
+
let total = 0;
|
|
52
|
+
let wroteAt = 0;
|
|
53
|
+
let closed = false;
|
|
54
|
+
const write = (body) => {
|
|
55
|
+
// Through a temp name, so a reader never catches half a file.
|
|
56
|
+
const target = join(plan.dir, README_FILE);
|
|
57
|
+
const temp = `${target}.tmp`;
|
|
58
|
+
writeFileSync(temp, body);
|
|
59
|
+
renameSync(temp, target);
|
|
60
|
+
wroteAt = Date.now();
|
|
61
|
+
};
|
|
62
|
+
const report = () => write(building({
|
|
63
|
+
documents,
|
|
64
|
+
embedding: plan.embedding,
|
|
65
|
+
started,
|
|
66
|
+
phase,
|
|
67
|
+
done,
|
|
68
|
+
total,
|
|
69
|
+
counts,
|
|
70
|
+
}));
|
|
71
|
+
const maybe = () => {
|
|
72
|
+
if (!closed && Date.now() - wroteAt >= INTERVAL_MS) {
|
|
73
|
+
report();
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
// The first embedding request can block for ten seconds or more, so a purely
|
|
77
|
+
// event-driven throttle would leave the elapsed line frozen through it.
|
|
78
|
+
const ticker = setInterval(maybe, INTERVAL_MS);
|
|
79
|
+
ticker.unref();
|
|
80
|
+
const close = (body) => {
|
|
81
|
+
if (closed) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
closed = true;
|
|
85
|
+
clearInterval(ticker);
|
|
86
|
+
write(body);
|
|
87
|
+
rmSync(join(plan.dir, LOCK_FILE), { force: true });
|
|
88
|
+
};
|
|
89
|
+
report();
|
|
90
|
+
return {
|
|
91
|
+
phase(name) {
|
|
92
|
+
phase = name;
|
|
93
|
+
maybe();
|
|
94
|
+
},
|
|
95
|
+
read(seen) {
|
|
96
|
+
counts = seen;
|
|
97
|
+
total = seen.entities;
|
|
98
|
+
maybe();
|
|
99
|
+
},
|
|
100
|
+
progress(at, of) {
|
|
101
|
+
done = at;
|
|
102
|
+
total = of;
|
|
103
|
+
maybe();
|
|
104
|
+
},
|
|
105
|
+
finish(manifest) {
|
|
106
|
+
close(complete(plan.dir, manifest, Date.now() - started));
|
|
107
|
+
},
|
|
108
|
+
fail(reason) {
|
|
109
|
+
close(failed({ documents, phase, reason, started }));
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* `wx` makes the create and the check one operation, so two builds racing for
|
|
115
|
+
* the same directory cannot both win. A lock whose process is gone is stale by
|
|
116
|
+
* definition and is taken rather than respected — a crashed build must not make
|
|
117
|
+
* a directory permanently unbuildable.
|
|
118
|
+
*/
|
|
119
|
+
function claim(path, lock) {
|
|
120
|
+
const body = `${JSON.stringify(lock, null, 4)}\n`;
|
|
121
|
+
try {
|
|
122
|
+
writeFileSync(path, body, { flag: 'wx' });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (err.code !== 'EEXIST') {
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const held = readLock(path);
|
|
131
|
+
if (held && held.host === hostname() && alive(held.pid)) {
|
|
132
|
+
throw new CliError(`this index is already being built (pid ${held.pid}, since ${held.startedAt})`, EXIT.failed, `wait for it, or build elsewhere with --out`);
|
|
133
|
+
}
|
|
134
|
+
writeFileSync(path, body);
|
|
135
|
+
}
|
|
136
|
+
function readLock(path) {
|
|
137
|
+
try {
|
|
138
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* `kill(pid, 0)` sends no signal and only asks whether the process exists.
|
|
146
|
+
* EPERM means it exists and belongs to someone else, which still counts.
|
|
147
|
+
*/
|
|
148
|
+
function alive(pid) {
|
|
149
|
+
try {
|
|
150
|
+
process.kill(pid, 0);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
return err.code === 'EPERM';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function building(state) {
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
const rows = [
|
|
160
|
+
['documents', state.documents.join(', ')],
|
|
161
|
+
['embedding', state.embedding],
|
|
162
|
+
[
|
|
163
|
+
'started',
|
|
164
|
+
`${new Date(state.started).toISOString()} (${duration(now - state.started)} ago)`,
|
|
165
|
+
],
|
|
166
|
+
['step', PHASES[state.phase]],
|
|
167
|
+
];
|
|
168
|
+
if (state.counts) {
|
|
169
|
+
rows.push(['found', entities(state.counts)]);
|
|
170
|
+
}
|
|
171
|
+
if (state.total > 0) {
|
|
172
|
+
const percent = Math.round((state.done / state.total) * 100);
|
|
173
|
+
rows.push(['embedded', `${state.done} of ${state.total} · ${percent}%`]);
|
|
174
|
+
}
|
|
175
|
+
rows.push(['updated', new Date(now).toISOString()]);
|
|
176
|
+
return [
|
|
177
|
+
'# Schema index — being built',
|
|
178
|
+
'',
|
|
179
|
+
'A searchable index of the API documents named below, written by `zen rag schema index`.',
|
|
180
|
+
'**It is incomplete. Nothing should read it yet.**',
|
|
181
|
+
'',
|
|
182
|
+
...fields(rows),
|
|
183
|
+
'',
|
|
184
|
+
`These lines are refreshed at most every ${INTERVAL_MS / 1000} seconds while the build runs, and`,
|
|
185
|
+
'the whole file is replaced by a description of the index when it finishes. If it still says',
|
|
186
|
+
'"being built" and `.lock` names no living process, the build died part way.',
|
|
187
|
+
'',
|
|
188
|
+
].join('\n');
|
|
189
|
+
}
|
|
190
|
+
function complete(dir, manifest, ms) {
|
|
191
|
+
const titles = manifest.sources.map((s) => s.title).filter(Boolean);
|
|
192
|
+
const what = titles.length > 0 ? titles.join(', ') : basename(dir);
|
|
193
|
+
return [
|
|
194
|
+
`# Schema index — ${what}`,
|
|
195
|
+
'',
|
|
196
|
+
`A searchable index of ${plural(manifest.sources.length, 'API document')}, built with`,
|
|
197
|
+
`${manifest.embedding.ref} (${manifest.embedding.dimensions}d) in ${duration(ms)}.`,
|
|
198
|
+
'Ask it for the operations and types behind a question and it answers with a subgraph:',
|
|
199
|
+
'the endpoints that match, the schemas they carry, and the fields inside those — printed',
|
|
200
|
+
'as text, Mermaid, TypeScript or OpenAPI.',
|
|
201
|
+
'',
|
|
202
|
+
'## What it covers',
|
|
203
|
+
'',
|
|
204
|
+
...sourceTable(manifest.sources),
|
|
205
|
+
'',
|
|
206
|
+
`${entities(manifest.counts)},`,
|
|
207
|
+
`${searched(manifest.indexes)}.`,
|
|
208
|
+
'',
|
|
209
|
+
'## Files',
|
|
210
|
+
'',
|
|
211
|
+
...fields([
|
|
212
|
+
['manifest.json', 'what this index is and what built it — read this first'],
|
|
213
|
+
['graph.json', 'the nodes and edges: operations, types, fields'],
|
|
214
|
+
['schemas.json', 'the JSON Schema of every type'],
|
|
215
|
+
['operations.json', 'every operation, with its parameters and responses'],
|
|
216
|
+
...(manifest.sources.some((s) => s.path)
|
|
217
|
+
? [['sources/', 'the documents themselves, bundled, exactly as indexed']]
|
|
218
|
+
: []),
|
|
219
|
+
['lance/', 'the LanceDB table: the search text, the vectors, the filter columns'],
|
|
220
|
+
]),
|
|
221
|
+
'',
|
|
222
|
+
'## Asking it something',
|
|
223
|
+
'',
|
|
224
|
+
'From this directory:',
|
|
225
|
+
'',
|
|
226
|
+
'```',
|
|
227
|
+
'zen rag schema search --dir . --all "how do I cancel a subscription"',
|
|
228
|
+
'```',
|
|
229
|
+
'',
|
|
230
|
+
`Built by ${manifest.indexer} on ${manifest.createdAt}.`,
|
|
231
|
+
'',
|
|
232
|
+
].join('\n');
|
|
233
|
+
}
|
|
234
|
+
function failed(state) {
|
|
235
|
+
return [
|
|
236
|
+
'# Schema index — failed',
|
|
237
|
+
'',
|
|
238
|
+
'This index was not finished and what is here is incomplete. Nothing should read it;',
|
|
239
|
+
'build it again with `zen rag schema index`.',
|
|
240
|
+
'',
|
|
241
|
+
...fields([
|
|
242
|
+
['documents', state.documents.join(', ')],
|
|
243
|
+
['step', PHASES[state.phase]],
|
|
244
|
+
['reason', message(state.reason)],
|
|
245
|
+
['started', new Date(state.started).toISOString()],
|
|
246
|
+
[
|
|
247
|
+
'failed',
|
|
248
|
+
`${new Date().toISOString()} (after ${duration(Date.now() - state.started)})`,
|
|
249
|
+
],
|
|
250
|
+
]),
|
|
251
|
+
'',
|
|
252
|
+
].join('\n');
|
|
253
|
+
}
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Small renderings
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
/** An indented block, which markdown renders verbatim and an agent reads as a table. */
|
|
258
|
+
function fields(rows) {
|
|
259
|
+
const width = Math.max(...rows.map((r) => (r[0] ?? '').length));
|
|
260
|
+
return rows.map(([name, value]) => ` ${(name ?? '').padEnd(width)} ${value ?? ''}`);
|
|
261
|
+
}
|
|
262
|
+
const HEADERS = ['document', 'dialect', 'paths', 'operations', 'schemas', 'fields'];
|
|
263
|
+
function sourceTable(sources) {
|
|
264
|
+
const rows = sources.map((s) => [
|
|
265
|
+
s.file,
|
|
266
|
+
s.dialect,
|
|
267
|
+
String(s.paths),
|
|
268
|
+
String(s.methods),
|
|
269
|
+
String(s.types),
|
|
270
|
+
String(s.properties),
|
|
271
|
+
]);
|
|
272
|
+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
273
|
+
const line = (cells) => `| ${cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' | ')} |`;
|
|
274
|
+
return [
|
|
275
|
+
line(HEADERS),
|
|
276
|
+
`| ${widths.map((w) => '-'.repeat(w)).join(' | ')} |`,
|
|
277
|
+
...rows.map(line),
|
|
278
|
+
];
|
|
279
|
+
}
|
|
280
|
+
function entities(counts) {
|
|
281
|
+
return (`${plural(counts.entities, 'entity', 'entities')}: ` +
|
|
282
|
+
`${counts.methods} operations, ${counts.types} schemas, ${counts.properties} fields`);
|
|
283
|
+
}
|
|
284
|
+
function searched(indexes) {
|
|
285
|
+
if (!indexes.fts && !indexes.vector) {
|
|
286
|
+
return 'scanned flat: neither index was built';
|
|
287
|
+
}
|
|
288
|
+
if (!indexes.vector) {
|
|
289
|
+
// Below a couple of thousand rows an IVF index has nothing to train on.
|
|
290
|
+
return 'searched by full text and by vector, the latter as a flat scan';
|
|
291
|
+
}
|
|
292
|
+
return indexes.fts
|
|
293
|
+
? 'searched by full text and by vector'
|
|
294
|
+
: 'searched by vector, with no full-text index';
|
|
295
|
+
}
|
|
296
|
+
function plural(n, one, many = `${one}s`) {
|
|
297
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
298
|
+
}
|
|
299
|
+
function duration(ms) {
|
|
300
|
+
const seconds = Math.round(ms / 1000);
|
|
301
|
+
if (seconds < 1) {
|
|
302
|
+
return 'under a second';
|
|
303
|
+
}
|
|
304
|
+
if (seconds < 60) {
|
|
305
|
+
return `${seconds}s`;
|
|
306
|
+
}
|
|
307
|
+
const minutes = Math.floor(seconds / 60);
|
|
308
|
+
return minutes < 60
|
|
309
|
+
? `${minutes}m${seconds % 60}s`
|
|
310
|
+
: `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
311
|
+
}
|
|
312
|
+
function message(reason) {
|
|
313
|
+
const text = reason instanceof Error ? reason.message : String(reason);
|
|
314
|
+
return text.split('\n')[0]?.trim() || 'no reason given';
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=progress.js.map
|
package/dist/schema/spec.d.ts
CHANGED
|
@@ -36,7 +36,10 @@ export interface Operation {
|
|
|
36
36
|
responses: ResponseSpec[];
|
|
37
37
|
}
|
|
38
38
|
export interface ApiDoc {
|
|
39
|
+
/** the document's name within this index, unique in the corpus */
|
|
39
40
|
source: string;
|
|
41
|
+
/** what the file was called on the machine that read it */
|
|
42
|
+
file: string;
|
|
40
43
|
sha256: string;
|
|
41
44
|
dialect: Dialect;
|
|
42
45
|
title: string;
|
|
@@ -49,6 +52,8 @@ export interface Corpus {
|
|
|
49
52
|
types: Record<string, Schema>;
|
|
50
53
|
/** which document each type id came from */
|
|
51
54
|
typeSource: Record<string, string>;
|
|
55
|
+
/** the bundled document behind each `source`, as JSON text */
|
|
56
|
+
documents: Record<string, string>;
|
|
52
57
|
}
|
|
53
58
|
/** A `CliError` so an unreadable document exits 3 wherever it is raised. */
|
|
54
59
|
export declare class SpecError extends CliError {
|
package/dist/schema/spec.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
2
3
|
import { createHash } from 'node:crypto';
|
|
3
4
|
import { basename, extname } from 'node:path';
|
|
4
|
-
import { CliError, EXIT } from '@zenera/cli/lib';
|
|
5
5
|
import { docOf, isObject, normalize } from "./schema.js";
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
7
7
|
// Documents, flattened — but not dereferenced
|
|
@@ -38,12 +38,26 @@ export async function loadSpecs(files) {
|
|
|
38
38
|
throw new SpecError('no document given', 'name at least one openapi/swagger file');
|
|
39
39
|
}
|
|
40
40
|
const loaded = [];
|
|
41
|
+
const taken = new Set();
|
|
41
42
|
for (const file of files) {
|
|
42
|
-
loaded.push(await loadSpec(file));
|
|
43
|
+
loaded.push(await loadSpec(file, distinct(slugOf(file), taken)));
|
|
43
44
|
}
|
|
44
45
|
return settle(loaded);
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The slug is the document's identity everywhere below this file — the node
|
|
49
|
+
* attribute, the store column, the name its copy is written under — so two
|
|
50
|
+
* `api.yaml`s in different directories must not answer to the same word.
|
|
51
|
+
*/
|
|
52
|
+
function distinct(slug, taken) {
|
|
53
|
+
let candidate = slug;
|
|
54
|
+
for (let n = 2; taken.has(candidate); n++) {
|
|
55
|
+
candidate = `${slug}_${n}`;
|
|
56
|
+
}
|
|
57
|
+
taken.add(candidate);
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
async function loadSpec(file, slug) {
|
|
47
61
|
let raw;
|
|
48
62
|
try {
|
|
49
63
|
raw = (await SwaggerParser.bundle(file));
|
|
@@ -51,6 +65,8 @@ async function loadSpec(file) {
|
|
|
51
65
|
catch (err) {
|
|
52
66
|
throw new SpecError(`${file}: ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`, 'the document must be a readable OpenAPI 3.x or Swagger 2.0 file');
|
|
53
67
|
}
|
|
68
|
+
// `bundle` has resolved every external `$ref`, so this text stands alone.
|
|
69
|
+
const document = JSON.stringify(raw, null, 2);
|
|
54
70
|
const dialect = dialectOf(raw, file);
|
|
55
71
|
const source = raw.components?.schemas ?? raw.definitions ?? {};
|
|
56
72
|
const types = new Map();
|
|
@@ -59,15 +75,17 @@ async function loadSpec(file) {
|
|
|
59
75
|
}
|
|
60
76
|
return {
|
|
61
77
|
doc: {
|
|
62
|
-
source:
|
|
63
|
-
|
|
78
|
+
source: slug,
|
|
79
|
+
file: basename(file),
|
|
80
|
+
sha256: createHash('sha256').update(document).digest('hex'),
|
|
64
81
|
dialect,
|
|
65
82
|
title: raw.info?.title?.trim() || basename(file),
|
|
66
83
|
version: raw.info?.version?.trim() || '',
|
|
67
84
|
},
|
|
68
|
-
slug
|
|
69
|
-
operations: operationsOf(raw, dialect,
|
|
85
|
+
slug,
|
|
86
|
+
operations: operationsOf(raw, dialect, slug),
|
|
70
87
|
types,
|
|
88
|
+
document,
|
|
71
89
|
};
|
|
72
90
|
}
|
|
73
91
|
function dialectOf(doc, file) {
|
|
@@ -89,7 +107,7 @@ function slugOf(file) {
|
|
|
89
107
|
// ---------------------------------------------------------------------------
|
|
90
108
|
// Operations
|
|
91
109
|
// ---------------------------------------------------------------------------
|
|
92
|
-
function operationsOf(raw, dialect,
|
|
110
|
+
function operationsOf(raw, dialect, source) {
|
|
93
111
|
const prefix = dialect === 'swagger-2.0' ? (raw.basePath ?? '') : '';
|
|
94
112
|
const out = [];
|
|
95
113
|
for (const [template, item] of Object.entries(raw.paths ?? {})) {
|
|
@@ -107,7 +125,7 @@ function operationsOf(raw, dialect, file) {
|
|
|
107
125
|
const path = join(prefix, template);
|
|
108
126
|
const own = paramsOf(op.parameters, dialect);
|
|
109
127
|
out.push({
|
|
110
|
-
source
|
|
128
|
+
source,
|
|
111
129
|
method,
|
|
112
130
|
path,
|
|
113
131
|
operationId: text(op.operationId) || synthesizeId(method, path),
|
|
@@ -275,7 +293,13 @@ function settle(loaded) {
|
|
|
275
293
|
});
|
|
276
294
|
}
|
|
277
295
|
});
|
|
278
|
-
return {
|
|
296
|
+
return {
|
|
297
|
+
docs: loaded.map((one) => one.doc),
|
|
298
|
+
operations,
|
|
299
|
+
types,
|
|
300
|
+
typeSource,
|
|
301
|
+
documents: Object.fromEntries(loaded.map((one) => [one.slug, one.document])),
|
|
302
|
+
};
|
|
279
303
|
}
|
|
280
304
|
function unique(id, slug, seen) {
|
|
281
305
|
let candidate = seen.has(id) ? `${slug}.${id}` : id;
|
|
@@ -37,6 +37,12 @@ export interface StitchOptions {
|
|
|
37
37
|
export declare const DEFAULT_MAX_HOPS = 3;
|
|
38
38
|
export declare const DEFAULT_MAX_NODES = 200;
|
|
39
39
|
export declare function stitch(graph: ApiGraph, seeds: readonly Seed[], options?: StitchOptions): Subgraph[];
|
|
40
|
+
/**
|
|
41
|
+
* Exactly the nodes named, and only the edges between them. The counterpart to
|
|
42
|
+
* `stitch`: no neighbours are gathered, because the caller is not asking what
|
|
43
|
+
* this is connected to — they already know what they want and want it whole.
|
|
44
|
+
*/
|
|
45
|
+
export declare function select(graph: ApiGraph, ids: readonly string[]): Subgraph;
|
|
40
46
|
/** Breadth-first, ignoring edge direction, giving up past `maxHops`. */
|
|
41
47
|
export declare function path(graph: ApiGraph, from: string, to: string, maxHops: number): string[] | undefined;
|
|
42
48
|
//# sourceMappingURL=subgraph.d.ts.map
|
package/dist/schema/subgraph.js
CHANGED
|
@@ -150,6 +150,33 @@ function connect(graph, seeds, maxHops) {
|
|
|
150
150
|
}
|
|
151
151
|
return out;
|
|
152
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* Exactly the nodes named, and only the edges between them. The counterpart to
|
|
155
|
+
* `stitch`: no neighbours are gathered, because the caller is not asking what
|
|
156
|
+
* this is connected to — they already know what they want and want it whole.
|
|
157
|
+
*/
|
|
158
|
+
export function select(graph, ids) {
|
|
159
|
+
const kept = new Set(ids.filter((id) => graph.hasNode(id)));
|
|
160
|
+
const nodes = [...kept].map((id) => ({
|
|
161
|
+
id,
|
|
162
|
+
kind: graph.getNodeAttribute(id, 'kind'),
|
|
163
|
+
attributes: graph.getNodeAttributes(id),
|
|
164
|
+
hit: true,
|
|
165
|
+
score: 1,
|
|
166
|
+
}));
|
|
167
|
+
const edges = [];
|
|
168
|
+
for (const id of kept) {
|
|
169
|
+
for (const edge of graph.outEdges(id)) {
|
|
170
|
+
const target = graph.target(edge);
|
|
171
|
+
if (!kept.has(target)) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const a = graph.getEdgeAttributes(edge);
|
|
175
|
+
edges.push({ source: id, target, relation: a.relation, status: a.status, in: a.in });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { nodes, edges, hits: [...kept], score: kept.size, truncated: false };
|
|
179
|
+
}
|
|
153
180
|
/** Breadth-first, ignoring edge direction, giving up past `maxHops`. */
|
|
154
181
|
export function path(graph, from, to, maxHops) {
|
|
155
182
|
if (from === to) {
|