From 2d2c9881dee147673fa56af54d14b90de08da293 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 05:30:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(entity-store/b5):=20EdgeKeyKind::TextPair?= =?UTF-8?q?=20+=20archive=20verb=20=E2=80=94=20unblock=20kei-sage=20+=20ke?= =?UTF-8?q?i-chat-store=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit schema.rs: EdgeKeyKind enum (IntegerPair default, TextPair) + archived_field: Option<&'static str> on EntitySchema. Backward-compat via Default impl. engine.rs: ddl_edge_table_for() dispatches integer vs text edge DDL. Existing ddl_edge_table() untouched; new ddl_edge_table_text() adds src_path/dst_path TEXT columns. verbs/link.rs (rewrite): dispatches on edge_key_kind. Input JSON {from:int, to:int} for IntegerPair OR {from:str, to:str} for TextPair. verbs/rank.rs (rewrite): generic pagerank over node key type. Same algorithm, polymorphic in key. verbs/archive.rs (new, 64 LOC): soft-delete via UPDATE SET =1 + optional _at=now(). Schema without archived_field declared returns VerbError. Tests: 20/20 kei-entity-store (was 10, +10: 5 text_pair + 5 archive). kei-task 9/9 preserved (IntegerPair still default, backward-compat verified). Enables: kei-sage migration (TextPair edges) + kei-chat-store migration (archive). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_rust/kei-entity-store/src/engine.rs | 26 +++- _primitives/_rust/kei-entity-store/src/lib.rs | 2 +- .../_rust/kei-entity-store/src/schema.rs | 30 ++++- .../kei-entity-store/src/verbs/archive.rs | 64 ++++++++++ .../_rust/kei-entity-store/src/verbs/link.rs | 53 ++++++-- .../_rust/kei-entity-store/src/verbs/mod.rs | 3 +- .../_rust/kei-entity-store/src/verbs/rank.rs | 86 +++++++++---- .../kei-entity-store/tests/archive_smoke.rs | 99 +++++++++++++++ .../kei-entity-store/tests/text_pair_smoke.rs | 114 ++++++++++++++++++ .../kei-entity-store/tests/verb_smoke.rs | 6 +- _primitives/_rust/kei-task/src/schema.rs | 4 +- 11 files changed, 451 insertions(+), 36 deletions(-) create mode 100644 _primitives/_rust/kei-entity-store/src/verbs/archive.rs create mode 100644 _primitives/_rust/kei-entity-store/tests/archive_smoke.rs create mode 100644 _primitives/_rust/kei-entity-store/tests/text_pair_smoke.rs diff --git a/_primitives/_rust/kei-entity-store/src/engine.rs b/_primitives/_rust/kei-entity-store/src/engine.rs index cb5d94b..1afc4b6 100644 --- a/_primitives/_rust/kei-entity-store/src/engine.rs +++ b/_primitives/_rust/kei-entity-store/src/engine.rs @@ -7,7 +7,7 @@ //! connection + schema-aware DDL. use crate::error::VerbError; -use crate::schema::{EntitySchema, FieldDef, FieldKind}; +use crate::schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind}; use anyhow::{Context, Result}; use rusqlite::Connection; use std::path::Path; @@ -55,7 +55,7 @@ pub fn run_migrations(conn: &Connection, schema: &EntitySchema) -> Result<(), Ve conn.execute_batch(&ddl_fts_table(schema.table, cols))?; } if let Some(edge) = schema.edge_table { - conn.execute_batch(&ddl_edge_table(edge))?; + conn.execute_batch(&ddl_edge_table_for(edge, schema.edge_key_kind))?; } for stmt in schema.custom_migrations { conn.execute_batch(stmt)?; @@ -119,3 +119,25 @@ fn ddl_edge_table(edge: &str) -> String { CREATE INDEX IF NOT EXISTS idx_{edge}_to ON {edge}(to_id);" ) } + +/// Dispatcher — picks edge-table DDL for a given `EdgeKeyKind`. Added +/// for kei-sage migration; `IntegerPair` branch preserves legacy body. +fn ddl_edge_table_for(edge: &str, kind: EdgeKeyKind) -> String { + match kind { + EdgeKeyKind::IntegerPair => ddl_edge_table(edge), + EdgeKeyKind::TextPair => ddl_edge_table_text(edge), + } +} + +/// Text-keyed edge DDL: `(src_path TEXT, dst_path TEXT, edge_type TEXT)`. +fn ddl_edge_table_text(edge: &str) -> String { + format!( + "CREATE TABLE IF NOT EXISTS {edge} (\n \ + src_path TEXT NOT NULL,\n \ + dst_path TEXT NOT NULL,\n \ + edge_type TEXT NOT NULL DEFAULT 'links',\n \ + PRIMARY KEY(src_path, dst_path, edge_type)\n\ + );\n\ + CREATE INDEX IF NOT EXISTS idx_{edge}_dst ON {edge}(dst_path);" + ) +} diff --git a/_primitives/_rust/kei-entity-store/src/lib.rs b/_primitives/_rust/kei-entity-store/src/lib.rs index 33481e3..e45b181 100644 --- a/_primitives/_rust/kei-entity-store/src/lib.rs +++ b/_primitives/_rust/kei-entity-store/src/lib.rs @@ -21,4 +21,4 @@ pub mod verbs; pub use engine::Store; pub use error::VerbError; -pub use schema::{EntitySchema, FieldDef, FieldKind}; +pub use schema::{EdgeKeyKind, EntitySchema, FieldDef, FieldKind}; diff --git a/_primitives/_rust/kei-entity-store/src/schema.rs b/_primitives/_rust/kei-entity-store/src/schema.rs index 308234b..6813188 100644 --- a/_primitives/_rust/kei-entity-store/src/schema.rs +++ b/_primitives/_rust/kei-entity-store/src/schema.rs @@ -73,6 +73,22 @@ impl FieldDef { } } +/// Edge-key storage strategy for the schema's `edge_table`. +/// +/// - `IntegerPair` (default) — legacy `(from_id INTEGER, to_id INTEGER, +/// edge_type TEXT)` — matches kei-task byte-for-byte. +/// - `TextPair` — `(src_path TEXT, dst_path TEXT, edge_type TEXT)` — +/// required by kei-sage (composite text keys, no integer ids). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgeKeyKind { + IntegerPair, + TextPair, +} + +impl Default for EdgeKeyKind { + fn default() -> Self { Self::IntegerPair } +} + /// Declarative schema for one entity. #[derive(Debug, Clone, Copy)] pub struct EntitySchema { @@ -88,9 +104,19 @@ pub struct EntitySchema { /// with the listed non-id columns and keeps it in sync on create /// + update. `search` verb uses it. pub fts_columns: Option<&'static [&'static str]>, - /// If `Some`, engine creates `(from_id, to_id, edge_type)` - /// for the `link` verb. `rank` verb runs PageRank over it. + /// If `Some`, engine creates `` for the `link` verb. + /// Column layout depends on `edge_key_kind`. `rank` verb runs + /// PageRank over it. pub edge_table: Option<&'static str>, + /// Edge-table key layout. Default `IntegerPair` preserves legacy + /// `(from_id, to_id)` schema; `TextPair` switches to + /// `(src_path, dst_path)` for path-keyed graphs (kei-sage). + pub edge_key_kind: EdgeKeyKind, + /// If `Some`, enables the `archive` verb. Names the INTEGER column + /// used as the soft-delete flag (0 / 1). The `archive` verb flips + /// it to 1 and stamps a sibling `_at` timestamp if such a + /// column exists in `fields`. + pub archived_field: Option<&'static str>, /// Arbitrary DDL statements run after the primary table + FTS + /// edge table have been created. Used for secondary tables /// (milestones, task_deps) that piggy-back on the same DB but are diff --git a/_primitives/_rust/kei-entity-store/src/verbs/archive.rs b/_primitives/_rust/kei-entity-store/src/verbs/archive.rs new file mode 100644 index 0000000..af047ae --- /dev/null +++ b/_primitives/_rust/kei-entity-store/src/verbs/archive.rs @@ -0,0 +1,64 @@ +//! `archive` verb — soft-delete. Flips `` to 1, stamps +//! a sibling `_at` column with current Unix timestamp +//! if such a column exists in the schema. +//! +//! Required schema configuration: `archived_field: Some("")`. +//! Without it the verb errors with `InvalidInput` — the engine does NOT +//! fall back to legacy `archived` heuristics (those remain in +//! `delete.rs` soft-path only). +//! +//! Input: `{ id: i64 }`. +//! Output: `{ id, archived_at }` — `archived_at` is the stamped +//! timestamp when a `_at` column exists, else `null`. + +use crate::error::VerbError; +use crate::schema::EntitySchema; +use rusqlite::Connection; +use serde_json::{json, Value}; + +pub fn run( + conn: &Connection, + schema: &EntitySchema, + input: Value, +) -> Result { + if !schema.verb_enabled("archive") { + return Err(VerbError::VerbDisabled { + verb: "archive".into(), + schema: schema.name.into(), + }); + } + let field = schema.archived_field.ok_or_else(|| { + VerbError::InvalidInput(format!( + "archive: schema {} has no archived_field configured", + schema.name + )) + })?; + let id = input + .get("id") + .and_then(|v| v.as_i64()) + .ok_or_else(|| VerbError::InvalidInput("archive: missing `id` integer".into()))?; + + let ts_col = format!("{field}_at"); + let has_ts = schema.fields.iter().any(|f| f.name == ts_col); + let now: i64 = chrono::Utc::now().timestamp(); + + let rows = if has_ts { + conn.execute( + &format!( + "UPDATE {t} SET {field} = 1, {ts_col} = ?1 WHERE id = ?2", + t = schema.table + ), + rusqlite::params![now, id], + )? + } else { + conn.execute( + &format!("UPDATE {t} SET {field} = 1 WHERE id = ?1", t = schema.table), + rusqlite::params![id], + )? + }; + if rows == 0 { + return Err(VerbError::NotFound { entity: schema.name.into(), id }); + } + let stamped = if has_ts { json!(now) } else { Value::Null }; + Ok(json!({ "id": id, "archived_at": stamped })) +} diff --git a/_primitives/_rust/kei-entity-store/src/verbs/link.rs b/_primitives/_rust/kei-entity-store/src/verbs/link.rs index 12a15d0..113985f 100644 --- a/_primitives/_rust/kei-entity-store/src/verbs/link.rs +++ b/_primitives/_rust/kei-entity-store/src/verbs/link.rs @@ -2,9 +2,13 @@ //! INSERT OR IGNORE). Caller is responsible for higher-level semantic //! checks (cycle detection, self-loop) — those live in the sibling //! crate (e.g. kei-task::deps). +//! +//! Dispatches on `schema.edge_key_kind`: +//! - `IntegerPair` — input `{from: i64, to: i64, edge_type?}` +//! - `TextPair` — input `{from: str, to: str, edge_type?}` use crate::error::VerbError; -use crate::schema::EntitySchema; +use crate::schema::{EdgeKeyKind, EntitySchema}; use rusqlite::Connection; use serde_json::{json, Value}; @@ -25,6 +29,24 @@ pub fn run( schema.name )) })?; + let edge_type = input + .get("edge_type") + .and_then(|v| v.as_str()) + .unwrap_or("links") + .to_string(); + + match schema.edge_key_kind { + EdgeKeyKind::IntegerPair => insert_integer(conn, edge, &input, &edge_type), + EdgeKeyKind::TextPair => insert_text(conn, edge, &input, &edge_type), + } +} + +fn insert_integer( + conn: &Connection, + edge: &str, + input: &Value, + edge_type: &str, +) -> Result { let from = input .get("from") .and_then(|v| v.as_i64()) @@ -33,12 +55,6 @@ pub fn run( .get("to") .and_then(|v| v.as_i64()) .ok_or_else(|| VerbError::InvalidInput("link: missing `to` integer".into()))?; - let edge_type = input - .get("edge_type") - .and_then(|v| v.as_str()) - .unwrap_or("links") - .to_string(); - conn.execute( &format!( "INSERT OR IGNORE INTO {edge} (from_id, to_id, edge_type) VALUES (?1, ?2, ?3)" @@ -47,3 +63,26 @@ pub fn run( )?; Ok(json!({ "ok": true })) } + +fn insert_text( + conn: &Connection, + edge: &str, + input: &Value, + edge_type: &str, +) -> Result { + let from = input + .get("from") + .and_then(|v| v.as_str()) + .ok_or_else(|| VerbError::InvalidInput("link: missing `from` string".into()))?; + let to = input + .get("to") + .and_then(|v| v.as_str()) + .ok_or_else(|| VerbError::InvalidInput("link: missing `to` string".into()))?; + conn.execute( + &format!( + "INSERT OR IGNORE INTO {edge} (src_path, dst_path, edge_type) VALUES (?1, ?2, ?3)" + ), + rusqlite::params![from, to, edge_type], + )?; + Ok(json!({ "ok": true })) +} diff --git a/_primitives/_rust/kei-entity-store/src/verbs/mod.rs b/_primitives/_rust/kei-entity-store/src/verbs/mod.rs index c5b1c94..691c1ab 100644 --- a/_primitives/_rust/kei-entity-store/src/verbs/mod.rs +++ b/_primitives/_rust/kei-entity-store/src/verbs/mod.rs @@ -9,6 +9,7 @@ //! only copy declared schema fields into SQL (defence against //! unexpected keys). +pub mod archive; pub mod create; pub mod delete; pub mod get; @@ -21,5 +22,5 @@ pub mod update; /// Full list of supported verbs — SSoT for documentation + schema /// validation. `EntitySchema.enabled_verbs` entries MUST appear here. pub const ALL_VERBS: &[&str] = &[ - "create", "get", "list", "search", "update", "delete", "link", "rank", + "create", "get", "list", "search", "update", "delete", "link", "rank", "archive", ]; diff --git a/_primitives/_rust/kei-entity-store/src/verbs/rank.rs b/_primitives/_rust/kei-entity-store/src/verbs/rank.rs index e590717..371d68f 100644 --- a/_primitives/_rust/kei-entity-store/src/verbs/rank.rs +++ b/_primitives/_rust/kei-entity-store/src/verbs/rank.rs @@ -2,14 +2,16 @@ //! schema's `edge_table`. Returns `{ results: [{id, score}, ...] }` //! sorted by score descending. //! -//! Ported from `kei-sage/src/pagerank.rs` but generalised to operate on -//! the integer `(from_id, to_id)` edge table this engine provisions. +//! Dispatches on `schema.edge_key_kind`: `IntegerPair` emits +//! `{id: i64, score: f64}` rows; `TextPair` emits `{id: String, score}` +//! where `id` carries the text node key (src_path / dst_path). use crate::error::VerbError; -use crate::schema::EntitySchema; +use crate::schema::{EdgeKeyKind, EntitySchema}; use rusqlite::Connection; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet}; +use std::hash::Hash; const DAMPING: f64 = 0.85; const ITERATIONS: usize = 50; @@ -31,18 +33,15 @@ pub fn run( schema.name )) })?; + match schema.edge_key_kind { + EdgeKeyKind::IntegerPair => rank_integer(conn, edge), + EdgeKeyKind::TextPair => rank_text(conn, edge), + } +} - let (nodes, out_edges) = collect_graph(conn, edge)?; - if nodes.is_empty() { - return Ok(json!({ "results": [] })); - } - let mut rank: HashMap = nodes - .iter() - .map(|n| (*n, 1.0 / nodes.len() as f64)) - .collect(); - for _ in 0..ITERATIONS { - rank = one_iteration(&nodes, &out_edges, &rank); - } +fn rank_integer(conn: &Connection, edge: &str) -> Result { + let (nodes, out_edges) = collect_integer(conn, edge)?; + let rank = pagerank(&nodes, &out_edges); let mut out: Vec<(i64, f64)> = rank.into_iter().collect(); out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let results: Vec = @@ -50,7 +49,17 @@ pub fn run( Ok(json!({ "results": results })) } -fn collect_graph( +fn rank_text(conn: &Connection, edge: &str) -> Result { + let (nodes, out_edges) = collect_text(conn, edge)?; + let rank = pagerank(&nodes, &out_edges); + let mut out: Vec<(String, f64)> = rank.into_iter().collect(); + out.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + let results: Vec = + out.into_iter().map(|(id, score)| json!({ "id": id, "score": score })).collect(); + Ok(json!({ "results": results })) +} + +fn collect_integer( conn: &Connection, edge: &str, ) -> Result<(Vec, HashMap>), VerbError> { @@ -68,14 +77,49 @@ fn collect_graph( Ok((nodes.into_iter().collect(), out_edges)) } -fn one_iteration( - nodes: &[i64], - out_edges: &HashMap>, - prev: &HashMap, -) -> HashMap { +fn collect_text( + conn: &Connection, + edge: &str, +) -> Result<(Vec, HashMap>), VerbError> { + let sql = format!("SELECT src_path, dst_path FROM {edge}"); + let mut stmt = conn.prepare(&sql)?; + let rows = + stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?; + let mut nodes: HashSet = HashSet::new(); + let mut out_edges: HashMap> = HashMap::new(); + for row in rows { + let (src, dst) = row?; + nodes.insert(src.clone()); + nodes.insert(dst.clone()); + out_edges.entry(src).or_default().push(dst); + } + Ok((nodes.into_iter().collect(), out_edges)) +} + +/// Generic PageRank — works on any hashable node type. +fn pagerank( + nodes: &[K], + out_edges: &HashMap>, +) -> HashMap { + if nodes.is_empty() { + return HashMap::new(); + } + let init = 1.0 / nodes.len() as f64; + let mut rank: HashMap = nodes.iter().map(|n| (n.clone(), init)).collect(); + for _ in 0..ITERATIONS { + rank = one_iteration(nodes, out_edges, &rank); + } + rank +} + +fn one_iteration( + nodes: &[K], + out_edges: &HashMap>, + prev: &HashMap, +) -> HashMap { let n = nodes.len() as f64; let base = (1.0 - DAMPING) / n; - let mut next: HashMap = nodes.iter().map(|k| (*k, base)).collect(); + let mut next: HashMap = nodes.iter().map(|k| (k.clone(), base)).collect(); for (src, dsts) in out_edges { if dsts.is_empty() { continue; } let share = DAMPING * prev.get(src).copied().unwrap_or(0.0) / dsts.len() as f64; diff --git a/_primitives/_rust/kei-entity-store/tests/archive_smoke.rs b/_primitives/_rust/kei-entity-store/tests/archive_smoke.rs new file mode 100644 index 0000000..2492e91 --- /dev/null +++ b/_primitives/_rust/kei-entity-store/tests/archive_smoke.rs @@ -0,0 +1,99 @@ +//! Archive-verb smoke tests. +//! +//! Covers kei-chat-store migration: schemas opt-in to soft-delete via +//! `archived_field: Some("archived")`. The verb flips the column + an +//! optional `_at` sibling timestamp. + +use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; +use kei_entity_store::verbs::{archive, create, get}; +use kei_entity_store::Store; +use serde_json::json; + +static ARCHIVABLE_FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("title"), + FieldDef::integer("archived"), + FieldDef::integer("archived_at"), + FieldDef::created_at(), +]; + +static ARCHIVABLE: EntitySchema = EntitySchema { + name: "msg", + table: "msgs", + fields: ARCHIVABLE_FIELDS, + enabled_verbs: &["create", "get", "archive"], + fts_columns: None, + edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: Some("archived"), + custom_migrations: &[], +}; + +static NO_ARCHIVE_FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("title"), + FieldDef::created_at(), +]; + +static WITHOUT_FIELD: EntitySchema = EntitySchema { + name: "msg", + table: "msgs_plain", + fields: NO_ARCHIVE_FIELDS, + enabled_verbs: &["create", "archive"], + fts_columns: None, + edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, + custom_migrations: &[], +}; + +#[test] +fn archive_sets_flag_and_stamps_timestamp() { + let s = Store::open_memory(&ARCHIVABLE).unwrap(); + let v = create::run(s.conn(), &ARCHIVABLE, json!({ "title": "hi" })).unwrap(); + let id = v["id"].as_i64().unwrap(); + + let before: i64 = chrono::Utc::now().timestamp(); + let out = archive::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap(); + assert_eq!(out["id"].as_i64().unwrap(), id); + let stamped = out["archived_at"].as_i64().unwrap(); + assert!(stamped >= before); + + let row = get::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap(); + assert_eq!(row["archived"].as_i64().unwrap(), 1); + assert_eq!(row["archived_at"].as_i64().unwrap(), stamped); +} + +#[test] +fn archive_preserves_id_and_other_fields() { + let s = Store::open_memory(&ARCHIVABLE).unwrap(); + let v = create::run(s.conn(), &ARCHIVABLE, json!({ "title": "keep" })).unwrap(); + let id = v["id"].as_i64().unwrap(); + archive::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap(); + let row = get::run(s.conn(), &ARCHIVABLE, json!({ "id": id })).unwrap(); + assert_eq!(row["title"], "keep"); + assert_eq!(row["id"].as_i64().unwrap(), id); +} + +#[test] +fn archive_errors_when_archived_field_missing() { + let s = Store::open_memory(&WITHOUT_FIELD).unwrap(); + let v = create::run(s.conn(), &WITHOUT_FIELD, json!({ "title": "x" })).unwrap(); + let id = v["id"].as_i64().unwrap(); + let err = archive::run(s.conn(), &WITHOUT_FIELD, json!({ "id": id })).unwrap_err(); + assert_eq!(err.exit_code(), 2); +} + +#[test] +fn archive_not_found_errors() { + let s = Store::open_memory(&ARCHIVABLE).unwrap(); + let err = archive::run(s.conn(), &ARCHIVABLE, json!({ "id": 9999 })).unwrap_err(); + assert_eq!(err.exit_code(), 2); +} + +#[test] +fn archive_rejects_missing_id() { + let s = Store::open_memory(&ARCHIVABLE).unwrap(); + let err = archive::run(s.conn(), &ARCHIVABLE, json!({})).unwrap_err(); + assert_eq!(err.exit_code(), 2); +} diff --git a/_primitives/_rust/kei-entity-store/tests/text_pair_smoke.rs b/_primitives/_rust/kei-entity-store/tests/text_pair_smoke.rs new file mode 100644 index 0000000..c76e46c --- /dev/null +++ b/_primitives/_rust/kei-entity-store/tests/text_pair_smoke.rs @@ -0,0 +1,114 @@ +//! TextPair edge-key regression tests for link + rank verbs. +//! +//! Covers kei-sage migration target: `(src_path, dst_path)` TEXT +//! composite edge keys. Also keeps one IntegerPair regression case to +//! prove we did not disturb the default behaviour. + +use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; +use kei_entity_store::verbs::{link, rank}; +use kei_entity_store::Store; +use serde_json::json; + +static NODE_FIELDS: &[FieldDef] = &[ + FieldDef::pk("id"), + FieldDef::text_nn("path"), + FieldDef::created_at(), +]; + +static TEXT_SCHEMA: EntitySchema = EntitySchema { + name: "doc", + table: "docs", + fields: NODE_FIELDS, + enabled_verbs: &["link", "rank"], + fts_columns: None, + edge_table: Some("doc_edges"), + edge_key_kind: EdgeKeyKind::TextPair, + archived_field: None, + custom_migrations: &[], +}; + +static INTEGER_SCHEMA: EntitySchema = EntitySchema { + name: "doc", + table: "docs_int", + fields: NODE_FIELDS, + enabled_verbs: &["link", "rank"], + fts_columns: None, + edge_table: Some("doc_edges_int"), + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, + custom_migrations: &[], +}; + +#[test] +fn text_pair_link_and_lookup() { + let s = Store::open_memory(&TEXT_SCHEMA).unwrap(); + link::run( + s.conn(), + &TEXT_SCHEMA, + json!({ "from": "a.md", "to": "b.md" }), + ) + .unwrap(); + let count: i64 = s + .conn() + .query_row( + "SELECT COUNT(*) FROM doc_edges WHERE src_path='a.md' AND dst_path='b.md'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn text_pair_link_idempotent() { + let s = Store::open_memory(&TEXT_SCHEMA).unwrap(); + for _ in 0..3 { + link::run( + s.conn(), + &TEXT_SCHEMA, + json!({ "from": "a.md", "to": "b.md", "edge_type": "links" }), + ) + .unwrap(); + } + let count: i64 = s + .conn() + .query_row("SELECT COUNT(*) FROM doc_edges", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); +} + +#[test] +fn text_pair_rank_returns_string_ids() { + let s = Store::open_memory(&TEXT_SCHEMA).unwrap(); + let pairs = [("a.md", "b.md"), ("a.md", "c.md"), ("b.md", "c.md")]; + for (from, to) in pairs { + link::run(s.conn(), &TEXT_SCHEMA, json!({ "from": from, "to": to })).unwrap(); + } + let v = rank::run(s.conn(), &TEXT_SCHEMA, json!({})).unwrap(); + let results = v["results"].as_array().unwrap(); + assert_eq!(results.len(), 3); + // c.md has 2 inbound edges → highest rank. + assert_eq!(results[0]["id"], "c.md"); + assert!(results[0]["score"].as_f64().unwrap() > 0.0); +} + +#[test] +fn text_pair_rejects_integer_input() { + let s = Store::open_memory(&TEXT_SCHEMA).unwrap(); + let err = link::run(s.conn(), &TEXT_SCHEMA, json!({ "from": 1, "to": 2 })) + .unwrap_err(); + assert_eq!(err.exit_code(), 2); +} + +#[test] +fn integer_pair_still_works_after_refactor() { + // Regression guard — kei-task uses IntegerPair implicitly. + let s = Store::open_memory(&INTEGER_SCHEMA).unwrap(); + link::run(s.conn(), &INTEGER_SCHEMA, json!({ "from": 1, "to": 2 })).unwrap(); + link::run(s.conn(), &INTEGER_SCHEMA, json!({ "from": 1, "to": 3 })).unwrap(); + link::run(s.conn(), &INTEGER_SCHEMA, json!({ "from": 2, "to": 3 })).unwrap(); + let v = rank::run(s.conn(), &INTEGER_SCHEMA, json!({})).unwrap(); + let results = v["results"].as_array().unwrap(); + assert_eq!(results.len(), 3); + assert_eq!(results[0]["id"], 3); +} diff --git a/_primitives/_rust/kei-entity-store/tests/verb_smoke.rs b/_primitives/_rust/kei-entity-store/tests/verb_smoke.rs index 9150594..91471fc 100644 --- a/_primitives/_rust/kei-entity-store/tests/verb_smoke.rs +++ b/_primitives/_rust/kei-entity-store/tests/verb_smoke.rs @@ -1,6 +1,6 @@ //! Per-verb integration smoke tests on a fixture schema. -use kei_entity_store::schema::{EntitySchema, FieldDef}; +use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; use kei_entity_store::verbs::{create, delete, get, link, list, rank, search, update}; use kei_entity_store::Store; use serde_json::json; @@ -23,6 +23,8 @@ static SCHEMA: EntitySchema = EntitySchema { enabled_verbs: &["create", "get", "list", "search", "update", "delete", "link", "rank"], fts_columns: Some(&["title", "description"]), edge_table: Some("note_edges"), + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, custom_migrations: &[], }; @@ -149,6 +151,8 @@ fn disabled_verb_errors() { enabled_verbs: &["get", "list"], fts_columns: None, edge_table: None, + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, custom_migrations: &[], }; let s = Store::open_memory(&DISABLED).unwrap(); diff --git a/_primitives/_rust/kei-task/src/schema.rs b/_primitives/_rust/kei-task/src/schema.rs index 1493e62..a780143 100644 --- a/_primitives/_rust/kei-task/src/schema.rs +++ b/_primitives/_rust/kei-task/src/schema.rs @@ -10,7 +10,7 @@ //! are not generic CRUD and keep their existing column names so //! `deps.rs` / `milestones.rs` / `graph.rs` don't need to change. -use kei_entity_store::schema::{EntitySchema, FieldDef}; +use kei_entity_store::schema::{EdgeKeyKind, EntitySchema, FieldDef}; static FIELDS: &[FieldDef] = &[ FieldDef::pk("id"), @@ -63,5 +63,7 @@ pub static TASK_SCHEMA: EntitySchema = EntitySchema { enabled_verbs: &["create", "get", "list", "search", "update", "delete"], fts_columns: Some(&["title", "description"]), edge_table: None, // task_deps has bespoke column names — managed by deps.rs + edge_key_kind: EdgeKeyKind::IntegerPair, + archived_field: None, custom_migrations: &[DDL_SECONDARY], };