docs: add project research
This commit is contained in:
493
.planning/research/ARCHITECTURE.md
Normal file
493
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Architecture Research
|
||||
|
||||
**Domain:** Go CMF (WinterCMS/Laravel-shaped), headless-first, compiled-plugin model, v1 = Płytarium port
|
||||
**Researched:** 2026-09-16
|
||||
**Confidence:** HIGH for plugin-registration mechanics (verified against Caddy/xcaddy and PocketBase current docs) and for request-lifecycle mapping (grounded directly in the real `fonoteka` `Plugin.php`/`routes.php`); MEDIUM for the cross-plugin schema-extension pattern and the framework/app repo split (design recommendation, not yet validated by a build)
|
||||
|
||||
## Standard Architecture
|
||||
|
||||
### System Overview
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────────┐
|
||||
│ APPLICATION REPO (e.g. fonoteka.go) │
|
||||
│ ┌─────────────────────────────┐ ┌────────────────────────────────┐ │
|
||||
│ │ plugins/ (go.work members) │ │ cmd/summer (app binary entry) │ │
|
||||
│ │ fonoteka/ (domain plugin) │ │ plugins.gen.go (blank imports) │ │
|
||||
│ │ user/ (stack plugin) │ │ main.go -> backpack.Boot() │ │
|
||||
│ │ translate/, websockets/, │ └────────────────────────────────┘ │
|
||||
│ │ feedback/, sitemap/, ... │ │
|
||||
│ └──────────────┬───────────────┘ │
|
||||
├─────────────────┼───────────────────────────────────────────────────────┤
|
||||
│ ▼ FRAMEWORK CORE (this repo, one Go module) │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ party │ │ backpack│ │ festival │ │ compass │ │ bonfire │ kernel │
|
||||
│ │ (plugin │ │ (app │ │ (event │ │ (config)│ │ (CLI) │ layer │
|
||||
│ │ registry│ │ container)│ bus) │ │ │ │ │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬─────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │ │ │
|
||||
├───────┴───────────┴───────────┴────────────┴───────────┴─────────────────┤
|
||||
│ INFRASTRUCTURE ADAPTERS (framework core) │
|
||||
│ ┌────────┐ ┌─────────┐ ┌───────────┐ ┌────────┐ ┌────────┐ ┌──────────┐ │
|
||||
│ │ surf │ │ lagoon │ │ bouncer │ │ cooler │ │ conga │ │ postcard │ │
|
||||
│ │ (HTTP/ │ │ (GORM/ │ │ (JWT/ │ │ (cache)│ │ (River │ │ (mail) │ │
|
||||
│ │ router)│ │ Postgres│ │ session/ │ │ │ │ jobs) │ │ │ │
|
||||
│ │ │ │ /migrate│ │ OAuth2) │ │ │ │ │ │ │ │
|
||||
│ └────────┘ └─────────┘ └───────────┘ └────────┘ └────────┘ └──────────┘ │
|
||||
│ ┌───────────┐ ┌───────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ lifeguard │ │ sandcastle│ │phrasebk│ │ sunset │ pact = shared │
|
||||
│ │ (validate)│ │ (blob) │ │ (i18n) │ │ (view, │ interfaces, imported │
|
||||
│ │ │ │ │ │ │ │ stub) │ by every layer, zero │
|
||||
│ └───────────┘ └───────────┘ └────────┘ └────────┘ deps on the others │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ EXTERNAL SERVICES │
|
||||
│ Postgres · Centrifugo (WS) · Typesense · Discogs/Anthropic/OpenAI APIs │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
▲ ▲
|
||||
│ REST (JWT / token / OAuth2 bearer) │ WS (client-side)
|
||||
vue-fonoteka-app (Nuxt 4) fonoteka-mcp (MCP server)
|
||||
```
|
||||
|
||||
Key structural decision: **the framework core is one Go module; plugins are `go.work` workspace members, each its own module.** This is a deliberate split of the "single module vs multi-module workspace" question, not an either/or:
|
||||
|
||||
- `backpack`, `pact`, `towel`, `compass`, `festival`, `surf`, `lagoon`, `bouncer`, `lifeguard`, `cooler`, `conga`, `postcard`, `bonfire`, `sandcastle`, `phrasebook`, `sunset`, `party` ship together, version together, and are released as one thing — internal packages in one module is correct here (no plugin ever needs a different version of `lagoon` than another plugin in the same binary; a shared module guarantees that).
|
||||
- Plugins (`fonoteka`, `user`, `translate`, `websockets`, ...) are independently ownable, independently testable, and — per the already-recorded decision in `why-go-not-scala.md` — a `go.work` workspace of separate modules, mirroring Caddy/xcaddy where each plugin module registers itself via a blank import and `init()`. `go.work`'s `use` directive resolves local plugin modules without `replace` hacks or tagged releases during development; a plugin gets a real semver tag only once it needs to be shared across apps (see Integration Points).
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Responsibility | Typical Implementation |
|
||||
|-----------|-----------------|-------------------------|
|
||||
| `pact` | Interfaces shared across packages to avoid import cycles (`Plugin`, `Authenticator`, `Searchable`, locale/user contracts) | Pure interfaces, zero deps, imported by everything |
|
||||
| `towel` | String/slice/map helpers, small stdlib gap-fillers (Illuminate\Support equivalent) | Generic functions, stdlib only |
|
||||
| `compass` | Config discovery, env layering, plugin config namespaces | Ported design from `summer-compass` (HOCON→koanf), unchanged layering semantics |
|
||||
| `festival` | Typed in-process event bus; "filter" events that plugins fill in additively | ~200 LOC hand-rolled generic bus (no library dominates this niche per ecosystem research) |
|
||||
| `backpack` | App container: boot lifecycle (Register→Boot, mirroring `PluginBase`), type-keyed service binding/resolution | `App` struct + `Bind[T any]`/`Resolve[T any]` generics helpers |
|
||||
| `party` | Plugin descriptor contract, registry, dependency-ordered boot/migrate, generated import list, `summer build`/`make:plugin` | Caddy/xcaddy-style: `init()` self-registration + generated blank-import file |
|
||||
| `bonfire` | Console command framework, scaffolding, rich output | Ported design from `summer-bonfire`; cobra or hand-rolled per that doc's "no external TUI framework" stance |
|
||||
| `surf` | net/http ServeMux wrapper, middleware chain, route groups | stdlib `ServeMux` (1.22+ patterns) + typed middleware stack |
|
||||
| `lagoon` | GORM models, Postgres, per-plugin migrations (goose), pagination | GORM + goose, `AutoMigrate`-free (explicit migrations only, for the "drop last migration and fix it" workflow) |
|
||||
| `bouncer` | JWT (SPA), personal API tokens, OAuth2/OIDC (MCP/ChatGPT), backend admin sessions | `golang-jwt/jwt`, `zitadel/oidc`, `alexedwards/scs` |
|
||||
| `lifeguard` | YAML `rules:` → runtime validation with translated messages | `go-playground/validator`'s `Var()` (per-field runtime rules, not struct tags — see Patterns) |
|
||||
| `cooler` | In-process cache, rate-limit buckets | `otter` |
|
||||
| `conga` | Queue/jobs, transactional enqueue | `riverqueue/river` (Postgres) |
|
||||
| `postcard` | Mail sending, template layouts | `wneessen/go-mail` + `html/template` |
|
||||
| `sandcastle` | File storage, cover images | `gocloud.dev/blob` |
|
||||
| `phrasebook` | i18n, CLDR plurals, namespaced keys | Ported design from `summer-phrasebook` |
|
||||
| `sunset` | View/templating — minimal in v1 (headless) | `html/template` stub only; no theme engine work in v1 |
|
||||
|
||||
## Recommended Project Structure
|
||||
|
||||
```
|
||||
summercms.go/ # THIS repo — framework core, one Go module
|
||||
├── go.mod # module github.com/golem15/summercms
|
||||
├── pact/ # interfaces only
|
||||
├── towel/
|
||||
├── compass/
|
||||
├── festival/
|
||||
├── backpack/
|
||||
├── party/ # plugin descriptor + registry + build tooling
|
||||
│ ├── plugin.go # Plugin, HasModels, HasRoutes, ... capability interfaces
|
||||
│ ├── registry.go # Register(), topological Requires() sort
|
||||
│ └── build/ # `summer build`/`make:plugin` codegen (invoked by bonfire commands)
|
||||
├── bonfire/
|
||||
├── surf/
|
||||
├── lagoon/
|
||||
├── bouncer/
|
||||
├── lifeguard/
|
||||
├── cooler/
|
||||
├── conga/
|
||||
├── postcard/
|
||||
├── sandcastle/
|
||||
├── phrasebook/
|
||||
├── sunset/
|
||||
└── cmd/summer/ # the `summer` CLI binary itself (make:plugin, build, migrate, serve)
|
||||
|
||||
fonoteka.go/ # SEPARATE repo — the Płytarium application
|
||||
├── go.work # use ./plugins/fonoteka, ./plugins/user, ./plugins/translate, ...
|
||||
├── go.mod # requires github.com/golem15/summercms
|
||||
├── config/ # compass base + env overlays, per-plugin namespaces
|
||||
├── plugins/
|
||||
│ ├── fonoteka/ # domain plugin — its own go.mod (workspace member)
|
||||
│ │ ├── go.mod
|
||||
│ │ ├── plugin.go # descriptor: ID/Requires/Register/Boot + capability methods
|
||||
│ │ ├── models/ # Album, Artist, Collection, ... (25 GORM structs)
|
||||
│ │ ├── migrations/ # 27 goose migrations, embed.FS
|
||||
│ │ ├── routes.go # surf.Router registration (mirrors routes.php groups exactly)
|
||||
│ │ ├── admin/ # 5 AdminController impls + fields.yaml/columns.yaml
|
||||
│ │ ├── console/ # ReindexAlbums, IssueOAuthClient, PruneNotifications
|
||||
│ │ ├── jobs/ # CsvImportWorker, WishlistDigestWorker (conga.JobWorker)
|
||||
│ │ └── lang/ # pl/en phrasebook namespaces
|
||||
│ ├── user/ # ported stack plugin — own go.mod
|
||||
│ ├── translate/, websockets/, feedback/, sitemap/, apparatus/, golem/ (AI)
|
||||
│ └── ...
|
||||
├── cmd/summer/
|
||||
│ ├── plugins.gen.go # generated blank-import list (regenerated by `summer build`)
|
||||
│ └── main.go # calls backpack.Boot(app) then bonfire dispatch or surf.Serve()
|
||||
├── admin-spa/ # Vue 3 + TS, consumes /_admin/api/schema/* + OpenAPI types
|
||||
└── test/parity/ # API parity harness (see Patterns)
|
||||
```
|
||||
|
||||
### Structure Rationale
|
||||
|
||||
- **Framework repo has no knowledge of Płytarium.** No Discogs client, no Centrifugo channel names, no Typesense schema live here — those are app concerns. This is the enforceable version of "core plugins... cannot have breaking changes... unless directly asked" from the user's global rules: the framework's `pact` contracts are the only surface an app plugin may depend on for cross-cutting concerns, so the framework can evolve without touching app code as long as `pact` interfaces hold.
|
||||
- **`plugins/<name>/go.mod` per plugin, `go.work` at the app root.** Matches the already-recorded decision (`why-go-not-scala.md`): "plugins/ is a workspace of Go modules." `go.work`'s `use` directive means a plugin never needs a `replace` line or a tagged release to be part of the local build — `summer build` just runs `go build` inside the workspace.
|
||||
- **Stack plugins (user, translate, websockets, ...) start inside the app repo's `plugins/` workspace, not the framework repo.** They are genuinely shared across projects today (per `CLAUDE.md`'s core-plugin constraint) but v1 has exactly one consumer. Promote a plugin to its own repo (mirroring today's PHP golem15 stack-plugin-per-repo + submodule model) only when a second app (keios.eu) needs it — this is the Go-workspace equivalent of `go mod init` + `git subtree split`, a mechanical move because the plugin's import path is already package-qualified.
|
||||
- **`cmd/summer` lives in the app repo, not the framework repo**, because the generated `plugins.gen.go` import list is inherently app-specific (it lists *this app's* plugin set). The framework repo's own `cmd/summer` is a template/skeleton the app repo's `summer make:plugin` scaffolding is generated from, not a binary anyone runs directly.
|
||||
|
||||
## Architectural Patterns
|
||||
|
||||
### Pattern 1: Plugin descriptor via one required interface + optional capability interfaces
|
||||
|
||||
**What:** WinterCMS's `PluginBase` uses optional methods (`registerNavigation()`, `registerPermissions()`, `registerSettings()`, `registerMailTemplates()`, `registerSchedule()`) that a plugin overrides only if relevant, PHP silently no-ops the rest. Go has no optional-override inheritance, so the port is small capability interfaces checked with a type assertion — exactly PocketBase's and Caddy's own idiom (a module "is" whatever interfaces it satisfies).
|
||||
|
||||
**When to use:** Every plugin. This is the core extension mechanism requested by the milestone.
|
||||
|
||||
**Trade-offs:** More boilerplate per plugin (must declare which interfaces it satisfies) but fully static — `go vet`/`go build` catch a typo in a route handler signature that PHP would only surface at runtime.
|
||||
|
||||
**Example** (directly modeled on the real `golem15.fonoteka` `Plugin.php` read for this research — `$require`, `register()`, `boot()`, `registerConsoleCommand`, `registerNavigation`, `registerPermissions`, `registerSchedule`, `Event::listen`):
|
||||
|
||||
```go
|
||||
package pact
|
||||
|
||||
type Plugin interface {
|
||||
ID() string // "golem15.fonoteka"
|
||||
Requires() []string // ["golem15.apparatus", "golem15.user"] — party topo-sorts on this
|
||||
Register(app *backpack.App) error // bind own services; must not call other plugins yet
|
||||
Boot(app *backpack.App) error // safe to reference other plugins' bound services
|
||||
}
|
||||
|
||||
// Optional capabilities — a plugin implements whichever it needs.
|
||||
type HasModels interface{ Models() []any } // GORM structs, for migrate/reflect
|
||||
type HasMigrations interface{ Migrations() fs.FS } // goose files, embed.FS
|
||||
type HasRoutes interface{ Routes(r *surf.Router) }
|
||||
type HasCommands interface{ Commands() []bonfire.Command }
|
||||
type HasJobs interface{ Jobs() []conga.JobWorker }
|
||||
type HasListeners interface{ Listeners(bus *festival.Bus) }
|
||||
type HasAdminControls interface{ AdminControllers() []party.AdminController }
|
||||
type HasNavigation interface{ Navigation() []party.NavItem }
|
||||
type HasPermissions interface{ Permissions() []party.Permission }
|
||||
type HasSchedule interface{ Schedule() []party.ScheduledCommand }
|
||||
type HasMailTemplates interface{ MailTemplates() fs.FS }
|
||||
type HasConfig interface{ ConfigDir() fs.FS } // compass.AddNamespace
|
||||
type HasLang interface{ LangDir() fs.FS } // phrasebook namespace
|
||||
```
|
||||
|
||||
```go
|
||||
// plugins/fonoteka/plugin.go
|
||||
package fonoteka
|
||||
|
||||
type Plugin struct{}
|
||||
|
||||
func New() *Plugin { return &Plugin{} }
|
||||
func (p *Plugin) ID() string { return "golem15.fonoteka" }
|
||||
func (p *Plugin) Requires() []string { return []string{"golem15.apparatus", "golem15.user"} }
|
||||
|
||||
func (p *Plugin) Register(app *backpack.App) error { return nil }
|
||||
func (p *Plugin) Boot(app *backpack.App) error {
|
||||
p.registerNotificationListeners(app) // Event::listen('eloquent.created: Album', ...)
|
||||
p.registerCollectionProvisioning(app) // Event::listen('golem15.user.register', ...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Models() []any { return []any{&Album{}, &Artist{}, &Collection{}, /* ...25 total */} }
|
||||
func (p *Plugin) Migrations() fs.FS { return migrationsFS } // //go:embed migrations/*.sql
|
||||
func (p *Plugin) Routes(r *surf.Router) { registerRoutes(r) }
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return []bonfire.Command{ReindexAlbums{}, IssueOAuthClient{}, PruneNotifications{}}
|
||||
}
|
||||
func (p *Plugin) Jobs() []conga.JobWorker { return []conga.JobWorker{CsvImportWorker{}, WishlistDigestWorker{}} }
|
||||
func (p *Plugin) Navigation() []party.NavItem { /* mirrors registerNavigation() */ return nav }
|
||||
func (p *Plugin) Permissions() []party.Permission { /* mirrors registerPermissions() */ return perms }
|
||||
func (p *Plugin) Schedule() []party.ScheduledCommand {
|
||||
return []party.ScheduledCommand{{Command: "fonoteka:prune-notifications", Cron: "@daily"}}
|
||||
}
|
||||
|
||||
func init() { party.Register(New()) } // Caddy-style: blank-imported package self-registers
|
||||
```
|
||||
|
||||
### Pattern 2: Generated import list + `summer build` (Caddy/xcaddy model, verified)
|
||||
|
||||
**What:** Confirmed against Caddy's own docs and xcaddy's generated `main.go`: each module registers itself in `init()` via `caddy.RegisterModule`, and xcaddy generates a `main.go` with blank imports for `caddycmd`, the standard modules, and any requested plugins, then calls `caddycmd.Main()`. SummerCMS's `party` mirrors this exactly, one file, regenerated on demand:
|
||||
|
||||
```go
|
||||
// cmd/summer/plugins.gen.go — REGENERATED by `summer build`, do not hand-edit
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/golem15/fonoteka.go/plugins/fonoteka"
|
||||
_ "github.com/golem15/fonoteka.go/plugins/user"
|
||||
_ "github.com/golem15/fonoteka.go/plugins/translate"
|
||||
_ "github.com/golem15/fonoteka.go/plugins/websockets"
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
`summer make:plugin <name>` scaffolds `plugins/<name>/{go.mod,plugin.go,models/,migrations/,routes.go}`, adds a `use ./plugins/<name>` line to `go.work`, and appends the blank import to `plugins.gen.go`. `summer build` regenerates `plugins.gen.go` from a manifest (or by scanning `plugins/*/plugin.go` for the `party.Register` call) and runs `go build ./cmd/summer`. A dev watch loop (reflex/air-style, restart-on-save) makes iterating on a plugin feel like WinterCMS's drop-in autoload, at the cost of a sub-second rebuild.
|
||||
|
||||
**When to use:** Always, for every plugin, first-party or (later) third-party. This is the only supported extension path in v1 — no runtime plugin loading, per the recorded decision.
|
||||
|
||||
**Trade-offs:** Requires the Go toolchain at build/deploy time (acceptable — CI already needs it); adding a plugin is a build-time operation, not a runtime one (this is the entire point of the compiled-plugin decision, not a regression).
|
||||
|
||||
### Pattern 3: Plugin-extends-plugin without dynamic traits — three concrete mechanisms, not one
|
||||
|
||||
WinterCMS's `Model::extend()` and `\Event::listen()` closures let a plugin bolt fillable attributes, relations, and behavior onto another plugin's model at runtime. Go has no runtime monkey-patching. Three separate, concrete techniques replace it, matched to what the extension actually needs to do — and this is a load-bearing distinction, not a single hand-wave:
|
||||
|
||||
**3a. Serialization/response extension → typed "filter" events on `festival`.**
|
||||
This is the port of a pattern already live in the real `fonoteka` `Plugin.php` (`Event::listen('golem15.user.getApiArray', ...)`, additive, `halt=false`, flat-merge). It requires no schema change — only extra keys in an outgoing JSON payload.
|
||||
|
||||
```go
|
||||
package user // owned by the user plugin
|
||||
|
||||
type APIArrayEvent struct {
|
||||
User *User
|
||||
Extra map[string]any // listeners append; core serializer merges this into the response
|
||||
}
|
||||
```
|
||||
```go
|
||||
// plugins/fonoteka/plugin.go — Boot()
|
||||
bus.Listen(func(e *user.APIArrayEvent) {
|
||||
e.Extra["organisation_id"] = e.User.OrganisationID
|
||||
e.Extra["must_change_password"] = e.User.MustChangePassword
|
||||
})
|
||||
```
|
||||
The `user` plugin's serializer never imports `fonoteka`; `fonoteka` imports `user`'s exported event type. One-directional dependency, no cycle.
|
||||
|
||||
**3b. Lifecycle extension → GORM's own callback registry, not a custom event.**
|
||||
For "when this other plugin's model is created/updated, do something" (the port of `Event::listen('eloquent.created: '.Album::class, ...)`), GORM already has the right primitive: a plugin registers a named callback directly on the shared `*gorm.DB`, keyed by model type via `db.Statement.Schema`. No modification to the owning model's file.
|
||||
|
||||
```go
|
||||
db.Callback().Create().After("gorm:create").Register("fonoteka:notify_album_created", func(tx *gorm.DB) {
|
||||
if tx.Statement.Schema.ModelType != reflect.TypeOf(Album{}) { return }
|
||||
// ... build actor, call NotificationService — same logic as the PHP listener
|
||||
})
|
||||
```
|
||||
This is registered once at boot (typically inside the owning plugin's `Boot()`, alongside `festival` listeners), and is a real GORM feature (its own `Plugin` interface: `Initialize(*gorm.DB) error` plus `db.Callback()`), not an invented abstraction.
|
||||
|
||||
**3c. Schema extension (one plugin adding columns to another plugin's table) → migration + companion struct, not struct mutation.**
|
||||
WinterCMS plugins routinely `Schema::table('users', ...)` from a different plugin's migration to add columns to a shared table. The Go port keeps this real capability but makes the *querying* side explicit and compile-time safe: the extending plugin ships its own migration (ALTER TABLE) and defines its own struct that maps onto the same table, embedding the owning struct for the fields it doesn't add:
|
||||
|
||||
```go
|
||||
// plugins/fonoteka/models/user_org.go — fonoteka's own file, never touches plugins/user/models/user.go
|
||||
type UserWithOrg struct {
|
||||
user.User // embeds the owning plugin's columns
|
||||
OrganisationID string `gorm:"column:organisation_id"`
|
||||
OrganisationRole string `gorm:"column:organisation_role"`
|
||||
}
|
||||
func (UserWithOrg) TableName() string { return "users" }
|
||||
```
|
||||
`fonoteka`'s migration adds the columns; `fonoteka`'s own code queries via `UserWithOrg`; `user`'s own code is unaware the columns exist and keeps using its own `User` struct. This is real Go/GORM composition, not hand-waving, and it is exactly what "plugins extend other plugins' models through ordinary Go interfaces and events" reduces to once you need actual new columns rather than just new response keys.
|
||||
|
||||
**Explicit non-goal:** arbitrary runtime field injection into another plugin's *existing* struct is out of scope — Go structs are fixed at compile time. Anything needing that dynamism belongs behind the WASM extension seam later (`.planning/seeds/wasm-extension-api.md`), not the compiled plugin API.
|
||||
|
||||
### Pattern 4: Rule-string validation without struct tags
|
||||
|
||||
**What:** WinterCMS's `fields.yaml`/`columns.yaml` `rules:` block is a YAML-authored, per-field pipe string (`required|max:255`) resolved at runtime, not at compile time — so it cannot become a Go struct tag (struct tags are fixed at compile time; these rules are data). `go-playground/validator`'s `validator.Var(value, tag)` — not `validator.Struct` — is the correct primitive: `lifeguard` parses each YAML rule string once into a validator tag string, and calls `Var()` per field at request time, translating validator's field/tag errors through `phrasebook` for the message.
|
||||
|
||||
**When to use:** Every admin-form save and every plugin-defined YAML validation block.
|
||||
|
||||
**Trade-offs:** Slightly slower than compile-time struct validation (reflection-light, not reflection-free) but is the only approach that keeps `fields.yaml` authorable without a rebuild — which is the entire reason WinterCMS put rules in YAML in the first place.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Request Flow — SPA-authenticated API call (the 154 ported routes)
|
||||
|
||||
This is a direct structural port of the real `routes.php` middleware groups read for this research (`jwt.auth`+`bindings`, `inv.scope:*`, the public/onboarding groups, the OAuth groups):
|
||||
|
||||
```
|
||||
Nuxt app request
|
||||
│
|
||||
▼
|
||||
surf: recover/panic guard → request-id/logging → CORS
|
||||
│
|
||||
▼
|
||||
surf: locale middleware (Accept-Language / user.preferred_locale → phrasebook.SetLocale on ctx)
|
||||
│
|
||||
▼
|
||||
bouncer: route-group-specific auth
|
||||
├─ JWT group (/_fonoteka/api/v1/*) → decode+verify JWT, set User in ctx
|
||||
├─ token group (/api/v1/fonoteka/*) → resolve inv-token, then inv.scope:<read|write|ai>
|
||||
├─ OAuth2 group (/oauth/mcp/*) → zitadel/oidc RS validation, or unauthenticated token/register/metadata endpoints
|
||||
└─ public/onboarding groups → no auth, IP-throttled (cooler-backed limiter)
|
||||
│
|
||||
▼
|
||||
surf: must-change-password gate (if JWT group) → org/collection context resolver
|
||||
(sets ActiveCollection into ctx via an unexported context-key type — never a string key)
|
||||
│
|
||||
▼
|
||||
surf: per-route rate limiter (cooler token buckets — mirrors the named RateLimiter::for() buckets)
|
||||
│
|
||||
▼
|
||||
plugin route handler (registered by fonoteka.Routes(r))
|
||||
│
|
||||
├─→ lagoon (GORM query/save) ──→ Postgres
|
||||
├─→ festival (fire event; other plugins' Listeners react — Pattern 3a/3b)
|
||||
├─→ conga (enqueue job, same DB transaction as the write — River's transactional enqueue)
|
||||
└─→ sandcastle / cooler / postcard as needed
|
||||
│
|
||||
▼
|
||||
JSON response (encoding/json, byte-shape-compatible with the PHP response — parity is the acceptance test)
|
||||
```
|
||||
|
||||
### Job Flow
|
||||
|
||||
```
|
||||
HTTP handler (e.g. CSV import POST)
|
||||
→ conga.Enqueue(tx, CsvImportJob{...}) // same DB transaction as the parent write
|
||||
→ tx.Commit() // job only becomes visible to workers on commit
|
||||
River worker pool (in-process goroutines or a separate `summer work` process)
|
||||
→ picks up job → runs plugin-registered JobWorker.Work(ctx, job)
|
||||
→ festival event fired on completion/failure
|
||||
→ postcard (digest email) / websocket publisher (Centrifugo) as side effects
|
||||
```
|
||||
|
||||
### Migration Flow
|
||||
|
||||
```
|
||||
`summer migrate` (bonfire command)
|
||||
→ party.Registry resolves plugin boot/migrate order via Requires() (topological sort)
|
||||
→ for each plugin, in order:
|
||||
lagoon.Migrator runs goose against a PER-PLUGIN version table
|
||||
(goose -table golem15_fonoteka_goose_version, not the shared default)
|
||||
→ cross-plugin ALTER-table migrations (Pattern 3c) execute after the
|
||||
altered table's owning plugin has already migrated — guaranteed by
|
||||
the same Requires() order used for Boot()
|
||||
`summer migrate:rollback --plugin=fonoteka` targets one plugin's own table
|
||||
(ports the "drop the last migration and fix it" workflow named in PROJECT.md)
|
||||
```
|
||||
|
||||
### Admin Form Save Flow
|
||||
|
||||
```
|
||||
Admin SPA: GET /_admin/api/schema/albums/fields
|
||||
→ party parses fields.yaml (embedded via AdminController.FieldsYAML()) into typed FieldSchema
|
||||
→ served as JSON (field name, type ∈ {text,textarea,number,switch,dropdown,relation,repeater,fileupload,datepicker}, rules, label key)
|
||||
Admin SPA renders form generically (PocketBase-style: the SPA does not need a
|
||||
compile-time TS type for the schema itself — only for the underlying Album
|
||||
API shape, which IS OpenAPI-generated, per PROJECT.md)
|
||||
User submits → PUT /_admin/api/albums/{id}
|
||||
→ lifeguard.Validate(fields, payload) — per-field validator.Var() runtime calls (Pattern 4)
|
||||
→ on success: lagoon Save() → GORM callbacks fire (Pattern 3b) → festival event (Pattern 3a)
|
||||
→ response
|
||||
```
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
| Scale | Architecture Adjustments |
|
||||
|-------|---------------------------|
|
||||
| 0-1k users (household-scale reality of Płytarium) | Single `summer` binary, single Postgres, River workers as goroutines inside the same process. This is the permanent steady state for v1's actual target. |
|
||||
| 1k-100k users (second app, e.g. keios.eu, or a future SaaS framing) | JWT/token auth is fully stateless — horizontal scale-out of the API binary behind a load balancer needs no sticky sessions. Only the **backend admin cookie session** (bouncer/scs) needs a shared store (Postgres-backed scs, not in-memory) once more than one instance runs. River workers can be split into a dedicated `summer work` process pool separate from the HTTP-serving process. |
|
||||
| 100k+ users | Out of scope for the stated domain (a personal/household record collection tool) — noted only so the design doesn't accidentally block it: Typesense already offloads search, `gocloud.dev/blob` already offloads file storage to object storage, and Postgres read replicas are the natural next lever for `lagoon` if ever needed. |
|
||||
|
||||
### Scaling Priorities
|
||||
|
||||
1. **First real bottleneck, if any:** the admin cookie session store, the moment a second app instance runs — must move from in-memory `scs` to its Postgres-backed store before scaling out horizontally.
|
||||
2. **Second:** River worker throughput for CSV import/wishlist digest under real household-scale data is unlikely to matter; not a design concern for v1.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Anti-Pattern 1: Treating `backpack`'s container as a service locator everywhere
|
||||
|
||||
**What people do:** Resolve everything via `backpack.Resolve[T](app)` inside handlers and jobs, Laravel-facade style, because it's convenient and mirrors WinterCMS.
|
||||
**Why it's wrong:** Defeats Go's compile-time dependency graph — `go vet`/the compiler can no longer tell you a handler's real dependencies, and tests need a fully-booted container instead of a struct literal.
|
||||
**Instead:** Use the container only at `Register()`/`Boot()` time to wire concrete dependencies into each plugin's own structs (handlers, workers, admin controllers) via constructor injection; application code holds typed fields, not container references.
|
||||
|
||||
### Anti-Pattern 2: Marshaling the full plugin surface over RPC/WASM now
|
||||
|
||||
**What people do:** Reach for `hashicorp/go-plugin` or `extism`/`wazero` to get "real" plugin isolation, because it sounds safer.
|
||||
**Why it's wrong:** Already decided against in `why-go-not-scala.md` and `wasm-extension-api.md` — the plugin surface here (register models, extend other plugins' models via GORM callbacks and events, add form field types, add HTTP routes) is too wide to marshal across a process/sandbox boundary without losing the entire point of Patterns 1-3.
|
||||
**Instead:** Compiled plugins only, until the compiled API has been stable for a full milestone; then a narrow, explicitly-scoped WASM slot per the seed doc.
|
||||
|
||||
### Anti-Pattern 3: String-keyed request context values for org/locale/user
|
||||
|
||||
**What people do:** `ctx.Value("orgID")` — easy, and how a lot of quick Go HTTP code gets written.
|
||||
**Why it's wrong:** Collides across packages, no compile-time safety, easy to typo.
|
||||
**Instead:** Unexported struct key types per concern (`type orgCtxKey struct{}`), one per `surf`/`bouncer`/`phrasebook` concern, accessor functions (`surf.OrgFromContext(ctx)`) exported instead of the key.
|
||||
|
||||
### Anti-Pattern 4: Letting a single shared `goose_db_version` table serialize unrelated plugins' migrations
|
||||
|
||||
**What people do:** One migrations table for the whole app, because it's goose's default.
|
||||
**Why it's wrong:** Forces a strict global migration order across plugins that don't actually depend on each other, and makes "roll back just this plugin's last migration" (the workflow PROJECT.md explicitly asks for) impossible without hand-editing the tracking table.
|
||||
**Instead:** Per-plugin goose version table (`-table golem15_<plugin>_goose_version`), ordered only by the `Requires()` DAG, not by a single global sequence.
|
||||
|
||||
## Integration Points
|
||||
|
||||
### External Services
|
||||
|
||||
| Service | Integration Pattern | Notes |
|
||||
|---------|----------------------|-------|
|
||||
| Postgres | `lagoon` (GORM) + `conga` (River, same DB) | One migration target for v1, per the recorded constraint. |
|
||||
| Centrifugo | `bouncer`/`festival` publish only; Nuxt client connects directly, unchanged | Framework never becomes a WS server itself — only a publisher + token issuer, per the recorded constraint. |
|
||||
| Typesense | A thin `lagoon`-adjacent search client, driven by `festival` events on model create/update | Mirrors Scout's observer-driven sync; `Album.disableSearchSyncing()` equivalent gate ported as a settings check. |
|
||||
| Discogs / Anthropic / OpenAI | Plain `net/http` clients inside the `fonoteka` plugin, never inside the framework core | App-specific; framework core has zero knowledge these exist. |
|
||||
| ChatGPT connector / MCP | `bouncer`'s `zitadel/oidc` OAuth2/OIDC provider, mirrored 1:1 from the `/oauth/mcp/*` routes read in `routes.php` | RFC 8414 metadata, RFC 7591 DCR, auth code + PKCE, refresh tokens — same shapes, same acceptance test. |
|
||||
|
||||
### Internal Boundaries
|
||||
|
||||
| Boundary | Communication | Notes |
|
||||
|----------|----------------|-------|
|
||||
| Framework core ↔ plugins | Go interfaces (`pact`) only, one direction (plugins import framework, never the reverse) | Enforces the "core plugins can't have breaking changes" rule structurally: the framework doesn't know a specific plugin exists. |
|
||||
| Plugin ↔ plugin (e.g. `fonoteka` ↔ `user`) | Exported events (`festival`, Pattern 3a), GORM callbacks (Pattern 3b), companion structs over shared tables (Pattern 3c), and `Requires()`-ordered boot | Never a concrete import of another plugin's internal package beyond its exported model/event types — mirrors the real `Plugin.php`'s `class_exists()` guards for optional cross-plugin coupling (e.g. `Golem15\WebSockets\Classes\AuthorizerRegistry`), which Go should port as an interface-typed, nil-checked optional dependency rather than a hard import. |
|
||||
| Framework repo ↔ application repo | `go.mod` require (versioned once stable) or `go.work` `use ../summercms.go` (local dev) | The framework never imports application code; the application always imports the framework by module path. |
|
||||
| API parity harness ↔ both backends | Plain HTTP client replaying a recorded fixture corpus | The harness has almost no dependency on SummerCMS internals — it can be built and its fixture corpus assembled starting at t=0, in parallel with all framework work, since it only needs *a* running PHP backend to record against. |
|
||||
|
||||
## Framework vs Application Repo Boundary
|
||||
|
||||
Płytarium is **not** a plugin inside `summercms.go`. It is a plugin — actually a small workspace of plugins — inside a **separate application repo** (proposed name: `fonoteka.go`, sibling to the existing PHP `fonoteka` repo), which `go.mod`-requires this framework. Three tiers, mirroring WinterCMS's own reality more faithfully than a two-tier split would:
|
||||
|
||||
1. **Framework core** (`summercms.go`, this repo) — `backpack` through `party`, `surf` through `sunset`. No app knowledge. Versioned and released on its own.
|
||||
2. **Shared stack plugins** (`user`, and eventually `blog`/`pages`/`payment` equivalents) — start life inside the app repo's `plugins/` workspace for v1 velocity (only one consumer exists), get extracted to their own repos the moment a second app (keios.eu) needs one, exactly as `../IDEA_LIB_NAMES.md`'s "Porting Illuminate module by module... the target app pulls what is needed" already implies. Extraction is mechanical: the plugin's own `go.mod` already exists as a workspace member; promoting it is a repo split, not a rewrite.
|
||||
3. **App-specific domain plugin** (`fonoteka`) — always lives in the app repo. Never shared, never promoted.
|
||||
|
||||
This gives the roadmap a clean phase boundary: framework-core phases produce `summercms.go` releases; app phases produce `fonoteka.go` commits against a pinned (or `go.work`-local) framework version. The two repos can be developed by the same people without merging their concerns.
|
||||
|
||||
## First Vertical Slice
|
||||
|
||||
The smallest binary that proves the whole stack end-to-end against a real Płytarium endpoint: **`GET /_fonoteka/api/v1/genres`** (or `styles` — either is equally minimal: no relations beyond a simple has-many from Album, no jobs, no search, no storage, no OAuth). It still forces every layer to exist in its simplest form:
|
||||
|
||||
1. `compass` boots config (DB DSN, `SUMMER_ENV`).
|
||||
2. `backpack` boots the app container.
|
||||
3. `lagoon` connects to Postgres, runs one goose migration (`create_genres`).
|
||||
4. `party` registers a minimal `fonoteka` plugin descriptor with one model (`Genre`) and one route.
|
||||
5. `surf` serves the route through the real middleware chain, including...
|
||||
6. `bouncer`'s JWT middleware (decode + verify a real token, minted by a throwaway `summer` command against a seeded test user) — even the simplest authed route in `routes.php` sits behind `jwt.auth`, so skipping this would not be a faithful slice.
|
||||
7. Response JSON is diffed, byte-for-byte on shape, against the same request recorded from the live PHP backend — the first fixture in the API parity harness.
|
||||
|
||||
Everything past this (the other 24 models, 153 routes, jobs, realtime, search, OAuth2, admin SPA) is the same pattern repeated and parallelized; this slice is the one phase that cannot be skipped or parallelized, because every later phase depends on all seven of its pieces existing and working together.
|
||||
|
||||
## Suggested Build Order
|
||||
|
||||
```
|
||||
Layer 0 (parallel, no deps): pact, towel
|
||||
Layer 1 (parallel, needs L0 only): compass, phrasebook, bonfire ← bonfire has zero inter-module deps per its own doc
|
||||
Layer 2: festival (needs pact)
|
||||
Layer 3: backpack (needs compass, festival, pact)
|
||||
Layer 4: party (needs backpack, pact, festival, bonfire)
|
||||
Layer 5 (parallel, needs L0-4): lagoon, surf ← neither depends on the other
|
||||
Layer 6: bouncer (needs lagoon for user storage, surf for middleware shape)
|
||||
── FIRST VERTICAL SLICE checkpoint here ──
|
||||
Layer 7 (parallel, independent adapters, needs L0-4 only):
|
||||
lifeguard, cooler, conga (needs lagoon for Postgres), postcard, sandcastle
|
||||
Layer 8 (parallel, independent of each other, needs L6-7):
|
||||
admin schema pipeline (fields.yaml→JSON, party+lagoon+lifeguard+surf)
|
||||
API parity harness (near-zero summer-* deps — can actually start at t=0,
|
||||
fixture recording against the live PHP backend needs no Go work at all)
|
||||
bulk model/route porting (24 remaining models, 153 remaining routes,
|
||||
parallelizable across plugins, gated only by each model's own FK order —
|
||||
the same DAG the 27 PHP migrations already encode)
|
||||
Layer 9 (last, lowest risk): sunset (stub only — no theme work needed in v1)
|
||||
```
|
||||
|
||||
`sunset` is deliberately last and thin: v1 is headless, so the only place templating shows up at all is `postcard`'s mail layouts, which `html/template` covers without a separate investment.
|
||||
|
||||
## Sources
|
||||
|
||||
- Caddy build docs and xcaddy generated-`main.go`/`caddy.RegisterModule` mechanics — verified 2026-09-16: [Extending Caddy](https://caddyserver.com/docs/extending-caddy), [Build from source](https://caddyserver.com/docs/build), [xcaddy DeepWiki](https://deepwiki.com/caddyserver/xcaddy)
|
||||
- PocketBase hook system (event-driven extension, schema-driven admin) — verified 2026-09-16: [Extend with Go — Event hooks](https://pocketbase.io/docs/go-event-hooks/), [Hook System — DeepWiki](https://deepwiki.com/pocketbase/pocketbase/2.3-hook-system), [Collection schema management — DeepWiki](https://deepwiki.com/pocketbase/pocketbase/9.2-collection-schema-management)
|
||||
- GORM's own `Callback()`/`Plugin` extension mechanism (training-knowledge, MEDIUM confidence — not independently re-verified this session; validate against current GORM docs in the phase that first uses cross-plugin callbacks)
|
||||
- `.planning/PROJECT.md`, `.planning/research/go-ecosystem.md`, `.planning/notes/why-go-not-scala.md`, `.planning/notes/v1-target-plytarium.md`, `.planning/seeds/wasm-extension-api.md` (this repo)
|
||||
- `modules/summer-compass/README.md`, `modules/summer-bonfire/PLAN.md`, `modules/summer-phrasebook/README.md` (design carry-overs, this meta-repo)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php` and `routes.php` — read in full for this research; the concrete source of the request-lifecycle mapping, the `Event::listen` extension examples, and the plugin descriptor's optional-method shape
|
||||
|
||||
---
|
||||
*Architecture research for: SummerCMS (Go) — framework + Płytarium v1 port*
|
||||
*Researched: 2026-09-16*
|
||||
272
.planning/research/FEATURES.md
Normal file
272
.planning/research/FEATURES.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Feature Research
|
||||
|
||||
**Domain:** WinterCMS/Laravel-shaped content management framework (Go), headless-first, compiled plugins — v1 ports the Płytarium PHP backend so its Nuxt 4 app and MCP server run unchanged
|
||||
**Researched:** 2026-09-16
|
||||
**Confidence:** HIGH for table stakes (grounded in Płytarium source), MEDIUM for differentiators (Go ecosystem + PocketBase/Directus/Strapi/Goravel comparisons), MEDIUM for anti-features (informed by WinterCMS structure, not exhaustively re-audited)
|
||||
|
||||
## Method
|
||||
|
||||
Read `plugins/golem15/fonoteka` in full (`Plugin.php`, `routes.php` — 569 lines / ~160 routes, all 5 admin controllers and their `config_form.yaml`/`config_list.yaml`/`config_relation.yaml`, all 25 models, 3 jobs, 3 console commands, `config/fonoteka.php`), skimmed the `user` and `websockets` stack plugins for auth/realtime shape, skimmed `golem` (AI) plugin structure, and skimmed the Nuxt app's composables and the MCP server's client for what the frontend/agent actually calls. Cross-checked PocketBase/Directus/Strapi/Goravel via `.planning/research/go-ecosystem.md` and a web search for 2026-current comparisons.
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes (Płytarium does not run without these)
|
||||
|
||||
Grouped by category. Every row cites the concrete file(s) that depend on the capability.
|
||||
|
||||
#### Kernel / plugins
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Plugin descriptor with register/boot lifecycle | `Plugin.php` registers config, console commands, mail templates/layouts, permissions, settings page, navigation, and wires cross-plugin event listeners in `boot()` | MEDIUM | WinterCMS's `register()`/`boot()` split matters: `register()` runs first (config merge, service providers), `boot()` runs once every plugin is registered (safe to reference other plugins' classes). SummerCMS's descriptor must preserve this two-phase order — Fonoteka's `boot()` guards on `class_exists` for optional plugins (WebSockets, TokenScope) precisely because boot order across plugins isn't guaranteed. |
|
||||
| Plugin dependency declaration (`$require`) | `public $require = ['Golem15.Apparatus', 'Golem15.User']` | LOW | Needed so build-time registration can order/validate plugin graphs. |
|
||||
| Cross-plugin extension via events, not inheritance | `registerNotificationListeners()` hooks `eloquent.created: Album`; `registerCollectionProvisioning()` hooks `golem15.user.register`; `surfaceOrganisationToApi()` hooks `golem15.user.getApiArray` to add fields to the User plugin's serialized payload **without editing User** | HIGH | This is the core "plugins extend each other" pattern PROJECT.md names. Needs (a) Eloquent-model lifecycle events (`created`, `updated`, `deleting`, etc.) fired generically by the ORM layer, (b) a named/string-keyed event bus plugins can both fire and listen on, (c) a "collect all listener return values into one array" halt=false semantics for the getApiArray pattern specifically (not just fire-and-forget pubsub). |
|
||||
| Auth guard registration extension point | `registerTokenGuard()` registers a custom `inv-token` guard driver; `user`'s `JwtAuthGuardServiceProvider`/`JwtAuthGuard` do the same for `api` | HIGH | A plugin must be able to add a new authentication strategy (not just middleware) that other controllers' `auth()->user()`-equivalent resolves against. |
|
||||
| Middleware aliasing/registration from a plugin | `registerPasswordChangeGuard()` aliases `inv.must-change-password`; `TokenScope` aliases `inv.scope` | LOW | Route middleware needs to be a named, composable pipeline segment a plugin can register and other plugins' routes can reference by name. |
|
||||
| `registerSchedule` (cron-style recurring commands) | `Plugin::registerSchedule()` runs `fonoteka:prune-notifications` daily | LOW | Needs at minimum daily/interval scheduling tied to console commands — doesn't need a full cron DSL for v1. |
|
||||
| Permissions registry + role-gated navigation | `registerPermissions()` (6 permissions, all gated to `UserRole::CODE_DEVELOPER`), `registerNavigation()` (top nav + 4-item side menu, each gated by its own permission) | MEDIUM | Backend nav and permission declarations are structurally separate from frontend/API auth — a second, admin-only RBAC system. |
|
||||
| Settings page registration | `registerSettings()` binds a `Settings` model to a backend settings screen (`search_use_typesense` switch) | LOW | Single-row config model editable through the same fields.yaml pipeline as domain models. |
|
||||
| Mail template/layout registration | `registerMailTemplates()` (6 templates incl. pl/en pairs), `registerMailLayouts()` | LOW | Plugin-owned mail templates resolved by dotted name, per-locale suffix convention (`-en`). |
|
||||
| `elevated` plugin flag (privileged boot under test/console) | `public $elevated = true`, with the explicit rationale that WinterCMS's `PluginManager::$noInit` skips non-elevated plugins' `register()/boot()` under `PluginTestCase` and privileged requests | MEDIUM | A Go port needs an equivalent "does this plugin's register/boot run under the test harness and console bootstrap" concept, or the Go answer is simply "always run boot for every registered plugin" (simpler — flag this as a place the Go design can improve on Winter, see Differentiators). |
|
||||
| Console command registration from a plugin | `registerConsoleCommand('fonoteka.reindex', ReindexAlbums::class)` etc., 3 commands | LOW | Already required by PROJECT.md's command framework requirement. |
|
||||
| Optional-plugin dependency via `class_exists` guard | WebSockets authorizer registration, TokenScope middleware alias, ai-scope routes all wrapped in existence checks so Fonoteka boots without WebSockets/Golem present | MEDIUM | A compiled-plugin Go system can't do a runtime `class_exists` — needs a build-time equivalent (e.g., a registered-plugins set checked at boot, or Go build tags) so a plugin can be optional to another without a hard import. |
|
||||
|
||||
#### Data / models
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| `belongsTo` / `hasMany` / `hasOne` / `belongsToMany` (with pivot columns and a dedicated pivot model) | `Album` (belongsTo Collection/Genre, hasMany ratings, hasOne reservation, belongsToMany styles/artists with `pivot: ['sort_order']`), `Collection` (belongsTo owner, hasMany albums, belongsToMany editors with `pivotModel: CollectionEditor` and pivot columns `role, granted_at, granted_by`), `Artist`/`Style` (belongsToMany albums with pivot `sort_order`) | HIGH | GORM supports all four, but the **named pivot model with extra pivot columns** (`CollectionEditor` as a first-class model, not just an array) and **ordered belongsToMany** (`'order' => 'name'`) need explicit design — this is not GORM's default many2many ergonomics. |
|
||||
| Polymorphic file attachments (`attachOne` / `attachMany` to `System\Models\File`) | `Collection` has `attachMany 'photos'` (ordered by `sort_order`) and `attachOne 'image'` (`public => true`); `Album` has `attachMany` cover photos | HIGH | WinterCMS's `System\Models\File` is a single polymorphic files table (attachable to any model). This is Płytarium's entire file/image storage model — a Go port needs a generic polymorphic attachment table (owner type + owner id + field name + disk path), not a bespoke `album_photos` table per model. Public vs. private disk visibility is part of the contract (`'public' => true` on the image relation). |
|
||||
| `$jsonable` array-cast columns (Storm-specific, distinct from Laravel's array cast) | `Album->$jsonable = ['tracklist', 'cover_import_failures']`; `OAuthClient->$jsonable = ['redirect_uris', 'grant_types', 'scope_ceiling']` | MEDIUM | Needs a documented Go equivalent (custom GORM type / JSON column serializer) so JSON round-trips exactly as the PHP `$jsonable` trait did — no `$casts => array` semantics differences (e.g., empty-array vs null handling). |
|
||||
| `$fillable` allow-list + `$guarded = ['*']` mass-assignment discipline | Every model in the plugin sets `$fillable` explicitly and resets `$guarded` — this is the actual authorization boundary for what a PUT/POST body can touch (e.g., `Collection`'s `public_token`, `kind`, and the three OAuth secret fields are deliberately excluded from `$fillable`) | HIGH | This is a *security-load-bearing* pattern, not cosmetic — several Płytarium security invariants (D-01, D-09, Phase 9 D-01) depend on specific fields being absent from the allow-list. The Go ORM layer needs an equivalent explicit allow-list per model that request-binding code must go through, not raw struct-field binding. |
|
||||
| `Winter\Storm\Database\Traits\Validation` (`$rules` array validated on save) | Every model (`Artist`, `Genre`, `Style`, `Collection`, `Album`) declares `public $rules = [...]` with Laravel-style rule strings (`required`, `between:3,64`, `unique:table`, `nullable|integer|between:1889,2100`) | HIGH | This is the YAML/array `rules:` requirement already named in PROJECT.md — `go-playground/validator` is the named pick. Needs `unique:table` (DB-hitting rule) and cross-field/conditional rules to match. |
|
||||
| `Winter\Storm\Database\Traits\SoftDelete` | `Album`, `Collection` use it; cascading soft-delete of children in `beforeDelete()` (`Collection::beforeDelete()` soft-deletes every child Album inside a transaction before soft-deleting itself) | MEDIUM | Standard GORM soft-delete covers the column; the **cascading soft-delete inside a DB transaction, driven from a model lifecycle hook**, is the part that needs an equivalent hook point. |
|
||||
| Model lifecycle hooks (`beforeValidate`, `beforeCreate`, `beforeDelete`, `afterDelete`) used for **manual slug generation**, dedup-key normalization, and cascade cleanup | `Artist::beforeValidate()` (slug + `name_key` dedup normalization), `Genre`/`Style::beforeValidate()` (slug generation, `Style` has a bespoke padding algorithm for short names), `Collection::beforeDelete()` (cascade) | HIGH | **Płytarium does NOT use WinterCMS's `Sluggable` behavior anywhere** — slugs are hand-rolled in `beforeValidate()`. Confirms PROJECT.md's fields.yaml list (no Sluggable dependency) but the *lifecycle hook* mechanism itself (pre-validate/pre-save/pre-delete/post-delete callbacks a model can override) is still table stakes — just as a plain hook, not a packaged behavior. |
|
||||
| Encrypted-at-rest column casts (`'encrypted'` cast, app-key-managed, IV+MAC) | `OrgDiscogsCredential`/`UserDiscogsCredential`->`casts = ['token' => 'encrypted']`; `OrgAiCredential`/`UserAiCredential`->`casts = ['api_key' => 'encrypted']`; `user`'s own `Settings` model uses the `Encryptable` trait | HIGH | Explicitly documented in these models as "hand-rolled AES is forbidden" — this is a named security invariant, not incidental. Go port needs a `encrypted` GORM field type (AES-GCM or similar, app-secret-derived key) plus `$hidden`-equivalent (never serialize) on the same fields — both together are the actual contract. |
|
||||
| `$hidden` (never-serialize) fields, distinct from `$fillable` | `Collection->$hidden = ['public_token']`; every `*Credential` model hides its secret column | HIGH | Serialization-layer allow/deny-list, independent from the mass-assignment allow-list — two different boundaries on the same struct. A straight Go `json:"-"` tag can cover static cases, but `public_token`'s hiding is *conditional* (one controller explicitly reads it out-of-band) — needs a serializer that supports "hidden by default, explicit override in one code path." |
|
||||
| Custom attribute casts beyond primitives | `Album->$casts = ['market_price_stored' => MarketPriceCast::class]` — a **hand-written class cast**, explicitly chosen over Laravel's built-in `decimal:4` because the built-in cast breaks Eloquent's dirty-checking on a blank-string "clear the price" input | MEDIUM | Confirms the ORM layer needs a pluggable custom-cast mechanism (not just a fixed set of built-in types), and that a naive decimal cast can misbehave on an empty-string-clears-the-value UX — a documented pitfall to carry into the Go port. |
|
||||
| Dedicated pivot model (not just a pivot array) | `CollectionEditor` is a first-class model referenced via `'pivotModel' => CollectionEditor::class` on `Collection`'s `editors` belongsToMany, carrying `role`, `granted_at`, `granted_by` | MEDIUM | GORM's `many2many` supports a join-table struct; the design must let a pivot table carry business columns and be queried/validated like any other model, not just as opaque pivot data. |
|
||||
| Table-per-plugin naming convention (`golem15_fonoteka_*`) | Every model's `$table` | LOW | Cosmetic but must be preserved for a byte-compatible Postgres cutover if the same physical DB is reused, or explicitly renamed with a documented mapping otherwise. |
|
||||
| Pagination, timestamps | Implicit across all list/index endpoints | LOW | Already named in PROJECT.md. |
|
||||
|
||||
#### HTTP / auth
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Three parallel, mutually-exclusive auth groups on **overlapping route sets** | (1) `/_fonoteka/api/v1` guarded by `jwt.auth` (SPA, cookie/JWT), (2) `/api/v1/fonoteka` guarded by `inv.scope:<read\|write\|ai>` (personal API tokens / MCP), (3) fully public groups (`onboarding/*`, `public/{token}/*`, `public-wishlist/{token}/*`, invitation inspection) — same controllers reused across groups 1 and 2 via `TokenScope` middleware binding the request user | HIGH | This is the single hardest routing requirement: **the same controller class must serve two differently-authenticated route groups** with different exposed subsets of its actions (e.g., token group has zero `tokens`/`oauth`/CSV-import routes; JWT group has all of them). The routing layer needs first-class support for "same handler, different middleware stack, different route subset," not a 1:1 handler-per-route assumption. |
|
||||
| Per-route, per-group rate limiting with distinct named buckets | 7+ named limiters in `routes.php` alone: `fonoteka-api-token` (60/min by token id), `fonoteka-oauth-token` (30/min by IP), `fonoteka-oauth-register` (30/min by IP), `fonoteka-public-token` (60/min by token), `fonoteka-public-ip` (120/min by IP), plus inline `throttle:10,1` / `throttle:20,1` / `throttle:12,1` on individual sensitive routes (regenerate share link, upload photo, cover-price fetch, CSV import) | HIGH | Needs a rate-limiter abstraction keyed by an arbitrary resolver function (token id, IP, route param), not just a global per-IP limiter — and the ability to stack two limiters on one route (`['throttle:fonoteka-public-token', 'throttle:fonoteka-public-ip']`). |
|
||||
| Route-model binding / typed route params with regex constraints | `->where('id', '[0-9]+')` on nearly every numeric-id route; string tokens with no constraint deliberately (so malformed tokens get the same 404 as unknown ones) | LOW | net/http ServeMux (1.22+) wildcard patterns handle the shape; regex constraint needs to be layered on top (either in the mux pattern or a validating decorator). |
|
||||
| Custom scoped-token auth guard, separate from JWT | `ApiTokenGuard`/`ApiTokenManager`/`inv-token` guard driver + `inv.scope` middleware — personal API tokens with a `read\|write\|ai` scope ceiling, distinct token model (`ApiToken`) | HIGH | A second, independent credential type alongside JWT and OAuth2 bearer tokens — three different "who is this request" resolution strategies that must compose with the same downstream `auth()->user()`-equivalent. |
|
||||
| OAuth2.1 authorization server (auth code + PKCE, refresh tokens, DCR) for MCP/ChatGPT | `/.well-known/oauth-authorization-server` (RFC 8414 metadata), `/oauth/mcp/authorize` (302 to a consent screen), `/oauth/mcp/token` (authorization_code + refresh_token grants), `/oauth/mcp/register` (RFC 7591 Dynamic Client Registration, JSON) — `OAuthClient`/`OAuthAuthCode`/`OAuthRefreshToken` models; consent flow (`OAuthConsentController`), connected-apps management (`ConnectedAppController`) | HIGH | Already named in PROJECT.md (`zitadel/oidc`). Confirmed from source: needs RFC 8414 discovery, RFC 7591 DCR, PKCE, a resource-parameter check (RFC 8707) tolerant of "absent" vs strict on "present-and-different" (Claude vs. other clients differ here), and a **separate rate-limit/no-CSRF/form-urlencoded** requirement on the token endpoint (explicitly "NO `web` middleware group" since CSRF would break machine-to-machine POST). |
|
||||
| Console-issued OAuth clients | `fonoteka:oauth-client` command: create, list (`--list`, never prints the secret again), add redirect URIs to an existing client without rotating the secret, set a scope ceiling | MEDIUM | Confirms scaffolding-style admin console commands need argument/flag parsing with repeatable options (`--redirect-uri=*`, `--scope=*`) — already covered by the cobra pick. |
|
||||
| Per-request locale + org context middleware | `me/locale` GET/PUT; `organisation_id`/`organisation_role` surfaced onto every user payload via an event seam; `RequirePasswordChange` (423 Locked) middleware gating an entire authed surface until a flag clears | MEDIUM | Confirms PROJECT.md's "middleware for auth, org context, locale" line item; the locale field is user-scoped and persisted, not just an Accept-Language header. |
|
||||
| SSRF-safe outbound fetch as a routed capability | `albums/{id}/cover-price/discogs`, `albums/{id}/photos` (manual `cover_url`) — both explicitly host-allow-listed and byte/time capped, called out as "an SSRF-fetch surface" in code comments | MEDIUM | Not a generic feature, but any Go port of an endpoint that fetches an admin/user-supplied URL server-side must carry the same host/size/timeout guardrails — a pitfall as much as a feature. |
|
||||
| CORS | Named in PROJECT.md; implicit for the Nuxt SPA talking cross-origin in dev | LOW | Standard. |
|
||||
| 404-not-403 information-hiding convention on ownership-scoped resources, with an explicit narrow exception on the OAuth surface (401/403 there) | Stated directly in `routes.php`'s OAuth section comment | MEDIUM | A response-shape convention that must be preserved exactly for parity — not negotiable per PROJECT.md's "don't improve response shapes" constraint. |
|
||||
|
||||
#### Admin / forms
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| `fields.yaml` → form schema, with `span` (left/right/full), `context`-scoped fields, and field-level `attributes` (e.g. `readonly`) | All 5 controllers' `models/*/fields.yaml`; `Style`'s `slug` field is `context: update` + `attributes: { readonly: true }` (editable only implicitly via slug-gen on create, read-only on edit) | MEDIUM | Confirms PROJECT.md's field-type list is right for v1, but the schema needs **layout hints** (span) and **per-context field visibility/attributes**, not just field type + label. |
|
||||
| Field types actually used: `text`, `textarea`, `checkbox`, `switch`, `dropdown` (with a model-method `options:` callback), `relation` (with `nameFrom`, `emptyOption`), `partial` (arbitrary `.htm` include, tab-scoped, context-scoped) | Every `fields.yaml` in the plugin | HIGH | **Correction to PROJECT.md's assumed list**: Płytarium's 5 admin controllers do **not** use `number`, `fileupload`, `repeater`, or `datepicker` in their `fields.yaml`. They do use `checkbox` (distinct from `switch`), a dropdown fed by a model-side PHP method (`getFormatOptions`) rather than a static option list, and a `partial` field type that embeds an arbitrary template fragment (`_editors.htm`) inline in the form — this last one has no clean schema-driven equivalent and is effectively an escape hatch to hand-written markup. Flag `partial` as the one field type that may need a bespoke solution or a documented v1 gap (Collections' "editors" tab uses it only as a wrapper around the relation manager, so it may be replaceable by wiring the relation manager into the SPA directly instead of porting `partial` as a generic type). |
|
||||
| `columns.yaml` → list schema, with `searchable`, `sortable`, relation columns (`relation: genre, select: name`), and `type: datetime`/`type: switch` column renderers | All 5 controllers' `models/*/columns.yaml` | MEDIUM | Needs the list schema to express "render this related model's column" and a small set of column renderer types, separate from the form field types. |
|
||||
| RelationController behavior (link/unlink management UI for a belongsToMany with a pivot) | `Collections`' `config_relation.yaml` — an `editors` relation manager with `manage`/`view` list configs, `toolbarButtons: link|unlink`, `showSearch: true` | HIGH | This is a third schema type beyond form+list: a **relation manager** UI (search-and-attach/detach a related record) that only some controllers need. It's used exactly once across Płytarium's 5 controllers but is the single most complex admin behavior — building the fields.yaml→JSON pipeline without at least one relation-manager schema would be incomplete for the actual v1 scope. |
|
||||
| Query-scoping hooks in the controller layer (`listExtendQuery`, `formExtendQuery`, `formBeforeCreate`, `formBeforeUpdate`, `relationExtendManageQuery`) | `Albums` controller scopes every list/form query to the backend admin's "active collection" and rejects a cross-collection update attempt with a 404-shaped error; `Collections` controller auto-assigns a default owner on create and excludes the owner from the editor-picker | HIGH | The admin controller is not just "render this YAML" — it's a real place for authorization/tenancy-scoping logic that runs before/after the generic CRUD behavior. The admin framework needs extension points at each CRUD lifecycle stage, not just declarative config. |
|
||||
| Bulk delete action wired to a list toolbar (`index_onDelete`) | All 5 controllers implement `index_onDelete()` reading `post('checked')`, some with tenancy-scoping, all iterating individual `->delete()` calls (so per-model cascade/soft-delete hooks still fire) | MEDIUM | Confirms bulk actions must go through the same per-record model lifecycle as a single delete (not a raw bulk SQL delete), because at least one model's `afterDelete` has side effects (Genre/Style detach from Albums; Album's soft-delete cascade). |
|
||||
| Backend-only permissions gating both navigation and controller access (`$requiredPermissions`) | Every controller declares `$requiredPermissions = ['golem15.fonoteka.access_*']` | LOW | Separate RBAC system from the frontend JWT/org roles — confirms PROJECT.md's "backend admin users, separate from frontend users." |
|
||||
| OpenAPI-generated TypeScript types for the SPA | PROJECT.md requirement; not present in the PHP version (PHP relies on hand-written composables) but required for the new admin SPA | MEDIUM | New capability, not a port — the admin SPA is new, not a like-for-like port of a WinterCMS backend view. |
|
||||
|
||||
#### Jobs / realtime / search / files
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Postgres-backed queued jobs with typed payloads, delayed re-dispatch, and per-job timeout | `AlbumCsvImportJob` (write pass, no network calls), `AlbumCsvMatchJob` (Discogs match pass, `timeout = 240`, explicitly re-dispatches itself with a delay on a rate-limit exception rather than failing the batch), `WishlistDigestJob` (30-minute coalescing window, deletes its own queue row on completion so the next item starts a fresh window) | HIGH | River (already the named pick) supports delayed re-enqueue and per-job config; the **debounce/coalescing pattern** (`WishlistDigestQueue` accumulates a count, one job fires after a fixed delay, then the row is deleted) is a job-orchestration pattern to preserve exactly, not just "queue exists." |
|
||||
| A small job-management facade the domain plugin depends on (`Golem15\Apparatus\Classes\JobManager`, `ApparatusQueueJob` contract) | All 3 jobs implement this contract (`assignJobId`, `handle(JobManager $jobManager)`) and call `$jobManager->failJob()`/`completeJob()` | MEDIUM | Confirms jobs need a status-tracking layer above the raw queue (job succeeded/failed/skipped, recorded somewhere queryable) — not just "enqueue and forget." River's job table can serve this if the port wires status updates through it. |
|
||||
| Scheduled/cron console commands | `fonoteka:prune-notifications` (daily, via `registerSchedule`), `fonoteka:reindex` (manual, with a `--drop-old-items-index` flag) | LOW | Covered by the console + scheduling requirement above. |
|
||||
| Full-text/faceted search via Typesense, toggleable per-deployment, tenant-scoped | `Album` uses `Laravel\Scout\Searchable`; `Settings::get('search_use_typesense', false)` gates syncing; `ReindexAlbums` verifies **zero documents have `collection_id:=0`** (a multi-tenant leak check) before/after reindex, and can drop a legacy index name | HIGH | Confirms the search index itself is tenant-scoped (`collection_id` as a filterable field) and the reindex command needs a tenant-leak assertion baked in, not just a bulk resync. Already the named Go pick (Typesense Go client) — the schema and the leak-check discipline are the parts to port faithfully. |
|
||||
| Search syncing kill-switch that degrades gracefully with no DB / no config | `guardSearchSyncing()` disables search syncing if `!App::hasDatabase()` or the setting is off, wrapped in a try/catch | LOW | A boot-time capability check pattern (don't crash if a downstream dependency is unconfigured) worth carrying over generally. |
|
||||
| Realtime pub/sub over Centrifugo, with per-channel-namespace authorizers registered by the domain plugin | `AuthorizerRegistry::register('collection', CollectionChannelAuthorizer::class)` and `('wishlist', WishlistChannelAuthorizer::class)`; the SPA's `useCentrifugo.ts` fetches a connection token from `GET /api/realtime/token` (owned by the WebSockets stack plugin, **not** Fonoteka) and subscribes to per-user and per-collection/wishlist channels; server-side authorization is re-validated on every subscribe regardless of the client-requested channel name | HIGH | Confirms PROJECT.md's "keep Centrifugo, port only the publisher and token issuing" framing exactly: the **authorizer registry is a pluggable extension point** other plugins hook into (Fonoteka registers 2 channel-namespace authorizers into a registry owned by the WebSockets plugin), and channel names deliberately never leak raw internal IDs the client could resubmit (`me/context` returns an opaque channel-name string, not a numeric collection_id). |
|
||||
| Model-level "broadcastable" trait/interface for WS live-patch | `Album implements BroadcastableInterface, use BroadcastableModel`; explicitly a **separate concern** from the durable notification registry (WS broadcast is throttled during bulk writes via `withoutBroadcasting`; the notification listener in `Plugin.php` is not) | HIGH | Two independent event-driven side-channels off the same model write (live WS patch vs. durable notification/email), each with its own throttling rule — must not be collapsed into one code path in the port. |
|
||||
| Polymorphic file storage with public/private visibility per attachment | Covered above under Data/models; also the CSV import/export and cover-photo upload endpoints all go through this file layer | HIGH | `gocloud.dev/blob` is the named pick; the polymorphic-attachment table design is the part still to be specified. |
|
||||
| CSV import as a multi-step, resumable, queued pipeline with a review/edit UI before commit | Routes: `import/csv` (store) → `import/csv/{id}` (show/poll) → `PATCH mapping` → `PATCH rows/{rowId}` (per-row edit) → `commit` → `cancel`; two distinct jobs (write pass vs. Discogs match pass) | HIGH | Not a single "upload CSV, done" action — it's a stateful import session (`CsvImport`/`CsvImportRow` models) the user can edit row-by-row before committing, matching against Discogs asynchronously. This entire workflow, not just "a CSV parser," is table stakes. |
|
||||
| CSV export | `export/csv` route on both the JWT and token auth groups | LOW | Simple by comparison — a streamed/generated CSV download. |
|
||||
|
||||
#### Integrations
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Discogs HTTP client with rate limiting, retry-after handling, and a wait budget | `config/fonoteka.php`'s `discogs.*` block: `rate_threshold: 50` (proactive, below Discogs' documented 60/min), `wait_budget_seconds: 15` (hard budget before giving up inside one HTTP request), `retry_after_fallback_seconds: 10`; the match job explicitly re-dispatches on `DiscogsRateLimitException` rather than treating it as a batch failure | HIGH | A generic `net/http` client is not enough — needs a **rate limiter with a bounded in-request wait budget** distinct from the job-level retry/backoff strategy used when the budget is exceeded. |
|
||||
| Discogs credential resolution with a 3-tier precedence: env-configured site-admin token → org-shared BYOK → per-user BYOK | `ai_org_lock` config flag; `OrgDiscogsCredential`/`UserDiscogsCredential`/env `DISCOGS_TOKEN`; `DiscogsConfigResolver`/`DiscogsGate` classes | HIGH | Same shape repeated for AI credentials (`OrgAiCredential`/`UserAiCredential`, `ai_org_lock`). This "3-tier BYOK-or-shared-or-org-locked credential resolution" is a reusable pattern, not incidental to Discogs — likely worth generalizing in the Go port rather than hand-copying twice. |
|
||||
| SSRF-guarded outbound fetch for user-supplied URLs (cover images) | `covers.manual_url_max_bytes` (10MB), `manual_url_timeout_seconds`, contrasted explicitly with Discogs-only cover fetches which are additionally host-suffix-allow-listed (`cover_host_suffix: .discogs.com`) | MEDIUM | Two different SSRF postures in the same plugin: Discogs cover fetch is host-locked; manual cover-URL fetch is open-host but size/time capped and behind an explicit SSRF guard class (`ManualCoverUrlFetcher` per code comments) — both need to be preserved distinctly. |
|
||||
| AI cover recognition via pluggable providers (Anthropic, OpenAI), BYOK per-user or per-org | `RecognizeApiController`, `UserAiCredential`/`OrgAiCredential` (`provider` = claude\|openai, optional `model`/`base_url` overrides) | HIGH | Already named in PROJECT.md; confirmed the credential model supports overriding model name and base URL per credential (e.g., for OpenAI-compatible proxies), not just an API key. |
|
||||
| MCP server as an OAuth2 client, talking to the personal-token surface for CRUD and to the OAuth endpoints for auth | `fonoteka-mcp`'s `client.ts` is a thin Bearer-token proxy over `/api/v1/fonoteka/*`; `server.ts`/`install.ts` drive the OAuth2.1 authorize/token/register flow to obtain that bearer token | HIGH | Confirms the OAuth surface and the personal-token surface are both real, separately-tested contracts the MCP server depends on — not just documentation. |
|
||||
| Feedback submissions, sitemap output (stack plugins, not Fonoteka itself) | Named in PROJECT.md context as stack plugins Płytarium uses | LOW | Small, standalone features — a feedback-form-to-storage endpoint and a sitemap XML generator. Lower priority than the auth/data/admin core. |
|
||||
|
||||
#### Console / i18n
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Namespaced translation keys resolved per-plugin, with per-locale mail template suffixes | `golem15.fonoteka::lang.*` keys throughout every yaml/model; mail templates registered as `...invitation` and `...invitation-en` pairs (pl implied default, en explicit suffix) | MEDIUM | Matches PROJECT.md's `vendor.plugin::group.key` i18n design exactly; confirms the **mail-template-per-locale-by-suffix** convention specifically (not a generic i18n string catalog covering mail bodies). |
|
||||
| `preferred_locale` as a persisted user attribute, settable via its own always-available API route even when the account is otherwise locked | `me/locale` GET/PUT registered on a *separate* middleware group (`jwt.auth, bindings` — deliberately without `inv.must-change-password`) so a forced-password-change screen can still switch language | MEDIUM | A locale-switch endpoint must be reachable even when most of the API is gated behind an account-state lock — an ordering/exception detail in the middleware pipeline design. |
|
||||
| Console commands with rich flags: repeatable options, mutually exclusive modes, listing without ever re-printing a secret | `fonoteka:oauth-client`'s `--redirect-uri=*`, `--scope=*`, `--list`, `--client-id=` | LOW | Already covered by the cobra pick; confirms repeatable flags and "never reprint a secret" as a concrete command-design convention to keep. |
|
||||
|
||||
### Differentiators (competitive advantage over WinterCMS for the next port)
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| Compile-time plugin registration (no `class_exists`/`elevated` boot-order fragility) | WinterCMS's `elevated`/`noInit`/`class_exists`-guarded optional dependency dance (seen 3+ times in `Plugin.php` alone) exists because plugin boot order and test-harness short-circuiting are runtime-discovered. A Go build graph makes plugin dependencies and boot order a compile-time fact, checked by `go build`/`go vet`, not a runtime guard a developer must remember to add. | MEDIUM | Directly addresses a documented pain point in the PHP source (the `elevated` docblock explicitly narrates the bug class it exists to avoid). |
|
||||
| A single explicit "extension point" type (typed event bus with return-value collection) instead of ad hoc string-keyed `Event::listen` | Płytarium hand-rolls the "collect all listeners' return values into one flat array" pattern for `golem15.user.getApiArray` via a code comment explaining the merge semantics. A typed Go event/hook system can make "fire and collect" vs. "fire and forget" vs. "fire until handled" three distinct, statically-checked primitives instead of one string-keyed bus with implicit per-event contracts. | MEDIUM | Directly generalizes a pattern Płytarium needed twice (getApiArray, notification listeners) into a documented, reusable kernel primitive. |
|
||||
| One generalized "3-tier BYOK-or-org-or-site-admin credential resolver" instead of two hand-copied implementations | AI and Discogs credentials both implement the same per-user/per-org/env precedence with an org-lock flag — currently two separate resolver classes | LOW-MEDIUM | Named above as a table-stakes pattern to generalize; the differentiator is doing it once, generically, in the framework/plugin-support layer rather than per-integration. |
|
||||
| Schema-driven admin with a native relation-manager and no `partial`-type escape hatch | PocketBase's collection-schema-driven admin (best-fit reference per `go-ecosystem.md`) and Directus's field/relation model both avoid an arbitrary-template-fragment field type; Płytarium's one use of `partial` (Collections' editors tab) is really "show the relation manager in a tab," which a first-class relation-manager schema type can express without an escape hatch. | MEDIUM | Removes the one field type that resists clean JSON-schema generation for the admin SPA + OpenAPI pipeline. |
|
||||
| Policy-based, row/field-level backend permissions (Directus-style) instead of role-string permission points only | Płytarium's admin permissions are coarse role gates (`UserRole::CODE_DEVELOPER` on every permission) — fine for a household app, but Directus's free-tier field/row-level policy engine is the 2026 bar for "schema-driven admin done well." | HIGH | Worth flagging as a v1.x differentiator, not v1 — Płytarium itself doesn't need it (single-admin household app), so building it now would be scope creep against the "table stakes = what Płytarium needs" rule; note it here so the next port (which may need finer admin RBAC) isn't surprised. |
|
||||
| `go vet`/`go test` as a correctness net over the entire plugin-extension surface | WinterCMS's model behaviors, event listeners, and guard registration are invisible to any static tool; a wrong event name or a missing `class_exists` guard is a runtime failure discovered by a human or a test run. Compiled Go interfaces make most of this a build-time or `go vet` failure. | LOW | Already named in `go-ecosystem.md`'s plugin-architecture section as the core argument for compile-time registration; restated here as a direct product differentiator for the *next* plugin author, which is the milestone's explicit audience. |
|
||||
| Single static binary deploy vs. PHP + Composer + opcache + queue worker processes | Simplifies the ops story for a project the size of Płytarium (household app) or the next port target | LOW | Already a given of the Go choice; mentioned for completeness against WinterCMS's deployment footprint. |
|
||||
|
||||
### Anti-Features (WinterCMS subsystems to deliberately NOT port in v1)
|
||||
|
||||
| Anti-Feature | Why It Exists in WinterCMS | Why Problematic to Port Now | Alternative |
|
||||
|--------------|----------------------------|------------------------------|-------------|
|
||||
| Server-rendered theme engine (Twig-like `.htm` partials/layouts/pages, component-in-template system) | WinterCMS's original identity is a CMS with themeable front-end pages | Płytarium is 100% headless — its one `_editors.htm` partial is an admin-form escape hatch, not a public theme. Building a template engine to port zero real usage is pure scope creep. | Keep the admin SPA schema-driven (form/list/relation-manager JSON); defer theming entirely to the keios.eu port (per PROJECT.md's Out of Scope) |
|
||||
| Runtime plugin autoloading / marketplace-style hot install | WinterCMS plugins can be dropped into `plugins/` and picked up without a rebuild | Already rejected in `why-go-not-scala.md` and PROJECT.md's constraints; the `class_exists`/`elevated` fragility documented above is partly a *consequence* of runtime flexibility WinterCMS has to support that a compiled system doesn't need | Compiled, build-time plugin registration (Caddy/xcaddy model), already the chosen design |
|
||||
| Backend AJAX framework (`data-request` attributes, partial-refresh AJAX handlers like `index_onDelete` triggered via a generic AJAX dispatcher) | WinterCMS's whole backend UI is built on server-rendered forms + an AJAX framework that partially re-renders `.htm` fragments | The new admin SPA is a Vue 3 + TypeScript app talking to a JSON API (OpenAPI-typed) — porting the AJAX-handler dispatch mechanism itself (as opposed to the *capability* it provides, e.g. bulk delete) would rebuild a rejected UI paradigm | Bulk actions, quick actions etc. become ordinary REST endpoints the SPA calls directly |
|
||||
| WinterCMS's full Twig/markdown/mail-template rendering pipeline as a generic templating subsystem | Needed for theme pages and rich mail bodies with conditionals/loops | v1's mail needs (invitation, wishlist digest/purchase) are a handful of fixed templates per locale — a generic Twig-equivalent templating engine is more than what's used | `html/template` (stdlib) is sufficient per PROJECT.md's constraints; add a richer engine only if a later port needs it |
|
||||
| Nested-set / tree models (`NestedTree` behavior), Revisionable/audit-trail behavior | Common WinterCMS behaviors for hierarchical taxonomies and change history | **Not used anywhere in Płytarium** (confirmed: no `NestedTree`/`Revisionable` usage in the plugin) — Genre/Style are flat lookup tables | Skip entirely for v1; revisit only if a later port's domain needs a real tree or audit trail |
|
||||
| `Sortable` model behavior | Common WinterCMS behavior for drag-reorder lists | **Not used anywhere in Płytarium's models** — the only "ordering" in play is a `sort_order` pivot column on two belongsToMany relations and an `'order' => 'name'` static sort, neither of which needs a full drag-reorder behavior | A plain integer pivot column + `ORDER BY`, no behavior needed |
|
||||
| `Sluggable` model behavior (as a packaged behavior) | Common WinterCMS/Laravel-ecosystem behavior for auto-slug-from-name | **Not used as a behavior anywhere in Płytarium** — every slug is hand-rolled in `beforeValidate()`, including one bespoke padding algorithm for short strings that a generic Sluggable couldn't express anyway | Keep model lifecycle hooks (table stakes above) as the mechanism; don't build a packaged Sluggable behavior for v1 — nothing in scope needs it and Płytarium's own slug logic wouldn't use it if it existed |
|
||||
| MySQL / multi-database abstraction | WinterCMS supports MySQL, Postgres, SQLite | Already rejected in PROJECT.md ("Postgres only in v1... one migration target keeps the port simple") | Postgres only |
|
||||
| Payments, chat/forum/video subsystems, WASM extension API | Other WinterCMS stack plugins / seeds for later projects | Explicitly out of scope per PROJECT.md, not used by Płytarium at all | Deferred to named later seeds (keios.eu, wavepath.org, wasm-extension-api.md) |
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
Plugin descriptor (register/boot, elevated-equivalent)
|
||||
└──requires──> Compiled plugin registry (build-time import list)
|
||||
|
||||
Cross-plugin event bus (fire-and-collect + fire-and-forget)
|
||||
└──requires──> ORM model lifecycle events (created/updated/deleting)
|
||||
└──enables───> Auth guard registration extension point (inv-token, inv.scope)
|
||||
└──enables───> getApiArray-style payload extension (org fields on User)
|
||||
└──enables───> Notification listeners (Album created -> NotificationService)
|
||||
|
||||
fields.yaml/columns.yaml -> JSON schema pipeline
|
||||
└──requires──> Custom field-type registry (text, textarea, checkbox, switch,
|
||||
dropdown-with-callback, relation, datetime column, partial-or-replacement)
|
||||
└──requires──> $fillable/$hidden serialization discipline (security boundary)
|
||||
└──enables───> Admin SPA rendering (forms + lists)
|
||||
└──enables───> RelationController-equivalent (link/unlink UI)
|
||||
└──requires──> Pivot model with business columns (CollectionEditor)
|
||||
|
||||
Three parallel auth groups (JWT / personal-token / public)
|
||||
└──requires──> Named middleware pipeline + per-route rate-limit buckets
|
||||
└──requires──> Same-handler-different-route-subset routing capability
|
||||
└──enables───> OAuth2.1 authorization server (auth code + PKCE + DCR)
|
||||
└──enables───> MCP server / ChatGPT connector integration
|
||||
|
||||
Polymorphic file attachment table (attachOne/attachMany)
|
||||
└──enables───> Cover photo upload, Collection image, CSV-adjacent file flows
|
||||
|
||||
Queued jobs (River) + JobManager status contract
|
||||
└──enables───> CSV import pipeline (multi-step, resumable, reviewable)
|
||||
└──enables───> Discogs match pass (rate-limited, self-redispatching)
|
||||
└──enables───> Wishlist digest (debounce/coalesce pattern)
|
||||
|
||||
Realtime channel-authorizer registry (WebSockets plugin)
|
||||
└──requires──> Cross-plugin event/registry extension point (same primitive as above)
|
||||
└──enables───> Collection/Wishlist live-patch channels
|
||||
└──conflicts──> Naive single-channel-per-model design (two independent channels:
|
||||
WS live-patch vs. durable notification registry, different throttling)
|
||||
|
||||
3-tier credential resolver (env -> org BYOK -> user BYOK, org-lock flag)
|
||||
└──requires──> Encrypted-at-rest column cast + $hidden serialization
|
||||
└──enables───> Discogs import, AI cover recognition (both instances of the same pattern)
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **Admin SPA rendering requires the fields.yaml/columns.yaml pipeline, which requires the field-type registry and the serialization security boundary** — these three cannot be phased independently; a phase that ships "forms" without also nailing down `$fillable`/`$hidden` semantics will under-build the security contract Płytarium actually depends on (several models exclude specific fields from `$fillable` for named security reasons, e.g. `public_token`, `kind`).
|
||||
- **The OAuth2.1 server depends on the personal-token auth group existing first** conceptually (both are "not the SPA's JWT" auth strategies sharing the routing pattern of same-handler/different-middleware), but they are two distinct credential types in Płytarium's actual code (`ApiToken` vs. `OAuthClient`/`OAuthRefreshToken`) — build the routing/middleware abstraction once, then implement both guards against it, rather than building one and retrofitting the other.
|
||||
- **Realtime authorizer registry and the cross-plugin "fire and collect" event bus are the same underlying kernel primitive** (a plugin registers a handler into another plugin's registry/hook point) — the `AuthorizerRegistry::register()` pattern and the `Event::listen('golem15.user.getApiArray', ...)` pattern should converge on one Go design, not two.
|
||||
- **CSV import conflicts with a "keep it simple" queue design**: it needs delayed self-redispatch on rate-limit (not just retry-with-backoff-then-fail) and a stateful multi-step session model (`CsvImport`/`CsvImportRow`) — plan for this complexity explicitly rather than assuming River's defaults are enough out of the box.
|
||||
- **The generalized 3-tier credential resolver enhances but does not block** the Discogs and AI integrations — v1 could ship two copies (matching the PHP source exactly) and generalize later; flagged as a differentiator, not a blocking dependency.
|
||||
|
||||
## MVP Definition
|
||||
|
||||
### Launch With (v1 — Płytarium parity)
|
||||
|
||||
Everything in Table Stakes above, prioritized by what blocks the Nuxt app / MCP server from working at all:
|
||||
|
||||
- [ ] Plugin descriptor + compiled registry + cross-plugin event bus — nothing else can be built without this
|
||||
- [ ] GORM models with all relation types, `$jsonable`, `$fillable`/`$hidden`/`encrypted` cast discipline, lifecycle hooks, cascading soft-delete — the data layer every endpoint touches
|
||||
- [ ] Three-auth-group routing (JWT, personal-token, public) with per-bucket rate limiting — the entire API surface sits behind this
|
||||
- [ ] OAuth2.1 server (zitadel/oidc) — MCP server and ChatGPT connector are dead without it
|
||||
- [ ] All ~160 routes across the 5 domain resources (collections, albums, wishlist, sharing, notifications, tokens, credentials, CSV import/export, onboarding) — this *is* the acceptance test
|
||||
- [ ] fields.yaml/columns.yaml pipeline + relation-manager schema type, for the 5 admin controllers
|
||||
- [ ] River queue with CSV import (multi-step) and wishlist digest (debounce) jobs
|
||||
- [ ] Centrifugo publisher + channel-authorizer registry + token issuing
|
||||
- [ ] Typesense sync with tenant-scoped (`collection_id`) filtering
|
||||
- [ ] Polymorphic file attachment layer via `gocloud.dev/blob`
|
||||
- [ ] Discogs + AI (Anthropic/OpenAI) clients with BYOK credential resolution
|
||||
- [ ] i18n (pl/en) with namespaced keys and per-locale mail templates
|
||||
|
||||
### Add After Validation (v1.x)
|
||||
|
||||
- [ ] Generalize the 3-tier credential resolver into one reusable framework/plugin-support piece (currently two hand-copied instances in the PHP source)
|
||||
- [ ] Replace the `partial` field-type escape hatch with a first-class relation-manager-in-tab schema, if a later port needs more than Płytarium's one instance
|
||||
- [ ] Policy-based, row/field-level backend permissions (Directus-style), if a later port's admin needs finer-grained RBAC than Płytarium's single-role-gate model
|
||||
|
||||
### Future Consideration (v2+)
|
||||
|
||||
- [ ] Server-rendered theme engine (Twig-like), component-in-template system — only for the keios.eu port
|
||||
- [ ] Payments — only for the keios.eu port
|
||||
- [ ] Sortable/NestedTree/Revisionable model behaviors — build only when a real domain plugin needs them; nothing in Płytarium does
|
||||
- [ ] WASM sandboxed extension API — deferred per existing seed, needs a stable compiled-plugin API for a full milestone first
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | User Value (parity impact) | Implementation Cost | Priority |
|
||||
|---------|------------------------------|----------------------|----------|
|
||||
| Cross-plugin event bus (fire-and-collect + fire-and-forget) | HIGH | MEDIUM | P1 |
|
||||
| GORM relation/pivot/cast/hook fidelity | HIGH | HIGH | P1 |
|
||||
| Three-auth-group routing + rate limiting | HIGH | HIGH | P1 |
|
||||
| OAuth2.1 server (zitadel/oidc) | HIGH | HIGH | P1 |
|
||||
| fields.yaml/columns.yaml pipeline + relation manager | HIGH | HIGH | P1 |
|
||||
| Polymorphic file attachments | HIGH | HIGH | P1 |
|
||||
| River jobs incl. CSV import/digest patterns | HIGH | HIGH | P1 |
|
||||
| Centrifugo publisher + authorizer registry | MEDIUM | MEDIUM | P1 |
|
||||
| Typesense tenant-scoped sync | MEDIUM | MEDIUM | P1 |
|
||||
| Discogs/AI clients + BYOK credentials | MEDIUM | MEDIUM | P1 |
|
||||
| i18n + locale-suffixed mail templates | MEDIUM | LOW | P1 |
|
||||
| Generalized 3-tier credential resolver | LOW (parity-neutral) | LOW | P2 |
|
||||
| Relation-manager as first-class schema (replace `partial`) | LOW (parity-neutral) | MEDIUM | P2 |
|
||||
| Policy-based row/field-level admin permissions | LOW (not needed by Płytarium) | HIGH | P3 |
|
||||
| Theme engine, payments | NONE for v1 | HIGH | P3 (later milestone) |
|
||||
| Sortable/NestedTree/Revisionable behaviors | NONE (unused) | MEDIUM | P3 (build on demand) |
|
||||
|
||||
## Competitor / Reference Feature Analysis
|
||||
|
||||
| Feature | PocketBase | Directus | Strapi | Goravel | SummerCMS Approach |
|
||||
|---------|------------|----------|--------|---------|---------------------|
|
||||
| Schema source of truth | Collection schema owned by PocketBase itself (SQLite) | Introspects an existing SQL schema (database-first) | Schema as code (JSON files in the Strapi project) | No CMS-schema concept — it's a Laravel-shaped app framework, not a CMS | Model-first (GORM structs + migrations), same as Płytarium's PHP source — closest to Strapi's "schema as code," not Directus's introspection |
|
||||
| Admin UI | Built-in, auto-generated from collection schema; hooks + goja (JS) plugin VM | Vue "Data Studio": collections/records, custom layouts (kanban/calendar/map), Flows automation | React admin, content-type builder, RBAC behind paid tiers for advanced rules | None — bring your own frontend | Minimal Vue 3 + TS SPA for 5 controllers' forms/lists/relation-manager only — deliberately not a general-purpose content-type builder |
|
||||
| Permissions | Basic per-collection rules (SQL-like expressions) | Free-tier policy-based, field/row-level per role | Basic RBAC free; granular rules paid | Framework-level Auth facade, no admin-specific RBAC | Role-gated permission strings + backend/frontend RBAC split, matching Płytarium's actual (coarse) needs; row/field-level policies flagged as a v1.x differentiator, not v1 |
|
||||
| Realtime | Built-in SSE/realtime subscriptions per collection | Not a core feature (via Flows/webhooks) | Not a core feature | None built-in | Centrifugo (existing infra) + a channel-authorizer registry extension point, not a built-in pub/sub — deliberately keeps the existing WS server rather than replacing it |
|
||||
| Plugin/extensibility model | JS VM (goja) hooks, single Go binary | Extensions (interfaces, hooks, endpoints, modules) as separate npm packages | Plugins as npm packages + lifecycle hooks | Facades + service providers (Laravel-style DI) | Compiled Go modules registered at build time — closer to Goravel's DI-facade shape than PocketBase's embedded-JS-VM or Directus/Strapi's npm-package model, chosen specifically because Płytarium's plugins need typed, compiled cross-plugin extension (event bus, guard registration, registries), which an embedded scripting VM or loosely-typed npm plugin can't give the same static-checking guarantees for |
|
||||
| Best-fit takeaway | Best reference for *collection-schema-driven admin rendering* (forms/lists from schema) | Best reference for *field/row-level permission policy* design (v1.x differentiator) | Best reference for *schema-as-code* (matches SummerCMS's model-first approach) | Best reference for *Laravel-shaped facade/DI ergonomics* in Go (reference only, not a base) | — |
|
||||
|
||||
## Sources
|
||||
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/Plugin.php` — plugin lifecycle, event listeners, permissions, navigation, settings, mail templates, scheduling (HIGH confidence, primary source)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` (569 lines, ~160 routes) — three auth groups, rate-limit buckets, OAuth2.1 endpoints (HIGH confidence, primary source)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/{Albums,Artists,Collections,Genres,Styles}.php` and their `config_form.yaml`/`config_list.yaml`/`config_relation.yaml` (HIGH confidence, primary source)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/*.php` (25 models) and `models/{album,artist,collection,genre,style,settings}/{fields,columns}.yaml` (HIGH confidence, primary source)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/jobs/{AlbumCsvImportJob,AlbumCsvMatchJob,WishlistDigestJob}.php`, `console/{IssueOAuthClient,PruneNotifications,ReindexAlbums}.php`, `config/fonoteka.php` (HIGH confidence, primary source)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/` (models/User.php, guards, JWT service provider) and `/media/nvme/dev/golem15/fonoteka/plugins/golem15/websockets/` (classes: AuthorizerRegistry, CentrifugoBroadcaster, CentrifugoClient, JwtTokenGenerator) — skimmed (MEDIUM-HIGH confidence)
|
||||
- `/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app/app/composables/useCentrifugo.ts` and `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/client.ts` — skimmed for client-side contract confirmation (MEDIUM confidence)
|
||||
- `.planning/PROJECT.md`, `.planning/notes/v1-target-plytarium.md`, `.planning/research/go-ecosystem.md` (this repo) — prior decisions and ecosystem picks (HIGH confidence, internal)
|
||||
- WebSearch: Directus vs Strapi vs PocketBase 2026 comparisons (MEDIUM confidence, cross-checked against training knowledge) — https://directus.com/strapi , https://unfoldcms.com/blog/strapi-vs-directus-2026 , https://elmapicms.com/blog/payload-strapi-directus-which-one-2026
|
||||
- WebSearch: Goravel framework facades/ORM/queue 2026 (MEDIUM confidence) — https://docs.goravel.dev/ , https://pkg.go.dev/github.com/goravel/framework/facades , https://www.goravel.dev/architecutre-concepts/facades.html
|
||||
|
||||
---
|
||||
*Feature research for: WinterCMS/Laravel-shaped CMS framework in Go, v1 = Płytarium headless backend port*
|
||||
*Researched: 2026-09-16*
|
||||
421
.planning/research/PITFALLS.md
Normal file
421
.planning/research/PITFALLS.md
Normal file
@@ -0,0 +1,421 @@
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** Go rewrite of a WinterCMS/Laravel-shaped CMF, porting a live PHP headless API (Płytarium/fonoteka) to Go with byte-level response parity for an existing Nuxt app and MCP/OAuth client.
|
||||
**Researched:** 2026-09-16
|
||||
**Confidence:** HIGH for anything cited to actual Płytarium source (routes.php, controllers/api/*.php, models/Album.php, traits/SerializesFonoteka.php, fonoteka-mcp/src/http.ts) or to official GORM/River docs; MEDIUM for Go-ecosystem-general claims verified against GitHub issues/docs; MEDIUM for the "Laravel-shaped framework in Go" class, informed by the project's own `why-go-not-scala.md` post-mortem and the Caddy/xcaddy precedent in `go-ecosystem.md`.
|
||||
|
||||
This report groups pitfalls into two classes named in the brief: (A) building a Laravel-shaped framework in Go, and (B) porting the PHP API to Go with byte-for-byte parity. Class B pitfalls are cited to specific lines/patterns in the Płytarium source where possible, since that codebase already encodes the exact contract the Go port must reproduce.
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: Building plugin/kernel infrastructure bottom-up again
|
||||
|
||||
**What goes wrong:**
|
||||
The team designs a general plugin SDK, event bus, config layering, and i18n system to elegant completion before any of the 25 Płytarium models or 154 routes exercise them, discovers the abstractions don't fit the app's actual needs, and stalls or rewrites.
|
||||
|
||||
**Why it happens:**
|
||||
This is documented, not hypothetical: `why-go-not-scala.md` records that the Scala attempt "stalled before any application ran... building Illuminate-shaped infrastructure bottom-up with no real app pulling requirements." The temptation recurs in Go because the kernel phase (config, plugin registration, event bus, i18n, command framework) is listed first in `PROJECT.md`'s Active requirements and reads like a framework project rather than an application port.
|
||||
|
||||
**How to avoid:**
|
||||
Treat every kernel abstraction as provisional until a real Płytarium model/route/job needs it. Sequence phases so the data layer and HTTP/auth phases start porting concrete Płytarium models and routes as early as possible, even against a minimal kernel, and let the kernel's final shape be discovered from what the port actually calls. Do not let the plugin descriptor format, event bus generics, or config schema get a "v1.0" design pass before at least one full vertical slice (one model, one route, one migration, one test) exists end to end.
|
||||
|
||||
**Warning signs:** Kernel-phase plans grow in scope without a corresponding Płytarium model/route landing; PRs touch `compass`/`phrasebook`/`bonfire`-equivalent packages for multiple consecutive phases with no plugin actually registered; the plugin descriptor changes shape more than once before plugin #1 (fonoteka) exists.
|
||||
|
||||
**Phase to address:** Kernel phase, but bounded — roadmap should interleave kernel work with the first data-layer/route slice rather than sequencing "finish the framework, then port the app."
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: Package-level global state where WinterCMS used per-request context
|
||||
|
||||
**What goes wrong:**
|
||||
Płytarium's `ActiveCollectionResolver`, `MeContextController`, and the org/tenant-scoping pattern all resolve a request-scoped "active collection" from the authenticated user. A Go port that caches this in a package-level variable, a singleton service, or a mutable struct field on a shared controller instance (instead of `context.Context` or a per-request value) will leak state across goroutines under concurrent requests — each request in Go runs on its own goroutine, unlike PHP-FPM's one-process-per-request isolation, which silently protected this kind of mistake for years.
|
||||
|
||||
**Why it happens:**
|
||||
Porting `app(ActiveCollectionResolver::class)` (a per-request-resolved singleton in Laravel's container) literally suggests "a Go singleton with a resolve method," which is safe in PHP-FPM but not in Go's shared-process, concurrent-request model.
|
||||
|
||||
**How to avoid:**
|
||||
Thread the active-collection/tenant resolution result through `context.Context` (or explicit function parameters) from the HTTP middleware layer down, never through package-level or struct-level mutable fields shared across requests. Any "resolver" type must be stateless (safe for concurrent use) and return a value per call, not cache it on itself.
|
||||
|
||||
**Warning signs:** A struct field that is set in one method and read in another on the same receiver, where that receiver is registered once (e.g., at plugin init) rather than constructed per request; `go test -race` failures or flaky tests under `-race` involving user/collection context; data appearing to leak between concurrent requests in manual testing.
|
||||
|
||||
**Phase to address:** Kernel phase (establish the context-passing convention before any plugin uses it) and HTTP/auth phase (org/collection context middleware) — both must agree on one mechanism before the API-port phase starts consuming it.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: Reusing GORM model structs as JSON request-binding structs (losing $fillable/$guarded)
|
||||
|
||||
**What goes wrong:**
|
||||
`Album` in PHP declares `protected $fillable = [...]` (an explicit allow-list of mass-assignable columns) and `protected $guarded = ['*']` as a backstop. This is what stops a client from setting `market_price_source`, `collection_id`, or any other server-owned field via a crafted request body. Go structs have no equivalent concept: if the same struct is used both for `json.Unmarshal(requestBody)` and as the GORM model persisted to the DB, every exported field becomes writable by any caller who knows its JSON tag — a mass-assignment vulnerability the PHP code explicitly guards against (see the `market_price_source` comment: "Server-owned — see D-3, this is NOT in AlbumWriteService::FILL_FIELDS or the PUT /albums validator").
|
||||
|
||||
**Why it happens:**
|
||||
It looks like less code to decode straight into the persistence model, especially when porting line-by-line from a PHP model that conflates "the model" and "the fillable shape" via one class.
|
||||
|
||||
**How to avoid:**
|
||||
Define separate request DTOs per write endpoint (create/update) distinct from the GORM model, validated with `go-playground/validator`, then explicitly copy allow-listed fields onto the model (mirroring `AlbumWriteService::FILL_FIELDS` from the PHP source, not the full model). Never `json.Unmarshal` directly into a GORM-tagged struct for write endpoints.
|
||||
|
||||
**Warning signs:** A single struct type used for both `db:"..."`/GORM tags and incoming request binding; a new server-computed field (like `market_price_source`) added to the model without a corresponding audit of every write endpoint's DTO.
|
||||
|
||||
**Phase to address:** Data layer (establish the DTO-vs-model convention with the first model) and API-port phase (apply it to all 25 models, cross-checked against each PHP `*Rules()`/`FILL_FIELDS` list).
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 4: Nil slice encodes as `null`, PHP's guarded array always encodes as `[]`
|
||||
|
||||
**What goes wrong:**
|
||||
`Album::$jsonable = ['tracklist', 'cover_import_failures']`, and `SerializesFonoteka::serializeAlbum()` explicitly guards both: `'tracklist' => is_array($album->tracklist) ? $album->tracklist : []` and the same pattern for `cover_import_failures`. The PHP code goes out of its way to guarantee these keys are *always* a JSON array, never `null`. Go's `encoding/json` marshals a nil slice as `null`, not `[]`. A direct field-for-field port (`Tracklist []Track `json:"tracklist"``) without an explicit non-nil default will silently emit `"tracklist": null` whenever the value hasn't been set, breaking any Nuxt/MCP client code that does `.map()` or `.length` on that field without a null check it never needed against the PHP backend.
|
||||
|
||||
**Why it happens:**
|
||||
Go's `nil`-slice-marshals-to-`null` behavior is easy to forget because it is "correct JSON" and passes casual review; the byte-diff only shows up against a live parity harness or a client crash.
|
||||
|
||||
**How to avoid:**
|
||||
For every field the PHP serializer explicitly defaults to `[]` (audit `SerializesFonoteka.php` field-by-field, not just Album), initialize the Go slice to `[]T{}` at construction/hydration time, or give the response type a custom `MarshalJSON` that normalizes nil to `[]`. Cover this with a parity-harness assertion, not developer memory, since there will be dozens of such fields across 25 models.
|
||||
|
||||
**Warning signs:** A JSON diff between PHP and Go responses showing `null` where PHP has `[]`; client-side `.map is not a function` errors in the Nuxt app or MCP server after a Go cutover.
|
||||
|
||||
**Phase to address:** API-port phase, verified by the parity harness (this is exactly the class of bug the parity suite exists to catch — make it an explicit assertion category, not just "responses match," because a naive deep-equal that treats `null` and `[]` as different will catch it, but a lenient comparator might not).
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: Money field ported as float64 instead of the fixed-precision string PHP deliberately uses
|
||||
|
||||
**What goes wrong:**
|
||||
`Album::$casts['market_price_stored'] = MarketPriceCast::class`, and the docblock is explicit: this is a hand-written cast that keeps the value "a stable 4-decimal string, never a float," specifically *because* Laravel's built-in `decimal:4` cast "crashes Eloquent's own internal dirty-check on a blank string." The wire format is `"market_price_stored": "129.5000"` — a JSON string, always 4 decimals, never a bare number. A Go port using `float64` for this column (the "obvious" numeric mapping) will (a) produce `129.5` instead of `"129.5000"` on the wire — a type AND format mismatch the Nuxt client's price parser doesn't expect — and (b) reintroduce classic float-precision bugs (`999999.9999` ceiling checks, arithmetic) that the PHP code deliberately avoided by never treating this as a float.
|
||||
|
||||
**Why it happens:**
|
||||
"Price" reads as a number, and Go's ecosystem defaults (encoding/json, most ORMs) map SQL `decimal` to `float64` unless a decimal type is deliberately chosen.
|
||||
|
||||
**How to avoid:**
|
||||
Model `market_price_stored` (and any other DECIMAL column with a similar contract) as a string type end-to-end in Go — store/format with fixed 4-decimal precision (e.g. `shopspring/decimal` internally, but marshal via a custom type that always renders 4 decimals as a JSON string) — never `float64`. Write a unit test that round-trips the exact `999999.9999` ceiling and a "clear the price" blank-string case, matching the PHP comment's stated reason for the custom cast.
|
||||
|
||||
**Warning signs:** Any model field typed `float64`/`float32` that maps to a PHP `decimal` column with a custom cast comment in the source; JSON diffs showing a bare number where PHP emits a quoted string.
|
||||
|
||||
**Phase to address:** Data layer (choose the Go type for every DECIMAL column against its PHP cast, not just its SQL type) and API-port phase (serialization).
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: `time.Time` JSON marshaling doesn't match Carbon's `toIso8601String()`, and nullable dates must emit `null`, never be omitted
|
||||
|
||||
**What goes wrong:**
|
||||
Every timestamp in `SerializesFonoteka` goes through `$x?->toIso8601String()`, which is Carbon's ISO-8601 format with a colon-separated UTC offset: `2026-09-16T12:34:56+00:00`. Go's default `time.Time.MarshalJSON` emits RFC 3339 with a `Z` suffix for UTC: `2026-09-16T12:34:56Z`. These are both valid ISO-8601 but are different byte sequences — a strict client-side date parser or a byte-diff parity check will flag this as a mismatch even though "the date is the same." Separately, `$x?->toIso8601String()` on a null Carbon instance evaluates to `null`, and that key is still present in the array (PHP's `null` serializes to a JSON `null`, not an absent key) — a Go `*time.Time` field tagged `json:"...,omitempty"` will *omit the key entirely* when nil, which is a different contract (`null` present vs. key absent) that the SPA/MCP client's TypeScript types were generated against.
|
||||
|
||||
**Why it happens:**
|
||||
Both mismatches look like "the same information" to a reviewer and only fail in exact string/shape comparison, not in casual manual testing.
|
||||
|
||||
**How to avoid:**
|
||||
Write a custom JSON marshaling wrapper for timestamps that renders `+00:00` (or the stored offset) instead of `Z`, matching Carbon's format exactly. For nullable date fields, do not use `omitempty` on a pointer type if the PHP contract always includes the key with `null` — decide field-by-field (per `SerializesFonoteka.php`) whether a null is emitted or the key is dropped (see Pitfall 8 for the general "key presence" issue), and encode that decision explicitly rather than relying on struct tag defaults.
|
||||
|
||||
**Warning signs:** Parity-harness diffs showing `Z` vs `+00:00` on every single timestamp field (this will be a very loud, very total failure the first time the harness runs against real ported endpoints); a missing key in a Go response where the PHP response has the key with value `null`.
|
||||
|
||||
**Phase to address:** API-port phase; should be solved once as a shared response-time-formatting helper before the 154-route port starts, not per-endpoint.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 7: Go's zero-value bool can't represent PHP's tri-state null/true/false fields
|
||||
|
||||
**What goes wrong:**
|
||||
`serializeCollection()` computes `'is_owner' => $userId !== null ? ((int) $collection->owner_id === (int) $userId) : null` and `'is_active' => $activeCollectionId === null ? null : (...)`, with an explicit comment that "null means this response did not compute it." This is a genuine three-state field: `true`, `false`, or `null`-meaning-not-computed, and different endpoints (index vs. show/create/update) deliberately choose different states. A Go `bool` field has exactly two states; if the port uses a plain `bool`, "not computed" and "false" become indistinguishable, and any endpoint that intentionally passes `null` here will instead emit `false`, changing client behavior (e.g. an SPA badge that should be hidden when unknown will instead show "not owner").
|
||||
|
||||
**Why it happens:**
|
||||
Direct field-by-field translation of PHP into Go without noticing the ternary's third branch, because Go's type system doesn't force you to represent "unknown" unless you deliberately reach for `*bool` or a small enum.
|
||||
|
||||
**How to avoid:**
|
||||
Audit every field in `SerializesFonoteka.php` (and its siblings for other models) for ternaries that can produce `null` alongside `true`/`false`, and model those as `*bool` (nil = not computed) with an explicit "always include the key, possibly as JSON `null`" marshaling contract, per Pitfall 6's key-presence rule.
|
||||
|
||||
**Warning signs:** A PHP serializer method with a ternary whose false branch is `null` rather than `false`; a Go field typed plain `bool` backing such a method.
|
||||
|
||||
**Phase to address:** API-port phase, same pass as Pitfall 6.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 8: Response envelope, conditional key inclusion, and pagination/error shapes drift from the PHP house style
|
||||
|
||||
**What goes wrong:**
|
||||
Three shapes are load-bearing and inconsistent with each other by design, which is exactly the kind of nuance a generic Go serialization approach will flatten:
|
||||
- List endpoints wrap in `{"data": [...], "meta": {"current_page","last_page","per_page","total"}}` (`AlbumApiController::paginated()`) — no `links` key, unlike Laravel's own default paginator output. A Go port that uses a "standard" pager envelope (e.g. adding `links`, or using `page`/`limit`/`count` naming) breaks every list endpoint at once.
|
||||
- Validation failures are `{"error": "Validation failed", "errors": {field: [messages, ...]}}` at HTTP 422 (`validationFailure()`), where `errors` is a map of field name to an array of message strings, even for a single message — this is Laravel's `$validator->errors()` shape verbatim. go-playground/validator's native error type is structurally different (a slice of `FieldError` objects with methods, not a map of strings) and needs a translation layer to reproduce this exact map shape, including array-field paths (`tracklist.*.artist` → concrete indices like `tracklist.0.artist` in the actual errors map).
|
||||
- A field like `reservation` in `serializeAlbum()` is **omitted from the payload entirely** when `$reservationContext === null`, not present-as-null — "this keeps every existing caller's payload byte-identical without touching a single call site," per the source comment. This is a per-endpoint, conditional key-presence contract that a single shared Go response struct with `omitempty` cannot express correctly in general (`omitempty` on a struct/slice/map has its own zero-value quirks, and doesn't distinguish "caller opted out of this field" from "the field is empty").
|
||||
|
||||
**Why it happens:**
|
||||
Go encourages "define one response struct, marshal it" as the idiomatic pattern, but the PHP house style was built by hand, field by field, with different envelope and inclusion rules per endpoint family (paginated list vs. single resource vs. OAuth/RFC endpoints — see Pitfall 9) and no single shared struct could express all of it without per-endpoint escape hatches.
|
||||
|
||||
**How to avoid:**
|
||||
Do not build one generic "API response" Go type used everywhere. Build small, explicit per-endpoint response constructors (functions returning `map[string]any` or endpoint-specific structs assembled conditionally) that mirror each PHP controller's own `paginated()`/`validationFailure()`/serializer method, and drive the parity harness off actual recorded Nuxt/MCP requests (per `CLAUDE.md`'s acceptance rule) rather than a hand-designed "clean" envelope. Build the validator-error-map translator once, early, as a dedicated shared piece, and unit-test it against Laravel's exact array-index-in-dotted-path convention for nested/array fields.
|
||||
|
||||
**Warning signs:** A shared `APIResponse[T]` generic wrapper appearing in the plan for the API-port phase before any endpoint is ported — treat this as a smell, not a shortcut. A parity-harness diff showing an extra `links` key, a `count`/`limit` pagination key, or a `null` where a key should be absent.
|
||||
|
||||
**Phase to address:** API-port phase, and this is precisely what the parity-harness phase should be built to catch mechanically — plan the harness to diff full response bodies key-by-key (not just status code / rough shape) from day one.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 9: A global response-envelope or auth middleware wraps RFC-compliant OAuth endpoints
|
||||
|
||||
**What goes wrong:**
|
||||
`OAuthMetadataController::show()` (RFC 8414) emits its JSON **at the document root**, with a PHP comment stating outright: "NOT wrapped in the house `{"data": …}` envelope. The missing envelope is deliberate and required by RFC 8414; a reviewer must not 'fix' it back onto the house shape." `OAuthTokenController::token()` and its `rfcError()` helper emit bare `{"error": "invalid_grant"}` / `{"access_token": ..., "token_type": "Bearer", ...}` shapes per RFC 6749, again unenveloped, and add `Cache-Control: no-store` / `Pragma: no-cache` headers the house JSON responses don't carry. If the Go HTTP layer applies one blanket middleware to wrap all JSON bodies in a house envelope, or one blanket error-formatter for all 4xx responses (a natural thing to build for consistency), it will corrupt every OAuth/metadata endpoint the MCP server and ChatGPT connector depend on — and because these are machine-consumed RFC endpoints, the failure mode is a silent auth breakage for external clients, not something a developer notices browsing the SPA.
|
||||
|
||||
**Why it happens:**
|
||||
"One consistent response envelope" and "one consistent error formatter" are natural Go-framework instincts (middleware composability is a strength of Go's `net/http`), but this domain has at least three incompatible envelope families (house REST, RFC 8414/6749 OAuth, and whatever the realtime/Centrifugo token endpoint uses) that must not be unified.
|
||||
|
||||
**How to avoid:**
|
||||
Explicitly exempt `/oauth/*`, `/.well-known/*`, and any other RFC-mandated endpoint from generic envelope/error middleware at the routing layer — make this a structural routing decision (separate route group with no shared middleware for body-wrapping), not a per-handler opt-out that's easy to forget when a 26th model's routes are added later.
|
||||
|
||||
**Warning signs:** A middleware named something like `wrapResponse`/`standardEnvelope` registered on the top-level router rather than scoped to the house-API route group; any OAuth/metadata endpoint whose test asserts against a wrapped body.
|
||||
|
||||
**Phase to address:** OAuth phase, decided in the same phase that stands up `net/http` routing/middleware conventions (HTTP/auth phase) — the routing structure must separate these families from the start, since retrofitting it after 154 routes exist is expensive.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 10: WWW-Authenticate / Protected Resource Metadata header details are exactly what the MCP client and ChatGPT connector parse
|
||||
|
||||
**What goes wrong:**
|
||||
`fonoteka-mcp/src/http.ts` shows the MCP proxy itself constructing a 401 with `WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="<PRM URL>", scope="read write ai"`, explicitly commented "Claude copies this 401 hint onto [its own response]." Per RFC 9728 and the MCP authorization spec (verified via web search against modelcontextprotocol.io), MCP clients discover the authorization server either from this exact header parameter or from `/.well-known/oauth-protected-resource`. If the Go backend's own 401 responses (for the JWT-guarded and OAuth-protected routes) get this header's parameter names, quoting, or scope list wrong — or omit `resource_metadata` — client-side auto-discovery breaks in a way that surfaces as a generic, hard-to-diagnose "auth failed" in Claude/ChatGPT, not a clear Go-side error.
|
||||
|
||||
**Why it happens:**
|
||||
This header is easy to treat as cosmetic ("just a 401") when in fact its parameters are a discovery protocol consumed by machine clients that don't tolerate deviation the way a human eyeballing an SPA login page would.
|
||||
|
||||
**How to avoid:**
|
||||
Treat the exact header string (parameter set, order is not critical but presence and values are) as part of the API contract to be parity-tested, not just the 401 status code. Write it once as a shared helper used by every protected-route 401 path, derived from the same values `OAuthMetadataController`/PRM endpoint publishes, so they can't drift independently. If `zitadel/oidc` has its own default 401/WWW-Authenticate emission, verify it against this exact contract before trusting its defaults — override if needed.
|
||||
|
||||
**Warning signs:** The MCP server or ChatGPT connector failing to "discover" auth against the Go backend in integration testing while direct `curl` calls to the same endpoint "look right" to a human; a 401 test asserting only `resp.StatusCode == 401` without asserting header contents.
|
||||
|
||||
**Phase to address:** OAuth phase, verified in the same phase's test plan against `fonoteka-mcp`'s actual client behavior (not just against the Go server's own unit tests).
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 11: GORM soft delete + unique constraints silently break "delete and re-add" flows
|
||||
|
||||
**What goes wrong:**
|
||||
Płytarium models use `Winter\Storm\Database\Traits\SoftDelete` (nullable `deleted_at`, global scope excludes soft-deleted rows) alongside real uniqueness constraints — e.g. `AlbumReservation` is explicitly `hasOne` (not `hasMany`) "because `album_id` is UNIQUE on `golem15_fonoteka_album_reservations`." GORM's soft delete (`gorm.DeletedAt`) works the same way at the ORM level, but Postgres unique indexes/constraints created by a naive migration (`UNIQUE(album_id)` with no `deleted_at` in the key) do not know about the soft-delete flag: a soft-deleted-then-recreated row (or two soft-deleted rows for the same `album_id`) will violate the constraint at the database level even though GORM's application-level scope would have allowed it. Laravel apps often get this right by convention/experience; a straight schema port to Go migrations without re-deriving "should this be a partial unique index on `deleted_at IS NULL`" per column will regress this per-migration.
|
||||
|
||||
**Why it happens:**
|
||||
The unique constraint is defined once in a migration and rarely revisited; the soft-delete interaction is a property of *how the column is used together with the ORM scope*, which isn't visible by reading the migration alone.
|
||||
|
||||
**How to avoid:**
|
||||
Audit all 27 migrations for unique constraints/indexes on soft-deletable tables, and reproduce Postgres partial unique indexes (`CREATE UNIQUE INDEX ... WHERE deleted_at IS NULL`) wherever the PHP behavior relied on "unique among non-deleted rows." Write a migration-level test that soft-deletes a row and recreates one with the same unique key, asserting it succeeds, for every such table.
|
||||
|
||||
**Warning signs:** A migration with a plain `UNIQUE` constraint on a table that also has `deleted_at`; a GORM insert failing with a unique-violation error in a scenario the PHP app supported (delete-then-re-add).
|
||||
|
||||
**Phase to address:** Data layer.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 12: Naive GORM many-to-many drops pivot/extra columns (ordering breaks silently)
|
||||
|
||||
**What goes wrong:**
|
||||
`Album::$belongsToMany['artists']` carries `'pivot' => ['sort_order']`, and `SerializesFonoteka::serializeAlbum()` explicitly sorts by `$a->pivot->sort_order` before serializing — artist display order on an album is driven by this pivot column. GORM's default `many2many` association (a plain `[]Artist` field with a `gorm:"many2many:..."` tag) does not expose extra pivot columns without explicitly modeling the join table as its own struct and using `SetupJoinTable`. A straightforward "list of associated structs" port will compile, look correct in a demo with one artist per album, and then silently lose custom artist ordering for every multi-artist album (a common case — collaborations, compilations) — a regression that is easy to miss because nothing errors, the order is just wrong.
|
||||
|
||||
**Why it happens:**
|
||||
GORM's simple many2many API is the first thing documentation and examples show; the extra-columns pattern is a secondary, easy-to-miss feature.
|
||||
|
||||
**How to avoid:**
|
||||
For `Album.artists` (and the `styles` many-to-many, and any other WinterCMS `$belongsToMany` with pivot data across the 25 models — audit all of them, not just Album), model the join table as an explicit Go struct with `sort_order` (or other pivot columns) and use `SetupJoinTable`, or query the join rows directly and hydrate ordering in application code. Cover with a test asserting round-trip order for a 3+ artist album.
|
||||
|
||||
**Warning signs:** A `$belongsToMany` PHP relation with a `'pivot' => [...]` key or an `'order' => ...` key that has no corresponding join-table modeling in the Go port.
|
||||
|
||||
**Phase to address:** Data layer.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 13: GORM AutoMigrate used as the migration mechanism instead of real up/down migrations
|
||||
|
||||
**What goes wrong:**
|
||||
The roadmap requirement is explicit: "per-plugin migrations runnable up and down, with a 'drop the last migration and fix it' workflow" across 27 PHP migrations that must be reproduced with matching column types, defaults, and indexes. `AutoMigrate` is additive-only — it creates tables/columns/indexes it doesn't find, but never drops or alters existing columns, and has no concept of "down." Reaching for it as "the" schema mechanism (attractive because it's zero-migration-file DX, very Eloquent-like) cannot satisfy the down-migration requirement and cannot exactly reproduce the 27 originals' constraints (e.g., partial unique indexes from Pitfall 11, decimal precision from Pitfall 5, pivot tables from Pitfall 12).
|
||||
|
||||
**Why it happens:**
|
||||
AutoMigrate is the most Eloquent-like, least-code path in GORM's own docs, and "GORM chosen for Eloquent-like DX" (per `PROJECT.md`'s Key Decisions) can be read as license to lean on it everywhere.
|
||||
|
||||
**How to avoid:**
|
||||
Use goose (per `go-ecosystem.md`'s pick) with explicit up/down SQL or Go migrations, one per PHP migration, and treat GORM strictly as the runtime query/model layer, not the schema-authoring tool. AutoMigrate may be acceptable for ephemeral test databases only, never as the source of truth for the 27-migration port.
|
||||
|
||||
**Warning signs:** A migration directory containing GORM model structs with `AutoMigrate` calls instead of goose migration files; no down-migration path exercised in CI.
|
||||
|
||||
**Phase to address:** Data layer.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 14: River and GORM sharing one Postgres connection pool the wrong way
|
||||
|
||||
**What goes wrong:**
|
||||
River's own documentation (verified via web search, `riverqueue.com/docs/gorm`) states that sharing a pool with GORM requires the `database/sql`-compatible `riverdatabasesql` driver, which runs in **poll-only mode** (no Postgres LISTEN/NOTIFY) because `database/sql` doesn't support LISTEN — jobs are picked up on a polling interval, not instantly. Getting this backwards — assuming one shared pool gives both transactional enqueue *and* low-latency dispatch — either loses the "enqueue the job in the same DB transaction as the business write" guarantee (mirroring the PHP `DB::transaction()` wrapping bulk album creation) if a separate pgx-only client is used everywhere, or accepts polling-latency job pickup everywhere if only the shared/database-sql driver is used for both enqueue and work.
|
||||
|
||||
**Why it happens:**
|
||||
"One pool for the whole app" is the simpler mental model and is what a Laravel-shaped mind expects (Laravel's queue and Eloquent share one DB connection transparently); River's split-driver design isn't obvious until read carefully.
|
||||
|
||||
**How to avoid:**
|
||||
Use `riverdatabasesql` (sharing GORM's `*sql.DB`) specifically for transactional job inserts that must commit atomically with a business write (e.g. CSV import row creation, wishlist digest scheduling tied to a write). Use a separate `riverpgxv5`-backed River client (its own pgx pool) for the actual work-processing client that pulls and executes jobs, to get real LISTEN/NOTIFY dispatch instead of poll-only latency. Do not use a single client/driver for both roles.
|
||||
|
||||
**Warning signs:** Background jobs (CSV import, wishlist digest) taking up to the poll interval to start even under light load; a single River client instance referenced from both the HTTP write path and the worker startup code.
|
||||
|
||||
**Phase to address:** Background/integrations phase (jobs), decided alongside the data-layer phase's choice of GORM's underlying `*sql.DB`/pool setup so the two are compatible from the start.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 15: Typesense treated as a source of truth instead of a pre-filter, reopening an authorization bypass
|
||||
|
||||
**What goes wrong:**
|
||||
`AlbumApiController`'s docblock states the architecture explicitly: "Typesense is only a pre-filter: SQL hydration repeats the same two gates" (the `accessibleBy` scope and the active-collection id). `AlbumSearchService` re-applies both gates in SQL after getting candidate IDs from Typesense, specifically so a stale, mis-scoped, or delayed-to-reindex search document can never leak a result the requesting user isn't authorized to see. A Go port that treats a Typesense hit as already-authorized (e.g., serializing fields straight from the Typesense document, or trusting its scoping without a subsequent SQL re-check) reopens a cross-collection data leak — this is a security regression, not just a parity mismatch, and it's the kind of thing that only shows up when the search index and the DB briefly disagree (exactly when it matters).
|
||||
|
||||
**Why it happens:**
|
||||
Returning data straight from the search engine is faster and is what most Typesense/Elasticsearch tutorials show; the PHP code's extra SQL round-trip looks like unnecessary duplication unless the authorization comment is read.
|
||||
|
||||
**How to avoid:**
|
||||
Preserve the two-step architecture verbatim: Typesense/search engine returns candidate IDs only, and every result is re-fetched and re-scoped through the same GORM query-scoping (`accessibleBy`-equivalent + active-collection filter) used by the non-search list endpoints, before serialization.
|
||||
|
||||
**Warning signs:** A search endpoint whose response fields come directly from a search-engine document type rather than from the same model-serialization path as `index()`/`show()`; no SQL query visible in traces for a search request.
|
||||
|
||||
**Phase to address:** API-port phase (search endpoints), reviewed against the security checklist, not treated as purely a search-relevance concern.
|
||||
|
||||
---
|
||||
|
||||
## Moderate Pitfalls
|
||||
|
||||
### Init-order plugin registration nondeterminism
|
||||
|
||||
**What goes wrong:** Go's `init()` functions run in package-dependency order, which is deterministic *within a build*, but a compile-time plugin registry (the chosen Caddy/xcaddy-style model) that iterates a `map[string]Plugin` for registration/route-mounting order can still produce nondeterministic route registration order across runs, since Go map iteration order is randomized. WinterCMS ported plugin-load-order bugs (one plugin's migration/route depending on another's having registered first) are easy to reintroduce if the generated plugin import list or the registry's internal storage is map-based rather than an explicit ordered slice.
|
||||
|
||||
**Prevention:** Generate an explicit ordered list (slice, not map) for plugin registration in the `summer build`-generated import file, and make any plugin-to-plugin dependency explicit (a declared "depends on" in the plugin descriptor) rather than implicit ordering. Add a test that registers plugins in two different orders and asserts identical resulting route/model state.
|
||||
|
||||
### Rate-limit bucket parity
|
||||
|
||||
**What goes wrong:** PHP defines multiple named buckets with specific keys and limits — `fonoteka-api-token` (60/min, keyed by token id, falling back to IP), `fonoteka-oauth-token` and `fonoteka-oauth-register` (30/min per IP, deliberately generous because "ChatGPT/Claude DCR originates from vendor egress, not household IPs — 5/hour would cap the whole platform"). A generic single Go rate-limiter middleware redesigned during the port will likely under- or over-throttle these specific, deliberately-tuned cases.
|
||||
|
||||
**Prevention:** Port bucket-by-bucket with identical keys and limits; treat the specific numbers and keying strategy as part of the contract, not an implementation detail to be redesigned.
|
||||
|
||||
### Centrifugo broadcast-suppression and channel-name contract
|
||||
|
||||
**What goes wrong:** Bulk operations use `Album::withoutBroadcasting()` to suppress per-row events, then emit exactly one summary event (`collection.bulk_updated`) after — a deliberate choke on event volume the Nuxt client is tuned to expect. A literal per-row port without the suppress-then-emit-once pattern will flood the client with events during bulk imports (CSV import in particular), and channel name strings (`collection:{id}`) must match exactly since Centrifugo channel names are opaque strings to any client subscribed by name.
|
||||
|
||||
**Prevention:** Port the suppress/batch/emit-once pattern as an explicit mechanism (not "just call publish for every write"), and treat channel name strings as part of the wire contract.
|
||||
|
||||
### snake_case JSON keys across ~25 models' worth of fields
|
||||
|
||||
**What goes wrong:** Every response key (`artist_display`, `market_price_checked_at`, `cover_import_failures`, etc.) is snake_case. Go's idiomatic default and most OpenAPI-codegen tool defaults are PascalCase/camelCase; across hundreds of fields, relying on developers to hand-write `json:"snake_case"` tags correctly and consistently (including the OpenAPI-generated admin SPA types mentioned in the requirements) will drift.
|
||||
|
||||
**Prevention:** Enforce via a lint rule or generated-code test (e.g., a test that walks all response struct fields via reflection and asserts every JSON tag is snake_case), not developer discipline. Configure the OpenAPI generator explicitly for snake_case output and verify against a sample.
|
||||
|
||||
### Constant-time secret comparison lost in translation
|
||||
|
||||
**What goes wrong:** `OAuthTokenController::authenticateClient()` uses PHP's `hash_equals()` for comparing the client secret hash — a timing-safe comparison. A Go port using `==` on byte slices/strings for the equivalent check reintroduces a timing side channel that was deliberately closed in the PHP original.
|
||||
|
||||
**Prevention:** Use `crypto/subtle.ConstantTimeCompare` (or an equivalent constant-time helper) for every secret/hash comparison ported from a PHP `hash_equals()` call; grep the PHP codebase for `hash_equals` and check each Go equivalent explicitly.
|
||||
|
||||
### go 1.27 json/v2 strictness meeting years of lenient PHP-written data
|
||||
|
||||
**What goes wrong:** Go 1.27's `encoding/json` (json/v2-backed) rejects duplicate keys and invalid UTF-8 on decode, stricter than PHP's `json_decode`. Historic JSON-column data (`tracklist`, `cover_import_failures`, and similar `jsonable` columns across other models) written over years by the PHP app could contain data that decodes fine in PHP but fails in Go.
|
||||
|
||||
**Prevention:** Run a one-time validation pass over existing JSON/JSONB column data during migration/import to catch decode failures before cutover, rather than discovering them per-request in production; decide a fallback behavior (log-and-empty vs. hard error) for any row that still fails.
|
||||
|
||||
### Dev rebuild loop friction undermining the "feels like WinterCMS" goal
|
||||
|
||||
**What goes wrong:** The compiled-plugin model's promised dev-loop ("a watch-rebuild loop... makes it feel like WinterCMS") depends entirely on the scaffolding tooling being built well; if `summer build`'s rebuild is slow (import-list regeneration triggering a full rebuild, not an incremental one) or the watch loop is flaky, AI-agent-driven development (the stated primary user, per `CLAUDE.md`) will be materially slower per iteration than the PHP original's drop-in-and-reload model, and this friction compounds across a 154-route, 25-model port.
|
||||
|
||||
**Prevention:** Budget explicit time in the kernel phase to measure and optimize rebuild time (Go's incremental build cache should make this sub-second to a few seconds for a single plugin change; verify it, don't assume it), and treat rebuild latency as a phase acceptance criterion, not an afterthought.
|
||||
|
||||
## Technical Debt Patterns
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|--------------------|-----------------|------------------|
|
||||
| One generic `APIResponse[T]{Data, Meta}` struct for all endpoints | Less boilerplate per handler | Cannot express the OAuth/RFC-root, conditional-key, and per-endpoint envelope variations (Pitfalls 8, 9); becomes a rewrite once the first non-conforming endpoint is ported | Never for the Płytarium port; maybe for a genuinely new endpoint family post-cutover with no PHP parity requirement |
|
||||
| GORM `AutoMigrate` for a new admin-only table with no PHP precedent | Fast to stand up | Schema drift from goose-managed migrations, no down path | Only for a brand-new table that has no PHP original and no down-migration requirement (rare in v1) |
|
||||
| Direct field-for-field struct port from PHP model to Go GORM model, used for both read and write | Fastest initial port | Reopens mass-assignment (Pitfall 3), loses tri-state bools (Pitfall 7), loses nil-vs-empty-array control (Pitfall 4) | Never for models with any write endpoint; borderline acceptable for read-only reference tables (e.g. `Genre`, `Style`) with no user-writable fields |
|
||||
| Trusting `zitadel/oidc`'s default error/response shapes without diffing against RFC 8414/6749 examples in the PHP source | Less code to write for the OAuth phase | Silent divergence from the exact shapes `fonoteka-mcp` and ChatGPT's connector were built against | Never — always diff library defaults against the PHP behavioral reference (`wavepath.org/plugins/golem15/oauthserver` and Płytarium's own `OAuthTokenController`/`OAuthMetadataController`) before trusting them |
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
| Integration | Common Mistake | Correct Approach |
|
||||
|-------------|------------------|-------------------|
|
||||
| River + GORM (Postgres pool) | Using one client/driver for both transactional enqueue and job execution | `riverdatabasesql` (shared `*sql.DB`) for transactional enqueue; separate `riverpgxv5` client for LISTEN/NOTIFY-based work processing (Pitfall 14) |
|
||||
| Centrifugo | Publishing per-row broadcast events during bulk writes | Suppress-then-emit-once pattern (`withoutBroadcasting` equivalent), exact channel name strings preserved |
|
||||
| Typesense | Serializing search results directly from the index document | Search returns candidate IDs only; SQL re-hydration re-applies authorization scoping (Pitfall 15) |
|
||||
| zitadel/oidc | Trusting default 401/WWW-Authenticate and metadata output verbatim | Diff every OAuth/OIDC response against the PHP `OAuthTokenController`/`OAuthMetadataController` shapes and the MCP client's actual expectations (RFC 9728 `resource_metadata`) before shipping |
|
||||
| MCP server / ChatGPT connector | Treating a 401 as "just a status code" | Header parameters (`resource_metadata`, `scope`, `error`) are a discovery protocol consumed by the client; test against the actual `fonoteka-mcp` proxy, not just curl |
|
||||
| gocloud.dev/blob (file uploads/covers) | Assuming public URL shape is automatically compatible with the PHP app's existing stored URLs | Match the PHP stack's current public URL shape exactly (Płytarium's `relativeMediaUrl`/`getThumb()` conventions) since the Nuxt client and any already-stored URLs in the DB depend on it |
|
||||
|
||||
## Performance Traps
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|-----------------|
|
||||
| N+1 queries on list endpoints where PHP deliberately used `withCount`/eager-loading | Slow list endpoints under real collection sizes; DB connection pool exhaustion | Preserve the exact `embedsFor($user)`/`EMBEDS` eager-load contract per endpoint via GORM `Preload()`, and replicate the `isset($x->count)`-fallback pattern (eager count on list, live count on single-record) | Noticeable once a household collection reaches a few hundred albums with related artists/styles/photos |
|
||||
| River poll-only mode used for latency-sensitive job pickup | Background jobs (CSV import, digests) appear to "hang" for the poll interval | Use the pgx-based River client for the work-processing side (Pitfall 14) | Any use of poll-only mode for jobs a user is actively waiting on (e.g. CSV import feedback) |
|
||||
| GORM `Update(struct)` silently skipping zero-value fields | A field set to `0`/`""`/`false` never persists; "the update isn't sticking" bug reports | Use `Select("field1","field2")` or a `map[string]interface{}` for updates where a zero value is a legitimate target value (verified against GORM's own docs and GitHub issue #4297) | Any update path touching a nullable-to-zero or boolean-to-false transition — will misbehave from day one, not just at scale |
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| Treating Typesense results as pre-authorized | Cross-collection/cross-household data leak via stale or mis-scoped search index | SQL re-gate after every search (Pitfall 15) |
|
||||
| `==` string/byte comparison for OAuth client secret or token hashes | Timing side channel reintroduced during the port | `crypto/subtle.ConstantTimeCompare` everywhere PHP used `hash_equals()` |
|
||||
| Reusing GORM models as request-binding structs | Mass assignment of server-owned fields (e.g. `market_price_source`, `collection_id`) | Per-endpoint DTOs + explicit allow-listed field copy (Pitfall 3) |
|
||||
| Blanket envelope/error middleware applied to OAuth/RFC routes | Auth flows silently broken for MCP/ChatGPT clients, not caught by SPA-focused manual testing | Route-group-level exemption for `/oauth/*` and `/.well-known/*` (Pitfall 9) |
|
||||
| Exposing a raw numeric tenant/collection id where the PHP original deliberately used an opaque channel-name string (see `realtime/channels`' own comment about the "opaque-tenancy invariant") | Reopens an IDOR-adjacent concern the PHP code closed on purpose | Read the "why" comments in the PHP source before "simplifying" a response shape — several fields are deliberately obfuscated/omitted for a stated security reason, not by oversight |
|
||||
|
||||
## UX Pitfalls
|
||||
|
||||
| Pitfall | User Impact | Better Approach |
|
||||
|---------|--------------|-------------------|
|
||||
| Bulk-write realtime event flooding (per-row instead of suppress-then-emit-once) | UI flicker, duplicate toasts/notifications during CSV import or bulk album add | Port the exact suppression/batch pattern, not just the underlying write logic |
|
||||
| Date/boolean/array shape mismatches surfacing as client-side exceptions rather than visibly wrong data | Confusing crashes in the Nuxt app or MCP tool calls with no obvious link to "the backend changed" | Parity-harness assertions on exact JSON shape (Pitfalls 4, 6, 7) so these are caught before reaching a user session |
|
||||
| Locked-out users via a mis-ported `423`/must-change-password gate ordering | A user stuck unable to reach the one endpoint (`me/locale`) deliberately excluded from the lock, per the PHP routing comment | Reproduce the exact middleware ordering and route-group boundaries from `routes.php`, including which routes are deliberately outside a given gate |
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
- [ ] **Pagination:** Often missing the exact `meta` key set (`current_page`, `last_page`, `per_page`, `total`, no `links`) — verify against `AlbumApiController::paginated()` byte-for-byte, not against a "reasonable" pager shape.
|
||||
- [ ] **Validation errors:** Often returns a shape close to but not identical to Laravel's `field -> []string` map, especially for nested/array field paths (`tracklist.0.artist`) — verify against real 422 responses recorded from the PHP app, including multi-error and array-field cases.
|
||||
- [ ] **OAuth token/metadata endpoints:** Often "work" for the happy path (Claude/ChatGPT completes login once) but drift on refresh-token rotation, `WWW-Authenticate` header exactness on 401, or the RFC-8414-root-not-enveloped rule — verify against a second, later session (refresh flow) and against the exact metadata document bytes, not just a successful first login.
|
||||
- [ ] **Soft-deleted + unique columns:** Often passes all tests written against fresh data but breaks the first time a user deletes-then-recreates a uniquely-keyed row (a reservation, a wishlist subscription) — verify with an explicit delete-then-recreate test per soft-deletable+unique table.
|
||||
- [ ] **Money/date/array/bool field shapes:** Often "look right" in a browser JSON viewer (which doesn't distinguish `"1.50"` from `1.5`, or `Z` from `+00:00`, at a glance) but fail a byte-level parity diff — verify with an automated diff, not visual inspection.
|
||||
- [ ] **Background jobs:** Often work in manual testing (where poll-interval latency of a second or two goes unnoticed) but reveal the transactional-enqueue-vs-fast-dispatch tradeoff only under real concurrent load or when a job must be visibly "instant" to a user (e.g. after CSV import completes) — verify the River driver split (Pitfall 14) under a timed test, not just "the job eventually ran."
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|-----------------|------------------|
|
||||
| JSON shape drift caught late (post-cutover) by a client bug report | MEDIUM | Add the specific field to the parity harness's assertion set, fix the serializer, re-run the harness against all 154 routes to catch siblings of the same bug (e.g. if one nullable date is wrong, audit all nullable dates at once) |
|
||||
| Missing pivot/extra columns discovered after data has been written without them | MEDIUM–HIGH | Backfill `sort_order` (or equivalent) from any still-available PHP-side source (export before cutover, or infer from creation order) before the join-table modeling fix ships, since the data itself — not just the code — is now wrong |
|
||||
| Unique-constraint-vs-soft-delete bug causing failed writes in production | LOW–MEDIUM | Add the partial unique index via a new migration; existing data is usually unaffected since the bug manifests as a write failure, not corrupted data |
|
||||
| Kernel/plugin infrastructure over-built before an app pulls requirements (recurrence of the Scala failure mode) | HIGH | Stop, freeze the abstraction, and force a real vertical slice (one model/route/job) through it before touching the kernel further — the same recovery `why-go-not-scala.md` implies was never actually attempted in the Scala effort |
|
||||
| OAuth/MCP client silently failing to auto-discover auth after a header/metadata regression | LOW–MEDIUM | Since the header/metadata contract is small and centralizable (Pitfall 10), a single shared helper fix resolves it everywhere at once — but verify against `fonoteka-mcp`'s actual client, since a spec-compliant-looking fix can still not match what that specific client parses |
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|-------------------|----------------|
|
||||
| Bottom-up kernel over-building (Pitfall 1) | Kernel phase (interleaved with first data/route slice) | A named phase checkpoint: "has at least one Płytarium model/route/job exercised the kernel yet?" before kernel work continues |
|
||||
| Global state instead of context.Context (Pitfall 2) | Kernel phase + HTTP/auth phase | `go test -race` in CI from phase 1 onward; a concurrency test simulating two users' requests interleaved |
|
||||
| Mass-assignment via reused structs (Pitfall 3) | Data layer + API-port phase | Code review checklist item: every write endpoint has a distinct request DTO; a fuzz/property test posting extra unknown fields and asserting they're rejected/ignored, not persisted |
|
||||
| Nil slice vs `[]` (Pitfall 4) | API-port phase | Parity harness asserts exact JSON, including `null` vs `[]`, for every jsonable/array field across all 25 models |
|
||||
| Float vs fixed-decimal string (Pitfall 5) | Data layer + API-port phase | Unit test round-tripping the exact PHP ceiling/ blank-string cases; parity harness diffs the money field as a raw JSON token type (string vs number) |
|
||||
| ISO8601 offset / null-vs-omitted dates (Pitfall 6) | API-port phase | Shared time-marshaling helper with a unit test against Carbon's exact output format; parity harness includes timestamp fields in its diff |
|
||||
| Tri-state bool (Pitfall 7) | API-port phase | Field-by-field audit of `SerializesFonoteka.php`-equivalent methods for ternaries with a null branch; parity harness diffs `null`/`true`/`false` states |
|
||||
| Envelope/error/conditional-key drift (Pitfall 8) | API-port phase, caught by parity harness | Parity harness diffs full response bodies (not just status/rough shape) against recorded real Nuxt/MCP requests, per `CLAUDE.md`'s acceptance rule |
|
||||
| Blanket middleware corrupting OAuth/RFC endpoints (Pitfall 9) | HTTP/auth phase (routing structure) + OAuth phase | A route-group-isolation test: OAuth/metadata routes have no house-envelope middleware attached, checked structurally (route registration inspection), not just by example response |
|
||||
| WWW-Authenticate/PRM header exactness (Pitfall 10) | OAuth phase | Integration test against the actual `fonoteka-mcp` proxy's discovery flow, not just a unit test on the Go handler |
|
||||
| Soft-delete + unique constraint interaction (Pitfall 11) | Data layer | Per-table delete-then-recreate test for every soft-deletable table with a unique constraint |
|
||||
| Many-to-many pivot columns dropped (Pitfall 12) | Data layer | Round-trip test asserting stored ordering for multi-row pivot associations (3+ artists on one album) |
|
||||
| AutoMigrate as schema source of truth (Pitfall 13) | Data layer | CI check that no `AutoMigrate` call exists outside test-only code paths; all 27 migrations exist as goose files with tested down migrations |
|
||||
| River/GORM pool conflation (Pitfall 14) | Background/integrations phase (jobs) | A timed test asserting job pickup latency matches the LISTEN/NOTIFY path, not the poll interval, for the work-processing client |
|
||||
| Typesense trusted as pre-authorized (Pitfall 15) | API-port phase (search) | A security test: inject a stale/mis-scoped search-index document and assert the SQL re-gate still excludes it from an unauthorized user's results |
|
||||
| Init-order plugin registration nondeterminism | Kernel phase | Test registering plugins in reordered input and asserting identical resulting state |
|
||||
| Rate-limit bucket parity | HTTP/auth phase + API-port phase | Enumerate every PHP `RateLimiter::for()` bucket and assert a matching Go bucket with the same key strategy and limit |
|
||||
| Centrifugo suppress/batch/channel-name contract | Background/integrations phase + API-port phase | Integration test against a real (or recorded) Centrifugo session asserting exactly one event per bulk operation and exact channel name strings |
|
||||
| snake_case key enforcement | API-port phase | Reflection-based test walking all response types asserting snake_case JSON tags; OpenAPI generator config test |
|
||||
| Constant-time secret comparison | OAuth phase | Static check / lint rule flagging non-`subtle` comparisons of any value named `secret`/`hash`/`token` in the OAuth package |
|
||||
| json/v2 strictness vs legacy data | Data layer (migration/import) | A one-time data-validation pass over existing JSON/JSONB columns before cutover, with a documented decision on failure handling |
|
||||
| Dev rebuild loop friction | Kernel phase | Measured rebuild-time acceptance criterion (e.g., "under N seconds for a single-plugin change") as a phase exit condition |
|
||||
|
||||
## Sources
|
||||
|
||||
- `.planning/PROJECT.md`, `.planning/notes/why-go-not-scala.md`, `.planning/notes/v1-target-plytarium.md`, `.planning/research/go-ecosystem.md`, `CLAUDE.md` (project-internal, HIGH confidence — decisions and constraints as recorded by the project itself)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php` — route groups, middleware ordering, rate-limit bucket definitions (HIGH confidence, read directly)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/AlbumApiController.php` — pagination envelope, validation error envelope, duplicate-detection and broadcast-suppression patterns (HIGH confidence, read directly)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthTokenController.php`, `OAuthMetadataController.php` — RFC 6749/8414 response shapes, client authentication, constant-time secret comparison (HIGH confidence, read directly)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Album.php` — casts, fillable/guarded, jsonable defaults, soft delete, pivot columns (HIGH confidence, read directly)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php` — the full serialization contract: conditional key inclusion, tri-state booleans, ISO8601 dates, empty-array defaults (HIGH confidence, read directly)
|
||||
- `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts` — 401/WWW-Authenticate/PRM discovery construction as consumed by the MCP client (HIGH confidence, read directly)
|
||||
- River docs (`riverqueue.com/docs/gorm`) and GitHub discussion/issue threads on River+GORM pool sharing (MEDIUM-HIGH confidence, official docs + verified via web search 2026-09-16)
|
||||
- GORM official docs (`gorm.io/docs/update.html`) and GitHub issue #4297 on zero-value update skipping (HIGH confidence, official docs)
|
||||
- modelcontextprotocol.io authorization spec and RFC 9728 discussion threads on Protected Resource Metadata / WWW-Authenticate discovery (MEDIUM-HIGH confidence, verified via web search 2026-09-16)
|
||||
- Caddy/xcaddy compiled-plugin precedent as discussed in `go-ecosystem.md` (MEDIUM confidence, carried from prior research pass)
|
||||
|
||||
---
|
||||
*Pitfalls research for: WinterCMS/Laravel-shaped CMF rewrite in Go, PHP-to-Go API parity port*
|
||||
*Researched: 2026-09-16*
|
||||
247
.planning/research/STACK.md
Normal file
247
.planning/research/STACK.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Stack Research
|
||||
|
||||
**Domain:** Go 1.27 backend framework (WinterCMS/Laravel-shaped CMF), headless-first, compiled plugins, porting a PHP backend (Płytarium) to Go
|
||||
**Researched:** 2026-09-16
|
||||
**Confidence:** HIGH for versions and maintenance status (checked via pkg.go.dev, GitHub, official docs this session); MEDIUM for architectural recommendations that require judgment calls (migration tool choice, OpenAPI approach, config format)
|
||||
|
||||
This extends `.planning/research/go-ecosystem.md` (2026-09-16 GitHub-API-verified ecosystem picks). It does not re-litigate that report's ecosystem choices; it adds versions and answers the specific integration questions the milestone raised.
|
||||
|
||||
## Recommended Stack
|
||||
|
||||
### Core Technologies
|
||||
|
||||
| Technology | Version | Purpose | Why Recommended |
|
||||
|------------|---------|---------|-----------------|
|
||||
| Go | 1.27 (Aug 2026) | Language/runtime | Already decided. Generic methods clean up repository/query-builder APIs; `encoding/json` is now json/v2-backed (stricter: rejects duplicate keys, invalid UTF-8) — matters for hand-authored `fields.yaml`/`columns.yaml` round-tripped through JSON for the admin SPA. |
|
||||
| GORM | v1.31.2 (Jun 22, 2026) | ORM, Postgres only | Already decided. Current stable line; v1.31.x added generics-based `Count` etc. Confirmed via pkg.go.dev version list. HIGH confidence. |
|
||||
| gorm.io/driver/postgres | v1.6.3 (Sep 14, 2026) | Postgres driver for GORM | Latest patch release, two days before this research. Its `go.mod` pins `github.com/jackc/pgx/v5 v5.10.0` — GORM's "Postgres driver" is pgx under the hood, not lib/pq. This is the crux of the River pool-sharing question below. HIGH confidence (read go.mod directly). |
|
||||
| River | v0.47.0 (confirmed via multiple independent 2026 dependabot PRs bumping to this version; GitHub releases page fetch returned stale cached 2024 dates — do not trust that page directly, cross-checked against `riverdriver`/`riverpgxv5`/`riverdatabasesql` sub-package publish dates of Apr–Jul 2026) | Postgres-backed job queue | Already decided. MEDIUM-HIGH confidence on the exact patch version; HIGH confidence it's the current v0.4x line and actively maintained. |
|
||||
| zitadel/oidc | v3.51.0 (Sep 14, 2026) | OAuth2/OIDC provider | Already decided, and confirmed correct: **v4 exists only as `v4.0.0-next.4` (pre-release, Jul 30, 2026)**. v3 is the maintained stable line (v3.51.0 shipped two days before this research, newer than the v4 pre-release). Stay on v3; do not chase v4 until it ships stable. Flagged explicitly per the quality gate — this does not contradict the existing decision, it confirms it. |
|
||||
| golang-jwt/jwt | v5.3.1 (Jan 28, 2026), import path `github.com/golang-jwt/jwt/v5` | JWT for the SPA and for signing Centrifugo connection/subscription tokens | Already decided. One library covers both jobs — see Centrifugo section. |
|
||||
| go-playground/validator | v10.30.4 (Sep 3, 2026), import path `.../validator/v10` | Struct-tag validation from YAML `rules:` | Already decided. Current. |
|
||||
| cobra | v1.10.2 (Dec 3, 2025) | CLI framework | Already decided. Current stable. |
|
||||
| gocloud.dev | v0.46.0 (Jun 2, 2026) | Blob storage abstraction | Already decided. Current. |
|
||||
| Vue 3 + TypeScript | (frontend, out of Go versioning scope) | Minimal admin SPA | Already decided. Types generated from OpenAPI — see OpenAPI section. |
|
||||
|
||||
### Supporting Libraries
|
||||
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| go-gormigrate/gormigrate/v2 | latest tag as of May 26, 2026 (1.2k stars, actively maintained, PostgreSQL 18 in its own CI matrix) | Migrations, up/down, per-plugin | **Recommended primary migration tool.** See "Migration tooling" below for why this beats goose and atlas for this project's shape. |
|
||||
| pressly/goose | v3.27.3 (Jul 27, 2026, 11.5k stars) | Alternative migration tool | Use instead of gormigrate only if a future plugin needs pure-SQL migrations independent of `*gorm.DB`, or a non-GORM connection. Not recommended as primary — see rationale below. |
|
||||
| jackc/pgx/v5 | v5.10.0 (pinned by gorm.io/driver/postgres's own go.mod) | Postgres driver, shared by GORM and River | Not a separate app-level choice — it's already there transitively through GORM's driver. River's own driver (`riverpgxv5`) also needs pgx v5. Pin the same `pgx/v5` version across `go.sum` (Go's module resolution does this automatically via MVS; just don't force a divergent replace directive). |
|
||||
| koanf/v2 (`github.com/knadh/koanf/v2`) | v2.3.4 (Mar 21, 2026) | Layered config with plugin namespaces | Already decided per go-ecosystem.md. See "Config format" below for the HOCON question. |
|
||||
| koanf providers: `providers/file`, `providers/env`, `providers/confmap` | ships alongside koanf/v2, versioned independently as sub-modules | Base file + env-var overlay + programmatic plugin defaults | `file` provider loads `base.yaml`/`<env>.yaml`; `env` provider overlays `SUMMER_`-prefixed env vars with a transform func mapping `SUMMER_DB__HOST` → `db.host`; `confmap` lets each plugin register its own namespaced defaults (`plugins.<name>.*`) before the file/env layers are merged on top. |
|
||||
| koanf/parsers/yaml | tracks koanf/v2 | YAML parsing for koanf | Wraps `goccy/go-yaml` as of koanf's current release — **not** `gopkg.in/yaml.v3`, which is why the direct fields/columns parsing question below matters independently. |
|
||||
| goccy/go-yaml | v1.19.2 (Jan 8, 2026) | Parse `fields.yaml` / `columns.yaml` | **Use this, not `gopkg.in/yaml.v3`.** See "What NOT to Use" — yaml.v3's upstream repo (`go-yaml/yaml`) was archived by its maintainer on Apr 1, 2025 and is explicitly marked unmaintained. goccy/go-yaml passes 355/402 cases of the YAML test suite vs 295/402 for yaml.v3, has an AST/tokenizer API useful for round-tripping comments if `summer make:*` scaffolds YAML files, and is what koanf itself has moved to. HIGH confidence — this is a load-bearing finding, not a style preference. |
|
||||
| swaggo/swag | v1.16.6 (Jul 29, 2026, stable; v2.0.0-rc6 exists but is not production-ready) | Generate OpenAPI from annotated net/http handlers | **Recommended primary.** See "OpenAPI generation" below. |
|
||||
| openapi-typescript | current npm release (JS ecosystem, not Go-versioned) | Generate TS types for the Vue admin SPA from the OpenAPI doc swag produces | Already decided (types generated from OpenAPI). Feeds directly off swag's output JSON/YAML. |
|
||||
| typesense-go | v3.2.0 (Mar 27, 2025), confirms Typesense server API v28 support | Typesense client | Already decided. This is the most recent tagged release found; no newer tag surfaced in this research pass — treat the exact patch as MEDIUM confidence (worth a re-check at implementation time since it is over a year old relative to today) but the library itself is the only real Go client and is the correct pick. |
|
||||
| centrifugal/gocent/v3 | current release, import path `github.com/centrifugal/gocent/v3` | Centrifugo HTTP API client (publish/broadcast/presence) | Official client from the Centrifugo org. Small (87 stars is normal for a niche official SDK, not a red flag — same maintainers as Centrifugo itself). Given it wraps ~4 REST calls, hand-rolling a thin `net/http` client is a legitimate stdlib-first alternative if the team wants zero non-decided dependencies; gocent is the pragmatic default. |
|
||||
| — (no separate library for Centrifugo tokens) | — | Sign Centrifugo connection/subscription JWTs | Centrifugo does not need its own token library — it verifies plain JWTs signed with an HMAC secret (or RSA/ECDSA). **Use the already-decided `golang-jwt/jwt/v5` to sign these tokens too**, with claims shaped per Centrifugo's connection/subscription token spec. One JWT library, two token types (SPA auth, Centrifugo realtime auth). |
|
||||
| go-i18n/v2 (`github.com/nicksnyder/go-i18n/v2`) | v2.6.1 (Jan 1, 2026) | CLDR-plural i18n | Already decided. CLDR v48 as of this release (up from CLDR 44 in older v2.3.0 — make sure any tutorial/blog post referencing go-i18n is checked against the current release, plural-rule edge cases have shifted). Also replaced an unmaintained YAML dependency internally in a recent release — another confirmation that the Go YAML-library churn described above is a live, current issue, not stale training-data noise. |
|
||||
| testcontainers-go + testcontainers-go/modules/postgres | v0.44.0 (Aug 7, 2026) | Spin up real Postgres in tests | For GORM model tests, migration up/down tests, and River job tests that need real Postgres behavior (JSON columns, `LISTEN`/`NOTIFY`, constraint behavior) rather than SQLite-in-CI approximations. |
|
||||
| stretchr/testify | v1.12.1 (Aug 17, 2026) | Assertions in Go tests | Standard choice; use `assert`/`require` for readability in the API-parity diff tests, not as a BDD framework — keep tests as plain `func Test...(*testing.T)`. |
|
||||
| air-verse/air | v1.67.4 (Aug 1, 2026), actively maintained | Dev watch-rebuild loop | This is the "watch loop that rebuilds the compiled-plugin binary on change" the plugin architecture already calls for. Config via `.air.toml`; point `cmd`/`bin` at `go build -o ./tmp/summer ./cmd/summer && ./tmp/summer serve`. |
|
||||
| golangci-lint | v2.13.2 (Aug 27, 2026) | Linting | v2's config schema (`version: "2"` in `.golangci.yml`) is a breaking change from v1 — do not copy a v1 config from an older Go project without migrating it. |
|
||||
|
||||
### Development Tools
|
||||
|
||||
| Tool | Purpose | Notes |
|
||||
|------|---------|-------|
|
||||
| air | live rebuild in dev | Config as above; combine with the plugin system's own `plugins.go` regeneration step so editing a plugin's route/model file triggers both codegen and rebuild. |
|
||||
| golangci-lint v2 | static analysis in CI and pre-commit | Enable `govet`, `staticcheck`, `errcheck`, `revive` at minimum; the stdlib-first constraint makes `depguard` worth configuring to fail CI if an undecided dependency is imported. |
|
||||
| testcontainers-go | integration tests against real Postgres | Gate these behind a build tag or `-short` skip so unit tests stay fast; the phase-ending "unit tests" plan (per this repo's lean-mode rule) should still run everywhere, with testcontainers tests as a separate, slower suite. |
|
||||
| golden-file API parity tests | diff Go backend responses against recorded PHP responses | Not a library — a pattern: record Płytarium's actual JSON responses per route as fixtures (`testdata/parity/<route>.golden.json`), replay the same requests against the Go backend in `httptest.Server`, diff with `testify/assert.JSONEq` or a small custom normalizer for non-deterministic fields (timestamps, IDs). This is the literal implementation of the PROJECT.md requirement "an API parity test suite replays the Nuxt app's and MCP server's requests against both backends and diffs responses." |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Core (already decided, versions confirmed this session)
|
||||
go get gorm.io/gorm@v1.31.2
|
||||
go get gorm.io/driver/postgres@v1.6.3
|
||||
go get github.com/riverqueue/river@v0.47.0
|
||||
go get github.com/riverqueue/river/riverdriver/riverdatabasesql
|
||||
go get github.com/riverqueue/river/riverdriver/riverpgxv5 # only if using a raw pgxpool.Pool listener alongside the shared *sql.DB
|
||||
go get github.com/zitadel/oidc/v3@v3.51.0
|
||||
go get github.com/golang-jwt/jwt/v5@v5.3.1
|
||||
go get github.com/go-playground/validator/v10@v10.30.4
|
||||
go get github.com/spf13/cobra@v1.10.2
|
||||
go get gocloud.dev@v0.46.0
|
||||
|
||||
# Migrations
|
||||
go get github.com/go-gormigrate/gormigrate/v2
|
||||
|
||||
# Config
|
||||
go get github.com/knadh/koanf/v2@v2.3.4
|
||||
go get github.com/knadh/koanf/providers/file
|
||||
go get github.com/knadh/koanf/providers/env
|
||||
go get github.com/knadh/koanf/providers/confmap
|
||||
go get github.com/knadh/koanf/parsers/yaml
|
||||
|
||||
# YAML for fields.yaml / columns.yaml (NOT gopkg.in/yaml.v3)
|
||||
go get github.com/goccy/go-yaml@v1.19.2
|
||||
|
||||
# i18n
|
||||
go get github.com/nicksnyder/go-i18n/v2@v2.6.1
|
||||
go get golang.org/x/text # CLDR plural support dependency
|
||||
|
||||
# OpenAPI
|
||||
go install github.com/swaggo/swag/cmd/swag@v1.16.6
|
||||
|
||||
# Search / realtime clients
|
||||
go get github.com/typesense/typesense-go/v3@v3.2.0 # confirm exact tag/module path at implementation time
|
||||
go get github.com/centrifugal/gocent/v3
|
||||
|
||||
# Dev / test dependencies
|
||||
go get github.com/testcontainers/testcontainers-go@v0.44.0
|
||||
go get github.com/testcontainers/testcontainers-go/modules/postgres@v0.44.0
|
||||
go get github.com/stretchr/testify@v1.12.1
|
||||
|
||||
# Dev tools (not go.mod dependencies)
|
||||
go install github.com/air-verse/air@v1.67.4
|
||||
# golangci-lint installed via its install script, pinned to v2.13.2, not go install
|
||||
```
|
||||
|
||||
## Deep Dives on the Milestone's Specific Questions
|
||||
|
||||
### GORM: plain structs, not gorm gen or the new GORM CLI
|
||||
|
||||
GORM now has two competing codegen tools: the older `gorm.io/gen` (full DAO codegen, pre-generics) and the new `github.com/go-gorm/cli` (2026, generics-based, smaller generated surface, GORM's own docs call it the forward direction for new projects). **Neither is recommended for this project's v1.** The explicit design goal is "closest to Eloquent's mutable-model DX, simplest line-by-line port of 25 PHP models" (PROJECT.md) — a straight one-model-to-one-struct port reads and reviews better without a codegen layer in between, and it keeps the dependency tree smaller per the stdlib-first constraint. Revisit `go-gorm/cli` later for new, non-ported plugins if the team wants compile-time-checked queries — that is a second-milestone decision, not a v1 one.
|
||||
|
||||
### Migration tooling: gormigrate, not goose or atlas
|
||||
|
||||
The three real options and why gormigrate wins for this specific shape of problem:
|
||||
|
||||
- **gormigrate** (`go-gorm/gormigrate/v2`) defines migrations as `{ID, Migrate(tx *gorm.DB) error, Rollback(tx *gorm.DB) error}` structs, operates directly on the shared `*gorm.DB`, and — confirmed by reading the source (`gormigrate.go`) directly rather than trusting a README excerpt — exposes `RollbackLast()` and `RollbackTo(id)` as first-class methods, plus `MigrateTo(id)`. This is a literal implementation of "drop the last migration and fix it": run `RollbackLast()`, edit the migration's `Migrate`/`Rollback` funcs, run `Migrate()` again. No separate migration-file format, no separate driver, no SQL string embedding required (though `tx.Exec(...)` is available inside a migration when raw SQL is easier than `Migrator()` calls).
|
||||
- **goose** supports Go-code migrations (not just `.sql` files) via `goose.NewGoMigration`, which is the reason it was flagged as a candidate. But it registers migrations into its own provider backed by an `embed.FS` per source tree; making per-plugin migration sets compose cleanly (each plugin contributing its own ordered slice, aggregated by the kernel at boot from the generated plugin import list) is more natural with gormigrate's plain `[]*gormigrate.Migration` slices than with goose's filesystem-and-provider model. goose is still the right tool if a future plugin needs raw-SQL migrations decoupled from `*gorm.DB` entirely (e.g. a plugin that talks to Postgres via bare `pgx` for performance reasons) — keep it as the named fallback, not the default.
|
||||
- **atlas** is a declarative schema-diff tool (desired-state HCL/SQL compared against actual state, migration generated automatically). It is the better fit when GORM's own struct tags are treated as the single source of truth and hand-written data-backfill migrations are rare. This project's port needs custom, hand-authored up/down logic per PHP migration (27 of them, with real data semantics, not just DDL) — atlas's diffing model fights that instead of helping it, and it adds a second DSL and a separate binary. Not recommended for v1.
|
||||
|
||||
**Concrete per-plugin pattern:** each plugin package exposes `var Migrations = []*gormigrate.Migration{...}` with IDs namespaced by plugin (`golem15_fonoteka_20260916143000_create_albums`) to keep global ordering unambiguous when the kernel concatenates every registered plugin's slice, in plugin-registration order, into one `gormigrate.New(db, gormigrate.DefaultOptions, all)` at boot. `summer migrate`, `summer migrate:rollback` (→ `RollbackLast`), and `summer make:migration <plugin> <name>` (scaffolds a Go file with empty `Migrate`/`Rollback` stubs and a timestamp ID) are cobra subcommands wrapping this.
|
||||
|
||||
### River + GORM: share one `*sql.DB`, not one `pgxpool.Pool`
|
||||
|
||||
Both GORM's Postgres driver and River need pgx v5 (confirmed: `gorm.io/driver/postgres`'s go.mod pins `jackc/pgx/v5 v5.10.0`), which raises the pooling question directly. River's own documentation (`riverqueue.com/docs/gorm`) answers it: don't try to hand a raw `pgxpool.Pool` to both libraries — instead:
|
||||
|
||||
1. Open one `*sql.DB` via `sql.Open("pgx", dsn)` (using pgx's `database/sql` stdlib driver registration, `jackc/pgx/v5/stdlib`).
|
||||
2. Pass that `*sql.DB` to GORM: `gorm.Open(postgres.New(postgres.Config{Conn: sqlDB}), &gorm.Config{})`.
|
||||
3. Pass the same `*sql.DB` to River via its `riverdatabasesql` driver, using `riverdatabasesql.NewWithPgxListener(sqlDB, listenerPool)` — River needs `LISTEN`/`NOTIFY` for real-time job wake-ups, which plain `database/sql` cannot expose, so a small separate `pgxpool.Pool` is used *only* for the listener, while all actual queries (both GORM's and River's job table access) go through the one shared `*sql.DB`.
|
||||
4. Sharing a GORM transaction with River's job insertion: `tx := gormDB.Begin(); sqlTx := tx.Statement.ConnPool.(*sql.Tx); riverClient.InsertTx(ctx, sqlTx, args, nil); tx.Commit()`. This is how "insert a River job in the same transaction as the GORM write that triggered it" (e.g. queue `ReindexAlbums` in the same transaction as an album save) is done correctly.
|
||||
|
||||
This is a documented, first-party integration path (River's own docs have a dedicated GORM page), not an improvised workaround — confidence HIGH.
|
||||
|
||||
### zitadel/oidc v3 storage interface: what to implement
|
||||
|
||||
Read directly from `pkg/op/storage.go` on the `main` branch (v3 line). The interfaces to implement, split by concern:
|
||||
|
||||
- **`AuthStorage`** — the core of the authorization-code + PKCE flow: `CreateAuthRequest`, `AuthRequestByID`, `AuthRequestByCode`, `SaveAuthCode`, `DeleteAuthRequest`, `CreateAccessToken`, `CreateAccessAndRefreshTokens`, `TokenRequestByRefreshToken`, `TerminateSession`, `RevokeToken`, `GetRefreshTokenInfo`, `SigningKey`, `SignatureAlgorithms`, `KeySet`. This is the interface Płytarium's `OAuthAuthCode`, `OAuthClient`, `OAuthRefreshToken` models map onto directly.
|
||||
- **`OPStorage`** — `GetClientByClientID`, `AuthorizeClientIDSecret`, `SetUserinfoFromToken`, `SetIntrospectionFromToken`, `GetPrivateClaimsFromScopes`, `GetKeyByIDAndClientID`, `ValidateJWTProfileScopes`. `SetUserinfoFromScopes` exists but is explicitly documented as deprecated in favor of the optional `CanSetUserinfoFromRequest` interface — implement the newer one, leave the old one empty.
|
||||
- **`ClientCredentialsStorage`** (optional) — needed only if the client-credentials grant is used (worth checking whether the MCP server or ChatGPT connector needs it; Płytarium's OAuth models list doesn't obviously call for it, flag as an open question for the phase that implements this).
|
||||
- **`TokenExchangeStorage`** / **`TokenExchangeTokensVerifierStorage`** (optional) — RFC 8693 token exchange; almost certainly out of scope for a straight port unless the existing PHP OAuth server implements it (check `wavepath.org/plugins/golem15/oauthserver` for this before assuming it's unneeded).
|
||||
- **Optional `Can*` interfaces** (`CanTerminateSessionFromRequest`, `CanSetUserinfoFromRequest`, and others in the same file) let the request object itself (not just IDs) reach the storage implementation — worth implementing from the start rather than the older, more limited required methods, since they carry richer context.
|
||||
|
||||
Confirmed: v4 exists only as a pre-release (`v4.0.0-next.4`, Jul 30, 2026); v3 (`v3.51.0`, Sep 14, 2026) is the actively maintained stable line and the correct choice. No contradiction with the existing decision.
|
||||
|
||||
### Config: koanf + YAML, not a literal HOCON parser
|
||||
|
||||
The existing decision says "HOCON-style layered config" — read as a design (layering + overlays + namespaces), not a mandate to use literal HOCON syntax, and that reading is the right one. The Go HOCON ecosystem is not viable: `gurkankaymak/hocon` (91 stars, maintenance unclear) and `go-hocon/hocon` (0 stars, 6 commits) are both far below the bar this project applies to every other dependency. There is no maintained, widely used Go HOCON parser to point at.
|
||||
|
||||
**Recommendation: plain YAML files, layered with koanf.** `base.yaml` + `<env>.yaml` (env overlay) + `plugins/<name>.yaml` (per-plugin namespace, loaded under a `plugins.<name>` key) + an env-var provider (`SUMMER_*`) for secrets/overrides, all merged via successive `koanf.Load()` calls (koanf recursively merges nested maps, later loads win on scalars). This gets HOCON's practical benefits — layering, per-plugin sections, env overlay — without a fragile single-maintainer parser dependency, and it matches YAML already being the framework's admin-schema format (`fields.yaml`, `columns.yaml`), so there's one YAML parser (goccy/go-yaml, via koanf's yaml parser) in the whole binary instead of two config-format parsers. TOML was the other pragmatic option; YAML wins because WinterCMS plugin authors already read/write YAML for `fields.yaml`/`columns.yaml`, so config files look familiar rather than introducing a second syntax.
|
||||
|
||||
### go-i18n + CLDR plurals
|
||||
|
||||
`go-i18n/v2` v2.6.1 (Jan 1, 2026) ships CLDR v48 plural rules, generated into the library at build time (not a runtime CLDR database dependency) — this is why go-i18n has no extra runtime dependency on ICU or similar. Namespaced keys (`vendor.plugin::group.key`, per PROJECT.md's phrasebook design carry-over) are an application-level convention on top of go-i18n's flat message-ID space, not a feature go-i18n provides natively — implement the `vendor.plugin::group.key` parsing as a thin lookup layer that resolves to go-i18n message IDs, with per-plugin message bundles merged at boot the same way config namespaces are.
|
||||
|
||||
### YAML parsing: goccy/go-yaml, confirmed necessary not just preferred
|
||||
|
||||
This is the one finding in this pass that changes a "nice to have" into a "must": `gopkg.in/yaml.v3`'s upstream (`go-yaml/yaml`) was archived by its sole maintainer on **April 1, 2025**, with an explicit "THIS PROJECT IS UNMAINTAINED" notice in the README (confirmed by reading the repo directly, not a secondhand blog post). It still compiles and works today because YAML parsing is a solved, static problem, but there will be no fixes for edge cases, no security patches, and no Go-version-compatibility work going forward. `goccy/go-yaml` is the community's de facto replacement: actively released (v1.19.2, Jan 8, 2026), higher YAML-test-suite conformance (355/402 vs 295/402), and it's what koanf's own YAML parser has moved to. Use it directly for `fields.yaml`/`columns.yaml` parsing and let koanf pull it in transitively for config. HIGH confidence, verified by reading the archived repo's own README.
|
||||
|
||||
### OpenAPI generation: swaggo/swag (code-first, comment-driven), not Huma, not hand-written
|
||||
|
||||
Three real approaches exist:
|
||||
|
||||
1. **swaggo/swag** (v1.16.6 stable, 12.9k stars, updated Jul 29, 2026) scans comment annotations above existing `net/http` handlers and generates an OpenAPI/Swagger document. Handlers stay ordinary `func(w http.ResponseWriter, r *http.Request)` — **no signature changes**, which is the deciding factor given the stdlib-first constraint and the plugin model ("plugins register their routes" as ordinary handlers). Its v2 line (`v2.0.0-rc6`, Sep 13, 2026) targets OpenAPI 3.1 but is explicitly not production-ready yet — pin v1.16.6.
|
||||
2. **Huma** (`danielgtaylor/huma`) is framework-agnostic and does support plain `http.ServeMux` via its `humago` adapter — but only by making handlers Huma-shaped: `func(ctx context.Context, input *Input) (*Output, error)` registered through `huma.Register(api, operation, handler)`, not idiomatic `http.HandlerFunc`. This buys automatic request validation and schema generation from Go types (no comment-drift risk, which is swag's classic failure mode), but it's a bigger structural commitment across every one of 154 routes being ported, and it moves request/response shape control into Huma's conventions right when the whole point of v1 is byte-compatible parity with an existing contract. Reasonable to revisit for new (non-ported) plugins later; not recommended for the parity port itself.
|
||||
3. **Hand-written OpenAPI spec** (spec-first, e.g. feeding `oapi-codegen` to generate server stubs) front-loads a lot of authoring work for 154 routes that already need Go handlers written to match PHP responses exactly — writing the spec by hand first and the code second roughly doubles the authoring surface for this specific port. Not recommended at this scale; more attractive for a from-scratch API with far fewer routes.
|
||||
|
||||
**Recommendation:** swaggo/swag v1.16.6, annotate handlers as they're ported, generate the OpenAPI doc, feed it to `openapi-typescript` for the Vue admin's generated types. Revisit Huma for any genuinely new (non-parity) API surface in a later milestone.
|
||||
|
||||
### Testing stack
|
||||
|
||||
- **testcontainers-go** (v0.44.0, Aug 7, 2026) + its `modules/postgres` for real-Postgres integration tests: GORM migrations up/down, River job processing (needs real `LISTEN`/`NOTIFY`), JSON column round-tripping. Gate behind a build tag/`-short` so the fast unit-test loop stays fast.
|
||||
- **testify** (v1.12.1, Aug 17, 2026) for assertions only (`assert`/`require`) — keep tests as plain stdlib `func TestX(t *testing.T)`, no BDD DSL, consistent with stdlib-first.
|
||||
- **Golden-file API parity tests**: no library needed. Record real Płytarium PHP responses as JSON fixtures per route, replay identical requests against the Go backend via `httptest.NewServer`, diff with a normalizer that ignores non-deterministic fields (timestamps, generated IDs) before comparing. This directly implements the PROJECT.md acceptance test.
|
||||
|
||||
### Dev loop and linting
|
||||
|
||||
- **air** (v1.67.4, Aug 1, 2026) for the watch-rebuild loop the compiled-plugin architecture needs to "feel like WinterCMS's drop-in loop" (go-ecosystem.md's own phrase). Point its build command at the full `go build` of the `summer` binary plus the plugin-import-list regeneration step, not just a bare `go build ./...`.
|
||||
- **golangci-lint v2.13.2** (Aug 27, 2026). v2's `.golangci.yml` schema (`version: "2"`) is a breaking change from v1 configs — do not reuse a v1 config verbatim. Configure `depguard` to enforce the stdlib-first / decided-dependency-only constraint at CI time, not just by convention.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
| Recommended | Alternative | When to Use Alternative |
|
||||
|--------------|-------------|--------------------------|
|
||||
| gormigrate | goose | A future plugin needs raw-SQL migrations independent of `*gorm.DB`/GORM entirely. |
|
||||
| gormigrate | atlas | Schema is treated as pure declarative state (GORM struct tags as source of truth) with rare hand-authored data migrations — not this project's shape for v1. |
|
||||
| plain GORM structs | go-gorm/cli or gorm.io/gen | A later, non-ported plugin wants compile-time-checked queries and is willing to add a codegen step. |
|
||||
| swaggo/swag | huma + humago | A later, non-parity API surface where request/response shape isn't constrained by an existing contract, and the team accepts Huma's handler-shape convention project-wide. |
|
||||
| swaggo/swag | hand-written OpenAPI + oapi-codegen | A small, from-scratch API (not a 154-route port) where authoring the spec first is cheaper than annotating existing handlers. |
|
||||
| YAML (via koanf) | literal HOCON via a Go parser | Never, for this project — no maintained Go HOCON library exists at the bar this project applies elsewhere. |
|
||||
| gocent/v3 | hand-rolled Centrifugo HTTP client | Team wants zero additional non-decided dependencies; Centrifugo's HTTP API is small enough (~4 calls) that this is a legitimate, if slightly more work, stdlib-first option. |
|
||||
|
||||
## What NOT to Use
|
||||
|
||||
| Avoid | Why | Use Instead |
|
||||
|-------|-----|--------------|
|
||||
| `gopkg.in/yaml.v3` (`go-yaml/yaml`) | Archived by its maintainer Apr 1, 2025; README explicitly says "THIS PROJECT IS UNMAINTAINED." No further fixes, security patches, or Go-compatibility work will land. | `goccy/go-yaml` v1.19.2 |
|
||||
| `gorm.io/gen` or `go-gorm/cli` for v1 model code | Adds a codegen layer that works against the explicit goal of a simple, reviewable, line-by-line Eloquent-to-GORM port of 25 models. | Plain GORM structs and methods |
|
||||
| `atlas` for v1 migrations | Declarative schema-diff model fights hand-authored, data-aware up/down migrations for 27 real PHP migrations with actual data semantics, and adds a second DSL/binary. | gormigrate |
|
||||
| Sharing a raw `pgxpool.Pool` directly between GORM's postgres driver and River's `riverpgxv5` driver as two independent pools | Two separate pools against the same database is wasteful and defeats the point of "share one pool"; naively wiring them both to the *same* pool object isn't how either library's constructor is designed to be used. | One shared `*sql.DB` (via `pgx/v5/stdlib`) for both GORM and River's queries, plus one small separate `pgxpool.Pool` used only for River's `LISTEN`/`NOTIFY` wake-ups |
|
||||
| zitadel/oidc v4 | Only exists as `v4.0.0-next.4`, a pre-release from Jul 30, 2026 — not production-ready, and older than the current v3.51.0 stable release. | zitadel/oidc v3 (`/v3`), already decided |
|
||||
| Huma for the v1 parity port specifically | Requires restructuring every ported handler into Huma's typed Input/Output convention rather than ordinary `net/http` handlers, right when byte-compatible parity with an existing contract is the entire point. | swaggo/swag, annotating existing handlers |
|
||||
| swaggo/swag v2 (`v2.0.0-rc6`) | Still a release candidate as of Sep 13, 2026; OpenAPI 3.1 support and dependency cleanup are still in flux. | swaggo/swag v1.16.6 |
|
||||
| A literal HOCON parser in Go (`gurkankaymak/hocon`, `go-hocon/hocon`) | Both are tiny, single-or-zero-star projects with unclear or no active maintenance — far below this project's ecosystem-depth bar for every other pick. | koanf + YAML, achieving the same layering design without literal HOCON syntax |
|
||||
| golangci-lint v1-style config on a v2 install | v2 changed the `.golangci.yml` schema; a copied v1 config will not behave as expected. | A `version: "2"` config written against v2's current schema |
|
||||
|
||||
## Stack Patterns by Variant
|
||||
|
||||
**If a plugin needs raw-SQL migrations outside GORM's model layer (e.g. a performance-critical bulk import path querying via bare `pgx`):**
|
||||
- Use goose's Go-code migrations for that plugin specifically
|
||||
- Because gormigrate assumes `*gorm.DB`-shaped `Migrate`/`Rollback` funcs; a plugin that deliberately bypasses GORM for a hot path has no natural `*gorm.DB` to hand it
|
||||
|
||||
**If a later (post-v1) plugin is greenfield rather than a PHP port:**
|
||||
- Consider Huma instead of hand-annotated swag comments, and `go-gorm/cli` instead of plain structs
|
||||
- Because the byte-compatible-parity constraint that rules both out for v1 doesn't apply to code with no existing contract to match
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
| Package A | Compatible With | Notes |
|
||||
|-----------|------------------|-------|
|
||||
| gorm.io/gorm v1.31.2 | gorm.io/driver/postgres v1.6.3 | Driver's own go.mod targets this gorm version; keep them bumped together. |
|
||||
| gorm.io/driver/postgres v1.6.3 | jackc/pgx/v5 v5.10.0 | Pinned directly in the driver's go.mod — this is the pgx version that ends up in `go.sum` for the whole binary via GORM. |
|
||||
| River v0.47.0 | jackc/pgx/v5 (River's own `riverpgxv5` driver requires v5; `riverdatabasesql` driver works with any `database/sql`-registered driver, including pgx's `stdlib` registration) | Go's module resolution (MVS) will pick one pgx/v5 version for the whole build; do not force a divergent `replace` for either GORM's or River's sake — let them converge. |
|
||||
| zitadel/oidc v3.51.0 | go-jose/go-jose/v4 (imported directly in `pkg/op/storage.go`) | v3's storage interfaces use `jose.SignatureAlgorithm` and `*jose.JSONWebKey` types from go-jose v4 in method signatures — a storage implementation's key-handling code will import this directly. |
|
||||
| koanf/v2 v2.3.4 | koanf/parsers/yaml (wraps goccy/go-yaml) | Confirms goccy/go-yaml is already in the dependency tree via koanf; using it directly for `fields.yaml`/`columns.yaml` doesn't add a new library, just a direct dependency on one already present transitively. |
|
||||
| go-i18n/v2 v2.6.1 | golang.org/x/text v0.32.0+ | go-i18n's own recent release notes cite bumping to this x/text version alongside the CLDR v48 update. |
|
||||
| golangci-lint v2.13.2 | `.golangci.yml` schema `version: "2"` | Not backward compatible with v1-style config files without migration. |
|
||||
|
||||
## Sources
|
||||
|
||||
- pkg.go.dev version pages for `gorm.io/gorm`, `gorm.io/driver/postgres`, `github.com/golang-jwt/jwt/v5`, `github.com/go-playground/validator/v10`, `github.com/spf13/cobra`, `gocloud.dev`, `github.com/goccy/go-yaml`, `github.com/stretchr/testify`, `github.com/testcontainers/testcontainers-go` — versions and dates, fetched 2026-09-16. HIGH confidence.
|
||||
- `raw.githubusercontent.com/go-gorm/postgres/master/go.mod` — read directly for the pgx/v5 pin. HIGH confidence.
|
||||
- `riverqueue.com/docs/gorm` — official River+GORM integration guide, the source for the shared-`*sql.DB` pattern. HIGH confidence.
|
||||
- `raw.githubusercontent.com/zitadel/oidc/main/pkg/op/storage.go` — read directly for interface definitions. HIGH confidence.
|
||||
- github.com/zitadel/oidc releases page — v3.51.0 vs v4.0.0-next.4 dates. HIGH confidence.
|
||||
- `raw.githubusercontent.com/go-gormigrate/gormigrate/master/gormigrate.go` and `README.md` — read directly for `RollbackLast`/`RollbackTo`/`MigrateTo` API and usage pattern. HIGH confidence.
|
||||
- github.com/go-yaml/yaml repo page — archived status and "UNMAINTAINED" README notice, read directly. HIGH confidence.
|
||||
- WebSearch results on koanf's yaml parser migrating to goccy/go-yaml — MEDIUM confidence (WebSearch-sourced, not independently re-verified against koanf's own go.mod, but corroborated by two independent search results and by go-i18n's own release notes mentioning a similar unmaintained-YAML-dependency swap).
|
||||
- github.com/swaggo/swag releases page, huma.rocks docs (`bring-your-own-router`, `humago` adapter), oapi-codegen GitHub — OpenAPI approach comparison. HIGH confidence on version/maintenance facts, MEDIUM-HIGH (judgment call) on the recommendation itself.
|
||||
- github.com/gurkankaymak/hocon, github.com/go-hocon/hocon — star counts and activity, read directly. HIGH confidence these are not viable options.
|
||||
- github.com/typesense/typesense-go releases — v3.2.0, Mar 27 2025. MEDIUM confidence this is still the latest tag; worth a fresh check at implementation time given the gap since this research date.
|
||||
- github.com/centrifugal/gocent repo page — v3 module path, star count. MEDIUM confidence (couldn't confirm an exact latest tag/date from the page content retrieved).
|
||||
- github.com/air-verse/air releases — v1.67.4, Aug 1 2026. HIGH confidence.
|
||||
- golangci-lint changelog/release references — v2.13.2, Aug 27 2026, v2 config schema change. HIGH confidence.
|
||||
|
||||
---
|
||||
*Stack research for: Go 1.27 CMS/CMF framework (SummerCMS), v1 = Płytarium port*
|
||||
*Researched: 2026-09-16*
|
||||
185
.planning/research/SUMMARY.md
Normal file
185
.planning/research/SUMMARY.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** SummerCMS (Go) — v1 = Płytarium (fonoteka) headless backend port
|
||||
**Domain:** Go 1.27 WinterCMS/Laravel-shaped CMF, compiled-plugin model, porting a live PHP headless API to Go with byte-level response parity for an unchanged Nuxt 4 app and MCP/OAuth client
|
||||
**Researched:** 2026-09-16
|
||||
**Confidence:** HIGH on stack versions, plugin-architecture mechanics, and PHP-source-grounded pitfalls; MEDIUM on architectural judgment calls (migration tool, OpenAPI approach, config format) and on features/patterns inferred from ecosystem comparison rather than direct source reading
|
||||
|
||||
## Executive Summary
|
||||
|
||||
SummerCMS v1 is not a from-scratch CMS design problem — it is a constrained port of a fully-specified, already-running PHP application (Płytarium/fonoteka: 25 models, 154 routes, 27 migrations, 3 jobs, 3 console commands) onto a compiled-plugin Go framework whose shape (Caddy/xcaddy-style build-time registration) is already decided and well-precedented. All four research passes converge on the same operating principle: **the PHP source is the spec, and the parity harness is the compiler.** Every stack pick, feature, architectural pattern, and pitfall in this research is grounded in reading the actual `fonoteka` PHP source (models, controllers, routes.php, serializer traits) rather than a generic "how do headless CMSes work" survey — this is the single most load-bearing fact for how the roadmap should be sequenced.
|
||||
|
||||
The recommended approach: stand up a minimal kernel (config, plugin registry, event bus, container) only far enough to support one real vertical slice — `GET /_fonoteka/api/v1/genres` through every layer (config → DB → plugin registry → routing → JWT auth → GORM → JSON response), diffed byte-for-byte against a recorded PHP fixture — before any further kernel abstraction work. This directly avoids the one documented failure mode this project has already suffered once: the Scala predecessor stalled building "Illuminate-shaped infrastructure bottom-up with no real app pulling requirements" (`why-go-not-scala.md`). In parallel, an API-parity fixture-recording harness can start at t=0 with zero Go code, since it only needs a running PHP backend to record against — this should be treated as an independent workstream, not a phase gated behind framework progress.
|
||||
|
||||
The key risks are not "will Go's ecosystem support this" (STACK.md and go-ecosystem.md answer that affirmatively for every named concern, OAuth2/OIDC included) but **silent parity drift**: Go's idioms actively fight several PHP behaviors this app depends on byte-for-byte — nil slices marshal to `null` where PHP guarantees `[]`, `time.Time` renders `Z` where Carbon renders `+00:00`, a plain `bool` cannot express PHP's tri-state `null`/`true`/`false` fields, GORM's default many-to-many silently drops the `sort_order` pivot column driving album-artist display order, and a "clean" unified response-envelope or error-middleware instinct will break the RFC 8414/6749 OAuth endpoints the MCP server and ChatGPT connector depend on. None of these fail loudly; all are exactly the class of bug a parity harness diffing full response bodies (not just status codes) is built to catch. The roadmap should treat the parity harness as a first-class deliverable from phase 1, not a late QA pass.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The stack is almost entirely already-decided (per PROJECT.md); this research pass confirmed versions and resolved five open integration questions. All core picks (Go 1.27, GORM v1.31.2 + `gorm.io/driver/postgres` v1.6.3, River v0.47.0, zitadel/oidc v3.51.0, golang-jwt/jwt v5.3.1, go-playground/validator v10.30.4, cobra v1.10.2, gocloud.dev v0.46.0, koanf/v2, go-i18n/v2) are current and actively maintained as of 2026-09-16 (verified via pkg.go.dev, GitHub, and official docs directly, not secondhand).
|
||||
|
||||
**Core technologies and resolved questions:**
|
||||
- **GORM + River share one `*sql.DB`** (via `pgx/v5/stdlib`), not two independent pools — River's own docs (`riverqueue.com/docs/gorm`) document this pattern directly; a small separate `pgxpool.Pool` is used only for River's LISTEN/NOTIFY wake-ups.
|
||||
- **Migrations: gormigrate, not goose.** This is a load-bearing correction to earlier documents (ARCHITECTURE.md and PITFALLS.md both still reference goose per the older `go-ecosystem.md` pick). STACK.md's deeper dive concludes gormigrate's plain `[]*gormigrate.Migration` structs (with first-class `RollbackLast()`/`RollbackTo()`) compose more naturally with per-plugin migration sets aggregated at boot than goose's filesystem-and-provider model, and matches the "drop the last migration and fix it" workflow PROJECT.md names verbatim. **This is a genuine open decision to close before the data-layer phase — see Gaps below.**
|
||||
- **Config: koanf + plain YAML, not literal HOCON.** No maintained Go HOCON parser exists at this project's dependency-quality bar. PROJECT.md's "HOCON-style layered config" should be read as a layering *design* (base + env overlay + per-plugin namespace), which koanf achieves natively with YAML — not a mandate for HOCON syntax.
|
||||
- **YAML parsing: goccy/go-yaml, not `gopkg.in/yaml.v3`.** `gopkg.in/yaml.v3`'s upstream was archived by its maintainer April 2025 and is explicitly marked unmaintained (confirmed by reading the repo directly) — this changes a style preference into a must, for both `fields.yaml`/`columns.yaml` parsing and (transitively, via koanf) config.
|
||||
- **OpenAPI: swaggo/swag (comment-driven, code-first), not Huma.** Huma's typed-handler convention would restructure all 154 ported routes right when byte-compatible parity with an existing contract is the entire point; swag annotates ordinary `net/http` handlers with no signature changes.
|
||||
- **GORM plain structs, not gorm gen/go-gorm/cli codegen**, for v1 — matches the explicit "simplest line-by-line Eloquent-to-GORM port" design goal.
|
||||
|
||||
### Expected Features
|
||||
|
||||
Płytarium's actual source (not a generic CMS feature list) defines table stakes. FEATURES.md read `Plugin.php`, `routes.php` (569 lines / ~160 routes), all 5 admin controllers' YAML configs, all 25 models, 3 jobs, and 3 console commands directly.
|
||||
|
||||
**Must have (table stakes, all P1):**
|
||||
- Plugin descriptor with register/boot lifecycle, dependency declaration, and cross-plugin extension via events (not inheritance) — the core mechanism plugins use to extend each other (`getApiArray` payload extension, model-created notification listeners, auth guard registration)
|
||||
- Full GORM relation/cast/hook fidelity: pivot models with business columns (not bare pivot arrays), polymorphic file attachments, `$jsonable`/`$fillable`/`$hidden`/`encrypted`-cast discipline, cascading soft-delete inside transactions, custom attribute casts (e.g. money as fixed-decimal string)
|
||||
- **Three parallel, mutually-exclusive auth groups sharing controllers**: JWT (SPA), personal scoped token (`inv.scope:read|write|ai`, used by MCP), and public/onboarding — the single hardest routing requirement, since the same controller must serve different route subsets per group, plus 7+ named per-bucket rate limiters
|
||||
- OAuth2.1 authorization server (auth code + PKCE, refresh tokens, RFC 7591 DCR, RFC 8414 discovery) for MCP/ChatGPT — already the named zitadel/oidc pick
|
||||
- `fields.yaml`/`columns.yaml` → JSON schema pipeline including a **relation-manager schema type** (link/unlink UI for a belongsToMany with pivot) — used once in Płytarium (Collections' editors tab) but structurally necessary
|
||||
- River-backed jobs with a debounce/coalescing pattern (wishlist digest) and a self-redispatching rate-limit-aware pattern (Discogs match job) — not just "a queue exists"
|
||||
- Centrifugo publisher + pluggable per-channel-namespace authorizer registry (kept as existing infra, only publisher/token-issuing ported)
|
||||
- Typesense sync that is tenant-scoped and always treated as a pre-filter re-gated in SQL, never a source of truth
|
||||
- Multi-step, resumable, queued CSV import pipeline (not a single upload action)
|
||||
- 3-tier BYOK-or-org-or-site-admin credential resolver pattern (used identically for Discogs and AI credentials)
|
||||
|
||||
**Should have / differentiators (not v1 blockers):** compile-time plugin registration removing WinterCMS's `class_exists`/`elevated` boot-order fragility; a single typed "fire and collect vs. fire and forget" event bus generalizing two hand-rolled PHP patterns into one kernel primitive; generalizing the 3-tier credential resolver into one reusable piece instead of two copies; a first-class relation-manager schema type replacing the `partial` field-type escape hatch.
|
||||
|
||||
**Explicitly deferred (confirmed unused by Płytarium, do not build for v1):** server-rendered theme engine, backend AJAX/partial-refresh framework, `Sluggable`/`Sortable`/`NestedTree`/`Revisionable` model behaviors (none used anywhere in the plugin — slugs are hand-rolled in lifecycle hooks), MySQL support, payments/chat/forum/video, policy-based row/field-level admin permissions (Płytarium's admin RBAC is coarse role gates only).
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
Two-repo split: `summercms.go` (framework core, one Go module: `pact`, `towel`, `compass`, `festival`, `backpack`, `party`, `bonfire`, `surf`, `lagoon`, `bouncer`, `lifeguard`, `cooler`, `conga`, `postcard`, `sandcastle`, `phrasebook`, `sunset`) and `fonoteka.go` (the application, a `go.work` workspace of per-plugin Go modules requiring the framework). The framework has zero knowledge of Płytarium; plugins import the framework, never the reverse. Stack plugins (user, translate, websockets) live inside the app repo's workspace for v1 and are only extracted to their own repos when a second app needs them.
|
||||
|
||||
**Major components:**
|
||||
1. **`party` (plugin registry)** — Caddy/xcaddy-style: each plugin self-registers via `init()`, `summer build` regenerates a blank-import list; one required `Plugin` interface (`ID/Requires/Register/Boot`) plus small optional capability interfaces (`HasModels`, `HasRoutes`, `HasJobs`, etc.) replace WinterCMS's optional-override inheritance.
|
||||
2. **Three concrete plugin-extends-plugin mechanisms**, not one generic hook system: (a) typed "filter" events on `festival` for response/serialization extension, (b) GORM's own callback registry (`db.Callback()`) for model lifecycle extension, (c) migration + companion embedding struct for schema extension (one plugin adding columns to another's table). Conflating these into one abstraction is explicitly called out as the wrong move.
|
||||
3. **`lagoon` (GORM/Postgres) + `conga` (River)** sharing one `*sql.DB`, with a separate pgx listener pool for LISTEN/NOTIFY — the concrete first-party-documented integration pattern from STACK.md.
|
||||
4. **`surf` (routing)** implementing the three-auth-group/per-bucket-rate-limit requirement as first-class routing structure, with OAuth/RFC routes in a structurally separate group carrying no shared envelope/error middleware.
|
||||
5. **First vertical slice**: `GET /_fonoteka/api/v1/genres` end-to-end (config → container → migration → plugin registry → JWT-guarded route → GORM → JSON diffed against a PHP fixture) is identified as the one phase that cannot be skipped or parallelized — every other phase's pattern is a repetition of this slice's pieces.
|
||||
6. Suggested build order is a 9-layer DAG (pact/towel → compass/phrasebook/bonfire → festival → backpack → party → lagoon/surf → bouncer → [vertical-slice checkpoint] → lifeguard/cooler/conga/postcard/sandcastle → admin schema + parity harness + bulk model/route porting, parallelizable → sunset last, thin).
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
Fifteen critical pitfalls were identified, each cited to specific PHP source lines. The five most consequential for roadmap sequencing:
|
||||
|
||||
1. **Bottom-up kernel over-building (recurrence risk)** — this project already failed once this way in Scala. Avoid by interleaving kernel work with the first real vertical slice; never let plugin descriptor/event-bus/config shape get a "finished" design pass before a real model/route exists end to end.
|
||||
2. **Package-level global state instead of `context.Context`** for per-request org/tenant/collection resolution — PHP-FPM's process-per-request model silently protected against this; Go's shared-process concurrency does not. Must be settled as a convention before any plugin consumes it.
|
||||
3. **Reusing GORM model structs as JSON request-binding structs** — loses the `$fillable`/`$guarded` mass-assignment boundary that is a named security invariant in the PHP source (e.g., `market_price_source`, `public_token` deliberately excluded). Requires per-endpoint DTOs from the first ported model onward, not retrofitted later.
|
||||
4. **A "clean" generic response envelope or blanket error/auth middleware** — the PHP house style deliberately has at least three incompatible envelope families (house REST with no `links` key, Laravel-shaped validation-error maps, and unenveloped RFC 8414/6749 OAuth responses with specific cache headers). A single `APIResponse[T]` generic wrapper is flagged explicitly as a smell to catch in phase planning, not a shortcut to allow.
|
||||
5. **Type-level parity gaps that don't fail loudly**: nil slice → `null` vs PHP's guaranteed `[]`; `time.Time` → `Z` vs Carbon's `+00:00`; plain `bool` unable to express tri-state null/true/false; `float64` reintroducing float-precision bugs PHP deliberately avoided for money fields via a custom cast. All of these require the parity harness to diff full response bodies field-by-field, not just check status codes or rough shape.
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
### Phase 1: Kernel foundation + first vertical slice (interleaved, not sequential)
|
||||
**Rationale:** Directly avoids Pitfall 1 (the documented Scala failure mode). The kernel must be built exactly as far as the first vertical slice needs it, no further, before any additional kernel design work.
|
||||
**Delivers:** `compass` (config), `backpack` (container), `festival` (event bus), `party` (plugin registry + `summer build`), `bonfire` (CLI skeleton) — each only fleshed out to the point `GET /_fonoteka/api/v1/genres` requires. Also establishes the `context.Context`-based org/tenant-resolution convention (Pitfall 2) before any plugin uses it.
|
||||
**Addresses:** Kernel/plugins table-stakes features (plugin descriptor, dependency declaration, event bus, console command registration).
|
||||
**Avoids:** Pitfall 1 (bottom-up over-build), Pitfall 2 (global state vs. context).
|
||||
|
||||
### Phase 2: First vertical slice checkpoint — one real endpoint end to end
|
||||
**Rationale:** ARCHITECTURE.md identifies this as the one phase that cannot be skipped or parallelized: every later phase depends on all its pieces (config, container, migration, plugin registry, routing, JWT auth, GORM, JSON response) existing together.
|
||||
**Delivers:** `Genre` (or `Style`) model + migration (via gormigrate — see Gaps), one JWT-guarded route through `surf`, a working `party` plugin registration, and the first parity-harness fixture (recorded from the live PHP backend, diffed byte-for-byte).
|
||||
**Uses:** GORM + gormigrate, `golang-jwt/jwt`, stdlib `ServeMux`.
|
||||
**Implements:** The full request-flow pattern in ARCHITECTURE.md that every subsequent route repeats.
|
||||
|
||||
### Phase 3: API parity harness (parallel workstream, starts at t=0)
|
||||
**Rationale:** Near-zero dependency on framework internals — it only needs a running PHP backend to record fixtures against. All four research passes agree this should not be gated behind kernel/framework progress.
|
||||
**Delivers:** Fixture-recording tooling against the live PHP backend, an `httptest.Server`-based replay-and-diff harness with a normalizer for non-deterministic fields, and — critically — assertions covering the specific parity classes named in Pitfalls 4/6/7/8 (nil-vs-empty-array, date format/null-vs-omitted, tri-state bool, envelope/conditional-key shape), not just status-code/rough-shape checks.
|
||||
**Avoids:** Pitfalls 4, 5, 6, 7, 8 (all of which "look done but aren't" without a byte-level diff).
|
||||
|
||||
### Phase 4: Data layer — full model/relation/cast fidelity
|
||||
**Rationale:** Every endpoint touches this; the security-load-bearing `$fillable`/`$hidden`/encrypted-cast discipline and the DTO-vs-model convention (Pitfall 3) must be established here, not retrofitted after models exist.
|
||||
**Delivers:** Per-plugin gormigrate migration sets (one per PHP migration, up/down tested), all 25 models with correct relation types (dedicated pivot models with business columns, not bare arrays — Pitfall 12), polymorphic file attachments, soft-delete + partial unique index audits (Pitfall 11), money-as-fixed-decimal-string and other custom casts (Pitfall 5).
|
||||
**Addresses:** Data/models table-stakes row in FEATURES.md in full.
|
||||
**Avoids:** Pitfalls 3, 5, 11, 12, 13 (GORM AutoMigrate misuse).
|
||||
|
||||
### Phase 5: HTTP/auth — three-group routing, rate limiting, personal tokens
|
||||
**Rationale:** The entire 154-route API surface sits behind this; retrofitting the routing structure after routes exist is called out explicitly as expensive.
|
||||
**Delivers:** JWT + personal-scoped-token (`inv.scope`) + public/onboarding route groups sharing controllers, named per-bucket rate limiters ported 1:1 (not redesigned), the must-change-password gate with its one deliberate exception route, and — critically — a structural exemption of any future OAuth/RFC route group from generic envelope/error middleware, decided now rather than after 26 models' worth of routes exist.
|
||||
**Uses:** `surf`, `bouncer`, `cooler` (rate-limit buckets).
|
||||
**Avoids:** Pitfall 9 (blanket middleware corrupting OAuth), "rate-limit bucket parity" moderate pitfall.
|
||||
|
||||
### Phase 6: OAuth2.1 server (zitadel/oidc)
|
||||
**Rationale:** Depends on the routing/middleware abstraction from Phase 5 existing first (same "not-the-SPA's-JWT" pattern), but is a distinct credential type (`OAuthClient`/`OAuthAuthCode`/`OAuthRefreshToken` vs. `ApiToken`) — build the shared abstraction once, then implement both guards against it.
|
||||
**Delivers:** `AuthStorage`/`OPStorage` interface implementations mapped onto the existing OAuth models, RFC 8414/7591 endpoints, and exact `WWW-Authenticate`/Protected-Resource-Metadata header contract (Pitfall 10) verified against the actual `fonoteka-mcp` client, not just unit tests.
|
||||
**Avoids:** Pitfalls 9, 10; the "trusting zitadel/oidc defaults without diffing" technical-debt pattern.
|
||||
**Research flag:** needs verification of whether `ClientCredentialsStorage`/`TokenExchangeStorage` are actually needed (check `wavepath.org/plugins/golem15/oauthserver` before assuming not).
|
||||
|
||||
### Phase 7: Admin schema pipeline (fields.yaml/columns.yaml → JSON) + minimal Vue 3 SPA
|
||||
**Rationale:** Requires the data-layer serialization boundary (Phase 4) to already exist; cannot be phased independently of it per FEATURES.md's dependency notes.
|
||||
**Delivers:** Form/list/relation-manager JSON schema for the 5 admin controllers, OpenAPI generation via swaggo/swag, generated TS types, and a decision on the `partial` field-type escape hatch (likely: replace with the relation-manager schema directly, per the Collections' editors-tab case).
|
||||
**Research flag:** relation-manager schema design and the `partial`-type replacement are the least-precedented parts of this research pass (used once in the PHP source) — worth a `--research-phase` pass.
|
||||
|
||||
### Phase 8: Background jobs, realtime, search, integrations
|
||||
**Rationale:** Depends on Phase 4 (models/events) and benefits from Phase 1's event bus; independently parallelizable across CSV import, Discogs/AI clients, Centrifugo, and Typesense once the data layer is stable.
|
||||
**Delivers:** River with the correct dual-driver split (`riverdatabasesql` for transactional enqueue, `riverpgxv5` for LISTEN/NOTIFY work processing — Pitfall 14), the CSV import multi-step pipeline, wishlist digest debounce, Centrifugo suppress-then-emit-once broadcast pattern, Typesense sync with the SQL re-gate (never trusting search results as pre-authorized — Pitfall 15), Discogs/AI clients with the 3-tier BYOK credential resolver.
|
||||
**Avoids:** Pitfalls 14, 15, and the moderate "Centrifugo broadcast-suppression" and "rate-limit bucket parity" pitfalls.
|
||||
|
||||
### Phase 9: Bulk remaining route/model port + full parity cutover
|
||||
**Rationale:** By this point the pattern is proven and repeatable; this phase is mostly volume, parallelizable across plugins/models gated only by FK order (the same DAG the 27 PHP migrations already encode).
|
||||
**Delivers:** All remaining routes/models, full parity-harness green against all 154 routes, `vue-fonoteka-app` and `fonoteka-mcp` running unchanged against the Go backend — the literal definition of done in PROJECT.md.
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- Kernel and first-slice phases are interleaved specifically to prevent the Scala-era failure mode from recurring — this is the single strongest signal across all four research documents.
|
||||
- The parity harness is pulled out as an explicit parallel-from-t=0 workstream because three of the four research documents independently flag it as near-zero-dependency and because its assertions are the actual mechanism that catches most of the "critical pitfalls" — sequencing it late would defeat its purpose.
|
||||
- Data layer precedes HTTP/auth precedes OAuth because each has a documented one-way dependency (auth needs models to authenticate against; OAuth reuses the auth-group routing pattern) — reversing this order was explicitly flagged as more expensive to retrofit.
|
||||
- Admin schema pipeline is placed after data layer specifically because FEATURES.md's dependency notes say these "cannot be phased independently" without under-building the `$fillable`/`$hidden` security contract.
|
||||
- Background/integrations is placed late because it is the most parallelizable-once-unblocked phase and has the fewest cross-dependencies on routing/OAuth decisions.
|
||||
|
||||
### Research Flags
|
||||
|
||||
Needs deeper research during planning:
|
||||
- **OAuth2.1 phase** — `zitadel/oidc` storage-interface implementation against the real PHP OAuth models, and the exact `ClientCredentialsStorage`/`TokenExchangeStorage` scope question (check `wavepath.org/plugins/golem15/oauthserver` first).
|
||||
- **Admin schema pipeline phase** — the relation-manager schema type and the `partial` field-type replacement are the least-precedented design surface in this research (one real usage in the PHP source, no direct library equivalent).
|
||||
- **Background/jobs phase** — River's dual-driver (`riverdatabasesql` vs `riverpgxv5`) split is documented but not yet implemented in this codebase; verify the transactional-enqueue-plus-LISTEN/NOTIFY pattern against a real test before trusting the docs-only description.
|
||||
|
||||
Phases with standard, well-documented patterns (safe to skip `--research-phase`):
|
||||
- **Kernel/plugin-registry phase** — Caddy/xcaddy's compiled-plugin model is directly precedented and already verified against real xcaddy-generated code.
|
||||
- **Data layer phase** — GORM patterns (relations, casts, callbacks) are well-documented; the risk here is PHP-source-fidelity auditing (a planning/testing discipline), not unknown Go patterns.
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | Versions and maintenance status verified directly via pkg.go.dev/GitHub/official docs this session; MEDIUM on judgment calls (migration tool, OpenAPI approach, config format) but those calls are well-reasoned and explicit about trade-offs |
|
||||
| Features | HIGH for table stakes (grounded in direct reading of the full `fonoteka` PHP source — Plugin.php, routes.php, all models/controllers/YAML configs); MEDIUM for differentiators and anti-features (informed by ecosystem comparison, not exhaustively re-audited) |
|
||||
| Architecture | HIGH for plugin-registration mechanics (verified against real Caddy/xcaddy docs) and request-lifecycle mapping (grounded in the real `Plugin.php`/`routes.php`); MEDIUM for the cross-plugin schema-extension pattern and the two-repo split (design recommendation, not yet validated by an actual build) |
|
||||
| Pitfalls | HIGH for anything cited to actual PHP source (routes.php, controllers/api/*.php, models/Album.php, SerializesFonoteka.php, fonoteka-mcp/src/http.ts) or official GORM/River docs; MEDIUM for Go-ecosystem-general claims and the "Laravel-shaped framework in Go" class of pitfall |
|
||||
|
||||
**Overall confidence:** HIGH — this research is unusually well-grounded because three of the four passes read the actual target PHP application's source directly rather than reasoning from a generic domain description.
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **gormigrate vs. goose is not yet settled across documents.** STACK.md's deep-dive recommends gormigrate (better fit for per-plugin migration composition and the "drop the last migration and fix it" workflow); ARCHITECTURE.md and PITFALLS.md still reference goose per the earlier `go-ecosystem.md` pick and have not been updated. **Resolve this explicitly before the data-layer phase** — do not let two documents silently disagree into implementation. STACK.md's reasoning is more recent and more specific to this project's shape; the roadmap should default to gormigrate unless a phase decision overrides it.
|
||||
- **PROJECT.md's field-type list needs correction.** PROJECT.md's Active requirements list `number`, `fileupload`, `repeater`, `datepicker` as WinterCMS field types Płytarium uses; FEATURES.md's direct read of all 5 controllers' `fields.yaml` files found the actual set is `text`, `textarea`, `checkbox`, `switch`, `dropdown` (model-method-fed), `relation`, and `partial` (an escape hatch, not a clean schema type) — no `number`/`fileupload`/`repeater`/`datepicker` anywhere in the plugin. Update PROJECT.md's requirement line to match, and treat `partial` as the one field type needing a bespoke design decision (see Phase 7's research flag).
|
||||
- **PROJECT.md's "HOCON-style layered config" wording should be clarified as a design, not a syntax mandate** — no viable Go HOCON parser exists; koanf + YAML achieves the same layering semantics. Worth a one-line PROJECT.md update so a future reader doesn't chase a literal HOCON parser.
|
||||
- **No Sluggable/Sortable/NestedTree/Revisionable behaviors are used anywhere in Płytarium** — confirmed by direct source read. These should not appear as implicit v1 scope anywhere; PROJECT.md does not currently list them, but flag this explicitly so no phase accidentally builds a packaged Sluggable behavior when the PHP source only ever hand-rolls slugs in lifecycle hooks.
|
||||
- **`gopkg.in/yaml.v3` is unmaintained (archived April 2025)** — any lingering assumption (in code, docs, or a future dependency choice) that yaml.v3 is the default YAML library should be corrected to `goccy/go-yaml` before it's load-bearing anywhere.
|
||||
- **typesense-go (v3.2.0, Mar 2025) and centrifugal/gocent/v3's exact latest tag** are both flagged MEDIUM confidence in STACK.md due to research-session gaps since their last verified release — re-check both at the implementation time of the search/realtime phase rather than trusting the pinned versions blindly.
|
||||
- **zitadel/oidc's `ClientCredentialsStorage`/`TokenExchangeStorage` necessity** is an open question STACK.md itself flags — resolve by reading `wavepath.org/plugins/golem15/oauthserver` before the OAuth phase's interface-implementation work begins.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/` — Plugin.php, routes.php (569 lines/~160 routes), all 5 admin controllers + config_form/config_list/config_relation.yaml, all 25 models, 3 jobs, 3 console commands, config/fonoteka.php (read directly for FEATURES.md, ARCHITECTURE.md, PITFALLS.md)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/traits/SerializesFonoteka.php`, `controllers/api/AlbumApiController.php`, `controllers/api/OAuthTokenController.php`, `controllers/api/OAuthMetadataController.php`, `models/Album.php` — serialization/envelope/security contract detail (PITFALLS.md)
|
||||
- `/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts`, `vue-fonoteka-app/app/composables/useCentrifugo.ts` — client-side contract confirmation
|
||||
- pkg.go.dev version pages, GitHub repos/releases for every core and supporting library (GORM, River, zitadel/oidc, golang-jwt, goccy/go-yaml, gormigrate, swaggo/swag, etc.) — fetched/read directly 2026-09-16
|
||||
- `riverqueue.com/docs/gorm` — official River+GORM pool-sharing integration guide
|
||||
- Caddy/xcaddy official docs and generated `main.go` mechanics; PocketBase hook-system/schema-driven-admin docs
|
||||
- This repo's `.planning/PROJECT.md`, `.planning/notes/why-go-not-scala.md`, `.planning/research/go-ecosystem.md`, `CLAUDE.md`
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `/media/nvme/dev/golem15/fonoteka/plugins/golem15/user/`, `plugins/golem15/websockets/` — skimmed, not fully read
|
||||
- WebSearch: Directus/Strapi/PocketBase/Goravel 2026 comparisons (cross-checked against training knowledge, MEDIUM confidence)
|
||||
- koanf's YAML parser migrating to goccy/go-yaml — WebSearch-sourced, corroborated by two independent results and go-i18n's own release notes
|
||||
|
||||
### Tertiary (LOW confidence, needs validation)
|
||||
- Exact latest tags for typesense-go and centrifugal/gocent/v3 (both flagged as needing a fresh check at implementation time)
|
||||
- otter-vs-ristretto cache performance claim carried from go-ecosystem.md (maintainer benchmarks, not independently verified)
|
||||
|
||||
---
|
||||
*Research completed: 2026-09-16*
|
||||
*Ready for roadmap: yes*
|
||||
Reference in New Issue
Block a user