feat(storage): B2b — settings/profile schema + templates SQLite persistence
Settings: 8 new fields (lastLaunchAt, reentryFreshStartUntil,
lastActiveProfileId, microStepGranularity, nudgeMode, nudgeDigestTimes,
sparklineRangeDays, energyLabels) with sane defaults; nudgesEnabled
auto-maps to nudgeMode on first load (true→immediate, false→off).
Profile: optional energyLabelsOverride field on the localStorage
Profile shape — when present, overrides the global energyLabels;
absent/null falls through to settings.energyLabels via the new
resolveEnergyLabels helper.
Templates: migration v17 adds the templates table (id PK, name,
sections JSON, created_at, updated_at). 5 storage CRUD functions
plus import_templates with idempotent duplicate-id handling. 5 Tauri
commands (list/create/update/delete/import) wired and registered.
Frontend store rewired to read from SQLite via list_templates_cmd;
one-time migration imports kon_templates localStorage and clears it
on success. Fresh installs only seed the new {Meeting notes, Daily
check-in} defaults. Existing user templates survive the migration
verbatim.
Test coverage: 5 new Rust tests (CRUD, import idempotency, atomic
failure, updated_at bump, missing-id delete is no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -839,6 +839,154 @@ pub async fn import_task_lists(
|
||||
Ok(ImportSummary { imported, skipped })
|
||||
}
|
||||
|
||||
// --- B2b: templates CRUD ------------------------------------------------
|
||||
//
|
||||
// Migration v17 added the templates table. Same pattern as task_lists:
|
||||
// the storage layer owns the SQL, the Tauri command layer owns the
|
||||
// JSON-array (de)serialisation for the `sections` column. Sections are
|
||||
// stored as JSON text rather than a join table — they're small
|
||||
// (a handful of short labels), order-significant, and never queried by
|
||||
// section text.
|
||||
|
||||
pub async fn list_templates(pool: &SqlitePool) -> Result<Vec<TemplateRow>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, name, sections, created_at, updated_at \
|
||||
FROM templates ORDER BY created_at ASC, id ASC",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("List templates failed: {e}")))?;
|
||||
Ok(rows.iter().map(template_row_from).collect())
|
||||
}
|
||||
|
||||
pub async fn create_template(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: &str,
|
||||
sections_json: &str,
|
||||
) -> Result<TemplateRow> {
|
||||
sqlx::query("INSERT INTO templates (id, name, sections) VALUES (?, ?, ?)")
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(sections_json)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Create template failed: {e}")))?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, sections, created_at, updated_at FROM templates WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Refetch template failed: {e}")))?;
|
||||
Ok(template_row_from(&row))
|
||||
}
|
||||
|
||||
/// Patch-style update. `name` and `sections_json` use COALESCE so `None`
|
||||
/// preserves the existing column. `updated_at` always advances to
|
||||
/// `datetime('now')` so callers can detect mutations after the fact (the
|
||||
/// frontend uses this for cache invalidation; the bump test asserts it).
|
||||
pub async fn update_template(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
sections_json: Option<&str>,
|
||||
) -> Result<TemplateRow> {
|
||||
sqlx::query(
|
||||
"UPDATE templates \
|
||||
SET name = COALESCE(?, name), \
|
||||
sections = COALESCE(?, sections), \
|
||||
updated_at = datetime('now') \
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(name)
|
||||
.bind(sections_json)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Update template failed: {e}")))?;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, name, sections, created_at, updated_at FROM templates WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Refetch template failed: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
KonError::StorageError(format!("update_template: template {id} not found after update"))
|
||||
})?;
|
||||
Ok(template_row_from(&row))
|
||||
}
|
||||
|
||||
/// Delete is a no-op when the id is missing — matches the contract for
|
||||
/// task_lists/transcripts and lets the frontend issue an idempotent
|
||||
/// "delete then maybe re-create" sequence without special-casing missing
|
||||
/// rows.
|
||||
pub async fn delete_template(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM templates WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Delete template failed: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bulk import for the localStorage → SQLite migration. Same idempotency
|
||||
/// contract as `import_task_lists`: pre-existing IDs skip, duplicate IDs
|
||||
/// within the input batch hard-fail and roll back.
|
||||
pub async fn import_templates(
|
||||
pool: &SqlitePool,
|
||||
items: &[TemplateRow],
|
||||
) -> Result<ImportSummary> {
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||
|
||||
let mut imported = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut seen_in_batch: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
|
||||
for item in items {
|
||||
if !seen_in_batch.insert(item.id.clone()) {
|
||||
return Err(KonError::StorageError(format!(
|
||||
"import_templates: duplicate id '{}' within batch",
|
||||
item.id
|
||||
)));
|
||||
}
|
||||
|
||||
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM templates WHERE id = ?")
|
||||
.bind(&item.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Check template exists failed: {e}")))?;
|
||||
|
||||
if exists.is_some() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
sqlx::query("INSERT INTO templates (id, name, sections) VALUES (?, ?, ?)")
|
||||
.bind(&item.id)
|
||||
.bind(&item.name)
|
||||
.bind(&item.sections_json)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KonError::StorageError(format!("Insert template during import failed: {e}"))
|
||||
})?;
|
||||
imported += 1;
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||
|
||||
Ok(ImportSummary { imported, skipped })
|
||||
}
|
||||
|
||||
// --- Phase 8: daily completion counts -----------------------------------
|
||||
//
|
||||
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
|
||||
@@ -1142,6 +1290,19 @@ pub struct TaskListRow {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// B2b — templates row. Mirrors the `templates` table introduced in
|
||||
/// migration v17. `sections_json` is a JSON-encoded array of strings;
|
||||
/// the storage layer keeps it opaque and the Tauri command layer
|
||||
/// (de)serialises into the frontend-facing `Vec<String>` representation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TemplateRow {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub sections_json: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Result of `import_task_lists` — how many rows landed and how many were
|
||||
/// skipped because their `id` already exists. The whole batch runs in one
|
||||
/// transaction so a duplicate inside the batch (rather than against the
|
||||
@@ -1241,6 +1402,16 @@ fn task_list_row_from(r: &sqlx::sqlite::SqliteRow) -> TaskListRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn template_row_from(r: &sqlx::sqlite::SqliteRow) -> TemplateRow {
|
||||
TemplateRow {
|
||||
id: r.get("id"),
|
||||
name: r.get("name"),
|
||||
sections_json: r.get("sections"),
|
||||
created_at: r.get("created_at"),
|
||||
updated_at: r.get("updated_at"),
|
||||
}
|
||||
}
|
||||
|
||||
fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
|
||||
ImplementationRuleRow {
|
||||
id: r.get("id"),
|
||||
@@ -3145,4 +3316,173 @@ mod tests {
|
||||
"WHERE archived = 0 must skip the already-archived row"
|
||||
);
|
||||
}
|
||||
|
||||
// --- B2b: templates CRUD tests -------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn template_crud_roundtrip() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
// Baseline: fresh DB has no templates.
|
||||
let baseline = list_templates(&pool).await.unwrap();
|
||||
assert!(baseline.is_empty(), "fresh DB has no templates");
|
||||
|
||||
let created = create_template(
|
||||
&pool,
|
||||
"tpl-1",
|
||||
"Meeting notes",
|
||||
r#"["Action items","Decisions"]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(created.id, "tpl-1");
|
||||
assert_eq!(created.name, "Meeting notes");
|
||||
assert_eq!(created.sections_json, r#"["Action items","Decisions"]"#);
|
||||
assert!(!created.created_at.is_empty(), "DB default fills created_at");
|
||||
assert!(!created.updated_at.is_empty(), "DB default fills updated_at");
|
||||
|
||||
let listed = list_templates(&pool).await.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, "tpl-1");
|
||||
|
||||
// Update name only — sections preserved via COALESCE.
|
||||
let renamed = update_template(&pool, "tpl-1", Some("Daily check-in"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(renamed.name, "Daily check-in");
|
||||
assert_eq!(
|
||||
renamed.sections_json, r#"["Action items","Decisions"]"#,
|
||||
"sections None means leave alone"
|
||||
);
|
||||
|
||||
// Update sections only — name preserved.
|
||||
let resectioned = update_template(
|
||||
&pool,
|
||||
"tpl-1",
|
||||
None,
|
||||
Some(r#"["Wins","Blockers","Next"]"#),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resectioned.name, "Daily check-in", "name None means leave alone");
|
||||
assert_eq!(resectioned.sections_json, r#"["Wins","Blockers","Next"]"#);
|
||||
|
||||
delete_template(&pool, "tpl-1").await.unwrap();
|
||||
let after_delete = list_templates(&pool).await.unwrap();
|
||||
assert!(after_delete.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_templates_is_idempotent() {
|
||||
let pool = test_pool().await;
|
||||
let items = vec![
|
||||
TemplateRow {
|
||||
id: "i1".into(),
|
||||
name: "Meeting notes".into(),
|
||||
sections_json: r#"["A","B"]"#.into(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
TemplateRow {
|
||||
id: "i2".into(),
|
||||
name: "Daily check-in".into(),
|
||||
sections_json: r#"["Wins"]"#.into(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
];
|
||||
|
||||
let summary = import_templates(&pool, &items).await.unwrap();
|
||||
assert_eq!(summary.imported, 2);
|
||||
assert_eq!(summary.skipped, 0);
|
||||
|
||||
let summary2 = import_templates(&pool, &items).await.unwrap();
|
||||
assert_eq!(summary2.imported, 0);
|
||||
assert_eq!(summary2.skipped, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_templates_atomic_on_failure() {
|
||||
let pool = test_pool().await;
|
||||
let baseline = list_templates(&pool).await.unwrap();
|
||||
|
||||
// Two items share the same id — must abort the whole batch.
|
||||
let bad = vec![
|
||||
TemplateRow {
|
||||
id: "dup".into(),
|
||||
name: "First".into(),
|
||||
sections_json: r#"["A"]"#.into(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
TemplateRow {
|
||||
id: "dup".into(),
|
||||
name: "Second".into(),
|
||||
sections_json: r#"["B"]"#.into(),
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
},
|
||||
];
|
||||
|
||||
let res = import_templates(&pool, &bad).await;
|
||||
assert!(res.is_err(), "duplicate id within batch must error");
|
||||
|
||||
let after = list_templates(&pool).await.unwrap();
|
||||
assert_eq!(
|
||||
after.len(),
|
||||
baseline.len(),
|
||||
"rollback: no rows from the failed batch should remain"
|
||||
);
|
||||
assert!(after.iter().all(|t| t.id != "dup"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_template_bumps_updated_at() {
|
||||
// SQLite datetime('now') has 1-second resolution; we sleep a hair
|
||||
// over a second to guarantee a tick. This test guards the
|
||||
// documented contract that any update advances updated_at, which
|
||||
// the frontend relies on for invalidating cached template state.
|
||||
let pool = test_pool().await;
|
||||
|
||||
let created = create_template(
|
||||
&pool,
|
||||
"bump",
|
||||
"Before",
|
||||
r#"["S1"]"#,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let initial_updated = created.updated_at.clone();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||
|
||||
let updated = update_template(&pool, "bump", Some("After"), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
updated.updated_at, initial_updated,
|
||||
"updated_at must advance on a name change",
|
||||
);
|
||||
// Ordering check rather than just inequality — if the trigger
|
||||
// ever stamps an earlier value we'd want to know.
|
||||
assert!(
|
||||
updated.updated_at > initial_updated,
|
||||
"updated_at must move forward, got {} -> {}",
|
||||
initial_updated,
|
||||
updated.updated_at,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_template_is_noop_when_missing() {
|
||||
let pool = test_pool().await;
|
||||
|
||||
// No row with this id exists. Idempotent contract: deleting a
|
||||
// missing id returns Ok without raising.
|
||||
let res = delete_template(&pool, "does-not-exist").await;
|
||||
assert!(res.is_ok(), "delete of missing template must be Ok, got: {res:?}");
|
||||
|
||||
let listed = list_templates(&pool).await.unwrap();
|
||||
assert!(listed.is_empty(), "no rows touched by missing-id delete");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,21 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
||||
pub use database::{
|
||||
add_profile_term, archive_inbox_older_than, archive_task,
|
||||
complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||
create_profile, create_task_list, delete_implementation_rule, delete_profile,
|
||||
delete_profile_term, delete_task, delete_task_list, delete_transcript,
|
||||
get_implementation_rule, get_profile, get_setting, get_task_by_id, get_transcript,
|
||||
import_task_lists, init, init_readonly, insert_implementation_rule, insert_subtask,
|
||||
insert_task, insert_transcript, list_archived_tasks, list_feedback_examples,
|
||||
create_profile, create_task_list, create_template, delete_implementation_rule,
|
||||
delete_profile, delete_profile_term, delete_task, delete_task_list,
|
||||
delete_template, delete_transcript, get_implementation_rule, get_profile,
|
||||
get_setting, get_task_by_id, get_transcript, import_task_lists, import_templates,
|
||||
init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
|
||||
insert_transcript, list_archived_tasks, list_feedback_examples,
|
||||
list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions,
|
||||
list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_transcripts,
|
||||
list_transcripts_paged, log_error, mark_implementation_rule_fired, prune_error_log,
|
||||
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
|
||||
set_task_energy, unarchive_task, uncomplete_task, update_profile, update_task,
|
||||
update_task_list, update_transcript, update_transcript_meta, DailyCompletionCount,
|
||||
ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, ImportSummary,
|
||||
InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow,
|
||||
TaskRow, TranscriptRow,
|
||||
list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_templates,
|
||||
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
||||
set_setting, set_task_energy, unarchive_task, uncomplete_task, update_profile,
|
||||
update_task, update_task_list, update_template, update_transcript,
|
||||
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow,
|
||||
FeedbackTargetType, ImplementationRuleRow, ImportSummary, InsertTranscriptParams,
|
||||
ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow, TaskRow, TemplateRow,
|
||||
TranscriptRow,
|
||||
};
|
||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||
|
||||
@@ -495,6 +495,33 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived);
|
||||
"#,
|
||||
),
|
||||
(
|
||||
17,
|
||||
"templates: SQLite-backed section scaffold templates (B2b)",
|
||||
r#"
|
||||
-- B2b moves dictation section templates from
|
||||
-- localStorage["kon_templates"] into SQLite so they survive across
|
||||
-- machines and behave consistently with the rest of the userland
|
||||
-- data (task_lists in B2a, transcripts since v1).
|
||||
--
|
||||
-- sections is a JSON array of strings. We keep it as TEXT rather
|
||||
-- than a join table because templates are small (a handful of
|
||||
-- short labels) and the order matters but is fully ordered by
|
||||
-- the array. Index on name is for the future "search templates"
|
||||
-- affordance and to keep alphabetised lookups cheap.
|
||||
--
|
||||
-- Comment style note: avoid semicolons inside SQL comments. The
|
||||
-- statement splitter is naive and will treat them as boundaries.
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
sections TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name);
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||
@@ -644,7 +671,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 16);
|
||||
assert_eq!(count, 17);
|
||||
|
||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||
.execute(&pool)
|
||||
@@ -663,7 +690,7 @@ mod tests {
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count, 16);
|
||||
assert_eq!(count, 17);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user