synops/spacetimedb/src/lib.rs
vegard 8fa2849f0c Legg til node_access i STDB + synk fra maskinrommet
Visibility-filtrering (oppgave 4.3, del 1/2):
- Ny node_access-tabell i STDB-modulen som speiler PG
- Reducers: upsert_node_access, delete_node_access, delete_node_access_for_subject
- STDB-klient i maskinrommet: metoder for node_access
- Warmup synker node_access fra PG til STDB ved oppstart
- Tilgangsgivende edges synker node_access til STDB etter PG-commit
- clear_all tømmer også node_access

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:09:55 +01:00

297 lines
7.9 KiB
Rust

// 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(())
}
// =============================================================================
// 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(())
}