synops/maskinrommet/src/stdb.rs
vegard 8bf82a78d9 Implementer message_placements (oppgave 20.1)
Plasseringsrelasjon som sporer hvor meldinger vises på tvers av
kontekster (chat, kanban, storyboard, kalender, notes). Grunnmuren
for universell overføring mellom verktøy-paneler.

Tre deler:
- PG-migrasjon 016: message_placements tabell med UNIQUE constraint
  og indekser for kontekst- og meldingsoppslag
- SpacetimeDB: MessagePlacement tabell + place_message, remove_placement,
  move_on_canvas reducers for sanntids UI-oppdatering
- Maskinrommet: STDB-klientmetoder for de tre reducerne

Avvik fra spec: FK refererer nodes(id) i stedet for messages(id) siden
meldinger er noder (node_kind = 'melding'). Spec oppdatert tilsvarende.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 07:59:07 +00:00

466 lines
12 KiB
Rust

// SpacetimeDB HTTP-klient for maskinrommet.
//
// Kaller STDB-reducere via HTTP JSON API.
// Maskinrommet eier all skriving — denne klienten er eneste vei inn.
//
// API-format: POST /v1/database/{db}/call/{reducer}
// Body: JSON-objekt med navngitte parametre.
// Auth: Bearer-token fra STDB-identitet.
//
// Ref: docs/retninger/datalaget.md, docs/infra/synkronisering.md
use reqwest::Client;
use serde::Serialize;
/// SpacetimeDB-klient som kaller reducere via HTTP.
#[derive(Clone)]
pub struct StdbClient {
client: Client,
base_url: String,
database: String,
token: String,
}
impl StdbClient {
/// Opprett ny klient. `base_url` er STDB-serverens URL (f.eks. "http://spacetimedb:3000").
pub fn new(base_url: &str, database: &str, token: &str) -> Self {
Self {
client: Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
database: database.to_string(),
token: token.to_string(),
}
}
/// Hent en ny identitet og token fra STDB-serveren.
/// Brukes ved oppstart hvis ingen token er konfigurert.
pub async fn create_identity(base_url: &str) -> Result<(String, String), StdbError> {
let client = Client::new();
let url = format!("{}/v1/identity", base_url.trim_end_matches('/'));
let resp = client.post(&url).send().await?;
if !resp.status().is_success() {
return Err(StdbError::Http(format!(
"Kunne ikke opprette identitet: {}",
resp.status()
)));
}
let body: serde_json::Value = resp.json().await?;
let identity = body["identity"]
.as_str()
.ok_or_else(|| StdbError::Http("Mangler identity i respons".into()))?
.to_string();
let token = body["token"]
.as_str()
.ok_or_else(|| StdbError::Http("Mangler token i respons".into()))?
.to_string();
Ok((identity, token))
}
/// Kall en reducer med navngitte parametre (JSON-objekt).
async fn call_reducer<T: Serialize>(&self, reducer: &str, args: &T) -> Result<(), StdbError> {
let url = format!(
"{}/v1/database/{}/call/{}",
self.base_url, self.database, reducer
);
let resp = self
.client
.post(&url)
.bearer_auth(&self.token)
.json(args)
.send()
.await?;
if resp.status().is_success() {
Ok(())
} else {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
Err(StdbError::Reducer {
reducer: reducer.to_string(),
status: status.as_u16(),
message: body,
})
}
}
// =========================================================================
// Node-operasjoner
// =========================================================================
pub async fn create_node(
&self,
id: &str,
node_kind: &str,
title: &str,
content: &str,
visibility: &str,
metadata: &str,
created_by: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
node_kind: &'a str,
title: &'a str,
content: &'a str,
visibility: &'a str,
metadata: &'a str,
created_by: &'a str,
}
self.call_reducer(
"create_node",
&Args {
id,
node_kind,
title,
content,
visibility,
metadata,
created_by,
},
)
.await
}
pub async fn update_node(
&self,
id: &str,
node_kind: &str,
title: &str,
content: &str,
visibility: &str,
metadata: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
node_kind: &'a str,
title: &'a str,
content: &'a str,
visibility: &'a str,
metadata: &'a str,
}
self.call_reducer(
"update_node",
&Args {
id,
node_kind,
title,
content,
visibility,
metadata,
},
)
.await
}
pub async fn delete_node(&self, id: &str) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
}
self.call_reducer("delete_node", &Args { id }).await
}
// =========================================================================
// Edge-operasjoner
// =========================================================================
pub async fn create_edge(
&self,
id: &str,
source_id: &str,
target_id: &str,
edge_type: &str,
metadata: &str,
system: bool,
created_by: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
source_id: &'a str,
target_id: &'a str,
edge_type: &'a str,
metadata: &'a str,
system: bool,
created_by: &'a str,
}
self.call_reducer(
"create_edge",
&Args {
id,
source_id,
target_id,
edge_type,
metadata,
system,
created_by,
},
)
.await
}
pub async fn update_edge(
&self,
id: &str,
edge_type: &str,
metadata: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
edge_type: &'a str,
metadata: &'a str,
}
self.call_reducer("update_edge", &Args { id, edge_type, metadata })
.await
}
pub async fn delete_edge(&self, id: &str) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
}
self.call_reducer("delete_edge", &Args { id }).await
}
// =========================================================================
// NodeAccess-operasjoner
// =========================================================================
pub async fn upsert_node_access(
&self,
subject_id: &str,
object_id: &str,
access: &str,
via_edge: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
subject_id: &'a str,
object_id: &'a str,
access: &'a str,
via_edge: &'a str,
}
self.call_reducer(
"upsert_node_access",
&Args {
subject_id,
object_id,
access,
via_edge,
},
)
.await
}
pub async fn delete_node_access(
&self,
subject_id: &str,
object_id: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
subject_id: &'a str,
object_id: &'a str,
}
self.call_reducer("delete_node_access", &Args { subject_id, object_id })
.await
}
// =========================================================================
// Live-rom (LiveKit)
// =========================================================================
pub async fn create_live_room(
&self,
room_id: &str,
communication_id: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
room_id: &'a str,
communication_id: &'a str,
}
self.call_reducer("create_live_room", &Args { room_id, communication_id })
.await
}
pub async fn add_room_participant(
&self,
room_id: &str,
user_id: &str,
display_name: &str,
role: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
room_id: &'a str,
user_id: &'a str,
display_name: &'a str,
role: &'a str,
}
self.call_reducer(
"add_room_participant",
&Args { room_id, user_id, display_name, role },
)
.await
}
pub async fn remove_room_participant(
&self,
room_id: &str,
user_id: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
room_id: &'a str,
user_id: &'a str,
}
self.call_reducer("remove_room_participant", &Args { room_id, user_id })
.await
}
pub async fn close_live_room(&self, room_id: &str) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
room_id: &'a str,
}
self.call_reducer("close_live_room", &Args { room_id }).await
}
// =========================================================================
// Placement-operasjoner (message_placements)
// =========================================================================
/// Plasser en melding i en kontekst. Idempotent (upsert).
pub async fn place_message(
&self,
id: &str,
message_id: &str,
context_type: &str,
context_id: &str,
position_json: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
id: &'a str,
message_id: &'a str,
context_type: &'a str,
context_id: &'a str,
position_json: &'a str,
}
self.call_reducer(
"place_message",
&Args { id, message_id, context_type, context_id, position_json },
)
.await
}
/// Fjern en meldings plassering fra en kontekst.
pub async fn remove_placement(
&self,
message_id: &str,
context_type: &str,
context_id: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
message_id: &'a str,
context_type: &'a str,
context_id: &'a str,
}
self.call_reducer(
"remove_placement",
&Args { message_id, context_type, context_id },
)
.await
}
/// Flytt en plassering (oppdater posisjon).
pub async fn move_on_canvas(
&self,
placement_id: &str,
new_position_json: &str,
) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Args<'a> {
placement_id: &'a str,
new_position_json: &'a str,
}
self.call_reducer(
"move_on_canvas",
&Args { placement_id, new_position_json },
)
.await
}
// =========================================================================
// Vedlikehold
// =========================================================================
/// Tøm alle noder og edges. Brukes ved warmup for å unngå duplikater.
pub async fn clear_all(&self) -> Result<(), StdbError> {
#[derive(Serialize)]
struct Empty {}
self.call_reducer("clear_all", &Empty {}).await
}
}
// =============================================================================
// Feilhåndtering
// =============================================================================
#[derive(Debug)]
pub enum StdbError {
/// HTTP-transportfeil (nettverk, timeout)
Http(String),
/// Reducer returnerte feil (400, 500, etc.)
Reducer {
reducer: String,
status: u16,
message: String,
},
}
impl std::fmt::Display for StdbError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StdbError::Http(msg) => write!(f, "STDB HTTP-feil: {msg}"),
StdbError::Reducer {
reducer,
status,
message,
} => write!(f, "STDB reducer {reducer} feilet ({status}): {message}"),
}
}
}
impl std::error::Error for StdbError {}
impl From<reqwest::Error> for StdbError {
fn from(e: reqwest::Error) -> Self {
StdbError::Http(e.to_string())
}
}