Skip to content

Commit ba94a83

Browse files
committed
fix: embed plugin migrations in binary — fixes 0/0 status and entity errors
Root cause: plugin migrations used relative filesystem paths (internal/plugins/*/migrations/) that only resolve when CWD is the project root. In Docker, the binary runs from /app so migrations were never found, tables were never created, and entity pages crashed on missing tables. Fix: each plugin now embeds its migrations/*.sql via Go's embed.FS. The migration system reads from the embedded FS instead of the filesystem, making it work regardless of working directory or deployment method. Changes: - Add embed.go to each plugin (calendar, maps, sessions, timeline, syncapi) - Replace PluginSchema.MigrationsDir (string) with MigrationsFS (fs.FS) - Move RegisteredPlugins() from database pkg to cmd/server (avoids cycles) - Pass PluginSchemas through App struct to database explorer - parsePluginMigrations and LatestMigrationVersion now use fs.FS https://claude.ai/code/session_01QJLkgjQDu5qohzJKGV4hj9
1 parent 4b7026c commit ba94a83

10 files changed

Lines changed: 127 additions & 56 deletions

File tree

cmd/server/main.go

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package main
55

66
import (
77
"context"
8+
"io/fs"
89
"log/slog"
910
"os"
1011
"os/signal"
@@ -14,6 +15,11 @@ import (
1415
"github.com/keyxmakerx/chronicle/internal/app"
1516
"github.com/keyxmakerx/chronicle/internal/config"
1617
"github.com/keyxmakerx/chronicle/internal/database"
18+
"github.com/keyxmakerx/chronicle/internal/plugins/calendar"
19+
"github.com/keyxmakerx/chronicle/internal/plugins/maps"
20+
"github.com/keyxmakerx/chronicle/internal/plugins/sessions"
21+
"github.com/keyxmakerx/chronicle/internal/plugins/syncapi"
22+
"github.com/keyxmakerx/chronicle/internal/plugins/timeline"
1723
"github.com/keyxmakerx/chronicle/internal/systems"
1824

1925
// Import system packages for their init() factory registrations.
@@ -56,9 +62,11 @@ func main() {
5662

5763
// --- Run Plugin Migrations ---
5864
// Each plugin runs its own schema migrations independently. Failures
59-
// disable the plugin instead of crashing the app.
65+
// disable the plugin instead of crashing the app. Migrations are
66+
// embedded in the binary via embed.FS so they work in any environment.
6067
pluginHealth := database.NewPluginHealthRegistry()
61-
pluginResults := database.RunPluginMigrations(db, database.RegisteredPlugins())
68+
pluginSchemas := registeredPlugins()
69+
pluginResults := database.RunPluginMigrations(db, pluginSchemas)
6270
for _, r := range pluginResults {
6371
pluginHealth.Register(r.Slug, r.Healthy, r.Error, r.Version, r.LatestVersion)
6472
}
@@ -85,7 +93,7 @@ func main() {
8593
}
8694

8795
// --- Create Application ---
88-
application := app.New(cfg, db, rdb, pluginHealth)
96+
application := app.New(cfg, db, rdb, pluginHealth, pluginSchemas)
8997

9098
// Register all routes (public, plugin, system, widget, API).
9199
application.RegisterRoutes()
@@ -139,3 +147,24 @@ func setupLogging(cfg *config.Config) {
139147

140148
slog.SetDefault(slog.New(handler))
141149
}
150+
151+
// registeredPlugins returns the list of built-in plugins with their embedded
152+
// migration filesystems. Each plugin embeds its own migrations/*.sql files
153+
// via Go's embed package, ensuring they're available in the compiled binary
154+
// regardless of working directory.
155+
func registeredPlugins() []database.PluginSchema {
156+
mustSub := func(fsys fs.FS, dir string) fs.FS {
157+
sub, err := fs.Sub(fsys, dir)
158+
if err != nil {
159+
panic("embedded migrations sub-dir: " + err.Error())
160+
}
161+
return sub
162+
}
163+
return []database.PluginSchema{
164+
{Slug: "calendar", MigrationsFS: mustSub(calendar.MigrationsFS, database.PluginMigrationsSubdir)},
165+
{Slug: "maps", MigrationsFS: mustSub(maps.MigrationsFS, database.PluginMigrationsSubdir)},
166+
{Slug: "sessions", MigrationsFS: mustSub(sessions.MigrationsFS, database.PluginMigrationsSubdir)},
167+
{Slug: "timeline", MigrationsFS: mustSub(timeline.MigrationsFS, database.PluginMigrationsSubdir)},
168+
{Slug: "syncapi", MigrationsFS: mustSub(syncapi.MigrationsFS, database.PluginMigrationsSubdir)},
169+
}
170+
}

internal/app/app.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,15 @@ type App struct {
4949
// PluginHealth tracks which built-in plugins have healthy schemas.
5050
// Used during route registration to skip degraded plugins.
5151
PluginHealth *database.PluginHealthRegistry
52+
53+
// PluginSchemas holds the registered plugin migration configurations.
54+
// Used by the database explorer to re-run migrations on demand.
55+
PluginSchemas []database.PluginSchema
5256
}
5357

5458
// New creates a new App instance with the given dependencies and configures
5559
// the Echo server with global middleware and error handling.
56-
func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, pluginHealth *database.PluginHealthRegistry) *App {
60+
func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, pluginHealth *database.PluginHealthRegistry, pluginSchemas []database.PluginSchema) *App {
5761
e := echo.New()
5862

5963
// Disable Echo's default banner and startup message -- we log our own.
@@ -76,7 +80,8 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, pluginHealth *databa
7680
DB: db,
7781
Redis: rdb,
7882
Echo: e,
79-
PluginHealth: pluginHealth,
83+
PluginHealth: pluginHealth,
84+
PluginSchemas: pluginSchemas,
8085
}
8186

8287
// Register global middleware in order of execution.

internal/app/routes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ func (a *App) RegisterRoutes() {
862862
adminHandler.SetHygieneScanner(hygieneScanner)
863863

864864
// Database explorer: schema visualization and migration management.
865-
dbExplorer := admin.NewDatabaseExplorer(a.DB, a.PluginHealth)
865+
dbExplorer := admin.NewDatabaseExplorer(a.DB, a.PluginHealth, a.PluginSchemas)
866866
adminHandler.SetDatabaseExplorer(dbExplorer)
867867

868868
// Wire security event logging into the auth handler so logins, logouts,

internal/database/plugin_schema.go

Lines changed: 22 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import (
88
"context"
99
"database/sql"
1010
"fmt"
11+
"io/fs"
1112
"log/slog"
12-
"os"
13-
"path/filepath"
1413
"regexp"
1514
"sort"
1615
"strconv"
@@ -23,9 +22,10 @@ type PluginSchema struct {
2322
// Slug is the unique plugin identifier (e.g., "calendar", "maps").
2423
Slug string
2524

26-
// MigrationsDir is the absolute or relative path to the plugin's
27-
// migrations/ directory containing numbered SQL files.
28-
MigrationsDir string
25+
// MigrationsFS is an embedded filesystem containing the plugin's
26+
// numbered SQL migration files. Using embed.FS ensures migrations
27+
// are available regardless of the binary's working directory.
28+
MigrationsFS fs.FS
2929
}
3030

3131
// PluginMigrationResult holds the outcome of running a single plugin's
@@ -109,20 +109,13 @@ func ensurePluginSchemaTable(db *sql.DB) error {
109109
func runSinglePluginMigrations(db *sql.DB, plugin PluginSchema) PluginMigrationResult {
110110
ctx := context.Background()
111111

112-
// Check if the migrations directory exists.
113-
if _, err := os.Stat(plugin.MigrationsDir); err != nil {
114-
if os.IsNotExist(err) {
115-
// No migrations directory = nothing to do, plugin is healthy.
116-
return PluginMigrationResult{Slug: plugin.Slug, Healthy: true, Version: 0}
117-
}
118-
return PluginMigrationResult{
119-
Slug: plugin.Slug,
120-
Error: fmt.Errorf("checking migrations dir: %w", err),
121-
}
112+
if plugin.MigrationsFS == nil {
113+
// No embedded migrations = nothing to do, plugin is healthy.
114+
return PluginMigrationResult{Slug: plugin.Slug, Healthy: true, Version: 0}
122115
}
123116

124-
// Parse migration files from disk.
125-
migrations, err := parsePluginMigrations(plugin.MigrationsDir)
117+
// Parse migration files from embedded filesystem.
118+
migrations, err := parsePluginMigrations(plugin.MigrationsFS)
126119
if err != nil {
127120
return PluginMigrationResult{
128121
Slug: plugin.Slug,
@@ -192,10 +185,10 @@ func runSinglePluginMigrations(db *sql.DB, plugin PluginSchema) PluginMigrationR
192185
}
193186
}
194187

195-
// parsePluginMigrations reads numbered SQL migration files from a directory.
188+
// parsePluginMigrations reads numbered SQL migration files from an embedded filesystem.
196189
// Returns migrations sorted by version number ascending.
197-
func parsePluginMigrations(dir string) ([]pluginMigration, error) {
198-
entries, err := os.ReadDir(dir)
190+
func parsePluginMigrations(migrationsFS fs.FS) ([]pluginMigration, error) {
191+
entries, err := fs.ReadDir(migrationsFS, ".")
199192
if err != nil {
200193
return nil, fmt.Errorf("reading migrations dir: %w", err)
201194
}
@@ -225,7 +218,7 @@ func parsePluginMigrations(dir string) ([]pluginMigration, error) {
225218
pairs[version] = &migPair{}
226219
}
227220

228-
content, err := os.ReadFile(filepath.Join(dir, entry.Name()))
221+
content, err := fs.ReadFile(migrationsFS, entry.Name())
229222
if err != nil {
230223
return nil, fmt.Errorf("reading %s: %w", entry.Name(), err)
231224
}
@@ -331,16 +324,13 @@ func splitPluginStatements(sql string) []string {
331324
}
332325

333326
// LatestMigrationVersion returns the highest migration version number found
334-
// in a plugin's migrations directory. Returns 0 if no migrations exist.
335-
func LatestMigrationVersion(migrationsDir string) (int, error) {
336-
if _, err := os.Stat(migrationsDir); err != nil {
337-
if os.IsNotExist(err) {
338-
return 0, nil
339-
}
340-
return 0, err
327+
// in a plugin's embedded migrations filesystem. Returns 0 if nil or empty.
328+
func LatestMigrationVersion(migrationsFS fs.FS) (int, error) {
329+
if migrationsFS == nil {
330+
return 0, nil
341331
}
342332

343-
entries, err := os.ReadDir(migrationsDir)
333+
entries, err := fs.ReadDir(migrationsFS, ".")
344334
if err != nil {
345335
return 0, err
346336
}
@@ -366,14 +356,6 @@ func LatestMigrationVersion(migrationsDir string) (int, error) {
366356
return highest, nil
367357
}
368358

369-
// RegisteredPlugins returns the list of built-in plugins that have schema
370-
// migrations. The MigrationsDir paths are relative to the working directory.
371-
func RegisteredPlugins() []PluginSchema {
372-
return []PluginSchema{
373-
{Slug: "calendar", MigrationsDir: "internal/plugins/calendar/migrations"},
374-
{Slug: "maps", MigrationsDir: "internal/plugins/maps/migrations"},
375-
{Slug: "sessions", MigrationsDir: "internal/plugins/sessions/migrations"},
376-
{Slug: "timeline", MigrationsDir: "internal/plugins/timeline/migrations"},
377-
{Slug: "syncapi", MigrationsDir: "internal/plugins/syncapi/migrations"},
378-
}
379-
}
359+
// PluginMigrationsSubdir is the subdirectory within each plugin's embed.FS
360+
// that contains migration SQL files (from //go:embed migrations/*.sql).
361+
const PluginMigrationsSubdir = "migrations"

internal/plugins/admin/database_service.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,17 @@ type MigrationHistory struct {
8080

8181
// databaseExplorer implements DatabaseExplorer with direct DB access.
8282
type databaseExplorer struct {
83-
db *sql.DB
84-
pluginHealth *database.PluginHealthRegistry
83+
db *sql.DB
84+
pluginHealth *database.PluginHealthRegistry
85+
pluginSchemas []database.PluginSchema
8586
}
8687

8788
// NewDatabaseExplorer creates a new database explorer service.
88-
func NewDatabaseExplorer(db *sql.DB, pluginHealth *database.PluginHealthRegistry) DatabaseExplorer {
89+
func NewDatabaseExplorer(db *sql.DB, pluginHealth *database.PluginHealthRegistry, pluginSchemas []database.PluginSchema) DatabaseExplorer {
8990
return &databaseExplorer{
90-
db: db,
91-
pluginHealth: pluginHealth,
91+
db: db,
92+
pluginHealth: pluginHealth,
93+
pluginSchemas: pluginSchemas,
9294
}
9395
}
9496

@@ -220,10 +222,9 @@ func (e *databaseExplorer) GetSchema(ctx context.Context) (*DatabaseSchema, erro
220222

221223
// GetMigrationStatus computes the migration status for each registered plugin.
222224
func (e *databaseExplorer) GetMigrationStatus(ctx context.Context) ([]PluginMigrationStatus, error) {
223-
plugins := database.RegisteredPlugins()
224-
statuses := make([]PluginMigrationStatus, 0, len(plugins))
225+
statuses := make([]PluginMigrationStatus, 0, len(e.pluginSchemas))
225226

226-
for _, p := range plugins {
227+
for _, p := range e.pluginSchemas {
227228
status := PluginMigrationStatus{
228229
Slug: p.Slug,
229230
Healthy: true,
@@ -285,8 +286,7 @@ func (e *databaseExplorer) getMigrationHistory(ctx context.Context, slug string)
285286
// ApplyPendingMigrations runs all pending plugin migrations and updates the
286287
// health registry. Returns the results for each plugin.
287288
func (e *databaseExplorer) ApplyPendingMigrations(ctx context.Context) ([]database.PluginMigrationResult, error) {
288-
plugins := database.RegisteredPlugins()
289-
results := database.RunPluginMigrations(e.db, plugins)
289+
results := database.RunPluginMigrations(e.db, e.pluginSchemas)
290290

291291
// Update the health registry with new results.
292292
for _, r := range results {

internal/plugins/calendar/embed.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package calendar provides the calendar/events addon for campaigns.
2+
// This file embeds the plugin's SQL migration files so they are available
3+
// in the compiled binary regardless of the runtime working directory.
4+
package calendar
5+
6+
import "embed"
7+
8+
// MigrationsFS contains the embedded SQL migration files for the calendar plugin.
9+
//
10+
//go:embed migrations/*.sql
11+
var MigrationsFS embed.FS

internal/plugins/maps/embed.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package maps provides the interactive maps addon for campaigns.
2+
// This file embeds the plugin's SQL migration files so they are available
3+
// in the compiled binary regardless of the runtime working directory.
4+
package maps
5+
6+
import "embed"
7+
8+
// MigrationsFS contains the embedded SQL migration files for the maps plugin.
9+
//
10+
//go:embed migrations/*.sql
11+
var MigrationsFS embed.FS

internal/plugins/sessions/embed.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package sessions provides the game session tracking addon for campaigns.
2+
// This file embeds the plugin's SQL migration files so they are available
3+
// in the compiled binary regardless of the runtime working directory.
4+
package sessions
5+
6+
import "embed"
7+
8+
// MigrationsFS contains the embedded SQL migration files for the sessions plugin.
9+
//
10+
//go:embed migrations/*.sql
11+
var MigrationsFS embed.FS

internal/plugins/syncapi/embed.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package syncapi provides the sync API addon for campaigns.
2+
// This file embeds the plugin's SQL migration files so they are available
3+
// in the compiled binary regardless of the runtime working directory.
4+
package syncapi
5+
6+
import "embed"
7+
8+
// MigrationsFS contains the embedded SQL migration files for the syncapi plugin.
9+
//
10+
//go:embed migrations/*.sql
11+
var MigrationsFS embed.FS

internal/plugins/timeline/embed.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Package timeline provides the timeline addon for campaigns.
2+
// This file embeds the plugin's SQL migration files so they are available
3+
// in the compiled binary regardless of the runtime working directory.
4+
package timeline
5+
6+
import "embed"
7+
8+
// MigrationsFS contains the embedded SQL migration files for the timeline plugin.
9+
//
10+
//go:embed migrations/*.sql
11+
var MigrationsFS embed.FS

0 commit comments

Comments
 (0)