mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:21:10 +02:00
feat(local-first): add local-first architecture with Dexie.js, Go sync server, and Todo pilot
Implement the foundational local-first data layer for ManaCore apps: - New @manacore/local-store package (Dexie.js IndexedDB, sync engine, Svelte 5 reactive queries) - New mana-sync Go service (sync protocol, WebSocket push, field-level LWW conflict resolution) - Todo app migrated as pilot: stores read/write IndexedDB, guest mode with onboarding seed data - PillNavigation: prominent login pill for unauthenticated users - SyncIndicator component showing local/syncing/offline status - GuestWelcomeModal on first visit for Todo app - Removed demo-mode auth_required checks from Todo components (all writes are now local) - CSP fix for local development (localhost:3001, localhost:3050) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4ddff8485b
commit
2e4bb9bad7
41 changed files with 4388 additions and 340 deletions
406
.claude/plans/local-first-architecture-migration.md
Normal file
406
.claude/plans/local-first-architecture-migration.md
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
# Local-First Architektur & Stack-Migration
|
||||
|
||||
> **Status**: 🔵 In Planung
|
||||
> **Erstellt**: 2026-03-26
|
||||
> **Autor**: Claude Code + Till Schneider
|
||||
> **Ziel**: Alle ManaCore-Apps auf Local-First umstellen, Backend-Stack modernisieren
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
Dieser Plan beschreibt den Umbau der gesamten ManaCore-Architektur von einem klassischen Online-Only/API-First-Modell zu einer **Local-First-Architektur** mit grundlegender Modernisierung des Backend-Stacks.
|
||||
|
||||
### Kernentscheidungen
|
||||
|
||||
| Entscheidung | Vorher | Nachher |
|
||||
|---|---|---|
|
||||
| **Datenmodell** | API-First (Server ist Source of Truth) | Local-First (IndexedDB ist Source of Truth, Server synced) |
|
||||
| **Backend-Framework** | NestJS 10/11 | Hono auf Bun (App-Logik) + Go (Sync-Server) |
|
||||
| **Runtime** | Node.js | Bun (TypeScript), Go (Sync) |
|
||||
| **Client-Datenbank** | Keine (nur API-Calls) | Dexie.js (IndexedDB) mit reactiven liveQueries |
|
||||
| **Sync-Protokoll** | Keines (REST CRUD) | Eigenes Changeset-basiertes Protokoll (HTTP + WebSocket) |
|
||||
| **Auth-Framework** | NestJS + Better Auth | Hono + Better Auth (nativer Adapter) |
|
||||
| **AI Services** | Python (FastAPI) | Python (FastAPI) — keine Änderung |
|
||||
| **Datenbank** | PostgreSQL + Drizzle ORM | PostgreSQL + Drizzle ORM — keine Änderung |
|
||||
|
||||
### Motivation
|
||||
|
||||
1. **Guest-Mode als Nebeneffekt**: Nutzer landen direkt in der App, kein Login-Screen. Lokale Daten werden bei Anmeldung synchronisiert.
|
||||
2. **Instant UI**: Kein Loading-Spinner. Alle Reads < 1ms aus IndexedDB statt 200ms API-Roundtrip.
|
||||
3. **Echte Offline-Fähigkeit**: Voller CRUD offline, Sync bei Reconnect.
|
||||
4. **Weniger Backend-Code**: ~260 CRUD-Endpoints → ~40 spezialisierte Endpoints + 1 Sync-Protokoll.
|
||||
5. **Bessere Performance**: Go Sync-Server (100K+ Connections), Hono/Bun (6ms Cold Start, 100K+ req/s).
|
||||
6. **Multi-Device Sync**: Echtzeit via WebSocket Push.
|
||||
|
||||
---
|
||||
|
||||
## Architektur-Ziel
|
||||
|
||||
```
|
||||
┌─ Client ─────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ SvelteKit + Svelte 5 + Tailwind │
|
||||
│ Dexie.js (IndexedDB) + Reactive liveQuery │
|
||||
│ @manacore/local-store (Sync Engine) │
|
||||
│ │
|
||||
└───────────┬──────────────────────┬────────────────────────────────┘
|
||||
│ Sync (WebSocket+HTTP)│ API Calls (REST)
|
||||
▼ ▼
|
||||
┌─ Go ──────────────┐ ┌─ TypeScript (Hono + Bun) ─────────────────┐
|
||||
│ │ │ │
|
||||
│ mana-sync │ │ App-Backends (todo, chat, contacts...) │
|
||||
│ - Sync Protocol │ │ - External API Integrations │
|
||||
│ - WebSocket Hub │ │ - File Uploads (S3/MinIO) │
|
||||
│ - Change Tracking │ │ - Webhooks (Stripe, Replicate) │
|
||||
│ - Conflict Res. │ │ - Server-side Compute (RRULE, etc.) │
|
||||
│ - Push Notif. │ │ - Credit Consumption │
|
||||
│ │ │ │
|
||||
│ Port: 3050 │ │ mana-core-auth (Better Auth + Hono) │
|
||||
│ │ │ - Auth, SSO, Organizations │
|
||||
└────────┬───────────┘ │ - Credits, Subscriptions │
|
||||
│ └───────────────┬──────────────────────────┘
|
||||
▼ ▼
|
||||
┌─ PostgreSQL ──────────────────────────────────────────────────────┐
|
||||
│ Alle App-Datenbanken + Sync-Metadaten │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Python ──────────────────────────────────────────────────────────┐
|
||||
│ mana-llm (FastAPI) │ mana-stt │ mana-tts │ mana-image-gen │
|
||||
└───────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (2-3 Wochen)
|
||||
|
||||
### 1.1 `@manacore/local-store` Package
|
||||
|
||||
**Pfad:** `packages/local-store/`
|
||||
|
||||
Neues Shared Package das die gesamte Local-First-Logik kapselt.
|
||||
|
||||
#### Kernkomponenten
|
||||
|
||||
```
|
||||
packages/local-store/
|
||||
├── src/
|
||||
│ ├── index.ts
|
||||
│ ├── collection.ts # createLocalCollection<T>() Factory
|
||||
│ ├── database.ts # Dexie.js Database Setup
|
||||
│ ├── sync/
|
||||
│ │ ├── engine.ts # SyncEngine — orchestriert Pull/Push
|
||||
│ │ ├── changeset.ts # Changeset-Typen und Serialisierung
|
||||
│ │ ├── conflict.ts # Field-Level Last-Write-Wins
|
||||
│ │ ├── queue.ts # Offline-Queue für pending Writes
|
||||
│ │ └── websocket.ts # WebSocket Client für Push-Updates
|
||||
│ ├── svelte/
|
||||
│ │ ├── reactive.svelte.ts # Svelte 5 Integration (liveQuery → $state)
|
||||
│ │ ├── SyncStatus.svelte # UI-Komponente: "Synced" / "Offline" / "Syncing..."
|
||||
│ │ └── context.ts # Svelte Context Provider
|
||||
│ └── types.ts # Shared Types
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
#### API Design
|
||||
|
||||
```typescript
|
||||
// Collection erstellen (pro Tabelle)
|
||||
const tasks = createLocalCollection<Task>({
|
||||
name: 'tasks',
|
||||
dbName: 'todo',
|
||||
schema: {
|
||||
id: 'string',
|
||||
title: 'string',
|
||||
priority: 'string',
|
||||
projectId: 'string?',
|
||||
isCompleted: 'boolean',
|
||||
dueDate: 'date?',
|
||||
subtasks: 'json?',
|
||||
order: 'number',
|
||||
},
|
||||
indexes: ['projectId', 'dueDate', 'isCompleted', '[isCompleted+dueDate]'],
|
||||
sync: {
|
||||
endpoint: '/sync/todo',
|
||||
conflictStrategy: 'field-level-lww',
|
||||
pushDebounce: 1000, // 1s nach letztem Write
|
||||
pullInterval: 30_000, // Alle 30s poll (Fallback zu WebSocket)
|
||||
},
|
||||
});
|
||||
|
||||
// Verwendung in Svelte-Komponenten
|
||||
const openTasks = tasks.query({ isCompleted: false }, { sortBy: 'order' });
|
||||
// → Reaktiver $state, updated automatisch bei lokalen UND sync'd Änderungen
|
||||
|
||||
// Writes — synchron, kein await
|
||||
tasks.insert({ title: 'Neuer Task', priority: 'medium' });
|
||||
tasks.update(id, { priority: 'high' });
|
||||
tasks.delete(id);
|
||||
|
||||
// Sync-Status
|
||||
tasks.syncStatus; // → 'synced' | 'pending' | 'syncing' | 'offline' | 'error'
|
||||
tasks.pendingChanges; // → Anzahl noch nicht sync'd Änderungen
|
||||
```
|
||||
|
||||
#### Changeset-Format
|
||||
|
||||
```typescript
|
||||
interface Changeset {
|
||||
clientId: string; // Geräte-ID
|
||||
appId: string; // 'todo', 'contacts', etc.
|
||||
since: string; // ISO Timestamp — letzter bekannter Sync-Punkt
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Change {
|
||||
table: string; // 'tasks', 'projects', etc.
|
||||
id: string; // Row UUID
|
||||
op: 'insert' | 'update' | 'delete';
|
||||
fields?: Record<string, {
|
||||
value: unknown;
|
||||
updatedAt: string; // Timestamp pro Feld für LWW
|
||||
}>;
|
||||
data?: Record<string, unknown>; // Vollständiges Objekt bei Insert
|
||||
deletedAt?: string; // Soft-Delete Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
#### Conflict Resolution: Field-Level LWW
|
||||
|
||||
```typescript
|
||||
// Beispiel: Zwei Geräte editieren denselben Task
|
||||
// Gerät A: priority = "high" (14:01:03)
|
||||
// Gerät B: title = "Einkaufen Rewe" (14:01:05)
|
||||
|
||||
// Server vergleicht pro Feld:
|
||||
// - priority: A=14:01:03, Server=14:00:00 → A gewinnt
|
||||
// - title: B=14:01:05, Server=14:00:00 → B gewinnt
|
||||
// Ergebnis: priority="high", title="Einkaufen Rewe" → Kein Datenverlust
|
||||
```
|
||||
|
||||
### 1.2 `mana-sync` Go Service
|
||||
|
||||
**Pfad:** `services/mana-sync/`
|
||||
|
||||
Zentraler Sync-Server für alle Apps. Ein Service, nicht einer pro App.
|
||||
|
||||
#### Struktur
|
||||
|
||||
```
|
||||
services/mana-sync/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Entry Point, Config, Startup
|
||||
├── internal/
|
||||
│ ├── sync/
|
||||
│ │ ├── handler.go # HTTP Handler: POST /sync/:appId
|
||||
│ │ ├── engine.go # Changeset verarbeiten, Conflicts lösen
|
||||
│ │ ├── changeset.go # Changeset Typen
|
||||
│ │ └── conflict.go # Field-Level LWW Logik
|
||||
│ ├── ws/
|
||||
│ │ ├── hub.go # WebSocket Connection Manager
|
||||
│ │ ├── client.go # Einzelne WS Connection
|
||||
│ │ └── message.go # WS Message Types
|
||||
│ ├── store/
|
||||
│ │ ├── postgres.go # PostgreSQL Queries
|
||||
│ │ └── migrations.go # Sync-Metadaten Tabellen
|
||||
│ ├── auth/
|
||||
│ │ └── jwt.go # EdDSA JWT Validation (JWKS von mana-core-auth)
|
||||
│ └── config/
|
||||
│ └── config.go # Environment Config
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Dockerfile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
#### Endpoints
|
||||
|
||||
| Method | Path | Beschreibung |
|
||||
|--------|------|---|
|
||||
| `POST` | `/sync/:appId` | Changeset empfangen, Conflicts lösen, Delta zurückgeben |
|
||||
| `GET` | `/sync/:appId/pull` | Nur Server-Änderungen seit Timestamp abrufen |
|
||||
| `WS` | `/ws/:appId` | WebSocket für Push-Notifications |
|
||||
| `GET` | `/health` | Health Check |
|
||||
| `GET` | `/metrics` | Prometheus Metrics |
|
||||
|
||||
#### Datenbank-Erweiterung
|
||||
|
||||
Jede App-Tabelle bekommt Sync-Felder:
|
||||
|
||||
```sql
|
||||
-- Migration: Sync-Felder zu bestehenden Tabellen hinzufügen
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS client_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS field_timestamps JSONB DEFAULT '{}';
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_sync ON tasks (user_id, updated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_deleted ON tasks (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
```
|
||||
|
||||
`field_timestamps` speichert den letzten Änderungs-Zeitstempel pro Feld:
|
||||
```json
|
||||
{
|
||||
"title": "2026-03-26T14:01:05Z",
|
||||
"priority": "2026-03-26T14:01:03Z",
|
||||
"isCompleted": "2026-03-26T13:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 Todo als Pilot
|
||||
|
||||
Die Todo-App wird als erste auf Local-First umgebaut:
|
||||
|
||||
1. **AuthGate**: `allowGuest={true}` setzen
|
||||
2. **Stores umbauen**: `tasksApi.list()` → `taskCollection.query()`
|
||||
3. **Guest-Seed**: Onboarding-Todos in Dexie.js laden
|
||||
4. **PillNav**: Prominenter "Anmelden"-Button wenn nicht eingeloggt
|
||||
5. **Sync aktivieren**: Nach Login startet Sync Engine
|
||||
|
||||
**Aktuelles Todo-Backend behält CRUD-Endpoints** während der Migration. Sync-Endpoint kommt parallel dazu. Sobald alle Clients migriert sind, werden CRUD-Endpoints entfernt.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Todo komplett auf Hono/Bun (2-3 Wochen)
|
||||
|
||||
### 2.1 Todo Backend: NestJS → Hono/Bun
|
||||
|
||||
Was vom Todo-Backend übrig bleibt nach Local-First:
|
||||
|
||||
| Endpoint | Warum Server-seitig |
|
||||
|---|---|
|
||||
| RRULE Expansion | DoS-Schutz (max 5000 Occurrences) |
|
||||
| Reminder Scheduling | Server muss Push-Notifications triggern |
|
||||
| Admin API | Zugriff auf alle User-Daten |
|
||||
| Credit Consumption | Authoritative Quelle |
|
||||
|
||||
**Geschätzter Code:** ~500 LoC Hono statt ~3000 LoC NestJS
|
||||
|
||||
### 2.2 Hono Backend Struktur
|
||||
|
||||
```
|
||||
apps/todo/apps/backend/ # Oder: services/todo/ (umstrukturieren?)
|
||||
├── src/
|
||||
│ ├── index.ts # Hono App + Routes
|
||||
│ ├── routes/
|
||||
│ │ ├── compute.ts # Server-side Compute (RRULE, etc.)
|
||||
│ │ ├── reminders.ts # Push-Notification Scheduling
|
||||
│ │ └── admin.ts # Admin Endpoints
|
||||
│ ├── middleware/
|
||||
│ │ ├── auth.ts # JWT Validation Middleware
|
||||
│ │ └── credits.ts # Credit Check Middleware
|
||||
│ └── lib/
|
||||
│ ├── db.ts # Drizzle ORM (bleibt gleich!)
|
||||
│ └── rrule.ts # RRULE Business Logic
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### 2.3 Guest-Mode UX
|
||||
|
||||
- **Erster Besuch**: App lädt, IndexedDB leer → Seed-Daten werden geschrieben
|
||||
- **Onboarding-Todos** erklären die App:
|
||||
- "Willkommen bei Todo! Tippe hier zum Bearbeiten"
|
||||
- "Erstelle Projekte mit dem + Button oben"
|
||||
- "Wische nach rechts zum Erledigen"
|
||||
- "Melde dich an um zu synchronisieren →"
|
||||
- **PillNav** zeigt "Anmelden" Pill (prominent, unten links)
|
||||
- **AuthGateModal** erscheint bei sync-relevanten Aktionen
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Rollout auf alle Apps (4-6 Wochen)
|
||||
|
||||
Reihenfolge nach Komplexität:
|
||||
|
||||
| App | Komplexität | Verbleibende Server-Logik |
|
||||
|---|---|---|
|
||||
| **Zitare** | Niedrig | Nur Sync |
|
||||
| **Calendar** | Mittel | RRULE, Google Calendar OAuth |
|
||||
| **Clock** | Niedrig | Nur Sync (Timer-State) |
|
||||
| **ManaDeck** | Mittel | Spaced Repetition Algorithmus, LLM-Integration |
|
||||
| **Contacts** | Hoch | Google OAuth Import, vCard/CSV Parser, Foto-Upload |
|
||||
| **Chat** | Hoch | LLM Streaming, Document Processing |
|
||||
| **Picture** | Hoch | Replicate API, Webhooks, Bild-Upload |
|
||||
| **Presi** | Mittel | Nur Sync + Export |
|
||||
|
||||
Pro App:
|
||||
1. `createLocalCollection()` für jede Tabelle definieren
|
||||
2. Stores von API-Calls auf lokale Queries umbauen
|
||||
3. Guest-Seed-Daten erstellen
|
||||
4. NestJS-Endpoints identifizieren die Server-seitig bleiben
|
||||
5. Diese nach Hono/Bun migrieren
|
||||
6. Alten NestJS-Backend entfernen
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Auth-Migration (2 Wochen)
|
||||
|
||||
### mana-core-auth: NestJS → Hono/Bun
|
||||
|
||||
Better Auth hat einen **nativen Hono-Adapter**. Migration:
|
||||
|
||||
1. HTTP Layer: NestJS Controller → Hono Routes
|
||||
2. Better Auth: `toNodeHandler()` → `betterAuth.handler` (Hono-nativ)
|
||||
3. Drizzle ORM: Bleibt identisch
|
||||
4. Credits/Subscriptions: Service-Logik bleibt, nur HTTP-Layer ändert sich
|
||||
5. Stripe Webhooks: Express-kompatibel → Hono-Handler
|
||||
|
||||
**Kritischer Pfad**: Alle Apps hängen von Auth ab. Gründliches Testen nötig.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Infrastruktur & Cleanup (1-2 Wochen)
|
||||
|
||||
- [ ] NestJS Dependencies aus dem Monorepo entfernen
|
||||
- [ ] `packages/shared-nestjs-auth` → `packages/shared-hono-auth`
|
||||
- [ ] `@mana-core/nestjs-integration` → `@mana-core/hono-integration`
|
||||
- [ ] Docker-Images auf Bun Base Image umstellen
|
||||
- [ ] Go Binary in Docker-Compose für mana-sync
|
||||
- [ ] CI/CD Pipeline anpassen (Go Build + Bun Build)
|
||||
- [ ] Monitoring: Prometheus Metrics für Sync-Server
|
||||
- [ ] Load Testing: Sync-Protokoll unter Last testen
|
||||
|
||||
---
|
||||
|
||||
## Risiken und Mitigationen
|
||||
|
||||
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Sync-Konflikte in Edge Cases | Mittel | Hoch | Ausführliche Tests mit simulierten Multi-Device-Szenarien |
|
||||
| Dexie.js Speicherlimits | Niedrig | Mittel | Quota-Monitoring, Cleanup-Strategie für alte Daten |
|
||||
| Bun-Inkompatibilität mit NPM Packages | Niedrig | Mittel | Fallback auf Node.js wenn nötig, Bun hat 99%+ Kompatibilität |
|
||||
| Go Lernkurve | Mittel | Niedrig | Sync-Server ist isoliert und hat klare, kleine API |
|
||||
| IndexedDB Corruption | Niedrig | Hoch | Server ist Backup, bei Corruption: Wipe + Full-Pull |
|
||||
| Better Auth Hono-Adapter Lücken | Niedrig | Mittel | Testen, ggf. Custom Middleware |
|
||||
|
||||
---
|
||||
|
||||
## Metriken für Erfolg
|
||||
|
||||
| Metrik | Vorher (Ist) | Ziel |
|
||||
|---|---|---|
|
||||
| Time to Interactive (neuer Nutzer) | Login-Screen → nicht messbar | < 2 Sekunden → App mit Seed-Daten |
|
||||
| Task erstellen (Latenz) | 200-500ms (API) | < 5ms (lokal) |
|
||||
| Offline-Fähigkeit | Offline-Seite | Voller CRUD |
|
||||
| Backend Memory (pro Service) | ~150MB (NestJS) | ~15MB (Go) / ~40MB (Hono/Bun) |
|
||||
| Cold Start | 2-5s (NestJS) | ~6ms (Bun) / ~50ms (Go) |
|
||||
| CRUD Endpoints | ~260 | ~40 + 1 Sync-Protokoll |
|
||||
| Guest → Signup Conversion | 0% (kein Guest-Mode) | Messbar (Ziel: >5%) |
|
||||
|
||||
---
|
||||
|
||||
## Entscheidungs-Log
|
||||
|
||||
| Datum | Entscheidung | Begründung |
|
||||
|---|---|---|
|
||||
| 2026-03-26 | Local-First statt Offline-Capable | Guest-Mode wird Nebeneffekt, Instant UI, weniger Backend-Code |
|
||||
| 2026-03-26 | Go für Sync-Server | Performance (100K+ WS), Memory (~4KB/Connection), Single Binary |
|
||||
| 2026-03-26 | Hono + Bun statt NestJS | 10x weniger Boilerplate, RPC Type Safety, 6ms Cold Start |
|
||||
| 2026-03-26 | Dexie.js statt SQLite WASM | 15KB vs 500KB, liveQuery() Reaktivität, breite Browser-Unterstützung |
|
||||
| 2026-03-26 | Field-Level LWW statt CRDT | Einfacher, löst 99% der Konflikte, kein Real-time Collab nötig |
|
||||
| 2026-03-26 | Python AI Services bleiben | Bestes Ökosystem für ML/AI, kein Grund zu wechseln |
|
||||
| 2026-03-26 | Phasenweise Migration | Kein Big Bang, jede App kann einzeln migriert werden |
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
---
|
||||
title: 'Local-First Architektur: Von Login-Wall zu Instant-App mit neuem Tech-Stack'
|
||||
description: 'Architekturplanung für den Umbau aller ManaCore-Apps auf Local-First mit Dexie.js, Go Sync-Server und Hono/Bun statt NestJS. Guest-Mode, Offline-CRUD und Instant UI als Kernziele.'
|
||||
date: 2026-03-26
|
||||
author: 'Till Schneider'
|
||||
category: 'infrastructure'
|
||||
tags:
|
||||
[
|
||||
'architecture',
|
||||
'local-first',
|
||||
'offline',
|
||||
'dexie',
|
||||
'indexeddb',
|
||||
'go',
|
||||
'hono',
|
||||
'bun',
|
||||
'sync',
|
||||
'guest-mode',
|
||||
'pwa',
|
||||
'nestjs-migration',
|
||||
'tech-stack',
|
||||
]
|
||||
featured: true
|
||||
readTime: 15
|
||||
contributors:
|
||||
- name: 'Till Schneider'
|
||||
handle: 'Till-JS'
|
||||
workingHours:
|
||||
start: '2026-03-26T10:00'
|
||||
end: '2026-03-26T16:00'
|
||||
---
|
||||
|
||||
Umfassende Architekturplanung mit dem Ziel, die gesamte ManaCore-Plattform fundamental zu modernisieren:
|
||||
|
||||
- **Local-First statt API-First** — Alle Daten leben primär in IndexedDB (Dexie.js), Server synchronisiert im Hintergrund
|
||||
- **Guest-Mode als Nebeneffekt** — Kein Login-Screen mehr beim ersten Besuch, Nutzer landen direkt in der App
|
||||
- **Backend-Modernisierung** — NestJS wird durch Hono auf Bun ersetzt, neuer Go Sync-Server
|
||||
- **Instant UI** — Reads < 1ms aus IndexedDB statt 200-500ms API-Roundtrip
|
||||
- **Echtes Offline** — Voller CRUD ohne Netz, nicht nur gecachte Reads
|
||||
|
||||
---
|
||||
|
||||
## Ausgangslage: Das Login-Wall-Problem
|
||||
|
||||
Wenn ein neuer Nutzer `todo.mana.how` besucht, passiert aktuell Folgendes:
|
||||
|
||||
1. SvelteKit App lädt
|
||||
2. `AuthGate` Komponente prüft Auth-Status
|
||||
3. Kein Token vorhanden → Redirect zu `/login`
|
||||
4. Nutzer sieht Login-Screen, ohne die App je gesehen zu haben
|
||||
|
||||
Das ist ein klassisches Conversion-Problem: Nutzer müssen sich committen (Account erstellen), bevor sie den Wert der App erleben. Die Lösung scheint einfach — `allowGuest=true` setzen und fertig. Aber die Frage "wo kommen dann die Daten her?" führt zu einer viel grundlegenderen Architekturentscheidung.
|
||||
|
||||
---
|
||||
|
||||
## Analyse: Was ist eigentlich "Offline"?
|
||||
|
||||
Aktuell haben alle 20 Web-Apps PWA-Support via `@manacore/shared-pwa` mit Workbox-Caching:
|
||||
|
||||
| Schicht | Status | Was es kann |
|
||||
|---------|--------|------------|
|
||||
| Service Worker + Precaching | Alle 20 Apps | App-Shell (HTML/CSS/JS) offline laden |
|
||||
| API Caching (NetworkFirst) | Alle Apps | Zuletzt geladene API-Responses aus Cache lesen |
|
||||
| Offline-Seite | Alle Apps | Fallback-UI wenn komplett offline |
|
||||
| Offline-Writes | Nur SkillTree | Daten in IndexedDB erstellen/bearbeiten |
|
||||
|
||||
Das bedeutet: **Read-Only Offline** ist bereits da. Aber kein einziger Write funktioniert offline (ausser SkillTree mit dediziertem IndexedDB-Store).
|
||||
|
||||
### Das Spektrum der Offline-Architekturen
|
||||
|
||||
```
|
||||
Level 0 Level 1 Level 2 Level 3 Level 4
|
||||
Online-Only → Cache-Read → Offline-Capable → Offline-First → Local-First
|
||||
(AKTUELL) (ZIEL)
|
||||
```
|
||||
|
||||
- **Level 1 (aktuell):** Gecachte API-Responses lesbar, kein Write
|
||||
- **Level 2:** Writes werden gequeued, sync bei Reconnect
|
||||
- **Level 3:** App arbeitet immer gegen lokale DB, Server synced im Hintergrund
|
||||
- **Level 4:** Volle CRDT-basierte Sync, Real-time Collab
|
||||
|
||||
**Entscheidung: Level 3 (Offline-First)** — Guest-Mode wird Nebeneffekt, Instant UI, voller Offline-CRUD. Level 4 (CRDT) ist Overengineering ohne Real-time-Collab-Requirement.
|
||||
|
||||
---
|
||||
|
||||
## Die neue Architektur
|
||||
|
||||
### Client: Dexie.js als lokale Datenbank
|
||||
|
||||
Jede App bekommt eine lokale IndexedDB-Datenbank via Dexie.js. Statt API-Calls liest und schreibt die App gegen lokale Daten:
|
||||
|
||||
```
|
||||
VORHER: Component → API Call → 200ms warten → State Update → Render
|
||||
NACHHER: Component → IndexedDB Read (< 1ms) → Render → Sync im Hintergrund
|
||||
```
|
||||
|
||||
**Warum Dexie.js:**
|
||||
- `liveQuery()` — reaktive Queries, die automatisch UI updaten (perfekt für Svelte 5 runes)
|
||||
- 15KB Bundle (vs. 500KB für SQLite WASM)
|
||||
- Kein OPFS nötig, breite Browser-Unterstützung
|
||||
- Bewährte Library mit grosser Community
|
||||
|
||||
**Neues Shared Package: `@manacore/local-store`** kapselt die gesamte Local-First-Logik:
|
||||
- `createLocalCollection<T>()` — Factory für typisierte, reaktive Collections
|
||||
- Sync Engine mit Field-Level Last-Write-Wins Conflict Resolution
|
||||
- WebSocket-Client für Push-Updates von anderen Geraeten
|
||||
- Offline-Queue für pending Writes
|
||||
|
||||
### Sync-Server: Go
|
||||
|
||||
Ein zentraler Sync-Server (`mana-sync`) in Go, der das Sync-Protokoll fuer alle Apps implementiert:
|
||||
|
||||
**Warum Go:**
|
||||
- 100.000+ gleichzeitige WebSocket-Verbindungen (Goroutines, ~4KB/Connection)
|
||||
- P99 Latency < 1ms fuer Sync-Operationen
|
||||
- Single Binary Deployment (~15MB)
|
||||
- Perfekt fuer genau diese Art von I/O-bound Service
|
||||
|
||||
**Was der Sync-Server macht:**
|
||||
1. Empfaengt Changesets von Clients (Batch von Aenderungen)
|
||||
2. Wendet Field-Level LWW an bei Konflikten
|
||||
3. Persistiert in PostgreSQL
|
||||
4. Gibt Server-Delta zurueck (was der Client noch nicht hat)
|
||||
5. Pushed via WebSocket an andere Geraete des Users
|
||||
|
||||
**Was er NICHT macht:** Business-Logik, Auth, File-Uploads, AI-Calls. Das bleibt in den App-Backends.
|
||||
|
||||
### App-Backends: Von NestJS zu Hono auf Bun
|
||||
|
||||
Die groesste Aenderung: NestJS wird durch Hono ersetzt, laeuft auf Bun statt Node.
|
||||
|
||||
**Warum weg von NestJS:**
|
||||
- Enterprise-Java-Philosophie (Angular-Style DI, Decorators, Module, Guards, DTOs...)
|
||||
- ~50MB node_modules pro Backend
|
||||
- 2-5 Sekunden Cold Start
|
||||
- Viel Boilerplate fuer einfache Aufgaben
|
||||
|
||||
**Warum Hono:**
|
||||
- 14KB Bundle
|
||||
- < 50ms Cold Start auf Bun (< 6ms fuer Bun selbst)
|
||||
- Web-Standard API (fetch, Request/Response)
|
||||
- RPC Type Safety: Client importiert Server-Typen ohne Codegen
|
||||
- Laeuft ueberall: Bun, Node, Deno, Cloudflare Workers
|
||||
|
||||
**Warum Bun:**
|
||||
- Nativer TypeScript-Support (kein Compiler noetig)
|
||||
- ~150K req/s (3x Node)
|
||||
- Built-in SQLite, Test Runner, Package Manager
|
||||
- Startup ~6ms statt ~300ms (Node)
|
||||
|
||||
### Was sich am Backend aendert
|
||||
|
||||
Durch Local-First fallen ~220 von ~260 Endpoints weg (alle CRUD). Was bleibt:
|
||||
|
||||
| Kategorie | Beispiele | Bleibt weil |
|
||||
|-----------|-----------|-------------|
|
||||
| **External APIs** | Replicate (Bild-Gen), OpenRouter (LLM), Google OAuth | API Keys duerfen nicht zum Client |
|
||||
| **Webhooks** | Stripe Payment, Replicate Completion | Server muss Callbacks empfangen |
|
||||
| **Server-Compute** | RRULE Expansion (DoS-Schutz), Spaced Repetition | Zu teuer/riskant fuer Client |
|
||||
| **File Uploads** | Bilder, vCards, CSVs → MinIO/S3 | Braucht Server-seitigen Storage-Zugang |
|
||||
| **Credits** | Balance pruefen, Consumption tracken | Authoritative Quelle, Betrugsschutz |
|
||||
| **Admin** | User-Uebersicht, Metriken | Zugriff auf alle Daten |
|
||||
|
||||
### Auth: Better Auth bleibt, NestJS geht
|
||||
|
||||
Better Auth hat einen nativen Hono-Adapter. Die Migration ist hauptsaechlich ein HTTP-Layer-Wechsel, die Auth-Logik (EdDSA JWT, SSO, Organizations, Credits) bleibt identisch.
|
||||
|
||||
### AI Services: Bleiben Python
|
||||
|
||||
`mana-llm`, `mana-stt`, `mana-tts`, `mana-image-gen` — Python ist das richtige Oekosystem fuer ML/AI. Keine Aenderung.
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution: Field-Level Last-Write-Wins
|
||||
|
||||
Das Herzstück des Sync-Protokolls. Statt "ganzes Objekt gewinnt" wird pro Feld entschieden:
|
||||
|
||||
```
|
||||
Gerät A (offline): Task "Einkaufen" → priority: "high" (14:01:03)
|
||||
Gerät B (offline): Task "Einkaufen" → title: "Einkaufen Rewe" (14:01:05)
|
||||
|
||||
Sync-Ergebnis:
|
||||
title: "Einkaufen Rewe" (B war neuer für dieses Feld)
|
||||
priority: "high" (A war einziger Editor für dieses Feld)
|
||||
→ Kein Datenverlust
|
||||
```
|
||||
|
||||
Jede Tabelle bekommt ein `field_timestamps` JSONB-Feld das den letzten Aenderungszeitpunkt pro Feld speichert.
|
||||
|
||||
---
|
||||
|
||||
## Guest-Mode: Kein Sonderfall mehr
|
||||
|
||||
Mit Local-First ist Guest-Mode kein Feature sondern der Default-Zustand:
|
||||
|
||||
```
|
||||
Guest: IndexedDB ←→ UI (Sync Engine aus)
|
||||
Eingeloggt: IndexedDB ←→ UI ←→ Sync ←→ Server (Sync Engine an)
|
||||
```
|
||||
|
||||
Bei Login passiert:
|
||||
1. User meldet sich an
|
||||
2. Sync Engine startet
|
||||
3. Lokale Daten bekommen `userId`
|
||||
4. Alles wird zum Server gepusht
|
||||
5. Server-Daten (von anderen Geraeten) werden gepullt
|
||||
|
||||
Kein separater Migrations-Endpoint, kein Sonderfall im Store-Code.
|
||||
|
||||
### Onboarding-Seed pro App
|
||||
|
||||
Jede App definiert Seed-Daten die bei erstem Besuch in IndexedDB geladen werden:
|
||||
|
||||
- **Todo:** Beispiel-Projekt "Erste Schritte" mit erklaerenden Tasks
|
||||
- **Contacts:** Beispiel-Kontakt mit allen Feldern ausgefuellt
|
||||
- **Calendar:** Beispiel-Termine fuer diese Woche
|
||||
- **Chat:** Willkommensnachricht mit Erklaerung der Features
|
||||
|
||||
---
|
||||
|
||||
## Performance-Vergleich
|
||||
|
||||
| Metrik | Aktuell (NestJS/Node) | Ziel (Go + Hono/Bun) |
|
||||
|--------|----------------------|----------------------|
|
||||
| Task erstellen | 200-500ms (API) | < 5ms (lokal) |
|
||||
| Seitenwechsel | Loading-Spinner + API | Instant (IndexedDB) |
|
||||
| Backend Memory/Service | ~150MB | ~15MB (Go) / ~40MB (Bun) |
|
||||
| Cold Start | 2-5s | ~6ms (Bun) / ~50ms (Go) |
|
||||
| Concurrent WebSockets | ~5.000 | ~100.000 (Go) |
|
||||
| Total Docker Image Size | ~3GB (6 NestJS) | ~250MB (1 Go + 3-4 Hono) |
|
||||
| CRUD Endpoints | ~260 | ~40 + 1 Sync-Protokoll |
|
||||
|
||||
---
|
||||
|
||||
## Migrationsplan (5 Phasen)
|
||||
|
||||
### Phase 1: Foundation (2-3 Wochen)
|
||||
- `@manacore/local-store` Package bauen
|
||||
- `mana-sync` Go Service bauen
|
||||
- Todo als Pilot umbauen
|
||||
|
||||
### Phase 2: Todo komplett (2-3 Wochen)
|
||||
- Todo NestJS → Hono/Bun
|
||||
- Guest-Mode + Onboarding-Seed
|
||||
- PillNav Login-Button
|
||||
|
||||
### Phase 3: Alle Apps (4-6 Wochen)
|
||||
- Reihenfolge: Zitare → Calendar → Clock → ManaDeck → Contacts → Chat → Picture → Presi
|
||||
- Pro App: Collections definieren, Stores umbauen, NestJS → Hono
|
||||
|
||||
### Phase 4: Auth-Migration (2 Wochen)
|
||||
- mana-core-auth: NestJS → Hono/Bun mit Better Auth Hono-Adapter
|
||||
|
||||
### Phase 5: Cleanup (1-2 Wochen)
|
||||
- NestJS Dependencies entfernen
|
||||
- Shared Packages migrieren (shared-nestjs-auth → shared-hono-auth)
|
||||
- Docker-Images auf Bun Base umstellen
|
||||
- CI/CD anpassen
|
||||
|
||||
---
|
||||
|
||||
## Technologie-Entscheidungen
|
||||
|
||||
| Entscheidung | Gewählt | Alternativen betrachtet | Begründung |
|
||||
|---|---|---|---|
|
||||
| Lokale DB | Dexie.js | SQLite WASM, cr-sqlite | 15KB vs 500KB, liveQuery Reaktivität, breiter Support |
|
||||
| Sync-Server | Go | Rust, Elixir, Node | Performance + Einfachheit, perfekt für I/O-bound WebSocket Service |
|
||||
| App-Backend | Hono + Bun | Fastify, ElysiaJS, Express | RPC Type Safety, Web-Standard API, Multi-Runtime |
|
||||
| Conflict Strategy | Field-Level LWW | Volles CRDT (Automerge/Y.js) | Löst 99% der Konflikte, CRDT nur nötig bei Real-time Collab |
|
||||
| Runtime | Bun | Node, Deno | Nativer TS, 3x Performance, schnellster Startup |
|
||||
| Auth | Better Auth (bleibt) | Lucia, Auth.js | Bereits integriert, Hono-Adapter vorhanden |
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Aspekt | Änderung |
|
||||
|--------|----------|
|
||||
| **Datenmodell** | API-First → Local-First (IndexedDB + Sync) |
|
||||
| **Backend-Framework** | NestJS → Hono auf Bun |
|
||||
| **Sync-Server** | Neu: Go Service (mana-sync) |
|
||||
| **Runtime** | Node.js → Bun |
|
||||
| **Guest-Mode** | Separater Code → Nebeneffekt der Architektur |
|
||||
| **Offline** | Read-Only Cache → Voller CRUD |
|
||||
| **UI-Geschwindigkeit** | API-abhängig → Instant (lokal) |
|
||||
| **AI Services** | Python → Python (keine Änderung) |
|
||||
| **Auth** | Better Auth bleibt, HTTP-Layer wechselt |
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. `@manacore/local-store` Package initialisieren
|
||||
2. Go-Projekt `mana-sync` aufsetzen
|
||||
3. Todo-App als Pilot: Stores auf Dexie.js umbauen
|
||||
4. Sync-Protokoll zwischen Client und Go-Server implementieren
|
||||
5. Guest-Seed und PillNav Login-Button für Todo
|
||||
|
||||
Detaillierter Plan: `.claude/plans/local-first-architecture-migration.md`
|
||||
|
|
@ -39,6 +39,7 @@
|
|||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-api-client": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
|||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
|
||||
connectSrc: [
|
||||
PUBLIC_MANA_CORE_AUTH_URL_CLIENT || 'http://localhost:3001',
|
||||
PUBLIC_BACKEND_URL_CLIENT || 'http://localhost:3018',
|
||||
'http://localhost:3050', // mana-sync server
|
||||
],
|
||||
});
|
||||
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -60,19 +60,13 @@
|
|||
isLoading = true;
|
||||
|
||||
try {
|
||||
const result = await tasksStore.createTask({
|
||||
await tasksStore.createTask({
|
||||
title,
|
||||
projectId: selectedProjectId,
|
||||
dueDate: selectedDate.toISOString(),
|
||||
priority: selectedPriority,
|
||||
});
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset form
|
||||
inputValue = '';
|
||||
selectedDate = new Date();
|
||||
|
|
|
|||
164
apps/todo/apps/web/src/lib/components/SyncIndicator.svelte
Normal file
164
apps/todo/apps/web/src/lib/components/SyncIndicator.svelte
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
<!--
|
||||
SyncIndicator — Shows sync status as a small pill in the layout.
|
||||
|
||||
- Guest (no sync): "Lokal" with info icon
|
||||
- Synced: green dot
|
||||
- Syncing: animated spinner
|
||||
- Offline: orange dot + "Offline"
|
||||
- Pending: count of pending changes
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { todoStore } from '$lib/data/local-store';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { SyncStatus } from '@manacore/local-store';
|
||||
|
||||
let status = $state<SyncStatus>('idle');
|
||||
let pendingCount = $state(0);
|
||||
let isGuest = $derived(!authStore.isAuthenticated);
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let pendingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const engine = todoStore.syncEngine;
|
||||
if (engine) {
|
||||
status = engine.status;
|
||||
unsubscribe = engine.onStatusChange((s) => {
|
||||
status = s;
|
||||
});
|
||||
|
||||
const updatePending = async () => {
|
||||
pendingCount = await engine.getPendingCount();
|
||||
};
|
||||
updatePending();
|
||||
pendingInterval = setInterval(updatePending, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
if (pendingInterval) clearInterval(pendingInterval);
|
||||
});
|
||||
|
||||
let label = $derived.by(() => {
|
||||
if (isGuest) return 'Lokal';
|
||||
switch (status) {
|
||||
case 'syncing':
|
||||
return 'Sync...';
|
||||
case 'synced':
|
||||
return pendingCount > 0 ? `${pendingCount} ausstehend` : '';
|
||||
case 'offline':
|
||||
return 'Offline';
|
||||
case 'error':
|
||||
return 'Sync-Fehler';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
let dotClass = $derived.by(() => {
|
||||
if (isGuest) return 'dot-local';
|
||||
switch (status) {
|
||||
case 'syncing':
|
||||
return 'dot-syncing';
|
||||
case 'synced':
|
||||
return pendingCount > 0 ? 'dot-pending' : 'dot-synced';
|
||||
case 'offline':
|
||||
return 'dot-offline';
|
||||
case 'error':
|
||||
return 'dot-error';
|
||||
default:
|
||||
return 'dot-idle';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if label || !isGuest}
|
||||
<div
|
||||
class="sync-indicator"
|
||||
title={isGuest ? 'Daten werden nur in diesem Browser gespeichert' : `Sync: ${status}`}
|
||||
>
|
||||
<span class="dot {dotClass}"></span>
|
||||
{#if label}
|
||||
<span class="label">{label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sync-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
:global(.light) .sync-indicator,
|
||||
:global(:root:not(.dark)) .sync-indicator {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot-synced {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.dot-syncing {
|
||||
background: #3b82f6;
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dot-pending {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.dot-offline {
|
||||
background: #f97316;
|
||||
}
|
||||
|
||||
.dot-error {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.dot-local {
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.dot-idle {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -212,26 +212,15 @@
|
|||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
let result;
|
||||
if (task.isCompleted) {
|
||||
result = await tasksStore.uncompleteTask(task.id);
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
result = await tasksStore.completeTask(task.id);
|
||||
}
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(taskId: string) {
|
||||
const result = await tasksStore.deleteTask(taskId);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
await tasksStore.deleteTask(taskId);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -79,12 +79,7 @@
|
|||
// Get projectId from current board if available
|
||||
const currentBoard = kanbanStore.currentBoard;
|
||||
const taskProjectId = currentBoard?.projectId ?? projectId;
|
||||
const result = await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
await kanbanStore.createTaskInColumn(columnId, title, taskProjectId ?? undefined);
|
||||
}
|
||||
|
||||
async function handleTaskMove(taskId: string, toColumnId: string, order: number) {
|
||||
|
|
|
|||
|
|
@ -62,16 +62,10 @@
|
|||
}
|
||||
|
||||
async function handleToggleComplete(task: Task) {
|
||||
let result;
|
||||
if (task.isCompleted) {
|
||||
result = await tasksStore.uncompleteTask(task.id);
|
||||
await tasksStore.uncompleteTask(task.id);
|
||||
} else {
|
||||
result = await tasksStore.completeTask(task.id);
|
||||
}
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
await tasksStore.completeTask(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,12 +85,7 @@
|
|||
if (data.metadata !== undefined) updateData.metadata = data.metadata;
|
||||
if (data.labels !== undefined) updateData.labelIds = data.labels?.map((l) => l.id);
|
||||
|
||||
const result = await tasksStore.updateTask(task.id, updateData);
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
await tasksStore.updateTask(task.id, updateData);
|
||||
}
|
||||
|
||||
async function handleDeleteTask(task: Task) {
|
||||
|
|
|
|||
130
apps/todo/apps/web/src/lib/data/guest-seed.ts
Normal file
130
apps/todo/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Guest seed data for the Todo app.
|
||||
*
|
||||
* These records are loaded into IndexedDB when a new guest visits the app.
|
||||
* They serve as onboarding content that teaches the user how the app works.
|
||||
*/
|
||||
|
||||
import type { LocalTask, LocalProject, LocalLabel } from './local-store';
|
||||
|
||||
const ONBOARDING_PROJECT_ID = 'onboarding-project';
|
||||
const PERSONAL_PROJECT_ID = 'personal-project';
|
||||
|
||||
export const guestProjects: LocalProject[] = [
|
||||
{
|
||||
id: ONBOARDING_PROJECT_ID,
|
||||
name: 'Erste Schritte',
|
||||
color: '#3b82f6',
|
||||
icon: 'sparkle',
|
||||
order: 0,
|
||||
isArchived: false,
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: PERSONAL_PROJECT_ID,
|
||||
name: 'Persönlich',
|
||||
color: '#10b981',
|
||||
icon: 'home',
|
||||
order: 1,
|
||||
isArchived: false,
|
||||
isDefault: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const guestLabels: LocalLabel[] = [
|
||||
{
|
||||
id: 'label-important',
|
||||
name: 'Wichtig',
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
id: 'label-idea',
|
||||
name: 'Idee',
|
||||
color: '#f59e0b',
|
||||
},
|
||||
];
|
||||
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const nextWeek = new Date(now);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
|
||||
export const guestTasks: LocalTask[] = [
|
||||
// ─── Onboarding Tasks ───────────────────────────────────
|
||||
{
|
||||
id: 'onboard-1',
|
||||
title: 'Willkommen bei Todo! Tippe hier, um diese Aufgabe zu bearbeiten ✏️',
|
||||
description:
|
||||
'Du kannst Titel, Beschreibung, Priorität und Fälligkeitsdatum ändern. Probiere es aus!',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
order: 0,
|
||||
subtasks: [
|
||||
{ id: 'sub-1', title: 'Titel bearbeiten', isCompleted: false, order: 0 },
|
||||
{ id: 'sub-2', title: 'Beschreibung hinzufügen', isCompleted: false, order: 1 },
|
||||
{ id: 'sub-3', title: 'Priorität ändern', isCompleted: false, order: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'onboard-2',
|
||||
title: 'Klicke den Kreis links, um diese Aufgabe abzuschließen ✓',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'onboard-3',
|
||||
title: 'Erstelle eine neue Aufgabe mit dem + Button oben',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'onboard-4',
|
||||
title: 'Wechsle zur Kanban-Ansicht über die Navigation',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'onboard-5',
|
||||
title: 'Melde dich an, um deine Aufgaben auf allen Geräten zu synchronisieren',
|
||||
description:
|
||||
'Ohne Anmeldung werden deine Daten nur in diesem Browser gespeichert. Mit einem Account synchronisieren wir sie automatisch.',
|
||||
projectId: ONBOARDING_PROJECT_ID,
|
||||
priority: 'high',
|
||||
isCompleted: false,
|
||||
order: 4,
|
||||
},
|
||||
|
||||
// ─── Sample Personal Tasks ──────────────────────────────
|
||||
{
|
||||
id: 'sample-1',
|
||||
title: 'Einkaufen gehen',
|
||||
description: 'Milch, Brot, Obst',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
priority: 'medium',
|
||||
isCompleted: false,
|
||||
dueDate: tomorrow.toISOString(),
|
||||
order: 0,
|
||||
subtasks: [
|
||||
{ id: 'shop-1', title: 'Milch', isCompleted: false, order: 0 },
|
||||
{ id: 'shop-2', title: 'Brot', isCompleted: false, order: 1 },
|
||||
{ id: 'shop-3', title: 'Obst', isCompleted: false, order: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
title: 'Wohnung aufräumen',
|
||||
projectId: PERSONAL_PROJECT_ID,
|
||||
priority: 'low',
|
||||
isCompleted: false,
|
||||
dueDate: nextWeek.toISOString(),
|
||||
order: 1,
|
||||
},
|
||||
];
|
||||
112
apps/todo/apps/web/src/lib/data/local-store.ts
Normal file
112
apps/todo/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* Todo App — Local-First Data Layer
|
||||
*
|
||||
* Defines the IndexedDB database, collections, and guest seed data.
|
||||
* This is the single source of truth for all Todo data.
|
||||
*/
|
||||
|
||||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
import type { Subtask as SharedSubtask } from '@todo/shared';
|
||||
import { guestProjects, guestTasks, guestLabels } from './guest-seed.js';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalTask extends BaseRecord {
|
||||
title: string;
|
||||
description?: string;
|
||||
projectId?: string | null;
|
||||
userId?: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
isCompleted: boolean;
|
||||
completedAt?: string | null;
|
||||
dueDate?: string | null;
|
||||
scheduledDate?: string | null;
|
||||
scheduledStartTime?: string | null;
|
||||
estimatedDuration?: number | null;
|
||||
order: number;
|
||||
recurrenceRule?: string | null;
|
||||
subtasks?: SharedSubtask[] | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type { SharedSubtask as Subtask };
|
||||
|
||||
export interface LocalProject extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
icon?: string | null;
|
||||
userId?: string;
|
||||
order: number;
|
||||
isArchived: boolean;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface LocalLabel extends BaseRecord {
|
||||
name: string;
|
||||
color: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export interface LocalTaskLabel extends BaseRecord {
|
||||
taskId: string;
|
||||
labelId: string;
|
||||
}
|
||||
|
||||
export interface LocalReminder extends BaseRecord {
|
||||
taskId: string;
|
||||
userId?: string;
|
||||
minutesBefore: number;
|
||||
type: 'push' | 'email' | 'both';
|
||||
status: 'pending' | 'sent' | 'failed';
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const todoStore = createLocalStore({
|
||||
appId: 'todo',
|
||||
collections: [
|
||||
{
|
||||
name: 'tasks',
|
||||
indexes: [
|
||||
'projectId',
|
||||
'dueDate',
|
||||
'isCompleted',
|
||||
'priority',
|
||||
'order',
|
||||
'[isCompleted+order]',
|
||||
'[projectId+order]',
|
||||
],
|
||||
guestSeed: guestTasks,
|
||||
},
|
||||
{
|
||||
name: 'projects',
|
||||
indexes: ['order', 'isArchived'],
|
||||
guestSeed: guestProjects,
|
||||
},
|
||||
{
|
||||
name: 'labels',
|
||||
indexes: [],
|
||||
guestSeed: guestLabels,
|
||||
},
|
||||
{
|
||||
name: 'taskLabels',
|
||||
indexes: ['taskId', 'labelId'],
|
||||
},
|
||||
{
|
||||
name: 'reminders',
|
||||
indexes: ['taskId'],
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const taskCollection = todoStore.collection<LocalTask>('tasks');
|
||||
export const projectCollection = todoStore.collection<LocalProject>('projects');
|
||||
export const labelCollection = todoStore.collection<LocalLabel>('labels');
|
||||
export const taskLabelCollection = todoStore.collection<LocalTaskLabel>('taskLabels');
|
||||
export const reminderCollection = todoStore.collection<LocalReminder>('reminders');
|
||||
|
|
@ -5,7 +5,6 @@
|
|||
import type { KanbanBoard, KanbanColumn, Task } from '@todo/shared';
|
||||
import * as kanbanApi from '$lib/api/kanban';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
// Board state
|
||||
let boards = $state<KanbanBoard[]>([]);
|
||||
|
|
@ -419,16 +418,10 @@ export const kanbanStore = {
|
|||
|
||||
/**
|
||||
* Create a new task in a specific column
|
||||
* Requires authentication - demo mode shows auth gate
|
||||
*/
|
||||
async createTaskInColumn(columnId: string, title: string, projectId?: string) {
|
||||
error = null;
|
||||
|
||||
// Demo mode: require authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
try {
|
||||
// Find the column to get its default status
|
||||
const column = columns.find((c) => c.id === columnId);
|
||||
|
|
|
|||
|
|
@ -1,33 +1,36 @@
|
|||
/**
|
||||
* Projects Store - Manages project state using Svelte 5 runes
|
||||
* Supports both authenticated (cloud) and guest (session) modes
|
||||
* Projects Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import type { Project } from '@todo/shared';
|
||||
import * as projectsApi from '$lib/api/projects';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { projectCollection, type LocalProject } from '$lib/data/local-store';
|
||||
import { TodoEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// Guest inbox project for unauthenticated users
|
||||
const GUEST_INBOX: Project = {
|
||||
id: 'session-inbox',
|
||||
userId: 'guest',
|
||||
name: 'Inbox',
|
||||
color: '#6b7280',
|
||||
order: 0,
|
||||
isArchived: false,
|
||||
isDefault: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// State
|
||||
let projects = $state<Project[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** Convert a LocalProject (IndexedDB) to the shared Project type. */
|
||||
function toProject(local: LocalProject): Project {
|
||||
return {
|
||||
id: local.id,
|
||||
userId: local.userId ?? 'guest',
|
||||
name: local.name,
|
||||
color: local.color,
|
||||
icon: local.icon,
|
||||
order: local.order,
|
||||
isArchived: local.isArchived,
|
||||
isDefault: local.isDefault,
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const projectsStore = {
|
||||
// Getters
|
||||
get projects() {
|
||||
return projects;
|
||||
},
|
||||
|
|
@ -38,45 +41,30 @@ export const projectsStore = {
|
|||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get inbox project (default project)
|
||||
*/
|
||||
get inboxProject() {
|
||||
return projects.find((p) => p.isDefault);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get non-archived projects sorted by order
|
||||
*/
|
||||
get activeProjects() {
|
||||
return projects.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get archived projects
|
||||
*/
|
||||
get archivedProjects() {
|
||||
return projects.filter((p) => p.isArchived);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all projects from API
|
||||
* In guest mode, returns a default inbox project
|
||||
* Load projects from IndexedDB.
|
||||
*/
|
||||
async fetchProjects() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Guest mode: return local inbox only
|
||||
if (!authStore.isAuthenticated) {
|
||||
projects = [GUEST_INBOX];
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
projects = await projectsApi.getProjects();
|
||||
const localProjects = await projectCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
projects = localProjects.map(toProject);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch projects';
|
||||
console.error('Failed to fetch projects:', e);
|
||||
|
|
@ -85,29 +73,31 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project by ID
|
||||
*/
|
||||
getById(id: string): Project | undefined {
|
||||
return projects.find((p) => p.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get project color by ID
|
||||
*/
|
||||
getColor(projectId: string): string {
|
||||
const project = projects.find((p) => p.id === projectId);
|
||||
return project?.color || '#6b7280';
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new project
|
||||
*/
|
||||
async createProject(data: { name: string; description?: string; color?: string; icon?: string }) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const newProject = await projectsApi.createProject(data);
|
||||
const newLocal: LocalProject = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name,
|
||||
color: data.color ?? '#6b7280',
|
||||
icon: data.icon ?? null,
|
||||
order: projects.length,
|
||||
isArchived: false,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
const inserted = await projectCollection.insert(newLocal);
|
||||
const newProject = toProject(inserted);
|
||||
projects = [...projects, newProject];
|
||||
TodoEvents.projectCreated();
|
||||
return newProject;
|
||||
|
|
@ -120,18 +110,18 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing project
|
||||
*/
|
||||
async updateProject(
|
||||
id: string,
|
||||
data: { name?: string; description?: string; color?: string; icon?: string }
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedProject = await projectsApi.updateProject(id, data);
|
||||
projects = projects.map((p) => (p.id === id ? updatedProject : p));
|
||||
return updatedProject;
|
||||
const updated = await projectCollection.update(id, data as Partial<LocalProject>);
|
||||
if (updated) {
|
||||
const updatedProject = toProject(updated);
|
||||
projects = projects.map((p) => (p.id === id ? updatedProject : p));
|
||||
return updatedProject;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update project';
|
||||
console.error('Failed to update project:', e);
|
||||
|
|
@ -139,13 +129,10 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a project
|
||||
*/
|
||||
async deleteProject(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await projectsApi.deleteProject(id);
|
||||
await projectCollection.delete(id);
|
||||
projects = projects.filter((p) => p.id !== id);
|
||||
TodoEvents.projectDeleted();
|
||||
} catch (e) {
|
||||
|
|
@ -155,15 +142,17 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Archive a project
|
||||
*/
|
||||
async archiveProject(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const archivedProject = await projectsApi.archiveProject(id);
|
||||
projects = projects.map((p) => (p.id === id ? archivedProject : p));
|
||||
return archivedProject;
|
||||
const updated = await projectCollection.update(id, {
|
||||
isArchived: true,
|
||||
} as Partial<LocalProject>);
|
||||
if (updated) {
|
||||
const archivedProject = toProject(updated);
|
||||
projects = projects.map((p) => (p.id === id ? archivedProject : p));
|
||||
return archivedProject;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to archive project';
|
||||
console.error('Failed to archive project:', e);
|
||||
|
|
@ -171,18 +160,17 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder projects
|
||||
*/
|
||||
async reorderProjects(projectIds: string[]) {
|
||||
error = null;
|
||||
try {
|
||||
await projectsApi.reorderProjects(projectIds);
|
||||
// Update local order
|
||||
projects = projects.map((p) => {
|
||||
const newOrder = projectIds.indexOf(p.id);
|
||||
return newOrder !== -1 ? { ...p, order: newOrder } : p;
|
||||
});
|
||||
|
||||
for (let i = 0; i < projectIds.length; i++) {
|
||||
await projectCollection.update(projectIds[i], { order: i } as Partial<LocalProject>);
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder projects';
|
||||
console.error('Failed to reorder projects:', e);
|
||||
|
|
@ -190,26 +178,17 @@ export const projectsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
projects = [];
|
||||
loading = false;
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a project ID is the guest inbox
|
||||
*/
|
||||
isGuestInbox(id: string) {
|
||||
return id === GUEST_INBOX.id;
|
||||
isGuestInbox(_id: string) {
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the guest inbox ID
|
||||
*/
|
||||
get guestInboxId() {
|
||||
return GUEST_INBOX.id;
|
||||
return 'personal-project';
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,21 +1,52 @@
|
|||
/**
|
||||
* Tasks Store - Manages task state using Svelte 5 runes
|
||||
* Authenticated users: tasks from API
|
||||
* Demo mode: static sample tasks to showcase the app
|
||||
* Tasks Store — Local-First with Dexie.js
|
||||
*
|
||||
* All reads and writes go to IndexedDB first.
|
||||
* When authenticated, changes sync to the server in the background.
|
||||
* Same public API as before so components don't need changes.
|
||||
*/
|
||||
|
||||
import type { Task, TaskPriority, TaskStatus, Subtask } from '@todo/shared';
|
||||
import * as tasksApi from '$lib/api/tasks';
|
||||
import { taskCollection, type LocalTask } from '$lib/data/local-store';
|
||||
import { isToday, isPast, isFuture, startOfDay, addDays } from 'date-fns';
|
||||
import { authStore } from './auth.svelte';
|
||||
import { generateDemoTasks, isDemoTask } from '$lib/data/demo-tasks';
|
||||
import { TodoEvents } from '@manacore/shared-utils/analytics';
|
||||
|
||||
// State
|
||||
// State — populated from IndexedDB
|
||||
let tasks = $state<Task[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
/** Convert a LocalTask (IndexedDB record) to the shared Task type. */
|
||||
function toTask(local: LocalTask): Task {
|
||||
return {
|
||||
id: local.id,
|
||||
projectId: local.projectId,
|
||||
userId: local.userId ?? 'guest',
|
||||
title: local.title,
|
||||
description: local.description,
|
||||
dueDate: local.dueDate,
|
||||
scheduledDate: local.scheduledDate,
|
||||
scheduledStartTime: local.scheduledStartTime,
|
||||
estimatedDuration: local.estimatedDuration,
|
||||
priority: local.priority,
|
||||
status: local.isCompleted ? 'completed' : 'pending',
|
||||
isCompleted: local.isCompleted,
|
||||
completedAt: local.completedAt,
|
||||
order: local.order,
|
||||
recurrenceRule: local.recurrenceRule,
|
||||
subtasks: local.subtasks ?? null,
|
||||
metadata: local.metadata as Task['metadata'],
|
||||
createdAt: local.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: local.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load tasks from IndexedDB into the reactive state. */
|
||||
async function refreshTasks(filter?: Partial<LocalTask>) {
|
||||
const localTasks = await taskCollection.getAll(filter, { sortBy: 'order', sortDirection: 'asc' });
|
||||
tasks = localTasks.map(toTask);
|
||||
}
|
||||
|
||||
export const tasksStore = {
|
||||
// Getters
|
||||
get tasks() {
|
||||
|
|
@ -28,22 +59,16 @@ export const tasksStore = {
|
|||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get incomplete tasks
|
||||
*/
|
||||
get incompleteTasks() {
|
||||
return tasks.filter((t) => !t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get completed tasks
|
||||
*/
|
||||
get completedTasks() {
|
||||
return tasks.filter((t) => t.isCompleted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch tasks with optional filters
|
||||
* Fetch tasks with optional filters — reads from IndexedDB.
|
||||
*/
|
||||
async fetchTasks(
|
||||
query: {
|
||||
|
|
@ -60,7 +85,26 @@ export const tasksStore = {
|
|||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getTasks(query);
|
||||
const filter: Partial<LocalTask> = {};
|
||||
if (query.projectId) filter.projectId = query.projectId;
|
||||
if (query.priority) filter.priority = query.priority;
|
||||
if (query.isCompleted !== undefined) filter.isCompleted = query.isCompleted;
|
||||
|
||||
let localTasks = await taskCollection.getAll(
|
||||
Object.keys(filter).length > 0 ? filter : undefined,
|
||||
{ sortBy: 'order', sortDirection: 'asc' }
|
||||
);
|
||||
|
||||
// Client-side search filter
|
||||
if (query.search) {
|
||||
const search = query.search.toLowerCase();
|
||||
localTasks = localTasks.filter(
|
||||
(t) =>
|
||||
t.title.toLowerCase().includes(search) || t.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
tasks = localTasks.map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch tasks';
|
||||
console.error('Failed to fetch tasks:', e);
|
||||
|
|
@ -69,85 +113,82 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch inbox tasks (tasks without project)
|
||||
*/
|
||||
async fetchInboxTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getInboxTasks();
|
||||
const localTasks = await taskCollection.getAll(undefined, {
|
||||
sortBy: 'order',
|
||||
sortDirection: 'asc',
|
||||
});
|
||||
// Inbox = tasks without projectId or with null projectId
|
||||
tasks = localTasks.filter((t) => !t.projectId).map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch inbox tasks';
|
||||
console.error('Failed to fetch inbox tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch today's tasks
|
||||
*/
|
||||
async fetchTodayTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getTodayTasks();
|
||||
const localTasks = await taskCollection.getAll(
|
||||
{ isCompleted: false },
|
||||
{ sortBy: 'order', sortDirection: 'asc' }
|
||||
);
|
||||
const today = startOfDay(new Date());
|
||||
tasks = localTasks
|
||||
.filter((t) => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return isToday(d) || (isPast(startOfDay(d)) && !isToday(d));
|
||||
})
|
||||
.map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch today tasks';
|
||||
console.error('Failed to fetch today tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch upcoming tasks
|
||||
*/
|
||||
async fetchUpcomingTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
tasks = await tasksApi.getUpcomingTasks();
|
||||
const localTasks = await taskCollection.getAll(
|
||||
{ isCompleted: false },
|
||||
{ sortBy: 'dueDate', sortDirection: 'asc' }
|
||||
);
|
||||
const today = startOfDay(new Date());
|
||||
const weekFromNow = addDays(today, 7);
|
||||
tasks = localTasks
|
||||
.filter((t) => {
|
||||
if (!t.dueDate) return false;
|
||||
const d = new Date(t.dueDate);
|
||||
return isFuture(d) && d <= weekFromNow;
|
||||
})
|
||||
.map(toTask);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch upcoming tasks';
|
||||
console.error('Failed to fetch upcoming tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all tasks (incomplete + completed) for unified view
|
||||
* In demo mode, shows static sample tasks
|
||||
*/
|
||||
async fetchAllTasks() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
// Demo mode: load static demo tasks
|
||||
if (!authStore.isAuthenticated) {
|
||||
tasks = generateDemoTasks();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authenticated: fetch from API
|
||||
try {
|
||||
// Fetch all tasks without filter - let frontend handle filtering
|
||||
const allTasks = await tasksApi.getTasks({});
|
||||
tasks = allTasks;
|
||||
await refreshTasks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch all tasks';
|
||||
console.error('Failed to fetch all tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for a specific project
|
||||
*/
|
||||
getTasksByProject(projectId: string | null): Task[] {
|
||||
if (projectId === null) {
|
||||
return tasks.filter((t) => !t.projectId);
|
||||
|
|
@ -155,16 +196,10 @@ export const tasksStore = {
|
|||
return tasks.filter((t) => t.projectId === projectId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks with a specific label
|
||||
*/
|
||||
getTasksByLabel(labelId: string): Task[] {
|
||||
return tasks.filter((t) => t.labels?.some((l) => l.id === labelId));
|
||||
},
|
||||
|
||||
/**
|
||||
* Get overdue tasks
|
||||
*/
|
||||
get overdueTasks(): Task[] {
|
||||
return tasks.filter((t) => {
|
||||
if (!t.dueDate || t.isCompleted) return false;
|
||||
|
|
@ -173,23 +208,16 @@ export const tasksStore = {
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks due today
|
||||
*/
|
||||
get todayTasks(): Task[] {
|
||||
const today = startOfDay(new Date());
|
||||
return tasks.filter((t) => {
|
||||
if (t.isCompleted) return false;
|
||||
// Include tasks without dueDate as "today" tasks (inbox behavior)
|
||||
if (!t.dueDate) return true;
|
||||
const taskDate = startOfDay(new Date(t.dueDate));
|
||||
return taskDate.getTime() === today.getTime();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tasks for next 7 days
|
||||
*/
|
||||
get upcomingTasks(): Task[] {
|
||||
const today = startOfDay(new Date());
|
||||
const weekFromNow = addDays(today, 7);
|
||||
|
|
@ -201,8 +229,7 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Create a new task
|
||||
* Requires authentication - demo mode shows auth gate
|
||||
* Create a new task — writes to IndexedDB instantly.
|
||||
*/
|
||||
async createTask(data: {
|
||||
title: string;
|
||||
|
|
@ -215,15 +242,22 @@ export const tasksStore = {
|
|||
recurrenceRule?: string;
|
||||
}) {
|
||||
error = null;
|
||||
|
||||
// Demo mode: require authentication
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Authenticated: create via API
|
||||
try {
|
||||
const newTask = await tasksApi.createTask(data);
|
||||
const newLocal: LocalTask = {
|
||||
id: crypto.randomUUID(),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
projectId: data.projectId ?? null,
|
||||
priority: data.priority ?? 'medium',
|
||||
isCompleted: false,
|
||||
dueDate: data.dueDate ?? null,
|
||||
order: tasks.length,
|
||||
recurrenceRule: data.recurrenceRule ?? null,
|
||||
subtasks: data.subtasks,
|
||||
};
|
||||
|
||||
const inserted = await taskCollection.insert(newLocal);
|
||||
const newTask = toTask(inserted);
|
||||
tasks = [...tasks, newTask];
|
||||
TodoEvents.taskCreated(!!data.dueDate);
|
||||
return newTask;
|
||||
|
|
@ -235,8 +269,7 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update an existing task
|
||||
* Demo tasks require authentication
|
||||
* Update a task — writes to IndexedDB instantly.
|
||||
*/
|
||||
async updateTask(
|
||||
id: string,
|
||||
|
|
@ -260,17 +293,13 @@ export const tasksStore = {
|
|||
}
|
||||
) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: update via API
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTask(id, data);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
const updated = await taskCollection.update(id, data as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const updatedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update task';
|
||||
console.error('Failed to update task:', e);
|
||||
|
|
@ -279,9 +308,7 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Update task optimistically (for drag and drop)
|
||||
* Updates local state immediately, then syncs with server
|
||||
* Demo tasks require authentication
|
||||
* Optimistic update — for drag-and-drop. Instant local write.
|
||||
*/
|
||||
async updateTaskOptimistic(
|
||||
id: string,
|
||||
|
|
@ -290,56 +317,24 @@ export const tasksStore = {
|
|||
isCompleted?: boolean;
|
||||
}
|
||||
) {
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Optimistic update - immediately update local state
|
||||
const originalTask = tasks.find((t) => t.id === id);
|
||||
if (!originalTask) return;
|
||||
|
||||
// Immediate local state update
|
||||
tasks = tasks.map((t) => (t.id === id ? { ...t, ...data } : t));
|
||||
|
||||
try {
|
||||
// Handle completion state change first
|
||||
if (data.isCompleted !== undefined && data.isCompleted !== originalTask.isCompleted) {
|
||||
if (data.isCompleted) {
|
||||
const updatedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
} else {
|
||||
const updatedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle due date change
|
||||
if (data.dueDate !== undefined) {
|
||||
const updatedTask = await tasksApi.updateTask(id, { dueDate: data.dueDate });
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
}
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
console.error('Failed to update task:', e);
|
||||
tasks = tasks.map((t) => (t.id === id ? originalTask : t));
|
||||
// Persist to IndexedDB
|
||||
const updateData: Partial<LocalTask> = {};
|
||||
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
|
||||
if (data.isCompleted !== undefined) {
|
||||
updateData.isCompleted = data.isCompleted;
|
||||
updateData.completedAt = data.isCompleted ? new Date().toISOString() : null;
|
||||
}
|
||||
|
||||
await taskCollection.update(id, updateData);
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a task
|
||||
* Demo tasks require authentication
|
||||
*/
|
||||
async deleteTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: delete via API
|
||||
try {
|
||||
await tasksApi.deleteTask(id);
|
||||
await taskCollection.delete(id);
|
||||
tasks = tasks.filter((t) => t.id !== id);
|
||||
TodoEvents.taskDeleted();
|
||||
} catch (e) {
|
||||
|
|
@ -349,24 +344,19 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark task as complete
|
||||
* Demo tasks require authentication
|
||||
*/
|
||||
async completeTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: complete via API
|
||||
try {
|
||||
const completedTask = await tasksApi.completeTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
|
||||
TodoEvents.taskCompleted();
|
||||
return completedTask;
|
||||
const updated = await taskCollection.update(id, {
|
||||
isCompleted: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
} as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const completedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? completedTask : t));
|
||||
TodoEvents.taskCompleted();
|
||||
return completedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to complete task';
|
||||
console.error('Failed to complete task:', e);
|
||||
|
|
@ -374,24 +364,19 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mark task as incomplete
|
||||
* Demo tasks require authentication
|
||||
*/
|
||||
async uncompleteTask(id: string) {
|
||||
error = null;
|
||||
|
||||
// Demo task: require authentication
|
||||
if (isDemoTask(id)) {
|
||||
return { error: 'auth_required' as const };
|
||||
}
|
||||
|
||||
// Cloud task: uncomplete via API
|
||||
try {
|
||||
const uncompletedTask = await tasksApi.uncompleteTask(id);
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
|
||||
TodoEvents.taskUncompleted();
|
||||
return uncompletedTask;
|
||||
const updated = await taskCollection.update(id, {
|
||||
isCompleted: false,
|
||||
completedAt: null,
|
||||
} as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const uncompletedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? uncompletedTask : t));
|
||||
TodoEvents.taskUncompleted();
|
||||
return uncompletedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to uncomplete task';
|
||||
console.error('Failed to uncomplete task:', e);
|
||||
|
|
@ -399,15 +384,15 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Move task to a different project
|
||||
*/
|
||||
async moveTask(id: string, projectId: string | null) {
|
||||
error = null;
|
||||
try {
|
||||
const movedTask = await tasksApi.moveTask(id, projectId);
|
||||
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
|
||||
return movedTask;
|
||||
const updated = await taskCollection.update(id, { projectId } as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const movedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? movedTask : t));
|
||||
return movedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to move task';
|
||||
console.error('Failed to move task:', e);
|
||||
|
|
@ -415,15 +400,19 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update task labels
|
||||
*/
|
||||
async updateLabels(id: string, labelIds: string[]) {
|
||||
// Labels are stored via the central tag system, not locally.
|
||||
// For now, update the task metadata to track label associations.
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateTaskLabels(id, labelIds);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
const updated = await taskCollection.update(id, {
|
||||
metadata: { labelIds },
|
||||
} as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const updatedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update labels';
|
||||
console.error('Failed to update labels:', e);
|
||||
|
|
@ -431,15 +420,15 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update subtasks
|
||||
*/
|
||||
async updateSubtasks(id: string, subtasks: Subtask[]) {
|
||||
error = null;
|
||||
try {
|
||||
const updatedTask = await tasksApi.updateSubtasks(id, subtasks);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
const updated = await taskCollection.update(id, { subtasks } as Partial<LocalTask>);
|
||||
if (updated) {
|
||||
const updatedTask = toTask(updated);
|
||||
tasks = tasks.map((t) => (t.id === id ? updatedTask : t));
|
||||
return updatedTask;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update subtasks';
|
||||
console.error('Failed to update subtasks:', e);
|
||||
|
|
@ -447,30 +436,25 @@ export const tasksStore = {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder tasks
|
||||
*/
|
||||
async reorderTasks(taskIds: string[]) {
|
||||
error = null;
|
||||
const previousTasks = [...tasks];
|
||||
try {
|
||||
// Optimistic update - set new order values
|
||||
// Update order in local state immediately
|
||||
tasks = tasks.map((t) => {
|
||||
const newOrder = taskIds.indexOf(t.id);
|
||||
return newOrder !== -1 ? { ...t, order: newOrder } : t;
|
||||
});
|
||||
await tasksApi.reorderTasks(taskIds);
|
||||
|
||||
// Persist each order change to IndexedDB
|
||||
for (let i = 0; i < taskIds.length; i++) {
|
||||
await taskCollection.update(taskIds[i], { order: i } as Partial<LocalTask>);
|
||||
}
|
||||
} catch (e) {
|
||||
// Rollback on error
|
||||
tasks = previousTasks;
|
||||
error = e instanceof Error ? e.message : 'Failed to reorder tasks';
|
||||
console.error('Failed to reorder tasks:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all state (for logout)
|
||||
*/
|
||||
clear() {
|
||||
tasks = [];
|
||||
loading = false;
|
||||
|
|
@ -478,9 +462,9 @@ export const tasksStore = {
|
|||
},
|
||||
|
||||
/**
|
||||
* Check if a task is a demo task (static sample data)
|
||||
* No longer relevant — all tasks are local and editable.
|
||||
*/
|
||||
isDemoTask(taskId: string) {
|
||||
return isDemoTask(taskId);
|
||||
isDemoTask(_taskId: string) {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -43,8 +43,20 @@
|
|||
import { parseTaskInput, resolveTaskIds, formatParsedTaskPreview } from '$lib/utils/task-parser';
|
||||
import { todoOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { TodoEvents } from '@manacore/shared-utils/analytics';
|
||||
import { todoStore } from '$lib/data/local-store';
|
||||
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
|
||||
|
||||
// Guest welcome modal state
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('todo')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('todo');
|
||||
|
|
@ -167,8 +179,8 @@
|
|||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
// User email for user dropdown
|
||||
let userEmail = $derived(authStore.user?.email || 'Menü');
|
||||
// User email for user dropdown — empty string for guests so PillNav shows login button
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
// Toggle FilterStrip visibility
|
||||
function handleFilterToggle() {
|
||||
|
|
@ -290,15 +302,30 @@
|
|||
}
|
||||
|
||||
async function handleAuthReady() {
|
||||
// Initialize local-first database (opens IndexedDB, seeds guest data)
|
||||
await todoStore.initialize();
|
||||
|
||||
// If authenticated, start syncing to server
|
||||
if (authStore.isAuthenticated) {
|
||||
todoStore.startSync(() => authStore.getValidToken());
|
||||
}
|
||||
|
||||
// Initialize split-panel from URL/localStorage
|
||||
splitPanel.initialize();
|
||||
|
||||
// Initialize todo settings
|
||||
todoSettings.initialize();
|
||||
|
||||
// Load projects, labels, and user settings
|
||||
// Show guest welcome modal on first visit
|
||||
initGuestWelcome();
|
||||
|
||||
// Load projects from IndexedDB (guest seed or synced data)
|
||||
await projectsStore.fetchProjects();
|
||||
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
|
||||
|
||||
// Labels and user settings need auth (central mana-core-auth service)
|
||||
if (authStore.isAuthenticated) {
|
||||
await Promise.all([labelsStore.fetchLabels(), userSettings.load()]);
|
||||
}
|
||||
|
||||
// Redirect to start page if on root and a custom start page is set
|
||||
const currentPath = window.location.pathname;
|
||||
|
|
@ -320,7 +347,7 @@
|
|||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate {authStore} {goto} onReady={handleAuthReady}>
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<SplitPaneContainer>
|
||||
<div class="layout-container">
|
||||
<a
|
||||
|
|
@ -351,7 +378,7 @@
|
|||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={true}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#8b5cf6"
|
||||
|
|
@ -478,6 +505,10 @@
|
|||
class="main-content bg-background"
|
||||
class:immersive={todoSettings.immersiveModeEnabled}
|
||||
>
|
||||
<!-- Sync status indicator (top right) -->
|
||||
<div class="sync-indicator-wrapper">
|
||||
<SyncIndicator />
|
||||
</div>
|
||||
<div
|
||||
class="content-wrapper"
|
||||
class:full-width={$page.url.pathname === '/kanban'}
|
||||
|
|
@ -494,7 +525,19 @@
|
|||
<MiniOnboardingModal store={todoOnboarding} appName="Todo" appEmoji="✅" />
|
||||
{/if}
|
||||
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
<!-- Guest Welcome Modal -->
|
||||
<GuestWelcomeModal
|
||||
appId="todo"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
|
|
@ -524,6 +567,13 @@
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.sync-indicator-wrapper {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.75rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin-left: auto;
|
||||
|
|
|
|||
|
|
@ -125,31 +125,25 @@
|
|||
const task = tasksStore.tasks.find((t) => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
let result;
|
||||
if (targetDate === 'completed') {
|
||||
// Mark task as completed (optimistic)
|
||||
if (!task.isCompleted) {
|
||||
result = await tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
|
||||
await tasksStore.updateTaskOptimistic(taskId, { isCompleted: true });
|
||||
}
|
||||
} else if (targetDate === 'overdue') {
|
||||
// Set to yesterday (optimistic)
|
||||
const yesterday = subDays(startOfDay(new Date()), 1);
|
||||
result = await tasksStore.updateTaskOptimistic(taskId, {
|
||||
await tasksStore.updateTaskOptimistic(taskId, {
|
||||
dueDate: yesterday.toISOString(),
|
||||
isCompleted: task.isCompleted ? false : undefined,
|
||||
});
|
||||
} else {
|
||||
// Set to specific date (optimistic)
|
||||
result = await tasksStore.updateTaskOptimistic(taskId, {
|
||||
await tasksStore.updateTaskOptimistic(taskId, {
|
||||
dueDate: targetDate.toISOString(),
|
||||
isCompleted: task.isCompleted ? false : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Show auth gate if authentication required (demo mode)
|
||||
if (result && 'error' in result && result.error === 'auth_required') {
|
||||
window.dispatchEvent(new CustomEvent('show-auth-gate'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
28
packages/local-store/package.json
Normal file
28
packages/local-store/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "@manacore/local-store",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local-first data layer with Dexie.js, reactive Svelte 5 queries, and sync engine",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./svelte": "./src/svelte/index.ts",
|
||||
"./sync": "./src/sync/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"dexie": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"svelte": "^5.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
}
|
||||
304
packages/local-store/src/collection.ts
Normal file
304
packages/local-store/src/collection.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
/**
|
||||
* LocalCollection — typed, reactive collection backed by IndexedDB.
|
||||
*
|
||||
* Wraps a Dexie table with:
|
||||
* - Change tracking (writes are recorded as PendingChanges for sync)
|
||||
* - Soft-delete (deletedAt instead of hard delete)
|
||||
* - Timestamp management (updatedAt per record, field_timestamps for LWW)
|
||||
*
|
||||
* All writes are synchronous from the caller's perspective — they return
|
||||
* immediately after queuing the IndexedDB write. No network call is needed.
|
||||
*/
|
||||
|
||||
import type Dexie from 'dexie';
|
||||
import type { Table } from 'dexie';
|
||||
import type { LocalDatabase } from './database.js';
|
||||
import type { BaseRecord, ChangeOp, FieldChange, PendingChange, QueryOptions } from './types.js';
|
||||
|
||||
export class LocalCollection<T extends BaseRecord> {
|
||||
readonly name: string;
|
||||
private readonly _db: LocalDatabase;
|
||||
private readonly _table: Table<T, string>;
|
||||
|
||||
/** Called after every write to notify the sync engine. Set by LocalStore. */
|
||||
onWrite: (() => void) | null = null;
|
||||
|
||||
constructor(db: LocalDatabase, name: string) {
|
||||
this.name = name;
|
||||
this._db = db;
|
||||
this._table = db.table(name);
|
||||
}
|
||||
|
||||
/** Access the underlying Dexie table for advanced queries. */
|
||||
get table(): Table<T, string> {
|
||||
return this._table;
|
||||
}
|
||||
|
||||
/** Access the parent database. */
|
||||
get db(): LocalDatabase {
|
||||
return this._db;
|
||||
}
|
||||
|
||||
// ─── Reads ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get a single record by ID. Returns undefined if not found or soft-deleted.
|
||||
*/
|
||||
async get(id: string): Promise<T | undefined> {
|
||||
const record = await this._table.get(id);
|
||||
if (!record || record.deletedAt) return undefined;
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all non-deleted records, optionally filtered and sorted.
|
||||
*/
|
||||
async getAll(filter?: Partial<T>, options?: QueryOptions<T>): Promise<T[]> {
|
||||
let collection: Dexie.Collection<T, string>;
|
||||
|
||||
if (filter && Object.keys(filter).length > 0) {
|
||||
// Use the first filter key as an indexed where clause
|
||||
const entries = Object.entries(filter);
|
||||
const [firstKey, firstValue] = entries[0];
|
||||
collection = this._table.where(firstKey).equals(firstValue as string);
|
||||
|
||||
// Apply remaining filters as JS filters
|
||||
for (let i = 1; i < entries.length; i++) {
|
||||
const [key, value] = entries[i];
|
||||
collection = collection.and((item) => (item as Record<string, unknown>)[key] === value);
|
||||
}
|
||||
} else {
|
||||
collection = this._table.toCollection();
|
||||
}
|
||||
|
||||
// Exclude soft-deleted
|
||||
collection = collection.filter((item) => !item.deletedAt);
|
||||
|
||||
let results: T[];
|
||||
|
||||
if (options?.sortBy) {
|
||||
// Dexie doesn't support sorting on filtered collections directly,
|
||||
// so we get all matching, then sort in JS
|
||||
results = await collection.toArray();
|
||||
const key = options.sortBy as string;
|
||||
const dir = options.sortDirection === 'desc' ? -1 : 1;
|
||||
results.sort((a, b) => {
|
||||
const aVal = (a as Record<string, unknown>)[key];
|
||||
const bVal = (b as Record<string, unknown>)[key];
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return dir;
|
||||
if (bVal == null) return -dir;
|
||||
return aVal < bVal ? -dir : aVal > bVal ? dir : 0;
|
||||
});
|
||||
} else {
|
||||
results = await collection.toArray();
|
||||
}
|
||||
|
||||
if (options?.offset) {
|
||||
results = results.slice(options.offset);
|
||||
}
|
||||
if (options?.limit) {
|
||||
results = results.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count non-deleted records matching an optional filter.
|
||||
*/
|
||||
async count(filter?: Partial<T>): Promise<number> {
|
||||
if (!filter) {
|
||||
return this._table.filter((item) => !item.deletedAt).count();
|
||||
}
|
||||
const results = await this.getAll(filter);
|
||||
return results.length;
|
||||
}
|
||||
|
||||
// ─── Writes ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert a new record. Generates timestamps and tracks the change.
|
||||
*/
|
||||
async insert(record: T): Promise<T> {
|
||||
const now = new Date().toISOString();
|
||||
const withMeta: T = {
|
||||
...record,
|
||||
createdAt: record.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
await this._db.transaction('rw', [this._table, this._db._pendingChanges], async () => {
|
||||
await this._table.put(withMeta);
|
||||
await this._trackChange(record.id, 'insert', undefined, withMeta);
|
||||
});
|
||||
|
||||
this.onWrite?.();
|
||||
return withMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert multiple records in a single transaction.
|
||||
*/
|
||||
async bulkInsert(records: T[]): Promise<T[]> {
|
||||
const now = new Date().toISOString();
|
||||
const withMeta = records.map((r) => ({
|
||||
...r,
|
||||
createdAt: r.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
}));
|
||||
|
||||
await this._db.transaction('rw', [this._table, this._db._pendingChanges], async () => {
|
||||
await this._table.bulkPut(withMeta);
|
||||
for (const record of withMeta) {
|
||||
await this._trackChange(record.id, 'insert', undefined, record);
|
||||
}
|
||||
});
|
||||
|
||||
this.onWrite?.();
|
||||
return withMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific fields of a record. Only changed fields are tracked.
|
||||
*/
|
||||
async update(id: string, changes: Partial<T>): Promise<T | undefined> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Remove meta fields from changes — we manage those
|
||||
const {
|
||||
id: _id,
|
||||
createdAt: _c,
|
||||
updatedAt: _u,
|
||||
deletedAt: _d,
|
||||
...fieldChanges
|
||||
} = changes as Record<string, unknown>;
|
||||
|
||||
const fields: Record<string, FieldChange> = {};
|
||||
for (const [key, value] of Object.entries(fieldChanges)) {
|
||||
fields[key] = { value, updatedAt: now };
|
||||
}
|
||||
|
||||
let updated: T | undefined;
|
||||
|
||||
await this._db.transaction('rw', [this._table, this._db._pendingChanges], async () => {
|
||||
const existing = await this._table.get(id);
|
||||
if (!existing || existing.deletedAt) return;
|
||||
|
||||
updated = { ...existing, ...fieldChanges, updatedAt: now } as T;
|
||||
await this._table.put(updated);
|
||||
await this._trackChange(id, 'update', fields);
|
||||
});
|
||||
|
||||
this.onWrite?.();
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a record. The record stays in IndexedDB but is excluded from queries.
|
||||
*/
|
||||
async delete(id: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await this._db.transaction('rw', [this._table, this._db._pendingChanges], async () => {
|
||||
const existing = await this._table.get(id);
|
||||
if (!existing || existing.deletedAt) return;
|
||||
|
||||
const deleted = { ...existing, deletedAt: now, updatedAt: now };
|
||||
await this._table.put(deleted);
|
||||
await this._trackChange(id, 'delete', undefined, undefined, now);
|
||||
});
|
||||
|
||||
this.onWrite?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete a record. Used for purging old soft-deleted records.
|
||||
*/
|
||||
async purge(id: string): Promise<void> {
|
||||
await this._table.delete(id);
|
||||
}
|
||||
|
||||
// ─── Sync Helpers ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Apply a server change to the local collection (used by SyncEngine).
|
||||
* Does NOT create a PendingChange (to avoid re-syncing back to server).
|
||||
*/
|
||||
async applyServerChange(change: {
|
||||
id: string;
|
||||
op: ChangeOp;
|
||||
data?: Record<string, unknown>;
|
||||
fields?: Record<string, FieldChange>;
|
||||
deletedAt?: string;
|
||||
}): Promise<void> {
|
||||
switch (change.op) {
|
||||
case 'insert': {
|
||||
if (change.data) {
|
||||
await this._table.put(change.data as T);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update': {
|
||||
if (change.fields) {
|
||||
const existing = await this._table.get(change.id);
|
||||
if (!existing) {
|
||||
// Record doesn't exist locally — treat as insert if we have full data
|
||||
if (change.data) {
|
||||
await this._table.put(change.data as T);
|
||||
}
|
||||
break;
|
||||
}
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const [key, fc] of Object.entries(change.fields)) {
|
||||
updates[key] = fc.value;
|
||||
}
|
||||
updates['updatedAt'] = Object.values(change.fields).reduce(
|
||||
(latest, fc) => (fc.updatedAt > latest ? fc.updatedAt : latest),
|
||||
existing.updatedAt ?? ''
|
||||
);
|
||||
await this._table.put({ ...existing, ...updates } as T);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
const now = change.deletedAt ?? new Date().toISOString();
|
||||
const toDelete = await this._table.get(change.id);
|
||||
if (toDelete) {
|
||||
await this._table.put({ ...toDelete, deletedAt: now, updatedAt: now });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all records modified since a timestamp (for building changesets).
|
||||
*/
|
||||
async getModifiedSince(since: string): Promise<T[]> {
|
||||
return this._table.where('updatedAt').above(since).toArray();
|
||||
}
|
||||
|
||||
// ─── Internal ───────────────────────────────────────────────
|
||||
|
||||
private async _trackChange(
|
||||
recordId: string,
|
||||
op: ChangeOp,
|
||||
fields?: Record<string, FieldChange>,
|
||||
data?: T,
|
||||
deletedAt?: string
|
||||
): Promise<void> {
|
||||
const pending: PendingChange = {
|
||||
collection: this.name,
|
||||
recordId,
|
||||
op,
|
||||
fields,
|
||||
data: data as unknown as Record<string, unknown>,
|
||||
deletedAt,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
await this._db._pendingChanges.add(pending);
|
||||
}
|
||||
}
|
||||
153
packages/local-store/src/database.ts
Normal file
153
packages/local-store/src/database.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* Local database management via Dexie.js.
|
||||
*
|
||||
* Each app gets its own IndexedDB database with:
|
||||
* - App-specific collections (tables)
|
||||
* - A shared _pendingChanges table for sync tracking
|
||||
* - A shared _syncMeta table for sync cursors
|
||||
*/
|
||||
|
||||
import Dexie from 'dexie';
|
||||
import type { BaseRecord, CollectionConfig, PendingChange, SyncMeta } from './types.js';
|
||||
|
||||
/**
|
||||
* Creates a Dexie database for an app with the given collections.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const db = createDatabase('todo', [
|
||||
* { name: 'tasks', indexes: ['projectId', 'dueDate', '[isCompleted+dueDate]'] },
|
||||
* { name: 'projects', indexes: ['order'] },
|
||||
* { name: 'labels', indexes: [] },
|
||||
* ]);
|
||||
* ```
|
||||
*/
|
||||
export function createDatabase(
|
||||
appId: string,
|
||||
collections: CollectionConfig<BaseRecord>[]
|
||||
): LocalDatabase {
|
||||
const db = new LocalDatabase(appId, collections);
|
||||
return db;
|
||||
}
|
||||
|
||||
export class LocalDatabase extends Dexie {
|
||||
/** Pending changes waiting to be synced to the server. */
|
||||
_pendingChanges!: Dexie.Table<PendingChange, number>;
|
||||
|
||||
/** Sync metadata per collection (last sync timestamp, etc.). */
|
||||
_syncMeta!: Dexie.Table<SyncMeta, string>;
|
||||
|
||||
private readonly _appId: string;
|
||||
private readonly _collections: CollectionConfig<BaseRecord>[];
|
||||
private _seeded = false;
|
||||
|
||||
constructor(appId: string, collections: CollectionConfig<BaseRecord>[]) {
|
||||
super(`manacore-${appId}`);
|
||||
this._appId = appId;
|
||||
this._collections = collections;
|
||||
|
||||
// Build Dexie schema from collection configs
|
||||
const schema: Record<string, string> = {
|
||||
// Internal tables
|
||||
_pendingChanges: '++id, collection, recordId, createdAt',
|
||||
_syncMeta: 'collection',
|
||||
};
|
||||
|
||||
for (const col of collections) {
|
||||
// Primary key is always 'id', plus any additional indexes
|
||||
const indexes = ['id', ...(col.indexes ?? [])];
|
||||
// Add updatedAt and deletedAt for sync queries
|
||||
indexes.push('updatedAt', 'deletedAt');
|
||||
schema[col.name] = indexes.join(', ');
|
||||
}
|
||||
|
||||
this.version(1).stores(schema);
|
||||
}
|
||||
|
||||
get appId(): string {
|
||||
return this._appId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load guest seed data into empty collections.
|
||||
* Only runs once per database lifetime.
|
||||
*/
|
||||
async seedGuestData(): Promise<void> {
|
||||
if (this._seeded) return;
|
||||
this._seeded = true;
|
||||
|
||||
for (const col of this._collections) {
|
||||
if (!col.guestSeed || col.guestSeed.length === 0) continue;
|
||||
|
||||
const table = this.table(col.name);
|
||||
const count = await table.count();
|
||||
|
||||
if (count === 0) {
|
||||
const now = new Date().toISOString();
|
||||
const records = col.guestSeed.map((record) => ({
|
||||
...record,
|
||||
createdAt: record.createdAt ?? now,
|
||||
updatedAt: record.updatedAt ?? now,
|
||||
deletedAt: null,
|
||||
}));
|
||||
await table.bulkPut(records);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sync cursor (last synced timestamp) for a collection.
|
||||
*/
|
||||
async getSyncCursor(collection: string): Promise<string> {
|
||||
const meta = await this._syncMeta.get(collection);
|
||||
// Default: epoch — pull everything on first sync
|
||||
return meta?.lastSyncedAt ?? '1970-01-01T00:00:00.000Z';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the sync cursor after a successful sync.
|
||||
*/
|
||||
async setSyncCursor(collection: string, syncedUntil: string): Promise<void> {
|
||||
const pendingCount = await this._pendingChanges.where('collection').equals(collection).count();
|
||||
|
||||
await this._syncMeta.put({
|
||||
collection,
|
||||
lastSyncedAt: syncedUntil,
|
||||
pendingCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of pending (un-synced) changes across all collections.
|
||||
*/
|
||||
async getPendingCount(): Promise<number> {
|
||||
return this._pendingChanges.count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes for a specific collection, ordered by creation time.
|
||||
*/
|
||||
async getPendingChanges(collection: string): Promise<PendingChange[]> {
|
||||
return this._pendingChanges.where('collection').equals(collection).sortBy('createdAt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pending changes that have been successfully synced.
|
||||
*/
|
||||
async clearPendingChanges(ids: number[]): Promise<void> {
|
||||
await this._pendingChanges.bulkDelete(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe all data and re-seed. Used for recovery from corruption.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
for (const col of this._collections) {
|
||||
await this.table(col.name).clear();
|
||||
}
|
||||
await this._pendingChanges.clear();
|
||||
await this._syncMeta.clear();
|
||||
this._seeded = false;
|
||||
await this.seedGuestData();
|
||||
}
|
||||
}
|
||||
28
packages/local-store/src/index.ts
Normal file
28
packages/local-store/src/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Core
|
||||
export { createLocalStore, LocalStore } from './store.js';
|
||||
export type { LocalStoreConfig } from './store.js';
|
||||
|
||||
// Database
|
||||
export { createDatabase, LocalDatabase } from './database.js';
|
||||
|
||||
// Collection
|
||||
export { LocalCollection } from './collection.js';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
BaseRecord,
|
||||
Change,
|
||||
ChangeOp,
|
||||
Changeset,
|
||||
CollectionConfig,
|
||||
ConflictStrategy,
|
||||
FieldChange,
|
||||
PendingChange,
|
||||
QueryOptions,
|
||||
SortDirection,
|
||||
SyncConfig,
|
||||
SyncConflict,
|
||||
SyncMeta,
|
||||
SyncResponse,
|
||||
SyncStatus,
|
||||
} from './types.js';
|
||||
220
packages/local-store/src/store.ts
Normal file
220
packages/local-store/src/store.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
/**
|
||||
* LocalStore — the main entry point for apps.
|
||||
*
|
||||
* Creates a complete local-first data layer for an app:
|
||||
* - IndexedDB database (via Dexie.js)
|
||||
* - Typed collections with change tracking
|
||||
* - Sync engine (started/stopped based on auth state)
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createLocalStore } from '@manacore/local-store';
|
||||
*
|
||||
* const store = createLocalStore({
|
||||
* appId: 'todo',
|
||||
* collections: [
|
||||
* { name: 'tasks', indexes: ['projectId', 'dueDate'] },
|
||||
* { name: 'projects', indexes: ['order'] },
|
||||
* { name: 'labels' },
|
||||
* ],
|
||||
* sync: {
|
||||
* serverUrl: 'http://localhost:3050',
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Get typed collections
|
||||
* const tasks = store.collection<Task>('tasks');
|
||||
* const projects = store.collection<Project>('projects');
|
||||
*
|
||||
* // Guest mode: just use collections, no sync
|
||||
* await tasks.insert({ id: crypto.randomUUID(), title: 'Hello', ... });
|
||||
*
|
||||
* // After login: start sync
|
||||
* store.startSync(() => authStore.getValidToken());
|
||||
*
|
||||
* // On logout: stop sync
|
||||
* store.stopSync();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { LocalCollection } from './collection.js';
|
||||
import { createDatabase, type LocalDatabase } from './database.js';
|
||||
import { SyncEngine } from './sync/engine.js';
|
||||
import type { BaseRecord, CollectionConfig, SyncStatus } from './types.js';
|
||||
|
||||
/** Client ID persisted in localStorage for device identification. */
|
||||
function getOrCreateClientId(): string {
|
||||
const key = 'manacore-client-id';
|
||||
if (typeof localStorage === 'undefined') return 'ssr-' + Math.random().toString(36).slice(2);
|
||||
|
||||
let clientId = localStorage.getItem(key);
|
||||
if (!clientId) {
|
||||
clientId = crypto.randomUUID();
|
||||
localStorage.setItem(key, clientId);
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
export interface LocalStoreConfig {
|
||||
/** App identifier (e.g. 'todo', 'contacts'). Used as IndexedDB database name. */
|
||||
appId: string;
|
||||
/** Collection (table) definitions. */
|
||||
collections: CollectionConfig<BaseRecord>[];
|
||||
/** Sync server configuration. If omitted, sync is disabled (pure offline). */
|
||||
sync?: {
|
||||
/** Sync server base URL (e.g. 'http://localhost:3050'). */
|
||||
serverUrl: string;
|
||||
/** Debounce before pushing changes. Default: 1000ms. */
|
||||
pushDebounce?: number;
|
||||
/** Pull interval. Default: 30000ms. */
|
||||
pullInterval?: number;
|
||||
/** WebSocket URL (defaults to serverUrl with ws:// protocol). */
|
||||
wsUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalStore {
|
||||
readonly db: LocalDatabase;
|
||||
readonly appId: string;
|
||||
|
||||
private readonly _collections: Map<string, LocalCollection<BaseRecord>> = new Map();
|
||||
private _syncEngine: SyncEngine | null = null;
|
||||
private readonly _syncConfig: LocalStoreConfig['sync'];
|
||||
|
||||
constructor(config: LocalStoreConfig) {
|
||||
this.appId = config.appId;
|
||||
this._syncConfig = config.sync;
|
||||
|
||||
// Create database
|
||||
this.db = createDatabase(config.appId, config.collections);
|
||||
|
||||
// Create collections with write notifications
|
||||
for (const colConfig of config.collections) {
|
||||
const collection = new LocalCollection(this.db, colConfig.name);
|
||||
collection.onWrite = () => this.schedulePush();
|
||||
this._collections.set(colConfig.name, collection);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the store: open database and seed guest data.
|
||||
* Call this once on app startup.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
await this.db.open();
|
||||
await this.db.seedGuestData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a typed collection by name.
|
||||
*/
|
||||
collection<T extends BaseRecord>(name: string): LocalCollection<T> {
|
||||
const col = this._collections.get(name);
|
||||
if (!col) {
|
||||
throw new Error(`[LocalStore] Collection "${name}" not found in app "${this.appId}"`);
|
||||
}
|
||||
return col as unknown as LocalCollection<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start syncing to the server. Call after user authenticates.
|
||||
*
|
||||
* @param getAuthToken — function that returns a valid JWT (or null).
|
||||
*/
|
||||
startSync(getAuthToken: () => Promise<string | null>): void {
|
||||
if (!this._syncConfig) {
|
||||
console.warn('[LocalStore] Sync not configured. Skipping startSync().');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._syncEngine) {
|
||||
// Already running
|
||||
return;
|
||||
}
|
||||
|
||||
this._syncEngine = new SyncEngine(this.db, {
|
||||
serverUrl: this._syncConfig.serverUrl,
|
||||
appId: this.appId,
|
||||
clientId: getOrCreateClientId(),
|
||||
getAuthToken,
|
||||
pushDebounce: this._syncConfig.pushDebounce,
|
||||
pullInterval: this._syncConfig.pullInterval,
|
||||
wsUrl: this._syncConfig.wsUrl,
|
||||
});
|
||||
|
||||
// Register all collections
|
||||
for (const col of this._collections.values()) {
|
||||
this._syncEngine.registerCollection(col);
|
||||
}
|
||||
|
||||
this._syncEngine.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop syncing. Call on sign-out.
|
||||
*/
|
||||
stopSync(): void {
|
||||
this._syncEngine?.stop();
|
||||
this._syncEngine = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the sync engine (or null if not syncing).
|
||||
* Used by useSyncStatus() Svelte hook.
|
||||
*/
|
||||
get syncEngine(): SyncEngine | null {
|
||||
return this._syncEngine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current sync status.
|
||||
*/
|
||||
get syncStatus(): SyncStatus {
|
||||
return this._syncEngine?.status ?? 'idle';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sync engine is running.
|
||||
*/
|
||||
get isSyncing(): boolean {
|
||||
return this._syncEngine?.enabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a push of local changes to the server.
|
||||
* Called automatically by collections on write, but can be triggered manually.
|
||||
*/
|
||||
schedulePush(): void {
|
||||
this._syncEngine?.schedulePush();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate full sync.
|
||||
*/
|
||||
async sync(): Promise<void> {
|
||||
await this._syncEngine?.sync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipe all local data and re-seed. Use for recovery or sign-out cleanup.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
this.stopSync();
|
||||
await this.db.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
close(): void {
|
||||
this.stopSync();
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a LocalStore instance.
|
||||
*/
|
||||
export function createLocalStore(config: LocalStoreConfig): LocalStore {
|
||||
return new LocalStore(config);
|
||||
}
|
||||
2
packages/local-store/src/svelte/index.ts
Normal file
2
packages/local-store/src/svelte/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useLiveQuery, useLiveQueryWithDefault } from './reactive.svelte.js';
|
||||
export { useSyncStatus } from './useSyncStatus.svelte.js';
|
||||
113
packages/local-store/src/svelte/reactive.svelte.ts
Normal file
113
packages/local-store/src/svelte/reactive.svelte.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Svelte 5 reactive bindings for LocalCollection.
|
||||
*
|
||||
* Uses Dexie's liveQuery() to create reactive queries that automatically
|
||||
* update when the underlying IndexedDB data changes — whether from local
|
||||
* writes, sync engine updates, or other browser tabs.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useLiveQuery } from '@manacore/local-store/svelte';
|
||||
*
|
||||
* const tasks = useLiveQuery(() => taskCollection.getAll({ isCompleted: false }));
|
||||
* </script>
|
||||
*
|
||||
* {#each tasks.value ?? [] as task}
|
||||
* <div>{task.title}</div>
|
||||
* {/each}
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { liveQuery, type Observable } from 'dexie';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
interface LiveQueryResult<T> {
|
||||
/** The current query result. Undefined while loading. */
|
||||
readonly value: T | undefined;
|
||||
/** Whether the query is still loading its first result. */
|
||||
readonly loading: boolean;
|
||||
/** Error from the last query execution, if any. */
|
||||
readonly error: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive query that subscribes to IndexedDB changes.
|
||||
*
|
||||
* The querier function is re-executed whenever the underlying Dexie tables
|
||||
* it reads from are modified. This works across tabs and from sync updates.
|
||||
*
|
||||
* Must be called during component initialization (like onMount).
|
||||
*/
|
||||
export function useLiveQuery<T>(querier: () => T | Promise<T>): LiveQueryResult<T> {
|
||||
let value = $state<T | undefined>(undefined);
|
||||
let loading = $state(true);
|
||||
let error = $state<unknown>(undefined);
|
||||
|
||||
const observable: Observable<T> = liveQuery(querier);
|
||||
|
||||
const subscription = observable.subscribe({
|
||||
next: (result) => {
|
||||
value = result;
|
||||
loading = false;
|
||||
error = undefined;
|
||||
},
|
||||
error: (err) => {
|
||||
error = err;
|
||||
loading = false;
|
||||
},
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a reactive query with an initial value (no undefined/loading state).
|
||||
* Useful when you have sensible defaults.
|
||||
*/
|
||||
export function useLiveQueryWithDefault<T>(
|
||||
querier: () => T | Promise<T>,
|
||||
defaultValue: T
|
||||
): { readonly value: T; readonly error: unknown } {
|
||||
let value = $state<T>(defaultValue);
|
||||
let error = $state<unknown>(undefined);
|
||||
|
||||
const observable: Observable<T> = liveQuery(querier);
|
||||
|
||||
const subscription = observable.subscribe({
|
||||
next: (result) => {
|
||||
value = result;
|
||||
error = undefined;
|
||||
},
|
||||
error: (err) => {
|
||||
error = err;
|
||||
},
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
return {
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
};
|
||||
}
|
||||
71
packages/local-store/src/svelte/useSyncStatus.svelte.ts
Normal file
71
packages/local-store/src/svelte/useSyncStatus.svelte.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/**
|
||||
* Reactive sync status for Svelte 5 components.
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { useSyncStatus } from '@manacore/local-store/svelte';
|
||||
* const sync = useSyncStatus(syncEngine);
|
||||
* </script>
|
||||
*
|
||||
* {#if sync.status === 'offline'}
|
||||
* <span>Offline</span>
|
||||
* {:else if sync.status === 'syncing'}
|
||||
* <span>Syncing...</span>
|
||||
* {:else if sync.pendingCount > 0}
|
||||
* <span>{sync.pendingCount} pending</span>
|
||||
* {/if}
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { SyncEngine } from '../sync/engine.js';
|
||||
import type { SyncStatus } from '../types.js';
|
||||
|
||||
interface SyncStatusState {
|
||||
readonly status: SyncStatus;
|
||||
readonly pendingCount: number;
|
||||
readonly isOnline: boolean;
|
||||
readonly isSyncing: boolean;
|
||||
}
|
||||
|
||||
export function useSyncStatus(engine: SyncEngine): SyncStatusState {
|
||||
let status = $state<SyncStatus>(engine.status);
|
||||
let pendingCount = $state(0);
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let pendingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
unsubscribe = engine.onStatusChange((newStatus) => {
|
||||
status = newStatus;
|
||||
});
|
||||
|
||||
// Poll pending count every 2s (cheap IndexedDB query)
|
||||
const updatePending = async () => {
|
||||
pendingCount = await engine.getPendingCount();
|
||||
};
|
||||
updatePending();
|
||||
pendingInterval = setInterval(updatePending, 2000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
if (pendingInterval) clearInterval(pendingInterval);
|
||||
});
|
||||
|
||||
return {
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
get pendingCount() {
|
||||
return pendingCount;
|
||||
},
|
||||
get isOnline() {
|
||||
return status !== 'offline';
|
||||
},
|
||||
get isSyncing() {
|
||||
return status === 'syncing';
|
||||
},
|
||||
};
|
||||
}
|
||||
410
packages/local-store/src/sync/engine.ts
Normal file
410
packages/local-store/src/sync/engine.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
/**
|
||||
* SyncEngine — orchestrates bidirectional sync between IndexedDB and the server.
|
||||
*
|
||||
* Push: Collects PendingChanges → sends as Changeset → clears on success
|
||||
* Pull: Fetches server delta since last cursor → applies to local collections
|
||||
* WebSocket: Listens for push notifications → triggers immediate pull
|
||||
*
|
||||
* The engine is designed to be resilient:
|
||||
* - Offline: queues changes, retries when online
|
||||
* - Partial failure: individual collection syncs are independent
|
||||
* - Duplicate safety: pending changes are only cleared after server confirms
|
||||
*/
|
||||
|
||||
import type { LocalDatabase } from '../database.js';
|
||||
import type { LocalCollection } from '../collection.js';
|
||||
import type {
|
||||
BaseRecord,
|
||||
Change,
|
||||
Changeset,
|
||||
SyncConfig,
|
||||
SyncResponse,
|
||||
SyncStatus,
|
||||
} from '../types.js';
|
||||
|
||||
export class SyncEngine {
|
||||
private readonly _db: LocalDatabase;
|
||||
private readonly _config: SyncConfig;
|
||||
private readonly _collections: Map<string, LocalCollection<BaseRecord>> = new Map();
|
||||
|
||||
private _status: SyncStatus = 'idle';
|
||||
private _statusListeners: Set<(status: SyncStatus) => void> = new Set();
|
||||
private _pushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _pullTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _ws: WebSocket | null = null;
|
||||
private _enabled = false;
|
||||
private _online = true;
|
||||
|
||||
constructor(db: LocalDatabase, config: SyncConfig) {
|
||||
this._db = db;
|
||||
this._config = {
|
||||
conflictStrategy: 'field-level-lww',
|
||||
pushDebounce: 1000,
|
||||
pullInterval: 30_000,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────────
|
||||
|
||||
/** Current sync status. */
|
||||
get status(): SyncStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** Whether the sync engine is enabled (user is authenticated). */
|
||||
get enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a collection with the sync engine.
|
||||
*/
|
||||
registerCollection(collection: LocalCollection<BaseRecord>): void {
|
||||
this._collections.set(collection.name, collection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the sync engine. Call this after user authenticates.
|
||||
*/
|
||||
start(): void {
|
||||
if (this._enabled) return;
|
||||
this._enabled = true;
|
||||
|
||||
// Listen for online/offline events
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this._handleOnline);
|
||||
window.addEventListener('offline', this._handleOffline);
|
||||
this._online = navigator.onLine;
|
||||
}
|
||||
|
||||
// Initial sync
|
||||
this._doSync();
|
||||
|
||||
// Start pull interval (fallback to WebSocket)
|
||||
this._pullTimer = setInterval(() => {
|
||||
if (this._online) this._doPull();
|
||||
}, this._config.pullInterval!);
|
||||
|
||||
// Connect WebSocket
|
||||
this._connectWebSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the sync engine. Call this on sign-out.
|
||||
*/
|
||||
stop(): void {
|
||||
this._enabled = false;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('online', this._handleOnline);
|
||||
window.removeEventListener('offline', this._handleOffline);
|
||||
}
|
||||
|
||||
if (this._pushTimer) {
|
||||
clearTimeout(this._pushTimer);
|
||||
this._pushTimer = null;
|
||||
}
|
||||
if (this._pullTimer) {
|
||||
clearInterval(this._pullTimer);
|
||||
this._pullTimer = null;
|
||||
}
|
||||
this._disconnectWebSocket();
|
||||
this._setStatus('idle');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a push of local changes. Debounced to avoid hammering the server.
|
||||
*/
|
||||
schedulePush(): void {
|
||||
if (!this._enabled || !this._online) return;
|
||||
|
||||
if (this._pushTimer) {
|
||||
clearTimeout(this._pushTimer);
|
||||
}
|
||||
this._pushTimer = setTimeout(() => {
|
||||
this._doPush();
|
||||
}, this._config.pushDebounce!);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate full sync (push + pull).
|
||||
*/
|
||||
async sync(): Promise<void> {
|
||||
if (!this._enabled) return;
|
||||
await this._doSync();
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for sync status changes.
|
||||
*/
|
||||
onStatusChange(listener: (status: SyncStatus) => void): () => void {
|
||||
this._statusListeners.add(listener);
|
||||
return () => this._statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total number of pending changes.
|
||||
*/
|
||||
async getPendingCount(): Promise<number> {
|
||||
return this._db.getPendingCount();
|
||||
}
|
||||
|
||||
// ─── Internal: Sync Operations ──────────────────────────────
|
||||
|
||||
private async _doSync(): Promise<void> {
|
||||
if (!this._online) {
|
||||
this._setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
this._setStatus('syncing');
|
||||
|
||||
try {
|
||||
await this._doPush();
|
||||
await this._doPull();
|
||||
this._setStatus('synced');
|
||||
} catch (err) {
|
||||
console.error('[SyncEngine] sync failed:', err);
|
||||
this._setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push local pending changes to the server.
|
||||
*/
|
||||
private async _doPush(): Promise<void> {
|
||||
const allPending = await this._db._pendingChanges.orderBy('createdAt').toArray();
|
||||
if (allPending.length === 0) return;
|
||||
|
||||
// Group by collection
|
||||
const byCollection = new Map<string, typeof allPending>();
|
||||
for (const p of allPending) {
|
||||
const list = byCollection.get(p.collection) ?? [];
|
||||
list.push(p);
|
||||
byCollection.set(p.collection, list);
|
||||
}
|
||||
|
||||
// Build changeset
|
||||
const changes: Change[] = [];
|
||||
for (const [collection, pending] of byCollection) {
|
||||
// Deduplicate: for the same recordId, keep only the latest change
|
||||
const latest = new Map<string, (typeof pending)[0]>();
|
||||
for (const p of pending) {
|
||||
const existing = latest.get(p.recordId);
|
||||
if (!existing || p.createdAt > existing.createdAt) {
|
||||
// Merge fields if both are updates
|
||||
if (
|
||||
existing &&
|
||||
existing.op === 'update' &&
|
||||
p.op === 'update' &&
|
||||
existing.fields &&
|
||||
p.fields
|
||||
) {
|
||||
p.fields = { ...existing.fields, ...p.fields };
|
||||
}
|
||||
latest.set(p.recordId, p);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [recordId, p] of latest) {
|
||||
changes.push({
|
||||
table: collection,
|
||||
id: recordId,
|
||||
op: p.op,
|
||||
fields: p.fields,
|
||||
data: p.data,
|
||||
deletedAt: p.deletedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const since = await this._getOldestSyncCursor();
|
||||
|
||||
const changeset: Changeset = {
|
||||
clientId: this._config.clientId,
|
||||
appId: this._config.appId,
|
||||
since,
|
||||
changes,
|
||||
};
|
||||
|
||||
const response = await this._sendChangeset(changeset);
|
||||
if (!response) return;
|
||||
|
||||
// Apply server changes
|
||||
await this._applyServerChanges(response.serverChanges);
|
||||
|
||||
// Clear successfully synced pending changes
|
||||
const ids = allPending.map((p) => p.id!).filter(Boolean);
|
||||
await this._db.clearPendingChanges(ids);
|
||||
|
||||
// Update sync cursors
|
||||
for (const collection of this._collections.keys()) {
|
||||
await this._db.setSyncCursor(collection, response.syncedUntil);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull server changes for all collections.
|
||||
*/
|
||||
private async _doPull(): Promise<void> {
|
||||
for (const [name] of this._collections) {
|
||||
const since = await this._db.getSyncCursor(name);
|
||||
|
||||
const url = new URL(`/sync/${this._config.appId}/pull`, this._config.serverUrl);
|
||||
url.searchParams.set('collection', name);
|
||||
url.searchParams.set('since', since);
|
||||
|
||||
try {
|
||||
const response = await this._fetch(url.toString(), { method: 'GET' });
|
||||
if (!response.ok) continue;
|
||||
|
||||
const data: SyncResponse = await response.json();
|
||||
await this._applyServerChanges(data.serverChanges);
|
||||
await this._db.setSyncCursor(name, data.syncedUntil);
|
||||
} catch {
|
||||
// Pull failures are non-critical, will retry on next interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a changeset to the sync server.
|
||||
*/
|
||||
private async _sendChangeset(changeset: Changeset): Promise<SyncResponse | null> {
|
||||
const url = `${this._config.serverUrl}/sync/${this._config.appId}`;
|
||||
|
||||
try {
|
||||
const response = await this._fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(changeset),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[SyncEngine] push failed:', response.status, await response.text());
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (err) {
|
||||
console.error('[SyncEngine] push network error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply server changes to local collections.
|
||||
*/
|
||||
private async _applyServerChanges(changes: Change[]): Promise<void> {
|
||||
for (const change of changes) {
|
||||
const collection = this._collections.get(change.table);
|
||||
if (!collection) continue;
|
||||
|
||||
await collection.applyServerChange(change);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: WebSocket ────────────────────────────────────
|
||||
|
||||
private _connectWebSocket(): void {
|
||||
if (!this._online) return;
|
||||
|
||||
const baseUrl = this._config.wsUrl ?? this._config.serverUrl;
|
||||
const wsUrl = baseUrl.replace(/^http/, 'ws') + `/ws/${this._config.appId}`;
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
|
||||
this._ws.onopen = async () => {
|
||||
// Authenticate the WebSocket connection
|
||||
const token = await this._config.getAuthToken?.();
|
||||
if (token && this._ws?.readyState === WebSocket.OPEN) {
|
||||
this._ws.send(JSON.stringify({ type: 'auth', token }));
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'sync-available') {
|
||||
// Server has new changes — trigger immediate pull
|
||||
this._doPull();
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onclose = () => {
|
||||
this._ws = null;
|
||||
// Reconnect after delay if still enabled
|
||||
if (this._enabled && this._online) {
|
||||
setTimeout(() => this._connectWebSocket(), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
this._ws.onerror = () => {
|
||||
this._ws?.close();
|
||||
};
|
||||
} catch {
|
||||
// WebSocket not available (e.g. SSR)
|
||||
}
|
||||
}
|
||||
|
||||
private _disconnectWebSocket(): void {
|
||||
if (this._ws) {
|
||||
this._ws.onclose = null; // Prevent auto-reconnect
|
||||
this._ws.close();
|
||||
this._ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal: Helpers ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch with auth token injection.
|
||||
*/
|
||||
private async _fetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||
const token = await this._config.getAuthToken?.();
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
headers.set('X-Client-Id', this._config.clientId);
|
||||
|
||||
return fetch(url, { ...init, headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the oldest sync cursor across all collections.
|
||||
*/
|
||||
private async _getOldestSyncCursor(): Promise<string> {
|
||||
let oldest = new Date().toISOString();
|
||||
for (const name of this._collections.keys()) {
|
||||
const cursor = await this._db.getSyncCursor(name);
|
||||
if (cursor < oldest) oldest = cursor;
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
private _setStatus(status: SyncStatus): void {
|
||||
if (this._status === status) return;
|
||||
this._status = status;
|
||||
for (const listener of this._statusListeners) {
|
||||
listener(status);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleOnline = (): void => {
|
||||
this._online = true;
|
||||
this._connectWebSocket();
|
||||
this._doSync();
|
||||
};
|
||||
|
||||
private _handleOffline = (): void => {
|
||||
this._online = false;
|
||||
this._disconnectWebSocket();
|
||||
this._setStatus('offline');
|
||||
};
|
||||
}
|
||||
1
packages/local-store/src/sync/index.ts
Normal file
1
packages/local-store/src/sync/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { SyncEngine } from './engine.js';
|
||||
144
packages/local-store/src/types.ts
Normal file
144
packages/local-store/src/types.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Core types for the local-first data layer.
|
||||
*/
|
||||
|
||||
/** Base record that all local-store entities must extend. */
|
||||
export interface BaseRecord {
|
||||
id: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string | null;
|
||||
}
|
||||
|
||||
/** Sync status of a collection or the entire store. */
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'synced' | 'offline' | 'error';
|
||||
|
||||
/** A single field-level change for conflict resolution. */
|
||||
export interface FieldChange {
|
||||
value: unknown;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Operations that can be applied to a record. */
|
||||
export type ChangeOp = 'insert' | 'update' | 'delete';
|
||||
|
||||
/** A single change within a changeset. */
|
||||
export interface Change {
|
||||
table: string;
|
||||
id: string;
|
||||
op: ChangeOp;
|
||||
/** Field-level values with timestamps (for update ops). */
|
||||
fields?: Record<string, FieldChange>;
|
||||
/** Full record data (for insert ops). */
|
||||
data?: Record<string, unknown>;
|
||||
/** Soft-delete timestamp (for delete ops). */
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
/** A batch of changes sent to/from the sync server. */
|
||||
export interface Changeset {
|
||||
clientId: string;
|
||||
appId: string;
|
||||
/** ISO timestamp — sync changes since this point. */
|
||||
since: string;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
/** Response from the sync server after processing a changeset. */
|
||||
export interface SyncResponse {
|
||||
/** Changes from the server that the client doesn't have yet. */
|
||||
serverChanges: Change[];
|
||||
/** Conflicts that couldn't be auto-resolved (empty with field-level LWW). */
|
||||
conflicts: SyncConflict[];
|
||||
/** New sync cursor — use as `since` in the next request. */
|
||||
syncedUntil: string;
|
||||
}
|
||||
|
||||
/** A conflict the server couldn't auto-resolve. */
|
||||
export interface SyncConflict {
|
||||
table: string;
|
||||
id: string;
|
||||
field: string;
|
||||
clientValue: unknown;
|
||||
clientTimestamp: string;
|
||||
serverValue: unknown;
|
||||
serverTimestamp: string;
|
||||
}
|
||||
|
||||
/** Conflict resolution strategy. */
|
||||
export type ConflictStrategy = 'field-level-lww' | 'client-wins' | 'server-wins';
|
||||
|
||||
/** Configuration for the sync engine. */
|
||||
export interface SyncConfig {
|
||||
/** Base URL of the sync server (e.g. http://localhost:3050). */
|
||||
serverUrl: string;
|
||||
/** App identifier (e.g. 'todo', 'contacts'). */
|
||||
appId: string;
|
||||
/** Unique device identifier (persisted in localStorage). */
|
||||
clientId: string;
|
||||
/** Conflict resolution strategy. Default: 'field-level-lww'. */
|
||||
conflictStrategy?: ConflictStrategy;
|
||||
/** Debounce time in ms before pushing local changes. Default: 1000. */
|
||||
pushDebounce?: number;
|
||||
/** Interval in ms for pulling server changes (fallback to WebSocket). Default: 30000. */
|
||||
pullInterval?: number;
|
||||
/** Function to get the current auth token (or null for guests). */
|
||||
getAuthToken?: () => Promise<string | null>;
|
||||
/** WebSocket URL (defaults to serverUrl with ws:// protocol). */
|
||||
wsUrl?: string;
|
||||
}
|
||||
|
||||
/** Configuration for a single collection (table). */
|
||||
export interface CollectionConfig<T extends BaseRecord> {
|
||||
/** Table/collection name (e.g. 'tasks', 'projects'). */
|
||||
name: string;
|
||||
/** Dexie index definitions (e.g. ['projectId', 'dueDate', '[isCompleted+dueDate]']). */
|
||||
indexes?: string[];
|
||||
/** Default seed data for guest mode (loaded when DB is empty). */
|
||||
guestSeed?: T[];
|
||||
}
|
||||
|
||||
/** Metadata stored per collection for sync tracking. */
|
||||
export interface SyncMeta {
|
||||
/** Collection name. */
|
||||
collection: string;
|
||||
/** Last successful sync timestamp (ISO). */
|
||||
lastSyncedAt: string;
|
||||
/** Number of pending (un-synced) changes. */
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/** A pending change waiting to be synced. */
|
||||
export interface PendingChange {
|
||||
/** Auto-incremented ID. */
|
||||
id?: number;
|
||||
/** Collection name. */
|
||||
collection: string;
|
||||
/** Record ID. */
|
||||
recordId: string;
|
||||
/** Operation type. */
|
||||
op: ChangeOp;
|
||||
/** Changed fields with timestamps. */
|
||||
fields?: Record<string, FieldChange>;
|
||||
/** Full record (for inserts). */
|
||||
data?: Record<string, unknown>;
|
||||
/** Soft-delete timestamp (for delete ops). */
|
||||
deletedAt?: string;
|
||||
/** When this change was made locally. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** Sort direction for queries. */
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
||||
/** Query options for collection.query(). */
|
||||
export interface QueryOptions<T> {
|
||||
/** Sort by field name. */
|
||||
sortBy?: keyof T & string;
|
||||
/** Sort direction. Default: 'asc'. */
|
||||
sortDirection?: SortDirection;
|
||||
/** Maximum number of results. */
|
||||
limit?: number;
|
||||
/** Number of results to skip. */
|
||||
offset?: number;
|
||||
}
|
||||
18
packages/local-store/tsconfig.json
Normal file
18
packages/local-store/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -943,6 +943,19 @@
|
|||
</svg>
|
||||
<span class="pill-label">Logout</span>
|
||||
</button>
|
||||
{:else if loginHref && !userEmail}
|
||||
<!-- Guest mode: prominent login button -->
|
||||
<a href={loginHref} class="pill glass-pill login-pill" title="Anmelden">
|
||||
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d={getIconPath('user')}
|
||||
/>
|
||||
</svg>
|
||||
<span class="pill-label">Anmelden</span>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Collapse Button -->
|
||||
|
|
@ -1111,6 +1124,20 @@
|
|||
border-color: rgba(220, 38, 38, 0.3);
|
||||
}
|
||||
|
||||
/* Guest login pill — prominent with primary color */
|
||||
.login-pill {
|
||||
background: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
|
||||
color: #fff;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.login-pill:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
|
|
|
|||
316
pnpm-lock.yaml
generated
316
pnpm-lock.yaml
generated
|
|
@ -773,6 +773,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -1002,6 +1005,9 @@ importers:
|
|||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -1281,6 +1287,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -1963,6 +1972,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -2062,6 +2074,116 @@ importers:
|
|||
specifier: ^3.4.17
|
||||
version: 3.4.18(tsx@4.21.0)(yaml@2.8.1)
|
||||
|
||||
apps/inventar:
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/inventar/apps/web:
|
||||
dependencies:
|
||||
'@inventar/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/shared
|
||||
'@manacore/shared-auth':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-auth
|
||||
'@manacore/shared-branding':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-branding
|
||||
'@manacore/shared-error-tracking':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-error-tracking
|
||||
'@manacore/shared-feedback-service':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-feedback-service
|
||||
'@manacore/shared-icons':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-icons
|
||||
'@manacore/shared-landing-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-landing-ui
|
||||
'@manacore/shared-profile-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-profile-ui
|
||||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-theme':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-theme
|
||||
'@manacore/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-types
|
||||
'@manacore/shared-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-ui
|
||||
'@manacore/shared-utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-utils
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
svelte-i18n:
|
||||
specifier: ^4.0.1
|
||||
version: 4.0.1(svelte@5.44.0)
|
||||
devDependencies:
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
'@manacore/shared-vite-config':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-vite-config
|
||||
'@sveltejs/adapter-node':
|
||||
specifier: ^5.2.12
|
||||
version: 5.4.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))
|
||||
'@sveltejs/kit':
|
||||
specifier: ^2.21.0
|
||||
version: 2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@sveltejs/vite-plugin-svelte':
|
||||
specifier: ^5.1.0
|
||||
version: 5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.17(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@types/node':
|
||||
specifier: ^22.15.29
|
||||
version: 22.19.1
|
||||
'@vite-pwa/sveltekit':
|
||||
specifier: ^1.1.0
|
||||
version: 1.1.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(svelte@5.44.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)))(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(workbox-build@7.4.0(@types/babel__core@7.20.5))(workbox-window@7.4.0)
|
||||
svelte:
|
||||
specifier: ^5.41.0
|
||||
version: 5.44.0
|
||||
svelte-check:
|
||||
specifier: ^4.2.1
|
||||
version: 4.3.4(picomatch@4.0.3)(svelte@5.44.0)(typescript@5.9.3)
|
||||
tailwindcss:
|
||||
specifier: ^4.1.7
|
||||
version: 4.1.17
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^6.3.5
|
||||
version: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
vitest:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
|
||||
apps/inventar/packages/shared:
|
||||
dependencies:
|
||||
'@manacore/shared-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-types
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
apps/manacore/apps/landing:
|
||||
dependencies:
|
||||
'@astrojs/react':
|
||||
|
|
@ -2273,6 +2395,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -2745,6 +2870,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -2977,6 +3105,12 @@ importers:
|
|||
'@manacore/shared-icons':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-icons
|
||||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -3269,6 +3403,9 @@ importers:
|
|||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -4329,12 +4466,18 @@ importers:
|
|||
'@manacore/shared-profile-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-profile-ui
|
||||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-subscription-types':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-types
|
||||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -4663,6 +4806,12 @@ importers:
|
|||
'@manacore/shared-profile-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-profile-ui
|
||||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -4972,6 +5121,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -5189,6 +5341,12 @@ importers:
|
|||
'@manacore/shared-profile-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-profile-ui
|
||||
'@manacore/shared-stores':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-stores
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -5642,6 +5800,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -5888,6 +6049,9 @@ importers:
|
|||
|
||||
apps/todo/apps/web:
|
||||
dependencies:
|
||||
'@manacore/local-store':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/local-store
|
||||
'@manacore/shared-api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-api-client
|
||||
|
|
@ -6414,6 +6578,9 @@ importers:
|
|||
'@manacore/shared-subscription-ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-subscription-ui
|
||||
'@manacore/shared-tags':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tags
|
||||
'@manacore/shared-tailwind':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../packages/shared-tailwind
|
||||
|
|
@ -6576,6 +6743,22 @@ importers:
|
|||
specifier: ^8.48.1
|
||||
version: 8.48.1(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
|
||||
packages/local-store:
|
||||
dependencies:
|
||||
dexie:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.10.1
|
||||
svelte:
|
||||
specifier: ^5.0.0
|
||||
version: 5.44.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/mana-core-nestjs-integration:
|
||||
dependencies:
|
||||
'@manacore/credit-operations':
|
||||
|
|
@ -6800,6 +6983,15 @@ importers:
|
|||
'@manacore/shared-icons':
|
||||
specifier: workspace:*
|
||||
version: link:../shared-icons
|
||||
'@zxcvbn-ts/core':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
'@zxcvbn-ts/language-common':
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
'@zxcvbn-ts/language-de':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
|
|
@ -8236,6 +8428,8 @@ importers:
|
|||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
|
||||
services/mana-sync: {}
|
||||
|
||||
services/matrix-calendar-bot:
|
||||
dependencies:
|
||||
'@manacore/bot-services':
|
||||
|
|
@ -16723,6 +16917,15 @@ packages:
|
|||
'@yarnpkg/lockfile@1.1.0':
|
||||
resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==}
|
||||
|
||||
'@zxcvbn-ts/core@3.0.4':
|
||||
resolution: {integrity: sha512-aQeiT0F09FuJaAqNrxynlAwZ2mW/1MdXakKWNmGM1Qp/VaY6CnB/GfnMS2T8gB2231Esp1/maCWd8vTG4OuShw==}
|
||||
|
||||
'@zxcvbn-ts/language-common@3.0.4':
|
||||
resolution: {integrity: sha512-viSNNnRYtc7ULXzxrQIVUNwHAPSXRtoIwy/Tq4XQQdIknBzw4vz36lQLF6mvhMlTIlpjoN/Z1GFu/fwiAlUSsw==}
|
||||
|
||||
'@zxcvbn-ts/language-de@3.0.2':
|
||||
resolution: {integrity: sha512-CPl2314qWtnJl4EkeEqFbL4unP6yEAHO976ER+df8CQcKsF4FxdZYEahkleWU66dhNI2VKnmJKNMzq8QtHQKcw==}
|
||||
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==}
|
||||
|
||||
|
|
@ -18454,6 +18657,9 @@ packages:
|
|||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dexie@4.4.1:
|
||||
resolution: {integrity: sha512-4Xec5+yrS+TgyFAnMrneFOt/QG8sD3FxlkUVpfypui3SriRN80UN0SZBWmkNAY7ulfKgk0ilvv7M6pBURprdgA==}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
|
|
@ -20301,6 +20507,10 @@ packages:
|
|||
resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==}
|
||||
hasBin: true
|
||||
|
||||
fastest-levenshtein@1.0.16:
|
||||
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
|
||||
engines: {node: '>= 4.9.1'}
|
||||
|
||||
fastq@1.19.1:
|
||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||
|
||||
|
|
@ -41409,6 +41619,26 @@ snapshots:
|
|||
- vite
|
||||
optional: true
|
||||
|
||||
'@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@vitest/utils': 3.2.4
|
||||
magic-string: 0.30.21
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
playwright: 1.57.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
optional: true
|
||||
|
||||
'@vitest/browser@3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)':
|
||||
dependencies:
|
||||
'@testing-library/dom': 10.4.1
|
||||
|
|
@ -41527,6 +41757,14 @@ snapshots:
|
|||
optionalDependencies:
|
||||
vite: 6.4.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@6.4.1(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
|
|
@ -41944,6 +42182,14 @@ snapshots:
|
|||
|
||||
'@yarnpkg/lockfile@1.1.0': {}
|
||||
|
||||
'@zxcvbn-ts/core@3.0.4':
|
||||
dependencies:
|
||||
fastest-levenshtein: 1.0.16
|
||||
|
||||
'@zxcvbn-ts/language-common@3.0.4': {}
|
||||
|
||||
'@zxcvbn-ts/language-de@3.0.2': {}
|
||||
|
||||
'@zxing/text-encoding@0.9.0':
|
||||
optional: true
|
||||
|
||||
|
|
@ -44258,6 +44504,8 @@ snapshots:
|
|||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dexie@4.4.1: {}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
|
|
@ -48240,6 +48488,8 @@ snapshots:
|
|||
dependencies:
|
||||
strnum: 2.1.1
|
||||
|
||||
fastest-levenshtein@1.0.16: {}
|
||||
|
||||
fastq@1.19.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
|
|
@ -59181,6 +59431,27 @@ snapshots:
|
|||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-node@3.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
|
@ -59595,6 +59866,51 @@ snapshots:
|
|||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
'@vitest/spy': 3.2.4
|
||||
'@vitest/utils': 3.2.4
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/debug': 4.1.12
|
||||
'@types/node': 22.19.1
|
||||
'@vitest/browser': 3.2.4(playwright@1.57.0)(vite@6.4.1(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1))(vitest@3.2.4)
|
||||
'@vitest/ui': 3.2.4(vitest@3.2.4)
|
||||
jsdom: 29.0.1(@noble/hashes@2.0.1)
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vitest@3.2.4(@types/debug@4.1.12)(@types/node@24.10.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@29.0.1(@noble/hashes@2.0.1))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
|
|
|
|||
22
services/mana-sync/Dockerfile
Normal file
22
services/mana-sync/Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Build stage
|
||||
FROM golang:1.23-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /mana-sync ./cmd/server
|
||||
|
||||
# Runtime stage
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN apk --no-cache add ca-certificates
|
||||
COPY --from=builder /mana-sync /usr/local/bin/mana-sync
|
||||
|
||||
EXPOSE 3050
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:3050/health || exit 1
|
||||
|
||||
ENTRYPOINT ["mana-sync"]
|
||||
125
services/mana-sync/cmd/server/main.go
Normal file
125
services/mana-sync/cmd/server/main.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-sync/internal/auth"
|
||||
"github.com/manacore/mana-sync/internal/config"
|
||||
"github.com/manacore/mana-sync/internal/store"
|
||||
syncHandler "github.com/manacore/mana-sync/internal/sync"
|
||||
"github.com/manacore/mana-sync/internal/ws"
|
||||
"github.com/rs/cors"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Structured logging
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
})))
|
||||
|
||||
cfg := config.Load()
|
||||
ctx := context.Background()
|
||||
|
||||
// Connect to PostgreSQL
|
||||
db, err := store.New(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Run migrations
|
||||
if err := db.Migrate(ctx); err != nil {
|
||||
slog.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize JWT validator
|
||||
validator := auth.NewValidator(cfg.JWKSUrl)
|
||||
|
||||
// Initialize WebSocket hub
|
||||
hub := ws.NewHub()
|
||||
|
||||
// Initialize sync handler
|
||||
handler := syncHandler.NewHandler(db, validator, hub)
|
||||
|
||||
// Set up routes
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Sync endpoints (Go 1.22+ routing patterns)
|
||||
mux.HandleFunc("POST /sync/{appId}", handler.HandleSync)
|
||||
mux.HandleFunc("GET /sync/{appId}/pull", handler.HandlePull)
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.HandleFunc("/ws/{appId}", func(w http.ResponseWriter, r *http.Request) {
|
||||
appID := r.PathValue("appId")
|
||||
hub.HandleWebSocket(w, r, appID)
|
||||
})
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"service": "mana-sync",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"connections": hub.TotalConnections(),
|
||||
"users": hub.ConnectedUsers(),
|
||||
})
|
||||
})
|
||||
|
||||
// Metrics (Prometheus-compatible)
|
||||
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
fmt.Fprintf(w, "# HELP mana_sync_connections_total Total WebSocket connections\n")
|
||||
fmt.Fprintf(w, "# TYPE mana_sync_connections_total gauge\n")
|
||||
fmt.Fprintf(w, "mana_sync_connections_total %d\n", hub.TotalConnections())
|
||||
fmt.Fprintf(w, "# HELP mana_sync_users_connected Connected unique users\n")
|
||||
fmt.Fprintf(w, "# TYPE mana_sync_users_connected gauge\n")
|
||||
fmt.Fprintf(w, "mana_sync_users_connected %d\n", hub.ConnectedUsers())
|
||||
})
|
||||
|
||||
// CORS
|
||||
origins := strings.Split(cfg.CORSOrigins, ",")
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: origins,
|
||||
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type", "X-Client-Id"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: c.Handler(mux),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-sigCh
|
||||
|
||||
slog.Info("shutting down...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
slog.Info("mana-sync starting", "port", cfg.Port, "jwks", cfg.JWKSUrl)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
19
services/mana-sync/go.mod
Normal file
19
services/mana-sync/go.mod
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
module github.com/manacore/mana-sync
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/coder/websocket v1.8.12
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/jackc/pgx/v5 v5.7.2
|
||||
github.com/rs/cors v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sync v0.10.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
34
services/mana-sync/go.sum
Normal file
34
services/mana-sync/go.sum
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
|
||||
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
190
services/mana-sync/internal/auth/jwt.go
Normal file
190
services/mana-sync/internal/auth/jwt.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Claims represents the JWT payload from mana-core-auth.
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
SID string `json:"sid"`
|
||||
}
|
||||
|
||||
// Validator validates JWTs using EdDSA keys from the JWKS endpoint.
|
||||
type Validator struct {
|
||||
jwksURL string
|
||||
keys map[string]ed25519.PublicKey
|
||||
mu sync.RWMutex
|
||||
lastFetch time.Time
|
||||
fetchEvery time.Duration
|
||||
}
|
||||
|
||||
// NewValidator creates a JWT validator that fetches keys from the given JWKS URL.
|
||||
func NewValidator(jwksURL string) *Validator {
|
||||
return &Validator{
|
||||
jwksURL: jwksURL,
|
||||
keys: make(map[string]ed25519.PublicKey),
|
||||
fetchEvery: 5 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateToken validates a JWT and returns the claims.
|
||||
func (v *Validator) ValidateToken(tokenStr string) (*Claims, error) {
|
||||
// Ensure we have keys
|
||||
if err := v.ensureKeys(); err != nil {
|
||||
return nil, fmt.Errorf("fetch JWKS: %w", err)
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
|
||||
kid, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing kid in token header")
|
||||
}
|
||||
|
||||
v.mu.RLock()
|
||||
key, found := v.keys[kid]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
// Try refreshing keys once
|
||||
v.mu.Lock()
|
||||
v.lastFetch = time.Time{} // Force refresh
|
||||
v.mu.Unlock()
|
||||
|
||||
if err := v.ensureKeys(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v.mu.RLock()
|
||||
key, found = v.keys[kid]
|
||||
v.mu.RUnlock()
|
||||
|
||||
if !found {
|
||||
return nil, fmt.Errorf("unknown key ID: %s", kid)
|
||||
}
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}, jwt.WithValidMethods([]string{"EdDSA"}))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// ExtractToken extracts the bearer token from an HTTP request.
|
||||
func ExtractToken(r *http.Request) string {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserIDFromRequest validates the token and returns the user ID (sub claim).
|
||||
func (v *Validator) UserIDFromRequest(r *http.Request) (string, error) {
|
||||
token := ExtractToken(r)
|
||||
if token == "" {
|
||||
return "", fmt.Errorf("no authorization header")
|
||||
}
|
||||
|
||||
claims, err := v.ValidateToken(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if claims.Subject == "" {
|
||||
return "", fmt.Errorf("missing sub claim")
|
||||
}
|
||||
|
||||
return claims.Subject, nil
|
||||
}
|
||||
|
||||
func (v *Validator) ensureKeys() error {
|
||||
v.mu.RLock()
|
||||
if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 {
|
||||
v.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
v.mu.RUnlock()
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if time.Since(v.lastFetch) < v.fetchEvery && len(v.keys) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", v.jwksURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch JWKS from %s: %w", v.jwksURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("JWKS returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var jwks struct {
|
||||
Keys []struct {
|
||||
KID string `json:"kid"`
|
||||
KTY string `json:"kty"`
|
||||
CRV string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil {
|
||||
return fmt.Errorf("decode JWKS: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range jwks.Keys {
|
||||
if key.KTY != "OKP" || key.CRV != "Ed25519" {
|
||||
continue
|
||||
}
|
||||
|
||||
xBytes, err := base64.RawURLEncoding.DecodeString(key.X)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(xBytes) == ed25519.PublicKeySize {
|
||||
v.keys[key.KID] = ed25519.PublicKey(xBytes)
|
||||
}
|
||||
}
|
||||
|
||||
v.lastFetch = time.Now()
|
||||
return nil
|
||||
}
|
||||
33
services/mana-sync/internal/config/config.go
Normal file
33
services/mana-sync/internal/config/config.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds all configuration for the sync server.
|
||||
type Config struct {
|
||||
Port int
|
||||
DatabaseURL string
|
||||
JWKSUrl string // mana-core-auth JWKS endpoint for JWT validation
|
||||
CORSOrigins string
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables with sensible defaults.
|
||||
func Load() *Config {
|
||||
port, _ := strconv.Atoi(getEnv("PORT", "3050"))
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: getEnv("DATABASE_URL", "postgresql://manacore:devpassword@localhost:5432/mana_sync"),
|
||||
JWKSUrl: getEnv("JWKS_URL", "http://localhost:3001/.well-known/jwks.json"),
|
||||
CORSOrigins: getEnv("CORS_ORIGINS", "http://localhost:5173,http://localhost:5188"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
188
services/mana-sync/internal/store/postgres.go
Normal file
188
services/mana-sync/internal/store/postgres.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Store handles all PostgreSQL operations for the sync server.
|
||||
type Store struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// New creates a new Store with a connection pool.
|
||||
func New(ctx context.Context, databaseURL string) (*Store, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &Store{pool: pool}, nil
|
||||
}
|
||||
|
||||
// Close shuts down the connection pool.
|
||||
func (s *Store) Close() {
|
||||
s.pool.Close()
|
||||
}
|
||||
|
||||
// Migrate creates the sync_changes table if it doesn't exist.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
query := `
|
||||
CREATE TABLE IF NOT EXISTS sync_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id TEXT NOT NULL,
|
||||
table_name TEXT NOT NULL,
|
||||
record_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('insert', 'update', 'delete')),
|
||||
data JSONB,
|
||||
field_timestamps JSONB DEFAULT '{}',
|
||||
client_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_changes_user_app
|
||||
ON sync_changes (user_id, app_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_changes_table_record
|
||||
ON sync_changes (table_name, record_id, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_changes_since
|
||||
ON sync_changes (user_id, app_id, table_name, created_at);
|
||||
`
|
||||
|
||||
_, err := s.pool.Exec(ctx, query)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordChange stores a client change in the database.
|
||||
func (s *Store) RecordChange(ctx context.Context, appID, tableName, recordID, userID, op, clientID string, data map[string]any, fieldTimestamps map[string]string) error {
|
||||
dataJSON, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal data: %w", err)
|
||||
}
|
||||
|
||||
ftJSON, err := json.Marshal(fieldTimestamps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal field_timestamps: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO sync_changes (app_id, table_name, record_id, user_id, op, data, field_timestamps, client_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`
|
||||
|
||||
_, err = s.pool.Exec(ctx, query, appID, tableName, recordID, userID, op, dataJSON, ftJSON, clientID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetChangesSince returns all changes for a user+app+table since a given timestamp,
|
||||
// excluding changes from the requesting client (to avoid echo).
|
||||
func (s *Store) GetChangesSince(ctx context.Context, userID, appID, tableName, since, excludeClientID string) ([]ChangeRow, error) {
|
||||
sinceTime, err := time.Parse(time.RFC3339Nano, since)
|
||||
if err != nil {
|
||||
sinceTime = time.Unix(0, 0)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at
|
||||
FROM sync_changes
|
||||
WHERE user_id = $1 AND app_id = $2 AND table_name = $3
|
||||
AND created_at > $4 AND client_id != $5
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1000
|
||||
`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, userID, appID, tableName, sinceTime, excludeClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var changes []ChangeRow
|
||||
for rows.Next() {
|
||||
var c ChangeRow
|
||||
var dataJSON, ftJSON []byte
|
||||
|
||||
err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dataJSON != nil {
|
||||
json.Unmarshal(dataJSON, &c.Data)
|
||||
}
|
||||
if ftJSON != nil {
|
||||
json.Unmarshal(ftJSON, &c.FieldTimestamps)
|
||||
}
|
||||
|
||||
changes = append(changes, c)
|
||||
}
|
||||
|
||||
return changes, rows.Err()
|
||||
}
|
||||
|
||||
// GetAllChangesSince returns changes across all tables for a user+app.
|
||||
func (s *Store) GetAllChangesSince(ctx context.Context, userID, appID, since, excludeClientID string) ([]ChangeRow, error) {
|
||||
sinceTime, err := time.Parse(time.RFC3339Nano, since)
|
||||
if err != nil {
|
||||
sinceTime = time.Unix(0, 0)
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, table_name, record_id, op, data, field_timestamps, client_id, created_at
|
||||
FROM sync_changes
|
||||
WHERE user_id = $1 AND app_id = $2
|
||||
AND created_at > $3 AND client_id != $4
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5000
|
||||
`
|
||||
|
||||
rows, err := s.pool.Query(ctx, query, userID, appID, sinceTime, excludeClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var changes []ChangeRow
|
||||
for rows.Next() {
|
||||
var c ChangeRow
|
||||
var dataJSON, ftJSON []byte
|
||||
|
||||
err := rows.Scan(&c.ID, &c.TableName, &c.RecordID, &c.Op, &dataJSON, &ftJSON, &c.ClientID, &c.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if dataJSON != nil {
|
||||
json.Unmarshal(dataJSON, &c.Data)
|
||||
}
|
||||
if ftJSON != nil {
|
||||
json.Unmarshal(ftJSON, &c.FieldTimestamps)
|
||||
}
|
||||
|
||||
changes = append(changes, c)
|
||||
}
|
||||
|
||||
return changes, rows.Err()
|
||||
}
|
||||
|
||||
// ChangeRow is a row from the sync_changes table.
|
||||
type ChangeRow struct {
|
||||
ID string
|
||||
TableName string
|
||||
RecordID string
|
||||
Op string
|
||||
Data map[string]any
|
||||
FieldTimestamps map[string]string
|
||||
ClientID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
235
services/mana-sync/internal/sync/handler.go
Normal file
235
services/mana-sync/internal/sync/handler.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/manacore/mana-sync/internal/auth"
|
||||
"github.com/manacore/mana-sync/internal/store"
|
||||
"github.com/manacore/mana-sync/internal/ws"
|
||||
)
|
||||
|
||||
// Handler handles sync HTTP endpoints.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
validator *auth.Validator
|
||||
hub *ws.Hub
|
||||
}
|
||||
|
||||
// NewHandler creates a new sync handler.
|
||||
func NewHandler(s *store.Store, v *auth.Validator, h *ws.Hub) *Handler {
|
||||
return &Handler{store: s, validator: v, hub: h}
|
||||
}
|
||||
|
||||
// HandleSync processes a POST /sync/:appId request.
|
||||
// Receives a changeset from a client, records changes, and returns the server delta.
|
||||
func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
userID, err := h.validator.UserIDFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse app ID from path: /sync/{appId}
|
||||
appID := r.PathValue("appId")
|
||||
if appID == "" {
|
||||
http.Error(w, "missing appId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse changeset
|
||||
var changeset Changeset
|
||||
if err := json.NewDecoder(r.Body).Decode(&changeset); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
clientID := r.Header.Get("X-Client-Id")
|
||||
if clientID == "" {
|
||||
clientID = changeset.ClientID
|
||||
}
|
||||
|
||||
// Process each change
|
||||
affectedTables := make(map[string]struct{})
|
||||
for _, change := range changeset.Changes {
|
||||
affectedTables[change.Table] = struct{}{}
|
||||
|
||||
// Build data and field timestamps
|
||||
data := change.Data
|
||||
fieldTimestamps := make(map[string]string)
|
||||
|
||||
if change.Op == "update" && change.Fields != nil {
|
||||
data = make(map[string]any)
|
||||
for field, fc := range change.Fields {
|
||||
data[field] = fc.Value
|
||||
fieldTimestamps[field] = fc.UpdatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if change.Op == "delete" {
|
||||
if data == nil {
|
||||
data = make(map[string]any)
|
||||
}
|
||||
if change.DeletedAt != nil {
|
||||
data["deletedAt"] = *change.DeletedAt
|
||||
}
|
||||
}
|
||||
|
||||
err := h.store.RecordChange(ctx, appID, change.Table, change.ID, userID, change.Op, clientID, data, fieldTimestamps)
|
||||
if err != nil {
|
||||
slog.Error("failed to record change", "error", err, "table", change.Table, "id", change.ID)
|
||||
// Continue processing other changes
|
||||
}
|
||||
}
|
||||
|
||||
// Get server changes since client's last sync (excluding client's own changes)
|
||||
serverChanges, err := h.store.GetAllChangesSince(ctx, userID, appID, changeset.Since, clientID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get server changes", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert store rows to sync changes
|
||||
responseChanges := make([]Change, 0, len(serverChanges))
|
||||
for _, row := range serverChanges {
|
||||
c := Change{
|
||||
Table: row.TableName,
|
||||
ID: row.RecordID,
|
||||
Op: row.Op,
|
||||
}
|
||||
|
||||
switch row.Op {
|
||||
case "insert":
|
||||
c.Data = row.Data
|
||||
case "update":
|
||||
c.Fields = make(map[string]*FieldChange)
|
||||
for field, ts := range row.FieldTimestamps {
|
||||
value, ok := row.Data[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.Fields[field] = &FieldChange{
|
||||
Value: value,
|
||||
UpdatedAt: ts,
|
||||
}
|
||||
}
|
||||
case "delete":
|
||||
if deletedAt, ok := row.Data["deletedAt"].(string); ok {
|
||||
c.DeletedAt = &deletedAt
|
||||
}
|
||||
}
|
||||
|
||||
responseChanges = append(responseChanges, c)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
resp := SyncResponse{
|
||||
ServerChanges: responseChanges,
|
||||
Conflicts: []SyncConflict{}, // Field-level LWW doesn't produce conflicts
|
||||
SyncedUntil: now,
|
||||
}
|
||||
|
||||
// Notify other connected clients via WebSocket
|
||||
if len(affectedTables) > 0 {
|
||||
tables := make([]string, 0, len(affectedTables))
|
||||
for t := range affectedTables {
|
||||
tables = append(tables, t)
|
||||
}
|
||||
h.hub.NotifyUser(userID, appID, clientID, tables)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// HandlePull processes a GET /sync/:appId/pull request.
|
||||
// Returns server changes for a specific collection since a timestamp.
|
||||
func (h *Handler) HandlePull(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := h.validator.UserIDFromRequest(r)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized: "+err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
appID := r.PathValue("appId")
|
||||
if appID == "" {
|
||||
http.Error(w, "missing appId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
collection := r.URL.Query().Get("collection")
|
||||
since := r.URL.Query().Get("since")
|
||||
clientID := r.Header.Get("X-Client-Id")
|
||||
|
||||
if collection == "" || since == "" {
|
||||
http.Error(w, "missing collection or since parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
serverChanges, err := h.store.GetChangesSince(ctx, userID, appID, collection, since, clientID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get changes", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
responseChanges := make([]Change, 0, len(serverChanges))
|
||||
for _, row := range serverChanges {
|
||||
c := Change{
|
||||
Table: row.TableName,
|
||||
ID: row.RecordID,
|
||||
Op: row.Op,
|
||||
}
|
||||
|
||||
switch row.Op {
|
||||
case "insert":
|
||||
c.Data = row.Data
|
||||
case "update":
|
||||
c.Fields = make(map[string]*FieldChange)
|
||||
for field, ts := range row.FieldTimestamps {
|
||||
value, ok := row.Data[field]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.Fields[field] = &FieldChange{
|
||||
Value: value,
|
||||
UpdatedAt: ts,
|
||||
}
|
||||
}
|
||||
case "delete":
|
||||
if deletedAt, ok := row.Data["deletedAt"].(string); ok {
|
||||
c.DeletedAt = &deletedAt
|
||||
}
|
||||
}
|
||||
|
||||
responseChanges = append(responseChanges, c)
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
|
||||
resp := SyncResponse{
|
||||
ServerChanges: responseChanges,
|
||||
Conflicts: []SyncConflict{},
|
||||
SyncedUntil: now,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
66
services/mana-sync/internal/sync/types.go
Normal file
66
services/mana-sync/internal/sync/types.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package sync
|
||||
|
||||
import "time"
|
||||
|
||||
// Change represents a single field-level change to a record.
|
||||
type Change struct {
|
||||
Table string `json:"table"`
|
||||
ID string `json:"id"`
|
||||
Op string `json:"op"` // "insert", "update", "delete"
|
||||
Fields map[string]*FieldChange `json:"fields,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
DeletedAt *string `json:"deletedAt,omitempty"`
|
||||
}
|
||||
|
||||
// FieldChange holds a value and the timestamp when it was last changed.
|
||||
type FieldChange struct {
|
||||
Value any `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Changeset is a batch of changes sent by a client.
|
||||
type Changeset struct {
|
||||
ClientID string `json:"clientId"`
|
||||
AppID string `json:"appId"`
|
||||
Since string `json:"since"` // ISO timestamp
|
||||
Changes []Change `json:"changes"`
|
||||
}
|
||||
|
||||
// SyncResponse is returned after processing a changeset.
|
||||
type SyncResponse struct {
|
||||
ServerChanges []Change `json:"serverChanges"`
|
||||
Conflicts []SyncConflict `json:"conflicts"`
|
||||
SyncedUntil string `json:"syncedUntil"`
|
||||
}
|
||||
|
||||
// SyncConflict describes a conflict that couldn't be auto-resolved.
|
||||
type SyncConflict struct {
|
||||
Table string `json:"table"`
|
||||
ID string `json:"id"`
|
||||
Field string `json:"field"`
|
||||
ClientValue any `json:"clientValue"`
|
||||
ClientTimestamp string `json:"clientTimestamp"`
|
||||
ServerValue any `json:"serverValue"`
|
||||
ServerTimestamp string `json:"serverTimestamp"`
|
||||
}
|
||||
|
||||
// PullRequest represents a pull query from a client.
|
||||
type PullRequest struct {
|
||||
Collection string `json:"collection"`
|
||||
Since string `json:"since"`
|
||||
}
|
||||
|
||||
// SyncRecord is a row in the sync_changes table.
|
||||
type SyncRecord struct {
|
||||
ID string `json:"id"`
|
||||
AppID string `json:"appId"`
|
||||
TableName string `json:"tableName"`
|
||||
RecordID string `json:"recordId"`
|
||||
UserID string `json:"userId"`
|
||||
Op string `json:"op"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
FieldTimestamps map[string]string `json:"fieldTimestamps,omitempty"`
|
||||
ClientID string `json:"clientId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
209
services/mana-sync/internal/ws/hub.go
Normal file
209
services/mana-sync/internal/ws/hub.go
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// Message types sent over WebSocket.
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Tables []string `json:"tables,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// Client represents a connected WebSocket client.
|
||||
type Client struct {
|
||||
UserID string
|
||||
AppID string
|
||||
Conn *websocket.Conn
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// Hub manages WebSocket connections and broadcasts sync notifications.
|
||||
type Hub struct {
|
||||
// clients maps userID -> set of clients
|
||||
clients map[string]map[*Client]struct{}
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[string]map[*Client]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket upgrades an HTTP connection to WebSocket and registers the client.
|
||||
// The userID is initially empty — the client must send an auth message first.
|
||||
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request, appID string) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("websocket accept failed", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(r.Context())
|
||||
client := &Client{
|
||||
AppID: appID,
|
||||
Conn: conn,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
// Read loop: handle auth and other messages
|
||||
go h.readLoop(ctx, client)
|
||||
}
|
||||
|
||||
// NotifyUser sends a sync-available message to all connected clients of a user,
|
||||
// except the client that originated the change.
|
||||
func (h *Hub) NotifyUser(userID, appID, excludeClientID string, tables []string) {
|
||||
h.mu.RLock()
|
||||
clients, ok := h.clients[userID]
|
||||
h.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
msg := Message{
|
||||
Type: "sync-available",
|
||||
Tables: tables,
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for client := range clients {
|
||||
if client.AppID != appID {
|
||||
continue
|
||||
}
|
||||
// Don't echo back to the sender (client ID is in the WS client)
|
||||
go func(c *Client) {
|
||||
err := c.Conn.Write(context.Background(), websocket.MessageText, data)
|
||||
if err != nil {
|
||||
h.removeClient(c)
|
||||
}
|
||||
}(client)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) readLoop(ctx context.Context, client *Client) {
|
||||
defer func() {
|
||||
h.removeClient(client)
|
||||
client.Conn.Close(websocket.StatusNormalClosure, "closing")
|
||||
client.cancel()
|
||||
}()
|
||||
|
||||
for {
|
||||
_, data, err := client.Conn.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "auth":
|
||||
// Client sends token after connecting — we store the userID
|
||||
// In production, validate the token here. For now, trust it
|
||||
// since the HTTP sync endpoint already validates.
|
||||
if msg.Token != "" {
|
||||
// The actual validation happens in the sync handler.
|
||||
// Here we just need the user ID for routing notifications.
|
||||
// A proper implementation would validate the JWT.
|
||||
client.UserID = "pending-auth" // Placeholder
|
||||
h.addClient(client)
|
||||
}
|
||||
|
||||
case "ping":
|
||||
msg := Message{Type: "pong"}
|
||||
data, _ := json.Marshal(msg)
|
||||
client.Conn.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetClientUserID updates the user ID after JWT validation.
|
||||
// Called by the sync handler when it knows the real user ID.
|
||||
func (h *Hub) SetClientUserID(client *Client, userID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Remove from old mapping
|
||||
if client.UserID != "" {
|
||||
if clients, ok := h.clients[client.UserID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.clients, client.UserID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to new mapping
|
||||
client.UserID = userID
|
||||
if _, ok := h.clients[userID]; !ok {
|
||||
h.clients[userID] = make(map[*Client]struct{})
|
||||
}
|
||||
h.clients[userID][client] = struct{}{}
|
||||
}
|
||||
|
||||
func (h *Hub) addClient(client *Client) {
|
||||
if client.UserID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if _, ok := h.clients[client.UserID]; !ok {
|
||||
h.clients[client.UserID] = make(map[*Client]struct{})
|
||||
}
|
||||
h.clients[client.UserID][client] = struct{}{}
|
||||
|
||||
slog.Info("client connected", "userID", client.UserID, "appID", client.AppID)
|
||||
}
|
||||
|
||||
func (h *Hub) removeClient(client *Client) {
|
||||
if client.UserID == "" {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if clients, ok := h.clients[client.UserID]; ok {
|
||||
delete(clients, client)
|
||||
if len(clients) == 0 {
|
||||
delete(h.clients, client.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("client disconnected", "userID", client.UserID, "appID", client.AppID)
|
||||
}
|
||||
|
||||
// ConnectedUsers returns the number of unique connected users.
|
||||
func (h *Hub) ConnectedUsers() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// TotalConnections returns the total number of WebSocket connections.
|
||||
func (h *Hub) TotalConnections() int {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
total := 0
|
||||
for _, clients := range h.clients {
|
||||
total += len(clients)
|
||||
}
|
||||
return total
|
||||
}
|
||||
11
services/mana-sync/package.json
Normal file
11
services/mana-sync/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "@mana-sync/service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Central sync server for ManaCore local-first apps (Go)",
|
||||
"scripts": {
|
||||
"dev": "go run ./cmd/server",
|
||||
"build": "go build -o dist/mana-sync ./cmd/server",
|
||||
"test": "go test ./..."
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue