dsh-plugin-dbgraph-store 0.0.0-stage → 0.1.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/lib/aliases.d.ts +34 -0
- package/lib/aliases.js +92 -0
- package/lib/connections.d.ts +42 -0
- package/lib/connections.js +172 -0
- package/lib/database.d.ts +61 -0
- package/lib/database.js +176 -0
- package/lib/index.d.ts +222 -0
- package/lib/index.js +414 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +23 -0
- package/lib/normalize.d.ts +59 -0
- package/lib/normalize.js +158 -0
- package/lib/paths.d.ts +83 -0
- package/lib/paths.js +130 -0
- package/lib/reader.d.ts +207 -0
- package/lib/reader.js +713 -0
- package/lib/resolve.d.ts +70 -0
- package/lib/resolve.js +762 -0
- package/lib/rust-engine.d.ts +108 -0
- package/lib/rust-engine.js +148 -0
- package/lib/status.d.ts +64 -0
- package/lib/status.js +141 -0
- package/lib/terms.d.ts +93 -0
- package/lib/terms.js +146 -0
- package/lib/writer-js.d.ts +42 -0
- package/lib/writer-js.js +269 -0
- package/package.json +64 -3
- package/README.md +0 -3
package/lib/reader.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read path over one connection's index: `state`, `schema`, `objects`, `columns`, `impact`, `refs`,
|
|
3
|
+
* plus the summary `record()` rebuilds the prompt digest from.
|
|
4
|
+
*
|
|
5
|
+
* Every operation here is offline and read-only by construction, and one rule separates them:
|
|
6
|
+
* {@link IndexReader.state} **never throws** — a configuration surface asking "is this indexed" must
|
|
7
|
+
* get an answer even when the file is missing or unusable — while every other read fails loudly, so
|
|
8
|
+
* "no index" and "empty index" never look the same.
|
|
9
|
+
*
|
|
10
|
+
* Identifiers that reach SQL as identifiers (as opposed to bound values) are never taken from the
|
|
11
|
+
* index's own text; the only identifier interpolation is a placeholder list whose length comes from a
|
|
12
|
+
* count we control.
|
|
13
|
+
* @module dsh-plugin-dbgraph-store/reader
|
|
14
|
+
*/
|
|
15
|
+
import { DbgraphError, DEFAULT_INDEX_STALE_HOURS, DEFAULT_STATUS_DIGEST_OBJECTS, INDEX_META_KEYS, isValidConnectionId, searchMatchQuery, } from 'dsh-plugin-dbgraph-service';
|
|
16
|
+
import { IndexPool } from "./database.js";
|
|
17
|
+
import { normalizeComment } from "./normalize.js";
|
|
18
|
+
import { indexPath } from "./paths.js";
|
|
19
|
+
/** Defaults for a store mounted without tuning. */
|
|
20
|
+
export const DEFAULT_READER_CONFIG = {
|
|
21
|
+
staleHours: DEFAULT_INDEX_STALE_HOURS,
|
|
22
|
+
maxOpenIndexes: 4,
|
|
23
|
+
maxSchemaObjects: 500,
|
|
24
|
+
maxColumns: 500,
|
|
25
|
+
defaultObjectLimit: 50,
|
|
26
|
+
maxDigestObjects: DEFAULT_STATUS_DIGEST_OBJECTS,
|
|
27
|
+
impactDepth: 3,
|
|
28
|
+
maxRelations: 200,
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* A string cell.
|
|
32
|
+
* @param row - a SQLite row.
|
|
33
|
+
* @param key - the column name.
|
|
34
|
+
* @returns the value when it is a string.
|
|
35
|
+
*/
|
|
36
|
+
function textOf(row, key) {
|
|
37
|
+
const value = row[key];
|
|
38
|
+
return typeof value === 'string' ? value : undefined;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A numeric cell with a fallback.
|
|
42
|
+
* @param row - a SQLite row.
|
|
43
|
+
* @param key - the column name.
|
|
44
|
+
* @param fallback - returned when the cell is absent or not numeric.
|
|
45
|
+
* @returns the number.
|
|
46
|
+
*/
|
|
47
|
+
function numberOf(row, key, fallback = 0) {
|
|
48
|
+
const value = row[key];
|
|
49
|
+
if (typeof value === 'number')
|
|
50
|
+
return value;
|
|
51
|
+
if (typeof value === 'bigint')
|
|
52
|
+
return Number(value);
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* An optional numeric cell.
|
|
57
|
+
* @param row - a SQLite row.
|
|
58
|
+
* @param key - the column name.
|
|
59
|
+
* @returns the number, or undefined when the cell is NULL or not numeric.
|
|
60
|
+
*/
|
|
61
|
+
function optionalNumberOf(row, key) {
|
|
62
|
+
const value = row[key];
|
|
63
|
+
if (typeof value === 'number')
|
|
64
|
+
return value;
|
|
65
|
+
if (typeof value === 'bigint')
|
|
66
|
+
return Number(value);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A boolean cell stored as 0/1.
|
|
71
|
+
* @param row - a SQLite row.
|
|
72
|
+
* @param key - the column name.
|
|
73
|
+
* @returns true when the cell is non-zero.
|
|
74
|
+
*/
|
|
75
|
+
function flagOf(row, key) {
|
|
76
|
+
return numberOf(row, key, 0) !== 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render an object for a model-facing surface.
|
|
80
|
+
* @param record - the object row.
|
|
81
|
+
* @returns `schema.name` when a schema is present, else `name`.
|
|
82
|
+
*/
|
|
83
|
+
export function displayName(record) {
|
|
84
|
+
return record.schema === '' ? record.name : `${record.schema}.${record.name}`;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Convert an `objects` row.
|
|
88
|
+
* @param row - a SQLite row with the object columns selected.
|
|
89
|
+
* @returns the record.
|
|
90
|
+
*/
|
|
91
|
+
function toObjectRecord(row) {
|
|
92
|
+
return {
|
|
93
|
+
id: numberOf(row, 'id'),
|
|
94
|
+
schema: textOf(row, 'schema_name') ?? '',
|
|
95
|
+
name: textOf(row, 'name') ?? '',
|
|
96
|
+
kind: textOf(row, 'kind') ?? '',
|
|
97
|
+
comment: normalizeComment(textOf(row, 'comment')),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Convert a `columns` row.
|
|
102
|
+
* @param row - a SQLite row with the column columns selected.
|
|
103
|
+
* @returns the record.
|
|
104
|
+
*/
|
|
105
|
+
function toColumnRecord(row) {
|
|
106
|
+
return {
|
|
107
|
+
id: numberOf(row, 'id'),
|
|
108
|
+
name: textOf(row, 'name') ?? '',
|
|
109
|
+
type: textOf(row, 'type') ?? '',
|
|
110
|
+
nullable: flagOf(row, 'nullable'),
|
|
111
|
+
defaultValue: textOf(row, 'default_value'),
|
|
112
|
+
comment: normalizeComment(textOf(row, 'comment')),
|
|
113
|
+
isPrimaryKey: flagOf(row, 'is_primary_key'),
|
|
114
|
+
isUnique: flagOf(row, 'is_unique'),
|
|
115
|
+
ordinal: numberOf(row, 'ordinal'),
|
|
116
|
+
enumValues: parseEnumValues(textOf(row, 'enum_values_json')),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Parse the sampled-literal JSON column.
|
|
121
|
+
* @param encoded - the stored JSON, when present.
|
|
122
|
+
* @returns the values, or undefined when the cell is absent or unparsable.
|
|
123
|
+
*/
|
|
124
|
+
function parseEnumValues(encoded) {
|
|
125
|
+
if (encoded === undefined)
|
|
126
|
+
return undefined;
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(encoded);
|
|
129
|
+
if (!Array.isArray(parsed))
|
|
130
|
+
return undefined;
|
|
131
|
+
const values = parsed.filter((value) => typeof value === 'string');
|
|
132
|
+
return values.length === 0 ? undefined : values;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Read one required meta value.
|
|
140
|
+
* @param opened - the open index.
|
|
141
|
+
* @param key - the meta key.
|
|
142
|
+
* @returns the value.
|
|
143
|
+
*/
|
|
144
|
+
function requireMeta(opened, key) {
|
|
145
|
+
const value = opened.meta.get(key);
|
|
146
|
+
if (value === undefined) {
|
|
147
|
+
throw new DbgraphError(`the index records no meta.${key}`, 'DBGRAPH_MALFORMED_INDEX');
|
|
148
|
+
}
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Read an optional meta value as a number.
|
|
153
|
+
* @param opened - the open index.
|
|
154
|
+
* @param key - the meta key.
|
|
155
|
+
* @returns the number, or undefined.
|
|
156
|
+
*/
|
|
157
|
+
function numberMeta(opened, key) {
|
|
158
|
+
const value = opened.meta.get(key);
|
|
159
|
+
if (value === undefined)
|
|
160
|
+
return undefined;
|
|
161
|
+
// "Unknown" is an absent key by contract; an empty string is not a timestamp. Coercing `''` would
|
|
162
|
+
// make it epoch zero and describe the index as written in 1970.
|
|
163
|
+
const trimmed = value.trim();
|
|
164
|
+
if (trimmed === '')
|
|
165
|
+
return undefined;
|
|
166
|
+
const parsed = Number(trimmed);
|
|
167
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* One thrown value's message.
|
|
171
|
+
* @param error - the thrown value.
|
|
172
|
+
* @returns a non-empty description.
|
|
173
|
+
*/
|
|
174
|
+
function messageOf(error) {
|
|
175
|
+
if (error instanceof Error)
|
|
176
|
+
return error.message;
|
|
177
|
+
return typeof error === 'string' ? error : String(error);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Whether an FTS5 error is the "this MATCH expression is not valid" kind — the one failure a listing
|
|
181
|
+
* may survive. SQLite reports it with an `fts5:` prefix or `malformed MATCH expression`.
|
|
182
|
+
* @param error - the thrown value.
|
|
183
|
+
* @returns true when the expression, not the index, is at fault.
|
|
184
|
+
*/
|
|
185
|
+
function isMatchSyntaxError(error) {
|
|
186
|
+
return /fts5|malformed MATCH expression/i.test(messageOf(error));
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The FTS expression for free-text words, or undefined when the words hold no token.
|
|
190
|
+
* @param words - the user's words.
|
|
191
|
+
* @returns a `MATCH` expression or undefined.
|
|
192
|
+
*/
|
|
193
|
+
function matchExpression(words) {
|
|
194
|
+
const expression = searchMatchQuery(words);
|
|
195
|
+
return expression === '' ? undefined : expression;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Object ids matched by the FTS index, best rank first.
|
|
199
|
+
*
|
|
200
|
+
* Both document kinds count: an object document carries the table's own name and comment, a column
|
|
201
|
+
* document carries one column's — and a question about a field is a question about the table holding
|
|
202
|
+
* it. `bm25()` is lower-is-better, so merging keeps the minimum rank.
|
|
203
|
+
* @param db - the open index.
|
|
204
|
+
* @param match - an FTS expression.
|
|
205
|
+
* @param limit - maximum ids to return.
|
|
206
|
+
* @returns object ids in rank order.
|
|
207
|
+
*/
|
|
208
|
+
export function ftsObjectIds(db, match, limit) {
|
|
209
|
+
const ranks = new Map();
|
|
210
|
+
const remember = (id, rank) => {
|
|
211
|
+
const known = ranks.get(id);
|
|
212
|
+
if (known === undefined || rank < known)
|
|
213
|
+
ranks.set(id, rank);
|
|
214
|
+
};
|
|
215
|
+
try {
|
|
216
|
+
const objectRows = db
|
|
217
|
+
.prepare(`SELECT object_id AS id, bm25(search) AS rank FROM search
|
|
218
|
+
WHERE search MATCH ? AND object_id IS NOT NULL ORDER BY rank LIMIT ?`)
|
|
219
|
+
.all(match, limit);
|
|
220
|
+
for (const row of objectRows) {
|
|
221
|
+
const id = optionalNumberOf(row, 'id');
|
|
222
|
+
if (id !== undefined)
|
|
223
|
+
remember(id, numberOf(row, 'rank'));
|
|
224
|
+
}
|
|
225
|
+
const columnRows = db
|
|
226
|
+
.prepare(`SELECT c.object_id AS id, bm25(search) AS rank FROM search
|
|
227
|
+
JOIN columns c ON c.id = search.column_id
|
|
228
|
+
WHERE search MATCH ? AND column_id IS NOT NULL ORDER BY rank LIMIT ?`)
|
|
229
|
+
.all(match, limit);
|
|
230
|
+
for (const row of columnRows) {
|
|
231
|
+
const id = optionalNumberOf(row, 'id');
|
|
232
|
+
if (id !== undefined)
|
|
233
|
+
remember(id, numberOf(row, 'rank'));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Only a rejected MATCH expression may fall back to LIKE: `searchMatchQuery` always quotes its
|
|
238
|
+
// tokens, so that case is a defect guard rather than a normal path. Anything else — a corrupt
|
|
239
|
+
// index, a missing table — is a real fault, and flattening it into "no hits" would hide it.
|
|
240
|
+
if (!isMatchSyntaxError(error))
|
|
241
|
+
throw error;
|
|
242
|
+
console.error(`[dbgraph-store] the FTS index rejected the query (${messageOf(error)}); falling back to LIKE`);
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
return [...ranks.entries()]
|
|
246
|
+
.sort((left, right) => left[1] - right[1] || left[0] - right[0])
|
|
247
|
+
.map(entry => entry[0])
|
|
248
|
+
.slice(0, limit);
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Fetch objects by id, preserving the caller's order.
|
|
252
|
+
* @param db - the open index.
|
|
253
|
+
* @param ids - object ids, in display order.
|
|
254
|
+
* @returns the object views that exist, in the same order.
|
|
255
|
+
*/
|
|
256
|
+
function objectViewsByIds(db, ids) {
|
|
257
|
+
if (ids.length === 0)
|
|
258
|
+
return [];
|
|
259
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
260
|
+
const rows = db
|
|
261
|
+
.prepare(`SELECT o.id, o.schema_name, o.name, o.kind, o.comment, o.row_estimate,
|
|
262
|
+
(SELECT COUNT(*) FROM columns c WHERE c.object_id = o.id) AS column_count
|
|
263
|
+
FROM objects o WHERE o.id IN (${placeholders})`)
|
|
264
|
+
.all(...ids);
|
|
265
|
+
const byId = new Map();
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
const record = toObjectRecord(row);
|
|
268
|
+
const rowEstimate = optionalNumberOf(row, 'row_estimate');
|
|
269
|
+
byId.set(record.id, {
|
|
270
|
+
object: displayName(record),
|
|
271
|
+
kind: record.kind,
|
|
272
|
+
...(record.comment === undefined ? {} : { comment: record.comment }),
|
|
273
|
+
columnCount: numberOf(row, 'column_count'),
|
|
274
|
+
...(rowEstimate === undefined ? {} : { rowEstimate }),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const ordered = [];
|
|
278
|
+
for (const id of ids) {
|
|
279
|
+
const view = byId.get(id);
|
|
280
|
+
if (view !== undefined)
|
|
281
|
+
ordered.push(view);
|
|
282
|
+
}
|
|
283
|
+
return ordered;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Suggest objects when an exact name missed.
|
|
287
|
+
* @param db - the open index.
|
|
288
|
+
* @param words - what the caller typed.
|
|
289
|
+
* @returns up to five near matches, best first.
|
|
290
|
+
*/
|
|
291
|
+
export function suggestObjects(db, words) {
|
|
292
|
+
const match = matchExpression(words);
|
|
293
|
+
if (match !== undefined) {
|
|
294
|
+
const ids = ftsObjectIds(db, match, 5);
|
|
295
|
+
if (ids.length > 0) {
|
|
296
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
297
|
+
const rows = db
|
|
298
|
+
.prepare(`SELECT id, schema_name, name, kind, comment FROM objects WHERE id IN (${placeholders})`)
|
|
299
|
+
.all(...ids);
|
|
300
|
+
const byId = new Map();
|
|
301
|
+
for (const row of rows) {
|
|
302
|
+
const record = toObjectRecord(row);
|
|
303
|
+
byId.set(record.id, record);
|
|
304
|
+
}
|
|
305
|
+
return ids.map(id => byId.get(id)).filter((record) => record !== undefined);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const needle = `%${(words.find(word => word !== '') ?? '').toLowerCase()}%`;
|
|
309
|
+
if (needle === '%%')
|
|
310
|
+
return [];
|
|
311
|
+
const rows = db
|
|
312
|
+
.prepare(`SELECT id, schema_name, name, kind, comment FROM objects
|
|
313
|
+
WHERE lower(name) LIKE ? OR lower(comment) LIKE ? ORDER BY name LIMIT 5`)
|
|
314
|
+
.all(needle, needle);
|
|
315
|
+
return rows.map(toObjectRecord);
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Locate an object by its listed name — bare (`sys_user`) or schema-qualified (`public.sys_user`).
|
|
319
|
+
* @param db - the open index.
|
|
320
|
+
* @param name - the name the caller used.
|
|
321
|
+
* @returns the unique match, the ambiguous matches, or suggestions.
|
|
322
|
+
*/
|
|
323
|
+
export function lookupObject(db, name) {
|
|
324
|
+
const wanted = name.trim();
|
|
325
|
+
if (wanted === '')
|
|
326
|
+
return { matches: [], suggestions: [] };
|
|
327
|
+
const rows = db
|
|
328
|
+
.prepare(`SELECT id, schema_name, name, kind, comment FROM objects
|
|
329
|
+
WHERE lower(name) = lower(?)
|
|
330
|
+
OR (schema_name <> '' AND lower(schema_name || '.' || name) = lower(?))`)
|
|
331
|
+
.all(wanted, wanted);
|
|
332
|
+
if (rows.length === 1) {
|
|
333
|
+
const record = toObjectRecord(rows[0]);
|
|
334
|
+
return { record, matches: [record], suggestions: [] };
|
|
335
|
+
}
|
|
336
|
+
if (rows.length > 1) {
|
|
337
|
+
return { matches: rows.map(toObjectRecord), suggestions: [] };
|
|
338
|
+
}
|
|
339
|
+
return { matches: [], suggestions: suggestObjects(db, [wanted]) };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Load one object's columns in declared order.
|
|
343
|
+
* @param db - the open index.
|
|
344
|
+
* @param objectId - the object id.
|
|
345
|
+
* @param limit - maximum rows (a caller passes `cap + 1` to detect truncation).
|
|
346
|
+
* @returns column records.
|
|
347
|
+
*/
|
|
348
|
+
export function loadColumns(db, objectId, limit) {
|
|
349
|
+
const rows = db
|
|
350
|
+
.prepare(`SELECT id, name, type, nullable, default_value, comment, is_primary_key, is_unique,
|
|
351
|
+
ordinal, enum_values_json
|
|
352
|
+
FROM columns WHERE object_id = ? ORDER BY ordinal, id LIMIT ?`)
|
|
353
|
+
.all(objectId, limit);
|
|
354
|
+
return rows.map(toColumnRecord);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Load a set of objects by id.
|
|
358
|
+
* @param db - the open index.
|
|
359
|
+
* @param ids - their ids.
|
|
360
|
+
* @returns records keyed by id.
|
|
361
|
+
*/
|
|
362
|
+
export function loadObjects(db, ids) {
|
|
363
|
+
const found = new Map();
|
|
364
|
+
if (ids.length === 0)
|
|
365
|
+
return found;
|
|
366
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
367
|
+
const rows = db
|
|
368
|
+
.prepare(`SELECT id, schema_name, name, kind, comment FROM objects WHERE id IN (${placeholders})`)
|
|
369
|
+
.all(...ids);
|
|
370
|
+
for (const row of rows) {
|
|
371
|
+
const record = toObjectRecord(row);
|
|
372
|
+
found.set(record.id, record);
|
|
373
|
+
}
|
|
374
|
+
return found;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* List one object's columns for the seam.
|
|
378
|
+
* @param db - the open index.
|
|
379
|
+
* @param request - the columns request.
|
|
380
|
+
* @param limit - the column cap.
|
|
381
|
+
* @returns the column listing.
|
|
382
|
+
*/
|
|
383
|
+
function listColumns(db, request, limit) {
|
|
384
|
+
const lookup = lookupObject(db, request.object);
|
|
385
|
+
if (lookup.record === undefined) {
|
|
386
|
+
const known = lookup.matches.length > 0
|
|
387
|
+
? lookup.matches.map(displayName).join(', ')
|
|
388
|
+
: lookup.suggestions.map(displayName).join(', ') || 'none';
|
|
389
|
+
throw new DbgraphError(`no object "${request.object}" in the index; ${lookup.matches.length > 1 ? 'matches' : 'similar'}: ${known}`, 'DBGRAPH_UNKNOWN_OBJECT');
|
|
390
|
+
}
|
|
391
|
+
const rows = loadColumns(db, lookup.record.id, limit + 1);
|
|
392
|
+
const columns = rows.slice(0, limit).map(column => ({
|
|
393
|
+
name: column.name,
|
|
394
|
+
type: column.type,
|
|
395
|
+
nullable: column.nullable,
|
|
396
|
+
...(column.defaultValue === undefined ? {} : { defaultValue: column.defaultValue }),
|
|
397
|
+
...(column.comment === undefined ? {} : { comment: column.comment }),
|
|
398
|
+
isPrimaryKey: column.isPrimaryKey,
|
|
399
|
+
isUnique: column.isUnique,
|
|
400
|
+
...(column.enumValues === undefined ? {} : { enumValues: column.enumValues }),
|
|
401
|
+
}));
|
|
402
|
+
return {
|
|
403
|
+
object: displayName(lookup.record),
|
|
404
|
+
kind: lookup.record.kind,
|
|
405
|
+
...(lookup.record.comment === undefined ? {} : { comment: lookup.record.comment }),
|
|
406
|
+
columns,
|
|
407
|
+
truncated: rows.length > limit,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
/** The `reader` half of the store: one pool, one root, no network. */
|
|
411
|
+
export class IndexReader {
|
|
412
|
+
root;
|
|
413
|
+
config;
|
|
414
|
+
pool;
|
|
415
|
+
/**
|
|
416
|
+
* @param root - index root.
|
|
417
|
+
* @param config - the bounds this reader imposes.
|
|
418
|
+
*/
|
|
419
|
+
constructor(root, config = DEFAULT_READER_CONFIG) {
|
|
420
|
+
this.root = root;
|
|
421
|
+
this.config = config;
|
|
422
|
+
this.pool = new IndexPool(config.maxOpenIndexes);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Open one connection's index, validating the identity first so no caller-supplied id becomes a
|
|
426
|
+
* path fragment.
|
|
427
|
+
* @param connection - connection identity.
|
|
428
|
+
* @returns the open index.
|
|
429
|
+
*/
|
|
430
|
+
open(connection) {
|
|
431
|
+
if (!isValidConnectionId(connection)) {
|
|
432
|
+
throw new DbgraphError(`"${connection}" is not a usable connection id`, 'DBGRAPH_UNKNOWN_CONNECTION');
|
|
433
|
+
}
|
|
434
|
+
return this.pool.acquire(indexPath(connection, this.root));
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* State of one connection's index. Never throws: an unusable index is reported as empty and stale.
|
|
438
|
+
* @param connection - connection identity.
|
|
439
|
+
* @returns the state.
|
|
440
|
+
*/
|
|
441
|
+
state(connection) {
|
|
442
|
+
try {
|
|
443
|
+
const opened = this.open(connection);
|
|
444
|
+
return this.stateOf(opened);
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return { objects: 0, columns: 0, stale: true };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Structural overview.
|
|
452
|
+
* @param request - which connection.
|
|
453
|
+
* @returns the overview.
|
|
454
|
+
*/
|
|
455
|
+
schema(request) {
|
|
456
|
+
const opened = this.open(request.connection);
|
|
457
|
+
const engine = requireMeta(opened, INDEX_META_KEYS.engine);
|
|
458
|
+
const database = requireMeta(opened, INDEX_META_KEYS.database);
|
|
459
|
+
const total = numberOf(opened.db.prepare('SELECT COUNT(*) AS count FROM objects').get() ?? {}, 'count');
|
|
460
|
+
const rows = opened.db
|
|
461
|
+
.prepare(`SELECT o.schema_name, o.name, o.kind, o.comment, o.row_estimate,
|
|
462
|
+
(SELECT COUNT(*) FROM columns c WHERE c.object_id = o.id) AS column_count
|
|
463
|
+
FROM objects o ORDER BY column_count DESC, o.name ASC, o.id ASC LIMIT ?`)
|
|
464
|
+
.all(this.config.maxSchemaObjects);
|
|
465
|
+
const objects = rows.map(row => {
|
|
466
|
+
const comment = normalizeComment(textOf(row, 'comment'));
|
|
467
|
+
const rowEstimate = optionalNumberOf(row, 'row_estimate');
|
|
468
|
+
const schema = textOf(row, 'schema_name') ?? '';
|
|
469
|
+
const name = textOf(row, 'name') ?? '';
|
|
470
|
+
return {
|
|
471
|
+
object: schema === '' ? name : `${schema}.${name}`,
|
|
472
|
+
kind: textOf(row, 'kind') ?? '',
|
|
473
|
+
...(comment === undefined ? {} : { comment }),
|
|
474
|
+
columnCount: numberOf(row, 'column_count'),
|
|
475
|
+
...(rowEstimate === undefined ? {} : { rowEstimate }),
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
return {
|
|
479
|
+
connection: request.connection,
|
|
480
|
+
engine: engine,
|
|
481
|
+
database,
|
|
482
|
+
objects,
|
|
483
|
+
truncated: total > objects.length,
|
|
484
|
+
index: this.stateOf(opened),
|
|
485
|
+
commentCoverage: this.commentCoverageOf(opened),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Ranked object listing.
|
|
490
|
+
* @param request - which connection and what to look for.
|
|
491
|
+
* @returns the listing.
|
|
492
|
+
*/
|
|
493
|
+
objects(request) {
|
|
494
|
+
const opened = this.open(request.connection);
|
|
495
|
+
const limit = this.objectLimit(request.limit);
|
|
496
|
+
const query = request.query?.trim() ?? '';
|
|
497
|
+
if (query === '') {
|
|
498
|
+
const total = numberOf(opened.db.prepare('SELECT COUNT(*) AS count FROM objects').get() ?? {}, 'count');
|
|
499
|
+
const ids = opened.db
|
|
500
|
+
.prepare(`SELECT o.id, (SELECT COUNT(*) FROM columns c WHERE c.object_id = o.id) AS column_count
|
|
501
|
+
FROM objects o ORDER BY column_count DESC, o.name ASC, o.id ASC LIMIT ?`)
|
|
502
|
+
.all(limit)
|
|
503
|
+
.map(row => numberOf(row, 'id'));
|
|
504
|
+
return { objects: objectViewsByIds(opened.db, ids), truncated: total > ids.length };
|
|
505
|
+
}
|
|
506
|
+
const match = matchExpression([query]);
|
|
507
|
+
const ids = match === undefined ? [] : ftsObjectIds(opened.db, match, limit + 1);
|
|
508
|
+
if (ids.length > 0) {
|
|
509
|
+
return {
|
|
510
|
+
objects: objectViewsByIds(opened.db, ids.slice(0, limit)),
|
|
511
|
+
truncated: ids.length > limit,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
// No FTS token (punctuation) or no hit: a LIKE over the readable columns still answers.
|
|
515
|
+
const needle = `%${query.toLowerCase()}%`;
|
|
516
|
+
const rows = opened.db
|
|
517
|
+
.prepare(`SELECT o.id, o.schema_name, o.name, o.kind, o.comment, o.row_estimate,
|
|
518
|
+
(SELECT COUNT(*) FROM columns c WHERE c.object_id = o.id) AS column_count
|
|
519
|
+
FROM objects o WHERE lower(o.name) LIKE ? OR lower(o.comment) LIKE ?
|
|
520
|
+
ORDER BY column_count DESC, o.name ASC LIMIT ?`)
|
|
521
|
+
.all(needle, needle, limit + 1);
|
|
522
|
+
const objects = rows.slice(0, limit).map(row => {
|
|
523
|
+
const record = toObjectRecord(row);
|
|
524
|
+
const rowEstimate = optionalNumberOf(row, 'row_estimate');
|
|
525
|
+
return {
|
|
526
|
+
object: displayName(record),
|
|
527
|
+
kind: record.kind,
|
|
528
|
+
...(record.comment === undefined ? {} : { comment: record.comment }),
|
|
529
|
+
columnCount: numberOf(row, 'column_count'),
|
|
530
|
+
...(rowEstimate === undefined ? {} : { rowEstimate }),
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
return { objects, truncated: rows.length > limit };
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* One object's columns.
|
|
537
|
+
* @param request - which object.
|
|
538
|
+
* @returns the listing.
|
|
539
|
+
*/
|
|
540
|
+
columns(request) {
|
|
541
|
+
return listColumns(this.open(request.connection).db, request, this.config.maxColumns);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Objects a change to this one reaches: the transitive inbound reference closure, nearest first.
|
|
545
|
+
* @param request - the object to trace.
|
|
546
|
+
* @returns the reached objects.
|
|
547
|
+
*/
|
|
548
|
+
impact(request) {
|
|
549
|
+
const opened = this.open(request.connection);
|
|
550
|
+
const lookup = lookupObject(opened.db, request.object);
|
|
551
|
+
if (lookup.record === undefined)
|
|
552
|
+
throw unknownObject(request.object, lookup);
|
|
553
|
+
const entries = [];
|
|
554
|
+
const visited = new Set([lookup.record.id]);
|
|
555
|
+
let frontier = [lookup.record.id];
|
|
556
|
+
let truncated = false;
|
|
557
|
+
for (let depth = 0; depth < this.config.impactDepth && frontier.length > 0 && !truncated; depth += 1) {
|
|
558
|
+
const remaining = this.config.maxRelations - entries.length;
|
|
559
|
+
const rows = inboundEdges(opened.db, frontier, remaining + 1);
|
|
560
|
+
if (rows.length > remaining)
|
|
561
|
+
truncated = true;
|
|
562
|
+
const next = [];
|
|
563
|
+
for (const row of rows.slice(0, Math.max(0, remaining))) {
|
|
564
|
+
entries.push(relationFromRow(row));
|
|
565
|
+
const source = numberOf(row, 'src_object');
|
|
566
|
+
if (!visited.has(source)) {
|
|
567
|
+
visited.add(source);
|
|
568
|
+
next.push(source);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
frontier = next;
|
|
572
|
+
}
|
|
573
|
+
return { object: displayName(lookup.record), entries, truncated };
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Objects directly referencing this one.
|
|
577
|
+
* @param request - the object to trace.
|
|
578
|
+
* @returns the referencing objects.
|
|
579
|
+
*/
|
|
580
|
+
refs(request) {
|
|
581
|
+
const opened = this.open(request.connection);
|
|
582
|
+
const lookup = lookupObject(opened.db, request.object);
|
|
583
|
+
if (lookup.record === undefined)
|
|
584
|
+
throw unknownObject(request.object, lookup);
|
|
585
|
+
const rows = inboundEdges(opened.db, [lookup.record.id], this.config.maxRelations + 1);
|
|
586
|
+
return {
|
|
587
|
+
object: displayName(lookup.record),
|
|
588
|
+
entries: rows.slice(0, this.config.maxRelations).map(row => relationFromRow(row)),
|
|
589
|
+
truncated: rows.length > this.config.maxRelations,
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Close every cached connection. Idempotent.
|
|
594
|
+
*/
|
|
595
|
+
close() {
|
|
596
|
+
this.pool.close();
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* State derived from an already-open index, so `schema` does not stat the file twice.
|
|
600
|
+
* @param opened - the open index.
|
|
601
|
+
* @returns the state.
|
|
602
|
+
*/
|
|
603
|
+
stateOf(opened) {
|
|
604
|
+
const counts = opened.db
|
|
605
|
+
.prepare('SELECT (SELECT COUNT(*) FROM objects) AS objects, (SELECT COUNT(*) FROM columns) AS columns')
|
|
606
|
+
.get() ?? {};
|
|
607
|
+
const l1IndexedAt = numberMeta(opened, INDEX_META_KEYS.l1IndexedAt)
|
|
608
|
+
?? numberMeta(opened, INDEX_META_KEYS.fetchedAt);
|
|
609
|
+
const l2IndexedAt = numberMeta(opened, INDEX_META_KEYS.l2IndexedAt)
|
|
610
|
+
?? (opened.meta.get(INDEX_META_KEYS.level) === 'l2' ? numberMeta(opened, INDEX_META_KEYS.fetchedAt) : undefined);
|
|
611
|
+
const stale = l2IndexedAt === undefined || Date.now() - l2IndexedAt > this.config.staleHours * 3_600_000;
|
|
612
|
+
const writerValue = opened.meta.get(INDEX_META_KEYS.writer);
|
|
613
|
+
const writer = writerValue === 'js' || writerValue === 'rust' ? writerValue : undefined;
|
|
614
|
+
return {
|
|
615
|
+
...(l1IndexedAt === undefined ? {} : { l1IndexedAt }),
|
|
616
|
+
...(l2IndexedAt === undefined ? {} : { l2IndexedAt }),
|
|
617
|
+
objects: numberOf(counts, 'objects'),
|
|
618
|
+
columns: numberOf(counts, 'columns'),
|
|
619
|
+
stale,
|
|
620
|
+
...(writer === undefined ? {} : { writer }),
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Fraction of columns carrying a comment.
|
|
625
|
+
* @param opened - the open index.
|
|
626
|
+
* @returns 0..1, and 0 for an empty index (no evidence, not full coverage).
|
|
627
|
+
*/
|
|
628
|
+
commentCoverageOf(opened) {
|
|
629
|
+
const row = opened.db
|
|
630
|
+
.prepare(`SELECT COUNT(*) AS total,
|
|
631
|
+
SUM(CASE WHEN comment IS NOT NULL AND comment <> '' THEN 1 ELSE 0 END) AS commented
|
|
632
|
+
FROM columns`)
|
|
633
|
+
.get() ?? {};
|
|
634
|
+
const total = numberOf(row, 'total');
|
|
635
|
+
const commented = numberOf(row, 'commented');
|
|
636
|
+
return total === 0 ? 0 : commented / total;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Clamp a requested object limit into the configured bounds.
|
|
640
|
+
* @param requested - the caller's value, when one was set.
|
|
641
|
+
* @returns a limit of at least 1, at most 500.
|
|
642
|
+
*/
|
|
643
|
+
objectLimit(requested) {
|
|
644
|
+
const fallback = this.config.defaultObjectLimit;
|
|
645
|
+
if (requested === undefined || !Number.isFinite(requested) || requested < 1) {
|
|
646
|
+
return Math.min(fallback, 500);
|
|
647
|
+
}
|
|
648
|
+
return Math.min(Math.floor(requested), 500);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Throw the structured error for an object name that did not resolve uniquely.
|
|
653
|
+
* @param requested - the name the request used.
|
|
654
|
+
* @param lookup - what the lookup found.
|
|
655
|
+
* @returns never.
|
|
656
|
+
*/
|
|
657
|
+
function unknownObject(requested, lookup) {
|
|
658
|
+
const known = lookup.matches.length > 0
|
|
659
|
+
? lookup.matches.map(displayName).join(', ')
|
|
660
|
+
: lookup.suggestions.map(displayName).join(', ') || 'none';
|
|
661
|
+
const qualifier = lookup.matches.length > 1 ? 'matches' : 'similar';
|
|
662
|
+
return new DbgraphError(`no object "${requested}" in the index; ${qualifier}: ${known}`, 'DBGRAPH_UNKNOWN_OBJECT');
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Inbound edges (`dst_object` in the frontier) with both endpoints' display data.
|
|
666
|
+
* @param db - the open index.
|
|
667
|
+
* @param destinationIds - the objects being referenced.
|
|
668
|
+
* @param limit - maximum rows.
|
|
669
|
+
* @returns the rows.
|
|
670
|
+
*/
|
|
671
|
+
function inboundEdges(db, destinationIds, limit) {
|
|
672
|
+
if (destinationIds.length === 0)
|
|
673
|
+
return [];
|
|
674
|
+
const placeholders = destinationIds.map(() => '?').join(', ');
|
|
675
|
+
return db
|
|
676
|
+
.prepare(`SELECT e.id, e.kind, e.name, e.src_object, e.dst_object,
|
|
677
|
+
so.schema_name AS src_schema, so.name AS src_name,
|
|
678
|
+
sc.name AS src_column, dc.name AS dst_column
|
|
679
|
+
FROM edges e
|
|
680
|
+
JOIN objects so ON so.id = e.src_object
|
|
681
|
+
LEFT JOIN columns sc ON sc.id = e.src_column_id
|
|
682
|
+
LEFT JOIN columns dc ON dc.id = e.dst_column_id
|
|
683
|
+
WHERE e.dst_object IN (${placeholders})
|
|
684
|
+
ORDER BY e.dst_object, e.kind, so.name, e.id
|
|
685
|
+
LIMIT ?`)
|
|
686
|
+
.all(...destinationIds, limit);
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Convert an inbound-edge row to the seam's relation shape.
|
|
690
|
+
*
|
|
691
|
+
* The named object in a `refs`/`impact` request is the edge's destination, so `object` is the source
|
|
692
|
+
* end, `column` is the destination column (on the named object) and `refColumn` is the source column
|
|
693
|
+
* (on the object that points at it).
|
|
694
|
+
* @param row - the row.
|
|
695
|
+
* @returns the relation view.
|
|
696
|
+
*/
|
|
697
|
+
function relationFromRow(row) {
|
|
698
|
+
const schema = textOf(row, 'src_schema') ?? '';
|
|
699
|
+
const name = textOf(row, 'src_name') ?? '';
|
|
700
|
+
const object = displayName({ id: 0, schema, name, kind: '', comment: undefined });
|
|
701
|
+
const srcColumn = textOf(row, 'src_column');
|
|
702
|
+
const dstColumn = textOf(row, 'dst_column');
|
|
703
|
+
const edgeName = textOf(row, 'name');
|
|
704
|
+
const kind = (textOf(row, 'kind') ?? 'fk');
|
|
705
|
+
return {
|
|
706
|
+
kind,
|
|
707
|
+
object,
|
|
708
|
+
...(dstColumn === undefined ? {} : { column: dstColumn }),
|
|
709
|
+
...(srcColumn === undefined ? {} : { refColumn: srcColumn }),
|
|
710
|
+
...(edgeName === undefined ? {} : { name: edgeName }),
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
//# sourceMappingURL=reader.js.map
|