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/package_migrations.go
2026-03-19 18:50:27 +00:00

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"
"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
}