From 390d2b5cf6929b41073c738ba14da29298ca4a74 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 15 Jul 2026 15:55:20 +0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20room=E2=86=94document=20junction=20?= =?UTF-8?q?table=20(schema=20v15,=20#689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New room_documents table with room_id + doc_slug composite PK. Enables explicit room-to-document linking beyond memory wikilinks. Store methods: - room_add_document(room_id, doc_slug) - room_remove_document(room_id, doc_slug) - room_list_documents(room_id) → Vec - document_list_rooms(doc_slug) → Vec Server endpoints: - PUT /room/document/add - DELETE /room/document/remove - POST /room/document/list - POST /doc/room/list Refs #689 --- crates/uteke-core/src/edges.rs | 2 +- crates/uteke-core/src/memory/crud.rs | 2 +- crates/uteke-core/src/memory/rooms.rs | 49 ++++++++++++++++ crates/uteke-core/src/memory/schema.rs | 21 +++++++ crates/uteke-core/src/memory/store.rs | 14 ++++- crates/uteke-core/src/timeline.rs | 2 +- crates/uteke-server/src/handlers.rs | 79 ++++++++++++++++++++++++++ 7 files changed, 164 insertions(+), 5 deletions(-) diff --git a/crates/uteke-core/src/edges.rs b/crates/uteke-core/src/edges.rs index 15f3d23..40dffba 100644 --- a/crates/uteke-core/src/edges.rs +++ b/crates/uteke-core/src/edges.rs @@ -1276,7 +1276,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/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 ae6425e..3a1374b 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -59,9 +59,13 @@ 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", ]; let is_read = method == Method::Get || read_only_post_paths.iter().any(|ep| path == *ep); @@ -952,6 +956,81 @@ 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.store.room_list_documents(&req_data.room_id) { + Ok(slugs) => ctx.ok_response_for(req, &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.store.room_add_document(&req_data.room_id, &req_data.doc_slug) { + Ok(()) => ctx.ok_response_for(req, &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.store.room_remove_document(&req_data.room_id, &req_data.doc_slug) { + Ok(()) => ctx.ok_response_for(req, &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.store.document_list_rooms(&req_data.doc_slug) { + Ok(room_ids) => ctx.ok_response_for(req, &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") From 0e6709a390b5cee63f828001e3e67c091b6f6d2c Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 15 Jul 2026 16:04:20 +0700 Subject: [PATCH 2/3] fix: use public Uteke wrappers instead of private store field - Add room_add_document, room_remove_document, room_list_documents, document_list_rooms to Uteke public API (rooms.rs) - Replace uteke.store.xxx with uteke.xxx in server handlers - Add missing ns binding in /tags GET handler - Apply cargo fmt --- crates/uteke-core/src/rooms.rs | 22 ++++++++++++++++++++++ crates/uteke-server/src/handlers.rs | 18 ++++++++++++------ 2 files changed, 34 insertions(+), 6 deletions(-) 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-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index 3a1374b..257a1a9 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -964,8 +964,11 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< room_id: String, } match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.store.room_list_documents(&req_data.room_id) { - Ok(slugs) => ctx.ok_response_for(req, &json!({ "room_id": req_data.room_id, "doc_slugs": slugs })), + Ok(req_data) => match uteke.room_list_documents(&req_data.room_id) { + Ok(slugs) => ctx.ok_response_for( + req, + &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") @@ -983,7 +986,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< doc_slug: String, } match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.store.room_add_document(&req_data.room_id, &req_data.doc_slug) { + Ok(req_data) => match uteke.room_add_document(&req_data.room_id, &req_data.doc_slug) { Ok(()) => ctx.ok_response_for(req, &json!({ "status": "linked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), Err(e) => { error!("Internal error: {e}"); @@ -1002,7 +1005,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< doc_slug: String, } match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.store.room_remove_document(&req_data.room_id, &req_data.doc_slug) { + Ok(req_data) => match uteke.room_remove_document(&req_data.room_id, &req_data.doc_slug) { Ok(()) => ctx.ok_response_for(req, &json!({ "status": "unlinked", "room_id": req_data.room_id, "doc_slug": req_data.doc_slug })), Err(e) => { error!("Internal error: {e}"); @@ -1020,8 +1023,11 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< doc_slug: String, } match read_body::(req.as_reader()) { - Ok(req_data) => match uteke.store.document_list_rooms(&req_data.doc_slug) { - Ok(room_ids) => ctx.ok_response_for(req, &json!({ "doc_slug": req_data.doc_slug, "room_ids": room_ids })), + Ok(req_data) => match uteke.document_list_rooms(&req_data.doc_slug) { + Ok(room_ids) => ctx.ok_response_for( + req, + &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") From 4f7217a6063b7cbe6d048f5049bf96172f025577 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Wed, 15 Jul 2026 16:14:55 +0700 Subject: [PATCH 3/3] fix: use serde_json::json! instead of bare json! macro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handlers used json!() which is not in scope — must be serde_json::json!() to match the rest of handlers.rs. --- crates/uteke-server/src/handlers.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index 257a1a9..e5b5318 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -967,7 +967,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< Ok(req_data) => match uteke.room_list_documents(&req_data.room_id) { Ok(slugs) => ctx.ok_response_for( req, - &json!({ "room_id": req_data.room_id, "doc_slugs": slugs }), + &serde_json::json!({ "room_id": req_data.room_id, "doc_slugs": slugs }), ), Err(e) => { error!("Internal error: {e}"); @@ -987,7 +987,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< } 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, &json!({ "status": "linked", "room_id": req_data.room_id, "doc_slug": 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") @@ -1006,7 +1006,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< } 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, &json!({ "status": "unlinked", "room_id": req_data.room_id, "doc_slug": 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") @@ -1026,7 +1026,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< Ok(req_data) => match uteke.document_list_rooms(&req_data.doc_slug) { Ok(room_ids) => ctx.ok_response_for( req, - &json!({ "doc_slug": req_data.doc_slug, "room_ids": room_ids }), + &serde_json::json!({ "doc_slug": req_data.doc_slug, "room_ids": room_ids }), ), Err(e) => { error!("Internal error: {e}");