Skip to main content

max / goingson

Show the changes sync could not apply, with a retry D4 step 5. The engine now holds remote changes it cannot write instead of losing them at the cursor, but a held change is still absent from the database, and until now nothing in GoingsOn said so: another device's task simply was not here and the app looked perfectly synced. Cloud Sync grows a "Not Applied" tile, rendered only when the count is non-zero so a permanent zero is not sitting there as noise. It spans the stats grid rather than becoming a third column, because it is a condition to act on rather than a statistic to read. The sidebar sync indicator goes to its warn state for the same reason: this is the one sync problem a user cannot otherwise notice. The tile opens a list of what is held. A held row is identified internally by table and wire row id, which tells a person nothing, so describe_table maps each manifest table to a display name and the payload fields worth trying as a title. "tasks" plus {"title": "Call the bank"} reads as "Task: Call the bank"; a table with nothing worth showing (a timer, a join row) is listed by type alone. A test pins that every table in the frozen manifest has a description, so adding a table without one fails rather than shipping a bare "Change". Each row gets Retry, which requeues that entry with a fresh attempt budget for the next sync. Deliberately no Discard: it would be an irreversible delete of someone's change behind one confirm dialog, and this feature exists to stop changes disappearing. The engine's clear() is there when a reason for it turns up. Counts and the list cover the personal scope and every group scope together. Two new commands (sync_held_changes, sync_retry_held_change) over the SyncStore facade, so the engine keeps owning what "retryable" means. sync_status gains heldChanges, and degrades to zero if the read fails rather than taking the whole status panel down; the drill-in surfaces the real error. 375 lib tests pass, workspace green, clippy clean, frontend lint clean including the CHRONIC-XSS gate.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 23:08 UTC
Signed with PGP, not checked
Commit: 24f549406e31d38516d876736247c89d596dea3c
Parent: b887484
6 files changed, +346 insertions, -1 deletion
@@ -270,6 +270,8 @@
270 270 // Sync
271 271 $crate::commands::sync_get_tiers,
272 272 $crate::commands::sync_status,
273 + $crate::commands::sync_held_changes,
274 + $crate::commands::sync_retry_held_change,
273 275 $crate::commands::sync_start_auth,
274 276 $crate::commands::sync_complete_auth,
275 277 $crate::commands::sync_disconnect,
@@ -2070,6 +2070,56 @@
2070 2070 .sync-stat-value {
2071 2071 font-size: var(--font-size-md, 0.9rem);
2072 2072 }
2073 + /* Held changes: data from another device that could not be written here. Spans
2074 + the grid rather than sitting as a third column, because it is a condition to
2075 + act on, not a statistic to read alongside the others. Only rendered when the
2076 + count is non-zero. */
2077 + .sync-stat-warn {
2078 + grid-column: 1 / -1;
2079 + border: var(--border-width) solid var(--warning);
2080 + }
2081 + .sync-stat-button {
2082 + font: inherit;
2083 + font-size: var(--font-size-md, 0.9rem);
2084 + color: var(--content);
2085 + background: none;
2086 + border: none;
2087 + padding: 0;
2088 + cursor: pointer;
2089 + text-decoration: underline;
2090 + }
2091 + .held-change-intro {
2092 + margin-bottom: var(--gap-section);
2093 + }
2094 + .held-change-list {
2095 + display: flex;
2096 + flex-direction: column;
2097 + gap: var(--gap-group);
2098 + }
2099 + .held-change {
2100 + display: flex;
2101 + align-items: flex-start;
2102 + justify-content: space-between;
2103 + gap: var(--gap-group);
2104 + padding: var(--gap-group);
2105 + background: var(--surface-overlay);
2106 + border-radius: var(--radius-md);
2107 + }
2108 + .held-change-body {
2109 + min-width: 0;
2110 + }
2111 + .held-change-name {
2112 + overflow-wrap: anywhere;
2113 + }
2114 + .held-change-cause,
2115 + .held-change-meta {
2116 + font-size: var(--font-size-sm);
2117 + margin-top: var(--gap-bound);
2118 + overflow-wrap: anywhere;
2119 + }
2120 + .held-change-retry {
2121 + flex-shrink: 0;
2122 + }
2073 2123 .sync-encryption-error {
2074 2124 color: var(--danger);
2075 2125 font-size: var(--font-size-sm);
@@ -360,6 +360,8 @@
360 360 subscriptionStatus: () => invoke('sync_subscription_status'),
361 361 subscribe: (interval) => invoke('sync_subscribe', { interval }),
362 362 accountInfo: () => invoke('sync_account_info'),
363 + heldChanges: () => invoke('sync_held_changes'), // Remote changes this device could not apply
364 + retryHeldChange: (scope, table, rowId) => invoke('sync_retry_held_change', { scope, table, rowId }),
363 365 },
364 366
365 367 // Preferences: small JSON file in the app config dir for settings read before the DB is up
@@ -6,6 +6,7 @@
6 6 (function() {
7 7 'use strict';
8 8 const esc = GoingsOn.utils.escapeHtml;
9 + const escAttr = GoingsOn.utils.escapeAttrValue;
9 10
10 11 // Cloud Sync
11 12
@@ -97,6 +98,7 @@
97 98 <div class="sync-stat-label">Pending Changes</div>
98 99 <div class="sync-stat-value">${status.pendingChanges}</div>
99 100 </div>
101 + ${renderHeldStat(status.heldChanges)}
100 102 </div>
101 103
102 104 <div class="sync-section-actions">
@@ -422,6 +424,13 @@
422 424 dot.classList.add('warn');
423 425 labelText = 'Set up encryption';
424 426 titleText = 'Cloud Sync: encryption setup needed';
427 + } else if (status.heldChanges) {
428 + // Held changes are the one sync condition a user cannot see any
429 + // other way: data from another device is missing here and the app
430 + // would otherwise look perfectly synced.
431 + dot.classList.add('warn');
432 + labelText = `${status.heldChanges} not applied`;
433 + titleText = `Cloud Sync: ${status.heldChanges} change${status.heldChanges === 1 ? '' : 's'} could not be applied`;
425 434 } else {
426 435 dot.classList.add('connected');
427 436 const ago = _formatSyncAgo(status.lastSyncAt);
@@ -438,6 +447,99 @@
438 447 }
439 448 }
440 449
450 + // Held changes
451 +
452 + /**
453 + * The "Not Applied" stat tile. Rendered only when something is held: a
454 + * permanent zero would be noise, and every count here is a real gap between
455 + * what another device has and what this one does.
456 + * @param {number} held - Held change count across all scopes
457 + * @returns {string} Tile markup, or an empty string when nothing is held
458 + */
459 + function renderHeldStat(held) {
460 + if (!held) return '';
461 + return `
462 + <div class="sync-stat sync-stat-warn">
463 + <div class="sync-stat-label">Not Applied</div>
464 + <button class="sync-stat-value sync-stat-button" data-act="settings.showHeldChanges"
465 + title="Show the changes this device could not apply">${held}</button>
466 + </div>
467 + `;
468 + }
469 +
470 + /**
471 + * List the changes this device is holding, with a Retry for each.
472 + *
473 + * These are changes another device made that could not be written here: a
474 + * task whose project has not arrived yet, a row from a newer version of the
475 + * app. The engine keeps them and retries the retryable ones on every sync,
476 + * so this list is what is left after it has tried.
477 + */
478 + async function showHeldChanges() {
479 + let held;
480 + try {
481 + held = await GoingsOn.api.sync.heldChanges();
482 + } catch (err) {
483 + GoingsOn.ui.showToast(`Could not load held changes: ${GoingsOn.utils.getErrorMessage(err)}`, 'error');
484 + return;
485 + }
486 +
487 + GoingsOn.ui.openModal('Changes Not Applied', renderHeldList(held), { large: true });
488 + }
489 +
490 + function renderHeldList(held) {
491 + if (!held.length) {
492 + return `<p class="text-secondary">Every change from your other devices has been applied.</p>`;
493 + }
494 +
495 + const rows = held.map(h => {
496 + const name = h.title ? `${esc(h.label)}: ${esc(h.title)}` : esc(h.label);
497 + const waiting = h.retryable
498 + ? `Retrying automatically. ${h.attempts} attempt${h.attempts === 1 ? '' : 's'} so far.`
499 + : 'No longer retried automatically.';
500 + return `
501 + <div class="held-change">
502 + <div class="held-change-body">
503 + <div class="held-change-name">${name}</div>
504 + <div class="held-change-cause text-secondary">${esc(h.cause)}</div>
505 + <div class="held-change-meta text-secondary">${esc(waiting)}</div>
506 + </div>
507 + <button class="btn btn-secondary held-change-retry" data-act="settings.retryHeldChange"
508 + data-a1="${escAttr(h.scope)}" data-a2="${escAttr(h.table)}" data-a3="${escAttr(h.rowId)}"
509 + data-args='["@a1", "@a2", "@a3"]'>Retry</button>
510 + </div>
511 + `;
512 + }).join('');
513 +
514 + return `
515 + <p class="text-secondary held-change-intro">
516 + These changes came from another device and could not be written here yet.
517 + They are kept, not lost, and retried on each sync.
518 + </p>
519 + <div class="held-change-list">${rows}</div>
520 + `;
521 + }
522 +
523 + /**
524 + * Queue one held change for another attempt on the next sync.
525 + */
526 + async function retryHeldChange(scope, table, rowId) {
527 + try {
528 + await GoingsOn.api.sync.retryHeldChange(scope, table, rowId);
529 + } catch (err) {
530 + GoingsOn.ui.showToast(`Could not retry: ${GoingsOn.utils.getErrorMessage(err)}`, 'error');
531 + return;
532 + }
533 + GoingsOn.ui.showToast('Queued for the next sync.');
534 + // Re-read rather than patching the row: a retry can change what is left,
535 + // and the engine is the only thing that knows.
536 + try {
537 + GoingsOn.ui.openModal('Changes Not Applied', renderHeldList(await GoingsOn.api.sync.heldChanges()), { large: true });
538 + } catch (_) {
539 + GoingsOn.ui.closeModal();
540 + }
541 + }
542 +
441 543 /**
442 544 * Subscribe to cloud sync with the given interval.
443 545 * Opens the Stripe checkout page in the user's browser.
@@ -495,6 +597,8 @@
495 597 updateSyncSettings,
496 598 disconnectSync,
497 599 refreshSyncIndicator,
600 + showHeldChanges,
601 + retryHeldChange,
498 602 subscribeSyncAnnual: () => subscribeSync('annual'),
499 603 subscribeSyncMonthly: () => subscribeSync('monthly'),
500 604 });
@@ -15,7 +15,8 @@
15 15 use crate::oauth::credentials::CredentialStore;
16 16 use crate::oauth::provider::{generate_code_challenge, generate_code_verifier, generate_state};
17 17 use crate::state::AppState;
18 - use crate::syncstore::sync_state;
18 + use crate::syncstore::{manifest, sync_state};
19 + use synckit_client::store::HoldState;
19 20
20 21 // Types
21 22
@@ -41,6 +42,33 @@
41 42 pub sync_interval_minutes: u32,
42 43 pub last_sync_at: Option<String>,
43 44 pub pending_changes: i64,
45 + /// Remote changes this device could not apply and is holding, across every
46 + /// scope. Non-zero means some of another device's data is not here yet.
47 + pub held_changes: i64,
48 + }
49 +
50 + /// One held change, for the drill-in list.
51 + #[derive(Debug, Serialize)]
52 + #[serde(rename_all = "camelCase")]
53 + pub struct HeldChangeResponse {
54 + /// Scope the change belongs to: empty for personal, otherwise a group id.
55 + pub scope: String,
56 + /// Engine table name, needed to address the row on retry.
57 + pub table: String,
58 + /// Wire row id, needed to address the row on retry.
59 + pub row_id: String,
60 + /// Display name for the kind of thing this is ("Task", "Contact").
61 + pub label: String,
62 + /// A title pulled from the payload, when the row has one worth showing.
63 + pub title: Option<String>,
64 + /// Why it could not be applied.
65 + pub cause: String,
66 + /// Whether a retry is still expected to help.
67 + pub retryable: bool,
68 + /// Retries already spent.
69 + pub attempts: i64,
70 + /// When it was first held (RFC 3339).
71 + pub first_seen: String,
44 72 }
45 73
46 74 /// Response for sync_start_auth command.
@@ -150,6 +178,18 @@
150 178 let states = states_result.unwrap_or_default();
151 179 let pending_changes = pending_changes.unwrap_or(0);
152 180
181 + // Held changes come from the engine, which owns the hold's semantics (what
182 + // counts as retryable, when an entry is spent). A read failure degrades to
183 + // zero rather than failing the whole status call; the drill-in reports the
184 + // real error if the user opens it.
185 + let held_changes = match state.sync_store.as_ref() {
186 + Some(store) => store
187 + .held_counts()
188 + .await
189 + .map_or(0, |c| i64::try_from(c.total()).unwrap_or(i64::MAX)),
190 + None => 0,
191 + };
192 +
153 193 let device_id = states.get("device_id").filter(|s| !s.is_empty()).cloned();
154 194 let auto_sync_enabled = states.get("auto_sync_enabled").is_none_or(|v| v == "1");
155 195 let sync_interval_minutes = states
@@ -172,9 +212,70 @@
172 212 sync_interval_minutes,
173 213 last_sync_at,
174 214 pending_changes,
215 + held_changes,
175 216 })
176 217 }
177 218
219 + /// Lists the remote changes this device is holding because it could not apply
220 + /// them, newest first, across the personal scope and every group.
221 + ///
222 + /// A held change is not lost (the engine keeps the entry and retries the
223 + /// retryable ones on every pull), but it is also not in the database yet, so
224 + /// this is the one place a user can see what is missing and why.
225 + #[tauri::command]
226 + #[instrument(skip_all)]
227 + pub async fn sync_held_changes(
228 + state: State<'_, Arc<AppState>>,
229 + ) -> Result<Vec<HeldChangeResponse>, ApiError> {
230 + let Some(store) = state.sync_store.as_ref() else {
231 + return Ok(Vec::new());
232 + };
233 + let held = store
234 + .held_entries()
235 + .await
236 + .map_api_err("Failed to list held changes", ApiError::internal)?;
237 +
238 + Ok(held
239 + .into_iter()
240 + .map(|h| {
241 + let (label, _) = manifest::describe_table(&h.table);
242 + HeldChangeResponse {
243 + title: manifest::payload_title(&h.table, h.payload.as_ref()),
244 + scope: h.scope,
245 + table: h.table,
246 + row_id: h.row_id,
247 + label: label.to_string(),
248 + cause: h.cause,
249 + retryable: matches!(h.state, HoldState::Deferred),
250 + attempts: h.attempts,
251 + first_seen: h.first_seen,
252 + }
253 + })
254 + .collect())
255 + }
256 +
257 + /// Puts one held change back in the retry queue with a fresh attempt budget.
258 + ///
259 + /// The per-row Retry: for an entry the engine has stopped retrying on its own,
260 + /// after a user has plausibly fixed the cause (upgraded another device, restored
261 + /// the parent record). Returns whether such an entry was there to requeue.
262 + #[tauri::command]
263 + #[instrument(skip_all)]
264 + pub async fn sync_retry_held_change(
265 + state: State<'_, Arc<AppState>>,
266 + scope: String,
267 + table: String,
268 + row_id: String,
269 + ) -> Result<bool, ApiError> {
270 + let Some(store) = state.sync_store.as_ref() else {
271 + return Ok(false);
272 + };
273 + store
274 + .retry_held(&scope, &table, &row_id)
275 + .await
276 + .map_api_err("Failed to retry the held change", ApiError::internal)
277 + }
278 +
178 279 /// Starts the SyncKit OAuth2 PKCE flow.
179 280 #[tauri::command]
180 281 #[instrument(skip_all)]
@@ -358,6 +358,58 @@
358 358 .conflict_strategy(ConflictStrategy::HybridLogicalClock)
359 359 }
360 360
361 + /// How to describe one of the manifest's tables to a person: a display name, and
362 + /// the payload fields worth trying as a title, best first.
363 + ///
364 + /// The dead-letter hold identifies a stuck change by table and wire row id, which
365 + /// tells a user nothing about which of their items is affected. This turns
366 + /// `("tasks", {"title": "Call the bank", ...})` into "Task: Call the bank". A
367 + /// table with nothing worth showing (a join row, a timer) returns no fields and
368 + /// is listed by its type alone.
369 + pub(crate) fn describe_table(table: &str) -> (&'static str, &'static [&'static str]) {
370 + match table {
371 + "projects" => ("Project", &["name"]),
372 + "contacts" => ("Contact", &["display_name", "nickname", "company"]),
373 + "email_accounts" => ("Email account", &["email", "account_name"]),
374 + "sync_accounts" => ("Calendar account", &["account_name", "email"]),
375 + "milestones" => ("Milestone", &["name"]),
376 + "tasks" => ("Task", &["title"]),
377 + "time_sessions" => ("Time session", &[]),
378 + "attachments" => ("Attachment", &["filename"]),
379 + "events" => ("Event", &["title"]),
380 + "annotations" => ("Note", &["note"]),
381 + "subtasks" => ("Subtask", &["text"]),
382 + "task_status_tokens" => ("Task status", &["reference"]),
383 + "contact_emails" => ("Contact email", &["address"]),
384 + "contact_phones" => ("Contact phone", &["number"]),
385 + "contact_social_handles" => ("Contact handle", &["handle", "platform"]),
386 + "contact_custom_fields" => ("Contact field", &["label"]),
387 + "daily_notes" => ("Daily note", &["note_date"]),
388 + "saved_views" => ("Saved view", &["name"]),
389 + "weekly_reviews" => ("Weekly review", &["week_start_date"]),
390 + "monthly_goals" => ("Monthly goal", &["text"]),
391 + "monthly_reflections" => ("Monthly reflection", &["month"]),
392 + "user_config" => ("Setting", &["key"]),
393 + // A table this build does not know. Legitimate at runtime: the change came
394 + // from a newer client, which is exactly the unknown-table case the hold
395 + // defers until this client is upgraded. A test pins that every table the
396 + // manifest DOES declare is described.
397 + _ => ("Change", &[]),
398 + }
399 + }
400 +
401 + /// Pull a human-readable title out of a held change's payload, per
402 + /// [`describe_table`]. `None` when the payload is absent (a delete) or carries
403 + /// nothing worth showing.
404 + pub(crate) fn payload_title(table: &str, payload: Option<&serde_json::Value>) -> Option<String> {
405 + let payload = payload?;
406 + let (_, fields) = describe_table(table);
407 + fields.iter().find_map(|f| {
408 + let text = payload.get(*f)?.as_str()?.trim();
409 + (!text.is_empty()).then(|| text.to_string())
410 + })
411 + }
412 +
361 413 #[cfg(test)]
362 414 mod tests {
363 415 use super::goingson_schema;
@@ -420,6 +472,40 @@
420 472 }
421 473 }
422 474
475 + #[test]
476 + fn every_manifest_table_has_a_description() {
477 + for name in EXPECTED_TABLES {
478 + let (label, _) = super::describe_table(name);
479 + assert_ne!(
480 + label, "Change",
481 + "{name} has no display name; a held change would be listed as a bare 'Change'"
482 + );
483 + }
484 + }
485 +
486 + #[test]
487 + fn a_title_is_pulled_from_the_payload_and_falls_back_cleanly() {
488 + use serde_json::json;
489 + let task = json!({"id": "t1", "title": "Call the bank"});
490 + assert_eq!(
491 + super::payload_title("tasks", Some(&task)).as_deref(),
492 + Some("Call the bank")
493 + );
494 + // Blank and absent both mean "nothing worth showing", not an empty label.
495 + assert_eq!(
496 + super::payload_title("tasks", Some(&json!({"title": " "}))),
497 + None
498 + );
499 + assert_eq!(super::payload_title("time_sessions", Some(&task)), None);
500 + assert_eq!(super::payload_title("tasks", None), None);
501 + // The first field that has something wins.
502 + let contact = json!({"display_name": null, "nickname": "Bo"});
503 + assert_eq!(
504 + super::payload_title("contacts", Some(&contact)).as_deref(),
505 + Some("Bo")
506 + );
507 + }
508 +
423 509 #[test]
424 510 fn manifest_table_set_and_order_is_frozen() {
425 511 let schema = goingson_schema();