diff --git a/lib/utils/migration-utils.js b/lib/utils/migration-utils.js index e5f5df990..7e7eeb45e 100644 --- a/lib/utils/migration-utils.js +++ b/lib/utils/migration-utils.js @@ -1,343 +1,359 @@ // @flow import invariant from 'invariant'; import { authoritativeKeyserverID } from './authoritative-keyserver.js'; import { getConfig } from './config.js'; import type { TranslatedThreadMessageInfos } from './message-ops-utils.js'; import { entries } from './objects.js'; import { convertRawMessageInfoToNewIDSchema, convertRawThreadInfoToNewIDSchema, } from '../_generated/migration-utils.js'; import { parsePendingThreadID, getPendingThreadID, draftKeySuffix, } from '../shared/thread-utils.js'; import type { ClientDBDraftInfo, ClientDBDraftStoreOperation, DraftStore, } from '../types/draft-types'; import type { RawMessageInfo } from '../types/message-types.js'; import type { BaseNavInfo } from '../types/nav-types.js'; import type { BaseAppState } from '../types/redux-types.js'; import type { StoreOperations } from '../types/store-ops-types.js'; import { syncedMetadataNames } from '../types/synced-metadata-types.js'; import { threadPermissions, threadPermissionPropagationPrefixes, threadPermissionFilterPrefixes, } from '../types/thread-permission-types.js'; import type { MixedRawThreadInfos } from '../types/thread-types.js'; function convertDraftKeyToNewIDSchema(key: string): string { const threadID = key.slice(0, -draftKeySuffix.length); const convertedThreadID = convertIDToNewSchema( threadID, authoritativeKeyserverID(), ); return `${convertedThreadID}${draftKeySuffix}`; } function convertDraftStoreToNewIDSchema(store: DraftStore): DraftStore { return { drafts: Object.fromEntries( entries(store.drafts).map(([key, value]) => [ convertDraftKeyToNewIDSchema(key), value, ]), ), }; } function generateIDSchemaMigrationOpsForDrafts( drafts: $ReadOnlyArray, ): $ReadOnlyArray { const operations = drafts.map(draft => ({ type: 'update', payload: { key: convertDraftKeyToNewIDSchema(draft.key), text: draft.text, }, })); return [{ type: 'remove_all' }, ...operations]; } function convertMessageStoreThreadsToNewIDSchema( messageStoreThreads: TranslatedThreadMessageInfos, ): TranslatedThreadMessageInfos { return Object.fromEntries( entries(messageStoreThreads).map(([id, translatedThreadMessageInfo]) => [ `${authoritativeKeyserverID()}|` + id, translatedThreadMessageInfo, ]), ); } function convertThreadStoreThreadInfosToNewIDSchema( threadStoreThreadInfos: MixedRawThreadInfos, ): MixedRawThreadInfos { return Object.fromEntries( entries(threadStoreThreadInfos).map(([id, threadInfo]) => { invariant( !threadInfo.minimallyEncoded, `threadInfo during ID schema migration shouldn't be minimallyEncoded`, ); return [ `${authoritativeKeyserverID()}|` + id, convertRawThreadInfoToNewIDSchema(threadInfo), ]; }), ); } function convertIDToNewSchema(threadID: string, idPrefix: string): string { const pendingIDContents = parsePendingThreadID(threadID); if (!pendingIDContents) { return convertNonPendingIDToNewSchema(threadID, idPrefix); } const { threadType, sourceMessageID, memberIDs } = pendingIDContents; if (!sourceMessageID) { return threadID; } return getPendingThreadID( threadType, memberIDs, convertNonPendingIDToNewSchema(sourceMessageID, idPrefix), ); } function convertNonPendingIDToNewSchema( threadID: string, idPrefix: string, ): string { if (threadID.indexOf('|') === -1) { return `${idPrefix}|${threadID}`; } return threadID; } function convertNotificationMessageInfoToNewIDSchema( messageInfosString: ?string, ): ?$ReadOnlyArray { let messageInfos: ?$ReadOnlyArray = null; if (messageInfosString) { messageInfos = JSON.parse(messageInfosString); } if (messageInfos?.some(message => message.threadID.indexOf('|') === -1)) { messageInfos = messageInfos?.map(convertRawMessageInfoToNewIDSchema); } return messageInfos; } // This is an array of all permissions that need to be removed // in an upcoming migration for roles. Once the migrations are landed, // no changes to this array should be made to prevent future migrations // from having unexpected behavior. // See context in https://linear.app/comm/issue/ENG-5622/#comment-2d98a2cd const permissionsToRemoveInMigration: $ReadOnlyArray = [ threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.OPEN + threadPermissions.VOICED, threadPermissions.JOIN_THREAD, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_ENTRIES, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_NAME, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_DESCRIPTION, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_COLOR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.CREATE_SUBCHANNELS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_THREAD_AVATAR, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissionFilterPrefixes.TOP_LEVEL + threadPermissions.CREATE_SIDEBARS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.ADD_MEMBERS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.REMOVE_MEMBERS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.CHANGE_ROLE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_PERMISSIONS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_PINS, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.REACT_TO_MESSAGE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.EDIT_MESSAGE, threadPermissionPropagationPrefixes.DESCENDANT + threadPermissions.MANAGE_INVITE_LINKS, ]; -type LegacyMigrationManifest> = { +export type LegacyMigrationManifest> = { +[number | string]: (T) => Promise, }; type PersistedState> = T | void; type ConfigType = { +debug: boolean, }; export type StorageMigrationFunction> = ( debug: boolean, ) => Promise>; -type MigrationManifest> = { +export type MigrationManifest> = { +[number | string]: (PersistedState) => Promise<{ +state: T, +ops: StoreOperations, + +changesSchema?: boolean, }>, }; function createAsyncMigrate>( legacyMigrations: LegacyMigrationManifest, config: ConfigType, migrations: MigrationManifest, handleException: (error: Error, state: T) => T, storageMigration: ?StorageMigrationFunction, ): ( state: PersistedState, currentVersion: number, ) => Promise> { const debug = process.env.NODE_ENV !== 'production' && !!config?.debug; return async function ( state: ?PersistedState, currentVersion: number, ): Promise> { if (!state && storageMigration) { state = await storageMigration(debug); } if (!state) { if (debug) { console.log('redux-persist: no inbound state, skipping migration'); } return undefined; } const inboundVersion: number = state?._persist?.version ?? -1; if (inboundVersion === currentVersion) { if (debug) { console.log('redux-persist: versions match, noop migration'); } return state; } if (inboundVersion > currentVersion) { if (debug) { console.error('redux-persist: downgrading version is not supported'); } return state; } - return await runMigrations( + const { state: newState } = await runMigrations( legacyMigrations, migrations, state, inboundVersion, currentVersion, debug, handleException, ); + + return newState; }; } async function runMigrations>( legacyMigrations: LegacyMigrationManifest, migrations: MigrationManifest, state: T, inboundVersion: number, currentVersion: number, debug: boolean, - handleException: (error: Error, state: T) => T, -): Promise> { + handleException?: (error: Error, state: T) => T, +): Promise<{ + +state: PersistedState, + +schemaChanged: boolean, +}> { const migrationKeys = [ ...Object.keys(legacyMigrations), ...Object.keys(migrations), ] .map(ver => parseInt(ver)) .filter(key => currentVersion >= key && key > inboundVersion); const sortedMigrationKeys = migrationKeys.sort((a, b) => a - b); if (debug) { console.log('redux-persist: migrationKeys', sortedMigrationKeys); } let migratedState = state; + let schemaChanged = false; for (const versionKey of sortedMigrationKeys) { if (debug) { console.log( 'redux-persist: running migration for versionKey', versionKey, ); } if (!versionKey) { continue; } - - if (legacyMigrations[versionKey]) { - migratedState = await legacyMigrations[versionKey](migratedState); - } else { - const { state: newState, ops } = - await migrations[versionKey](migratedState); - migratedState = newState; - const versionUpdateOp = { - type: 'replace_synced_metadata_entry', - payload: { - name: syncedMetadataNames.DB_VERSION, - data: versionKey.toString(), - }, - }; - const dbOps = { - ...ops, - syncedMetadataStoreOperations: [ - ...(ops.syncedMetadataStoreOperations ?? []), - versionUpdateOp, - ], - }; - try { + try { + if (legacyMigrations[versionKey]) { + migratedState = await legacyMigrations[versionKey](migratedState); + } else { + const { + state: newState, + ops, + changesSchema, + } = await migrations[versionKey](migratedState); + schemaChanged ||= !!changesSchema; + migratedState = newState; + const versionUpdateOp = { + type: 'replace_synced_metadata_entry', + payload: { + name: syncedMetadataNames.DB_VERSION, + data: versionKey.toString(), + }, + }; + const dbOps = { + ...ops, + syncedMetadataStoreOperations: [ + ...(ops.syncedMetadataStoreOperations ?? []), + versionUpdateOp, + ], + }; await getConfig().sqliteAPI.processDBStoreOperations(dbOps); - } catch (exception) { - return handleException(exception, state); } + } catch (exception) { + if (handleException) { + return { + state: handleException(exception, state), + schemaChanged, + }; + } + throw exception; } } - return migratedState; + return { state: migratedState, schemaChanged }; } export { convertDraftKeyToNewIDSchema, convertDraftStoreToNewIDSchema, generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, convertNonPendingIDToNewSchema, convertIDToNewSchema, convertNotificationMessageInfoToNewIDSchema, permissionsToRemoveInMigration, createAsyncMigrate, runMigrations, };