1165 lines
32 KiB
JavaScript
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()
|