package handlers // ext_db_schema.go — v0.29.2 // // DDL generation and lifecycle management for extension-owned database tables. // Tables are namespaced as ext_{pkg_slug}_{logical_name} and tracked in the // ext_data_tables catalog. CreateExtTables is called on install; // DropExtTables is called on uninstall. import ( "context" "database/sql" "fmt" "log" "regexp" "strings" "chat-switchboard/store" ) // validSchemaIdentifier matches safe SQL identifiers for table and column names. var validSchemaIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`) // TableDef describes a single extension-owned table as declared in a manifest. type TableDef struct { Columns map[string]string // colName → manifest type ("text","int","real","bool","timestamp") Indexes [][]string // each inner slice is an ordered list of column names for one index } // ParseDBTables extracts the "db_tables" key from a package manifest map. // Returns the map of logicalName→TableDef and ok=true when the key is present // and produces at least one valid table definition. func ParseDBTables(manifest map[string]any) (map[string]TableDef, bool) { raw, ok := manifest["db_tables"] if !ok { return nil, false } tablesRaw, ok := raw.(map[string]any) if !ok || len(tablesRaw) == 0 { return nil, false } tables := make(map[string]TableDef, len(tablesRaw)) for name, defRaw := range tablesRaw { if !validSchemaIdentifier.MatchString(name) { log.Printf("[ext-db] skipping invalid table name %q", name) continue } defMap, ok := defRaw.(map[string]any) if !ok { continue } td := TableDef{Columns: map[string]string{}} // Parse columns map. if colsRaw, ok := defMap["columns"].(map[string]any); ok { for col, typ := range colsRaw { if !validSchemaIdentifier.MatchString(col) { log.Printf("[ext-db] skipping invalid column name %q in table %q", col, name) continue } if t, ok := typ.(string); ok { td.Columns[col] = t } } } // Parse indexes array. if idxsRaw, ok := defMap["indexes"].([]any); ok { for _, idxRaw := range idxsRaw { if idxArr, ok := idxRaw.([]any); ok { var cols []string for _, c := range idxArr { if s, ok := c.(string); ok && validSchemaIdentifier.MatchString(s) { cols = append(cols, s) } } if len(cols) > 0 { td.Indexes = append(td.Indexes, cols) } } } } tables[name] = td } if len(tables) == 0 { return nil, false } return tables, true } // mapColType maps a manifest type string to a SQL column type for the given dialect. func mapColType(typStr string, isPostgres bool) string { switch strings.ToLower(typStr) { case "int", "integer": return "INTEGER" case "real", "float": return "REAL" case "bool", "boolean": if isPostgres { return "BOOLEAN" } return "INTEGER" case "timestamp": if isPostgres { return "TIMESTAMPTZ" } return "TEXT" default: // "text" and anything unrecognized return "TEXT" } } // extPhysicalTable builds the namespaced physical table name for a package's logical table. // e.g. packageID="my-ext", logicalName="logs" → "ext_my_ext_logs" func extPhysicalTable(packageID, logicalName string) string { slug := strings.ReplaceAll(packageID, "-", "_") return "ext_" + slug + "_" + logicalName } // createdAtColDef returns the dialect-correct DDL fragment for the created_at column. func createdAtColDef(isPostgres bool) string { if isPostgres { return "created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()" } return "created_at TEXT NOT NULL DEFAULT (datetime('now'))" } // CreateExtTables generates dialect-correct DDL and executes it for all tables // in the provided map. Each successfully created table is registered in the // ext_data_tables catalog via stores.ExtData. // // Errors from individual table creation are returned immediately (fail-fast). // Index creation failures are logged but non-fatal. // Catalog registration failures are logged but non-fatal. func CreateExtTables( ctx context.Context, db *sql.DB, isPostgres bool, stores store.Stores, packageID string, tables map[string]TableDef, ) error { if db == nil { return nil } for logicalName, td := range tables { physical := extPhysicalTable(packageID, logicalName) // Build column list: id first, user columns, created_at last. cols := []string{"id TEXT PRIMARY KEY"} for col, typ := range td.Columns { cols = append(cols, col+" "+mapColType(typ, isPostgres)) } cols = append(cols, createdAtColDef(isPostgres)) ddl := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n\t%s\n)", physical, strings.Join(cols, ",\n\t")) if _, err := db.ExecContext(ctx, ddl); err != nil { return fmt.Errorf("create ext table %s: %w", physical, err) } // Create declared indexes. 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) } } // Record logical name in catalog. if stores.ExtData != nil { if err := stores.ExtData.RegisterTable(ctx, packageID, logicalName); err != nil { log.Printf("[ext-db] catalog register failed (%s/%s): %v", packageID, logicalName, err) } } log.Printf("[ext-db] created table %s for package %s", physical, packageID) } return nil } // DropExtTables drops all physical tables registered for a package and removes // their entries from the ext_data_tables catalog. // // Table drop errors are logged but non-fatal; the catalog is cleared regardless. func DropExtTables( ctx context.Context, db *sql.DB, stores store.Stores, packageID string, ) error { if db == nil || stores.ExtData == nil { return nil } tables, err := stores.ExtData.ListTables(ctx, packageID) if err != nil { return fmt.Errorf("list ext tables for %s: %w", packageID, err) } for _, logicalName := range tables { physical := extPhysicalTable(packageID, logicalName) if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+physical); err != nil { log.Printf("[ext-db] drop table %s failed: %v", physical, err) } else { log.Printf("[ext-db] dropped table %s for package %s", physical, packageID) } } return stores.ExtData.DeletePackageTables(ctx, packageID) }