Skip to main content

max / goingson

fix(recurrence): re-anchor backfilled chains to their earliest member (migration 051) Migration 047 reconstructed recurrence chains anchored at MIN(id) over random UUIDv4 ids -- an arbitrary sibling, not the first instance -- contradicting its own "earliest sibling" comment. The anchor is only a join key (chain membership and streaks key off created_at), so this is harmless at runtime, but the stored data violated the invariant that runtime-created chains hold: the earliest task is the root (recurrence_parent_id IS NULL). Migration 051 aligns the historical data with that single invariant. Within each existing chain group it picks the earliest member by created_at (id as a deterministic, device-convergent tie-break) as the root and points the rest at it; membership is unchanged and the operation is idempotent. A snapshot temp table keeps the rewrite from reading its own partial writes. 047's comment is corrected to describe the MIN(id) anchor accurately and note the inherent best-effort limits of reconstructing chains from pre-recurrence_parent_id rows. Tests prove chain membership is complete regardless of anchor, that 051 moves the anchor to the earliest member without changing membership, idempotency, and that a lone recurring task stays its own root. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-15 23:39 UTC
Commit: 534a7f6c06d2a1998def0bdbd0f41db05a13fe26
Parent: 180a687
3 files changed, +192 insertions, -4 deletions
@@ -0,0 +1,134 @@
1 + //! Migration 051 re-anchors recurrence chains to their earliest-created member.
2 + //!
3 + //! Migration 047 anchored backfilled chains at MIN(id) over random UUIDs (an
4 + //! arbitrary sibling). 051 re-anchors each chain at its earliest task by
5 + //! created_at so the stored data matches the runtime invariant (first instance =
6 + //! root). These tests prove: (1) chain membership is complete regardless of which
7 + //! sibling is the anchor -- the property the app actually relies on -- and (2) 051
8 + //! moves the anchor to the earliest member without changing membership, and is
9 + //! idempotent.
10 +
11 + mod common;
12 +
13 + use goingson_core::{NewTask, Recurrence, TaskId, TaskRepository};
14 + use goingson_db_sqlite::SqliteTaskRepository;
15 + use sqlx::SqlitePool;
16 +
17 + const M051: &str = include_str!("../../../migrations/sqlite/051_reanchor_recurrence_chain.sql");
18 +
19 + async fn set_created_at(pool: &SqlitePool, id: TaskId, when: &str) {
20 + sqlx::query("UPDATE tasks SET created_at = ? WHERE id = ?")
21 + .bind(when)
22 + .bind(id.to_string())
23 + .execute(pool)
24 + .await
25 + .unwrap();
26 + }
27 +
28 + async fn set_parent(pool: &SqlitePool, id: TaskId, parent: Option<TaskId>) {
29 + sqlx::query("UPDATE tasks SET recurrence_parent_id = ? WHERE id = ?")
30 + .bind(parent.map(|p| p.to_string()))
31 + .bind(id.to_string())
32 + .execute(pool)
33 + .await
34 + .unwrap();
35 + }
36 +
37 + async fn parent_of(repo: &SqliteTaskRepository, id: TaskId, user: goingson_core::UserId) -> Option<TaskId> {
38 + repo.get_by_id(id, user).await.unwrap().unwrap().recurrence_parent_id
39 + }
40 +
41 + #[tokio::test]
42 + async fn migration_051_reanchors_to_earliest_and_preserves_membership() {
43 + let pool = common::setup_test_db().await;
44 + let user = common::create_test_user(&pool).await;
45 + let tasks = SqliteTaskRepository::new(pool.clone());
46 +
47 + // Three daily-recurring instances of one series.
48 + let mut made = Vec::new();
49 + for _ in 0..3 {
50 + made.push(
51 + tasks
52 + .create(
53 + user,
54 + NewTask::builder("Daily standup")
55 + .recurrence(Recurrence::Daily)
56 + .build(),
57 + )
58 + .await
59 + .unwrap(),
60 + );
61 + }
62 + let ids: Vec<TaskId> = made.iter().map(|t| t.id).collect();
63 +
64 + // The arbitrary MIN(id) anchor migration 047 would have chosen.
65 + let min_id = *ids.iter().min_by_key(|id| id.to_string()).unwrap();
66 + // Earliest-created and middle: any two members that are not the MIN(id) one.
67 + let others: Vec<TaskId> = ids.iter().copied().filter(|id| *id != min_id).collect();
68 + let earliest = others[0];
69 + let middle = others[1];
70 +
71 + // Stamp created_at so the earliest-created member is deliberately NOT the
72 + // MIN(id) anchor -- this is exactly the 047 defect.
73 + set_created_at(&pool, earliest, "2024-01-01 00:00:00").await;
74 + set_created_at(&pool, middle, "2024-03-01 00:00:00").await;
75 + set_created_at(&pool, min_id, "2024-06-01 00:00:00").await;
76 +
77 + // Simulate 047's post-state: everyone points at MIN(id); the anchor is root.
78 + for id in &ids {
79 + set_parent(&pool, *id, Some(min_id)).await;
80 + }
81 + set_parent(&pool, min_id, None).await;
82 +
83 + // Property that actually matters: the chain is complete regardless of anchor.
84 + let before = tasks.list_recurrence_chain(min_id, user).await.unwrap();
85 + assert_eq!(before.len(), 3, "chain complete under the MIN(id) anchor");
86 +
87 + // Re-anchor.
88 + sqlx::raw_sql(M051).execute(&pool).await.unwrap();
89 +
90 + // Earliest-created is now the root; the others point at it; nobody else is root.
91 + assert_eq!(parent_of(&tasks, earliest, user).await, None, "earliest is the new root");
92 + assert_eq!(parent_of(&tasks, middle, user).await, Some(earliest), "member points at earliest");
93 + assert_eq!(parent_of(&tasks, min_id, user).await, Some(earliest), "old anchor is now a member");
94 +
95 + // Membership is unchanged and still complete under the new root.
96 + let after = tasks.list_recurrence_chain(earliest, user).await.unwrap();
97 + assert_eq!(after.len(), 3, "chain complete under the re-anchored root");
98 + let after_ids: std::collections::BTreeSet<String> =
99 + after.iter().map(|t| t.id.to_string()).collect();
100 + let want_ids: std::collections::BTreeSet<String> =
101 + ids.iter().map(|id| id.to_string()).collect();
102 + assert_eq!(after_ids, want_ids, "membership unchanged");
103 +
104 + // Idempotent: a second run is a no-op.
105 + sqlx::raw_sql(M051).execute(&pool).await.unwrap();
106 + assert_eq!(parent_of(&tasks, earliest, user).await, None);
107 + assert_eq!(parent_of(&tasks, middle, user).await, Some(earliest));
108 + assert_eq!(parent_of(&tasks, min_id, user).await, Some(earliest));
109 + }
110 +
111 + #[tokio::test]
112 + async fn migration_051_leaves_a_singleton_recurring_task_as_its_own_root() {
113 + let pool = common::setup_test_db().await;
114 + let user = common::create_test_user(&pool).await;
115 + let tasks = SqliteTaskRepository::new(pool.clone());
116 +
117 + let solo = tasks
118 + .create(
119 + user,
120 + NewTask::builder("Weekly review")
121 + .recurrence(Recurrence::Weekly)
122 + .build(),
123 + )
124 + .await
125 + .unwrap();
126 +
127 + sqlx::raw_sql(M051).execute(&pool).await.unwrap();
128 +
129 + assert_eq!(
130 + parent_of(&tasks, solo.id, user).await,
131 + None,
132 + "a lone recurring task stays its own root"
133 + );
134 + }
@@ -1,9 +1,17 @@
1 1 -- Backfill recurrence_parent_id for existing recurring tasks.
2 - -- Links completed recurring tasks to their earliest sibling (the chain root)
3 - -- by matching on description + recurrence pattern + user_id.
2 + -- Links recurring tasks that share a description + recurrence pattern + user_id
3 + -- into one chain, anchored at MIN(id).
4 4 --
5 - -- This is best-effort: tasks whose description was edited after spawning
6 - -- won't match, but those are rare edge cases.
5 + -- NOTE: MIN(id) over random UUIDv4 ids picks an arbitrary-but-stable sibling as
6 + -- the anchor, NOT the earliest-created one. That is harmless on its own (the
7 + -- anchor is only a join key; chain membership and ordering key off created_at),
8 + -- but migration 051 re-anchors these chains to their earliest member so the
9 + -- stored data matches the runtime invariant (first instance = root).
10 + --
11 + -- This is best-effort: tasks whose description was edited after spawning won't
12 + -- match, and two genuinely separate series with the same description collapse
13 + -- into one chain -- both are rare and inherent to reconstructing chains from
14 + -- historical rows that predate recurrence_parent_id.
7 15
8 16 UPDATE tasks
9 17 SET recurrence_parent_id = (
@@ -0,0 +1,46 @@
1 + -- Re-anchor recurrence chains to their earliest-created member.
2 + --
3 + -- Migration 047 backfilled recurrence_parent_id using MIN(id) over random
4 + -- UUIDv4 ids, so each reconstructed chain was anchored at an arbitrary sibling
5 + -- rather than its first instance. Runtime-created chains always anchor at the
6 + -- first instance (the earliest task has recurrence_parent_id IS NULL); this
7 + -- aligns the backfilled historical data with that single invariant.
8 + --
9 + -- Within each existing chain group (the set of tasks sharing one effective root,
10 + -- COALESCE(recurrence_parent_id, id)), the earliest task by created_at -- with id
11 + -- as a deterministic tie-break so every device computes the same result -- becomes
12 + -- the root, and every other member points to it. Chain membership is unchanged;
13 + -- only which member is the anchor changes. The operation is idempotent.
14 +
15 + -- Snapshot the current grouping up front so the UPDATE below cannot read its own
16 + -- partial writes: the chosen anchor depends only on this immutable snapshot and on
17 + -- tasks.id (never on the recurrence_parent_id column being rewritten).
18 + CREATE TEMP TABLE _chain_member AS
19 + SELECT id AS member_id,
20 + COALESCE(recurrence_parent_id, id) AS root_id,
21 + created_at
22 + FROM tasks
23 + WHERE recurrence_parent_id IS NOT NULL OR recurrence != 'None';
24 +
25 + -- Point every member at the earliest member of its group. The chosen anchor
26 + -- temporarily points to itself and is nulled in the next statement.
27 + UPDATE tasks
28 + SET recurrence_parent_id = (
29 + SELECT (
30 + SELECT m2.member_id
31 + FROM _chain_member m2
32 + WHERE m2.root_id = m1.root_id
33 + ORDER BY m2.created_at ASC, m2.member_id ASC
34 + LIMIT 1
35 + )
36 + FROM _chain_member m1
37 + WHERE m1.member_id = tasks.id
38 + )
39 + WHERE recurrence_parent_id IS NOT NULL OR recurrence != 'None';
40 +
41 + -- The anchor pointed at itself above; null it so it is the chain root.
42 + UPDATE tasks
43 + SET recurrence_parent_id = NULL
44 + WHERE recurrence_parent_id = id;
45 +
46 + DROP TABLE _chain_member;