// Synops SpacetimeDB-modul // // Speiler PG-skjema (nodes + edges) som sanntids-cache. // PG er autoritativ; SpacetimeDB er varm cache for frontend. // Maskinrommet gjør warmup (PG → ST) ved oppstart og toveissynk deretter. // // Ref: docs/retninger/datalaget.md, docs/primitiver/nodes.md, docs/primitiver/edges.md use spacetimedb::{reducer, Table, ReducerContext, Timestamp}; // ============================================================================= // Tabeller // ============================================================================= /// Speiler PG nodes-tabellen. UUID-er representeres som String. /// visibility og node_kind er strenger (PG-enums har ikke STDB-ekvivalent). /// metadata er JSON-streng (PG JSONB). #[spacetimedb::table(accessor = node, public)] pub struct Node { #[primary_key] pub id: String, pub node_kind: String, pub title: String, pub content: String, pub visibility: String, pub metadata: String, pub created_at: Timestamp, pub created_by: String, } /// Speiler PG node_access-tabellen (materialisert tilgangsmatrise). /// subject_id = bruker/team, object_id = noden det gis tilgang til. /// Brukes av frontend for visibility-filtrering. #[spacetimedb::table(accessor = node_access, public)] pub struct NodeAccess { #[primary_key] pub id: String, // "{subject_id}:{object_id}" — kompositt-nøkkel som streng #[index(btree)] pub subject_id: String, #[index(btree)] pub object_id: String, pub access: String, // reader, member, admin, owner pub via_edge: String, } /// Speiler PG edges-tabellen. #[spacetimedb::table(accessor = edge, public)] pub struct Edge { #[primary_key] pub id: String, #[index(btree)] pub source_id: String, #[index(btree)] pub target_id: String, pub edge_type: String, pub metadata: String, pub system: bool, pub created_at: Timestamp, pub created_by: String, } // ============================================================================= // Livssyklus // ============================================================================= #[reducer(init)] pub fn init(_ctx: &ReducerContext) { log::info!("Synops-modul initialisert"); } #[reducer(client_connected)] pub fn client_connected(ctx: &ReducerContext) { log::info!("Klient tilkoblet: {}", ctx.sender().to_hex()); } #[reducer(client_disconnected)] pub fn client_disconnected(ctx: &ReducerContext) { log::info!("Klient frakoblet: {}", ctx.sender().to_hex()); } // ============================================================================= // Node CRUD // ============================================================================= #[reducer] pub fn create_node( ctx: &ReducerContext, id: String, node_kind: String, title: String, content: String, visibility: String, metadata: String, created_by: String, ) -> Result<(), String> { if id.is_empty() { return Err("id kan ikke være tom".into()); } if ctx.db.node().id().find(&id).is_some() { return Err(format!("Node med id {} finnes allerede", id)); } ctx.db.node().insert(Node { id, node_kind, title, content, visibility, metadata, created_at: ctx.timestamp, created_by, }); Ok(()) } #[reducer] pub fn update_node( ctx: &ReducerContext, id: String, node_kind: String, title: String, content: String, visibility: String, metadata: String, ) -> Result<(), String> { let existing = ctx.db.node().id().find(&id) .ok_or_else(|| format!("Node {} ikke funnet", id))?; ctx.db.node().id().update(Node { node_kind, title, content, visibility, metadata, ..existing }); Ok(()) } #[reducer] pub fn delete_node(ctx: &ReducerContext, id: String) -> Result<(), String> { // Slett tilhørende edges først (speiler PG ON DELETE CASCADE) let source_edges: Vec<_> = ctx.db.edge().source_id().filter(&id).collect(); for e in source_edges { ctx.db.edge().id().delete(&e.id); } let target_edges: Vec<_> = ctx.db.edge().target_id().filter(&id).collect(); for e in target_edges { ctx.db.edge().id().delete(&e.id); } ctx.db.node().id().delete(&id); Ok(()) } // ============================================================================= // NodeAccess CRUD // ============================================================================= /// Upsert: opprett eller oppdater tilgang. id = "{subject_id}:{object_id}". #[reducer] pub fn upsert_node_access( ctx: &ReducerContext, subject_id: String, object_id: String, access: String, via_edge: String, ) -> Result<(), String> { let id = format!("{subject_id}:{object_id}"); if let Some(existing) = ctx.db.node_access().id().find(&id) { ctx.db.node_access().id().update(NodeAccess { access, via_edge, ..existing }); } else { ctx.db.node_access().insert(NodeAccess { id, subject_id, object_id, access, via_edge, }); } Ok(()) } /// Slett en spesifikk tilgangsrad. #[reducer] pub fn delete_node_access( ctx: &ReducerContext, subject_id: String, object_id: String, ) -> Result<(), String> { let id = format!("{subject_id}:{object_id}"); ctx.db.node_access().id().delete(&id); Ok(()) } /// Slett all tilgang for et gitt subject (brukes ved fjerning av bruker/team). #[reducer] pub fn delete_node_access_for_subject( ctx: &ReducerContext, subject_id: String, ) -> Result<(), String> { let entries: Vec<_> = ctx.db.node_access().subject_id().filter(&subject_id).collect(); for entry in entries { ctx.db.node_access().id().delete(&entry.id); } Ok(()) } // ============================================================================= // Edge CRUD // ============================================================================= #[reducer] pub fn create_edge( ctx: &ReducerContext, id: String, source_id: String, target_id: String, edge_type: String, metadata: String, system: bool, created_by: String, ) -> Result<(), String> { if id.is_empty() { return Err("id kan ikke være tom".into()); } if ctx.db.edge().id().find(&id).is_some() { return Err(format!("Edge med id {} finnes allerede", id)); } ctx.db.edge().insert(Edge { id, source_id, target_id, edge_type, metadata, system, created_at: ctx.timestamp, created_by, }); Ok(()) } #[reducer] pub fn update_edge( ctx: &ReducerContext, id: String, edge_type: String, metadata: String, ) -> Result<(), String> { let existing = ctx.db.edge().id().find(&id) .ok_or_else(|| format!("Edge {} ikke funnet", id))?; ctx.db.edge().id().update(Edge { edge_type, metadata, ..existing }); Ok(()) } #[reducer] pub fn delete_edge(ctx: &ReducerContext, id: String) -> Result<(), String> { ctx.db.edge().id().delete(&id); Ok(()) } // ============================================================================= // Live-rom (sanntidslyd via LiveKit) // ============================================================================= /// Aktive LiveKit-rom knyttet til kommunikasjonsnoder. /// Transient — finnes bare mens rommet er aktivt. #[spacetimedb::table(accessor = live_room, public)] pub struct LiveRoom { #[primary_key] pub room_id: String, // "communication_{uuid}" #[index(btree)] pub communication_id: String, pub is_active: bool, pub started_at: Timestamp, pub participant_count: u32, } /// Deltakere i aktive LiveKit-rom. #[spacetimedb::table(accessor = room_participant, public)] pub struct RoomParticipant { #[primary_key] pub id: String, // "{room_id}:{user_id}" #[index(btree)] pub room_id: String, #[index(btree)] pub user_id: String, pub display_name: String, pub role: String, // "publisher" | "subscriber" pub joined_at: Timestamp, } #[reducer] pub fn create_live_room( ctx: &ReducerContext, room_id: String, communication_id: String, ) -> Result<(), String> { if room_id.is_empty() { return Err("room_id kan ikke være tom".into()); } // Idempotent — hvis rommet allerede finnes, oppdater if let Some(existing) = ctx.db.live_room().room_id().find(&room_id) { ctx.db.live_room().room_id().update(LiveRoom { is_active: true, ..existing }); return Ok(()); } ctx.db.live_room().insert(LiveRoom { room_id, communication_id, is_active: true, started_at: ctx.timestamp, participant_count: 0, }); Ok(()) } #[reducer] pub fn add_room_participant( ctx: &ReducerContext, room_id: String, user_id: String, display_name: String, role: String, ) -> Result<(), String> { let id = format!("{room_id}:{user_id}"); // Idempotent — oppdater hvis allerede finnes if let Some(existing) = ctx.db.room_participant().id().find(&id) { ctx.db.room_participant().id().update(RoomParticipant { display_name, role, ..existing }); return Ok(()); } ctx.db.room_participant().insert(RoomParticipant { id, room_id: room_id.clone(), user_id, display_name, role, joined_at: ctx.timestamp, }); // Oppdater deltakertelling if let Some(room) = ctx.db.live_room().room_id().find(&room_id) { ctx.db.live_room().room_id().update(LiveRoom { participant_count: room.participant_count + 1, ..room }); } Ok(()) } #[reducer] pub fn remove_room_participant( ctx: &ReducerContext, room_id: String, user_id: String, ) -> Result<(), String> { let id = format!("{room_id}:{user_id}"); ctx.db.room_participant().id().delete(&id); // Oppdater deltakertelling if let Some(room) = ctx.db.live_room().room_id().find(&room_id) { let new_count = room.participant_count.saturating_sub(1); ctx.db.live_room().room_id().update(LiveRoom { participant_count: new_count, ..room }); } Ok(()) } #[reducer] pub fn close_live_room( ctx: &ReducerContext, room_id: String, ) -> Result<(), String> { // Fjern alle deltakere let participants: Vec<_> = ctx.db.room_participant().room_id().filter(&room_id).collect(); for p in participants { ctx.db.room_participant().id().delete(&p.id); } // Marker rommet som inaktivt if let Some(room) = ctx.db.live_room().room_id().find(&room_id) { ctx.db.live_room().room_id().update(LiveRoom { is_active: false, participant_count: 0, ..room }); } Ok(()) } // ============================================================================= // Warmup/vedlikehold // ============================================================================= /// Tøm alle noder, edges og node_access (brukes ved restart/warmup for å unngå duplikater) #[reducer] pub fn clear_all(ctx: &ReducerContext) -> Result<(), String> { let all_access: Vec<_> = ctx.db.node_access().iter().collect(); for a in all_access { ctx.db.node_access().id().delete(&a.id); } let all_edges: Vec<_> = ctx.db.edge().iter().collect(); for e in all_edges { ctx.db.edge().id().delete(&e.id); } let all_nodes: Vec<_> = ctx.db.node().iter().collect(); for n in all_nodes { ctx.db.node().id().delete(&n.id); } log::info!("Alle noder, edges og node_access slettet (clear_all)"); Ok(()) }