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.
|
og orkestrerer konsekvenser.
|
||||||
|
|
||||||
Sanntid: PG LISTEN/NOTIFY → maskinrommet → WebSocket `/ws` → frontend.
|
Sanntid: PG LISTEN/NOTIFY → maskinrommet → WebSocket `/ws` → frontend.
|
||||||
SpacetimeDB er under utfasing — frontend kobler til begge i parallell
|
SpacetimeDB er under utfasing — frontend bruker kun portvokterens WebSocket (Fase M2).
|
||||||
under verifiseringsfasen (Fase M1).
|
|
||||||
Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet → PG.
|
Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet → PG.
|
||||||
|
|
||||||
## 2. Kommunikasjonskart
|
## 2. Kommunikasjonskart
|
||||||
|
|
@ -20,40 +19,38 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
|
||||||
│ │
|
│ │
|
||||||
│ HTTP (intensjoner) │ WebSocket (sanntid)
|
│ HTTP (intensjoner) │ WebSocket (sanntid)
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌──────────────────────┐ ┌──────────────────┐
|
┌──────────────────────────────────────┐
|
||||||
│ Maskinrommet (Rust) │ │ SpacetimeDB │
|
│ Maskinrommet / Portvokteren (Rust) │
|
||||||
│ axum HTTP API │ │ │
|
│ axum HTTP API + WebSocket │
|
||||||
│ │ │ - Hele grafen │
|
│ │
|
||||||
│ Ansvar: │ │ - Push til │
|
│ Ansvar: │
|
||||||
│ - Validere │ │ klienter │
|
│ - Validere intensjoner │
|
||||||
│ - Skrive STDB+PG │ │ │
|
│ - Skrive til PG │
|
||||||
│ - Orkestrere │ └──────────────────┘
|
│ - Orkestrere bakgrunnsjobber │
|
||||||
│ - Tunge spørringer │
|
│ - Tunge spørringer │
|
||||||
│ - Bakgrunnsjobber │
|
│ - Sanntid via PG LISTEN → WS │
|
||||||
└──┬─────┬─────┬───────┘
|
└──┬─────────────┬─────────────────────┘
|
||||||
│ │ │
|
│ │
|
||||||
▼ ▼ ▼
|
▼ ▼
|
||||||
┌─────┐┌─────┐┌─────────────┐
|
┌─────┐ ┌─────────────┐
|
||||||
│ PG ││STDB ││ Whisper, │
|
│ PG │ │ Whisper, │
|
||||||
│ ││ ││ LiteLLM, │
|
│ │ │ LiteLLM, │
|
||||||
│ ││ ││ LiveKit ... │
|
│ │ │ LiveKit ... │
|
||||||
└─────┘└─────┘└─────────────┘
|
└─────┘ └─────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3. Ansvarsfordeling
|
## 3. Ansvarsfordeling
|
||||||
|
|
||||||
| Komponent | Rolle | Snakker med |
|
| Komponent | Rolle | Snakker med |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **SvelteKit (klient)** | UI, brukerinteraksjon | Maskinrommet (HTTP), SpacetimeDB (WS) |
|
| **SvelteKit (klient)** | UI, brukerinteraksjon | Maskinrommet (HTTP + WS) |
|
||||||
| **Maskinrommet (Rust)** | Intensjons-API, orkestrering, tunge spørringer | PG, SpacetimeDB, CAS, Whisper, LiteLLM |
|
| **Maskinrommet (Rust)** | Intensjons-API, orkestrering, sanntid, tunge spørringer | PG, CAS, Whisper, LiteLLM |
|
||||||
| **SpacetimeDB** | Sanntids state, push til klienter | Klienter (WS), maskinrommet (skriver) |
|
| **PostgreSQL** | Eneste datakilde, sanntid via LISTEN/NOTIFY | Maskinrommet (SQL) |
|
||||||
| **PostgreSQL** | Persistent arkiv, søk, statistikk | Maskinrommet (SQL) |
|
|
||||||
|
|
||||||
## 4. Viktige avklaringer
|
## 4. Viktige avklaringer
|
||||||
|
|
||||||
- **Maskinrommet er en HTTP API-server** (axum). Frontend sender intensjoner hit.
|
- **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 eller STDB.
|
- **Maskinrommet eier alle skrivinger.** Frontend skriver aldri direkte til PG.
|
||||||
- **SpacetimeDB nås direkte** fra klienten via WebSocket for lesing (sanntid).
|
|
||||||
- **SvelteKit er et rent frontend-prosjekt.** Ingen server-side PG-tilgang.
|
- **SvelteKit er et rent frontend-prosjekt.** Ingen server-side PG-tilgang.
|
||||||
- **Bakgrunnsjobber** (Whisper, LLM, TTS) orkestreres av maskinrommet, aldri direkte fra frontend.
|
- **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
|
### Offentlige
|
||||||
- `GET /health` — Helsesjekk. Verifiserer PG- og STDB-tilkobling.
|
- `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.
|
- `GET /ws?token=<JWT>` — WebSocket-oppgradering for sanntidsstrøm.
|
||||||
Token sendes som query-parameter (nettlesere støtter ikke custom headers ved WS upgrade).
|
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.
|
- Ved tilkobling: sender `initial_sync` med alle noder, edges, access og mixer_channels
|
||||||
- Deretter: strømmer `node_changed`, `edge_changed` og `access_changed` events,
|
brukeren kan se.
|
||||||
filtrert på brukerens tilgangsmatrise (node_access).
|
- Deretter: strømmer berikede events (`node_changed`, `edge_changed`, `access_changed`,
|
||||||
- Kilder: PG LISTEN/NOTIFY-triggere på nodes, edges og node_access-tabellene.
|
`mixer_channel_changed`), filtrert på brukerens tilgangsmatrise (node_access).
|
||||||
- Events er JSON med `{ "type": "node_changed"|"edge_changed"|"access_changed", ... }`.
|
- 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>`)
|
### Autentiserte (krever `Authorization: Bearer <JWT>`)
|
||||||
- `GET /me` — Returnerer autentisert brukers `node_id` og `authentik_sub`.
|
- `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 }`
|
- Body (JSON): `{ communication_id }`
|
||||||
- Respons: `{ status: "closed" }`
|
- Respons: `{ status: "closed" }`
|
||||||
|
|
||||||
### SpacetimeDB sanntidstabeller (LiveKit)
|
### Mixer-kanaler (oppgave 22.2)
|
||||||
- `live_room` — Aktive rom. Felt: `room_id`, `communication_id`, `is_active`, `started_at`, `participant_count`.
|
Erstatter SpacetimeDB-reducers for delt mixer-tilstand i LiveKit-rom.
|
||||||
- `room_participant` — Deltakere i rom. Felt: `id`, `room_id`, `user_id`, `display_name`, `role`, `joined_at`.
|
Skriver til PG `mixer_channels`-tabell; NOTIFY-trigger propagerer til WS.
|
||||||
Frontend abonnerer på disse via SpacetimeDB WebSocket for sanntids deltakerliste.
|
- `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
|
## 6. Instruks for Claude Code
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,16 +126,18 @@ i prototype-fasen. Men for produksjon på én server:
|
||||||
|
|
||||||
## Migrasjonsplan: STDB → PG LISTEN/NOTIFY
|
## Migrasjonsplan: STDB → PG LISTEN/NOTIFY
|
||||||
|
|
||||||
### Fase M1: WebSocket-lag i portvokteren
|
### Fase M1: WebSocket-lag i portvokteren ✅
|
||||||
Implementer LISTEN/NOTIFY-lytter og WebSocket-endepunkt i
|
Implementert LISTEN/NOTIFY-lytter og WebSocket-endepunkt i
|
||||||
portvokteren. Legg til PG-triggers for nodes og edges.
|
portvokteren. PG-triggers for nodes, edges og access.
|
||||||
Frontend kobler til begge (STDB + nytt WS) i parallell.
|
Frontend koblet til begge (STDB + nytt WS) i parallell.
|
||||||
|
|
||||||
### Fase M2: Frontend-migrering
|
### Fase M2: Frontend-migrering ✅
|
||||||
Endre frontend fra SpacetimeDB-klient til vanlig WebSocket.
|
Frontend bruker nå kun portvokterens WebSocket. SpacetimeDB-klient
|
||||||
Erstatt STDB-stores med reaktive stores som lytter på
|
fjernet. Reactive stores oppdateres direkte fra WS-meldinger.
|
||||||
portvokterens WebSocket. Verifiser at all sanntidsfunksjonalitet
|
Berikede events: portvokteren henter full raddata fra PG etter
|
||||||
fungerer.
|
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
|
### Fase M3: Fjern skrivestien til STDB
|
||||||
Portvokteren slutter å skrive til SpacetimeDB. All skriving
|
Portvokteren slutter å skrive til SpacetimeDB. All skriving
|
||||||
|
|
|
||||||
|
|
@ -1281,3 +1281,70 @@ export async function fetchNodeUsage(
|
||||||
}
|
}
|
||||||
return res.json();
|
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">
|
<script lang="ts">
|
||||||
import type { Node } from '$lib/spacetime';
|
import type { Node, MixerChannel } from '$lib/spacetime';
|
||||||
import { edgeStore, nodeStore, mixerChannelStore, stdb } from '$lib/spacetime';
|
import { edgeStore, nodeStore, mixerChannelStore } from '$lib/spacetime';
|
||||||
import type { MixerChannel } from '$lib/spacetime';
|
import {
|
||||||
|
createMixerChannel as apiCreateMixerChannel,
|
||||||
|
setMixerGain,
|
||||||
|
setMixerMute,
|
||||||
|
toggleMixerEffect,
|
||||||
|
setMixerRole as apiSetMixerRole,
|
||||||
|
} from '$lib/api';
|
||||||
import TraitPanel from './TraitPanel.svelte';
|
import TraitPanel from './TraitPanel.svelte';
|
||||||
import SoundPadGrid from './SoundPadGrid.svelte';
|
import SoundPadGrid from './SoundPadGrid.svelte';
|
||||||
import {
|
import {
|
||||||
|
|
@ -57,7 +63,7 @@
|
||||||
let channelLevels: Map<string, ChannelLevels> = $state(new Map());
|
let channelLevels: Map<string, ChannelLevels> = $state(new Map());
|
||||||
let masterLevels: ChannelLevels | null = $state(null);
|
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, {
|
let voiceParams = $state(new Map<string, {
|
||||||
robotFrequency: number;
|
robotFrequency: number;
|
||||||
robotDepth: number;
|
robotDepth: number;
|
||||||
|
|
@ -78,7 +84,7 @@
|
||||||
// Animation frame for VU meters
|
// Animation frame for VU meters
|
||||||
let animFrameId: number | null = null;
|
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;
|
let suppressRemoteSync = false;
|
||||||
|
|
||||||
// Derive room_id from the collection's communication node
|
// Derive room_id from the collection's communication node
|
||||||
|
|
@ -101,7 +107,7 @@
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get shared mixer channels from STDB for this room
|
// Delte mixer-kanaler for dette rommet
|
||||||
const sharedChannels = $derived(roomId ? mixerChannelStore.byRoom(roomId) : []);
|
const sharedChannels = $derived(roomId ? mixerChannelStore.byRoom(roomId) : []);
|
||||||
|
|
||||||
// Check if the local user is a viewer (read-only)
|
// Check if the local user is a viewer (read-only)
|
||||||
|
|
@ -113,7 +119,7 @@
|
||||||
|
|
||||||
const isViewer = $derived(localRole === 'viewer');
|
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 } {
|
function getSharedState(identity: string): { gain: number; muted: boolean } {
|
||||||
if (!roomId) return { gain: getChannelGain(identity), muted: false };
|
if (!roomId) return { gain: getChannelGain(identity), muted: false };
|
||||||
const ch = mixerChannelStore.byParticipant(roomId, identity);
|
const ch = mixerChannelStore.byParticipant(roomId, identity);
|
||||||
|
|
@ -126,14 +132,13 @@
|
||||||
participants = getParticipants();
|
participants = getParticipants();
|
||||||
localIdentity = getLocalIdentity();
|
localIdentity = getLocalIdentity();
|
||||||
|
|
||||||
// Ensure STDB mixer channels exist for new participants
|
// Opprett mixer-kanaler for nye deltakere via API
|
||||||
const activeIdentities = getChannelIdentities();
|
const activeIdentities = getChannelIdentities();
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
|
||||||
for (const id of activeIdentities) {
|
for (const id of activeIdentities) {
|
||||||
const existing = mixerChannelStore.byParticipant(roomId, id);
|
const existing = mixerChannelStore.byParticipant(roomId, id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
conn.reducers.createMixerChannel({ roomId, targetUserId: id, updatedBy: localIdentity });
|
apiCreateMixerChannel(accessToken, roomId, id).catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +146,7 @@
|
||||||
return unsub;
|
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(() => {
|
$effect(() => {
|
||||||
if (suppressRemoteSync) return;
|
if (suppressRemoteSync) return;
|
||||||
for (const ch of sharedChannels) {
|
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) {
|
function handleGainChange(identity: string, value: number) {
|
||||||
setChannelGain(identity, value);
|
setChannelGain(identity, value);
|
||||||
|
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
|
||||||
suppressRemoteSync = true;
|
suppressRemoteSync = true;
|
||||||
conn.reducers.setGain({ roomId, targetUserId: identity, gain: value, updatedBy: localIdentity });
|
setMixerGain(accessToken, roomId, identity, value).catch(console.error);
|
||||||
// Release suppress after a tick to allow STDB callback to settle
|
|
||||||
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -211,10 +214,9 @@
|
||||||
unmuteChannel(identity, state.gain);
|
unmuteChannel(identity, state.gain);
|
||||||
}
|
}
|
||||||
|
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
|
||||||
suppressRemoteSync = true;
|
suppressRemoteSync = true;
|
||||||
conn.reducers.setMute({ roomId, targetUserId: identity, isMuted: newMuted, updatedBy: localIdentity });
|
setMixerMute(accessToken, roomId, identity, newMuted).catch(console.error);
|
||||||
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -238,15 +240,14 @@
|
||||||
|
|
||||||
// Access control: set a participant to viewer mode
|
// Access control: set a participant to viewer mode
|
||||||
function handleSetRole(identity: string, role: 'editor' | 'viewer') {
|
function handleSetRole(identity: string, role: 'editor' | 'viewer') {
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
apiSetMixerRole(accessToken, roomId, identity, role).catch(console.error);
|
||||||
conn.reducers.setMixerRole({ roomId, targetUserId: identity, role, updatedBy: localIdentity });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── EQ effect handling ─────────────────────────────────────────────────
|
// ─── 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 } {
|
function parseEffects(json: string | undefined): EqState & { robot: boolean; monster: boolean } {
|
||||||
if (!json) return { fat_bottom: false, sparkle: false, exciter: false, robot: false, monster: false };
|
if (!json) return { fat_bottom: false, sparkle: false, exciter: false, robot: false, monster: false };
|
||||||
try {
|
try {
|
||||||
|
|
@ -274,10 +275,9 @@
|
||||||
const newEnabled = !current[effect];
|
const newEnabled = !current[effect];
|
||||||
setChannelEffect(identity, effect, newEnabled);
|
setChannelEffect(identity, effect, newEnabled);
|
||||||
|
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
|
||||||
suppressRemoteSync = true;
|
suppressRemoteSync = true;
|
||||||
conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity });
|
toggleMixerEffect(accessToken, roomId, identity, effect).catch(console.error);
|
||||||
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -295,11 +295,10 @@
|
||||||
setMonsterVoice(identity, newEnabled, params.monsterPitchFactor);
|
setMonsterVoice(identity, newEnabled, params.monsterPitchFactor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync on/off toggle via STDB (params are local)
|
// Synk on/off toggle via API (params er lokale)
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
|
||||||
suppressRemoteSync = true;
|
suppressRemoteSync = true;
|
||||||
conn.reducers.toggleEffect({ roomId, targetUserId: identity, effectName: effect, updatedBy: localIdentity });
|
toggleMixerEffect(accessToken, roomId, identity, effect).catch(console.error);
|
||||||
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -328,14 +327,13 @@
|
||||||
|
|
||||||
applyEqPreset(identity, preset);
|
applyEqPreset(identity, preset);
|
||||||
|
|
||||||
// Sync each effect to STDB
|
// Synk hver effekt via API
|
||||||
const conn = stdb.getConnection();
|
if (accessToken && roomId) {
|
||||||
if (conn && roomId) {
|
|
||||||
suppressRemoteSync = true;
|
suppressRemoteSync = true;
|
||||||
const current = getSharedEffects(identity);
|
const current = getSharedEffects(identity);
|
||||||
for (const [effect, enabled] of Object.entries(preset.effects)) {
|
for (const [effect, enabled] of Object.entries(preset.effects)) {
|
||||||
if (current[effect as EqEffectName] !== enabled) {
|
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; });
|
requestAnimationFrame(() => { suppressRemoteSync = false; });
|
||||||
|
|
@ -354,7 +352,7 @@
|
||||||
return 'custom';
|
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(() => {
|
$effect(() => {
|
||||||
if (suppressRemoteSync) return;
|
if (suppressRemoteSync) return;
|
||||||
for (const ch of sharedChannels) {
|
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).
|
* Handles both EQ effects (boolean) and voice effects (boolean toggle).
|
||||||
*/
|
*/
|
||||||
export function applyActiveEffectsJson(identity: string, json: string): void {
|
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,
|
* Erstatter SpacetimeDB-klient i Fase M2. Kobler til /ws-endepunktet,
|
||||||
* subscribes to nodes and edges tables, binds reactive stores.
|
* mottar initial_sync og inkrementelle events, og oppdaterer reactive stores.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { stdb, connectionState } from '$lib/spacetime/connection.svelte';
|
* import { wsConnect, wsDisconnect, connectionState } from '$lib/spacetime/connection.svelte';
|
||||||
* stdb.connect();
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DbConnection, type SubscriptionEventContext, type ErrorContext } from './module_bindings';
|
import { page } from '$app/stores';
|
||||||
import { bindStores } from './stores.svelte';
|
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';
|
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
let connection: DbConnection | null = null;
|
let ws: WebSocket | null = null;
|
||||||
let _state = $state<ConnectionState>('disconnected');
|
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 = {
|
export const connectionState = {
|
||||||
get current() {
|
get current() {
|
||||||
return _state;
|
return _state;
|
||||||
|
|
@ -25,82 +34,100 @@ export const connectionState = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to SpacetimeDB and subscribe to nodes+edges.
|
* Koble til portvokterens WebSocket-endepunkt.
|
||||||
* Token is optional — anonymous connections are supported.
|
|
||||||
*/
|
*/
|
||||||
function connect(token?: string): DbConnection {
|
export function wsConnect(accessToken: string) {
|
||||||
if (connection) return connection;
|
if (ws) return;
|
||||||
|
|
||||||
const url = import.meta.env.VITE_SPACETIMEDB_URL;
|
const apiBase = import.meta.env.VITE_API_URL ?? '/api';
|
||||||
const moduleName = import.meta.env.VITE_SPACETIMEDB_MODULE || 'synops';
|
let wsUrl: string;
|
||||||
|
|
||||||
if (!url) {
|
if (apiBase.startsWith('http')) {
|
||||||
console.error('[stdb] VITE_SPACETIMEDB_URL not configured');
|
wsUrl = apiBase.replace(/^http/, 'ws') + '/ws';
|
||||||
_state = 'error';
|
} else {
|
||||||
throw new Error('VITE_SPACETIMEDB_URL not configured');
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
wsUrl = `${proto}//${window.location.host}${apiBase}/ws`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
wsUrl += `?token=${encodeURIComponent(accessToken)}`;
|
||||||
|
|
||||||
_state = 'connecting';
|
_state = 'connecting';
|
||||||
|
console.log('[ws] Kobler til portvokteren...');
|
||||||
|
|
||||||
const builder = DbConnection.builder()
|
ws = new WebSocket(wsUrl);
|
||||||
.withUri(url)
|
|
||||||
.withDatabaseName(moduleName)
|
ws.onopen = () => {
|
||||||
.onConnect((conn: DbConnection) => {
|
console.log('[ws] Tilkoblet');
|
||||||
console.log('[stdb] Connected');
|
|
||||||
_state = 'connected';
|
_state = 'connected';
|
||||||
|
};
|
||||||
|
|
||||||
// Bind reactive stores to table callbacks
|
ws.onmessage = (event) => {
|
||||||
bindStores(conn);
|
try {
|
||||||
|
const msg: WsMessage = JSON.parse(event.data);
|
||||||
|
|
||||||
// Subscribe to all nodes and edges
|
switch (msg.type) {
|
||||||
conn.subscriptionBuilder()
|
case 'initial_sync':
|
||||||
.onApplied((_ctx: SubscriptionEventContext) => {
|
console.log(
|
||||||
console.log('[stdb] Subscription applied — initial data loaded');
|
`[ws] Initial sync: ${msg.nodes?.length ?? 0} noder, ` +
|
||||||
})
|
`${msg.edges?.length ?? 0} edges, ${msg.access?.length ?? 0} access, ` +
|
||||||
.onError((ctx: ErrorContext) => {
|
`${msg.mixer_channels?.length ?? 0} mixer`,
|
||||||
console.error('[stdb] Subscription error:', ctx);
|
);
|
||||||
})
|
handleInitialSync(msg);
|
||||||
.subscribe([
|
break;
|
||||||
'SELECT * FROM node',
|
case 'node_changed':
|
||||||
'SELECT * FROM edge',
|
nodeStore._handleEvent(msg);
|
||||||
'SELECT * FROM node_access',
|
break;
|
||||||
'SELECT * FROM mixer_channel',
|
case 'edge_changed':
|
||||||
]);
|
edgeStore._handleEvent(msg);
|
||||||
})
|
break;
|
||||||
.onConnectError((_ctx: ErrorContext, err: Error) => {
|
case 'access_changed':
|
||||||
console.error('[stdb] Connection error:', err);
|
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';
|
_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) {
|
if (token) {
|
||||||
builder.withToken(token);
|
console.log('[ws] Automatisk gjenoppkobling...');
|
||||||
|
wsConnect(token);
|
||||||
}
|
}
|
||||||
|
}, 3000);
|
||||||
connection = builder.build();
|
}
|
||||||
return connection;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disconnect and clean up. */
|
/** Koble fra WebSocket. */
|
||||||
function disconnect() {
|
export function wsDisconnect() {
|
||||||
if (connection) {
|
if (reconnectTimer) {
|
||||||
connection.disconnect();
|
clearTimeout(reconnectTimer);
|
||||||
connection = null;
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
_state = 'disconnected';
|
_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.
|
* Re-exports for convenient imports.
|
||||||
*
|
*
|
||||||
* Usage:
|
* 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 { nodeStore, edgeStore, nodeAccessStore, mixerChannelStore, nodeVisibility } from './stores.svelte';
|
||||||
export { pgWsConnect, pgWsDisconnect, pgWsState } from './pg-ws.svelte';
|
export type { Node, Edge, NodeAccess, MixerChannel } from './types';
|
||||||
export type { Node, Edge, NodeAccess, MixerChannel } from './module_bindings/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.
|
* Bruker Svelte 5 runes ($state) for finkornet reaktivitet.
|
||||||
* Subscribes to table callbacks (onInsert/onDelete/onUpdate) and
|
* Fylles fra portvokterens WebSocket (initial_sync + inkrementelle events).
|
||||||
* maintains Maps keyed by id for O(1) lookups.
|
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* import { nodeStore, edgeStore } from '$lib/spacetime/stores.svelte';
|
* import { nodeStore, edgeStore } from '$lib/spacetime/stores.svelte';
|
||||||
*
|
*
|
||||||
* // In a .svelte component:
|
* // I en .svelte-komponent:
|
||||||
* const nodes = $derived(nodeStore.all);
|
* const nodes = $derived(nodeStore.all);
|
||||||
* const myEdges = $derived(edgeStore.bySource('user-123'));
|
* const myEdges = $derived(edgeStore.bySource('user-123'));
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Node, Edge, NodeAccess, MixerChannel } from './module_bindings/types';
|
import type {
|
||||||
import type { DbConnection, EventContext } from './module_bindings';
|
Node,
|
||||||
|
Edge,
|
||||||
|
NodeAccess,
|
||||||
|
MixerChannel,
|
||||||
|
WsNodeChanged,
|
||||||
|
WsEdgeChanged,
|
||||||
|
WsAccessChanged,
|
||||||
|
WsMixerChannelChanged,
|
||||||
|
WsInitialSync,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Node store
|
// Node store
|
||||||
|
|
@ -25,52 +33,48 @@ let _nodeVersion = $state(0);
|
||||||
|
|
||||||
function createNodeStore() {
|
function createNodeStore() {
|
||||||
return {
|
return {
|
||||||
/** All nodes as an array. */
|
|
||||||
get all(): Node[] {
|
get all(): Node[] {
|
||||||
void _nodeVersion;
|
void _nodeVersion;
|
||||||
return [..._nodes.values()];
|
return [..._nodes.values()];
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Number of nodes. */
|
|
||||||
get count(): number {
|
get count(): number {
|
||||||
void _nodeVersion;
|
void _nodeVersion;
|
||||||
return _nodes.size;
|
return _nodes.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get a node by id. */
|
|
||||||
get(id: string): Node | undefined {
|
get(id: string): Node | undefined {
|
||||||
void _nodeVersion;
|
void _nodeVersion;
|
||||||
return _nodes.get(id);
|
return _nodes.get(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get nodes filtered by node_kind. */
|
|
||||||
byKind(kind: string): Node[] {
|
byKind(kind: string): Node[] {
|
||||||
void _nodeVersion;
|
void _nodeVersion;
|
||||||
return [..._nodes.values()].filter((n) => n.nodeKind === kind);
|
return [..._nodes.values()].filter((n) => n.nodeKind === kind);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get nodes created by a specific user. */
|
|
||||||
byCreator(userId: string): Node[] {
|
byCreator(userId: string): Node[] {
|
||||||
void _nodeVersion;
|
void _nodeVersion;
|
||||||
return [..._nodes.values()].filter((n) => n.createdBy === userId);
|
return [..._nodes.values()].filter((n) => n.createdBy === userId);
|
||||||
},
|
},
|
||||||
|
|
||||||
// -- Internal callbacks for SpacetimeDB --
|
_handleEvent(msg: WsNodeChanged) {
|
||||||
_onInsert(_ctx: EventContext, row: Node) {
|
if (msg.op === 'DELETE') {
|
||||||
console.log('[stdb] node inserted:', row.id, row.title);
|
_nodes.delete(msg.id);
|
||||||
_nodes.set(row.id, row);
|
} else if (msg.node) {
|
||||||
|
_nodes.set(msg.id, msg.node);
|
||||||
|
}
|
||||||
_nodeVersion++;
|
_nodeVersion++;
|
||||||
},
|
},
|
||||||
_onDelete(_ctx: EventContext, row: Node) {
|
|
||||||
console.log('[stdb] node deleted:', row.id);
|
_loadInitial(nodes: Node[]) {
|
||||||
_nodes.delete(row.id);
|
_nodes = new Map();
|
||||||
_nodeVersion++;
|
for (const n of nodes) {
|
||||||
},
|
_nodes.set(n.id, n);
|
||||||
_onUpdate(_ctx: EventContext, _oldRow: Node, newRow: Node) {
|
}
|
||||||
console.log('[stdb] node updated:', newRow.id, newRow.title);
|
|
||||||
_nodes.set(newRow.id, newRow);
|
|
||||||
_nodeVersion++;
|
_nodeVersion++;
|
||||||
},
|
},
|
||||||
|
|
||||||
_clear() {
|
_clear() {
|
||||||
_nodes = new Map();
|
_nodes = new Map();
|
||||||
_nodeVersion++;
|
_nodeVersion++;
|
||||||
|
|
@ -86,8 +90,6 @@ export const nodeStore = createNodeStore();
|
||||||
|
|
||||||
let _edges = $state<Map<string, Edge>>(new Map());
|
let _edges = $state<Map<string, Edge>>(new Map());
|
||||||
let _edgeVersion = $state(0);
|
let _edgeVersion = $state(0);
|
||||||
|
|
||||||
// Secondary indexes for fast lookups
|
|
||||||
let _edgesBySource = $state<Map<string, Set<string>>>(new Map());
|
let _edgesBySource = $state<Map<string, Set<string>>>(new Map());
|
||||||
let _edgesByTarget = $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() {
|
function createEdgeStore() {
|
||||||
return {
|
return {
|
||||||
/** All edges as an array. */
|
|
||||||
get all(): Edge[] {
|
get all(): Edge[] {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
return [..._edges.values()];
|
return [..._edges.values()];
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Number of edges. */
|
|
||||||
get count(): number {
|
get count(): number {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
return _edges.size;
|
return _edges.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get an edge by id. */
|
|
||||||
get(id: string): Edge | undefined {
|
get(id: string): Edge | undefined {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
return _edges.get(id);
|
return _edges.get(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get all edges originating from a source node. */
|
|
||||||
bySource(sourceId: string): Edge[] {
|
bySource(sourceId: string): Edge[] {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
const ids = _edgesBySource.get(sourceId);
|
const ids = _edgesBySource.get(sourceId);
|
||||||
|
|
@ -136,7 +144,6 @@ function createEdgeStore() {
|
||||||
return [...ids].map((id) => _edges.get(id)!).filter(Boolean);
|
return [...ids].map((id) => _edges.get(id)!).filter(Boolean);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get all edges pointing to a target node. */
|
|
||||||
byTarget(targetId: string): Edge[] {
|
byTarget(targetId: string): Edge[] {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
const ids = _edgesByTarget.get(targetId);
|
const ids = _edgesByTarget.get(targetId);
|
||||||
|
|
@ -144,13 +151,11 @@ function createEdgeStore() {
|
||||||
return [...ids].map((id) => _edges.get(id)!).filter(Boolean);
|
return [...ids].map((id) => _edges.get(id)!).filter(Boolean);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get edges of a specific type. */
|
|
||||||
byType(edgeType: string): Edge[] {
|
byType(edgeType: string): Edge[] {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
return [..._edges.values()].filter((e) => e.edgeType === edgeType);
|
return [..._edges.values()].filter((e) => e.edgeType === edgeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get edges between two specific nodes (any direction). */
|
|
||||||
between(nodeA: string, nodeB: string): Edge[] {
|
between(nodeA: string, nodeB: string): Edge[] {
|
||||||
void _edgeVersion;
|
void _edgeVersion;
|
||||||
return [..._edges.values()].filter(
|
return [..._edges.values()].filter(
|
||||||
|
|
@ -160,33 +165,31 @@ function createEdgeStore() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// -- Internal callbacks for SpacetimeDB --
|
_handleEvent(msg: WsEdgeChanged) {
|
||||||
_onInsert(_ctx: EventContext, row: Edge) {
|
if (msg.op === 'DELETE') {
|
||||||
console.log('[stdb] edge inserted:', row.id, row.edgeType, row.sourceId, '→', row.targetId);
|
const old = _edges.get(msg.id);
|
||||||
_edges.set(row.id, row);
|
if (old) unindexEdge(old);
|
||||||
addToIndex(_edgesBySource, row.sourceId, row.id);
|
_edges.delete(msg.id);
|
||||||
addToIndex(_edgesByTarget, row.targetId, row.id);
|
} else if (msg.edge) {
|
||||||
_edgeVersion++;
|
const old = _edges.get(msg.id);
|
||||||
},
|
if (old) unindexEdge(old);
|
||||||
_onDelete(_ctx: EventContext, row: Edge) {
|
_edges.set(msg.id, msg.edge);
|
||||||
console.log('[stdb] edge deleted:', row.id);
|
indexEdge(msg.edge);
|
||||||
_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);
|
|
||||||
}
|
}
|
||||||
if (oldRow.targetId !== newRow.targetId) {
|
|
||||||
removeFromIndex(_edgesByTarget, oldRow.targetId, oldRow.id);
|
|
||||||
addToIndex(_edgesByTarget, newRow.targetId, newRow.id);
|
|
||||||
}
|
|
||||||
_edges.set(newRow.id, newRow);
|
|
||||||
_edgeVersion++;
|
_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() {
|
_clear() {
|
||||||
_edges = new Map();
|
_edges = new Map();
|
||||||
_edgesBySource = new Map();
|
_edgesBySource = new Map();
|
||||||
|
|
@ -204,63 +207,69 @@ export const edgeStore = createEdgeStore();
|
||||||
|
|
||||||
let _access = $state<Map<string, NodeAccess>>(new Map());
|
let _access = $state<Map<string, NodeAccess>>(new Map());
|
||||||
let _accessVersion = $state(0);
|
let _accessVersion = $state(0);
|
||||||
|
|
||||||
// Secondary index: subject_id → set of object_ids
|
|
||||||
let _accessBySubject = $state<Map<string, Set<string>>>(new Map());
|
let _accessBySubject = $state<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
function createNodeAccessStore() {
|
function createNodeAccessStore() {
|
||||||
return {
|
return {
|
||||||
/** Get all object_ids this subject has access to. */
|
|
||||||
objectsForSubject(subjectId: string): Set<string> {
|
objectsForSubject(subjectId: string): Set<string> {
|
||||||
void _accessVersion;
|
void _accessVersion;
|
||||||
return _accessBySubject.get(subjectId) ?? new Set();
|
return _accessBySubject.get(subjectId) ?? new Set();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Check if subject has access to object. */
|
|
||||||
hasAccess(subjectId: string, objectId: string): boolean {
|
hasAccess(subjectId: string, objectId: string): boolean {
|
||||||
void _accessVersion;
|
void _accessVersion;
|
||||||
const key = `${subjectId}:${objectId}`;
|
const key = `${subjectId}:${objectId}`;
|
||||||
return _access.has(key);
|
return _access.has(key);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get access level for subject→object, or undefined. */
|
|
||||||
getAccess(subjectId: string, objectId: string): string | undefined {
|
getAccess(subjectId: string, objectId: string): string | undefined {
|
||||||
void _accessVersion;
|
void _accessVersion;
|
||||||
const key = `${subjectId}:${objectId}`;
|
const key = `${subjectId}:${objectId}`;
|
||||||
return _access.get(key)?.access;
|
return _access.get(key)?.access;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Number of access entries. */
|
|
||||||
get count(): number {
|
get count(): number {
|
||||||
void _accessVersion;
|
void _accessVersion;
|
||||||
return _access.size;
|
return _access.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
// -- Internal callbacks for SpacetimeDB --
|
_handleEvent(msg: WsAccessChanged) {
|
||||||
_onInsert(_ctx: EventContext, row: NodeAccess) {
|
const key = `${msg.subject_id}:${msg.object_id}`;
|
||||||
_access.set(row.id, row);
|
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);
|
let set = _accessBySubject.get(row.subjectId);
|
||||||
if (!set) {
|
if (!set) {
|
||||||
set = new Set();
|
set = new Set();
|
||||||
_accessBySubject.set(row.subjectId, set);
|
_accessBySubject.set(row.subjectId, set);
|
||||||
}
|
}
|
||||||
set.add(row.objectId);
|
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++;
|
_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() {
|
_clear() {
|
||||||
_access = new Map();
|
_access = new Map();
|
||||||
_accessBySubject = new Map();
|
_accessBySubject = new Map();
|
||||||
|
|
@ -277,25 +286,20 @@ export const nodeAccessStore = createNodeAccessStore();
|
||||||
|
|
||||||
let _mixerChannels = $state<Map<string, MixerChannel>>(new Map());
|
let _mixerChannels = $state<Map<string, MixerChannel>>(new Map());
|
||||||
let _mixerVersion = $state(0);
|
let _mixerVersion = $state(0);
|
||||||
|
|
||||||
// Secondary index: room_id → set of mixer channel ids
|
|
||||||
let _mixerByRoom = $state<Map<string, Set<string>>>(new Map());
|
let _mixerByRoom = $state<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
function createMixerChannelStore() {
|
function createMixerChannelStore() {
|
||||||
return {
|
return {
|
||||||
/** All mixer channels as an array. */
|
|
||||||
get all(): MixerChannel[] {
|
get all(): MixerChannel[] {
|
||||||
void _mixerVersion;
|
void _mixerVersion;
|
||||||
return [..._mixerChannels.values()];
|
return [..._mixerChannels.values()];
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get a mixer channel by id ("{room_id}:{target_user_id}"). */
|
|
||||||
get(id: string): MixerChannel | undefined {
|
get(id: string): MixerChannel | undefined {
|
||||||
void _mixerVersion;
|
void _mixerVersion;
|
||||||
return _mixerChannels.get(id);
|
return _mixerChannels.get(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Get all mixer channels for a room. */
|
|
||||||
byRoom(roomId: string): MixerChannel[] {
|
byRoom(roomId: string): MixerChannel[] {
|
||||||
void _mixerVersion;
|
void _mixerVersion;
|
||||||
const ids = _mixerByRoom.get(roomId);
|
const ids = _mixerByRoom.get(roomId);
|
||||||
|
|
@ -303,43 +307,53 @@ function createMixerChannelStore() {
|
||||||
return [...ids].map((id) => _mixerChannels.get(id)!).filter(Boolean);
|
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 {
|
byParticipant(roomId: string, userId: string): MixerChannel | undefined {
|
||||||
void _mixerVersion;
|
void _mixerVersion;
|
||||||
return _mixerChannels.get(`${roomId}:${userId}`);
|
return _mixerChannels.get(`${roomId}:${userId}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Number of mixer channels. */
|
|
||||||
get count(): number {
|
get count(): number {
|
||||||
void _mixerVersion;
|
void _mixerVersion;
|
||||||
return _mixerChannels.size;
|
return _mixerChannels.size;
|
||||||
},
|
},
|
||||||
|
|
||||||
// -- Internal callbacks for SpacetimeDB --
|
_handleEvent(msg: WsMixerChannelChanged) {
|
||||||
_onInsert(_ctx: EventContext, row: MixerChannel) {
|
const id = `${msg.room_id}:${msg.target_user_id}`;
|
||||||
_mixerChannels.set(row.id, row);
|
if (msg.op === 'DELETE') {
|
||||||
let set = _mixerByRoom.get(row.roomId);
|
_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) {
|
if (!set) {
|
||||||
set = new Set();
|
set = new Set();
|
||||||
_mixerByRoom.set(row.roomId, set);
|
_mixerByRoom.set(msg.room_id, set);
|
||||||
}
|
}
|
||||||
set.add(row.id);
|
set.add(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);
|
|
||||||
}
|
}
|
||||||
_mixerVersion++;
|
_mixerVersion++;
|
||||||
},
|
},
|
||||||
_onUpdate(_ctx: EventContext, oldRow: MixerChannel, newRow: MixerChannel) {
|
|
||||||
_mixerChannels.set(newRow.id, newRow);
|
_loadInitial(channels: MixerChannel[]) {
|
||||||
// room_id doesn't change (part of composite key), so no index update needed
|
_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++;
|
_mixerVersion++;
|
||||||
},
|
},
|
||||||
|
|
||||||
_clear() {
|
_clear() {
|
||||||
_mixerChannels = new Map();
|
_mixerChannels = new Map();
|
||||||
_mixerByRoom = new Map();
|
_mixerByRoom = new Map();
|
||||||
|
|
@ -354,34 +368,19 @@ export const mixerChannelStore = createMixerChannelStore();
|
||||||
// Visibility filter
|
// 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(
|
export function nodeVisibility(
|
||||||
node: Node,
|
node: Node,
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
): 'full' | 'discoverable' | 'hidden' {
|
): 'full' | 'discoverable' | 'hidden' {
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Anonymous: only public nodes
|
|
||||||
if (node.visibility === 'readable' || node.visibility === 'open') return 'full';
|
if (node.visibility === 'readable' || node.visibility === 'open') return 'full';
|
||||||
if (node.visibility === 'discoverable') return 'discoverable';
|
if (node.visibility === 'discoverable') return 'discoverable';
|
||||||
return 'hidden';
|
return 'hidden';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Own node
|
|
||||||
if (node.createdBy === userId) return 'full';
|
if (node.createdBy === userId) return 'full';
|
||||||
|
|
||||||
// Explicit access via node_access
|
|
||||||
if (nodeAccessStore.hasAccess(userId, node.id)) return 'full';
|
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)) {
|
for (const edge of edgeStore.bySource(node.id)) {
|
||||||
if (edge.edgeType === 'belongs_to') {
|
if (edge.edgeType === 'belongs_to') {
|
||||||
const parent = nodeStore.get(edge.targetId);
|
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 === 'readable' || node.visibility === 'open') return 'full';
|
||||||
if (node.visibility === 'discoverable') return 'discoverable';
|
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.
|
* Prosesser en initial_sync-melding og fyll alle stores.
|
||||||
* Called by connection manager after connect.
|
|
||||||
*/
|
*/
|
||||||
export function bindStores(conn: DbConnection) {
|
export function handleInitialSync(msg: WsInitialSync) {
|
||||||
// Clear any stale data
|
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();
|
nodeStore._clear();
|
||||||
edgeStore._clear();
|
edgeStore._clear();
|
||||||
nodeAccessStore._clear();
|
nodeAccessStore._clear();
|
||||||
mixerChannelStore._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">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { stdb, pgWsConnect, pgWsDisconnect } from '$lib/spacetime';
|
import { wsConnect, wsDisconnect } from '$lib/spacetime';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import SystemAnnouncements from '$lib/components/SystemAnnouncements.svelte';
|
import SystemAnnouncements from '$lib/components/SystemAnnouncements.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
// Connect to SpacetimeDB when authenticated and in browser
|
// Koble til portvokterens WebSocket når autentisert og i 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)
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const session = $page.data.session as Record<string, unknown> | undefined;
|
const session = $page.data.session as Record<string, unknown> | undefined;
|
||||||
const accessToken = session?.accessToken as string | undefined;
|
const accessToken = session?.accessToken as string | undefined;
|
||||||
if (browser && accessToken) {
|
if (browser && accessToken) {
|
||||||
pgWsConnect(accessToken);
|
wsConnect(accessToken);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
if (browser) pgWsDisconnect();
|
if (browser) wsDisconnect();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</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.
|
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.
|
- [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).
|
- [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).
|
||||||
> Påbegynt: 2026-03-18T12:05
|
|
||||||
- [ ] 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.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.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.
|
- [ ] 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