KeiSeiKit-1.0/_primitives/_rust/kei-entity-store/src/verbs/link.rs
Parfii-bot 2d2c9881de feat(entity-store/b5): EdgeKeyKind::TextPair + archive verb — unblock kei-sage + kei-chat-store migration
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<K: Hash + Eq + Clone> over
node key type. Same algorithm, polymorphic in key.

verbs/archive.rs (new, 64 LOC): soft-delete via UPDATE <tbl> SET
<archived_field>=1 + optional <archived_field>_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) <noreply@anthropic.com>
2026-04-23 05:30:33 +08:00

88 lines
2.7 KiB
Rust

//! `link` verb — INSERT edge into `<edge_table>` (idempotent via
//! 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::{EdgeKeyKind, EntitySchema};
use rusqlite::Connection;
use serde_json::{json, Value};
pub fn run(
conn: &Connection,
schema: &EntitySchema,
input: Value,
) -> Result<Value, VerbError> {
if !schema.verb_enabled("link") {
return Err(VerbError::VerbDisabled {
verb: "link".into(),
schema: schema.name.into(),
});
}
let edge = schema.edge_table.ok_or_else(|| {
VerbError::InvalidInput(format!(
"link: schema {} has no edge_table configured",
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<Value, VerbError> {
let from = input
.get("from")
.and_then(|v| v.as_i64())
.ok_or_else(|| VerbError::InvalidInput("link: missing `from` integer".into()))?;
let to = input
.get("to")
.and_then(|v| v.as_i64())
.ok_or_else(|| VerbError::InvalidInput("link: missing `to` integer".into()))?;
conn.execute(
&format!(
"INSERT OR IGNORE INTO {edge} (from_id, to_id, edge_type) VALUES (?1, ?2, ?3)"
),
rusqlite::params![from, to, edge_type],
)?;
Ok(json!({ "ok": true }))
}
fn insert_text(
conn: &Connection,
edge: &str,
input: &Value,
edge_type: &str,
) -> Result<Value, VerbError> {
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 }))
}