Skip to content

feat(offline,store): retention, dead-letter visibility, per-entity conflict policy, memoized selectors - #1288

Merged
RUKAYAT-CODER merged 1 commit into
rinafcode:mainfrom
Cybermaxi7:feat/offline-sync-retention-dlq-conflict-policy-selectors
Aug 30, 2026
Merged

feat(offline,store): retention, dead-letter visibility, per-entity conflict policy, memoized selectors#1288
RUKAYAT-CODER merged 1 commit into
rinafcode:mainfrom
Cybermaxi7:feat/offline-sync-retention-dlq-conflict-policy-selectors

Conversation

@Cybermaxi7

Copy link
Copy Markdown
Contributor

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.ts

gcAckedRecords already dropped records past their retention window, but two gaps left the store growing anyway:

  • Age alone does not bound the store. A device that syncs thousands of operations inside the 7-day window keeps every one of them. GC now also evicts oldest-first down to a record cap (SYNC_MAX_ACKED_RECORDS, DEAD_LETTER_MAX_RECORDS). Both halves are needed — a cap alone would keep stale records forever on a quiet device.
  • Collection depended on a successful authenticated sync. GC ran inside syncData, but syncData returns early when there is no session — so a signed-out or long-offline client, precisely the one whose store grows unattended, never collected anything. Added runRetentionSweep (callable without a session, and now run on that early-return path) and startRetentionSweep(intervalMs) returning a stop function.

persistenceLayer had no retention at all. It now stamps a write timestamp per entry and exposes purgeExpiredPersistedEntries. 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.ts

getDeadLetterCount existed 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. getDeadLetterSummary returns count, breakdown by type, and the oldest failure; retryAllDeadLetter re-enqueues the lot. The hook exposes deadLetter, hasDeadLetter, refreshDeadLetter, retryDeadLetterOperations and isRetryingDeadLetter, and getDeadLetterCount now 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 in src/services/offlineSync.ts

resolveConflictStrategy ignored the conflict's entityType and returned 'merge' for everything. ConflictResolutionPolicy maps entity type to strategy with a default, and the service consults it when resolution is auto; an explicit resolveConflicts option still wins for that drain.

  • Policies are frozen, and withEntityStrategy returns 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_progress is 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.
  • resolveByEntityType now reads the merge-strategy registry rather than the frozen MERGE_STRATEGIES map. 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.ts

Zustand compares a selector's result with Object.is. useUnreadNotifications returned notifications.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.

memoizeByInputs caches 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) use useShallow, where a per-field comparison is cheaper than caching. useUnreadCount now 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:

  1. persistenceLayer.removeItem threw on a fresh profile. It opened the database without the upgrade callback the read and write paths pass, so on a browser that had never persisted state it created an empty database and then failed with NotFoundError: No objectStore named app_state.

  2. The offlineSync suite was asserting against drains that never ran. The session gate added in 367328b makes syncData return early without a valid token, and nothing in the test environment provides one — so 9 of that file's 10 tests were failing on main before this branch. beforeEach now satisfies the gate. Worth knowing why nobody noticed: the CI test step runs timeout 30s pnpm vitest run --coverage and exits 0 when the 30 seconds elapse, so a suite that is slow or hanging reports success. On this machine vitest run on main did 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

vitest (touched files)   130 passed / 8 files   — 9 were failing on main before this branch
tsc --noEmit             clean
next lint --max-warnings=0   clean
validate:ui              passed (43 pre-existing warnings)
validate:web3            passed
next build               clean

…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).
@drips-wave

drips-wave Bot commented Aug 30, 2026

Copy link
Copy Markdown

@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! 🚀

Learn more about application limits

@RUKAYAT-CODER

Copy link
Copy Markdown
Contributor

Thank you for contributing to the project.

@RUKAYAT-CODER
RUKAYAT-CODER merged commit 24e87c6 into rinafcode:main Aug 30, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants