package handlers // package_migrations.go — v0.30.0 CS1 // // Schema migration engine for extension packages. When a package declares // schema_version and migrations in its manifest, this engine runs Starlark // migration scripts on install (fresh or upgrade). // // Manifest format: // { // "schema_version": 2, // "migrations": { // "1": "def migrate(db):\n db.insert('settings', {'key': 'v1'})", // "2": "def migrate(db):\n ..." // } // } // // On fresh install: runs migrations 1..schema_version. // On upgrade: runs migrations (current+1)..new_schema_version. // On downgrade: rejected (409). import ( "context" "database/sql" "fmt" "log" "strconv" "go.starlark.net/starlark" "chat-switchboard/sandbox" "chat-switchboard/store" ) // ParseSchemaVersion extracts the "schema_version" integer from a manifest. // Returns 0 if not present or not a number. func ParseSchemaVersion(manifest map[string]any) int { raw, ok := manifest["schema_version"] if !ok { return 0 } switch v := raw.(type) { case float64: return int(v) case int: return v } return 0 } // ParseMigrations extracts the "migrations" map from a manifest. // Keys are string integers ("1", "2", ...), values are Starlark source code. func ParseMigrations(manifest map[string]any) map[int]string { raw, ok := manifest["migrations"] if !ok { return nil } migrationsRaw, ok := raw.(map[string]any) if !ok || len(migrationsRaw) == 0 { return nil } result := make(map[int]string, len(migrationsRaw)) for key, val := range migrationsRaw { version, err := strconv.Atoi(key) if err != nil { log.Printf("[pkg-migrate] skipping non-integer migration key %q", key) continue } script, ok := val.(string) if !ok || script == "" { log.Printf("[pkg-migrate] skipping empty migration for version %d", version) continue } result[version] = script } return result } // RunSchemaMigrations executes Starlark migration scripts from fromVersion+1 // to toVersion, updating schema_version in the store after each successful step. // // The sandbox runs each migration script with a db module at write level, // scoped to the package's namespace. This bypasses the permission system // because migrations are admin-initiated. // // On error, returns immediately — partial migration state is tracked via // the per-step schema_version update. func RunSchemaMigrations( ctx context.Context, sb *sandbox.Sandbox, stores store.Stores, db *sql.DB, isPostgres bool, packageID string, manifest map[string]any, fromVersion int, toVersion int, ) error { if fromVersion >= toVersion { return nil } migrations := ParseMigrations(manifest) if migrations == nil && toVersion > 0 { // No migration scripts but schema_version declared — just set the version return stores.Packages.SetSchemaVersion(ctx, packageID, toVersion) } for step := fromVersion + 1; step <= toVersion; step++ { script, ok := migrations[step] if !ok { // No script for this step — just bump the version log.Printf("[pkg-migrate] %s: no migration script for version %d, skipping", packageID, step) if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil { return fmt.Errorf("set schema_version to %d: %w", step, err) } continue } log.Printf("[pkg-migrate] %s: running migration %d", packageID, step) // Build a db module at write level for the migration dbModule := sandbox.BuildDBModule(ctx, sandbox.DBModuleConfig{ PackageID: packageID, CanWrite: true, DB: db, IsPostgres: isPostgres, }) modules := map[string]starlark.Value{ "db": dbModule, } // Execute the migration script result, err := sb.Exec(ctx, fmt.Sprintf("%s_migrate_%d.star", packageID, step), script, modules) if err != nil { return fmt.Errorf("migration %d for %s failed: %w", step, packageID, err) } // Call migrate(db) if defined if migrateFn, ok := result.Globals["migrate"]; ok { if callable, ok := migrateFn.(starlark.Callable); ok { _, _, err := sb.Call(ctx, callable, starlark.Tuple{dbModule}, nil) if err != nil { return fmt.Errorf("migration %d for %s: migrate() failed: %w", step, packageID, err) } } } // Mark this step as complete if err := stores.Packages.SetSchemaVersion(ctx, packageID, step); err != nil { return fmt.Errorf("set schema_version to %d: %w", step, err) } log.Printf("[pkg-migrate] %s: migration %d complete", packageID, step) } return nil }