diff --git a/docs/ANDROID-BETA.md b/docs/ANDROID-BETA.md index 9a10f1c..60fcec7 100644 --- a/docs/ANDROID-BETA.md +++ b/docs/ANDROID-BETA.md @@ -77,6 +77,11 @@ Alimentação e Lazer; tempo = Foco profundo, Leitura, Saúde, Pessoal e Descanso. `Transporte` e `Administração` saíram do seed (recriáveis como categoria custom) para valer também no PC. +O backstop em Rust só reverte um lote quando ele **aumenta** a contagem acima +do limite. Dados legados ou vindos de um aparelho Pro que já estejam acima do +limite continuam editáveis (grandfathering) — não podem é crescer mais. O banco +do modo de teste (`atlas.test.db`) fica fora do gating. + ## Fatias 1. **Fundação** ✅ — versão 0.4.0, `tauri android init`, JDK 17 + NDK 27, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2a29a9..c10ef17 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -71,6 +71,17 @@ async fn db_batch( DbPool::Sqlite(pool) => pool, }; let mut tx = pool.begin().await.map_err(|error| error.to_string())?; + + // Freemium backstop: conta o estado **antes** do lote para permitir editar + // dados legados que já estão acima dos limites do free. O banco de teste + // fica fora do gating — o seed de exemplo passa dos limites de propósito. + #[cfg(any(target_os = "android", target_os = "ios"))] + let limit_before = if db.ends_with("atlas.test.db") { + None + } else { + Some(plan::count_usage(&app, &mut tx).await?) + }; + for statement in statements { let mut query = sqlx::query(&statement.sql); for param in &statement.params { @@ -82,10 +93,12 @@ async fn db_batch( .map_err(|error| error.to_string())?; } - // Backstop do freemium: sem Pro, um lote que estoura qualquer limite - // inteiro é revertido (a UI já bloqueia antes, mas o IPC não é confiável). + // Backstop do freemium: sem Pro, um lote que leva a contagem acima de um + // limite é revertido. A UI já bloqueia antes, mas o IPC não é confiável. #[cfg(any(target_os = "android", target_os = "ios"))] - plan::check_limits(&app, &mut tx).await?; + if let Some(before) = limit_before { + plan::check_limits(&app, &mut tx, &before).await?; + } tx.commit().await.map_err(|error| error.to_string())?; Ok(()) diff --git a/src-tauri/src/plan.rs b/src-tauri/src/plan.rs index 9786b63..842a8df 100644 --- a/src-tauri/src/plan.rs +++ b/src-tauri/src/plan.rs @@ -188,78 +188,119 @@ fn attachment_count(app: &AppHandle) -> u64 { .sum() } -/// Chamado dentro da transação: rollback se o free passou de algum limite. +/// Contagem atual das tabelas limitadas, tirada **antes** do lote. +/// +/// Serve para grandfathering: quem já está acima do limite (dados antigos, +/// sync de um Pro, modo de teste) continua editando; só **aumentar** além do +/// limite é bloqueado. #[cfg(any(target_os = "android", target_os = "ios"))] -pub async fn check_limits( +#[derive(Debug, Clone, Copy)] +pub struct LimitCounts { + notes: i64, + habits: i64, + trackers: i64, + finance_categories: i64, + time_categories: i64, + graph_nodes: i64, + attachments: u64, +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn count_usage( app: &AppHandle, tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, -) -> Result<(), String> { - let stored = read_stored(app); - if is_pro(&stored) { - return Ok(()); - } - let limit = limits(); - +) -> Result { let notes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notes WHERE deleted_at IS NULL") .fetch_one(&mut **tx) .await .map_err(|error| error.to_string())?; - if notes > i64::from(limit.notes) { - return Err(format!("plan-limit:notes:{}", limit.notes)); - } - let habits: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM habits WHERE deleted_at IS NULL AND COALESCE(json_extract(payload, '$.active'), 1) = 1", ) .fetch_one(&mut **tx) .await .map_err(|error| error.to_string())?; - if habits > i64::from(limit.habits) { - return Err(format!("plan-limit:habits:{}", limit.habits)); - } - let trackers: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM trackers WHERE deleted_at IS NULL AND COALESCE(json_extract(payload, '$.active'), 1) = 1", ) .fetch_one(&mut **tx) .await .map_err(|error| error.to_string())?; - if trackers > i64::from(limit.trackers) { - return Err(format!("plan-limit:trackers:{}", limit.trackers)); - } - - let finance: i64 = sqlx::query_scalar::<_, i64>( + let finance_categories: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM categories WHERE deleted_at IS NULL AND module = 'finance'", ) .fetch_one(&mut **tx) .await .map_err(|error| error.to_string())?; - if finance > i64::from(limit.finance_categories) { - return Err(format!("plan-limit:finance_categories:{}", limit.finance_categories)); - } - - let time: i64 = sqlx::query_scalar::<_, i64>( + let time_categories: i64 = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM categories WHERE deleted_at IS NULL AND module = 'time'", ) .fetch_one(&mut **tx) .await .map_err(|error| error.to_string())?; - if time > i64::from(limit.time_categories) { - return Err(format!("plan-limit:time_categories:{}", limit.time_categories)); - } - - let nodes: i64 = sqlx::query_scalar( + let graph_nodes: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM graph_nodes WHERE deleted_at IS NULL AND COALESCE(json_extract(payload, '$.origin'), 0) = 0", ) .fetch_one(&mut **tx) .await .map_err(|error| error.to_string())?; - if nodes > i64::from(limit.graph_nodes) { - return Err(format!("plan-limit:graph_nodes:{}", limit.graph_nodes)); + Ok(LimitCounts { + notes, + habits, + trackers, + finance_categories, + time_categories, + graph_nodes, + attachments: attachment_count(app), + }) +} + +/// `true` quando o lote levou a contagem acima do limite E aumentou a contagem. +#[cfg(any(test, target_os = "android", target_os = "ios"))] +fn exceeds(after: i64, before: i64, limit: u32) -> bool { + after > i64::from(limit) && after > before +} + +#[cfg(any(test, target_os = "android", target_os = "ios"))] +fn exceeds_u64(after: u64, before: u64, limit: u32) -> bool { + after > u64::from(limit) && after > before +} + +/// Chamado dentro da transação: rollback se o lote **aumentou** a contagem de +/// algum item acima do limite free. Dados que já estavam acima do limite ficam +/// (grandfathering) — senão um banco antigo ou vindo do Pro não abriria mais. +#[cfg(any(target_os = "android", target_os = "ios"))] +pub async fn check_limits( + app: &AppHandle, + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + before: &LimitCounts, +) -> Result<(), String> { + let stored = read_stored(app); + if is_pro(&stored) { + return Ok(()); } + let limit = limits(); + let after = count_usage(app, tx).await?; - let attachments = attachment_count(app); - if attachments > u64::from(limit.attachments) { + if exceeds(after.notes, before.notes, limit.notes) { + return Err(format!("plan-limit:notes:{}", limit.notes)); + } + if exceeds(after.habits, before.habits, limit.habits) { + return Err(format!("plan-limit:habits:{}", limit.habits)); + } + if exceeds(after.trackers, before.trackers, limit.trackers) { + return Err(format!("plan-limit:trackers:{}", limit.trackers)); + } + if exceeds(after.finance_categories, before.finance_categories, limit.finance_categories) { + return Err(format!("plan-limit:finance_categories:{}", limit.finance_categories)); + } + if exceeds(after.time_categories, before.time_categories, limit.time_categories) { + return Err(format!("plan-limit:time_categories:{}", limit.time_categories)); + } + if exceeds(after.graph_nodes, before.graph_nodes, limit.graph_nodes) { + return Err(format!("plan-limit:graph_nodes:{}", limit.graph_nodes)); + } + if exceeds_u64(after.attachments, before.attachments, limit.attachments) { return Err(format!("plan-limit:attachments:{}", limit.attachments)); } @@ -288,6 +329,21 @@ mod tests { assert_eq!(GRACE_MS, 604_800_000); } + #[test] + fn grandfathers_data_already_above_the_limit() { + // No limite: não bloqueia. + assert!(!exceeds(3, 3, 3)); + // Já acima e editando (sem aumentar): não bloqueia. + assert!(!exceeds(5, 5, 3)); + // Já acima e diminuindo: não bloqueia. + assert!(!exceeds(4, 5, 3)); + // Abaixo e criando além do limite: bloqueia. + assert!(exceeds(4, 3, 3)); + assert!(exceeds(6, 5, 5)); + assert!(!exceeds_u64(5, 5, 5)); + assert!(exceeds_u64(6, 5, 5)); + } + /// O guard depende de `json_extract` (JSON1) no SQLite empacotado. #[tokio::test] async fn json_extract_reads_payload_flags() { diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 737af8e..c9ac9f0 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -824,7 +824,14 @@ import { collectAttachments, loadNotes, replaceNotesState, resetNotesCache, rest snapshot = loaded; ready = true; wasPaused = isCurrentPause(loaded); - await saveSnapshot(loaded); + // Gravar no boot não é essencial: se o backstop do free ou o disco + // falharem, o app abre com o snapshot carregado em vez de travar na + // tela de erro. As gravações seguintes avisam o usuário. + try { + await saveSnapshot(loaded); + } catch (error) { + console.error('Falha ao salvar o snapshot inicial', error); + } // As notas são necessárias no planejador (referências `tempo`). notesState = await loadNotes(); await syncIOSReminders(loaded);