diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index fff13b8..61d567e 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -76,15 +76,20 @@ export const useChores = (includeArchive = false) => { refetchOnWindowFocus: true, queryFn: async () => { if (isOfflineFeatureEnabled()) { - // Sync from server first (no-op if already syncing or offline) - if (networkManager.isOnline) { - await syncEngine.sync() - } - const cursor = await offlineDB.getSyncCursor() - if (cursor > 0) { - const cached = await offlineDB.getChores(includeArchive) - const merged = await mergePendingCreates(cached || []) - return { res: merged } + try { + // Sync from server first (no-op if already syncing or offline) + if (networkManager.isOnline) { + await syncEngine.sync() + } + const cursor = await offlineDB.getSyncCursor() + if (cursor > 0) { + const cached = await offlineDB.getChores(includeArchive) + const merged = await mergePendingCreates(cached || []) + return { res: merged } + } + } catch (err) { + // A broken cache must not brick the app — fall through to the API + console.error('Offline cache read failed, falling back to API', err) } } diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js index f9f8794..44df434 100644 --- a/src/utils/CommandQueue.js +++ b/src/utils/CommandQueue.js @@ -17,6 +17,17 @@ export const CommandType = { 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 } @@ -77,7 +88,8 @@ class CommandQueue { const commands = await offlineDB.getCommands() return commands .filter(c => c.status === 'pending' || c.status === 'syncing') - .map(c => ({ ...c, payload: JSON.parse(c.payload) })) + .map(parsePayload) + .filter(Boolean) } // Get all failed commands @@ -86,7 +98,8 @@ class CommandQueue { const commands = await offlineDB.getCommands() return commands .filter(c => c.status === 'failed') - .map(c => ({ ...c, payload: JSON.parse(c.payload) })) + .map(parsePayload) + .filter(Boolean) } // Get pending commands for a specific entity (for undo/UI) @@ -103,7 +116,8 @@ class CommandQueue { .sort((a, b) => a.createdAt - b.createdAt) return commands .filter(c => c.status === 'pending' || c.status === 'syncing') - .map(c => ({ ...c, payload: JSON.parse(c.payload) })) + .map(parsePayload) + .filter(Boolean) } // Cancel/undo a pending command diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js index 24b8b68..39b511a 100644 --- a/src/utils/OfflineDB.js +++ b/src/utils/OfflineDB.js @@ -20,6 +20,29 @@ const isNative = () => { return _isNative } +// The raw CapacitorSQLite query API prepends a metadata row on iOS +// (e.g. { ios_columns: ["data"] }). The plugin's SQLiteDBConnection wrapper +// strips it, but we call the plugin directly, so filter it here — otherwise +// the metadata row reaches JSON.parse(undefined) and crashes offline reads. +const queryRows = result => + (result?.values || []).filter( + row => row && typeof row === 'object' && !('ios_columns' in row), + ) + +// Parse a JSON column across rows, skipping corrupt rows instead of letting +// one bad row take down the whole offline cache. +const parseJsonRows = (result, column) => { + const parsed = [] + for (const row of queryRows(result)) { + try { + parsed.push(JSON.parse(row[column])) + } catch (err) { + console.warn('Skipping corrupt offline cache row', err) + } + } + return parsed +} + // ── SQLite backend (iOS/Android) ── class SQLiteBackend { @@ -125,7 +148,7 @@ class SQLiteBackend { statement: 'SELECT data FROM cached_chores', values: [], }) - const chores = (result.values || []).map(row => JSON.parse(row.data)) + const chores = parseJsonRows(result, 'data') if (includeArchive) { return chores } @@ -139,10 +162,7 @@ class SQLiteBackend { statement: 'SELECT data FROM cached_chores WHERE id = ?', values: [isNaN(numericId) ? id : numericId], }) - if (result.values && result.values.length > 0) { - return JSON.parse(result.values[0].data) - } - return null + return parseJsonRows(result, 'data')[0] ?? null } async deleteChores(ids) { @@ -216,7 +236,7 @@ class SQLiteBackend { 'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC', values: [Number(choreId)], }) - return (result.values || []).map(row => JSON.parse(row.data)) + return parseJsonRows(result, 'data') } async getHistoryByDays(days) { @@ -229,7 +249,7 @@ class SQLiteBackend { : 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC', values: since === 0 ? [] : [since], }) - return (result.values || []).map(row => JSON.parse(row.data)) + return parseJsonRows(result, 'data') } async deleteHistory(ids) { @@ -248,10 +268,16 @@ class SQLiteBackend { values: [historyId], }) - if (!existing.values?.length) return + const existingRows = queryRows(existing) + if (!existingRows.length) return - const row = existing.values[0] - const current = JSON.parse(row.data) + const row = existingRows[0] + let current + try { + current = JSON.parse(row.data) + } catch { + return + } const merged = { ...current, ...updates, @@ -314,7 +340,7 @@ class SQLiteBackend { statement: 'SELECT * FROM command_queue ORDER BY created_at ASC', values: [], }) - return (result.values || []).map(row => ({ + return queryRows(result).map(row => ({ id: row.id, commandType: row.command_type, entityId: row.entity_id, @@ -332,7 +358,7 @@ class SQLiteBackend { 'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC', values: [entityId], }) - return (result.values || []).map(row => ({ + return queryRows(result).map(row => ({ id: row.id, commandType: row.command_type, entityId: row.entity_id, @@ -358,9 +384,10 @@ class SQLiteBackend { values: [id], }) - if (!result.values?.length) return + const resultRows = queryRows(result) + if (!resultRows.length) return - const row = result.values[0] + const row = resultRows[0] await CapacitorSQLite.run({ database: DB_NAME, statement: `UPDATE command_queue @@ -403,8 +430,10 @@ class SQLiteBackend { statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'", values: [], }) - if (result.values && result.values.length > 0) { - return Number(result.values[0].value) + const rows = queryRows(result) + if (rows.length > 0) { + const cursor = Number(rows[0].value) + return Number.isFinite(cursor) ? cursor : 0 } return 0 } @@ -424,8 +453,10 @@ class SQLiteBackend { statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'", values: [], }) - if (result.values && result.values.length > 0) { - return Number(result.values[0].value) + const rows = queryRows(result) + if (rows.length > 0) { + const time = Number(rows[0].value) + return Number.isFinite(time) ? time : null } return null } @@ -453,9 +484,10 @@ class SQLiteBackend { statement: 'SELECT value FROM sync_meta WHERE key = ?', values: [key], }) - if (result.values && result.values.length > 0) { + const rows = queryRows(result) + if (rows.length > 0) { try { - return JSON.parse(result.values[0].value) + return JSON.parse(rows[0].value) } catch { return null }