feat(dashboard): add owner-scoped layouts with atomic revisions

This commit is contained in:
cesnimda
2026-10-21 20:59:58 +02:00
parent 421f902d21
commit 5edbe59692
11 changed files with 539 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# P8-00 — Baseline and architecture
Completed 21 October 2026. Entry `ef94d579cbfec62d1ac38346f556f80276cee95a`, clean working tree, local and direct remote `codex/ui-redesign-phase4` equal. Requested Phase 8 branch did not exist locally or remotely; created `feature/phase8-dashboard-customisation` from that entry. Planning checkpoint `421f902d21c08a814de12d7c32bc6ab8d4c01e15` pushed normally; `git ls-remote` directly confirms it. Main remains outside this work.
Reviewed Phase 7 final acceptance and Phase 8 handoff, Phase 6 handoff, original implementation plan, dashboard/Phase 3 architecture, design system, responsive/accessibility, guardrails, information architecture, flows and master spec. The current implementation confirms only five local visibility flags; attention, stage-age, source-response and salary sections were outside them. The new nine-entry [catalogue and design](../README.md) preserves these sections and all existing data owners.
## Fresh starting tests
| Command | Result | Evidence |
|---|---|---|
| Pinned .NET `test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore` | 1,060 passed; 0 failed, skipped; 16s test duration | `.artifacts/phase8/baseline/p8-baseline.trx`, `baseline-backend.log` |
| UI `npm test -- --runInBand --json --outputFile=../.artifacts/phase8/baseline-frontend.json` | 899 passed / 101 suites; 0 failed, pending; 284.546s | `.artifacts/phase8/baseline-frontend.json`, `baseline-frontend.log` |
These executions precede product source changes. PowerShell's native stderr wrapping is retained in the raw log; Jest's result JSON records the actual outcomes. Historic Phase 7 browser351, separate23 and sidecar56 are inherited evidence, not fresh Phase 8 runs.
## Prerequisites and decisions
Account persistence is a necessary extension explicitly approved by the current brief, superseding Phase 3's proposed device-first option. Extend ApplicationUser with two additive fields and a narrow owner-only GET/PUT. Compare-and-update revision in one SQL statement; avoid whole-Identity updates for layout saves. Supported sizes are standard/full with a fixed full-width summary, one logical DOM order and single-column mobile. Reuse installed native drag helper and touch/keyboard move buttons; no dependency comparison/download needed.
The known failure investigation reviewed P7's deterministic held-reader reproduction against DELETE journaling, its WAL-before-bootstrap repair, and finite passing regression. Add actual layout reader/write and competing-save tests on that baseline. Do not infer permanent immunity to writer contention or identify the historical browser's exact competing connection.
No mandatory missing dependency blocks safe development. Retained conditions remain: real MariaDB runtime, human screen reader/physical devices/complete enlargement, other-browser/output and production release checks. Production NO-GO, P5-13 withheld. The [risk register](../README.md) carries these forward.
+61
View File
@@ -0,0 +1,61 @@
# P8-03 backend persistence evidence
Verified locally on 21 October 2026 against the Phase 7 WAL baseline. No production database or external service was used.
## Contract and ownership
`GET` and `PUT /api/auth/preferences/dashboard` require the existing `local` authentication scheme. The principal supplies the account ID; query/body account IDs have no authority. Missing/unknown subjects return 401. DTO: `{version:1, revision, widgets:[{id,visible,size}]}`. No saved layout returns revision 0 and an empty widget array. Successful PUT returns the canonical DTO with revision incremented by one; a stale revision returns 409 with `error: dashboard_revision_conflict`.
Writes accept at most 16,384 actual UTF-8 bytes, including chunked requests; larger bodies return 413. Invalid JSON, unsupported/missing version, non-integer/negative/exhausted revision, absent widgets, more than 32 entries, unknown/duplicate IDs, absent/non-boolean visibility, and invalid sizes return 400 without saving. Incoming revision must be below 9,007,199,254,740,991 so the incremented revision remains an exact JavaScript integer. Supported IDs are the nine in the Phase 8 catalogue. `cards` accepts only `full`; every other widget accepts `standard` or `full`. Unsupported sizes are rejected on writes. Only validated canonical fields are stored; extra request fields are discarded. An empty saved list is valid, but has a positive revision and therefore remains distinct from an unsaved account. The frontend owns missing-widget defaults and explicit reset behaviour.
Reads retain the database revision. Unknown, duplicate or malformed saved widgets are omitted; unsupported saved sizes become `full` for cards and `standard` otherwise. Corrupt or oversized stored JSON becomes an empty list without resetting the revision. This permits a deliberate revision-guarded repair save and prevents corrupt storage from rendering arbitrary content. A bounded JSON document declaring an integer schema version greater than 1 instead exposes that actual version and an empty widget list. PUT rejects such stored future schemas with 409 `dashboard_version_unsupported`, even when a version-1 request supplies the matching revision; newer settings remain intact and the frontend can disable editing.
`ApplicationUser` owns nullable `DashboardLayoutJson` and default-zero `DashboardLayoutRevision`. The endpoint uses one SQL `ExecuteUpdateAsync` guarded by owner ID and expected revision; it updates exactly these two columns. Simultaneous first writes cannot both win. No layout write invokes `UserManager.UpdateAsync`.
A reproduced additional race showed that an unrelated stale Identity whole-user update could otherwise replay the old layout/revision. Both layout properties ignore ordinary EF tracked updates after insertion. Their conditional endpoint update remains effective. The regression marks every property modified, matching Identity's store behaviour, then proves the newer layout survives. Profile, language, notification settings and Identity stamps remain unchanged by layout saves.
## Migration and startup
Additive migration `20261021090000_AddDashboardLayoutPreference` adds nullable TEXT/longtext JSON and non-null INTEGER/bigint revision default 0. No new table or backfill of layout choices. The snapshot includes both properties. SQLite and MariaDB startup reconciliation intentionally leave these columns to the migration, matching the existing language preference owner; adding them before the per-migration loop would collide on fresh startup. Actual fresh and legacy file startup plus existing complete migration-chain and repeated-startup tests pass.
The explicit SQLite downgrade drops only these independent columns using native SQLite `DROP COLUMN` (requires SQLite 3.35+, satisfied by the tested bundled runtime). MariaDB uses normal EF DropColumn operations. Migration-chain downgrade/upgrade tests pass; downgrade necessarily removes saved layouts. No downgrade was performed outside disposable tests.
## Test evidence
All commands used the pinned executable `C:\Users\Connor\.dotnet\dotnet.exe` through `rtk`, project `JobTrackerApi.Tests/JobTrackerApi.Tests.csproj`, flags `--configuration Release --no-restore`, and a TRX logger/results directory below `.artifacts/phase8/`.
| Run | Filter | Actual result | TRX |
|---|---|---|---|
| Meaningful pre-implementation RED | `FullyQualifiedName~DashboardPreferencesTests` | 0 pass / 13 fail / 0 skipped; absent routes/model | `backend-red/dashboard-red.trx` |
| Initial implementation | same | 13 pass / 0 fail / 0 skipped | `backend-green/dashboard-green.trx` |
| Stale Identity race RED | `FullyQualifiedName~Stale_identity_profile_update` | 0 pass / 1 fail; saved revision 1 overwritten by 0 | `backend-stale-red/dashboard-stale-red.trx` |
| First expanded run | dashboard + migration chain + SQLite startup + schema ownership | 25 pass / 16 fail; 15 downgrade SQL generation failures and one malformed fixture SQL | `backend-focused/dashboard-focused.trx` |
| Downgrade fix successor | same | 40 pass / 1 fail; remaining malformed fixture SQL | `backend-focused-successor/dashboard-focused.trx` |
| Final expanded run | same, plus two further dashboard HTTP cases | **43 pass / 0 fail / 0 skipped** | `backend-focused-final/dashboard-focused.trx` |
| Complete backend | no filter | **1081 pass / 0 fail / 0 skipped**, 29 seconds | `backend-full/dashboard-full.trx` |
| Future-schema / JS-safe revision RED | `FullyQualifiedName~Future_schema\|FullyQualifiedName~Invalid_payload_does_not_save` | 12 pass / 2 fail; future version hidden and unsafe exhausted revision accepted | `backend-future-red/dashboard-future-red.trx` |
| Complete backend after future-schema repair | no filter | **1083 pass / 0 fail / 0 skipped**, 32 seconds | `backend-full-final/dashboard-full.trx` |
The initial test-harness attempt omitted historical startup reconciliation and failed before reaching endpoints (missing historical AvatarImageDataUrl column). The meaningful RED above reran after wiring the actual startup owner. The subsequent malformed fixture SQL used a literal brace through EF's formatting API; parameterising it corrected the fixture without changing the product expectation.
Final focused command:
```powershell
rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter "FullyQualifiedName~DashboardPreferencesTests|FullyQualifiedName~MigrationChainTests|FullyQualifiedName~SqliteStartupConcurrencyTests|FullyQualifiedName~StartupSchemaOwnershipTests" --logger "trx;LogFileName=dashboard-focused.trx" --results-directory .artifacts/phase8/backend-focused-final
```
Final complete command:
```powershell
rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=dashboard-full.trx" --results-directory .artifacts/phase8/backend-full-final
```
The 23 new dashboard cases use an actual local Kestrel HTTP listener, real EF SQLite files, the complete migrations and actual startup reconciliation. They verify 401, owner isolation despite an attempted query account override, GET/PUT acknowledgement, 409, concurrent first saves, unchanged unrelated account fields/stamps, stale Identity preservation, invalid/oversized/chunked payloads, all catalogue IDs/order, empty saved lists, corrupt reads, future-schema downgrade protection, exact JavaScript revision bounds, legacy defaults and provider SQL generation. They also hold a SELECT reader open while saving the actual layout against the WAL file.
## Limits
The HTTP fixture substitutes a test-only local authentication handler; it proves endpoint scheme/owner enforcement, not production cookie issuance, CSRF/session expiry or proxy behaviour. It uses the existing minimal EF host conventions; TestHostFactory has no reusable real HTTP host, so the fixture adds only the services needed by AuthController/startup. Existing auth tests remain in the passing complete suite. Browser/session integration belongs to the UI acceptance lane.
MariaDB migration SQL is generated with the installed Pomelo provider and explicitly checks `longtext NULL` and `bigint NOT NULL DEFAULT 0`. This is SQL inspection, not MariaDB runtime evidence. `rtk docker ps --format "{{.Names}} {{.Image}} {{.Ports}}"` failed because the Docker Desktop Linux engine pipe was unavailable; no disposable MariaDB instance was exercised or created.
The held-reader and simultaneous-save tests establish finite regression evidence. WAL does not eliminate every form of writer contention; no retries, swallowed provider failures or permanent reliability claim were introduced. Human/device, deployed configuration, backup/restore and production gates remain outstanding; production remains NO-GO.