diff --git a/docs/patterns/01-dual-storage.md b/docs/patterns/01-dual-storage.md index eb4809b..40921d1 100644 --- a/docs/patterns/01-dual-storage.md +++ b/docs/patterns/01-dual-storage.md @@ -2,7 +2,7 @@ > Backported from adaptive-learner. Template-neutral; adapt the names to your app. -**Status in this template:** not yet implemented. Only `frontend/src/db/drafts.ts` uses Dexie today (single-purpose draft autosave); no `IStorageService` abstraction or `frontend/src/storage/` directory exists. +**Status in this template:** the **seam ships**; the second backing does not. `frontend/src/storage/` provides `IStorageService` (`types.ts`, minimal: `settings` + `backup`), `ApiStorage` (`api-storage.ts`, delegates to the `api` client), and `getStorage()` (`index.ts`, selects by `VITE_STORAGE_MODE`, api-only for now). `DataSettings` already reaches its backup calls through `getStorage()` as the worked example. **Adopt it incrementally**: grow the interface and route one component off `api.*` at a time. A real `DexieStorage` (browser IndexedDB) is the per-app effort you add when you need offline/static deployment — `getStorage()` warns and falls back to api if `VITE_STORAGE_MODE=dexie` is set before it exists. (`frontend/src/db/drafts.ts` already uses Dexie for draft autosave, unrelated to this seam.) ## Why diff --git a/frontend/src/components/settings/DataSettings.tsx b/frontend/src/components/settings/DataSettings.tsx index a5184e4..a42b91f 100644 --- a/frontend/src/components/settings/DataSettings.tsx +++ b/frontend/src/components/settings/DataSettings.tsx @@ -14,7 +14,8 @@ */ import {useRef, useState} from "react"; import {Download, Loader2, Trash2, Upload} from "lucide-react"; -import {api, ApiError} from "../../api/client"; +import {ApiError} from "../../api/client"; +import {getStorage} from "../../storage"; import {useI18n} from "../../hooks/useI18n"; import {useDialog} from "../AppDialog"; import {notify} from "../../utils/notify"; @@ -33,7 +34,7 @@ export function DataSettings() { // FileResponse on the backend carries Content-Disposition, so a plain // anchor download picks up the server-suggested filename. const anchor = document.createElement("a"); - anchor.href = api.backup.exportUrl(); + anchor.href = getStorage().backup.exportUrl(); anchor.rel = "noopener"; document.body.appendChild(anchor); anchor.click(); @@ -53,7 +54,7 @@ export function DataSettings() { if (!ok) return; setBusy("import"); try { - const result = await api.backup.import(file); + const result = await getStorage().backup.import(file); const total = result.imported_books + (result.imported_articles ?? 0); notify.success( t("ui.data.import_done", "Backup importiert: {count} Einträge").replace("{count}", String(total)), diff --git a/frontend/src/storage/api-storage.ts b/frontend/src/storage/api-storage.ts new file mode 100644 index 0000000..2a38693 --- /dev/null +++ b/frontend/src/storage/api-storage.ts @@ -0,0 +1,23 @@ +/** + * ApiStorage - the default {@link IStorageService} backing. + * + * A thin delegation layer over the existing typed `api` client. It adds no + * behaviour; its only job is to put the data access behind the interface so + * a second backing (DexieStorage) can be swapped in later without touching + * the consuming components. + */ +import {api} from "../api/client"; +import type {BackupImportResult, IStorageService} from "./types"; + +export class ApiStorage implements IStorageService { + settings = { + getApp: (): Promise> => api.settings.getApp(), + updateApp: (data: Record): Promise> => + api.settings.updateApp(data), + }; + + backup = { + exportUrl: (includeAudiobook = false): string => api.backup.exportUrl(includeAudiobook), + import: (file: File): Promise => api.backup.import(file), + }; +} diff --git a/frontend/src/storage/index.test.ts b/frontend/src/storage/index.test.ts new file mode 100644 index 0000000..9fe0f93 --- /dev/null +++ b/frontend/src/storage/index.test.ts @@ -0,0 +1,37 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; + +const h = vi.hoisted(() => ({ + exportUrl: vi.fn(() => "/api/backup/export"), + importFn: vi.fn(async () => ({imported_books: 1})), + getApp: vi.fn(async () => ({ok: true})), + updateApp: vi.fn(async () => ({ok: true})), +})); + +vi.mock("../api/client", () => ({ + api: { + settings: {getApp: h.getApp, updateApp: h.updateApp}, + backup: {exportUrl: h.exportUrl, import: h.importFn}, + }, +})); + +import {getStorage, resetStorageForTests} from "./index"; + +afterEach(() => { + resetStorageForTests(); + vi.clearAllMocks(); +}); + +describe("getStorage (ApiStorage backing)", () => { + it("delegates backup + settings to the api client", async () => { + const storage = getStorage(); + expect(storage.backup.exportUrl()).toBe("/api/backup/export"); + await storage.backup.import(new File(["x"], "b.bgb")); + expect(h.importFn).toHaveBeenCalledTimes(1); + await storage.settings.getApp(); + expect(h.getApp).toHaveBeenCalledTimes(1); + }); + + it("returns a stable singleton", () => { + expect(getStorage()).toBe(getStorage()); + }); +}); diff --git a/frontend/src/storage/index.ts b/frontend/src/storage/index.ts new file mode 100644 index 0000000..5e12e94 --- /dev/null +++ b/frontend/src/storage/index.ts @@ -0,0 +1,47 @@ +/** + * Storage factory (docs/patterns/01-dual-storage.md). + * + * `getStorage()` returns the active {@link IStorageService}. The mode is read + * from `VITE_STORAGE_MODE` at build time: + * - "api" (default): {@link ApiStorage}, talks to the FastAPI backend. + * - "dexie" (future): a browser IndexedDB implementation for offline / + * static deployments. Not implemented in the skeleton; + * requested but absent -> we warn and fall back to api + * so the app still runs, and you implement DexieStorage + * when you adopt the offline mode. + * + * Components should call `getStorage()..()` instead of + * `api.*` directly, so they are backing-agnostic. + */ +import {ApiStorage} from "./api-storage"; +import type {IStorageService, StorageMode} from "./types"; + +export type {IStorageService, StorageMode} from "./types"; + +function resolveMode(): StorageMode { + const mode = import.meta.env.VITE_STORAGE_MODE; + return mode === "dexie" ? "dexie" : "api"; +} + +let instance: IStorageService | null = null; + +export function getStorage(): IStorageService { + if (instance) return instance; + const mode = resolveMode(); + if (mode === "dexie") { + // No DexieStorage in the skeleton yet. Fail OPEN to api so the app runs; + // implement DexieStorage (implements IStorageService) and select it here + // when you adopt the offline/static deployment mode. + console.warn( + "[storage] VITE_STORAGE_MODE=dexie requested but DexieStorage is not " + + "implemented; falling back to ApiStorage. See docs/patterns/01-dual-storage.md.", + ); + } + instance = new ApiStorage(); + return instance; +} + +/** Test helper: drop the cached instance so a test can re-resolve the mode. */ +export function resetStorageForTests(): void { + instance = null; +} diff --git a/frontend/src/storage/types.ts b/frontend/src/storage/types.ts new file mode 100644 index 0000000..d801fb8 --- /dev/null +++ b/frontend/src/storage/types.ts @@ -0,0 +1,37 @@ +/** + * Storage abstraction seam (docs/patterns/01-dual-storage.md). + * + * `IStorageService` is the interface every page/component should reach data + * through, so the SAME UI can run against different backings - the FastAPI + * backend (ApiStorage, default) or, once an app needs offline/static + * deployment, a browser IndexedDB implementation (DexieStorage). + * + * This is the MINIMAL seam: only the domain-neutral `settings` + `backup` + * namespaces are typed here as the worked example. Grow the interface one + * namespace at a time as you migrate components off direct `api.*` calls - + * that incremental adoption is the whole point (no big-bang rewrite). + */ + +export interface BackupImportResult { + imported_books: number; + imported_articles?: number; +} + +export interface SettingsStorage { + getApp(): Promise>; + updateApp(data: Record): Promise>; +} + +export interface BackupStorage { + /** URL to GET the full backup archive (FileResponse with Content-Disposition). */ + exportUrl(includeAudiobook?: boolean): string; + /** Restore from an uploaded backup archive. */ + import(file: File): Promise; +} + +export interface IStorageService { + settings: SettingsStorage; + backup: BackupStorage; +} + +export type StorageMode = "api" | "dexie";