diff --git a/apps/desktop/package.json b/apps/desktop/package.json index dff4ac0..4c4ab2c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "electron-updater": "^6.3.9", "lucide-react": "^0.555.0", "monaco-editor": "^0.55.1", + "mysql2": "^3.15.3", "pg": "^8.16.3", "sql-formatter": "^15.6.10", "tailwind-merge": "^3.4.0", diff --git a/apps/desktop/src/main/adapters/mysql-adapter.ts b/apps/desktop/src/main/adapters/mysql-adapter.ts new file mode 100644 index 0000000..45fde56 --- /dev/null +++ b/apps/desktop/src/main/adapters/mysql-adapter.ts @@ -0,0 +1,689 @@ +import mysql from 'mysql2/promise' +import type { + ConnectionConfig, + SchemaInfo, + TableInfo, + ColumnInfo, + QueryField, + ForeignKeyInfo, + TableDefinition, + ColumnDefinition, + ConstraintDefinition, + IndexDefinition, + SequenceInfo, + CustomTypeInfo +} from '@shared/index' +import type { DatabaseAdapter, AdapterQueryResult, ExplainResult } from '../db-adapter' + +/** + * MySQL type codes to type name mapping + * Based on mysql2 field type constants + */ +const MYSQL_TYPE_MAP: Record = { + 0: 'decimal', + 1: 'tinyint', + 2: 'smallint', + 3: 'int', + 4: 'float', + 5: 'double', + 6: 'null', + 7: 'timestamp', + 8: 'bigint', + 9: 'mediumint', + 10: 'date', + 11: 'time', + 12: 'datetime', + 13: 'year', + 14: 'newdate', + 15: 'varchar', + 16: 'bit', + 245: 'json', + 246: 'newdecimal', + 247: 'enum', + 248: 'set', + 249: 'tiny_blob', + 250: 'medium_blob', + 251: 'long_blob', + 252: 'blob', + 253: 'var_string', + 254: 'string', + 255: 'geometry' +} + +/** + * Resolve MySQL type code to human-readable type name + */ +function resolveMySQLType(typeCode: number): string { + return MYSQL_TYPE_MAP[typeCode] ?? `unknown(${typeCode})` +} + +/** + * Create MySQL connection config from our ConnectionConfig + */ +function toMySQLConfig(config: ConnectionConfig): mysql.ConnectionOptions { + return { + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.database, + ssl: config.ssl ? {} : undefined + } +} + +/** + * Normalize row from MySQL query to lowercase keys + * MySQL can return column names in different cases depending on configuration + */ +function normalizeRow>(row: Record): T { + const normalized: Record = {} + for (const [key, value] of Object.entries(row)) { + normalized[key.toLowerCase()] = value + } + return normalized as T +} + +/** + * MySQL database adapter + */ +export class MySQLAdapter implements DatabaseAdapter { + readonly dbType = 'mysql' as const + + async connect(config: ConnectionConfig): Promise { + const connection = await mysql.createConnection(toMySQLConfig(config)) + await connection.end() + } + + async query(config: ConnectionConfig, sql: string): Promise { + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + const [rows, fields] = await connection.query(sql) + + const queryFields: QueryField[] = (fields as mysql.FieldPacket[]).map((f) => ({ + name: f.name, + dataType: resolveMySQLType(f.type ?? 253), // 253 = var_string as fallback + dataTypeID: f.type ?? 253 + })) + + const resultRows = Array.isArray(rows) ? rows : [rows] + + return { + rows: resultRows as Record[], + fields: queryFields, + rowCount: resultRows.length + } + } finally { + await connection.end() + } + } + + async execute( + config: ConnectionConfig, + sql: string, + params: unknown[] + ): Promise<{ rowCount: number | null }> { + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + const [result] = await connection.execute(sql, params) + const affectedRows = (result as mysql.ResultSetHeader).affectedRows ?? null + return { rowCount: affectedRows } + } finally { + await connection.end() + } + } + + async executeTransaction( + config: ConnectionConfig, + statements: Array<{ sql: string; params: unknown[] }> + ): Promise<{ rowsAffected: number; results: Array<{ rowCount: number | null }> }> { + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + await connection.beginTransaction() + + const results: Array<{ rowCount: number | null }> = [] + let rowsAffected = 0 + + for (const stmt of statements) { + const [result] = await connection.execute(stmt.sql, stmt.params) + const affectedRows = (result as mysql.ResultSetHeader).affectedRows ?? 0 + results.push({ rowCount: affectedRows }) + rowsAffected += affectedRows + } + + await connection.commit() + return { rowsAffected, results } + } catch (error) { + await connection.rollback().catch(() => {}) + throw error + } finally { + await connection.end() + } + } + + async getSchemas(config: ConnectionConfig): Promise { + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + // In MySQL, "schema" = "database" + // We'll show all databases as schemas, excluding system databases + const [schemasRows] = await connection.query(` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') + ORDER BY schema_name + `) + + const schemasRaw = schemasRows as Array> + const schemas = schemasRaw.map((row) => normalizeRow<{ schema_name: string }>(row)) + + // Get all tables and views + const [tablesRows] = await connection.query(` + SELECT + table_schema, + table_name, + table_type + FROM information_schema.tables + WHERE table_schema NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') + ORDER BY table_schema, table_name + `) + + const tablesRaw = tablesRows as Array> + const tables = tablesRaw.map((row) => + normalizeRow<{ + table_schema: string + table_name: string + table_type: string + }>(row) + ) + + // Get all columns with primary key info + const [columnsRows] = await connection.query(` + SELECT + c.table_schema, + c.table_name, + c.column_name, + c.data_type, + c.column_type, + c.is_nullable, + c.column_default, + c.ordinal_position, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + c.extra, + CASE WHEN kcu.column_name IS NOT NULL THEN true ELSE false END as is_primary_key + FROM information_schema.columns c + LEFT JOIN information_schema.key_column_usage kcu + ON c.table_schema = kcu.table_schema + AND c.table_name = kcu.table_name + AND c.column_name = kcu.column_name + AND kcu.constraint_name = 'PRIMARY' + WHERE c.table_schema NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') + ORDER BY c.table_schema, c.table_name, c.ordinal_position + `) + + const columnsRaw = columnsRows as Array> + const columns = columnsRaw.map((row) => + normalizeRow<{ + table_schema: string + table_name: string + column_name: string + data_type: string + column_type: string + is_nullable: string + column_default: string | null + ordinal_position: number + character_maximum_length: number | null + numeric_precision: number | null + numeric_scale: number | null + extra: string + is_primary_key: number + }>(row) + ) + + // Get all foreign key relationships + const [fkRows] = await connection.query(` + SELECT + kcu.table_schema, + kcu.table_name, + kcu.column_name, + kcu.constraint_name, + kcu.referenced_table_schema AS referenced_schema, + kcu.referenced_table_name AS referenced_table, + kcu.referenced_column_name AS referenced_column + FROM information_schema.key_column_usage kcu + WHERE kcu.referenced_table_name IS NOT NULL + AND kcu.table_schema NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') + ORDER BY kcu.table_schema, kcu.table_name, kcu.column_name + `) + + const fkRaw = fkRows as Array> + const foreignKeys = fkRaw.map((row) => + normalizeRow<{ + table_schema: string + table_name: string + column_name: string + constraint_name: string + referenced_schema: string + referenced_table: string + referenced_column: string + }>(row) + ) + + // Build foreign key lookup map + const fkMap = new Map() + for (const row of foreignKeys) { + const key = `${row.table_schema}.${row.table_name}.${row.column_name}` + fkMap.set(key, { + constraintName: row.constraint_name, + referencedSchema: row.referenced_schema, + referencedTable: row.referenced_table, + referencedColumn: row.referenced_column + }) + } + + // Build schema structure + const schemaMap = new Map() + + for (const row of schemas) { + schemaMap.set(row.schema_name, { + name: row.schema_name, + tables: [] + }) + } + + // Build tables map + const tableMap = new Map() + for (const row of tables) { + const tableKey = `${row.table_schema}.${row.table_name}` + const table: TableInfo = { + name: row.table_name, + type: row.table_type === 'VIEW' ? 'view' : 'table', + columns: [] + } + tableMap.set(tableKey, table) + + const schema = schemaMap.get(row.table_schema) + if (schema) { + schema.tables.push(table) + } + } + + // Assign columns to tables + for (const row of columns) { + const tableKey = `${row.table_schema}.${row.table_name}` + const table = tableMap.get(tableKey) + if (table) { + // Format data type with length/precision + const dataType = row.column_type || row.data_type + // MySQL column_type already includes size info like varchar(255) + + const fkKey = `${row.table_schema}.${row.table_name}.${row.column_name}` + const foreignKey = fkMap.get(fkKey) + + // Handle auto_increment + let defaultValue = row.column_default || undefined + if (row.extra?.includes('auto_increment')) { + defaultValue = 'auto_increment' + } + + const column: ColumnInfo = { + name: row.column_name, + dataType, + isNullable: row.is_nullable === 'YES', + isPrimaryKey: Boolean(row.is_primary_key), + defaultValue, + ordinalPosition: row.ordinal_position, + foreignKey + } + table.columns.push(column) + } + } + + return Array.from(schemaMap.values()) + } finally { + await connection.end() + } + } + + async explain(config: ConnectionConfig, sql: string, analyze: boolean): Promise { + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + // MySQL uses EXPLAIN ANALYZE (8.0.18+) or just EXPLAIN + const explainQuery = analyze ? `EXPLAIN ANALYZE ${sql}` : `EXPLAIN FORMAT=JSON ${sql}` + + const start = Date.now() + const [rows] = await connection.query(explainQuery) + const duration = Date.now() - start + + // For JSON format, the result is in the first row + let plan: unknown + if (analyze) { + // EXPLAIN ANALYZE returns text output + plan = rows + } else { + // EXPLAIN FORMAT=JSON returns JSON in EXPLAIN column + const resultRows = rows as Array<{ EXPLAIN: string }> + if (resultRows.length > 0 && resultRows[0].EXPLAIN) { + plan = JSON.parse(resultRows[0].EXPLAIN) + } else { + plan = rows + } + } + + return { + plan, + durationMs: duration + } + } finally { + await connection.end() + } + } + + async getTableDDL( + config: ConnectionConfig, + schema: string, + table: string + ): Promise { + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + // Get columns with full metadata + const [columnsRows] = await connection.query( + ` + SELECT + c.column_name, + c.data_type, + c.column_type, + c.is_nullable, + c.column_default, + c.ordinal_position, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + c.collation_name, + c.column_comment, + c.extra, + CASE WHEN kcu.column_name IS NOT NULL THEN true ELSE false END as is_primary_key + FROM information_schema.columns c + LEFT JOIN information_schema.key_column_usage kcu + ON c.table_schema = kcu.table_schema + AND c.table_name = kcu.table_name + AND c.column_name = kcu.column_name + AND kcu.constraint_name = 'PRIMARY' + WHERE c.table_schema = ? AND c.table_name = ? + ORDER BY c.ordinal_position + `, + [schema, table] + ) + + const columnResults = columnsRows as Array<{ + column_name: string + data_type: string + column_type: string + is_nullable: string + column_default: string | null + ordinal_position: number + character_maximum_length: number | null + numeric_precision: number | null + numeric_scale: number | null + collation_name: string | null + column_comment: string | null + extra: string + is_primary_key: number + }> + + // Get constraints + const [constraintsRows] = await connection.query( + ` + SELECT + tc.constraint_name, + tc.constraint_type, + kcu.column_name, + kcu.referenced_table_schema AS ref_schema, + kcu.referenced_table_name AS ref_table, + kcu.referenced_column_name AS ref_column, + rc.update_rule, + rc.delete_rule + FROM information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + LEFT JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + AND tc.table_schema = rc.constraint_schema + WHERE tc.table_schema = ? AND tc.table_name = ? + ORDER BY tc.constraint_name, kcu.ordinal_position + `, + [schema, table] + ) + + const constraintResults = constraintsRows as Array<{ + constraint_name: string + constraint_type: string + column_name: string | null + ref_schema: string | null + ref_table: string | null + ref_column: string | null + update_rule: string | null + delete_rule: string | null + }> + + // Get indexes + const [indexesRows] = await connection.query( + ` + SELECT + index_name, + non_unique, + column_name, + seq_in_index, + index_type + FROM information_schema.statistics + WHERE table_schema = ? AND table_name = ? + AND index_name != 'PRIMARY' + ORDER BY index_name, seq_in_index + `, + [schema, table] + ) + + const indexResults = indexesRows as Array<{ + index_name: string + non_unique: number + column_name: string + seq_in_index: number + index_type: string + }> + + // Get table comment + const [tableCommentRows] = await connection.query( + ` + SELECT table_comment + FROM information_schema.tables + WHERE table_schema = ? AND table_name = ? + `, + [schema, table] + ) + + const tableCommentResult = tableCommentRows as Array<{ table_comment: string | null }> + + // Build columns + const columns: ColumnDefinition[] = columnResults.map((row, idx) => { + let defaultValue = row.column_default || undefined + if (row.extra?.includes('auto_increment')) { + defaultValue = undefined // Will be handled as auto_increment + } + + return { + id: `col-${idx}`, + name: row.column_name, + dataType: row.data_type, + length: row.character_maximum_length || undefined, + precision: row.numeric_precision || undefined, + scale: row.numeric_scale || undefined, + isNullable: row.is_nullable === 'YES', + isPrimaryKey: Boolean(row.is_primary_key), + isUnique: false, // Will be set from constraints + defaultValue, + comment: row.column_comment || undefined, + collation: row.collation_name || undefined + } + }) + + // Build constraints + const constraintMap = new Map< + string, + { + type: string + columns: string[] + refSchema?: string + refTable?: string + refColumns?: string[] + onUpdate?: string + onDelete?: string + } + >() + + for (const row of constraintResults) { + const key = row.constraint_name + if (!constraintMap.has(key)) { + constraintMap.set(key, { + type: row.constraint_type, + columns: [], + refSchema: row.ref_schema || undefined, + refTable: row.ref_table || undefined, + refColumns: [], + onUpdate: row.update_rule || undefined, + onDelete: row.delete_rule || undefined + }) + } + const constraint = constraintMap.get(key)! + if (row.column_name && !constraint.columns.includes(row.column_name)) { + constraint.columns.push(row.column_name) + } + if (row.ref_column && !constraint.refColumns!.includes(row.ref_column)) { + constraint.refColumns!.push(row.ref_column) + } + } + + const constraints: ConstraintDefinition[] = [] + let constraintIdx = 0 + for (const [name, data] of constraintMap) { + if (data.type === 'PRIMARY KEY') continue + + const constraintDef: ConstraintDefinition = { + id: `constraint-${constraintIdx++}`, + name, + type: data.type === 'FOREIGN KEY' ? 'foreign_key' : 'unique', + columns: data.columns + } + + if (data.type === 'FOREIGN KEY') { + constraintDef.referencedSchema = data.refSchema + constraintDef.referencedTable = data.refTable + constraintDef.referencedColumns = data.refColumns + constraintDef.onUpdate = data.onUpdate as ConstraintDefinition['onUpdate'] + constraintDef.onDelete = data.onDelete as ConstraintDefinition['onDelete'] + } + + if (data.type === 'UNIQUE' && data.columns.length === 1) { + const col = columns.find((c) => c.name === data.columns[0]) + if (col) col.isUnique = true + } + + constraints.push(constraintDef) + } + + // Build indexes + const indexMap = new Map() + for (const row of indexResults) { + if (!indexMap.has(row.index_name)) { + indexMap.set(row.index_name, { + isUnique: !row.non_unique, + method: row.index_type.toLowerCase(), + columns: [] + }) + } + indexMap.get(row.index_name)!.columns.push(row.column_name) + } + + const indexes: IndexDefinition[] = [] + let indexIdx = 0 + for (const [name, data] of indexMap) { + indexes.push({ + id: `index-${indexIdx++}`, + name, + columns: data.columns.map((c) => ({ name: c })), + isUnique: data.isUnique, + method: data.method as IndexDefinition['method'] + }) + } + + return { + schema, + name: table, + columns, + constraints, + indexes, + comment: tableCommentResult[0]?.table_comment || undefined + } + } finally { + await connection.end() + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getSequences(_config: ConnectionConfig): Promise { + // MySQL doesn't have sequences - it uses AUTO_INCREMENT + // Return empty array as sequences are a PostgreSQL concept + return [] + } + + async getTypes(config: ConnectionConfig): Promise { + // Get MySQL ENUM types from columns + const connection = await mysql.createConnection(toMySQLConfig(config)) + + try { + // MySQL doesn't have standalone enum types, they're defined per column + // We'll extract unique enum definitions from columns + const [enumRows] = await connection.query(` + SELECT DISTINCT + table_schema as schema_name, + column_type + FROM information_schema.columns + WHERE data_type = 'enum' + AND table_schema NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys') + ORDER BY table_schema, column_type + `) + + const enums = enumRows as Array<{ schema_name: string; column_type: string }> + + const types: CustomTypeInfo[] = [] + let idx = 0 + + for (const row of enums) { + // Parse enum values from column_type like "enum('a','b','c')" + const match = row.column_type.match(/^enum\((.*)\)$/i) + if (match) { + const valuesStr = match[1] + const values = valuesStr.split(',').map((v) => v.replace(/^'|'$/g, '')) + + types.push({ + schema: row.schema_name, + name: `enum_${idx++}`, + type: 'enum', + values + }) + } + } + + return types + } finally { + await connection.end() + } + } +} diff --git a/apps/desktop/src/main/adapters/postgres-adapter.ts b/apps/desktop/src/main/adapters/postgres-adapter.ts new file mode 100644 index 0000000..13a3c86 --- /dev/null +++ b/apps/desktop/src/main/adapters/postgres-adapter.ts @@ -0,0 +1,667 @@ +import { Client } from 'pg' +import type { + ConnectionConfig, + SchemaInfo, + TableInfo, + ColumnInfo, + QueryField, + ForeignKeyInfo, + TableDefinition, + ColumnDefinition, + ConstraintDefinition, + IndexDefinition, + SequenceInfo, + CustomTypeInfo +} from '@shared/index' +import type { DatabaseAdapter, AdapterQueryResult, ExplainResult } from '../db-adapter' + +/** + * PostgreSQL OID to Type Name Mapping + * Reference: https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_type.dat + */ +const PG_TYPE_MAP: Record = { + 16: 'boolean', + 17: 'bytea', + 18: 'char', + 19: 'name', + 20: 'bigint', + 21: 'smallint', + 23: 'integer', + 24: 'regproc', + 25: 'text', + 26: 'oid', + 114: 'json', + 142: 'xml', + 600: 'point', + 601: 'lseg', + 602: 'path', + 603: 'box', + 604: 'polygon', + 628: 'line', + 700: 'real', + 701: 'double precision', + 718: 'circle', + 790: 'money', + 829: 'macaddr', + 869: 'inet', + 650: 'cidr', + 1042: 'char', + 1043: 'varchar', + 1082: 'date', + 1083: 'time', + 1114: 'timestamp', + 1184: 'timestamptz', + 1186: 'interval', + 1266: 'timetz', + 1560: 'bit', + 1562: 'varbit', + 1700: 'numeric', + 2950: 'uuid', + 3802: 'jsonb', + 3904: 'int4range', + 3906: 'numrange', + 3908: 'tsrange', + 3910: 'tstzrange', + 3912: 'daterange', + 3926: 'int8range', + // Array types (common ones) + 1000: 'boolean[]', + 1001: 'bytea[]', + 1005: 'smallint[]', + 1007: 'integer[]', + 1009: 'text[]', + 1014: 'char[]', + 1015: 'varchar[]', + 1016: 'bigint[]', + 1021: 'real[]', + 1022: 'double precision[]', + 1028: 'oid[]', + 1115: 'timestamp[]', + 1182: 'date[]', + 1183: 'time[]', + 1231: 'numeric[]', + 2951: 'uuid[]', + 3807: 'jsonb[]', + 199: 'json[]' +} + +/** + * Resolve PostgreSQL OID to human-readable type name + */ +function resolvePostgresType(dataTypeID: number): string { + return PG_TYPE_MAP[dataTypeID] ?? `unknown(${dataTypeID})` +} + +/** + * PostgreSQL database adapter + */ +export class PostgresAdapter implements DatabaseAdapter { + readonly dbType = 'postgresql' as const + + async connect(config: ConnectionConfig): Promise { + const client = new Client(config) + await client.connect() + await client.end() + } + + async query(config: ConnectionConfig, sql: string): Promise { + const client = new Client(config) + await client.connect() + + try { + const res = await client.query(sql) + + const fields: QueryField[] = res.fields.map((f) => ({ + name: f.name, + dataType: resolvePostgresType(f.dataTypeID), + dataTypeID: f.dataTypeID + })) + + return { + rows: res.rows, + fields, + rowCount: res.rowCount + } + } finally { + await client.end() + } + } + + async execute( + config: ConnectionConfig, + sql: string, + params: unknown[] + ): Promise<{ rowCount: number | null }> { + const client = new Client(config) + await client.connect() + + try { + const res = await client.query(sql, params) + return { rowCount: res.rowCount } + } finally { + await client.end() + } + } + + async executeTransaction( + config: ConnectionConfig, + statements: Array<{ sql: string; params: unknown[] }> + ): Promise<{ rowsAffected: number; results: Array<{ rowCount: number | null }> }> { + const client = new Client(config) + await client.connect() + + try { + await client.query('BEGIN') + + const results: Array<{ rowCount: number | null }> = [] + let rowsAffected = 0 + + for (const stmt of statements) { + const res = await client.query(stmt.sql, stmt.params) + results.push({ rowCount: res.rowCount }) + rowsAffected += res.rowCount ?? 0 + } + + await client.query('COMMIT') + return { rowsAffected, results } + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + throw error + } finally { + await client.end() + } + } + + async getSchemas(config: ConnectionConfig): Promise { + const client = new Client(config) + await client.connect() + + try { + // Query 1: Get all schemas (excluding system schemas) + const schemasResult = await client.query(` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY schema_name + `) + + // Query 2: Get all tables and views + const tablesResult = await client.query(` + SELECT + table_schema, + table_name, + table_type + FROM information_schema.tables + WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY table_schema, table_name + `) + + // Query 3: Get all columns with primary key info + const columnsResult = await client.query(` + SELECT + c.table_schema, + c.table_name, + c.column_name, + c.data_type, + c.udt_name, + c.is_nullable, + c.column_default, + c.ordinal_position, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key + FROM information_schema.columns c + LEFT JOIN ( + SELECT + kcu.table_schema, + kcu.table_name, + kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + ) pk ON c.table_schema = pk.table_schema + AND c.table_name = pk.table_name + AND c.column_name = pk.column_name + WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY c.table_schema, c.table_name, c.ordinal_position + `) + + // Query 4: Get all foreign key relationships + const foreignKeysResult = await client.query(` + SELECT + tc.table_schema, + tc.table_name, + kcu.column_name, + tc.constraint_name, + ccu.table_schema AS referenced_schema, + ccu.table_name AS referenced_table, + ccu.column_name AS referenced_column + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + JOIN information_schema.constraint_column_usage ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.constraint_schema + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY tc.table_schema, tc.table_name, kcu.column_name + `) + + // Build foreign key lookup map: "schema.table.column" -> ForeignKeyInfo + const fkMap = new Map() + for (const row of foreignKeysResult.rows) { + const key = `${row.table_schema}.${row.table_name}.${row.column_name}` + fkMap.set(key, { + constraintName: row.constraint_name, + referencedSchema: row.referenced_schema, + referencedTable: row.referenced_table, + referencedColumn: row.referenced_column + }) + } + + // Build schema structure + const schemaMap = new Map() + + // Initialize schemas + for (const row of schemasResult.rows) { + schemaMap.set(row.schema_name, { + name: row.schema_name, + tables: [] + }) + } + + // Build tables map for easy column assignment + const tableMap = new Map() + for (const row of tablesResult.rows) { + const tableKey = `${row.table_schema}.${row.table_name}` + const table: TableInfo = { + name: row.table_name, + type: row.table_type === 'VIEW' ? 'view' : 'table', + columns: [] + } + tableMap.set(tableKey, table) + + // Add table to its schema + const schema = schemaMap.get(row.table_schema) + if (schema) { + schema.tables.push(table) + } + } + + // Assign columns to tables + for (const row of columnsResult.rows) { + const tableKey = `${row.table_schema}.${row.table_name}` + const table = tableMap.get(tableKey) + if (table) { + // Format data type nicely + let dataType = row.udt_name + if (row.character_maximum_length) { + dataType = `${row.udt_name}(${row.character_maximum_length})` + } else if (row.numeric_precision && row.numeric_scale) { + dataType = `${row.udt_name}(${row.numeric_precision},${row.numeric_scale})` + } + + // Check for foreign key relationship + const fkKey = `${row.table_schema}.${row.table_name}.${row.column_name}` + const foreignKey = fkMap.get(fkKey) + + const column: ColumnInfo = { + name: row.column_name, + dataType, + isNullable: row.is_nullable === 'YES', + isPrimaryKey: row.is_primary_key, + defaultValue: row.column_default || undefined, + ordinalPosition: row.ordinal_position, + foreignKey + } + table.columns.push(column) + } + } + + return Array.from(schemaMap.values()) + } finally { + await client.end() + } + } + + async explain(config: ConnectionConfig, sql: string, analyze: boolean): Promise { + const client = new Client(config) + await client.connect() + + try { + const explainOptions = analyze + ? 'ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON' + : 'COSTS, VERBOSE, FORMAT JSON' + const explainQuery = `EXPLAIN (${explainOptions}) ${sql}` + + const start = Date.now() + const res = await client.query(explainQuery) + const duration = Date.now() - start + + const planJson = res.rows[0]?.['QUERY PLAN'] + + return { + plan: planJson, + durationMs: duration + } + } finally { + await client.end() + } + } + + async getTableDDL( + config: ConnectionConfig, + schema: string, + table: string + ): Promise { + const client = new Client(config) + await client.connect() + + try { + // Query columns with full metadata + const columnsResult = await client.query( + ` + SELECT + c.column_name, + c.data_type, + c.udt_name, + c.is_nullable, + c.column_default, + c.ordinal_position, + c.character_maximum_length, + c.numeric_precision, + c.numeric_scale, + c.collation_name, + CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key, + col_description( + (quote_ident($1) || '.' || quote_ident($2))::regclass, + c.ordinal_position + ) as column_comment + FROM information_schema.columns c + LEFT JOIN ( + SELECT kcu.column_name + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = $1 + AND tc.table_name = $2 + ) pk ON c.column_name = pk.column_name + WHERE c.table_schema = $1 AND c.table_name = $2 + ORDER BY c.ordinal_position + `, + [schema, table] + ) + + // Query constraints + const constraintsResult = await client.query( + ` + SELECT + tc.constraint_name, + tc.constraint_type, + kcu.column_name, + ccu.table_schema AS ref_schema, + ccu.table_name AS ref_table, + ccu.column_name AS ref_column, + rc.update_rule, + rc.delete_rule, + cc.check_clause + FROM information_schema.table_constraints tc + LEFT JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + LEFT JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.constraint_type = 'FOREIGN KEY' + LEFT JOIN information_schema.referential_constraints rc + ON tc.constraint_name = rc.constraint_name + LEFT JOIN information_schema.check_constraints cc + ON tc.constraint_name = cc.constraint_name + WHERE tc.table_schema = $1 AND tc.table_name = $2 + ORDER BY tc.constraint_name, kcu.ordinal_position + `, + [schema, table] + ) + + // Query indexes + const indexesResult = await client.query( + ` + SELECT + i.relname as index_name, + ix.indisunique as is_unique, + am.amname as index_method, + array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns, + pg_get_expr(ix.indpred, ix.indrelid) as where_clause + FROM pg_index ix + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_am am ON am.oid = i.relam + LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) + WHERE n.nspname = $1 AND t.relname = $2 + AND NOT ix.indisprimary -- Exclude primary key index + GROUP BY i.relname, ix.indisunique, am.amname, ix.indpred, ix.indrelid + `, + [schema, table] + ) + + // Query table comment + const tableCommentResult = await client.query( + ` + SELECT obj_description( + (quote_ident($1) || '.' || quote_ident($2))::regclass + ) as comment + `, + [schema, table] + ) + + // Build TableDefinition + const columns: ColumnDefinition[] = columnsResult.rows.map((row, idx) => ({ + id: `col-${idx}`, + name: row.column_name, + dataType: row.udt_name, + length: row.character_maximum_length || undefined, + precision: row.numeric_precision || undefined, + scale: row.numeric_scale || undefined, + isNullable: row.is_nullable === 'YES', + isPrimaryKey: row.is_primary_key, + isUnique: false, // Will be set from constraints + defaultValue: row.column_default || undefined, + comment: row.column_comment || undefined, + collation: row.collation_name || undefined + })) + + // Build constraints from query results + const constraintMap = new Map< + string, + { + type: string + columns: string[] + refSchema?: string + refTable?: string + refColumns?: string[] + onUpdate?: string + onDelete?: string + checkExpression?: string + } + >() + + for (const row of constraintsResult.rows) { + const key = row.constraint_name + if (!constraintMap.has(key)) { + constraintMap.set(key, { + type: row.constraint_type, + columns: [], + refSchema: row.ref_schema, + refTable: row.ref_table, + refColumns: [], + onUpdate: row.update_rule, + onDelete: row.delete_rule, + checkExpression: row.check_clause + }) + } + const constraint = constraintMap.get(key)! + if (row.column_name && !constraint.columns.includes(row.column_name)) { + constraint.columns.push(row.column_name) + } + if (row.ref_column && !constraint.refColumns!.includes(row.ref_column)) { + constraint.refColumns!.push(row.ref_column) + } + } + + const constraints: ConstraintDefinition[] = [] + let constraintIdx = 0 + for (const [name, data] of constraintMap) { + // Skip primary key (handled at column level) + if (data.type === 'PRIMARY KEY') continue + + const constraintDef: ConstraintDefinition = { + id: `constraint-${constraintIdx++}`, + name, + type: + data.type === 'FOREIGN KEY' + ? 'foreign_key' + : data.type === 'UNIQUE' + ? 'unique' + : data.type === 'CHECK' + ? 'check' + : 'unique', + columns: data.columns + } + + if (data.type === 'FOREIGN KEY') { + constraintDef.referencedSchema = data.refSchema + constraintDef.referencedTable = data.refTable + constraintDef.referencedColumns = data.refColumns + constraintDef.onUpdate = data.onUpdate as ConstraintDefinition['onUpdate'] + constraintDef.onDelete = data.onDelete as ConstraintDefinition['onDelete'] + } + + if (data.type === 'CHECK') { + constraintDef.checkExpression = data.checkExpression + } + + // Mark columns as unique for UNIQUE constraints + if (data.type === 'UNIQUE' && data.columns.length === 1) { + const col = columns.find((c) => c.name === data.columns[0]) + if (col) col.isUnique = true + } + + constraints.push(constraintDef) + } + + // Build indexes + const indexes: IndexDefinition[] = indexesResult.rows.map((row, idx) => { + // Handle columns array - could be null, undefined, or not an array in some cases + const columnsArray = Array.isArray(row.columns) + ? row.columns.filter((c: string | null) => c !== null) + : [] + + return { + id: `index-${idx}`, + name: row.index_name, + columns: columnsArray.map((c: string) => ({ name: c })), + isUnique: row.is_unique, + method: row.index_method as IndexDefinition['method'], + where: row.where_clause || undefined + } + }) + + return { + schema, + name: table, + columns, + constraints, + indexes, + comment: tableCommentResult.rows[0]?.comment || undefined + } + } finally { + await client.end() + } + } + + async getSequences(config: ConnectionConfig): Promise { + const client = new Client(config) + await client.connect() + + try { + const result = await client.query(` + SELECT + schemaname as schema, + sequencename as name, + data_type, + start_value::text, + increment_by::text as increment + FROM pg_sequences + WHERE schemaname NOT IN ('pg_catalog', 'information_schema') + ORDER BY schemaname, sequencename + `) + + return result.rows.map((row) => ({ + schema: row.schema, + name: row.name, + dataType: row.data_type, + startValue: row.start_value, + increment: row.increment + })) + } finally { + await client.end() + } + } + + async getTypes(config: ConnectionConfig): Promise { + const client = new Client(config) + await client.connect() + + try { + // Get enum types with their values + const enumsResult = await client.query(` + SELECT + n.nspname as schema, + t.typname as name, + 'enum' as type_category, + array_agg(e.enumlabel ORDER BY e.enumsortorder) as values + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + JOIN pg_enum e ON e.enumtypid = t.oid + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') + GROUP BY n.nspname, t.typname + ORDER BY n.nspname, t.typname + `) + + // Get domain types + const domainsResult = await client.query(` + SELECT + n.nspname as schema, + t.typname as name, + 'domain' as type_category + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typtype = 'd' + AND n.nspname NOT IN ('pg_catalog', 'information_schema') + ORDER BY n.nspname, t.typname + `) + + return [ + ...enumsResult.rows.map((row) => ({ + schema: row.schema, + name: row.name, + type: 'enum' as const, + values: row.values + })), + ...domainsResult.rows.map((row) => ({ + schema: row.schema, + name: row.name, + type: 'domain' as const + })) + ] + } finally { + await client.end() + } + } +} diff --git a/apps/desktop/src/main/db-adapter.ts b/apps/desktop/src/main/db-adapter.ts new file mode 100644 index 0000000..13872d1 --- /dev/null +++ b/apps/desktop/src/main/db-adapter.ts @@ -0,0 +1,102 @@ +import type { + ConnectionConfig, + DatabaseType, + SchemaInfo, + QueryField, + TableDefinition, + SequenceInfo, + CustomTypeInfo +} from '@shared/index' + +/** + * Query result with metadata + */ +export interface AdapterQueryResult { + rows: Record[] + fields: QueryField[] + rowCount: number | null +} + +/** + * Explain plan result + */ +export interface ExplainResult { + plan: unknown + durationMs: number +} + +/** + * Database adapter interface - abstracts database-specific operations + */ +export interface DatabaseAdapter { + /** Database type identifier */ + readonly dbType: DatabaseType + + /** Test connection */ + connect(config: ConnectionConfig): Promise + + /** Execute a query and return results */ + query(config: ConnectionConfig, sql: string): Promise + + /** Execute a statement (for INSERT/UPDATE/DELETE in transactions) */ + execute( + config: ConnectionConfig, + sql: string, + params: unknown[] + ): Promise<{ rowCount: number | null }> + + /** Execute multiple statements in a transaction */ + executeTransaction( + config: ConnectionConfig, + statements: Array<{ sql: string; params: unknown[] }> + ): Promise<{ rowsAffected: number; results: Array<{ rowCount: number | null }> }> + + /** Fetch database schemas, tables, and columns */ + getSchemas(config: ConnectionConfig): Promise + + /** Get query execution plan */ + explain(config: ConnectionConfig, sql: string, analyze: boolean): Promise + + /** Get table definition (reverse engineer DDL) */ + getTableDDL(config: ConnectionConfig, schema: string, table: string): Promise + + /** Get available sequences (PostgreSQL-specific, returns empty for MySQL) */ + getSequences(config: ConnectionConfig): Promise + + /** Get custom types (enums, etc.) */ + getTypes(config: ConnectionConfig): Promise +} + +// Import adapters +import { PostgresAdapter } from './adapters/postgres-adapter' +import { MySQLAdapter } from './adapters/mysql-adapter' + +// Adapter instances (singletons) +const adapters: Record = { + postgresql: new PostgresAdapter(), + mysql: new MySQLAdapter(), + sqlite: new PostgresAdapter() // Placeholder - SQLite not implemented yet +} + +/** + * Get the appropriate database adapter for a connection + */ +export function getAdapter(config: ConnectionConfig): DatabaseAdapter { + const dbType = config.dbType || 'postgresql' // Default to postgresql for backward compatibility + const adapter = adapters[dbType] + if (!adapter) { + throw new Error(`Unsupported database type: ${dbType}`) + } + return adapter +} + +/** + * Get adapter by database type + */ +export function getAdapterByType(dbType: DatabaseType): DatabaseAdapter { + const adapter = adapters[dbType] + if (!adapter) { + throw new Error(`Unsupported database type: ${dbType}`) + } + return adapter +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 370198e..2b9c58d 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,25 +1,14 @@ import { app, shell, BrowserWindow, ipcMain } from 'electron' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' -import { Client } from 'pg' import icon from '../../resources/icon.png?asset' import type { ConnectionConfig, - SchemaInfo, - TableInfo, - ColumnInfo, - QueryField, - ForeignKeyInfo, EditBatch, EditResult, TableDefinition, AlterTableBatch, - DDLResult, - SequenceInfo, - CustomTypeInfo, - ColumnDefinition, - ConstraintDefinition, - IndexDefinition + DDLResult } from '@shared/index' import { buildQuery, validateOperation, buildPreviewSql } from './sql-builder' import { @@ -32,83 +21,7 @@ import { import { createMenu } from './menu' import { setupContextMenu } from './context-menu' import { getWindowState, trackWindowState } from './window-state' - -// ============================================ -// PostgreSQL OID to Type Name Mapping -// Reference: https://github.com/postgres/postgres/blob/master/src/include/catalog/pg_type.dat -// ============================================ -const PG_TYPE_MAP: Record = { - 16: 'boolean', - 17: 'bytea', - 18: 'char', - 19: 'name', - 20: 'bigint', - 21: 'smallint', - 23: 'integer', - 24: 'regproc', - 25: 'text', - 26: 'oid', - 114: 'json', - 142: 'xml', - 600: 'point', - 601: 'lseg', - 602: 'path', - 603: 'box', - 604: 'polygon', - 628: 'line', - 700: 'real', - 701: 'double precision', - 718: 'circle', - 790: 'money', - 829: 'macaddr', - 869: 'inet', - 650: 'cidr', - 1042: 'char', - 1043: 'varchar', - 1082: 'date', - 1083: 'time', - 1114: 'timestamp', - 1184: 'timestamptz', - 1186: 'interval', - 1266: 'timetz', - 1560: 'bit', - 1562: 'varbit', - 1700: 'numeric', - 2950: 'uuid', - 3802: 'jsonb', - 3904: 'int4range', - 3906: 'numrange', - 3908: 'tsrange', - 3910: 'tstzrange', - 3912: 'daterange', - 3926: 'int8range', - // Array types (common ones) - 1000: 'boolean[]', - 1001: 'bytea[]', - 1005: 'smallint[]', - 1007: 'integer[]', - 1009: 'text[]', - 1014: 'char[]', - 1015: 'varchar[]', - 1016: 'bigint[]', - 1021: 'real[]', - 1022: 'double precision[]', - 1028: 'oid[]', - 1115: 'timestamp[]', - 1182: 'date[]', - 1183: 'time[]', - 1231: 'numeric[]', - 2951: 'uuid[]', - 3807: 'jsonb[]', - 199: 'json[]' -} - -/** - * Resolve PostgreSQL OID to human-readable type name - */ -function resolvePostgresType(dataTypeID: number): string { - return PG_TYPE_MAP[dataTypeID] ?? `unknown(${dataTypeID})` -} +import { getAdapter } from './db-adapter' // electron-store v11 is ESM-only, use dynamic import type StoreType = import('electron-store').default<{ connections: ConnectionConfig[] }> @@ -224,11 +137,10 @@ app.whenReady().then(async () => { }) // IPC Handlers - ipcMain.handle('db:connect', async (_, config) => { + ipcMain.handle('db:connect', async (_, config: ConnectionConfig) => { try { - const client = new Client(config) - await client.connect() - await client.end() + const adapter = getAdapter(config) + await adapter.connect(config) return { success: true } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) @@ -236,36 +148,26 @@ app.whenReady().then(async () => { } }) - ipcMain.handle('db:query', async (_, { config, query }) => { + ipcMain.handle('db:query', async (_, { config, query }: { config: ConnectionConfig; query: string }) => { console.log('[main:db:query] Received query request') console.log('[main:db:query] Config:', { ...config, password: '***' }) console.log('[main:db:query] Query:', query) try { - const client = new Client(config) + const adapter = getAdapter(config) console.log('[main:db:query] Connecting...') - await client.connect() - console.log('[main:db:query] Connected, executing query...') const start = Date.now() - const res = await client.query(query) + const result = await adapter.query(config, query) const duration = Date.now() - start console.log('[main:db:query] Query completed in', duration, 'ms') - console.log('[main:db:query] Rows:', res.rowCount) - await client.end() - - // Map fields with resolved type names - const fields: QueryField[] = res.fields.map((f) => ({ - name: f.name, - dataType: resolvePostgresType(f.dataTypeID), - dataTypeID: f.dataTypeID - })) + console.log('[main:db:query] Rows:', result.rowCount) return { success: true, data: { - rows: res.rows, - fields, - rowCount: res.rowCount, + rows: result.rows, + fields: result.fields, + rowCount: result.rowCount, durationMs: duration } } @@ -278,160 +180,9 @@ app.whenReady().then(async () => { // Fetch database schemas, tables, and columns ipcMain.handle('db:schemas', async (_, config: ConnectionConfig) => { - const client = new Client(config) - try { - await client.connect() - - // Query 1: Get all schemas (excluding system schemas) - const schemasResult = await client.query(` - SELECT schema_name - FROM information_schema.schemata - WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') - ORDER BY schema_name - `) - - // Query 2: Get all tables and views - const tablesResult = await client.query(` - SELECT - table_schema, - table_name, - table_type - FROM information_schema.tables - WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') - ORDER BY table_schema, table_name - `) - - // Query 3: Get all columns with primary key info - const columnsResult = await client.query(` - SELECT - c.table_schema, - c.table_name, - c.column_name, - c.data_type, - c.udt_name, - c.is_nullable, - c.column_default, - c.ordinal_position, - c.character_maximum_length, - c.numeric_precision, - c.numeric_scale, - CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key - FROM information_schema.columns c - LEFT JOIN ( - SELECT - kcu.table_schema, - kcu.table_name, - kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - ) pk ON c.table_schema = pk.table_schema - AND c.table_name = pk.table_name - AND c.column_name = pk.column_name - WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') - ORDER BY c.table_schema, c.table_name, c.ordinal_position - `) - - // Query 4: Get all foreign key relationships - const foreignKeysResult = await client.query(` - SELECT - tc.table_schema, - tc.table_name, - kcu.column_name, - tc.constraint_name, - ccu.table_schema AS referenced_schema, - ccu.table_name AS referenced_table, - ccu.column_name AS referenced_column - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.constraint_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') - ORDER BY tc.table_schema, tc.table_name, kcu.column_name - `) - - await client.end() - - // Build foreign key lookup map: "schema.table.column" -> ForeignKeyInfo - const fkMap = new Map() - for (const row of foreignKeysResult.rows) { - const key = `${row.table_schema}.${row.table_name}.${row.column_name}` - fkMap.set(key, { - constraintName: row.constraint_name, - referencedSchema: row.referenced_schema, - referencedTable: row.referenced_table, - referencedColumn: row.referenced_column - }) - } - - // Build schema structure - const schemaMap = new Map() - - // Initialize schemas - for (const row of schemasResult.rows) { - schemaMap.set(row.schema_name, { - name: row.schema_name, - tables: [] - }) - } - - // Build tables map for easy column assignment - const tableMap = new Map() - for (const row of tablesResult.rows) { - const tableKey = `${row.table_schema}.${row.table_name}` - const table: TableInfo = { - name: row.table_name, - type: row.table_type === 'VIEW' ? 'view' : 'table', - columns: [] - } - tableMap.set(tableKey, table) - - // Add table to its schema - const schema = schemaMap.get(row.table_schema) - if (schema) { - schema.tables.push(table) - } - } - - // Assign columns to tables - for (const row of columnsResult.rows) { - const tableKey = `${row.table_schema}.${row.table_name}` - const table = tableMap.get(tableKey) - if (table) { - // Format data type nicely - let dataType = row.udt_name - if (row.character_maximum_length) { - dataType = `${row.udt_name}(${row.character_maximum_length})` - } else if (row.numeric_precision && row.numeric_scale) { - dataType = `${row.udt_name}(${row.numeric_precision},${row.numeric_scale})` - } - - // Check for foreign key relationship - const fkKey = `${row.table_schema}.${row.table_name}.${row.column_name}` - const foreignKey = fkMap.get(fkKey) - - const column: ColumnInfo = { - name: row.column_name, - dataType, - isNullable: row.is_nullable === 'YES', - isPrimaryKey: row.is_primary_key, - defaultValue: row.column_default || undefined, - ordinalPosition: row.ordinal_position, - foreignKey - } - table.columns.push(column) - } - } - - // Convert map to array - const schemas = Array.from(schemaMap.values()) + const adapter = getAdapter(config) + const schemas = await adapter.getSchemas(config) return { success: true, @@ -441,7 +192,6 @@ app.whenReady().then(async () => { } } } catch (error: unknown) { - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } @@ -506,7 +256,8 @@ app.whenReady().then(async () => { console.log('[main:db:execute] Context:', batch.context) console.log('[main:db:execute] Operations count:', batch.operations.length) - const client = new Client(config) + const adapter = getAdapter(config) + const dbType = config.dbType || 'postgresql' const result: EditResult = { success: true, rowsAffected: 0, @@ -514,69 +265,60 @@ app.whenReady().then(async () => { errors: [] } + // Validate operations first + const validOperations: Array<{ sql: string; params: unknown[]; preview: string; opId: string }> = [] + for (const operation of batch.operations) { + const validation = validateOperation(operation) + if (!validation.valid) { + result.errors!.push({ + operationId: operation.id, + message: validation.error! + }) + continue + } + + const query = buildQuery(operation, batch.context, dbType) + const previewSql = buildPreviewSql(operation, batch.context, dbType) + validOperations.push({ sql: query.sql, params: query.params, preview: previewSql, opId: operation.id }) + } + + // If all operations have validation errors, return early + if (validOperations.length === 0 && result.errors!.length > 0) { + result.success = false + return { success: true, data: result } + } + try { - await client.connect() + const statements = validOperations.map((op) => ({ sql: op.sql, params: op.params })) + const txResult = await adapter.executeTransaction(config, statements) - // Start transaction for atomicity - await client.query('BEGIN') + result.rowsAffected = txResult.rowsAffected + result.executedSql = validOperations.map((op) => op.preview) - for (const operation of batch.operations) { - // Validate operation - const validation = validateOperation(operation) - if (!validation.valid) { - result.errors!.push({ - operationId: operation.id, - message: validation.error! - }) - continue - } - - try { - // Build parameterized query - const query = buildQuery(operation, batch.context, 'postgresql') - const previewSql = buildPreviewSql(operation, batch.context, 'postgresql') - result.executedSql.push(previewSql) - - console.log('[main:db:execute] Executing:', query.sql) - console.log('[main:db:execute] Params:', query.params) - - const res = await client.query(query.sql, query.params) - result.rowsAffected += res.rowCount ?? 0 - } catch (opError: unknown) { - const errorMessage = opError instanceof Error ? opError.message : String(opError) - result.errors!.push({ - operationId: operation.id, - message: errorMessage - }) - } - } - - // If any errors, rollback; otherwise commit - if (result.errors && result.errors.length > 0) { - await client.query('ROLLBACK') - result.success = false - } else { - await client.query('COMMIT') - } - - await client.end() return { success: true, data: result } } catch (error: unknown) { console.error('[main:db:execute] Error:', error) - await client.query('ROLLBACK').catch(() => {}) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } + // Mark all valid operations as failed + for (const op of validOperations) { + result.errors!.push({ + operationId: op.opId, + message: errorMessage + }) + } + result.success = false + return { success: true, data: result } } } ) // Preview SQL for edit operations (without executing) - ipcMain.handle('db:preview-sql', (_, { batch }: { batch: EditBatch }) => { + ipcMain.handle('db:preview-sql', (_, { batch, dbType }: { batch: EditBatch; dbType?: string }) => { try { + const targetDbType = (dbType || 'postgresql') as 'postgresql' | 'mysql' | 'sqlite' const previews = batch.operations.map((op) => ({ operationId: op.id, - sql: buildPreviewSql(op, batch.context, 'postgresql') + sql: buildPreviewSql(op, batch.context, targetDbType) })) return { success: true, data: previews } } catch (error: unknown) { @@ -586,37 +328,18 @@ app.whenReady().then(async () => { }) // Execute EXPLAIN ANALYZE for query plan analysis - ipcMain.handle('db:explain', async (_, { config, query, analyze }) => { + ipcMain.handle('db:explain', async (_, { config, query, analyze }: { config: ConnectionConfig; query: string; analyze: boolean }) => { console.log('[main:db:explain] Received explain request') console.log('[main:db:explain] Query:', query) console.log('[main:db:explain] Analyze:', analyze) try { - const client = new Client(config) - await client.connect() - - // Build EXPLAIN query with JSON format for easy parsing - const explainOptions = analyze - ? 'ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON' - : 'COSTS, VERBOSE, FORMAT JSON' - const explainQuery = `EXPLAIN (${explainOptions}) ${query}` - - console.log('[main:db:explain] Running:', explainQuery) - const start = Date.now() - const res = await client.query(explainQuery) - const duration = Date.now() - start - - await client.end() - - // EXPLAIN with JSON format returns a single row with "QUERY PLAN" column containing JSON array - const planJson = res.rows[0]?.['QUERY PLAN'] + const adapter = getAdapter(config) + const result = await adapter.explain(config, query, analyze) return { success: true, - data: { - plan: planJson, - durationMs: duration - } + data: result } } catch (error: unknown) { console.error('[main:db:explain] Error:', error) @@ -647,7 +370,8 @@ app.whenReady().then(async () => { } } - const client = new Client(config) + const adapter = getAdapter(config) + const dbType = config.dbType || 'postgresql' const result: DDLResult = { success: true, executedSql: [], @@ -655,31 +379,26 @@ app.whenReady().then(async () => { } try { - await client.connect() - await client.query('BEGIN') - - // Build and execute CREATE TABLE statement - const { sql } = buildCreateTable(definition, 'postgresql') + // Build CREATE TABLE statement + const { sql } = buildCreateTable(definition, dbType) result.executedSql.push(sql) console.log('[main:db:create-table] Executing:', sql) // Execute each statement separately (CREATE TABLE, COMMENT, indexes) const statements = sql.split(/;\s*\n\n/).filter((s) => s.trim()) - for (const stmt of statements) { - if (stmt.trim()) { - await client.query(stmt.trim().endsWith(';') ? stmt : stmt + ';') - } - } + const stmtParams = statements + .filter((s) => s.trim()) + .map((stmt) => ({ + sql: stmt.trim().endsWith(';') ? stmt : stmt + ';', + params: [] + })) - await client.query('COMMIT') - await client.end() + await adapter.executeTransaction(config, stmtParams) return { success: true, data: result } } catch (error: unknown) { console.error('[main:db:create-table] Error:', error) - await client.query('ROLLBACK').catch(() => {}) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } @@ -692,7 +411,8 @@ app.whenReady().then(async () => { async (_, { config, batch }: { config: ConnectionConfig; batch: AlterTableBatch }) => { console.log('[main:db:alter-table] Altering table:', batch.schema, batch.table) - const client = new Client(config) + const adapter = getAdapter(config) + const dbType = config.dbType || 'postgresql' const result: DDLResult = { success: true, executedSql: [], @@ -700,39 +420,22 @@ app.whenReady().then(async () => { } try { - await client.connect() - await client.query('BEGIN') + // Build ALTER TABLE statements + const queries = buildAlterTable(batch, dbType) + const statements = queries.map((q) => ({ sql: q.sql, params: [] })) + result.executedSql = queries.map((q) => q.sql) - // Build and execute ALTER TABLE statements - const queries = buildAlterTable(batch, 'postgresql') + console.log('[main:db:alter-table] Executing:', result.executedSql) - for (const query of queries) { - result.executedSql.push(query.sql) - console.log('[main:db:alter-table] Executing:', query.sql) + await adapter.executeTransaction(config, statements) - try { - await client.query(query.sql) - } catch (opError: unknown) { - const errorMessage = opError instanceof Error ? opError.message : String(opError) - result.errors!.push(errorMessage) - } - } - - if (result.errors && result.errors.length > 0) { - await client.query('ROLLBACK') - result.success = false - } else { - await client.query('COMMIT') - } - - await client.end() return { success: true, data: result } } catch (error: unknown) { console.error('[main:db:alter-table] Error:', error) - await client.query('ROLLBACK').catch(() => {}) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) - return { success: false, error: errorMessage } + result.errors!.push(errorMessage) + result.success = false + return { success: true, data: result } } } ) @@ -751,16 +454,14 @@ app.whenReady().then(async () => { ) => { console.log('[main:db:drop-table] Dropping table:', schema, table) - const client = new Client(config) + const adapter = getAdapter(config) + const dbType = config.dbType || 'postgresql' try { - await client.connect() - - const { sql } = buildDropTable(schema, table, cascade, 'postgresql') + const { sql } = buildDropTable(schema, table, cascade, dbType) console.log('[main:db:drop-table] Executing:', sql) - await client.query(sql) - await client.end() + await adapter.executeTransaction(config, [{ sql, params: [] }]) return { success: true, @@ -768,7 +469,6 @@ app.whenReady().then(async () => { } } catch (error: unknown) { console.error('[main:db:drop-table] Error:', error) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } @@ -784,227 +484,12 @@ app.whenReady().then(async () => { ) => { console.log('[main:db:get-table-ddl] Getting DDL for:', schema, table) - const client = new Client(config) - try { - await client.connect() - - // Query columns with full metadata - const columnsResult = await client.query( - ` - SELECT - c.column_name, - c.data_type, - c.udt_name, - c.is_nullable, - c.column_default, - c.ordinal_position, - c.character_maximum_length, - c.numeric_precision, - c.numeric_scale, - c.collation_name, - CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as is_primary_key, - col_description( - (quote_ident($1) || '.' || quote_ident($2))::regclass, - c.ordinal_position - ) as column_comment - FROM information_schema.columns c - LEFT JOIN ( - SELECT kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = $1 - AND tc.table_name = $2 - ) pk ON c.column_name = pk.column_name - WHERE c.table_schema = $1 AND c.table_name = $2 - ORDER BY c.ordinal_position - `, - [schema, table] - ) - - // Query constraints - const constraintsResult = await client.query( - ` - SELECT - tc.constraint_name, - tc.constraint_type, - kcu.column_name, - ccu.table_schema AS ref_schema, - ccu.table_name AS ref_table, - ccu.column_name AS ref_column, - rc.update_rule, - rc.delete_rule, - cc.check_clause - FROM information_schema.table_constraints tc - LEFT JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - LEFT JOIN information_schema.constraint_column_usage ccu - ON tc.constraint_name = ccu.constraint_name - AND tc.constraint_type = 'FOREIGN KEY' - LEFT JOIN information_schema.referential_constraints rc - ON tc.constraint_name = rc.constraint_name - LEFT JOIN information_schema.check_constraints cc - ON tc.constraint_name = cc.constraint_name - WHERE tc.table_schema = $1 AND tc.table_name = $2 - ORDER BY tc.constraint_name, kcu.ordinal_position - `, - [schema, table] - ) - - // Query indexes - const indexesResult = await client.query( - ` - SELECT - i.relname as index_name, - ix.indisunique as is_unique, - am.amname as index_method, - array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns, - pg_get_expr(ix.indpred, ix.indrelid) as where_clause - FROM pg_index ix - JOIN pg_class i ON i.oid = ix.indexrelid - JOIN pg_class t ON t.oid = ix.indrelid - JOIN pg_namespace n ON n.oid = t.relnamespace - JOIN pg_am am ON am.oid = i.relam - LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) - WHERE n.nspname = $1 AND t.relname = $2 - AND NOT ix.indisprimary -- Exclude primary key index - GROUP BY i.relname, ix.indisunique, am.amname, ix.indpred, ix.indrelid - `, - [schema, table] - ) - - // Query table comment - const tableCommentResult = await client.query( - ` - SELECT obj_description( - (quote_ident($1) || '.' || quote_ident($2))::regclass - ) as comment - `, - [schema, table] - ) - - await client.end() - - // Build TableDefinition - const columns: ColumnDefinition[] = columnsResult.rows.map((row, idx) => ({ - id: `col-${idx}`, - name: row.column_name, - dataType: row.udt_name, - length: row.character_maximum_length || undefined, - precision: row.numeric_precision || undefined, - scale: row.numeric_scale || undefined, - isNullable: row.is_nullable === 'YES', - isPrimaryKey: row.is_primary_key, - isUnique: false, // Will be set from constraints - defaultValue: row.column_default || undefined, - comment: row.column_comment || undefined, - collation: row.collation_name || undefined - })) - - // Build constraints from query results - const constraintMap = new Map< - string, - { type: string; columns: string[]; refSchema?: string; refTable?: string; refColumns?: string[]; onUpdate?: string; onDelete?: string; checkExpression?: string } - >() - - for (const row of constraintsResult.rows) { - const key = row.constraint_name - if (!constraintMap.has(key)) { - constraintMap.set(key, { - type: row.constraint_type, - columns: [], - refSchema: row.ref_schema, - refTable: row.ref_table, - refColumns: [], - onUpdate: row.update_rule, - onDelete: row.delete_rule, - checkExpression: row.check_clause - }) - } - const constraint = constraintMap.get(key)! - if (row.column_name && !constraint.columns.includes(row.column_name)) { - constraint.columns.push(row.column_name) - } - if (row.ref_column && !constraint.refColumns!.includes(row.ref_column)) { - constraint.refColumns!.push(row.ref_column) - } - } - - const constraints: ConstraintDefinition[] = [] - let constraintIdx = 0 - for (const [name, data] of constraintMap) { - // Skip primary key (handled at column level) - if (data.type === 'PRIMARY KEY') continue - - const constraintDef: ConstraintDefinition = { - id: `constraint-${constraintIdx++}`, - name, - type: - data.type === 'FOREIGN KEY' - ? 'foreign_key' - : data.type === 'UNIQUE' - ? 'unique' - : data.type === 'CHECK' - ? 'check' - : 'unique', - columns: data.columns - } - - if (data.type === 'FOREIGN KEY') { - constraintDef.referencedSchema = data.refSchema - constraintDef.referencedTable = data.refTable - constraintDef.referencedColumns = data.refColumns - constraintDef.onUpdate = data.onUpdate as ConstraintDefinition['onUpdate'] - constraintDef.onDelete = data.onDelete as ConstraintDefinition['onDelete'] - } - - if (data.type === 'CHECK') { - constraintDef.checkExpression = data.checkExpression - } - - // Mark columns as unique for UNIQUE constraints - if (data.type === 'UNIQUE' && data.columns.length === 1) { - const col = columns.find((c) => c.name === data.columns[0]) - if (col) col.isUnique = true - } - - constraints.push(constraintDef) - } - - // Build indexes - const indexes: IndexDefinition[] = indexesResult.rows.map((row, idx) => { - // Handle columns array - could be null, undefined, or not an array in some cases - const columnsArray = Array.isArray(row.columns) - ? row.columns.filter((c: string | null) => c !== null) - : [] - - return { - id: `index-${idx}`, - name: row.index_name, - columns: columnsArray.map((c: string) => ({ name: c })), - isUnique: row.is_unique, - method: row.index_method as IndexDefinition['method'], - where: row.where_clause || undefined - } - }) - - const definition: TableDefinition = { - schema, - name: table, - columns, - constraints, - indexes, - comment: tableCommentResult.rows[0]?.comment || undefined - } - + const adapter = getAdapter(config) + const definition = await adapter.getTableDDL(config, schema, table) return { success: true, data: definition } } catch (error: unknown) { console.error('[main:db:get-table-ddl] Error:', error) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } @@ -1015,37 +500,12 @@ app.whenReady().then(async () => { ipcMain.handle('db:get-sequences', async (_, config: ConnectionConfig) => { console.log('[main:db:get-sequences] Fetching sequences') - const client = new Client(config) - try { - await client.connect() - - const result = await client.query(` - SELECT - schemaname as schema, - sequencename as name, - data_type, - start_value::text, - increment_by::text as increment - FROM pg_sequences - WHERE schemaname NOT IN ('pg_catalog', 'information_schema') - ORDER BY schemaname, sequencename - `) - - await client.end() - - const sequences: SequenceInfo[] = result.rows.map((row) => ({ - schema: row.schema, - name: row.name, - dataType: row.data_type, - startValue: row.start_value, - increment: row.increment - })) - + const adapter = getAdapter(config) + const sequences = await adapter.getSequences(config) return { success: true, data: sequences } } catch (error: unknown) { console.error('[main:db:get-sequences] Error:', error) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } @@ -1055,68 +515,22 @@ app.whenReady().then(async () => { ipcMain.handle('db:get-types', async (_, config: ConnectionConfig) => { console.log('[main:db:get-types] Fetching custom types') - const client = new Client(config) - try { - await client.connect() - - // Get enum types with their values - const enumsResult = await client.query(` - SELECT - n.nspname as schema, - t.typname as name, - 'enum' as type_category, - array_agg(e.enumlabel ORDER BY e.enumsortorder) as values - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - JOIN pg_enum e ON e.enumtypid = t.oid - WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - GROUP BY n.nspname, t.typname - ORDER BY n.nspname, t.typname - `) - - // Get domain types - const domainsResult = await client.query(` - SELECT - n.nspname as schema, - t.typname as name, - 'domain' as type_category - FROM pg_type t - JOIN pg_namespace n ON n.oid = t.typnamespace - WHERE t.typtype = 'd' - AND n.nspname NOT IN ('pg_catalog', 'information_schema') - ORDER BY n.nspname, t.typname - `) - - await client.end() - - const types: CustomTypeInfo[] = [ - ...enumsResult.rows.map((row) => ({ - schema: row.schema, - name: row.name, - type: 'enum' as const, - values: row.values - })), - ...domainsResult.rows.map((row) => ({ - schema: row.schema, - name: row.name, - type: 'domain' as const - })) - ] - + const adapter = getAdapter(config) + const types = await adapter.getTypes(config) return { success: true, data: types } } catch (error: unknown) { console.error('[main:db:get-types] Error:', error) - await client.end().catch(() => {}) const errorMessage = error instanceof Error ? error.message : String(error) return { success: false, error: errorMessage } } }) // Preview DDL without executing - ipcMain.handle('db:preview-ddl', (_, { definition }: { definition: TableDefinition }) => { + ipcMain.handle('db:preview-ddl', (_, { definition, dbType }: { definition: TableDefinition; dbType?: string }) => { try { - const sql = buildPreviewDDL(definition, 'postgresql') + const targetDbType = (dbType || 'postgresql') as 'postgresql' | 'mysql' | 'sqlite' + const sql = buildPreviewDDL(definition, targetDbType) return { success: true, data: sql } } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/apps/desktop/src/renderer/src/components/add-connection-dialog.tsx b/apps/desktop/src/renderer/src/components/add-connection-dialog.tsx index bcdce60..02d32b1 100644 --- a/apps/desktop/src/renderer/src/components/add-connection-dialog.tsx +++ b/apps/desktop/src/renderer/src/components/add-connection-dialog.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useEffect } from 'react' import { Loader2, Database, CheckCircle2, XCircle, Link, Settings2 } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -12,16 +12,33 @@ import { SheetHeader, SheetTitle } from '@/components/ui/sheet' -import { useConnectionStore } from '@/stores' +import { useConnectionStore, type Connection } from '@/stores' +import type { DatabaseType } from '@shared/index' interface AddConnectionDialogProps { open: boolean onOpenChange: (open: boolean) => void + connection?: Connection | null } type InputMode = 'manual' | 'connection-string' -function parseConnectionString(connectionString: string): { +const DB_DEFAULTS: Record = { + postgresql: { port: '5432', user: 'postgres', database: 'postgres' }, + mysql: { port: '3306', user: 'root', database: '' }, + sqlite: { port: '', user: '', database: '' } +} + +const DB_PROTOCOLS: Record = { + postgresql: ['postgres', 'postgresql'], + mysql: ['mysql'], + sqlite: [] +} + +function parseConnectionString( + connectionString: string, + dbType: DatabaseType +): { host: string port: string database: string @@ -30,17 +47,20 @@ function parseConnectionString(connectionString: string): { ssl: boolean } | null { try { - // Handle postgresql:// or postgres:// URLs const url = new URL(connectionString) + const protocol = url.protocol.replace(':', '') - if (!url.protocol.startsWith('postgres')) { + // Validate protocol matches db type + const validProtocols = DB_PROTOCOLS[dbType] + if (!validProtocols.some((p) => protocol.startsWith(p))) { return null } + const defaults = DB_DEFAULTS[dbType] const host = url.hostname || 'localhost' - const port = url.port || '5432' - const database = url.pathname.replace(/^\//, '') || 'postgres' - const user = url.username || 'postgres' + const port = url.port || defaults.port + const database = url.pathname.replace(/^\//, '') || defaults.database + const user = url.username || defaults.user const password = decodeURIComponent(url.password || '') // Check for SSL in query params @@ -53,9 +73,16 @@ function parseConnectionString(connectionString: string): { } } -export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogProps) { +export function AddConnectionDialog({ + open, + onOpenChange, + connection: editConnection +}: AddConnectionDialogProps) { const addConnection = useConnectionStore((s) => s.addConnection) + const updateConnection = useConnectionStore((s) => s.updateConnection) + const isEditMode = !!editConnection + const [dbType, setDbType] = useState('postgresql') const [inputMode, setInputMode] = useState('manual') const [connectionString, setConnectionString] = useState('') const [parseError, setParseError] = useState(null) @@ -73,6 +100,40 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP const [testResult, setTestResult] = useState<'success' | 'error' | null>(null) const [testError, setTestError] = useState(null) + // Populate form when editing + useEffect(() => { + if (editConnection && open) { + setDbType(editConnection.dbType || 'postgresql') + setName(editConnection.name) + setHost(editConnection.host) + setPort(String(editConnection.port)) + setDatabase(editConnection.database) + setUser(editConnection.user) + setPassword(editConnection.password || '') + setSsl(editConnection.ssl || false) + setInputMode('manual') + setConnectionString('') + setParseError(null) + setTestResult(null) + setTestError(null) + } + }, [editConnection, open]) + + const handleDbTypeChange = (newType: DatabaseType) => { + setDbType(newType) + const defaults = DB_DEFAULTS[newType] + setPort(defaults.port) + setUser(defaults.user) + if (defaults.database) { + setDatabase(defaults.database) + } + // Clear connection string when switching types + setConnectionString('') + setParseError(null) + setTestResult(null) + setTestError(null) + } + const handleConnectionStringChange = (value: string) => { setConnectionString(value) setParseError(null) @@ -81,7 +142,7 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP return } - const parsed = parseConnectionString(value) + const parsed = parseConnectionString(value, dbType) if (parsed) { setHost(parsed.host) setPort(parsed.port) @@ -90,11 +151,16 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP setPassword(parsed.password) setSsl(parsed.ssl) } else { - setParseError('Invalid connection string format') + const expectedFormat = + dbType === 'mysql' + ? 'mysql://user:password@host:3306/database' + : 'postgresql://user:password@host:5432/database' + setParseError(`Invalid connection string format. Expected: ${expectedFormat}`) } } const resetForm = () => { + setDbType('postgresql') setInputMode('manual') setConnectionString('') setParseError(null) @@ -115,14 +181,15 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP } const getConnectionConfig = () => ({ - id: crypto.randomUUID(), + id: editConnection?.id || crypto.randomUUID(), name: name || `${host}/${database}`, host, port: parseInt(port, 10), database, user, password: password || undefined, - ssl + ssl, + dbType }) const handleTestConnection = async () => { @@ -154,16 +221,22 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP try { const config = getConnectionConfig() - // Save to persistent storage - const result = await window.api.connections.add(config) - - if (result.success && result.data) { - // Add to local store - addConnection(result.data) + if (isEditMode) { + // Update existing connection + await updateConnection(config.id, config) handleClose() } else { - setTestResult('error') - setTestError(result.error || 'Failed to save connection') + // Save new connection to persistent storage + const result = await window.api.connections.add(config) + + if (result.success && result.data) { + // Add to local store + addConnection(result.data) + handleClose() + } else { + setTestResult('error') + setTestError(result.error || 'Failed to save connection') + } } } catch (error) { setTestResult('error') @@ -184,15 +257,45 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP - Add Connection + {isEditMode ? 'Edit Connection' : 'Add Connection'} - Add a new PostgreSQL database connection. Your credentials are stored securely on your - device. + {isEditMode + ? 'Update your database connection settings.' + : 'Add a new database connection. Your credentials are stored securely on your device.'}
+ {/* Database Type Selector */} +
+ +
+ + +
+
+ {/* Input Mode Toggle */}
+ +
{index < 9 && ⌘⇧{index + 1}} ))} @@ -156,7 +263,40 @@ export function ConnectionSwitcher() { - + + {/* Add/Edit Connection Dialog */} + { + if (!open) { + setIsAddDialogOpen(false) + setEditingConnection(null) + } + }} + connection={editingConnection} + /> + + {/* Delete Confirmation Dialog */} + setDeletingConnection(null)}> + + + Delete connection? + + Are you sure you want to delete "{deletingConnection?.name}"? This action cannot be + undone. + + + + Cancel + + Delete + + + + ) } diff --git a/apps/desktop/src/renderer/src/stores/connection-store.ts b/apps/desktop/src/renderer/src/stores/connection-store.ts index 91b1038..0e926ab 100644 --- a/apps/desktop/src/renderer/src/stores/connection-store.ts +++ b/apps/desktop/src/renderer/src/stores/connection-store.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { ConnectionConfig, SchemaInfo, TableInfo, ColumnInfo } from '@shared/index' +import { ConnectionConfig, SchemaInfo, TableInfo, ColumnInfo, DatabaseType } from '@shared/index' export interface Connection { id: string @@ -11,6 +11,7 @@ export interface Connection { password?: string ssl?: boolean group?: string + dbType: DatabaseType } export interface ConnectionWithStatus extends Connection { @@ -59,6 +60,8 @@ interface ConnectionState { // Helper to convert ConnectionConfig to ConnectionWithStatus const toConnectionWithStatus = (config: ConnectionConfig): ConnectionWithStatus => ({ ...config, + // Default to postgresql for backward compatibility with existing connections + dbType: config.dbType || 'postgresql', isConnected: false, isConnecting: false }) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2b9835a..984fcf3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,7 @@ export interface ConnectionConfig { user: string; password?: string; ssl?: boolean; + dbType: DatabaseType; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index beb3312..8cfe6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: monaco-editor: specifier: ^0.55.1 version: 0.55.1 + mysql2: + specifier: ^3.15.3 + version: 3.15.3 pg: specifier: ^8.16.3 version: 8.16.3 @@ -197,7 +200,7 @@ importers: version: 2.1.1 drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@types/pg@8.15.6)(better-sqlite3@12.4.6)(pg@8.16.3)(postgres@3.4.7) + version: 0.44.7(@types/pg@8.15.6)(better-sqlite3@12.4.6)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7) lucide-react: specifier: ^0.555.0 version: 0.555.0(react@19.2.0) @@ -240,7 +243,7 @@ importers: version: 9.39.1(jiti@2.6.1) eslint-config-next: specifier: 16.0.5 - version: 16.0.5(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) + version: 16.0.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) tailwindcss: specifier: ^4 version: 4.1.17 @@ -2217,6 +2220,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axe-core@4.11.0: resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} engines: {node: '>=4'} @@ -2571,6 +2578,10 @@ packages: delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -3135,6 +3146,9 @@ packages: engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} deprecated: This package is no longer supported. + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -3306,6 +3320,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3432,6 +3450,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3689,6 +3710,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3711,6 +3735,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lru.min@1.1.3: + resolution: {integrity: sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.555.0: resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} peerDependencies: @@ -3844,6 +3872,14 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.3: + resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==} + engines: {node: '>=12.0.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4363,6 +4399,9 @@ packages: engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + serialize-error@7.0.1: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} @@ -4478,6 +4517,10 @@ packages: resolution: {integrity: sha512-0bJOPQrRO/JkjQhiThVayq0hOKnI1tHI+2OTkmT7TGtc6kqS+V7kveeMzRW+RNQGxofmTmet9ILvztyuxv0cJQ==} hasBin: true + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + ssri@9.0.1: resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -6872,6 +6915,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-ssl-profiles@1.1.2: {} + axe-core@4.11.0: {} axobject-query@4.1.0: {} @@ -7255,6 +7300,8 @@ snapshots: delegates@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -7325,10 +7372,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.44.7(@types/pg@8.15.6)(better-sqlite3@12.4.6)(pg@8.16.3)(postgres@3.4.7): + drizzle-orm@0.44.7(@types/pg@8.15.6)(better-sqlite3@12.4.6)(mysql2@3.15.3)(pg@8.16.3)(postgres@3.4.7): optionalDependencies: '@types/pg': 8.15.6 better-sqlite3: 12.4.6 + mysql2: 3.15.3 pg: 8.16.3 postgres: 3.4.7 @@ -7619,13 +7667,13 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-next@16.0.5(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): + eslint-config-next@16.0.5(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3): dependencies: '@next/eslint-plugin-next': 16.0.5 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.1(jiti@2.6.1)) @@ -7651,7 +7699,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -7662,22 +7710,21 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): + eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7688,7 +7735,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@2.6.1)) + eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)))(eslint@9.39.1(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -7699,8 +7746,6 @@ snapshots: semver: 6.3.1 string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.48.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -7995,6 +8040,10 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -8198,6 +8247,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -8315,6 +8368,8 @@ snapshots: is-number@7.0.0: {} + is-property@1.0.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -8534,6 +8589,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -8552,6 +8609,8 @@ snapshots: lru-cache@7.18.3: {} + lru.min@1.1.3: {} + lucide-react@0.555.0(react@19.2.0): dependencies: react: 19.2.0 @@ -8682,6 +8741,22 @@ snapshots: ms@2.1.3: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.0 + long: 5.3.2 + lru.min: 1.1.3 + named-placeholders: 1.1.3 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.3: + dependencies: + lru-cache: 7.18.3 + nanoid@3.3.11: {} napi-build-utils@2.0.0: {} @@ -9238,6 +9313,8 @@ snapshots: semver@7.7.3: {} + seq-queue@0.0.5: {} + serialize-error@7.0.1: dependencies: type-fest: 0.13.1 @@ -9398,6 +9475,8 @@ snapshots: argparse: 2.0.1 nearley: 2.20.1 + sqlstring@2.3.3: {} + ssri@9.0.1: dependencies: minipass: 3.3.6 diff --git a/seeds/acme_saas_mysql_seed.sql b/seeds/acme_saas_mysql_seed.sql new file mode 100644 index 0000000..87c98f0 --- /dev/null +++ b/seeds/acme_saas_mysql_seed.sql @@ -0,0 +1,460 @@ +-- ============================================================================ +-- ACME SaaS - Sample Database for data-peek (MySQL Version) +-- ============================================================================ +-- A fictional SaaS platform database with realistic structure and data. +-- Perfect for testing, demos, and screenshots. +-- ============================================================================ + +-- Use the database +-- CREATE DATABASE IF NOT EXISTS playground; +-- USE playground; + +-- ============================================================================ +-- TABLES +-- ============================================================================ + +-- Users +CREATE TABLE users ( + id CHAR(36) PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + avatar_url VARCHAR(512), + email_verified_at DATETIME, + last_login_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_users_email (email), + INDEX idx_users_created_at (created_at) +); + +-- Organizations +CREATE TABLE organizations ( + id CHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + plan ENUM('free', 'starter', 'pro', 'enterprise') NOT NULL DEFAULT 'free', + logo_url VARCHAR(512), + website VARCHAR(255), + billing_email VARCHAR(255), + metadata JSON, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_organizations_slug (slug), + INDEX idx_organizations_plan (plan) +); + +-- Memberships (users <-> organizations) +CREATE TABLE memberships ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + organization_id CHAR(36) NOT NULL, + role ENUM('owner', 'admin', 'member', 'viewer') NOT NULL DEFAULT 'member', + invited_by CHAR(36), + joined_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + UNIQUE KEY unique_user_org (user_id, organization_id), + INDEX idx_memberships_user_id (user_id), + INDEX idx_memberships_organization_id (organization_id), + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Projects +CREATE TABLE projects ( + id CHAR(36) PRIMARY KEY, + organization_id CHAR(36) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + status ENUM('active', 'paused', 'archived', 'deleted') NOT NULL DEFAULT 'active', + settings JSON, + created_by CHAR(36), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_projects_organization_id (organization_id), + INDEX idx_projects_status (status), + + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- API Keys +CREATE TABLE api_keys ( + id CHAR(36) PRIMARY KEY, + organization_id CHAR(36) NOT NULL, + name VARCHAR(100) NOT NULL, + key_prefix VARCHAR(12) NOT NULL, + key_hash VARCHAR(255) NOT NULL, + scopes JSON, + last_used_at DATETIME, + expires_at DATETIME, + created_by CHAR(36), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + revoked_at DATETIME, + + INDEX idx_api_keys_organization_id (organization_id), + INDEX idx_api_keys_key_prefix (key_prefix), + + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); + +-- Subscriptions +CREATE TABLE subscriptions ( + id CHAR(36) PRIMARY KEY, + organization_id CHAR(36) NOT NULL, + plan ENUM('free', 'starter', 'pro', 'enterprise') NOT NULL, + status ENUM('active', 'past_due', 'canceled', 'trialing') NOT NULL DEFAULT 'active', + stripe_subscription_id VARCHAR(100), + stripe_customer_id VARCHAR(100), + current_period_start DATETIME NOT NULL, + current_period_end DATETIME NOT NULL, + cancel_at DATETIME, + canceled_at DATETIME, + trial_end DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_subscriptions_organization_id (organization_id), + INDEX idx_subscriptions_status (status), + + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE +); + +-- Invoices +CREATE TABLE invoices ( + id CHAR(36) PRIMARY KEY, + organization_id CHAR(36) NOT NULL, + subscription_id CHAR(36), + stripe_invoice_id VARCHAR(100), + amount_cents INT NOT NULL, + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + status ENUM('draft', 'pending', 'paid', 'failed', 'refunded') NOT NULL DEFAULT 'pending', + description VARCHAR(500), + invoice_url VARCHAR(512), + pdf_url VARCHAR(512), + due_date DATETIME, + paid_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_invoices_organization_id (organization_id), + INDEX idx_invoices_status (status), + INDEX idx_invoices_created_at (created_at), + + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE, + FOREIGN KEY (subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL +); + +-- Events (audit log / activity feed) +CREATE TABLE events ( + id CHAR(36) PRIMARY KEY, + organization_id CHAR(36), + user_id CHAR(36), + type ENUM('user.created', 'user.updated', 'org.created', 'project.created', 'subscription.started', 'subscription.canceled', 'invoice.paid', 'api_key.created') NOT NULL, + resource_type VARCHAR(50), + resource_id CHAR(36), + payload JSON, + ip_address VARCHAR(45), + user_agent VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_events_organization_id (organization_id), + INDEX idx_events_user_id (user_id), + INDEX idx_events_type (type), + INDEX idx_events_created_at (created_at), + + FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE SET NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +); + +-- Feature Flags +CREATE TABLE feature_flags ( + id CHAR(36) PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + rollout_percentage TINYINT UNSIGNED DEFAULT 0, + allowed_organizations JSON, + metadata JSON, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_feature_flags_name (name), + INDEX idx_feature_flags_enabled (enabled), + + CONSTRAINT chk_rollout CHECK (rollout_percentage >= 0 AND rollout_percentage <= 100) +); + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Users (20 users with realistic data) +INSERT INTO users (id, email, name, avatar_url, email_verified_at, last_login_at, created_at) VALUES + ('a1b2c3d4-e5f6-4789-abcd-111111111111', 'sarah.chen@gmail.com', 'Sarah Chen', 'https://api.dicebear.com/7.x/avataaars/svg?seed=sarah', DATE_SUB(NOW(), INTERVAL 89 DAY), DATE_SUB(NOW(), INTERVAL 2 HOUR), DATE_SUB(NOW(), INTERVAL 90 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-222222222222', 'marcus.johnson@outlook.com', 'Marcus Johnson', 'https://api.dicebear.com/7.x/avataaars/svg?seed=marcus', DATE_SUB(NOW(), INTERVAL 84 DAY), DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 85 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-333333333333', 'priya.patel@company.io', 'Priya Patel', 'https://api.dicebear.com/7.x/avataaars/svg?seed=priya', DATE_SUB(NOW(), INTERVAL 79 DAY), DATE_SUB(NOW(), INTERVAL 5 HOUR), DATE_SUB(NOW(), INTERVAL 80 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-444444444444', 'alex.rivera@techstartup.com', 'Alex Rivera', NULL, DATE_SUB(NOW(), INTERVAL 59 DAY), DATE_SUB(NOW(), INTERVAL 3 DAY), DATE_SUB(NOW(), INTERVAL 60 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-555555555555', 'emma.wilson@agency.co', 'Emma Wilson', 'https://api.dicebear.com/7.x/avataaars/svg?seed=emma', DATE_SUB(NOW(), INTERVAL 44 DAY), DATE_SUB(NOW(), INTERVAL 12 HOUR), DATE_SUB(NOW(), INTERVAL 45 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-666666666666', 'james.kim@devhouse.io', 'James Kim', 'https://api.dicebear.com/7.x/avataaars/svg?seed=james', DATE_SUB(NOW(), INTERVAL 39 DAY), DATE_SUB(NOW(), INTERVAL 6 HOUR), DATE_SUB(NOW(), INTERVAL 40 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-777777777777', 'olivia.martinez@freelance.dev', 'Olivia Martinez', NULL, NULL, DATE_SUB(NOW(), INTERVAL 7 DAY), DATE_SUB(NOW(), INTERVAL 35 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-888888888888', 'david.thompson@bigcorp.com', 'David Thompson', 'https://api.dicebear.com/7.x/avataaars/svg?seed=david', DATE_SUB(NOW(), INTERVAL 29 DAY), DATE_SUB(NOW(), INTERVAL 4 HOUR), DATE_SUB(NOW(), INTERVAL 30 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-999999999999', 'sofia.andersson@nordic.tech', 'Sofia Andersson', 'https://api.dicebear.com/7.x/avataaars/svg?seed=sofia', DATE_SUB(NOW(), INTERVAL 24 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 25 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-aaaaaaaaaaaa', 'ryan.ogrady@startup.ie', 'Ryan O\'Grady', 'https://api.dicebear.com/7.x/avataaars/svg?seed=ryan', DATE_SUB(NOW(), INTERVAL 19 DAY), DATE_SUB(NOW(), INTERVAL 8 HOUR), DATE_SUB(NOW(), INTERVAL 20 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-bbbbbbbbbbbb', 'mei.zhang@enterprise.cn', 'Mei Zhang', 'https://api.dicebear.com/7.x/avataaars/svg?seed=mei', DATE_SUB(NOW(), INTERVAL 14 DAY), DATE_SUB(NOW(), INTERVAL 1 HOUR), DATE_SUB(NOW(), INTERVAL 15 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-cccccccccccc', 'lucas.fernandez@latam.io', 'Lucas Fernandez', NULL, DATE_SUB(NOW(), INTERVAL 9 DAY), DATE_SUB(NOW(), INTERVAL 16 HOUR), DATE_SUB(NOW(), INTERVAL 10 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-dddddddddddd', 'anna.kowalski@polish.dev', 'Anna Kowalski', 'https://api.dicebear.com/7.x/avataaars/svg?seed=anna', DATE_SUB(NOW(), INTERVAL 7 DAY), DATE_SUB(NOW(), INTERVAL 3 HOUR), DATE_SUB(NOW(), INTERVAL 8 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-eeeeeeeeeeee', 'tom.nguyen@saigon.tech', 'Tom Nguyen', 'https://api.dicebear.com/7.x/avataaars/svg?seed=tom', DATE_SUB(NOW(), INTERVAL 6 DAY), DATE_SUB(NOW(), INTERVAL 45 MINUTE), DATE_SUB(NOW(), INTERVAL 7 DAY)), + ('a1b2c3d4-e5f6-4789-abcd-ffffffffffff', 'lisa.jackson@remote.work', 'Lisa Jackson', 'https://api.dicebear.com/7.x/avataaars/svg?seed=lisa', DATE_SUB(NOW(), INTERVAL 4 DAY), DATE_SUB(NOW(), INTERVAL 20 HOUR), DATE_SUB(NOW(), INTERVAL 5 DAY)), + ('b1b2c3d4-e5f6-4789-abcd-111111111111', 'ahmed.hassan@cairo.dev', 'Ahmed Hassan', NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 4 DAY)), + ('b1b2c3d4-e5f6-4789-abcd-222222222222', 'nina.volkov@moscow.io', 'Nina Volkov', 'https://api.dicebear.com/7.x/avataaars/svg?seed=nina', DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 5 HOUR), DATE_SUB(NOW(), INTERVAL 3 DAY)), + ('b1b2c3d4-e5f6-4789-abcd-333333333333', 'chris.baker@london.agency', 'Chris Baker', 'https://api.dicebear.com/7.x/avataaars/svg?seed=chris', DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_SUB(NOW(), INTERVAL 30 MINUTE), DATE_SUB(NOW(), INTERVAL 2 DAY)), + ('b1b2c3d4-e5f6-4789-abcd-444444444444', 'yuki.tanaka@tokyo.tech', 'Yuki Tanaka', 'https://api.dicebear.com/7.x/avataaars/svg?seed=yuki', DATE_SUB(NOW(), INTERVAL 12 HOUR), DATE_SUB(NOW(), INTERVAL 2 HOUR), DATE_SUB(NOW(), INTERVAL 1 DAY)), + ('b1b2c3d4-e5f6-4789-abcd-555555555555', 'fatima.ali@dubai.startup', 'Fatima Ali', NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 6 HOUR)); + +-- Organizations (8 organizations with varied plans) +INSERT INTO organizations (id, name, slug, plan, logo_url, website, billing_email, metadata, created_at) VALUES + ('c1c2c3c4-e5f6-4789-abcd-111111111111', 'Acme Corporation', 'acme-corp', 'enterprise', 'https://api.dicebear.com/7.x/identicon/svg?seed=acme', 'https://acme.example.com', 'billing@acme.example.com', '{"industry": "technology", "size": "500-1000", "region": "north-america"}', DATE_SUB(NOW(), INTERVAL 88 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-222222222222', 'Startup Labs', 'startup-labs', 'pro', 'https://api.dicebear.com/7.x/identicon/svg?seed=startuplabs', 'https://startuplabs.io', 'finance@startuplabs.io', '{"industry": "saas", "size": "10-50", "region": "europe"}', DATE_SUB(NOW(), INTERVAL 75 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-333333333333', 'DevHouse Agency', 'devhouse', 'pro', 'https://api.dicebear.com/7.x/identicon/svg?seed=devhouse', 'https://devhouse.io', NULL, '{"industry": "agency", "size": "50-100"}', DATE_SUB(NOW(), INTERVAL 60 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-444444444444', 'Nordic Tech Solutions', 'nordic-tech', 'starter', NULL, 'https://nordic-tech.se', 'accounts@nordic-tech.se', '{"industry": "consulting", "size": "10-50", "region": "europe"}', DATE_SUB(NOW(), INTERVAL 45 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-555555555555', 'Solo Developer', 'solo-dev', 'free', NULL, NULL, NULL, '{}', DATE_SUB(NOW(), INTERVAL 30 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-666666666666', 'Enterprise Global Inc', 'enterprise-global', 'enterprise', 'https://api.dicebear.com/7.x/identicon/svg?seed=enterprise', 'https://enterprise-global.com', 'ap@enterprise-global.com', '{"industry": "finance", "size": "1000+", "region": "global"}', DATE_SUB(NOW(), INTERVAL 20 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-777777777777', 'Fresh Startup', 'fresh-startup', 'starter', NULL, NULL, 'hello@freshstartup.co', '{"industry": "fintech", "size": "1-10"}', DATE_SUB(NOW(), INTERVAL 10 DAY)), + ('c1c2c3c4-e5f6-4789-abcd-888888888888', 'Trial Company', 'trial-company', 'free', NULL, NULL, NULL, '{}', DATE_SUB(NOW(), INTERVAL 3 DAY)); + +-- Memberships +INSERT INTO memberships (id, user_id, organization_id, role, invited_by, joined_at) VALUES + -- Acme Corporation (enterprise) - 6 members + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-111111111111', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 88 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-222222222222', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'admin', 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 85 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-333333333333', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'member', 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 80 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-888888888888', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'member', 'a1b2c3d4-e5f6-4789-abcd-222222222222', DATE_SUB(NOW(), INTERVAL 30 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-bbbbbbbbbbbb', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'viewer', 'a1b2c3d4-e5f6-4789-abcd-222222222222', DATE_SUB(NOW(), INTERVAL 15 DAY)), + (UUID(), 'b1b2c3d4-e5f6-4789-abcd-333333333333', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'member', 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 2 DAY)), + + -- Startup Labs (pro) - 4 members + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 75 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'admin', 'a1b2c3d4-e5f6-4789-abcd-444444444444', DATE_SUB(NOW(), INTERVAL 45 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-cccccccccccc', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'member', 'a1b2c3d4-e5f6-4789-abcd-444444444444', DATE_SUB(NOW(), INTERVAL 10 DAY)), + (UUID(), 'b1b2c3d4-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'member', 'a1b2c3d4-e5f6-4789-abcd-555555555555', DATE_SUB(NOW(), INTERVAL 1 DAY)), + + -- DevHouse Agency (pro) - 3 members + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-666666666666', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 60 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-777777777777', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'admin', 'a1b2c3d4-e5f6-4789-abcd-666666666666', DATE_SUB(NOW(), INTERVAL 35 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-dddddddddddd', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'member', 'a1b2c3d4-e5f6-4789-abcd-666666666666', DATE_SUB(NOW(), INTERVAL 8 DAY)), + + -- Nordic Tech Solutions (starter) - 2 members + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-999999999999', 'c1c2c3c4-e5f6-4789-abcd-444444444444', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 45 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-aaaaaaaaaaaa', 'c1c2c3c4-e5f6-4789-abcd-444444444444', 'member', 'a1b2c3d4-e5f6-4789-abcd-999999999999', DATE_SUB(NOW(), INTERVAL 20 DAY)), + + -- Solo Developer (free) - 1 member + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-eeeeeeeeeeee', 'c1c2c3c4-e5f6-4789-abcd-555555555555', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 30 DAY)), + + -- Enterprise Global (enterprise) - 4 members + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-ffffffffffff', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 20 DAY)), + (UUID(), 'b1b2c3d4-e5f6-4789-abcd-111111111111', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'admin', 'a1b2c3d4-e5f6-4789-abcd-ffffffffffff', DATE_SUB(NOW(), INTERVAL 4 DAY)), + (UUID(), 'b1b2c3d4-e5f6-4789-abcd-222222222222', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'member', 'a1b2c3d4-e5f6-4789-abcd-ffffffffffff', DATE_SUB(NOW(), INTERVAL 3 DAY)), + (UUID(), 'b1b2c3d4-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'viewer', 'b1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 6 HOUR)), + + -- Fresh Startup (starter) - 2 members + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-cccccccccccc', 'c1c2c3c4-e5f6-4789-abcd-777777777777', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 10 DAY)), + (UUID(), 'a1b2c3d4-e5f6-4789-abcd-dddddddddddd', 'c1c2c3c4-e5f6-4789-abcd-777777777777', 'admin', 'a1b2c3d4-e5f6-4789-abcd-cccccccccccc', DATE_SUB(NOW(), INTERVAL 5 DAY)), + + -- Trial Company (free) - 1 member + (UUID(), 'b1b2c3d4-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-888888888888', 'owner', NULL, DATE_SUB(NOW(), INTERVAL 3 DAY)); + +-- Projects (15 projects across organizations) +INSERT INTO projects (id, organization_id, name, description, status, settings, created_by, created_at) VALUES + -- Acme Corporation projects + ('d1d2d3d4-e5f6-4789-abcd-111111111111', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Customer Portal', 'Main customer-facing web application', 'active', '{"environment": "production", "framework": "next.js"}', 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 87 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-222222222222', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Internal Dashboard', 'Admin tools and analytics', 'active', '{"environment": "production", "framework": "react"}', 'a1b2c3d4-e5f6-4789-abcd-222222222222', DATE_SUB(NOW(), INTERVAL 80 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-333333333333', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Mobile App v2', 'React Native mobile application', 'active', '{"environment": "staging", "framework": "react-native"}', 'a1b2c3d4-e5f6-4789-abcd-333333333333', DATE_SUB(NOW(), INTERVAL 45 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Legacy API', 'Deprecated REST API (sunset Q2 2024)', 'paused', '{"environment": "production", "deprecated": true}', 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 300 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Data Pipeline', NULL, 'archived', '{}', 'a1b2c3d4-e5f6-4789-abcd-222222222222', DATE_SUB(NOW(), INTERVAL 200 DAY)), + + -- Startup Labs projects + ('d1d2d3d4-e5f6-4789-abcd-666666666666', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'SaaS Platform', 'Core product platform', 'active', '{"environment": "production"}', 'a1b2c3d4-e5f6-4789-abcd-444444444444', DATE_SUB(NOW(), INTERVAL 74 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-777777777777', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'Marketing Site', 'Public website and blog', 'active', '{"environment": "production", "cms": "contentful"}', 'a1b2c3d4-e5f6-4789-abcd-555555555555', DATE_SUB(NOW(), INTERVAL 50 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-888888888888', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'Analytics Service', 'Internal metrics and tracking', 'active', '{}', 'a1b2c3d4-e5f6-4789-abcd-444444444444', DATE_SUB(NOW(), INTERVAL 30 DAY)), + + -- DevHouse Agency projects + ('d1d2d3d4-e5f6-4789-abcd-999999999999', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'Client: TechCorp', 'E-commerce platform build', 'active', '{"client": "techcorp", "deadline": "2024-06-30"}', 'a1b2c3d4-e5f6-4789-abcd-666666666666', DATE_SUB(NOW(), INTERVAL 55 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-aaaaaaaaaaaa', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'Client: HealthApp', 'Healthcare mobile app', 'active', '{"client": "healthapp"}', 'a1b2c3d4-e5f6-4789-abcd-777777777777', DATE_SUB(NOW(), INTERVAL 20 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-bbbbbbbbbbbb', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'Client: OldBank', 'Legacy system maintenance', 'archived', '{"client": "oldbank", "archived_reason": "contract_ended"}', 'a1b2c3d4-e5f6-4789-abcd-666666666666', DATE_SUB(NOW(), INTERVAL 180 DAY)), + + -- Nordic Tech Solutions + ('d1d2d3d4-e5f6-4789-abcd-cccccccccccc', 'c1c2c3c4-e5f6-4789-abcd-444444444444', 'Consulting Tools', 'Internal tooling suite', 'active', '{}', 'a1b2c3d4-e5f6-4789-abcd-999999999999', DATE_SUB(NOW(), INTERVAL 40 DAY)), + + -- Solo Developer + ('d1d2d3d4-e5f6-4789-abcd-dddddddddddd', 'c1c2c3c4-e5f6-4789-abcd-555555555555', 'Side Project', 'Personal SaaS experiment', 'active', '{"personal": true}', 'a1b2c3d4-e5f6-4789-abcd-eeeeeeeeeeee', DATE_SUB(NOW(), INTERVAL 28 DAY)), + + -- Enterprise Global + ('d1d2d3d4-e5f6-4789-abcd-eeeeeeeeeeee', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'Trading Platform', 'Real-time trading system', 'active', '{"environment": "production", "compliance": "sox"}', 'a1b2c3d4-e5f6-4789-abcd-ffffffffffff', DATE_SUB(NOW(), INTERVAL 18 DAY)), + ('d1d2d3d4-e5f6-4789-abcd-ffffffffffff', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'Risk Analytics', 'Risk assessment dashboard', 'active', '{"environment": "staging"}', 'b1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 3 DAY)); + +-- API Keys +INSERT INTO api_keys (id, organization_id, name, key_prefix, key_hash, scopes, last_used_at, expires_at, created_by, created_at, revoked_at) VALUES + -- Acme Corporation + ('e1e2e3e4-e5f6-4789-abcd-111111111111', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Production API', 'pk_live_acm', 'sha256$a1b2c3d4e5f6g7h8i9j0...', '["read", "write"]', DATE_SUB(NOW(), INTERVAL 5 MINUTE), DATE_ADD(NOW(), INTERVAL 1 YEAR), 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 85 DAY), NULL), + ('e1e2e3e4-e5f6-4789-abcd-222222222222', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Staging API', 'pk_test_acm', 'sha256$b2c3d4e5f6g7h8i9j0k1...', '["read", "write", "delete"]', DATE_SUB(NOW(), INTERVAL 2 HOUR), NULL, 'a1b2c3d4-e5f6-4789-abcd-222222222222', DATE_SUB(NOW(), INTERVAL 80 DAY), NULL), + ('e1e2e3e4-e5f6-4789-abcd-333333333333', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'CI/CD Pipeline', 'pk_ci_acme', 'sha256$c3d4e5f6g7h8i9j0k1l2...', '["read"]', DATE_SUB(NOW(), INTERVAL 1 DAY), DATE_ADD(NOW(), INTERVAL 90 DAY), 'a1b2c3d4-e5f6-4789-abcd-333333333333', DATE_SUB(NOW(), INTERVAL 60 DAY), NULL), + ('e1e2e3e4-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'Old Integration', 'pk_old_acme', 'sha256$d4e5f6g7h8i9j0k1l2m3...', '["read"]', DATE_SUB(NOW(), INTERVAL 180 DAY), DATE_SUB(NOW(), INTERVAL 30 DAY), 'a1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 365 DAY), DATE_SUB(NOW(), INTERVAL 30 DAY)), + + -- Startup Labs + ('e1e2e3e4-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'Main API Key', 'pk_live_stl', 'sha256$e5f6g7h8i9j0k1l2m3n4...', '["read", "write"]', DATE_SUB(NOW(), INTERVAL 30 MINUTE), NULL, 'a1b2c3d4-e5f6-4789-abcd-444444444444', DATE_SUB(NOW(), INTERVAL 70 DAY), NULL), + ('e1e2e3e4-e5f6-4789-abcd-666666666666', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'Webhook Service', 'pk_whk_stl', 'sha256$f6g7h8i9j0k1l2m3n4o5...', '["read"]', DATE_SUB(NOW(), INTERVAL 4 HOUR), NULL, 'a1b2c3d4-e5f6-4789-abcd-555555555555', DATE_SUB(NOW(), INTERVAL 45 DAY), NULL), + + -- DevHouse Agency + ('e1e2e3e4-e5f6-4789-abcd-777777777777', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'Client Projects', 'pk_live_dvh', 'sha256$g7h8i9j0k1l2m3n4o5p6...', '["read", "write", "delete"]', DATE_SUB(NOW(), INTERVAL 12 HOUR), NULL, 'a1b2c3d4-e5f6-4789-abcd-666666666666', DATE_SUB(NOW(), INTERVAL 55 DAY), NULL), + + -- Enterprise Global + ('e1e2e3e4-e5f6-4789-abcd-888888888888', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'Trading API', 'pk_live_ent', 'sha256$h8i9j0k1l2m3n4o5p6q7...', '["read", "write", "trade"]', DATE_SUB(NOW(), INTERVAL 2 MINUTE), NULL, 'a1b2c3d4-e5f6-4789-abcd-ffffffffffff', DATE_SUB(NOW(), INTERVAL 15 DAY), NULL), + ('e1e2e3e4-e5f6-4789-abcd-999999999999', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'Read-Only Analytics', 'pk_ro_ent', 'sha256$i9j0k1l2m3n4o5p6q7r8...', '["read"]', NULL, NULL, 'b1b2c3d4-e5f6-4789-abcd-111111111111', DATE_SUB(NOW(), INTERVAL 3 DAY), NULL); + +-- Subscriptions +INSERT INTO subscriptions (id, organization_id, plan, status, stripe_subscription_id, stripe_customer_id, current_period_start, current_period_end, cancel_at, canceled_at, trial_end, created_at) VALUES + -- Acme Corporation - Enterprise (active) + ('f1f2f3f4-e5f6-4789-abcd-111111111111', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'enterprise', 'active', 'sub_1NxYz123456789', 'cus_AcmeCorp001', DATE_SUB(NOW(), INTERVAL 28 DAY), DATE_ADD(NOW(), INTERVAL 2 DAY), NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 88 DAY)), + + -- Startup Labs - Pro (active) + ('f1f2f3f4-e5f6-4789-abcd-222222222222', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'pro', 'active', 'sub_2AbCd234567890', 'cus_StartupLabs01', DATE_SUB(NOW(), INTERVAL 15 DAY), DATE_ADD(NOW(), INTERVAL 15 DAY), NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 75 DAY)), + + -- DevHouse Agency - Pro (past_due) + ('f1f2f3f4-e5f6-4789-abcd-333333333333', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'pro', 'past_due', 'sub_3CdEf345678901', 'cus_DevHouse0001', DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 5 DAY), NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 60 DAY)), + + -- Nordic Tech Solutions - Starter (active) + ('f1f2f3f4-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-444444444444', 'starter', 'active', 'sub_4EfGh456789012', 'cus_NordicTech01', DATE_SUB(NOW(), INTERVAL 10 DAY), DATE_ADD(NOW(), INTERVAL 20 DAY), NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 45 DAY)), + + -- Enterprise Global - Enterprise (active) + ('f1f2f3f4-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'enterprise', 'active', 'sub_5GhIj567890123', 'cus_EntGlobal001', DATE_SUB(NOW(), INTERVAL 5 DAY), DATE_ADD(NOW(), INTERVAL 25 DAY), NULL, NULL, NULL, DATE_SUB(NOW(), INTERVAL 20 DAY)), + + -- Fresh Startup - Starter (trialing) + ('f1f2f3f4-e5f6-4789-abcd-666666666666', 'c1c2c3c4-e5f6-4789-abcd-777777777777', 'starter', 'trialing', 'sub_6IjKl678901234', 'cus_FreshStart01', DATE_SUB(NOW(), INTERVAL 10 DAY), DATE_ADD(NOW(), INTERVAL 20 DAY), NULL, NULL, DATE_ADD(NOW(), INTERVAL 4 DAY), DATE_SUB(NOW(), INTERVAL 10 DAY)), + + -- Trial Company - Canceled subscription (for history) + ('f1f2f3f4-e5f6-4789-abcd-777777777777', 'c1c2c3c4-e5f6-4789-abcd-888888888888', 'starter', 'canceled', 'sub_7KlMn789012345', 'cus_TrialCo00001', DATE_SUB(NOW(), INTERVAL 30 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 2 DAY), DATE_SUB(NOW(), INTERVAL 5 DAY), NULL, DATE_SUB(NOW(), INTERVAL 30 DAY)); + +-- Invoices +INSERT INTO invoices (id, organization_id, subscription_id, stripe_invoice_id, amount_cents, currency, status, description, invoice_url, due_date, paid_at, created_at) VALUES + -- Acme Corporation invoices + ('11111111-e5f6-4789-abcd-111111111111', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'f1f2f3f4-e5f6-4789-abcd-111111111111', 'in_acme_001', 99900, 'USD', 'paid', 'Enterprise Plan - Monthly', 'https://invoice.stripe.com/i/acme_001', DATE_SUB(NOW(), INTERVAL 58 DAY), DATE_SUB(NOW(), INTERVAL 57 DAY), DATE_SUB(NOW(), INTERVAL 60 DAY)), + ('11111111-e5f6-4789-abcd-222222222222', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'f1f2f3f4-e5f6-4789-abcd-111111111111', 'in_acme_002', 99900, 'USD', 'paid', 'Enterprise Plan - Monthly', 'https://invoice.stripe.com/i/acme_002', DATE_SUB(NOW(), INTERVAL 28 DAY), DATE_SUB(NOW(), INTERVAL 27 DAY), DATE_SUB(NOW(), INTERVAL 30 DAY)), + ('11111111-e5f6-4789-abcd-333333333333', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'f1f2f3f4-e5f6-4789-abcd-111111111111', 'in_acme_003', 99900, 'USD', 'pending', 'Enterprise Plan - Monthly', 'https://invoice.stripe.com/i/acme_003', DATE_ADD(NOW(), INTERVAL 2 DAY), NULL, NOW()), + + -- Startup Labs invoices + ('11111111-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'f1f2f3f4-e5f6-4789-abcd-222222222222', 'in_stl_001', 4900, 'USD', 'paid', 'Pro Plan - Monthly', 'https://invoice.stripe.com/i/stl_001', DATE_SUB(NOW(), INTERVAL 45 DAY), DATE_SUB(NOW(), INTERVAL 45 DAY), DATE_SUB(NOW(), INTERVAL 45 DAY)), + ('11111111-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-222222222222', 'f1f2f3f4-e5f6-4789-abcd-222222222222', 'in_stl_002', 4900, 'USD', 'paid', 'Pro Plan - Monthly', 'https://invoice.stripe.com/i/stl_002', DATE_SUB(NOW(), INTERVAL 15 DAY), DATE_SUB(NOW(), INTERVAL 14 DAY), DATE_SUB(NOW(), INTERVAL 15 DAY)), + + -- DevHouse Agency invoices (one failed) + ('11111111-e5f6-4789-abcd-666666666666', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'f1f2f3f4-e5f6-4789-abcd-333333333333', 'in_dvh_001', 4900, 'USD', 'paid', 'Pro Plan - Monthly', 'https://invoice.stripe.com/i/dvh_001', DATE_SUB(NOW(), INTERVAL 35 DAY), DATE_SUB(NOW(), INTERVAL 34 DAY), DATE_SUB(NOW(), INTERVAL 35 DAY)), + ('11111111-e5f6-4789-abcd-777777777777', 'c1c2c3c4-e5f6-4789-abcd-333333333333', 'f1f2f3f4-e5f6-4789-abcd-333333333333', 'in_dvh_002', 4900, 'USD', 'failed', 'Pro Plan - Monthly', 'https://invoice.stripe.com/i/dvh_002', DATE_SUB(NOW(), INTERVAL 5 DAY), NULL, DATE_SUB(NOW(), INTERVAL 5 DAY)), + + -- Nordic Tech Solutions + ('11111111-e5f6-4789-abcd-888888888888', 'c1c2c3c4-e5f6-4789-abcd-444444444444', 'f1f2f3f4-e5f6-4789-abcd-444444444444', 'in_nts_001', 1900, 'USD', 'paid', 'Starter Plan - Monthly', 'https://invoice.stripe.com/i/nts_001', DATE_SUB(NOW(), INTERVAL 10 DAY), DATE_SUB(NOW(), INTERVAL 10 DAY), DATE_SUB(NOW(), INTERVAL 10 DAY)), + + -- Enterprise Global + ('11111111-e5f6-4789-abcd-999999999999', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'f1f2f3f4-e5f6-4789-abcd-555555555555', 'in_ent_001', 99900, 'USD', 'paid', 'Enterprise Plan - Monthly', 'https://invoice.stripe.com/i/ent_001', DATE_SUB(NOW(), INTERVAL 5 DAY), DATE_SUB(NOW(), INTERVAL 4 DAY), DATE_SUB(NOW(), INTERVAL 5 DAY)), + + -- Refunded invoice example + ('11111111-e5f6-4789-abcd-aaaaaaaaaaaa', 'c1c2c3c4-e5f6-4789-abcd-888888888888', 'f1f2f3f4-e5f6-4789-abcd-777777777777', 'in_trial_001', 1900, 'USD', 'refunded', 'Starter Plan - Monthly (Refunded)', 'https://invoice.stripe.com/i/trial_001', DATE_SUB(NOW(), INTERVAL 30 DAY), DATE_SUB(NOW(), INTERVAL 29 DAY), DATE_SUB(NOW(), INTERVAL 30 DAY)); + +-- Events (audit log) +INSERT INTO events (id, organization_id, user_id, type, resource_type, resource_id, payload, ip_address, user_agent, created_at) VALUES + -- User signups + ('22222222-e5f6-4789-abcd-111111111111', NULL, 'a1b2c3d4-e5f6-4789-abcd-111111111111', 'user.created', 'user', 'a1b2c3d4-e5f6-4789-abcd-111111111111', '{"email": "sarah.chen@gmail.com"}', '73.162.214.130', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 90 DAY)), + ('22222222-e5f6-4789-abcd-222222222222', NULL, 'b1b2c3d4-e5f6-4789-abcd-444444444444', 'user.created', 'user', 'b1b2c3d4-e5f6-4789-abcd-444444444444', '{"email": "yuki.tanaka@tokyo.tech"}', '103.5.140.219', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', DATE_SUB(NOW(), INTERVAL 1 DAY)), + ('22222222-e5f6-4789-abcd-333333333333', NULL, 'b1b2c3d4-e5f6-4789-abcd-555555555555', 'user.created', 'user', 'b1b2c3d4-e5f6-4789-abcd-555555555555', '{"email": "fatima.ali@dubai.startup"}', '94.56.229.180', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)', DATE_SUB(NOW(), INTERVAL 6 HOUR)), + + -- Organization creation + ('22222222-e5f6-4789-abcd-444444444444', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'a1b2c3d4-e5f6-4789-abcd-111111111111', 'org.created', 'organization', 'c1c2c3c4-e5f6-4789-abcd-111111111111', '{"name": "Acme Corporation", "plan": "free"}', '73.162.214.130', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 88 DAY)), + ('22222222-e5f6-4789-abcd-555555555555', 'c1c2c3c4-e5f6-4789-abcd-888888888888', 'b1b2c3d4-e5f6-4789-abcd-444444444444', 'org.created', 'organization', 'c1c2c3c4-e5f6-4789-abcd-888888888888', '{"name": "Trial Company", "plan": "free"}', '103.5.140.219', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', DATE_SUB(NOW(), INTERVAL 3 DAY)), + + -- Project creation + ('22222222-e5f6-4789-abcd-666666666666', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'a1b2c3d4-e5f6-4789-abcd-111111111111', 'project.created', 'project', 'd1d2d3d4-e5f6-4789-abcd-111111111111', '{"name": "Customer Portal"}', '73.162.214.130', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 87 DAY)), + ('22222222-e5f6-4789-abcd-777777777777', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'b1b2c3d4-e5f6-4789-abcd-111111111111', 'project.created', 'project', 'd1d2d3d4-e5f6-4789-abcd-ffffffffffff', '{"name": "Risk Analytics"}', '185.45.12.89', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 3 DAY)), + + -- Subscription events + ('22222222-e5f6-4789-abcd-888888888888', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'a1b2c3d4-e5f6-4789-abcd-111111111111', 'subscription.started', 'subscription', 'f1f2f3f4-e5f6-4789-abcd-111111111111', '{"plan": "enterprise", "amount": 999}', '73.162.214.130', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 88 DAY)), + ('22222222-e5f6-4789-abcd-999999999999', 'c1c2c3c4-e5f6-4789-abcd-888888888888', 'b1b2c3d4-e5f6-4789-abcd-444444444444', 'subscription.canceled', 'subscription', 'f1f2f3f4-e5f6-4789-abcd-777777777777', '{"reason": "too_expensive", "feedback": "Will come back when we have more budget"}', '103.5.140.219', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', DATE_SUB(NOW(), INTERVAL 5 DAY)), + + -- Invoice paid + ('22222222-e5f6-4789-abcd-aaaaaaaaaaaa', 'c1c2c3c4-e5f6-4789-abcd-111111111111', NULL, 'invoice.paid', 'invoice', '11111111-e5f6-4789-abcd-222222222222', '{"amount": 999, "currency": "USD"}', NULL, NULL, DATE_SUB(NOW(), INTERVAL 27 DAY)), + ('22222222-e5f6-4789-abcd-bbbbbbbbbbbb', 'c1c2c3c4-e5f6-4789-abcd-666666666666', NULL, 'invoice.paid', 'invoice', '11111111-e5f6-4789-abcd-999999999999', '{"amount": 999, "currency": "USD"}', NULL, NULL, DATE_SUB(NOW(), INTERVAL 4 DAY)), + + -- API key created + ('22222222-e5f6-4789-abcd-cccccccccccc', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'a1b2c3d4-e5f6-4789-abcd-111111111111', 'api_key.created', 'api_key', 'e1e2e3e4-e5f6-4789-abcd-111111111111', '{"name": "Production API", "scopes": ["read", "write"]}', '73.162.214.130', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 85 DAY)), + ('22222222-e5f6-4789-abcd-dddddddddddd', 'c1c2c3c4-e5f6-4789-abcd-666666666666', 'a1b2c3d4-e5f6-4789-abcd-ffffffffffff', 'api_key.created', 'api_key', 'e1e2e3e4-e5f6-4789-abcd-888888888888', '{"name": "Trading API", "scopes": ["read", "write", "trade"]}', '185.45.12.89', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 15 DAY)), + + -- Recent activity burst + ('22222222-e5f6-4789-abcd-eeeeeeeeeeee', 'c1c2c3c4-e5f6-4789-abcd-111111111111', 'b1b2c3d4-e5f6-4789-abcd-333333333333', 'user.updated', 'user', 'b1b2c3d4-e5f6-4789-abcd-333333333333', '{"changes": ["avatar_url"]}', '88.120.45.67', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)', DATE_SUB(NOW(), INTERVAL 30 MINUTE)); + +-- Feature Flags +INSERT INTO feature_flags (id, name, description, enabled, rollout_percentage, allowed_organizations, metadata, created_at) VALUES + ('33333333-e5f6-4789-abcd-111111111111', 'new_dashboard', 'Redesigned analytics dashboard with charts', TRUE, 100, '[]', '{"version": "2.0", "designer": "emma"}', DATE_SUB(NOW(), INTERVAL 60 DAY)), + ('33333333-e5f6-4789-abcd-222222222222', 'ai_suggestions', 'AI-powered query suggestions in editor', TRUE, 25, '["c1c2c3c4-e5f6-4789-abcd-111111111111", "c1c2c3c4-e5f6-4789-abcd-666666666666"]', '{"model": "gpt-4", "beta": true}', DATE_SUB(NOW(), INTERVAL 30 DAY)), + ('33333333-e5f6-4789-abcd-333333333333', 'dark_mode_v2', 'Updated dark mode color scheme', TRUE, 50, '[]', '{}', DATE_SUB(NOW(), INTERVAL 20 DAY)), + ('33333333-e5f6-4789-abcd-444444444444', 'export_pdf', 'Export reports to PDF format', FALSE, 0, '[]', '{"status": "in_development", "eta": "2024-Q2"}', DATE_SUB(NOW(), INTERVAL 15 DAY)), + ('33333333-e5f6-4789-abcd-555555555555', 'team_collaboration', 'Real-time collaboration features', TRUE, 10, '["c1c2c3c4-e5f6-4789-abcd-111111111111"]', '{"websocket": true}', DATE_SUB(NOW(), INTERVAL 10 DAY)), + ('33333333-e5f6-4789-abcd-666666666666', 'sso_okta', 'Okta SSO integration', FALSE, 0, '["c1c2c3c4-e5f6-4789-abcd-666666666666"]', '{"enterprise_only": true}', DATE_SUB(NOW(), INTERVAL 5 DAY)), + ('33333333-e5f6-4789-abcd-777777777777', 'billing_v2', 'New billing system with usage-based pricing', FALSE, 0, '[]', '{"migration_required": true}', DATE_SUB(NOW(), INTERVAL 2 DAY)); + +-- ============================================================================ +-- HELPER VIEWS +-- ============================================================================ + +-- Organization summary view +CREATE VIEW organization_summary AS +SELECT + o.id, + o.name, + o.slug, + o.plan, + COUNT(DISTINCT m.user_id) as member_count, + COUNT(DISTINCT CASE WHEN p.status = 'active' THEN p.id END) as project_count, + s.status as subscription_status, + o.created_at +FROM organizations o +LEFT JOIN memberships m ON o.id = m.organization_id +LEFT JOIN projects p ON o.id = p.organization_id +LEFT JOIN subscriptions s ON o.id = s.organization_id +GROUP BY o.id, o.name, o.slug, o.plan, s.status, o.created_at; + +-- Recent activity view +CREATE VIEW recent_activity AS +SELECT + e.id, + e.type, + e.created_at, + u.name as user_name, + u.email as user_email, + o.name as organization_name, + e.payload +FROM events e +LEFT JOIN users u ON e.user_id = u.id +LEFT JOIN organizations o ON e.organization_id = o.id +ORDER BY e.created_at DESC +LIMIT 100; + +-- ============================================================================ +-- DONE! +-- ============================================================================ +-- +-- Summary: +-- - 20 users +-- - 8 organizations (across all plan tiers) +-- - 23 memberships +-- - 15 projects +-- - 9 API keys +-- - 7 subscriptions +-- - 10 invoices +-- - 15 events +-- - 7 feature flags +-- - 2 helper views +-- +-- MySQL Version - Enjoy! 🚀 +-- ============================================================================ \ No newline at end of file diff --git a/acme_saas_seed.sql b/seeds/acme_saas_seed.sql similarity index 100% rename from acme_saas_seed.sql rename to seeds/acme_saas_seed.sql