Improvements to persisted state migration (#2098)

* Fix session email/emailConfirmed types, update usage for safer access

* Handle fallback better, better errors

* Better handling, add test

* Add test for default data

* Remove fallback, not needed, update logs
This commit is contained in:
Eric Bailey 2023-12-05 19:59:34 -06:00 committed by GitHub
parent a915a57b10
commit 3c8036587e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 48 additions and 30 deletions

View file

@ -0,0 +1,13 @@
import {expect, test} from '@jest/globals'
import {transform} from '#/state/persisted/legacy'
import {defaults, schema} from '#/state/persisted/schema'
test('defaults', () => {
expect(() => schema.parse(defaults)).not.toThrow()
})
test('transform', () => {
const data = transform({})
expect(() => schema.parse(data)).not.toThrow()
})

View file

@ -26,7 +26,10 @@ export async function init() {
try { try {
await migrate() // migrate old store await migrate() // migrate old store
const stored = await store.read() // check for new store const stored = await store.read() // check for new store
if (!stored) await store.write(defaults) // opt: init new store if (!stored) {
logger.info('persisted state: initializing default storage')
await store.write(defaults) // opt: init new store
}
_state = stored || defaults // return new store _state = stored || defaults // return new store
logger.log('persisted state: initialized') logger.log('persisted state: initialized')
} catch (e) { } catch (e) {

View file

@ -1,7 +1,7 @@
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import {logger} from '#/logger' import {logger} from '#/logger'
import {defaults, Schema} from '#/state/persisted/schema' import {defaults, Schema, schema} from '#/state/persisted/schema'
import {write, read} from '#/state/persisted/store' import {write, read} from '#/state/persisted/store'
/** /**
@ -66,7 +66,6 @@ type LegacySchema = {
const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root' const DEPRECATED_ROOT_STATE_STORAGE_KEY = 'root'
// TODO remove, assume that partial data may be here during our refactor
export function transform(legacy: Partial<LegacySchema>): Schema { export function transform(legacy: Partial<LegacySchema>): Schema {
return { return {
colorMode: legacy.shell?.colorMode || defaults.colorMode, colorMode: legacy.shell?.colorMode || defaults.colorMode,
@ -116,7 +115,7 @@ export function transform(legacy: Partial<LegacySchema>): Schema {
* local storage AND old storage exists. * local storage AND old storage exists.
*/ */
export async function migrate() { export async function migrate() {
logger.info('persisted state: migrate') logger.info('persisted state: check need to migrate')
try { try {
const rawLegacyData = await AsyncStorage.getItem( const rawLegacyData = await AsyncStorage.getItem(
@ -138,6 +137,7 @@ export async function migrate() {
), ),
}) })
logger.info(`persisted state: debug new data`, { logger.info(`persisted state: debug new data`, {
hasNewData: Boolean(newData),
hasExistingLoggedInAccount: Boolean(newData?.session?.currentAccount), hasExistingLoggedInAccount: Boolean(newData?.session?.currentAccount),
numberOfExistingAccounts: newData?.session?.accounts?.length, numberOfExistingAccounts: newData?.session?.accounts?.length,
existingAccountMatchesLegacy: Boolean( existingAccountMatchesLegacy: Boolean(
@ -145,27 +145,32 @@ export async function migrate() {
legacy?.session?.data?.did, legacy?.session?.data?.did,
), ),
}) })
} else {
logger.info(`persisted state: no legacy to debug, fresh install`)
} }
} catch (e) { } catch (e: any) {
logger.error(`persisted state: legacy debugging failed`, {error: e}) logger.error(e, {message: `persisted state: legacy debugging failed`})
} }
if (!alreadyMigrated && rawLegacyData) { if (!alreadyMigrated && rawLegacyData) {
logger.info('persisted state: migrating legacy storage') logger.info('persisted state: migrating legacy storage')
const legacyData = JSON.parse(rawLegacyData) const legacyData = JSON.parse(rawLegacyData)
const newData = transform(legacyData) const newData = transform(legacyData)
const validate = schema.safeParse(newData)
if (validate.success) {
await write(newData) await write(newData)
// track successful migrations
logger.log('persisted state: migrated legacy storage') logger.log('persisted state: migrated legacy storage')
} else { } else {
// track successful migrations logger.error('persisted state: legacy data failed validation', {
error: validate.error,
})
}
} else {
logger.log('persisted state: no migration needed') logger.log('persisted state: no migration needed')
} }
} catch (e) { } catch (e: any) {
logger.error('persisted state: error migrating legacy storage', { logger.error(e, {
error: String(e), message: 'persisted state: error migrating legacy storage',
}) })
} }
} }

View file

@ -2,17 +2,14 @@ import {z} from 'zod'
import {deviceLocales} from '#/platform/detection' import {deviceLocales} from '#/platform/detection'
// only data needed for rendering account page // only data needed for rendering account page
// TODO agent.resumeSession requires the following fields
const accountSchema = z.object({ const accountSchema = z.object({
service: z.string(), service: z.string(),
did: z.string(), did: z.string(),
handle: z.string(), handle: z.string(),
email: z.string(), email: z.string().optional(),
emailConfirmed: z.boolean(), emailConfirmed: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire accessJwt: z.string().optional(), // optional because it can expire
// displayName: z.string().optional(),
// aviUrl: z.string().optional(),
}) })
export type PersistedAccount = z.infer<typeof accountSchema> export type PersistedAccount = z.infer<typeof accountSchema>

View file

@ -245,7 +245,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
service: agent.service.toString(), service: agent.service.toString(),
did: agent.session.did, did: agent.session.did,
handle: agent.session.handle, handle: agent.session.handle,
email: agent.session.email!, // TODO this is always defined? email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false, emailConfirmed: agent.session.emailConfirmed || false,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,
@ -342,7 +342,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
service: agent.service.toString(), service: agent.service.toString(),
did: agent.session.did, did: agent.session.did,
handle: agent.session.handle, handle: agent.session.handle,
email: agent.session.email!, // TODO this is always defined? email: agent.session.email,
emailConfirmed: agent.session.emailConfirmed || false, emailConfirmed: agent.session.emailConfirmed || false,
refreshJwt: agent.session.refreshJwt, refreshJwt: agent.session.refreshJwt,
accessJwt: agent.session.accessJwt, accessJwt: agent.session.accessJwt,

View file

@ -118,8 +118,8 @@ export function Component() {
) : stage === Stages.ConfirmCode ? ( ) : stage === Stages.ConfirmCode ? (
<Trans> <Trans>
An email has been sent to your previous address,{' '} An email has been sent to your previous address,{' '}
{currentAccount?.email || ''}. It includes a confirmation code {currentAccount?.email || '(no email)'}. It includes a
which you can enter below. confirmation code which you can enter below.
</Trans> </Trans>
) : ( ) : (
<Trans> <Trans>

View file

@ -108,8 +108,8 @@ export function Component({showReminder}: {showReminder?: boolean}) {
</Trans> </Trans>
) : stage === Stages.ConfirmCode ? ( ) : stage === Stages.ConfirmCode ? (
<Trans> <Trans>
An email has been sent to {currentAccount?.email || ''}. It An email has been sent to {currentAccount?.email || '(no email)'}.
includes a confirmation code which you can enter below. It includes a confirmation code which you can enter below.
</Trans> </Trans>
) : ( ) : (
'' ''
@ -125,7 +125,7 @@ export function Component({showReminder}: {showReminder?: boolean}) {
size={16} size={16}
/> />
<Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}> <Text type="xl-medium" style={[pal.text, s.flex1, {minWidth: 0}]}>
{currentAccount?.email || ''} {currentAccount?.email || '(no email)'}
</Text> </Text>
</View> </View>
<Pressable <Pressable

View file

@ -299,7 +299,7 @@ export function SettingsScreen({}: Props) {
</> </>
)} )}
<Text type="lg" style={pal.text}> <Text type="lg" style={pal.text}>
{currentAccount.email}{' '} {currentAccount.email || '(no email)'}{' '}
</Text> </Text>
<Link onPress={() => openModal({name: 'change-email'})}> <Link onPress={() => openModal({name: 'change-email'})}>
<Text type="lg" style={pal.link}> <Text type="lg" style={pal.link}>

View file

@ -58,8 +58,8 @@ export function DesktopRightNav() {
type="md" type="md"
style={pal.link} style={pal.link}
href={FEEDBACK_FORM_URL({ href={FEEDBACK_FORM_URL({
email: currentAccount!.email, email: currentAccount?.email,
handle: currentAccount!.handle, handle: currentAccount?.handle,
})} })}
text={_(msg`Feedback`)} text={_(msg`Feedback`)}
/> />