2026 03 21 Hierarchical Dock Layout User Preferences Spec
Documentation for Plans
Hierarchical Dock Layout Persistence — Specification
Date: 2026-03-21
Status: Draft — partially implemented (S253): backend GET/PUT /api/spaces/:spaceId/me/dock-layout-presets, space DB merge via dockLayoutPresetsByStorageKey, @libs-ui/dock-layout optional DOCK_LAYOUT_CLOUD_SYNC + container inputs, Vitto Studio HTTP adapter + shell wiring.
Scope: Per-user, per-space dock layout presets for apps using @libs-ui/dock-layout (e.g. Vitto Studio), with server sync, local cache, and performance guarantees.
Related code:
| Area | Location |
|---|---|
| Dock presets (client, v2 localStorage) | libs/libs-ui/dock-layout — DockLayoutService, SavedLayoutProfile in layout-node.model.ts |
| Global user preferences (Mongo, main API DB) | libs/libs-api/backend-core — user-preferences.schema.ts, UserPreferencesService; HTTP users/me/preferences |
| Merged prefs + Redis | APIs/backend-api/src/app/preferences/preferences.service.ts |
| Space-scoped user prefs (per-space Mongo DB) | APIs/backend-api/src/app/spaces/space-database/services/space-database-preferences.service.ts |
| Space prefs HTTP + membership | APIs/backend-api/src/app/preferences/preferences.controller.ts |
canUserModify / flatten |
libs/core/shared-models/src/lib/services/preference-resolver.ts |
0. Verified facts — APIs/backend-api (code as of this document)
The following were read from the repo; do not rely on unverified assumptions when implementing.
0.1 HTTP surface
- Global API prefix is
api(APIs/backend-api/src/main.ts). - Space preference routes live on
PreferencesControllerat@Controller('spaces/:spaceId/preferences'), i.e.:GET/PUT /api/spaces/:spaceId/preferences/me— merged prefs (GET) / update space user prefs (PUT)GET /api/spaces/:spaceId/preferences/me/raw— raw stored space user prefs- Plus admin/config routes under the same controller prefix.
0.2 Where space user preferences are stored
SpaceDatabasePreferencesService.saveUserSpacePreferences / getUserSpacePreferences:
- Resolves the per-space database name via
SpaceDatabaseResolverService.resolveSpaceDatabaseName(spaceId), thenconnection.useDb(dbName). - Collection name:
user_preferences(inside that space DB — not the main API DBgograb-api.user_preferencescollection used byUserPreferencesmongoose model). - Document identity:
_id: 'user-pref-${userId}'(string id). - On save,
updateOneuses$set: { userId, spaceId, preferences, updatedAt }— the entirepreferencesobject is replaced with the value passed in. There is no server-side merge with the previouspreferencessubtree in that method.
0.3 What happens on PUT /api/spaces/:spaceId/preferences/me
PreferencesService.updateUserSpacePreferences:
- Loads space
preference_overridesconfig. flattenPreferences(input)on the request body.- For each flattened key, throws
ForbiddenExceptionif the key is enforced orPreferenceResolver.canUserModifyreturns false.
Implication for dock data: For a key with no matching settingRules entry, getSettingRule is undefined, getEffectivePolicy falls back to the space defaultPolicy, which is space-wins on the default config created in getPreferenceOverrideConfig. Then canUserModify returns false unless a user-specific override grants user-wins for that user. So you cannot add arbitrary dock-related keys to the existing PUT .../preferences/me body without also extending preference_overrides.settingRules (and related resolver behavior) or changing this gate.
0.4 Typed input today
UpdateUserSpacePreferencesInput in preferences.service.ts only allows: theme, language, timezone, dateFormat, timeFormat, notifications, interface (subset). The Mongo layer’s saveUserSpacePreferences TypeScript parameter matches that shape; runtime is still driven by the object passed from the service.
0.5 Authorization
User endpoints call requireSpaceMember: allows user.role === 'system', space owner, or any member. Otherwise ForbiddenException (NotFoundException if space missing).
0.6 Merged preferences cache
getMergedPreferences uses Redis key prefs:${spaceId}:${userId} with TTL PREFS_CACHE_TTL = 120 seconds when Redis is connected. No DEL / invalidation of that key appears in updateUserSpacePreferences in the current file — stale merged results are possible for up to ~120s after a successful space prefs PUT (until TTL expires or another process invalidates). Dock sync design should not rely on merged prefs refreshing instantly unless this is fixed separately.
0.7 Concurrency / revision
Space user_preferences documents have createdAt / updatedAt on write, but no revision / optimistic-lock field exists in the current save path.
0.8 GDPR / export (scoped check)
GET /api/users/me/preferences/export exists on users.controller.ts for global user preferences. A quick search under APIs/backend-api did not find an equivalent export that reads per-space DB user_preferences; whether space-scoped prefs are included in any export pipeline must be verified elsewhere (docs-api, jobs, or future work).
0.9 Client storageKey today (cross-space localStorage)
vitto-studio provides storageKey: 'vitto-studio-dock-layout-v2' in studio-dock-shell.component.ts — no spaceId in the string. Presets persist under vitto-studio-dock-layout-v2-presets in localStorage (DockLayoutService.presetsStorageKey()). Switching spaces on the same origin can share one local preset blob unless the app namespaces the key or migrates keys per space.
1. Goals
- Hierarchical resolution: User sees a layout derived from defaults first; saved user data only overrides when present.
- Additive feature: Users with no saved server data behave exactly as today (code default + optional localStorage).
- Per-space: A user’s Vitto Studio layout in Space A does not overwrite their layout in Space B.
- Performance: No regression to first paint, drag/splitter interaction, or unrelated preference reads.
- UX: Clear sync state (optional subtle indicator), no blocking spinners on routine navigation, graceful offline and conflict behavior.
2. Non-goals (v1)
- Storing every pointer move / splitter tick to the server (only discrete saves).
- Merging dock JSON into
getMergedPreferencesresponse body or its Redis cache key (see §6). - AI / agent memory learning from layout (unless explicitly product-scoped later).
- Space-admin “published default layout” for all members (optional v1.5; see §12).
3. Resolution hierarchy
Resolve effective presets store for (userId, spaceId, appStorageKey) in strict order; first defined layer wins for that field only where noted.
| Layer | Name | Source | Applies to |
|---|---|---|---|
| L0 | App built-in default | Code (DockLayoutService / container config) |
Initial LayoutState when nothing else applies |
| L1 | (Optional future) Space template | Space settings or admin config | Default before any user save |
| L2 | User session / ephemeral | In-memory only | Current tab session |
| L3a | User local cache | localStorage key ${storageKey}-presets (v2 blob) |
Offline, fast, same-device |
| L3b | User server copy | Per-space user preference document (see §4) | Cross-device, durable |
v1 rule (explicit): L1 is absent. Effective hierarchy is L0 → L3a ↔ L3b (see merge rules below) → L2 is just runtime.
Hydration merge (client, on authenticated load):
- Parse L3a (local) and L3b (server) if both exist.
- Compare
revision(if implemented — not in current backend-api save path; until then useupdatedAt+ content hash) from §4. - If server newer: replace local presets store for that
(spaceId, storageKey)and write to localStorage. - If local newer (e.g. offline edits): keep local UI; queue debounced PUT; on next successful GET, converge to server.
- If equal: no write.
Unauthenticated: Use L0 + L3a only; never call sync API.
4. Data model
4.1 Canonical blob: DockLayoutPresetsStore
Aligned with existing client shape (extend, do not fork silently):
/** Persisted per (spaceId, userId, storageKey) — v1 server contract */
interface DockLayoutPresetsStore {
/** Schema version for migrations (mirror client; e.g. 2) */
version: number;
/** Monotonic revision for optimistic concurrency (server-assigned on write) */
revision: number;
/** Last server persistence time (ISO 8601) */
updatedAt: string;
/** Same semantics as client today */
activeProfileId: string | null;
profiles: SavedLayoutProfile[];
}
SavedLayoutProfilematcheslibs/libs-ui/dock-layout(id,name,layout,savedAt).- Storage keying: Server stores a map keyed by app
storageKeystring (e.g.vitto-studio-dock-layout-v2) so multiple dock shells per space do not collide.
Physical document (fits current storage): There is already one document per (spaceId, userId) in each space’s user_preferences collection (_id: user-pref-${userId}). The nested object is today the whole preferences field. A map such as preferences.dockLayoutPresetsByStorageKey is compatible with that shape if every PUT that touches prefs merges with the existing preferences object server-side or the client sends the full merged preferences object — see §0.2 (full replace on save today).
4.2 Global vs space DB user_preferences (names collide; databases differ)
- Main API DB: Mongoose model
UserPreferences/ collectionuser_preferences— global defaults (UserPreferencesService). Different product surface from space-scoped prefs. - Per-space DB: Native driver in
SpaceDatabasePreferencesService— collection also nameduser_preferences, but inuseDb(spaceDatabaseName). Dock layouts for Vitto-in-space belong here (or a new collection in the same space DB), not in the global mongoose document, unless product explicitly wants cross-space layout sync.
4.3 Concurrency
- Today: no revision field; optional new field
dockLayoutRevisionor store-levelrevisioninside the dock blob, with 409 on mismatch — new behavior to implement. - Every PUT (on the dock-specific API recommended in §5) should send
expectedRevisionorIf-Matchonce implemented; server rejects with 409 if mismatch. - Client on 409: GET latest, re-merge with local (policy: prompt “Keep mine / Use server” for named profiles, or auto server-wins for non-power users — product choice).
5. API design
5.1 Why not only extend PUT /api/spaces/:spaceId/preferences/me?
Per §0.3, new flattened keys are denied by canUserModify under default space policy. Even if rules were extended:
- Every save replaces the entire
preferencesobject (§0.2). A client sending only dock fields would wipe theme/language/etc. unless the server or client always performs a full merge.
Recommended v1: Dedicated routes (names illustrative) implemented in backend-api, reusing requireSpaceMember (same pattern as PreferencesController):
GET /api/spaces/:spaceId/me/dock-layout-presets?storageKey=...- Returns
{ store: DockLayoutPresetsStore | null }—null= no server data for that key.
- Returns
PUT /api/spaces/:spaceId/me/dock-layout-presets?storageKey=...- Body: store + optional
expectedRevision. - Response: final store with bumped
revisionandupdatedAt.
- Body: store + optional
Alternative (higher risk): Extend preference_overrides.settingRules so dock keys are user-wins, and change saveUserSpacePreferences to $set dot-paths or read-merge-write the preferences object so partial updates never erase sibling fields. This touches more surfaces (modifiable settings UI, resolver, admin tooling).
Auth: Mirror requireSpaceMember from preferences.controller.ts (§0.5).
5.2 Explicit exclusion
- Do not attach full
DockLayoutPresetsStoretogetMergedPreferencesJSON or Redis value keyedprefs:${spaceId}:${userId}. - Dock consumers opt in with one GET when the dock shell mounts.
5.3 Cache (server)
- Optional short TTL Redis key:
dock-presets:${spaceId}:${userId}:${storageKey}(e.g. 60–120s), mustDELor bump version on successful PUT for that tuple (avoid repeating the stale-cache gap described in §0.6 for merged prefs). - If Redis unavailable, degrade to Mongo only.
5.4 Angular client
- New thin service (e.g.
DockLayoutSyncService) in an appropriate lib — not insideauth-uiunless you want coupling; prefer@libs-foundationalor feature module next to Vitto Studio. - Uses
API_BASE_URLand existing HTTP interceptors (auth).
6. Performance requirements
| Concern | Requirement |
|---|---|
| First paint | L0 applies immediately; server GET must not block shell render. Use defer (afterNextRender, route resolve: false, or idle callback) unless store is tiny and cached. |
| Drag / resize | Zero network calls during pointer streams; only local DockLayoutService updates. |
| Writes | Debounce PUT (e.g. 1–3s after last “save profile” / “rename” / “delete”) or explicit “Sync now” only for power users — v1 recommendation: explicit save actions + optional debounce for “update snapshot.” |
| Payload size | Cap profiles count and serialized size (§7). |
| Main thread | JSON parse/stringify of large blobs off hot path where possible (chunk or idle); avoid cloning full tree on every CD cycle. |
| Merged prefs | No increase in size/frequency of existing merged-prefs fetches. |
Acceptance (measurable): With cold cache, Vitto Studio shell Time to Interactive delta vs baseline ≤ one frame budget of async work attributable to dock sync (target: < 50ms main-thread work after response, excluding network).
7. Limits and validation
Enforce on server (authoritative) and client (UX):
| Limit | Suggested v1 | On exceed |
|---|---|---|
Profiles per (spaceId, storageKey) |
20 | 400 + message; client prevents enqueue |
| Max JSON size per store | 256 KB – 512 KB (pick one; document in config) | 413 / 400 |
| Profile name length | 128 chars | trim / reject |
storageKey allowlist |
Regex ^[a-z0-9][a-z0-9-]{0,63}$ |
400 |
Sanitization on read (server): Strip unknown top-level keys; validate version; if unsupported major version, return null or empty store + log (client falls back to L0/L3a).
8. User experience
8.1 States
- Local only: Guest or logged out — no cloud icon; data in localStorage only.
- Synced: Last successful PUT; show optional subtle “saved” or synced timestamp in settings panel (not toast spam).
- Pending: Debounced or in-flight PUT — disable double-submit on “Save layout” or allow idempotent retry.
- Conflict (409): Non-blocking banner in dock settings: “Your layout was updated elsewhere” + actions Reload from server / Overwrite (overwrite sends new PUT with latest expected revision after user confirms).
8.2 Offline
- Queue at most one pending PUT per
(spaceId, storageKey); flush ononlineevent or next app init. - If flush fails, retain local data; never wipe local on network error.
8.3 Logout / user switch
- Clear in-memory dock state for previous user; re-hydrate for new user from their L3a/L3b.
- Assumption: localStorage is either per-user namespaced (preferred) or cleared on logout for dock keys — verify auth storage strategy to avoid leaking layouts across accounts on shared machines.
9. Edge cases matrix
| # | Scenario | Expected behavior |
|---|---|---|
| E1 | No server document | L0 + local only; first PUT creates server row |
| E2 | Server empty, rich local | Optional one-time migration: “Back up to account” prompt or silent upload on first authenticated visit (product choice) |
| E3 | Two browser tabs, same user | Same as concurrent devices: revision / 409; last successful write wins unless user resolves |
| E4 | App deploy removes a panel type | Sanitize on apply: drop unknown panel nodes; log metric; optional toast “Some panels were removed” |
| E5 | App deploy changes layout schema version | Client migration runs first; if server has old version, migration on read or reject with fallback to L0 |
| E6 | User removed from space | GET/PUT 403; client drops server copy for that spaceId; local cache may remain but scoped key should include spaceId |
| E7 | Space deleted | Same as E6 |
| E8 | GDPR export | Global prefs export exists at /api/users/me/preferences/export; per-space user_preferences export not found in backend-api grep — define whether dock data must appear in a space/user export and where that job lives |
| E15 | Partial PUT .../preferences/me |
With current $set of whole preferences, a partial body drops omitted keys; clients must send full merged prefs or server must merge |
| E16 | Dock fields on existing PUT | Forbidden for new keys under default space-wins / canUserModify (§0.3) |
| E9 | GDPR delete | Cascade delete or anonymize preset blobs with user space membership |
| E10 | storageKey typo in new app |
Isolated empty store; no cross-app leakage |
| E11 | Malicious huge payload | Reject at size limit; rate-limit PUT per user |
| E12 | Redis stale cache | TTL + invalidate on write; accept brief staleness or skip Redis for dock v1 |
| E13 | User clears site data | Local gone; next session pulls server if any |
| E14 | Server has data, user wants “factory reset” | UI action: delete all profiles server + local, reapply L0 |
10. Security and tenancy
- Authorize every request against space membership (same as other space APIs).
- Never return another user’s presets.
- Do not log full layout JSON in production logs (PII risk low, but noise and size high); log
storageKey,revision, byte length only.
11. Observability
- Metrics:
dock_presets_get_total,dock_presets_put_total,dock_presets_409_total, payload bytes histogram. - Traces: single span around GET/PUT for correlation with slow Mongo.
12. Future: Space default template (L1)
If product requires “Space admin sets default Vitto Studio layout for new members”:
- Store read-only template under space settings (not user prefs).
- Resolution: L0 < L1 < L3 for users without L3; users with L3 unchanged unless “Reset to space default” action.
- Enforcement: If space locks layout, intersect with
PreferenceResolver/ space policy pattern (forbidden PUT or strip disallowed panel types).
This is out of v1 unless explicitly prioritized.
13. Implementation phases
- Phase A — Server: Schema field + GET/PUT + validation + limits + 409 revision; no UI change.
- Phase B — Client sync service: Hydrate after auth + space context; debounced PUT on save actions; localStorage key includes
spaceIdto fix E6/E10 cross-space leakage in local cache. - Phase C — UX: Conflict banner, sync indicator, optional “backup to account.”
- Phase D — Docs & export: GDPR and admin docs.
14. Acceptance criteria (testing)
- Logged-out user: identical behavior to pre-sync baseline.
- Logged-in, no server data: default layout; saving creates server row; refresh on second device shows same profiles.
- PUT with stale revision returns 409; client recovery path works without data loss without explicit user choice (or with it, per product).
- No dock network traffic during splitter drag (verify in DevTools).
-
getMergedPreferencesresponse size unchanged when dock sync enabled (spot-check). - Limits enforced: 413/400 on oversized body; profile cap enforced.
- User removed from space: subsequent GET 403; UI degrades to L0 + local.
15. Open questions (resolve before coding)
- localStorage namespacing: Verified: Vitto Studio uses
storageKey: 'vitto-studio-dock-layout-v2'(nospaceId). Phase B must either pass a space-scopedstorageKeyfrom the shell or teachdock-layoutto suffixlocalStoragekeys withspaceIdwhen provided via config. - Persistence location for dedicated dock API: Same document’s
preferences.dockLayoutPresetsByStorageKey(requires safe merge on write) vs new collection in the space DB (e.g.dock_layout_presets) — trade-offs: document size vs query simplicity. - Conflict UX: Auto server-wins vs mandatory user prompt for 409.
- Silent cloud backup on first login vs explicit user consent (enterprise may care).
- Invalidate merged prefs cache on space prefs update: Should
updateUserSpacePreferencesdeleteprefs:${spaceId}:${userId}when Redis is available (fixes §0.6 for theme/language flows; orthogonal but recommended if dock ever touches same doc).
Document maintenance: Update revision / version semantics here when the client DockLayoutPresetsStore on disk format changes; keep this spec synchronized with SavedLayoutProfile in layout-node.model.ts.