Fullfør oppgave 22.2: Frontend-migrering fra SpacetimeDB til portvokteren

Frontend bruker nå kun portvokterens WebSocket for sanntidsdata.
SpacetimeDB-klienten er erstattet med en enkel WebSocket-klient
som kobler til /ws-endepunktet og oppdaterer reactive stores direkte.

Frontend-endringer:
- Nye lokale typer (types.ts) erstatter STDB module_bindings
- connection.svelte.ts: WebSocket til portvokteren med auto-reconnect
- stores.svelte.ts: Prosesserer WS-meldinger (initial_sync + events)
- MixerTrait: STDB-reducers erstattet med HTTP API-kall
- api.ts: Nye mixer-endepunkter (create, gain, mute, effect, role)
- +layout.svelte: Fjernet dual-tilkobling, kun portvokterens WS
- pg-ws.svelte.ts: Slettet (erstattet av connection.svelte.ts)

Dokumentasjon:
- datalaget.md: Fase M1+M2 markert som fullført
- api_grensesnitt.md: Oppdatert arkitekturdiagram, nye mixer-endepunkter
This commit is contained in:
vegard 2026-03-18 12:26:33 +00:00
parent 652d2b8917
commit 8e80102f6b
12 changed files with 506 additions and 443 deletions

View file

@ -7,8 +7,7 @@ Maskinrommet eier alle skrivinger: det validerer, skriver til PG,
og orkestrerer konsekvenser.
Sanntid: PG LISTEN/NOTIFY → maskinrommet → WebSocket `/ws` → frontend.
SpacetimeDB er under utfasing — frontend kobler til begge i parallell
under verifiseringsfasen (Fase M1).
SpacetimeDB er under utfasing — frontend bruker kun portvokterens WebSocket (Fase M2).
Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet → PG.
## 2. Kommunikasjonskart
@ -20,40 +19,38 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
│ │
│ HTTP (intensjoner) │ WebSocket (sanntid)
▼ ▼
┌──────────────────────┐ ┌──────────────────┐
│ Maskinrommet (Rust) │ │ SpacetimeDB
│ axum HTTP API │ │
│ │ - Hele grafen
│ Ansvar: │ │ - Push til
│ - Validere │ │ klienter
│ - Skrive STDB+PG │ │
│ - Orkestrere │ └──────────────────┘
┌──────────────────────────────────────┐
│ Maskinrommet / Portvokteren (Rust)
│ axum HTTP API + WebSocket
│ Ansvar:
│ - Validere intensjoner
│ - Skrive til PG
│ - Orkestrere bakgrunnsjobber │
│ - Tunge spørringer │
│ - Bakgrunnsjobber
└──┬─────┬─────┬───────┘
┌─────┐┌─────┐┌─────────────┐
│ PG ││STDB ││ Whisper, │
│ │ │ LiteLLM, │
│ │ │ LiveKit ... │
└─────┘└─────┘└─────────────┘
│ - Sanntid via PG LISTEN → WS
└──┬─────────────┬─────────────────────┘
┌─────┐ ┌─────────────┐
│ PG │ │ Whisper, │
│ │ │ LiteLLM, │
│ │ │ LiveKit ... │
└─────┘ └─────────────┘
```
## 3. Ansvarsfordeling
| Komponent | Rolle | Snakker med |
|---|---|---|
| **SvelteKit (klient)** | UI, brukerinteraksjon | Maskinrommet (HTTP), SpacetimeDB (WS) |
| **Maskinrommet (Rust)** | Intensjons-API, orkestrering, tunge spørringer | PG, SpacetimeDB, CAS, Whisper, LiteLLM |
| **SpacetimeDB** | Sanntids state, push til klienter | Klienter (WS), maskinrommet (skriver) |
| **PostgreSQL** | Persistent arkiv, søk, statistikk | Maskinrommet (SQL) |
| **SvelteKit (klient)** | UI, brukerinteraksjon | Maskinrommet (HTTP + WS) |
| **Maskinrommet (Rust)** | Intensjons-API, orkestrering, sanntid, tunge spørringer | PG, CAS, Whisper, LiteLLM |
| **PostgreSQL** | Eneste datakilde, sanntid via LISTEN/NOTIFY | Maskinrommet (SQL) |
## 4. Viktige avklaringer
- **Maskinrommet er en HTTP API-server** (axum). Frontend sender intensjoner hit.
- **Maskinrommet eier alle skrivinger.** Frontend skriver aldri direkte til PG eller STDB.
- **SpacetimeDB nås direkte** fra klienten via WebSocket for lesing (sanntid).
- **Maskinrommet er HTTP API + WebSocket-server** (axum). Frontend sender intensjoner via HTTP, mottar sanntid via WebSocket.
- **Maskinrommet eier alle skrivinger.** Frontend skriver aldri direkte til PG.
- **SvelteKit er et rent frontend-prosjekt.** Ingen server-side PG-tilgang.
- **Bakgrunnsjobber** (Whisper, LLM, TTS) orkestreres av maskinrommet, aldri direkte fra frontend.
@ -62,14 +59,17 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
### Offentlige
- `GET /health` — Helsesjekk. Verifiserer PG- og STDB-tilkobling.
### WebSocket (sanntid, oppgave 22.1)
### WebSocket (sanntid, oppgave 22.122.2)
- `GET /ws?token=<JWT>` — WebSocket-oppgradering for sanntidsstrøm.
Token sendes som query-parameter (nettlesere støtter ikke custom headers ved WS upgrade).
- Ved tilkobling: sender `initial_sync` med alle noder, edges og access brukeren kan se.
- Deretter: strømmer `node_changed`, `edge_changed` og `access_changed` events,
filtrert på brukerens tilgangsmatrise (node_access).
- Kilder: PG LISTEN/NOTIFY-triggere på nodes, edges og node_access-tabellene.
- Events er JSON med `{ "type": "node_changed"|"edge_changed"|"access_changed", ... }`.
- Ved tilkobling: sender `initial_sync` med alle noder, edges, access og mixer_channels
brukeren kan se.
- Deretter: strømmer berikede events (`node_changed`, `edge_changed`, `access_changed`,
`mixer_channel_changed`), filtrert på brukerens tilgangsmatrise (node_access).
- Berikede events: INSERT/UPDATE-events inneholder full raddata fra PG (ikke bare ID),
slik at frontend kan oppdatere stores direkte uten ekstra API-kall.
- Kilder: PG LISTEN/NOTIFY-triggere på nodes, edges, node_access og mixer_channels.
- Ved lag (klient sakker etter): sender full resync (ny initial_sync).
### Autentiserte (krever `Authorization: Bearer <JWT>`)
- `GET /me` — Returnerer autentisert brukers `node_id` og `authentik_sub`.
@ -123,10 +123,19 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
- Body (JSON): `{ communication_id }`
- Respons: `{ status: "closed" }`
### SpacetimeDB sanntidstabeller (LiveKit)
- `live_room` — Aktive rom. Felt: `room_id`, `communication_id`, `is_active`, `started_at`, `participant_count`.
- `room_participant` — Deltakere i rom. Felt: `id`, `room_id`, `user_id`, `display_name`, `role`, `joined_at`.
Frontend abonnerer på disse via SpacetimeDB WebSocket for sanntids deltakerliste.
### Mixer-kanaler (oppgave 22.2)
Erstatter SpacetimeDB-reducers for delt mixer-tilstand i LiveKit-rom.
Skriver til PG `mixer_channels`-tabell; NOTIFY-trigger propagerer til WS.
- `POST /intentions/create_mixer_channel` — Opprett mixer-kanal for deltaker i rom.
- Body: `{ room_id, target_user_id }`
- `POST /intentions/set_gain` — Sett gain (0.01.5) for en kanal.
- Body: `{ room_id, target_user_id, gain }`
- `POST /intentions/set_mute` — Mute/unmute en kanal.
- Body: `{ room_id, target_user_id, is_muted }`
- `POST /intentions/toggle_effect` — Toggle en lydeffekt.
- Body: `{ room_id, target_user_id, effect_name }`
- `POST /intentions/set_mixer_role` — Sett rolle (editor/viewer) for deltaker.
- Body: `{ room_id, target_user_id, role }`
## 6. Instruks for Claude Code

View file

@ -126,16 +126,18 @@ i prototype-fasen. Men for produksjon på én server:
## Migrasjonsplan: STDB → PG LISTEN/NOTIFY
### Fase M1: WebSocket-lag i portvokteren
Implementer LISTEN/NOTIFY-lytter og WebSocket-endepunkt i
portvokteren. Legg til PG-triggers for nodes og edges.
Frontend kobler til begge (STDB + nytt WS) i parallell.
### Fase M1: WebSocket-lag i portvokteren
Implementert LISTEN/NOTIFY-lytter og WebSocket-endepunkt i
portvokteren. PG-triggers for nodes, edges og access.
Frontend koblet til begge (STDB + nytt WS) i parallell.
### Fase M2: Frontend-migrering
Endre frontend fra SpacetimeDB-klient til vanlig WebSocket.
Erstatt STDB-stores med reaktive stores som lytter på
portvokterens WebSocket. Verifiser at all sanntidsfunksjonalitet
fungerer.
### Fase M2: Frontend-migrering ✅
Frontend bruker nå kun portvokterens WebSocket. SpacetimeDB-klient
fjernet. Reactive stores oppdateres direkte fra WS-meldinger.
Berikede events: portvokteren henter full raddata fra PG etter
NOTIFY (ikke bare ID) slik at stores kan oppdateres uten ekstra
API-kall. Mixer-kanaler migrert fra STDB til PG-tabell med
tilhørende NOTIFY-trigger og HTTP API-endepunkter.
### Fase M3: Fjern skrivestien til STDB
Portvokteren slutter å skrive til SpacetimeDB. All skriving

View file

@ -1281,3 +1281,70 @@ export async function fetchNodeUsage(
}
return res.json();
}
// =============================================================================
// Mixer-kanaler (oppgave 22.2 — erstatter STDB-reducers)
// =============================================================================
export async function createMixerChannel(
accessToken: string,
roomId: string,
targetUserId: string,
): Promise<void> {
await post(accessToken, '/intentions/create_mixer_channel', {
room_id: roomId,
target_user_id: targetUserId,
});
}
export async function setMixerGain(
accessToken: string,
roomId: string,
targetUserId: string,
gain: number,
): Promise<void> {
await post(accessToken, '/intentions/set_gain', {
room_id: roomId,
target_user_id: targetUserId,
gain,
});
}
export async function setMixerMute(
accessToken: string,
roomId: string,
targetUserId: string,
isMuted: boolean,
): Promise<void> {
await post(accessToken, '/intentions/set_mute', {
room_id: roomId,
target_user_id: targetUserId,
is_muted: isMuted,
});
}
export async function toggleMixerEffect(
accessToken: string,
roomId: string,
targetUserId: string,
effectName: string,
): Promise<void> {
await post(accessToken, '/intentions/toggle_effect', {
room_id: roomId,
target_user_id: targetUserId,
effect_name: effectName,
});
}
export async function setMixerRole(
accessToken: string,
roomId: string,
targetUserId: string,
role: string,
): Promise<void> {
await post(accessToken, '/intentions/set_mixer_role', {
room_id: roomId,
target_user_id: targetUserId,
role,
});
}

View file

@ -1,7 +1,13 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore, mixerChannelStore, stdb } from '$lib/spacetime';
import type { MixerChannel } from '$lib/spacetime';
import type { Node, MixerChannel } from '$lib/spacetime';
import { edgeStore, nodeStore, mixerChannelStore } from '$lib/spacetime';
import {
createMixerChannel as apiCreateMixerChannel,
setMixerGain,
setMixerMute,
toggleMixerEffect,
setMixerRole as apiSetMixerRole,
} from '$lib/api';
import TraitPanel from './TraitPanel.svelte';
import SoundPadGrid from './SoundPadGrid.svelte';
import {
@ -57,7 +63,7 @@
let channelLevels: Map<string, ChannelLevels> = $state(new Map());
let masterLevels: ChannelLevels | null = $state(null);
// Voice effect local params (per-client, not synced via STDB)
// Voice effect local params (per-client, not synced)
let voiceParams = $state(new Map<string, {
robotFrequency: number;
robotDepth: number;
@ -78,7 +84,7 @@
// Animation frame for VU meters
let animFrameId: number | null = null;
// Flag to suppress STDB sync feedback for own changes
// Flag to suppress remote sync feedback for own changes
let suppressRemoteSync = false;
// Derive room_id from the collection's communication node
@ -101,7 +107,7 @@
return null;
});
// Get shared mixer channels from STDB for this room
// Delte mixer-kanaler for dette rommet
const sharedChannels = $derived(roomId ? mixerChannelStore.byRoom(roomId) : []);
// Check if the local user is a viewer (read-only)
@ -113,7 +119,7 @@
const isViewer = $derived(localRole === 'viewer');
// Get channel state from STDB shared state, fallback to local
// Hent kanaltilstand fra delt state, fallback til lokalt
function getSharedState(identity: string): { gain: number; muted: boolean } {
if (!roomId) return { gain: getChannelGain(identity), muted: false };
const ch = mixerChannelStore.byParticipant(roomId, identity);
@ -126,14 +132,13 @@
participants = getParticipants();
localIdentity = getLocalIdentity();
// Ensure STDB mixer channels exist for new participants
// Opprett mixer-kanaler for nye deltakere via API
const activeIdentities = getChannelIdentities();
const conn = stdb.getConnection();
if (conn && roomId) {
if (accessToken && roomId) {
for (const id of activeIdentities) {
const existing = mixerChannelStore.byParticipant(roomId, id);
if (!existing) {
conn.reducers.createMixerChannel({ roomId, targetUserId: id, updatedBy: localIdentity });
apiCreateMixerChannel(accessToken, roomId, id).catch(console.error);
}
}
}
@ -141,7 +146,7 @@
return unsub;
});
// Sync Web Audio graph when STDB mixer state changes from other clients
// Synk Web Audio-graf når mixer-tilstand endres fra andre klienter
$effect(() => {
if (suppressRemoteSync) return;
for (const ch of sharedChannels) {
@ -188,15 +193,13 @@
}
}
// Channel controls — update local Web Audio AND broadcast via STDB
// Channel controls — update local Web Audio AND broadcast via API
function handleGainChange(identity: string, value: number) {
setChannelGain(identity, value);
const conn = stdb.getConnection();
if (conn && roomId) {
if (accessToken && roomId) {
suppressRemoteSync = true;
conn.reducers.setGain({ roomId, targetUserId: identity, gain: value, updatedBy: localIdentity });
// Release suppress after a tick to allow STDB callback to settle
setMixerGain(accessToken, roomId, identity, value).catch(console.error);
requestAnimationFrame(() => { suppressRemoteSync = false; });
}
}
@ -211,10 +214,9 @@
unmuteChannel(identity, state.gain);
}
const conn = stdb.getConnection();
if (conn && roomId) {
if (accessToken && roomId) {
suppressRemoteSync = true;
conn.reducers.setMute({ roomId, targetUserId: identity, isMuted: newMuted, updatedBy: localIdentity });
setMixerMute(accessToken, roomId, identity, newMuted).catch(console.error);
requestAnimationFrame(() => { suppressRemoteSync = false; });
}
}
@ -238,15 +240,14 @@
// Access control: set a participant to viewer mode
function handleSetRole(identity: string, role: 'editor' | 'viewer') {
const conn = stdb.getConnection();
if (conn && roomId) {
conn.reducers.setMixerRole({ roomId, targetUserId: identity, role, updatedBy: localIdentity });
if (accessToken && roomId) {
apiSetMixerRole(accessToken, roomId, identity, role).catch(console.error);
}
}
// ─── EQ effect handling ─────────────────────────────────────────────────
// Parse active_effects JSON from STDB into typed state
// Parse active_effects JSON into typed state
function parseEffects(json: string | undefined): EqState & { robot: boolean; monster: boolean } {
if (!json) return { fat_bottom: false, sparkle: false, exciter: false, robot: false, monster: false };
try {
@ -274,10 +275,9 @@
const newEnabled = !current[effect];
setChannelEffect(identity, effect, newEnabled);
const conn = stdb.getConnection();
if (conn && roomId) {
if (accessToken && roomId) {
suppressRemoteSync = true;
conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity });
toggleMixerEffect(accessToken, roomId, identity, effect).catch(console.error);
requestAnimationFrame(() => { suppressRemoteSync = false; });
}
}
@ -295,11 +295,10 @@
setMonsterVoice(identity, newEnabled, params.monsterPitchFactor);
}
// Sync on/off toggle via STDB (params are local)
const conn = stdb.getConnection();
if (conn && roomId) {
// Synk on/off toggle via API (params er lokale)
if (accessToken && roomId) {
suppressRemoteSync = true;
conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity });
toggleMixerEffect(accessToken, roomId, identity, effect).catch(console.error);
requestAnimationFrame(() => { suppressRemoteSync = false; });
}
}
@ -328,14 +327,13 @@
applyEqPreset(identity, preset);
// Sync each effect to STDB
const conn = stdb.getConnection();
if (conn && roomId) {
// Synk hver effekt via API
if (accessToken && roomId) {
suppressRemoteSync = true;
const current = getSharedEffects(identity);
for (const [effect, enabled] of Object.entries(preset.effects)) {
if (current[effect as EqEffectName] !== enabled) {
conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity });
toggleMixerEffect(accessToken, roomId, identity, effect).catch(console.error);
}
}
requestAnimationFrame(() => { suppressRemoteSync = false; });
@ -354,7 +352,7 @@
return 'custom';
}
// Sync EQ state from STDB to Web Audio when remote changes arrive
// Synk EQ-tilstand fra server til Web Audio ved fjernendringer
$effect(() => {
if (suppressRemoteSync) return;
for (const ch of sharedChannels) {

View file

@ -479,7 +479,7 @@ export function applyEqPreset(identity: string, preset: EqPreset): void {
}
/**
* Apply active_effects JSON from STDB to a channel's Web Audio nodes.
* Apply active_effects JSON to a channel's Web Audio nodes.
* Handles both EQ effects (boolean) and voice effects (boolean toggle).
*/
export function applyActiveEffectsJson(identity: string, json: string): void {

View file

@ -1,23 +1,32 @@
/**
* SpacetimeDB connection manager with reactive state.
* WebSocket-tilkobling til portvokteren (maskinrommet).
*
* Establishes WebSocket connection to SpacetimeDB,
* subscribes to nodes and edges tables, binds reactive stores.
* Erstatter SpacetimeDB-klient i Fase M2. Kobler til /ws-endepunktet,
* mottar initial_sync og inkrementelle events, og oppdaterer reactive stores.
*
* Usage:
* import { stdb, connectionState } from '$lib/spacetime/connection.svelte';
* stdb.connect();
* import { wsConnect, wsDisconnect, connectionState } from '$lib/spacetime/connection.svelte';
*/
import { DbConnection, type SubscriptionEventContext, type ErrorContext } from './module_bindings';
import { bindStores } from './stores.svelte';
import { page } from '$app/stores';
import { get } from 'svelte/store';
import type { WsMessage } from './types';
import {
nodeStore,
edgeStore,
nodeAccessStore,
mixerChannelStore,
handleInitialSync,
clearAllStores,
} from './stores.svelte';
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
let connection: DbConnection | null = null;
let ws: WebSocket | null = null;
let _state = $state<ConnectionState>('disconnected');
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** Reactive connection state. Use connectionState.current in components. */
/** Reactive connection state. */
export const connectionState = {
get current() {
return _state;
@ -25,82 +34,100 @@ export const connectionState = {
};
/**
* Connect to SpacetimeDB and subscribe to nodes+edges.
* Token is optional anonymous connections are supported.
* Koble til portvokterens WebSocket-endepunkt.
*/
function connect(token?: string): DbConnection {
if (connection) return connection;
export function wsConnect(accessToken: string) {
if (ws) return;
const url = import.meta.env.VITE_SPACETIMEDB_URL;
const moduleName = import.meta.env.VITE_SPACETIMEDB_MODULE || 'synops';
const apiBase = import.meta.env.VITE_API_URL ?? '/api';
let wsUrl: string;
if (!url) {
console.error('[stdb] VITE_SPACETIMEDB_URL not configured');
_state = 'error';
throw new Error('VITE_SPACETIMEDB_URL not configured');
if (apiBase.startsWith('http')) {
wsUrl = apiBase.replace(/^http/, 'ws') + '/ws';
} else {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${proto}//${window.location.host}${apiBase}/ws`;
}
wsUrl += `?token=${encodeURIComponent(accessToken)}`;
_state = 'connecting';
console.log('[ws] Kobler til portvokteren...');
const builder = DbConnection.builder()
.withUri(url)
.withDatabaseName(moduleName)
.onConnect((conn: DbConnection) => {
console.log('[stdb] Connected');
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[ws] Tilkoblet');
_state = 'connected';
};
// Bind reactive stores to table callbacks
bindStores(conn);
ws.onmessage = (event) => {
try {
const msg: WsMessage = JSON.parse(event.data);
// Subscribe to all nodes and edges
conn.subscriptionBuilder()
.onApplied((_ctx: SubscriptionEventContext) => {
console.log('[stdb] Subscription applied — initial data loaded');
})
.onError((ctx: ErrorContext) => {
console.error('[stdb] Subscription error:', ctx);
})
.subscribe([
'SELECT * FROM node',
'SELECT * FROM edge',
'SELECT * FROM node_access',
'SELECT * FROM mixer_channel',
]);
})
.onConnectError((_ctx: ErrorContext, err: Error) => {
console.error('[stdb] Connection error:', err);
switch (msg.type) {
case 'initial_sync':
console.log(
`[ws] Initial sync: ${msg.nodes?.length ?? 0} noder, ` +
`${msg.edges?.length ?? 0} edges, ${msg.access?.length ?? 0} access, ` +
`${msg.mixer_channels?.length ?? 0} mixer`,
);
handleInitialSync(msg);
break;
case 'node_changed':
nodeStore._handleEvent(msg);
break;
case 'edge_changed':
edgeStore._handleEvent(msg);
break;
case 'access_changed':
nodeAccessStore._handleEvent(msg);
break;
case 'mixer_channel_changed':
mixerChannelStore._handleEvent(msg);
break;
}
} catch {
console.warn('[ws] Ikke-JSON-melding:', event.data);
}
};
ws.onerror = (event) => {
console.error('[ws] Feil:', event);
_state = 'error';
})
.onDisconnect((_ctx: ErrorContext, error?: Error) => {
console.log('[stdb] Disconnected', error ?? '');
connection = null;
_state = 'disconnected';
});
};
ws.onclose = (event) => {
console.log('[ws] Frakoblet:', event.code, event.reason);
ws = null;
_state = 'disconnected';
clearAllStores();
// Auto-reconnect etter 3 sekunder
if (!reconnectTimer) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
const session = get(page)?.data?.session as Record<string, unknown> | undefined;
const token = session?.accessToken as string | undefined;
if (token) {
builder.withToken(token);
console.log('[ws] Automatisk gjenoppkobling...');
wsConnect(token);
}
connection = builder.build();
return connection;
}, 3000);
}
};
}
/** Disconnect and clean up. */
function disconnect() {
if (connection) {
connection.disconnect();
connection = null;
/** Koble fra WebSocket. */
export function wsDisconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.close();
ws = null;
_state = 'disconnected';
clearAllStores();
}
}
/** Get the current connection (null if not connected). */
function getConnection(): DbConnection | null {
return connection;
}
export const stdb = {
connect,
disconnect,
getConnection,
};

View file

@ -2,10 +2,9 @@
* Re-exports for convenient imports.
*
* Usage:
* import { stdb, connectionState, nodeStore, edgeStore } from '$lib/spacetime';
* import { wsConnect, connectionState, nodeStore, edgeStore } from '$lib/spacetime';
*/
export { stdb, connectionState } from './connection.svelte';
export { wsConnect, wsDisconnect, connectionState } from './connection.svelte';
export { nodeStore, edgeStore, nodeAccessStore, mixerChannelStore, nodeVisibility } from './stores.svelte';
export { pgWsConnect, pgWsDisconnect, pgWsState } from './pg-ws.svelte';
export type { Node, Edge, NodeAccess, MixerChannel } from './module_bindings/types';
export type { Node, Edge, NodeAccess, MixerChannel } from './types';

View file

@ -1,119 +0,0 @@
/**
* PG WebSocket verification connection (Fase M1).
*
* Connects to portvokterens /ws endpoint in parallel with SpacetimeDB
* for verification. Logs received events to console for comparison.
*
* This module will replace SpacetimeDB entirely in Fase M2.
*/
import { page } from '$app/stores';
import { get } from 'svelte/store';
export type PgWsState = 'disconnected' | 'connecting' | 'connected' | 'error';
let ws: WebSocket | null = null;
let _state = $state<PgWsState>('disconnected');
let _eventCount = $state(0);
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
/** Reactive connection state for the PG WebSocket. */
export const pgWsState = {
get current() {
return _state;
},
get eventCount() {
return _eventCount;
},
};
/**
* Connect to portvokterens WebSocket endpoint.
* Uses the same access token as maskinrommet API calls.
*/
export function pgWsConnect(accessToken: string) {
if (ws) return;
// Build WebSocket URL from maskinrommet base URL
const apiBase = import.meta.env.VITE_API_URL ?? '/api';
let wsUrl: string;
if (apiBase.startsWith('http')) {
// Absolute URL: convert http(s) → ws(s)
wsUrl = apiBase.replace(/^http/, 'ws') + '/ws';
} else {
// Relative URL: build from current page origin
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl = `${proto}//${window.location.host}${apiBase}/ws`;
}
// Add token as query parameter (browsers can't send headers on WS upgrade)
wsUrl += `?token=${encodeURIComponent(accessToken)}`;
_state = 'connecting';
console.log('[pg-ws] Connecting to', wsUrl.replace(/token=.*/, 'token=***'));
ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[pg-ws] Connected');
_state = 'connected';
};
ws.onmessage = (event) => {
_eventCount++;
try {
const data = JSON.parse(event.data);
const type = data.type;
if (type === 'initial_sync') {
console.log(
`[pg-ws] Initial sync: ${data.nodes?.length ?? 0} nodes, ` +
`${data.edges?.length ?? 0} edges, ${data.access?.length ?? 0} access`,
);
} else {
console.log(`[pg-ws] Event #${_eventCount}:`, type, data);
}
} catch {
console.warn('[pg-ws] Non-JSON message:', event.data);
}
};
ws.onerror = (event) => {
console.error('[pg-ws] Error:', event);
_state = 'error';
};
ws.onclose = (event) => {
console.log('[pg-ws] Disconnected:', event.code, event.reason);
ws = null;
_state = 'disconnected';
// Auto-reconnect after 5 seconds (for verification phase)
if (!reconnectTimer) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
// Re-read token from session
const session = get(page)?.data?.session as Record<string, unknown> | undefined;
const token = session?.accessToken as string | undefined;
if (token) {
console.log('[pg-ws] Auto-reconnecting...');
pgWsConnect(token);
}
}, 5000);
}
};
}
/** Disconnect the PG WebSocket. */
export function pgWsDisconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (ws) {
ws.close();
ws = null;
_state = 'disconnected';
}
}

View file

@ -1,20 +1,28 @@
/**
* Reactive Svelte 5 stores for SpacetimeDB nodes and edges.
* Reactive Svelte 5 stores for nodes, edges, access og mixer-kanaler.
*
* Uses Svelte 5 runes ($state) for fine-grained reactivity.
* Subscribes to table callbacks (onInsert/onDelete/onUpdate) and
* maintains Maps keyed by id for O(1) lookups.
* Bruker Svelte 5 runes ($state) for finkornet reaktivitet.
* Fylles fra portvokterens WebSocket (initial_sync + inkrementelle events).
*
* Usage:
* import { nodeStore, edgeStore } from '$lib/spacetime/stores.svelte';
*
* // In a .svelte component:
* // I en .svelte-komponent:
* const nodes = $derived(nodeStore.all);
* const myEdges = $derived(edgeStore.bySource('user-123'));
*/
import type { Node, Edge, NodeAccess, MixerChannel } from './module_bindings/types';
import type { DbConnection, EventContext } from './module_bindings';
import type {
Node,
Edge,
NodeAccess,
MixerChannel,
WsNodeChanged,
WsEdgeChanged,
WsAccessChanged,
WsMixerChannelChanged,
WsInitialSync,
} from './types';
// ---------------------------------------------------------------------------
// Node store
@ -25,52 +33,48 @@ let _nodeVersion = $state(0);
function createNodeStore() {
return {
/** All nodes as an array. */
get all(): Node[] {
void _nodeVersion;
return [..._nodes.values()];
},
/** Number of nodes. */
get count(): number {
void _nodeVersion;
return _nodes.size;
},
/** Get a node by id. */
get(id: string): Node | undefined {
void _nodeVersion;
return _nodes.get(id);
},
/** Get nodes filtered by node_kind. */
byKind(kind: string): Node[] {
void _nodeVersion;
return [..._nodes.values()].filter((n) => n.nodeKind === kind);
},
/** Get nodes created by a specific user. */
byCreator(userId: string): Node[] {
void _nodeVersion;
return [..._nodes.values()].filter((n) => n.createdBy === userId);
},
// -- Internal callbacks for SpacetimeDB --
_onInsert(_ctx: EventContext, row: Node) {
console.log('[stdb] node inserted:', row.id, row.title);
_nodes.set(row.id, row);
_handleEvent(msg: WsNodeChanged) {
if (msg.op === 'DELETE') {
_nodes.delete(msg.id);
} else if (msg.node) {
_nodes.set(msg.id, msg.node);
}
_nodeVersion++;
},
_onDelete(_ctx: EventContext, row: Node) {
console.log('[stdb] node deleted:', row.id);
_nodes.delete(row.id);
_nodeVersion++;
},
_onUpdate(_ctx: EventContext, _oldRow: Node, newRow: Node) {
console.log('[stdb] node updated:', newRow.id, newRow.title);
_nodes.set(newRow.id, newRow);
_loadInitial(nodes: Node[]) {
_nodes = new Map();
for (const n of nodes) {
_nodes.set(n.id, n);
}
_nodeVersion++;
},
_clear() {
_nodes = new Map();
_nodeVersion++;
@ -86,8 +90,6 @@ export const nodeStore = createNodeStore();
let _edges = $state<Map<string, Edge>>(new Map());
let _edgeVersion = $state(0);
// Secondary indexes for fast lookups
let _edgesBySource = $state<Map<string, Set<string>>>(new Map());
let _edgesByTarget = $state<Map<string, Set<string>>>(new Map());
@ -108,27 +110,33 @@ function removeFromIndex(index: Map<string, Set<string>>, key: string, edgeId: s
}
}
function indexEdge(edge: Edge) {
addToIndex(_edgesBySource, edge.sourceId, edge.id);
addToIndex(_edgesByTarget, edge.targetId, edge.id);
}
function unindexEdge(edge: Edge) {
removeFromIndex(_edgesBySource, edge.sourceId, edge.id);
removeFromIndex(_edgesByTarget, edge.targetId, edge.id);
}
function createEdgeStore() {
return {
/** All edges as an array. */
get all(): Edge[] {
void _edgeVersion;
return [..._edges.values()];
},
/** Number of edges. */
get count(): number {
void _edgeVersion;
return _edges.size;
},
/** Get an edge by id. */
get(id: string): Edge | undefined {
void _edgeVersion;
return _edges.get(id);
},
/** Get all edges originating from a source node. */
bySource(sourceId: string): Edge[] {
void _edgeVersion;
const ids = _edgesBySource.get(sourceId);
@ -136,7 +144,6 @@ function createEdgeStore() {
return [...ids].map((id) => _edges.get(id)!).filter(Boolean);
},
/** Get all edges pointing to a target node. */
byTarget(targetId: string): Edge[] {
void _edgeVersion;
const ids = _edgesByTarget.get(targetId);
@ -144,13 +151,11 @@ function createEdgeStore() {
return [...ids].map((id) => _edges.get(id)!).filter(Boolean);
},
/** Get edges of a specific type. */
byType(edgeType: string): Edge[] {
void _edgeVersion;
return [..._edges.values()].filter((e) => e.edgeType === edgeType);
},
/** Get edges between two specific nodes (any direction). */
between(nodeA: string, nodeB: string): Edge[] {
void _edgeVersion;
return [..._edges.values()].filter(
@ -160,33 +165,31 @@ function createEdgeStore() {
);
},
// -- Internal callbacks for SpacetimeDB --
_onInsert(_ctx: EventContext, row: Edge) {
console.log('[stdb] edge inserted:', row.id, row.edgeType, row.sourceId, '→', row.targetId);
_edges.set(row.id, row);
addToIndex(_edgesBySource, row.sourceId, row.id);
addToIndex(_edgesByTarget, row.targetId, row.id);
_edgeVersion++;
},
_onDelete(_ctx: EventContext, row: Edge) {
console.log('[stdb] edge deleted:', row.id);
_edges.delete(row.id);
removeFromIndex(_edgesBySource, row.sourceId, row.id);
removeFromIndex(_edgesByTarget, row.targetId, row.id);
_edgeVersion++;
},
_onUpdate(_ctx: EventContext, oldRow: Edge, newRow: Edge) {
if (oldRow.sourceId !== newRow.sourceId) {
removeFromIndex(_edgesBySource, oldRow.sourceId, oldRow.id);
addToIndex(_edgesBySource, newRow.sourceId, newRow.id);
_handleEvent(msg: WsEdgeChanged) {
if (msg.op === 'DELETE') {
const old = _edges.get(msg.id);
if (old) unindexEdge(old);
_edges.delete(msg.id);
} else if (msg.edge) {
const old = _edges.get(msg.id);
if (old) unindexEdge(old);
_edges.set(msg.id, msg.edge);
indexEdge(msg.edge);
}
if (oldRow.targetId !== newRow.targetId) {
removeFromIndex(_edgesByTarget, oldRow.targetId, oldRow.id);
addToIndex(_edgesByTarget, newRow.targetId, newRow.id);
}
_edges.set(newRow.id, newRow);
_edgeVersion++;
},
_loadInitial(edges: Edge[]) {
_edges = new Map();
_edgesBySource = new Map();
_edgesByTarget = new Map();
for (const e of edges) {
_edges.set(e.id, e);
indexEdge(e);
}
_edgeVersion++;
},
_clear() {
_edges = new Map();
_edgesBySource = new Map();
@ -204,63 +207,69 @@ export const edgeStore = createEdgeStore();
let _access = $state<Map<string, NodeAccess>>(new Map());
let _accessVersion = $state(0);
// Secondary index: subject_id → set of object_ids
let _accessBySubject = $state<Map<string, Set<string>>>(new Map());
function createNodeAccessStore() {
return {
/** Get all object_ids this subject has access to. */
objectsForSubject(subjectId: string): Set<string> {
void _accessVersion;
return _accessBySubject.get(subjectId) ?? new Set();
},
/** Check if subject has access to object. */
hasAccess(subjectId: string, objectId: string): boolean {
void _accessVersion;
const key = `${subjectId}:${objectId}`;
return _access.has(key);
},
/** Get access level for subject→object, or undefined. */
getAccess(subjectId: string, objectId: string): string | undefined {
void _accessVersion;
const key = `${subjectId}:${objectId}`;
return _access.get(key)?.access;
},
/** Number of access entries. */
get count(): number {
void _accessVersion;
return _access.size;
},
// -- Internal callbacks for SpacetimeDB --
_onInsert(_ctx: EventContext, row: NodeAccess) {
_access.set(row.id, row);
_handleEvent(msg: WsAccessChanged) {
const key = `${msg.subject_id}:${msg.object_id}`;
if (msg.op === 'DELETE') {
_access.delete(key);
const set = _accessBySubject.get(msg.subject_id);
if (set) {
set.delete(msg.object_id);
if (set.size === 0) _accessBySubject.delete(msg.subject_id);
}
} else if (msg.row) {
_access.set(key, msg.row);
let set = _accessBySubject.get(msg.subject_id);
if (!set) {
set = new Set();
_accessBySubject.set(msg.subject_id, set);
}
set.add(msg.object_id);
}
_accessVersion++;
},
_loadInitial(rows: NodeAccess[]) {
_access = new Map();
_accessBySubject = new Map();
for (const row of rows) {
const key = `${row.subjectId}:${row.objectId}`;
_access.set(key, row);
let set = _accessBySubject.get(row.subjectId);
if (!set) {
set = new Set();
_accessBySubject.set(row.subjectId, set);
}
set.add(row.objectId);
_accessVersion++;
},
_onDelete(_ctx: EventContext, row: NodeAccess) {
_access.delete(row.id);
const set = _accessBySubject.get(row.subjectId);
if (set) {
set.delete(row.objectId);
if (set.size === 0) _accessBySubject.delete(row.subjectId);
}
_accessVersion++;
},
_onUpdate(_ctx: EventContext, oldRow: NodeAccess, newRow: NodeAccess) {
_access.set(newRow.id, newRow);
// subject/object don't change (id is composite key), only access level
_accessVersion++;
},
_clear() {
_access = new Map();
_accessBySubject = new Map();
@ -277,25 +286,20 @@ export const nodeAccessStore = createNodeAccessStore();
let _mixerChannels = $state<Map<string, MixerChannel>>(new Map());
let _mixerVersion = $state(0);
// Secondary index: room_id → set of mixer channel ids
let _mixerByRoom = $state<Map<string, Set<string>>>(new Map());
function createMixerChannelStore() {
return {
/** All mixer channels as an array. */
get all(): MixerChannel[] {
void _mixerVersion;
return [..._mixerChannels.values()];
},
/** Get a mixer channel by id ("{room_id}:{target_user_id}"). */
get(id: string): MixerChannel | undefined {
void _mixerVersion;
return _mixerChannels.get(id);
},
/** Get all mixer channels for a room. */
byRoom(roomId: string): MixerChannel[] {
void _mixerVersion;
const ids = _mixerByRoom.get(roomId);
@ -303,43 +307,53 @@ function createMixerChannelStore() {
return [...ids].map((id) => _mixerChannels.get(id)!).filter(Boolean);
},
/** Get a specific channel for a user in a room. */
byParticipant(roomId: string, userId: string): MixerChannel | undefined {
void _mixerVersion;
return _mixerChannels.get(`${roomId}:${userId}`);
},
/** Number of mixer channels. */
get count(): number {
void _mixerVersion;
return _mixerChannels.size;
},
// -- Internal callbacks for SpacetimeDB --
_onInsert(_ctx: EventContext, row: MixerChannel) {
_mixerChannels.set(row.id, row);
let set = _mixerByRoom.get(row.roomId);
_handleEvent(msg: WsMixerChannelChanged) {
const id = `${msg.room_id}:${msg.target_user_id}`;
if (msg.op === 'DELETE') {
_mixerChannels.delete(id);
const set = _mixerByRoom.get(msg.room_id);
if (set) {
set.delete(id);
if (set.size === 0) _mixerByRoom.delete(msg.room_id);
}
} else if (msg.channel) {
_mixerChannels.set(id, msg.channel);
let set = _mixerByRoom.get(msg.room_id);
if (!set) {
set = new Set();
_mixerByRoom.set(row.roomId, set);
_mixerByRoom.set(msg.room_id, set);
}
set.add(row.id);
_mixerVersion++;
},
_onDelete(_ctx: EventContext, row: MixerChannel) {
_mixerChannels.delete(row.id);
const set = _mixerByRoom.get(row.roomId);
if (set) {
set.delete(row.id);
if (set.size === 0) _mixerByRoom.delete(row.roomId);
set.add(id);
}
_mixerVersion++;
},
_onUpdate(_ctx: EventContext, oldRow: MixerChannel, newRow: MixerChannel) {
_mixerChannels.set(newRow.id, newRow);
// room_id doesn't change (part of composite key), so no index update needed
_loadInitial(channels: MixerChannel[]) {
_mixerChannels = new Map();
_mixerByRoom = new Map();
for (const ch of channels) {
const id = ch.id || `${ch.roomId}:${ch.targetUserId}`;
_mixerChannels.set(id, ch);
let set = _mixerByRoom.get(ch.roomId);
if (!set) {
set = new Set();
_mixerByRoom.set(ch.roomId, set);
}
set.add(id);
}
_mixerVersion++;
},
_clear() {
_mixerChannels = new Map();
_mixerByRoom = new Map();
@ -354,34 +368,19 @@ export const mixerChannelStore = createMixerChannelStore();
// Visibility filter
// ---------------------------------------------------------------------------
/**
* Determines if a node is visible to the given user based on:
* 1. User created the node (created_by)
* 2. User has explicit access via node_access
* 3. Node visibility is 'readable' or 'open' (public to all)
* 4. Node visibility is 'discoverable' (visible but content limited)
*
* Returns: 'full' | 'discoverable' | 'hidden'
*/
export function nodeVisibility(
node: Node,
userId: string | undefined,
): 'full' | 'discoverable' | 'hidden' {
if (!userId) {
// Anonymous: only public nodes
if (node.visibility === 'readable' || node.visibility === 'open') return 'full';
if (node.visibility === 'discoverable') return 'discoverable';
return 'hidden';
}
// Own node
if (node.createdBy === userId) return 'full';
// Explicit access via node_access
if (nodeAccessStore.hasAccess(userId, node.id)) return 'full';
// Inherited access: if this node belongs_to a node the user has access to
// (e.g. messages in a communication node)
for (const edge of edgeStore.bySource(node.id)) {
if (edge.edgeType === 'belongs_to') {
const parent = nodeStore.get(edge.targetId);
@ -391,7 +390,6 @@ export function nodeVisibility(
}
}
// Public visibility
if (node.visibility === 'readable' || node.visibility === 'open') return 'full';
if (node.visibility === 'discoverable') return 'discoverable';
@ -399,34 +397,25 @@ export function nodeVisibility(
}
// ---------------------------------------------------------------------------
// Bind stores to a DbConnection
// WebSocket-meldingshåndtering
// ---------------------------------------------------------------------------
/**
* Register table callbacks on a DbConnection.
* Called by connection manager after connect.
* Prosesser en initial_sync-melding og fyll alle stores.
*/
export function bindStores(conn: DbConnection) {
// Clear any stale data
export function handleInitialSync(msg: WsInitialSync) {
nodeStore._loadInitial(msg.nodes);
edgeStore._loadInitial(msg.edges);
nodeAccessStore._loadInitial(msg.access);
mixerChannelStore._loadInitial(msg.mixer_channels ?? []);
}
/**
* Tøm alle stores (kalles ved disconnect).
*/
export function clearAllStores() {
nodeStore._clear();
edgeStore._clear();
nodeAccessStore._clear();
mixerChannelStore._clear();
// Register callbacks
conn.db.node.onInsert(nodeStore._onInsert);
conn.db.node.onDelete(nodeStore._onDelete);
conn.db.node.onUpdate(nodeStore._onUpdate);
conn.db.edge.onInsert(edgeStore._onInsert);
conn.db.edge.onDelete(edgeStore._onDelete);
conn.db.edge.onUpdate(edgeStore._onUpdate);
conn.db.node_access.onInsert(nodeAccessStore._onInsert);
conn.db.node_access.onDelete(nodeAccessStore._onDelete);
conn.db.node_access.onUpdate(nodeAccessStore._onUpdate);
conn.db.mixer_channel.onInsert(mixerChannelStore._onInsert);
conn.db.mixer_channel.onDelete(mixerChannelStore._onDelete);
conn.db.mixer_channel.onUpdate(mixerChannelStore._onUpdate);
}

View file

@ -0,0 +1,102 @@
/**
* Lokale type-definisjoner for noder, edges, access og mixer-kanaler.
*
* Erstatter SpacetimeDB module_bindings/types.ts.
* Feltnavnene matcher JSON-formatet fra portvokterens WebSocket (camelCase).
*/
export interface Node {
id: string;
nodeKind: string;
title: string;
content: string;
visibility: string;
metadata: string;
createdAt: number; // Unix microseconds
createdBy: string;
}
export interface Edge {
id: string;
sourceId: string;
targetId: string;
edgeType: string;
metadata: string;
system: boolean;
createdAt: number;
createdBy: string;
}
export interface NodeAccess {
id: string; // "subjectId:objectId"
subjectId: string;
objectId: string;
access: string;
viaEdge: string;
}
export interface MixerChannel {
id: string; // "roomId:targetUserId"
roomId: string;
targetUserId: string;
gain: number;
isMuted: boolean;
activeEffects: string; // JSON
role: string;
updatedBy: string;
updatedAt: number;
}
// ---------------------------------------------------------------------------
// WebSocket-meldingstyper fra portvokteren
// ---------------------------------------------------------------------------
export interface WsNodeChanged {
type: 'node_changed';
op: string;
id: string;
kind: string;
node?: Node;
}
export interface WsEdgeChanged {
type: 'edge_changed';
op: string;
id: string;
source_id: string;
target_id: string;
edge_type: string;
edge?: Edge;
}
export interface WsAccessChanged {
type: 'access_changed';
op: string;
subject_id: string;
object_id: string;
access?: string;
row?: NodeAccess;
}
export interface WsMixerChannelChanged {
type: 'mixer_channel_changed';
op: string;
room_id: string;
target_user_id: string;
channel?: MixerChannel;
}
export interface WsInitialSync {
type: 'initial_sync';
nodes: Node[];
edges: Edge[];
access: NodeAccess[];
mixer_channels: MixerChannel[];
}
export type WsMessage =
| WsNodeChanged
| WsEdgeChanged
| WsAccessChanged
| WsMixerChannelChanged
| WsInitialSync;

View file

@ -1,31 +1,21 @@
<script lang="ts">
import '../app.css';
import { page } from '$app/stores';
import { stdb, pgWsConnect, pgWsDisconnect } from '$lib/spacetime';
import { wsConnect, wsDisconnect } from '$lib/spacetime';
import { browser } from '$app/environment';
import SystemAnnouncements from '$lib/components/SystemAnnouncements.svelte';
let { children } = $props();
// Connect to SpacetimeDB when authenticated and in browser
$effect(() => {
if (browser && $page.data.session?.user) {
stdb.connect();
}
return () => {
if (browser) stdb.disconnect();
};
});
// Connect PG WebSocket in parallel for verification (Fase M1, oppgave 22.1)
// Koble til portvokterens WebSocket når autentisert og i browser
$effect(() => {
const session = $page.data.session as Record<string, unknown> | undefined;
const accessToken = session?.accessToken as string | undefined;
if (browser && accessToken) {
pgWsConnect(accessToken);
wsConnect(accessToken);
}
return () => {
if (browser) pgWsDisconnect();
if (browser) wsDisconnect();
};
});
</script>

View file

@ -280,8 +280,7 @@ til fordel for PG LISTEN/NOTIFY + WebSocket i portvokteren. Én datakilde,
ingen synk-kompleksitet.
- [x] 22.1 WebSocket-lag i portvokteren: implementer PG LISTEN/NOTIFY-lytter og WebSocket-endepunkt. Legg til PG-triggers (`notify_node_change`, `notify_edge_change`) for nodes og edges. Frontend kobler til begge (STDB + nytt WS) i parallell for verifisering.
- [~] 22.2 Frontend-migrering: erstatt SpacetimeDB-klient med vanlig WebSocket til portvokteren. Erstatt STDB-stores med reaktive stores som lytter på WebSocket. Verifiser all sanntidsfunksjonalitet (chat, kanban, kalender, mixer, canvas).
> Påbegynt: 2026-03-18T12:05
- [x] 22.2 Frontend-migrering: erstatt SpacetimeDB-klient med vanlig WebSocket til portvokteren. Erstatt STDB-stores med reaktive stores som lytter på WebSocket. Verifiser all sanntidsfunksjonalitet (chat, kanban, kalender, mixer, canvas).
- [ ] 22.3 Fjern STDB-skrivestien: portvokteren slutter å skrive til SpacetimeDB. All skriving går kun til PG. NOTIFY-triggere er eneste push-mekanisme. Verifiser at ingenting avhenger av STDB-data.
- [ ] 22.4 Fjern SpacetimeDB: stopp Docker-container, fjern STDB-modul, fjern STDB-klient fra portvokteren og frontend, fjern synkroniseringskode, oppdater docs og CLAUDE.md.
- [ ] 22.5 Opprydding: arkiver STDB-relaterte erfaringsdocs, oppdater alle docs-referanser, fjern Docker-konfig for SpacetimeDB, fjern SpacetimeDB-loven fra feedback-memories.