feat(offline,store): retention, dead-letter visibility, per-entity conflict policy, memoized selectors - #1288
Merged
RUKAYAT-CODER merged 1 commit intoAug 30, 2026
Conversation
…nflict policy, memoized selectors Closes rinafcode#1169 Closes rinafcode#1170 Closes rinafcode#1172 Closes rinafcode#1174 rinafcode#1169 — Retention and GC for acked offline sync records. gcAckedRecords now evicts oldest-first past a record cap as well as past the retention window: age alone leaves the store unbounded, since a device that syncs thousands of operations inside the window keeps every one of them. Added runRetentionSweep/startRetentionSweep so collection no longer depends on a successful authenticated drain — syncData returns early with no session, which is exactly the client whose store grows unattended, so that path sweeps too. persistenceLayer records a write timestamp per entry and gains purgeExpiredPersistedEntries; entries predating the metadata are kept rather than deleted on upgrade. rinafcode#1170 — Dead-letter queue surfaced to the UI. getDeadLetterSummary reports count, breakdown by type and oldest failure, because a bare count cannot tell the user whether one operation is stuck from this morning or forty from last month. retryAllDeadLetter re-enqueues the lot. useOfflineSync reads the queue on mount rather than waiting for a drain that a stuck user has not triggered, and exposes deadLetter, hasDeadLetter, refreshDeadLetter and retryDeadLetterOperations. rinafcode#1172 — Per-entity conflict-resolution strategies. ConflictResolutionPolicy maps entity type to strategy with a default; resolveConflictStrategy consults it instead of returning a global 'merge'. Policies are frozen and combined immutably so resolution cannot depend on call order. The merge-strategy registry is now what resolveByEntityType reads, so a registered strategy is actually used instead of silently falling through to the generic merge. rinafcode#1174 — Memoized store selectors. memoizeByInputs caches by input identity, so a selector deriving an array returns the same reference while its inputs are unchanged and zustand's Object.is check stops the re-render; object-literal selectors use useShallow. useUnreadCount now derives from the same cached array instead of filtering a second time. Also fixes two defects found while testing this code: persistenceLayer .removeItem opened the database without its upgrade callback and threw NotFoundError on a profile that had never written state, and the offlineSync suite asserted against drains that never ran because the session gate added in 367328b is unsatisfied under test — 9 of its 10 tests were failing before this change. 130 tests pass across the touched files (88 of them new).
|
@Cybermaxi7 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Contributor
|
Thank you for contributing to the project. |
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.
Four related pieces of the offline sync path and the store that renders it.
Closes #1169
Closes #1170
Closes #1172
Closes #1174
#1169 — Retention and GC for acked offline sync records
src/services/offlineSync.ts,src/store/persistenceLayer.tsgcAckedRecordsalready dropped records past their retention window, but two gaps left the store growing anyway:SYNC_MAX_ACKED_RECORDS,DEAD_LETTER_MAX_RECORDS). Both halves are needed — a cap alone would keep stale records forever on a quiet device.syncData, butsyncDatareturns early when there is no session — so a signed-out or long-offline client, precisely the one whose store grows unattended, never collected anything. AddedrunRetentionSweep(callable without a session, and now run on that early-return path) andstartRetentionSweep(intervalMs)returning a stop function.persistenceLayerhad no retention at all. It now stamps a write timestamp per entry and exposespurgeExpiredPersistedEntries. Metadata lives under a__meta__:key prefix in the existing object store rather than in a new one — adding a store means bumping the database version and running an upgrade in every existing browser, which is a lot of risk for a timestamp. Entries written before this metadata existed have no timestamp and are kept, since deleting them would silently drop a user's state on upgrade.#1170 — Dead-letter queue length in the offline UI
src/services/offlineSync.ts,src/hooks/useOfflineSync.tsgetDeadLetterCountexisted but nothing surfaced it until after a drain — and a user arriving with a stuck queue has not triggered one. The hook now reads on mount.A bare count also isn't enough to prompt on: one operation stuck since this morning and forty stuck since last month need different words.
getDeadLetterSummaryreturns count, breakdown by type, and the oldest failure;retryAllDeadLetterre-enqueues the lot. The hook exposesdeadLetter,hasDeadLetter,refreshDeadLetter,retryDeadLetterOperationsandisRetryingDeadLetter, andgetDeadLetterCountnow uses an index count instead of loading every record to measure.length.#1172 — Per-entity conflict-resolution strategies
src/lib/conflict/resolver.ts,src/lib/conflict/types.ts, wired insrc/services/offlineSync.tsresolveConflictStrategyignored the conflict'sentityTypeand returned'merge'for everything.ConflictResolutionPolicymaps entity type to strategy with a default, and the service consults it when resolution isauto; an explicitresolveConflictsoption still wins for that drain.withEntityStrategyreturns a copy. A policy mutated after the fact would make resolution depend on call order, and two subsystems could quietly fight over the same entity type.course_progressis listed explicitly rather than left to the default, so changing the default later cannot silently change how progress — the one payload with a proven deterministic merge — is resolved.resolveByEntityTypenow reads the merge-strategy registry rather than the frozenMERGE_STRATEGIESmap. Registering a strategy for a new entity type previously had no effect: the lookup missed and the value fell through to the generic shallow merge.#1174 — Memoize expensive store selectors
src/store/selectors.ts,src/store/stateManager.tsZustand compares a selector's result with
Object.is.useUnreadNotificationsreturnednotifications.filter(...)— a fresh array on every store update — so every subscriber re-rendered whenever any unrelated slice changed, and the filter ran again each time.memoizeByInputscaches the last inputs and result, so unchanged inputs return the previous reference and the comparison holds. Only the most recent call is cached, which is the right size: a selector is called with current state, and state moves forward, so a larger cache would retain memory to serve inputs that are not coming back. Object-literal selectors (useSearchFilters,useQuizProgress) useuseShallow, where a per-field comparison is cheaper than caching.useUnreadCountnow derives from the same cached array, so a component showing both the badge and the list filters once rather than twice.Two defects found while testing this code
Both are fixed here, and both are why some of the diff sits outside the four issues:
persistenceLayer.removeItemthrew on a fresh profile. It opened the database without theupgradecallback the read and write paths pass, so on a browser that had never persisted state it created an empty database and then failed withNotFoundError: No objectStore named app_state.The
offlineSyncsuite was asserting against drains that never ran. The session gate added in 367328b makessyncDatareturn early without a valid token, and nothing in the test environment provides one — so 9 of that file's 10 tests were failing onmainbefore this branch.beforeEachnow satisfies the gate. Worth knowing why nobody noticed: the CI test step runstimeout 30s pnpm vitest run --coverageand exits 0 when the 30 seconds elapse, so a suite that is slow or hanging reports success. On this machinevitest runonmaindid not finish at all — one worker sat at 99% CPU for over six minutes before I killed it — so in practice that check has been passing without ever reporting a result.Verification