Skip to content

chore(release): memory-core 0.3.1 - durable couch tombstones + resilient syncNow#17

Merged
vreshch merged 1 commit into
masterfrom
fix/couch-delete-reconcile
Jul 6, 2026
Merged

chore(release): memory-core 0.3.1 - durable couch tombstones + resilient syncNow#17
vreshch merged 1 commit into
masterfrom
fix/couch-delete-reconcile

Conversation

@vreshch

@vreshch vreshch commented Jul 6, 2026

Copy link
Copy Markdown
Member

Why

Two verified frictions in the couch channel (src/channel/), surfaced by the CLI integration. Public repo, patch bump 0.3.0 -> 0.3.1. Zero new deps; doc model and byte-compat fixtures untouched.

Friction 1 - a failed tombstone was silently lost (data integrity)

CouchSync.removeFile was fire-once. Unlike pushFileLive (which self-enqueues on failure), a tombstone that failed mid-outage (network down, 401) was dropped: pushAll/syncNow only reconciled existing files, so the couch doc survived forever and the deleted note could resurrect on a later pull (and persisted on other devices).

Fix, built inside the module's existing idioms:

  • Kind-aware queue. CouchSyncState gains an optional deletions: string[] set (parallel to pending). Old 0.3.0 snapshots that lack the field load fine (?? []). removeFile now self-enqueues a pending deletion on transport failure and clears it on a terminal outcome; flushPending retries deletions alongside pending pushes.
  • Local-deletion reconciliation. pushAll (and thus syncNow) reconcile deletions using the rev cache as the disambiguator: a path present in the rev cache (we synced it) but absent from FileStore.listMarkdown = locally deleted -> issue the tombstone, then drop the rev-cache entry. A couch doc with no rev-cache entry is new remote content -> pull writes it (unchanged behavior).
  • Phantom-delete guard. The existing pull-delete path already drops the rev cache, so a pull-applied delete never resurfaces as a local deletion. Echo suppression intact.
  • Stale-delete race (edit-wins-over-stale-delete). File deleted locally, tombstone pending, but the couch doc advanced (another device edited). A tombstone with a stale rev 409s -> we drop the pending deletion and drop the rev, letting the next pull re-deliver the newer remote version. We also pre-check couch content-rev vs the cached rev and abandon if they diverge. Never force-deletes with the fresh rev.

Friction 2 - syncNow resilience asymmetry

tick() caught pullOnce errors; syncNow() did not (it threw). syncNow now catches both push and pull failures (records, does not throw) and returns a SyncResult { pushed, pulled, error? } so callers still learn success/failure. Old callers awaiting void remain source-compatible.

Tests

+9 channel tests; all 154 green (was 145, none changed):

  • failed-delete enqueues + retries + eventually lands (fetch failure then success)
  • persisted state round-trips the new shape + a 0.3.0 old-shape snapshot loads
  • local-deletion reconciliation, rev-cache disambiguation both directions
  • pull-delete does not trigger a phantom local deletion
  • tombstone-409 drops the deletion and the next pull restores the newer doc
  • syncNow swallows pull errors and reports status

Behavior change for existing consumers

None beyond delete durability. syncNow's return changes void -> SyncResult (additive; awaiting it still compiles). removeFile is now never-throw (was able to throw on transport error) - strictly safer.

Downstream port candidate (not touched here)

The Obsidian plugin (agentage/obsidian-sync) carries its own copy of these channel modules with the same fire-once tombstone gap. Left untouched per scope; flag as a follow-up port.

…ent syncNow

Delete reconciliation for the couch channel and syncNow resilience parity.

FRICTION 1 (data integrity): a failed removeFile tombstone was fire-once and
silently lost, so a deleted note could resurrect on a later pull. Now:
- CouchSyncState carries an optional `deletions` set (loads fine from 0.3.0
  snapshots that lack it); removeFile self-enqueues on transport failure and
  flushPending retries deletions alongside pending pushes.
- pushAll/syncNow reconcile local deletions using the rev cache as the
  disambiguator: a known (rev-cached) path absent from the file set is a local
  deletion -> tombstone + drop rev; a couch doc with no rev-cache entry is new
  remote content -> pull writes it (unchanged). The pull-delete path already
  drops the rev, so it never resurfaces as a phantom local deletion.
- Stale-delete race: a tombstone whose rev advanced (409, or couch content no
  longer matches the cached rev) is abandoned; the next pull re-delivers the
  newer remote version (edit-wins-over-stale-delete). Never force-deletes.

FRICTION 2 (resilience): syncNow now catches push/pull errors like tick() and
returns a SyncResult { pushed, pulled, error? } instead of throwing.

Tests: +9 channel tests (delete durability, state shape round-trip + old-shape
load, reconciliation both directions, phantom-delete guard, 409 restore,
syncNow swallow). All 154 tests green; doc model and byte-compat fixtures
untouched.
@vreshch vreshch marked this pull request as ready for review July 6, 2026 16:49
@vreshch vreshch merged commit 48c6a84 into master Jul 6, 2026
1 check passed
@vreshch vreshch deleted the fix/couch-delete-reconcile branch July 6, 2026 16:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant