The REPAIR paths built their vector clock from the per-tab in-memory clock cache and then REPLACED the durable clock with it. A stale cache (another tab advanced the clock) regressed the durable clock, letting subsequent captures reuse counters already shipped — silently corrupting cross-device dominance comparisons. - createRepairOperation: route through appendMixedSourceBatchSkipDuplicates; the in-transaction rebase makes regression unrepresentable. State cache stores the clock actually written. - replaceRejectedRepair: rebase the replacement clock onto the durable clock inside its transaction (shared rebaseLocalClockOnDurable helper). - Drop client-side clock pruning from repair op building: inert under the rebase, and it dropped client IDs the server still tracks (false CONCURRENT). Server prunes after conflict detection. - Rename appendWithVectorClockUpdate -> appendWithVectorClockOverwrite and document the derivation invariant; the capture path (its only remaining production caller) already satisfies it.
12 KiB
SQLite Migration Plan — Op-Log Persistence
Status: Phase A complete; Phase B in progress. Tracks the data-loss class behind issue #7892 (Android WebView storage evicted → total data loss with no sync configured).
Progress:
- ✅
OpLogDbAdapter/OpLogTxport + declarative schema descriptor.- ✅
IndexedDbOpLogAdapter(faithfulidbbackend) + 30 specs.- ✅
adoptConnection()seam: the adapter shares the owning service's single connection, so each service migrates method-by-method with one connection and no spec breakage.- ✅
OperationLogStoreServicefully migrated — every method routes through the adapter, including the two flagship atomic flows (appendWithVectorClockOverwrite,runDestructiveStateReplacement). No directthis.dbcalls remain.- ✅
ArchiveStoreServicefully migrated (own adopted connection +_withRetryOnClosere-adopt path).- ✅ Phase B step 1 — DI: both services inject
OP_LOG_DB_ADAPTER_FACTORY(a factory token; each service gets its own adapter).adoptConnectionis now an optional, IDB-only bridge method on the interface.- ✅ Phase B step 2 —
SqliteOpLogAdapter(fully implemented): dependency-free schema→table planning + DDL (planTables/buildDdl), value→column extraction, all query/index/range/count methods, cursoriterate(incl. keyed + delete), andBEGIN/COMMIT/ROLLBACKtransactions with rollback-on-throw and SQLite→DOMExceptionerror mapping (UNIQUE→ConstraintError, disk-full→QuotaExceededError). Talks only to a minimalSqliteDbport, so no native dependency. 23 specs validate the translation layer + transaction semantics against an in-memory SQLite stand-in.- ✅ Phase B step 3 — real-engine validation (CI):
sql.jsserved into Karma drives the adapter's behavioral contract (sqlite-op-log-adapter.spec.ts) and a store-level pass (remote-apply-store-port.integration.spec.ts) against actual SQLite. Confirms theUNIQUE→ConstraintErrormapping,AUTOINCREMENT-after-clear(), compound-index/NULL ranges, realBEGIN IMMEDIATErollback. No surprises.- ✅ Phase C step (algorithm) — backend migration:
migrateOpLogBackend(op-log-backend-migration.ts) copies the whole DB source→dest with verify-before-commit; tested real-IDB → sql.js. Not yet wired into startup.- ⏳ Remaining (device-gated): add
@capacitor-community/sqlite+ a thinSqliteDbwrapper over itsSQLiteDBConnection(with the bridge-perf mitigations — see followup B1), overrideOP_LOG_DB_ADAPTER_FACTORYfor native behind a flag, fix the storeinit()to calladapter.init()/ skip the IDB open on SQLite (see followup B3), wire the C1 migration trigger, and run on-device. The other small IDB consumers (theme, credential, oauth, client-id) are out of the data-loss scope (Phase D).Open decisions (need on-device validation):
- Adding
@capacitor-community/sqliteis a native dependency that can't be validated in CI (its web build is WASM-on-IndexedDB, not the native path; sql.js's universal build statically importsnode:modules webpack can't bundle for Karma). Defer the plugin + on-device run to a device-capable environment.- Consider shipping the cheap #7892 safeguards independently and sooner: diagnostic logging of
navigator.storage.persist()result on native, and a periodic Capacitor Filesystem auto-backup (a second copy outside the evictable WebView store).- Gate after each group: 170 store unit + 3 archive unit + 367 op-log integration specs green.
Follow-up backlog: the actionable, ordered list of what remains (the near-term #7892 safeguards, the native SQLite wiring, and data migration) lives in
sqlite-migration-followup.md.
0. Goal & non-goal
Goal: On native (Capacitor iOS/Android), move the op-log persistence off WebView IndexedDB into app-private SQLite, so task data no longer lives in the OS-evictable WebView sandbox.
Non-goal: Replacing IndexedDB on web/PWA/Electron. Those either have no
native SQLite or an already-adequate persistence model (Electron). Note that
@capacitor-community/sqlite's web build falls back to WASM SQLite
persisted into IndexedDB — which reintroduces the exact eviction risk. So this
is a native-only backend swap behind a shared abstraction, not a global
rewrite.
1. Why (root cause)
On Capacitor Android the app is a WebView. All op-log data lives in the
WebView's IndexedDB (SUP_OPS database), which is subject to OS eviction under
storage pressure and to being cleared as "cache" by the system or cleaner apps.
navigator.storage.persist() (startup.service.ts) is the only mitigation
today, and on Android WebView it is unlikely to be honored — and the
"persistence not allowed" warning is deliberately suppressed on native. Moving
the data into app-private SQLite (/data/data/<pkg>/databases/) makes it
non-evictable; only a full Clear storage or uninstall removes it.
2. Today's constraints (from the code)
- No storage-adapter seam exists. 8 non-test files import
idbdirectly.operation-log-store.service.tsis ~1,750 lines implementingRemoteOperationApplyStorePort+ ~40 more public methods over ~84 transaction/index/cursor calls. - Prior art: the legacy
pfapilayer injected anIndexedDbAdapterbehind aDBAdapterinterface (src/app/pfapi/api/pfapi.js). Same pattern, revived for the op-log system. - Critical DB is
SUP_OPS(9 stores). Other IDB databases (SUPThemes,sup-sync,sup-plugin-oauth, legacypf) are cosmetic / re-acquirable / read-only-migration and are out of scope for the data-loss fix. - Atomicity is the hard part, not CRUD. Two methods need single-transaction
multi-store writes that MUST stay atomic:
appendWithVectorClockOverwrite()— OPS + VECTOR_CLOCK in one tx.runDestructiveStateReplacement()— OPS + STATECACHE + VECTOR_CLOCK + CLIENT_ID (+ ARCHIVE*) in one tx (crash-safety, issues #7709, #7732). Plus: auto-incrementseqkeypath, a uniquebyIdindex, and a compound[source, applicationStatus]index.
- 38 integration specs in
op-log/testing/integration/exercise this store; several are IDB-specific (indexeddb-error-recovery,clean-slate-interrupt,multi-entity-atomicity,race-conditions). These are the regression gate.
3. Strategy: adapter seam first, SQLite second
Phase A — Extract the persistence port (no behavior change) ⭐ highest risk/effort
Define OpLogDbAdapter (+ OpLogTx) expressed so neither IDB nor SQL leaks
through — shaped around the operations the store needs, with a callback-based
transaction() as the atomicity linchpin (IDB auto-commit and SQLite
BEGIN/COMMIT both map onto "run fn, commit on resolve, roll back on throw").
- Define
OpLogDbAdapter/OpLogTxinterfaces + a declarativeStoreSchemadescriptor (replacingrunDbUpgrade's imperativecreateObjectStore/createIndex). - Implement
IndexedDbOpLogAdapteroveridb— faithful wrapper of today's behavior incl. open-retry,versionchange/closelisteners, andConstraintError→duplicate /QuotaExceededErrormappings. - Refactor
operation-log-store.service.ts+archive-store.service.tsonto the injected adapter. No behavior change. - Keep all 38 integration specs green against the IDB adapter — the gate.
Ship Phase A on its own. Pure refactor, independently valuable, nothing user-visible changes.
Phase B — SQLite backend (native only)
- Add
@capacitor-community/sqlite+ iOS/Android native config. - Implement
SqliteOpLogAdapter implements OpLogDbAdapter:- One table per store. Ops table:
seq INTEGER PRIMARY KEY AUTOINCREMENT,op_id TEXT UNIQUE(→byId), index onsynced_atand(source, application_status). Singleton stores → single-row tables keyed bySINGLETON_KEY. - Store the encoded
CompactOperationas a JSON/TEXTpayloadcolumn — no need to query inside ops, soencode/decodeOperationis unchanged. transaction()→BEGIN IMMEDIATE/COMMIT/ROLLBACK(real ACID, stronger than IDB's auto-commit-on-microtask-gap).- Map SQLite errors → the same
StorageQuotaExceededError/ duplicate-op errors the rest of the system expects.
- One table per store. Ops table:
- DI wiring: bind
OpLogDbAdaptertoSqliteOpLogAdapterwhenplatformService.isNative, elseIndexedDbOpLogAdapter. One token, one factory; the store doesn't know which backend it has.
Phase C — One-time data migration (native, first launch after update)
- Detect: SQLite empty/absent and legacy
SUP_OPSIndexedDB present. - Copy OPS, STATECACHE, VECTOR_CLOCK, CLIENT_ID, ARCHIVE* IDB→SQLite in one SQLite transaction (reuse IDB adapter read side + SQLite adapter write side).
- Verify (count + last-seq + vector-clock match), set a migration-complete marker, keep the IDB copy untouched ≥1 release as fallback.
- Slots into the existing
_initBackups()/loadStateCache()startup flow, mirroring the proven legacypf→SUP_OPSmigration pattern.
Phase D — Other databases (optional, deferred)
SUPThemes, sup-plugin-oauth, sup-sync are cosmetic / re-acquirable;
migrate only if fully evacuating WebView storage. They do not affect the #7892
data-loss class.
4. Sequencing & rollout
- Phase A → merge behind no flag (IDB still the only backend). Gate: all unit + 38 integration specs green.
- Phase B + C → merge behind a native feature flag, default off. Dogfood on real Android devices.
- Parameterize the integration harness to run a second time against
SqliteOpLogAdapter— catches auto-increment/unique-index/atomicity gaps. - Staged enable on native; retain IDB fallback ≥1 release; then add cleanup.
5. Risk register
| Risk | Severity | Mitigation |
|---|---|---|
| Refactor regresses sync correctness | High | Phase A behavior-preserving; 38 specs gate; run suite against both adapters |
| Atomicity differs (IDB auto-commit vs SQL BEGIN/COMMIT) | High | Callback transaction(); SQLite stricter; dedicated runDestructiveStateReplacement interrupt specs (#7709) |
@capacitor-community/sqlite web fallback = WASM-on-IDB |
Medium | Native-only binding; never use SQLite backend on web/PWA |
| Migration data loss/corruption | High | Verify-before-mark; retain IDB copy ≥1 release; reuse pf→SUP_OPS pattern |
| Plugin/native build complexity | Medium | Standard Capacitor plugin; CI for both platforms |
seq autoinc + byId unique parity |
Medium | Schema-level AUTOINCREMENT + UNIQUE; explicit parity specs |
6. Effort
- Phase A is the bulk (multi-week; touches the most correctness-sensitive subsystem). This cost exists regardless of SQLite.
- Phase B is comparatively small once A exists — the "just another adapter" part.
- Phase C moderate, pattern-matched to existing code.
- Phase D optional.
Because Phase A takes months of careful work, pair this with the cheap interim
mitigations (diagnostic logging of persist() result on native; native
filesystem auto-backup) so users are protected in the meantime.