Internal
Public Access
/api/sync/ only returns rows changed since a device's last sync token. Any device that synced before tag/time-entry syncing existed already has a token from that era; pre-existing tags untouched since then were never "changed since" that token and so were permanently invisible to that device, even after tag syncing shipped. Adds a sync_format_version check that ignores the stored token and forces exactly one full resync per device when it detects an old-format token, backfilling anything that was missed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
186 lines
7.4 KiB
JavaScript
186 lines
7.4 KiB
JavaScript
/**
|
|
* KeepItGoing - Offline sync replay
|
|
* Talks to /api/sync/ to pull server changes into IndexedDB and push any
|
|
* locally-queued offline edits back up. Only ever runs from a real,
|
|
* network-loaded page (never from the cached offline app).
|
|
*/
|
|
|
|
const BACKGROUND_SYNC_MIN_INTERVAL_MS = 2 * 60 * 1000;
|
|
|
|
// Bump this whenever a previously-untracked entity type starts being synced
|
|
// (e.g. tags/time_entries were added after tasks-only syncing already
|
|
// shipped). /api/sync/ only returns rows changed since a device's last sync
|
|
// token, so any device with an old token would otherwise never receive
|
|
// pre-existing rows of the newly-tracked type - they were never "changed
|
|
// since" a token issued before that type existed at all. Forces exactly one
|
|
// full resync (by clearing the stored token) per bump, per device.
|
|
const SYNC_FORMAT_VERSION = 2;
|
|
|
|
function getCsrfToken() {
|
|
const el = document.querySelector('[name=csrfmiddlewaretoken]');
|
|
return el ? el.value : null;
|
|
}
|
|
|
|
function stripLocalFields(record) {
|
|
const { _dirty, _pending_conflict_id, ...clean } = record;
|
|
return clean;
|
|
}
|
|
|
|
// server_changes.time_entries[] uses a field named "task" holding the task's
|
|
// sync_id, but *creating* a new entry requires "task_sync_id" instead
|
|
// (sync/views.py process_time_entry_changes). Normalize to task_sync_id
|
|
// locally so the rest of the app only ever deals with one field name.
|
|
function timeEntryToWire(entry) {
|
|
return stripLocalFields(entry);
|
|
}
|
|
|
|
function timeEntryFromWire(serverEntry) {
|
|
const { task, ...rest } = serverEntry;
|
|
// duration_seconds isn't part of the sync wire format at all (a
|
|
// pre-existing gap in TimeEntrySyncSerializer) - compute it locally so
|
|
// the offline app's time totals are correct regardless of where an
|
|
// entry came from.
|
|
const durationSeconds = rest.ended_at
|
|
? Math.round((new Date(rest.ended_at) - new Date(rest.started_at)) / 1000)
|
|
: null;
|
|
return { ...rest, task_sync_id: task, duration_seconds: durationSeconds };
|
|
}
|
|
|
|
// Shared merge-then-auto-resolve logic for one entity type's slice of a
|
|
// sync response: merge server rows (skipping anything about to be
|
|
// re-asserted by a conflict resolution), clear dirty flags for rows that
|
|
// synced cleanly, then resolve each conflict as "local wins".
|
|
async function mergeAndResolveEntity({ entityType, dirtyLocal, serverRows, conflicts, csrfToken, putLocal, fromWire }) {
|
|
const normalize = fromWire || ((r) => r);
|
|
const relevantConflicts = conflicts.filter((c) => c.entity_type === entityType);
|
|
const conflictedIds = new Set(
|
|
relevantConflicts.map((c) => c.local_data && c.local_data.sync_id).filter(Boolean)
|
|
);
|
|
|
|
for (const serverRow of serverRows) {
|
|
const normalized = normalize(serverRow);
|
|
if (conflictedIds.has(normalized.sync_id)) {
|
|
continue;
|
|
}
|
|
await putLocal({ ...normalized, _dirty: false, _pending_conflict_id: null });
|
|
}
|
|
|
|
for (const row of dirtyLocal) {
|
|
if (!conflictedIds.has(row.sync_id)) {
|
|
await putLocal({ ...row, _dirty: false, _pending_conflict_id: null });
|
|
}
|
|
}
|
|
|
|
for (const conflict of relevantConflicts) {
|
|
const syncId = conflict.local_data && conflict.local_data.sync_id;
|
|
if (!syncId) {
|
|
continue;
|
|
}
|
|
try {
|
|
const resolveResponse = await fetch(`/api/sync/conflicts/${conflict.id}/resolve/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken,
|
|
},
|
|
body: JSON.stringify({ resolution: 'local' }),
|
|
});
|
|
if (!resolveResponse.ok) {
|
|
throw new Error('resolve failed');
|
|
}
|
|
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
|
|
if (localRow) {
|
|
await putLocal({ ...localRow, _dirty: false, _pending_conflict_id: null });
|
|
}
|
|
} catch (err) {
|
|
const localRow = dirtyLocal.find((r) => r.sync_id === syncId);
|
|
if (localRow) {
|
|
await putLocal({ ...localRow, _dirty: true, _pending_conflict_id: conflict.id });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Returns true if locally-queued offline changes were successfully pushed up
|
|
// this call - meaning the current (server-rendered) page was rendered before
|
|
// those changes existed and is now stale, so the caller should refresh it.
|
|
// Returns false if there was nothing to push, or the sync didn't complete.
|
|
async function runBackgroundSync() {
|
|
const csrfToken = getCsrfToken();
|
|
if (!csrfToken) {
|
|
return false; // Not on an authenticated page (e.g. login/register).
|
|
}
|
|
|
|
const deviceId = await ensureDeviceId();
|
|
const syncFormatVersion = await getMeta('sync_format_version');
|
|
const forceFullResync = (syncFormatVersion || 1) < SYNC_FORMAT_VERSION;
|
|
const lastSyncToken = forceFullResync ? null : await getMeta('last_sync_token');
|
|
|
|
const dirtyTasks = await getDirtyTasks();
|
|
const dirtyTags = await getDirtyTags();
|
|
const dirtyTimeEntries = await getDirtyTimeEntries();
|
|
const hadPendingChanges = dirtyTasks.length + dirtyTags.length + dirtyTimeEntries.length > 0;
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch('/api/sync/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken,
|
|
},
|
|
body: JSON.stringify({
|
|
device_id: deviceId,
|
|
last_sync_token: lastSyncToken || null,
|
|
changes: {
|
|
tasks: dirtyTasks.map(stripLocalFields),
|
|
tags: dirtyTags.map(stripLocalFields),
|
|
time_entries: dirtyTimeEntries.map(timeEntryToWire),
|
|
},
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
return false; // Offline or network error - retry next time, nothing to clean up.
|
|
}
|
|
|
|
if (!response.ok) {
|
|
return false; // Includes 429 throttled - retry next time.
|
|
}
|
|
|
|
const data = await response.json();
|
|
const conflicts = data.conflicts || [];
|
|
|
|
await mergeAndResolveEntity({
|
|
entityType: 'task', dirtyLocal: dirtyTasks, serverRows: data.server_changes.tasks,
|
|
conflicts, csrfToken, putLocal: putLocalTask,
|
|
});
|
|
await mergeAndResolveEntity({
|
|
entityType: 'tag', dirtyLocal: dirtyTags, serverRows: data.server_changes.tags,
|
|
conflicts, csrfToken, putLocal: putLocalTag,
|
|
});
|
|
await mergeAndResolveEntity({
|
|
entityType: 'time_entry', dirtyLocal: dirtyTimeEntries, serverRows: data.server_changes.time_entries,
|
|
conflicts, csrfToken, putLocal: putLocalTimeEntry, fromWire: timeEntryFromWire,
|
|
});
|
|
|
|
await setMeta('last_sync_token', data.sync_token);
|
|
await setMeta('last_sync_at', new Date().toISOString());
|
|
await setMeta('sync_format_version', SYNC_FORMAT_VERSION);
|
|
|
|
// A forced full resync just backfilled previously-missed data (e.g. tags
|
|
// that existed before tag syncing shipped) - worth a refresh even if
|
|
// nothing local was dirty, since the current page may be missing it too.
|
|
return hadPendingChanges || forceFullResync;
|
|
}
|
|
|
|
async function maybeBackgroundSync() {
|
|
const lastSyncAt = await getMeta('last_sync_at');
|
|
if (lastSyncAt && Date.now() - new Date(lastSyncAt).getTime() < BACKGROUND_SYNC_MIN_INTERVAL_MS) {
|
|
return;
|
|
}
|
|
const pushedChanges = await runBackgroundSync();
|
|
if (pushedChanges) {
|
|
window.location.reload();
|
|
}
|
|
}
|