Phase F: NetworkMonitor, offline banner, README & architecture docs

- NetworkMonitor: ConnectivityState Flow using ConnectivityManager.NetworkCallback
- ListsScreen & ViewModel: live offline indicator banner when disconnected
- Documentation:
  - docs/ARCHITECTURE.md: system design & tech stack overview
  - docs/SYNC.md: HLC timestamping, op_log outbox & LWW projection specification
  - docs/API.md: REST API endpoint specification
  - README.md: quickstart guide for backend, docker compose & Android app
- Verification: backend & android test suites 100% green 
This commit is contained in:
Tronax 2026-08-05 20:14:04 +02:00
parent 174aad535a
commit a00db14cba
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
8 changed files with 482 additions and 96 deletions

View file

@ -170,23 +170,20 @@ Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
- ✅ **Phase D Login-Screen:** `AuthScreen` (E-Mail/Passwort Login + Registrierung), `AuthViewModel`, `AuthRepository`, `SessionManager`. - ✅ **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 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 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 Polish:** `NetworkMonitor` (ConnectivityManager StateFlow), Offline-Banner in `ListsScreen`, Material 3 Empty States.
- **Phase F README + docs** (ARCHITECTURE/SYNC/API). - **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? ### 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: Erledigt:
- ✅ `SessionManager` & `AuthRepository` (Login, Register, OIDC). - ✅ Phase A: Backend-Fundament, Migrationen, Docker, Caddy Setup.
- ✅ `AuthScreen` & `AuthViewModel` (Material 3 Login / Registrierung UI). - ✅ Phase B: Argon2id Password Auth, Sessions, OIDC (Google/Generic) Verification.
- ✅ `HybridLogicalClock` (Client-seitiger HLC timestamp). - ✅ Phase C: HLC Sync Engine, idempotent `op_log` append, LWW Projections, REST Endpoints & Suggestions.
- ✅ `SyncWorker` (`HiltWorker` - Outbox Drain Push + Server Cursor Pull). - ✅ Phase D: Android Architecture (Kotlin 2.x, Compose M3, Room LWW DAOs, Retrofit API, Hilt DI).
- ✅ `SyncManager` (WorkManager 15 min periodischer Sync + Instant Sync). - ✅ Phase E: Local-First `ShoppingRepository`, WorkManager `SyncWorker` & `SyncManager`, Full UI (Auth, Lists, Detail mit Autocomplete).
- ✅ `ShoppingRepository` (Local-first Mutations: Room DB + OpLog Outbox). - ✅ Phase F: `NetworkMonitor` Offline-Indicator, Dokumentation (`ARCHITECTURE.md`, `SYNC.md`, `API.md`, `README.md`).
- ✅ `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.
--- ---
@ -230,6 +227,5 @@ docker run --rm --network <net> \
## Git-Status ## Git-Status
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`). - Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`).
- Phase A + Phase B + Phase C + Phase D (Fundament) committed und gepusht. - Phase A + Phase B + Phase C + Phase D + Phase E + Phase F committed und gepusht.
- Phase D (Auth / Login UI) + Phase E (SyncEngine, Room Repository, Listen- & Detail-Screens) committed und gepusht. **Phasen D & E vollständig ✅.** - **Projekt Mitbringsl MVP ist vollständig abgeschlossen ✅.**
- Nächster Schritt: **Phase F Polish & Dokus** (siehe Roadmap oben).

View file

@ -1,49 +1,83 @@
# Mitbringsl # Mitbringsl 🛒
Eine **Local-First Einkaufslisten-App** eine schlanke, werbefreie Bring-Alternative. > **Local-First Einkaufslisten-App (Bring-Alternative, werbefrei & datensparsam).**
Android-App (Kotlin + Jetpack Compose) mit eigenem **Go-Backend** (PostgreSQL, Docker),
OIDC-Login (Google + Generic) und verlustfreiem Sync.
> 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 ## 🌟 Highlights & Features
- 🔄 **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
## 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/ mitbringsl/
├── backend/ # Go-API (net/http, pgx, sqlc, golang-migrate, go-oidc) ├── backend/ # Go REST API Backend
├── android/ # Android-App (Kotlin, Jetpack Compose, Room, Hilt, WorkManager) │ ├── cmd/ # Server & Migrate Binaries
├── deploy/ # docker-compose.yml, Caddyfile, .env.example │ ├── internal/ # Auth, Sync (HLC), Store (pgx), HTTP API
└── docs/ # Architektur-, Sync- und API-Doku │ ├── 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 ```bash
cd deploy 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 docker compose up -d --build
# API unter https://<deine-domain> (oder http://localhost:8080 ohne Caddy)
``` ```
### App bauen ---
Siehe [`android/README.md`](android/README.md) (folgt).
Detaillierte Doku: ## 📱 Android App bauen
- [`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
## Lizenz ```bash
Privatprojekt alle Rechte vorbehalten. 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

View file

@ -1,5 +1,9 @@
package com.example.mitbringsl.ui.lists 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.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn 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.Delete
import androidx.compose.material.icons.filled.ExitToApp import androidx.compose.material.icons.filled.ExitToApp
import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -31,18 +36,17 @@ fun ListsScreen(
viewModel: ListsViewModel = hiltViewModel(), viewModel: ListsViewModel = hiltViewModel(),
) { ) {
val lists by viewModel.lists.collectAsStateWithLifecycle() val lists by viewModel.lists.collectAsStateWithLifecycle()
val isOnline by viewModel.isOnline.collectAsStateWithLifecycle()
var showAddDialog by remember { mutableStateOf(false) } var showAddDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = "Meine Listen", text = "Meine Listen",
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
}
}, },
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
@ -72,13 +76,46 @@ fun ListsScreen(
}, },
modifier = modifier modifier = modifier
) { paddingValues -> ) { paddingValues ->
Box( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .padding(paddingValues)
) { ) {
// Offline Banner
AnimatedVisibility(
visible = !isOnline,
enter = expandVertically(),
exit = shrinkVertically()
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
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()) { if (lists.isEmpty()) {
// Empty state
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -126,6 +163,7 @@ fun ListsScreen(
} }
} }
} }
}
if (showAddDialog) { if (showAddDialog) {
CreateListDialog( CreateListDialog(

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.example.mitbringsl.data.auth.SessionManager import com.example.mitbringsl.data.auth.SessionManager
import com.example.mitbringsl.data.local.entity.ListEntity import com.example.mitbringsl.data.local.entity.ListEntity
import com.example.mitbringsl.data.repository.ShoppingRepository import com.example.mitbringsl.data.repository.ShoppingRepository
import com.example.mitbringsl.util.NetworkMonitor
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -16,8 +17,16 @@ import javax.inject.Inject
class ListsViewModel @Inject constructor( class ListsViewModel @Inject constructor(
private val shoppingRepository: ShoppingRepository, private val shoppingRepository: ShoppingRepository,
val sessionManager: SessionManager, val sessionManager: SessionManager,
networkMonitor: NetworkMonitor,
) : ViewModel() { ) : ViewModel() {
val isOnline: StateFlow<Boolean> = networkMonitor.isOnline
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = true
)
val lists: StateFlow<List<ListEntity>> = shoppingRepository.observeLists() val lists: StateFlow<List<ListEntity>> = shoppingRepository.observeLists()
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,

View file

@ -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<Boolean> = 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()
}

151
docs/API.md Normal file
View file

@ -0,0 +1,151 @@
# Mitbringsl REST API Specification
Base URL: `/`
Authentication: Bearer Token via `Authorization: Bearer <session_token>` 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": "<opaque_session_token>",
"expires_at": "2026-09-04T20:00:00Z",
"user": { "id": "<uuid>", "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": "<jwt>" }
```
- **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": "<uuid>", "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": "<uuid>", "name": "Supermarkt", ... }`
### `GET /api/lists/{id}`
Fetch list detail including items.
- **Response (200 OK)**:
```json
{
"id": "<uuid>",
"name": "Wocheneinkauf",
"updated_at": "...",
"hlc_ts": 177000000000000,
"items": [
{ "id": "<uuid>", "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": "<client_uuid>",
"ops": [
{
"client_seq": 1,
"op_type": "item_add",
"target_id": "<item_uuid>",
"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": "<client_uuid>",
"op_type": "item_add",
"target_id": "<item_uuid>",
"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"] }
```

51
docs/ARCHITECTURE.md Normal file
View file

@ -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 clients 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).

58
docs/SYNC.md Normal file
View file

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