From e59533b38a5795ad38efd50d125f9155f7d06cae Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 31 Aug 2026 14:08:34 -0500 Subject: [PATCH] Move session persistence into base API --- src/state/persisted/__tests__/api-test.ts | 2 +- src/state/persisted/index.ts | 12 +++++-- src/state/persisted/index.web.ts | 12 +++++-- src/state/persisted/session.ts | 35 ------------------- src/state/persisted/storage-lock.ts | 3 ++ src/state/persisted/storage-lock.web.ts | 3 ++ src/state/persisted/types.ts | 9 +++++ .../session/__tests__/provider-abort-test.tsx | 8 +++-- .../__tests__/provider-clients-test.tsx | 8 +++-- .../provider-refresh-session-test.tsx | 8 +++-- .../provider-session-events-test.tsx | 10 +++--- src/state/session/index.tsx | 21 ++++++----- src/state/session/util.ts | 4 +-- 13 files changed, 68 insertions(+), 67 deletions(-) delete mode 100644 src/state/persisted/session.ts diff --git a/src/state/persisted/__tests__/api-test.ts b/src/state/persisted/__tests__/api-test.ts index 89a8b83007..adf023f2c0 100644 --- a/src/state/persisted/__tests__/api-test.ts +++ b/src/state/persisted/__tests__/api-test.ts @@ -6,7 +6,7 @@ import {defaults} from '../schema' describe('generic persisted API', () => { it('rejects session writes', () => { expect(() => persisted.write('session', defaults.session)).toThrow( - "Session state must be written through '#/state/persisted/session'", + 'Session state must be written through persisted.writeSession()', ) }) }) diff --git a/src/state/persisted/index.ts b/src/state/persisted/index.ts index d792bc6ea1..1fd22d6f92 100644 --- a/src/state/persisted/index.ts +++ b/src/state/persisted/index.ts @@ -15,6 +15,11 @@ import { import {type PersistedApi} from './types' import {normalizeData} from './util' +export type {SessionCredentialMutation} from './session-merge' +export { + runWithPersistedStorageLock as runWithCredentialLock, + runWithPersistedStorageLock, +} from './storage-lock' export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' @@ -59,7 +64,7 @@ export function write( ): Promise { if (key === 'session') { throw new Error( - "Session state must be written through '#/state/persisted/session'", + 'Session state must be written through persisted.writeSession()', ) } return enqueueWrite(async () => { @@ -73,8 +78,7 @@ export function write( } write satisfies PersistedApi['write'] -/** @internal Use `#/state/persisted/session` instead. */ -export function writeSessionInternal({ +export function writeSession({ nextSession, credentialMutations, }: { @@ -93,6 +97,8 @@ export function writeSessionInternal({ return session }) } +writeSession satisfies PersistedApi['writeSession'] + export function onUpdate( _key: K, _cb: (v: Schema[K]) => void, diff --git a/src/state/persisted/index.web.ts b/src/state/persisted/index.web.ts index f0de527a16..eff0aad4ea 100644 --- a/src/state/persisted/index.web.ts +++ b/src/state/persisted/index.web.ts @@ -16,6 +16,11 @@ import {runWithPersistedStorageLock} from './storage-lock' import {type PersistedApi} from './types' import {normalizeData} from './util' +export type {SessionCredentialMutation} from './session-merge' +export { + runWithPersistedStorageLock as runWithCredentialLock, + runWithPersistedStorageLock, +} from './storage-lock' export type {PersistedAccount, Schema} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema' @@ -73,7 +78,7 @@ export function write( ): Promise { if (key === 'session') { throw new Error( - "Session state must be written through '#/state/persisted/session'", + 'Session state must be written through persisted.writeSession()', ) } return runWithPersistedStorageLock({ @@ -107,9 +112,8 @@ export function write( } write satisfies PersistedApi['write'] -/** @internal Use `#/state/persisted/session` instead. */ // eslint-disable-next-line @typescript-eslint/require-await -export async function writeSessionInternal({ +export async function writeSession({ nextSession, credentialMutations, }: { @@ -129,6 +133,8 @@ export async function writeSessionInternal({ broadcastUpdate({key: 'session'}) return session } +writeSession satisfies PersistedApi['writeSession'] + export function onUpdate( key: K, cb: (v: Schema[K]) => void, diff --git a/src/state/persisted/session.ts b/src/state/persisted/session.ts deleted file mode 100644 index 1a957aa22c..0000000000 --- a/src/state/persisted/session.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as persisted from './index' -import {type Schema} from './schema' -import {type SessionCredentialMutation} from './session-merge' - -export type {SessionCredentialMutation} from './session-merge' -export {runWithPersistedStorageLock as runWithCredentialLock} from './storage-lock' - -export function read(): Schema['session'] { - return persisted.get('session') -} - -/** On web, synchronously read the authoritative localStorage session. */ -export function readLatest(): Schema['session'] { - return persisted.readLatest('session') -} - -/** Conditionally commit a session update inside {@link runWithCredentialLock}. */ -export function write({ - nextSession, - credentialMutations, -}: { - nextSession: Schema['session'] - credentialMutations: SessionCredentialMutation[] -}): Promise { - return persisted.writeSessionInternal({ - nextSession, - credentialMutations, - }) -} - -export function onUpdate( - callback: (session: Schema['session']) => void, -): () => void { - return persisted.onUpdate('session', callback) -} diff --git a/src/state/persisted/storage-lock.ts b/src/state/persisted/storage-lock.ts index 1fb4ef3e65..35201a7aab 100644 --- a/src/state/persisted/storage-lock.ts +++ b/src/state/persisted/storage-lock.ts @@ -1,3 +1,5 @@ +import {type PersistedApi} from './types' + export function runWithPersistedStorageLock({ operation, }: { @@ -13,3 +15,4 @@ export function runWithPersistedStorageLock({ ) } } +runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock'] diff --git a/src/state/persisted/storage-lock.web.ts b/src/state/persisted/storage-lock.web.ts index bfbb7e7526..8023caa7a1 100644 --- a/src/state/persisted/storage-lock.web.ts +++ b/src/state/persisted/storage-lock.web.ts @@ -1,3 +1,5 @@ +import {type PersistedApi} from './types' + const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage' export function runWithPersistedStorageLock({ @@ -20,6 +22,7 @@ export function runWithPersistedStorageLock({ return lockManager.request(PERSISTED_STORAGE_LOCK, operation) } +runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock'] function getLockManager(): LockManager | undefined { if (typeof navigator === 'undefined' || !('locks' in navigator)) { diff --git a/src/state/persisted/types.ts b/src/state/persisted/types.ts index 4b5a29d938..f9df6fece1 100644 --- a/src/state/persisted/types.ts +++ b/src/state/persisted/types.ts @@ -1,4 +1,5 @@ import {type Schema} from './schema' +import {type SessionCredentialMutation} from './session-merge' export type PersistedApi = { init(): Promise @@ -13,6 +14,14 @@ export type PersistedApi = { */ readLatest(key: K): Schema[K] write(key: K, value: Schema[K]): Promise + /** Conditionally merges session credentials; generic session writes throw. */ + writeSession(args: { + nextSession: Schema['session'] + credentialMutations: SessionCredentialMutation[] + }): Promise + runWithPersistedStorageLock(args: { + operation: () => T | Promise + }): Promise onUpdate( key: K, cb: (v: Schema[K]) => void, diff --git a/src/state/session/__tests__/provider-abort-test.tsx b/src/state/session/__tests__/provider-abort-test.tsx index a11f68b471..21d6cdde7d 100644 --- a/src/state/session/__tests__/provider-abort-test.tsx +++ b/src/state/session/__tests__/provider-abort-test.tsx @@ -6,14 +6,16 @@ import {act, render} from '@testing-library/react-native' * account factories. These mocks cut the tree back to the session lifecycle * itself, which is all these tests drive. */ -jest.mock('#/state/persisted/session', () => { +jest.mock('#/state/persisted', () => { + const actual = jest.requireActual('#/state/persisted') const { defaults, }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') return { - read: () => defaults.session, + ...actual, + get: () => defaults.session, readLatest: () => defaults.session, - write: ({nextSession}: {nextSession: typeof defaults.session}) => + writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => Promise.resolve(nextSession), runWithCredentialLock: ({operation}: {operation: () => unknown}) => Promise.resolve(operation()), diff --git a/src/state/session/__tests__/provider-clients-test.tsx b/src/state/session/__tests__/provider-clients-test.tsx index 1c638068af..0b526a750e 100644 --- a/src/state/session/__tests__/provider-clients-test.tsx +++ b/src/state/session/__tests__/provider-clients-test.tsx @@ -10,14 +10,16 @@ import {type SessionAccount} from '../types' * account factories. These mocks cut the tree back to the session lifecycle * itself, mirroring provider-abort-test.tsx. */ -jest.mock('#/state/persisted/session', () => { +jest.mock('#/state/persisted', () => { + const actual = jest.requireActual('#/state/persisted') const { defaults, }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') return { - read: () => defaults.session, + ...actual, + get: () => defaults.session, readLatest: () => defaults.session, - write: ({nextSession}: {nextSession: typeof defaults.session}) => + writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => Promise.resolve(nextSession), runWithCredentialLock: ({operation}: {operation: () => unknown}) => Promise.resolve(operation()), diff --git a/src/state/session/__tests__/provider-refresh-session-test.tsx b/src/state/session/__tests__/provider-refresh-session-test.tsx index f576a2b9e4..9733082463 100644 --- a/src/state/session/__tests__/provider-refresh-session-test.tsx +++ b/src/state/session/__tests__/provider-refresh-session-test.tsx @@ -9,14 +9,16 @@ import {type SessionAccount} from '../types' * account factories. These mocks cut the tree back to the session lifecycle * itself, mirroring provider-clients-test.tsx. */ -jest.mock('#/state/persisted/session', () => { +jest.mock('#/state/persisted', () => { + const actual = jest.requireActual('#/state/persisted') const { defaults, }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') return { - read: () => defaults.session, + ...actual, + get: () => defaults.session, readLatest: () => defaults.session, - write: ({nextSession}: {nextSession: typeof defaults.session}) => + writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => Promise.resolve(nextSession), runWithCredentialLock: ({operation}: {operation: () => unknown}) => Promise.resolve(operation()), diff --git a/src/state/session/__tests__/provider-session-events-test.tsx b/src/state/session/__tests__/provider-session-events-test.tsx index bcbddfa1e1..10b06eb040 100644 --- a/src/state/session/__tests__/provider-session-events-test.tsx +++ b/src/state/session/__tests__/provider-session-events-test.tsx @@ -24,15 +24,15 @@ const mockPersisted: {session: Schema['session']; latest: Schema['session']} = { * exists to catch. */ const mockPersistedListeners: ((value: Schema['session']) => void)[] = [] -jest.mock('#/state/persisted/session', () => ({ - read: () => mockPersisted.session, +jest.mock('#/state/persisted', () => ({ + get: () => mockPersisted.session, readLatest: () => mockPersisted.latest, - write: ({ + writeSession: ({ nextSession, credentialMutations, }: { nextSession: Schema['session'] - credentialMutations: import('#/state/persisted/session').SessionCredentialMutation[] + credentialMutations: import('#/state/persisted').SessionCredentialMutation[] }) => { const { applySessionUpdate, @@ -48,7 +48,7 @@ jest.mock('#/state/persisted/session', () => ({ }, runWithCredentialLock: ({operation}: {operation: () => unknown}) => Promise.resolve(operation()), - onUpdate: (callback: (value: Schema['session']) => void) => { + onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => { mockPersistedListeners.push(callback) return () => {} }, diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 051137dbae..3b9be69c2b 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -12,9 +12,8 @@ import { import {type Client} from '@atproto/lex' import {type SessionData} from '@atproto/lex-password-session' -import {type Schema} from '#/state/persisted' -import * as persistedSession from '#/state/persisted/session' -import {type SessionCredentialMutation} from '#/state/persisted/session' +import * as persistedSession from '#/state/persisted' +import {type Schema, type SessionCredentialMutation} from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' @@ -90,7 +89,9 @@ class SessionStore { constructor() { // Careful: By the time this runs, persisted state must already be initialized. - const initialState = getInitialState(persistedSession.read().accounts) + const initialState = getInitialState( + persistedSession.get('session').accounts, + ) addSessionDebugLog({type: 'reducer:init', state: redactState(initialState)}) this.state = initialState } @@ -127,7 +128,7 @@ class SessionStore { type: 'persisted:broadcast', data: redactPersistedSession(persistedData), }) - persistence = persistedSession.write({ + persistence = persistedSession.writeSession({ nextSession: persistedData, credentialMutations, }) @@ -228,7 +229,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { failedSet.add(dyingRefreshJwt) const persistedCandidate = persistedSession - .readLatest() + .readLatest('session') .accounts.find(a => a.did === accountDid) const reducerCandidate = current.accounts.find( a => a.did === accountDid, @@ -479,7 +480,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) { const accountDids = [ ...new Set([ ...prevState.accounts.map(account => account.did), - ...persistedSession.readLatest().accounts.map(account => account.did), + ...persistedSession + .readLatest('session') + .accounts.map(account => account.did), ]), ] void persistedSession @@ -525,7 +528,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { }) const signal = cancelPendingTask() const latestStoredAccount = persistedSession - .readLatest() + .readLatest('session') .accounts.find(account => account.did === storedAccount.did) if (!latestStoredAccount?.refreshJwt) return const {bundle, account} = await createSessionBundleAndResume( @@ -726,7 +729,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { [store, cancelPendingTask], ) useEffect(() => { - return persistedSession.onUpdate(nextSession => { + return persistedSession.onUpdate('session', nextSession => { const synced = nextSession addSessionDebugLog({ type: 'persisted:receive', diff --git a/src/state/session/util.ts b/src/state/session/util.ts index a1c330b67c..c91a1ba40b 100644 --- a/src/state/session/util.ts +++ b/src/state/session/util.ts @@ -2,7 +2,7 @@ import {PasswordSession} from '@atproto/lex-password-session' import {createLexClient} from '#/lib/lexClient' import {type TemporaryPushClient} from '#/lib/notifications/notifications' -import * as persistedSession from '#/state/persisted/session' +import * as persistedSession from '#/state/persisted' import {networkAwareFetch} from './network' import {sessionAccountToSessionData} from './session-data' import {type SessionAccount} from './types' @@ -10,7 +10,7 @@ import {type SessionAccount} from './types' export {isSessionExpired, isSignupQueued} from './session-data' export function readLastActiveAccount() { - const {currentAccount, accounts} = persistedSession.read() + const {currentAccount, accounts} = persistedSession.get('session') return accounts.find(a => a.did === currentAccount?.did) }