This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/ext_db_schema.go
Jeffrey Smith 115004a3ab Changeset 0.29.2 (#197)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-17 22:31:34 +00:00

216 lines
6.3 KiB
Go

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"
"git.gobha.me/xcaliber/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)
}