This commit is contained in:
Eric Bailey
2026-08-28 08:46:30 -05:00
parent 9e9ef59386
commit 691239d230
16 changed files with 1264 additions and 213 deletions
@@ -0,0 +1,250 @@
import {describe, expect, it} from '@jest/globals'
import {type PersistedAccount, type Schema} from '../schema'
import {
applySessionUpdate,
getCredentialState,
type SessionCredentialMutation,
} from '../session'
const DID = 'did:plc:example123'
function jwt({jti, issuedAt}: {jti: string; issuedAt: number}) {
const encode = (value: object) =>
Buffer.from(JSON.stringify(value)).toString('base64url')
return `${encode({alg: 'none'})}.${encode({jti, iat: issuedAt})}.signature`
}
function account({
refreshJwt,
accessJwt = `access-${refreshJwt}`,
}: {
refreshJwt?: string
accessJwt?: string
}): PersistedAccount {
return {
service: 'https://bsky.social/',
did: DID,
handle: 'alice.test',
refreshJwt,
accessJwt,
}
}
function session({
account: sessionAccount,
credentialStates,
}: {
account?: PersistedAccount
credentialStates?: Schema['session']['credentialStates']
}): Schema['session'] {
return {
accounts: sessionAccount ? [sessionAccount] : [],
currentAccount: sessionAccount,
credentialStates,
}
}
function update({
storedSession,
nextAccount,
mutation,
}: {
storedSession: Schema['session']
nextAccount?: PersistedAccount
mutation?: SessionCredentialMutation
}) {
return applySessionUpdate({
storedSession,
nextSession: session({
account: nextAccount,
credentialStates: storedSession.credentialStates,
}),
credentialMutations: mutation ? [mutation] : [],
})
}
describe('versioned persisted sessions', () => {
const refreshA = jwt({jti: 'A', issuedAt: 1})
const refreshB = jwt({jti: 'B', issuedAt: 2})
const refreshBAlias = jwt({jti: 'B', issuedAt: 3})
const refreshC = jwt({jti: 'C', issuedAt: 4})
it('advances the credential version when refresh moves to a new jti', () => {
const storedSession = session({account: account({refreshJwt: refreshA})})
const next = account({refreshJwt: refreshB})
const result = update({
storedSession,
nextAccount: next,
mutation: {
type: 'refresh',
accountDid: DID,
baseRefreshJwt: refreshA,
resultRefreshJwt: refreshB,
},
})
expect(result.accounts[0].refreshJwt).toBe(refreshB)
expect(getCredentialState({session: result, accountDid: DID})).toEqual({
credentialVersion: 1,
refreshJti: 'B',
status: 'active',
})
})
it('keeps one version for convergent aliases with the same jti', () => {
const generationB = update({
storedSession: session({account: account({refreshJwt: refreshA})}),
nextAccount: account({refreshJwt: refreshB}),
mutation: {
type: 'refresh',
accountDid: DID,
baseRefreshJwt: refreshA,
resultRefreshJwt: refreshB,
},
})
const result = update({
storedSession: generationB,
nextAccount: account({refreshJwt: refreshBAlias}),
mutation: {
type: 'refresh',
accountDid: DID,
baseRefreshJwt: refreshA,
resultRefreshJwt: refreshBAlias,
},
})
expect(result.accounts[0].refreshJwt).toBe(refreshB)
expect(
getCredentialState({session: result, accountDid: DID}).credentialVersion,
).toBe(1)
})
it('does not let a stale refresh overwrite a newer generation', () => {
const storedSession = session({
account: account({refreshJwt: refreshB}),
credentialStates: {
[DID]: {credentialVersion: 8, refreshJti: 'B', status: 'active'},
},
})
const result = update({
storedSession,
nextAccount: account({refreshJwt: refreshC}),
mutation: {
type: 'refresh',
accountDid: DID,
baseRefreshJwt: refreshA,
resultRefreshJwt: refreshC,
},
})
expect(result.accounts[0].refreshJwt).toBe(refreshB)
expect(
getCredentialState({session: result, accountDid: DID}).credentialVersion,
).toBe(8)
})
it('patches metadata without replacing authoritative credentials', () => {
const storedSession = session({
account: account({refreshJwt: refreshB}),
credentialStates: {
[DID]: {credentialVersion: 8, refreshJti: 'B', status: 'active'},
},
})
const staleAccount = {
...account({refreshJwt: refreshA}),
handle: 'alice-renamed.test',
}
const result = update({storedSession, nextAccount: staleAccount})
expect(result.accounts[0].handle).toBe('alice-renamed.test')
expect(result.accounts[0].refreshJwt).toBe(refreshB)
expect(result.accounts[0].accessJwt).toBe(`access-${refreshB}`)
})
it('does not let a stale expiry clear a newer generation', () => {
const storedSession = session({
account: account({refreshJwt: refreshB}),
credentialStates: {
[DID]: {credentialVersion: 8, refreshJti: 'B', status: 'active'},
},
})
const result = update({
storedSession,
nextAccount: account({refreshJwt: undefined, accessJwt: undefined}),
mutation: {
type: 'expire',
accountDid: DID,
baseRefreshJwt: refreshA,
},
})
expect(result.accounts[0].refreshJwt).toBe(refreshB)
expect(getCredentialState({session: result, accountDid: DID}).status).toBe(
'active',
)
})
it('preserves logout and removal tombstones until an explicit login', () => {
const active = session({
account: account({refreshJwt: refreshB}),
credentialStates: {
[DID]: {credentialVersion: 8, refreshJti: 'B', status: 'active'},
},
})
const loggedOut = update({
storedSession: active,
nextAccount: account({refreshJwt: undefined, accessJwt: undefined}),
mutation: {type: 'logout', accountDid: DID},
})
const staleRefresh = update({
storedSession: loggedOut,
nextAccount: account({refreshJwt: refreshC}),
mutation: {
type: 'refresh',
accountDid: DID,
baseRefreshJwt: refreshB,
resultRefreshJwt: refreshC,
},
})
expect(staleRefresh.accounts[0].refreshJwt).toBeUndefined()
expect(
getCredentialState({session: staleRefresh, accountDid: DID}),
).toEqual({credentialVersion: 9, status: 'logged-out'})
const removed = update({
storedSession: staleRefresh,
mutation: {type: 'remove', accountDid: DID},
})
const staleSnapshot = update({
storedSession: removed,
nextAccount: account({refreshJwt: refreshB}),
})
expect(staleSnapshot.accounts).toEqual([])
expect(
getCredentialState({session: staleSnapshot, accountDid: DID}),
).toEqual({credentialVersion: 10, status: 'removed'})
const loggedIn = update({
storedSession: staleSnapshot,
nextAccount: account({refreshJwt: refreshC}),
mutation: {
type: 'login',
accountDid: DID,
resultRefreshJwt: refreshC,
},
})
expect(loggedIn.accounts[0].refreshJwt).toBe(refreshC)
expect(getCredentialState({session: loggedIn, accountDid: DID})).toEqual({
credentialVersion: 11,
refreshJti: 'C',
status: 'active',
})
})
})
+63 -13
View File
@@ -8,15 +8,20 @@ import {
tryStringify,
} from '#/state/persisted/schema'
import {device} from '#/storage'
import {applySessionUpdate} from './session'
import {runWithSessionCredentialLock} from './session-lock'
import {type PersistedApi} from './types'
import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session'
export {runWithSessionCredentialLock} from './session-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
const BSKY_STORAGE = 'BSKY_STORAGE'
let _state: Schema = defaults
let pendingWrite: Promise<unknown> = Promise.resolve()
export async function init() {
const stored = await readFromStorage()
@@ -43,18 +48,43 @@ export function readLatest<K extends keyof Schema>(key: K): Schema[K] {
}
readLatest satisfies PersistedApi['readLatest']
export async function write<K extends keyof Schema>(
export function write<K extends keyof Schema>(
key: K,
value: Schema[K],
): Promise<void> {
_state = normalizeData({
..._state,
[key]: value,
return enqueueWrite(async () => {
const next = normalizeData({
..._state,
[key]: value,
})
await persistWithRetry(next)
_state = next
})
await writeToStorage(_state)
}
write satisfies PersistedApi['write']
export function updateSession({
nextSession,
credentialMutations,
}: {
nextSession: Schema['session']
credentialMutations: import('./session').SessionCredentialMutation[]
}): Promise<Schema['session']> {
return enqueueWrite(async () => {
const session = applySessionUpdate({
storedSession: _state.session,
nextSession,
credentialMutations,
})
const next = normalizeData({..._state, session})
await persistWithRetry(next)
_state = next
return session
})
}
updateSession satisfies PersistedApi['updateSession']
runWithSessionCredentialLock satisfies PersistedApi['runWithSessionCredentialLock']
export function onUpdate<K extends keyof Schema>(
_key: K,
_cb: (v: Schema[K]) => void,
@@ -73,16 +103,36 @@ export async function clearStorage() {
}
clearStorage satisfies PersistedApi['clearStorage']
function enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const result = pendingWrite.then(operation, operation)
pendingWrite = result.then(
() => undefined,
() => undefined,
)
return result
}
async function persistWithRetry(value: Schema) {
try {
await writeToStorage(value)
} catch {
/* Retry the latest complete snapshot while the process still owns it. */
await writeToStorage(value)
}
}
async function writeToStorage(value: Schema) {
const rawData = tryStringify(value)
if (rawData) {
try {
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
} catch (e) {
logger.error(`persisted state: failed writing root state to storage`, {
message: e,
})
}
if (!rawData) {
throw new Error('Failed to serialize persisted state')
}
try {
await AsyncStorage.setItem(BSKY_STORAGE, rawData)
} catch (e) {
logger.error(`persisted state: failed writing root state to storage`, {
message: e,
})
throw e
}
}
+110 -37
View File
@@ -8,9 +8,17 @@ import {
tryParse,
tryStringify,
} from '#/state/persisted/schema'
import {
applySessionUpdate,
getCredentialState,
type SessionCredentialMutation,
} from './session'
import {runWithSessionCredentialLock} from './session-lock'
import {type PersistedApi} from './types'
import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session'
export {runWithSessionCredentialLock} from './session-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
@@ -62,38 +70,73 @@ export function readLatest<K extends keyof Schema>(key: K): Schema[K] {
}
readLatest satisfies PersistedApi['readLatest']
// eslint-disable-next-line @typescript-eslint/require-await
export async function write<K extends keyof Schema>(
export function write<K extends keyof Schema>(
key: K,
value: Schema[K],
): Promise<void> {
const next = readFromStorage()
if (next) {
// The storage could have been updated by a different tab before this tab is notified.
// Make sure this write is applied on top of the latest data in the storage as long as it's valid.
_state = next
// Don't fire the update listeners yet to avoid a loop.
// If there was a change, we'll receive the broadcast event soon enough which will do that.
}
try {
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
// Fast path for updates that are guaranteed to be noops.
// This is good mostly because it avoids useless broadcasts to other tabs.
return
}
} catch (e) {
// Ignore and go through the normal path.
}
_state = normalizeData({
..._state,
[key]: value,
return runWithSessionCredentialLock({
accountDids: [],
operation: () => {
const next = readFromStorage()
if (next) {
// The storage could have been updated by a different tab before this tab is notified.
// Make sure this write is applied on top of the latest data in the storage as long as it's valid.
_state = next
// Don't fire the update listeners yet to avoid a loop.
// If there was a change, we'll receive the broadcast event soon enough which will do that.
}
try {
if (JSON.stringify({v: _state[key]}) === JSON.stringify({v: value})) {
// Fast path for updates that are guaranteed to be noops.
// This is good mostly because it avoids useless broadcasts to other tabs.
return
}
} catch (e) {
// Ignore and go through the normal path.
}
const updated = normalizeData({
..._state,
[key]: value,
})
writeToStorage(updated)
_state = updated
broadcastUpdate({key})
},
})
writeToStorage(_state)
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
}
write satisfies PersistedApi['write']
// eslint-disable-next-line @typescript-eslint/require-await
export async function updateSession({
nextSession,
credentialMutations,
}: {
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
}): Promise<Schema['session']> {
const stored = readFromStorage() ?? _state
const session = applySessionUpdate({
storedSession: stored.session,
nextSession,
credentialMutations,
})
const updated = normalizeData({...stored, session})
writeToStorage(updated)
_state = updated
const invalidations = credentialMutations.map(mutation => ({
accountDid: mutation.accountDid,
credentialVersion: getCredentialState({
session,
accountDid: mutation.accountDid,
}).credentialVersion,
}))
broadcastUpdate({key: 'session', invalidations})
return session
}
updateSession satisfies PersistedApi['updateSession']
runWithSessionCredentialLock satisfies PersistedApi['runWithSessionCredentialLock']
export function onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,
@@ -108,13 +151,17 @@ export function onUpdate<K extends keyof Schema>(
}
onUpdate satisfies PersistedApi['onUpdate']
// eslint-disable-next-line @typescript-eslint/require-await
export async function clearStorage() {
try {
localStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
// Expected on the web in private mode.
}
export function clearStorage(): Promise<void> {
return runWithSessionCredentialLock({
accountDids: [],
operation: () => {
try {
localStorage.removeItem(BSKY_STORAGE)
} catch (e: any) {
// Expected on the web in private mode.
}
},
})
}
clearStorage satisfies PersistedApi['clearStorage']
@@ -156,14 +203,40 @@ async function onBroadcastMessage({data}: MessageEvent) {
}
}
function broadcastUpdate({
key,
invalidations = [],
}: {
key: keyof Schema
invalidations?: {accountDid: string; credentialVersion: number}[]
}) {
if (invalidations.length > 0) {
for (const invalidation of invalidations) {
broadcast.postMessage({
event: {type: UPDATE_EVENT, key, ...invalidation},
})
}
} else {
broadcast.postMessage({event: {type: UPDATE_EVENT, key}})
}
broadcast.postMessage({event: UPDATE_EVENT}) // Backcompat while upgrading
}
function writeToStorage(value: Schema) {
const rawData = tryStringify(value)
if (rawData) {
try {
localStorage.setItem(BSKY_STORAGE, rawData)
} catch (e) {
// Expected on the web in private mode.
if (!rawData) {
throw new Error('Failed to serialize persisted state')
}
try {
localStorage.setItem(BSKY_STORAGE, rawData)
if (localStorage.getItem(BSKY_STORAGE) !== rawData) {
throw new Error('Failed to verify persisted state')
}
} catch (error) {
logger.error('persisted state: failed writing root state to storage', {
message: error,
})
throw error
}
}
+9
View File
@@ -57,12 +57,21 @@ const currentAccountSchema = accountSchema.extend({
})
export type PersistedCurrentAccount = z.infer<typeof currentAccountSchema>
const credentialStateSchema = z.object({
credentialVersion: z.number().int().nonnegative(),
refreshJti: z.string().optional(),
status: z.enum(['active', 'logged-out', 'removed']),
})
export type PersistedCredentialState = z.infer<typeof credentialStateSchema>
const schema = z.object({
colorMode: z.enum(['system', 'light', 'dark']),
darkTheme: z.enum(['dim', 'dark']).optional(),
session: z.object({
accounts: z.array(accountSchema),
currentAccount: currentAccountSchema.optional(),
/** Per-account credential ordering, including logout and removal tombstones. */
credentialStates: z.record(z.string(), credentialStateSchema).optional(),
}),
reminders: z.object({
lastEmailConfirm: z.string().optional(),
+18
View File
@@ -0,0 +1,18 @@
export function runWithSessionCredentialLock<T>({
accountDids,
operation,
}: {
accountDids: string[]
operation: () => T | Promise<T>
}): Promise<T> {
void accountDids
try {
return Promise.resolve(operation())
} catch (error) {
return Promise.reject(
error instanceof Error
? error
: new Error('Session credential operation failed', {cause: error}),
)
}
}
+37
View File
@@ -0,0 +1,37 @@
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
const SESSION_LOCK_PREFIX = 'bsky-session:'
export function runWithSessionCredentialLock<T>({
accountDids,
operation,
}: {
accountDids: string[]
operation: () => T | Promise<T>
}): Promise<T> {
const lockManager = navigator.locks
if (!lockManager) {
try {
return Promise.resolve(operation())
} catch (error) {
return Promise.reject(
error instanceof Error
? error
: new Error('Session credential operation failed', {cause: error}),
)
}
}
const accountLockNames = [...new Set(accountDids)]
.sort()
.map(did => `${SESSION_LOCK_PREFIX}${did}`)
/* All values share one localStorage blob, so every write also takes its lock. */
const lockNames = [PERSISTED_STORAGE_LOCK, ...accountLockNames]
const run = (index: number): Promise<T> => {
const lockName = lockNames[index]
if (!lockName) return Promise.resolve(operation())
return lockManager.request(lockName, () => run(index + 1))
}
return run(0)
}
+294
View File
@@ -0,0 +1,294 @@
import {jwtDecode} from 'jwt-decode'
import {
type PersistedAccount,
type PersistedCredentialState,
type Schema,
} from './schema'
export type SessionCredentialMutation =
| {
type: 'refresh'
accountDid: string
baseRefreshJwt: string | undefined
resultRefreshJwt: string | undefined
}
| {
type: 'expire'
accountDid: string
baseRefreshJwt: string | undefined
}
| {
type: 'login'
accountDid: string
resultRefreshJwt: string | undefined
}
| {
type: 'logout'
accountDid: string
}
| {
type: 'remove'
accountDid: string
}
const CREDENTIAL_FIELDS = new Set<keyof PersistedAccount>([
'accessJwt',
'refreshJwt',
])
/** Read the server-side refresh-token generation, tolerating legacy bad data. */
export function getRefreshJti({
refreshJwt,
}: {
refreshJwt: string | undefined
}): string | undefined {
if (!refreshJwt) return undefined
try {
const decoded = jwtDecode(refreshJwt)
if (typeof decoded.jti === 'string') return decoded.jti
} catch {}
/* A malformed legacy token still needs stable local identity. */
return refreshJwt
}
export function getCredentialState({
session,
accountDid,
}: {
session: Schema['session']
accountDid: string
}): PersistedCredentialState {
const stored = session.credentialStates?.[accountDid]
if (stored) return stored
const account = session.accounts.find(
candidate => candidate.did === accountDid,
)
return {
credentialVersion: 0,
refreshJti: getRefreshJti({refreshJwt: account?.refreshJwt}),
status: account?.refreshJwt ? 'active' : 'logged-out',
}
}
/**
* Apply a session snapshot to the latest persisted session without allowing its
* credential fields to overwrite newer credential generations.
*/
export function applySessionUpdate({
storedSession,
nextSession,
credentialMutations,
}: {
storedSession: Schema['session']
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
}): Schema['session'] {
const incomingByDid = new Map(
nextSession.accounts.map(account => [account.did, account]),
)
const storedByDid = new Map(
storedSession.accounts.map(account => [account.did, account]),
)
/* Incoming metadata wins, but stored credentials remain authoritative. */
const accounts = nextSession.accounts.map(incoming => {
const stored = storedByDid.get(incoming.did)
return stored
? mergeAccountMetadata({storedAccount: stored, incomingAccount: incoming})
: incoming
})
for (const stored of storedSession.accounts) {
if (!incomingByDid.has(stored.did)) accounts.push(stored)
}
const credentialStates: Record<string, PersistedCredentialState> = {
...storedSession.credentialStates,
}
for (const account of storedSession.accounts) {
credentialStates[account.did] = getCredentialState({
session: storedSession,
accountDid: account.did,
})
}
const result: Schema['session'] = {
accounts,
currentAccount: nextSession.currentAccount,
credentialStates,
}
for (const mutation of credentialMutations) {
applyCredentialMutation({session: result, nextSession, mutation})
}
result.accounts = result.accounts
.filter(
account =>
getCredentialState({session: result, accountDid: account.did})
.status !== 'removed',
)
.map(account => {
const credentialState = getCredentialState({
session: result,
accountDid: account.did,
})
return credentialState.status === 'logged-out'
? {...account, accessJwt: undefined, refreshJwt: undefined}
: account
})
const currentDid = result.currentAccount?.did
const currentAccount = currentDid
? result.accounts.find(account => account.did === currentDid)
: undefined
const currentCredentialState = currentDid
? getCredentialState({session: result, accountDid: currentDid})
: undefined
result.currentAccount =
currentAccount && currentCredentialState?.status === 'active'
? currentAccount
: undefined
return result
}
function applyCredentialMutation({
session,
nextSession,
mutation,
}: {
session: Schema['session']
nextSession: Schema['session']
mutation: SessionCredentialMutation
}) {
const previousState = getCredentialState({
session,
accountDid: mutation.accountDid,
})
const nextAccount = nextSession.accounts.find(
account => account.did === mutation.accountDid,
)
switch (mutation.type) {
case 'refresh': {
const baseRefreshJti = getRefreshJti({
refreshJwt: mutation.baseRefreshJwt,
})
const resultRefreshJti = getRefreshJti({
refreshJwt: mutation.resultRefreshJwt,
})
if (
previousState.status !== 'active' ||
previousState.refreshJti !== baseRefreshJti
) {
/* The stored generation already advanced or became a tombstone. */
return
}
if (!nextAccount || !resultRefreshJti) return
replaceAccount({session, account: nextAccount})
session.credentialStates![mutation.accountDid] = {
credentialVersion:
resultRefreshJti === previousState.refreshJti
? previousState.credentialVersion
: previousState.credentialVersion + 1,
refreshJti: resultRefreshJti,
status: 'active',
}
return
}
case 'expire': {
const baseRefreshJti = getRefreshJti({
refreshJwt: mutation.baseRefreshJwt,
})
if (
previousState.status !== 'active' ||
previousState.refreshJti !== baseRefreshJti
) {
return
}
clearAccountCredentials({session, accountDid: mutation.accountDid})
session.credentialStates![mutation.accountDid] = {
credentialVersion: previousState.credentialVersion + 1,
status: 'logged-out',
}
return
}
case 'login': {
const resultRefreshJti = getRefreshJti({
refreshJwt: mutation.resultRefreshJwt,
})
if (!nextAccount || !resultRefreshJti) return
replaceAccount({session, account: nextAccount})
session.credentialStates![mutation.accountDid] = {
credentialVersion: previousState.credentialVersion + 1,
refreshJti: resultRefreshJti,
status: 'active',
}
return
}
case 'logout': {
clearAccountCredentials({session, accountDid: mutation.accountDid})
session.credentialStates![mutation.accountDid] = {
credentialVersion: previousState.credentialVersion + 1,
status: 'logged-out',
}
return
}
case 'remove': {
session.accounts = session.accounts.filter(
account => account.did !== mutation.accountDid,
)
session.credentialStates![mutation.accountDid] = {
credentialVersion: previousState.credentialVersion + 1,
status: 'removed',
}
return
}
}
}
function mergeAccountMetadata({
storedAccount,
incomingAccount,
}: {
storedAccount: PersistedAccount
incomingAccount: PersistedAccount
}): PersistedAccount {
const merged = {...storedAccount}
for (const key of Object.keys(
incomingAccount,
) as (keyof PersistedAccount)[]) {
if (!CREDENTIAL_FIELDS.has(key)) {
Object.assign(merged, {[key]: incomingAccount[key]})
}
}
return merged
}
function replaceAccount({
session,
account,
}: {
session: Schema['session']
account: PersistedAccount
}) {
session.accounts = [
account,
...session.accounts.filter(candidate => candidate.did !== account.did),
]
}
function clearAccountCredentials({
session,
accountDid,
}: {
session: Schema['session']
accountDid: string
}) {
session.accounts = session.accounts.map(account =>
account.did === accountDid
? {...account, accessJwt: undefined, refreshJwt: undefined}
: account,
)
}
+9
View File
@@ -1,4 +1,5 @@
import {type Schema} from './schema'
import {type SessionCredentialMutation} from './session'
export type PersistedApi = {
init(): Promise<void>
@@ -13,6 +14,14 @@ export type PersistedApi = {
*/
readLatest<K extends keyof Schema>(key: K): Schema[K]
write<K extends keyof Schema>(key: K, value: Schema[K]): Promise<void>
updateSession(params: {
nextSession: Schema['session']
credentialMutations: SessionCredentialMutation[]
}): Promise<Schema['session']>
runWithSessionCredentialLock<T>(params: {
accountDids: string[]
operation: () => T | Promise<T>
}): Promise<T>
onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,