* test(op-log): validate SqliteOpLogAdapter against a real sql.js engine
The 23 adapter specs ran only against an in-memory regex stand-in that
models the SQL shapes the adapter emits — it validates the translation
layer, not SQLite itself. Add sql.js (dev-only; never in the app bundle)
served into Karma, and run the behavioral contract against BOTH the fake
and a real SQLite engine.
This exercises genuine-engine behavior the stand-in could only model:
the real UNIQUE-constraint message -> ConstraintError mapping,
AUTOINCREMENT never reusing seq after clear(), compound-index + NULL
range handling, and real BEGIN IMMEDIATE rollback. 51/51 green.
B2 (translation-layer pass) per docs/sync-and-op-log/sqlite-migration.md.
The integration-harness second pass and the on-device real-engine run
remain.
* test(op-log): run store-port integration against real sql.js (B2 stage 2)
Parameterize the RemoteOperationApplyStorePort integration scenarios to
run against BOTH the default IndexedDB backend and a sql.js-backed
SqliteOpLogAdapter, exercising the store's COMPOSED flows (apply/mark/
merge-clock, partial-failure persistence, full-state import clearing,
vector-clock persistence) on a real SQL engine — not just the adapter in
isolation. 6/6 green.
Surfaced a real B3 wiring gap: OperationLogStoreService.init() is
IDB-shaped (opens+adopts an IndexedDB connection, never calls the
adapter's own init()). For a self-managing backend like SQLite the
tables would not exist. The sql.js setup creates them once on the shared
db to mirror the store-init change B3 must make on native (call
adapter.init() / skip the IDB open when the backend is SQLite).
* feat(op-log): add verified IDB->SQLite backend migration (C1)
One-time copy of the entire op-log from a source adapter (legacy
IndexedDB) to a dest adapter (SQLite) in a single dest transaction with
verify-before-commit: a mismatch in op count, last seq, or vector clock
throws and rolls the dest back, leaving it empty and the source
untouched.
Adapter-agnostic (talks only to the OpLogDbAdapter port), so it is
validated in CI with a real Chrome IndexedDB source + a sql.js SQLite
dest; the native @capacitor-community/sqlite dest behaves identically
through the same port. The generic iterate->put copy preserves ops seq
(incl. gaps) via the put-honors-seq path and writes singletons at their
out-of-line key uniformly, with no per-store special-casing.
Not wired into startup — Phase B3/C2 decide WHEN to run it (SQLite empty
+ legacy SUP_OPS present) and retain the IDB copy >= 1 release. 5/5
green, incl. seq-fidelity, AUTOINCREMENT-continues-past-migrated,
empty-source, non-empty-dest guard, and verify-rollback.
* docs(sync): record sql.js validation, C1, and the B1/B3 findings
Update the SQLite migration plan + follow-up backlog to reflect what
landed this pass and hand off the device-gated remainder:
- B2: real-engine (sql.js) adapter contract + store-port second pass are
done in CI; only the on-device run remains.
- C1: the backend-migration algorithm + verify-before-commit are done
and tested (real IDB -> sql.js); only the startup wiring remains.
- B3 finding: OperationLogStoreService.init() is IDB-shaped (opens+adopts
IDB, never calls adapter.init()); native must call adapter.init() and
skip the IDB open on SQLite.
- B1 perf note: bridge round-trips dominate on native; return lastId from
the plugin's run response and add a runBatch/executeSet bulk path so
appendBatch is one crossing, with RETURNING-seq for per-op seq.
* feat(op-log): make store init backend-aware for self-managing backends (B3)
OperationLogStoreService.init() and ArchiveStoreService._init() were
IDB-shaped: they unconditionally opened+adopted a WebView IndexedDB
connection and never called the adapter's own init(). For a self-managing
backend (SQLite) that meant (a) the adapter's tables were never created
and (b) it still touched the evictable WebView store this migration
exists to escape.
Now: when the adapter exposes no adoptConnection (i.e. it self-manages,
like SQLite), call adapter.init() and skip the IndexedDB open. The
adopt-connection (IndexedDB) path is unchanged. The new branch is dead in
production until B3 flips the native token, so this is risk-free now and
unblocks that flip.
Tested: two unit tests cover both branches (self-managing -> adapter.init,
no IDB open; IDB -> open+adopt, no adapter.init). The store-port
integration spec now drives the store fully on SQLite with _db undefined,
so its earlier pre-init workaround is removed. 521 persistence + 6
integration green.
* docs(sync): mark the B3 backend-aware init fix as landed
The store-init half of B3 (call adapter.init() / skip the IDB open for
self-managing backends) is implemented + CI-tested; only the device-gated
native token flip + SqliteDb wrapper remain.
* fix(android): escape JS bridge args via JSONObject.quote (#7925)
`loadFromDb` interpolated the stored value into a single-quoted JS string
literal passed to `evaluateJavascript`. Beyond the security smell, this is
a real data-loss bug: `JSON.stringify` does not escape apostrophes, so a
backup blob containing one (e.g. a task titled "don't…") terminated the JS
literal and the load returned garbage — silently corrupting any restore
from `KeyValStore`.
Use `JSONObject.quote()` (already established in the file for
`emitForegroundServiceStartFailed`) for all three callback args, so values
containing `'`, `\`, newlines or `</script>` round-trip cleanly.
* feat(startup): log storage-persistence outcome on all branches (#7925)
`_requestPersistence()` was silent on native and on the `false` resolution
of `persist()`, so #7892-style "woke up blank" reports carried no signal
about whether the WebView store was actually persistent.
Always log `{persisted, granted, isNative, isElectron}` — including the
already-persisted branch, the persist-resolved branch (both true and false),
the error branch, and the no-`navigator.storage` branch. User-facing snack
gating is unchanged (still web-only, non-onboarding). Logging-only — no
behavioral change.
This is Track A1 in `docs/sync-and-op-log/sqlite-migration-followup.md`:
the diagnostics that decide whether the deeper protective steps
(near-empty write guard, SQLite migration) are worth the added complexity.
* docs(sync): refresh sqlite-migration followup after #7924 (#7925)
The followup backlog described the local-backup ring as TODO under A2,
but #7924 already shipped the periodic + app-private backup, two-generation
ring, empty-state write guard, and informed restore prompt. Bring the doc
in sync:
- Add the shipped local-backup work to "Where we are now".
- Replace the old A2 ("Periodic local auto-backup") with the narrower
remaining gap: a debounced data-change backup trigger to complement the
5-min timer.
- Add A3 (near-empty write-time overwrite guard) with a concrete starting
threshold and a fail-safe rationale, sequenced after A1 so the
diagnostics tune the threshold before it lands.
- New "Cross-cutting / hardening" section consolidating the items
surfaced by the #7924 review (Kotlin JS-bridge escaping — now done;
backup-date reader bridge for the restore prompt; robust restore on
degraded boot; last-backup visibility; onboarding nudge for no-sync
users).
* feat(local-backup): debounced on-data-change backup trigger (#7925)
The 5-min `interval()` was the only thing that drove `LocalBackupService._backup()`,
so a destructive event in the minutes before a WebView eviction could be lost
from the backup ring even though the live store had it.
Merge a `LOCAL_ACTIONS`-driven trigger into `_triggerBackupSave$` that fires
once after a 30s quiet period. Catches the typical "user made changes then
put phone down" pattern before the next periodic tick. `LOCAL_ACTIONS`
already filters out remote/hydration replays, and the existing empty-state
guard prevents writing a degraded post-eviction snapshot over a good
backup, so this strictly adds backup frequency — never spam.
Logic-level only — `_backup()` continues to early-return on non-target
platforms (web/PWA), so the trigger is safe to subscribe everywhere.
Closes A2 (remaining gap) in
`docs/sync-and-op-log/sqlite-migration-followup.md`.
* docs(sync): mark A1 + A2 shipped in sqlite-migration followup (#7925)
A1 (storage-persistence diagnostics) and A2 (debounced data-change backup
trigger) both landed this round. Update the suggested order and the A2
section so the doc accurately reflects what's left in Track A (just A3,
the near-empty write-time overwrite guard).
* feat(local-backup): near-empty write-time overwrite guard (A3, #7925)
The exact-empty guard in `_backup()` only catches a fully-degraded store.
The residual gap is a post-eviction boot that leaves the store near-empty,
the user adds 1-2 tasks before the 5-min timer fires, and the degraded
state then overwrites the good primary slot. The prev slot is still safe,
but the informed restore prompt only fires on a wholly fresh launch — so
without this guard the user has lost direct access to the better backup
until they uninstall/reinstall.
Add a per-platform near-empty guard in `_backupAndroid` / `_backupIOS`:
read the existing primary, compare task counts (active + young-archived +
old-archived via the new shared `countAllTasks` helper), and bail when a
< 3-task snapshot would clobber a >= 10-task existing backup. Electron is
unchanged — its rotated, timestamped backup chain isn't a single-slot
overwrite.
Threshold rationale: `summarizeBackupStr` counts archived tasks too, so
"near-empty" means the same thing on the read side (the restore prompt)
and the write side (this guard). Fail-safe — skipping a write only delays
capturing a real wipe, never loses data; the guard self-clears once the
store grows back past 3 tasks, so a legitimate bulk-delete is captured
on the next tick.
Marks Track A (#7925 / sqlite-migration-followup.md) complete.
* fix(android): JSONObject.quote() the sibling JS bridge callbacks (#7925)
`saveToDbCallback` / `removeFromDbCallback` / `clearDbCallback` still raw-
interpolated `requestId` into single-quoted JS string literals. The args are
nanoid strings today so it works — but only by caller hygiene. Mirror the
`loadFromDb` fix: quote all three so the bridge contract no longer depends
on what the caller happens to pass.
Compress the `loadFromDb` rationale comment in the process — the file-level
intent now lives on one line near the cluster.
* refactor(local-backup, startup): trim Track A code per multi-agent review
Two independent reviewers flagged the same set of cleanups on the Track A
commits (#7925). Applying the high-confidence ones:
- Drop `_escapeAndroidNewlines` + its two call sites. The Kotlin bridge fix
(#7925, 663d747b4) now JSON-escapes newlines on the way out, so the JS-side
workaround replaces nothing on any post-fix write. Removes the awkward
"raw vs escaped" split in `_backupAndroid`.
- Hoist the A3 skip-and-log block into one private `_guardNearEmptyOverwrite`
helper so the warn template can't drift between Android and iOS. Keep
`_isNearEmptyOverwrite` as the pure predicate (the spec pins it).
- Tighten the A2 comment: `bulkApplyOperations` / `loadAllData` actually do
transit `LOCAL_ACTIONS` (they aren't tagged `meta.isRemote`). Behaviour
is still correct because the empty-state + A3 guards handle degraded data,
but the previous comment overstated upstream filtering.
- Cut the multi-paragraph comment blocks around `DATA_CHANGE_BACKUP_DEBOUNCE`,
the A3 constants, `_isNearEmptyOverwrite`, and the `_backup()` guard. Keep
the issue refs; drop the prose that paraphrased the next line of code.
- Inline the `context` object spread in `_requestPersistence` — three log
calls on adjacent lines didn't need a hoisted bag of fields.
No behavioural change. 21/21 local-backup specs + 18/18 startup specs green.
* docs(sync): add SQLite migration plan + Phase A adapter port skeleton
Documents the op-log persistence migration off WebView IndexedDB into
app-private SQLite on native (Capacitor), addressing the data-loss class
where Android can evict WebView storage when no sync is configured.
Phase A skeleton (no behavior change, not yet wired in):
- OpLogDbAdapter / OpLogTx: backend-agnostic persistence port with a
callback-based transaction() as the atomicity seam both IndexedDB and
SQLite map onto.
- OP_LOG_DB_SCHEMA: declarative SUP_OPS schema descriptor (mirrors
db-upgrade.ts v6) that both backends can consume.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* feat(op-log): add IndexedDbOpLogAdapter implementing the persistence port
Phase A continuation of the SQLite migration (docs/sync-and-op-log/
sqlite-migration.md). Implements the IndexedDB backend behind the
OpLogDbAdapter port: open-retry with the existing budgets, versionchange/
close re-open handling, IndexedDBOpenError wrapping, index/range queries,
and a callback-based transaction() that commits on resolve and aborts on
throw — the atomicity seam both backends share.
Extends the port with cursor-style iterate() (continue/stop/delete/
delete-stop) to cover the latest-entry lookups and predicate pruning the
store does today, plus a close() teardown hook.
Spec exercises CRUD, the unique byId index, range queries, cursor
direction/stop/delete, and — critically — multi-store transaction commit
and rollback against fake-indexeddb. 10/10 pass.
Not yet wired into OperationLogStoreService; additive scaffolding only.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): harden persistence port after multi-agent review
Addresses blocking fidelity gaps found reviewing the adapter against the
real store's usage, so the upcoming store refactor can be behavior-
preserving:
- iterate() visitor is now synchronous and receives the primary key.
An async visitor could await real I/O mid-cursor, letting the IDB
transaction auto-commit and the next continue() throw
TransactionInactiveError. Synchronous-only also lets a buffered SQLite
backend honor it without materializing the whole result set.
- DbIterateOptions.query positions an index cursor at an exact key
(clearFullStateOpsExcept's keyed delete).
- getAll()/count() take an optional primary-key range (getOpsAfterSeq and
the getUnsynced/getAppliedOpIds incremental caches use
getAll(OPS, lowerBound(seq))).
- getKeyFromIndex() for cheap existence probes
(appendBatchSkipDuplicates' getKey, avoids deserializing the value).
Tests expanded 10 -> 26: destructive clear()+delete() rollback, abort on
inner-op rejection, transactional reads/index/cursor, readonly mode,
keyed index iteration, compound-index match, getAll/count ranges, the
close() re-open cliff, and the open-retry budgets via the _openDbOnce
seam.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route import-backup methods through the persistence adapter
First method group of the Phase A store migration. Adds an
adoptConnection() seam so IndexedDbOpLogAdapter operates on the store's
existing connection rather than opening a second one to SUP_OPS (avoiding
versionchange deadlocks and doubled close/upgrade handling during the
transition). The store adopts/releases the connection alongside its own
_db in init()/close/versionchange.
saveImportBackup / loadImportBackup / clearImportBackup / hasImportBackup
now go through the adapter. Behavior is identical — same connection, same
store, same keys.
Verified: 170 store unit specs, 26 adapter specs, 3 archive specs, and
the import-sync integration spec all green.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route state_cache + compaction methods through the adapter
Second method group of the Phase A store migration. saveStateCache,
loadStateCache, the migration-safety backup methods (save/load/clear/has/
restore), and the compaction counter (get/increment/reset) now go through
the shared adapter. The two atomic read-modify-write methods
(incrementCompactionCounter, resetCompactionCounter) use the adapter's
callback transaction(), preserving their single-transaction semantics.
Introduces a StateCacheEntry type; `id` is optional so the read-side
return types stay assignable from the looser snapshot shapes callers
construct (the pre-migration return types didn't surface `id`).
Verified: 170 store unit, 53 compaction unit, 27 vector-clock, 20
compaction integration specs all green; full tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* docs(sync): track Phase A migration progress in sqlite-migration.md
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route ops-table append + markApplied through the adapter
Third method group of the Phase A store migration — the higher-risk
write path. append, appendBatch, appendBatchSkipDuplicates and markApplied
now go through the shared adapter. The batch methods use the adapter's
callback transaction() (one atomic unit, same as before); the TOCTOU-free
duplicate guard uses tx.getKeyFromIndex (the byId unique index probe,
issue #6343). ConstraintError->DUPLICATE and QuotaExceededError->
StorageQuotaExceededError mappings are preserved — the adapter rethrows
the original DOMException so the store's catch blocks still fire.
Verified: 170 store unit + 367 op-log integration specs green; tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route ops-table reads + full-state clears through adapter
Fourth method group. getPendingRemoteOps (compound-index match expressed
as a degenerate [k,k] range, with the pre-v3 fallback scan preserved),
hasOp, getOpById, getOpsAfterSeq (primary-key range), the two reverse-
cursor latest-full-state lookups, and clearFullStateOps/
clearFullStateOpsExcept now go through the adapter's iterate()/getAll()/
getAllFromIndex(). The keyed-index-cursor delete is factored into a
_deleteOpsByIds() helper using iterate({index, query}) + delete-stop in a
single atomic transaction, matching the prior behavior (no-op + no cache
invalidation on empty list).
Verified: 170 store unit + 367 op-log integration specs green; tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route unsynced/applied caches + mark methods through adapter
Fifth method group. The getUnsynced/getAppliedOpIds incremental cache
builds (getAll with a primary-key range), getFailedRemoteOps (compound
index), markSynced/markRejected/clearUnsyncedOps/markFailed (transactional
get+put loops), deleteOpsWhere (predicate cursor delete) and getLastSeq
(reverse cursor reading the primary key via the iterate visitor's key arg)
now go through the adapter. markFailed keeps its original behavior of NOT
invalidating the unsynced cache.
Verified: 170 store unit + 367 op-log integration specs green; tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route remaining store methods through adapter
Final OperationLogStoreService group — every method now goes through the
persistence adapter; no direct `this.db` calls remain. Covers hasSyncedOps
(bySyncedAt index cursor), clearAllOperations, _clearAllDataForTesting
(multi-store clear in one transaction), the vector-clock accessors, and
the two flagship atomic flows:
- appendWithVectorClockUpdate (OPS + VECTOR_CLOCK in one transaction)
- runDestructiveStateReplacement (OPS + STATE_CACHE + VECTOR_CLOCK +
CLIENT_ID + archive). The hand-rolled try/abort is replaced by the
adapter's commit-on-resolve / abort-on-throw transaction(); success-only
cache + clientId-cache invalidation now runs after the resolved
transaction. The #7709 interrupt atomicity tests still pass — the
adapter operates on the same adopted connection the tests spy on, so a
poisoned opsStore.add still aborts and unwinds the queued clientId
rotation.
Verified: 170 store unit + 367 op-log integration specs (incl. the 3
clean-slate-interrupt atomicity tests) green; tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(op-log): route ArchiveStoreService through the persistence adapter
Completes the Phase A store/archive migration. ArchiveStoreService gets
its own IndexedDbOpLogAdapter that adopts its independent SUP_OPS
connection (released on close/versionchange and on the iOS
connection-closing retry path in _withRetryOnClose). All six accessors
plus saveArchivesAtomic/_clearAllDataForTesting now go through the
adapter; the dead `db` getter and its unused error constant are removed.
No direct `this.db` calls remain in either persistence service.
Verified: 3 archive unit + 170 store unit + 367 op-log integration specs
green; tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* refactor(sync): fix readonly cursor regression + multi-review findings
Multi-agent review of the Phase A op-log adapter (post full store/cursor
migration). Addresses one live regression plus hardening; no functional
behavior change.
W1 (live regression fix): the migrated read-only cursor methods —
getLastSeq, hasSyncedOps, getLatestFullStateOp(Entry) — ran through
iterate(), which always opened a 'readwrite' transaction, so pure reads on
the hot ops store took an exclusive write lock and serialized against
appends (pre-migration they were 'readonly'). Add `mode` to DbIterateOptions
(default 'readwrite' so delete-walks keep working) and pass `mode:'readonly'`
from those four readers; clearFullStateOps* delete-walks stay readwrite.
W2: op-log-db-schema reuses DB_NAME/DB_VERSION from db-keys.const instead of
re-literaling 'SUP_OPS'/6 (no third source of truth), and a new
op-log-db-schema.spec.ts asserts the descriptor matches both DB_VERSION and
the stores/indexes runDbUpgrade actually creates (the contract Phase B builds
on).
W3: test the adoptConnection seam (both branches) — ops route onto an adopted
external connection, and adoptConnection(undefined) returns to the
not-initialized cliff (the store's close/versionchange path).
W4: convert the two open-retry specs from real ~8s backoff sleeps to
fakeAsync + tick (adapter spec ~0.04s vs ~8s) and assert exact attempt
budgets; add a full-lock-budget case.
Gates: adapter 30 + schema 2 + store 170 unit, and 59 op-log integration
specs (race-conditions, multi-entity-atomicity, compaction, server-migration,
clean-slate-interrupt, indexeddb-error-recovery) green; checkFile clean.
* refactor(op-log): inject the persistence adapter via DI token
Phase B step 1. Both persistence services now obtain their OpLogDbAdapter
from OP_LOG_DB_ADAPTER_FACTORY instead of constructing IndexedDbOpLogAdapter
directly. The token vends a factory (not a singleton) because each service
adopts its own connection into its own adapter instance. Defaults to
IndexedDB on all platforms; Phase B step 2 will override it to return a
SqliteOpLogAdapter when running native, with the stores untouched.
adoptConnection() becomes an optional bridge method on the OpLogDbAdapter
interface — documented as IDB-transition-only; a self-managing backend
(SQLite) leaves it undefined and callers guard with `?.()`.
Verified: 170 store unit + 3 archive unit + 367 op-log integration specs
green; tsc clean.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* feat(op-log): add SqliteOpLogAdapter skeleton (Phase B, no native dep)
Dependency-free skeleton of the SQLite backend behind the OpLogDbAdapter
port. Solves the hard schema-mapping question the reviewers flagged without
pulling in a native plugin or anything untestable in CI:
- planTables()/buildDdl(): derive the physical SQL layout from the shared
OP_LOG_DB_SCHEMA. Each store -> a table with a JSON `value` column plus
one extracted column per IDB index. ops gets `seq INTEGER PRIMARY KEY
AUTOINCREMENT` (monotonic, never-reused — matches IDB + getLastSeq),
`op_id TEXT UNIQUE` (byId), `synced_at` (bySyncedAt) and a composite
(source, application_status) index. keyPath stores -> TEXT PK from the
keyPath; keyless singletons -> caller-supplied TEXT key.
- A minimal SqliteDb port (run/query) the adapter talks to instead of
importing @capacitor-community/sqlite, so this file has no native
dependency and is unit-testable with a fake.
- init() applies the DDL (idempotent); query/tx methods throw a loud
not-implemented error (fail loudly rather than silently lose data) with
the intended SQL documented per method. adoptConnection is intentionally
absent — SQLite self-manages its handle.
12 specs cover the plan/DDL derivation and that init() emits the expected
DDL. Doc updated with status + the deferred native-dependency decision.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* feat(op-log): fully implement SqliteOpLogAdapter (still no native dep)
Completes the SQLite backend behind the OpLogDbAdapter port. All query,
index, range, count, cursor-iterate and transaction methods are now
implemented against the minimal SqliteDb port:
- value→column extraction: each store row stores the JSON object in a
`value` column plus extracted columns for the indexed paths
(op_id/synced_at/source/application_status); writes populate them.
- transactions map to BEGIN IMMEDIATE/COMMIT/ROLLBACK with rollback-on-
throw; readonly iterate/transaction use no write lock.
- SQLite errors map to the SAME DOMException names the store's existing
catch blocks expect: UNIQUE→ConstraintError (→DUPLICATE_OPERATION_ERROR),
disk-full→QuotaExceededError (→StorageQuotaExceededError).
- ops uses AUTOINCREMENT so seq is monotonic and never reused across
clear() — matching IDB + getLastSeq.
Still imports no native plugin: a thin wrapper over
@capacitor-community/sqlite's SQLiteDBConnection will satisfy SqliteDb on
device. 23 specs validate the translation layer + transaction semantics
(commit/rollback/abort-on-unique) against an in-memory SQLite stand-in;
a real-engine on-device run is the remaining Phase B step (documented).
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* docs(sync): add SQLite migration follow-up backlog
Actionable, ordered backlog companion to sqlite-migration.md:
- Track A: ship the #7892 safeguards now (persist() diagnostics +
native filesystem auto-backup) — independent of SQLite, recommended
near-term fix.
- Track B: finish the native SQLite backend (plugin + SqliteDb wrapper,
real-engine validation, DI flip behind a flag).
- Track C: one-time IDB→SQLite data migration, staged rollout.
- Track D: cleanup once SQLite is the native default.
https://claude.ai/code/session_011wcqZgubKqoT6wxt1L1KBT
* fix(op-log): scan full-state ops read-only to drop the write lock
clearFullStateOps / clearFullStateOpsExcept iterate the ops store only to
collect ids (the delete runs in a separate transaction), but the migrated
iterate() defaulted to 'readwrite' — so these pure-read scans took an
exclusive write lock on the hot ops store and serialized against appends.
Pre-adapter (master) these scans used a readonly cursor. Pass
mode:'readonly' to restore parity. Same regression class the earlier W1
fix addressed for getLastSeq/hasSyncedOps/getLatestFullStateOp(Entry);
these two scans were missed because they are no longer delete-walks.
Verified: 170 store unit + server-migration/import-sync/remote-apply/
vector-clock-import integration specs green; checkFile + tsc clean.
* fix(op-log): correct SQLite seq round-trip + enforce tx scope and readonly
Hardens the dormant SQLiteOpLogAdapter against three multi-review findings
(translation-layer only; the backend is still wired to nothing):
- C1 (data duplication): the autoinc `ops` PK (`seq`) lived only in its own
column, never the JSON value, and was never re-injected on read. So reads
returned seq===undefined and put() emitted INSERT…ON CONFLICT(seq) with no
seq bound — the conflict never fired and every mark*/clearUnsynced re-put
inserted a duplicate row. Now buildInsert binds seq when the value carries
one (re-put / explicit-seq add) and decodeRow injects the PK back from a
`__pk` alias on every read, matching IDB's keyPath+autoIncrement store.
ON CONFLICT no longer overwrites the PK column.
- W2 (atomicity scope): transaction() discarded its `stores` argument, so the
OpLogTx could touch any store — silently passing where IDB throws. The tx
now enforces the declared scope (and inherits the tx mode for iterate).
- W3 (readonly contract): a delete action under a readonly iterate executed
the DELETE outside any transaction; it now rejects with ReadOnlyError,
matching IDB.
Also makes the in-memory FakeSqliteDb faithfully model AUTOINCREMENT (honor
an explicit seq, upsert on PK conflict, advance the high-water mark) so the
spec actually catches C1-class bugs — verified: reverting the seq fix makes
the new "updates in place" test fail with the real UNIQUE violation.
Verified: 27 SQLite adapter specs (4 new) green; checkFile + tsc clean.
* refactor(op-log): strip the autoinc keyPath prefix via extractPath idiom
Follow-up review nit: decodeRow stripped the `$.` from the autoinc keyPath
with slice(2); use the same `.replace(/^\$\./, '')` idiom as extractPath for
consistency, and note that the autoinc keyPath is a top-level field. No
behavior change (keyJsonPath is always `$.seq` for the only autoinc store).
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix(android): restore share title derivation and dedupe shared tasks
Commit d32f7037a3 accidentally reverted the EXTRA_SUBJECT handling from
edb102534e, so the share handler stopped sending the page subject and
defaulted the title to the literal "Shared Content". The frontend's
subject -> title -> derived title chain then always fell through to that
placeholder, producing blank-looking shared tasks.
- Restore EXTRA_SUBJECT extraction; leave title/subject empty when absent
so the frontend can derive a meaningful title from the URL or note.
- Ignore empty/blank shared text instead of creating a useless task.
- Skip handleIntent() on Activity recreation (config change) so the same
share Intent isn't re-processed into a duplicate task.
- Extract buildTaskTitle/readableUrl as pure functions with unit tests
and guard the effect against empty payloads.
* fix(snack): scale error/warning snack duration with message length
Long error messages (e.g. multi-sentence sync errors) were auto-dismissed
after a fixed 8s, too short to read. Error/warning snacks now stay visible
proportional to message length (~90ms/char), clamped to 10-30s.
* feat(tasks): skip undo snack when deleting a blank task
A sub task or parent task with an empty title and no data (notes, time,
estimate, attachments, issue link, reminder, repeat, scheduling,
deadline, non-blank sub tasks) no longer shows the undo-delete snack.
* fix(sync): include archive data in REPAIR operations
validateAndRepairCurrentState built the REPAIR op from the synchronous
getStateSnapshot(), which hardcodes empty archiveYoung/archiveOld
(archives live in IndexedDB, not NgRx state). The resulting REPAIR op
carried empty archives, so every other client that applied it
overwrote its archive with nothing — wiping archived tasks on all
devices except the one that ran the repair.
- Use getStateSnapshotAsync() so the REPAIR op carries real archives.
- Extend the empty-archive overwrite guard in
ArchiveOperationHandler._handleLoadAllData() to also cover OpType.Repair
(previously only SYNC_IMPORT/BACKUP_IMPORT), as defense in depth.
* test(sync): add archive REPAIR round-trip integration test
Wires the real StateSnapshotService, ArchiveDbAdapter, ArchiveStoreService
and ArchiveOperationHandler against real IndexedDB to verify archive data
survives the REPAIR-op round-trip:
- getStateSnapshotAsync() loads IndexedDB archives; getStateSnapshot() does not
- archive round-trips from client A's IndexedDB through a REPAIR op into a
fresh client B's IndexedDB
- a REPAIR op carrying empty archives no longer wipes a client that has
archive data (empty-archive guard regression)
* perf(sync): skip archive IndexedDB reads when post-sync state is valid
validateAndRepairCurrentState validated the full async snapshot (two
IndexedDB archive reads + structured-clone deserialization) on every
Checkpoint D, even when state was valid and no repair was needed. It now
validates the cheap synchronous snapshot first and only loads the async
snapshot (with archives) when a repair is actually required — the rare
path. The REPAIR op still carries archive data.
Also addresses multi-review follow-ups:
- archive-operation-handler: reword the empty-archive guard comment so it
no longer over-promises reconciliation for REPAIR ops.
- archive-repair-roundtrip test: add isPersistent to the applied-op meta
to match the real applier; scope the file docstring accurately.
* fix(task-repeat-cfg): schedule inbox task for today when made recurring
When an Inbox task (no dueDay) was made repeatable via the dialog with a
recurrence starting today, it stayed unscheduled. The TODAY-first-occurrence
branch of updateTaskAfterMakingItRepeatable$ derived currentDueDay from
task.created as a fallback, so a task created today looked already scheduled
and dueDay was never set.
Key the decision on task.dueDay directly. Skip timed tasks and tasks that
already have dueWithTime, since dueDay/dueWithTime are mutually exclusive and
timed scheduling is handled by addRepeatCfgToTaskUpdateTask$.
Closes#7725
* fix(tasks): correct monthly first/last-day recurrence anchoring
The "Every month on the first day" and "Every month on the last day"
quick settings scheduled the first task instance in the past. Both
presets produced a backdated startDate (1st of the current month;
hardcoded January 31), which getFirstRepeatOccurrence returns verbatim
for monthly recurrences.
- MONTHLY_FIRST_DAY now anchors startDate to the next 1st-of-month
that is today or later.
- MONTHLY_LAST_DAY anchors startDate to the current month's last day
and sets a new monthlyLastDay flag, so the occurrence engine clamps
to month-end every month regardless of startDate's day-of-month.
- _normalizeMonthlyAnchor strips a stale monthlyLastDay flag when a
config leaves the preset (CUSTOM mode has no control for it).
Closes#7726
* fix(task-repeat-cfg): re-anchor start date after instance deleted
When the user moved a repeat config's startDate earlier after deleting
its only live task instance, the stale lastTaskCreationDay anchor kept
suppressing every projected/created instance between the new startDate
and the old anchor.
rescheduleTaskOnRepeatCfgUpdate$ only re-anchored lastTaskCreationDay
when a live task instance existed (the #7423 fix) — it returned early
before the re-anchoring when there was none. Hoist the
isStartDateMovedEarlier detection above that early return: when no live
instance exists but startDate moved earlier, re-anchor to the day
before the new first occurrence so it and every following day is
created and projected fresh.
Closes#7724
* test(task-repeat-cfg): cover startDate re-anchor with no live instance
Add coverage for the #7724 fix beyond the effect unit test:
- Selector integration tests: feed a config re-anchored to the day
before the new startDate through selectTaskRepeatCfgsForExactDay and
assert it projects the new startDate and every following day, while
still excluding the anchor day and earlier. Also documents that the
stale anchor suppresses the gap days.
- E2E reproduction (recurring-move-start-date-earlier-no-instance):
create a recurring task, delete its live instance, move startDate
earlier via a transparent projection, and assert the new days appear
in the planner. Verified to fail on pre-fix code.
* feat(sync): move clientId from pf into SUP_OPS for atomic rotation
Migrate the sync clientId out of the legacy `pf` IndexedDB database into
`SUP_OPS` (new `client_id` store, schema v6). The clientId write now joins
the atomic transaction in `runDestructiveStateReplacement`, so destructive
flows (clean-slate, backup-restore) rotate it atomically with
OPS/STATE_CACHE/VECTOR_CLOCK instead of a hand-rolled cross-database
two-phase commit.
- ClientIdService rewritten: SUP_OPS-backed via an independent connection,
inline one-time pf->SUP_OPS migration, error-aware resolver. Read
failures propagate (getOrGenerateClientId never mints a fresh id over a
transient error — that would orphan the device's non-regenerable
identity); loadClientId never throws.
- Delete withRotation, generateNewClientId and the CAS/rollback machinery.
- Extract pure generateClientId() + isValidClientIdFormat() into
core/util/generate-client-id.ts.
- pf becomes a read-only, one-time migration source (never written/deleted).
Closes#7732
* fix(sync): dedup SUP_OPS connection open in ClientIdService
Address multi-agent review findings on the clientId migration:
- _getSupOpsDb() shares a single in-flight open via _supOpsDbPromise;
concurrent cold-start callers previously each opened their own
SUP_OPS connection, leaking all but the last.
- _putClientIdIfAbsent() collapsed to a single tx.done / exit point.
- db-upgrade.spec.ts: cover the v6 client_id store; the createObjectStore
count assertions were stale and failing after the schema bump.
- operation-log-migration.service.ts: correct a misleading comment about
the genesis-op clientId fallback.
* refactor(sync): align ClientIdService SUP_OPS open with in-house idiom
Re-review of the connection-leak fix recommended matching
OperationLogStoreService._ensureInit's pattern:
- _getSupOpsDb() clears the in-flight promise in .catch (failure only)
instead of an unconditional finally — the resolved handle lives in
_supOpsDb, so the promise field is pure in-flight coordination state.
- close/versionchange handlers now also null _supOpsDbPromise, so a
stale (closed) connection is never re-handed-out.
- Add a regression test asserting _openSupOpsDb runs exactly once for
concurrent cold-start callers.
* docs(sync): link the single-connection follow-up to #7735
Reference the tracked follow-up issue from the ClientIdService JSDoc
and the plan's out-of-scope section, so the deliberate trade-off (one
extra SUP_OPS connection) is traceable rather than forgotten.
* test(sync): update ClientIdService spies for getOrGenerateClientId
The op-log capture effect now resolves the clientId via
getOrGenerateClientId() (was loadClientId() ?? generateNewClientId()).
These two specs still mocked only loadClientId, so the effect called an
undefined method, captured no op, and 4 tests failed in the full suite.
- task-done-replay.integration.spec.ts
- operation-log-lock-reentry.regression.spec.ts
* test(sync): open SUP_OPS versionless in e2e read helpers
DB_VERSION was bumped 5->6; five e2e helpers still opened SUP_OPS at
the hardcoded old version 5 to read state after the app had already
upgraded it to v6, throwing VersionError. Open versionless instead —
matches the ~10 other e2e files that already do, and is future-proof
against the next schema bump.
- migration/legacy-data-migration.spec.ts (x2)
- sync/supersync-legacy-migration-sync.spec.ts
- sync/webdav-legacy-migration-sync.spec.ts
- recurring/invalid-clock-string-bug-7067.spec.ts
Collapse the sprawling, partly-stale docs/sync-and-op-log/ tree into a
small authoritative set and make the sync-correctness invariant
partly lint-enforced instead of convention-only.
Docs:
- Delete superseded/duplicate/provably-stale design, plan, and
background-research docs (quick-reference, the architecture-diagrams
monolith, the "Hybrid Manifest" docs describing code that does not
exist, completed long-term plans, LLM-synthesis analyses).
- Salvage load-bearing decision history into the surviving docs before
deletion: rejected-alternatives rationale -> operation-log-architecture
("Why this architecture"); vector-clock pruning incident history ->
vector-clocks.md; archive-payload optimization -> architecture E.7.
- Add contributor-sync-model.md as the single-invariant entry point
(one user intent = one op; replayed/remote ops must not re-trigger
effects), with a decision table mapping to the enforcing linters.
- Repoint external/internal cross-refs; add CONTRIBUTING.md + CLAUDE.md
pointers; record the migration in a dated docs/plans/ design doc.
Enforcement (new eslint-local-rules):
- no-actions-in-effects (error): effects must inject LOCAL_ACTIONS /
ALL_ACTIONS, never the raw @ngrx/effects Actions stream.
- no-multi-entity-effect (warn, heuristic): flags a literal returned
array of >=2 action-creator calls; docstring + valid-case specs pin
exactly which shapes are and are not detected.
- run-specs.js runner wired into `npm run lint` via test:lint-rules;
refuses to run under test-framework globals and counts RuleTester.run
invocations so a spec that asserts nothing fails instead of passing.
- Correct the ALL_ACTIONS JSDoc in local-actions.token.ts to match
reality (archive-operation-handler uses LOCAL_ACTIONS).
Reviewed via parallel multi-agent review; findings W1/W2/W4 and a
dangling doc anchor addressed.
* refactor(sync): tighten extracted package surfaces
Combined polish from the post-extraction review:
- sync-core: strip NgRx-shaped types from EntityConfig/EntityRegistry;
expose host extensions via generic param. Move StateSelector,
PropsStateSelector, SelectByIdFactory, SelectById, EntityUpdateLike,
EntityAdapterLike to a new app-side entity-registry-host.types.ts.
- sync-core: mark OpType.SyncImport/BackupImport/Repair as @deprecated;
hosts should use createFullStateOpTypeHelpers().
- sync-providers: resolve provider.types.ts vs provider-types.ts
duplication; inline implementation into the dashed canonical name.
- sync-providers: drop unused root barrel and "." export; consumers
already use focused subpath barrels (/dropbox, /webdav, etc.).
- sync-providers: replace wildcard "@sp/sync-providers/*" tsconfig path
alias with 11 explicit subpath entries matching package.json exports;
deep-internal imports now fail at typecheck.
- sync-providers: move @sp/sync-core from dependencies to
peerDependencies (kept in devDependencies for tests).
- both packages: add composite: true to enable project references;
introduce tsconfig.build.json overlay so tsup DTS bundler still works.
- gitignore: ignore **/*.tsbuildinfo composite outputs.
* refactor(sync-core): prune 47 unused barrel exports
Removes exports with zero consumers outside the package. Source files
are unchanged; only the public barrel is trimmed. Covers compression
helper classes, sync-file-prefix error/config types, replay coordinator
internals, remote-apply result types, upload/download planning option
and plan types, ports misc, conflict-resolution helper types, and
sync-import-filter decision types.
* refactor(sync-core): drop unused encryption migration path
decryptWithMigration and DecryptResult had no host consumer; they
exposed a structural-migration entry point ("here is your ciphertext
re-encrypted under Argon2id") that nothing in the codebase reads. The
side-channel setLegacyKdfWarningHandler — which IS used — stays.
encryptWithDerivedKey/decryptWithDerivedKey lose their export keyword
and remain as module-internal helpers; encrypt/decrypt/encryptBatch/
decryptBatch still call them. Wire format and legacy-fallback semantics
are unchanged, so existing ciphertext continues to decrypt.
Test imports for compression and sync-file-prefix specs now go via
their source files instead of the trimmed barrel.
* fix(sync-providers): bound dropbox token refresh to single retry; share md5 rev helper
The five hand-rolled token-refresh blocks in Dropbox.{getFileRev,
downloadFile, uploadFile, removeFile, listFiles} recursed on themselves
after refresh. If the post-refresh call still saw a token error (real
case: the refresh token itself was revoked), the recursion would not
terminate. Consolidated into a single _withTokenRefresh helper that
attempts the call, refreshes once on a token error, retries once, then
lets the outer 401 classifier surface AuthFailSPError.
Same log message, same _isTokenError discriminator, same refresh call.
Same five sites still apply their post-call non-token error mapping
(NoRev, InvalidData, RemoteFileNotFound, path-not-found swallow, etc.).
Also extracts md5 content-rev computation duplicated between
LocalFileSyncBase._getLocalRev and WebdavApi._computeContentHash into a
shared file-based/content-rev.ts; both call sites preserve their own
error wrapping at the boundary.
* refactor(sync): split oversized super-sync and conflict-resolution
sync-providers: extract request-ID hashing from super-sync.ts (1017 ->
918 lines) into a new request-id.ts. The helpers were free functions
already in disguise (none referenced this), so the move is mechanical.
HTTP plumbing (_doWebFetch/_doNativeFetch/_fetchApi*) stays as private
methods — it transitively touches 12 instance members and would need
either a wide context object or a separate http-client collaborator
class to extract cleanly. Left as a follow-up.
sync-core: split conflict-resolution.ts into three cohesive files:
- entity-frontier.ts now owns buildEntityFrontier and
adjustForClockCorruption (per-entity vector-clock domain).
- extractEntityFromPayload and extractUpdateChanges move to
operation.types.ts next to the existing extractActionPayload.
- conflict-resolution.ts keeps deep-equality, LWW planning,
partitioning, and identical-conflict detection.
Public barrel exports unchanged; tests now import the moved symbols
from their new homes.
* refactor(sync-core): drop redundant OperationStorePort
OperationStorePort overlapped with RemoteOperationApplyStorePort on the
two state-transition methods (markSynced/markApplied,
markRejected/markFailed) and had zero non-structural consumers — the
only implementer was OperationLogStoreService, which already exposes
the three methods as its own public surface. Removing the port leaves
the service contract intact and removes the verb-pair confusion noted
in the post-extraction review.
Spec contract test still drives the same state transitions; only the
local typing of the test fixture changes from the deleted interface to
Pick<OperationLogStoreService, ...>.
* refactor(sync-providers): decouple SuperSync provider from SP-specific host
Two coupling leaks the package shouldn't carry:
1. SUPER_SYNC_DEFAULT_BASE_URL was an implicit fallback inside
SuperSyncProvider — an SP-specific URL baked into a "framework-
agnostic" package. Make defaultBaseUrl a required SuperSyncDeps
field; the host factory supplies the SP default. The constant stays
exported as a suggested default for hosts targeting the SP-hosted
server.
2. Consumers that wanted the WebSocket path had to do
`provider as unknown as SuperSyncProvider` to call
getWebSocketParams. Introduce SuperSyncWebSocketAccess interface +
isSuperSyncWebSocketAccess structural guard; SuperSyncProvider
implements it. sync-wrapper.service drops its cast in favor of the
guard.
super-sync-restore.service still casts to SuperSyncProvider for the
restore path — same pattern would solve it, but out of scope here.
* test(sync-providers): extract shared test helpers and prefer barrels
Adds tests/helpers/sync-logger.ts and tests/helpers/credential-store.ts
to centralize the noopLogger and CredentialStore mocks that were copy-
pasted across 8 spec files. createStatefulCredentialStore covers the
"load/upsert/clear with state" cases; createMockCredentialStore covers
bare vi.fn() ports. Spec sites that needed a unique mockResolvedValue
chain it after the helper, preserving behavior 1:1.
Also migrates 5 spec files from deep ../src/<file> paths to the
matching sub-barrel (../src/webdav, /http, /super-sync, /platform) for
symbols already exported there. No new barrel exports added — internal
types (WebDavHttpAdapter, WebdavApi, DropboxApi, etc.) stay on deep
paths because they are intentionally not part of the public surface.
super-sync.spec.ts keeps its own credential/logger mocks (special
__asPort wrapper and vi.spyOn against the live NOOP_SYNC_LOGGER) that
the generic helpers cannot reproduce without bloat.
* test(sync): pin vector-clock pruning, error-meta privacy, and sync-import edges
Fills three test gaps surfaced by the post-extraction review:
- vector-clock pruning correctness across clocks: 4 cases pinning that
pruning legitimately flips GREATER_THAN to CONCURRENT/LESS_THAN when
the dropped keys are still present in the comparison clock. This is
the documented behavior (compareVectorClocks is intentionally not
pruning-aware); the protocol handles flips server-side via the
rejected-ops retry loop. preserveClientIds case also covered.
- error-meta privacy boundary: 22 new cases covering urlPathOnly (strip
query/fragment/userinfo, preserve host+path+port, leave non-URLs
intact) and errorMeta (no leakage of headers, response bodies, OAuth
tokens, signed-URL params, user emails, or attached error fields).
Real negative assertions (.not.toContain), not shape checks.
- sync-import-filter edge cases: 8 cases covering empty clocks on
either side, op clock listing the import client at 0, same-client
with equal counter (pinning the strict-greater-than boundary), and
different-client knowledge above the import counter.
sync-core 195 -> 207 tests, sync-providers 319 -> 341 tests; no
production code changed.
* style(sync-core): format sync-file-prefix.spec import line
* fix(sync): address package review feedback
Adds a 2-retry / 1s+2s backoff loop to the browser/Electron SuperSync
request path, mirroring the existing native retry. A new typed
NetworkUnavailableSPError replaces the previous string-shape contract
between provider and wrapper: the provider throws the typed error from
both web-retry exhaustion and native-failure handler, and the wrapper
matches via instanceof to show a transient WARNING snackbar
(F.SYNC.S.NETWORK_ERROR) without flipping into a hard ERROR state.
Drops the wrapper-side regex classifier (and its defensive
^HTTP\s+\d{3}\b filter), the now-unused isTransientNetworkError alias,
and dedupes the predicate call inside the web-retry catch. The single
remaining call site for the broad regex (operation-log-upload) now
imports isRetryableUploadError directly under its honest name.
Tests cover the 1s/2s cadence, retry-then-success, retry-exhaustion via
the typed error, and negative paths (AbortError, HTTP-status 5xx,
AuthFailSPError must NOT retry). The error class is added to the
cross-module identity safety net.
Rename stale _hasMeaningfulLocalData() refs to _hasMeaningfulStoreData()
and remove the dead Encryption-only flowchart node — PASSWORD_CHANGED
SYNC_IMPORTs without pending ops now fall through the standard gate.
Inline _hasAnyMeaningfulData at its remaining caller (the snapshot/provider-
switch path) and rename _hasMeaningfulLocalData to _hasMeaningfulStoreData
for parallel naming with _hasMeaningfulPendingOps. Strengthen the silent-
accept piggyback test to assert kind === 'completed' rather than
\!== 'cancelled', and clarify the originating-device cross-reference in the
piggyback (D.6) doc and the IMPORT_CONFLICT diamond in the flowchart.
Receiving clients with only already-synced data (no unsynced pending
changes) used to see a conflict dialog when an incoming SYNC_IMPORT
arrived. If the user picked USE_LOCAL — a natural reaction to "your
data may be lost" — forceUploadLocalState() re-uploaded the pre-import
state as a new SYNC_IMPORT, rolling back the import (e.g. encryption
change) for every device.
The originating device already gates the SYNC_IMPORT behind a strong
warning (D_SERVER_MIGRATION_CONFIRM, b761efd8). The receiving-side
dialog is now scoped to the case where unsynced pending user changes
would actually be lost; already-synced store data is no longer treated
as a conflict.
Switches the gate from _hasAnyMeaningfulData (pending OR store) to
_hasMeaningfulPendingOps (pending only) in both the download and
piggyback paths. Drops the now-redundant isEncryptionOnlyChange
short-circuit — under the new gate, PASSWORD_CHANGED SYNC_IMPORTs
without pending ops fall through to silent acceptance for free.
- New unit tests for the silent-accept path on both code paths
- New e2e regression guard (supersync-import-conflict-dialog) — fails
if the gate ever reverts to including store contents
- supersync-scenarios.md D.1 / D.6 and the flowchart gate updated
- Remove outdated feature requests from .github/CONTRIBUTING.md (GitLab
support already exists) and add commit message format section
- Improve PR template with type-of-change checkboxes and checklist
- Update commit guideline links in README and CONTRIBUTING.md to
reference the project's own format instead of external angular.js docs
- Add "only edit en.json" rule to TRANSLATING.md and clarify workflow
- Update add-new-integration.md provider list to match codebase (add
Trello, ClickUp, Linear, Azure DevOps, Nextcloud Deck; note GitHub
plugin migration; fix type name to BuiltInIssueProviderKey)
- Add cross-references between mac certificate docs and remove 240-line
duplicate section from update-mac-certificates.md
- Clean up update-android-app.md (specify npm version args, collapse
deprecated workflow, translate German UI labels to English)
- Add context to howto-refresh-snap-credentials.md
- Fix fine-grained token note in github-access-token-instructions.md
- Fix absolute URL to relative path in gitlab-access-token-instructions.md
- Fix grammar in i18n-script-usage.md
- Add status headers to all 19 long-term plan files (Planned, Completed,
Archived with reason, Investigation Complete)
- Fix broken relative link in hybrid-manifest-architecture.md
- Delete supersync-scenarios-simplified.md (duplicate of
supersync-scenarios.md; known issues already covered there)
- Rename vector-clock-pruning-research.md to
vector-clock-history-and-alternatives.md for clarity
Parallel to the SuperSync flowchart, covering the file-based sync
decision tree: gap detection, snapshot hydration, rev-based upload
retry, and error handling. Verified against source code with matching
abstraction level to the SuperSync chart.
Correct the flowchart to match actual codebase behavior:
- Move fresh-client dialogs under the "has remote ops" branch (was incorrectly under "no remote ops")
- Split single password dialog into two distinct decrypt error dialogs (DecryptNoPasswordError vs DecryptError)
- Route SYNC_IMPORT conflicts to ImportConflictDialog (was incorrectly using SyncConflictDialog)
- Add encryption-only change bypass for password-change SYNC_IMPORTs
- Add LWW tie-breaking details (remote wins on tie, archive ops always win)
- Add retry limit note on re-download, correct "Cancel" to "Disable SuperSync"
- Show silent server migration path for fresh clients with local data on empty server
Rename generic "Conflict dialog" labels to SyncConflictDialog and
ImportConflictDialog to reflect the two distinct components. Add orange
action styling for key state-changing nodes (apply, force upload/download,
enable encryption, upload).
Add limitVectorClockSize to OperationLogCompactionService._doCompact()
which was the remaining saveStateCache caller that persisted unpruned
clocks. Update vector-clocks.md exhaustive pruning table with the three
new client-side pruning locations. Add boundary tests at exactly
MAX_VECTOR_CLOCK_SIZE for snapshot and hydrator services.
Remove references to deleted docs/ai/ files, update stale comments about
protectedClientIds and pruning-aware comparison to reflect current REPLACE
semantics, fix MAX=30→20 heading, client_0..29→19 comment, use ?? over ||,
preserve lastSeq in server clean-slate path to prevent sequence reuse, and
add clarifying comments for mock limitations and validation guards.
- Use JWT_EXPIRY_PASSKEY (7d) for replaceToken instead of 365d magic link
expiry — token replacement is a security action, shorter lifetime is safer
- Replace localeCompare with locale-independent comparator in vector clock
tie-breaking to ensure deterministic behavior across environments
- Fix 5 additional stale MAX=30 references in docs and tests (now 20)
- Update authentication.md to reflect dual JWT expiry tiers
- Clean up isLikelyPruningArtifact references in docs and LEGACY_MAX in tests
Lower the cap to leave headroom for future increases and surface
size-related edge cases earlier. All pruning logic is MAX-agnostic
so this is a safe constant change with documentation updates.
At MAX=10, pruning triggered frequently enough (11+ unique client IDs from
reinstalls/new browsers) to require 4 defense layers compensating for
information loss: pruning-aware comparison, protected client IDs with
migration, isLikelyPruningArtifact heuristic, and same-client check.
At MAX=30, pruning almost never triggers (needs 31+ unique client IDs).
A 30-entry clock is ~500 bytes — negligible bandwidth. This allows removing
most defense layers while keeping two cheap backward-compat checks for old
10-entry pruned data still on servers.
Removed:
- Pruning-aware mode in compareVectorClocks (standard comparison now)
- Protected client IDs mechanism (storage, migration, preservation)
- selectProtectedClientIds function
- Clock normalization in SyncImportFilterService
Kept temporarily (backward compat with old 10-entry data):
- isLikelyPruningArtifact with LEGACY_MAX=10
- Same-client check (always mathematically correct)
Replace the scattered, contradictory document with a coherent 13-section
architecture reference covering the full vector clock system: core
operations, pruning, conflict detection, SYNC_IMPORT filtering, defense
layers against pruning artifacts, and step-by-step scenario traces.
When a client with an established vector clock (10+ entries) received a
remote SYNC_IMPORT/BACKUP_IMPORT with a fresh clock, mergeRemoteOpClocks()
merged the import's clock into the old clock instead of replacing it.
This caused clock bloat (11+ entries), which led to server-side pruning
dropping the import's entry (lowest counter). Other clients then saw
these ops as CONCURRENT with the import and discarded them.
Fix: In mergeRemoteOpClocks(), when a full-state op is present, use its
clock as the base instead of the existing local clock. Regular ops
continue to merge normally.
The sanitizeVectorClock() DoS cap was changed to 5x MAX (50 entries) but
comments in CLAUDE.md, vector-clocks.md, sync.types.ts, and
validation.service.ts still referenced the old 3x MAX (30) value.
The retry counter incremented per-op instead of per-entity per cycle.
Multiple ops for the same entity in one batch would burn through all
MAX_CONCURRENT_RESOLUTION_ATTEMPTS immediately, causing permanent
rejection on the first sync cycle instead of allowing 3 retry cycles.
Also uses toEntityKey utility instead of manual string construction
and fixes docs/code mismatch (>= vs ===) for pruning-aware comparison.
* fix(sync): prevent infinite loop when concurrent modification resolution keeps failing
When vector clock pruning makes it impossible to create a dominating clock
(e.g., entity clock has MAX entries and client ID isn't among them), the cycle
"upload → CONFLICT_CONCURRENT → merge clocks → upload → reject again" repeats
endlessly. This adds a per-entity retry counter (MAX_CONCURRENT_RESOLUTION_ATTEMPTS=3)
that permanently rejects ops after exceeding the limit, breaking the sync loop.
The counter resets when a sync cycle completes with no rejections (healthy state).
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* fix(sync): move vector clock pruning after conflict detection to fix root cause
The infinite sync loop happens because it's mathematically impossible to build
a dominating clock with MAX_VECTOR_CLOCK_SIZE entries when the entity's clock
already has MAX entries and the client's ID isn't among them. The merged clock
needs MAX+1 entries (all entity clock IDs + client ID), but client-side pruning
drops one entity clock ID. The server's pruning-aware comparison then sees the
dropped key as non-shared and returns CONCURRENT instead of GREATER_THAN.
Fix: Move limitVectorClockSize from validation (before comparison) to
processOperation (after comparison, before storage). The full unpruned clock
is now used for conflict detection — all entity clock IDs are present so
bOnlyCount=0 → GREATER_THAN. Storage still gets the pruned clock.
Client-side: Stop pruning in SupersededOperationResolverService. The server
handles pruning after conflict detection.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* fix(sync): tighten vector clock sanitize limit from 100 to 3x MAX_VECTOR_CLOCK_SIZE
The old sanitize cap of 100 entries was unnecessarily wide. Since conflict
resolution clocks are at most ~12-15 entries (entity clock MAX=10 + client ID
+ a few merged), cap at 3x MAX (30) for DoS protection while leaving ample
room for legitimate clocks.
Also update server-side pruning tests to use realistic clock sizes (20 entries
instead of 50) to stay within the new sanitize limit.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* docs(sync): document vector clock pruning invariant and infinite loop fix
- Add "Pruning and the Pruning-Aware Comparison" section to vector-clocks.md
explaining the critical invariant: server prunes AFTER comparison, not before
- Add rule 13 to CLAUDE.md to prevent future regressions
- Update conflict resolution key files table with rejected-ops-handler and
superseded-operation-resolver services
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* docs(sync): update last-updated date in conflict resolution docs
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* test(sync): update pruning tests to reflect server-side pruning design
Client no longer prunes vector clocks during conflict resolution — the
server handles pruning after conflict detection. Tests now verify the
merged clock is sent unpruned with all keys preserved.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* test(sync): fix client-side pruning tests and add server-side regression test
- Update 7 SupersededOperationResolverService tests to verify client
does NOT prune (server handles pruning after conflict detection)
- Remove redundant `newClock` alias in superseded-operation-resolver
- Add regression test: MAX+1 entry clock accepted as GREATER_THAN
when it dominates a MAX entry entity clock (the core infinite loop fix)
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Remove redundant subset condition in compareVectorClocks
- Add clarifying comment for conservative return in hasVectorClockChanges
- Batch per-key verbose logs into single summary log
- Add defensive warning for negative startingSeq in file-based sync
- Extract magic timeout constant in E2E test
- Update stale doc date
- Replace fragile VectorClockComparison[result] enum lookup with safe cast
- Return CONCURRENT instead of EQUAL when only one side has non-shared
keys in pruning-aware mode (safe direction: triggers LWW instead of
silent skip)
- Fix docs claiming clocks "reset" at MAX_SAFE_INTEGER (they throw)
Make hasVectorClockChanges pruning-aware by checking clock size before
logging missing keys — downgrade to verbose when pruning is likely,
warn when corruption is likely. Add return value assertion to LWW retry
exhaustion test, asymmetric pruning test cases, server-side pruning
tradeoff documentation, and fix stale "max 50" in docs.
Includes backward compatibility: client accepts both CONFLICT_SUPERSEDED
and CONFLICT_STALE from the server, and the server keeps a deprecated
CONFLICT_STALE alias. Remove after all deployments are updated.
Add documentation explaining why all vectorClock keys must be protected
during SYNC_IMPORT/BACKUP_IMPORT operations to prevent incorrect
CONCURRENT comparisons after vector clock pruning.
Update tests to verify multi-key vectorClock protection.
- Remove dead test:shard:pfapi script from package.json
- Update AGENTS.md persistence layer path to op-log and sync
- Update documentation file paths in secure-storage.md,
vector-clocks.md, and quick-reference.md
Create a quick reference document with ASCII diagrams summarizing:
- Area 1: Write Path
- Area 2: Read Path (Hydration)
- Area 3: Server Sync (SuperSync)
- Area 4: Conflict Detection
- Area 5: Conflict Resolution (LWW)
- Area 6: SYNC_IMPORT Filtering
- Area 7: Archive Handling
Includes decision tables, key invariants, and file references.
Areas 8-12 placeholders for future additions.
- C.5: Correct "Current timestamp" to "Preserved maximum timestamp
from local ops" - critical for correct LWW semantics
- C.7: Rewrite "Late-Joiner Replay" as "SYNC_IMPORT Filtering" to
reflect actual "Clean Slate Semantics" implementation:
- CONCURRENT ops are now DROPPED, not replayed
- Uses SyncImportFilterService, not removed _replayLocalSyncedOpsAfterImport()
- Vector clock comparison determines causality, not UUIDv7 timestamps