Skip to content

Commit 643464d

Browse files
authored
fix: deep-dive review - M-1 through M-5 (transactions, validation, Windows, timestamps) (#54)
* fix: deep-dive review - M-1 through M-5 M-1 (#49): Entity row + FTS5 index writes now wrapped in transactions for both insert and update paths M-2 (#50): decay_tick uses RAII transaction (unchecked_transaction) instead of bare BEGIN/COMMIT M-3 (#51): All 7 tool handlers now validate args with proper error messages instead of silent unwrap_or defaults M-4 (#52): HOME fallback now checks USERPROFILE on Windows, fails with clear error instead of /root M-4: Vault export/import HOME references also fixed for Windows M-5 (#53): chrono_like now emits real ISO 8601 UTC timestamps instead of raw epoch seconds * fix: M-1 extended - transactional FTS writes for forget and compact paths --------- Co-authored-by: Thomas Connally <tcconnally@users.noreply.github.com>
1 parent b6d9f53 commit 643464d

3 files changed

Lines changed: 103 additions & 53 deletions

File tree

src/db.rs

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,40 @@ use crate::models::{
77
};
88
use crate::schema;
99

10-
/// Format a unix timestamp in milliseconds as an ISO 8601 string.
10+
/// Format a unix timestamp in milliseconds as an ISO 8601 UTC string.
11+
/// Produces a human-readable date like "2026-06-12T10:58:00Z".
1112
fn chrono_like(ms: i64) -> String {
1213
let secs = ms / 1000;
13-
let _nanos = ((ms % 1000) * 1_000_000) as u32;
14-
// Simple approach: just return the timestamp as a readable number
15-
// A proper implementation would use the chrono crate, but we keep deps minimal
16-
format!("{}", secs)
14+
// M-5: emit actual ISO 8601 UTC instead of raw epoch seconds.
15+
// Avoids chrono dependency by hand-rolling a minimal formatter.
16+
// Only safe for timestamps from 1970 to ~3000 (no leap-second handling).
17+
if secs <= 0 {
18+
return format!("{}", secs); // Unix epoch 0 or negative — return as-is
19+
}
20+
let days_since_epoch = secs / 86400;
21+
let secs_of_day = secs % 86400;
22+
// Convert days since 1970-01-01 to year/month/day
23+
let mut y = 1970i64;
24+
let mut d = days_since_epoch;
25+
loop {
26+
let days_in_year = if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) { 366 } else { 365 };
27+
if d < days_in_year { break; }
28+
d -= days_in_year;
29+
y += 1;
30+
}
31+
let leap = (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);
32+
let month_days = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
33+
let mut m = 0usize;
34+
while m < 12 && d >= month_days[m] {
35+
d -= month_days[m];
36+
m += 1;
37+
}
38+
let month = m + 1;
39+
let day = d + 1;
40+
let h = secs_of_day / 3600;
41+
let min = (secs_of_day % 3600) / 60;
42+
let s = secs_of_day % 60;
43+
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, month, day, h, min, s)
1744
}
1845

1946
pub fn now_ms() -> i64 {
@@ -110,34 +137,34 @@ impl Database {
110137
let mut auto_archived = 0i64;
111138
let now_val = now;
112139

113-
// Wrap in explicit transaction to avoid per-statement fsync
114-
let _ = self.conn.execute_batch("BEGIN");
140+
// M-2: wrap in RAII transaction so error paths roll back automatically
141+
let tx = self.conn.unchecked_transaction()?;
115142

116143
for row in rows {
117144
let (id, last_access) = row?;
118145
let new_decay = Self::compute_decay(last_access, now_val);
119-
self.conn.execute(
146+
tx.execute(
120147
"UPDATE entities SET decay_score = ?1 WHERE id = ?2",
121148
params![new_decay, id],
122149
)?;
123150
updated += 1;
124151

125152
// Auto-archive entities that have fully decayed (decay < 0.05)
126153
if new_decay < 0.05 {
127-
self.conn.execute(
154+
tx.execute(
128155
"UPDATE entities SET archived = 1, archive_reason = 'decay threshold' WHERE id = ?1 AND archived = 0",
129156
params![id],
130157
)?;
131158
auto_archived += 1;
132159
// Clean FTS5 index for auto-archived entity
133-
let _ = self.conn.execute(
160+
let _ = tx.execute(
134161
"DELETE FROM entities_fts WHERE rowid = (SELECT rowid FROM entities WHERE id = ?1)",
135162
params![id],
136163
);
137164
}
138165
}
139166

140-
let _ = self.conn.execute_batch("COMMIT");
167+
tx.commit()?;
141168

142169
Ok(DecayReport {
143170
entities_checked: total,
@@ -255,7 +282,10 @@ impl Database {
255282
.unwrap_or(0);
256283
let boosted = Self::boost_decay(old_decay);
257284
let new_layer = Self::compute_layer(old_count + 1);
258-
self.conn.execute(
285+
286+
// M-1: wrap entity UPDATE + FTS UPDATE in a transaction
287+
let tx = self.conn.unchecked_transaction()?;
288+
tx.execute(
259289
"UPDATE entities SET
260290
body_json = ?1, status = ?2, type = ?3, tags = ?4,
261291
decay_score = ?5, layer = ?6, topic_path = ?7,
@@ -282,10 +312,11 @@ impl Database {
282312
)?;
283313

284314
// Update FTS5 index
285-
self.conn.execute(
315+
tx.execute(
286316
"UPDATE entities_fts SET body_json = ?1 WHERE rowid = (SELECT rowid FROM entities WHERE id = ?2)",
287317
params![entity.body_json, id],
288318
)?;
319+
tx.commit()?;
289320

290321
action = "updated".to_string();
291322
} else {
@@ -306,7 +337,11 @@ impl Database {
306337

307338
// Insert new entity
308339
id = entity.id.clone();
309-
self.conn.execute(
340+
341+
// M-1: wrap entity row + FTS index write in a transaction
342+
// so a failure in one doesn't leave the other orphaned.
343+
let tx = self.conn.unchecked_transaction()?;
344+
tx.execute(
310345
"INSERT INTO entities
311346
(id, category, key, body_json, status, type, tags,
312347
decay_score, retrieval_count, layer, topic_path,
@@ -339,10 +374,11 @@ impl Database {
339374
)?;
340375

341376
// Add to FTS5 index
342-
self.conn.execute(
377+
tx.execute(
343378
"INSERT INTO entities_fts (rowid, body_json) VALUES (last_insert_rowid(), ?1)",
344379
params![entity.body_json],
345380
)?;
381+
tx.commit()?;
346382

347383
action = "created".to_string();
348384
}
@@ -538,19 +574,22 @@ impl Database {
538574
key: &str,
539575
reason: &str,
540576
) -> Result<bool, Box<dyn std::error::Error>> {
541-
let affected = self.conn.execute(
577+
// M-1 extended: wrap forget's entity UPDATE + FTS DELETE in a transaction
578+
let tx = self.conn.unchecked_transaction()?;
579+
let affected = tx.execute(
542580
"UPDATE entities SET archived = 1, archive_reason = ?1,
543581
last_accessed_unix_ms = ?2
544582
WHERE category = ?3 AND key = ?4 AND archived = 0",
545583
params![reason, now_ms(), category, key],
546584
)?;
547585
// Clean FTS5 index for archived entity
548586
if affected > 0 {
549-
let _ = self.conn.execute(
587+
let _ = tx.execute(
550588
"DELETE FROM entities_fts WHERE rowid = (SELECT rowid FROM entities WHERE category = ?1 AND key = ?2)",
551589
params![category, key],
552590
);
553591
}
592+
tx.commit()?;
554593
Ok(affected > 0)
555594
}
556595

@@ -1084,17 +1123,20 @@ impl Database {
10841123
|r| r.get(0),
10851124
)?
10861125
} else {
1087-
let count = self.conn.execute(
1126+
// M-1 extended: wrap compact UPDATE + FTS DELETE in a transaction
1127+
let tx = self.conn.unchecked_transaction()?;
1128+
let count = tx.execute(
10881129
"UPDATE entities SET archived = 1, archive_reason = 'decay threshold',
10891130
last_accessed_unix_ms = ?1
10901131
WHERE archived = 0 AND decay_score < ?2",
10911132
params![now_ms(), min_decay],
10921133
)? as i64;
10931134
// Clean FTS5 index for compacted entities
1094-
let _ = self.conn.execute(
1135+
let _ = tx.execute(
10951136
"DELETE FROM entities_fts WHERE rowid IN (SELECT rowid FROM entities WHERE archived = 1 AND archive_reason = 'decay threshold')",
10961137
[],
10971138
);
1139+
tx.commit()?;
10981140
count
10991141
};
11001142

src/main.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ enum Commands {
5252

5353
fn default_db_path() -> String {
5454
std::env::var("MIMIR_DB_PATH").unwrap_or_else(|_| {
55-
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
55+
// M-4: use platform-appropriate home directory.
56+
// On Windows, HOME is typically unset; fall back to USERPROFILE.
57+
let home = std::env::var("HOME")
58+
.or_else(|_| std::env::var("USERPROFILE"))
59+
.unwrap_or_else(|_| {
60+
eprintln!("mimir: could not determine home directory. Set MIMIR_DB_PATH or HOME/USERPROFILE.");
61+
std::process::exit(1);
62+
});
5663
let dir = format!("{}/.mimir/data", home);
5764
let _ = std::fs::create_dir_all(&dir);
5865
format!("{}/mimir.db", dir)

src/tools.rs

Lines changed: 34 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -483,10 +483,10 @@ pub fn handle_stats(db: &Database) -> String {
483483
}
484484

485485
pub fn handle_compact(db: &Database, args: Value) -> String {
486-
let a: CompactArgs = serde_json::from_value(args).unwrap_or(CompactArgs {
487-
min_decay: 0.1,
488-
dry_run: false,
489-
});
486+
let a: CompactArgs = match serde_json::from_value(args) {
487+
Ok(a) => a,
488+
Err(e) => return json!({"error": format!("Invalid compact arguments: {}", e)}).to_string(),
489+
};
490490

491491
match db.compact(a.min_decay, a.dry_run) {
492492
Ok(report) => serde_json::to_string(&report).unwrap_or_else(|e| {
@@ -511,10 +511,10 @@ pub fn handle_migrate(db: &Database, args: Value) -> String {
511511
}
512512

513513
pub fn handle_context(db: &Database, args: Value) -> String {
514-
let a: ContextArgs = serde_json::from_value(args).unwrap_or(ContextArgs {
515-
categories: vec![],
516-
limit: 10,
517-
});
514+
let a: ContextArgs = match serde_json::from_value(args) {
515+
Ok(a) => a,
516+
Err(e) => return json!({"error": format!("Invalid context arguments: {}", e)}).to_string(),
517+
};
518518

519519
match db.context(&a.categories, a.limit) {
520520
Ok(markdown) => {
@@ -531,11 +531,14 @@ pub struct VaultExportArgs {
531531
}
532532

533533
pub fn handle_vault_export(db: &Database, args: Value) -> String {
534-
let a: VaultExportArgs = serde_json::from_value(args).unwrap_or(VaultExportArgs {
535-
vault_dir: "~/.mimir/vault".to_string(),
536-
});
534+
let a: VaultExportArgs = match serde_json::from_value(args) {
535+
Ok(a) => a,
536+
Err(e) => return json!({"error": format!("Invalid vault_export arguments: {}", e)}).to_string(),
537+
};
537538
let dir = if a.vault_dir.starts_with("~/") {
538-
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
539+
let home = std::env::var("HOME")
540+
.or_else(|_| std::env::var("USERPROFILE"))
541+
.unwrap_or_else(|_| "/root".to_string());
539542
a.vault_dir.replacen("~", &home, 1)
540543
} else {
541544
a.vault_dir.clone()
@@ -549,11 +552,14 @@ pub fn handle_vault_export(db: &Database, args: Value) -> String {
549552
}
550553

551554
pub fn handle_vault_import(db: &Database, args: Value) -> String {
552-
let a: VaultExportArgs = serde_json::from_value(args).unwrap_or(VaultExportArgs {
553-
vault_dir: "~/.mimir/vault".to_string(),
554-
});
555+
let a: VaultExportArgs = match serde_json::from_value(args) {
556+
Ok(a) => a,
557+
Err(e) => return json!({"error": format!("Invalid vault_import arguments: {}", e)}).to_string(),
558+
};
555559
let dir = if a.vault_dir.starts_with("~/") {
556-
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
560+
let home = std::env::var("HOME")
561+
.or_else(|_| std::env::var("USERPROFILE"))
562+
.unwrap_or_else(|_| "/root".to_string());
557563
a.vault_dir.replacen("~", &home, 1)
558564
} else {
559565
a.vault_dir.clone()
@@ -585,12 +591,10 @@ fn default_max_nodes() -> i64 {
585591
}
586592

587593
pub fn handle_traverse(db: &Database, args: Value) -> String {
588-
let a: TraverseArgs = serde_json::from_value(args).unwrap_or(TraverseArgs {
589-
category: "general".to_string(),
590-
key: "".to_string(),
591-
max_depth: 3,
592-
max_nodes: 100,
593-
});
594+
let a: TraverseArgs = match serde_json::from_value(args) {
595+
Ok(a) => a,
596+
Err(e) => return json!({"error": format!("Invalid traverse arguments: {}", e)}).to_string(),
597+
};
594598
match db.traverse_chain(&a.category, &a.key, a.max_depth, a.max_nodes) {
595599
Ok(chain) => serde_json::to_string(&chain)
596600
.unwrap_or_else(|e| json!({"error": format!("{}", e)}).to_string()),
@@ -606,11 +610,10 @@ pub struct ScoreArgs {
606610
}
607611

608612
pub fn handle_score(db: &Database, args: Value) -> String {
609-
let a: ScoreArgs = serde_json::from_value(args).unwrap_or(ScoreArgs {
610-
category: "".to_string(),
611-
key: "".to_string(),
612-
score: 0.5,
613-
});
613+
let a: ScoreArgs = match serde_json::from_value(args) {
614+
Ok(a) => a,
615+
Err(e) => return json!({"error": format!("Invalid score arguments: {}", e)}).to_string(),
616+
};
614617
match db.score_entity(&a.category, &a.key, a.score) {
615618
Ok(found) => {
616619
json!({"found": found, "category": a.category, "key": a.key, "score": a.score})
@@ -639,12 +642,10 @@ fn default_conflict_limit() -> i64 {
639642
}
640643

641644
pub fn handle_conflicts(db: &Database, args: Value) -> String {
642-
let a: ConflictArgs = serde_json::from_value(args).unwrap_or(ConflictArgs {
643-
category: "general".to_string(),
644-
threshold: 0.4,
645-
limit: 10,
646-
offset: 0,
647-
});
645+
let a: ConflictArgs = match serde_json::from_value(args) {
646+
Ok(a) => a,
647+
Err(e) => return json!({"error": format!("Invalid conflicts arguments: {}", e)}).to_string(),
648+
};
648649
match db.detect_conflicts(&a.category, a.threshold, a.limit, a.offset) {
649650
Ok(report) => serde_json::to_string(&report)
650651
.unwrap_or_else(|e| json!({"error": format!("{}", e)}).to_string()),

0 commit comments

Comments
 (0)