/* eslint-disable @typescript-eslint/no-unused-vars */ import { type AccountDB, type MongoAccountDB, type Workspace, addSocialIdToPerson, ensurePerson, findFullSocialIdBySocialKey, findPersonBySocialKey, mergeSpecifiedPersons, mergeSpecifiedAccounts, createAccount } from '@hcengineering/account' import { getFirstName, getLastName } from '@hcengineering/contact' import { systemAccountUuid, type BackupClient, type Client, type Doc, MeasureMetricsContext, SocialIdType, type PersonUuid, type SocialKey, type AccountUuid, parseSocialIdString, DOMAIN_SPACE, AccountRole, generateId, type WorkspaceDataId, type WorkspaceUuid, generateUuid, type WorkspaceMode, type Tx } from '@hcengineering/core' import { getMongoClient, getWorkspaceMongoDB } from '@hcengineering/mongo' import { convertDoc, createTables, getDBClient, getDocFieldsByDomains, retryTxn, translateDomain, setDBExtraOptions } from '@hcengineering/postgres' import { type DBDoc } from '@hcengineering/postgres/types/utils' import { getTransactorEndpoint } from '@hcengineering/server-client' import { generateToken } from '@hcengineering/server-token' import { connect, sendTransactorEvent } from '@hcengineering/server-tool' import { type MongoAccountDB as v6MongoAccountDB, type Account as OldAccount, type Workspace as OldWorkspace } from '@hcengineering/account-service' import { type MongoClient } from 'mongodb' import type postgres from 'postgres' import { type Row } from 'postgres' import { getToolToken } from './utils' import { type BackupStorage, createFileBackupStorage, restore } from '@hcengineering/server-backup' import { buildStorageFromConfig, storageConfigFromEnv } from '@hcengineering/server-storage' import { getPlatformQueue } from '@hcengineering/kafka' import { type Pipeline, QueueTopic, type QueueWorkspaceMessage, type StorageAdapter, workspaceEvents } from '@hcengineering/server-core' import { createBackupPipeline, createEmptyBroadcastOps } from '@hcengineering/server-pipeline' export async function moveFromMongoToPG ( accountDb: AccountDB, mongoUrl: string, dbUrl: string | undefined, workspaces: Workspace[], region: string ): Promise { if (dbUrl === undefined) { throw new Error('dbUrl is required') } const client = getMongoClient(mongoUrl) const mongo = await client.getClient() const pg = getDBClient(dbUrl) const pgClient = await pg.getClient() for (let index = 0; index < workspaces.length; index++) { const ws = workspaces[index] try { await moveWorkspace(accountDb, mongo, pgClient, ws, region) console.log('Move workspace', index, workspaces.length) } catch (err) { console.log('Error when move workspace', ws.name ?? ws.url, err) throw err } } pg.close() client.close() } async function moveWorkspace ( accountDb: AccountDB, mongo: MongoClient, pgClient: postgres.Sql, ws: Workspace, region: string, include?: Set, force = false ): Promise { try { console.log('move workspace', ws.name ?? ws.url) const wsId = ws.uuid // TODO: get workspace mongoDB const mongoDB = getWorkspaceMongoDB(mongo, ws.dataId ?? wsId) const collections = await mongoDB.collections() let tables = collections.map((c) => c.collectionName) if (include !== undefined) { tables = tables.filter((t) => include.has(t)) } await createTables(new MeasureMetricsContext('', {}), pgClient, '', tables) const token = generateToken(systemAccountUuid, wsId, { service: 'tool' }) const endpoint = await getTransactorEndpoint(token, 'external') const connection = (await connect(endpoint, wsId, undefined, { model: 'upgrade' })) as unknown as Client & BackupClient for (const collection of collections) { const domain = translateDomain(collection.collectionName) if (include !== undefined && !include.has(domain)) { continue } const cursor = collection.find() const current = await pgClient`SELECT _id FROM ${pgClient(domain)} WHERE "workspaceId" = ${ws.uuid}` const currentIds = new Set(current.map((r) => r._id)) console.log('move domain', domain) const docs: Doc[] = [] const fields = getDocFieldsByDomains(domain) const filedsWithData = [...fields, 'data'] const insertFields: string[] = ['workspaceId'] for (const field of filedsWithData) { insertFields.push(field) } while (true) { const toRemove: string[] = [] while (docs.length < 5000) { const doc = (await cursor.next()) as Doc | null if (doc === null) break if (currentIds.has(doc._id)) { if (force) { toRemove.push(doc._id) } else { continue } } docs.push(doc) } while (toRemove.length > 0) { const part = toRemove.splice(0, 100) await retryTxn(pgClient, async (client) => { await client.unsafe( `DELETE FROM ${translateDomain(domain)} WHERE "workspaceId" = '${ws.uuid}' AND _id IN (${part.map((c) => `'${c}'`).join(', ')})` ) }) } if (docs.length === 0) break while (docs.length > 0) { const part = docs.splice(0, 100) const values: DBDoc[] = [] for (let i = 0; i < part.length; i++) { const doc = part[i] const d = convertDoc(domain, doc, wsId) values.push(d) } try { await retryTxn(pgClient, async (client) => { await client`INSERT INTO ${client(translateDomain(domain))} ${client(values, insertFields)}` }) } catch (err) { console.log('Error when insert', domain, err) } } } } // TODO: FIXME // await updateWorkspace(accountDb, ws, { region }) await connection.sendForceClose() await connection.close() } catch (err) { console.log('Error when move workspace', ws.name ?? ws.url, err) throw err } } export async function moveWorkspaceFromMongoToPG ( accountDb: AccountDB, mongoUrl: string, dbUrl: string | undefined, ws: Workspace, region: string, include?: Set, force?: boolean ): Promise { if (dbUrl === undefined) { throw new Error('dbUrl is required') } const client = getMongoClient(mongoUrl) const mongo = await client.getClient() const pg = getDBClient(dbUrl) const pgClient = await pg.getClient() await moveWorkspace(accountDb, mongo, pgClient, ws, region, include, force) pg.close() client.close() } export async function moveAccountDbFromMongoToPG ( ctx: MeasureMetricsContext, mongoDb: AccountDB, pgDb: AccountDB ): Promise { const mdb = mongoDb as MongoAccountDB const BATCH_SIZE = 5000 const WS_BATCH_SIZE = 2000 ctx.info('Starting migration of persons...') const personsCursor = mdb.person.findCursor({}) try { let personsCount = 0 let personsBatch: any[] = [] while (await personsCursor.hasNext()) { const person = await personsCursor.next() if (person == null) break const exists = await pgDb.person.findOne({ uuid: person.uuid }) if (exists == null) { if (person.firstName == null) { person.firstName = 'n/a' } if (person.lastName == null) { person.lastName = '' } personsBatch.push(person) if (personsBatch.length >= BATCH_SIZE) { await pgDb.person.insertMany(personsBatch) personsCount += personsBatch.length ctx.info(`Migrated ${personsCount} persons...`) personsBatch = [] } } } // Insert remaining batch if (personsBatch.length > 0) { await pgDb.person.insertMany(personsBatch) personsCount += personsBatch.length } ctx.info(`Migrated ${personsCount} persons`) } finally { await personsCursor.close() } ctx.info('Starting migration of accounts...') const accountsCursor = mdb.account.findCursor({}) try { let accountsCount = 0 let accountsBatch: any[] = [] let passwordsBatch: any[] = [] while (await accountsCursor.hasNext()) { const account = await accountsCursor.next() if (account == null) break const exists = await pgDb.account.findOne({ uuid: account.uuid }) if (exists == null) { const { hash, salt } = account delete account.hash delete account.salt accountsBatch.push(account) if (hash != null && salt != null) { passwordsBatch.push([account.uuid, hash, salt]) } if (accountsBatch.length >= BATCH_SIZE) { await pgDb.account.insertMany(accountsBatch) for (const [accountUuid, hash, salt] of passwordsBatch) { await pgDb.setPassword(accountUuid, hash, salt) } accountsCount += accountsBatch.length ctx.info(`Migrated ${accountsCount} accounts...`) accountsBatch = [] passwordsBatch = [] } } } // Insert remaining batch if (accountsBatch.length > 0) { await pgDb.account.insertMany(accountsBatch) accountsCount += accountsBatch.length } if (passwordsBatch.length > 0) { for (const [accountUuid, hash, salt] of passwordsBatch) { await pgDb.setPassword(accountUuid, hash, salt) } } ctx.info(`Migrated ${accountsCount} accounts`) } finally { await accountsCursor.close() } ctx.info('Starting migration of social IDs...') const socialIdsCursor = mdb.socialId.findCursor({}) try { let socialIdsCount = 0 let socialIdsBatch: any[] = [] while (await socialIdsCursor.hasNext()) { const socialId = await socialIdsCursor.next() if (socialId == null) break const exists = await pgDb.socialId.findOne({ key: socialId.key }) if (exists == null) { delete (socialId as any).key delete (socialId as any).id delete (socialId as any)._id // Types of _id are incompatible socialIdsBatch.push(socialId) if (socialIdsBatch.length >= BATCH_SIZE) { await pgDb.socialId.insertMany(socialIdsBatch) socialIdsCount += socialIdsBatch.length ctx.info(`Migrated ${socialIdsCount} social ids...`) socialIdsBatch = [] } } } // Insert remaining batch if (socialIdsBatch.length > 0) { await pgDb.socialId.insertMany(socialIdsBatch) socialIdsCount += socialIdsBatch.length } ctx.info(`Migrated ${socialIdsCount} social IDs`) } finally { await socialIdsCursor.close() } ctx.info('Starting migration of account events...') const accountEventsCursor = mdb.accountEvent.findCursor({}) try { let eventsCount = 0 let eventsBatch: any[] = [] while (await accountEventsCursor.hasNext()) { const accountEvent = await accountEventsCursor.next() if (accountEvent == null) break const exists = await pgDb.accountEvent.findOne({ accountUuid: accountEvent.accountUuid, eventType: accountEvent.eventType, time: accountEvent.time }) if (exists == null) { const account = await pgDb.account.findOne({ uuid: accountEvent.accountUuid }) if (account == null) continue // Not a big deal if we don't move the event for non-existing account eventsBatch.push(accountEvent) if (eventsBatch.length >= BATCH_SIZE) { await pgDb.accountEvent.insertMany(eventsBatch) eventsCount += eventsBatch.length ctx.info(`Migrated ${eventsCount} account events...`) eventsBatch = [] } } } // Insert remaining batch if (eventsBatch.length > 0) { await pgDb.accountEvent.insertMany(eventsBatch) eventsCount += eventsBatch.length } ctx.info(`Migrated ${eventsCount} account events`) } finally { await accountEventsCursor.close() } ctx.info('Starting migration of workspaces...') const workspacesCursor = mdb.workspace.findCursor({}) try { let workspacesCount = 0 let workspacesBatch: any[] = [] let workspacesStatusesBatch: any[] = [] let workspacesMembersBatch: any[] = [] let membersCount = 0 while (await workspacesCursor.hasNext()) { const workspace = await workspacesCursor.next() if (workspace == null) break const exists = await pgDb.workspace.findOne({ uuid: workspace.uuid }) if (exists != null) continue const status = workspace.status if (status == null) continue delete (workspace as any).status if (workspace.createdBy === 'N/A') { delete workspace.createdBy } if (workspace.billingAccount === 'N/A') { delete workspace.billingAccount } if (workspace.createdOn == null) { workspace.createdOn = Date.now() } workspacesBatch.push(workspace) workspacesStatusesBatch.push(status) const members = await mdb.getWorkspaceMembers(workspace.uuid) for (const member of members) { const alreadyAssigned = await pgDb.getWorkspaceRole(member.person, workspace.uuid) if (alreadyAssigned != null) continue workspacesMembersBatch.push([member.person, workspace.uuid, member.role]) } if (workspacesBatch.length >= WS_BATCH_SIZE) { const workspaceUuids = await pgDb.workspace.insertMany(workspacesBatch) workspacesCount += workspacesBatch.length workspacesBatch = [] await pgDb.workspaceStatus.insertMany( workspacesStatusesBatch.map((s, i) => ({ ...s, workspaceUuid: workspaceUuids[i] })) ) workspacesStatusesBatch = [] await pgDb.batchAssignWorkspace(workspacesMembersBatch) membersCount += workspacesMembersBatch.length workspacesMembersBatch = [] ctx.info(`Migrated ${workspacesCount} workspaces...`) } } // Insert remaining batch if (workspacesBatch.length > 0) { const workspaceUuids = await pgDb.workspace.insertMany(workspacesBatch) workspacesCount += workspacesBatch.length await pgDb.workspaceStatus.insertMany( workspacesStatusesBatch.map((s, i) => ({ ...s, workspaceUuid: workspaceUuids[i] })) ) await pgDb.batchAssignWorkspace(workspacesMembersBatch) membersCount += workspacesMembersBatch.length } ctx.info(`Migrated ${workspacesCount} workspaces with ${membersCount} member assignments`) } finally { await workspacesCursor.close() } ctx.info('Starting migration of invites...') const invitesCursor = mdb.invite.findCursor({}) try { // eslint-disable-next-line @typescript-eslint/no-loss-of-precision const MAX_INT_8 = 9223372036854775807 let invitesCount = 0 let invitesBatch: any[] = [] while (await invitesCursor.hasNext()) { const invite = await invitesCursor.next() if (invite == null) break if (invite.migratedFrom == null) { invite.migratedFrom = invite.id } delete (invite as any).id if (invite.expiresOn > MAX_INT_8 || typeof invite.expiresOn !== 'number') { invite.expiresOn = -1 } if (["USER'", 'ADMIN'].includes(invite.role as any)) { invite.role = AccountRole.User } const exists = await pgDb.invite.findOne({ migratedFrom: invite.migratedFrom }) if (exists == null) { invitesBatch.push(invite) if (invitesBatch.length >= BATCH_SIZE) { await pgDb.invite.insertMany(invitesBatch) invitesCount += invitesBatch.length ctx.info(`Migrated ${invitesCount} invites...`) invitesBatch = [] } } } if (invitesBatch.length > 0) { await pgDb.invite.insertMany(invitesBatch) invitesCount += invitesBatch.length } ctx.info(`Migrated ${invitesCount} invites`) } finally { await invitesCursor.close() } ctx.info('Account database migration completed') } export async function migrateCreatedModifiedBy ( ctx: MeasureMetricsContext, dbUrl: string, workspace: Workspace, includeDomains?: string[], excludeDomains?: string[], maxLifetimeSec?: number, batchSize?: number, force: boolean = false, maxReconnects: number = 30, maxRetries: number = 50 ): Promise { if (!dbUrl.startsWith('postgresql')) { throw new Error('Only CockroachDB is supported') } const wsUuid = workspace.uuid ctx.info('Processing workspace', { workspaceUuid: workspace.uuid, workspaceName: workspace.name, workspaceUrl: workspace.url }) if (maxLifetimeSec !== undefined) { setDBExtraOptions({ max_lifetime: maxLifetimeSec }) } let progressMade = false let connectsCount = 0 let retriesCount = 0 let reconnecting = false let retrying = false let done = false let pg: ReturnType | undefined let pgClient: postgres.Sql | undefined while (!done && (connectsCount === 0 || retrying || (reconnecting && progressMade))) { try { if (connectsCount === 0 || reconnecting) { ctx.info(reconnecting ? ' Reconnecting...' : ' Connecting...') progressMade = false connectsCount++ pg = getDBClient(dbUrl) pgClient = await pg.getClient() // Expect temp table with mapping to be created manually // Create mapping table await pgClient` CREATE TABLE IF NOT EXISTS temp_data.account_personid_mapping_v2 ( workspace_id uuid, old_account_id text, new_person_id text, CONSTRAINT account_personid_mapping_v2_pk PRIMARY KEY (workspace_id, old_account_id) ) ` const [res] = await pgClient`SELECT COUNT(*) FROM temp_data.account_personid_mapping_v2` if (res.count === '0') { // Populate mapping table await pgClient` INSERT INTO temp_data.account_personid_mapping_v2 WITH accounts as ( SELECT tx."workspaceId" as workspace_id, tx."objectId" as old_account_id, COALESCE( -- Get the latest email from updates ( SELECT tx2.data->'operations'->>'email' FROM model_tx tx2 WHERE tx2."objectId" = tx."objectId" AND tx2."workspaceId" = tx."workspaceId" AND tx2.data->>'objectClass' = 'contact:class:PersonAccount' AND tx2.data->'operations'->>'email' IS NOT NULL ORDER BY tx2."createdOn" DESC LIMIT 1 ), -- If no updates with email, get from create transaction tx.data->'attributes'->>'email' ) as latest_email FROM model_tx tx WHERE tx."_class" = 'core:class:TxCreateDoc' AND tx.data->>'objectClass' = 'contact:class:PersonAccount' AND tx.data->'attributes'->>'email' IS NOT null ), account_data as ( SELECT workspace_id, old_account_id, CASE WHEN latest_email LIKE 'github:%' THEN lower(latest_email) WHEN latest_email LIKE 'openid:%' THEN 'oidc:' || lower(substring(latest_email from 8)) ELSE 'email:' || lower(latest_email) END as social_key FROM accounts WHERE latest_email IS NOT NULL AND latest_email != '' ) SELECT ad.workspace_id, ad.old_account_id, si."_id" as new_person_id FROM account_data ad JOIN global_account.social_id si ON si."key" = ad.social_key WHERE ad.old_account_id NOT IN ('core:account:System', 'core:account:ConfigUser') ` } // Create progress table await pgClient` CREATE TABLE IF NOT EXISTS temp_data.account_personid_mapping_v2_progress ( workspace_id text, domain text, field text, CONSTRAINT account_personid_mapping_v2_progress_pk PRIMARY KEY (workspace_id, domain, field) ) ` } if (pgClient == null) { throw new Error('Could not connect to postgres') } if (retrying) { retriesCount++ } reconnecting = false retrying = false // Get list of tables to process const tables = await pgClient` SELECT table_name FROM information_schema.columns WHERE table_schema = 'public' AND column_name IN ('createdBy', 'modifiedBy') GROUP BY table_name HAVING COUNT(DISTINCT column_name) = 2 ` let filteredTables: Row[] = tables if (includeDomains != null && includeDomains.length > 0) { filteredTables = tables.filter((t) => includeDomains.includes(t.table_name)) } if (excludeDomains != null && excludeDomains.length > 0) { filteredTables = filteredTables.filter((t) => !excludeDomains.includes(t.table_name)) } ctx.info(` Found ${filteredTables.length} tables to process`, { domains: filteredTables.map((t) => t.table_name) }) // Process each table for (const table of filteredTables) { const tableName = table.table_name ctx.info(` Processing table: ${tableName}`) const progress = await pgClient` SELECT field FROM temp_data.account_personid_mapping_v2_progress WHERE workspace_id = ${wsUuid} AND domain = ${tableName} ` const createdDone = !force && progress.some((p) => p.field === 'createdBy') const modifiedDone = !force && progress.some((p) => p.field === 'modifiedBy') // Get counts for logging const [createdByCount] = !createdDone ? await pgClient` SELECT COUNT(*) FROM ${pgClient(tableName)} t JOIN temp_data.account_personid_mapping_v2 m ON t."workspaceId" = m.workspace_id AND t."createdBy" = m.old_account_id WHERE t."workspaceId" = ${wsUuid} ` : [{ count: 0 }] const [modifiedByCount] = !modifiedDone ? await pgClient` SELECT COUNT(*) FROM ${pgClient(tableName)} t JOIN temp_data.account_personid_mapping_v2 m ON t."workspaceId" = m.workspace_id AND t."modifiedBy" = m.old_account_id WHERE t."workspaceId" = ${wsUuid} ` : [{ count: 0 }] ctx.info( ` Table ${tableName}: ${createdByCount.count} createdBy and ${modifiedByCount.count} modifiedBy records need updating` ) if (createdByCount.count > 0) { ctx.info(` Updating createdBy for ${tableName}...`) const startTime = Date.now() if (batchSize == null || batchSize > createdByCount.count) { ctx.info(` Processing the whole table ${tableName}...`) await pgClient` UPDATE ${pgClient(tableName)} SET "createdBy" = m.new_person_id::text FROM temp_data.account_personid_mapping_v2 m WHERE ${pgClient(tableName)}."workspaceId" = ${wsUuid} AND ${pgClient(tableName)}."workspaceId" = m.workspace_id AND ${pgClient(tableName)}."createdBy" = m.old_account_id ` progressMade = true } else { ctx.info(` Processing the table ${tableName} in batches of ${batchSize}...`) let processed = 0 while (true) { const res = await pgClient` UPDATE ${pgClient(tableName)} SET "createdBy" = m.new_person_id::text FROM temp_data.account_personid_mapping_v2 m WHERE ${pgClient(tableName)}."workspaceId" = ${wsUuid} AND ${pgClient(tableName)}."workspaceId" = m.workspace_id AND ${pgClient(tableName)}."createdBy" = m.old_account_id LIMIT ${batchSize} ` progressMade = true if (res.count === 0) { break } processed += res.count const duration = (Date.now() - startTime) / 1000 const rate = Math.round(processed / duration) ctx.info( ` Processing createdBy for ${tableName}: ${processed} rows in ${duration}s (${rate} rows/sec)` ) } } await pgClient`INSERT INTO temp_data.account_personid_mapping_v2_progress (workspace_id, domain, field) VALUES (${wsUuid}, ${tableName}, 'createdBy') ON CONFLICT DO NOTHING` const duration = (Date.now() - startTime) / 1000 const rate = Math.round(createdByCount.count / duration) ctx.info( ` Updated createdBy for ${tableName}: ${createdByCount.count} rows in ${duration}s (${rate} rows/sec)` ) } else { if (createdDone) { ctx.info(' Skipping createdBy for table. Already done', { tableName }) } else { await pgClient`INSERT INTO temp_data.account_personid_mapping_v2_progress (workspace_id, domain, field) VALUES (${wsUuid}, ${tableName}, 'createdBy') ON CONFLICT DO NOTHING` } } if (modifiedByCount.count > 0) { ctx.info(` Updating modifiedBy for ${tableName}...`) const startTime = Date.now() if (batchSize == null || batchSize > modifiedByCount.count) { ctx.info(` Processing the whole table ${tableName}...`) await pgClient` UPDATE ${pgClient(tableName)} SET "modifiedBy" = m.new_person_id::text FROM temp_data.account_personid_mapping_v2 m WHERE ${pgClient(tableName)}."workspaceId" = ${wsUuid} AND ${pgClient(tableName)}."workspaceId" = m.workspace_id AND ${pgClient(tableName)}."modifiedBy" = m.old_account_id ` progressMade = true } else { ctx.info(` Processing the table ${tableName} in batches of ${batchSize}...`) let processed = 0 while (true) { const res = await pgClient` UPDATE ${pgClient(tableName)} SET "modifiedBy" = m.new_person_id::text FROM temp_data.account_personid_mapping_v2 m WHERE ${pgClient(tableName)}."workspaceId" = ${wsUuid} AND ${pgClient(tableName)}."workspaceId" = m.workspace_id AND ${pgClient(tableName)}."modifiedBy" = m.old_account_id LIMIT ${batchSize} ` progressMade = true if (res.count === 0) { break } processed += res.count const duration = (Date.now() - startTime) / 1000 const rate = Math.round(processed / duration) ctx.info( ` Processing modifiedBy for ${tableName}: ${processed} rows in ${duration}s (${rate} rows/sec)` ) } } await pgClient`INSERT INTO temp_data.account_personid_mapping_v2_progress (workspace_id, domain, field) VALUES (${wsUuid}, ${tableName}, 'modifiedBy') ON CONFLICT DO NOTHING` const duration = (Date.now() - startTime) / 1000 const rate = Math.round(modifiedByCount.count / duration) ctx.info( ` Updated modifiedBy for ${tableName}: ${modifiedByCount.count} rows in ${duration}s (${rate} rows/sec)` ) } else { if (modifiedDone) { ctx.info(' Skipping modifiedBy for table. Already done', { tableName }) } else { await pgClient`INSERT INTO temp_data.account_personid_mapping_v2_progress (workspace_id, domain, field) VALUES (${wsUuid}, ${tableName}, 'modifiedBy') ON CONFLICT DO NOTHING` } } } done = true ctx.info('Migration of created/modified completed successfully') } catch (err: any) { if (err.code === '40001' || err.code === '55P03') { // Retry transaction if (retriesCount === maxRetries) { ctx.error('Failed to migrate created/modified by. Max retries reached', { err }) } else { retrying = true continue } } if (err.code === 'CONNECTION_CLOSED') { // Reconnect ctx.info(' Connection closed...') if (connectsCount === maxReconnects) { ctx.error('Failed to migrate created/modified by. Max reconnects reached', { err }) } else { reconnecting = true continue } } throw err } finally { pg?.close() } } if (!done) { ctx.error('Failed to migrate created/modified by') } } async function fillAccountSocialKeyMapping (ctx: MeasureMetricsContext, pgClient: postgres.Sql): Promise { ctx.info('Creating account to social key mapping table...') // Create schema await pgClient`CREATE SCHEMA IF NOT EXISTS temp_data` // Create mapping table await pgClient` CREATE TABLE IF NOT EXISTS temp_data.account_socialkey_mapping ( workspace_id text, old_account_id text, new_social_key text, person_ref text, person_name text, INDEX idx_account_mapping_old_id (workspace_id, old_account_id) ) ` const [res] = await pgClient`SELECT COUNT(*) FROM temp_data.account_socialkey_mapping` if (res.count === '0') { // Populate mapping table await pgClient` INSERT INTO temp_data.account_socialkey_mapping WITH accounts AS ( SELECT tx."workspaceId", tx."objectId", COALESCE( -- Get the latest email from updates ( SELECT tx2.data->'operations'->>'email' FROM model_tx tx2 WHERE tx2."objectId" = tx."objectId" AND tx2."workspaceId" = tx."workspaceId" AND tx2.data->>'objectClass' = 'contact:class:PersonAccount' AND tx2.data->'operations'->>'email' IS NOT NULL ORDER BY tx2."createdOn" DESC LIMIT 1 ), -- If no updates with email, get from create transaction tx.data->'attributes'->>'email' ) as latest_email, COALESCE( -- Get the latest person from updates ( SELECT (tx2.data->'operations'->>'person')::text FROM model_tx tx2 WHERE tx2."objectId" = tx."objectId" AND tx2."workspaceId" = tx."workspaceId" AND tx2.data->>'objectClass' = 'contact:class:PersonAccount' AND tx2.data->'operations'->>'person' IS NOT NULL ORDER BY tx2."createdOn" DESC LIMIT 1 ), -- If no updates, get from create transaction (tx.data->'attributes'->>'person')::text ) as person_ref FROM model_tx tx WHERE tx."_class" = 'core:class:TxCreateDoc' AND tx.data->>'objectClass' = 'contact:class:PersonAccount' AND tx."objectId" NOT IN ('core:account:System', 'core:account:ConfigUser') ) SELECT a."workspaceId" as workspace_id, a."objectId" as old_account_id, CASE WHEN a.latest_email LIKE 'github:%' THEN lower(a.latest_email) WHEN a.latest_email LIKE 'openid:%' THEN 'oidc:' || lower(substring(a.latest_email from 8)) ELSE 'email:' || lower(a.latest_email) END as new_social_key, a.person_ref, c.data->>'name' as person_name FROM accounts as a LEFT JOIN public.contact c ON c."_id" = a.person_ref AND c."workspaceId" = a."workspaceId" WHERE a.latest_email IS NOT NULL AND a.latest_email != '' ` } } export async function migrateMergedAccounts ( ctx: MeasureMetricsContext, dbUrl: string, accountDb: AccountDB ): Promise { ctx.info('Migrating merged person accounts... ', {}) if (!dbUrl.startsWith('postgresql')) { throw new Error('Only CockroachDB is supported') } const pg = getDBClient(dbUrl) const pgClient = await pg.getClient() const token = getToolToken() try { await fillAccountSocialKeyMapping(ctx, pgClient) const personsAccounts = await pgClient` SELECT workspace_id, person_ref, array_agg(new_social_key) as social_keys FROM temp_data.account_socialkey_mapping WHERE new_social_key != 'email:huly.ai.bot@hc.engineering' GROUP BY workspace_id, person_ref HAVING count(*) > 1 ` ctx.info('Processing persons with merged accounts ', { count: personsAccounts.length }) let processed = 0 let errors = 0 for (const personAccounts of personsAccounts) { try { const socialKeys = personAccounts.social_keys // Every social id in the old account might either be already in the new account or not in the accounts at all // So we want to // 1. Take the first social id with the existing account // 2. Merge all other accounts into the first one // 3. Create social ids for the first account which haven't had their own accounts const toAdd = new Set() const toMergePersons = new Set() const toMergeAccounts = new Set() for (const socialKey of socialKeys) { const socialIdKey = parseSocialIdString(socialKey) const socialId = await findFullSocialIdBySocialKey(ctx, accountDb, null, token, { socialKey }) const personUuid = socialId?.personUuid const accountUuid = (await findPersonBySocialKey(ctx, accountDb, null, token, { socialString: socialKey, requireAccount: true })) as AccountUuid if (personUuid == null) { toAdd.add(socialIdKey) // Means not attached to any account yet, simply add the social id to the primary account } else if (accountUuid == null) { toMergePersons.add(personUuid) } else { // This is the case when the social id is already attached to an account. Merge the accounts. toMergeAccounts.add(accountUuid) } } if (toMergeAccounts.size === 0) { // No existing accounts for the person's social ids. Normally this should never be the case. ctx.info('No existing accounts for person', personAccounts) continue } const toMergeAccountsArray = Array.from(toMergeAccounts) const primaryAccount = toMergeAccountsArray[0] for (let i = 1; i < toMergeAccountsArray.length; i++) { const accountToMerge = toMergeAccountsArray[i] await mergeSpecifiedAccounts(ctx, accountDb, null, token, { primaryAccount, secondaryAccount: accountToMerge }) } const toMergePersonsArray = Array.from(toMergePersons) for (const personToMerge of toMergePersonsArray) { await mergeSpecifiedPersons(ctx, accountDb, null, token, { primaryPerson: primaryAccount, secondaryPerson: personToMerge }) } for (const addTarget of Array.from(toAdd)) { await addSocialIdToPerson(ctx, accountDb, null, token, { person: primaryAccount, ...addTarget, confirmed: false }) } processed++ if (processed % 10 === 0) { ctx.info(`Processed ${processed} of ${personsAccounts.length} persons`) } } catch (err: any) { errors++ ctx.error('Failed to merge accounts for person', { mergedGroup: personAccounts, err }) } } ctx.info('Finished processing persons with merged accounts', { processed, of: personsAccounts.length, errors }) } catch (err: any) { ctx.error('Failed to migrate merged accounts', { err }) } finally { pg.close() } } export async function filterMergedAccountsInMembers ( ctx: MeasureMetricsContext, dbUrl: string, accountDb: AccountDB ): Promise { ctx.info('Filtering merged accounts in members... ', {}) if (!dbUrl.startsWith('postgresql')) { throw new Error('Only CockroachDB is supported') } const pg = getDBClient(dbUrl) const pgClient = await pg.getClient() try { const mergedPersons = await accountDb.person.find({ migratedTo: { $ne: null } }) if (mergedPersons.length === 0) { ctx.info('No merged persons to migrate') return } ctx.info('Merged persons found', { count: mergedPersons.length }) const migrationMap = new Map() for (const person of mergedPersons) { if (person.migratedTo == null) { continue } migrationMap.set(person.uuid, person.migratedTo) } const spacesToUpdate = await pgClient` SELECT "workspaceId", _id, members FROM ${pgClient(DOMAIN_SPACE)} WHERE members && ${pgClient.array(Array.from(migrationMap.keys()))} ` ctx.info('Spaces to update', { count: spacesToUpdate.length }) let processed = 0 let errors = 0 for (const space of spacesToUpdate) { try { const newMembers = new Set(space.members.map((it: PersonUuid) => migrationMap.get(it) ?? it)) await pgClient` UPDATE ${pgClient(DOMAIN_SPACE)} SET members = ${pgClient.array(Array.from(newMembers))} WHERE "workspaceId" = ${space.workspaceId} AND "_id" = ${space._id} ` processed++ } catch (err: any) { errors++ ctx.error('Failed to update space members', { space, err }) } } ctx.info('Finished updating spaces', { processed, of: spacesToUpdate.length, errors }) } finally { pg.close() } } export async function ensureGlobalPersonsForLocalAccounts ( ctx: MeasureMetricsContext, dbUrl: string, accountDb: AccountDB ): Promise { ctx.info('Ensuring global persons for local accounts... ', {}) if (!dbUrl.startsWith('postgresql')) { throw new Error('Only CockroachDB is supported') } const pg = getDBClient(dbUrl) const pgClient = await pg.getClient() const token = getToolToken() try { await fillAccountSocialKeyMapping(ctx, pgClient) let count = 0 let failed = 0 const accountToSocialKey = await pgClient`SELECT * FROM temp_data.account_socialkey_mapping` for (const row of accountToSocialKey) { const newSocialKey = row.new_social_key const personName = row.person_name ?? '' const keyParts = newSocialKey.split(':') if (keyParts.length !== 2) { ctx.error('Invalid social key', row) continue } const keyType = keyParts[0] const keyValue = keyParts[1] if (!Object.values(SocialIdType).includes(keyType)) { ctx.error('Invalid social key type', row) continue } const firstName = getFirstName(personName) const lastName = getLastName(personName) const effectiveFirstName = firstName === '' ? keyValue : firstName try { await ensurePerson(ctx, accountDb, null, token, { socialType: keyType as SocialIdType, socialValue: keyValue, firstName: effectiveFirstName, lastName }) count++ } catch (err: any) { ctx.error('Failed to ensure person', { socialType: keyType as SocialIdType, socialValue: keyValue, firstName: effectiveFirstName, lastName }) failed++ } } ctx.info(`Successfully ensured ${count} people with failed count ${failed}`) } finally { pg.close() } } export async function migrateTrustedV6Accounts ( ctx: MeasureMetricsContext, accountDB: AccountDB, mongoDb: v6MongoAccountDB, dryRun: boolean, skipWorkspaces: Set, conflictSuffix?: string ): Promise { // Mapping between const accountsIdToUuid: Record = {} // Mapping between const accountsEmailToUuid: Record = {} // Mapping between const workspacesIdToUuid: Record = {} console.log('Migrating accounts database...') let accountsProcessed = 0 const accountsCursor = mongoDb.account.findCursor({}) try { while (await accountsCursor.hasNext()) { const account = await accountsCursor.next() if (account == null) { break } try { const accountUuid = await migrateAccount(account, accountDB, dryRun) if (accountUuid == null) { console.log('Account not migrated', account) continue } accountsIdToUuid[account._id.toString()] = accountUuid accountsEmailToUuid[account.email] = accountUuid accountsProcessed++ if (accountsProcessed % 100 === 0) { console.log('Processed accounts:', accountsProcessed) } } catch (err: any) { console.log('Failed to migrate account', account._id, account.email, err) } } } catch (err: any) { console.log('Failed to migrate accounts', err) } finally { await accountsCursor.close() } console.log('Total accounts processed:', accountsProcessed) let processedWorkspaces = 0 const workspacesCursor = mongoDb.workspace.findCursor({}) try { while (await workspacesCursor.hasNext()) { const workspace = await workspacesCursor.next() if (workspace == null) { break } if ( skipWorkspaces.has(workspace.workspace) || (workspace.workspaceUrl != null && skipWorkspaces.has(workspace.workspaceUrl)) ) { console.log('Skipping workspace', workspace.workspace, workspace.workspaceUrl) continue } try { const [workspaceUuid] = (await migrateWorkspace( workspace, accountDB, accountsIdToUuid, accountsEmailToUuid, dryRun, 'manual-creation', conflictSuffix )) ?? [] if (workspaceUuid !== undefined) { workspacesIdToUuid[workspace.workspace] = workspaceUuid } processedWorkspaces++ if (processedWorkspaces % 100 === 0) { console.log('Processed workspaces:', processedWorkspaces) } } catch (err: any) { console.log('Failed to migrate workspace', workspace.workspaceUrl, workspace.workspace, err) } } } catch (err: any) { console.log('Failed to migrate workspaces', err) } finally { await workspacesCursor.close() } console.log('Total workspaces processed:', processedWorkspaces) console.log('Total workspaces created/ensured:', Object.values(workspacesIdToUuid).length) let invitesProcessed = 0 const invitesCursor = mongoDb.invite.findCursor({}) try { while (await invitesCursor.hasNext()) { const invite = await invitesCursor.next() if (invite == null) { break } try { const workspaceUuid = workspacesIdToUuid[invite.workspace.name] if (workspaceUuid === undefined) { console.log('No workspace with id', invite.workspace.name, 'found for invite', invite._id) continue } const existing = await accountDB.invite.findOne({ migratedFrom: invite._id.toString() }) if (existing != null) { continue } const inviteRecord = { migratedFrom: invite._id.toString(), workspaceUuid, expiresOn: invite.exp, emailPattern: invite.emailMask, remainingUses: invite.limit, role: invite.role ?? AccountRole.User } if (!dryRun) { await accountDB.invite.insertOne(inviteRecord) } else { console.log('Creating invite record', inviteRecord) } invitesProcessed++ if (invitesProcessed % 100 === 0) { console.log('Processed invites:', invitesProcessed) } } catch (err: any) { console.log('Failed to migrate invite', invite._id, err) } } } catch (err: any) { console.log('Failed to migrate invites', err) } finally { await invitesCursor.close() } console.log('Total invites processed:', invitesProcessed) } async function migrateAccount ( account: OldAccount, accountDB: AccountDB, dryRun = true ): Promise { let primaryKey: SocialKey let secondaryKey: SocialKey | undefined if (account.githubId != null) { if (account.githubUser == null) { console.log('No github user found for github id', account.githubId) return } primaryKey = { type: SocialIdType.GITHUB, value: account.githubUser } secondaryKey = !account.email.startsWith('github:') ? { type: SocialIdType.EMAIL, value: account.email } : undefined } else if (account.openId != null) { primaryKey = { type: SocialIdType.OIDC, value: account.openId } secondaryKey = !account.email.startsWith('openid:') ? { type: SocialIdType.EMAIL, value: account.email } : undefined } else { primaryKey = { type: SocialIdType.EMAIL, value: account.email } } let personUuid: PersonUuid const verified = account.confirmed === true ? { verifiedOn: Date.now() } : {} const existing = await accountDB.socialId.findOne(primaryKey) if (existing == null) { // Create new global person const personRecord = { firstName: account.first, lastName: account.last } if (!dryRun) { personUuid = await accountDB.person.insertOne(personRecord) } else { console.log('Creating person record', personRecord) personUuid = generateUuid() as PersonUuid } const socialIdRecord = { ...primaryKey, personUuid, ...verified } if (!dryRun) { await accountDB.socialId.insertOne(socialIdRecord) } else { console.log('Creating social id record', socialIdRecord) } if (!dryRun) { await createAccount(accountDB, personUuid, account.confirmed, false, account.createdOn) } else { console.log('Creating account record', { personUuid, confirmed: account.confirmed }) } if (account.hash != null && account.salt != null) { if (!dryRun) { await accountDB.setPassword(personUuid as AccountUuid, account.hash, account.salt) } else { console.log('Updating account password', { personUuid }) } } } else { personUuid = existing.personUuid // if there's no existing account, create a new one const existingAcc = await accountDB.account.findOne({ uuid: personUuid as AccountUuid }) if (existingAcc == null) { if (!dryRun) { await createAccount(accountDB, personUuid, account.confirmed, false, account.createdOn) } else { console.log('Creating account record', { personUuid, confirmed: account.confirmed }) } if (account.hash != null && account.salt != null) { if (!dryRun) { await accountDB.setPassword(personUuid as AccountUuid, account.hash, account.salt) } else { console.log('Updating account password', { personUuid }) } } } } if (secondaryKey != null) { const existingSecondary = await accountDB.socialId.findOne(secondaryKey) if (existingSecondary == null) { if (!dryRun) { await accountDB.socialId.insertOne({ ...secondaryKey, personUuid, ...verified }) } else { console.log('Creating secondary social id', { personUuid, confirmed: account.confirmed, secondaryKey }) } } } return personUuid as AccountUuid } async function migrateWorkspace ( workspace: OldWorkspace, accountDB: AccountDB, accountsIdToUuid: Record, accountsEmailToUuid: Record, dryRun = true, forcedMode?: WorkspaceMode, conflictSuffix?: string, throwExisting?: boolean, region?: string, branding?: string ): Promise<[WorkspaceUuid, string] | undefined> { if (workspace.workspaceUrl == null) { console.log('No workspace url, skipping', workspace.workspace) return } const createdBy = workspace.createdBy !== undefined ? accountsEmailToUuid[workspace.createdBy] : undefined if (createdBy === undefined) { console.log('No account found for workspace', workspace.workspace, 'created by', workspace.createdBy) } let existingByUrl = await accountDB.workspace.findOne({ url: workspace.workspaceUrl }) const existingByUuid = await accountDB.workspace.findOne({ uuid: workspace.uuid }) let workspaceUuid: WorkspaceUuid let url = workspace.workspaceUrl if (existingByUuid == null) { if (existingByUrl != null && (conflictSuffix ?? '') !== '') { url = `${url}-${conflictSuffix}` existingByUrl = await accountDB.workspace.findOne({ url }) } if (existingByUrl != null) { console.log('Conflicting workspace url', url) // generate new url url = `${url}-${generateId('-')}` console.log('Generating new url', url) } const workspaceRecord = { uuid: workspace.uuid, name: workspace.workspaceName, url, dataId: workspace.workspace, branding: branding ?? workspace.branding, region: region ?? workspace.region, createdBy, billingAccount: createdBy, createdOn: workspace.createdOn ?? Date.now() } if (!dryRun) { workspaceUuid = await accountDB.workspace.insertOne(workspaceRecord) } else { console.log('Creating workspace record', workspaceRecord) workspaceUuid = generateUuid() as WorkspaceUuid } } else { if (throwExisting === true) { throw new Error(`Workspace with the same uuid ${workspace.uuid} already exists`) } workspaceUuid = existingByUuid.uuid } const existingStatus = await accountDB.workspaceStatus.findOne({ workspaceUuid }) if (existingStatus == null) { const statusRecord = { workspaceUuid, mode: forcedMode ?? workspace.mode, processingProgress: workspace.progress !== undefined ? Math.floor(workspace.progress) : undefined, versionMajor: workspace.version?.major, versionMinor: workspace.version?.minor, versionPatch: workspace.version?.patch, lastProcessingTime: workspace.lastProcessingTime, lastVisit: workspace.lastVisit, isDisabled: workspace.disabled, processingAttempts: workspace.attempts, processingMessage: workspace.message, backupInfo: workspace.backupInfo } if (!dryRun) { await accountDB.workspaceStatus.insertOne(statusRecord) } else { console.log('Creating workspace status record', statusRecord) } } const uniqueAccounts = Array.from(new Set((workspace.accounts ?? []).map((it) => it.toString()))) const existingMembers = new Set((await accountDB.getWorkspaceMembers(workspaceUuid)).map((mi) => mi.person)) for (const member of uniqueAccounts) { const accountUuid = accountsIdToUuid[member] if (accountUuid === undefined) { console.log('No account found for workspace', workspace.workspace, 'member', member) continue } if (existingMembers.has(accountUuid)) { continue } if (!dryRun) { // Actual roles are being set in workspace migration await accountDB.assignWorkspace(accountUuid, workspaceUuid, AccountRole.Guest) } else { console.log('Assigning account', member, accountUuid, 'to workspace', workspaceUuid) } } return [workspaceUuid, url] } export async function restoreFromv6All ( ctx: MeasureMetricsContext, accountDB: AccountDB, dirName: string, txes: Tx[], dbUrl: string ): Promise { ctx.info('Restoring from v6 dump...') const v6AccountsFile = 'account.accounts.json' const v6WorkspacesFile = 'account.workspaces.json' const v6InvitesFile = 'account.invites.json' const storage = await createFileBackupStorage(dirName) if (!(await storage.exists(v6AccountsFile))) { ctx.error('file not pressent', { file: v6AccountsFile }) throw new Error(`${v6AccountsFile} should present to restore`) } if (!(await storage.exists(v6WorkspacesFile))) { ctx.error('file not pressent', { file: v6WorkspacesFile }) throw new Error(`${v6WorkspacesFile} should present to restore`) } if (!(await storage.exists(v6InvitesFile))) { ctx.error('file not pressent', { file: v6InvitesFile }) throw new Error(`${v6InvitesFile} should present to restore`) } try { const v6AccountsRaw = JSON.parse((await storage.loadFile(v6AccountsFile)).toString()) const v6Workspaces = JSON.parse((await storage.loadFile(v6WorkspacesFile)).toString()) as OldWorkspace[] const v6Invites = JSON.parse((await storage.loadFile(v6InvitesFile)).toString()) const v6Accounts: OldAccount[] = [] for (const rawAccount of v6AccountsRaw) { const hashTypedArray = rawAccount.hash != null ? new Uint8Array(rawAccount.hash.data) : null const saltTypedArray = new Uint8Array(rawAccount.salt.data) v6Accounts.push({ ...rawAccount, hash: hashTypedArray != null ? Buffer.from(hashTypedArray.buffer) : null, salt: Buffer.from(saltTypedArray.buffer) }) } // Generate UUIDs for workspaces where missing for (const workspace of v6Workspaces) { if (workspace.uuid == null) { workspace.uuid = generateUuid() as WorkspaceUuid } } // Mapping between const accountsIdToUuid: Record = {} // Mapping between const accountsEmailToUuid: Record = {} // Mapping between const workspacesIdToUuid: Record = {} ctx.info('Restoring accounts database...') let accountsProcessed = 0 for (const account of v6Accounts) { try { const accountUuid = await migrateAccount(account, accountDB, false) if (accountUuid == null) { ctx.warn('Account not restored', account) continue } accountsIdToUuid[account._id.toString()] = accountUuid accountsEmailToUuid[account.email] = accountUuid accountsProcessed++ if (accountsProcessed % 100 === 0) { ctx.info('Processed accounts:', { accountsProcessed }) } } catch (err: any) { ctx.error('Failed to restore account', { _id: account._id, email: account.email, err }) } } ctx.info('Total accounts processed:', { accountsProcessed }) let processedWorkspaces = 0 const activeWorkspaces = new Set() for (const workspace of v6Workspaces) { const isActive = workspace.mode === 'active' try { // Create active workspaces as archived until they are actually restored const [workspaceUuid] = (await migrateWorkspace( workspace, accountDB, accountsIdToUuid, accountsEmailToUuid, false, isActive ? 'archived' : undefined )) ?? [] if (workspaceUuid !== undefined) { workspacesIdToUuid[workspace.workspace] = workspaceUuid if (isActive) { activeWorkspaces.add(workspaceUuid) } } processedWorkspaces++ if (processedWorkspaces % 100 === 0) { ctx.info('Processed workspaces:', { processedWorkspaces }) } } catch (err: any) { ctx.error('Failed to restore workspace', { url: workspace.workspaceUrl, workspace: workspace.workspace, err }) } } ctx.info('Total workspaces processed:', { processedWorkspaces }) ctx.info('Total workspaces created/ensured:', { count: Object.values(workspacesIdToUuid).length }) let invitesProcessed = 0 for (const invite of v6Invites) { try { const workspaceUuid = workspacesIdToUuid[invite.workspace.name] if (workspaceUuid === undefined) { ctx.error(`No workspace with id ${invite.workspace.name} found for invite ${invite._id}`) continue } const existing = await accountDB.invite.findOne({ migratedFrom: invite._id.toString() }) if (existing != null) { continue } const inviteRecord = { migratedFrom: invite._id.toString(), workspaceUuid, expiresOn: invite.exp, emailPattern: invite.emailMask, remainingUses: invite.limit, role: invite.role ?? AccountRole.User } await accountDB.invite.insertOne(inviteRecord) invitesProcessed++ if (invitesProcessed % 100 === 0) { ctx.info('Processed invites:', { invitesProcessed }) } } catch (err: any) { ctx.error('Failed to restore invite', { _id: invite._id, err }) } } ctx.info('Total invites processed:', { invitesProcessed }) ctx.info('Successfully restored accounts backup') ctx.info('Restoring workspaces...') for (const workspace of v6Workspaces) { const dataId = workspace.workspace const url = workspace.workspaceUrl const uuid = workspacesIdToUuid[dataId] if (url == null) { ctx.error('Workspace url not set', { dataId }) continue } if (uuid == null) { ctx.error('Workspace uuid not found', { dataId }) continue } const wsIds = { uuid, dataId, url } const storage = await createFileBackupStorage(`${dirName}/${dataId}`) const storageConfig = storageConfigFromEnv() const queue = getPlatformQueue('tool', workspace.region) const wsProducer = queue.getProducer(ctx, QueueTopic.Workspace) await wsProducer.send(ctx, uuid, [workspaceEvents.restoring()]) const workspaceStorage: StorageAdapter = buildStorageFromConfig(storageConfig) let pipeline: Pipeline | undefined try { pipeline = await createBackupPipeline(ctx, dbUrl, txes, { externalStorage: workspaceStorage, usePassedCtx: true })(ctx, wsIds, createEmptyBroadcastOps(), null) if (pipeline === undefined) { ctx.error('failed to restore, pipeline is undefined', { dataId }) return } await sendTransactorEvent(uuid, 'force-maintenance') await restore(ctx, pipeline, wsIds, storage, { date: -1, merge: false, parallel: 1, recheck: false }) await sendTransactorEvent(uuid, 'force-close') ctx.info('workspace restored', { dataId }) await wsProducer.send(ctx, uuid, [workspaceEvents.restored()]) if (activeWorkspaces.has(uuid)) { // set workspace back to active await accountDB.workspaceStatus.update({ workspaceUuid: uuid }, { mode: 'active' }) } } catch (err) { ctx.error('failed to restore workspace', { dataId, err }) } finally { await pipeline?.close() await queue.shutdown() await workspaceStorage?.close() } } ctx.info('Successfully restored v6 dump') } catch (err: any) { ctx.error('Failed to restore v6 dump', { err }) } } export async function restoreTrustedV6Workspace ( ctx: MeasureMetricsContext, accountDB: AccountDB, workspace: OldWorkspace, accounts: OldAccount[], invites: any[], backupWsStorage: BackupStorage, workspaceStorage: StorageAdapter, txes: Tx[], dbUrl: string, opts?: { conflictSuffix?: string region?: string branding?: string force?: boolean } ): Promise { const { conflictSuffix, region, branding, force } = opts ?? {} // Mapping between const accountsIdToUuid: Record = {} // Mapping between const accountsEmailToUuid: Record = {} let workspaceUuid: WorkspaceUuid | undefined let newWorkspaceUrl: string | undefined ctx.info('Restoring workspace accounts...') let accountsProcessed = 0 for (const account of accounts) { try { const accountUuid = await migrateAccount(account, accountDB, false) if (accountUuid == null) { ctx.warn('Account not restored', account) continue } accountsIdToUuid[account._id.toString()] = accountUuid accountsEmailToUuid[account.email] = accountUuid accountsProcessed++ if (accountsProcessed % 100 === 0) { ctx.info('Processed accounts:', { accountsProcessed }) } } catch (err: any) { ctx.error('Failed to restore account', { _id: account._id, email: account.email, err }) } } ctx.info('Total accounts processed:', { accountsProcessed }) const oldMode = workspace.mode try { // Create workspace with manual-creation mode until it is restored ;[workspaceUuid, newWorkspaceUrl] = (await migrateWorkspace( workspace, accountDB, accountsIdToUuid, accountsEmailToUuid, false, 'manual-creation', conflictSuffix, force !== true, region, branding )) ?? [] if (workspaceUuid === undefined) { ctx.error('Workspace uuid not set', { workspace: workspace.workspace }) throw new Error(`Workspace uuid not set ${workspace.workspace}`) } if (newWorkspaceUrl == null) { ctx.error('Workspace url not set', { workspace: workspace.workspace }) throw new Error(`Workspace url not set ${workspace.workspace}`) } let invitesProcessed = 0 for (const invite of invites) { try { if (workspace.workspace !== invite.workspace.name) { ctx.error( `Invite workspace ${invite.workspace.name} doesn't match workspace being restored ${workspace.workspace}` ) continue } const existing = await accountDB.invite.findOne({ migratedFrom: invite._id.toString() }) if (existing != null) { continue } const inviteRecord = { migratedFrom: invite._id.toString(), workspaceUuid, expiresOn: invite.exp, emailPattern: invite.emailMask, remainingUses: invite.limit, role: invite.role ?? AccountRole.User } await accountDB.invite.insertOne(inviteRecord) invitesProcessed++ if (invitesProcessed % 100 === 0) { ctx.info('Processed invites:', { invitesProcessed }) } } catch (err: any) { ctx.error('Failed to restore invite', { _id: invite._id, err }) } } ctx.info('Total invites processed:', { invitesProcessed }) const dataId = workspace.workspace const url = newWorkspaceUrl const uuid = workspaceUuid const wsIds = { uuid, dataId, url } const queue = getPlatformQueue('tool', region) const wsProducer = queue.getProducer(ctx, QueueTopic.Workspace) await wsProducer.send(ctx, uuid, [workspaceEvents.restoring()]) let pipeline: Pipeline | undefined try { pipeline = await createBackupPipeline(ctx, dbUrl, txes, { externalStorage: workspaceStorage, usePassedCtx: true })(ctx, wsIds, createEmptyBroadcastOps(), null) if (pipeline === undefined) { ctx.error('failed to restore, pipeline is undefined', { dataId }) return } await sendTransactorEvent(uuid, 'force-maintenance') await restore(ctx, pipeline, wsIds, backupWsStorage, { date: -1, merge: false, parallel: 1, recheck: false }) await sendTransactorEvent(uuid, 'force-close') ctx.info('workspace restored', { dataId }) await wsProducer.send(ctx, uuid, [workspaceEvents.restored()]) await accountDB.workspaceStatus.update({ workspaceUuid: uuid }, { mode: oldMode }) } catch (err) { ctx.error('failed to restore backup of the workspace', { url, dataId, err }) } finally { await pipeline?.close() await queue.shutdown() await workspaceStorage?.close() } } catch (err: any) { ctx.error('Failed to restore workspace', { url: workspace.workspaceUrl, workspace: workspace.workspace, err }) } }