import Database from 'better-sqlite3'; import { writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createSqliteLiveDatabaseIntrospection } from '../../../src/connectors/sqlite/live-database-introspection.js'; import { isKtxSqliteConnectionConfig, KtxSqliteScanConnector, sqliteDatabasePathFromConfig } from '../../../src/connectors/sqlite/connector.js'; import { tableRefSet } from '../../../src/context/scan/table-ref.js'; describe('KtxSqliteScanConnector', () => { let tempDir: string; let dbPath: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'ktx-connector-sqlite-')); dbPath = join(tempDir, 'warehouse.db'); const db = new Database(dbPath); db.exec(` PRAGMA foreign_keys = ON; CREATE TABLE customers ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, tier TEXT ); CREATE TABLE orders ( id INTEGER PRIMARY KEY, customer_id INTEGER NOT NULL, status TEXT, total NUMERIC, created_at TEXT, FOREIGN KEY(customer_id) REFERENCES customers(id) ); CREATE VIEW recent_orders AS SELECT id, customer_id, status FROM orders; INSERT INTO customers (id, name, tier) VALUES (1, 'Ada', 'enterprise'), (2, 'Grace', 'growth'); INSERT INTO orders (id, customer_id, status, total, created_at) VALUES (10, 1, 'paid', 42.5, '2026-04-28'), (11, 2, 'open', 9.5, '2026-04-29'); `); db.close(); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); it('resolves SQLite path configuration safely', () => { const originalDatabaseUrl = process.env.KTX_SQLITE_TEST_URL; const pointerPath = join(tempDir, 'sqlite-path.txt'); process.env.KTX_SQLITE_TEST_URL = `sqlite:${dbPath}`; writeFileSync(pointerPath, dbPath, 'utf-8'); try { expect(isKtxSqliteConnectionConfig({ driver: 'sqlite', path: 'warehouse.db' })).toBe(true); expect(isKtxSqliteConnectionConfig({ driver: 'postgres', url: 'env:DATABASE_URL' })).toBe(false); expect( sqliteDatabasePathFromConfig({ connectionId: 'warehouse', projectDir: tempDir, connection: { driver: 'sqlite', path: 'warehouse.db' }, }), ).toBe(dbPath); expect( sqliteDatabasePathFromConfig({ connectionId: 'warehouse', projectDir: tempDir, connection: { driver: 'sqlite', url: 'env:KTX_SQLITE_TEST_URL' }, }), ).toBe(dbPath); expect( sqliteDatabasePathFromConfig({ connectionId: 'warehouse', projectDir: tempDir, connection: { driver: 'sqlite', url: `file://${dbPath}` }, }), ).toBe(dbPath); expect( sqliteDatabasePathFromConfig({ connectionId: 'warehouse', projectDir: tempDir, connection: { driver: 'sqlite', path: `file:${pointerPath}` }, }), ).toBe(dbPath); expect( sqliteDatabasePathFromConfig({ connectionId: 'warehouse', projectDir: tempDir, connection: { driver: 'sqlite', path: 'warehouse.db' }, }), ).toBe(dbPath); expect(() => sqliteDatabasePathFromConfig({ connectionId: 'warehouse', projectDir: tempDir, connection: { driver: 'sqlite', file_path: 'warehouse.db' }, }), ).toThrow('Native SQLite connector requires connections.warehouse.path or url'); } finally { if (originalDatabaseUrl === undefined) { delete process.env.KTX_SQLITE_TEST_URL; } else { process.env.KTX_SQLITE_TEST_URL = originalDatabaseUrl; } } }); it('introspects schema, primary keys, row counts, views, and foreign keys', async () => { const connector = new KtxSqliteScanConnector({ connectionId: 'warehouse', connection: { driver: 'sqlite', path: dbPath }, now: () => new Date('2026-04-29T10:00:00.000Z'), }); const snapshot = await connector.introspect( { connectionId: 'warehouse', driver: 'sqlite' }, { runId: 'scan-run-1' }, ); expect(snapshot).toMatchObject({ connectionId: 'warehouse', driver: 'sqlite', extractedAt: '2026-04-29T10:00:00.000Z', metadata: { file_path: dbPath, table_count: 3, total_columns: 11, }, }); expect(snapshot.tables.map((table) => [table.name, table.kind, table.estimatedRows])).toEqual([ ['customers', 'table', 2], ['orders', 'table', 2], ['recent_orders', 'view', null], ]); expect(snapshot.tables.find((table) => table.name === 'customers')?.columns[0]).toMatchObject({ name: 'id', nativeType: 'INTEGER', normalizedType: 'INTEGER', dimensionType: 'number', nullable: false, primaryKey: true, }); expect(snapshot.tables.find((table) => table.name === 'orders')?.foreignKeys).toEqual([ { fromColumn: 'customer_id', toCatalog: null, toDb: null, toTable: 'customers', toColumn: 'id', constraintName: null, }, ]); }); it('lists schemaless tables and views for setup discovery', async () => { const connector = new KtxSqliteScanConnector({ connectionId: 'warehouse', connection: { driver: 'sqlite', path: dbPath }, }); await expect(connector.listSchemas()).resolves.toEqual([]); await expect(connector.listTables(['ignored'])).resolves.toEqual([ { catalog: null, schema: '', name: 'customers', kind: 'table' }, { catalog: null, schema: '', name: 'orders', kind: 'table' }, { catalog: null, schema: '', name: 'recent_orders', kind: 'view' }, ]); }); it('runs samples, distinct values, statistics, and read-only SQL', async () => { const connector = new KtxSqliteScanConnector({ connectionId: 'warehouse', connection: { driver: 'sqlite', path: dbPath }, }); await expect( connector.sampleTable( { connectionId: 'warehouse', table: { catalog: null, db: null, name: 'orders' }, columns: ['id'], limit: 1 }, { runId: 'scan-run-1' }, ), ).resolves.toEqual({ headers: ['id'], rows: [[10]], totalRows: 1 }); await expect( connector.sampleColumn( { connectionId: 'warehouse', table: { catalog: null, db: null, name: 'orders' }, column: 'status', limit: 5 }, { runId: 'scan-run-1' }, ), ).resolves.toMatchObject({ values: ['paid', 'open'], nullCount: null, distinctCount: null }); await expect( connector.getColumnDistinctValues( { catalog: null, db: null, name: 'orders' }, 'status', { maxCardinality: 5, limit: 10, sampleSize: 100 }, ), ).resolves.toEqual({ values: ['open', 'paid'], cardinality: 2 }); await expect( connector.executeReadOnly( { connectionId: 'warehouse', sql: 'select id, status from orders order by id', maxRows: 1 }, { runId: 'scan-run-1' }, ), ).resolves.toEqual({ headers: ['id', 'status'], rows: [[10, 'paid']], totalRows: 1, rowCount: 1 }); await expect( connector.executeReadOnly({ connectionId: 'warehouse', sql: 'delete from orders' }, { runId: 'scan-run-1' }), ).rejects.toThrow('Only read-only SELECT/WITH queries can be executed locally'); await expect( connector.columnStats( { connectionId: 'warehouse', table: { catalog: null, db: null, name: 'orders' }, column: 'status' }, { runId: 'scan-run-1' }, ), ).resolves.toBeNull(); }); it('limits introspection to tables in tableScope', async () => { const connector = new KtxSqliteScanConnector({ connectionId: 'warehouse', connection: { driver: 'sqlite', path: dbPath }, }); const scope = tableRefSet([{ catalog: null, db: null, name: 'orders' }]); const snapshot = await connector.introspect( { connectionId: 'warehouse', driver: 'sqlite', tableScope: scope }, { runId: 'scope-test' }, ); expect(snapshot.tables.map((table) => table.name)).toEqual(['orders']); }); it('adapts native SQLite snapshots to live-database introspection for local ingest', async () => { const introspection = createSqliteLiveDatabaseIntrospection({ projectDir: tempDir, connections: { warehouse: { driver: 'sqlite', path: 'warehouse.db' }, }, now: () => new Date('2026-04-29T10:00:00.000Z'), }); const snapshot = await introspection.extractSchema('warehouse'); expect(snapshot).toMatchObject({ connectionId: 'warehouse', extractedAt: '2026-04-29T10:00:00.000Z', }); expect(snapshot.tables.find((table) => table.name === 'customers')).toMatchObject({ name: 'customers', catalog: null, db: null, columns: [ { name: 'id', nativeType: 'INTEGER', normalizedType: 'INTEGER', dimensionType: 'number', nullable: false, primaryKey: true, comment: null, }, { name: 'name', nativeType: 'TEXT', normalizedType: 'TEXT', dimensionType: 'string', nullable: false, primaryKey: false, comment: null, }, { name: 'tier', nativeType: 'TEXT', normalizedType: 'TEXT', dimensionType: 'string', nullable: true, primaryKey: false, comment: null, }, ], foreignKeys: [], }); expect(snapshot.tables.find((table) => table.name === 'orders')).toMatchObject({ name: 'orders', catalog: null, db: null, foreignKeys: [{ fromColumn: 'customer_id', toTable: 'customers', toColumn: 'id' }], }); }); });