Changeset 0.30.0 (#199)
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
160
server/handlers/package_migrations.go
Normal file
160
server/handlers/package_migrations.go
Normal file
@@ -0,0 +1,160 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user