Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/uteke-core/src/edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/uteke-core/src/memory/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
49 changes: 49 additions & 0 deletions crates/uteke-core/src/memory/rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>, 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<Vec<String>, 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())
}
}
21 changes: 21 additions & 0 deletions crates/uteke-core/src/memory/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Expand Down Expand Up @@ -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(())
}
}
14 changes: 12 additions & 2 deletions crates/uteke-core/src/memory/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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"];
Expand Down
22 changes: 22 additions & 0 deletions crates/uteke-core/src/rooms.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,26 @@ impl crate::Uteke {
pub fn room_document(&self, room_id: &str) -> Result<Option<RoomDocument>, 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<Vec<String>, 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<Vec<String>, Error> {
self.store.document_list_rooms(doc_slug)
}
}
2 changes: 1 addition & 1 deletion crates/uteke-core/src/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
83 changes: 83 additions & 0 deletions crates/uteke-server/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ pub fn route(uteke: &Mutex<Uteke>, 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",
Expand Down Expand Up @@ -954,6 +956,87 @@ pub fn route(uteke: &Mutex<Uteke>, 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::<RoomDocListReq>(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::<RoomDocAddReq>(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::<RoomDocRemoveReq>(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::<DocRoomListReq>(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")
Expand Down
Loading