dsh-plugin-dbgraph-catalog 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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * SQLite dialect: `node:sqlite` (`DatabaseSync`), zero external dependencies.
3
+ *
4
+ * The catalog is read from `sqlite_master` plus the `PRAGMA` family rather than from a parser over
5
+ * `CREATE TABLE` text: PRAGMAs are what SQLite itself considers the schema, they survive formatting
6
+ * oddities, and `table_info` is the only way to learn a view's output columns.
7
+ *
8
+ * **Level split.** L1 lists objects and their columns (`table_info` is cheap, and a primary-key
9
+ * flag comes with it). L2 adds what L1 deliberately omits: foreign keys (`foreign_key_list`),
10
+ * indexes (`index_list` + `index_info`), single-column uniqueness, and enum samples. Comments are
11
+ * never produced — SQLite keeps none, and inventing a description from a name would be the one
12
+ * thing the catalog is not allowed to do.
13
+ *
14
+ * **Read-only at two layers.** Metadata (`test`, `fetchL1`, `fetchL2`) always opens the file with
15
+ * `readOnly: true`, and `execute` applies the shared keyword gate. A statement the caller allowed to
16
+ * write (`limits.readOnly: false`) is served by a read-write handle instead: the gate alone cannot see
17
+ * inside a function call, and a `PRAGMA` or a trigger may write, so the file mode is the layer that
18
+ * actually stops a write the gate cannot inspect. The handle cache keys on the mode, so a connection
19
+ * never holds two handles at once — switching mode closes the previous one before opening the next.
20
+ *
21
+ * **Sampling is L2 and bounded.** Only text-affinity columns of real tables are sampled, with
22
+ * `SELECT DISTINCT … WHERE … IS NOT NULL LIMIT ?` bounded by `DialectInput.enumSampleLimit`. Views
23
+ * are skipped because sampling one runs its query plan, which can be arbitrarily expensive.
24
+ *
25
+ * **Timeouts.** `node:sqlite` is synchronous and exposes no statement interrupt in the supported
26
+ * Node range, so `queryTimeoutMs` is not enforced here. Callers get an abort check between objects
27
+ * during a catalog walk and before a statement; a single runaway statement cannot be cancelled
28
+ * mid-flight. This is a capability gap of the binding, not a silent choice.
29
+ * @module dsh-plugin-dbgraph-catalog/sqlite
30
+ */
31
+ import type { ManagedDialect } from './support.ts';
32
+ /**
33
+ * The `node:sqlite` surface this dialect needs. Passed in rather than imported at module scope so a
34
+ * Node build without the builtin can disable this dialect instead of failing the whole package.
35
+ */
36
+ export type SqliteModule = Pick<typeof import('node:sqlite'), 'DatabaseSync'>;
37
+ /**
38
+ * Appended to every sqlite `SqlResult`: the seam advertises a statement timeout this binding cannot
39
+ * enforce, and a caller that is never told cannot tell a slow query from a hung one.
40
+ */
41
+ export declare const SQLITE_TIMEOUT_WARNING = "timeoutMs is not enforced by the sqlite dialect: node:sqlite cannot interrupt a running statement";
42
+ /**
43
+ * Create the SQLite dialect.
44
+ * @param module - the loaded `node:sqlite` module.
45
+ * @returns a dialect that caches one handle per connection id, in the mode each call needs.
46
+ */
47
+ export declare function createSqliteDialect(module: SqliteModule): ManagedDialect;
48
+ /**
49
+ * Quote a SQLite identifier for a `PRAGMA` argument. PRAGMAs cannot take bound parameters, so the
50
+ * name is the one place a catalog query interpolates — double quotes inside it are doubled.
51
+ * @param name - the identifier, as read from the database.
52
+ * @returns the quoted identifier.
53
+ */
54
+ export declare function sqliteIdentifier(name: string): string;
55
+ /**
56
+ * Whether a declared SQLite type has text affinity, and is therefore worth sampling for enum
57
+ * values. SQLite has no enum type of its own, but a column declared `ENUM(...)` or `SET(...)` is
58
+ * still semantically one, so those words count too.
59
+ * @param declaredType - the type text as declared.
60
+ * @returns true when the column is string-like.
61
+ */
62
+ export declare function isStringishSqliteType(declaredType: string): boolean;
63
+ //# sourceMappingURL=sqlite.d.ts.map
package/lib/sqlite.js ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * SQLite dialect: `node:sqlite` (`DatabaseSync`), zero external dependencies.
3
+ *
4
+ * The catalog is read from `sqlite_master` plus the `PRAGMA` family rather than from a parser over
5
+ * `CREATE TABLE` text: PRAGMAs are what SQLite itself considers the schema, they survive formatting
6
+ * oddities, and `table_info` is the only way to learn a view's output columns.
7
+ *
8
+ * **Level split.** L1 lists objects and their columns (`table_info` is cheap, and a primary-key
9
+ * flag comes with it). L2 adds what L1 deliberately omits: foreign keys (`foreign_key_list`),
10
+ * indexes (`index_list` + `index_info`), single-column uniqueness, and enum samples. Comments are
11
+ * never produced — SQLite keeps none, and inventing a description from a name would be the one
12
+ * thing the catalog is not allowed to do.
13
+ *
14
+ * **Read-only at two layers.** Metadata (`test`, `fetchL1`, `fetchL2`) always opens the file with
15
+ * `readOnly: true`, and `execute` applies the shared keyword gate. A statement the caller allowed to
16
+ * write (`limits.readOnly: false`) is served by a read-write handle instead: the gate alone cannot see
17
+ * inside a function call, and a `PRAGMA` or a trigger may write, so the file mode is the layer that
18
+ * actually stops a write the gate cannot inspect. The handle cache keys on the mode, so a connection
19
+ * never holds two handles at once — switching mode closes the previous one before opening the next.
20
+ *
21
+ * **Sampling is L2 and bounded.** Only text-affinity columns of real tables are sampled, with
22
+ * `SELECT DISTINCT … WHERE … IS NOT NULL LIMIT ?` bounded by `DialectInput.enumSampleLimit`. Views
23
+ * are skipped because sampling one runs its query plan, which can be arbitrarily expensive.
24
+ *
25
+ * **Timeouts.** `node:sqlite` is synchronous and exposes no statement interrupt in the supported
26
+ * Node range, so `queryTimeoutMs` is not enforced here. Callers get an abort check between objects
27
+ * during a catalog walk and before a statement; a single runaway statement cannot be cancelled
28
+ * mid-flight. This is a capability gap of the binding, not a silent choice.
29
+ * @module dsh-plugin-dbgraph-catalog/sqlite
30
+ */
31
+ import { DbgraphError } from 'dsh-plugin-dbgraph-service';
32
+ import { HandleCache } from "./pool.js";
33
+ import { asString, assertReadOnlyStatement, clampRowLimit, DBGRAPH_QUERY_FAILED_CODE, isReadStatement, isSensitiveName, messageOf, normalizeRows, throwIfAborted, } from "./support.js";
34
+ /**
35
+ * Appended to every sqlite `SqlResult`: the seam advertises a statement timeout this binding cannot
36
+ * enforce, and a caller that is never told cannot tell a slow query from a hung one.
37
+ */
38
+ export const SQLITE_TIMEOUT_WARNING = 'timeoutMs is not enforced by the sqlite dialect: node:sqlite cannot interrupt a running statement';
39
+ /** Prefix of SQLite's own bookkeeping tables, which are schema noise rather than user objects. */
40
+ const INTERNAL_TABLE_GLOB = 'sqlite_*';
41
+ /** Objects a catalog can describe: real tables and views. */
42
+ const LISTED_OBJECT_TYPES = new Set(['table', 'view']);
43
+ /** The statement that lists user objects. `NOT GLOB` treats `_` literally, unlike `LIKE`. */
44
+ const LIST_OBJECTS_QUERY = `SELECT type, name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT GLOB '${INTERNAL_TABLE_GLOB}' ORDER BY name`;
45
+ /** The server version, for diagnostics only. */
46
+ const SERVER_VERSION_QUERY = 'SELECT sqlite_version() AS version';
47
+ /** A dialect instance backed by a per-connection `DatabaseSync` cache. */
48
+ class SqliteDialect {
49
+ engine = 'sqlite';
50
+ module;
51
+ handles = new HandleCache();
52
+ /**
53
+ * @param module - the `node:sqlite` module, injected so availability is a plugin decision.
54
+ */
55
+ constructor(module) {
56
+ this.module = module;
57
+ }
58
+ /**
59
+ * Round-trip check: open the file (read-only) and read the library version.
60
+ * @param input - the connection to probe.
61
+ * @returns the probe answer; a failure is reported, not thrown, except an abort.
62
+ */
63
+ async test(input) {
64
+ throwIfAborted(input.signal);
65
+ const started = Date.now();
66
+ try {
67
+ const database = await this.acquire(input, true);
68
+ const row = database.prepare(SERVER_VERSION_QUERY).get();
69
+ const version = asString(row?.['version']);
70
+ return {
71
+ ok: true,
72
+ ...(version === undefined ? {} : { serverVersion: version }),
73
+ elapsedMs: Date.now() - started,
74
+ };
75
+ }
76
+ catch (error) {
77
+ return { ok: false, error: messageOf(error), elapsedMs: Date.now() - started };
78
+ }
79
+ }
80
+ /**
81
+ * Structural snapshot: every user table and view with its declared columns.
82
+ * @param input - the connection to read.
83
+ * @returns an `l1` catalog; per-object failures become warnings rather than aborting the run.
84
+ */
85
+ async fetchL1(input) {
86
+ throwIfAborted(input.signal);
87
+ const database = await this.acquire(input, true);
88
+ throwIfAborted(input.signal);
89
+ const target = requireTarget(input);
90
+ const fetchedAt = Date.now();
91
+ const warnings = [];
92
+ const objects = [];
93
+ for (const row of listObjects(database)) {
94
+ throwIfAborted(input.signal);
95
+ try {
96
+ objects.push({
97
+ kind: row.type === 'view' ? 'view' : 'table',
98
+ name: row.name,
99
+ columns: readColumns(database, row.name),
100
+ });
101
+ }
102
+ catch (error) {
103
+ // One broken object (a view over a dropped table, a malformed virtual table) must not cost
104
+ // the snapshot every other table it already read.
105
+ warnings.push(`sqlite: skipped "${row.name}": ${messageOf(error)}`);
106
+ }
107
+ }
108
+ const serverVersion = readServerVersion(database);
109
+ return {
110
+ engine: 'sqlite',
111
+ ...(serverVersion === undefined ? {} : { serverVersion }),
112
+ database: target,
113
+ objects,
114
+ fetchedAt,
115
+ level: 'l1',
116
+ ...(warnings.length === 0 ? {} : { warnings }),
117
+ };
118
+ }
119
+ /**
120
+ * Enrichment snapshot: merge foreign keys, indexes, single-column uniqueness and enum samples
121
+ * onto the L1 objects passed in.
122
+ * @param input - the connection to read.
123
+ * @param l1 - the structural snapshot this run starts from; it is never mutated.
124
+ * @returns an `l2` catalog; a per-object failure is recorded and that object stays at L1 shape.
125
+ */
126
+ async fetchL2(input, l1) {
127
+ throwIfAborted(input.signal);
128
+ const database = await this.acquire(input, true);
129
+ throwIfAborted(input.signal);
130
+ const warnings = [];
131
+ const objects = [];
132
+ for (const object of l1.objects) {
133
+ throwIfAborted(input.signal);
134
+ objects.push(enrichObject(database, input, object, warnings));
135
+ }
136
+ return {
137
+ engine: 'sqlite',
138
+ ...(l1.serverVersion === undefined ? {} : { serverVersion: l1.serverVersion }),
139
+ database: l1.database,
140
+ objects,
141
+ fetchedAt: Date.now(),
142
+ level: 'l2',
143
+ ...(warnings.length === 0 ? {} : { warnings }),
144
+ };
145
+ }
146
+ /**
147
+ * Execute one statement. Reads are streamed and stopped at the row cap; a non-read is refused
148
+ * before reaching the engine when the caller asked for read-only, and is otherwise run against a
149
+ * read-write file handle. A read that hides a write channel (`EXPLAIN ANALYZE`, `INTO OUTFILE`) is
150
+ * refused only while the execution is read-only; `allowWrites` is the deployment's explicit "this
151
+ * statement may write" switch, and the gate does not second-guess it.
152
+ *
153
+ * `node:sqlite` compiles only the first statement of a multi-statement string and ignores the
154
+ * rest, so a trailing `; DELETE …` is never executed — but the read-only rule still decides
155
+ * whether that first statement may run at all.
156
+ * @param input - the connection to run against.
157
+ * @param query - the statement.
158
+ * @param limits - row cap, timeout (not enforceable here) and the read-only flag.
159
+ * @param parameters - values bound to the statement's `?` placeholders, in order. Omitting them
160
+ * binds nothing, and a placeholder then evaluates as NULL — which silently turns
161
+ * `WHERE name = ?` into `WHERE name = NULL` (no rows).
162
+ * @returns the normalized result set.
163
+ */
164
+ async execute(input, query, limits, parameters) {
165
+ throwIfAborted(input.signal);
166
+ assertReadOnlyStatement(query, limits);
167
+ const database = await this.acquire(input, limits.readOnly);
168
+ throwIfAborted(input.signal);
169
+ const started = Date.now();
170
+ const cap = clampRowLimit(limits.maxRows);
171
+ const bindings = sqliteBindings(parameters);
172
+ try {
173
+ const statement = database.prepare(query);
174
+ if (!isReadStatement(query)) {
175
+ const changes = statement.run(...bindings).changes;
176
+ return {
177
+ columns: [],
178
+ rows: [],
179
+ rowCount: 0,
180
+ truncated: false,
181
+ redacted: [],
182
+ affectedRows: typeof changes === 'bigint' ? Number(changes) : changes,
183
+ elapsedMs: Date.now() - started,
184
+ warnings: [SQLITE_TIMEOUT_WARNING],
185
+ };
186
+ }
187
+ const columns = statement.columns().map(column => column.name);
188
+ const rawRows = [];
189
+ let truncated = false;
190
+ // Streaming stops at cap + one row: enough to prove there is more, without materializing a
191
+ // result set the caller explicitly asked to bound.
192
+ for (const row of statement.iterate(...bindings)) {
193
+ if (rawRows.length >= cap) {
194
+ truncated = true;
195
+ break;
196
+ }
197
+ rawRows.push({ ...row });
198
+ }
199
+ const normalized = normalizeRows(columns, rawRows, input.connection);
200
+ return {
201
+ columns,
202
+ rows: normalized.rows,
203
+ rowCount: normalized.rows.length,
204
+ truncated,
205
+ redacted: normalized.redacted,
206
+ elapsedMs: Date.now() - started,
207
+ warnings: [SQLITE_TIMEOUT_WARNING],
208
+ };
209
+ }
210
+ catch (error) {
211
+ if (error instanceof DbgraphError)
212
+ throw error;
213
+ throw new DbgraphError(`SQLite statement failed on connection "${input.connection.id}": ${messageOf(error)}`, DBGRAPH_QUERY_FAILED_CODE);
214
+ }
215
+ }
216
+ /** Close every cached file handle. */
217
+ async close() {
218
+ await this.handles.closeAll();
219
+ }
220
+ /**
221
+ * The warm handle for a connection, in the mode the current call needs.
222
+ *
223
+ * The cache fingerprint carries the mode, so a read-only and a writable execution cannot share a
224
+ * handle: the entry is keyed by connection id, and a changed fingerprint closes the previous handle
225
+ * before the next one is opened. Replacing rather than accumulating is the point — which mode a
226
+ * statement runs in must be the one it asked for, not whichever mode a connection happened to
227
+ * open first, and one connection id owns exactly one open file.
228
+ * @param input - the connection to open.
229
+ * @param readOnly - whether the handle must refuse writes.
230
+ * @returns the cached handle.
231
+ */
232
+ async acquire(input, readOnly) {
233
+ const target = requireTarget(input);
234
+ return this.handles.acquire(input.connection.id, `${target}\u0000${readOnly ? 'ro' : 'rw'}`, () => {
235
+ try {
236
+ // The file mode makes the guarantee real rather than a keyword-list promise: a read-only
237
+ // handle also means a mistyped path fails instead of silently creating an empty database.
238
+ // A writable call (`allowWrites`) needs the opposite mode, or every write would fail.
239
+ return new this.module.DatabaseSync(target, readOnly ? { readOnly: true } : {});
240
+ }
241
+ catch (error) {
242
+ throw new DbgraphError(`cannot open SQLite database "${target}" for connection "${input.connection.id}"`
243
+ + `${readOnly ? ' read-only' : ' for writing'}: ${messageOf(error)}`, 'DBGRAPH_UNAVAILABLE');
244
+ }
245
+ }, database => database.close());
246
+ }
247
+ }
248
+ /**
249
+ * Create the SQLite dialect.
250
+ * @param module - the loaded `node:sqlite` module.
251
+ * @returns a dialect that caches one handle per connection id, in the mode each call needs.
252
+ */
253
+ export function createSqliteDialect(module) {
254
+ return new SqliteDialect(module);
255
+ }
256
+ /**
257
+ * Quote a SQLite identifier for a `PRAGMA` argument. PRAGMAs cannot take bound parameters, so the
258
+ * name is the one place a catalog query interpolates — double quotes inside it are doubled.
259
+ * @param name - the identifier, as read from the database.
260
+ * @returns the quoted identifier.
261
+ */
262
+ export function sqliteIdentifier(name) {
263
+ return `"${name.replace(/"/g, '""')}"`;
264
+ }
265
+ /**
266
+ * Whether a declared SQLite type has text affinity, and is therefore worth sampling for enum
267
+ * values. SQLite has no enum type of its own, but a column declared `ENUM(...)` or `SET(...)` is
268
+ * still semantically one, so those words count too.
269
+ * @param declaredType - the type text as declared.
270
+ * @returns true when the column is string-like.
271
+ */
272
+ export function isStringishSqliteType(declaredType) {
273
+ const normalized = declaredType.toLowerCase();
274
+ return normalized.includes('char')
275
+ || normalized.includes('clob')
276
+ || normalized.includes('text')
277
+ || normalized.includes('enum')
278
+ || normalized.includes('set');
279
+ }
280
+ /**
281
+ * Convert caller-supplied predicate values into values `node:sqlite` accepts.
282
+ *
283
+ * The seam hands over JSON-ish values (`unknown[]`), while the binding accepts only null, numbers,
284
+ * text, bigints and binary views. Two conversions matter in practice: a JSON boolean becomes 1/0
285
+ * (SQLite's storage convention) and a `Date` becomes ISO text (the same representation
286
+ * {@link import('./support.ts').toJsonSafe} gives a result value), so a predicate value written the
287
+ * same way it will be read back still matches. Anything else is refused loudly rather than bound as
288
+ * something the caller did not ask for.
289
+ * @param parameters - the values from `DatabaseDialect.execute`, when supplied.
290
+ * @returns positional bindings, in order.
291
+ * @throws DbgraphError with `DBGRAPH_QUERY_FAILED` for a value SQLite has no representation for.
292
+ */
293
+ function sqliteBindings(parameters) {
294
+ if (parameters === undefined)
295
+ return [];
296
+ return parameters.map(toSqliteBinding);
297
+ }
298
+ /**
299
+ * Convert one caller value.
300
+ * @param value - the value to bind.
301
+ * @returns the SQLite representation.
302
+ */
303
+ function toSqliteBinding(value) {
304
+ if (value === null || value === undefined)
305
+ return null;
306
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'bigint')
307
+ return value;
308
+ if (typeof value === 'boolean')
309
+ return value ? 1 : 0;
310
+ if (value instanceof Uint8Array)
311
+ return value;
312
+ if (value instanceof Date) {
313
+ return Number.isNaN(value.getTime()) ? String(value) : value.toISOString();
314
+ }
315
+ throw new DbgraphError(`cannot bind a value of type ${typeof value} as a SQLite parameter; only scalars, dates and binary values are supported`, DBGRAPH_QUERY_FAILED_CODE);
316
+ }
317
+ /**
318
+ * The configured database file path for a connection.
319
+ * @param input - the connection.
320
+ * @returns the path.
321
+ * @throws DbgraphError with `DBGRAPH_UNAVAILABLE` when the connection names no database.
322
+ */
323
+ function requireTarget(input) {
324
+ const target = input.connection.database;
325
+ if (target === undefined || target === '') {
326
+ throw new DbgraphError(`connection "${input.connection.id}" has no SQLite database path configured`, 'DBGRAPH_UNAVAILABLE');
327
+ }
328
+ return target;
329
+ }
330
+ /**
331
+ * Every listed object, with its name and declared kind.
332
+ * @param database - the open database.
333
+ * @returns rows with only string fields; malformed rows are dropped.
334
+ */
335
+ function listObjects(database) {
336
+ const rows = queryAll(database, LIST_OBJECTS_QUERY);
337
+ const listed = [];
338
+ for (const row of rows) {
339
+ const name = asString(row.name);
340
+ const type = asString(row.type);
341
+ if (name === undefined || type === undefined)
342
+ continue;
343
+ if (!LISTED_OBJECT_TYPES.has(type))
344
+ continue;
345
+ listed.push({ type: type === 'view' ? 'view' : 'table', name });
346
+ }
347
+ return listed;
348
+ }
349
+ /**
350
+ * Read a table's or view's declared columns.
351
+ * @param database - the open database.
352
+ * @param name - the object name.
353
+ * @returns columns in declared order, with comment intentionally absent.
354
+ */
355
+ function readColumns(database, name) {
356
+ const rows = queryAll(database, `PRAGMA table_info(${sqliteIdentifier(name)})`);
357
+ return rows.map((row, index) => {
358
+ const declaredName = asString(row.name) ?? '';
359
+ const declaredType = asString(row.type) ?? '';
360
+ const defaultValue = asString(row.dflt_value);
361
+ const cid = asFiniteInteger(row.cid);
362
+ return {
363
+ name: declaredName,
364
+ ordinal: cid === undefined ? index + 1 : cid + 1,
365
+ type: declaredType,
366
+ nullable: asFiniteInteger(row.notnull) !== 1,
367
+ isPrimaryKey: (asFiniteInteger(row.pk) ?? 0) > 0,
368
+ ...(defaultValue === undefined ? {} : { defaultValue }),
369
+ };
370
+ });
371
+ }
372
+ /**
373
+ * Read every foreign-key constraint on one object.
374
+ * @param database - the open database.
375
+ * @param name - the object name.
376
+ * @returns one edge per constraint, columns in declaration order.
377
+ */
378
+ function readForeignKeys(database, name) {
379
+ const rows = queryAll(database, `PRAGMA foreign_key_list(${sqliteIdentifier(name)})`);
380
+ const groups = new Map();
381
+ for (const row of rows) {
382
+ const id = asFiniteInteger(row.id);
383
+ const from = asString(row.from);
384
+ const refObject = asString(row.table);
385
+ if (id === undefined || from === undefined || refObject === undefined)
386
+ continue;
387
+ const seq = asFiniteInteger(row.seq) ?? 0;
388
+ const to = asString(row.to) ?? null;
389
+ const group = groups.get(id) ?? { refObject, parts: [] };
390
+ group.parts.push({ seq, from, to });
391
+ groups.set(id, group);
392
+ }
393
+ const edges = [];
394
+ for (const group of groups.values()) {
395
+ const parts = [...group.parts].sort((left, right) => left.seq - right.seq);
396
+ const columns = parts.map(part => part.from);
397
+ const refColumns = parts.every(part => part.to !== null)
398
+ ? parts.map(part => part.to)
399
+ : undefined;
400
+ edges.push({
401
+ kind: 'fk',
402
+ columns,
403
+ refObject: group.refObject,
404
+ ...(refColumns === undefined ? {} : { refColumns }),
405
+ });
406
+ }
407
+ return edges;
408
+ }
409
+ /**
410
+ * Read every index on one object, and note which columns a single-column unique index covers.
411
+ * @param database - the open database.
412
+ * @param name - the object name.
413
+ * @returns index edges plus the set of singly-unique column names.
414
+ */
415
+ function readIndexes(database, name) {
416
+ const rows = queryAll(database, `PRAGMA index_list(${sqliteIdentifier(name)})`);
417
+ const edges = [];
418
+ const singleUnique = new Set();
419
+ for (const index of rows) {
420
+ const indexName = asString(index.name);
421
+ if (indexName === undefined)
422
+ continue;
423
+ const parts = queryAll(database, `PRAGMA index_info(${sqliteIdentifier(indexName)})`);
424
+ const columns = [];
425
+ for (const part of parts) {
426
+ const column = asString(part.name);
427
+ // An expression index has no column name (`name` is null); a column-name edge is the only
428
+ // shape the seam can express, so such an index is not recorded rather than recorded wrongly.
429
+ if (column !== undefined)
430
+ columns.push(column);
431
+ }
432
+ if (columns.length === 0)
433
+ continue;
434
+ const unique = asFiniteInteger(index.unique) === 1;
435
+ const only = columns.length === 1 ? columns[0] : undefined;
436
+ if (unique && only !== undefined)
437
+ singleUnique.add(only);
438
+ edges.push({ kind: 'index', name: indexName, columns, unique });
439
+ }
440
+ return { edges, singleUnique };
441
+ }
442
+ /**
443
+ * Add L2 enrichment to one object without touching the L1 object it was given.
444
+ * @param database - the open database.
445
+ * @param input - the connection, for the enum sample bound.
446
+ * @param object - the L1 object.
447
+ * @param warnings - collector for tolerated per-object failures.
448
+ * @returns the enriched object, or an L1 copy when its enrichment failed.
449
+ */
450
+ function enrichObject(database, input, object, warnings) {
451
+ let edges;
452
+ let singleUnique;
453
+ try {
454
+ const foreignKeys = readForeignKeys(database, object.name);
455
+ const indexes = readIndexes(database, object.name);
456
+ edges = [...foreignKeys, ...indexes.edges];
457
+ singleUnique = indexes.singleUnique;
458
+ }
459
+ catch (error) {
460
+ warnings.push(`sqlite: enrichment failed for "${object.name}": ${messageOf(error)}`);
461
+ return { ...object, columns: object.columns.map(column => ({ ...column })) };
462
+ }
463
+ // A single-column primary key is a single-column unique constraint too. A rowid primary key has
464
+ // no index for `index_list` to report, so this is the only way that fact reaches the catalog.
465
+ // (SQLite's legacy NULL quirk means a *nullable* non-INTEGER primary key is not a runtime unique
466
+ // guarantee; the catalog reports the declared constraint, which is what the schema says.)
467
+ const primaryKeys = object.columns.filter(column => column.isPrimaryKey === true);
468
+ const [onlyPrimaryKey] = primaryKeys;
469
+ if (primaryKeys.length === 1 && onlyPrimaryKey !== undefined) {
470
+ singleUnique = new Set([...singleUnique, onlyPrimaryKey.name]);
471
+ }
472
+ const columns = object.columns.map(column => {
473
+ const sampled = object.kind === 'table'
474
+ ? sampleEnumValues(database, input, object.name, column, warnings)
475
+ : undefined;
476
+ return {
477
+ ...column,
478
+ isUnique: singleUnique.has(column.name),
479
+ ...(sampled === undefined ? {} : { enumValues: sampled }),
480
+ };
481
+ });
482
+ return {
483
+ ...object,
484
+ columns,
485
+ ...(edges.length === 0 ? {} : { edges }),
486
+ };
487
+ }
488
+ /**
489
+ * Sample a bounded set of distinct literals for one text-affinity column.
490
+ * @param database - the open database.
491
+ * @param input - the connection, for the sample bound.
492
+ * @param table - the table name.
493
+ * @param column - the column being sampled.
494
+ * @param warnings - collector for a sample the server refused.
495
+ * @returns the literals, or `undefined` when sampling was skipped or produced nothing.
496
+ */
497
+ function sampleEnumValues(database, input, table, column, warnings) {
498
+ if (!isStringishSqliteType(column.type))
499
+ return undefined;
500
+ // A literal sampled from a password/token column would persist in the index; never take one.
501
+ if (isSensitiveName(input.connection, column.name))
502
+ return undefined;
503
+ if (!Number.isFinite(input.enumSampleLimit) || input.enumSampleLimit < 1)
504
+ return undefined;
505
+ const limit = Math.floor(input.enumSampleLimit);
506
+ const identifier = sqliteIdentifier(column.name);
507
+ const sql = `SELECT DISTINCT ${identifier} AS value FROM ${sqliteIdentifier(table)}`
508
+ + ` WHERE ${identifier} IS NOT NULL LIMIT ?`;
509
+ try {
510
+ const rows = database.prepare(sql).all(limit);
511
+ const values = [];
512
+ for (const row of rows) {
513
+ const value = enumText(row['value']);
514
+ // DISTINCT already deduplicated on the server; the set guards a type-coerced collision
515
+ // (1 and '1' in a text-affinity column).
516
+ if (value !== undefined && !values.includes(value))
517
+ values.push(value);
518
+ }
519
+ return values.length === 0 ? undefined : values;
520
+ }
521
+ catch (error) {
522
+ warnings.push(`sqlite: enum sampling failed for "${table}.${column.name}": ${messageOf(error)}`);
523
+ return undefined;
524
+ }
525
+ }
526
+ /**
527
+ * Render one sampled value as text.
528
+ * @param value - a driver value.
529
+ * @returns the text form, or `undefined` for values that are not meaningful enum literals.
530
+ */
531
+ function enumText(value) {
532
+ if (typeof value === 'string')
533
+ return value;
534
+ if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
535
+ return String(value);
536
+ }
537
+ return undefined;
538
+ }
539
+ /**
540
+ * The SQLite library version, when it can be read.
541
+ * @param database - the open database.
542
+ * @returns the version string, or `undefined`; a version probe never fails a catalog.
543
+ */
544
+ function readServerVersion(database) {
545
+ try {
546
+ const row = database.prepare(SERVER_VERSION_QUERY).get();
547
+ return asString(row?.['version']);
548
+ }
549
+ catch {
550
+ return undefined;
551
+ }
552
+ }
553
+ /**
554
+ * Run a catalog query and hand back plain row objects.
555
+ * @param database - the open database.
556
+ * @param sql - a statement with no bound parameters.
557
+ * @returns rows as ordinary objects (the driver returns null-prototype objects).
558
+ * @throws DbgraphError with `DBGRAPH_QUERY_FAILED` when the statement fails.
559
+ */
560
+ function queryAll(database, sql) {
561
+ try {
562
+ return database.prepare(sql).all().map(row => ({ ...row }));
563
+ }
564
+ catch (error) {
565
+ throw new DbgraphError(`SQLite catalog query failed: ${messageOf(error)}`, DBGRAPH_QUERY_FAILED_CODE);
566
+ }
567
+ }
568
+ /**
569
+ * A finite integer field, from a driver value of unknown shape.
570
+ * @param value - the field value.
571
+ * @returns the integer, or `undefined`.
572
+ */
573
+ function asFiniteInteger(value) {
574
+ if (typeof value === 'number')
575
+ return Number.isInteger(value) ? value : undefined;
576
+ if (typeof value === 'bigint') {
577
+ const converted = Number(value);
578
+ return Number.isSafeInteger(converted) ? converted : undefined;
579
+ }
580
+ return undefined;
581
+ }
582
+ //# sourceMappingURL=sqlite.js.map