Merge fix/b5-textpair — EdgeKeyKind + archive verb

This commit is contained in:
Parfii-bot 2026-04-23 05:31:03 +08:00
commit 70867a7fd0
11 changed files with 451 additions and 36 deletions

View file

@ -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;
@ -68,7 +68,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)?;
@ -153,3 +153,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);"
)
}

View file

@ -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};

View file

@ -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 `<edge_table>(from_id, to_id, edge_type)`
/// for the `link` verb. `rank` verb runs PageRank over it.
/// If `Some`, engine creates `<edge_table>` 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 `<field>_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

View file

@ -0,0 +1,64 @@
//! `archive` verb — soft-delete. Flips `<archived_field>` to 1, stamps
//! a sibling `<archived_field>_at` column with current Unix timestamp
//! if such a column exists in the schema.
//!
//! Required schema configuration: `archived_field: Some("<col>")`.
//! 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 `<field>_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<Value, VerbError> {
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 }))
}

View file

@ -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<Value, VerbError> {
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<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 }))
}

View file

@ -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;
@ -22,5 +23,5 @@ pub mod validate;
/// 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",
];

View file

@ -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<i64, f64> = 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<Value, VerbError> {
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<Value> =
@ -50,7 +49,17 @@ pub fn run(
Ok(json!({ "results": results }))
}
fn collect_graph(
fn rank_text(conn: &Connection, edge: &str) -> Result<Value, VerbError> {
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<Value> =
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<i64>, HashMap<i64, Vec<i64>>), VerbError> {
@ -68,14 +77,49 @@ fn collect_graph(
Ok((nodes.into_iter().collect(), out_edges))
}
fn one_iteration(
nodes: &[i64],
out_edges: &HashMap<i64, Vec<i64>>,
prev: &HashMap<i64, f64>,
) -> HashMap<i64, f64> {
fn collect_text(
conn: &Connection,
edge: &str,
) -> Result<(Vec<String>, HashMap<String, Vec<String>>), 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<String> = HashSet::new();
let mut out_edges: HashMap<String, Vec<String>> = 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<K: Eq + Hash + Clone>(
nodes: &[K],
out_edges: &HashMap<K, Vec<K>>,
) -> HashMap<K, f64> {
if nodes.is_empty() {
return HashMap::new();
}
let init = 1.0 / nodes.len() as f64;
let mut rank: HashMap<K, f64> = nodes.iter().map(|n| (n.clone(), init)).collect();
for _ in 0..ITERATIONS {
rank = one_iteration(nodes, out_edges, &rank);
}
rank
}
fn one_iteration<K: Eq + Hash + Clone>(
nodes: &[K],
out_edges: &HashMap<K, Vec<K>>,
prev: &HashMap<K, f64>,
) -> HashMap<K, f64> {
let n = nodes.len() as f64;
let base = (1.0 - DAMPING) / n;
let mut next: HashMap<i64, f64> = nodes.iter().map(|k| (*k, base)).collect();
let mut next: HashMap<K, f64> = 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;

View file

@ -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 `<field>_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);
}

View file

@ -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);
}

View file

@ -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();

View file

@ -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],
};