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:
2026-04-26 19:16:31 +01:00
parent 0111fc9e08
commit e4cbf62a20
10 changed files with 943 additions and 37 deletions

View File

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

View File

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

View File

@@ -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]