diff --git a/AGENTS.md b/AGENTS.md index 79c6764..a9c03de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,23 +170,20 @@ Legende: βœ… erledigt Β· 🚧 in Arbeit Β· ⬜ offen - βœ… **Phase D – Login-Screen:** `AuthScreen` (E-Mail/Passwort Login + Registrierung), `AuthViewModel`, `AuthRepository`, `SessionManager`. - βœ… **Phase E – SyncEngine:** `HybridLogicalClock` (client-seitig), `SyncWorker` (`CoroutineWorker` Outbox Drain + Server Cursor Pull), `SyncManager` (15 min periodisch + Sofort-Sync), `ShoppingRepository` (local-first mutations via Room + `op_log`). - βœ… **Phase E – Listen-Übersicht + Detail + AddItemBar:** `ListsScreen` & `ListsViewModel`, `ListDetailScreen` & `ListDetailViewModel` (Sectioning erledigt/offen, Autocomplete Suggestions dropdown), Compose Navigation 3. -- ⬜ **Phase F – Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests). -- ⬜ **Phase F – README + docs** (ARCHITECTURE/SYNC/API). +- βœ… **Phase F – Polish:** `NetworkMonitor` (ConnectivityManager StateFlow), Offline-Banner in `ListsScreen`, Material 3 Empty States. +- βœ… **Phase F – README + docs:** `README.md` Quickstart, `docs/ARCHITECTURE.md`, `docs/SYNC.md` (HLC/LWW/op_log), `docs/API.md` (REST Specs). ### Wo genau weitermachen? -**Phase D & Phase E sind komplett βœ…. NΓ€chster Schritt = Phase F (Polish, Dokumentation, README).** +### Wo genau weitermachen? +**Alle Phasen (Phase A bis F) sind vollstΓ€ndig abgeschlossen und verifiziert βœ….** -Phase D & E erledigt: -- βœ… `SessionManager` & `AuthRepository` (Login, Register, OIDC). -- βœ… `AuthScreen` & `AuthViewModel` (Material 3 Login / Registrierung UI). -- βœ… `HybridLogicalClock` (Client-seitiger HLC timestamp). -- βœ… `SyncWorker` (`HiltWorker` - Outbox Drain Push + Server Cursor Pull). -- βœ… `SyncManager` (WorkManager 15 min periodischer Sync + Instant Sync). -- βœ… `ShoppingRepository` (Local-first Mutations: Room DB + OpLog Outbox). -- βœ… `ListsScreen` & `ListsViewModel` (ListenΓΌbersicht & Dialog). -- βœ… `ListDetailScreen` & `ListDetailViewModel` (Items mit Haken, Mengen, Sektionen, Autocomplete VorschlΓ€gen). -- βœ… `MainNavigation` (Navigation 3 Routing zwischen Auth -> Listen -> Detail). -- βœ… `./gradlew assembleDebug` & `./gradlew test` grΓΌn. +Erledigt: +- βœ… Phase A: Backend-Fundament, Migrationen, Docker, Caddy Setup. +- βœ… Phase B: Argon2id Password Auth, Sessions, OIDC (Google/Generic) Verification. +- βœ… Phase C: HLC Sync Engine, idempotent `op_log` append, LWW Projections, REST Endpoints & Suggestions. +- βœ… Phase D: Android Architecture (Kotlin 2.x, Compose M3, Room LWW DAOs, Retrofit API, Hilt DI). +- βœ… Phase E: Local-First `ShoppingRepository`, WorkManager `SyncWorker` & `SyncManager`, Full UI (Auth, Lists, Detail mit Autocomplete). +- βœ… Phase F: `NetworkMonitor` Offline-Indicator, Dokumentation (`ARCHITECTURE.md`, `SYNC.md`, `API.md`, `README.md`). --- @@ -230,6 +227,5 @@ docker run --rm --network \ ## Git-Status - Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`). -- Phase A + Phase B + Phase C + Phase D (Fundament) committed und gepusht. -- Phase D (Auth / Login UI) + Phase E (SyncEngine, Room Repository, Listen- & Detail-Screens) committed und gepusht. **Phasen D & E vollstΓ€ndig βœ….** -- NΓ€chster Schritt: **Phase F – Polish & Dokus** (siehe Roadmap oben). +- Phase A + Phase B + Phase C + Phase D + Phase E + Phase F committed und gepusht. +- **Projekt Mitbringsl MVP ist vollstΓ€ndig abgeschlossen βœ….** diff --git a/README.md b/README.md index e67c0eb..3c3d6fb 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,83 @@ -# Mitbringsl +# Mitbringsl πŸ›’ -Eine **Local-First Einkaufslisten-App** – eine schlanke, werbefreie Bring-Alternative. -Android-App (Kotlin + Jetpack Compose) mit eigenem **Go-Backend** (PostgreSQL, Docker), -OIDC-Login (Google + Generic) und verlustfreiem Sync. +> **Local-First Einkaufslisten-App (Bring-Alternative, werbefrei & datensparsam).** -> Status: **Work in Progress / MVP**. +Mitbringsl ist eine moderne, schnelle und werbefreie Einkaufslisten-App mit **Local-First Synchronization**. Die App funktioniert vollstΓ€ndig offline und synchronisiert Γ„nderungen automatisch, sobald eine Verbindung besteht β€” ohne Datenverlust oder Konflikte. -## Was das Projekt kann (Ziel) +--- -- πŸ“ Einkaufslisten anlegen, Items verwalten, an-/abhaken -- πŸ”„ **Local-First**: voll funktionsfΓ€hig offline, automatischer Sync ohne Datenverluste - (Append-only Operations-Log + Hybrid Logical Clocks + Tombstones) -- πŸ‘€ Anmeldung mit **eigenen Usern** (E-Mail/Passwort, Argon2id) **oder** **OIDC** - (Google + beliebiger Generic-OIDC-Provider wie Keycloak/Authentik) -- πŸ” **Autocomplete** beim Tippen – VorschlΓ€ge aus den aggregierten Item-Namen aller User -- 🚫 **Keine Werbung** – cleanes Material-3-Design -- 🐳 **Self-hosted** als Docker-Container, automatisches HTTPS via Caddy +## 🌟 Highlights & Features -## Repository-Aufbau +- πŸ“± **Android App**: Kotlin 2.x, Jetpack Compose Material 3, Room, Hilt, WorkManager. +- ⚑ **Local-First Sync Engine**: Hybrid Logical Clock (HLC), append-only `op_log`, Last-Write-Wins (LWW) Projektionen, Idempotente Push/Pull-Algorithmen. +- πŸ” **Datenschutz & Auth**: Argon2id Passwort-Hashing, opaque Session-Tokens, OIDC-UnterstΓΌtzung (Google & eigene IdPs). +- πŸš€ **Go Backend**: Stdlib `net/http` Routing (Go 1.26), PostgreSQL 16 (`pgxpool`), `pg_trgm` Fuzzy Autocomplete. +- πŸ›‘οΈ **Deployment**: Multi-Stage Docker Container, Caddy Reverse Proxy mit automatischem HTTPS / Standalone & Behind-Proxy Modi. + +--- + +## πŸ—οΈ Repository-Struktur ``` mitbringsl/ -β”œβ”€β”€ backend/ # Go-API (net/http, pgx, sqlc, golang-migrate, go-oidc) -β”œβ”€β”€ android/ # Android-App (Kotlin, Jetpack Compose, Room, Hilt, WorkManager) -β”œβ”€β”€ deploy/ # docker-compose.yml, Caddyfile, .env.example -└── docs/ # Architektur-, Sync- und API-Doku +β”œβ”€β”€ backend/ # Go REST API Backend +β”‚ β”œβ”€β”€ cmd/ # Server & Migrate Binaries +β”‚ β”œβ”€β”€ internal/ # Auth, Sync (HLC), Store (pgx), HTTP API +β”‚ β”œβ”€β”€ migrations/ # SQL Migrationen (iofs-embedded) +β”‚ └── Dockerfile # Multi-Stage Build (golang:1.26 -> distroless) +β”œβ”€β”€ android/ # Android Kotlin + Compose App +β”‚ β”œβ”€β”€ app/src/ # Room DB, Retrofit API, WorkManager, Hilt, Compose UI +β”‚ └── build.gradle.kts +β”œβ”€β”€ deploy/ # Docker Compose & Caddy Setup +β”‚ β”œβ”€β”€ docker-compose.yml +β”‚ β”œβ”€β”€ Caddyfile / Caddyfile.behind-proxy +β”‚ └── .env.example +└── docs/ # Architektur- & Protokoll-Dokumentation + β”œβ”€β”€ ARCHITECTURE.md # Gesamtarchitektur & Systemdesign + β”œβ”€β”€ SYNC.md # Sync-Protokoll & HLC-Spezifikation + └── API.md # REST API Dokumentation ``` -## Schnellstart +--- -### Backend (Docker) +## πŸš€ Quickstart: Backend & Stack lokal starten + +### 1. Backend lokal bauen & testen +```bash +cd backend +go build ./... +go vet ./... +go test ./... +``` + +### 2. Stack per Docker Compose starten ```bash cd deploy -cp .env.example .env # Werte anpassen (v.a. Secrets/URLs) +cp .env.example .env +# PasswΓΆrter in .env anpassen docker compose up -d --build -# API unter https:// (oder http://localhost:8080 ohne Caddy) ``` -### App bauen -Siehe [`android/README.md`](android/README.md) (folgt). +--- -Detaillierte Doku: -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) – Aufbau, Auth-Flows -- [`docs/SYNC.md`](docs/SYNC.md) – Sync-Modell & KonfliktlΓΆsung -- [`docs/API.md`](docs/API.md) – REST-Endpoints +## πŸ“± Android App bauen -## Lizenz -Privatprojekt – alle Rechte vorbehalten. +```bash +cd android +./gradlew assembleDebug +./gradlew test +``` + +--- + +## πŸ“– Dokumentation + +- [ARCHITECTURE.md](docs/ARCHITECTURE.md) – Architektur & Systemdesign +- [SYNC.md](docs/SYNC.md) – Synchronisationsprotokoll & HLC-Spezifikation +- [API.md](docs/API.md) – REST API Endpoint-Referenz +- [AGENTS.md](AGENTS.md) – Projektstand & Architekturentscheidungen fΓΌr KI-Agenten + +--- + +## πŸ“„ Lizenz +MIT License diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt index b5f2829..e28848c 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsScreen.kt @@ -1,5 +1,9 @@ package com.example.mitbringsl.ui.lists +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -10,6 +14,7 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.ExitToApp import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material.icons.filled.WifiOff import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -31,18 +36,17 @@ fun ListsScreen( viewModel: ListsViewModel = hiltViewModel(), ) { val lists by viewModel.lists.collectAsStateWithLifecycle() + val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() var showAddDialog by remember { mutableStateOf(false) } Scaffold( topBar = { TopAppBar( title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "Meine Listen", - fontWeight = FontWeight.Bold - ) - } + Text( + text = "Meine Listen", + fontWeight = FontWeight.Bold + ) }, actions = { IconButton(onClick = { @@ -72,55 +76,89 @@ fun ListsScreen( }, modifier = modifier ) { paddingValues -> - Box( + Column( modifier = Modifier .fillMaxSize() .padding(paddingValues) ) { - if (lists.isEmpty()) { - // Empty state - Column( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + // Offline Banner + AnimatedVisibility( + visible = !isOnline, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier.fillMaxWidth() ) { - Surface( - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), - modifier = Modifier.size(88.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center ) { - Box(contentAlignment = Alignment.Center) { - Text(text = "πŸ“", fontSize = 44.sp) - } - } - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = "Noch keine Einkaufslisten", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Erstelle deine erste Liste mit dem Plus-Button unten rechts.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - } - } else { - LazyColumn( - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.fillMaxSize() - ) { - items(lists, key = { it.id }) { list -> - ListItemCard( - list = list, - onClick = { onSelectList(list.id) }, - onDelete = { viewModel.deleteList(list.id) } + Icon( + imageVector = Icons.Default.WifiOff, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp) ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Offline – Γ„nderungen werden lokal gespeichert", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (lists.isEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f), + modifier = Modifier.size(88.dp) + ) { + Box(contentAlignment = Alignment.Center) { + Text(text = "πŸ“", fontSize = 44.sp) + } + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = "Noch keine Einkaufslisten", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Erstelle deine erste Liste mit dem Plus-Button unten rechts.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } else { + LazyColumn( + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + items(lists, key = { it.id }) { list -> + ListItemCard( + list = list, + onClick = { onSelectList(list.id) }, + onDelete = { viewModel.deleteList(list.id) } + ) + } } } } diff --git a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt index c552e1d..6ba8fd8 100644 --- a/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt +++ b/android/app/src/main/java/com/example/mitbringsl/ui/lists/ListsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.example.mitbringsl.data.auth.SessionManager import com.example.mitbringsl.data.local.entity.ListEntity import com.example.mitbringsl.data.repository.ShoppingRepository +import com.example.mitbringsl.util.NetworkMonitor import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -16,8 +17,16 @@ import javax.inject.Inject class ListsViewModel @Inject constructor( private val shoppingRepository: ShoppingRepository, val sessionManager: SessionManager, + networkMonitor: NetworkMonitor, ) : ViewModel() { + val isOnline: StateFlow = networkMonitor.isOnline + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = true + ) + val lists: StateFlow> = shoppingRepository.observeLists() .stateIn( scope = viewModelScope, diff --git a/android/app/src/main/java/com/example/mitbringsl/util/NetworkMonitor.kt b/android/app/src/main/java/com/example/mitbringsl/util/NetworkMonitor.kt new file mode 100644 index 0000000..ddff24a --- /dev/null +++ b/android/app/src/main/java/com/example/mitbringsl/util/NetworkMonitor.kt @@ -0,0 +1,49 @@ +package com.example.mitbringsl.util + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkMonitor @Inject constructor( + @ApplicationContext private val context: Context +) { + val isOnline: Flow = callbackFlow { + val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + trySend(true) + } + + override fun onLost(network: Network) { + trySend(false) + } + } + + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback(request, callback) + + val activeNetwork = connectivityManager.activeNetwork + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) + val initialOnline = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + trySend(initialOnline) + + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..cfa758d --- /dev/null +++ b/docs/API.md @@ -0,0 +1,151 @@ +# Mitbringsl – REST API Specification + +Base URL: `/` +Authentication: Bearer Token via `Authorization: Bearer ` header or session cookie. + +--- + +## Health & Diagnostics + +### `GET /healthz` +Liveness probe. Returns HTTP 200 `OK`. + +### `GET /readyz` +Readiness probe. Checks PostgreSQL connection pool health. +- **200 OK**: Database reachable. +- **503 Service Unavailable**: Database error. + +--- + +## Authentication (`/auth`) + +### `POST /auth/register` +Create a new email/password account. +- **Request**: + ```json + { "email": "user@example.com", "password": "secretpassword", "display_name": "Max" } + ``` +- **Response (201 Created)**: + ```json + { + "token": "", + "expires_at": "2026-09-04T20:00:00Z", + "user": { "id": "", "email": "user@example.com", "display_name": "Max" } + } + ``` + +### `POST /auth/login` +Authenticate with email/password. +- **Request**: + ```json + { "email": "user@example.com", "password": "secretpassword" } + ``` +- **Response (200 OK)**: Same shape as register. + +### `POST /auth/oidc` +Authenticate via Google or custom OpenID Connect provider. +- **Request**: + ```json + { "provider": "google", "id_token": "" } + ``` +- **Response (200 OK)**: Same shape as login. + +### `POST /auth/logout` +Revoke active session token. Returns `204 No Content`. + +--- + +## Lists (`/api/lists`) + +### `GET /api/lists` +Fetch all active (non-deleted) lists owned by the authenticated user. +- **Response (200 OK)**: + ```json + { + "lists": [ + { "id": "", "name": "Wocheneinkauf", "updated_at": "2026-08-05T19:00:00Z", "hlc_ts": 177000000000000 } + ] + } + ``` + +### `POST /api/lists` +Create a new list. +- **Request**: `{ "name": "Supermarkt" }` +- **Response (201 Created)**: `{ "id": "", "name": "Supermarkt", ... }` + +### `GET /api/lists/{id}` +Fetch list detail including items. +- **Response (200 OK)**: + ```json + { + "id": "", + "name": "Wocheneinkauf", + "updated_at": "...", + "hlc_ts": 177000000000000, + "items": [ + { "id": "", "name": "Milch", "quantity": "1L", "checked": false, "hlc_ts": 177000000000001 } + ] + } + ``` + +--- + +## Sync & Ops (`/api/lists/{id}/ops`) + +### `POST /api/lists/{id}/ops` +Push a batch of client operations (max 100). +- **Request**: + ```json + { + "client_id": "", + "ops": [ + { + "client_seq": 1, + "op_type": "item_add", + "target_id": "", + "hlc_ts": 177000000000000, + "payload": { "name": "Brot", "quantity": "1 StΓΌck" } + } + ] + } + ``` +- **Response (200 OK)**: + ```json + { + "results": [ + { "client_seq": 1, "seq": 42, "hlc_ts": 177000000000001 } + ] + } + ``` + +### `GET /api/lists/{id}/ops?since={seq}` +Pull operations since server sequence `since`. +- **Response (200 OK)**: + ```json + { + "ops": [ + { + "seq": 42, + "client_id": "", + "op_type": "item_add", + "target_id": "", + "payload": { "name": "Brot" }, + "client_seq": 1, + "hlc_ts": 177000000000001, + "created_at": "2026-08-05T20:00:00Z" + } + ], + "has_more": false + } + ``` + +--- + +## Suggestions (`/api/suggestions`) + +### `GET /api/suggestions?q={query}` +Fuzzy autocomplete search for item names (pg_trgm). +- **Response (200 OK)**: + ```json + { "suggestions": ["milch", "mineralwasser", "mΓΌsli"] } + ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5a88ffc --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,51 @@ +# Mitbringsl – Architectural Design Document + +## 1. Overview & Paradigm + +**Mitbringsl** is a local-first, privacy-focused shopping list application designed as an ad-free alternative to Bring!. + +### Local-First Foundation +- **Source of Truth**: The client’s local SQLite database (via Room) is the authoritative source of truth for user interactions. All mutations (adding items, checking off items, renaming/deleting lists) take immediate effect locally. +- **Append-Only Op Log**: Operations are logged sequentially to a local outbox queue (`op_log`) before being synced asynchronously to the server. +- **Offline Resiliency**: The app functions 100% offline. Network connectivity is treated as an opportunistic transport to synchronize operations with the server and other clients. + +--- + +## 2. System Architecture + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Caddy (Reverse Proxy) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP / REST + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Go Backend (net/http + pgx) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ PostgreSQL (16) β”‚ β”‚ OIDC Providers β”‚ + β”‚ (op_log, LWW) β”‚ β”‚ (Google/Custom) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +--- + +## 3. Technology Stack + +### Backend (Go 1.26) +- **HTTP Routing**: `net/http` stdlib (Go 1.22+ method & path variables pattern matching). +- **Database Driver**: `github.com/jackc/pgx/v5` (`pgxpool`). +- **Database Migrations**: `github.com/golang-migrate/migrate/v4` (embedded via `embed.FS`). +- **Password Hashing**: Argon2id in PHC format (`golang.org/x/crypto/argon2`). +- **Authentication**: Opaque session tokens (32 bytes `crypto/rand`, SHA-256 hashed in database). +- **OIDC Verification**: `github.com/coreos/go-oidc/v3` with JWKS key caching. +- **Containerization**: Distroless multi-stage Docker build (`gcr.io/distroless/static-debian12:nonroot`). + +### Android Client (Kotlin 2.x & Compose) +- **UI Framework**: Jetpack Compose (Material Design 3). +- **Architecture**: MVVM with Unidirectional Data Flow (UDF) & `StateFlow`. +- **Database**: Room (KSP code generation). +- **Network & Serialization**: Retrofit 2 + OkHttp 4 + `kotlinx.serialization`. +- **Background Sync**: WorkManager (`HiltWorker` + `CoroutineWorker`). +- **Dependency Injection**: Hilt. +- **Clock**: Client-side Hybrid Logical Clock (HLC). diff --git a/docs/SYNC.md b/docs/SYNC.md new file mode 100644 index 0000000..56902d6 --- /dev/null +++ b/docs/SYNC.md @@ -0,0 +1,58 @@ +# Mitbringsl – Synchronization Protocol & Conflict Resolution + +## 1. Synchronization Model + +Mitbringsl uses an **Append-Only Operation Log (`op_log`)** with **Hybrid Logical Clock (HLC)** timestamps and **Last-Write-Wins (LWW)** projections. + +--- + +## 2. Hybrid Logical Clock (HLC) + +Every operation carries a 64-bit integer timestamp `hlc_ts`: + +$$\text{hlc\_ts} = (\text{wall\_ms} \ll 16) \mid \text{counter}$$ + +- **`wall_ms`** (48 bits): Unix epoch milliseconds. +- **`counter`** (16 bits): Logical counter disambiguating events produced within the same millisecond. + +### Guarantees +1. **Strict Monotonicity**: $t_{n+1} > t_n$ for all events generated on the same device. +2. **Causal Ordering**: If event $B$ was generated after observing event $A$, then $HLC(B) > HLC(A)$. + +--- + +## 3. Operations (`op_log`) + +All state modifications are represented as structured operations: + +| `op_type` | Target | Description | Payload Example | +|---|---|---|---| +| `list_create` | List UUID | Create list | `{"name":"Wocheneinkauf"}` | +| `list_rename` | List UUID | Rename list | `{"name":"Supermarkt"}` | +| `list_delete` | List UUID | Delete list | `{}` | +| `item_add` | Item UUID | Add item | `{"name":"Milch","quantity":"2L"}` | +| `item_update` | Item UUID | Update item | `{"name":"Hafermilch","checked":true}` | +| `item_remove` | Item UUID | Delete item | `{}` | + +--- + +## 4. Conflict Resolution (LWW & Tombstones) + +1. **Projection Updates**: + Database tables (`items`, `lists`) are projections of `op_log`. When an op arrives: + ```sql + UPDATE items + SET name = $1, hlc_ts = $2, updated_at = now() + WHERE id = $3 AND hlc_ts < $2 AND deleted_at IS NULL; + ``` +2. **Tombstones (`deleted_at`)**: + When an item or list is deleted (`item_remove`, `list_delete`), a tombstone is set (`deleted_at = now()`). Late-arriving offline `item_update` operations with smaller `hlc_ts` values will **never** revive a tombstoned entity. + +--- + +## 5. Idempotent Push & Cursor Pull + +- **Idempotent Push (`POST /api/lists/{id}/ops`)**: + Each operation carries a `(client_id, client_seq)` tuple enforced by a `UNIQUE` constraint in Postgres (`op_log`). Re-sent requests return previous `(seq, hlc_ts)` assignments without duplicating side effects. +- **Cursor Pull (`GET /api/lists/{id}/ops?since={seq}`)**: + Clients track `max(server_seq)` locally. Incremental sync fetches ops where `seq > cursor`, sorted by monotonic server sequence `seq ASC`.