diff --git a/README.md b/README.md
index 3582904..620f1a3 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,17 @@ MultiCam Planner is designed for quick, intuitive camera planning with essential
goes through the same check as cameras/lenses carried in a project file; one
that fails is skipped and counted in the sync line. The cache lives in local
storage and survives sign-out, so placed library cameras keep working
- offline. Under the selector a library entry shows its status, its number of
+ offline.
+- **Offline contract** (shared by every planner, `syncFrom` in the client):
+ the cache changes only on a successful response — offline, a timeout
+ (15 s per request, 120 s per upload batch), a server error, an expired
+ sign-in or signing out leave the last synced devices usable. Every server
+ address has its own cache slot under the same storage key; a single cache
+ from an older version is read as the slot of its server. When the server
+ reports a lower `latestSeq` than remembered (set up anew, restored from a
+ backup), the whole stand is fetched again and replaces the cache — unless it
+ is empty: then the sync fails with *server was set up anew … devices were
+ kept*, and nothing is deleted. Under the selector a library entry shows its status, its number of
confirmations and a link to its page.
- **Library entries travel in the project file**: the ones placed cameras use
are written into the `.mcplan` (and the `.avplan` cameras slot) as
@@ -130,7 +140,8 @@ MultiCam Planner is designed for quick, intuitive camera planning with essential
address must be added there, otherwise every request is blocked and the
settings report the server as unreachable. Changing the address signs out
at the old server, forgets the token (it must never reach another server)
- and starts an empty cache and upload record. Only `https://` is accepted (`http://` for
+ and switches to that server's cache slot and a fresh upload record; the
+ previous server's cache stays stored and is back when you switch back. Only `https://` is accepted (`http://` for
localhost).
- Client: `src/utils/deviceLibraryClient.ts`, an unchanged copy of
`larszu/av-device-library` `clients/deviceLibraryClient.ts` — changes go
diff --git a/src/__tests__/deviceLibrary.test.ts b/src/__tests__/deviceLibrary.test.ts
index af410bf..5bd108c 100644
--- a/src/__tests__/deviceLibrary.test.ts
+++ b/src/__tests__/deviceLibrary.test.ts
@@ -3,7 +3,7 @@ import type { Camera, Lens } from '../types';
import { CAMERAS } from '../data/cameras';
import { LENSES } from '../data/lenses';
import { cameraToFacet, lensToFacet, facetToItem, libraryIdFor, proposalCore } from '../library/facet';
-import { emptyCache, mergeSync, readCache } from '../library/sync';
+import { emptyCache, mergeSync, readCache, writeCache } from '../library/sync';
import type { SyncDevice, SyncResponse } from '../utils/deviceLibraryClient';
// ---------------------------------------------------------------------------
@@ -131,6 +131,17 @@ describe('sync merge', () => {
expect(readCache(a.cache, 'https://b')).toEqual(emptyCache('https://b'));
expect(readCache('kaputt', 'https://b')).toEqual(emptyCache('https://b'));
});
+
+ it('every server has its own slot; writing one keeps the others', () => {
+ const a = mergeSync(emptyCache('https://a'), antwort(1, [geraet('cam', 1, kamera)])).cache;
+ const b = mergeSync(emptyCache('https://b'), antwort(4, [geraet('lens', 4, optik)])).cache;
+ const ablage = writeCache(writeCache(null, a), b);
+ expect(readCache(ablage, 'https://a')).toEqual(a);
+ expect(readCache(ablage, 'https://b')).toEqual(b);
+ // Ein alter Einzelstand wird als Platz seines Servers gelesen.
+ expect(readCache(a, 'https://a')).toEqual(a);
+ expect(readCache(writeCache(a, b), 'https://a')).toEqual(a);
+ });
});
describe('store against a mocked server', () => {
@@ -195,7 +206,7 @@ describe('store against a mocked server', () => {
await useDeviceLibrary.getState().syncNow();
expect(fetchMock.mock.calls[2][0]).toBe('https://devices.zumpelars.de/api/sync?planner=multicam&after=7');
expect(getCameraById('devlib-sony-fx9-lib')).toBeUndefined();
- expect(JSON.parse(speicher['multicam-device-library-cache']).latestSeq).toBe(9);
+ expect(JSON.parse(speicher['multicam-device-library-cache']).byServer['https://devices.zumpelars.de'].latestSeq).toBe(9);
});
it('second factor: challenge header goes back with the code', async () => {
@@ -304,7 +315,7 @@ describe('store against a mocked server', () => {
expect(messageText('guidelines-outdated')).toMatch(/guidelines/);
});
- it('a changed server forgets the token and the cache; bad addresses are refused', async () => {
+ it('a changed server forgets the token; bad addresses are refused', async () => {
const { useDeviceLibrary, normaliseServerUrl } = await import('../library/store');
expect(normaliseServerUrl('http://devices.example.com')).toBeNull();
expect(normaliseServerUrl('http://localhost:8080/')).toBe('http://localhost:8080');
@@ -318,6 +329,104 @@ describe('store against a mocked server', () => {
expect(JSON.parse(speicher['multicam-device-library-server'])).toBeNull();
});
+ // ── Offline-Vertrag (`syncFrom` im gemeinsamen Client) ──────────────────
+ const eineKamera = () => antwort(3, [geraet('sony-fx9-lib', 3, cameraToFacet(eigeneKamera))]);
+ const cacheVon = (server: string) => readCache(JSON.parse(speicher['multicam-device-library-cache'] ?? 'null'), server);
+
+ it('switching the server keeps the other server\'s cache; switching back restores it', async () => {
+ fetchMock
+ .mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
+ .mockResolvedValueOnce(json(eineKamera()));
+ const { useDeviceLibrary } = await import('../library/store');
+ const { getCameraById } = await import('../data/cameras');
+ await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
+ expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
+
+ await useDeviceLibrary.getState().setServer('https://devices.example.com');
+ expect(useDeviceLibrary.getState().cache).toEqual(emptyCache('https://devices.example.com'));
+ expect(getCameraById('devlib-sony-fx9-lib')).toBeUndefined();
+ expect(cacheVon('https://devices.zumpelars.de').entries).toHaveLength(1);
+
+ await useDeviceLibrary.getState().setServer('https://devices.zumpelars.de');
+ expect(useDeviceLibrary.getState().cache.latestSeq).toBe(3);
+ expect(getCameraById('devlib-sony-fx9-lib')?.model).toBe('PXW-FX9 (eigene)');
+ });
+
+ it('a legacy single cache is read as the slot of its server and survives the next save', async () => {
+ const alt = mergeSync(emptyCache('https://devices.zumpelars.de'), eineKamera()).cache;
+ speicher['multicam-device-library-cache'] = JSON.stringify(alt);
+ const { useDeviceLibrary } = await import('../library/store');
+ expect(useDeviceLibrary.getState().cache).toEqual(alt);
+ fetchMock
+ .mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
+ .mockResolvedValueOnce(json(antwort(3, [])));
+ await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
+ expect(fetchMock.mock.calls[1][0]).toContain('after=3');
+ expect(JSON.parse(speicher['multicam-device-library-cache']).format).toBe('multicam-device-library-caches');
+ expect(cacheVon('https://devices.zumpelars.de').entries).toHaveLength(1);
+ });
+
+ it('a lower latestSeq fetches everything again and replaces the cache', async () => {
+ fetchMock
+ .mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
+ .mockResolvedValueOnce(json(antwort(10, [geraet('alt', 10, cameraToFacet(eigeneKamera))])));
+ const { useDeviceLibrary } = await import('../library/store');
+ await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
+ fetchMock
+ .mockResolvedValueOnce(json(antwort(2, [])))
+ .mockResolvedValueOnce(json(antwort(2, [geraet('neu', 2, lensToFacet(eigeneOptik))])));
+ await useDeviceLibrary.getState().syncNow();
+ expect(fetchMock.mock.calls[3][0]).toContain('after=0');
+ const c = useDeviceLibrary.getState().cache;
+ expect(c.entries.map((e) => e.slug)).toEqual(['neu']);
+ expect(c.latestSeq).toBe(2);
+ expect(cacheVon('https://devices.zumpelars.de').entries.map((e) => e.slug)).toEqual(['neu']);
+ });
+
+ it('an empty new server is an error and keeps the cache; so do offline and sign-out', async () => {
+ fetchMock
+ .mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
+ .mockResolvedValueOnce(json(eineKamera()));
+ const { useDeviceLibrary } = await import('../library/store');
+ const { libraryErrorText } = await import('../library/messages');
+ await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
+ const vorher = speicher['multicam-device-library-cache'];
+
+ fetchMock.mockResolvedValueOnce(json(antwort(0, []))).mockResolvedValueOnce(json(antwort(0, [])));
+ await useDeviceLibrary.getState().syncNow();
+ expect(useDeviceLibrary.getState().error).toBe('server-empty');
+ expect(libraryErrorText((_k, en) => en, 'server-empty')).toMatch(/were kept/);
+ expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
+ expect(speicher['multicam-device-library-cache']).toBe(vorher);
+
+ fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch'));
+ await useDeviceLibrary.getState().syncNow();
+ expect(useDeviceLibrary.getState().error).toBe('offline');
+ expect(libraryErrorText((_k, en) => en, 'offline')).toMatch(/last sync stay available/);
+ expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
+
+ fetchMock.mockResolvedValueOnce(json({ error: 'x' }, {}, 401));
+ await useDeviceLibrary.getState().syncNow();
+ expect(useDeviceLibrary.getState().signedIn).toBe(false);
+ expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
+ expect(speicher['multicam-device-library-cache']).toBe(vorher);
+ });
+
+ it('sign-out keeps the cache', async () => {
+ fetchMock
+ .mockResolvedValueOnce(json({ user: { id: 'u', email: 'a@b.de', username: 'l' } }, { 'set-auth-token': 'T' }))
+ .mockResolvedValueOnce(json(eineKamera()))
+ .mockResolvedValueOnce(json({}));
+ const { useDeviceLibrary } = await import('../library/store');
+ const { getCameraById } = await import('../data/cameras');
+ await useDeviceLibrary.getState().signIn('a@b.de', 'pw');
+ await useDeviceLibrary.getState().signOut();
+ expect(useDeviceLibrary.getState().signedIn).toBe(false);
+ expect(useDeviceLibrary.getState().cache.entries).toHaveLength(1);
+ expect(cacheVon('https://devices.zumpelars.de').entries).toHaveLength(1);
+ expect(getCameraById('devlib-sony-fx9-lib')?.model).toBe('PXW-FX9 (eigene)');
+ });
+
it('guidelines link follows the server address', async () => {
const { guidelinesUrl } = await import('../library/messages');
expect(guidelinesUrl('https://devices.zumpelars.de')).toBe('https://devices.zumpelars.de/guidelines');
diff --git a/src/components/Library/LibraryErrorLine.tsx b/src/components/Library/LibraryErrorLine.tsx
index 1ca3bc0..e1968d4 100644
--- a/src/components/Library/LibraryErrorLine.tsx
+++ b/src/components/Library/LibraryErrorLine.tsx
@@ -1,11 +1,10 @@
// Fehlerzeile der Geraetebibliothek; bei geaenderten Richtlinien mit dem Weg
// dorthin, denn annehmen laesst sie sich nur auf der Website.
import { useTranslation } from '../../i18n';
-import type { LibraryErrorCode } from '../../utils/deviceLibraryClient';
-import { guidelinesUrl, libraryErrorText } from '../../library/messages';
+import { guidelinesUrl, libraryErrorText, type LibraryFehler } from '../../library/messages';
import { useDeviceLibrary } from '../../library/store';
-export default function LibraryErrorLine({ code, detail }: { code: LibraryErrorCode; detail?: string }) {
+export default function LibraryErrorLine({ code, detail }: { code: LibraryFehler; detail?: string }) {
const { t } = useTranslation();
const server = useDeviceLibrary((s) => s.server);
return (
diff --git a/src/components/Settings/DeviceLibrarySection.tsx b/src/components/Settings/DeviceLibrarySection.tsx
index 2597b5d..1d19342 100644
--- a/src/components/Settings/DeviceLibrarySection.tsx
+++ b/src/components/Settings/DeviceLibrarySection.tsx
@@ -102,7 +102,7 @@ export default function DeviceLibrarySection() {
)}
- {t('library.server.hint', 'Changing the server signs you out and starts an empty cache: an account and its devices belong to one server.')}
+ {t('library.server.hint', 'Changing the server signs you out: an account and its devices belong to one server. Each server keeps its own cache — switching back brings its devices back.')}
diff --git a/src/i18n/de/library.ts b/src/i18n/de/library.ts
index 1a07f24..5677345 100644
--- a/src/i18n/de/library.ts
+++ b/src/i18n/de/library.ts
@@ -14,7 +14,7 @@ export const library: Record = {
'library.server.custom':
'Nicht der Standard-Server ({url}). Dieser Build erlaubt in seiner Content-Security-Policy nur den Standard; ein anderer Server muss dort eingetragen werden, sonst wird jede Anfrage blockiert.',
'library.server.hint':
- 'Ein Serverwechsel meldet ab und beginnt mit leerem Zwischenspeicher: Konto und Geräte gehören zu einem Server.',
+ 'Ein Serverwechsel meldet ab: Konto und Geräte gehören zu einem Server. Jeder Server hat seinen eigenen Zwischenspeicher — wer zurückwechselt, hat den alten Stand wieder.',
'library.account': 'Konto',
'library.signedInAs': 'Angemeldet als {name} ({email}).',
@@ -47,7 +47,9 @@ export const library: Record = {
'library.error.rateLimited': 'Zu viele Versuche. Eine Minute warten und erneut versuchen.',
'library.error.notSignedIn': 'Nicht angemeldet (oder die Sitzung ist abgelaufen). Bitte neu anmelden.',
'library.error.offline':
- 'Der Server ist nicht erreichbar. Verbindung prüfen — und bei einem anderen als dem Standard-Server, ob seine Adresse in der Content-Security-Policy dieses Builds steht.',
+ 'Der Server ist nicht erreichbar. Die Geräte vom letzten Abgleich bleiben verfügbar. Verbindung prüfen — und bei einem anderen als dem Standard-Server, ob seine Adresse in der Content-Security-Policy dieses Builds steht.',
+ 'library.error.serverEmpty':
+ 'Der Server wurde neu aufgesetzt und hat noch keine Geräte. Die Geräte vom letzten Abgleich wurden behalten.',
'library.error.server': 'Der Server hat mit einem Fehler geantwortet. Später erneut versuchen.',
'library.sync': 'Abgleich',
diff --git a/src/library/messages.ts b/src/library/messages.ts
index 7769a68..3b90d4c 100644
--- a/src/library/messages.ts
+++ b/src/library/messages.ts
@@ -2,8 +2,13 @@ import type { LibraryErrorCode } from '../utils/deviceLibraryClient';
type T = (key: string, en: string) => string;
+/** Die Codes des Clients plus `server-empty`: ein `server`-Fehler mit dieser
+ * Meldung aus `syncFrom` (neu aufgesetzter Server ohne Geraete). Er bekommt
+ * einen eigenen Satz, weil er sagen muss, dass die lokalen Geraete bleiben. */
+export type LibraryFehler = LibraryErrorCode | 'server-empty';
+
/** Die Fehlercodes des Clients in Saetzen, die sagen, was jetzt zu tun ist. */
-export function libraryErrorText(t: T, code: LibraryErrorCode): string {
+export function libraryErrorText(t: T, code: LibraryFehler): string {
switch (code) {
case 'wrong-credentials':
return t('library.error.wrongCredentials', 'Email/username or password is wrong.');
@@ -20,7 +25,9 @@ export function libraryErrorText(t: T, code: LibraryErrorCode): string {
case 'not-signed-in':
return t('library.error.notSignedIn', 'You are not signed in (or the session has expired). Sign in again.');
case 'offline':
- return t('library.error.offline', 'The server cannot be reached. Check the connection — and, for a server other than the default, that its address is allowed in the content security policy of this build.');
+ return t('library.error.offline', 'The server cannot be reached. The devices from the last sync stay available. Check the connection — and, for a server other than the default, that its address is allowed in the content security policy of this build.');
+ case 'server-empty':
+ return t('library.error.serverEmpty', 'The server was set up anew and has no devices yet. The devices from the last sync were kept.');
case 'server':
default:
return t('library.error.server', 'The server answered with an error. Try again later.');
diff --git a/src/library/store.ts b/src/library/store.ts
index 7398b53..53e71d3 100644
--- a/src/library/store.ts
+++ b/src/library/store.ts
@@ -9,11 +9,17 @@
// `DEFAULT_DEVICE_LIBRARY_URL` an. Eine geaenderte Adresse
// - meldet am alten Server ab und vergisst das Token — es gehoert zu ihm
// und darf nie an einen anderen Server gehen;
-// - beginnt einen leeren Cache (Slugs gelten je Server);
+// - wechselt auf den Cache-Platz dieses Servers (Slugs gelten je Server);
+// der Stand des alten Servers bleibt gespeichert und ist beim
+// Zurueckwechseln wieder da;
// - braucht einen Eintrag in der Content-Security-Policy (`index.html`
// und `electron/main.cjs`). Ohne ihn blockiert der Build die Anfrage,
// und der Client meldet `offline` — die Einstellungen sagen das dazu.
// Nur https, ausser fuer localhost: das Token ginge sonst im Klartext.
+//
+// OFFLINE-VERTRAG (`syncFrom` in `deviceLibraryClient.ts`): der Cache aendert
+// sich nur durch eine erfolgreiche Antwort. Offline, Zeitueberschreitung,
+// Serverfehler, abgelaufene Anmeldung, Abmelden: der letzte Stand bleibt.
// ───────────────────────────────────────────────────────────────────────────
import { create } from 'zustand';
import {
@@ -22,7 +28,7 @@ import {
currentUser,
signIn as signInRequest,
signOut as signOutRequest,
- sync as syncRequest,
+ syncFrom,
upload as uploadRequest,
verifySecondFactor,
type LibraryErrorCode,
@@ -33,7 +39,8 @@ import { loadJSON, saveJSON } from '../utils/storage';
import type { LibraryItem } from './facet';
import { applyUploadResults, emptyLedger, pendingUploads, pruneLedger, readLedger, type UploadLedger } from './upload';
import { setLibraryCatalog } from './registry';
-import { emptyCache, mergeSync, readCache, type LibraryCache, type SyncStats } from './sync';
+import { emptyCache, mergeSync, readCache, writeCache, type LibraryCache, type SyncStats } from './sync';
+import type { LibraryFehler } from './messages';
import { clearToken, loadToken, saveToken } from './tokenStore';
const SERVER_KEY = 'multicam-device-library-server';
@@ -74,7 +81,7 @@ interface LibraryState {
signedIn: boolean;
user: LibraryUser | null;
phase: LibraryPhase;
- error: LibraryErrorCode | null;
+ error: LibraryFehler | null;
/** Token nur fuer diese Sitzung gemerkt (kein Schluesselbund / Speicher voll). */
sessionOnly: boolean;
cache: LibraryCache;
@@ -108,6 +115,11 @@ function publish(cache: LibraryCache) {
});
}
+/** Legt den Stand auf den Platz seines Servers, die anderen bleiben. */
+function speichereCache(cache: LibraryCache) {
+ saveJSON(CACHE_KEY, writeCache(loadJSON(CACHE_KEY, null), cache));
+}
+
function initialCache(server: string): LibraryCache {
const c = readCache(loadJSON(CACHE_KEY, null), server);
publish(c);
@@ -132,9 +144,10 @@ export const useDeviceLibrary = create()((set, get) => {
await get().syncAll();
};
- const abgemeldetWenn = async (e: unknown) => {
- const code = e instanceof LibraryError ? e.code : 'server';
+ const abgemeldetWenn = async (e: unknown): Promise => {
+ const code: LibraryErrorCode = e instanceof LibraryError ? e.code : 'server';
if (code === 'not-signed-in' || code === 'wrong-credentials') await vergiss();
+ if (e instanceof LibraryError && e.message === 'server-empty') return 'server-empty';
return code === 'wrong-credentials' ? 'not-signed-in' : code;
};
@@ -183,8 +196,8 @@ export const useDeviceLibrary = create()((set, get) => {
if (token) await signOutRequest(alt, token);
await vergiss();
saveJSON(SERVER_KEY, neu === DEFAULT_DEVICE_LIBRARY_URL ? null : neu);
- const cache = emptyCache(neu);
- saveJSON(CACHE_KEY, cache);
+ // Kein Loeschen: der Stand des alten Servers bleibt auf seinem Platz.
+ const cache = readCache(loadJSON(CACHE_KEY, null), neu);
publish(cache);
const uploads = emptyLedger(neu);
saveJSON(LEDGER_KEY, uploads);
@@ -234,9 +247,13 @@ export const useDeviceLibrary = create()((set, get) => {
set({ phase: 'syncing', error: null });
try {
const { server, cache } = get();
- const antwort = await syncRequest(server, token, 'multicam', cache.latestSeq);
- const r = mergeSync(cache, antwort);
- saveJSON(CACHE_KEY, r.cache);
+ // Ob der Server noch derselbe ist, entscheidet `syncFrom` — dieselbe
+ // Regel in jedem Planner. `reset`: die Antwort ist der ganze Stand und
+ // ersetzt den Cache. Ein leerer neuer Server kommt als Fehler an, und
+ // der Stand bleibt.
+ const { reset, response } = await syncFrom(server, token, 'multicam', cache.latestSeq);
+ const r = mergeSync(reset ? emptyCache(server) : cache, response);
+ speichereCache(r.cache);
publish(r.cache);
set({ cache: r.cache, lastSync: { at: new Date().toISOString(), stats: r.stats }, phase: 'idle' });
} catch (e) {
diff --git a/src/library/sync.ts b/src/library/sync.ts
index 1b70289..2986cb6 100644
--- a/src/library/sync.ts
+++ b/src/library/sync.ts
@@ -12,8 +12,11 @@
// aktuelle Fassung wir nicht lesen koennen, als gueltig weiterzufuehren,
// hiesse Daten zu zeigen, die es so nicht mehr gibt.
//
-// Der Cache gehoert zu EINEM Server. Wechselt die Adresse, beginnt er leer —
-// Slugs und Sequenznummern sind nur innerhalb eines Servers eindeutig.
+// Ein Cache gehoert zu EINEM Server — Slugs und Sequenznummern sind nur
+// innerhalb eines Servers eindeutig. Gespeichert werden aber die Staende ALLER
+// Server nebeneinander (`CacheAblage`): ein Wechsel der Adresse loescht den
+// alten Stand nicht, und wer zurueckwechselt, hat ihn wieder (Vertrag Punkt 2
+// in `syncFrom`, `deviceLibraryClient.ts`).
// ───────────────────────────────────────────────────────────────────────────
import type { SyncDevice, SyncResponse } from '../utils/deviceLibraryClient';
import { facetToItem, type LibraryItem } from './facet';
@@ -78,11 +81,50 @@ export function mergeSync(cache: LibraryCache, response: SyncResponse): { cache:
};
}
-/** Liest einen gespeicherten Cache; alles Unlesbare oder fuer einen anderen
- * Server Gespeicherte ergibt einen leeren. */
+const istCache = (v: unknown): v is LibraryCache => {
+ if (!v || typeof v !== 'object') return false;
+ const c = v as Partial;
+ return typeof c.server === 'string' && typeof c.latestSeq === 'number' && Array.isArray(c.entries);
+};
+
+/**
+ * Die Staende aller Server unter einem Speicher-Schluessel.
+ *
+ * Bis 2026-09-28 lag dort genau EIN Cache, und eine andere Adresse hiess:
+ * leer anfangen und ueberschreiben. Wer auf einen Ersatzserver umstellte,
+ * weil devices.zumpelars.de gerade nicht lief, und zurueckwechselte, hatte
+ * danach eine leere Bibliothek. Ein alter Einzelstand wird beim Lesen als
+ * Platz seines Servers verstanden — nichts geht verloren.
+ */
+export interface CacheAblage {
+ format: 'multicam-device-library-caches';
+ version: 1;
+ byServer: Record;
+}
+
+const leereAblage = (): CacheAblage => ({ format: 'multicam-device-library-caches', version: 1, byServer: {} });
+
+/** Liest den Speicher-Inhalt — neue Ablage oder alter Einzelstand; Unlesbares
+ * ergibt eine leere Ablage. */
+export function readAblage(raw: unknown): CacheAblage {
+ if (istCache(raw)) return { ...leereAblage(), byServer: { [raw.server]: raw } };
+ const a = raw as Partial | null;
+ if (a && typeof a === 'object' && a.format === 'multicam-device-library-caches' && a.version === 1 && a.byServer && typeof a.byServer === 'object') {
+ const byServer: Record = {};
+ for (const [server, c] of Object.entries(a.byServer)) if (istCache(c) && c.server === server) byServer[server] = c;
+ return { ...leereAblage(), byServer };
+ }
+ return leereAblage();
+}
+
+/** Der Stand fuer DIESEN Server aus dem Speicher-Inhalt; keiner ergibt einen leeren. */
export function readCache(raw: unknown, server: string): LibraryCache {
- if (!raw || typeof raw !== 'object') return emptyCache(server);
- const c = raw as Partial;
- if (c.server !== server || typeof c.latestSeq !== 'number' || !Array.isArray(c.entries)) return emptyCache(server);
- return { server, latestSeq: c.latestSeq, entries: c.entries };
+ const c = readAblage(raw).byServer[server];
+ return c ? { server, latestSeq: c.latestSeq, entries: c.entries } : emptyCache(server);
+}
+
+/** Legt `cache` auf seinen Platz; die Staende der anderen Server bleiben. */
+export function writeCache(raw: unknown, cache: LibraryCache): CacheAblage {
+ const a = readAblage(raw);
+ return { ...a, byServer: { ...a.byServer, [cache.server]: cache } };
}
diff --git a/src/utils/deviceLibraryClient.ts b/src/utils/deviceLibraryClient.ts
index 313e7f8..3ea2154 100644
--- a/src/utils/deviceLibraryClient.ts
+++ b/src/utils/deviceLibraryClient.ts
@@ -100,13 +100,27 @@ export class LibraryError extends Error {
const basis = (server: string) => server.replace(/\/+$/, '')
+/**
+ * Hoechstens so lange wartet eine Anfrage, dann gilt der Server als offline.
+ *
+ * Ohne Frist haengt ein Abgleich an einem Server, der die Verbindung annimmt
+ * und dann schweigt (Proxy vor einem gestoppten Container, halbtote Leitung
+ * im Hallen-WLAN), unbegrenzt — und weil jeder Planner waehrenddessen
+ * „laeuft gerade" gesetzt hat, versucht er es bis zum Neustart nie wieder.
+ * Das Hochladen bekommt mehr Zeit: 100 Eintraege mit Facet sind kein
+ * Sekundenpaket.
+ */
+export const REQUEST_TIMEOUT_MS = 15_000
+export const UPLOAD_TIMEOUT_MS = 120_000
+
async function anfrage(
server: string,
pfad: string,
- init: { method?: 'GET' | 'POST'; token?: string; body?: unknown; headers?: Record } = {},
+ init: { method?: 'GET' | 'POST'; token?: string; body?: unknown; headers?: Record; timeoutMs?: number } = {},
): Promise {
try {
return await fetch(`${basis(server)}${pfad}`, {
+ signal: AbortSignal.timeout(init.timeoutMs ?? REQUEST_TIMEOUT_MS),
method: init.method ?? 'GET',
// Nie Cookies: der Planner ist ein fremder Ursprung, das Token ist
// die einzige Sitzung.
@@ -219,6 +233,43 @@ export async function sync(server: string, token: string, planner: LibraryPlanne
return body as unknown as SyncResponse
}
+export interface SyncResult {
+ /** `true`: `response` ist der GANZE Stand und ersetzt den Cache.
+ * `false`: `response` sind nur die Aenderungen nach `after`. */
+ reset: boolean
+ response: SyncResponse
+}
+
+/**
+ * Der Abgleich, wie ihn JEDER Planner faehrt — die Regel steht hier und nicht
+ * fuenfmal in fuenf Repos.
+ *
+ * DER VERTRAG. Die Bibliothek muss weiterarbeiten, wenn der Server weg ist.
+ * Daraus folgt fuer jeden Planner:
+ *
+ * 1. Der Cache wird nur durch eine ERFOLGREICHE Antwort geaendert. Offline,
+ * Zeitueberschreitung, Serverfehler, abgelaufene Anmeldung: der Stand
+ * bleibt, wie er ist. Abmelden leert ihn nicht.
+ * 2. Der Cache gehoert zu einer Server-Adresse, aber eine andere Adresse
+ * LOESCHT ihn nicht: jeder Server hat seinen eigenen Platz, und wer
+ * zurueckwechselt, hat seinen alten Stand wieder.
+ * 3. Meldet der Server einen kleineren `latestSeq` als gemerkt, ist er nicht
+ * mehr derselbe (neu aufgesetzt, Sicherung eingespielt): dann den ganzen
+ * Stand holen (`reset: true`). Kommt dabei NICHTS zurueck, ist das kein
+ * neuer Stand, sondern ein leerer Server — Fehler `server`, der Cache
+ * bleibt. Sonst loeschte ein frisch aufgesetzter Ersatzserver mit einem
+ * einzigen Abgleich jede Bibliothek, die sich mit ihm verbindet.
+ */
+export async function syncFrom(server: string, token: string, planner: LibraryPlanner, after: number): Promise {
+ const delta = await sync(server, token, planner, after)
+ if (delta.latestSeq >= after) return { reset: false, response: delta }
+ const ganz = await sync(server, token, planner, 0)
+ if (!ganz.devices.some((d) => !d.removed)) {
+ throw new LibraryError('server', 200, 'server-empty')
+ }
+ return { reset: true, response: ganz }
+}
+
/**
* Ein Geraet vorschlagen. Es geht in die Moderation und ist fuer andere erst
* nach der Freigabe sichtbar. `facet` ist das Objekt im Bibliotheksformat des
@@ -281,6 +332,7 @@ export async function upload(
method: 'POST',
token,
body: { planner, items: items.slice(i, i + UPLOAD_BATCH) },
+ timeoutMs: UPLOAD_TIMEOUT_MS,
})
const body = await jsonOderNull(res)
if (!res.ok) throw new LibraryError(fehlerAus(res, body), res.status, String(body?.error ?? ''))