diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index e7c5cc0..23fcd14 100644 --- a/crates/uteke-core/src/edges.rs +++ b/crates/uteke-core/src/edges.rs @@ -1343,7 +1343,7 @@ mod tests { fn migration_dispatcher_reaches_v8() { let store = Store::open(":memory:").unwrap(); let v = store.schema_version().unwrap(); - assert_eq!(v, 14, "fresh store must reach CURRENT_SCHEMA_VERSION=14"); + assert_eq!(v, 15, "fresh store must reach CURRENT_SCHEMA_VERSION=15"); // memory_edges table must exist and be queryable after migration. let n = store.count_memory_edges().unwrap(); diff --git a/crates/uteke-core/src/memory/crud.rs b/crates/uteke-core/src/memory/crud.rs index 8f21ed3..9bdbbf2 100644 --- a/crates/uteke-core/src/memory/crud.rs +++ b/crates/uteke-core/src/memory/crud.rs @@ -812,6 +812,6 @@ mod content_type_tests { let store = super::super::store::Store::open(":memory:").unwrap(); assert!(store.column_exists("content_type")); let version = store.schema_version().unwrap(); - assert_eq!(version, 14); // v14 = FTS5 memory_type column (#662); v13 = global docs no namespace (#614); v12 = hierarchical docs (#438); v11 = document engine (#406); v10 = source columns (#348); v9 = timeline (#347); v8 = edges + slug; v7 = graph + assert_eq!(version, 15); // v15 = room_documents junction (#689); v14 = FTS5 memory_type column (#662); v13 = global docs no namespace (#614); v12 = hierarchical docs (#438); v11 = document engine (#406); v10 = source columns (#348); v9 = timeline (#347); v8 = edges + slug; v7 = graph } } diff --git a/crates/uteke-core/src/memory/rooms.rs b/crates/uteke-core/src/memory/rooms.rs index 923392b..3c4b957 100644 --- a/crates/uteke-core/src/memory/rooms.rs +++ b/crates/uteke-core/src/memory/rooms.rs @@ -900,4 +900,53 @@ impl super::Store { sections, })) } + + // ── Room ↔ Document junction table (v15, #689) ────────────────────── + + /// Link a document to a room. No-op if already linked. + pub fn room_add_document(&self, room_id: &str, doc_slug: &str) -> Result<(), Error> { + let now = chrono::Utc::now().to_rfc3339(); + self.conn + .execute( + "INSERT OR IGNORE INTO room_documents (room_id, doc_slug, added_at) VALUES (?1, ?2, ?3)", + params![room_id, doc_slug, now], + ) + .map_err(|e| Error::db("room_add_document", e))?; + Ok(()) + } + + /// Unlink a document from a room. + pub fn room_remove_document(&self, room_id: &str, doc_slug: &str) -> Result<(), Error> { + self.conn + .execute( + "DELETE FROM room_documents WHERE room_id = ?1 AND doc_slug = ?2", + params![room_id, doc_slug], + ) + .map_err(|e| Error::db("room_remove_document", e))?; + Ok(()) + } + + /// List document slugs linked to a room. + pub fn room_list_documents(&self, room_id: &str) -> Result, Error> { + let mut stmt = self + .conn + .prepare("SELECT doc_slug FROM room_documents WHERE room_id = ?1 ORDER BY added_at") + .map_err(|e| Error::db("room_list_documents", e))?; + let rows = stmt + .query_map(params![room_id], |r| r.get::<_, String>(0)) + .map_err(|e| Error::db("query room_list_documents", e))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } + + /// List room IDs that have a given document linked. + pub fn document_list_rooms(&self, doc_slug: &str) -> Result, Error> { + let mut stmt = self + .conn + .prepare("SELECT room_id FROM room_documents WHERE doc_slug = ?1 ORDER BY added_at") + .map_err(|e| Error::db("document_list_rooms", e))?; + let rows = stmt + .query_map(params![doc_slug], |r| r.get::<_, String>(0)) + .map_err(|e| Error::db("query document_list_rooms", e))?; + Ok(rows.filter_map(|r| r.ok()).collect()) + } } diff --git a/crates/uteke-core/src/memory/schema.rs b/crates/uteke-core/src/memory/schema.rs index be14ffe..1450c70 100644 --- a/crates/uteke-core/src/memory/schema.rs +++ b/crates/uteke-core/src/memory/schema.rs @@ -372,6 +372,8 @@ impl super::Store { 13 => self.migrate_v12_to_v13()?, // v14: Add memory_type to FTS5 index (#662) 14 => self.migrate_v13_to_v14()?, + // v15: room_documents junction table (#689) + 15 => self.migrate_v14_to_v15()?, _ => { // No-op for future versions. } @@ -973,4 +975,23 @@ impl super::Store { tracing::info!("Migration v13 to v14 complete: memory_type now in FTS5 index"); Ok(()) } + + /// v15: Add room_documents junction table for room→document associations (#689). + fn migrate_v14_to_v15(&self) -> Result<(), Error> { + tracing::info!("Applying schema migration v14 to v15: room_documents junction table"); + + self.conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS room_documents ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + doc_slug TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (room_id, doc_slug) + )", + ) + .map_err(|e| Error::db("create room_documents table", e))?; + + tracing::info!("Migration v14 to v15 complete: room_documents table created"); + Ok(()) + } } diff --git a/crates/uteke-core/src/memory/store.rs b/crates/uteke-core/src/memory/store.rs index 9537f46..7a71530 100644 --- a/crates/uteke-core/src/memory/store.rs +++ b/crates/uteke-core/src/memory/store.rs @@ -151,6 +151,16 @@ CREATE TABLE IF NOT EXISTS room_memories ( ); CREATE INDEX IF NOT EXISTS idx_room_memories_room ON room_memories(room_id); CREATE INDEX IF NOT EXISTS idx_room_memories_author ON room_memories(author); + +-- v15: Room→document junction table (#689). +CREATE TABLE IF NOT EXISTS room_documents ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + doc_slug TEXT NOT NULL, + added_at TEXT NOT NULL, + PRIMARY KEY (room_id, doc_slug) +); +CREATE INDEX IF NOT EXISTS idx_room_documents_room ON room_documents(room_id); +CREATE INDEX IF NOT EXISTS idx_room_documents_slug ON room_documents(doc_slug); "#; /// Indexes that depend on migration-added columns. @@ -163,7 +173,7 @@ pub(super) const SCHEMA_INDEXES: &[&str] = &[ ]; /// Current schema version. Increment when adding migrations. -pub(super) const CURRENT_SCHEMA_VERSION: i32 = 14; +pub(super) const CURRENT_SCHEMA_VERSION: i32 = 15; /// Persistent SQLite store for memories. pub struct Store { @@ -1778,7 +1788,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(version, 14, "schema_version should be 14 after migration"); + assert_eq!(version, 15, "schema_version should be 15 after migration"); // 7. Verify hierarchy columns now exist (in documents table). let cols = ["parent_id", "path", "depth", "sort_order", "has_children"]; diff --git a/crates/uteke-core/src/rooms.rs b/crates/uteke-core/src/rooms.rs index 1ab4573..7d3badb 100644 --- a/crates/uteke-core/src/rooms.rs +++ b/crates/uteke-core/src/rooms.rs @@ -144,4 +144,26 @@ impl crate::Uteke { pub fn room_document(&self, room_id: &str) -> Result, Error> { self.store.room_document(room_id) } + + // ── Room ↔ Document junction (v15, #689) ───────────────────────────── + + /// Link a document to a room. No-op if already linked. + pub fn room_add_document(&self, room_id: &str, doc_slug: &str) -> Result<(), Error> { + self.store.room_add_document(room_id, doc_slug) + } + + /// Unlink a document from a room. + pub fn room_remove_document(&self, room_id: &str, doc_slug: &str) -> Result<(), Error> { + self.store.room_remove_document(room_id, doc_slug) + } + + /// List document slugs linked to a room. + pub fn room_list_documents(&self, room_id: &str) -> Result, Error> { + self.store.room_list_documents(room_id) + } + + /// List room IDs that have a given document linked. + pub fn document_list_rooms(&self, doc_slug: &str) -> Result, Error> { + self.store.document_list_rooms(doc_slug) + } } diff --git a/crates/uteke-core/src/timeline.rs b/crates/uteke-core/src/timeline.rs index e7948f8..c37d726 100644 --- a/crates/uteke-core/src/timeline.rs +++ b/crates/uteke-core/src/timeline.rs @@ -296,7 +296,7 @@ mod tests { fn migration_dispatcher_reaches_v9() { let store = Store::open(":memory:").unwrap(); let v = store.schema_version().unwrap(); - assert_eq!(v, 14, "fresh store must reach CURRENT_SCHEMA_VERSION=14"); + assert_eq!(v, 15, "fresh store must reach CURRENT_SCHEMA_VERSION=15"); // timeline_events table must exist. let m = mem(); store.insert(&m).unwrap(); diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index 3b022f8..be1e1ba 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -59,9 +59,11 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< "/room/summary", "/room/document", "/room/stats", + "/room/document/list", "/doc/get", "/doc/list", "/doc/search", + "/doc/room/list", "/memory/doc-refs", "/doc/mem-refs", "/orphans", @@ -954,6 +956,87 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< } } + // ── Room ↔ Document junction (#689) ────────────────────────────── + // POST /room/document/list — list documents linked to a room + (Method::Post, "/room/document/list") => { + #[derive(Deserialize)] + struct RoomDocListReq { + room_id: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_list_documents(&req_data.room_id) { + Ok(slugs) => ctx.ok_response_for( + req, + &serde_json::json!({ "room_id": req_data.room_id, "doc_slugs": slugs }), + ), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } + } + + // PUT /room/document/add — link a document to a room + (Method::Put, "/room/document/add") => { + #[derive(Deserialize)] + struct RoomDocAddReq { + room_id: String, + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_add_document(&req_data.room_id, &req_data.doc_slug) { + Ok(()) => ctx.ok_response_for(req, &serde_json::json!({ "status": "linked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } + } + + // DELETE /room/document/remove — unlink a document from a room + (Method::Delete, "/room/document/remove") => { + #[derive(Deserialize)] + struct RoomDocRemoveReq { + room_id: String, + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.room_remove_document(&req_data.room_id, &req_data.doc_slug) { + Ok(()) => ctx.ok_response_for(req, &serde_json::json!({ "status": "unlinked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } + } + + // POST /doc/room/list — list rooms linked to a document + (Method::Post, "/doc/room/list") => { + #[derive(Deserialize)] + struct DocRoomListReq { + doc_slug: String, + } + match read_body::(req.as_reader()) { + Ok(req_data) => match uteke.document_list_rooms(&req_data.doc_slug) { + Ok(room_ids) => ctx.ok_response_for( + req, + &serde_json::json!({ "doc_slug": req_data.doc_slug, "room_ids": room_ids }), + ), + Err(e) => { + error!("Internal error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + }, + Err(e) => ctx.error_response_for(req, 400, e), + } + } + (Method::Delete, p) if p == "/room/delete" || p.starts_with("/room/delete?") => { let room_id = if let Some(q) = p.strip_prefix("/room/delete?") { parse_query_param(q, "room_id")