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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-02 12:40:31 +02:00
parent 88864fd3a1
commit 05e5e957e8
14 changed files with 782 additions and 853 deletions

105
CLAUDE.md
View file

@ -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 |

View file

@ -1,9 +1,5 @@
<script lang="ts">
import {
crossTaskCollection,
crossEventCollection,
crossContactCollection,
} from '$lib/data/cross-app-stores';
import { db } from '$lib/data/database';
interface Props {
maxItems?: number;
@ -38,7 +34,7 @@
today.setHours(0, 0, 0, 0);
const todayStr = today.toISOString();
const tasks = await crossTaskCollection.getAll();
const tasks = await db.table('tasks').toArray();
recentTasks = tasks
.filter((t) => t.isCompleted && t.completedAt && t.completedAt >= todayStr)
.sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''))
@ -61,7 +57,7 @@
const now = new Date().toISOString();
const tomorrow = new Date(Date.now() + 86400000).toISOString();
const events = await crossEventCollection.getAll();
const events = await db.table('events').toArray();
upcomingEvents = events
.filter((e) => e.startDate >= now && e.startDate <= tomorrow)
.sort((a, b) => a.startDate.localeCompare(b.startDate))
@ -81,7 +77,7 @@
try {
// Recently added contacts
const contacts = await crossContactCollection.getAll();
const contacts = await db.table('contacts').toArray();
recentContacts = contacts
.filter((c) => !c.isArchived)
.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))

View file

@ -8,7 +8,6 @@
import { _ } from 'svelte-i18n';
import { useUpcomingEvents } from '$lib/data/cross-app-queries';
import type { CrossAppEvent } from '$lib/data/cross-app-stores';
import { APP_URLS } from '@manacore/shared-branding';
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
@ -18,7 +17,7 @@
const MAX_DISPLAY = 5;
function formatEventTime(event: CrossAppEvent): string {
function formatEventTime(event: any): string {
const start = new Date(event.startDate);
const today = new Date();
const tomorrow = new Date(today);

View file

@ -8,7 +8,6 @@
import { _ } from 'svelte-i18n';
import { useFavoriteContacts } from '$lib/data/cross-app-queries';
import type { CrossAppContact } from '$lib/data/cross-app-stores';
import { APP_URLS } from '@manacore/shared-branding';
const MAX_DISPLAY = 5;
@ -17,12 +16,12 @@
const isDev = typeof window !== 'undefined' && window.location.hostname === 'localhost';
const contactsUrl = isDev ? APP_URLS.contacts.dev : APP_URLS.contacts.prod;
function getDisplayName(contact: CrossAppContact): string {
function getDisplayName(contact: any): string {
const parts = [contact.firstName, contact.lastName].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : contact.email || 'Unbekannt';
}
function getInitials(contact: CrossAppContact): string {
function getInitials(contact: any): string {
const name = getDisplayName(contact);
const parts = name.split(' ');
if (parts.length >= 2) {

View file

@ -8,7 +8,7 @@
import { _ } from 'svelte-i18n';
import { useOpenTasks } from '$lib/data/cross-app-queries';
import { crossTaskCollection, type CrossAppTask } from '$lib/data/cross-app-stores';
import { db } from '$lib/data/database';
import { APP_URLS } from '@manacore/shared-branding';
import { format, isToday, isTomorrow, isPast } from 'date-fns';
import { de } from 'date-fns/locale';
@ -48,7 +48,7 @@
// Track tasks being toggled (for optimistic UI)
let togglingIds: Set<string> = $state(new Set());
async function handleToggleComplete(e: MouseEvent, task: CrossAppTask) {
async function handleToggleComplete(e: MouseEvent, task: any) {
e.preventDefault();
e.stopPropagation();
@ -56,18 +56,19 @@
togglingIds = new Set([...togglingIds, task.id]);
// Write directly to IndexedDB — sync engine will push to server
await crossTaskCollection.update(task.id, {
// Write directly to unified IndexedDB — Dexie hooks track the change for sync
await db.table('tasks').update(task.id, {
isCompleted: !task.isCompleted,
completedAt: task.isCompleted ? null : new Date().toISOString(),
} as Partial<CrossAppTask>);
updatedAt: new Date().toISOString(),
});
togglingIds = new Set([...togglingIds].filter((id) => id !== task.id));
}
function getSubtaskProgress(task: CrossAppTask): string | null {
function getSubtaskProgress(task: any): string | null {
if (!task.subtasks || task.subtasks.length === 0) return null;
const done = task.subtasks.filter((s) => s.isCompleted).length;
const done = task.subtasks.filter((s: any) => s.isCompleted).length;
return `${done}/${task.subtasks.length}`;
}
</script>

View file

@ -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<string, { value: unknown; updatedAt: string }>;
data?: Record<string, unknown>;
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<string, unknown>,
fields?: Record<string, { value: unknown; updatedAt: string }>
): Promise<void> {
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<string, unknown>
): Promise<void> {
const now = new Date().toISOString();
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
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<void> {
await trackChange(collection, recordId, 'delete');
}

View file

@ -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[],
}
);
}

View file

@ -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<CrossAppTask>('tasks');
export const crossProjectCollection = todoReader.collection<CrossAppProject>('projects');
// Calendar
export const crossEventCollection = calendarReader.collection<CrossAppEvent>('events');
export const crossCalendarCollection = calendarReader.collection<CrossAppCalendar>('calendars');
// Contacts
export const crossContactCollection = contactsReader.collection<CrossAppContact>('contacts');
// Chat
export const crossConversationCollection =
chatReader.collection<CrossAppConversation>('conversations');
export const crossMessageCollection = chatReader.collection<CrossAppMessage>('messages');
// Zitare
export const crossFavoriteCollection = zitareReader.collection<CrossAppFavorite>('favorites');
// Picture
export const crossImageCollection = pictureReader.collection<CrossAppImage>('images');
// Clock
export const crossAlarmCollection = clockReader.collection<CrossAppAlarm>('alarms');
export const crossTimerCollection = clockReader.collection<CrossAppTimer>('timers');
// Storage
export const crossFileCollection = storageReader.collection<CrossAppFile>('files');
export const crossFolderCollection = storageReader.collection<CrossAppFolder>('folders');
// Mukke
export const crossSongCollection = mukkeReader.collection<CrossAppSong>('songs');
export const crossPlaylistCollection = mukkeReader.collection<CrossAppPlaylist>('playlists');
// Presi
export const crossPresiDeckCollection = presiReader.collection<CrossAppDeck>('decks');
export const crossSlideCollection = presiReader.collection<CrossAppSlide>('slides');
// Context
export const crossSpaceCollection = contextReader.collection<CrossAppSpace>('spaces');
export const crossDocumentCollection = contextReader.collection<CrossAppDocument>('documents');
// Cards
export const crossCardsDeckCollection = cardsReader.collection<CrossAppCardsDeck>('decks');
export const crossCardsCardCollection = cardsReader.collection<CrossAppCardsCard>('cards');

View file

@ -229,3 +229,139 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
export const TABLE_TO_APP: Record<string, string> = 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<string, string> = {
// 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<string, Record<string, string>> = {};
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const map: Record<string, string> = {};
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<string, { value: unknown; updatedAt: string }> = {};
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<string, unknown>).deletedAt ? 'delete' : 'update',
fields,
deletedAt: (modifications as Record<string, unknown>).deletedAt as string | undefined,
createdAt: now,
});
});
}
}

View file

@ -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<string, Record<string, string>> {
const result: Record<string, Record<string, string>> = {};
for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
const map: Record<string, string> = {};
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<void> {
// 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<void> {
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<string, string>
): Promise<void> {
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);
}
}

View file

@ -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<T extends BaseRecord>(tableName: string) {
const table = db.table<T>(tableName);
export const manacoreStore = createLocalStore({
appId: 'manacore',
collections: [
{
name: 'userSettings',
indexes: ['key'],
guestSeed: guestSettings,
return {
async get(id: string): Promise<T | undefined> {
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<LocalUserSettings>('userSettings');
async getAll(
_filter?: unknown,
options?: { sortBy?: string; sortDirection?: 'asc' | 'desc' }
): Promise<T[]> {
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<void> {
const now = new Date().toISOString();
await table.put({
...record,
createdAt: record.createdAt ?? now,
updatedAt: now,
});
},
async update(id: string, changes: Partial<T>): Promise<void> {
await table.update(id, {
...changes,
updatedAt: new Date().toISOString(),
} as any);
},
async delete(id: string): Promise<void> {
await table.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any);
},
async count(): Promise<number> {
const all = await table.toArray();
return all.filter((r) => !(r as any).deletedAt).length;
},
};
}
export const settingsCollection = createCollectionWrapper<LocalUserSettings>('userSettings');
export const dashboardCollection =
manacoreStore.collection<LocalDashboardConfig>('dashboardConfigs');
createCollectionWrapper<LocalDashboardConfig>('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<void> {
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<string | null>): void {},
stopSync(): void {},
};

View file

@ -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<string | null>) {
const channels = new Map<string, SyncChannelState>();
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<str
connectWs(appId);
}
// Watch _pendingChanges for new writes
db.table('_pendingChanges').hook('creating', (primKey, obj) => {
// 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<str
}
function stopAll(): void {
for (const [appId, channel] of channels) {
for (const [, channel] of channels) {
if (channel.pushTimer) clearTimeout(channel.pushTimer);
if (channel.pullTimer) clearInterval(channel.pullTimer);
if (channel.ws) {
@ -108,6 +103,7 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
}
}
channels.clear();
_statusListeners = [];
if (typeof window !== 'undefined') {
window.removeEventListener('online', handleOnline);
@ -125,6 +121,11 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
channel.pushTimer = setTimeout(() => 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<void> {
const channel = channels.get(appId);
if (!channel) return;
@ -141,30 +142,50 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
if (pending.length === 0) return;
status = 'syncing';
setStatus('syncing');
try {
const changeset = buildChangeset(pending, clientId);
const res = await fetch(`${serverUrl}/sync/${appId}/push`, {
// Get oldest sync cursor for the `since` field
const oldestCursor = await getOldestSyncCursor(appId);
// Build changeset in backend protocol format
const changeset = buildChangeset(pending, clientId, oldestCursor);
const res = await fetch(`${serverUrl}/sync/${appId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
'X-Client-Id': clientId,
},
body: JSON.stringify(changeset),
});
if (!res.ok) throw new Error(`Push failed: ${res.status}`);
const data = await res.json();
// Apply server changes from the response
if (data.serverChanges?.length > 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<str
const token = await getToken();
if (!token) return;
status = 'syncing';
setStatus('syncing');
try {
for (const tableName of channel.tables) {
const syncName = toSyncName(tableName);
const cursor = await getSyncCursor(appId, tableName);
const res = await fetch(
`${serverUrl}/sync/${appId}/pull?collection=${tableName}&since=${encodeURIComponent(cursor)}&clientId=${clientId}`,
`${serverUrl}/sync/${appId}/pull?collection=${encodeURIComponent(syncName)}&since=${encodeURIComponent(cursor)}`,
{
headers: { Authorization: `Bearer ${token}` },
headers: {
Authorization: `Bearer ${token}`,
'X-Client-Id': clientId,
},
}
);
if (!res.ok) continue;
const data = await res.json();
if (!data.changes || data.changes.length === 0) continue;
if (!data.serverChanges || data.serverChanges.length === 0) continue;
// Apply changes to local DB
await applyServerChanges(tableName, data.changes);
await applyServerChanges(appId, data.serverChanges);
// Update cursor
if (data.syncedUntil) {
@ -205,10 +230,10 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
}
channel.lastError = null;
status = 'idle';
setStatus('idle');
} catch (err) {
channel.lastError = err instanceof Error ? err.message : 'Pull failed';
status = 'error';
setStatus('error');
}
}
@ -218,23 +243,30 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
const channel = channels.get(appId);
if (!channel || !online) return;
const wsUrl = serverUrl.replace(/^http/, 'ws') + `/sync/${appId}/ws?clientId=${clientId}`;
const wsUrl = serverUrl.replace(/^http/, 'ws') + `/ws/${appId}`;
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
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<str
}
}
// ─── Apply Server Changes ───────────────────────────────
async function applyServerChanges(appId: string, changes: any[]): Promise<void> {
setApplyingServerChanges(true);
try {
// Group changes by table (server returns backend collection names)
const byTable = new Map<string, any[]>();
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<string, unknown> = {};
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<string, unknown> = { id: recordId };
for (const [key, fc] of Object.entries(change.fields as Record<string, any>)) {
record[key] = fc.value;
}
await table.put(record);
} else {
// Merge — only update fields that are newer
const updates: Record<string, unknown> = {};
for (const [key, fc] of Object.entries(change.fields as Record<string, any>)) {
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<string> {
@ -273,66 +390,40 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
});
}
async function applyServerChanges(tableName: string, changes: any[]): Promise<void> {
const table = db.table(tableName);
async function getOldestSyncCursor(appId: string): Promise<string> {
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<string, unknown> = {};
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<str
function handleOffline() {
online = false;
status = 'offline';
setStatus('offline');
// Close all WebSockets
for (const channel of channels.values()) {
if (channel.ws) {
@ -352,15 +443,29 @@ export function createUnifiedSync(serverUrl: string, getToken: () => Promise<str
}
}
function setStatus(s: SyncStatus) {
status = s;
for (const listener of _statusListeners) {
listener(s);
}
}
return {
startAll,
stopAll,
onPendingChange,
get status() {
return status;
},
get online() {
return online;
},
onStatusChange(listener: (s: SyncStatus) => void) {
_statusListeners.push(listener);
return () => {
_statusListeners = _statusListeners.filter((l) => l !== listener);
};
},
getChannel: (appId: string) => channels.get(appId),
pushNow: push,
pullNow: pull,

View file

@ -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<any[]> {
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<AppSnapshot[]> {
const snapshots: AppSnapshot[] = [];
@ -49,24 +43,24 @@ export async function collectAppSnapshots(): Promise<AppSnapshot[]> {
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<AppSnapshot[]> {
// 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<AppSnapshot[]> {
// 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<AppSnapshot[]> {
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<unknown[]> }): Promise<unknown[]> {
try {
return await collection.getAll();
} catch {
return [];
}
}

View file

@ -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<typeof createUnifiedSync> | 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();