Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
161 lines
4.4 KiB
Go
161 lines
4.4 KiB
Go
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"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
|
|
"git.gobha.me/xcaliber/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
|
|
}
|