From 05e5e957e8cd89ec259fa06faa4ddb06cbdbcb0d Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 2 Apr 2026 12:40:31 +0200 Subject: [PATCH] feat(manacore/web): unified IndexedDB sync via Dexie hooks, eliminate cross-app readers Activate sync for the unified manacore IndexedDB by adding automatic change tracking via Dexie hooks on all 120+ tables. This replaces the unused manual trackChange() approach and eliminates the need for 12 separate cross-app IndexedDB reader instances. Key changes: - database.ts: Dexie hooks auto-record _pendingChanges for every write, TABLE_TO_SYNC_NAME mapping - sync.ts: rewritten with correct backend URLs, auth token, table name translation, server change guard - layout: unified sync engine replaces per-app manacoreStore/tag/link sync + 12 cross-app readers - cross-app-queries.ts: rewritten to query unified DB directly instead of via cross-app-stores - legacy-migration.ts: one-time migration from old per-app DBs (manacore-todo etc.) to unified DB - local-store.ts: refactored to use unified DB with collection wrappers instead of createLocalStore() - Deleted cross-app-stores.ts (383 lines) and change-tracker.ts (80 lines) - Updated ActivityFeed, TasksTodayWidget, CalendarEventsWidget, ContactsFavoritesWidget, spiral/collect.ts - Updated CLAUDE.md with unified IndexedDB architecture documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 105 ++--- .../src/lib/components/ActivityFeed.svelte | 12 +- .../widgets/CalendarEventsWidget.svelte | 3 +- .../widgets/ContactsFavoritesWidget.svelte | 5 +- .../dashboard/widgets/TasksTodayWidget.svelte | 15 +- .../apps/web/src/lib/data/change-tracker.ts | 80 ---- .../web/src/lib/data/cross-app-queries.ts | 222 ++++------ .../apps/web/src/lib/data/cross-app-stores.ts | 382 ------------------ .../apps/web/src/lib/data/database.ts | 136 +++++++ .../apps/web/src/lib/data/legacy-migration.ts | 173 ++++++++ .../apps/web/src/lib/data/local-store.ts | 117 ++++-- apps/manacore/apps/web/src/lib/data/sync.ts | 259 ++++++++---- .../web/src/lib/modules/spiral/collect.ts | 80 ++-- .../apps/web/src/routes/(app)/+layout.svelte | 46 +-- 14 files changed, 782 insertions(+), 853 deletions(-) delete mode 100644 apps/manacore/apps/web/src/lib/data/change-tracker.ts delete mode 100644 apps/manacore/apps/web/src/lib/data/cross-app-stores.ts create mode 100644 apps/manacore/apps/web/src/lib/data/legacy-migration.ts diff --git a/CLAUDE.md b/CLAUDE.md index b1d01f518..5bac5b04f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -570,51 +570,67 @@ $: doubled = count * 2; All web apps use a **local-first** data layer: reads/writes go to IndexedDB (Dexie.js) first, sync to server in the background. This enables guest mode, offline CRUD, and instant UI. -### Key Components +### Unified IndexedDB Architecture -| Component | Location | Purpose | -|-----------|----------|---------| -| `@manacore/local-store` | `packages/local-store/` | Dexie.js collections, sync engine, Svelte 5 reactive queries | -| `mana-sync` | `services/mana-sync/` | Go sync server (WebSocket push, field-level LWW conflict resolution) | -| Todo Hono Server | `apps/todo/apps/server/` | Lightweight compute server (RRULE, reminders, admin) on Bun | - -### Data Flow +The ManaCore unified app uses a **single IndexedDB** (`manacore`) containing all 120+ collections from all apps. Table names that collide across apps are prefixed (e.g., `todoProjects`, `cardDecks`, `presiDecks`). ``` -Guest: App → IndexedDB (Dexie.js) → UI (no sync) -Logged in: App → IndexedDB → UI → SyncEngine → mana-sync (Go) → PostgreSQL - ← WebSocket push ← +┌─────────────────────────────────────────────┐ +│ Unified IndexedDB: "manacore" │ +│ │ +│ tasks, todoProjects, labels, ... (todo) │ +│ calendars, events (calendar) │ +│ contacts (contacts) │ +│ conversations, messages (chat) │ +│ ... 120+ collections across 27 apps │ +│ │ +│ _pendingChanges (tagged with appId) │ +│ _syncMeta (keyed by [appId+coll]) │ +└──────────────────┬──────────────────────────┘ + │ Dexie hooks auto-track + │ all writes as pending changes + ▼ +┌──────────────────────────────────────────────┐ +│ Unified Sync Engine (sync.ts) │ +│ One sync channel per appId │ +│ POST /sync/{appId} (push) │ +│ GET /sync/{appId}/pull (pull) │ +│ WS /ws/{appId} (real-time notifications) │ +└──────────────────┬───────────────────────────┘ + ▼ + mana-sync (Go) + PostgreSQL (sync_changes) ``` -### Migrated Apps (21/23) +#### Key Files -| App | Collections | Status | -|-----|------------|--------| -| Todo | tasks, projects, labels, taskLabels, reminders | Done | -| Zitare | favorites, lists | Done | -| Calendar | calendars, events | Done | -| Clock | alarms, timers, worldClocks | Done | -| Contacts | contacts | Done | -| Cards | decks, cards | Done | -| Picture | images, boards, boardItems, tags, imageTags | Done | -| Presi | decks, slides | Done | -| Inventar | collections, items, locations, categories | Done | -| NutriPhi | meals, goals, favorites | Done | -| Planta | plants, plantPhotos, wateringSchedules, wateringLogs | Done | -| Storage | files, folders, tags, fileTags | Done | -| Chat | conversations, messages, templates | Done | -| Questions | collections, questions, answers | Done | -| Mukke | songs, playlists, playlistSongs, projects, markers | Done | -| Context | spaces, documents | Done | -| Photos | albums, albumItems, favorites, tags, photoTags | Done | -| SkilltTree | skills, activities, achievements | Done | -| CityCorners | locations, favorites | Done | -| Times | clients, projects, timeEntries, tags, templates, settings | Done | -| uLoad | links, tags, folders, linkTags | Done | -| Calc | calculations, savedFormulas | Done | -| ManaCore | userSettings, dashboardConfigs | Done | +| File | Purpose | +|------|---------| +| `apps/manacore/apps/web/src/lib/data/database.ts` | Unified Dexie DB, SYNC_APP_MAP, table name mappings, Dexie hooks | +| `apps/manacore/apps/web/src/lib/data/sync.ts` | Unified sync engine (push/pull/WS per appId) | +| `apps/manacore/apps/web/src/lib/data/legacy-migration.ts` | One-time migration from old per-app DBs | +| `packages/local-store/` | Standalone local-store (used by individual apps, not the unified app) | +| `services/mana-sync/` | Go sync server (WebSocket push, field-level LWW) | -**Not migrated (no CRUD data model):** Matrix (protocol client), Playground (stateless) +#### How Sync Works + +1. Module stores write directly to Dexie tables (`db.table('tasks').add(...)`) +2. Dexie hooks in `database.ts` automatically record each write to `_pendingChanges` with the correct `appId` +3. The unified sync engine groups pending changes by `appId` and pushes to `POST /sync/{appId}` +4. Table names are mapped between unified names (e.g., `todoProjects`) and backend names (e.g., `projects`) via `TABLE_TO_SYNC_NAME` +5. Server changes are pulled per collection and applied with a guard flag to prevent re-sync loops + +#### Adding a New App Module + +1. Add table definitions to `database.ts` schema (in `db.version(1).stores({...})`) +2. Add table-to-appId mapping in `SYNC_APP_MAP` +3. Add any renamed tables to `TABLE_TO_SYNC_NAME` +4. Create module in `src/lib/modules/{app}/` with collections, queries, stores +5. Dexie hooks automatically handle change tracking — no manual `trackChange()` needed + +### Standalone Apps (Legacy) + +Individual apps in `apps/*/apps/web/` still use `@manacore/local-store` with per-app IndexedDB databases (`manacore-{appId}`). When users first open the unified ManaCore app, `legacy-migration.ts` migrates data from these old DBs into the unified DB. ### Dev Commands (Local-First Stack) @@ -626,19 +642,6 @@ pnpm dev:todo:local # Web + sync + server (no auth needed) pnpm dev:todo:full # Everything incl. auth + DB setup ``` -### Adding Local-First to a New App - -1. Create `apps/{app}/apps/web/src/lib/data/local-store.ts` — define collections with `createLocalStore()` -2. Create `apps/{app}/apps/web/src/lib/data/guest-seed.ts` — onboarding data -3. Rewrite stores to use `collection.getAll()` / `collection.insert()` instead of API calls -4. In layout: `await store.initialize()`, `store.startSync()` on login, `allowGuest={true}` on AuthGate -5. Set `userEmail = ''` for guests so PillNav shows login button -6. Add `GuestWelcomeModal` for first-visit experience - -### Architecture Plan - -Full migration plan: `.claude/plans/local-first-architecture-migration.md` - ## Shared Packages (`packages/`) | Package | Purpose | diff --git a/apps/manacore/apps/web/src/lib/components/ActivityFeed.svelte b/apps/manacore/apps/web/src/lib/components/ActivityFeed.svelte index 9408658e0..493893dfe 100644 --- a/apps/manacore/apps/web/src/lib/components/ActivityFeed.svelte +++ b/apps/manacore/apps/web/src/lib/components/ActivityFeed.svelte @@ -1,9 +1,5 @@ diff --git a/apps/manacore/apps/web/src/lib/data/change-tracker.ts b/apps/manacore/apps/web/src/lib/data/change-tracker.ts deleted file mode 100644 index ad45e28da..000000000 --- a/apps/manacore/apps/web/src/lib/data/change-tracker.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Change Tracker — records local writes to _pendingChanges with appId routing. - * - * Usage in mutation stores: - * import { trackChange } from '$lib/data/change-tracker'; - * await taskTable.put(task); - * await trackChange('tasks', task.id, 'insert', task); - */ - -import { db, TABLE_TO_APP } from './database'; - -interface PendingChange { - appId: string; - collection: string; - recordId: string; - op: 'insert' | 'update' | 'delete'; - fields?: Record; - data?: Record; - deletedAt?: string; - createdAt: string; -} - -/** - * Record a local change to _pendingChanges for later sync. - */ -export async function trackChange( - collection: string, - recordId: string, - op: 'insert' | 'update' | 'delete', - data?: Record, - fields?: Record -): Promise { - const appId = TABLE_TO_APP[collection]; - if (!appId) { - console.warn(`[ChangeTracker] No appId mapping for collection "${collection}"`); - return; - } - - const now = new Date().toISOString(); - - const change: PendingChange = { - appId, - collection, - recordId, - op, - createdAt: now, - }; - - if (fields) change.fields = fields; - if (data) change.data = data; - if (op === 'delete') change.deletedAt = now; - - await db.table('_pendingChanges').add(change); -} - -/** - * Record a field-level update change (LWW). - * Only the changed fields are tracked, not the entire record. - */ -export async function trackFieldUpdate( - collection: string, - recordId: string, - updatedFields: Record -): Promise { - const now = new Date().toISOString(); - const fields: Record = {}; - - for (const [key, value] of Object.entries(updatedFields)) { - fields[key] = { value, updatedAt: now }; - } - - await trackChange(collection, recordId, 'update', undefined, fields); -} - -/** - * Record a soft-delete change. - */ -export async function trackDelete(collection: string, recordId: string): Promise { - await trackChange(collection, recordId, 'delete'); -} diff --git a/apps/manacore/apps/web/src/lib/data/cross-app-queries.ts b/apps/manacore/apps/web/src/lib/data/cross-app-queries.ts index 910bf57c5..c790b2ff5 100644 --- a/apps/manacore/apps/web/src/lib/data/cross-app-queries.ts +++ b/apps/manacore/apps/web/src/lib/data/cross-app-queries.ts @@ -1,58 +1,21 @@ /** * Cross-App Reactive Queries * - * Live queries that read directly from other apps' IndexedDB databases. - * Auto-update when data changes (local writes, sync, other tabs). - * Replaces REST API polling with instant reactive reads. + * Live queries on the unified IndexedDB. Auto-update when data changes + * (local writes, sync, other tabs) via Dexie's liveQuery. */ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; -import { - crossTaskCollection, - crossEventCollection, - crossContactCollection, - crossConversationCollection, - crossFavoriteCollection, - crossImageCollection, - crossAlarmCollection, - crossTimerCollection, - crossFileCollection, - crossSongCollection, - crossPlaylistCollection, - crossPresiDeckCollection, - crossSpaceCollection, - crossDocumentCollection, - crossCardsDeckCollection, - crossCardsCardCollection, - type CrossAppTask, - type CrossAppEvent, - type CrossAppContact, - type CrossAppConversation, - type CrossAppFavorite, - type CrossAppImage, - type CrossAppAlarm, - type CrossAppTimer, - type CrossAppFile, - type CrossAppSong, - type CrossAppPlaylist, - type CrossAppDeck, - type CrossAppSpace, - type CrossAppDocument, - type CrossAppCardsDeck, - type CrossAppCardsCard, -} from './cross-app-stores'; +import { db } from './database'; // ─── Todo Queries ─────────────────────────────────────────── /** All open (incomplete) tasks, sorted by order. */ export function useOpenTasks() { return useLiveQueryWithDefault(async () => { - const all = await crossTaskCollection.getAll(undefined, { - sortBy: 'order', - sortDirection: 'asc', - }); - return all.filter((t) => !t.isCompleted && !t.deletedAt); - }, [] as CrossAppTask[]); + const all = await db.table('tasks').orderBy('order').toArray(); + return all.filter((t: any) => !t.isCompleted && !t.deletedAt); + }, [] as any[]); } /** Tasks due today or overdue. */ @@ -62,18 +25,13 @@ export function useTodayTasks() { today.setHours(0, 0, 0, 0); const todayStr = today.toISOString().slice(0, 10); - const all = await crossTaskCollection.getAll(undefined, { - sortBy: 'order', - sortDirection: 'asc', - }); - - return all.filter((t) => { + const all = await db.table('tasks').orderBy('order').toArray(); + return all.filter((t: any) => { if (t.isCompleted || t.deletedAt) return false; if (!t.dueDate) return false; - const due = t.dueDate.slice(0, 10); - return due <= todayStr; + return t.dueDate.slice(0, 10) <= todayStr; }); - }, [] as CrossAppTask[]); + }, [] as any[]); } /** Tasks upcoming in the next N days. */ @@ -87,18 +45,14 @@ export function useUpcomingTasks(days = 7) { future.setDate(future.getDate() + days); const futureStr = future.toISOString().slice(0, 10); - const all = await crossTaskCollection.getAll(undefined, { - sortBy: 'dueDate', - sortDirection: 'asc', - }); - - return all.filter((t) => { + const all = await db.table('tasks').orderBy('dueDate').toArray(); + return all.filter((t: any) => { if (t.isCompleted || t.deletedAt) return false; if (!t.dueDate) return false; const due = t.dueDate.slice(0, 10); return due > todayStr && due <= futureStr; }); - }, [] as CrossAppTask[]); + }, [] as any[]); } // ─── Calendar Queries ─────────────────────────────────────── @@ -113,16 +67,12 @@ export function useUpcomingEvents(days = 7) { const nowStr = now.toISOString(); const futureStr = future.toISOString(); - const all = await crossEventCollection.getAll(undefined, { - sortBy: 'startDate', - sortDirection: 'asc', - }); - - return all.filter((e) => { + const all = await db.table('events').orderBy('startDate').toArray(); + return all.filter((e: any) => { if (e.deletedAt) return false; return e.startDate >= nowStr && e.startDate <= futureStr; }); - }, [] as CrossAppEvent[]); + }, [] as any[]); } // ─── Contacts Queries ─────────────────────────────────────── @@ -130,13 +80,9 @@ export function useUpcomingEvents(days = 7) { /** Favorite contacts. */ export function useFavoriteContacts(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await crossContactCollection.getAll(undefined, { - sortBy: 'firstName', - sortDirection: 'asc', - }); - - return all.filter((c) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit); - }, [] as CrossAppContact[]); + const all = await db.table('contacts').orderBy('firstName').toArray(); + return all.filter((c: any) => c.isFavorite && !c.isArchived && !c.deletedAt).slice(0, limit); + }, [] as any[]); } // ─── Chat Queries ─────────────────────────────────────────── @@ -144,27 +90,24 @@ export function useFavoriteContacts(limit = 5) { /** Recent conversations, sorted by updatedAt desc. */ export function useRecentConversations(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await crossConversationCollection.getAll(undefined, { - sortBy: 'updatedAt', - sortDirection: 'desc', - }); - return all.filter((c) => !c.isArchived && !c.deletedAt).slice(0, limit); - }, [] as CrossAppConversation[]); + const all = await db.table('conversations').toArray(); + return all + .filter((c: any) => !c.isArchived && !c.deletedAt) + .sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit); + }, [] as any[]); } // ─── Zitare Queries ───────────────────────────────────────── /** A random favorite quote. */ export function useRandomFavorite() { - return useLiveQueryWithDefault( - async () => { - const all = await crossFavoriteCollection.getAll(); - const active = all.filter((f) => !f.deletedAt); - if (active.length === 0) return null; - return active[Math.floor(Math.random() * active.length)]; - }, - null as CrossAppFavorite | null - ); + return useLiveQueryWithDefault(async () => { + const all = await db.table('zitareFavorites').toArray(); + const active = all.filter((f: any) => !f.deletedAt); + if (active.length === 0) return null; + return active[Math.floor(Math.random() * active.length)]; + }, null as any); } // ─── Picture Queries ──────────────────────────────────────── @@ -172,12 +115,12 @@ export function useRandomFavorite() { /** Recent generated images. */ export function useRecentImages(limit = 6) { return useLiveQueryWithDefault(async () => { - const all = await crossImageCollection.getAll(undefined, { - sortBy: 'createdAt', - sortDirection: 'desc', - }); - return all.filter((i) => !i.archivedAt && !i.deletedAt).slice(0, limit); - }, [] as CrossAppImage[]); + const all = await db.table('images').toArray(); + return all + .filter((i: any) => !i.archivedAt && !i.deletedAt) + .sort((a: any, b: any) => (b.createdAt ?? '').localeCompare(a.createdAt ?? '')) + .slice(0, limit); + }, [] as any[]); } // ─── Clock Queries ────────────────────────────────────────── @@ -185,17 +128,19 @@ export function useRecentImages(limit = 6) { /** Enabled alarms. */ export function useEnabledAlarms() { return useLiveQueryWithDefault(async () => { - const all = await crossAlarmCollection.getAll(); - return all.filter((a) => a.enabled && !a.deletedAt); - }, [] as CrossAppAlarm[]); + const all = await db.table('alarms').toArray(); + return all.filter((a: any) => a.enabled && !a.deletedAt); + }, [] as any[]); } /** Active/running timers. */ export function useActiveTimers() { return useLiveQueryWithDefault(async () => { - const all = await crossTimerCollection.getAll(); - return all.filter((t) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt); - }, [] as CrossAppTimer[]); + const all = await db.table('timers').toArray(); + return all.filter( + (t: any) => (t.status === 'running' || t.status === 'paused') && !t.deletedAt + ); + }, [] as any[]); } // ─── Storage Queries ──────────────────────────────────────── @@ -204,15 +149,15 @@ export function useActiveTimers() { export function useStorageStats() { return useLiveQueryWithDefault( async () => { - const files = await crossFileCollection.getAll(); - const active = files.filter((f) => !f.isDeleted && !f.deletedAt); - const totalSize = active.reduce((sum, f) => sum + (f.size || 0), 0); + const files = await db.table('files').toArray(); + const active = files.filter((f: any) => !f.isDeleted && !f.deletedAt); + const totalSize = active.reduce((sum: number, f: any) => sum + (f.size || 0), 0); const recent = active - .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) .slice(0, 5); return { totalFiles: active.length, totalSize, recentFiles: recent }; }, - { totalFiles: 0, totalSize: 0, recentFiles: [] as CrossAppFile[] } + { totalFiles: 0, totalSize: 0, recentFiles: [] as any[] } ); } @@ -222,21 +167,21 @@ export function useStorageStats() { export function useMukkeStats() { return useLiveQueryWithDefault( async () => { - const songs = await crossSongCollection.getAll(); - const playlists = await crossPlaylistCollection.getAll(); - const activeSongs = songs.filter((s) => !s.deletedAt); - const activePlaylists = playlists.filter((p) => !p.deletedAt); + const songs = await db.table('songs').toArray(); + const playlists = await db.table('mukkePlaylists').toArray(); + const activeSongs = songs.filter((s: any) => !s.deletedAt); + const activePlaylists = playlists.filter((p: any) => !p.deletedAt); const recent = activeSongs - .sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) .slice(0, 5); return { totalSongs: activeSongs.length, totalPlaylists: activePlaylists.length, - favoriteCount: activeSongs.filter((s) => s.favorite).length, + favoriteCount: activeSongs.filter((s: any) => s.favorite).length, recentSongs: recent, }; }, - { totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as CrossAppSong[] } + { totalSongs: 0, totalPlaylists: 0, favoriteCount: 0, recentSongs: [] as any[] } ); } @@ -245,12 +190,12 @@ export function useMukkeStats() { /** Recent presentation decks. */ export function useRecentDecks(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await crossPresiDeckCollection.getAll(undefined, { - sortBy: 'updatedAt', - sortDirection: 'desc', - }); - return all.filter((d) => !d.deletedAt).slice(0, limit); - }, [] as CrossAppDeck[]); + const all = await db.table('presiDecks').toArray(); + return all + .filter((d: any) => !d.deletedAt) + .sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit); + }, [] as any[]); } // ─── Context Queries ──────────────────────────────────────── @@ -258,22 +203,25 @@ export function useRecentDecks(limit = 5) { /** Recent documents + spaces. */ export function useRecentDocuments(limit = 5) { return useLiveQueryWithDefault(async () => { - const all = await crossDocumentCollection.getAll(undefined, { - sortBy: 'updatedAt', - sortDirection: 'desc', - }); - return all.filter((d) => !d.deletedAt).slice(0, limit); - }, [] as CrossAppDocument[]); + const all = await db.table('documents').toArray(); + return all + .filter((d: any) => !d.deletedAt) + .sort((a: any, b: any) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')) + .slice(0, limit); + }, [] as any[]); } export function useSpaces() { return useLiveQueryWithDefault(async () => { - const all = await crossSpaceCollection.getAll(undefined, { - sortBy: 'pinned', - sortDirection: 'desc', - }); - return all.filter((s) => !s.deletedAt); - }, [] as CrossAppSpace[]); + const all = await db.table('contextSpaces').toArray(); + return all + .filter((s: any) => !s.deletedAt) + .sort((a: any, b: any) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return 0; + }); + }, [] as any[]); } // ─── Cards Queries ───────────────────────────────────────── @@ -282,16 +230,16 @@ export function useSpaces() { export function useCardsProgress() { return useLiveQueryWithDefault( async () => { - const decks = await crossCardsDeckCollection.getAll(); - const cards = await crossCardsCardCollection.getAll(); - const activeDecks = decks.filter((d) => !d.deletedAt); - const activeCards = cards.filter((c) => !c.deletedAt); + const decks = await db.table('cardDecks').toArray(); + const cards = await db.table('cards').toArray(); + const activeDecks = decks.filter((d: any) => !d.deletedAt); + const activeCards = cards.filter((c: any) => !c.deletedAt); const now = new Date().toISOString(); - const dueCards = activeCards.filter((c) => c.nextReview && c.nextReview <= now); + const dueCards = activeCards.filter((c: any) => c.nextReview && c.nextReview <= now); return { totalDecks: activeDecks.length, totalCards: activeCards.length, - cardsLearned: activeCards.filter((c) => (c.reviewCount ?? 0) > 0).length, + cardsLearned: activeCards.filter((c: any) => (c.reviewCount ?? 0) > 0).length, dueForReview: dueCards.length, decks: activeDecks, }; @@ -301,7 +249,7 @@ export function useCardsProgress() { totalCards: 0, cardsLearned: 0, dueForReview: 0, - decks: [] as CrossAppCardsDeck[], + decks: [] as any[], } ); } diff --git a/apps/manacore/apps/web/src/lib/data/cross-app-stores.ts b/apps/manacore/apps/web/src/lib/data/cross-app-stores.ts deleted file mode 100644 index 32cfdc9dd..000000000 --- a/apps/manacore/apps/web/src/lib/data/cross-app-stores.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Cross-App IndexedDB Readers - * - * Opens other apps' IndexedDB databases for direct read access. - * All apps on the same origin share IndexedDB, so ManaCore can - * read from manacore-todo, manacore-calendar, etc. directly. - * - * Data is reactive via Dexie's liveQuery — updates when any app - * writes to the same database (including via sync). - * - * NOTE: These stores are read-only from ManaCore's perspective. - * Writes that need sync should go through the owning app's collections. - */ - -import { createLocalStore, type BaseRecord } from '@manacore/local-store'; - -// ─── Todo Types ───────────────────────────────────────────── - -export interface CrossAppTask extends BaseRecord { - title: string; - description?: string; - projectId?: string | null; - priority: 'low' | 'medium' | 'high' | 'urgent'; - isCompleted: boolean; - completedAt?: string | null; - dueDate?: string | null; - dueTime?: string | null; - scheduledDate?: string | null; - estimatedDuration?: number | null; - order: number; - subtasks?: { id: string; title: string; isCompleted: boolean; order: number }[] | null; - labels?: { id: string; name: string; color: string }[]; -} - -export interface CrossAppProject extends BaseRecord { - name: string; - color: string; - icon?: string | null; - order: number; - isArchived: boolean; - isDefault: boolean; -} - -// ─── Calendar Types ───────────────────────────────────────── - -export interface CrossAppEvent extends BaseRecord { - calendarId: string; - title: string; - description?: string | null; - startDate: string; - endDate: string; - allDay: boolean; - location?: string | null; - recurrenceRule?: string | null; - color?: string | null; -} - -export interface CrossAppCalendar extends BaseRecord { - name: string; - color: string; - isDefault: boolean; - isVisible: boolean; -} - -// ─── Contacts Types ───────────────────────────────────────── - -export interface CrossAppContact extends BaseRecord { - firstName?: string; - lastName?: string; - email?: string; - phone?: string; - company?: string; - jobTitle?: string; - photoUrl?: string; - isFavorite?: boolean; - isArchived?: boolean; -} - -// ─── Chat Types ───────────────────────────────────────────── - -export interface CrossAppConversation extends BaseRecord { - title?: string; - modelId?: string; - isArchived?: boolean; - isPinned?: boolean; - spaceId?: string; -} - -export interface CrossAppMessage extends BaseRecord { - conversationId: string; - sender: 'user' | 'assistant' | 'system'; - messageText: string; -} - -// ─── Zitare Types ─────────────────────────────────────────── - -export interface CrossAppFavorite extends BaseRecord { - quoteId: string; -} - -// ─── Picture Types ────────────────────────────────────────── - -export interface CrossAppImage extends BaseRecord { - prompt?: string; - publicUrl?: string; - storagePath?: string; - filename?: string; - width?: number; - height?: number; - isFavorite?: boolean; - isPublic?: boolean; - archivedAt?: string | null; -} - -// ─── Clock Types ──────────────────────────────────────────── - -export interface CrossAppAlarm extends BaseRecord { - label?: string; - time: string; - enabled: boolean; - repeatDays?: number[]; -} - -export interface CrossAppTimer extends BaseRecord { - label?: string; - durationSeconds: number; - remainingSeconds: number; - status: 'idle' | 'running' | 'paused' | 'finished'; - startedAt?: string; -} - -// ─── Storage Types ────────────────────────────────────────── - -export interface CrossAppFile extends BaseRecord { - name: string; - originalName?: string; - mimeType?: string; - size?: number; - parentFolderId?: string | null; - isFavorite?: boolean; - isDeleted?: boolean; -} - -export interface CrossAppFolder extends BaseRecord { - name: string; - parentFolderId?: string | null; - path?: string; - depth?: number; - isFavorite?: boolean; - isDeleted?: boolean; -} - -// ─── Mukke Types ──────────────────────────────────────────── - -export interface CrossAppSong extends BaseRecord { - title: string; - artist?: string; - album?: string; - duration?: number; - favorite?: boolean; -} - -export interface CrossAppPlaylist extends BaseRecord { - name: string; - description?: string; -} - -// ─── Presi Types ──────────────────────────────────────────── - -export interface CrossAppDeck extends BaseRecord { - title: string; - description?: string; - isPublic?: boolean; -} - -export interface CrossAppSlide extends BaseRecord { - deckId: string; - order: number; - content?: unknown; -} - -// ─── Context Types ────────────────────────────────────────── - -export interface CrossAppSpace extends BaseRecord { - name: string; - description?: string; - pinned?: boolean; -} - -export interface CrossAppDocument extends BaseRecord { - spaceId: string; - title: string; - type?: 'text' | 'context' | 'prompt'; - pinned?: boolean; -} - -// ─── Cards Types ─────────────────────────────────────────── - -export interface CrossAppCardsDeck extends BaseRecord { - name: string; - description?: string; - color?: string; - cardCount?: number; - lastStudied?: string; - isPublic?: boolean; -} - -export interface CrossAppCardsCard extends BaseRecord { - deckId: string; - front: string; - back: string; - difficulty?: number; - nextReview?: string; - reviewCount?: number; -} - -// ─── Store Instances ──────────────────────────────────────── -// These open existing IndexedDB databases created by other apps. -// No sync config — ManaCore only reads, the owning app handles sync. - -export const todoReader = createLocalStore({ - appId: 'todo', - collections: [ - { - name: 'tasks', - indexes: [ - 'projectId', - 'dueDate', - 'isCompleted', - 'priority', - 'order', - '[isCompleted+order]', - '[projectId+order]', - ], - }, - { - name: 'projects', - indexes: ['order', 'isArchived'], - }, - ], -}); - -export const calendarReader = createLocalStore({ - appId: 'calendar', - collections: [ - { - name: 'events', - indexes: ['calendarId', 'startDate', 'endDate', 'allDay', '[calendarId+startDate]'], - }, - { - name: 'calendars', - indexes: ['isDefault', 'isVisible'], - }, - ], -}); - -export const contactsReader = createLocalStore({ - appId: 'contacts', - collections: [ - { - name: 'contacts', - indexes: ['firstName', 'lastName', 'email', 'company', 'isFavorite', 'isArchived'], - }, - ], -}); - -export const chatReader = createLocalStore({ - appId: 'chat', - collections: [ - { name: 'conversations', indexes: ['isArchived', 'isPinned', 'spaceId'] }, - { name: 'messages', indexes: ['conversationId', 'sender', '[conversationId+sender]'] }, - ], -}); - -export const zitareReader = createLocalStore({ - appId: 'zitare', - collections: [{ name: 'favorites', indexes: ['quoteId'] }], -}); - -export const pictureReader = createLocalStore({ - appId: 'picture', - collections: [{ name: 'images', indexes: ['isFavorite', 'isPublic', 'archivedAt', 'prompt'] }], -}); - -export const clockReader = createLocalStore({ - appId: 'clock', - collections: [ - { name: 'alarms', indexes: ['enabled', 'time'] }, - { name: 'timers', indexes: ['status'] }, - ], -}); - -export const storageReader = createLocalStore({ - appId: 'storage', - collections: [ - { - name: 'files', - indexes: ['parentFolderId', 'mimeType', 'isFavorite', 'isDeleted', 'name'], - }, - { name: 'folders', indexes: ['parentFolderId', 'path', 'depth', 'isFavorite', 'isDeleted'] }, - ], -}); - -export const mukkeReader = createLocalStore({ - appId: 'mukke', - collections: [ - { name: 'songs', indexes: ['artist', 'album', 'genre', 'favorite', 'title'] }, - { name: 'playlists', indexes: ['name'] }, - ], -}); - -export const presiReader = createLocalStore({ - appId: 'presi', - collections: [ - { name: 'decks', indexes: ['isPublic'] }, - { name: 'slides', indexes: ['deckId', 'order', '[deckId+order]'] }, - ], -}); - -export const contextReader = createLocalStore({ - appId: 'context', - collections: [ - { name: 'spaces', indexes: ['pinned', 'prefix'] }, - { name: 'documents', indexes: ['spaceId', 'type', 'pinned', 'title', '[spaceId+type]'] }, - ], -}); - -export const cardsReader = createLocalStore({ - appId: 'cards', - collections: [ - { name: 'decks', indexes: ['isPublic'] }, - { name: 'cards', indexes: ['deckId', 'difficulty', 'nextReview', 'order', '[deckId+order]'] }, - ], -}); - -// ─── Typed Collection Accessors ───────────────────────────── - -// Todo -export const crossTaskCollection = todoReader.collection('tasks'); -export const crossProjectCollection = todoReader.collection('projects'); - -// Calendar -export const crossEventCollection = calendarReader.collection('events'); -export const crossCalendarCollection = calendarReader.collection('calendars'); - -// Contacts -export const crossContactCollection = contactsReader.collection('contacts'); - -// Chat -export const crossConversationCollection = - chatReader.collection('conversations'); -export const crossMessageCollection = chatReader.collection('messages'); - -// Zitare -export const crossFavoriteCollection = zitareReader.collection('favorites'); - -// Picture -export const crossImageCollection = pictureReader.collection('images'); - -// Clock -export const crossAlarmCollection = clockReader.collection('alarms'); -export const crossTimerCollection = clockReader.collection('timers'); - -// Storage -export const crossFileCollection = storageReader.collection('files'); -export const crossFolderCollection = storageReader.collection('folders'); - -// Mukke -export const crossSongCollection = mukkeReader.collection('songs'); -export const crossPlaylistCollection = mukkeReader.collection('playlists'); - -// Presi -export const crossPresiDeckCollection = presiReader.collection('decks'); -export const crossSlideCollection = presiReader.collection('slides'); - -// Context -export const crossSpaceCollection = contextReader.collection('spaces'); -export const crossDocumentCollection = contextReader.collection('documents'); - -// Cards -export const crossCardsDeckCollection = cardsReader.collection('decks'); -export const crossCardsCardCollection = cardsReader.collection('cards'); diff --git a/apps/manacore/apps/web/src/lib/data/database.ts b/apps/manacore/apps/web/src/lib/data/database.ts index 942daf04f..3cc960c36 100644 --- a/apps/manacore/apps/web/src/lib/data/database.ts +++ b/apps/manacore/apps/web/src/lib/data/database.ts @@ -229,3 +229,139 @@ export const SYNC_APP_MAP: Record = { export const TABLE_TO_APP: Record = Object.fromEntries( Object.entries(SYNC_APP_MAP).flatMap(([appId, tables]) => tables.map((table) => [table, appId])) ); + +// ─── Table Name Mapping (Unified ↔ Backend) ────────────────── +// The unified DB renames tables to avoid collisions (e.g., todoProjects, cardDecks). +// The backend (mana-sync) knows the original names from standalone apps. + +/** Unified table name → backend collection name (only renamed tables). */ +export const TABLE_TO_SYNC_NAME: Record = { + // todo + todoProjects: 'projects', + // chat + chatTemplates: 'templates', + // picture + pictureTags: 'tags', + // cards + cardDecks: 'decks', + // zitare + zitareFavorites: 'favorites', + zitareLists: 'lists', + // mukke + mukkePlaylists: 'playlists', + mukkeProjects: 'projects', + // storage + storageFolders: 'folders', + storageTags: 'tags', + // presi + presiDecks: 'decks', + // inventar + invCollections: 'collections', + invItems: 'items', + invLocations: 'locations', + invCategories: 'categories', + // photos + photoFavorites: 'favorites', + photoTags: 'tags', + photoMediaTags: 'photoTags', + // citycorners + ccLocations: 'locations', + ccFavorites: 'favorites', + // times + timeClients: 'clients', + timeProjects: 'projects', + timeTags: 'tags', + timeTemplates: 'templates', + timeSettings: 'settings', + // context + contextSpaces: 'spaces', + // questions + qCollections: 'collections', + // nutriphi + nutriFavorites: 'favorites', + // memoro + memoroTags: 'tags', + memoroSpaces: 'spaces', + // uload + uloadTags: 'tags', + uloadFolders: 'folders', + // guides + guideCollections: 'collections', + // shared: tags + globalTags: 'tags', + tagGroups: 'tagGroups', + // shared: links + manaLinks: 'links', +}; + +/** Get the backend collection name for a unified table. */ +export function toSyncName(tableName: string): string { + return TABLE_TO_SYNC_NAME[tableName] ?? tableName; +} + +/** Build reverse map: for a given appId, maps backend collection name → unified table name. */ +export const SYNC_NAME_TO_TABLE: Record> = {}; +for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { + const map: Record = {}; + for (const tableName of tables) { + const syncName = toSyncName(tableName); + map[syncName] = tableName; + } + SYNC_NAME_TO_TABLE[appId] = map; +} + +/** Get the unified table name for a backend collection + appId. */ +export function fromSyncName(appId: string, syncCollection: string): string { + return SYNC_NAME_TO_TABLE[appId]?.[syncCollection] ?? syncCollection; +} + +// ─── Change Tracking via Dexie Hooks ───────────────────────── +// Automatically records pending changes for every write to sync-relevant tables. +// This means module stores (taskTable.add(), etc.) don't need manual trackChange() calls. + +let _applyingServerChanges = false; + +/** Set to true while applying server changes to prevent sync loops. */ +export function setApplyingServerChanges(v: boolean): void { + _applyingServerChanges = v; +} + +const pendingChangesTable = db.table('_pendingChanges'); + +for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { + for (const tableName of tables) { + const table = db.table(tableName); + + table.hook('creating', function (_primKey, obj) { + if (_applyingServerChanges) return; + const now = new Date().toISOString(); + pendingChangesTable.add({ + appId, + collection: tableName, + recordId: obj.id, + op: 'insert', + data: { ...obj }, + createdAt: now, + }); + }); + + table.hook('updating', function (modifications, primKey) { + if (_applyingServerChanges) return; + const now = new Date().toISOString(); + const fields: Record = {}; + for (const [key, value] of Object.entries(modifications)) { + if (key === 'id') continue; + fields[key] = { value, updatedAt: now }; + } + pendingChangesTable.add({ + appId, + collection: tableName, + recordId: primKey as string, + op: (modifications as Record).deletedAt ? 'delete' : 'update', + fields, + deletedAt: (modifications as Record).deletedAt as string | undefined, + createdAt: now, + }); + }); + } +} diff --git a/apps/manacore/apps/web/src/lib/data/legacy-migration.ts b/apps/manacore/apps/web/src/lib/data/legacy-migration.ts new file mode 100644 index 000000000..aeef51694 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/data/legacy-migration.ts @@ -0,0 +1,173 @@ +/** + * Legacy Database Migration + * + * Migrates data from old per-app IndexedDB databases (manacore-todo, + * manacore-calendar, etc.) into the unified `manacore` database. + * + * This runs once on app startup. After migration, old DBs are kept + * (not deleted) as a safety net — they can be removed later. + */ + +import Dexie from 'dexie'; +import { db, SYNC_APP_MAP, TABLE_TO_SYNC_NAME } from './database'; + +const MIGRATION_KEY = 'manacore-unified-migrated'; +const MIGRATION_VERSION = '1'; + +/** + * Reverse of TABLE_TO_SYNC_NAME: for a given appId, maps + * old DB table name → unified DB table name. + */ +function buildLegacyToUnifiedMap(): Record> { + const result: Record> = {}; + + for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) { + const map: Record = {}; + for (const unifiedName of tables) { + // The old DB used the backend collection name (before renaming) + const legacyName = TABLE_TO_SYNC_NAME[unifiedName] ?? unifiedName; + map[legacyName] = unifiedName; + } + result[appId] = map; + } + + return result; +} + +const LEGACY_TO_UNIFIED = buildLegacyToUnifiedMap(); + +/** + * Migrate all legacy per-app databases into the unified DB. + * Idempotent — checks if each record already exists before inserting. + * Skips if migration was already completed. + */ +export async function migrateFromLegacyDbs(): Promise { + // Skip if already migrated + if (typeof localStorage !== 'undefined') { + const migrated = localStorage.getItem(MIGRATION_KEY); + if (migrated === MIGRATION_VERSION) return; + } + + const appIds = Object.keys(SYNC_APP_MAP); + let migratedAny = false; + + for (const appId of appIds) { + const legacyDbName = `manacore-${appId}`; + + // Check if legacy DB exists + const exists = await Dexie.exists(legacyDbName); + if (!exists) continue; + + try { + await migrateSingleApp(appId, legacyDbName); + migratedAny = true; + } catch (err) { + console.warn(`[LegacyMigration] Failed to migrate ${legacyDbName}:`, err); + // Continue with other apps — don't block on one failure + } + } + + // Also migrate shared stores + await migrateSharedStore('manacore-tags', { + tags: 'globalTags', + tagGroups: 'tagGroups', + }); + await migrateSharedStore('manacore-links', { + links: 'manaLinks', + }); + + // Mark migration as complete + if (typeof localStorage !== 'undefined') { + localStorage.setItem(MIGRATION_KEY, MIGRATION_VERSION); + } + + if (migratedAny) { + console.log('[LegacyMigration] Migration complete'); + } +} + +/** + * Migrate a single app's legacy DB into the unified DB. + */ +async function migrateSingleApp(appId: string, legacyDbName: string): Promise { + const legacyDb = new Dexie(legacyDbName); + + // Open without specifying schema — Dexie will read the existing schema + await legacyDb.open(); + + const tableMapping = LEGACY_TO_UNIFIED[appId] ?? {}; + const legacyTables = legacyDb.tables.map((t) => t.name); + + for (const legacyTableName of legacyTables) { + // Skip internal tables + if (legacyTableName.startsWith('_')) continue; + + // Find the unified table name + const unifiedTableName = tableMapping[legacyTableName] ?? legacyTableName; + + // Check if this table exists in the unified DB + try { + db.table(unifiedTableName); + } catch { + // Table doesn't exist in unified DB — skip + continue; + } + + // Read all records from legacy table + const records = await legacyDb.table(legacyTableName).toArray(); + if (records.length === 0) continue; + + // Batch upsert into unified DB (idempotent via bulkPut) + const unifiedTable = db.table(unifiedTableName); + await unifiedTable.bulkPut(records); + } + + // Migrate sync cursors (_syncMeta) + try { + const syncMeta = await legacyDb.table('_syncMeta').toArray(); + for (const meta of syncMeta) { + const collection = meta.collection; + const unifiedCollection = tableMapping[collection] ?? collection; + await db.table('_syncMeta').put({ + appId, + collection: unifiedCollection, + lastSyncedAt: meta.lastSyncedAt ?? meta.syncedUntil ?? '1970-01-01T00:00:00.000Z', + pendingCount: 0, + }); + } + } catch { + // _syncMeta may not exist in legacy DB + } + + legacyDb.close(); +} + +/** + * Migrate a shared store (tags, links) from its own legacy DB. + */ +async function migrateSharedStore( + legacyDbName: string, + tableMapping: Record +): Promise { + const exists = await Dexie.exists(legacyDbName); + if (!exists) return; + + try { + const legacyDb = new Dexie(legacyDbName); + await legacyDb.open(); + + for (const [legacyName, unifiedName] of Object.entries(tableMapping)) { + try { + const records = await legacyDb.table(legacyName).toArray(); + if (records.length === 0) continue; + await db.table(unifiedName).bulkPut(records); + } catch { + // Table may not exist + } + } + + legacyDb.close(); + } catch (err) { + console.warn(`[LegacyMigration] Failed to migrate ${legacyDbName}:`, err); + } +} diff --git a/apps/manacore/apps/web/src/lib/data/local-store.ts b/apps/manacore/apps/web/src/lib/data/local-store.ts index 076051aca..160d9dac0 100644 --- a/apps/manacore/apps/web/src/lib/data/local-store.ts +++ b/apps/manacore/apps/web/src/lib/data/local-store.ts @@ -1,14 +1,17 @@ /** * ManaCore App — Local-First Data Layer * - * Defines the IndexedDB database, collections, and guest seed data. + * Provides typed collection accessors on the unified DB for core ManaCore data. + * Uses the unified `manacore` Dexie database (not a separate per-app DB). + * * Collections: userSettings, dashboardConfigs * Tags use the shared tagLocalStore from @manacore/shared-stores. */ -import { createLocalStore, type BaseRecord } from '@manacore/local-store'; +import type { BaseRecord } from '@manacore/local-store'; import type { WidgetConfig } from '$lib/types/dashboard'; import type { TileNode } from '$lib/types/tiling'; +import { db } from './database'; import { guestSettings, guestDashboardConfigs } from './guest-seed.js'; // ─── Types ────────────────────────────────────────────────── @@ -33,30 +36,96 @@ export interface LocalDashboardConfig extends BaseRecord { tiling?: TileNode; } -// ─── Store ────────────────────────────────────────────────── +// ─── Collection Wrappers ──────────────────────────────────── +// Wraps Dexie tables with a LocalCollection-compatible API so existing +// consumers (queries.ts, dashboard.svelte.ts, tiling.svelte.ts) work unchanged. -const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; +function createCollectionWrapper(tableName: string) { + const table = db.table(tableName); -export const manacoreStore = createLocalStore({ - appId: 'manacore', - collections: [ - { - name: 'userSettings', - indexes: ['key'], - guestSeed: guestSettings, + return { + async get(id: string): Promise { + const record = await table.get(id); + if (record && (record as any).deletedAt) return undefined; + return record; }, - { - name: 'dashboardConfigs', - indexes: [], - guestSeed: guestDashboardConfigs, - }, - ], - sync: { - serverUrl: SYNC_SERVER_URL, - }, -}); -// Typed collection accessors -export const settingsCollection = manacoreStore.collection('userSettings'); + async getAll( + _filter?: unknown, + options?: { sortBy?: string; sortDirection?: 'asc' | 'desc' } + ): Promise { + let results = await table.toArray(); + results = results.filter((r) => !(r as any).deletedAt); + if (options?.sortBy) { + const key = options.sortBy as keyof T; + const dir = options.sortDirection === 'desc' ? -1 : 1; + results.sort((a, b) => { + const aVal = String(a[key] ?? ''); + const bVal = String(b[key] ?? ''); + return aVal.localeCompare(bVal) * dir; + }); + } + return results; + }, + + async insert(record: T): Promise { + const now = new Date().toISOString(); + await table.put({ + ...record, + createdAt: record.createdAt ?? now, + updatedAt: now, + }); + }, + + async update(id: string, changes: Partial): Promise { + await table.update(id, { + ...changes, + updatedAt: new Date().toISOString(), + } as any); + }, + + async delete(id: string): Promise { + await table.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as any); + }, + + async count(): Promise { + const all = await table.toArray(); + return all.filter((r) => !(r as any).deletedAt).length; + }, + }; +} + +export const settingsCollection = createCollectionWrapper('userSettings'); export const dashboardCollection = - manacoreStore.collection('dashboardConfigs'); + createCollectionWrapper('dashboardConfigs'); + +// ─── Store-compatible facade ──────────────────────────────── +// Provides initialize() / startSync() / stopSync() so the layout +// can call manacoreStore.initialize() without breaking. + +let _initialized = false; + +export const manacoreStore = { + async initialize(): Promise { + if (_initialized) return; + _initialized = true; + + // Seed guest data if tables are empty + const settingsCount = await db.table('userSettings').count(); + if (settingsCount === 0 && guestSettings.length > 0) { + await db.table('userSettings').bulkPut(guestSettings); + } + + const dashboardCount = await db.table('dashboardConfigs').count(); + if (dashboardCount === 0 && guestDashboardConfigs.length > 0) { + await db.table('dashboardConfigs').bulkPut(guestDashboardConfigs); + } + }, + + // No-ops — sync is handled by the unified sync engine + startSync(_getToken: () => Promise): void {}, + stopSync(): void {}, +}; diff --git a/apps/manacore/apps/web/src/lib/data/sync.ts b/apps/manacore/apps/web/src/lib/data/sync.ts index 36e22051b..c5a31811d 100644 --- a/apps/manacore/apps/web/src/lib/data/sync.ts +++ b/apps/manacore/apps/web/src/lib/data/sync.ts @@ -7,10 +7,14 @@ * Architecture: * Unified DB → PendingChange (tagged with appId) → SyncChannel per appId → mana-sync /sync/{appId} * mana-sync /sync/{appId} → WebSocket push → SyncChannel → applies to Unified DB + * + * Backend protocol (mana-sync Go): + * Push: POST /sync/{appId} — body: { clientId, since, changes: [{ table, id, op, fields, data }] } + * Pull: GET /sync/{appId}/pull?collection={name}&since={cursor} + * WS: GET /ws/{appId} — auth: { type: "auth", token: "..." } */ -import { db, SYNC_APP_MAP, TABLE_TO_APP } from './database'; -import type Dexie from 'dexie'; +import { db, SYNC_APP_MAP, toSyncName, fromSyncName, setApplyingServerChanges } from './database'; // ─── Types ──────────────────────────────────────────────────── @@ -42,21 +46,23 @@ interface SyncChannelState { lastError: string | null; } -type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; +export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline'; // ─── Config ─────────────────────────────────────────────────── const PUSH_DEBOUNCE = 1000; const PULL_INTERVAL = 30_000; const WS_RECONNECT_DELAY = 5000; +const WS_AUTH_TIMEOUT = 10_000; // ─── Unified Sync Manager ───────────────────────────────────── export function createUnifiedSync(serverUrl: string, getToken: () => Promise) { const channels = new Map(); - let clientId = getOrCreateClientId(); + const clientId = getOrCreateClientId(); let status: SyncStatus = 'idle'; let online = typeof navigator !== 'undefined' ? navigator.onLine : true; + let _statusListeners: Array<(s: SyncStatus) => void> = []; // ─── Lifecycle ────────────────────────────────────────── @@ -80,17 +86,6 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise { - // Auto-tag with appId based on collection - if (!obj.appId && obj.collection) { - obj.appId = TABLE_TO_APP[obj.collection] || 'manacore'; - } - // Debounced push - const appId = obj.appId; - if (appId) schedulePush(appId); - }); - // Listen for online/offline if (typeof window !== 'undefined') { window.addEventListener('online', handleOnline); @@ -99,7 +94,7 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise Promise Promise push(appId).catch(() => {}), PUSH_DEBOUNCE); } + /** Called from Dexie hooks when a pending change is recorded. */ + function onPendingChange(appId: string): void { + schedulePush(appId); + } + async function push(appId: string): Promise { const channel = channels.get(appId); if (!channel) return; @@ -141,30 +142,50 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise 0) { + await applyServerChanges(appId, data.serverChanges); + } + + // Update sync cursor + if (data.syncedUntil) { + for (const tableName of channel.tables) { + await setSyncCursor(appId, tableName, data.syncedUntil); + } + } + // Clear synced pending changes const ids = pending.map((p) => p.id).filter((id): id is number => id !== undefined); await db.table('_pendingChanges').bulkDelete(ids); channel.lastError = null; - status = 'idle'; + setStatus('idle'); } catch (err) { channel.lastError = err instanceof Error ? err.message : 'Push failed'; - status = 'error'; + setStatus('error'); } } @@ -177,26 +198,30 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise Promise Promise { + ws.onopen = async () => { channel.ws = ws; + // Authenticate — backend requires auth within 10 seconds + const token = await getToken(); + if (token && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'auth', token })); + } }; ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); - if (msg.type === 'push') { + if (msg.type === 'sync-available') { // Server notifies us of new changes — trigger pull pull(appId).catch(() => {}); } - } catch {} + } catch { + // Ignore malformed messages + } }; ws.onclose = () => { @@ -253,6 +285,91 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise { + setApplyingServerChanges(true); + try { + // Group changes by table (server returns backend collection names) + const byTable = new Map(); + for (const change of changes) { + const serverTable = change.table; + // Map backend collection name → unified table name + const unifiedTable = fromSyncName(appId, serverTable); + if (!byTable.has(unifiedTable)) byTable.set(unifiedTable, []); + byTable.get(unifiedTable)!.push(change); + } + + for (const [tableName, tableChanges] of byTable) { + const table = db.table(tableName); + + await db.transaction('rw', table, async () => { + for (const change of tableChanges) { + const recordId = change.id; + + if (change.deletedAt || change.op === 'delete') { + // Soft delete or hard delete + const existing = await table.get(recordId); + if (existing) { + if (change.deletedAt) { + await table.update(recordId, { + deletedAt: change.deletedAt, + updatedAt: change.deletedAt, + }); + } else { + await table.delete(recordId); + } + } + } else if (change.op === 'insert') { + // Upsert for inserts + const existing = await table.get(recordId); + if (!existing) { + await table.put(change.data ?? { id: recordId, ...change }); + } else { + // Record exists — merge with LWW + const updates: Record = {}; + const changeData = change.data ?? change; + for (const [key, val] of Object.entries(changeData)) { + if (key === 'id') continue; + updates[key] = val; + } + if (Object.keys(updates).length > 0) { + await table.update(recordId, updates); + } + } + } else if (change.op === 'update' && change.fields) { + // Field-level LWW update + const existing = await table.get(recordId); + if (!existing) { + // Record doesn't exist locally — reconstruct from fields + const record: Record = { id: recordId }; + for (const [key, fc] of Object.entries(change.fields as Record)) { + record[key] = fc.value; + } + await table.put(record); + } else { + // Merge — only update fields that are newer + const updates: Record = {}; + for (const [key, fc] of Object.entries(change.fields as Record)) { + const serverTime = fc.updatedAt ?? ''; + const localTime = (existing as any).updatedAt ?? ''; + if (serverTime >= localTime) { + updates[key] = fc.value; + } + } + if (Object.keys(updates).length > 0) { + await table.update(recordId, updates); + } + } + } + } + }); + } + } finally { + setApplyingServerChanges(false); + } + } + // ─── Helpers ───────────────────────────────────────────── async function getSyncCursor(appId: string, collection: string): Promise { @@ -273,66 +390,40 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise { - const table = db.table(tableName); + async function getOldestSyncCursor(appId: string): Promise { + const channel = channels.get(appId); + if (!channel) return '1970-01-01T00:00:00.000Z'; - await db.transaction('rw', table, async () => { - for (const change of changes) { - if (change.deletedAt) { - // Soft delete - const existing = await table.get(change.id); - if (existing) { - await table.update(change.id, { - deletedAt: change.deletedAt, - updatedAt: change.updatedAt, - }); - } - } else if (change.op === 'delete') { - await table.delete(change.id); - } else { - // Upsert — field-level LWW - const existing = await table.get(change.id); - if (!existing) { - await table.put(change.data ?? change); - } else { - // Only update fields that are newer - const updates: Record = {}; - const changeData = change.data ?? change; - for (const [key, val] of Object.entries(changeData)) { - if (key === 'id') continue; - const serverTime = change.fields?.[key]?.updatedAt ?? change.updatedAt; - const localTime = (existing as any).updatedAt ?? ''; - if (serverTime >= localTime) { - updates[key] = val; - } - } - if (Object.keys(updates).length > 0) { - await table.update(change.id, updates); - } - } - } - } - }); + let oldest = new Date().toISOString(); + for (const tableName of channel.tables) { + const cursor = await getSyncCursor(appId, tableName); + if (cursor < oldest) oldest = cursor; + } + return oldest; } - function buildChangeset(pending: PendingChange[], cid: string) { + /** + * Build changeset in backend protocol format. + * Maps unified table names to backend collection names. + */ + function buildChangeset(pending: PendingChange[], cid: string, since: string) { return { clientId: cid, + since, changes: pending.map((p) => ({ - collection: p.collection, - recordId: p.recordId, + table: toSyncName(p.collection), + id: p.recordId, op: p.op, fields: p.fields, data: p.data, deletedAt: p.deletedAt, - createdAt: p.createdAt, })), }; } function handleOnline() { online = true; - status = 'idle'; + setStatus('idle'); // Resume sync for all channels for (const appId of channels.keys()) { pull(appId).catch(() => {}); @@ -342,7 +433,7 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise Promise void) { + _statusListeners.push(listener); + return () => { + _statusListeners = _statusListeners.filter((l) => l !== listener); + }; + }, getChannel: (appId: string) => channels.get(appId), pushNow: push, pullNow: pull, diff --git a/apps/manacore/apps/web/src/lib/modules/spiral/collect.ts b/apps/manacore/apps/web/src/lib/modules/spiral/collect.ts index 3e25baf03..80e01cf7b 100644 --- a/apps/manacore/apps/web/src/lib/modules/spiral/collect.ts +++ b/apps/manacore/apps/web/src/lib/modules/spiral/collect.ts @@ -1,34 +1,28 @@ /** * Cross-App Activity Collector * - * Reads from all cross-app IndexedDB readers and produces + * Reads from the unified IndexedDB and produces * AppSnapshot objects for the Mana Spiral. */ import { MANA_APP_INDEX } from '@manacore/spiral-db'; -import { - crossTaskCollection, - crossEventCollection, - crossContactCollection, - crossConversationCollection, - crossFavoriteCollection, - crossImageCollection, - crossAlarmCollection, - crossFileCollection, - crossSongCollection, - crossPresiDeckCollection, - crossSpaceCollection, - crossCardsDeckCollection, - crossCardsCardCollection, - type CrossAppTask, - type CrossAppContact, - type CrossAppImage, -} from '$lib/data/cross-app-stores'; +import { db } from '$lib/data/database'; import type { AppSnapshot } from './stores/mana-spiral.svelte'; /** - * Collect snapshots from all cross-app readers. - * Each collection is read once and summarized into an AppSnapshot. + * Safe wrapper for db.table().toArray() — returns empty array on error. + */ +async function safeGetAll(tableName: string): Promise { + try { + return await db.table(tableName).toArray(); + } catch { + return []; + } +} + +/** + * Collect snapshots from all app tables in the unified DB. + * Each table is read once and summarized into an AppSnapshot. */ export async function collectAppSnapshots(): Promise { const snapshots: AppSnapshot[] = []; @@ -49,24 +43,24 @@ export async function collectAppSnapshots(): Promise { cardDecks, cards, ] = await Promise.all([ - safeGetAll(crossTaskCollection), - safeGetAll(crossEventCollection), - safeGetAll(crossContactCollection), - safeGetAll(crossConversationCollection), - safeGetAll(crossFavoriteCollection), - safeGetAll(crossImageCollection), - safeGetAll(crossAlarmCollection), - safeGetAll(crossFileCollection), - safeGetAll(crossSongCollection), - safeGetAll(crossPresiDeckCollection), - safeGetAll(crossSpaceCollection), - safeGetAll(crossCardsDeckCollection), - safeGetAll(crossCardsCardCollection), + safeGetAll('tasks'), + safeGetAll('events'), + safeGetAll('contacts'), + safeGetAll('conversations'), + safeGetAll('zitareFavorites'), + safeGetAll('images'), + safeGetAll('alarms'), + safeGetAll('files'), + safeGetAll('songs'), + safeGetAll('presiDecks'), + safeGetAll('contextSpaces'), + safeGetAll('cardDecks'), + safeGetAll('cards'), ]); // Todo if (tasks.length > 0) { - const completed = (tasks as CrossAppTask[]).filter((t) => t.isCompleted).length; + const completed = tasks.filter((t: any) => t.isCompleted).length; snapshots.push({ app: 'Todo', appIndex: MANA_APP_INDEX.todo, @@ -91,7 +85,7 @@ export async function collectAppSnapshots(): Promise { // Contacts if (contacts.length > 0) { - const favs = (contacts as CrossAppContact[]).filter((c) => c.isFavorite).length; + const favs = contacts.filter((c: any) => c.isFavorite).length; snapshots.push({ app: 'Contacts', appIndex: MANA_APP_INDEX.contacts, @@ -128,7 +122,7 @@ export async function collectAppSnapshots(): Promise { // Picture if (images.length > 0) { - const favs = (images as CrossAppImage[]).filter((i) => i.isFavorite).length; + const favs = images.filter((i: any) => i.isFavorite).length; snapshots.push({ app: 'Picture', appIndex: MANA_APP_INDEX.picture, @@ -213,15 +207,3 @@ export async function collectAppSnapshots(): Promise { return snapshots; } - -/** - * Safe wrapper for collection.getAll() — returns empty array on error - * (e.g. if the other app's DB doesn't exist yet) - */ -async function safeGetAll(collection: { getAll: () => Promise }): Promise { - try { - return await collection.getAll(); - } catch { - return []; - } -} diff --git a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte index 4b7af80ee..50885b7d4 100644 --- a/apps/manacore/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/+layout.svelte @@ -16,20 +16,8 @@ import { tagLocalStore, tagMutations, useAllTags } from '$lib/stores/tags.svelte'; import { linkLocalStore, linkMutations } from '@manacore/shared-links'; import { manacoreStore } from '$lib/data/local-store'; - import { - todoReader, - calendarReader, - contactsReader, - chatReader, - zitareReader, - pictureReader, - clockReader, - storageReader, - mukkeReader, - presiReader, - contextReader, - cardsReader, - } from '$lib/data/cross-app-stores'; + import { createUnifiedSync } from '$lib/data/sync'; + import { migrateFromLegacyDbs } from '$lib/data/legacy-migration'; import { dashboardStore } from '$lib/stores/dashboard.svelte'; import { THEME_DEFINITIONS, @@ -203,9 +191,12 @@ AppEvents.themeChanged(mode); } + // Unified sync manager — one sync engine for all apps + const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050'; + let unifiedSync: ReturnType | null = null; + async function handleSignOut() { - manacoreStore.stopSync(); - tagMutations.stopSync(); + unifiedSync?.stopAll(); await authStore.signOut(); goto('/login'); } @@ -244,29 +235,18 @@ manacoreStore.initialize(), tagLocalStore.initialize(), linkLocalStore.initialize(), - // Cross-app readers (read-only, no sync — owning apps handle sync) - todoReader.initialize(), - calendarReader.initialize(), - contactsReader.initialize(), - chatReader.initialize(), - zitareReader.initialize(), - pictureReader.initialize(), - clockReader.initialize(), - storageReader.initialize(), - mukkeReader.initialize(), - presiReader.initialize(), - contextReader.initialize(), - cardsReader.initialize(), ]); + // Migrate data from legacy per-app databases (one-time, idempotent) + await migrateFromLegacyDbs(); + // Initialize shared-uload (opens uLoad IndexedDB for cross-app link creation) initSharedUload(); - // Start syncing to server + // Start unified sync — one engine for all apps via Dexie hooks const getToken = () => authStore.getValidToken(); - manacoreStore.startSync(getToken); - tagMutations.startSync(getToken); - linkMutations.startSync(getToken); + unifiedSync = createUnifiedSync(SYNC_SERVER_URL, getToken); + unifiedSync.startAll(); // Initialize dashboard from IndexedDB await dashboardStore.initialize();