diff --git a/src/client/shared/db/client.ts b/src/client/shared/db/client.ts index 62b64f3..8e1de0d 100644 --- a/src/client/shared/db/client.ts +++ b/src/client/shared/db/client.ts @@ -11,18 +11,91 @@ async function initDb(db: PGlite): Promise { return db } -/** Get the singleton PGlite instance. Safe to call multiple times — only creates once. */ +/** Detect if we're on iOS Safari which has IndexedDB limitations */ +function isIOSSafari(): boolean { + const userAgent = navigator.userAgent + const isIOS = /iPad|iPhone|iPod/.test(userAgent) + const isSafari = + /Safari/.test(userAgent) && !/Chrome|CriOS|FxiOS/.test(userAgent) + return isIOS || (isSafari && "ontouchstart" in window) +} + +/** Get the singleton PGlite instance with iOS fallback. Safe to call multiple times — only creates once. */ export function getDb(): Promise { if (instance) return Promise.resolve(instance) if (!initPromise) { - initPromise = initDb(new PGlite("idb://agw")).then((db) => { - instance = db - return db - }) + initPromise = createDbWithFallback() + .then((db) => { + instance = db + return db + }) + .catch((error) => { + console.error("Database initialization failed:", error) + // Reset promise to allow retry + initPromise = null + throw error + }) } return initPromise } +async function createDbWithFallback(): Promise { + // Add timeout to prevent hanging + const timeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Database initialization timeout")), + 10000, + ) + }) + + try { + // First try IndexedDB with timeout + const dbPromise = Promise.race([ + initDb(new PGlite("idb://agw")), + timeoutPromise, + ]) + + const db = await dbPromise + console.log("Database initialized successfully with IndexedDB") + return db + } catch (error) { + console.warn( + "IndexedDB initialization failed, falling back to memory:", + error, + ) + + // iOS Safari fallback: use in-memory database + if (isIOSSafari()) { + console.log("iOS Safari detected, using in-memory database") + return await initDb(new PGlite()) + } + + // For other browsers, try one more time with shorter timeout + try { + const shortTimeoutPromise = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("Database initialization timeout (retry)")), + 5000, + ) + }) + + const retryDb = await Promise.race([ + initDb(new PGlite("idb://agw")), + shortTimeoutPromise, + ]) + + console.log("Database initialized successfully with IndexedDB (retry)") + return retryDb + } catch (retryError) { + console.warn( + "IndexedDB retry failed, using in-memory database:", + retryError, + ) + return await initDb(new PGlite()) + } + } +} + /** Create a fresh in-memory PGlite for tests. */ export async function createTestDb(): Promise { return initDb(new PGlite()) diff --git a/src/client/shared/db/migrate-from-localstorage.ts b/src/client/shared/db/migrate-from-localstorage.ts index f7e3dcc..85ebeca 100644 --- a/src/client/shared/db/migrate-from-localstorage.ts +++ b/src/client/shared/db/migrate-from-localstorage.ts @@ -3,87 +3,121 @@ import type { PGlite } from "@electric-sql/pglite" /** One-time migration: read existing localStorage data, insert into PGlite, remove localStorage keys. */ export async function migrateFromLocalStorage(db: PGlite): Promise { - // device ID - const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId) - if (deviceId) { - await db.query( - "INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", - [deviceId], - ) - localStorage.removeItem(STORAGE_KEYS.deviceId) - } + console.log("Starting localStorage migration...") - // follows - const followsRaw = localStorage.getItem(STORAGE_KEYS.follows) - if (followsRaw) { - try { - const follows = JSON.parse(followsRaw) as Array<{ - type: string - entity_id: number - label: string - }> - for (const f of follows) { + try { + // device ID + const deviceId = localStorage.getItem(STORAGE_KEYS.deviceId) + if (deviceId) { + try { await db.query( - "INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", - [f.type, f.entity_id, f.label], + "INSERT INTO device (id) VALUES ($1) ON CONFLICT DO NOTHING", + [deviceId], ) + localStorage.removeItem(STORAGE_KEYS.deviceId) + console.log("Migrated device ID") + } catch (error) { + console.warn("Failed to migrate device ID:", error) } - } catch { - // corrupt data — skip } - localStorage.removeItem(STORAGE_KEYS.follows) - } - // feed cache - const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache) - if (feedRaw) { - try { - const parsed = JSON.parse(feedRaw) as { - items: unknown[] - updatedAt: number + // follows + const followsRaw = localStorage.getItem(STORAGE_KEYS.follows) + if (followsRaw) { + try { + const follows = JSON.parse(followsRaw) as Array<{ + type: string + entity_id: number + label: string + }> + for (const f of follows) { + try { + await db.query( + "INSERT INTO follows (type, entity_id, label) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + [f.type, f.entity_id, f.label], + ) + } catch (error) { + console.warn("Failed to migrate follow item:", f, error) + } + } + localStorage.removeItem(STORAGE_KEYS.follows) + console.log("Migrated follows") + } catch (error) { + console.warn("Failed to migrate follows:", error) } - if (Array.isArray(parsed.items)) { + } + + // feed cache + const feedRaw = localStorage.getItem(STORAGE_KEYS.feedCache) + if (feedRaw) { + try { + const parsed = JSON.parse(feedRaw) as { + items: unknown[] + updatedAt: number + } + if (Array.isArray(parsed.items)) { + await db.query( + `INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0)) + ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = to_timestamp($2 / 1000.0)`, + [JSON.stringify({ items: parsed.items }), parsed.updatedAt], + ) + } + localStorage.removeItem(STORAGE_KEYS.feedCache) + console.log("Migrated feed cache") + } catch (error) { + console.warn("Failed to migrate feed cache:", error) + } + } + + // geo cache + const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache) + if (geoRaw) { + try { + const cache = JSON.parse(geoRaw) as Record< + string, + { timestamp: number; result: unknown } + > + for (const [bundesland, entry] of Object.entries(cache)) { + try { + await db.query( + `INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0)) + ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = to_timestamp($3 / 1000.0)`, + [bundesland, JSON.stringify(entry.result), entry.timestamp], + ) + } catch (error) { + console.warn( + "Failed to migrate geo cache entry:", + bundesland, + error, + ) + } + } + localStorage.removeItem(STORAGE_KEYS.geoCache) + console.log("Migrated geo cache") + } catch (error) { + console.warn("Failed to migrate geo cache:", error) + } + } + + // push enabled + const pushEnabled = localStorage.getItem(STORAGE_KEYS.pushEnabled) + if (pushEnabled) { + try { await db.query( - `INSERT INTO feed_cache (id, data, updated_at) VALUES ('feed_items', $1, to_timestamp($2 / 1000.0)) - ON CONFLICT (id) DO UPDATE SET data = $1, updated_at = to_timestamp($2 / 1000.0)`, - [JSON.stringify({ items: parsed.items }), parsed.updatedAt], + `INSERT INTO push_state (key, value) VALUES ('enabled', $1) + ON CONFLICT (key) DO UPDATE SET value = $1`, + [pushEnabled], ) + localStorage.removeItem(STORAGE_KEYS.pushEnabled) + console.log("Migrated push enabled state") + } catch (error) { + console.warn("Failed to migrate push enabled state:", error) } - } catch { - // corrupt data — skip } - localStorage.removeItem(STORAGE_KEYS.feedCache) - } - // geo cache - const geoRaw = localStorage.getItem(STORAGE_KEYS.geoCache) - if (geoRaw) { - try { - const cache = JSON.parse(geoRaw) as Record< - string, - { timestamp: number; result: unknown } - > - for (const [bundesland, entry] of Object.entries(cache)) { - await db.query( - `INSERT INTO geo_cache (bundesland, data, cached_at) VALUES ($1, $2, to_timestamp($3 / 1000.0)) - ON CONFLICT (bundesland) DO UPDATE SET data = $2, cached_at = to_timestamp($3 / 1000.0)`, - [bundesland, JSON.stringify(entry.result), entry.timestamp], - ) - } - } catch { - // corrupt data — skip - } - localStorage.removeItem(STORAGE_KEYS.geoCache) - } - - // push enabled - const pushEnabled = localStorage.getItem(STORAGE_KEYS.pushEnabled) - if (pushEnabled) { - await db.query( - `INSERT INTO push_state (key, value) VALUES ('enabled', $1) - ON CONFLICT (key) DO UPDATE SET value = $1`, - [pushEnabled], - ) - localStorage.removeItem(STORAGE_KEYS.pushEnabled) + console.log("localStorage migration completed") + } catch (error) { + console.error("Migration failed with critical error:", error) + // Don't throw - let the app continue with empty database } } diff --git a/src/client/shared/db/provider.tsx b/src/client/shared/db/provider.tsx index 36fa111..f8e7292 100644 --- a/src/client/shared/db/provider.tsx +++ b/src/client/shared/db/provider.tsx @@ -1,20 +1,146 @@ import type { PGlite } from "@electric-sql/pglite" -import { type ReactNode, createContext, use } from "react" +import { + Component, + type ErrorInfo, + type ReactNode, + Suspense, + createContext, + use, +} from "react" import { getDb } from "./client" import { migrateFromLocalStorage } from "./migrate-from-localstorage" -const dbPromise = getDb().then(async (db) => { - await migrateFromLocalStorage(db) - return db -}) +const dbPromise = getDb() + .then(async (db) => { + try { + await migrateFromLocalStorage(db) + console.log("Database migration completed successfully") + return db + } catch (error) { + console.warn("Database migration failed, continuing anyway:", error) + // Continue with database even if migration fails + return db + } + }) + .catch((error) => { + console.error("Database initialization completely failed:", error) + // Re-throw to be caught by ErrorBoundary + throw error + }) const DbContext = createContext(null) -export function DbProvider({ children }: { children: ReactNode }) { +interface ErrorBoundaryState { + hasError: boolean + error?: Error +} + +class DbErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + ErrorBoundaryState +> { + constructor(props: { children: ReactNode; onReset?: () => void }) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("DbErrorBoundary caught error:", error, errorInfo) + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }) + if (this.props.onReset) { + this.props.onReset() + } else { + // Default: reload the page + window.location.reload() + } + } + + render() { + if (this.state.hasError) { + return ( +
+
+ + Error + + +
+

+ Datenbank-Initialisierung fehlgeschlagen +

+

+ Die App konnte nicht geladen werden. Dies kann auf iOS Safari + vorkommen. +

+ + {this.state.error && ( +
+ Technische Details +
+								{this.state.error.message}
+							
+
+ )} +
+ ) + } + + return this.props.children + } +} + +function DbProviderInner({ children }: { children: ReactNode }) { const db = use(dbPromise) return {children} } +export function DbProvider({ children }: { children: ReactNode }) { + return ( + + +
+
+

+ Datenbank wird geladen... +

+
+
+ } + > + {children} +
+
+ ) +} + export function useDb(): PGlite { const db = use(DbContext) if (!db) throw new Error("useDb must be used within DbProvider")