--- phase: 05-data-layer-full-fidelity plan: 02 subsystem: database tags: [gorm, gormigrate, jsonable, money, validation, pivot, lifecycle, fillable] requires: - phase: 05-data-layer-full-fidelity provides: Winter-directory registries, lagoon.Fill, WithSoftDeleteCascade, RegisterJoinTable provides: - six fonoteka album-slice migrations with idempotent Various Artists seed - widened Album/Collection plus Artist, Style, AlbumArtist, AlbumRating, AlbumReservation - lagoon.Jsonable[T] TEXT cast preserving SQL NULL vs empty array - MoneyString 4-decimal numeric cast with no binary floating type - lagoon.Validate Laravel rule-string engine with unique:table soft-delete scoping - Artist/Genre/Style/Collection lifecycle hooks and Album.BeforeSave - AlbumWriteService/CollectionWriteService fill boundaries and syncArtists affects: [05-03-secrets, 05-04-attachments, 05-05-remaining-models, 05-06-tests] tech-stack: added: - github.com/go-playground/validator/v10 v10.30.4 patterns: - Jsonable struct with gorm-ignored inner fields so GORM uses Scanner/Valuer - pivot writes via delete-then-bulk-insert, never Association().Append - KeepMarketPriceSource gorm:"-" flag threads source provenance into BeforeSave key-files: created: - lagoon/jsonable.go - lagoon/jsonable_test.go - lagoon/validate.go - lagoon/validate_test.go - plugins/golem15/fonoteka/updates/10_album_slice.go - plugins/golem15/fonoteka/models/artist.go - plugins/golem15/fonoteka/models/style.go - plugins/golem15/fonoteka/models/album_artist.go - plugins/golem15/fonoteka/models/album_rating.go - plugins/golem15/fonoteka/models/album_reservation.go - plugins/golem15/fonoteka/models/money_string.go - plugins/golem15/fonoteka/models/slug.go - plugins/golem15/fonoteka/classes/album_write_service.go - plugins/golem15/fonoteka/classes/collection_write_service.go - plugins/golem15/fonoteka/classes/artist_resolver.go - plugins/golem15/fonoteka/classes/join_tables.go - plugins/golem15/fonoteka/classes/serialize.go modified: - lagoon/fill.go - plugins/golem15/fonoteka/models/album.go - plugins/golem15/fonoteka/models/collection.go - plugins/golem15/fonoteka/models/collection_editor.go - plugins/golem15/fonoteka/models/genre.go - plugins/golem15/fonoteka/go.mod - parity/migrate_test.go key-decisions: - "Jsonable payload field is Data, not Value, because driver.Valuer.Value() collides under go vet" - "KeepMarketPriceSource is a gorm-ignored Album flag set by SaveAlbum when requested contains market_price_source" - "CollectionFillFields is name+description; PHP has no CollectionWriteService" - "Various Artists natural key is name_key='various artists' (PHP seed), slug various-artists" - "go-playground/validator v10.30.4 is the STACK-named rule engine behind lagoon.Validate" patterns-established: - "One Register call per model/migration/class file init(); plugin.go is not edited" - "Pivot business columns are written with explicit SQL/GORM Create, Preload uses RegisterJoinTable" - "Money never lives as a binary floating type; range checks use big.Rat" requirements-completed: [DATA-03, DATA-04, DATA-05, DATA-06, DATA-07, DATA-09] duration: 27min completed: 2026-09-18 --- # Phase 5 Plan 02: Album/Collection vertical slice Summary **Album/Collection slice with six gormigrate steps, Jsonable/MoneyString casts, lagoon.Validate, ordered album_artists pivot writes, and PHP lifecycle hooks proven on real Postgres** ## Performance - **Duration:** 27 min - **Started:** 2026-09-18T16:55:11Z - **Completed:** 2026-09-18T17:22:30Z - **Tasks:** 3 - **Files modified:** 32 ## Accomplishments - Six Fonoteka migrations widen collections/albums (including `track_titles` and TEXT `discogs_id`) and create artists (Various Artists seed), styles, ratings, and reservations; each rolls back in isolation - `lagoon.Jsonable[T]` round-trips SQL NULL vs `'[]'`; `MoneyString` formats Postgres `numeric(10,4)` to a 4-decimal string with no binary floating type in the cast - Artist/Genre/Style `BeforeValidate`/`AfterDelete` and Collection `BeforeDelete` cascade (via `WithSoftDeleteCascade`) are ported from PHP and tested against real Postgres - `lagoon.Validate` translates this slice's rule strings (`between`, quoted `oneof`, custom money range, `unique:table` with `deleted_at`) and fails loudly on unknown tokens - `SaveAlbum` uses the PHP `FILL_FIELDS` subset, `syncArtists` delete-then-insert, and `Album.BeforeSave` (`refreshTrackTitles` + `stampMarketPrice`) including the PHP ceiling case `"12.55555"` → `"12.5556"` ## Task Commits Each task was committed atomically: 1. **Task 1: Album/collection slice migrations** - `e24a614` (feat) in fonoteka.go 2. **Task 2: lagoon.Jsonable** - `20b0199` (feat) in summercms.go 3. **Task 2: Widen models and port lifecycle hooks** - `445133e` (feat) in fonoteka.go 4. **Task 3: lagoon.Validate** - `2c5d92d` (feat) in summercms.go 5. **Task 3: Write services and BeforeSave** - `4879996` (feat) in fonoteka.go **Plan metadata:** pending (this file) ## Files Created/Modified - `lagoon/jsonable.go`, `lagoon/jsonable_test.go` — TEXT JSON cast, NULL vs `[]` - `lagoon/validate.go`, `lagoon/validate_test.go` — rule-string engine - `lagoon/fill.go` — nil zeros the field; jsonable columns Scan from JSON - `fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go` — six migrations - `fonoteka.go/plugins/golem15/fonoteka/models/album.go` — final columns, Fillable/Rules, BeforeSave - `fonoteka.go/plugins/golem15/fonoteka/models/{artist,style,album_artist,album_rating,album_reservation,money_string,slug}.go` - `fonoteka.go/plugins/golem15/fonoteka/models/{collection,collection_editor,genre}.go` — widened + hooks - `fonoteka.go/plugins/golem15/fonoteka/classes/{album_write_service,collection_write_service,artist_resolver,join_tables,serialize}.go` - `fonoteka.go/parity/migrate_test.go`, `album_slice_models_test.go`, `album_write_service_test.go` ## Decisions Made - Jsonable payload is `Data` (not `Value`) because `driver.Valuer.Value()` plus a field named `Value` fails `go vet` - `KeepMarketPriceSource` is a `gorm:"-"` flag on Album, set by `SaveAlbum` when `requested` contains `market_price_source` - `CollectionFillFields` is `name`, `description` — PHP has no CollectionWriteService, so the service list is a subset of `Fillable()` excluding `owner_id` - Various Artists seed uses PHP's natural key `name_key = "various artists"` (slug `various-artists`) - `github.com/go-playground/validator/v10 v10.30.4` is added to the framework module as the STACK-named engine behind `lagoon.Validate` ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 3 - Blocking] Jsonable cannot use a field named Value** - **Found during:** Task 2 (Jsonable cast) - **Issue:** `go vet` rejects a struct field and method both named `Value`; `type Jsonable[T] T` is also illegal in Go - **Fix:** Struct with `Data`/`Valid`/`NullOnEmpty` tagged `gorm:"-"` so GORM uses Scanner/Valuer instead of walking inner fields - **Files modified:** `lagoon/jsonable.go`, `lagoon/jsonable_test.go` - **Verification:** `go vet ./lagoon/...` and `go test ./lagoon/... -run TestJsonable` - **Committed in:** `20b0199` (Task 2) **2. [Rule 2 - Missing Critical] Fill ignored nil on non-pointer fields** - **Found during:** Task 3 (SaveAlbum money nil-in → NULL-out) - **Issue:** `lagoon.Fill` left `MoneyString` unchanged on explicit nil, so the blank-to-NULL path could not be requested - **Fix:** nil now zeros any field; types implementing `sql.Scanner` (Jsonable) Scan from JSON when convertValue cannot assign - **Files modified:** `lagoon/fill.go` - **Verification:** `TestSaveAlbumMoneyNormalization` nil case - **Committed in:** `2c5d92d` (Task 3) **3. [Rule 3 - Blocking] Existing migrate tests hardcoded two Fonoteka IDs** - **Found during:** Task 1 (six new migrations) - **Issue:** `TestMigrateSeedsCanonicalGenres` and `TestRollbackLastIsolatesFonoteka` expected exactly the Phase-3 pair - **Fix:** Expect eight IDs; RollbackLast now asserts `create_album_reservations` drops only that table - **Files modified:** `fonoteka.go/parity/migrate_test.go` - **Verification:** those tests plus `TestAlbumSliceMigrationsUpDown` - **Committed in:** `e24a614` (Task 1) **4. [Rule 2 - Missing Critical] widen_albums/collections needed columns omitted from Phase 3 stubs** - **Found during:** Task 1 (reading folded PHP files) - **Issue:** `description` (collections), `public_enabled`/`public_token_generated_at`, and `shelf`/`quantity`/`notes` exist in PHP but not in `00_base.go` - **Fix:** Added every column the folded PHP files (and the RESEARCH fillable list) require - **Files modified:** `fonoteka.go/plugins/golem15/fonoteka/updates/10_album_slice.go` - **Verification:** `TestAlbumSliceMigrationsUpDown` and later model tests - **Committed in:** `e24a614` (Task 1) --- **Total deviations:** 4 auto-fixed (2 missing critical, 2 blocking) **Impact on plan:** Required for vet, money nil-out, and a compiling schema that matches PHP. No scope creep. Album.Fillable() has 21 names copied from PHP/RESEARCH (plan said 20 — a count error). ## Issues Encountered None beyond the auto-fixes above. ## Authentication Gates None. ## Known Stubs - `classes/artist_resolver.go` `albumBeforeSaveCallback` is a type-guard registration seam; name-to-id find-or-create stays in PHP's `ArtistResolver::resolveOne` and is not needed for this plan's ID-based `syncArtists` - `SerializeAlbum`/`SerializeCollection` omit photos/thumbs (Plan 05-04) and reservation context (Phase 12) ## User Setup Required None - no external service configuration required. ## Next Phase Readiness Ready for 05-03 (secrets/encrypted credentials). Wave-2 Album/Collection models, validations, and write-service fill boundaries are in place. `lagoon.Validate` and `lagoon.Jsonable` are reusable for later models. ## Self-Check: PASSED - Key files exist on disk (jsonable, validate, 10_album_slice, write services, join_tables, models) - Commits `e24a614`, `20b0199`, `445133e`, `2c5d92d`, `4879996` exist - `go vet ./...` and `go test ./lagoon/...` green in summercms.go - `go vet ./...` and `go test ./...` green in fonoteka.go (parity + plugin modules) --- *Phase: 05-data-layer-full-fidelity* *Completed: 2026-09-18*