Merge pull request #3 from Rohithgilla12/claude/add-mysql-support-014ADuBH4s69Gn3nAx1qPwcf

feat: add MySQL database support
This commit is contained in:
Rohith Gilla 2025-11-29 08:35:07 +05:30 committed by GitHub
commit 8cf74ae473
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2398 additions and 730 deletions

View file

@ -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",

View file

@ -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<number, string> = {
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<T extends Record<string, unknown>>(row: Record<string, unknown>): T {
const normalized: Record<string, unknown> = {}
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<void> {
const connection = await mysql.createConnection(toMySQLConfig(config))
await connection.end()
}
async query(config: ConnectionConfig, sql: string): Promise<AdapterQueryResult> {
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<string, unknown>[],
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<SchemaInfo[]> {
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<Record<string, unknown>>
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<Record<string, unknown>>
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<Record<string, unknown>>
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<Record<string, unknown>>
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<string, ForeignKeyInfo>()
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<string, SchemaInfo>()
for (const row of schemas) {
schemaMap.set(row.schema_name, {
name: row.schema_name,
tables: []
})
}
// Build tables map
const tableMap = new Map<string, TableInfo>()
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<ExplainResult> {
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<TableDefinition> {
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<string, { isUnique: boolean; method: string; columns: string[] }>()
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<SequenceInfo[]> {
// MySQL doesn't have sequences - it uses AUTO_INCREMENT
// Return empty array as sequences are a PostgreSQL concept
return []
}
async getTypes(config: ConnectionConfig): Promise<CustomTypeInfo[]> {
// 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()
}
}
}

View file

@ -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<number, string> = {
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<void> {
const client = new Client(config)
await client.connect()
await client.end()
}
async query(config: ConnectionConfig, sql: string): Promise<AdapterQueryResult> {
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<SchemaInfo[]> {
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<string, ForeignKeyInfo>()
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<string, SchemaInfo>()
// 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<string, TableInfo>()
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<ExplainResult> {
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<TableDefinition> {
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<SequenceInfo[]> {
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<CustomTypeInfo[]> {
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()
}
}
}

View file

@ -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<string, unknown>[]
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<void>
/** Execute a query and return results */
query(config: ConnectionConfig, sql: string): Promise<AdapterQueryResult>
/** 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<SchemaInfo[]>
/** Get query execution plan */
explain(config: ConnectionConfig, sql: string, analyze: boolean): Promise<ExplainResult>
/** Get table definition (reverse engineer DDL) */
getTableDDL(config: ConnectionConfig, schema: string, table: string): Promise<TableDefinition>
/** Get available sequences (PostgreSQL-specific, returns empty for MySQL) */
getSequences(config: ConnectionConfig): Promise<SequenceInfo[]>
/** Get custom types (enums, etc.) */
getTypes(config: ConnectionConfig): Promise<CustomTypeInfo[]>
}
// Import adapters
import { PostgresAdapter } from './adapters/postgres-adapter'
import { MySQLAdapter } from './adapters/mysql-adapter'
// Adapter instances (singletons)
const adapters: Record<DatabaseType, DatabaseAdapter> = {
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
}

View file

@ -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<number, string> = {
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<string, ForeignKeyInfo>()
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<string, SchemaInfo>()
// 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<string, TableInfo>()
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)

View file

@ -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<DatabaseType, { port: string; user: string; database: string }> = {
postgresql: { port: '5432', user: 'postgres', database: 'postgres' },
mysql: { port: '3306', user: 'root', database: '' },
sqlite: { port: '', user: '', database: '' }
}
const DB_PROTOCOLS: Record<DatabaseType, string[]> = {
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<DatabaseType>('postgresql')
const [inputMode, setInputMode] = useState<InputMode>('manual')
const [connectionString, setConnectionString] = useState('')
const [parseError, setParseError] = useState<string | null>(null)
@ -73,6 +100,40 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP
const [testResult, setTestResult] = useState<'success' | 'error' | null>(null)
const [testError, setTestError] = useState<string | null>(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
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Database className="size-5" />
Add Connection
{isEditMode ? 'Edit Connection' : 'Add Connection'}
</SheetTitle>
<SheetDescription>
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.'}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 py-4 px-4">
{/* Database Type Selector */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Database Type</label>
<div className="flex rounded-lg border bg-muted p-1">
<button
type="button"
onClick={() => handleDbTypeChange('postgresql')}
className={`flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
dbType === 'postgresql'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
PostgreSQL
</button>
<button
type="button"
onClick={() => handleDbTypeChange('mysql')}
className={`flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
dbType === 'mysql'
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
MySQL
</button>
</div>
</div>
{/* Input Mode Toggle */}
<div className="flex rounded-lg border bg-muted p-1">
<button
@ -243,13 +346,20 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP
</label>
<Input
id="connection-string"
placeholder="postgresql://user:password@host:5432/database"
placeholder={
dbType === 'mysql'
? 'mysql://user:password@host:3306/database'
: 'postgresql://user:password@host:5432/database'
}
value={connectionString}
onChange={(e) => handleConnectionStringChange(e.target.value)}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Format: postgresql://user:password@host:port/database
Format:{' '}
{dbType === 'mysql'
? 'mysql://user:password@host:port/database'
: 'postgresql://user:password@host:port/database'}
</p>
{parseError && <p className="text-xs text-destructive">{parseError}</p>}
{connectionString && !parseError && (
@ -389,8 +499,10 @@ export function AddConnectionDialog({ open, onOpenChange }: AddConnectionDialogP
{isSaving ? (
<>
<Loader2 className="size-4 animate-spin" />
Saving...
{isEditMode ? 'Updating...' : 'Saving...'}
</>
) : isEditMode ? (
'Update Connection'
) : (
'Save Connection'
)}

View file

@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { ChevronDown, Database, Plus, Settings, Loader2 } from 'lucide-react'
import { ChevronDown, Plus, Settings, Loader2, Pencil, Trash2 } from 'lucide-react'
import {
DropdownMenu,
@ -12,10 +12,76 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog'
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'
import { useConnectionStore } from '@/stores'
import { useConnectionStore, type Connection } from '@/stores'
import { useNavigate } from '@tanstack/react-router'
import { AddConnectionDialog } from './add-connection-dialog'
import type { DatabaseType } from '@shared/index'
// Database type icons as simple SVG components
function PostgreSQLIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
<path d="M12 6v6l4 2" />
<text x="8" y="17" fontSize="8" fontWeight="bold" fill="currentColor" stroke="none">
PG
</text>
</svg>
)
}
function MySQLIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<ellipse cx="12" cy="6" rx="8" ry="3" />
<path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6" />
<path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6" />
</svg>
)
}
function DatabaseIcon({
dbType,
className
}: {
dbType: DatabaseType | undefined
className?: string
}) {
switch (dbType) {
case 'mysql':
return <MySQLIcon className={className} />
case 'postgresql':
default:
return <PostgreSQLIcon className={className} />
}
}
export function ConnectionSwitcher() {
const navigate = useNavigate()
@ -24,9 +90,12 @@ export function ConnectionSwitcher() {
const setActiveConnection = useConnectionStore((s) => s.setActiveConnection)
const setConnectionStatus = useConnectionStore((s) => s.setConnectionStatus)
const initializeConnections = useConnectionStore((s) => s.initializeConnections)
const removeConnection = useConnectionStore((s) => s.removeConnection)
const isInitialized = useConnectionStore((s) => s.isInitialized)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [editingConnection, setEditingConnection] = useState<Connection | null>(null)
const [deletingConnection, setDeletingConnection] = useState<Connection | null>(null)
// Initialize connections from persistent storage on mount
useEffect(() => {
@ -50,6 +119,23 @@ export function ConnectionSwitcher() {
navigate({ to: '/settings' })
}
const handleEditConnection = (e: React.MouseEvent, connection: Connection) => {
e.stopPropagation()
setEditingConnection(connection)
}
const handleDeleteConnection = (e: React.MouseEvent, connection: Connection) => {
e.stopPropagation()
setDeletingConnection(connection)
}
const confirmDelete = async () => {
if (deletingConnection) {
await removeConnection(deletingConnection.id)
setDeletingConnection(null)
}
}
// Show loading state while initializing
if (!isInitialized) {
return (
@ -91,7 +177,7 @@ export function ConnectionSwitcher() {
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-fit px-1.5">
<div className="relative flex aspect-square size-5 items-center justify-center">
<Database className="size-4 text-sidebar-primary" />
<DatabaseIcon dbType={activeConnection?.dbType} className="size-4 text-sidebar-primary" />
{activeConnection?.isConnected && (
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-green-500 ring-1 ring-sidebar" />
)}
@ -118,25 +204,46 @@ export function ConnectionSwitcher() {
<DropdownMenuItem
key={connection.id}
onClick={() => handleSelectConnection(connection.id)}
className="gap-2 p-2"
className="gap-2 p-2 group"
disabled={connection.isConnecting}
>
<div className="relative flex size-6 items-center justify-center rounded-xs border">
{connection.isConnecting ? (
<Loader2 className="size-4 shrink-0 animate-spin" />
) : (
<Database className="size-4 shrink-0" />
<DatabaseIcon dbType={connection.dbType} className="size-4 shrink-0" />
)}
{connection.isConnected && !connection.isConnecting && (
<span className="absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-green-500 ring-1 ring-background" />
)}
</div>
<div className="flex flex-1 flex-col">
<span className="font-medium">{connection.name}</span>
<div className="flex items-center gap-1.5">
<span className="font-medium">{connection.name}</span>
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground uppercase">
{connection.dbType === 'mysql' ? 'MySQL' : 'PG'}
</span>
</div>
<span className="text-xs text-muted-foreground">
{connection.host}:{connection.port}/{connection.database}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => handleEditConnection(e, connection)}
className="p-1 hover:bg-muted rounded"
title="Edit connection"
>
<Pencil className="size-3.5" />
</button>
<button
onClick={(e) => handleDeleteConnection(e, connection)}
className="p-1 hover:bg-destructive/10 hover:text-destructive rounded"
title="Delete connection"
>
<Trash2 className="size-3.5" />
</button>
</div>
{index < 9 && <DropdownMenuShortcut>{index + 1}</DropdownMenuShortcut>}
</DropdownMenuItem>
))}
@ -156,7 +263,40 @@ export function ConnectionSwitcher() {
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<AddConnectionDialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
{/* Add/Edit Connection Dialog */}
<AddConnectionDialog
open={isAddDialogOpen || !!editingConnection}
onOpenChange={(open) => {
if (!open) {
setIsAddDialogOpen(false)
setEditingConnection(null)
}
}}
connection={editingConnection}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deletingConnection} onOpenChange={() => setDeletingConnection(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete connection?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{deletingConnection?.name}"? This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SidebarMenu>
)
}

View file

@ -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
})

View file

@ -7,6 +7,7 @@ export interface ConnectionConfig {
user: string;
password?: string;
ssl?: boolean;
dbType: DatabaseType;
}
/**

View file

@ -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

View file

@ -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! 🚀
-- ============================================================================