Files
donetick/src/utils/CommandQueue.js

305 lines
9.0 KiB
JavaScript

import { offlineDB } from './OfflineDB'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
// Domain command types
export const CommandType = {
CREATE_CHORE: 'create_chore',
UPDATE_CHORE: 'update_chore',
UPDATE_CHORE_HISTORY: 'update_chore_history',
COMPLETE_CHORE: 'complete_chore',
SKIP_CHORE: 'skip_chore',
START_CHORE: 'start_chore',
PAUSE_CHORE: 'pause_chore',
DELETE_CHORE: 'delete_chore',
DELETE_CHORE_HISTORY: 'delete_chore_history',
RESCHEDULE_CHORE: 'reschedule_chore',
ARCHIVE_CHORE: 'archive_chore',
UNARCHIVE_CHORE: 'unarchive_chore',
}
// Parse a stored command payload; a corrupt payload must not take down every
// consumer of the queue, so parse failures surface as null payloads.
const parsePayload = command => {
try {
return { ...command, payload: JSON.parse(command.payload) }
} catch {
console.warn('Skipping corrupt command payload', command.id)
return null
}
}
class CommandQueue {
_sanitizeCreatePayload(payload = {}) {
const sanitized = { ...payload }
delete sanitized.id
delete sanitized._pendingCreate
delete sanitized._pendingUpdate
return sanitized
}
_clearPendingFlags(chore = {}) {
const next = { ...chore }
delete next._pending
delete next._pendingUpdate
return next
}
async _rollbackCancelledCommand(command) {
if (!command) return
if (
command.commandType !== CommandType.ARCHIVE_CHORE &&
command.commandType !== CommandType.UNARCHIVE_CHORE
) {
return
}
const cachedChore = await offlineDB.getChore(command.entityId)
if (!cachedChore) return
const restoredChore = this._clearPendingFlags({
...cachedChore,
isActive: command.commandType === CommandType.ARCHIVE_CHORE,
})
await offlineDB.saveChores([restoredChore])
}
// Enqueue a domain command
async enqueue(type, entityId, payload) {
if (!isOfflineFeatureEnabled()) {
throw new Error('Offline support is disabled on this device')
}
const command = {
commandType: type,
entityId: String(entityId),
payload: JSON.stringify(payload),
createdAt: Date.now(),
status: 'pending',
error: null,
}
return offlineDB.enqueueCommand(command)
}
// Get all pending commands in order
async getPending() {
if (!isOfflineFeatureEnabled()) return []
const commands = await offlineDB.getCommands()
return commands
.filter(c => c.status === 'pending' || c.status === 'syncing')
.map(parsePayload)
.filter(Boolean)
}
// Get all failed commands
async getFailed() {
if (!isOfflineFeatureEnabled()) return []
const commands = await offlineDB.getCommands()
return commands
.filter(c => c.status === 'failed')
.map(parsePayload)
.filter(Boolean)
}
// Get pending commands for a specific entity (for undo/UI)
async getPendingForEntity(entityId) {
if (!isOfflineFeatureEnabled()) return []
const allCommands = await offlineDB.getCommands()
const key = String(entityId)
const commands = allCommands
.filter(
c =>
c.entityId === key ||
(typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)),
)
.sort((a, b) => a.createdAt - b.createdAt)
return commands
.filter(c => c.status === 'pending' || c.status === 'syncing')
.map(parsePayload)
.filter(Boolean)
}
// Cancel/undo a pending command
async cancel(commandId) {
if (!isOfflineFeatureEnabled()) return
const allCommands = await offlineDB.getCommands()
const command = allCommands.find(c => String(c.id) === String(commandId))
await this._rollbackCancelledCommand(command)
return offlineDB.removeCommand(commandId)
}
// Rewrite queued commands after an offline-created entity gets its real
// server id: commands queued against the temp id (complete, skip, history
// edits, …) would otherwise replay against an id the server doesn't know.
async remapEntityId(tempId, realId) {
if (!isOfflineFeatureEnabled()) return
const tempKey = String(tempId)
const realKey = String(realId)
const commands = await offlineDB.getCommands()
for (const cmd of commands) {
const entityId = String(cmd.entityId)
const matches = entityId === tempKey || entityId.startsWith(`${tempKey}:`)
if (!matches) continue
const newEntityId =
entityId === tempKey
? realKey
: `${realKey}:${entityId.slice(tempKey.length + 1)}`
let newPayload = cmd.payload
try {
const parsed = JSON.parse(cmd.payload)
if (parsed && typeof parsed === 'object') {
if (String(parsed.id) === tempKey) parsed.id = realId
if (String(parsed.choreId) === tempKey) parsed.choreId = realId
newPayload = JSON.stringify(parsed)
}
} catch {
// unparseable payload — remap the entity id only
}
await offlineDB.updateCommand(cmd.id, {
entityId: newEntityId,
payload: newPayload,
})
}
}
// Track a transient failure so replay can give up after repeated attempts
async incrementRetry(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.incrementCommandRetry(commandId)
}
// Mark as syncing
async markSyncing(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.updateCommandStatus(commandId, 'syncing', null)
}
// Mark as failed (only for unrecoverable errors like conflicts)
async markFailed(commandId, error) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.updateCommandStatus(commandId, 'failed', error)
}
// Reset back to pending (for transient network/server errors so it retries)
async resetPending(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.updateCommandStatus(commandId, 'pending', null)
}
// Reset any in-flight commands so they remain retryable after aborted syncs
async resetSyncing() {
if (!isOfflineFeatureEnabled()) return
const commands = await offlineDB.getCommands()
const syncingCommands = commands.filter(c => c.status === 'syncing')
await Promise.all(
syncingCommands.map(cmd =>
offlineDB.updateCommandStatus(cmd.id, 'pending', null),
),
)
}
// Remove after successful sync
async markDone(commandId) {
if (!isOfflineFeatureEnabled()) return
return offlineDB.removeCommand(commandId)
}
// Compact: merge consecutive updates to same entity
async compact() {
if (!isOfflineFeatureEnabled()) return
const pending = await this.getPending()
const seen = new Map() // entityId -> last command
const toRemove = []
for (const cmd of pending) {
const prev = seen.get(cmd.entityId)
if (prev?.commandType === CommandType.CREATE_CHORE) {
if (cmd.commandType === CommandType.UPDATE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
...cmd.payload,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.RESCHEDULE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
dueDate: cmd.payload?.dueDate ?? prev.payload?.dueDate,
nextDueDate: cmd.payload?.dueDate ?? prev.payload?.nextDueDate,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.ARCHIVE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
isActive: false,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.UNARCHIVE_CHORE) {
const mergedPayload = this._sanitizeCreatePayload({
...prev.payload,
isActive: true,
})
await offlineDB.updateCommand(prev.id, {
payload: JSON.stringify(mergedPayload),
})
toRemove.push(cmd.id)
continue
}
if (cmd.commandType === CommandType.DELETE_CHORE) {
toRemove.push(prev.id, cmd.id)
seen.delete(cmd.entityId)
continue
}
}
if (cmd.commandType === CommandType.UPDATE_CHORE) {
if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
// Merge: keep latest payload, remove older
toRemove.push(prev.id)
}
}
if (cmd.commandType === CommandType.DELETE_CHORE) {
if (prev?.commandType === CommandType.UPDATE_CHORE) {
toRemove.push(prev.id)
}
}
seen.set(cmd.entityId, cmd)
}
for (const id of [...new Set(toRemove)]) {
await offlineDB.removeCommand(id)
}
}
}
export const commandQueue = new CommandQueue()