# 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)

```typescript
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:

```typescript
// 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`:

```rust
// 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.rs` — `parse_timestamp()`, `web/src/lib/chat/spacetime.svelte.ts` — `spacetimeRowToMessage()`.

## 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:

```rust
// 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

```bash
# 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:
```typescript
// 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`:

```rust
// 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:

```bash
# 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`.
