1pub 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
88pub 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
175pub 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}