Skrivestien: POST /intentions/create_node (oppgave 2.4)

Implementerer den første intensjonen i maskinrommet — skrivestien
som gjør at frontend kan opprette noder via maskinrommet.

Flyten:
1. Valider input (node_kind, visibility, metadata)
2. Generer UUIDv7 (tidssortert)
3. Skriv til SpacetimeDB (instant — frontend ser noden umiddelbart)
4. Spawn async tokio-task for PG-persistering
5. Returner node_id uten å vente på PG

Verifisert på server med fire testcaser:
1. Uten auth → 401
2. Ugyldig visibility → 400 med feilmelding
3. Minimal request (tomt body) → 200, node opprettet med defaults
4. Full request → 200, node verifisert i både STDB og PG

Også: Dockerfile oppdatert til Rust 1.88 (avhengigheter krevde >1.86),
og api_grensesnitt.md oppdatert med endepunktdokumentasjon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 13:09:50 +01:00
parent e7e8e8a10d
commit 61b35d3c01
5 changed files with 202 additions and 5 deletions

View file

@ -55,7 +55,20 @@ Tunge spørringer (søk, statistikk, graftraversering) går via maskinrommet →
- **SvelteKit er et rent frontend-prosjekt.** Ingen server-side PG-tilgang.
- **Bakgrunnsjobber** (Whisper, LLM, TTS) orkestreres av maskinrommet, aldri direkte fra frontend.
## 5. Instruks for Claude Code
## 5. Implementerte endepunkter
### Offentlige
- `GET /health` — Helsesjekk. Verifiserer PG- og STDB-tilkobling.
### Autentiserte (krever `Authorization: Bearer <JWT>`)
- `GET /me` — Returnerer autentisert brukers `node_id` og `authentik_sub`.
- `POST /intentions/create_node` — Opprett node. Skriv til STDB (instant),
spawn async PG-skriving, returner `node_id` umiddelbart.
- Body (JSON): `{ node_kind?, title?, content?, visibility?, metadata? }`
- Defaults: `node_kind="content"`, `visibility="hidden"`, andre felter tomme
- Respons: `{ node_id: "<uuid>" }`
## 6. Instruks for Claude Code
- Maskinrommet (`maskinrommet/`) er Rust-prosjektet med axum, tokio, sqlx.
- Intensjoner fra frontend → `POST /intentions/*` endepunkter i maskinrommet.

View file

@ -1,7 +1,7 @@
# Maskinrommet — flertrinns Docker-bygg
# Bygger Rust-binæren i et kompileringssteg, kopierer til minimal runtime.
FROM rust:1.86 AS builder
FROM rust:1.88 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./

View file

@ -0,0 +1,183 @@
// Intensjoner — skrivestien i maskinrommet.
//
// Frontend sender intensjoner (ikke data). Maskinrommet validerer,
// skriver til SpacetimeDB først (instant feedback via WebSocket),
// deretter persisterer til PostgreSQL asynkront.
//
// Ref: docs/retninger/maskinrommet.md, docs/retninger/datalaget.md
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::AppState;
// =============================================================================
// create_node
// =============================================================================
/// Gyldige visibility-verdier (speiler PG enum).
const VALID_VISIBILITIES: &[&str] = &["hidden", "discoverable", "readable", "open"];
#[derive(Deserialize)]
pub struct CreateNodeRequest {
/// Hint om hva noden er. Default: "content".
pub node_kind: Option<String>,
/// Visningstittel. Kan være null (f.eks. chatmeldinger).
pub title: Option<String>,
/// Ren tekst-innhold.
pub content: Option<String>,
/// Synlighet. Default: "hidden" (privat).
pub visibility: Option<String>,
/// Typespesifikk metadata (JSON-objekt).
pub metadata: Option<serde_json::Value>,
}
#[derive(Serialize)]
pub struct CreateNodeResponse {
pub node_id: Uuid,
}
#[derive(Serialize)]
pub struct ErrorResponse {
error: String,
}
/// POST /intentions/create_node
///
/// Validerer input, skriver til STDB (instant), spawner async PG-skriving.
/// Returnerer node_id umiddelbart.
pub async fn create_node(
State(state): State<AppState>,
user: AuthUser,
Json(req): Json<CreateNodeRequest>,
) -> Result<Json<CreateNodeResponse>, (StatusCode, Json<ErrorResponse>)> {
// -- Valider input --
let node_kind = req.node_kind.unwrap_or_else(|| "content".to_string());
if node_kind.is_empty() {
return Err(bad_request("node_kind kan ikke være tom"));
}
let visibility = req.visibility.unwrap_or_else(|| "hidden".to_string());
if !VALID_VISIBILITIES.contains(&visibility.as_str()) {
return Err(bad_request(&format!(
"Ugyldig visibility: '{visibility}'. Gyldige verdier: {VALID_VISIBILITIES:?}"
)));
}
let title = req.title.unwrap_or_default();
let content = req.content.unwrap_or_default();
let metadata = req
.metadata
.unwrap_or_else(|| serde_json::json!({}));
let metadata_str = metadata.to_string();
// -- Generer UUIDv7 (tidssortert) --
let node_id = Uuid::now_v7();
let node_id_str = node_id.to_string();
let created_by_str = user.node_id.to_string();
// -- Skriv til SpacetimeDB (instant) --
state
.stdb
.create_node(
&node_id_str,
&node_kind,
&title,
&content,
&visibility,
&metadata_str,
&created_by_str,
)
.await
.map_err(|e| {
tracing::error!("STDB create_node feilet: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("Kunne ikke skrive til SpacetimeDB: {e}"),
}),
)
})?;
tracing::info!(
node_id = %node_id,
node_kind = %node_kind,
created_by = %user.node_id,
"Node opprettet i STDB"
);
// -- Spawn async PG-skriving --
spawn_pg_insert(
state.db.clone(),
node_id,
node_kind,
title,
content,
visibility,
metadata,
user.node_id,
);
// -- Returner node_id umiddelbart --
Ok(Json(CreateNodeResponse { node_id }))
}
/// Spawner en tokio-task som skriver noden til PostgreSQL i bakgrunnen.
/// Frontend får oppdatering via STDB WebSocket uavhengig av denne.
fn spawn_pg_insert(
db: PgPool,
node_id: Uuid,
node_kind: String,
title: String,
content: String,
visibility: String,
metadata: serde_json::Value,
created_by: Uuid,
) {
tokio::spawn(async move {
let result = sqlx::query(
r#"
INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::visibility, $6, $7)
"#,
)
.bind(node_id)
.bind(&node_kind)
.bind(&title)
.bind(&content)
.bind(&visibility)
.bind(&metadata)
.bind(created_by)
.execute(&db)
.await;
match result {
Ok(_) => {
tracing::info!(
node_id = %node_id,
"Node persistert til PostgreSQL"
);
}
Err(e) => {
// Logg feilen. I fremtiden: dead letter queue (fase 12.3).
tracing::error!(
node_id = %node_id,
error = %e,
"Kunne ikke persistere node til PostgreSQL"
);
}
}
});
}
fn bad_request(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}

View file

@ -1,8 +1,9 @@
mod auth;
mod intentions;
mod stdb;
mod warmup;
use axum::{extract::State, http::StatusCode, routing::get, Json, Router};
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
use serde::Serialize;
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
@ -111,6 +112,7 @@ async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/me", get(me))
.route("/intentions/create_node", post(intentions::create_node))
.layer(TraceLayer::new_for_http())
.with_state(state);

View file

@ -54,8 +54,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 2.1 Rust-prosjekt: opprett `maskinrommet/` med axum, tokio, sqlx (PG), serde. Dockerfile. Kompilerer og starter. Ref: `docs/retninger/maskinrommet.md`.
- [x] 2.2 Auth-middleware: valider Authentik JWT-tokens, slå opp `auth_identities` → node_id. Returner 401 for ugyldige tokens.
- [x] 2.3 SpacetimeDB-klient i maskinrommet: koble til STDB, skriv noder og edges via reducers.
- [~] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart.
> Påbegynt: 2026-03-17T12:52
- [x] 2.4 Skrivestien: `POST /intentions/create_node` — valider, skriv STDB (instant), spawn async PG-skriving. Returner node_id umiddelbart.
- [ ] 2.5 Flere intensjoner: `create_edge`, `update_node`, `delete_node`. Validering av tilgang (created_by eller owner/admin-edge).
- [ ] 2.6 Docker Compose: legg maskinrommet inn i server-stacken. Intern nettverkstilgang til PG og STDB.