305 lines
9.0 KiB
JavaScript
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()
|