agnes-cli 0.0.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.
package/src/config.ts ADDED
@@ -0,0 +1,73 @@
1
+ import { resolve } from "node:path";
2
+ import type { Dialect } from "./dialect";
3
+ import type { DslSchema } from "./ir";
4
+
5
+ export interface CacheConfig {
6
+ enabled: boolean;
7
+ walPath?: string;
8
+ compactionThreshold?: number;
9
+ }
10
+
11
+ export interface AgnesConfig {
12
+ /** Target database dialect. */
13
+ driver: Dialect;
14
+ /** Connection URL, e.g. postgres://user:pass@host/db or sqlite://./dev.db */
15
+ url: string;
16
+ /** The schema object exported from your schema.ts (`export const schema = {...}`). */
17
+ schema: DslSchema;
18
+ /** Where `pull` writes the generated schema (default: ./schema.ts). */
19
+ out?: string;
20
+ /** Directory for versioned migration files (default: ./migrations). */
21
+ migrationsDir?: string;
22
+ maxConnections?: number;
23
+ /** Cache config baked into the client generated by `agnes generate`. */
24
+ cache?: CacheConfig;
25
+ /**
26
+ * Output path for `agnes generate` — the pre-wired AgnesClient module.
27
+ * Extension decides the language: `.ts` or `.js`. Nested dirs are created.
28
+ * e.g. "src/services/db.ts" or "db.js".
29
+ */
30
+ output?: string;
31
+ /**
32
+ * Module that exports `schema`, imported by the generated client.
33
+ * Default: `out` (the pull target) or "./schema". A filesystem path
34
+ * (e.g. "./schema.ts") — the generated import is made relative automatically.
35
+ */
36
+ schemaPath?: string;
37
+ }
38
+
39
+ /** Identity helper for type-checked config files. */
40
+ export function defineConfig(config: AgnesConfig): AgnesConfig {
41
+ return config;
42
+ }
43
+
44
+ const CANDIDATES = ["agnes.config.ts", "agnes.config.js", "agnes.config.mjs"];
45
+
46
+ /** Load and validate the config file (explicit path or auto-discovered). */
47
+ export async function loadConfig(explicitPath?: string): Promise<AgnesConfig> {
48
+ let path: string | undefined;
49
+ if (explicitPath) {
50
+ path = resolve(process.cwd(), explicitPath);
51
+ } else {
52
+ for (const c of CANDIDATES) {
53
+ const p = resolve(process.cwd(), c);
54
+ if (await Bun.file(p).exists()) {
55
+ path = p;
56
+ break;
57
+ }
58
+ }
59
+ }
60
+ if (!path) {
61
+ throw new Error(
62
+ `No config found. Create agnes.config.ts (see agnes-cli README) or pass --config <path>.`,
63
+ );
64
+ }
65
+
66
+ const mod = (await import(path)) as { default?: AgnesConfig } & Partial<AgnesConfig>;
67
+ const config = mod.default ?? (mod as AgnesConfig);
68
+
69
+ if (!config?.driver || !config.url || !config.schema) {
70
+ throw new Error(`Config at ${path} must export { driver, url, schema }.`);
71
+ }
72
+ return config;
73
+ }
package/src/db.ts ADDED
@@ -0,0 +1,26 @@
1
+ // Opens a database connection via agnes-library's AgnesClient (Rust bridge)
2
+ // and exposes the query/mutate surface the CLI needs.
3
+ import { AgnesClient } from "agnes-library";
4
+ import type { AgnesConfig } from "./config";
5
+ import type { QueryClient } from "./introspect";
6
+
7
+ export interface CliDb extends QueryClient {
8
+ mutate(sql: string, params?: unknown[]): Promise<number>;
9
+ }
10
+
11
+ export async function openDb(config: AgnesConfig): Promise<CliDb> {
12
+ const client = await AgnesClient.create(
13
+ {
14
+ driver: config.driver,
15
+ url: config.url,
16
+ maxConnections: config.maxConnections,
17
+ },
18
+ config.schema as never,
19
+ );
20
+
21
+ return {
22
+ query: <T = Record<string, unknown>>(sql: string, params?: unknown[]) =>
23
+ client.query<T>(sql, params, { bypassCache: true }),
24
+ mutate: (sql: string, params?: unknown[]) => client.mutate(sql, params),
25
+ };
26
+ }
package/src/dialect.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { ColumnType } from "./ir";
2
+
3
+ export type Dialect = "postgres" | "mysql" | "sqlite";
4
+
5
+ // Logical type → physical column type per dialect.
6
+ const TYPE_MAP: Record<Dialect, Record<ColumnType, string>> = {
7
+ postgres: {
8
+ int: "integer",
9
+ bigint: "bigint",
10
+ text: "text",
11
+ bool: "boolean",
12
+ float: "double precision",
13
+ bytes: "bytea",
14
+ json: "jsonb",
15
+ },
16
+ mysql: {
17
+ int: "int",
18
+ bigint: "bigint",
19
+ text: "text",
20
+ bool: "tinyint(1)",
21
+ float: "double",
22
+ bytes: "blob",
23
+ json: "json",
24
+ },
25
+ sqlite: {
26
+ int: "integer",
27
+ bigint: "integer",
28
+ text: "text",
29
+ bool: "integer",
30
+ float: "real",
31
+ bytes: "blob",
32
+ json: "text",
33
+ },
34
+ };
35
+
36
+ export function physicalType(dialect: Dialect, type: ColumnType): string {
37
+ return TYPE_MAP[dialect][type];
38
+ }
39
+
40
+ /** Reverse-map a raw DB type name back to a logical ColumnType (best effort). */
41
+ export function logicalType(raw: string): ColumnType {
42
+ const t = raw.toLowerCase();
43
+ if (t.includes("bigint")) return "bigint";
44
+ if (t.includes("int")) return "int"; // tinyint/smallint/int/integer
45
+ if (t.includes("bool")) return "bool";
46
+ if (t.includes("double") || t.includes("real") || t.includes("float") || t.includes("numeric") || t.includes("decimal"))
47
+ return "float";
48
+ if (t.includes("json")) return "json";
49
+ if (t.includes("blob") || t.includes("bytea") || t.includes("binary")) return "bytes";
50
+ return "text"; // text/varchar/char/uuid/timestamp/etc. collapse to text
51
+ }
52
+
53
+ export function ident(dialect: Dialect, name: string): string {
54
+ return dialect === "mysql" ? `\`${name}\`` : `"${name}"`;
55
+ }
56
+
57
+ /** Render a default value as a SQL literal. */
58
+ export function defaultLiteral(dialect: Dialect, value: unknown): string {
59
+ if (value === null) return "NULL";
60
+ if (typeof value === "boolean") {
61
+ if (dialect === "postgres") return value ? "TRUE" : "FALSE";
62
+ return value ? "1" : "0";
63
+ }
64
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
65
+ // Strings / everything else → single-quoted, escaped.
66
+ return `'${String(value).replace(/'/g, "''")}'`;
67
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { ColumnIR, DatabaseIR, ForeignKeyIR, IndexIR, TableIR } from "./ir";
2
+
3
+ // A single schema-change operation. `destructive` ops delete data or objects
4
+ // and require confirmation before execution.
5
+ export type Operation =
6
+ | { kind: "createTable"; table: TableIR }
7
+ | { kind: "dropTable"; table: string; destructive: true }
8
+ | { kind: "addColumn"; table: string; column: ColumnIR }
9
+ | { kind: "dropColumn"; table: string; column: string; destructive: true }
10
+ | { kind: "alterColumn"; table: string; from: ColumnIR; to: ColumnIR }
11
+ | { kind: "createIndex"; table: string; index: IndexIR }
12
+ | { kind: "dropIndex"; table: string; index: string }
13
+ | { kind: "addForeignKey"; table: string; fk: ForeignKeyIR }
14
+ | { kind: "dropForeignKey"; table: string; fk: string };
15
+
16
+ export function isDestructive(op: Operation): boolean {
17
+ return op.kind === "dropTable" || op.kind === "dropColumn";
18
+ }
19
+
20
+ function byName<T extends { name: string }>(items: T[]): Map<string, T> {
21
+ return new Map(items.map((i) => [i.name, i]));
22
+ }
23
+
24
+ function columnChanged(a: ColumnIR, b: ColumnIR): boolean {
25
+ return a.type !== b.type || a.nullable !== b.nullable || a.primary !== b.primary;
26
+ }
27
+
28
+ /**
29
+ * Diff `desired` (from schema.ts) against `current` (from the DB).
30
+ * Returns ordered operations: creates first, then column/index/fk changes,
31
+ * then drops last (so dependents go before the tables they reference).
32
+ */
33
+ export function diffSchemas(desired: DatabaseIR, current: DatabaseIR): Operation[] {
34
+ const creates: Operation[] = [];
35
+ const alters: Operation[] = [];
36
+ const drops: Operation[] = [];
37
+
38
+ // Tables present in desired.
39
+ for (const tableName in desired) {
40
+ const want = desired[tableName]!;
41
+ const have = current[tableName];
42
+
43
+ if (!have) {
44
+ creates.push({ kind: "createTable", table: want });
45
+ continue;
46
+ }
47
+
48
+ const wantCols = byName(want.columns);
49
+ const haveCols = byName(have.columns);
50
+
51
+ for (const [name, col] of wantCols) {
52
+ const existing = haveCols.get(name);
53
+ if (!existing) alters.push({ kind: "addColumn", table: tableName, column: col });
54
+ else if (columnChanged(existing, col))
55
+ alters.push({ kind: "alterColumn", table: tableName, from: existing, to: col });
56
+ }
57
+ for (const [name] of haveCols) {
58
+ if (!wantCols.has(name))
59
+ drops.push({ kind: "dropColumn", table: tableName, column: name, destructive: true });
60
+ }
61
+
62
+ // Indexes.
63
+ const wantIdx = byName(want.indexes);
64
+ const haveIdx = byName(have.indexes);
65
+ for (const [name, idx] of wantIdx) {
66
+ const existing = haveIdx.get(name);
67
+ if (!existing) alters.push({ kind: "createIndex", table: tableName, index: idx });
68
+ else if (existing.unique !== idx.unique || existing.columns.join() !== idx.columns.join()) {
69
+ alters.push({ kind: "dropIndex", table: tableName, index: name });
70
+ alters.push({ kind: "createIndex", table: tableName, index: idx });
71
+ }
72
+ }
73
+ for (const [name] of haveIdx) {
74
+ if (!wantIdx.has(name)) alters.push({ kind: "dropIndex", table: tableName, index: name });
75
+ }
76
+
77
+ // Foreign keys.
78
+ const wantFk = byName(want.foreignKeys);
79
+ const haveFk = byName(have.foreignKeys);
80
+ for (const [name, fk] of wantFk) {
81
+ if (!haveFk.has(name)) alters.push({ kind: "addForeignKey", table: tableName, fk });
82
+ }
83
+ for (const [name] of haveFk) {
84
+ if (!wantFk.has(name)) alters.push({ kind: "dropForeignKey", table: tableName, fk: name });
85
+ }
86
+ }
87
+
88
+ // Tables present only in the DB → drop (full sync).
89
+ for (const tableName in current) {
90
+ if (!desired[tableName]) drops.push({ kind: "dropTable", table: tableName, destructive: true });
91
+ }
92
+
93
+ // Order creates so referenced tables come first; drops in the reverse order
94
+ // (dependents before the tables they reference) to respect FK constraints.
95
+ const createOrder = topoSort(desired);
96
+ creates.sort((a, b) => {
97
+ const an = a.kind === "createTable" ? a.table.name : "";
98
+ const bn = b.kind === "createTable" ? b.table.name : "";
99
+ return (createOrder.get(an) ?? 0) - (createOrder.get(bn) ?? 0);
100
+ });
101
+ const dropOrder = topoSort(current);
102
+ drops.sort((a, b) => {
103
+ const an = a.kind === "dropTable" ? a.table : "";
104
+ const bn = b.kind === "dropTable" ? b.table : "";
105
+ return (dropOrder.get(bn) ?? 0) - (dropOrder.get(an) ?? 0);
106
+ });
107
+
108
+ return [...creates, ...alters, ...drops];
109
+ }
110
+
111
+ /** Assign each table a rank so that a table always ranks after tables it references. */
112
+ function topoSort(ir: DatabaseIR): Map<string, number> {
113
+ const rank = new Map<string, number>();
114
+ const visiting = new Set<string>();
115
+
116
+ const visit = (name: string): number => {
117
+ if (rank.has(name)) return rank.get(name)!;
118
+ if (visiting.has(name)) return 0; // cycle guard
119
+ visiting.add(name);
120
+ let r = 0;
121
+ for (const fk of ir[name]?.foreignKeys ?? []) {
122
+ if (fk.refTable !== name && ir[fk.refTable]) r = Math.max(r, visit(fk.refTable) + 1);
123
+ }
124
+ visiting.delete(name);
125
+ rank.set(name, r);
126
+ return r;
127
+ };
128
+
129
+ for (const name in ir) visit(name);
130
+ return rank;
131
+ }
@@ -0,0 +1,137 @@
1
+ import { defaultLiteral, ident, physicalType, type Dialect } from "./dialect";
2
+ import type { ColumnIR, ForeignKeyIR, TableIR } from "./ir";
3
+ import type { Operation } from "./diff";
4
+
5
+ function columnDef(dialect: Dialect, col: ColumnIR): string {
6
+ let s = `${ident(dialect, col.name)} ${physicalType(dialect, col.type)}`;
7
+ if (col.primary) s += " PRIMARY KEY";
8
+ if (!col.nullable && !col.primary) s += " NOT NULL";
9
+ if (col.default !== undefined) s += ` DEFAULT ${defaultLiteral(dialect, col.default)}`;
10
+ return s;
11
+ }
12
+
13
+ function fkClause(dialect: Dialect, fk: ForeignKeyIR): string {
14
+ return (
15
+ `CONSTRAINT ${ident(dialect, fk.name)} FOREIGN KEY (${ident(dialect, fk.column)}) ` +
16
+ `REFERENCES ${ident(dialect, fk.refTable)} (${ident(dialect, fk.refColumn)}) ` +
17
+ `ON UPDATE ${fk.onUpdate} ON DELETE ${fk.onDelete}`
18
+ );
19
+ }
20
+
21
+ function createTable(dialect: Dialect, t: TableIR): string {
22
+ const lines = t.columns.map((c) => ` ${columnDef(dialect, c)}`);
23
+ // SQLite can only declare FKs inline at table-create time.
24
+ if (dialect === "sqlite") {
25
+ for (const fk of t.foreignKeys) lines.push(` ${fkClause(dialect, fk)}`);
26
+ }
27
+ return `CREATE TABLE ${ident(dialect, t.name)} (\n${lines.join(",\n")}\n);`;
28
+ }
29
+
30
+ function createIndex(dialect: Dialect, table: string, name: string, cols: string[], unique: boolean): string {
31
+ const u = unique ? "UNIQUE " : "";
32
+ const colList = cols.map((c) => ident(dialect, c)).join(", ");
33
+ return `CREATE ${u}INDEX ${ident(dialect, name)} ON ${ident(dialect, table)} (${colList});`;
34
+ }
35
+
36
+ /**
37
+ * Render one operation into zero or more SQL statements for `dialect`.
38
+ * Emits `-- SKIPPED` comments for operations a dialect can't express (e.g.
39
+ * SQLite ALTER limitations) instead of producing invalid SQL.
40
+ */
41
+ export function renderOperation(dialect: Dialect, op: Operation): string[] {
42
+ const t = (name: string) => ident(dialect, name);
43
+
44
+ switch (op.kind) {
45
+ case "createTable": {
46
+ const out = [createTable(dialect, op.table)];
47
+ for (const idx of op.table.indexes)
48
+ out.push(createIndex(dialect, op.table.name, idx.name, idx.columns, idx.unique));
49
+ // Non-SQLite: add FKs after create (SQLite already inlined them).
50
+ if (dialect !== "sqlite")
51
+ for (const fk of op.table.foreignKeys)
52
+ out.push(`ALTER TABLE ${t(op.table.name)} ADD ${fkClause(dialect, fk)};`);
53
+ return out;
54
+ }
55
+
56
+ case "dropTable":
57
+ return [`DROP TABLE ${t(op.table)};`];
58
+
59
+ case "addColumn":
60
+ return [`ALTER TABLE ${t(op.table)} ADD COLUMN ${columnDef(dialect, op.column)};`];
61
+
62
+ case "dropColumn":
63
+ return [`ALTER TABLE ${t(op.table)} DROP COLUMN ${t(op.column)};`];
64
+
65
+ case "alterColumn": {
66
+ if (dialect === "sqlite")
67
+ return [`-- SKIPPED: SQLite cannot ALTER column ${op.table}.${op.to.name}; recreate the table manually.`];
68
+ if (dialect === "postgres") {
69
+ const col = t(op.to.name);
70
+ const type = physicalType(dialect, op.to.type);
71
+ const out = [
72
+ `ALTER TABLE ${t(op.table)} ALTER COLUMN ${col} TYPE ${type} USING ${col}::${type};`,
73
+ ];
74
+ out.push(
75
+ `ALTER TABLE ${t(op.table)} ALTER COLUMN ${col} ${op.to.nullable ? "DROP NOT NULL" : "SET NOT NULL"};`,
76
+ );
77
+ return out;
78
+ }
79
+ // mysql
80
+ const nullSql = op.to.nullable ? "NULL" : "NOT NULL";
81
+ return [
82
+ `ALTER TABLE ${t(op.table)} MODIFY COLUMN ${columnDef(dialect, op.to).replace(/ PRIMARY KEY/, "")} ${op.to.primary ? "" : nullSql};`,
83
+ ];
84
+ }
85
+
86
+ case "createIndex":
87
+ return [createIndex(dialect, op.table, op.index.name, op.index.columns, op.index.unique)];
88
+
89
+ case "dropIndex":
90
+ // MySQL scopes index names to the table; others are global.
91
+ return dialect === "mysql"
92
+ ? [`DROP INDEX ${t(op.index)} ON ${t(op.table)};`]
93
+ : [`DROP INDEX ${t(op.index)};`];
94
+
95
+ case "addForeignKey": {
96
+ if (dialect === "sqlite")
97
+ return [`-- SKIPPED: SQLite cannot add FK ${op.fk.name} after table creation.`];
98
+ return [`ALTER TABLE ${t(op.table)} ADD ${fkClause(dialect, op.fk)};`];
99
+ }
100
+
101
+ case "dropForeignKey": {
102
+ if (dialect === "sqlite")
103
+ return [`-- SKIPPED: SQLite cannot drop FK ${op.fk}.`];
104
+ if (dialect === "mysql") return [`ALTER TABLE ${t(op.table)} DROP FOREIGN KEY ${t(op.fk)};`];
105
+ return [`ALTER TABLE ${t(op.table)} DROP CONSTRAINT ${t(op.fk)};`];
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Render a full operation list into a flat SQL statement array. */
111
+ export function renderPlan(dialect: Dialect, ops: Operation[]): string[] {
112
+ return ops.flatMap((op) => renderOperation(dialect, op));
113
+ }
114
+
115
+ /** Human-readable one-line summary of an operation (for confirmation prompts). */
116
+ export function describeOperation(op: Operation): string {
117
+ switch (op.kind) {
118
+ case "createTable":
119
+ return `+ create table ${op.table.name}`;
120
+ case "dropTable":
121
+ return `- DROP TABLE ${op.table} (destructive)`;
122
+ case "addColumn":
123
+ return `+ add column ${op.table}.${op.column.name}`;
124
+ case "dropColumn":
125
+ return `- DROP COLUMN ${op.table}.${op.column} (destructive)`;
126
+ case "alterColumn":
127
+ return `~ alter column ${op.table}.${op.to.name}`;
128
+ case "createIndex":
129
+ return `+ create index ${op.index.name} on ${op.table}`;
130
+ case "dropIndex":
131
+ return `- drop index ${op.index}`;
132
+ case "addForeignKey":
133
+ return `+ add fk ${op.fk.name} on ${op.table}`;
134
+ case "dropForeignKey":
135
+ return `- drop fk ${op.fk} on ${op.table}`;
136
+ }
137
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // Public API — import `defineConfig` in your agnes.config.ts.
2
+ export { defineConfig, type AgnesConfig, type CacheConfig } from "./config";
3
+ export { run } from "./cli";
4
+ export { push } from "./commands/push";
5
+ export { pull } from "./commands/pull";
6
+ export { migrate } from "./commands/migrate";
7
+ export { generate } from "./commands/generate";
8
+ export { schemaToIR } from "./ir";
9
+ export { diffSchemas } from "./diff";
10
+ export { introspect } from "./introspect";
11
+ export type { DatabaseIR, TableIR, ColumnIR, IndexIR, ForeignKeyIR } from "./ir";
@@ -0,0 +1,242 @@
1
+ import { logicalType, type Dialect } from "./dialect";
2
+ import type { ColumnIR, DatabaseIR, ForeignKeyIR, IndexIR, TableIR } from "./ir";
3
+
4
+ // Minimal DB surface the CLI needs — satisfied by AgnesClient.
5
+ export interface QueryClient {
6
+ query<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
7
+ }
8
+
9
+ type Row = Record<string, unknown>;
10
+
11
+ const str = (v: unknown): string => (v == null ? "" : String(v));
12
+ const truthy = (v: unknown): boolean =>
13
+ v === true || v === 1 || v === "1" || v === "YES" || v === "t" || v === "true";
14
+
15
+ /** Tables the CLI manages internally and must never diff/drop. */
16
+ function isInternal(name: string): boolean {
17
+ return name.startsWith("_agnes") || name.startsWith("sqlite_");
18
+ }
19
+
20
+ export async function introspect(db: QueryClient, dialect: Dialect): Promise<DatabaseIR> {
21
+ switch (dialect) {
22
+ case "postgres":
23
+ return introspectPostgres(db);
24
+ case "mysql":
25
+ return introspectMysql(db);
26
+ case "sqlite":
27
+ return introspectSqlite(db);
28
+ }
29
+ }
30
+
31
+ // ─── PostgreSQL ─────────────────────────────────────────────────────────────
32
+
33
+ async function introspectPostgres(db: QueryClient): Promise<DatabaseIR> {
34
+ const ir: DatabaseIR = {};
35
+ const tables = await db.query<Row>(
36
+ `SELECT table_name FROM information_schema.tables
37
+ WHERE table_schema = 'public' AND table_type = 'BASE TABLE'`,
38
+ );
39
+
40
+ for (const tr of tables) {
41
+ const name = str(tr.table_name);
42
+ if (isInternal(name)) continue;
43
+
44
+ const cols = await db.query<Row>(
45
+ `SELECT column_name, data_type, is_nullable, column_default
46
+ FROM information_schema.columns
47
+ WHERE table_schema = 'public' AND table_name = $1
48
+ ORDER BY ordinal_position`,
49
+ [name],
50
+ );
51
+ const pks = await db.query<Row>(
52
+ `SELECT kcu.column_name FROM information_schema.table_constraints tc
53
+ JOIN information_schema.key_column_usage kcu
54
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
55
+ WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1 AND tc.table_schema = 'public'`,
56
+ [name],
57
+ );
58
+ const pkSet = new Set(pks.map((r) => str(r.column_name)));
59
+
60
+ const columns: ColumnIR[] = cols.map((c) => ({
61
+ name: str(c.column_name),
62
+ type: logicalType(str(c.data_type)),
63
+ nullable: str(c.is_nullable) === "YES",
64
+ primary: pkSet.has(str(c.column_name)),
65
+ default: c.column_default == null ? undefined : str(c.column_default),
66
+ }));
67
+
68
+ const idxRows = await db.query<Row>(
69
+ `SELECT i.relname AS index_name, a.attname AS column_name,
70
+ ix.indisunique AS is_unique, ix.indisprimary AS is_primary
71
+ FROM pg_class t
72
+ JOIN pg_index ix ON t.oid = ix.indrelid
73
+ JOIN pg_class i ON i.oid = ix.indexrelid
74
+ JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey)
75
+ WHERE t.relname = $1 AND t.relkind = 'r'`,
76
+ [name],
77
+ );
78
+ const indexes = groupIndexes(
79
+ idxRows
80
+ .filter((r) => !truthy(r.is_primary))
81
+ .map((r) => ({ index: str(r.index_name), column: str(r.column_name), unique: truthy(r.is_unique) })),
82
+ );
83
+
84
+ const fkRows = await db.query<Row>(
85
+ `SELECT tc.constraint_name, kcu.column_name,
86
+ ccu.table_name AS foreign_table, ccu.column_name AS foreign_column,
87
+ rc.update_rule, rc.delete_rule
88
+ FROM information_schema.table_constraints tc
89
+ JOIN information_schema.key_column_usage kcu
90
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
91
+ JOIN information_schema.constraint_column_usage ccu
92
+ ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
93
+ JOIN information_schema.referential_constraints rc
94
+ ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.table_schema
95
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = $1 AND tc.table_schema = 'public'`,
96
+ [name],
97
+ );
98
+ const foreignKeys: ForeignKeyIR[] = fkRows.map((r) => ({
99
+ name: str(r.constraint_name),
100
+ column: str(r.column_name),
101
+ refTable: str(r.foreign_table),
102
+ refColumn: str(r.foreign_column),
103
+ onUpdate: str(r.update_rule).toUpperCase(),
104
+ onDelete: str(r.delete_rule).toUpperCase(),
105
+ }));
106
+
107
+ ir[name] = { name, columns, indexes, foreignKeys };
108
+ }
109
+ return ir;
110
+ }
111
+
112
+ // ─── MySQL ──────────────────────────────────────────────────────────────────
113
+
114
+ async function introspectMysql(db: QueryClient): Promise<DatabaseIR> {
115
+ const ir: DatabaseIR = {};
116
+ const tables = await db.query<Row>(
117
+ `SELECT table_name AS table_name FROM information_schema.tables
118
+ WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'`,
119
+ );
120
+
121
+ for (const tr of tables) {
122
+ const name = str(tr.table_name);
123
+ if (isInternal(name)) continue;
124
+
125
+ const cols = await db.query<Row>(
126
+ `SELECT column_name, data_type, is_nullable, column_default, column_key
127
+ FROM information_schema.columns
128
+ WHERE table_schema = DATABASE() AND table_name = ?
129
+ ORDER BY ordinal_position`,
130
+ [name],
131
+ );
132
+ const columns: ColumnIR[] = cols.map((c) => ({
133
+ name: str(c.column_name),
134
+ type: logicalType(str(c.data_type)),
135
+ nullable: str(c.is_nullable) === "YES",
136
+ primary: str(c.column_key) === "PRI",
137
+ default: c.column_default == null ? undefined : str(c.column_default),
138
+ }));
139
+
140
+ const idxRows = await db.query<Row>(
141
+ `SELECT index_name, column_name, non_unique
142
+ FROM information_schema.statistics
143
+ WHERE table_schema = DATABASE() AND table_name = ?
144
+ ORDER BY index_name, seq_in_index`,
145
+ [name],
146
+ );
147
+ const indexes = groupIndexes(
148
+ idxRows
149
+ .filter((r) => str(r.index_name) !== "PRIMARY")
150
+ .map((r) => ({ index: str(r.index_name), column: str(r.column_name), unique: !truthy(r.non_unique) })),
151
+ );
152
+
153
+ const fkRows = await db.query<Row>(
154
+ `SELECT kcu.constraint_name, kcu.column_name,
155
+ kcu.referenced_table_name AS foreign_table, kcu.referenced_column_name AS foreign_column,
156
+ rc.update_rule, rc.delete_rule
157
+ FROM information_schema.key_column_usage kcu
158
+ JOIN information_schema.referential_constraints rc
159
+ ON rc.constraint_name = kcu.constraint_name AND rc.constraint_schema = kcu.table_schema
160
+ WHERE kcu.table_schema = DATABASE() AND kcu.table_name = ?
161
+ AND kcu.referenced_table_name IS NOT NULL`,
162
+ [name],
163
+ );
164
+ const foreignKeys: ForeignKeyIR[] = fkRows.map((r) => ({
165
+ name: str(r.constraint_name),
166
+ column: str(r.column_name),
167
+ refTable: str(r.foreign_table),
168
+ refColumn: str(r.foreign_column),
169
+ onUpdate: str(r.update_rule).toUpperCase(),
170
+ onDelete: str(r.delete_rule).toUpperCase(),
171
+ }));
172
+
173
+ ir[name] = { name, columns, indexes, foreignKeys };
174
+ }
175
+ return ir;
176
+ }
177
+
178
+ // ─── SQLite ─────────────────────────────────────────────────────────────────
179
+
180
+ async function introspectSqlite(db: QueryClient): Promise<DatabaseIR> {
181
+ const ir: DatabaseIR = {};
182
+ const tables = await db.query<Row>(
183
+ `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
184
+ );
185
+
186
+ for (const tr of tables) {
187
+ const name = str(tr.name);
188
+ if (isInternal(name)) continue;
189
+ const q = (s: string) => name.replace(/'/g, "''"); // guard, name only
190
+
191
+ const cols = await db.query<Row>(`PRAGMA table_info('${q(name)}')`);
192
+ const columns: ColumnIR[] = cols.map((c) => ({
193
+ name: str(c.name),
194
+ type: logicalType(str(c.type)),
195
+ nullable: !truthy(c.notnull),
196
+ primary: truthy(c.pk),
197
+ default: c.dflt_value == null ? undefined : str(c.dflt_value),
198
+ }));
199
+
200
+ const idxList = await db.query<Row>(`PRAGMA index_list('${q(name)}')`);
201
+ const indexes: IndexIR[] = [];
202
+ for (const idx of idxList) {
203
+ // Only user-created indexes (origin "c"); skip implicit PK ("pk") and
204
+ // UNIQUE-constraint ("u") auto-indexes, which can't be dropped directly.
205
+ if (str(idx.origin) !== "c") continue;
206
+ const idxName = str(idx.name);
207
+ const info = await db.query<Row>(`PRAGMA index_info('${idxName.replace(/'/g, "''")}')`);
208
+ indexes.push({
209
+ name: idxName,
210
+ columns: info.map((i) => str(i.name)),
211
+ unique: truthy(idx.unique),
212
+ });
213
+ }
214
+
215
+ const fkList = await db.query<Row>(`PRAGMA foreign_key_list('${q(name)}')`);
216
+ const foreignKeys: ForeignKeyIR[] = fkList.map((f) => ({
217
+ name: `fk_${name}_${str(f.from)}`,
218
+ column: str(f.from),
219
+ refTable: str(f.table),
220
+ refColumn: str(f.to),
221
+ onUpdate: str(f.on_update).toUpperCase() || "NO ACTION",
222
+ onDelete: str(f.on_delete).toUpperCase() || "NO ACTION",
223
+ }));
224
+
225
+ ir[name] = { name, columns, indexes, foreignKeys };
226
+ }
227
+ return ir;
228
+ }
229
+
230
+ // ─── Helpers ────────────────────────────────────────────────────────────────
231
+
232
+ function groupIndexes(
233
+ rows: { index: string; column: string; unique: boolean }[],
234
+ ): IndexIR[] {
235
+ const map = new Map<string, IndexIR>();
236
+ for (const r of rows) {
237
+ const existing = map.get(r.index);
238
+ if (existing) existing.columns.push(r.column);
239
+ else map.set(r.index, { name: r.index, columns: [r.column], unique: r.unique });
240
+ }
241
+ return [...map.values()];
242
+ }