@adminiumjs/adapter-sqlite 0.1.0-rc.1

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,641 @@
1
+ /**
2
+ * SQLite introspection — 05-introspection-engine.md §4.3.
3
+ *
4
+ * Reads pragma table-valued functions joined against `sqlite_master`, so the
5
+ * statement set stays FIXED regardless of table count (the per-table pragma
6
+ * loop happens inside SQLite, not over the executor). This module is
7
+ * executor-agnostic — `introspectSqlite` takes a `CatalogExecutor` (any
8
+ * `sql → rows` function) so the assembly logic is testable without the
9
+ * `better-sqlite3` driver; `src/index.ts` wires it to the database handle.
10
+ * Mirrors the postgres reference adapter file for file.
11
+ *
12
+ * THE "SCHEMA ONLY" INVARIANT (05 §10): every statement here references
13
+ * `sqlite_master` / `pragma_*` / `sqlite_stat1` exclusively — with the one
14
+ * documented exception of §4.3: exact `COUNT(*)` per table on files
15
+ * < 100 MB, which touches no column values.
16
+ */
17
+ import { AdapterError, ENGINE_CAPABILITY_MATRIX, } from '@adminium/engine/adapter';
18
+ import { quoteIdentifier } from './serialization.js';
19
+ import { classifyDefault, mapSqliteType } from './type-map.js';
20
+ /**
21
+ * Static dialect capabilities — 05 §2.1/§4.3 (probe refines per-connection).
22
+ * Canonical values live in the engine's capability matrix (M9-T04) so the
23
+ * wizard's degradation copy and the adapter never disagree.
24
+ */
25
+ export const SQLITE_CAPABILITIES = {
26
+ ...ENGINE_CAPABILITY_MATRIX.sqlite,
27
+ };
28
+ /** §4.3: exact per-table `COUNT(*)` only when the file is under 100 MB. */
29
+ export const EXACT_COUNT_MAX_FILE_BYTES = 100 * 1024 * 1024;
30
+ /** Migration/meta tables hidden behind "show system tables" — 05 §8.2. */
31
+ const SYSTEM_TABLE_PATTERN = /^(adminium_|knex_migrations)|^(_prisma_migrations|schema_migrations|ar_internal_metadata|django_migrations|sqlite_sequence)$/;
32
+ /** Enum values are capped at 256 with a warning — 05 §10. */
33
+ const ENUM_VALUE_CAP = 256;
34
+ // ---------------------------------------------------------------------------
35
+ // Catalog SQL (fixed set)
36
+ // ---------------------------------------------------------------------------
37
+ /** SQLite ≥ 3.37 — guarded with a sqlite_master fallback (05 §4.3). */
38
+ export const TABLE_LIST_SQL = `
39
+ SELECT name, type, wr, strict
40
+ FROM pragma_table_list
41
+ WHERE schema = 'main' AND name NOT LIKE 'sqlite_%'
42
+ ORDER BY name`;
43
+ /** DDL source — CHECK extraction, AUTOINCREMENT, WITHOUT ROWID fallback. */
44
+ export const MASTER_SQL = `
45
+ SELECT name, type, sql
46
+ FROM sqlite_master
47
+ WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
48
+ ORDER BY name`;
49
+ /** table_xinfo includes hidden/generated columns (05 §4.3). */
50
+ export const COLUMNS_SQL = `
51
+ SELECT m.name AS table_name,
52
+ ti.cid AS ordinal,
53
+ ti.name AS column_name,
54
+ ti.type AS declared_type,
55
+ ti."notnull" AS not_null,
56
+ ti.dflt_value AS default_text,
57
+ ti.pk AS pk_ordinal,
58
+ ti.hidden AS hidden
59
+ FROM sqlite_master m
60
+ JOIN pragma_table_xinfo(m.name) ti
61
+ WHERE m.type IN ('table', 'view') AND m.name NOT LIKE 'sqlite_%'
62
+ ORDER BY m.name, ti.cid`;
63
+ export const FOREIGN_KEYS_SQL = `
64
+ SELECT m.name AS table_name,
65
+ fk.id AS fk_id,
66
+ fk.seq AS seq,
67
+ fk."table" AS ref_table,
68
+ fk."from" AS from_column,
69
+ fk."to" AS to_column,
70
+ fk.on_update AS on_update,
71
+ fk.on_delete AS on_delete
72
+ FROM sqlite_master m
73
+ JOIN pragma_foreign_key_list(m.name) fk
74
+ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
75
+ ORDER BY m.name, fk.id, fk.seq`;
76
+ export const INDEX_LIST_SQL = `
77
+ SELECT m.name AS table_name,
78
+ il.name AS index_name,
79
+ il."unique" AS is_unique,
80
+ il.origin AS origin,
81
+ il.partial AS partial
82
+ FROM sqlite_master m
83
+ JOIN pragma_index_list(m.name) il
84
+ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
85
+ ORDER BY m.name, il.name`;
86
+ export const INDEX_COLUMNS_SQL = `
87
+ SELECT m.name AS table_name,
88
+ il.name AS index_name,
89
+ ii.seqno AS seqno,
90
+ ii.name AS column_name
91
+ FROM sqlite_master m
92
+ JOIN pragma_index_list(m.name) il
93
+ JOIN pragma_index_info(il.name) ii
94
+ WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%'
95
+ ORDER BY m.name, il.name, ii.seqno`;
96
+ /** Guarded: only present after ANALYZE has run (05 §4.3). */
97
+ export const STAT1_SQL = `
98
+ SELECT tbl AS table_name, idx AS index_name, stat AS stat
99
+ FROM sqlite_stat1`;
100
+ function quoteLiteral(value) {
101
+ return `'${value.replaceAll("'", "''")}'`;
102
+ }
103
+ /**
104
+ * One UNION ALL statement counting every included table — the §4.3 small-file
105
+ * exception. COUNT(*) touches no column values; `rowCountExact: true`.
106
+ */
107
+ export function exactCountsSql(tableNames) {
108
+ return tableNames
109
+ .map((name) => `SELECT ${quoteLiteral(name)} AS table_name, count(*) AS n FROM ${quoteIdentifier(name)}`)
110
+ .join('\nUNION ALL\n');
111
+ }
112
+ // ---------------------------------------------------------------------------
113
+ // Row coercion
114
+ // ---------------------------------------------------------------------------
115
+ function num(value) {
116
+ if (value === null || value === undefined)
117
+ return null;
118
+ const n = Number(value);
119
+ return Number.isFinite(n) ? n : null;
120
+ }
121
+ function str(value) {
122
+ return typeof value === 'string' && value.length > 0 ? value : null;
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // DDL scanning: CHECK constraints, AUTOINCREMENT, WITHOUT ROWID / STRICT.
126
+ // CHECK expressions and enum synthesis reuse the same `col IN (...)` parsing
127
+ // approach as the postgres adapter — duplicated by design (adapters never
128
+ // import each other, 05 §4.3 note).
129
+ // ---------------------------------------------------------------------------
130
+ /** Skip a quoted region starting at `i`; returns the index AFTER it. */
131
+ function skipQuoted(ddl, i) {
132
+ const open = ddl[i];
133
+ const close = open === '[' ? ']' : open;
134
+ let j = i + 1;
135
+ while (j < ddl.length) {
136
+ if (ddl[j] === close) {
137
+ // '' / "" / `` escape by doubling ([ ] does not double)
138
+ if (close !== ']' && ddl[j + 1] === close) {
139
+ j += 2;
140
+ continue;
141
+ }
142
+ return j + 1;
143
+ }
144
+ j += 1;
145
+ }
146
+ return j;
147
+ }
148
+ /**
149
+ * Scan a CREATE TABLE statement for CHECK constraints (column-level and
150
+ * table-level), respecting strings/quoted identifiers. Returns the balanced
151
+ * inner expression of each `CHECK (…)` plus the `CONSTRAINT <name>` when
152
+ * present.
153
+ */
154
+ export function scanCheckConstraints(ddl) {
155
+ const results = [];
156
+ const tokens = []; // trailing significant identifiers/keywords
157
+ let i = 0;
158
+ while (i < ddl.length) {
159
+ const ch = ddl[i] ?? '';
160
+ if (ch === "'" || ch === '"' || ch === '`' || ch === '[') {
161
+ const end = skipQuoted(ddl, i);
162
+ tokens.push(ddl.slice(i + 1, end - 1));
163
+ i = end;
164
+ continue;
165
+ }
166
+ if (ch === '-' && ddl[i + 1] === '-') {
167
+ const nl = ddl.indexOf('\n', i);
168
+ i = nl === -1 ? ddl.length : nl + 1;
169
+ continue;
170
+ }
171
+ if (/[A-Za-z_]/.test(ch)) {
172
+ let j = i;
173
+ while (j < ddl.length && /[\w$]/.test(ddl[j] ?? ''))
174
+ j += 1;
175
+ const word = ddl.slice(i, j);
176
+ if (word.toUpperCase() === 'CHECK') {
177
+ // optional CONSTRAINT <name> immediately before
178
+ const prev = tokens[tokens.length - 2];
179
+ const name = prev !== undefined && prev.toUpperCase() === 'CONSTRAINT'
180
+ ? (tokens[tokens.length - 1] ?? null)
181
+ : null;
182
+ // find the balanced ( … ) that follows
183
+ let k = j;
184
+ while (k < ddl.length && ddl[k] !== '(')
185
+ k += 1;
186
+ let depth = 0;
187
+ let end = k;
188
+ while (end < ddl.length) {
189
+ const c = ddl[end] ?? '';
190
+ if (c === "'" || c === '"' || c === '`' || c === '[') {
191
+ end = skipQuoted(ddl, end);
192
+ continue;
193
+ }
194
+ if (c === '(')
195
+ depth += 1;
196
+ else if (c === ')') {
197
+ depth -= 1;
198
+ if (depth === 0)
199
+ break;
200
+ }
201
+ end += 1;
202
+ }
203
+ if (k < ddl.length && end > k) {
204
+ results.push({ name, expression: ddl.slice(k + 1, end).trim() });
205
+ }
206
+ i = end + 1;
207
+ tokens.push(word);
208
+ continue;
209
+ }
210
+ tokens.push(word);
211
+ i = j;
212
+ continue;
213
+ }
214
+ i += 1;
215
+ }
216
+ return results;
217
+ }
218
+ const CHECK_IN_PATTERN = /["'`[]?([a-zA-Z_][\w$]*)["'`\]]?\s+in\s*\(([^)]+)\)/i;
219
+ /** Parse a `col IN ('a','b')` check shape into a synthesized enum (05 §4.3). */
220
+ export function parseCheckEnum(expression) {
221
+ const match = CHECK_IN_PATTERN.exec(expression);
222
+ if (match?.[1] === undefined || match[2] === undefined)
223
+ return null;
224
+ const values = [];
225
+ for (const literal of match[2].matchAll(/'((?:[^']|'')*)'/g)) {
226
+ values.push((literal[1] ?? '').replaceAll("''", "'"));
227
+ }
228
+ return values.length > 0 ? { column: match[1], values } : null;
229
+ }
230
+ /** Tail of the DDL after the closing paren — WITHOUT ROWID / STRICT options. */
231
+ function tableOptions(ddl) {
232
+ if (ddl === null)
233
+ return { withoutRowid: false, strict: false };
234
+ const tail = ddl.slice(ddl.lastIndexOf(')') + 1);
235
+ return {
236
+ withoutRowid: /without\s+rowid/i.test(tail),
237
+ strict: /\bstrict\b/i.test(tail),
238
+ };
239
+ }
240
+ // ---------------------------------------------------------------------------
241
+ // Assembly
242
+ // ---------------------------------------------------------------------------
243
+ const FK_ACTION_MAP = {
244
+ CASCADE: 'cascade',
245
+ RESTRICT: 'restrict',
246
+ 'SET NULL': 'set-null',
247
+ 'SET DEFAULT': 'set-default',
248
+ 'NO ACTION': 'no-action',
249
+ };
250
+ function compareBy(key) {
251
+ return (a, b) => {
252
+ const ka = key(a);
253
+ const kb = key(b);
254
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
255
+ };
256
+ }
257
+ /**
258
+ * Run the fixed catalog query set through `exec` and assemble the
259
+ * `DatabaseModel`. Schema only — never touches user rows (except the §4.3
260
+ * small-file COUNT exception, which reads no column values).
261
+ */
262
+ export async function introspectSqlite(exec, ctx, opts = {}) {
263
+ const startedAt = Date.now();
264
+ const timeoutMs = opts.timeoutMs ?? 30_000;
265
+ const collectRowEstimates = opts.collectRowEstimates ?? true;
266
+ const warnings = [];
267
+ const schema = 'main';
268
+ const guarded = async (sql) => {
269
+ if (Date.now() - startedAt > timeoutMs) {
270
+ throw new AdapterError('TIMEOUT', 'introspection exceeded the total time budget', {
271
+ detail: `timeoutMs=${timeoutMs}`,
272
+ hint: 'raise IntrospectOptions.timeoutMs or narrow the table selection',
273
+ });
274
+ }
275
+ return exec(sql);
276
+ };
277
+ // 1. DDL master list (always) ------------------------------------------------
278
+ const masterRows = await guarded(MASTER_SQL);
279
+ const ddlByTable = new Map(masterRows.map((row) => [str(row['name']) ?? '', str(row['sql'])]));
280
+ // 2. table_list (SQLite ≥ 3.37; fallback: derive wr/strict from the DDL) ----
281
+ const tableFlags = new Map();
282
+ try {
283
+ for (const row of await guarded(TABLE_LIST_SQL)) {
284
+ tableFlags.set(str(row['name']) ?? '', {
285
+ withoutRowid: num(row['wr']) === 1,
286
+ strict: num(row['strict']) === 1,
287
+ });
288
+ }
289
+ }
290
+ catch (error) {
291
+ if (error instanceof AdapterError && error.code === 'TIMEOUT')
292
+ throw error;
293
+ for (const [name, ddl] of ddlByTable)
294
+ tableFlags.set(name, tableOptions(ddl));
295
+ warnings.push({
296
+ code: 'table-list-unavailable',
297
+ message: 'pragma_table_list is unavailable (SQLite < 3.37) — flags derived from DDL',
298
+ tableId: null,
299
+ });
300
+ }
301
+ // 3–5. Columns, foreign keys, indexes (set-based over pragma TVF joins) -----
302
+ const columnRows = await guarded(COLUMNS_SQL);
303
+ const fkRows = await guarded(FOREIGN_KEYS_SQL);
304
+ const indexListRows = await guarded(INDEX_LIST_SQL);
305
+ const indexColumnRows = await guarded(INDEX_COLUMNS_SQL);
306
+ // 6. sqlite_stat1 (guarded: only exists after ANALYZE) ----------------------
307
+ const statByTable = new Map();
308
+ try {
309
+ for (const row of await guarded(STAT1_SQL)) {
310
+ const name = str(row['table_name']) ?? '';
311
+ const stat = str(row['stat']) ?? '';
312
+ const rows = Number.parseInt(stat.split(' ')[0] ?? '', 10);
313
+ if (Number.isFinite(rows)) {
314
+ statByTable.set(name, Math.max(statByTable.get(name) ?? 0, rows));
315
+ }
316
+ }
317
+ }
318
+ catch (error) {
319
+ if (error instanceof AdapterError && error.code === 'TIMEOUT')
320
+ throw error;
321
+ // No ANALYZE yet — small files fall through to exact counts below.
322
+ }
323
+ // -- tables -----------------------------------------------------------------
324
+ const tables = new Map();
325
+ const autoincrementTables = new Set();
326
+ for (const row of masterRows) {
327
+ const name = str(row['name']) ?? '';
328
+ if (opts.tableFilter !== undefined && !opts.tableFilter({ schema, name }))
329
+ continue;
330
+ const kind = str(row['type']) === 'view' ? 'view' : 'table';
331
+ const ddl = str(row['sql']);
332
+ if (kind === 'table' && ddl !== null && /\bAUTOINCREMENT\b/i.test(ddl)) {
333
+ autoincrementTables.add(name);
334
+ }
335
+ tables.set(name, {
336
+ id: `${schema}.${name}`,
337
+ schema,
338
+ name,
339
+ kind,
340
+ comment: null, // SQLite has no comment syntax (hasComments: false)
341
+ columns: [],
342
+ primaryKey: [],
343
+ uniques: [],
344
+ checks: [],
345
+ indexes: [],
346
+ rowCountEstimate: null, // filled from stat1 / exact counts below
347
+ rowCountExact: false,
348
+ sizeBytes: null, // per-table sizes need the dbstat vtable — out of scope v1
349
+ activity: null,
350
+ rls: null, // Postgres only
351
+ system: SYSTEM_TABLE_PATTERN.test(name),
352
+ semantics: null,
353
+ });
354
+ }
355
+ // -- columns ----------------------------------------------------------------
356
+ const pkOrdinals = new Map();
357
+ for (const row of columnRows) {
358
+ const tableName = str(row['table_name']) ?? '';
359
+ const table = tables.get(tableName);
360
+ if (table === undefined)
361
+ continue;
362
+ const hidden = num(row['hidden']) ?? 0;
363
+ if (hidden === 1)
364
+ continue; // truly hidden columns are not part of the model
365
+ const columnName = str(row['column_name']) ?? '';
366
+ const declaredType = str(row['declared_type']) ?? '';
367
+ const strict = tableFlags.get(tableName)?.strict ?? false;
368
+ const mapped = mapSqliteType(declaredType, { strict });
369
+ if (declaredType === '' && table.kind === 'table') {
370
+ warnings.push({
371
+ code: 'untyped-column',
372
+ message: `column "${tableName}.${columnName}" has no declared type — add one for a better mapping`,
373
+ tableId: table.id,
374
+ });
375
+ }
376
+ const pkOrdinal = num(row['pk_ordinal']) ?? 0;
377
+ if (pkOrdinal > 0) {
378
+ const list = pkOrdinals.get(tableName) ?? [];
379
+ list.push({ name: columnName, ordinal: pkOrdinal });
380
+ pkOrdinals.set(tableName, list);
381
+ }
382
+ table.columns.push({
383
+ name: columnName,
384
+ // table_xinfo.cid is 0-based; the IR convention is 1-based (pg attnum).
385
+ ordinal: (num(row['ordinal']) ?? 0) + 1,
386
+ dbType: declaredType, // verbatim — '' means "no declared type" (BLOB affinity)
387
+ logicalType: mapped.logicalType,
388
+ nullable: num(row['not_null']) !== 1,
389
+ default: hidden === 2 || hidden === 3 ? null : classifyDefault(str(row['default_text'])),
390
+ isPrimaryKey: false,
391
+ isUnique: false,
392
+ isGenerated: hidden === 2 || hidden === 3, // 2 = virtual, 3 = stored
393
+ enumRef: null,
394
+ maxLength: mapped.maxLength,
395
+ numericPrecision: mapped.numericPrecision,
396
+ numericScale: mapped.numericScale,
397
+ isArray: false,
398
+ comment: null,
399
+ references: null,
400
+ semantics: null,
401
+ });
402
+ }
403
+ // -- primary keys + rowid/AUTOINCREMENT defaults (05 §4.3) -------------------
404
+ for (const [tableName, list] of pkOrdinals) {
405
+ const table = tables.get(tableName);
406
+ if (table === undefined)
407
+ continue;
408
+ table.primaryKey = list.sort((a, b) => a.ordinal - b.ordinal).map((c) => c.name);
409
+ for (const column of table.columns) {
410
+ if (table.primaryKey.includes(column.name))
411
+ column.isPrimaryKey = true;
412
+ }
413
+ const withoutRowid = tableFlags.get(tableName)?.withoutRowid ?? false;
414
+ if (!withoutRowid && table.primaryKey.length === 1) {
415
+ const pkColumn = table.columns.find((c) => c.name === table.primaryKey[0]);
416
+ // INTEGER PRIMARY KEY is the rowid alias: auto-assigned on insert.
417
+ // AUTOINCREMENT additionally pins monotonicity via sqlite_sequence.
418
+ if (pkColumn !== undefined &&
419
+ (pkColumn.dbType.trim().toUpperCase() === 'INTEGER' ||
420
+ autoincrementTables.has(tableName)) &&
421
+ pkColumn.logicalType === 'integer' &&
422
+ pkColumn.default === null) {
423
+ pkColumn.default = { kind: 'autoincrement' };
424
+ }
425
+ }
426
+ }
427
+ // -- checks + enum synthesis from the DDL ------------------------------------
428
+ const checkEnums = new Map();
429
+ for (const [tableName, ddl] of ddlByTable) {
430
+ const table = tables.get(tableName);
431
+ if (table === undefined || table.kind !== 'table' || ddl === null)
432
+ continue;
433
+ for (const check of scanCheckConstraints(ddl)) {
434
+ table.checks.push({ name: check.name, expression: check.expression });
435
+ const parsed = parseCheckEnum(check.expression);
436
+ if (parsed === null || !table.columns.some((c) => c.name === parsed.column))
437
+ continue;
438
+ const id = `${table.id}.${parsed.column}`;
439
+ const column = table.columns.find((c) => c.name === parsed.column);
440
+ if (column === undefined || column.enumRef !== null)
441
+ continue;
442
+ let values = parsed.values;
443
+ if (values.length > ENUM_VALUE_CAP) {
444
+ warnings.push({
445
+ code: 'enum-capped',
446
+ message: `enum "${id}" has ${values.length} values; capped at ${ENUM_VALUE_CAP}`,
447
+ tableId: table.id,
448
+ });
449
+ values = values.slice(0, ENUM_VALUE_CAP);
450
+ }
451
+ checkEnums.set(id, { id, name: parsed.column, values, source: 'check' });
452
+ column.enumRef = id;
453
+ }
454
+ }
455
+ // -- indexes ------------------------------------------------------------------
456
+ const indexColumns = new Map();
457
+ for (const row of indexColumnRows) {
458
+ const indexName = str(row['index_name']) ?? '';
459
+ const columnName = str(row['column_name']); // null for rowid/expression parts
460
+ const list = indexColumns.get(indexName) ?? [];
461
+ if (columnName !== null)
462
+ list.push({ seq: num(row['seqno']) ?? 0, name: columnName });
463
+ indexColumns.set(indexName, list);
464
+ }
465
+ for (const row of indexListRows) {
466
+ const table = tables.get(str(row['table_name']) ?? '');
467
+ if (table === undefined)
468
+ continue;
469
+ const indexName = str(row['index_name']) ?? '';
470
+ const origin = str(row['origin']) ?? 'c';
471
+ const unique = num(row['is_unique']) === 1;
472
+ const partial = num(row['partial']) === 1;
473
+ const columns = (indexColumns.get(indexName) ?? [])
474
+ .sort((a, b) => a.seq - b.seq)
475
+ .map((c) => c.name);
476
+ table.indexes.push({
477
+ name: indexName,
478
+ columns,
479
+ expression: null, // expression indexes surface with empty columns in v1
480
+ unique,
481
+ primary: origin === 'pk',
482
+ method: null, // SQLite has one index structure; no method taxonomy
483
+ partial,
484
+ });
485
+ if (unique && origin === 'u') {
486
+ table.uniques.push({ name: indexName, columns });
487
+ }
488
+ // Covered by a single-column unique constraint/index (05 §2.1) — the PK
489
+ // autoindex counts, mirroring the pg reference adapter. (INTEGER PRIMARY
490
+ // KEY rowid aliases have no autoindex; isPrimaryKey already covers them.)
491
+ if (unique && !partial && columns.length === 1) {
492
+ const column = table.columns.find((c) => c.name === columns[0]);
493
+ if (column !== undefined)
494
+ column.isUnique = true;
495
+ }
496
+ }
497
+ // -- foreign keys (pragma foreign_key_list) ----------------------------------
498
+ const relations = [];
499
+ const fkAccumulators = new Map();
500
+ for (const row of fkRows) {
501
+ const table = tables.get(str(row['table_name']) ?? '');
502
+ if (table === undefined)
503
+ continue;
504
+ const key = `${table.name}\x00${num(row['fk_id']) ?? 0}`;
505
+ let acc = fkAccumulators.get(key);
506
+ if (acc === undefined) {
507
+ acc = {
508
+ table,
509
+ refTable: str(row['ref_table']) ?? '',
510
+ columns: [],
511
+ onUpdate: FK_ACTION_MAP[str(row['on_update']) ?? ''] ?? null,
512
+ onDelete: FK_ACTION_MAP[str(row['on_delete']) ?? ''] ?? null,
513
+ };
514
+ fkAccumulators.set(key, acc);
515
+ }
516
+ acc.columns.push({
517
+ seq: num(row['seq']) ?? 0,
518
+ from: str(row['from_column']) ?? '',
519
+ to: str(row['to_column']), // null = implicit reference to the target PK
520
+ });
521
+ }
522
+ for (const acc of fkAccumulators.values()) {
523
+ const target = tables.get(acc.refTable);
524
+ const toTableId = `${schema}.${acc.refTable}`;
525
+ if (target === undefined) {
526
+ warnings.push({
527
+ code: 'fk-target-excluded',
528
+ message: `foreign key on "${acc.table.name}" references "${toTableId}" which is outside the introspected set`,
529
+ tableId: acc.table.id,
530
+ });
531
+ continue;
532
+ }
533
+ const ordered = acc.columns.sort((a, b) => a.seq - b.seq);
534
+ const columns = ordered.map((c) => c.from);
535
+ const refColumns = ordered.map((c, i) => c.to ?? target.primaryKey[i] ?? '');
536
+ if (refColumns.some((c) => c === ''))
537
+ continue; // unresolvable implicit ref
538
+ relations.push({
539
+ id: `fk:${acc.table.id}(${columns.join(',')})->${toTableId}(${refColumns.join(',')})`,
540
+ kind: 'declared-fk',
541
+ cardinality: 'one-to-many', // refined below once uniques are known
542
+ from: { tableId: acc.table.id, columns },
543
+ to: { tableId: toTableId, columns: refColumns },
544
+ through: null,
545
+ onDelete: acc.onDelete,
546
+ onUpdate: acc.onUpdate,
547
+ selfReferential: acc.table.id === toTableId,
548
+ confidence: 1,
549
+ });
550
+ columns.forEach((columnName, position) => {
551
+ const column = acc.table.columns.find((c) => c.name === columnName);
552
+ const refColumn = refColumns[position];
553
+ if (column !== undefined && column.references === null && refColumn !== undefined) {
554
+ column.references = { tableId: toTableId, column: refColumn };
555
+ }
556
+ });
557
+ }
558
+ // -- one-to-one refinement: FK column set covered by a unique key -----------
559
+ for (const relation of relations) {
560
+ const table = [...tables.values()].find((t) => t.id === relation.from.tableId);
561
+ if (table === undefined)
562
+ continue;
563
+ const key = [...relation.from.columns].sort().join(' ');
564
+ const uniqueSets = [
565
+ table.primaryKey,
566
+ ...table.uniques.map((u) => u.columns),
567
+ ...table.indexes.filter((i) => i.unique && !i.partial).map((i) => i.columns),
568
+ ];
569
+ if (uniqueSets.some((set) => set.length > 0 && [...set].sort().join(' ') === key)) {
570
+ relation.cardinality = 'one-to-one';
571
+ }
572
+ }
573
+ // -- row counts: stat1 estimates, else small-file exact counts (05 §4.3) ----
574
+ if (collectRowEstimates) {
575
+ const uncounted = [];
576
+ for (const table of tables.values()) {
577
+ if (table.kind !== 'table')
578
+ continue;
579
+ const estimate = statByTable.get(table.name);
580
+ if (estimate !== undefined) {
581
+ table.rowCountEstimate = estimate;
582
+ }
583
+ else if (ctx.fileSizeBytes !== null &&
584
+ ctx.fileSizeBytes < EXACT_COUNT_MAX_FILE_BYTES) {
585
+ uncounted.push(table.name);
586
+ }
587
+ }
588
+ if (uncounted.length > 0) {
589
+ const countRows = await guarded(exactCountsSql(uncounted));
590
+ for (const row of countRows) {
591
+ const table = tables.get(str(row['table_name']) ?? '');
592
+ const count = num(row['n']);
593
+ if (table !== undefined && count !== null) {
594
+ table.rowCountEstimate = count;
595
+ table.rowCountExact = true; // 05 §2.1: true only for SQLite exact counts
596
+ }
597
+ }
598
+ }
599
+ else if (ctx.fileSizeBytes !== null &&
600
+ ctx.fileSizeBytes >= EXACT_COUNT_MAX_FILE_BYTES &&
601
+ [...tables.values()].some((t) => t.kind === 'table' && t.rowCountEstimate === null)) {
602
+ warnings.push({
603
+ code: 'counts-unavailable',
604
+ message: 'file exceeds the exact-count threshold — run ANALYZE for row counts',
605
+ tableId: null,
606
+ });
607
+ }
608
+ }
609
+ // -- final assembly ----------------------------------------------------------
610
+ const enums = [...checkEnums.values()].sort(compareBy((def) => def.id));
611
+ const sortedTables = [...tables.values()].sort(compareBy((t) => t.id));
612
+ for (const table of sortedTables) {
613
+ table.columns.sort((a, b) => a.ordinal - b.ordinal);
614
+ table.indexes.sort(compareBy((i) => i.name));
615
+ table.uniques.sort(compareBy((u) => u.name ?? ''));
616
+ table.checks.sort(compareBy((c) => c.name ?? ''));
617
+ }
618
+ relations.sort(compareBy((r) => r.id));
619
+ const columnCount = sortedTables.reduce((total, table) => total + table.columns.length, 0);
620
+ return {
621
+ irVersion: 1,
622
+ dialect: 'sqlite',
623
+ source: { kind: 'live', connectionId: ctx.connectionId },
624
+ name: ctx.databaseName,
625
+ defaultSchema: schema,
626
+ schemas: [schema],
627
+ tables: sortedTables,
628
+ enums,
629
+ relations,
630
+ capabilities: { ...SQLITE_CAPABILITIES },
631
+ introspectedAt: new Date().toISOString(),
632
+ stats: {
633
+ tableCount: sortedTables.length,
634
+ columnCount,
635
+ relationCount: relations.length,
636
+ durationMs: Date.now() - startedAt,
637
+ },
638
+ warnings,
639
+ };
640
+ }
641
+ //# sourceMappingURL=introspect.js.map