Move session persistence into base API
This commit is contained in:
@@ -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()',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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 +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,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user