Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/patterns/01-dual-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/settings/DataSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -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)),
Expand Down
23 changes: 23 additions & 0 deletions frontend/src/storage/api-storage.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> => api.settings.getApp(),
updateApp: (data: Record<string, unknown>): Promise<Record<string, unknown>> =>
api.settings.updateApp(data),
};

backup = {
exportUrl: (includeAudiobook = false): string => api.backup.exportUrl(includeAudiobook),
import: (file: File): Promise<BackupImportResult> => api.backup.import(file),
};
}
37 changes: 37 additions & 0 deletions frontend/src/storage/index.test.ts
Original file line number Diff line number Diff line change
@@ -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());
});
});
47 changes: 47 additions & 0 deletions frontend/src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -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().<namespace>.<method>()` 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;
}
37 changes: 37 additions & 0 deletions frontend/src/storage/types.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>;
updateApp(data: Record<string, unknown>): Promise<Record<string, unknown>>;
}

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<BackupImportResult>;
}

export interface IStorageService {
settings: SettingsStorage;
backup: BackupStorage;
}

export type StorageMode = "api" | "dexie";