fix(store): pair unread snapshots with the sync id they reflect

A `/reads` recount taken while new-entry hints were still hidden behind a
stale KV head already counted those entries; when the hints arrived with
the next pull their increments were added on top, and the reads that
followed left the hint values behind as phantom unread counts.

The engine now keeps the sync id an unread snapshot reflects
(`unreadSnapshotSyncId`, from the `/reads` answer or `/sync/state` read
just before) and applies the counter part of `N`, `U` and `inbox_entry`
`D` actions only above it. A refresh the user asks for recounts through
the engine, and the list-versus-counter check also fires when a fully
loaded unread-only list shows fewer unread entries than the counter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
DIYgod
2026-09-19 10:29:35 +08:00
parent 9a213b8a83
commit 141567e928
14 changed files with 530 additions and 29 deletions
@@ -343,7 +343,7 @@ export const useEntriesByView = ({ onReset }: { onReset?: () => void }) => {
type: remoteQuery.isReady ? ("remote" as const) : ("local" as const),
refetch: useCallback(() => {
const promise = query.refetch()
void unreadSyncService.refresh()
void unreadSyncService.refresh({ calibrate: true })
return promise
}, [query]),
entriesIds: entryIds,
@@ -1,6 +1,7 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import { usePrefetchSubscription } from "@follow/store/subscription/hooks"
import { usePrefetchUnread } from "@follow/store/unread/hooks"
import { unreadSyncService } from "@follow/store/unread/store"
import { nextFrame } from "@follow/utils"
import type { FlashListProps, FlashListRef } from "@shopify/flash-list"
import { FlashList } from "@shopify/flash-list"
@@ -153,6 +154,7 @@ export const TimelineSelectorList = ({
tintColor={systemFill}
onRefresh={() => {
unreadRefetch()
void unreadSyncService.refresh({ calibrate: true })
subscriptionRefetch()
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
onRefresh()
@@ -359,9 +359,19 @@ read first. Anything written between the two calls is replayed by the next delta
at most hourly, because unread entries age out of the retention window without an
action, and `/subscriptions` daily, because feed metadata is not owned by the user. A
recount also follows actions that change counters structurally (a subscription or list
membership added) or that come from a server that does not send the numbers yet, and
membership added) or that come from a server that does not send the numbers yet, a
refresh the user asks for (the desktop refresh button, pull to refresh on mobile), and
`useSyncUnreadWhenUnMatch` asks for one, at most once a minute, when the list on screen
still shows more unread entries than the counter after a delta pull.
disagrees with the counter after a delta pull: it shows more unread entries than the
counter, or it is an unread-only list with no more pages and shows fewer.
- An unread snapshot is paired with the sync id it reflects. `GET /reads` answers with
`lastSyncId`, read on the same replica after the counts, so every action up to it had its
change inside the counts; an older server gets `/sync/state` read just before instead.
The engine keeps that id in `sync_meta` (`unreadSnapshotSyncId`) and applies the counter
part of `N`, `U` and `inbox_entry` `D` actions only above it. Without this the log double
counts: a poll answered from a stale head, or a snapshot that simply ran before the pull
carrying the hints, put the snapshot ahead of the cursor, and the `N` increments that
followed were added on top of counts that already contained the entries.
- New entries never rearrange what is loaded. On launch, foreground and manual pulls the
engine fetches only the edge of the entry lists on screen where new entries arrive
(`refreshEntriesHead`): the first page of a newest-first list, merged in front of the
@@ -32,6 +32,7 @@ const engine = (synced: boolean) => ({
ensureSynced: vi.fn(async () => synced),
catchUp: vi.fn(async () => synced),
requestUnreadCalibration: vi.fn(async () => {}),
recordUnreadSnapshot: vi.fn(async () => {}),
})
describe("action rules and the sync engine", () => {
@@ -7,6 +7,7 @@ import { FEED_COLLECTION_LIST } from "../../constants/app"
import { queryClient } from "../../context"
import { useFeedUnreadIsDirty } from "../feed/hooks"
import { useSyncUnreadWhenUnMatch } from "../unread/hooks"
import type { UnreadListScope } from "../unread/types"
import {
getEntryIdsByCategorySelector,
getEntryIdsByFeedIdSelector,
@@ -322,7 +323,34 @@ export const useEntriesQuery = (
)
}, [query.data, query.isLoading, query.isError])
useSyncUnreadWhenUnMatch(entriesIds)
const unreadScope = useMemo<UnreadListScope>(
() => ({
view,
feedId: feedId === undefined ? undefined : String(feedId),
feedIdList,
listId: listId === undefined ? undefined : String(listId),
inboxId: inboxId === undefined ? undefined : String(inboxId),
isCollection,
excludePrivate: hidePrivateSubscriptionsInTimeline === true,
}),
[
view,
feedId,
feedIdList?.toString(),
listId,
inboxId,
isCollection,
hidePrivateSubscriptionsInTimeline,
],
)
// Once every unread entry is on screen, a counter above the list can only be wrong.
const unreadListComplete =
fetchUnread === true &&
!aiSort &&
query.isSuccess &&
!query.isFetching &&
query.hasNextPage === false
useSyncUnreadWhenUnMatch(entriesIds, { scope: unreadScope, complete: unreadListComplete })
return useMemo(() => {
return {
@@ -1,7 +1,11 @@
import { FeedViewType } from "@follow/constants"
import { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from "../../constants/app"
import { getListFeedIds } from "../list/getters"
import { getSubscribedFeedIdAndInboxHandlesByView } from "../subscription/getter"
import { unreadCountAllSelector, unreadCountIdSelector, unreadCountIdsSelector } from "./selectors"
import { useUnreadStore } from "./store"
import type { FeedIdOrInboxHandle } from "./types"
import type { FeedIdOrInboxHandle, UnreadListScope } from "./types"
export const getUnreadById = (id: FeedIdOrInboxHandle) => {
const state = useUnreadStore.getState()
@@ -19,3 +23,48 @@ export const getUnreadAll = () => {
const state = useUnreadStore.getState()
return unreadCountAllSelector(state)
}
const TIMELINE_VIEWS = [
FeedViewType.Articles,
FeedViewType.SocialMedia,
FeedViewType.Pictures,
FeedViewType.Videos,
FeedViewType.Audios,
FeedViewType.Notifications,
]
/**
* The feed ids and inbox handles whose unread entries an entry list shows, so the list can be
* compared with the counters. Undefined for lists without a counter, such as collections.
*/
export const getUnreadScopeIds = (scope: UnreadListScope): string[] | undefined => {
if (scope.isCollection || scope.feedId === FEED_COLLECTION_LIST) return
if (scope.inboxId) return [scope.inboxId]
if (scope.listId) return getListFeedIds(scope.listId) ?? []
if (scope.feedIdList) return scope.feedIdList
if (scope.feedId && scope.feedId !== ROUTE_FEED_PENDING) {
return scope.feedId.split(",").filter((id) => id.length > 0)
}
if (typeof scope.view !== "number") return
const views = scope.view === FeedViewType.All ? TIMELINE_VIEWS : [scope.view]
return Array.from(
new Set(
views.flatMap((view) =>
getSubscribedFeedIdAndInboxHandlesByView({
view,
excludePrivate: scope.excludePrivate === true,
// Hidden subscriptions are left out of the timeline by the server as well.
excludeHidden: true,
}),
),
),
)
}
/** The unread counter an entry list is expected to agree with. */
export const getUnreadCountForScope = (scope: UnreadListScope) => {
const ids = getUnreadScopeIds(scope)
if (!ids) return
return unreadCountIdsSelector(ids)(useUnreadStore.getState())
}
@@ -0,0 +1,126 @@
import { FeedViewType } from "@follow/constants"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { useEntryStore } from "../entry/store"
import type { EntryModel } from "../entry/types"
import { useListStore } from "../list/store"
import { useSubscriptionStore } from "../subscription/store"
import { getUnreadScopeIds } from "./getters"
import { hasUnreadCounterAboveList } from "./hooks"
import { unreadActions } from "./store"
vi.mock("@follow/database/services/entry", () => ({ EntryService: { patchMany: vi.fn() } }))
vi.mock("@follow/database/services/unread", () => ({
UnreadService: { getUnreadAll: vi.fn(), reset: vi.fn(), upsertMany: vi.fn() },
}))
vi.mock("@follow/database/services/sync-transaction", () => ({
SyncTransactionService: {
insert: vi.fn(async () => {}),
deleteMany: vi.fn(async () => {}),
getAll: vi.fn(async () => []),
reset: vi.fn(async () => {}),
},
}))
const createEntry = (id: string, feedId: string, read = false): EntryModel => ({
id,
guid: `${id}-guid`,
insertedAt: new Date("2026-01-01T00:00:00.000Z"),
publishedAt: new Date("2026-01-01T00:00:00.000Z"),
feedId,
read,
})
const subscription = (feedId: string, extra: Record<string, unknown> = {}) => ({
feedId,
view: FeedViewType.Articles,
...extra,
})
describe("hasUnreadCounterAboveList", () => {
beforeEach(() => {
const entries = {
entry1: createEntry("entry1", "feed1"),
entry2: createEntry("entry2", "feed1", true),
entry3: createEntry("entry3", "feed2"),
}
useEntryStore.setState((state) => ({
...state,
data: entries,
entryIdSet: new Set(Object.keys(entries)),
}))
useSubscriptionStore.setState((state) => ({
...state,
data: {
feed1: subscription("feed1"),
feed2: subscription("feed2"),
feed3: subscription("feed3", { isPrivate: true }),
feed4: subscription("feed4", { hideFromTimeline: true }),
} as never,
feedIdByView: {
...state.feedIdByView,
[FeedViewType.Articles]: new Set(["feed1", "feed2", "feed3", "feed4"]),
[FeedViewType.SocialMedia]: new Set(),
},
listIdByView: { ...state.listIdByView, [FeedViewType.Articles]: new Set() },
}))
useListStore.setState({
lists: { list1: { id: "list1", feedIds: ["feed1", "feed2"] } as never },
listIds: ["list1"],
})
unreadActions.upsertManyInSession(
[
{ id: "feed1", count: 1 },
{ id: "feed2", count: 1 },
{ id: "feed3", count: 5 },
{ id: "feed4", count: 5 },
],
{ reset: true },
)
})
it("agrees with a list that shows every unread entry of its feed", () => {
expect(hasUnreadCounterAboveList(["entry1", "entry2"], { feedId: "feed1" })).toBe(false)
})
it("notices a counter above what a complete list shows", () => {
unreadActions.upsertManyInSession([{ id: "feed1", count: 4 }])
expect(hasUnreadCounterAboveList(["entry1", "entry2"], { feedId: "feed1" })).toBe(true)
// Read entries on screen do not count towards the list.
expect(hasUnreadCounterAboveList(["entry2"], { feedId: "feed1" })).toBe(true)
})
it("sums a folder, a list and a view the way the timeline is filtered", () => {
expect(getUnreadScopeIds({ feedId: "feed1,feed2" })).toEqual(["feed1", "feed2"])
expect(getUnreadScopeIds({ listId: "list1" })).toEqual(["feed1", "feed2"])
expect(getUnreadScopeIds({ feedIdList: ["feed2"] })).toEqual(["feed2"])
expect(getUnreadScopeIds({ inboxId: "inbox-a" })).toEqual(["inbox-a"])
// Hidden subscriptions never reach the timeline; private ones only when they are not hidden.
expect(getUnreadScopeIds({ view: FeedViewType.Articles })).toEqual(["feed1", "feed2", "feed3"])
expect(getUnreadScopeIds({ view: FeedViewType.Articles, excludePrivate: true })).toEqual([
"feed1",
"feed2",
])
expect(getUnreadScopeIds({ view: FeedViewType.All, excludePrivate: true })).toEqual([
"feed1",
"feed2",
])
expect(
hasUnreadCounterAboveList(["entry1", "entry3"], {
view: FeedViewType.Articles,
excludePrivate: true,
}),
).toBe(false)
expect(
hasUnreadCounterAboveList(["entry1"], { view: FeedViewType.Articles, excludePrivate: true }),
).toBe(true)
})
it("has no opinion on lists without a counter", () => {
expect(getUnreadScopeIds({ isCollection: true })).toBeUndefined()
expect(getUnreadScopeIds({ feedId: "collections" })).toBeUndefined()
expect(getUnreadScopeIds({})).toBeUndefined()
expect(hasUnreadCounterAboveList([], { isCollection: true })).toBe(false)
})
})
@@ -11,8 +11,10 @@ import { getEntry } from "../entry/getter"
import { useListFeedIds } from "../list/hooks"
import { useSubscriptionIdsByView } from "../subscription/hooks"
import { useIsLoggedIn } from "../user/hooks"
import { getUnreadCountForScope } from "./getters"
import { unreadCountAllSelector, unreadCountIdSelector, unreadCountIdsSelector } from "./selectors"
import { unreadSyncService, useUnreadStore } from "./store"
import type { UnreadListScope } from "./types"
export const usePrefetchUnread = () => {
const isLoggedIn = useIsLoggedIn()
@@ -46,13 +48,46 @@ const hasUnreadMismatch = (entryIds: string[]) => {
}
/**
* Notice counters that are lower than the unread entries on screen. Without the sync engine
* the counters are fetched again. With it, the list is usually just ahead of the next pull,
* so the delta feed is applied first and a recount is only asked for if that did not help.
* A fully loaded unread-only list that shows fewer unread entries than its counter. The
* counter is wrong then: every unread entry the server knows of is on screen.
*/
export const useSyncUnreadWhenUnMatch = (entryIds: string[]) => {
export const hasUnreadCounterAboveList = (entryIds: string[], scope: UnreadListScope) => {
const expected = getUnreadCountForScope(scope)
if (!expected) return false
let shown = 0
for (const entryId of entryIds) {
const entry = getEntry(entryId)
if (entry && !entry.read) shown += 1
}
return expected > shown
}
export interface SyncUnreadWhenUnMatchOptions {
/** The list only holds unread entries and has no more pages to load. */
complete?: boolean
scope?: UnreadListScope
}
/**
* Notice counters that disagree with the list on screen: lower than the unread entries it
* shows, or higher than a fully loaded unread-only list. Without the sync engine the
* counters are fetched again. With it, the list is usually just ahead of the next pull, so
* the delta feed is applied first and a recount is only asked for if that did not help.
*/
export const useSyncUnreadWhenUnMatch = (
entryIds: string[],
options?: SyncUnreadWhenUnMatchOptions,
) => {
const complete = options?.complete === true
const scope = options?.scope
const scopeKey = scope ? JSON.stringify(scope) : ""
useEffect(() => {
if (!hasUnreadMismatch(entryIds)) return
const mismatch = () =>
hasUnreadMismatch(entryIds) ||
(complete && scope !== undefined && hasUnreadCounterAboveList(entryIds, scope))
if (!mismatch()) return
if (!isSyncEngineActive()) {
unreadSyncService.resetFromRemote()
@@ -61,11 +96,11 @@ export const useSyncUnreadWhenUnMatch = (entryIds: string[]) => {
void (async () => {
await ensureSyncedThroughEngine()
if (hasUnreadMismatch(entryIds)) {
if (mismatch()) {
await requestUnreadCalibration()
}
})()
}, [entryIds.toString()])
}, [entryIds.toString(), complete, scopeKey])
}
export const useAutoMarkAsRead = (entryId: string, enabled: boolean) => {
@@ -3,7 +3,8 @@ import { FollowAPIError } from "@follow-app/client-sdk"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { apiContext } from "../../context"
import { setSyncEngineActive } from "../../sync/sync-status"
import type { SyncEngineHandle } from "../../sync/sync-status"
import { registerSyncEngine, setSyncEngineActive } from "../../sync/sync-status"
import { transactionQueue } from "../../sync/transaction-queue"
import type { FollowAPI } from "../../types"
import { entryActions, useEntryStore } from "../entry/store"
@@ -519,4 +520,56 @@ describe("unreadSyncService", () => {
entryActions.upsertManyInSession([createEntry("entry1", "feed1", true)])
expect(useEntryStore.getState().data.entry1?.read).toBe(false)
})
describe("with the sync engine", () => {
const engine = (): SyncEngineHandle & {
requestUnreadCalibration: ReturnType<typeof vi.fn>
recordUnreadSnapshot: ReturnType<typeof vi.fn>
} => ({
ensureSynced: vi.fn(async () => true),
catchUp: vi.fn(async () => true),
requestUnreadCalibration: vi.fn(async () => {}),
recordUnreadSnapshot: vi.fn(async () => {}),
})
afterEach(() => {
registerSyncEngine(null)
})
it("recounts through the engine only when the refresh asks for it", async () => {
const handle = engine()
registerSyncEngine(handle)
setSyncEngineActive(true)
await unreadSyncService.refresh()
expect(handle.ensureSynced).toHaveBeenCalledTimes(1)
expect(handle.requestUnreadCalibration).not.toHaveBeenCalled()
expect(getUnreadMock).not.toHaveBeenCalled()
await unreadSyncService.refresh({ calibrate: true })
expect(handle.requestUnreadCalibration).toHaveBeenCalledTimes(1)
expect(getUnreadMock).not.toHaveBeenCalled()
})
it("tells the engine which sync id a snapshot reflects", async () => {
const handle = engine()
registerSyncEngine(handle)
setSyncEngineActive(true)
getUnreadMock.mockResolvedValue({ data: { feed1: 2 }, lastSyncId: 42 })
await unreadSyncService.resetFromRemote({ fallbackSyncId: 7 })
expect(handle.recordUnreadSnapshot).toHaveBeenCalledWith(42)
// Without an id in the answer the caller's fallback stands in.
getUnreadMock.mockResolvedValue({ data: { feed1: 2 } })
await unreadSyncService.resetFromRemote({ fallbackSyncId: 7 })
expect(handle.recordUnreadSnapshot).toHaveBeenLastCalledWith(7)
// A snapshot that changes nothing is still a snapshot.
handle.recordUnreadSnapshot.mockClear()
await unreadSyncService.resetFromRemote()
expect(handle.recordUnreadSnapshot).not.toHaveBeenCalled()
expect(useUnreadStore.getState().data).toEqual({ feed1: 2 })
})
})
})
@@ -9,7 +9,11 @@ import { api } from "../../context"
import type { Hydratable, Resetable } from "../../lib/base"
import { createTransaction, createZustandStore } from "../../lib/helper"
import { entryReadOverlayKey } from "../../sync/overlay-keys"
import { ensureSyncedThroughEngine } from "../../sync/sync-status"
import {
ensureSyncedThroughEngine,
recordUnreadSnapshotThroughEngine,
requestUnreadCalibration,
} from "../../sync/sync-status"
import {
defineTransactionKind,
readLastSyncId,
@@ -286,21 +290,33 @@ export const markAllReadTransaction = defineTransactionKind<
class UnreadSyncService {
/**
* Bring the counters up to date after a user gesture. The delta feed does it when the sync
* engine runs; the recount is the fallback.
* engine runs; the recount is the fallback. A refresh the user asked for also recounts
* through the engine, at most once a minute, so counters that drifted from the list on
* screen do not wait for the hourly calibration.
*/
async refresh() {
if (await ensureSyncedThroughEngine()) return
async refresh({ calibrate = false }: { calibrate?: boolean } = {}) {
if (await ensureSyncedThroughEngine()) {
if (calibrate) await requestUnreadCalibration()
return
}
await this.resetFromRemote()
}
async resetFromRemote() {
/**
* Replace the confirmed counters with the server's. The answer names the sync id the
* counts reflect, so the change log is not applied on top of what the snapshot already
* contains; `fallbackSyncId` stands in for servers that do not name it.
*/
async resetFromRemote({ fallbackSyncId }: { fallbackSyncId?: number } = {}) {
const res = await api().reads.get({})
const snapshotSyncId = readLastSyncId(res) ?? fallbackSyncId
if (unreadActions.isConfirmedEqual(res.data)) {
return res.data
if (!unreadActions.isConfirmedEqual(res.data)) {
await unreadActions.upsertMany(res.data, { reset: true })
}
if (snapshotSyncId !== undefined) {
await recordUnreadSnapshotThroughEngine(snapshotSyncId)
}
await unreadActions.upsertMany(res.data, { reset: true })
return res.data
}
@@ -1,3 +1,5 @@
import type { FeedViewType } from "@follow/constants"
export interface PublishAtTimeRangeFilter {
startTime: number
endTime: number
@@ -16,3 +18,16 @@ export type UnreadStoreModel = Record<FeedIdOrInboxHandle, number>
export interface UnreadState {
data: UnreadStoreModel
}
/** What an entry list shows, in the terms the unread counters are kept in. */
export interface UnreadListScope {
view?: FeedViewType
/** A feed id, or several joined with commas for a folder. */
feedId?: string
feedIdList?: string[]
listId?: string
inboxId?: string
isCollection?: boolean
/** Private subscriptions are hidden from the timeline, so their counters are left out. */
excludePrivate?: boolean
}
@@ -720,6 +720,126 @@ describe("syncEngine", () => {
expect(subscriptionsGetMock).toHaveBeenCalledTimes(1)
})
it("does not add new-entry hints a recount already counted, even when they arrive later", async () => {
const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000
seedCursor(200, twoHoursAgo)
// The crawler logged four new entries at 201, but this poll is still answered from a
// head published before that, so the hint is not delivered yet.
deltaMock.mockResolvedValueOnce(deltaResponse([], { lastSyncId: 200 }))
// The recount that follows already sees the four entries.
stateMock.mockResolvedValue({ code: 0, data: { lastSyncId: 201 } })
readsGetMock.mockResolvedValue({ data: { "feed-1": 4 } })
await syncEngine.pull("interval")
expect(useUnreadStore.getState().data["feed-1"]).toBe(4)
expect(syncMetaStore.get("unreadSnapshotSyncId")).toBe("201")
// The user reads one of them; the next poll delivers the hint and the flip together.
deltaMock.mockResolvedValueOnce(
deltaResponse([
createAction({
id: 201,
action: "N",
modelId: "feed-1",
data: {
feedId: "feed-1",
count: 4,
unread: 4,
latestPublishedAt: "2026-09-18T14:31:00.000Z",
from: ["feed"],
},
}),
createAction({
id: 202,
data: { entryIds: ["entry1"], read: true, isInbox: false, feeds: { "feed-1": 1 } },
}),
]),
)
await syncEngine.pull("interval")
// Four minus one, not four plus four minus one.
expect(useUnreadStore.getState().data["feed-1"]).toBe(3)
expect(setFeedUnreadDirtyMock).toHaveBeenCalledWith("feed-1")
})
it("takes the snapshot id from the recount answer and keeps it across restarts", async () => {
seedCursor(300, Date.now() - 2 * 60 * 60 * 1000)
deltaMock.mockResolvedValueOnce(deltaResponse([], { lastSyncId: 300 }))
stateMock.mockResolvedValue({ code: 0, data: { lastSyncId: 301 } })
// The server names the id its counts reflect; it beats the state read before.
readsGetMock.mockResolvedValue({ data: { "feed-1": 2 }, lastSyncId: 305 })
await syncEngine.pull("interval")
expect(syncMetaStore.get("unreadSnapshotSyncId")).toBe("305")
// A restart loads the id again: 305 is inside the snapshot, 306 is not.
syncEngine.clearInSession()
seedCursor(300)
deltaMock.mockResolvedValueOnce(
deltaResponse([
createAction({
id: 305,
action: "N",
modelId: "feed-1",
data: {
feedId: "feed-1",
count: 2,
unread: 2,
latestPublishedAt: "2026-09-18T00:00:00.000Z",
from: ["feed"],
},
}),
createAction({
id: 306,
action: "N",
modelId: "feed-1",
data: {
feedId: "feed-1",
count: 1,
unread: 1,
latestPublishedAt: "2026-09-18T00:00:00.000Z",
from: ["feed"],
},
}),
]),
)
await syncEngine.pull("interval")
expect(useUnreadStore.getState().data["feed-1"]).toBe(3)
expect(readsGetMock).toHaveBeenCalledTimes(1)
})
it("still flips read state and settles transactions for actions inside the snapshot", async () => {
seedCursor(400, Date.now() - 2 * 60 * 60 * 1000)
useEntryStore.setState((state) => ({
...state,
data: { entry1: createEntry("entry1", "feed-1") },
entryIdSet: new Set(["entry1"]),
}))
deltaMock.mockResolvedValueOnce(deltaResponse([], { lastSyncId: 400 }))
stateMock.mockResolvedValue({ code: 0, data: { lastSyncId: 400 } })
// Another device read entry1 at 401, and the recount already reflects that.
readsGetMock.mockResolvedValue({ data: { "feed-1": 1 }, lastSyncId: 401 })
await syncEngine.pull("interval")
expect(useUnreadStore.getState().data["feed-1"]).toBe(1)
deltaMock.mockResolvedValueOnce(
deltaResponse([
createAction({
id: 401,
data: { entryIds: ["entry1"], read: true, isInbox: false, feeds: { "feed-1": 1 } },
}),
]),
)
await syncEngine.pull("interval")
expect(useEntryStore.getState().data.entry1?.read).toBe(true)
expect(entryPatchManyMock).toHaveBeenCalledWith({ entry: { read: true }, entryIds: ["entry1"] })
// The counter was not moved twice.
expect(useUnreadStore.getState().data["feed-1"]).toBe(1)
})
it("lets callers rely on the local snapshot once a cursor exists", async () => {
seedCursor(100)
deltaMock.mockResolvedValue(deltaResponse([], { lastSyncId: 100 }))
@@ -56,6 +56,8 @@ import type {
const LAST_SYNC_ID_KEY = "lastSyncId"
const UNREAD_CALIBRATED_AT_KEY = "unreadCalibratedAt"
/** The sync id the last unread snapshot reflects; counters logged up to it are not applied again. */
const UNREAD_SNAPSHOT_SYNC_ID_KEY = "unreadSnapshotSyncId"
const SUBSCRIPTIONS_CALIBRATED_AT_KEY = "subscriptionsCalibratedAt"
/** Set once a registered model was loaded in full, so later launches rely on the change log. */
const modelBootstrappedKey = (model: string) => `bootstrapped:${model}`
@@ -109,6 +111,7 @@ class SyncEngine implements Resetable {
private loaded = false
private unavailable = false
private unreadCalibratedAt = 0
private unreadSnapshotSyncId = 0
private subscriptionsCalibratedAt = 0
private lastPullFinishedAt = 0
/** Models whose actions were skipped this session because nobody handled them yet. */
@@ -144,14 +147,17 @@ class SyncEngine implements Resetable {
private async loadCursor() {
if (this.loaded) return
try {
const [stored, unreadCalibratedAt, subscriptionsCalibratedAt] = await Promise.all([
SyncMetaService.get(LAST_SYNC_ID_KEY),
SyncMetaService.get(UNREAD_CALIBRATED_AT_KEY),
SyncMetaService.get(SUBSCRIPTIONS_CALIBRATED_AT_KEY),
])
const [stored, unreadCalibratedAt, unreadSnapshotSyncId, subscriptionsCalibratedAt] =
await Promise.all([
SyncMetaService.get(LAST_SYNC_ID_KEY),
SyncMetaService.get(UNREAD_CALIBRATED_AT_KEY),
SyncMetaService.get(UNREAD_SNAPSHOT_SYNC_ID_KEY),
SyncMetaService.get(SUBSCRIPTIONS_CALIBRATED_AT_KEY),
])
const parsed = stored === null ? Number.NaN : Number(stored)
this.lastSyncId = Number.isFinite(parsed) ? parsed : null
this.unreadCalibratedAt = Number(unreadCalibratedAt) || 0
this.unreadSnapshotSyncId = Number(unreadSnapshotSyncId) || 0
this.subscriptionsCalibratedAt = Number(subscriptionsCalibratedAt) || 0
setSyncEngineActive(this.lastSyncId !== null)
} catch (error) {
@@ -242,7 +248,8 @@ class SyncEngine implements Resetable {
await subscriptionSyncService.fetch()
await this.markCalibrated(SUBSCRIPTIONS_CALIBRATED_AT_KEY)
await unreadSyncService.resetFromRemote()
// The snapshot reflects at least the id read above; the response may name a later one.
await unreadSyncService.resetFromRemote({ fallbackSyncId: lastSyncId })
await this.markCalibrated(UNREAD_CALIBRATED_AT_KEY)
await this.bootstrapRegisteredModels({ force: true })
await this.setLastSyncId(lastSyncId)
@@ -273,11 +280,37 @@ class SyncEngine implements Resetable {
private async calibrateUnread() {
// Claim the slot first so concurrent triggers do not recount twice.
await this.markCalibrated(UNREAD_CALIBRATED_AT_KEY)
await unreadSyncService.resetFromRemote().catch((error) => {
await this.takeUnreadSnapshot().catch((error) => {
console.error("[sync-engine] failed to recount unread entries", error)
})
}
/**
* Recount through `/reads`. The snapshot is paired with the sync id it reflects, so the
* counters of actions logged before it are not added on top when the log delivers them
* later. That happens whenever the snapshot runs ahead of the pull that carries those
* actions: a poll answered from a stale head, or new-entry hints logged seconds earlier.
*
* A server that does not name the id in its answer gets the state read just before: every
* action visible then has its change inside a snapshot taken afterwards.
*/
private async takeUnreadSnapshot() {
const fallbackSyncId = await syncApi()
.state()
.then((response) => response.data.lastSyncId)
.catch(() => undefined)
await unreadSyncService.resetFromRemote({ fallbackSyncId })
}
/** Remember how far the log was reflected by the last unread snapshot. */
async recordUnreadSnapshot(lastSyncId: number) {
if (!Number.isFinite(lastSyncId) || lastSyncId <= this.unreadSnapshotSyncId) return
this.unreadSnapshotSyncId = lastSyncId
await SyncMetaService.set(UNREAD_SNAPSHOT_SYNC_ID_KEY, String(lastSyncId)).catch((error) => {
console.error("[sync-engine] failed to persist the unread snapshot id", error)
})
}
private async calibrateSubscriptions() {
await this.markCalibrated(SUBSCRIPTIONS_CALIBRATED_AT_KEY)
await subscriptionSyncService.fetch().catch((error) => {
@@ -318,6 +351,7 @@ class SyncEngine implements Resetable {
this.loaded = false
this.unavailable = false
this.unreadCalibratedAt = 0
this.unreadSnapshotSyncId = 0
this.subscriptionsCalibratedAt = 0
this.lastPullFinishedAt = 0
this.unhandledModels.clear()
@@ -536,6 +570,9 @@ class SyncEngine implements Resetable {
}
private async applyTimelineAction(action: SyncAction, summary: PullSummary) {
// The last snapshot already counted everything logged up to its id.
const countersInSnapshot = action.id <= this.unreadSnapshotSyncId
if (action.action === "U") {
if (!isRecord(action.data) || !Array.isArray(action.data.entryIds)) return
const data = action.data as unknown as TimelineReadActionData
@@ -550,6 +587,8 @@ class SyncEngine implements Resetable {
await EntryService.patchMany({ entry: { read: data.read }, entryIds })
}
if (countersInSnapshot) return
// The counters follow the server even when a local transaction keeps an entry's read
// state: that transaction is rebased on top of the confirmed counts.
if (isCountRecord(data.feeds)) {
@@ -584,6 +623,7 @@ class SyncEngine implements Resetable {
summary.newestEntryAt = Math.max(summary.newestEntryAt, loggedAt)
}
if (countersInSnapshot) return
if (typeof data.unread === "number") {
await unreadActions.applyConfirmedDelta({ [id]: data.unread })
} else {
@@ -677,6 +717,7 @@ class SyncEngine implements Resetable {
if (data.inboxId) {
setFeedUnreadDirty(data.inboxId)
}
if (action.id <= this.unreadSnapshotSyncId) return
if (typeof data.unread !== "boolean" || !data.inboxId) {
summary.refreshUnread = true
} else if (data.unread) {
@@ -22,6 +22,8 @@ export interface SyncEngineHandle {
ensureSynced: () => Promise<boolean>
catchUp: (delayMs?: number) => Promise<boolean>
requestUnreadCalibration: () => Promise<void>
/** A full unread snapshot was taken; the counters logged up to this id are inside it. */
recordUnreadSnapshot: (lastSyncId: number) => Promise<void>
}
let engine: SyncEngineHandle | null = null
@@ -42,3 +44,6 @@ export const catchUpThroughEngine = (delayMs?: number) =>
export const requestUnreadCalibration = () =>
engine?.requestUnreadCalibration() ?? Promise.resolve()
export const recordUnreadSnapshotThroughEngine = (lastSyncId: number) =>
engine?.recordUnreadSnapshot(lastSyncId) ?? Promise.resolve()