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>
466 lines
12 KiB
Rust
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())
|
|
}
|
|
}
|