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 680ec3b897
All checks were successful
CI/CD / detect-changes (push) Successful in 3s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m34s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m55s
Feat rebrand armature (#43)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
2026-03-31 23:25:37 +00:00

340 lines
10 KiB
Go

package handlers
// ext_db_schema.go
//
// 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"
"armature/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
}
// 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, &notnull, &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.
//
// 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)
}