# Infrastruktur: PostgreSQL ↔ SpacetimeDB Synkronisering **Filsti:** `docs/infra/synkronisering.md` ## 1. Konsept SpacetimeDB gir sanntidsopplevelsen, PostgreSQL er langtidsminnet. Denne spec-en definerer hvordan data flyter mellom dem, hvem som eier sannheten, og hva som skjer ved feil. ### 1.1 Grunnregel: SpacetimeDB er en ren sanntidsbuffer SpacetimeDB skal **aldri** være eneste lagringssted for data med varig verdi. All data som SpacetimeDB holder skal enten: 1. Allerede eksistere i PostgreSQL (read-only cache, f.eks. aktør-navn for autocomplete), eller 2. Synkes til PostgreSQL innen ~1 sekund (chat, kanban, markører). **Konsekvens for design:** Ethvert SpacetimeDB-modul-skjema skal ha en tilsvarende PostgreSQL-tabell som kan fungere som drop-in erstatning dersom SpacetimeDB fjernes. Frontend-kode som leser fra SpacetimeDB skal kunne peke om til et SvelteKit SSE-endepunkt + REST uten arkitekturendring. ### 1.2 PostgreSQL-fallback-prinsippet Dersom SpacetimeDB fjernes fra stacken, skal systemet fungere med følgende erstatning: - **Sanntidsoppdateringer:** PostgreSQL `LISTEN/NOTIFY` → SvelteKit Server-Sent Events (SSE) - **Skriving:** Direkte til PostgreSQL via SvelteKit server-side - **Autocomplete/cache:** PostgreSQL-spørringer med cursor-basert paginering Denne fallbacken trenger ikke implementeres på forhånd, men SpacetimeDB-moduler skal designes slik at fallbacken forblir triviell. ## 2. Strategi: Event-drevet med kort forsinkelse SpacetimeDB-modulene (Rust) produserer persisterings-events ved dataendringer. En Rust-worker konsumerer disse og skriver til PostgreSQL med ~1 sekunds intervall. **Akseptabelt datatap:** Maks 1 sekund ved hard krasj av SpacetimeDB. Dette er akseptabelt for chat, kanban og show notes. **Unntak — kritiske events:** Aha-markører fra studioet (live-innspilling) er tidssensitive og vanskelige å gjenskape. Disse bør flushes til PG umiddelbart (ikke batched) via en dedikert `sync_critical()`-funksjon som skriver direkte til PG i stedet for via `sync_outbox`. Alternativt kan SpacetimeDB-modulen skrive kritiske events til sin egen WAL/disk umiddelbart. Hvilke event-typer som er "kritiske" defineres per workspace i `workspaces.settings`. ## 3. Dataflyt ``` ┌──────────────┐ events ┌──────────────┐ batch write ┌──────────────┐ │ SpacetimeDB │ ──────────────► │ Rust Worker │ ────────────────► │ PostgreSQL │ │ (sanntid) │ │ (sync_to_pg) │ │ (persistent)│ └──────────────┘ └──────────────┘ └──────────────┘ ┌──────────────┐ oppvarming (oppstart / reconnect) ┌──────────────┐ │ PostgreSQL │ ──────────────────────────────────────► │ SpacetimeDB │ └──────────────┘ └──────────────┘ ``` ## 4. Eierskapsmodell | Data | Autoritativ kilde | Synkretning | Merknad | |---|---|---|---| | Chatmeldinger | SpacetimeDB | → PG (event, batched) | | | Kanban-posisjon | SpacetimeDB | → PG (event) | | | Show notes | SpacetimeDB | → PG (event) | | | Live studio-markører | SpacetimeDB | → PG (event) | | | Kunnskapsgraf | PostgreSQL | → SpacetimeDB (oppvarming) | Read-only i SpacetimeDB | | Episodemetadata | PostgreSQL | Ingen synk | | | Brukerkontoer | PostgreSQL (Authentik) | Ingen synk | | | Statistikk | PostgreSQL | Ingen synk | | | Valgomat | TBD | TBD | Konseptet må modnes. Mulig PG-autoritativ med SpacetimeDB som serveringslag | ## 5. Mekanisme ### 5.1 SpacetimeDB → PostgreSQL (persistering) - SpacetimeDB-reducerne legger sync-events i `sync_outbox`-tabellen med tidsstempel og payload - Rust-workeren poller `sync_outbox` hvert sekund, leser alle usynkede events, skriver til PostgreSQL, og markerer dem som synket via `mark_synced`-reducer - Ved PG-nedetid: events akkumuleres i `sync_outbox`. Workeren prøver igjen ved neste poll. Ingen data tapes så lenge SpacetimeDB kjører ### 5.2 PostgreSQL → SpacetimeDB (oppvarming) - Ved oppstart laster Rust-workeren data fra PG per kanal, basert på `channels.config`: - `warmup_mode: "all"` — alle meldinger i kanalen - `warmup_mode: "messages"` — de N nyeste *trådene* (komplett med svar) - `warmup_mode: "days"` — alle tråder med aktivitet siste N dager (komplett) - `warmup_mode: "none"` — kanalen hoppes over (arkivert/inaktiv) - Trådbasert henting: et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter) - Konfigureres per kanal via admin-UI (`/admin/channels`) eller `channels.config` JSONB - Kanalen ryddes (`clear_channel`) før lasting for å unngå duplikater ved restart - Reaksjoner lastes også inn via `load_reactions`-reducer ## 6. Feilhåndtering - **SpacetimeDB krasjer:** Data siden siste synk (~1 sek) tapes. Ved restart oppvarmes fra PG - **PostgreSQL nede:** Sanntidsfunksjoner fortsetter å fungere. `sync_outbox` vokser. Workeren logger advarsler. Ved PG-recovery synkes backloggen automatisk - **Rust-worker krasjer:** `sync_outbox` akkumuleres. Ved restart plukker workeren opp der den slapp (usynkede events har ingen markering) ## 7. Workspace-isolasjon SpacetimeDB-synkronisering er fullstendig workspace-scopet: * SpacetimeDB-tilkoblinger bærer `workspace_id` som context-token. Modulen partisjonerer minnet og kringkaster kun til klienter i samme workspace. * `sync_outbox`-events inkluderer `workspace_id` i payloaden, slik at Rust-workeren skriver til riktig silo i PostgreSQL. * Ved oppvarming (PG → SpacetimeDB) laster workeren kun data for den aktuelle workspace-en. ## 8. Konflikthåndtering ### 8.1 Strategi: Last-Write-Wins (LWW) med reservasjoner SpacetimeDB er autoritativ for sanntidsdata. Konflikter kan oppstå i to scenarioer: **Scenario A — Samtidig redigering i SpacetimeDB:** SpacetimeDB er single-threaded per modul. Reducer-funksjoner serialiseres automatisk. Ingen klassiske race conditions. **Scenario B — PG-oppvarming kolliderer med fersk SpacetimeDB-state:** Ved restart av SpacetimeDB lastes data fra PG (oppvarming). Hvis en klient skriver til SpacetimeDB *under* oppvarming, kan PG-dataen overskrive ferske endringer. **Løsning:** Oppvarming setter et `warming_up`-flagg i SpacetimeDB-modulen. Klienttilkoblinger aksepteres, men skrivinger bufres til oppvarmingen er fullført. Buffrede skrivinger appliseres deretter i rekkefølge. ### 8.2 Kanban: Posisjonskonflikter Kanban-kort har en `position`-kolonne (float). To brukere som drar kort samtidig kan skape overlappende posisjoner. Løsning: SpacetimeDB-modulen re-beregner posisjoner (midtpunkts-strategi) og kringkaster oppdatert rekkefølge til alle klienter. ### 8.3 Chat: Ingen konflikter Meldinger er append-only. Redigering av egne meldinger er last-write-wins — akseptabelt fordi kun én bruker eier meldingen. ## 9. Workers som endrer data frontend ser **Kritisk regel:** En worker (Rust) som transformerer data som frontend viser, MÅ skrive resultatet til SpacetimeDB — ikke direkte til PG. PG-oppdatering skjer via sync-worker i bakgrunnen. ### Eksempel: AI-vask av chatmelding **Riktig flyt:** ``` Frontend viser melding fra SpacetimeDB → Bruker trykker ✨ → SvelteKit oppretter jobb i PG job_queue → Worker plukker opp jobb → Worker leser prompt + routing fra PG (det er infrastrukturdata, ikke frontend-data) → Worker kaller AI Gateway → Worker skriver resultat til SpacetimeDB via edit_message reducer → SpacetimeDB pusher oppdatering til frontend via onUpdate → Sync-worker persisterer til PG i bakgrunnen ``` **Feil flyt (anti-pattern som har blitt implementert gjentatte ganger):** ``` Worker skriver til PG direkte → Frontend henter metadata fra PG via enrichFromPg() ← BRYTER ABSTRAKSJONEN → SpacetimeDB-oppdatering er "best-effort" ← FEIL PRIORITERING ``` ### Hva betyr dette for nye felter? Når frontend trenger å vise noe nytt (metadata, revisjoner, edited_at), er prosedyren: 1. **Utvid SpacetimeDB-modulen** — legg til feltet i Rust-strukturen 2. **Utvid warmup** — last feltet fra PG ved oppstart 3. **Utvid sync** — persist feltet til PG i bakgrunnen 4. **Worker skriver til SpacetimeDB** — via reducer, aldri direkte PG for synlig data 5. **Frontend leser fra SpacetimeDB** — ingen enrichFromPg, ingen PG API-kall ### Hva kan workers lese fra PG? Workers kan og bør lese infrastrukturdata direkte fra PG: - Jobbkø (`job_queue`) — det er jobbens opphavssted - AI-prompts, modellkonfigurasjon, routing — det er infrastruktur, ikke brukerdata - Workspace-konfigurasjon Skillet er: **data frontend viser** → SpacetimeDB. **Data kun worker trenger** → PG direkte. ## 10. Implementeringsstatus (mars 2026) ### Ferdig - **SpacetimeDB som cache foran PG:** PG er autoritativ, SpacetimeDB er varm cache. Frontend snakker kun med SpacetimeDB. - **SpacetimeDB Rust-modul** (`spacetimedb/src/lib.rs`): `ChatMessage` (med `metadata`, `edited_at`), `MessageReaction`, `MessageRevision` og `SyncOutbox`-tabeller. Reducers: `send_message`, `delete_message`, `edit_message`, `add_reaction`, `remove_reaction`, `load_messages`, `load_reactions`, `load_revisions`, `clear_channel`, `mark_synced`, `set_ai_processing`, `clear_ai_processing`, `ai_update_message`. - **Worker warmup** (`worker/src/warmup.rs`): Ved oppstart lastes meldinger (med metadata/edited_at), reaksjoner og revisjoner per kanal fra PG → SpacetimeDB. Originale timestamps parses korrekt. Kanaler ryddes først med `clear_channel` for å unngå duplikater. - **Worker sync** (`worker/src/sync.rs`): Poller `sync_outbox` hvert 1. sekund. Håndterer insert/delete/update/ai_update for meldinger og insert/delete for reaksjoner. - **SpacetimeDB-adapter** (`web/src/lib/chat/spacetime.svelte.ts`): Ren SpacetimeDB-adapter. Null PG API-kall (`fetch()`). Bruker `onInsert`/`onUpdate`/`onDelete` callbacks for sanntid. Reaksjoner bygges fra `message_reaction`-tabellen. Revisjoner leses fra `message_revision`-tabellen. - **PG-fallback** (`web/src/lib/chat/pg.svelte.ts`): Brukes kun når SpacetimeDB ikke er konfigurert. Markert som `readonly: true`. - **Adapter-mønster:** `ChatConnection`-interface med `send`, `edit`, `delete`, `react` metoder. Factory velger basert på env-variabel. ### Fikset (mars 2026) - **`enrichFromPg` fjernet** — SpacetimeDB-modulen har nå `metadata` og `edited_at` på `ChatMessage`. Frontend leser alt fra SpacetimeDB, null `fetch()`-kall i adapteren. - **Worker AI-vask via reducers** — `set_ai_processing`, `ai_update_message` og `clear_ai_processing` reducers erstatter direkte PG-skriving. Sync-worker persisterer til PG via `ai_update` outbox-action. - **Revisjoner i SpacetimeDB** — `MessageRevision`-tabell med `load_revisions` warmup-reducer. Frontend leser revisjoner via `getRevisions()` fra `conn.db.message_revision`. - **Warmup med metadata** — `load_messages` inkluderer `metadata`, `edited_at` og parser originale timestamps korrekt. ### Gjenstår - **Workspace-partisjonering (§7):** SpacetimeDB-modulen har `workspace_id`-felt men bruker ikke workspace-token på tilkobling ennå. - **Pin/konvertering via SpacetimeDB:** Pin og kanban/kalender-konvertering går fortsatt direkte til PG API. - **Lazy warmup per kanal:** Alle aktive kanaler oppvarmes ved oppstart. Kan optimaliseres til per-kanal ved tilkobling. ## 11. Instruks for Claude Code - `sync_outbox`-tabellen i SpacetimeDB bør ha et `synced`-flagg og `created_at`-tidsstempel - Workeren skal bruke jobbkø-infrastrukturen (se `docs/infra/jobbkø.md`) for sin egen helse/observabilitet, men selve pollingen er en egen loop — ikke en vanlig jobb i køen - Hold sync-payloaden enkel: `{ "table": "chat_messages", "action": "insert", "data": {...} }` — workeren mapper dette til riktig PG-tabell - Ikke optimaliser for store datamengder ennå. Enkle INSERTs er bra nok til volumet stabiliserer seg - Alle sync-payloads må inkludere `workspace_id`. Workeren bruker dette til å sette RLS-kontekst (`SET app.current_workspace_id`) før PG-skriving, eller kjører som superuser og filtrerer eksplisitt