Move session persistence into base API

This commit is contained in:
Eric Bailey
2026-08-31 14:08:34 -05:00
parent 262cb37998
commit e59533b38a
13 changed files with 68 additions and 67 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ import {defaults} from '../schema'
describe('generic persisted API', () => { describe('generic persisted API', () => {
it('rejects session writes', () => { it('rejects session writes', () => {
expect(() => persisted.write('session', defaults.session)).toThrow( expect(() => persisted.write('session', defaults.session)).toThrow(
"Session state must be written through '#/state/persisted/session'", 'Session state must be written through persisted.writeSession()',
) )
}) })
}) })
+9 -3
View File
@@ -15,6 +15,11 @@ import {
import {type PersistedApi} from './types' import {type PersistedApi} from './types'
import {normalizeData} from './util' 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 type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema'
@@ -59,7 +64,7 @@ export function write<K extends keyof Schema>(
): Promise<void> { ): Promise<void> {
if (key === 'session') { if (key === 'session') {
throw new Error( throw new Error(
"Session state must be written through '#/state/persisted/session'", 'Session state must be written through persisted.writeSession()',
) )
} }
return enqueueWrite(async () => { return enqueueWrite(async () => {
@@ -73,8 +78,7 @@ export function write<K extends keyof Schema>(
} }
write satisfies PersistedApi['write'] write satisfies PersistedApi['write']
/** @internal Use `#/state/persisted/session` instead. */ export function writeSession({
export function writeSessionInternal({
nextSession, nextSession,
credentialMutations, credentialMutations,
}: { }: {
@@ -93,6 +97,8 @@ export function writeSessionInternal({
return session return session
}) })
} }
writeSession satisfies PersistedApi['writeSession']
export function onUpdate<K extends keyof Schema>( export function onUpdate<K extends keyof Schema>(
_key: K, _key: K,
_cb: (v: Schema[K]) => void, _cb: (v: Schema[K]) => void,
+9 -3
View File
@@ -16,6 +16,11 @@ import {runWithPersistedStorageLock} from './storage-lock'
import {type PersistedApi} from './types' import {type PersistedApi} from './types'
import {normalizeData} from './util' 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 type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema' export {defaults} from '#/state/persisted/schema'
@@ -73,7 +78,7 @@ export function write<K extends keyof Schema>(
): Promise<void> { ): Promise<void> {
if (key === 'session') { if (key === 'session') {
throw new Error( throw new Error(
"Session state must be written through '#/state/persisted/session'", 'Session state must be written through persisted.writeSession()',
) )
} }
return runWithPersistedStorageLock({ return runWithPersistedStorageLock({
@@ -107,9 +112,8 @@ export function write<K extends keyof Schema>(
} }
write satisfies PersistedApi['write'] write satisfies PersistedApi['write']
/** @internal Use `#/state/persisted/session` instead. */
// eslint-disable-next-line @typescript-eslint/require-await // eslint-disable-next-line @typescript-eslint/require-await
export async function writeSessionInternal({ export async function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
}: { }: {
@@ -129,6 +133,8 @@ export async function writeSessionInternal({
broadcastUpdate({key: 'session'}) broadcastUpdate({key: 'session'})
return session return session
} }
writeSession satisfies PersistedApi['writeSession']
export function onUpdate<K extends keyof Schema>( export function onUpdate<K extends keyof Schema>(
key: K, key: K,
cb: (v: Schema[K]) => void, cb: (v: Schema[K]) => void,
-35
View File
@@ -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<Schema['session']> {
return persisted.writeSessionInternal({
nextSession,
credentialMutations,
})
}
export function onUpdate(
callback: (session: Schema['session']) => void,
): () => void {
return persisted.onUpdate('session', callback)
}
+3
View File
@@ -1,3 +1,5 @@
import {type PersistedApi} from './types'
export function runWithPersistedStorageLock<T>({ export function runWithPersistedStorageLock<T>({
operation, operation,
}: { }: {
@@ -13,3 +15,4 @@ export function runWithPersistedStorageLock<T>({
) )
} }
} }
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
+3
View File
@@ -1,3 +1,5 @@
import {type PersistedApi} from './types'
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage' const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
export function runWithPersistedStorageLock<T>({ export function runWithPersistedStorageLock<T>({
@@ -20,6 +22,7 @@ export function runWithPersistedStorageLock<T>({
return lockManager.request(PERSISTED_STORAGE_LOCK, operation) return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
} }
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
function getLockManager(): LockManager | undefined { function getLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined' || !('locks' in navigator)) { if (typeof navigator === 'undefined' || !('locks' in navigator)) {
+9
View File
@@ -1,4 +1,5 @@
import {type Schema} from './schema' import {type Schema} from './schema'
import {type SessionCredentialMutation} from './session-merge'
export type PersistedApi = { export type PersistedApi = {
init(): Promise<void> init(): Promise<void>
@@ -13,6 +14,14 @@ export type PersistedApi = {
*/ */
readLatest<K extends keyof Schema>(key: K): Schema[K] readLatest<K extends keyof Schema>(key: K): Schema[K]
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void> write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
/** Conditionally merges session credentials; generic session writes throw. */
writeSession(args: {
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
}): Promise<Schema['session']>
runWithPersistedStorageLock<T>(args: {
operation: () => T | Promise<T>
}): Promise<T>
onUpdate<K extends keyof Schema>( onUpdate<K extends keyof Schema>(
key: K, key: K,
cb: (v: Schema[K]) => void, cb: (v: Schema[K]) => void,
@@ -6,14 +6,16 @@ import {act, render} from '@testing-library/react-native'
* account factories. These mocks cut the tree back to the session lifecycle * account factories. These mocks cut the tree back to the session lifecycle
* itself, which is all these tests drive. * itself, which is all these tests drive.
*/ */
jest.mock('#/state/persisted/session', () => { jest.mock('#/state/persisted', () => {
const actual = jest.requireActual<object>('#/state/persisted')
const { const {
defaults, defaults,
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
return { return {
read: () => defaults.session, ...actual,
get: () => defaults.session,
readLatest: () => defaults.session, readLatest: () => defaults.session,
write: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) => runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()), Promise.resolve(operation()),
@@ -10,14 +10,16 @@ import {type SessionAccount} from '../types'
* account factories. These mocks cut the tree back to the session lifecycle * account factories. These mocks cut the tree back to the session lifecycle
* itself, mirroring provider-abort-test.tsx. * itself, mirroring provider-abort-test.tsx.
*/ */
jest.mock('#/state/persisted/session', () => { jest.mock('#/state/persisted', () => {
const actual = jest.requireActual<object>('#/state/persisted')
const { const {
defaults, defaults,
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
return { return {
read: () => defaults.session, ...actual,
get: () => defaults.session,
readLatest: () => defaults.session, readLatest: () => defaults.session,
write: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) => runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()), Promise.resolve(operation()),
@@ -9,14 +9,16 @@ import {type SessionAccount} from '../types'
* account factories. These mocks cut the tree back to the session lifecycle * account factories. These mocks cut the tree back to the session lifecycle
* itself, mirroring provider-clients-test.tsx. * itself, mirroring provider-clients-test.tsx.
*/ */
jest.mock('#/state/persisted/session', () => { jest.mock('#/state/persisted', () => {
const actual = jest.requireActual<object>('#/state/persisted')
const { const {
defaults, defaults,
}: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema') }: typeof import('#/state/persisted/schema') = require('#/state/persisted/schema')
return { return {
read: () => defaults.session, ...actual,
get: () => defaults.session,
readLatest: () => defaults.session, readLatest: () => defaults.session,
write: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) => runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()), Promise.resolve(operation()),
@@ -24,15 +24,15 @@ const mockPersisted: {session: Schema['session']; latest: Schema['session']} = {
* exists to catch. * exists to catch.
*/ */
const mockPersistedListeners: ((value: Schema['session']) => void)[] = [] const mockPersistedListeners: ((value: Schema['session']) => void)[] = []
jest.mock('#/state/persisted/session', () => ({ jest.mock('#/state/persisted', () => ({
read: () => mockPersisted.session, get: () => mockPersisted.session,
readLatest: () => mockPersisted.latest, readLatest: () => mockPersisted.latest,
write: ({ writeSession: ({
nextSession, nextSession,
credentialMutations, credentialMutations,
}: { }: {
nextSession: Schema['session'] nextSession: Schema['session']
credentialMutations: import('#/state/persisted/session').SessionCredentialMutation[] credentialMutations: import('#/state/persisted').SessionCredentialMutation[]
}) => { }) => {
const { const {
applySessionUpdate, applySessionUpdate,
@@ -48,7 +48,7 @@ jest.mock('#/state/persisted/session', () => ({
}, },
runWithCredentialLock: ({operation}: {operation: () => unknown}) => runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()), Promise.resolve(operation()),
onUpdate: (callback: (value: Schema['session']) => void) => { onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
mockPersistedListeners.push(callback) mockPersistedListeners.push(callback)
return () => {} return () => {}
}, },
+12 -9
View File
@@ -12,9 +12,8 @@ import {
import {type Client} from '@atproto/lex' import {type Client} from '@atproto/lex'
import {type SessionData} from '@atproto/lex-password-session' import {type SessionData} from '@atproto/lex-password-session'
import {type Schema} from '#/state/persisted' import * as persistedSession from '#/state/persisted'
import * as persistedSession from '#/state/persisted/session' import {type Schema, type SessionCredentialMutation} from '#/state/persisted'
import {type SessionCredentialMutation} from '#/state/persisted/session'
import {useCloseAllActiveElements} from '#/state/util' import {useCloseAllActiveElements} from '#/state/util'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics' import {AnalyticsContext, useAnalyticsBase, utils} from '#/analytics'
@@ -90,7 +89,9 @@ class SessionStore {
constructor() { constructor() {
// Careful: By the time this runs, persisted state must already be initialized. // 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)}) addSessionDebugLog({type: 'reducer:init', state: redactState(initialState)})
this.state = initialState this.state = initialState
} }
@@ -127,7 +128,7 @@ class SessionStore {
type: 'persisted:broadcast', type: 'persisted:broadcast',
data: redactPersistedSession(persistedData), data: redactPersistedSession(persistedData),
}) })
persistence = persistedSession.write({ persistence = persistedSession.writeSession({
nextSession: persistedData, nextSession: persistedData,
credentialMutations, credentialMutations,
}) })
@@ -228,7 +229,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
failedSet.add(dyingRefreshJwt) failedSet.add(dyingRefreshJwt)
const persistedCandidate = persistedSession const persistedCandidate = persistedSession
.readLatest() .readLatest('session')
.accounts.find(a => a.did === accountDid) .accounts.find(a => a.did === accountDid)
const reducerCandidate = current.accounts.find( const reducerCandidate = current.accounts.find(
a => a.did === accountDid, a => a.did === accountDid,
@@ -479,7 +480,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const accountDids = [ const accountDids = [
...new Set([ ...new Set([
...prevState.accounts.map(account => account.did), ...prevState.accounts.map(account => account.did),
...persistedSession.readLatest().accounts.map(account => account.did), ...persistedSession
.readLatest('session')
.accounts.map(account => account.did),
]), ]),
] ]
void persistedSession void persistedSession
@@ -525,7 +528,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}) })
const signal = cancelPendingTask() const signal = cancelPendingTask()
const latestStoredAccount = persistedSession const latestStoredAccount = persistedSession
.readLatest() .readLatest('session')
.accounts.find(account => account.did === storedAccount.did) .accounts.find(account => account.did === storedAccount.did)
if (!latestStoredAccount?.refreshJwt) return if (!latestStoredAccount?.refreshJwt) return
const {bundle, account} = await createSessionBundleAndResume( const {bundle, account} = await createSessionBundleAndResume(
@@ -726,7 +729,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
[store, cancelPendingTask], [store, cancelPendingTask],
) )
useEffect(() => { useEffect(() => {
return persistedSession.onUpdate(nextSession => { return persistedSession.onUpdate('session', nextSession => {
const synced = nextSession const synced = nextSession
addSessionDebugLog({ addSessionDebugLog({
type: 'persisted:receive', type: 'persisted:receive',
+2 -2
View File
@@ -2,7 +2,7 @@ import {PasswordSession} from '@atproto/lex-password-session'
import {createLexClient} from '#/lib/lexClient' import {createLexClient} from '#/lib/lexClient'
import {type TemporaryPushClient} from '#/lib/notifications/notifications' 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 {networkAwareFetch} from './network'
import {sessionAccountToSessionData} from './session-data' import {sessionAccountToSessionData} from './session-data'
import {type SessionAccount} from './types' import {type SessionAccount} from './types'
@@ -10,7 +10,7 @@ import {type SessionAccount} from './types'
export {isSessionExpired, isSignupQueued} from './session-data' export {isSessionExpired, isSignupQueued} from './session-data'
export function readLastActiveAccount() { export function readLastActiveAccount() {
const {currentAccount, accounts} = persistedSession.read() const {currentAccount, accounts} = persistedSession.get('session')
return accounts.find(a => a.did === currentAccount?.did) return accounts.find(a => a.did === currentAccount?.did)
} }