Files
donetick/src/utils/SyncEngine.js
Mohamad Tarbin 48dbc7a152 Fix button issues, improve layout, and enhance notification handling (#192)
* Fix approval /declien button issue. update action buttons and improve layout

* feat(notifications): enhance notification handling and default templates across components

* fix(sync): improve sync handling to coalesce concurrent requests and prevent lost writes

there is BUG that was very annoying cause by race condition we we update and sync happen as we update and doesn't return the latest added task. this basically fix it
2026-08-05 01:14:39 -04:00

360 lines
12 KiB
JavaScript

import { networkManager } from '../hooks/NetworkManager'
import { apiClient } from './ApiClient'
import { commandQueue, CommandType } from './CommandQueue'
import {
ArchiveChore,
CreateChore,
DeleteChore,
DeleteChoreHistory,
MarkChoreComplete,
PauseChore,
SaveChore,
SkipChore,
StartChore,
UnArchiveChore,
UpdateChoreHistory,
UpdateDueDate,
} from './Fetcher'
import { syncOfflineImages } from './ImageCache'
import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
// Give up on a command after this many transient failures so one stuck
// command can't starve the queue and delta sync forever. Failed commands
// stay visible via commandQueue.getFailed().
const MAX_COMMAND_RETRIES = 8
class SyncEngine {
constructor() {
this.isSyncing = false
this.listeners = []
// The run currently in flight, and the single follow-up run queued behind
// it. See sync() for why a follow-up is needed rather than just waiting.
this.inFlight = null
this.queued = null
}
// Register listener for sync state changes
onSyncStateChange(callback) {
this.listeners.push(callback)
return () => {
this.listeners = this.listeners.filter(l => l !== callback)
}
}
_notify(state) {
this.listeners.forEach(cb => cb(state))
}
// Main sync entry point — returns true if sync succeeded, false otherwise.
//
// Concurrent callers are coalesced rather than dropped. Returning early while
// another run is in flight used to lose writes: that run's /sync/changes
// request may have been issued *before* the caller's change reached the
// server, so its cursor skips past the change and the caller reads a cache
// that will never contain it until something else triggers a sync. That is
// why a task created from the modal could vanish on the refetch right after
// it was created. Waiting for the in-flight run is not enough for the same
// reason, so callers that arrive mid-run share one follow-up run instead.
async sync() {
if (!isOfflineFeatureEnabled()) return false
if (this.inFlight) {
if (!this.queued) {
this.queued = this.inFlight
.catch(() => false)
.then(() => {
this.queued = null
return this.sync()
})
}
return this.queued
}
this.inFlight = this._runSync().finally(() => {
this.inFlight = null
})
return this.inFlight
}
async _runSync() {
this.isSyncing = true
this._notify({ syncing: true, error: null })
try {
await commandQueue.resetSyncing()
// Step 1: Compact the queue (merge consecutive updates)
await commandQueue.compact()
// Step 2: Replay pending commands
await this._replayCommands()
// Step 3: Delta sync from server
await this._deltaSync()
// Sync succeeded — server is reachable (only sync success restores online status)
networkManager.setServerReachable()
this._notify({ syncing: false, lastSync: Date.now() })
// Reconcile the offline image store against the full cached chore list
// (prefetch referenced images, evict ones no longer referenced).
// Fire-and-forget: image downloads must not block or fail the sync.
offlineDB
.getChores(true)
.then(chores => syncOfflineImages(chores || []))
.catch(() => {})
return true
} catch (err) {
await commandQueue.resetSyncing()
console.error('Sync failed:', err)
this._notify({ syncing: false, error: err.message })
return false
} finally {
this.isSyncing = false
}
}
async _replayCommands() {
const commands = await commandQueue.getPending()
for (const cmd of commands) {
if (!networkManager.isOnline) break
await commandQueue.markSyncing(cmd.id)
try {
await this._executeCommand(cmd)
await commandQueue.markDone(cmd.id)
} catch (err) {
const status = err.status || err.statusCode
const isPermanentRejection =
status >= 400 &&
status < 500 &&
status !== 401 &&
status !== 408 &&
status !== 429
if (status === 409) {
// Conflict - mark for user attention but continue with other commands
await commandQueue.markFailed(
cmd.id,
'Conflict: modified by another user',
)
} else if (status === 404) {
// Entity no longer exists - discard command
await commandQueue.markDone(cmd.id)
} else if (isPermanentRejection) {
// The server rejected the command outright — retrying can never
// succeed, so park it as failed instead of blocking the queue.
await commandQueue.markFailed(
cmd.id,
`Rejected by server (${status})`,
)
} else if ((cmd.retryCount ?? 0) + 1 >= MAX_COMMAND_RETRIES) {
await commandQueue.markFailed(
cmd.id,
`Gave up after ${MAX_COMMAND_RETRIES} attempts: ${err.message}`,
)
} else {
// Transient network/server error - reset to pending so it retries
await commandQueue.incrementRetry(cmd.id)
await commandQueue.resetPending(cmd.id)
throw err
}
}
}
}
async _executeCommand(cmd) {
let response
switch (cmd.commandType) {
case CommandType.CREATE_CHORE:
response = await CreateChore(cmd.payload)
// Offline-created chores are queued under a temp id. Once the server
// assigns the real id, rewrite queued follow-up commands (complete,
// skip, history edits, …) so they don't replay against the temp id.
// Never throw past this point: the chore WAS created, and a retry of
// this command would create a duplicate.
if (response?.ok && String(cmd.entityId).startsWith('temp_')) {
try {
const created = await response.json().catch(() => null)
const realId = created?.res
if (realId != null) {
await commandQueue.remapEntityId(cmd.entityId, realId)
await offlineDB.deleteChores([cmd.entityId])
}
} catch (err) {
console.error('Failed to remap temp chore id after create', err)
}
}
break
case CommandType.UPDATE_CHORE:
response = await SaveChore(cmd.payload)
break
case CommandType.COMPLETE_CHORE: {
const { id, body, completedDate, performer } = cmd.payload
response = await MarkChoreComplete(
id,
body || {},
completedDate || null,
performer || null,
)
break
}
case CommandType.SKIP_CHORE:
response = await SkipChore(cmd.payload.id || cmd.entityId)
break
case CommandType.START_CHORE:
response = await StartChore(cmd.payload.id || cmd.entityId)
break
case CommandType.PAUSE_CHORE:
response = await PauseChore(cmd.payload.id || cmd.entityId)
break
case CommandType.DELETE_CHORE:
response = await DeleteChore(cmd.payload.id || cmd.entityId)
break
case CommandType.UPDATE_CHORE_HISTORY: {
const { choreId, historyId, historyData } = cmd.payload
response = await UpdateChoreHistory(choreId, historyId, historyData)
break
}
case CommandType.DELETE_CHORE_HISTORY: {
const { choreId, historyId } = cmd.payload
response = await DeleteChoreHistory(choreId, historyId)
break
}
case CommandType.RESCHEDULE_CHORE: {
const { id, dueDate } = cmd.payload
response = await UpdateDueDate(id, dueDate)
break
}
case CommandType.ARCHIVE_CHORE:
response = await ArchiveChore(cmd.payload.id || cmd.entityId)
break
case CommandType.UNARCHIVE_CHORE:
response = await UnArchiveChore(cmd.payload.id || cmd.entityId)
break
default:
console.warn('Unknown command type:', cmd.commandType)
return
}
// Check if the response indicates an error and throw so the caller can handle it
if (response && typeof response.ok !== 'undefined' && !response.ok) {
const err = new Error(`API error: ${response.status}`)
err.status = response.status
throw err
}
}
async _deltaSync() {
const cursor = (await offlineDB.getSyncCursor()) || -1
let hasMore = true
let currentCursor = cursor
while (hasMore && networkManager.deviceOnline) {
// Use apiClient.get which handles auth and returns a fetch Response
const response = await apiClient.get(
`/sync/changes?since=${currentCursor}`,
)
if (!response || !response.ok) {
const error = new Error(
response
? `Delta sync failed: ${response.status}`
: 'Delta sync failed: no response from server',
)
error.status = response?.status
throw error
}
const data = await response.json()
// Upsert changed chores first
const changedChores = data.changes?.chores ?? []
if (changedChores.length > 0) {
await offlineDB.saveChores(changedChores)
}
// Upsert changed history entries (also clears any pending entries for the same chore IDs)
const changedHistory = data.changes?.choreHistories ?? []
if (changedHistory.length > 0) {
await offlineDB.saveHistory(changedHistory)
}
// Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both)
const deletedIds = data.deletions?.chores ?? []
if (deletedIds.length > 0) {
await offlineDB.deleteChores(deletedIds)
}
const deletedHistoryIds = data.deletions?.choreHistories ?? []
if (deletedHistoryIds.length > 0) {
await offlineDB.deleteHistory(deletedHistoryIds)
}
// Always advance the cursor, even when there are no changes
if (data.cursor != null) {
currentCursor = data.cursor
}
hasMore = !!data.hasMore
}
await offlineDB.setSyncCursor(currentCursor)
await offlineDB.setLastSyncTime(Date.now())
}
// Cache current chores (call after a successful online fetch).
// Pass complete: true only when `chores` is the *full* list (including
// archived) — then cached rows missing from it are server-side deletions
// and get removed, so the offline cache doesn't keep ghost chores.
async cacheChores(chores, { complete = false } = {}) {
if (!isOfflineFeatureEnabled()) return
if (!chores || chores.length === 0) return
await offlineDB.saveChores(chores)
if (complete) {
try {
const fetchedIds = new Set(chores.map(chore => String(chore.id)))
const cached = await offlineDB.getChores(true)
const staleIds = (cached || [])
.filter(
chore =>
chore?.id != null &&
!String(chore.id).startsWith('temp_') &&
!fetchedIds.has(String(chore.id)),
)
.map(chore => chore.id)
if (staleIds.length > 0) {
await offlineDB.deleteChores(staleIds)
}
} catch (err) {
console.error('Failed to reconcile cached chores', err)
}
}
// Fire-and-forget: keep the offline image store in step with the data.
// Reconcile against the *full* cached list — the passed list may exclude
// archived chores, and eviction must only run against everything we have.
offlineDB
.getChores(true)
.then(all => syncOfflineImages(all || []))
.catch(() => {})
}
}
export const syncEngine = new SyncEngine()