Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src-tauri/src/db/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ pub struct PostRecord {
/// calendar uses this to surface a "Groupe · N posts" badge so the
/// user can see at a glance which drafts were generated together.
pub group_id: Option<i64>,
/// Count of consecutive failed publish attempts (v0.4.0 scheduler).
/// The retry policy backs off 5 min / 30 min / 2 h and after
/// `db::scheduler::MAX_FAILED_ATTEMPTS` flips the row to
/// `status = 'failed'`. Default 0 on legacy rows and drafts that
/// have never been auto-published.
#[serde(default)]
pub failed_attempts: i64,
/// RFC 3339 UTC timestamp of the most recent publish attempt
/// (success or failure). Drives the backoff window check in
/// `db::scheduler::list_due_for_publish`. NULL on legacy rows and
/// drafts that have never been picked up by the scheduler.
#[serde(default)]
pub last_attempt_at: Option<String>,
}

pub async fn insert_draft(
Expand Down Expand Up @@ -79,7 +92,7 @@ pub async fn insert_draft(
pub async fn get_by_id(pool: &SqlitePool, id: i64) -> Result<PostRecord, String> {
let row = sqlx::query(
"SELECT id, network, caption, hashtags, status, created_at, published_at,
scheduled_at, image_path, images, ig_media_id, account_id, published_url, group_id
scheduled_at, image_path, images, ig_media_id, account_id, published_url, group_id, failed_attempts, last_attempt_at
FROM post_history WHERE id = ?",
)
.bind(id)
Expand Down Expand Up @@ -112,7 +125,7 @@ pub async fn update_status(
pub async fn list_recent(pool: &SqlitePool, limit: i64) -> Result<Vec<PostRecord>, String> {
let rows: Vec<SqliteRow> = sqlx::query(
"SELECT id, network, caption, hashtags, status, created_at, published_at,
scheduled_at, image_path, images, ig_media_id, account_id, published_url, group_id
scheduled_at, image_path, images, ig_media_id, account_id, published_url, group_id, failed_attempts, last_attempt_at
FROM post_history ORDER BY created_at DESC LIMIT ?",
)
.bind(limit)
Expand Down Expand Up @@ -151,7 +164,7 @@ pub async fn list_in_range(
) -> Result<Vec<PostRecord>, String> {
let rows: Vec<SqliteRow> = sqlx::query(
"SELECT DISTINCT id, network, caption, hashtags, status, created_at, published_at,
scheduled_at, image_path, images, ig_media_id, account_id, published_url, group_id
scheduled_at, image_path, images, ig_media_id, account_id, published_url, group_id, failed_attempts, last_attempt_at
FROM post_history
WHERE COALESCE(published_at, scheduled_at, created_at) BETWEEN ? AND ?
ORDER BY COALESCE(published_at, scheduled_at, created_at) ASC",
Expand Down Expand Up @@ -287,6 +300,12 @@ pub(crate) fn row_to_post_record(r: &SqliteRow) -> Result<PostRecord, String> {
account_id: r.try_get("account_id").ok().flatten(),
published_url: r.try_get("published_url").ok().flatten(),
group_id: r.try_get("group_id").ok().flatten(),
// New v0.4.0 columns — `unwrap_or` keeps legacy in-flight
// queries safe if a future refactor accidentally drops them
// from a SELECT list. SQLite NOT NULL DEFAULT 0 guarantees
// the column itself always has a real value on disk.
failed_attempts: r.try_get("failed_attempts").unwrap_or(0),
last_attempt_at: r.try_get("last_attempt_at").ok().flatten(),
})
}

Expand Down
49 changes: 49 additions & 0 deletions src-tauri/src/db/migrations/019_scheduler_retry_columns.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- Migration 019 — scheduler retry bookkeeping on post_history.
--
-- Lays the data foundation for the v0.4.0 auto-publish scheduler. The
-- background task introduced in PR F2 will pick up posts whose
-- `scheduled_at` is past, attempt publish via the existing
-- `publish_post` / `publish_linkedin_post` commands, and increment
-- bookkeeping fields here on failure.
--
-- ## Columns
--
-- `failed_attempts` — count of consecutive failed publish attempts. The
-- scheduler retries with exponential backoff (5 min / 30 min / 2 h),
-- and after 3 failures the post is marked `status = 'failed'` until
-- the user reviews it (token expired, account disconnected, network
-- issue). Default 0 so existing rows behave as never-tried.
--
-- `last_attempt_at` — RFC 3339 UTC timestamp of the most recent
-- publish attempt (success or failure). Lets the scheduler compute
-- the next retry window without a separate attempts table. NULL on
-- legacy rows and on rows that have never been picked up by the
-- scheduler (i.e. user-published manually).
--
-- ## Why not a separate `publish_attempts` table
--
-- Single-row append works as long as we only care about the LAST
-- attempt (retry timing) and the COUNT (give-up threshold). A history
-- table would let us show "attempt 1 failed at T+5min, attempt 2
-- failed at T+30min, …" in the UI — but that's a UX polish item we
-- can ship later by promoting `last_attempt_at` + `failed_attempts`
-- into a JSON log column or a child table without breaking the
-- current schema. Keep migration 019 minimal so the foundation lands
-- without speculative columns.
--
-- ## Status transitions the scheduler will use
--
-- Status values already accepted by the existing publish flow:
-- - 'draft' — initial state after generation
-- - 'published' — successful publish (status set by publish_post)
-- - 'failed' — final-failure state after N retries
--
-- New intermediate state to land in PR F2 (not enforced in this
-- migration — SQLite is loose-typed and the `status` column accepts
-- any string):
-- - 'publishing' — scheduler picked the post up and is attempting
-- a publish call. Lets concurrent launches of the
-- app avoid double-publishing the same row.

ALTER TABLE post_history ADD COLUMN failed_attempts INTEGER NOT NULL DEFAULT 0;
ALTER TABLE post_history ADD COLUMN last_attempt_at TEXT;
48 changes: 48 additions & 0 deletions src-tauri/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub mod accounts;
pub mod ai_usage;
pub mod groups;
pub mod history;
pub mod scheduler;
pub mod settings_db;

pub async fn init_pool() -> Result<SqlitePool, String> {
Expand Down Expand Up @@ -651,6 +652,53 @@ mod migration_tests {
);
}

#[tokio::test]
async fn scheduler_retry_columns_exist_after_migration_019() {
// Migration 019 lays the data foundation for v0.4.0 auto-publish.
// Lock the contract that the scheduler depends on:
// 1. `failed_attempts INTEGER NOT NULL DEFAULT 0` — the
// retry counter must default to 0 so legacy rows behave
// as never-tried without a backfill.
// 2. `last_attempt_at TEXT` (nullable) — NULL on legacy rows,
// populated only when the scheduler picks the row up.
let pool = fresh_migrated_pool().await;
let cols = table_columns(&pool, "post_history").await;
for c in ["failed_attempts", "last_attempt_at"] {
assert!(
cols.contains(&c.to_string()),
"migration 019 must add `{c}` column, got: {cols:?}"
);
}

// failed_attempts must be NOT NULL DEFAULT 0 so a legacy row
// with no value plays correctly with the scheduler's
// `failed_attempts < N` filter.
let rows = sqlx::query("PRAGMA table_info(post_history)")
.fetch_all(&pool)
.await
.expect("PRAGMA table_info");
let fa_row = rows
.iter()
.find(|r| r.get::<String, _>("name") == "failed_attempts")
.expect("failed_attempts column missing");
let fa_notnull: i64 = fa_row.get("notnull");
let fa_default: Option<String> = fa_row.try_get("dflt_value").ok();
assert_eq!(fa_notnull, 1, "failed_attempts must be NOT NULL");
assert_eq!(
fa_default.as_deref(),
Some("0"),
"failed_attempts default must be 0, got: {fa_default:?}"
);

// last_attempt_at must be nullable.
let la_row = rows
.iter()
.find(|r| r.get::<String, _>("name") == "last_attempt_at")
.expect("last_attempt_at column missing");
let la_notnull: i64 = la_row.get("notnull");
assert_eq!(la_notnull, 0, "last_attempt_at must be nullable");
}

#[tokio::test]
async fn accounts_has_display_handle_from_migration_016() {
// Migration 016 adds a brand-handle override so LinkedIn accounts
Expand Down
Loading
Loading