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:
parent
652d2b8917
commit
8e80102f6b
12 changed files with 506 additions and 443 deletions
|
|
@ -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.1–22.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.0–1.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
102
frontend/src/lib/spacetime/types.ts
Normal file
102
frontend/src/lib/spacetime/types.ts
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue