fix(sync): recover journal metadata updates raised while the sync runtime is down - #970
Merged
Conversation
…is down Journals had the same fallback-less local adapter notes had before #963, and were additionally excluded from the recovery sweep that fixed notes (`recoverDirtyNotes` filters `journalDate IS NULL`). A journal metadata update raised while the sync runtime was down had nothing to re-push it: no queue row, no dirty marker, no sweep. - journal adapter `enqueueUpdate` falls back to `incrementNoteClockOffline`, which bumps under the real device id and clears `syncedAt`. - new `recoverDirtyJournals` arm re-pushes those rows at the next runtime start, routed through the journal sync service and passing the row's date. - `enqueueRecoveredUpdate` forwards extra args so the journal payload builder gets its date instead of throwing on `undefined`.
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
h4yfans
marked this pull request as ready for review
August 5, 2026 18:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #965
The hole
Journals had the same fallback-less local adapter notes had before #963 —
getJournalSyncService()?.enqueueUpdate(...), optional chaining, no else — and were additionally excluded from the sweep that fixed notes.recoverDirtyNotesfiltersjournalDate IS NULLby construction.So a journal metadata update raised while the sync runtime was down (quit, vault switch, re-auth) had nothing left to re-push it: no queue row, no dirty marker, no sweep. Real callers that hit this are
notes/entity-properties.ts(journal properties) andipc/journal-handlers.ts(tags/frontmatter).Nothing compensated — verified in source, not assumed
journalHandler.seedUnclockedonly selectsclock IS NULL, so it owns first pushes and never sees a journal the server already knows.seedUnclockedNotesexcludes journals outright (journalDate IS NULL).checkManifestIntegrityre-enqueues only items absent from the server manifest (if (!serverRef)). It does no version comparison, so a journal that exists on the server at a stale version is invisible to it.The fix
1. Offline fallback on the journal adapter's
enqueueUpdate(local-mutations.ts), reusingincrementNoteClockOffline. Journals arenote_metadatarows and share the exact columns that helper touches (clock,syncedAt,localOnly), so no second helper was warranted — its doc comment now states it serves both.2. A
recoverDirtyJournalsarm (dirty-recovery.ts) selectingjournalDate IS NOT NULL, routed through the journal sync service, addingjournalstoRecoveryResult.3.
enqueueRecoveredUpdateforwards extra args (sync-core,content-sync-base.ts) so the journal recovery can hand over the entry's date.Decisions, and why
Separate
recoverDirtyJournals, not a branch inrecoverDirtyNotes. The two route to different sync services and the journal payload builder takes an argument the note one does not. Folding them together would push a service switch and a date through a query that needs neither, and would edit the shipped note query — which I wanted left byte-for-byte alone. The doc comment that declared the ownership split is updated rather than contradicted.The recovery arm must pass the date — this is the non-obvious part.
enqueueRecoveredUpdate(itemId)callsenqueueForPush(itemId, 'update', ...getFallbackArgs()), andgetFallbackArgs()returns[]. For journalsTArgs = [string], so the date reachesJournalSyncService.buildSnapshotPayloadasundefined. That builder callsgetJournalPath(date)outside its own try/catch, andformatJournalFilenamedoesisoDate.split('-')— so a naive copy of the note arm throws aTypeErrorout of the recovery loop and takes the whole sweep down with it, tasks and projects included. Hence change (3).I considered instead defaulting to
cached.journalDateinside the payload builder (one file, no shared-package change). Rejected: it forces the builder to fabricate a payload for the "journal row with no date" case it cannot skip, and the frozen bad payload would still reach the wire viaresolvePushPayload's fallback. Passing the date keeps the builder'sdate: stringcontract intact and makes the omission a compile error at theContentSyncServiceboundary.The two fixed constraints
Clock is advanced at write time. The fallback bumps before returning; recovery deliberately does not bump (
content-sync-base.tsre-sends the stored clock). Pinned byjournal-sync.test.ts, which asserts the recovered push carries the stored clock and the row is not re-bumped, and by the dirty-recovery test asserting{ 'device-A': 3 }after the fallback.No
_offlinekey.incrementNoteClockOfflinebumps undersync_devices.is_current_deviceand no-ops when no device is registered — matchinghandleMissingDeviceon the online path.ContentSyncServicehas no rebinding hook, so an_offlinetick would reach peers as a device id two machines could both claim. Already covered by the note tests; journals inherit it by using the same helper.Backward compatibility
No schema change, no migration, no contract or payload change.
clockandsyncedAtare existing columns;syncedAtis already nullable and already means "never confirmed synced". Nothing new goes on the wire — the recovered push is an ordinary journal update, andjournalHandler.buildPushPayloadrebuilds it from the row at push time exactly as before. Peers on older builds are unaffected.enqueueRecoveredUpdate(itemId, ...extra)is additive: every existing caller passes no extra args and takes the identicalgetFallbackArgs()path.Tests
pnpm --filter @memry/desktop exec vitest run --config config/vitest.config.ts --project main src/main/syncpackages/sync-core:PASS (6) FAIL (0).pnpm typecheckgreen. ESLint 0 errors on all changed files.docs:impact --strictpasses,docs:buildgreen.New coverage:
dirty-recovery.test.ts— real in-memory data DB and the real recovery predicate: a diverged journal is recovered; clean, local-only, clock-less journals and plain notes are left alone; and end-to-end, a server-confirmed journal is not recovered until the fallback runs, then is re-pushed at an advanced clock.journal-sync.test.ts— realRecordSyncController+ real queue: a recovered update carries the date it was given and re-sends the stored clock without bumping it.local-mutations.test.ts— the journal adapter reaches the fallback when the service is null, and does not when it is up.Mutation check
Each half reverted separately, red observed, restored.
incrementNoteClockOffline→ no-op):recoverDirtyJournalscall removed):extraignored inenqueueRecoveredUpdate):Restored:
PASS (1654) FAIL (0).Deliberately out of scope
enqueueCreate/enqueueDeleteon journals keep the no-op. Confirmed in source rather than carried over from the note conclusion:journalHandler.seedUnclockedsweepsclock IS NULL AND journalDate IS NOT NULLinto a create at the next runtime start, so a journal that has never been pushed is already owned.checkManifestIntegritydoing version comparison. It would also cover this class of drift, but it is a 30-minute periodic network check with a much wider blast radius than the issue calls for.