From 0918d37df2085d049ab94ff15ac1d1dfc49e2a3e Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 20 Aug 2026 19:05:59 +0200 Subject: [PATCH] feat(client): mirror fold/synthesize model helpers for replicas-on-primary --- .../Admin/storage/storageModel.test.ts | 121 ++++++++++- .../components/Admin/storage/storageModel.ts | 198 ++++++++++++++++++ 2 files changed, 318 insertions(+), 1 deletion(-) diff --git a/client/src/components/Admin/storage/storageModel.test.ts b/client/src/components/Admin/storage/storageModel.test.ts index ad2b52c03..b6e2ee699 100644 --- a/client/src/components/Admin/storage/storageModel.test.ts +++ b/client/src/components/Admin/storage/storageModel.test.ts @@ -1,10 +1,21 @@ import { describe, expect, it } from 'vitest'; -import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageConfig } from '@trek/shared'; +import { + MASKED_SETTING_VALUE, + type StorageAdminState, + type StorageBackend, + type StorageConfig, +} from '@trek/shared'; import { categoriesPointingAt, + foldBackends, hasPlaintextSecret, mirrorsReferencing, + primaryNameOf, removeBackend, + removeBackendAndMirrors, + renameBackendRefs, + replicaOfPrimaries, + setMirrorTargets, settingsDocumentOf, upsertBackend, } from './storageModel'; @@ -102,3 +113,111 @@ describe('draft edits', () => { expect(removeBackend(draft, 'ghost').backends).toHaveLength(1); }); }); + +describe('mirror fold/synthesize (replicas-on-primary)', () => { + const MIRRORED_STATE: StorageAdminState = { + ...STATE, + backends: [ + ...STATE.backends, + { + name: 'mirror', type: 'mirror', source: 'settings', + options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'], + }, + { name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] }, + ], + categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } }, + }; + const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE); + + it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => { + const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft()); + expect(degenerate).toEqual([]); + expect(rows.map((r) => r.name)).not.toContain('mirror'); + const primary = rows.find((r) => r.name === 'backups-local')!; + expect(primary.mirrorTargets).toEqual(['off-box']); + expect(primary.mirrorName).toBe('mirror'); + expect(primary.categories).toContain('backups'); // via the mirror + const replica = rows.find((r) => r.name === 'off-box')!; + expect(replica.replicaOf).toEqual(['backups-local']); + expect(replica.categories).toContain('covers'); // its own direct assignment survives + }); + + it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => { + const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local + const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']); + const mirror = next.backends.find((b) => b.type === 'mirror')!; + expect(mirror.name).toBe('backups-local-mirror'); + expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] }); + expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten + expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched + }); + + it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => { + const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']); + const mirrors = next.backends.filter((b) => b.type === 'mirror'); + expect(mirrors).toHaveLength(1); + expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed + expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] }); + }); + + it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => { + // Build the synthesized state locally: default backups routed via a fresh mirror… + const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']); + // …then dissolve it: the backups entry must DROP (state says its default is backups-local). + const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []); + expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false); + expect(dissolved.categories).not.toHaveProperty('backups'); + // Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping. + const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []); + expect(repointed.categories.backups).toBe('backups-local'); + }); + + it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => { + const draft: StorageConfig = { + backends: [ + { name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend, + { name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } }, + { name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } }, + { name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } }, + { name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } }, + ], + categories: {}, + }; + const { rows, degenerate } = foldBackends(STATE, draft); + expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins + expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([ + ['m2', 'duplicate-mirror'], + ['m-env', 'env-primary'], + ['m-ghost', 'missing-primary'], + ]); + }); + + it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => { + const draft: StorageConfig = { + backends: [ + { name: 'nas', type: 'local', options: { root: '/mnt/nas' } }, + { name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } }, + ], + categories: { covers: 'nas' }, + }; + const renamed = renameBackendRefs(draft, 'nas', 'nas2'); + const mirror = renamed.backends.find((b) => b.type === 'mirror')!; + expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] }); + expect(renamed.categories.covers).toBe('nas2'); + expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job + }); + + it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => { + const draft = mirroredDraft(); + expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']); + const removed = removeBackendAndMirrors(draft, 'backups-local'); + expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false); + expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false); + }); + + it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => { + expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local'); + expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback + expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local'); + }); +}); diff --git a/client/src/components/Admin/storage/storageModel.ts b/client/src/components/Admin/storage/storageModel.ts index 25140b048..1dc90d6fb 100644 --- a/client/src/components/Admin/storage/storageModel.ts +++ b/client/src/components/Admin/storage/storageModel.ts @@ -1,5 +1,6 @@ import { MASKED_SETTING_VALUE, + STORAGE_CATEGORIES, storageSecretFields, type StorageAdminState, type StorageBackend, @@ -72,3 +73,200 @@ export function upsertBackend(draft: StorageConfig, backend: StorageBackend): St export function removeBackend(draft: StorageConfig, name: string): StorageConfig { return { ...draft, backends: draft.backends.filter((b) => b.name !== name) } } + +// ── Mirror fold/synthesize (replicas-on-primary spec) ───────────────────────── +// The draft stays the wire document; folding is a view rule, synthesis a set +// of pure draft operations. Mirror NAMES never reach the UI — every helper +// that renders phrases mirrors as their primary. + +type MirrorBackend = Extract +const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror' + +export interface FoldedBackendRow { + name: string + type: 'local' | 's3' + source: 'built-in' | 'env' | 'settings' + backend: StorageBackend + /** Direct assignments ∪ assignments routed via the adopted mirror. */ + categories: StorageCategory[] + /** Adopted mirror's replicas — [] when unmirrored. */ + mirrorTargets: string[] + /** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */ + mirrorName: string | null + /** Primaries whose adopted mirror lists this backend as a replica. */ + replicaOf: string[] +} + +export interface DegenerateMirror { + backend: StorageBackend + reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary' + categories: StorageCategory[] +} + +/** + * Re-fetchable content — replicating it is usually wasteful. photos-google is + * the Google photo cache (place-photo-cache.service.ts); places is mostly + * provider-derived imagery. + */ +export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places'] + +/** The nine categories resolved draft-over-state (draft entries win; defaults fill the rest). */ +export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record { + const map = {} as Record + for (const category of STORAGE_CATEGORIES) { + // The admin state's category record is exhaustive by schema contract. + map[category] = draft.categories[category] ?? state.categories[category]!.backend + } + return map +} + +/** First draft mirror wrapping the named primary — the one the UI manages. */ +export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined { + return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName) +} + +export function foldBackends( + state: StorageAdminState, + draft: StorageConfig, +): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } { + const draftNames = draft.backends.map((b) => b.name) + const effective = effectiveCategoryMap(state, draft) + const categoriesOf = (name: string): StorageCategory[] => + STORAGE_CATEGORIES.filter((category) => effective[category] === name) + + // Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state + // unless a draft row overrides the name; settings rows from the draft. + const base: Array> = [] + for (const b of state.backends) { + if (b.type === 'mirror') continue // mirrors fold below; the draft owns them + if (draftNames.includes(b.name)) continue + if (b.source === 'settings') continue // removed from the draft, pending save + base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) }) + } + for (const b of draft.backends) { + if (isMirror(b)) continue + base.push({ name: b.name, type: b.type, source: 'settings', backend: b }) + } + + // Adoption: the first mirror per editable primary; the rest are degenerate — + // rendered unfolded with a reason, never silently dropped. + const adopted = new Map() + const degenerate: DegenerateMirror[] = [] + for (const mirror of draft.backends.filter(isMirror)) { + const primary = base.find((row) => row.name === mirror.options.primary) + if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) }) + else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) }) + else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) }) + else adopted.set(primary.name, mirror) + } + + const rows = base.map((row): FoldedBackendRow => { + const mirror = adopted.get(row.name) + return { + ...row, + categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])], + mirrorTargets: mirror ? [...mirror.options.replicas] : [], + mirrorName: mirror?.name ?? null, + replicaOf: [...adopted.values()] + .filter((m) => m.options.replicas.includes(row.name)) + .map((m) => m.options.primary), + } + }) + return { rows, degenerate } +} + +function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string { + const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)]) + const base = `${primaryName}-mirror` + if (!taken.has(base)) return base + for (let n = 2; ; n++) { + if (!taken.has(`${base}-${n}`)) return `${base}-${n}` + } +} + +/** + * targets non-empty: update the adopted mirror in place (or synthesize one) + * and route every category that effectively resolves to the primary — + * default-sourced included — through it. targets empty: dissolve, re-pointing + * the mirror's categories at the primary and dropping entries that revert to + * their built-in default. + */ +export function setMirrorTargets( + state: StorageAdminState, + draft: StorageConfig, + primaryName: string, + targets: string[], +): StorageConfig { + const adopted = adoptedMirrorFor(draft, primaryName) + + if (targets.length === 0) { + if (!adopted) return draft + const categories = { ...draft.categories } + for (const category of STORAGE_CATEGORIES) { + if (categories[category] !== adopted.name) continue + const stateEntry = state.categories[category]! // exhaustive by schema contract + if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category] + else categories[category] = primaryName + } + return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories } + } + + const effective = effectiveCategoryMap(state, draft) + const mirror: StorageBackend = { + name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName), + type: 'mirror', + options: { primary: primaryName, replicas: [...targets] }, + } + const backends = adopted + ? draft.backends.map((b) => (b.name === adopted.name ? mirror : b)) + : [...draft.backends, mirror] + const categories = { ...draft.categories } + for (const category of STORAGE_CATEGORIES) { + if (effective[category] === primaryName) categories[category] = mirror.name + } + return { backends, categories } +} + +/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */ +export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig { + return { + backends: draft.backends.map((b) => { + if (!isMirror(b)) return b + return { + ...b, + options: { + primary: b.options.primary === oldName ? newName : b.options.primary, + replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)), + }, + } + }), + categories: Object.fromEntries( + Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]), + ) as StorageConfig['categories'], + } +} + +/** Remove a backend AND every mirror wrapping it as primary (they would dangle). */ +export function removeBackendAndMirrors(draft: StorageConfig, name: string): StorageConfig { + return { + ...draft, + backends: draft.backends.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name)), + } +} + +/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */ +export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] { + return draft.backends + .filter(isMirror) + .filter((m) => m.options.replicas.includes(name)) + .map((m) => m.options.primary) +} + +/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */ +export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string { + const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName) + if (draftMirror) return draftMirror.options.primary + const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror') + if (stateMirror) return String((stateMirror.options as Record).primary) + return backendName +}