Skip to content

Commit a46f378

Browse files
authored
Merge pull request #118 from keyxmakerx/claude/fix-navbar-features-page-8RZuE
Claude/fix navbar features page 8 r zu e
2 parents ec66fb2 + 06b0760 commit a46f378

15 files changed

Lines changed: 188 additions & 60 deletions

File tree

.ai/architecture.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,11 @@ chronicle/
9494
│ ├── config/ # CORE: Configuration loading (env vars)
9595
│ │ └── config.go
9696
│ │
97-
│ ├── database/ # CORE: Database connections
97+
│ ├── database/ # CORE: Database connections + migrations
9898
│ │ ├── mariadb.go # MariaDB connection pool
99-
│ │ └── redis.go # Redis client
99+
│ │ ├── redis.go # Redis client
100+
│ │ ├── plugin_schema.go # Plugin migration runner (reads embed.FS)
101+
│ │ └── plugin_health.go # Plugin health registry
100102
│ │
101103
│ ├── middleware/ # CORE: HTTP middleware
102104
│ │ ├── auth.go # Session validation
@@ -220,6 +222,7 @@ Every plugin follows this exact structure. No exceptions.
220222
```
221223
internal/plugins/<name>/
222224
.ai.md # Plugin-level AI documentation
225+
embed.go # Embeds migrations/*.sql via Go embed.FS (ADR-030)
223226
handler.go # Echo handlers (thin: bind, call service, render)
224227
handler_test.go # Handler tests (HTTP-level, mock service)
225228
service.go # Business logic (never imports Echo types)
@@ -228,6 +231,9 @@ internal/plugins/<name>/
228231
repository_test.go # Repository tests (integration, real DB)
229232
model.go # Domain models, DTOs, request/response structs
230233
routes.go # Route registration function
234+
migrations/ # Plugin-specific schema migrations (embedded in binary)
235+
001_*.up.sql
236+
001_*.down.sql
231237
templates/ # Templ components for this plugin
232238
index.templ # List view
233239
show.templ # Detail view

.ai/conventions.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,11 @@ Chronicle uses a **plugin-isolated database schema architecture**:
253253
- **Core schema** (`db/migrations/`): Single baseline migration with all core tables.
254254
Runs via golang-migrate on startup. Failure is fatal.
255255
- **Plugin schema** (`internal/plugins/<name>/migrations/`): Each built-in plugin
256-
has its own numbered migration files. Runs via `RunPluginMigrations()` after core
257-
migrations. Failure disables that plugin; app continues serving.
256+
has its own numbered migration files **embedded in the binary** via Go's `embed.FS`
257+
(ADR-030). Each plugin has an `embed.go` exporting `MigrationsFS`. Runs via
258+
`RunPluginMigrations()` after core migrations. Failure disables that plugin; app
259+
continues serving. `RegisteredPlugins()` lives in `cmd/server/main.go` (not in
260+
the database package) to avoid import cycles.
258261

259262
```sql
260263
-- Core migration example: db/migrations/000001_baseline.up.sql
@@ -280,6 +283,9 @@ CREATE TABLE IF NOT EXISTS calendars ( ... );
280283
in migration SQL. Update the valid sets there when adding new ENUM values.
281284
6. **Plugin tables**: Plugin tables belong in `internal/plugins/<name>/migrations/`,
282285
not in `db/migrations/`. Plugin schema failures degrade gracefully (ADR-028).
286+
Migrations are embedded in the binary via `embed.FS` (ADR-030). When adding a
287+
new plugin with migrations, create an `embed.go` in the plugin package and
288+
register it in `registeredPlugins()` in `cmd/server/main.go`.
283289

284290
### Permission Model
285291

.ai/decisions.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,3 +1050,42 @@ capabilities. Non-owners could see features but couldn't tell which were enabled
10501050
- Single source of truth for feature management.
10511051
- Owners can manage features directly from the same page all members see.
10521052
- Future enhancements (per-addon entity usage, "offline" banners) have one target page.
1053+
1054+
---
1055+
1056+
## ADR-030: Embed Plugin Migrations via Go embed.FS
1057+
1058+
**Date:** 2026-03-11
1059+
**Status:** Accepted (amends ADR-028)
1060+
1061+
**Context:** ADR-028 introduced per-plugin migration directories at
1062+
`internal/plugins/<name>/migrations/`. The migration runner used `os.Stat` and
1063+
`os.ReadDir` with relative filesystem paths. This worked in development (CWD =
1064+
project root) but failed silently in Docker: the runtime image copies the binary
1065+
to `/app` but never copies plugin migration directories. Since `os.Stat` returned
1066+
`os.IsNotExist`, the runner treated each plugin as "healthy with 0 migrations"
1067+
— no tables were created, entity pages crashed, and the DB Explorer showed 0/0.
1068+
1069+
**Decision:** Embed plugin migration SQL files in the binary using Go's `embed.FS`:
1070+
- Each plugin package gets an `embed.go` that exports `MigrationsFS embed.FS`
1071+
with `//go:embed migrations/*.sql`.
1072+
- `PluginSchema.MigrationsDir` (string) replaced with `MigrationsFS` (`fs.FS`).
1073+
- `parsePluginMigrations` and `LatestMigrationVersion` read from `fs.FS` instead
1074+
of the real filesystem.
1075+
- `RegisteredPlugins()` moved from `database` package to `cmd/server/main.go`
1076+
to avoid import cycles (database can't import plugin packages). Uses `fs.Sub`
1077+
to strip the `migrations/` prefix from each embed.FS.
1078+
- `PluginSchemas` stored on `App` struct and passed to `DatabaseExplorer` for
1079+
on-demand re-migration from the admin panel.
1080+
1081+
**Alternatives considered:**
1082+
- Copy plugin migration dirs to Docker runtime image: fragile, requires syncing
1083+
Dockerfile whenever plugins are added/removed. Still fails if CWD changes.
1084+
- Centralise all plugin migrations in one directory: loses per-plugin isolation
1085+
that ADR-028 established.
1086+
1087+
**Consequences:**
1088+
- Migrations work in any environment regardless of working directory.
1089+
- No Dockerfile changes needed when adding new plugins with migrations.
1090+
- Each plugin must have an `embed.go` exporting its `MigrationsFS`.
1091+
- `RegisteredPlugins()` now lives in `cmd/server/main.go` instead of `database`.

.ai/status.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
## Last Updated
1111
2026-03-11 -- **Sprint W-0.5: Visual Customization + Admin DB Explorer (IN PROGRESS).**
1212

13+
27. **Fix: Embed plugin migrations in binary (ADR-030).** Root cause of entity page errors and DB Explorer showing 0/0: plugin migrations used relative filesystem paths (`internal/plugins/*/migrations/`) that only resolve when the binary's CWD is the project root. In Docker, the binary runs from `/app` so migration directories were never found, tables were never created, and entity pages crashed. Fix: each plugin now embeds its `migrations/*.sql` via Go's `embed.FS`. `PluginSchema.MigrationsDir` (string) replaced with `MigrationsFS` (`fs.FS`). `RegisteredPlugins()` moved from `database` package to `cmd/server/main.go` to avoid import cycles (database can't import plugin packages). `PluginSchemas` stored on `App` struct and passed to `DatabaseExplorer` for on-demand re-migration. New `embed.go` files in calendar, maps, sessions, timeline, syncapi plugins.
14+
15+
### Previous Update
16+
2026-03-11 -- **Sprint W-0.5: Visual Customization + Admin DB Explorer (IN PROGRESS).**
17+
1318
26. Admin Database Explorer: New `/admin/database` page with interactive D3.js schema diagram showing all tables, FK relationships, plugin grouping, and migration status. Table detail panel on click. "Apply Pending Migrations" button (fixes sessions table missing issue). Removed "Manual DB Record (Advanced)" form from Features page. New files: `database_service.go` (info_schema introspection), `database.templ`, `db_explorer.js` widget. Added `LatestMigrationVersion()` to database package. Dashboard card + sidebar link.
1419

1520
### Previous Update

.ai/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Known broken or missing things, ordered by severity.
1616
### Critical
1717

1818
- [x] **Public campaign widget 403s** — Editor and attributes widgets return 403 for non-member visitors on public campaigns. Root cause: GET `/entry`, `/fields`, `/tags`, `/relations` only registered in authenticated route group, not in public-capable group. Fixed by adding routes to `pub` group in entities/routes.go, tags/routes.go, relations/routes.go.
19+
- [x] **Plugin migrations not applied in Docker (entity page errors, 0/0 in DB Explorer)** — Plugin migrations used relative filesystem paths (`internal/plugins/*/migrations/`) that only resolve when CWD is the project root. In Docker, binary runs from `/app` so dirs were never found, tables never created, entity pages crashed on missing tables, and DB Explorer showed 0/0 for all plugins. Fixed by embedding migrations in the binary via Go's `embed.FS` (ADR-030). Each plugin now has `embed.go` exporting `MigrationsFS`. `PluginSchema.MigrationsDir` replaced with `MigrationsFS` (`fs.FS`).
1920

2021
### High
2122

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 {

0 commit comments

Comments
 (0)