diff --git a/server/handlers/ext_db_schema.go b/server/handlers/ext_db_schema.go index 89ee8d7..c29fdf4 100644 --- a/server/handlers/ext_db_schema.go +++ b/server/handlers/ext_db_schema.go @@ -186,6 +186,130 @@ func CreateExtTables( return nil } +// ListExtColumns returns the set of column names for a physical table. +// Uses PRAGMA table_info on SQLite and information_schema on Postgres. +func ListExtColumns(ctx context.Context, db *sql.DB, isPostgres bool, physicalTable string) (map[string]bool, error) { + cols := make(map[string]bool) + + var rows *sql.Rows + var err error + if isPostgres { + rows, err = db.QueryContext(ctx, + `SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public'`, + physicalTable) + } else { + rows, err = db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", physicalTable)) + } + if err != nil { + return nil, fmt.Errorf("list columns for %s: %w", physicalTable, err) + } + defer rows.Close() + + for rows.Next() { + if isPostgres { + var name string + if err := rows.Scan(&name); err != nil { + continue + } + cols[name] = true + } else { + // PRAGMA table_info returns: cid, name, type, notnull, dflt_value, pk + var cid int + var name, colType string + var notnull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬null, &dfltValue, &pk); err != nil { + continue + } + cols[name] = true + } + } + return cols, rows.Err() +} + +// MigrateExtTables applies additive-only schema changes for a package update. +// For each table in the new manifest: +// - New table: CREATE TABLE (full creation, same as CreateExtTables) +// - Existing table: ALTER TABLE ADD COLUMN for each new column +// +// Indexes are applied idempotently with CREATE INDEX IF NOT EXISTS. +// New tables are registered in the ext_data_tables catalog. +// Returns a log of changes applied (for API response). +// +// No destructive changes: columns present in DB but absent from manifest are NOT dropped. +func MigrateExtTables( + ctx context.Context, + db *sql.DB, + isPostgres bool, + stores store.Stores, + packageID string, + newTables map[string]TableDef, +) ([]string, error) { + if db == nil { + return nil, nil + } + + // Get existing registered tables for this package + existingTableNames := make(map[string]bool) + if stores.ExtData != nil { + names, err := stores.ExtData.ListTables(ctx, packageID) + if err != nil { + return nil, fmt.Errorf("list existing tables for %s: %w", packageID, err) + } + for _, n := range names { + existingTableNames[n] = true + } + } + + var changes []string + + for logicalName, td := range newTables { + physical := extPhysicalTable(packageID, logicalName) + + if !existingTableNames[logicalName] { + // New table — full creation + if err := CreateExtTables(ctx, db, isPostgres, stores, packageID, + map[string]TableDef{logicalName: td}); err != nil { + return changes, fmt.Errorf("create new table %s: %w", physical, err) + } + changes = append(changes, fmt.Sprintf("created table %s", physical)) + continue + } + + // Existing table — diff columns and add missing ones + existingCols, err := ListExtColumns(ctx, db, isPostgres, physical) + if err != nil { + return changes, err + } + + for col, typ := range td.Columns { + if existingCols[col] { + continue + } + sqlType := mapColType(typ, isPostgres) + alterDDL := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", physical, col, sqlType) + if _, err := db.ExecContext(ctx, alterDDL); err != nil { + return changes, fmt.Errorf("add column %s.%s: %w", physical, col, err) + } + changes = append(changes, fmt.Sprintf("added column '%s' to %s", col, physical)) + log.Printf("[ext-db] added column %s (%s) to %s", col, sqlType, physical) + } + + // Re-apply indexes idempotently + for i, idxCols := range td.Indexes { + idxName := fmt.Sprintf("idx_%s_%d", physical, i) + idxDDL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)", + idxName, physical, strings.Join(idxCols, ", ")) + if _, err := db.ExecContext(ctx, idxDDL); err != nil { + log.Printf("[ext-db] index create failed (%s): %v", idxName, err) + } + } + } + + return changes, nil +} + // DropExtTables drops all physical tables registered for a package and removes // their entries from the ext_data_tables catalog. //