fix: harden setting sync auth lifecycle (#4872)
* fix: harden setting sync auth lifecycle * fix: preserve legacy setting sync queue * fix: keep setting queue on auth flaps * fix: persist queue reset on auth errors * fix: initialize setting sync queue earlier * fix: await mobile sync queue load
This commit is contained in:
parent
a491a82b58
commit
330ee9cd1a
|
|
@ -2,6 +2,8 @@ import { initializeDayjs } from "@follow/components/dayjs"
|
|||
import { registerGlobalContext } from "@follow/shared/bridge"
|
||||
import { DEV, ELECTRON_BUILD, IN_ELECTRON } from "@follow/shared/constants"
|
||||
import { hydrateDatabaseToStore } from "@follow/store/hydrate"
|
||||
import { whoami } from "@follow/store/user/getters"
|
||||
import { userSyncService } from "@follow/store/user/store"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { repository } from "@pkg"
|
||||
import { enableMapSet } from "immer"
|
||||
|
|
@ -82,14 +84,25 @@ export const initializeApp = async () => {
|
|||
apm("initializeSettings", initializeSettings)
|
||||
|
||||
await apm("i18n", initI18n)
|
||||
|
||||
apm("setting sync", () => {
|
||||
settingSyncQueue.init()
|
||||
settingSyncQueue.syncLocal()
|
||||
})
|
||||
|
||||
await apm("initAnalytics", initAnalytics)
|
||||
|
||||
void apm("setting sync", async () => {
|
||||
await settingSyncQueue.init()
|
||||
|
||||
await userSyncService.whoami().catch(() => null)
|
||||
|
||||
if (!whoami()) {
|
||||
return
|
||||
}
|
||||
await settingSyncQueue.syncLocal()
|
||||
}).catch((error) => {
|
||||
appLog("setting sync failed", error)
|
||||
void tracker.manager.captureException(error, {
|
||||
module: "setting_sync",
|
||||
stage: "bootstrap",
|
||||
})
|
||||
})
|
||||
|
||||
const loadingTime = Date.now() - now
|
||||
appLog(`Initialize ${APP_NAME} done,`, `${loadingTime}ms`)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import type { AISettings, GeneralSettings, UISettings } from "@follow/shared/settings/interface"
|
||||
import { whoami } from "@follow/store/user/getters"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { getStorageNS } from "@follow/utils/ns"
|
||||
import { isEmptyObject, sleep } from "@follow/utils/utils"
|
||||
import type { SettingsTab } from "@follow-app/client-sdk"
|
||||
import { FollowAPIError } from "@follow-app/client-sdk"
|
||||
import { omit } from "es-toolkit/compat"
|
||||
import type { PrimitiveAtom } from "jotai"
|
||||
|
||||
|
|
@ -56,14 +59,56 @@ const bizSettingKeyToTabMapping = {
|
|||
ai: "ai",
|
||||
}
|
||||
|
||||
const isUnauthorizedError = (error: unknown) => {
|
||||
if (error instanceof FollowAPIError) {
|
||||
return error.status === 401
|
||||
}
|
||||
|
||||
if (error && typeof error === "object" && "status" in error) {
|
||||
return Number((error as { status?: unknown }).status) === 401
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export type SettingSyncTab = keyof SettingMapping
|
||||
export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {
|
||||
tab: T
|
||||
payload: Partial<SettingMapping[T]>
|
||||
date: number
|
||||
}
|
||||
|
||||
interface PersistedSettingSyncQueue {
|
||||
ownerUserId: string | null
|
||||
queue: SettingSyncQueueItem[]
|
||||
}
|
||||
|
||||
class SettingSyncQueue {
|
||||
queue: SettingSyncQueueItem[] = []
|
||||
private ownerUserId: string | null = null
|
||||
|
||||
private getCurrentUserId() {
|
||||
return whoami()?.id ?? null
|
||||
}
|
||||
|
||||
private bindQueueOwner(currentUserId: string) {
|
||||
if (this.ownerUserId === null) {
|
||||
this.ownerUserId = currentUserId
|
||||
return
|
||||
}
|
||||
|
||||
if (this.ownerUserId !== currentUserId) {
|
||||
this.ownerUserId = currentUserId
|
||||
this.queue = []
|
||||
}
|
||||
}
|
||||
|
||||
private reportSyncError(stage: "flush" | "syncLocal", error: unknown) {
|
||||
void tracker.manager.captureException(error, {
|
||||
module: "setting_sync",
|
||||
stage,
|
||||
})
|
||||
}
|
||||
|
||||
private disposers: (() => void)[] = []
|
||||
async init() {
|
||||
|
|
@ -72,6 +117,11 @@ class SettingSyncQueue {
|
|||
this.load()
|
||||
|
||||
const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) return
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
const tab = bizSettingKeyToTabMapping[data.key]
|
||||
if (!tab) return
|
||||
|
||||
|
|
@ -97,14 +147,21 @@ class SettingSyncQueue {
|
|||
disposer()
|
||||
}
|
||||
this.queue = []
|
||||
this.ownerUserId = null
|
||||
}
|
||||
|
||||
private readonly storageKey = getStorageNS("setting_sync_queue")
|
||||
private persist() {
|
||||
if (this.queue.length === 0) {
|
||||
localStorage.removeItem(this.storageKey)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(this.queue))
|
||||
|
||||
const payload: PersistedSettingSyncQueue = {
|
||||
ownerUserId: this.ownerUserId,
|
||||
queue: this.queue,
|
||||
}
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
private load() {
|
||||
|
|
@ -114,11 +171,37 @@ class SettingSyncQueue {
|
|||
return
|
||||
}
|
||||
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
|
||||
try {
|
||||
this.queue = JSON.parse(queue)
|
||||
const parsed = JSON.parse(queue) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
// Backward compatibility: legacy versions persisted the queue array directly.
|
||||
this.queue = parsed
|
||||
this.ownerUserId = currentUserId
|
||||
} else if (!parsed || typeof parsed !== "object") {
|
||||
this.queue = []
|
||||
this.ownerUserId = null
|
||||
return
|
||||
} else {
|
||||
const payload = parsed as Partial<PersistedSettingSyncQueue>
|
||||
this.queue = Array.isArray(payload.queue) ? payload.queue : []
|
||||
if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) {
|
||||
this.ownerUserId = payload.ownerUserId
|
||||
} else {
|
||||
// Backward compatibility for payloads without owner information.
|
||||
this.ownerUserId = currentUserId
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
if (!currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
}
|
||||
|
||||
private chain = Promise.resolve()
|
||||
|
|
@ -127,6 +210,13 @@ class SettingSyncQueue {
|
|||
private enqueueTime = Date.now()
|
||||
|
||||
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
const now = Date.now()
|
||||
if (isEmptyObject(payload)) {
|
||||
return
|
||||
|
|
@ -144,6 +234,13 @@ class SettingSyncQueue {
|
|||
}
|
||||
|
||||
private async flush() {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
if (navigator.onLine === false) {
|
||||
return
|
||||
}
|
||||
|
|
@ -191,10 +288,27 @@ class SettingSyncQueue {
|
|||
promises.push(promise)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
try {
|
||||
await Promise.all(promises)
|
||||
} catch (error) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
this.queue = []
|
||||
this.ownerUserId = currentUserId
|
||||
return
|
||||
}
|
||||
|
||||
this.reportSyncError("flush", error)
|
||||
}
|
||||
}
|
||||
|
||||
replaceRemote(tab?: SettingSyncTab) {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return this.chain
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
if (!tab) {
|
||||
const promises = [] as Promise<any>[]
|
||||
for (const tab in localSettingGetterMap) {
|
||||
|
|
@ -225,7 +339,24 @@ class SettingSyncQueue {
|
|||
}
|
||||
|
||||
async syncLocal() {
|
||||
const remoteSettings = await settings.get().prefetch()
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) return
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
const remoteSettings = await settings
|
||||
.get()
|
||||
.prefetch()
|
||||
.catch((error) => {
|
||||
if (isUnauthorizedError(error)) {
|
||||
this.queue = []
|
||||
this.ownerUserId = currentUserId
|
||||
return null
|
||||
}
|
||||
|
||||
this.reportSyncError("syncLocal", error)
|
||||
return null
|
||||
})
|
||||
|
||||
if (!remoteSettings) return
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { initializeDB } from "@follow/database/db"
|
||||
import { hydrateDatabaseToStore } from "@follow/store/hydrate"
|
||||
import { whoami } from "@follow/store/user/getters"
|
||||
import { userSyncService } from "@follow/store/user/store"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { nativeApplicationVersion } from "expo-application"
|
||||
|
||||
|
|
@ -14,6 +16,20 @@ import { hydrateQueryClient, hydrateSettings } from "./hydrate"
|
|||
import { migrateDatabase } from "./migration"
|
||||
import { initializePlayer } from "./player"
|
||||
|
||||
type RequestIdleCallback = (callback: () => void, options?: { timeout?: number }) => number
|
||||
|
||||
const runWhenIdle = (callback: () => void) => {
|
||||
const requestIdle = (globalThis as { requestIdleCallback?: RequestIdleCallback })
|
||||
.requestIdleCallback
|
||||
|
||||
if (requestIdle) {
|
||||
requestIdle(callback, { timeout: 5000 })
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(callback, 0)
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
export const initializeApp = async () => {
|
||||
console.log(`Initialize...`)
|
||||
|
|
@ -38,19 +54,27 @@ export const initializeApp = async () => {
|
|||
dataHydratedTime = Date.now() - dataHydratedTime
|
||||
await apm("hydrateQueryClient", hydrateQueryClient)
|
||||
await apm("initializeAppCheck", initializeAppCheck)
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
apm("initializePlayer", initializePlayer)
|
||||
},
|
||||
{ timeout: 5000 }, // Max delay of 5 seconds
|
||||
)
|
||||
|
||||
apm("setting sync", () => {
|
||||
settingSyncQueue.init()
|
||||
settingSyncQueue.syncLocal()
|
||||
runWhenIdle(() => {
|
||||
apm("initializePlayer", initializePlayer)
|
||||
})
|
||||
|
||||
await initAnalytics()
|
||||
|
||||
void apm("setting sync", async () => {
|
||||
await settingSyncQueue.init()
|
||||
|
||||
await userSyncService.whoami().catch(() => null)
|
||||
|
||||
if (!whoami()) {
|
||||
return
|
||||
}
|
||||
await settingSyncQueue.syncLocal()
|
||||
}).catch((error) => {
|
||||
console.error("setting sync failed", error)
|
||||
void tracker.manager.captureException(error, {
|
||||
module: "setting_sync",
|
||||
stage: "bootstrap",
|
||||
})
|
||||
})
|
||||
const loadingTime = Date.now() - now
|
||||
tracker.appInit({
|
||||
rn: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type { GeneralSettings, UISettings } from "@follow/shared/settings/interface"
|
||||
import { whoami } from "@follow/store/user/getters"
|
||||
import { tracker } from "@follow/tracker"
|
||||
import { isEmptyObject, jotaiStore, sleep } from "@follow/utils"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import type { SettingsTab } from "@follow-app/client-sdk"
|
||||
import { FollowAPIError } from "@follow-app/client-sdk"
|
||||
import { omit } from "es-toolkit/compat"
|
||||
import type { PrimitiveAtom } from "jotai"
|
||||
|
||||
|
|
@ -47,6 +50,18 @@ const bizSettingKeyToTabMapping = {
|
|||
general: "general",
|
||||
}
|
||||
|
||||
const isUnauthorizedError = (error: unknown) => {
|
||||
if (error instanceof FollowAPIError) {
|
||||
return error.status === 401
|
||||
}
|
||||
|
||||
if (error && typeof error === "object" && "status" in error) {
|
||||
return Number((error as { status?: unknown }).status) === 401
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export type SettingSyncTab = keyof SettingMapping
|
||||
export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab> {
|
||||
tab: T
|
||||
|
|
@ -54,6 +69,11 @@ export interface SettingSyncQueueItem<T extends SettingSyncTab = SettingSyncTab>
|
|||
date: number
|
||||
}
|
||||
|
||||
interface PersistedSettingSyncQueue {
|
||||
ownerUserId: string | null
|
||||
queue: SettingSyncQueueItem[]
|
||||
}
|
||||
|
||||
declare module "@follow/utils/event-bus" {
|
||||
interface CustomEvent {
|
||||
SETTING_CHANGE_EVENT: {
|
||||
|
|
@ -65,14 +85,49 @@ declare module "@follow/utils/event-bus" {
|
|||
|
||||
class SettingSyncQueue {
|
||||
queue: SettingSyncQueueItem[] = []
|
||||
private ownerUserId: string | null = null
|
||||
|
||||
private getCurrentUserId() {
|
||||
return whoami()?.id ?? null
|
||||
}
|
||||
|
||||
private bindQueueOwner(currentUserId: string) {
|
||||
if (this.ownerUserId === null) {
|
||||
this.ownerUserId = currentUserId
|
||||
return
|
||||
}
|
||||
|
||||
if (this.ownerUserId !== currentUserId) {
|
||||
this.ownerUserId = currentUserId
|
||||
this.queue = []
|
||||
}
|
||||
}
|
||||
|
||||
private reportSyncError(stage: "flush" | "syncLocal", error: unknown) {
|
||||
void tracker.manager.captureException(error, {
|
||||
module: "setting_sync",
|
||||
stage,
|
||||
})
|
||||
}
|
||||
|
||||
private async clearQueueAndPersist(ownerUserId: string | null) {
|
||||
this.queue = []
|
||||
this.ownerUserId = ownerUserId
|
||||
await kv.delete(this.storageKey)
|
||||
}
|
||||
|
||||
private disposers: (() => void)[] = []
|
||||
async init() {
|
||||
this.teardown()
|
||||
|
||||
this.load()
|
||||
const loadPromise = this.load()
|
||||
|
||||
const d1 = EventBus.subscribe("SETTING_CHANGE_EVENT", (data) => {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) return
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
const tab = bizSettingKeyToTabMapping[data.key] as SettingSyncTab
|
||||
if (!tab) return
|
||||
|
||||
|
|
@ -80,10 +135,12 @@ class SettingSyncQueue {
|
|||
if (isEmptyObject(nextPayload)) return
|
||||
this.enqueue(tab, nextPayload)
|
||||
|
||||
this.persist()
|
||||
void this.persist()
|
||||
})
|
||||
|
||||
this.disposers.push(d1)
|
||||
|
||||
await loadPromise
|
||||
}
|
||||
|
||||
teardown() {
|
||||
|
|
@ -91,28 +148,69 @@ class SettingSyncQueue {
|
|||
disposer()
|
||||
}
|
||||
this.queue = []
|
||||
this.ownerUserId = null
|
||||
}
|
||||
|
||||
private readonly storageKey = "setting_sync_queue"
|
||||
private async persist() {
|
||||
if (this.queue.length === 0) {
|
||||
kv.delete(this.storageKey)
|
||||
return
|
||||
}
|
||||
kv.set(this.storageKey, JSON.stringify(this.queue))
|
||||
|
||||
const payload: PersistedSettingSyncQueue = {
|
||||
ownerUserId: this.ownerUserId,
|
||||
queue: this.queue,
|
||||
}
|
||||
kv.set(this.storageKey, JSON.stringify(payload))
|
||||
}
|
||||
|
||||
private async load() {
|
||||
const queue = await kv.get(this.storageKey)
|
||||
kv.delete(this.storageKey)
|
||||
await kv.delete(this.storageKey)
|
||||
if (!queue) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
let nextQueue: SettingSyncQueueItem[] = []
|
||||
let nextOwnerUserId: string | null = null
|
||||
|
||||
try {
|
||||
this.queue = JSON.parse(queue)
|
||||
const parsed = JSON.parse(queue) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
// Backward compatibility: legacy versions persisted the queue array directly.
|
||||
nextQueue = parsed
|
||||
nextOwnerUserId = currentUserId
|
||||
} else if (!parsed || typeof parsed !== "object") {
|
||||
return
|
||||
} else {
|
||||
const payload = parsed as Partial<PersistedSettingSyncQueue>
|
||||
nextQueue = Array.isArray(payload.queue) ? payload.queue : []
|
||||
if (typeof payload.ownerUserId === "string" || payload.ownerUserId === null) {
|
||||
nextOwnerUserId = payload.ownerUserId
|
||||
} else {
|
||||
// Backward compatibility for payloads without owner information.
|
||||
nextOwnerUserId = currentUserId
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
return
|
||||
}
|
||||
|
||||
// If queue state has already changed after init starts, keep the newer in-memory state.
|
||||
if (this.queue.length > 0 || this.ownerUserId !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
this.queue = nextQueue
|
||||
this.ownerUserId = nextOwnerUserId
|
||||
|
||||
if (!currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
}
|
||||
|
||||
private chain = Promise.resolve()
|
||||
|
|
@ -121,6 +219,13 @@ class SettingSyncQueue {
|
|||
private enqueueTime = Date.now()
|
||||
|
||||
async enqueue<T extends SettingSyncTab>(tab: T, payload: Partial<SettingMapping[T]>) {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
const now = Date.now()
|
||||
if (isEmptyObject(payload)) {
|
||||
return
|
||||
|
|
@ -138,6 +243,13 @@ class SettingSyncQueue {
|
|||
}
|
||||
|
||||
private async flush() {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
if (navigator.onLine === false) {
|
||||
return
|
||||
}
|
||||
|
|
@ -189,10 +301,26 @@ class SettingSyncQueue {
|
|||
promises.push(promise)
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
try {
|
||||
await Promise.all(promises)
|
||||
} catch (error) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
await this.clearQueueAndPersist(currentUserId)
|
||||
return
|
||||
}
|
||||
|
||||
this.reportSyncError("flush", error)
|
||||
}
|
||||
}
|
||||
|
||||
replaceRemote(tab?: SettingSyncTab) {
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) {
|
||||
return this.chain
|
||||
}
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
if (!tab) {
|
||||
const promises = [] as Promise<any>[]
|
||||
for (const tab in localSettingGetterMap) {
|
||||
|
|
@ -238,7 +366,23 @@ class SettingSyncQueue {
|
|||
return promise
|
||||
}
|
||||
async syncLocal() {
|
||||
const remoteSettings = await this.fetchSettingRemote()
|
||||
const currentUserId = this.getCurrentUserId()
|
||||
if (!currentUserId) return
|
||||
|
||||
this.bindQueueOwner(currentUserId)
|
||||
|
||||
let remoteSettings: Awaited<ReturnType<typeof this.fetchSettingRemote>> | null = null
|
||||
try {
|
||||
remoteSettings = await this.fetchSettingRemote()
|
||||
} catch (error) {
|
||||
if (isUnauthorizedError(error)) {
|
||||
await this.clearQueueAndPersist(currentUserId)
|
||||
return
|
||||
}
|
||||
|
||||
this.reportSyncError("syncLocal", error)
|
||||
return
|
||||
}
|
||||
|
||||
if (!remoteSettings) return
|
||||
if (__DEV__) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue