Files
donetick/src/utils/OfflineDB.js
2026-08-16 11:52:40 -04:00

1165 lines
32 KiB
JavaScript

import { Capacitor } from '@capacitor/core'
import { CapacitorSQLite } from '@capacitor-community/sqlite'
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
const DB_NAME = 'donetick_offline'
const DB_VERSION = 2
const IDB_NAME = 'donetick_offline'
const IDB_VERSION = 2
// Cache platform detection
let _isNative = null
const isNative = () => {
if (_isNative === null) {
try {
_isNative = Capacitor.isNativePlatform()
} catch {
_isNative = false
}
}
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 {
constructor() {
this.db = null
this.initialized = false
this._initPromise = null
}
async init() {
if (this.initialized) return
// Return the in-flight promise if init is already underway (prevents double createConnection)
if (this._initPromise) return this._initPromise
this._initPromise = this._doInit().finally(() => {
this._initPromise = null
})
return this._initPromise
}
async _doInit() {
try {
this.db = await CapacitorSQLite.createConnection({
database: DB_NAME,
version: DB_VERSION,
encrypted: false,
mode: 'no-encryption',
})
} catch (err) {
// Connection already open (e.g. React StrictMode double-mount) — reuse it
if (!err?.message?.includes('already exists')) throw err
}
await CapacitorSQLite.open({ database: DB_NAME })
await CapacitorSQLite.execute({
database: DB_NAME,
statements: `
CREATE TABLE IF NOT EXISTS cached_chores (
id INTEGER PRIMARY KEY,
data TEXT NOT NULL,
sync_version INTEGER NOT NULL DEFAULT 0,
cached_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS command_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
payload TEXT NOT NULL,
created_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
error TEXT,
retry_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sync_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS cached_history (
id INTEGER PRIMARY KEY,
chore_id INTEGER NOT NULL,
data TEXT NOT NULL,
performed_at INTEGER NOT NULL,
pending INTEGER NOT NULL DEFAULT 0,
cached_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_history_chore_id ON cached_history(chore_id);
CREATE INDEX IF NOT EXISTS idx_history_performed_at ON cached_history(performed_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_pending ON cached_history(pending);
`,
})
// Migration for databases created before retry tracking existed.
// ALTER TABLE fails harmlessly when the column is already there.
try {
await CapacitorSQLite.execute({
database: DB_NAME,
statements:
'ALTER TABLE command_queue ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0;',
})
} catch {
// column already exists
}
this.initialized = true
}
// ── Chore cache ──
async saveChores(chores) {
if (!chores.length) return
const statements = chores.map(chore => ({
statement:
'INSERT OR REPLACE INTO cached_chores (id, data, sync_version, cached_at) VALUES (?, ?, ?, ?)',
values: [
chore.id,
JSON.stringify(chore),
chore.syncVersion || 0,
Date.now(),
],
}))
await CapacitorSQLite.executeSet({
database: DB_NAME,
set: statements,
})
}
async getChores(includeArchive = false) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT data FROM cached_chores',
values: [],
})
const chores = parseJsonRows(result, 'data')
if (includeArchive) {
return chores
}
return chores.filter(chore => chore.isActive !== false)
}
async getChore(id) {
const numericId = Number(id)
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT data FROM cached_chores WHERE id = ?',
values: [isNaN(numericId) ? id : numericId],
})
return parseJsonRows(result, 'data')[0] ?? null
}
async deleteChores(ids) {
if (!ids.length) return
const statements = ids.map(id => ({
statement: 'DELETE FROM cached_chores WHERE id = ?',
values: [id],
}))
await CapacitorSQLite.executeSet({
database: DB_NAME,
set: statements,
})
}
async clearChores() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: 'DELETE FROM cached_chores',
})
}
// ── History cache ──
async saveHistory(entries) {
if (!entries.length) return
// Delete pending entries for the affected chore IDs first
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
if (choreIds.length) {
const placeholders = choreIds.map(() => '?').join(', ')
await CapacitorSQLite.run({
database: DB_NAME,
statement: `DELETE FROM cached_history WHERE pending = 1 AND chore_id IN (${placeholders})`,
values: choreIds,
})
}
const statements = entries.map(entry => ({
statement:
'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)',
values: [
entry.id,
Number(entry.choreId),
JSON.stringify(entry),
new Date(entry.performedAt).getTime(),
0,
Date.now(),
],
}))
await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements })
}
async savePendingHistory(entry) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)',
values: [
entry.id,
Number(entry.choreId),
JSON.stringify(entry),
new Date(entry.performedAt).getTime(),
1,
Date.now(),
],
})
}
async getHistoryByChore(choreId) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement:
'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC',
values: [Number(choreId)],
})
return parseJsonRows(result, 'data')
}
async getHistoryByDays(days) {
const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement:
since === 0
? 'SELECT data FROM cached_history ORDER BY performed_at DESC'
: 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC',
values: since === 0 ? [] : [since],
})
return parseJsonRows(result, 'data')
}
async deleteHistory(ids) {
if (!ids.length) return
const statements = ids.map(id => ({
statement: 'DELETE FROM cached_history WHERE id = ?',
values: [id],
}))
await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements })
}
async updateHistoryEntry(choreId, historyId, updates) {
const existing = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT data, pending FROM cached_history WHERE id = ?',
values: [historyId],
})
const existingRows = queryRows(existing)
if (!existingRows.length) return
const row = existingRows[0]
let current
try {
current = JSON.parse(row.data)
} catch {
return
}
const merged = {
...current,
...updates,
id: historyId,
choreId: Number(choreId),
}
await CapacitorSQLite.run({
database: DB_NAME,
statement:
'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)',
values: [
historyId,
Number(choreId),
JSON.stringify(merged),
new Date(merged.performedAt).getTime(),
row.pending || 0,
Date.now(),
],
})
}
async deleteHistoryEntry(historyId) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'DELETE FROM cached_history WHERE id = ?',
values: [historyId],
})
}
async clearHistory() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: 'DELETE FROM cached_history',
})
}
// ── Command queue ──
async enqueueCommand(command) {
const result = await CapacitorSQLite.run({
database: DB_NAME,
statement: `INSERT INTO command_queue (command_type, entity_id, payload, created_at, status, error)
VALUES (?, ?, ?, ?, ?, ?)`,
values: [
command.commandType,
command.entityId,
command.payload,
command.createdAt,
command.status,
command.error,
],
})
return result.changes?.lastId
}
async getCommands() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT * FROM command_queue ORDER BY created_at ASC',
values: [],
})
return queryRows(result).map(row => ({
id: row.id,
commandType: row.command_type,
entityId: row.entity_id,
payload: row.payload,
createdAt: row.created_at,
status: row.status,
error: row.error,
retryCount: row.retry_count ?? 0,
}))
}
async getCommandsByEntity(entityId) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement:
'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC',
values: [entityId],
})
return queryRows(result).map(row => ({
id: row.id,
commandType: row.command_type,
entityId: row.entity_id,
payload: row.payload,
createdAt: row.created_at,
status: row.status,
error: row.error,
retryCount: row.retry_count ?? 0,
}))
}
async updateCommandStatus(id, status, error) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'UPDATE command_queue SET status = ?, error = ? WHERE id = ?',
values: [status, error, id],
})
}
async updateCommand(id, updates) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT * FROM command_queue WHERE id = ?',
values: [id],
})
const resultRows = queryRows(result)
if (!resultRows.length) return
const row = resultRows[0]
await CapacitorSQLite.run({
database: DB_NAME,
statement: `UPDATE command_queue
SET command_type = ?, entity_id = ?, payload = ?, created_at = ?, status = ?, error = ?, retry_count = ?
WHERE id = ?`,
values: [
updates.commandType ?? row.command_type,
updates.entityId ?? row.entity_id,
updates.payload ?? row.payload,
updates.createdAt ?? row.created_at,
updates.status ?? row.status,
Object.prototype.hasOwnProperty.call(updates, 'error')
? updates.error
: row.error,
updates.retryCount ?? row.retry_count ?? 0,
id,
],
})
}
async incrementCommandRetry(id) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
'UPDATE command_queue SET retry_count = COALESCE(retry_count, 0) + 1 WHERE id = ?',
values: [id],
})
}
async removeCommand(id) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'DELETE FROM command_queue WHERE id = ?',
values: [id],
})
}
async clearCommands() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: 'DELETE FROM command_queue',
})
}
// ── Sync metadata ──
async getSyncCursor() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'",
values: [],
})
const rows = queryRows(result)
if (rows.length > 0) {
const cursor = Number(rows[0].value)
return Number.isFinite(cursor) ? cursor : 0
}
return 0
}
async setSyncCursor(cursor) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('sync_cursor', ?)",
values: [String(cursor)],
})
}
async getLastSyncTime() {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'",
values: [],
})
const rows = queryRows(result)
if (rows.length > 0) {
const time = Number(rows[0].value)
return Number.isFinite(time) ? time : null
}
return null
}
async setLastSyncTime(time) {
await CapacitorSQLite.run({
database: DB_NAME,
statement:
"INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync_time', ?)",
values: [String(time)],
})
}
async saveKV(key, value) {
await CapacitorSQLite.run({
database: DB_NAME,
statement: 'INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)',
values: [key, JSON.stringify(value)],
})
}
async getKV(key) {
const result = await CapacitorSQLite.query({
database: DB_NAME,
statement: 'SELECT value FROM sync_meta WHERE key = ?',
values: [key],
})
const rows = queryRows(result)
if (rows.length > 0) {
try {
return JSON.parse(rows[0].value)
} catch {
return null
}
}
return null
}
async clearAll() {
await CapacitorSQLite.execute({
database: DB_NAME,
statements: `
DELETE FROM cached_chores;
DELETE FROM command_queue;
DELETE FROM sync_meta;
DELETE FROM cached_history;
`,
})
}
}
// ── IndexedDB backend (Web) ──
class IndexedDBBackend {
constructor() {
this.db = null
this.initialized = false
}
_open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IDB_NAME, IDB_VERSION)
request.onupgradeneeded = event => {
const db = event.target.result
if (!db.objectStoreNames.contains('cached_chores')) {
db.createObjectStore('cached_chores', { keyPath: 'id' })
}
if (!db.objectStoreNames.contains('command_queue')) {
const cmdStore = db.createObjectStore('command_queue', {
keyPath: 'id',
autoIncrement: true,
})
cmdStore.createIndex('entity_id', 'entityId', { unique: false })
cmdStore.createIndex('created_at', 'createdAt', { unique: false })
cmdStore.createIndex('status', 'status', { unique: false })
}
if (!db.objectStoreNames.contains('sync_meta')) {
db.createObjectStore('sync_meta', { keyPath: 'key' })
}
if (!db.objectStoreNames.contains('cached_history')) {
const histStore = db.createObjectStore('cached_history', {
keyPath: 'id',
})
histStore.createIndex('chore_id', 'choreId', { unique: false })
histStore.createIndex('performed_at', 'performedAt', {
unique: false,
})
histStore.createIndex('pending', 'pending', { unique: false })
}
}
request.onsuccess = event => resolve(event.target.result)
request.onerror = event => reject(event.target.error)
})
}
async init() {
if (this.initialized) return
this.db = await this._open()
// Re-open if browser closes the connection (e.g. after device sleep)
this.db.onclose = () => {
this.initialized = false
}
this.initialized = true
}
async _tx(storeName, mode = 'readonly') {
// If the connection was closed (e.g. laptop sleep), re-open transparently
if (!this.initialized || !this.db) {
await this.init()
}
try {
const tx = this.db.transaction(storeName, mode)
const store = tx.objectStore(storeName)
return { tx, store }
} catch (err) {
// InvalidStateError = connection closed; re-open once and retry
if (
err.name === 'InvalidStateError' ||
err.name === 'TransactionInactiveError'
) {
this.initialized = false
await this.init()
const tx = this.db.transaction(storeName, mode)
const store = tx.objectStore(storeName)
return { tx, store }
}
throw err
}
}
_request(idbRequest) {
return new Promise((resolve, reject) => {
idbRequest.onsuccess = () => resolve(idbRequest.result)
idbRequest.onerror = () => reject(idbRequest.error)
})
}
// ── Chore cache ──
async saveChores(chores) {
if (!chores.length) return
const { store, tx } = await this._tx('cached_chores', 'readwrite')
for (const chore of chores) {
store.put({
id: chore.id,
data: chore,
syncVersion: chore.syncVersion || 0,
cachedAt: Date.now(),
})
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async getChores(includeArchive = false) {
const { store } = await this._tx('cached_chores')
const rows = await this._request(store.getAll())
const chores = rows.map(row => row.data)
if (includeArchive) {
return chores
}
return chores.filter(chore => chore.isActive !== false)
}
async getChore(id) {
const { store } = await this._tx('cached_chores')
// Try numeric ID first (chores are stored with numeric keys from the server)
// URL params are strings so we need to coerce
const numericId = Number(id)
const row = await this._request(
store.get(isNaN(numericId) ? id : numericId),
)
return row ? row.data : null
}
async deleteChores(ids) {
if (!ids.length) return
const { store, tx } = await this._tx('cached_chores', 'readwrite')
for (const id of ids) {
store.delete(id)
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async clearChores() {
const { store } = await this._tx('cached_chores', 'readwrite')
await this._request(store.clear())
}
// ── History cache ──
async saveHistory(entries) {
if (!entries.length) return
// Delete pending entries for the affected chore IDs first
const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
await this._deletePendingHistoryByChoreIds(choreIds)
// Upsert real entries
const { store, tx } = await this._tx('cached_history', 'readwrite')
for (const entry of entries) {
store.put({
id: entry.id,
choreId: Number(entry.choreId),
data: entry,
performedAt: new Date(entry.performedAt).getTime(),
pending: 0,
cachedAt: Date.now(),
})
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async savePendingHistory(entry) {
const { store } = await this._tx('cached_history', 'readwrite')
await this._request(
store.put({
id: entry.id,
choreId: Number(entry.choreId),
data: entry,
performedAt: new Date(entry.performedAt).getTime(),
pending: 1,
cachedAt: Date.now(),
}),
)
}
async _deletePendingHistoryByChoreIds(choreIds) {
if (!choreIds.length) return
const choreIdSet = new Set(choreIds)
// Tx 1: read all pending entries
const { store: readStore } = await this._tx('cached_history')
const index = readStore.index('pending')
const rows = await this._request(index.getAll(1))
const toDelete = rows
.filter(row => choreIdSet.has(Number(row.choreId)))
.map(row => row.id)
if (!toDelete.length) return
// Tx 2: delete them
const { store: writeStore, tx } = await this._tx(
'cached_history',
'readwrite',
)
for (const id of toDelete) {
writeStore.delete(id)
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async getHistoryByChore(choreId) {
const { store } = await this._tx('cached_history')
const index = store.index('chore_id')
const rows = await this._request(index.getAll(Number(choreId)))
return rows
.map(row => row.data)
.sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt))
}
async getHistoryByDays(days) {
const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000
const { store } = await this._tx('cached_history')
const rows = await this._request(store.getAll())
return rows
.map(row => row.data)
.filter(entry =>
since === 0 ? true : new Date(entry.performedAt).getTime() >= since,
)
.sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt))
}
async deleteHistory(ids) {
if (!ids.length) return
const { store, tx } = await this._tx('cached_history', 'readwrite')
for (const id of ids) {
store.delete(id)
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
}
async updateHistoryEntry(choreId, historyId, updates) {
const { store } = await this._tx('cached_history')
const existing = await this._request(store.get(historyId))
if (!existing) return
const merged = {
...existing,
choreId: Number(choreId),
data: {
...existing.data,
...updates,
id: historyId,
choreId: Number(choreId),
},
}
merged.performedAt = new Date(merged.data.performedAt).getTime()
merged.cachedAt = Date.now()
const { store: writeStore } = await this._tx('cached_history', 'readwrite')
await this._request(writeStore.put(merged))
}
async deleteHistoryEntry(historyId) {
const { store } = await this._tx('cached_history', 'readwrite')
await this._request(store.delete(historyId))
}
async clearHistory() {
const { store } = await this._tx('cached_history', 'readwrite')
await this._request(store.clear())
}
// ── Command queue ──
async enqueueCommand(command) {
const { store } = await this._tx('command_queue', 'readwrite')
const id = await this._request(
store.add({
commandType: command.commandType,
entityId: command.entityId,
payload: command.payload,
createdAt: command.createdAt,
status: command.status,
error: command.error,
retryCount: 0,
}),
)
return id
}
async getCommands() {
const { store } = await this._tx('command_queue')
const index = store.index('created_at')
const rows = await this._request(index.getAll())
return rows
}
async getCommandsByEntity(entityId) {
const { store } = await this._tx('command_queue')
const index = store.index('entity_id')
const rows = await this._request(index.getAll(entityId))
return rows.sort((a, b) => a.createdAt - b.createdAt)
}
async updateCommandStatus(id, status, error) {
const { store } = await this._tx('command_queue', 'readwrite')
const row = await this._request(store.get(id))
if (row) {
row.status = status
row.error = error
await this._request(store.put(row))
}
}
async updateCommand(id, updates) {
const { store } = await this._tx('command_queue', 'readwrite')
const row = await this._request(store.get(id))
if (row) {
await this._request(
store.put({
...row,
...updates,
}),
)
}
}
async incrementCommandRetry(id) {
const { store } = await this._tx('command_queue', 'readwrite')
const row = await this._request(store.get(id))
if (row) {
row.retryCount = (row.retryCount ?? 0) + 1
await this._request(store.put(row))
}
}
async removeCommand(id) {
const { store } = await this._tx('command_queue', 'readwrite')
await this._request(store.delete(id))
}
async clearCommands() {
const { store } = await this._tx('command_queue', 'readwrite')
await this._request(store.clear())
}
// ── Sync metadata ──
async getSyncCursor() {
const { store } = await this._tx('sync_meta')
const row = await this._request(store.get('sync_cursor'))
return row ? Number(row.value) : 0
}
async setSyncCursor(cursor) {
const { store } = await this._tx('sync_meta', 'readwrite')
await this._request(
store.put({ key: 'sync_cursor', value: String(cursor) }),
)
}
async getLastSyncTime() {
const { store } = await this._tx('sync_meta')
const row = await this._request(store.get('last_sync_time'))
return row ? Number(row.value) : null
}
async setLastSyncTime(time) {
const { store } = await this._tx('sync_meta', 'readwrite')
await this._request(
store.put({ key: 'last_sync_time', value: String(time) }),
)
}
async saveKV(key, value) {
const { store } = await this._tx('sync_meta', 'readwrite')
await this._request(store.put({ key, value: JSON.stringify(value) }))
}
async getKV(key) {
const { store } = await this._tx('sync_meta')
const row = await this._request(store.get(key))
if (row) {
try {
return JSON.parse(row.value)
} catch {
return null
}
}
return null
}
async clearAll() {
const storeNames = [
'cached_chores',
'command_queue',
'sync_meta',
'cached_history',
]
for (const storeName of storeNames) {
const { store } = await this._tx(storeName, 'readwrite')
await this._request(store.clear())
}
}
}
// ── OfflineDB facade ──
class OfflineDB {
constructor() {
this.backend = null
this.initialized = false
// When the tab becomes visible after being hidden (laptop wake/tab switch),
// reset so the next operation re-validates the IDB connection.
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && this.backend) {
// Signal the IDB backend to re-open on next use
this.backend.initialized = false
}
})
}
}
async init() {
if (this.initialized) return
if (this._initPromise) return this._initPromise
this._initPromise = (async () => {
this.backend = isNative() ? new SQLiteBackend() : new IndexedDBBackend()
await this.backend.init()
this.initialized = true
this._initPromise = null
})()
return this._initPromise
}
async _ensureInit() {
if (!this.initialized) {
await this.init()
}
}
// Chore cache
async saveChores(chores) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.saveChores(chores)
}
async getChores(includeArchive = false) {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getChores(includeArchive)
}
async getChore(id) {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.getChore(id)
}
async deleteChores(ids) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.deleteChores(ids)
}
async clearChores() {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.clearChores()
}
// Command queue
async enqueueCommand(command) {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.enqueueCommand(command)
}
async getCommands() {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getCommands()
}
async getCommandsByEntity(entityId) {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getCommandsByEntity(entityId)
}
async updateCommandStatus(id, status, error) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.updateCommandStatus(id, status, error)
}
async updateCommand(id, updates) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.updateCommand(id, updates)
}
async incrementCommandRetry(id) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.incrementCommandRetry(id)
}
async removeCommand(id) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.removeCommand(id)
}
async clearCommands() {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.clearCommands()
}
// Sync metadata
async getSyncCursor() {
if (!isOfflineFeatureEnabled()) return 0
await this._ensureInit()
return this.backend.getSyncCursor()
}
async setSyncCursor(cursor) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.setSyncCursor(cursor)
}
async getLastSyncTime() {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.getLastSyncTime()
}
async setLastSyncTime(time) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.setLastSyncTime(time)
}
// History cache
async saveHistory(entries) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.saveHistory(entries)
}
async savePendingHistory(entry) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.savePendingHistory(entry)
}
async getHistoryByChore(choreId) {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getHistoryByChore(choreId)
}
async getHistoryByDays(days) {
if (!isOfflineFeatureEnabled()) return []
await this._ensureInit()
return this.backend.getHistoryByDays(days)
}
async deleteHistory(ids) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.deleteHistory(ids)
}
async updateHistoryEntry(choreId, historyId, updates) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.updateHistoryEntry(choreId, historyId, updates)
}
async deleteHistoryEntry(historyId) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.deleteHistoryEntry(historyId)
}
async clearHistory() {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.clearHistory()
}
// General key-value cache (uses sync_meta store)
async saveKV(key, value) {
if (!isOfflineFeatureEnabled()) return
await this._ensureInit()
return this.backend.saveKV(key, value)
}
async getKV(key) {
if (!isOfflineFeatureEnabled()) return null
await this._ensureInit()
return this.backend.getKV(key)
}
async clearAll() {
await this._ensureInit()
return this.backend.clearAll()
}
}
export const offlineDB = new OfflineDB()