synops/docs/erfaringer/arkiv/spacetimedb_integrasjon.md
vegard c0b89949e3 Opprydding: arkiver STDB-docs, fjern Caddy-konfig, rydd referanser (oppgave 22.5)
SpacetimeDB ble fjernet i 22.4. Denne oppryddingen:
- Arkiverer spacetimedb_integrasjon.md og adapter_moenster.md til docs/erfaringer/arkiv/
- Fjerner SpacetimeDB reverse proxy-blokk fra Caddyfile
- Fjerner SpacetimeDB-loven fra Claude feedback-memories (ikke lenger relevant)
- Oppdaterer docs-referanser i CLAUDE.md, erfaringer/README.md,
  selvdokumenterende_system.md og tasks.md
- Markerer fase 22 som fullført i avhengighetsgrafen
2026-03-18 13:45:30 +00:00

9.9 KiB

Erfaring: SpacetimeDB-integrasjon

1. Genererte bindings — navnekonvensjoner

SpacetimeDB sin spacetime generate --lang typescript produserer bindings med inkonsistente konvensjoner. Sjekk alltid de genererte filene i stedet for å gjette.

Hva Konvensjon Eksempel
Tabell-accessor på conn.db snake_case conn.db.chat_message
Reducer-accessor på conn.reducers camelCase conn.reducers.sendMessage()
Felt-navn i tabellrader camelCase row.channelId, row.authorName
Reducer-parametere Enkelt objekt sendMessage({ id, channelId, body, ... })

Felle: Man forventer at tabell-accessorer er camelCase (chatMessage), men de er snake_case.

2. DbConnection.builder() — API-detaljer (SDK v2)

DbConnection.builder()
  .withUri(spacetimeUrl)
  .withDatabaseName(moduleName)   // IKKE withModuleName
  .withToken(token)
  .onConnect((connection) => {    // første param er DbConnection, ikke EventContext
    connection.subscriptionBuilder()
      .subscribe([`SELECT * FROM chat_message WHERE channel_id = '${id}'`]);
  })
  .build();

Feller:

  • withModuleName() finnes ikke — bruk withDatabaseName()
  • onConnect-callback mottar DbConnection, ikke EventContext
  • onConnectError-callback har signatur (ctx, errMessage) der errMessage er en string

3. Timestamps — bruk SDK-metoder, ikke interne felter

SpacetimeDB Timestamp-objektet har en intern property __timestamp_micros_since_unix_epoch__ (BigInt). Ikke bruk den direkte — bruk SDK-metodene:

// FEIL — microsSinceEpoch finnes ikke, __timestamp_micros_since_unix_epoch__ er internt
const micros = row.createdAt?.microsSinceEpoch;

// RIKTIG — bruk SDK-metoder
const iso = row.createdAt?.toISOString();       // "2026-03-15T23:57:11.677139Z"
const date = row.createdAt?.toDate();            // Date-objekt
const ms = row.createdAt?.toDate()?.getTime();   // millisekunder for sortering

Timestamp-parsing i Rust-modul (warmup)

PG returnerer timestamps som "2026-03-15 23:57:11.677139+00". chrono parser IKKE +00 — krever +00:00:

// FEIL — chrono gir ParseError(TooShort) på "+00"
let dt = s.parse::<chrono::DateTime<chrono::FixedOffset>>();

// RIKTIG — normaliser PG-offset først
let normalized = if s.ends_with("+00") { format!("{}:00", s) } else { s.to_string() };
let dt = chrono::DateTime::parse_from_str(&normalized, "%Y-%m-%d %H:%M:%S%.f%:z");

Uten korrekt parsing faller load_messages tilbake til ctx.timestamp (nåtidspunkt), og alle meldinger får samme klokkeslett.

Referanse: spacetimedb/src/lib.rsparse_timestamp(), web/src/lib/chat/spacetime.svelte.tsspacetimeRowToMessage().

4. Rust-modul — borrow checker med SpacetimeDB-makroer

SpacetimeDB-makroer genererer kode som tar eierskap over struct-felter. Bruk verdier før du flytter dem inn i structs:

// FEIL — channel_id er moved inn i ChatMessage, kan ikke bruke i log!() etterpå
let msg = ChatMessage { channel_id, body, ... };
log::info!("Melding i kanal {}", channel_id);  // borrow after move

// RIKTIG — bruk verdien før struct-opprettelse
let log_msg = format!("Melding i kanal {}", channel_id);
let msg = ChatMessage { channel_id, body, ... };
log::info!("{}", log_msg);

5. Publisering og lokal testing

# Publiser modul mot lokal SpacetimeDB (må kjøre i Docker først)
cd spacetimedb
spacetime publish sidelinja-realtime --server local

# Generer TypeScript-bindings
spacetime generate --lang typescript --out-dir ../web/src/lib/chat/module_bindings \
  --module-path .

Merk: SpacetimeDB-modulen publiseres manuelt med spacetime publish mot server-instansen.

6. Arkitekturendring: SpacetimeDB som cache foran PG (mars 2026)

Tidligere: Hybrid-adapter der frontend merget data fra PG (historikk) og SpacetimeDB (sanntid) med dedup, deletedIds og BigInt-workarounds.

Ny modell:

  • PG autoritativ — all persistent data i PostgreSQL
  • SpacetimeDB = varm cache — worker gjør warmup (PG → ST) ved oppstart
  • Frontend snakker KUN med ST — ingen PG API-kall fra chat-adapteren
  • Worker håndterer toveissynk — ST → PG for nye/redigerte/slettede meldinger og reaksjoner

Warmup-flyt

  1. Worker starter → warmup::run() leser kanaler med config fra PG
  2. Per kanal: sjekker channels.config.warmup_mode (all/messages/days/none)
  3. Kaller clear_channel reducer (unngår duplikater ved restart)
  4. Trådbasert henting: Finner kvalifiserende tråder, henter alle meldinger i disse (komplett med svar)
    • messages-modus: de N nyeste trådene (sortert etter siste aktivitet)
    • days-modus: alle tråder med minst én melding i tidsvinduet
    • Et svar som kvalifiserer tar med hele tråden (inkludert eldre trådstarter)
  5. Kaller load_messages reducer med JSON-array
  6. Laster også reaksjoner via load_reactions reducer

Per-kanal konfigurasjon

  • Lagres i channels.config JSONB: warmup_mode + warmup_value
  • Admin-UI: /admin/channels — tabell med inline-redigering
  • Default: "all" (last alt). Andre: "messages" (siste N tråder), "days" (siste N dager), "none" (inaktiv)

Sync-flyt (ST → PG)

  • SyncOutbox-events prosesseres hver 1. sekund
  • Støtter: messages/insert, messages/delete, messages/update, messages/ai_update, message_reactions/insert, message_reactions/delete
  • ai_update-action: oppdaterer body + metadata + edited_at i PG, inserter revisjon

7. Subscription-begrensninger

SpacetimeDB-subscriptions støtter IKKE JOINs. En subscription-query som SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE ... feiler stille — onApplied kalles aldri, og ingen data vises.

Bruk kun enkle SELECT * FROM tabell WHERE ...-queries i .subscribe([...]). Filtrer heller klient-side etter at data er lastet.

Eksempel:

// FEIL — feiler stille, ingen data
.subscribe([
  `SELECT * FROM chat_message WHERE channel_id = '${id}'`,
  `SELECT mr.* FROM message_reaction mr JOIN chat_message cm ON cm.id = mr.message_id WHERE cm.channel_id = '${id}'`
]);

// RIKTIG — last alle reaksjoner, filtrer i koden
.subscribe([
  `SELECT * FROM chat_message WHERE channel_id = '${id}'`,
  `SELECT * FROM message_reaction`
]);

Fallback

PG-polling adapter (pg.svelte.ts) brukes kun når SpacetimeDB ikke er konfigurert. Markeres som readonly: true.

8. Reducer-parameternavn — unngå underscore-prefix

Kodeeksemplene i denne seksjonen er fra v1 og bruker workspace_id-parametere. Workspace-modellen er erstattet av noder og edges (se docs/retninger/bruker_ikke_workspace.md), men lærdommen om underscore-prefix gjelder generelt for alle SpacetimeDB-reducere.

SpacetimeDB eksponerer Rust-parameternavn direkte i HTTP JSON API-et. Underscore-prefix (_workspace_id) blir til _workspace_id i JSON, ikke workspace_id:

// FEIL — HTTP-kall med {"workspace_id": "..."} feiler med 400
pub fn set_ai_processing(ctx: &ReducerContext, id: String, _workspace_id: String) { ... }

// RIKTIG — bruk vanlig navn, suppress warning med let _ = &var;
pub fn set_ai_processing(ctx: &ReducerContext, id: String, workspace_id: String) -> Result<(), String> {
    let _ = &workspace_id;
    // ...
}

9. Schema-migrering ved nye kolonner

Å legge til kolonner på eksisterende SpacetimeDB-tabeller krever --delete-data ved publish. Dette sletter all data og krever warmup på nytt:

# Feiler uten --delete-data:
# "Adding a column metadata to table chat_message requires a default value annotation"
echo "y" | spacetime publish sidelinja-realtime --server local --delete-data

10. AI-worker-flyt via SpacetimeDB

Worker som gjør AI-behandling av meldinger:

  1. Leser meldingens body fra PG (OK — PG er persistent lager)
  2. Kaller set_ai_processing reducer → frontend ser pulsering umiddelbart
  3. Kaller AI Gateway med prompt
  4. Kaller ai_update_message reducer → SpacetimeDB oppdaterer body/metadata/edited_at atomisk, lagrer revisjon, legger outbox-entry
  5. Sync-worker persisterer til PG via ai_update action
  6. Ved feil: clear_ai_processing reducer rydder flagget

11. HTTP API for å kalle reducere fra Rust (maskinrommet)

SpacetimeDB eksponerer et HTTP JSON API for å kalle reducere server-side. Maskinrommet bruker reqwest (allerede en avhengighet) — ingen STDB SDK nødvendig.

Endepunkt

POST /v1/database/{database_name}/call/{reducer_name}
Authorization: Bearer {stdb_token}
Content-Type: application/json

{
  "param1": "verdi1",
  "param2": "verdi2"
}

Viktige detaljer

  • Navngitte parametre: Body er et JSON-objekt med nøkler som matcher reducer-parameternavn, IKKE en {"args": [...]} array.
  • Token: Opprettes via POST /v1/identity{"identity": "...", "token": "..."}. Token er en JWT signert med serverens ES256-nøkkel. Ny server = ny nøkkel = nye tokens.
  • Suksess: HTTP 200 med tom body.
  • Reducer-feil: HTTP 200 med Err(String) — feilmeldingen er i body.
  • Valideringsfeil: HTTP 400 med beskrivende feilmelding.
  • Auth-feil: HTTP 401/403.

Token-håndtering ved containerrestart

Hvis SpacetimeDB-containeren gjenskapes (data slettet), må:

  1. Ny identitet opprettes via POST /v1/identity
  2. Modulen republiseres med spacetime publish
  3. STDB-tokenet i .env oppdateres

Maskinrommet kan automatisk opprette en ny identitet ved oppstart hvis SPACETIMEDB_TOKEN ikke er satt, men denne identiteten vil ikke ha tilgang til en eksisterende database. Bruk derfor alltid et stabilt token.

Warmup via HTTP API

Maskinrommet gjør warmup (PG → STDB) ved oppstart:

  1. clear_all — tøm STDB (unngå duplikater ved restart)
  2. Last alle noder fra PG, kall create_node for hver
  3. Last alle edges fra PG, kall create_edge for hver

NULLable PG-kolonner (title, content, created_by) må håndteres med COALESCE i SQL-spørringen — STDB-modulen bruker String, ikke Option.