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();