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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user