Skip to main content

bones_core/db/
schema.rs

1//! Canonical `SQLite` projection schema for bones.
2//!
3//! The schema is normalized for queryability and deterministic replay:
4//! - `items` keeps the latest aggregate fields for each work item
5//! - edge tables (`item_labels`, `item_assignees`, `item_dependencies`) model
6//!   multi-valued relationships
7//! - `item_comments` and `event_redactions` preserve event-driven side effects
8//! - `projection_meta` tracks replay cursor metadata for incremental rebuilds
9
10/// Migration v1: core normalized tables plus projection metadata.
11pub const MIGRATION_V1_SQL: &str = r"
12CREATE TABLE IF NOT EXISTS items (
13    item_id TEXT PRIMARY KEY,
14    title TEXT NOT NULL,
15    description TEXT,
16    kind TEXT NOT NULL CHECK (kind IN ('task', 'goal', 'bug')),
17    state TEXT NOT NULL CHECK (state IN ('open', 'doing', 'done', 'archived')),
18    urgency TEXT NOT NULL DEFAULT 'default' CHECK (urgency IN ('urgent', 'default', 'punt')),
19    size TEXT CHECK (size IS NULL OR size IN ('xs', 's', 'm', 'l', 'xl')),
20    parent_id TEXT REFERENCES items(item_id) ON DELETE SET NULL,
21    compact_summary TEXT,
22    snapshot_json TEXT,
23    is_deleted INTEGER NOT NULL DEFAULT 0 CHECK (is_deleted IN (0, 1)),
24    deleted_at_us INTEGER,
25    search_labels TEXT NOT NULL DEFAULT '',
26    created_at_us INTEGER NOT NULL,
27    updated_at_us INTEGER NOT NULL,
28    CHECK (item_id GLOB '[a-z][a-z]-*' OR item_id GLOB '[a-z][a-z][a-z]-*')
29);
30
31CREATE TABLE IF NOT EXISTS item_labels (
32    item_id TEXT NOT NULL REFERENCES items(item_id) ON DELETE CASCADE,
33    label TEXT NOT NULL CHECK (length(trim(label)) > 0),
34    created_at_us INTEGER NOT NULL,
35    PRIMARY KEY (item_id, label)
36);
37
38CREATE TABLE IF NOT EXISTS item_assignees (
39    item_id TEXT NOT NULL REFERENCES items(item_id) ON DELETE CASCADE,
40    agent TEXT NOT NULL CHECK (length(trim(agent)) > 0),
41    created_at_us INTEGER NOT NULL,
42    PRIMARY KEY (item_id, agent)
43);
44
45CREATE TABLE IF NOT EXISTS item_dependencies (
46    item_id TEXT NOT NULL REFERENCES items(item_id) ON DELETE CASCADE,
47    depends_on_item_id TEXT NOT NULL REFERENCES items(item_id) ON DELETE CASCADE,
48    link_type TEXT NOT NULL CHECK (length(trim(link_type)) > 0),
49    created_at_us INTEGER NOT NULL,
50    PRIMARY KEY (item_id, depends_on_item_id, link_type),
51    CHECK (item_id <> depends_on_item_id)
52);
53
54CREATE TABLE IF NOT EXISTS item_comments (
55    comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
56    item_id TEXT NOT NULL REFERENCES items(item_id) ON DELETE CASCADE,
57    event_hash TEXT NOT NULL UNIQUE,
58    author TEXT NOT NULL,
59    body TEXT NOT NULL,
60    created_at_us INTEGER NOT NULL
61);
62
63CREATE TABLE IF NOT EXISTS event_redactions (
64    target_event_hash TEXT PRIMARY KEY,
65    item_id TEXT NOT NULL REFERENCES items(item_id) ON DELETE CASCADE,
66    reason TEXT NOT NULL,
67    redacted_by TEXT NOT NULL,
68    redacted_at_us INTEGER NOT NULL
69);
70
71CREATE TABLE IF NOT EXISTS projection_meta (
72    id INTEGER PRIMARY KEY CHECK (id = 1),
73    schema_version INTEGER NOT NULL,
74    last_event_offset INTEGER NOT NULL DEFAULT 0,
75    last_event_hash TEXT,
76    last_rebuild_at_us INTEGER NOT NULL DEFAULT 0
77);
78
79INSERT OR IGNORE INTO projection_meta (
80    id,
81    schema_version,
82    last_event_offset,
83    last_event_hash,
84    last_rebuild_at_us
85) VALUES (1, 1, 0, NULL, 0);
86";
87
88/// Migration v2: read-path indexes and FTS5 table/triggers.
89pub const MIGRATION_V2_SQL: &str = r"
90CREATE INDEX IF NOT EXISTS idx_items_state_urgency_updated
91    ON items(state, urgency, updated_at_us DESC);
92
93CREATE INDEX IF NOT EXISTS idx_items_kind_state
94    ON items(kind, state);
95
96CREATE INDEX IF NOT EXISTS idx_items_parent
97    ON items(parent_id);
98
99CREATE INDEX IF NOT EXISTS idx_items_deleted_updated
100    ON items(is_deleted, updated_at_us DESC);
101
102CREATE INDEX IF NOT EXISTS idx_item_labels_label
103    ON item_labels(label, item_id);
104
105CREATE INDEX IF NOT EXISTS idx_item_assignees_agent
106    ON item_assignees(agent, item_id);
107
108CREATE INDEX IF NOT EXISTS idx_item_dependencies_target_type
109    ON item_dependencies(depends_on_item_id, link_type, item_id);
110
111CREATE INDEX IF NOT EXISTS idx_item_comments_item_created
112    ON item_comments(item_id, created_at_us DESC);
113
114CREATE INDEX IF NOT EXISTS idx_event_redactions_item
115    ON event_redactions(item_id);
116
117CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
118    title,
119    description,
120    labels,
121    item_id UNINDEXED,
122    tokenize='porter unicode61',
123    prefix='2 3'
124);
125
126CREATE TRIGGER IF NOT EXISTS items_ai
127AFTER INSERT ON items
128BEGIN
129    INSERT INTO items_fts(rowid, title, description, labels, item_id)
130    VALUES (
131        new.rowid,
132        new.title,
133        COALESCE(new.description, ''),
134        COALESCE(new.search_labels, ''),
135        new.item_id
136    );
137END;
138
139CREATE TRIGGER IF NOT EXISTS items_au
140AFTER UPDATE ON items
141BEGIN
142    DELETE FROM items_fts WHERE rowid = old.rowid;
143
144    INSERT INTO items_fts(rowid, title, description, labels, item_id)
145    VALUES (
146        new.rowid,
147        new.title,
148        COALESCE(new.description, ''),
149        COALESCE(new.search_labels, ''),
150        new.item_id
151    );
152END;
153
154CREATE TRIGGER IF NOT EXISTS items_ad
155AFTER DELETE ON items
156BEGIN
157    DELETE FROM items_fts WHERE rowid = old.rowid;
158END;
159
160DELETE FROM items_fts;
161INSERT INTO items_fts(rowid, title, description, labels, item_id)
162SELECT
163    rowid,
164    title,
165    COALESCE(description, ''),
166    COALESCE(search_labels, ''),
167    item_id
168FROM items;
169
170UPDATE projection_meta
171SET schema_version = 2
172WHERE id = 1;
173";
174
175/// Indexes expected by list/filter/triage query paths.
176pub const REQUIRED_INDEXES: &[&str] = &[
177    "idx_items_state_urgency_updated",
178    "idx_items_kind_state",
179    "idx_items_parent",
180    "idx_items_deleted_updated",
181    "idx_item_labels_label",
182    "idx_item_assignees_agent",
183    "idx_item_dependencies_target_type",
184    "idx_item_comments_item_created",
185    "idx_event_redactions_item",
186];
187
188#[cfg(test)]
189mod tests {
190    use crate::db::migrations;
191    use rusqlite::{Connection, params};
192
193    fn seeded_conn() -> rusqlite::Result<Connection> {
194        let mut conn = Connection::open_in_memory()?;
195        migrations::migrate(&mut conn)?;
196
197        for idx in 0..36_u32 {
198            let item_id = format!("bn-{idx:03x}");
199            let title = if idx % 4 == 0 {
200                format!("Auth timeout regression {idx}")
201            } else {
202                format!("General maintenance {idx}")
203            };
204            let description = if idx % 4 == 0 {
205                "Authentication retries fail after 30 seconds".to_string()
206            } else {
207                "Routine maintenance item".to_string()
208            };
209            let state = if idx % 2 == 0 { "open" } else { "doing" };
210            let urgency = if idx % 3 == 0 { "urgent" } else { "default" };
211            let labels = if idx % 4 == 0 { "auth backend" } else { "ops" };
212
213            conn.execute(
214                "INSERT INTO items (
215                    item_id,
216                    title,
217                    description,
218                    kind,
219                    state,
220                    urgency,
221                    is_deleted,
222                    search_labels,
223                    created_at_us,
224                    updated_at_us
225                 ) VALUES (?1, ?2, ?3, 'task', ?4, ?5, 0, ?6, ?7, ?8)",
226                params![
227                    item_id,
228                    title,
229                    description,
230                    state,
231                    urgency,
232                    labels,
233                    i64::from(idx),
234                    i64::from(idx) + 1_000
235                ],
236            )?;
237
238            if idx % 4 == 0 {
239                conn.execute(
240                    "INSERT INTO item_labels (item_id, label, created_at_us)
241                     VALUES (?1, 'backend', ?2)",
242                    params![format!("bn-{idx:03x}"), i64::from(idx)],
243                )?;
244            }
245
246            if idx % 5 == 0 {
247                conn.execute(
248                    "INSERT INTO item_assignees (item_id, agent, created_at_us)
249                     VALUES (?1, 'bones-dev/0/keen-engine', ?2)",
250                    params![format!("bn-{idx:03x}"), i64::from(idx)],
251                )?;
252            }
253        }
254
255        conn.execute(
256            "INSERT INTO item_dependencies (item_id, depends_on_item_id, link_type, created_at_us)
257             VALUES ('bn-006', 'bn-000', 'blocks', 10)",
258            [],
259        )?;
260        conn.execute(
261            "INSERT INTO item_dependencies (item_id, depends_on_item_id, link_type, created_at_us)
262             VALUES ('bn-00a', 'bn-000', 'blocks', 11)",
263            [],
264        )?;
265
266        Ok(conn)
267    }
268
269    fn query_plan_details(conn: &Connection, sql: &str) -> rusqlite::Result<Vec<String>> {
270        let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {sql}"))?;
271        stmt.query_map([], |row| row.get::<_, String>(3))?
272            .collect::<Result<Vec<_>, _>>()
273    }
274
275    #[test]
276    fn query_plan_uses_triage_index() -> rusqlite::Result<()> {
277        let conn = seeded_conn()?;
278        let details = query_plan_details(
279            &conn,
280            "SELECT item_id
281             FROM items
282             WHERE state = 'open' AND urgency = 'urgent'
283             ORDER BY updated_at_us DESC
284             LIMIT 20",
285        )?;
286
287        assert!(
288            details
289                .iter()
290                .any(|detail| detail.contains("idx_items_state_urgency_updated")),
291            "expected triage index in plan, got: {details:?}"
292        );
293
294        Ok(())
295    }
296
297    #[test]
298    fn query_plan_uses_label_lookup_index() -> rusqlite::Result<()> {
299        let conn = seeded_conn()?;
300        let details = query_plan_details(
301            &conn,
302            "SELECT item_id
303             FROM item_labels
304             WHERE label = 'backend'
305             ORDER BY item_id",
306        )?;
307
308        assert!(
309            details
310                .iter()
311                .any(|detail| detail.contains("idx_item_labels_label")),
312            "expected label index in plan, got: {details:?}"
313        );
314
315        Ok(())
316    }
317
318    #[test]
319    fn query_plan_uses_reverse_dependency_index() -> rusqlite::Result<()> {
320        let conn = seeded_conn()?;
321        let details = query_plan_details(
322            &conn,
323            "SELECT item_id
324             FROM item_dependencies
325             WHERE depends_on_item_id = 'bn-000' AND link_type = 'blocks'",
326        )?;
327
328        assert!(
329            details
330                .iter()
331                .any(|detail| detail.contains("idx_item_dependencies_target_type")),
332            "expected dependency index in plan, got: {details:?}"
333        );
334
335        Ok(())
336    }
337
338    #[test]
339    fn fts_supports_weighted_bm25_queries() -> rusqlite::Result<()> {
340        let conn = seeded_conn()?;
341        let mut stmt = conn.prepare(
342            "SELECT item_id
343             FROM items_fts
344             WHERE items_fts MATCH 'auth'
345             ORDER BY bm25(items_fts, 3.0, 2.0, 1.0)
346             LIMIT 5",
347        )?;
348
349        let rows = stmt
350            .query_map([], |row| row.get::<_, String>(0))?
351            .collect::<Result<Vec<_>, _>>()?;
352
353        assert!(
354            !rows.is_empty(),
355            "expected at least one lexical hit from items_fts"
356        );
357
358        Ok(())
359    }
360}