docs(05-02): complete album-collection vertical slice plan

- Record migrations, Jsonable/MoneyString, Validate, write services, and hooks
This commit is contained in:
Jakub Zych
2026-09-18 19:23:51 +02:00
parent 2c5d92d659
commit 628cffd66f

View File

@@ -0,0 +1,197 @@
---
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*