mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
88864fd3a1
commit
05e5e957e8
14 changed files with 782 additions and 853 deletions
105
CLAUDE.md
105
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 |
|
||||
|
|
|
|||
|
|
@ -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 || ''))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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[],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
173
apps/manacore/apps/web/src/lib/data/legacy-migration.ts
Normal file
173
apps/manacore/apps/web/src/lib/data/legacy-migration.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue