#!/usr/bin/env node import { writeFile } from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main'; const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || ''; const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, ''); const FUNCTION_URL = PARSE_URL.replace(/\/parse$/, '/api/functions'); const commit = process.argv.includes('--commit'); const refreshExisting = process.argv.includes('--refresh-existing'); const requestedModels = new Set( (process.argv.find((value) => value.startsWith('--models='))?.split('=')[1] || '53,54,56,58,59,60,61') .split(',').map(Number).filter(Boolean), ); const requestedPageStart = Math.max(1, Number(process.argv.find((value) => value.startsWith('--page-start='))?.split('=')[1] || 1)); const requestedPageEndRaw = Number(process.argv.find((value) => value.startsWith('--page-end='))?.split('=')[1] || 0); const requestedPageEnd = Number.isFinite(requestedPageEndRaw) && requestedPageEndRaw > 0 ? Math.max(requestedPageStart, requestedPageEndRaw) : 0; const sourceConcurrency = Math.min(10, Math.max(1, Number(process.env.XIAOSHU_SOURCE_CONCURRENCY || 6))); const writeConcurrency = Math.min(8, Math.max(1, Number(process.env.XIAOSHU_WRITE_CONCURRENCY || 3))); const pageSize = 1000; if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY'); const specs = { 53: { label: '练习记录', className: 'PracticeRecord', tableName: 'ZL_C_lxjl', nodeId: 32 }, 54: { label: '预约排课', className: 'CourseAppointment', tableName: 'ZL_C_order', nodeId: 29 }, 56: { label: '每日学习记录', className: 'DailyStudyRecord', tableName: 'ZL_C_ss', nodeId: 291 }, 58: { label: '课程绑定', className: 'CourseBinding', tableName: 'ZL_C_kcbd', nodeId: 28 }, 59: { label: '上课记录', className: 'LessonRecord', tableName: 'ZL_C_skjl', nodeId: 296 }, 60: { label: '抗遗忘记录', className: 'MemoryPracticeRecord', tableName: 'ZL_C_gywjl', nodeId: 388 }, 61: { label: '测评档案', className: 'AssessmentProfile', tableName: 'ZL_C_cpda', nodeId: 389 }, }; for (const modelId of requestedModels) if (!specs[modelId]) throw new Error(`不支持 Model ${modelId}`); const parseHeaders = { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY, 'Content-Type': 'application/json', }; const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); async function parse(path, init = {}, retries = 5) { try { const response = await fetch(`${PARSE_URL}${path}`, { ...init, headers: { ...parseHeaders, ...(init.headers || {}) }, }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.error) { const message = typeof payload.error === 'string' ? payload.error : JSON.stringify(payload.error || { status: response.status }); const error = new Error(message); error.status = response.status; throw error; } return payload; } catch (error) { if (!retries || (error.status && error.status < 500 && error.status !== 429)) throw error; await sleep((6 - retries) * 1000); return parse(path, init, retries - 1); } } const config = (await parse('/config')).params || {}; const legacyUrl = String(config.legacyScheduleApiUrl || ''); const legacyApiId = String(config.legacyScheduleApiId || ''); const legacyApiKey = String(config.legacyScheduleApiKey || ''); if (!legacyUrl || !legacyApiId || !legacyApiKey) throw new Error('生产 Parse Config 未配置旧系统只读接口'); const companyRow = (await parse('/classes/Company?limit=1&keys=objectId')).results?.[0]; if (!companyRow?.objectId) throw new Error('生产 Parse 未找到 Company'); const company = { __type: 'Pointer', className: 'Company', objectId: companyRow.objectId }; async function legacyPage(modelId, page, retries = 5) { try { const query = new URLSearchParams({ action: 'content_list', modelId: String(modelId), psize: String(pageSize), cpage: String(page), apiId: legacyApiId, apiKey: legacyApiKey, }); const response = await fetch(`${legacyUrl}${legacyUrl.includes('?') ? '&' : '?'}${query}`, { headers: { accept: 'application/json' } }); const payload = await response.json().catch(() => ({})); if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `旧接口失败:${response.status}`); let result = payload.result; if (typeof result === 'string') result = JSON.parse(result); return { items: Array.isArray(result) ? result : [], page: payload.page || {} }; } catch (error) { if (!retries) throw error; await sleep((6 - retries) * 800); return legacyPage(modelId, page, retries - 1); } } async function parallelMap(items, concurrency, worker, progress) { const output = new Array(items.length); let cursor = 0; let completed = 0; const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (true) { const index = cursor++; if (index >= items.length) return; output[index] = await worker(items[index], index); completed += 1; if (progress && (completed % progress === 0 || completed === items.length)) process.stderr.write(`读取进度 ${completed}/${items.length}\n`); } }); await Promise.all(runners); return output; } async function readLegacyModel(modelId) { const first = await legacyPage(modelId, 1); const total = Number(first.page.itemCount ?? first.items.length); const pageCount = Math.max(1, Number(first.page.pageCount || Math.ceil(total / pageSize))); const selectedPageStart = Math.min(pageCount, requestedPageStart); const selectedPageEnd = Math.min(pageCount, requestedPageEnd || pageCount); const pages = Array.from( { length: Math.max(0, selectedPageEnd - Math.max(2, selectedPageStart) + 1) }, (_, index) => Math.max(2, selectedPageStart) + index, ); const rest = await parallelMap(pages, sourceConcurrency, (page) => legacyPage(modelId, page), 50); const byId = new Map(); const selectedRows = [...(selectedPageStart === 1 ? first.items : []), ...rest.flatMap((page) => page.items)]; for (const row of selectedRows) { const generalId = Number(row.GeneralID ?? row.generalId ?? 0); if (generalId) byId.set(generalId, row); } return { total, pageCount, selectedPageStart, selectedPageEnd, selectedCount: selectedRows.length, rows: [...byId.values()], duplicateOrShifted: selectedRows.length - byId.size, }; } async function allParseRows(className, keyField, where, keys) { const rows = []; let cursor = -1; while (true) { const query = new URLSearchParams({ where: JSON.stringify({ ...where, [keyField]: { $gt: cursor } }), order: keyField, limit: '1000', keys, }); const page = (await parse(`/classes/${className}?${query}`)).results || []; rows.push(...page); if (page.length < 1000) break; cursor = Number(page.at(-1)?.[keyField]); if (!Number.isFinite(cursor)) throw new Error(`${className}.${keyField} 游标无效`); } return rows; } async function parseRowsByValues(className, keyField, where, values, keys) { const unique = [...new Set(values.map(Number).filter(Number.isFinite))]; if (!unique.length) return []; const chunks = []; for (let index = 0; index < unique.length; index += 400) chunks.push(unique.slice(index, index + 400)); const pages = await parallelMap(chunks, sourceConcurrency, async (chunk) => { const query = new URLSearchParams({ where: JSON.stringify({ ...where, [keyField]: { $in: chunk } }), limit: '1000', keys, }); return (await parse(`/classes/${className}?${query}`)).results || []; }, 100); return pages.flat(); } async function orphanAddonRows(className, keys) { const rows = []; for (let skip = 0; ; skip += 1000) { const query = new URLSearchParams({ where: JSON.stringify({ company, id: { $exists: false } }), order: 'createdAt', limit: '1000', skip: String(skip), keys, }); const page = (await parse(`/classes/${className}?${query}`)).results || []; rows.push(...page); if (page.length < 1000) break; } return rows; } function text(value) { return String(value ?? '').trim(); } function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; } function dateValue(value) { const raw = text(value); if (!/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?/.test(raw)) return null; const normalized = raw.replace(/^(\d{4})-(\d)(?=-)/, '$1-0$2').replace(/-(\d)(?=[ T])/, '-0$1').replace(' ', 'T'); const date = new Date(normalized + (normalized.includes('T') ? '+08:00' : 'T00:00:00+08:00')); return Number.isNaN(date.getTime()) ? null : { __type: 'Date', iso: date.toISOString() }; } function setIf(body, key, value) { if (value !== null && value !== undefined && value !== '') body[key] = value; } const legacyAddonFields = { 53: ['kcid','scid','xxcs','yhid','jrscb'], 54: ['pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'], 56: ['pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'], 58: ['yxx','cksl','kcid','syjd','yhid'], 59: ['pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'], 60: ['fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'], 61: ['df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'], }; const numericAddonFields = new Set(['scid','xxcs','yhid@53','djq','ygg','userId@56','learned','yxx','cksl','pf','kcid@60','plid','yhid@60','xxjlid','orderId']); function signatureValue(modelId, field, value) { return numericAddonFields.has(field) || numericAddonFields.has(`${field}@${modelId}`) ? number(value) : text(value); } function addonSignature(modelId, source) { return JSON.stringify(legacyAddonFields[modelId].map((field) => signatureValue(modelId, field, source[field]))); } function commonBody(modelId, row, itemId) { const spec = specs[modelId]; const generalId = number(row.GeneralID ?? row.generalId); const body = { company, sourceKey: `legacy:model:${modelId}:general:${generalId}`, generalId, itemId, modelId, nodeId: number(row.NodeID ?? row.nodeId, spec.nodeId), tableName: spec.tableName, title: text(row.Title ?? row.title), subtitle: text(row.Subtitle ?? row.subtitle), inputer: text(row.Inputer ?? row.inputer), topImg: text(row.TopImg ?? row.topImg), template: text(row.Template ?? row.template), hits: number(row.Hits ?? row.hits), status: 99, }; const created = dateValue(row.CreateTime ?? row.createTime); if (created) { body.createTime = created; body.upDateTime = created; } return body; } function addonBody(modelId, row, itemId, generalId) { // Parse REST/SDK reserves the property name `id`. The physical legacy id is // assigned in one controlled SQL step after both objects exist. const body = { company, sourceKey: `legacy:model:${modelId}:general:${generalId}` }; if (modelId === 53) Object.assign(body, { kcid: text(row.kcid), scid: number(row.scid), xxcs: number(row.xxcs), yhid: number(row.yhid), jrscb: text(row.jrscb), }); if (modelId === 54) for (const field of ['pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid']) body[field] = text(row[field]); if (modelId === 56) Object.assign(body, { pl: text(row.pl), con: text(row.con), djq: number(row.djq), ygg: number(row.ygg), dqrq: text(row.dqrq), dsid: text(row.dsid), fxrl: text(row.fxrl), xxqs: text(row.xxqs), szmdid: text(row.szmdid), userId: number(row.UserID ?? row.userId), learned: number(row.learned), }); if (modelId === 58) Object.assign(body, { yxx: number(row.yxx), cksl: number(row.cksl), kcid: text(row.kcid), syjd: text(row.syjd), yhid: text(row.yhid), }); if (modelId === 59) { for (const field of ['jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid']) body[field] = text(row[field]); setIf(body, 'pf', row.pf === null || row.pf === '' ? null : number(row.pf)); body.legacyGeneralId = generalId; body.studentName = text(row.Title ?? row.title); body.coachName = text(row.Inputer ?? row.inputer); body.courseName = text(row.kcmc ?? row.Subtitle ?? row.subtitle); body.storeId = number(row.szmdid); const lessonAt = dateValue(row.plpjsj) || dateValue(row.CreateTime ?? row.createTime); const sourceCreatedAt = dateValue(row.CreateTime ?? row.createTime); if (lessonAt) body.lessonAt = lessonAt; if (sourceCreatedAt) body.sourceCreatedAt = sourceCreatedAt; } if (modelId === 60) Object.assign(body, { fxzt: text(row.fxzt), kcid: number(row.kcid), plid: number(row.plid), wcsj: text(row.wcsj), yhid: number(row.yhid), kywrq: text(row.kywrq), kywsj: text(row.kywsj), xxjlid: number(row.xxjlid), orderId: number(row.orderId), }); if (modelId === 61) Object.assign(body, { df: text(row.df), askid: text(row.askid), wrong: text(row.wrong), userId: text(row.UserID ?? row.userId), answerid: text(row.answerid), dontKnow: text(row.dontKnow), prevScore: text(row.prev_score ?? row.prevScore), totalScore: text(row.totalScore), }); return body; } function requestGroups(requests, maxRequests = 50, maxBytes = 700_000) { const groups = []; let current = []; let bytes = 0; for (const request of requests) { const size = Buffer.byteLength(JSON.stringify(request)); if (current.length && (current.length >= maxRequests || bytes + size > maxBytes)) { groups.push(current); current = []; bytes = 0; } current.push(request); bytes += size; } if (current.length) groups.push(current); return groups; } async function executeGroups(groups, label) { let cursor = 0; let completed = 0; const runners = Array.from({ length: Math.min(writeConcurrency, groups.length) }, async () => { while (true) { const index = cursor++; if (index >= groups.length) return; const result = await parse('/batch', { method: 'POST', body: JSON.stringify({ requests: groups[index] }) }); const failures = (result || []).filter((item) => item.error); if (failures.length) throw new Error(`${label} 批次 ${index + 1} 写入失败:${JSON.stringify(failures.slice(0, 3))}`); completed += 1; if (completed % 100 === 0 || completed === groups.length) process.stderr.write(`${label} 写入 ${completed}/${groups.length}\n`); } }); await Promise.all(runners); } let linkUserId = ''; let linkFunctionId = ''; let linkToken = ''; let linkPath = ''; async function ensureLinkFunction() { if (linkFunctionId) return; const suffix = `${Date.now()}_${randomBytes(4).toString('hex')}`; const username = `backfill_link_${suffix}`; const password = `${randomBytes(24).toString('base64url')}Aa9!`; linkPath = `xiaoshu/system/backfill-link-addon-${suffix}`; const createdUser = await parse('/users', { method: 'POST', body: JSON.stringify({ username, password, isAdmin: true, role: 'admin', roles: ['admin', 'super-admin'], adminRoleKey: 'super-admin', company, realName: '核心业务迁移编号回填临时管理员', testCreatedBy: 'backfill-core-business-data', }), }); linkUserId = createdUser.objectId; linkToken = createdUser.sessionToken || ''; if (!linkToken) linkToken = (await parse('/login', { method: 'POST', body: JSON.stringify({ username, password }) })).sessionToken; if (!linkToken) throw new Error('临时超级管理员没有会话'); const functionCode = String.raw` async function handler(request, response) { try { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) return response.status(401).json({ success:false, message:'需要超级管理员会话' }); await current.fetch({ useMasterKey:true }); const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : []; if (current.get('adminRoleKey') !== 'super-admin' && !roles.includes('super-admin')) return response.status(403).json({ success:false, message:'仅超级管理员可执行' }); const modelId = Number(request.params && request.params.modelId || request.body && request.body.params && request.body.params.modelId || 0); const tableMap = ${JSON.stringify(Object.fromEntries(Object.entries(specs).map(([modelId, spec]) => [modelId, spec.className])))}; const table = tableMap[String(modelId)]; if (!table) return response.status(400).json({ success:false, message:'不支持该业务模型' }); const company = current.get('company'); const companyId = company && company.id; if (!companyId) return response.status(400).json({ success:false, message:'管理员缺少帐套' }); const rows = await Psql.query( 'UPDATE "' + table + '" a SET "id"=c."itemId","updatedAt"=NOW() FROM "CommonModel" c WHERE a."company"=$1 AND c."company"=$1 AND CAST(c."modelId" AS text)=$2 AND (a."sourceKey"=c."sourceKey" OR a."sourceKey"=(\'legacy:model:\'||$2||\':general:\'||CAST(c."generalId" AS text))) AND a."id" IS DISTINCT FROM c."itemId" RETURNING a."objectId"', [companyId, String(modelId)] ); response.json({ success:true, data:{ modelId, table, linked:rows.length } }); } catch (error) { response.status(Number(error.status)||500).json({ success:false, message:String(error.message||error) }); } }`; const createdFunction = await parse('/classes/Function', { method: 'POST', body: JSON.stringify({ name: linkPath, desc: '一次性回填核心业务附表旧编号', type: 'standalone', path: linkPath, code: functionCode, params: [], paramList: [], respType: 'json', respJson: { success: true }, enabled: true, }), }); linkFunctionId = createdFunction.objectId; } async function linkAddonIds(modelId) { await ensureLinkFunction(); const response = await fetch(`${FUNCTION_URL}/${linkPath}`, { method: 'POST', headers: { 'X-Parse-Application-Id': APP_ID, 'Content-Type': 'application/json' }, body: JSON.stringify({ token: linkToken, params: { modelId } }), }); const payload = await response.json().catch(() => ({})); if (!response.ok || payload.success !== true) throw new Error(payload.message || payload.error || `附表编号回填失败:${response.status}`); return Number(payload.data?.linked || 0); } async function cleanupLinkFunction() { if (linkFunctionId) await parse(`/classes/Function/${linkFunctionId}`, { method: 'DELETE' }).catch(() => undefined); if (linkUserId) { const where = encodeURIComponent(JSON.stringify({ user: { __type: 'Pointer', className: '_User', objectId: linkUserId } })); const sessions = await parse(`/classes/_Session?where=${where}&limit=1000&keys=objectId`).catch(() => ({ results: [] })); for (const session of sessions.results || []) await parse(`/classes/_Session/${session.objectId}`, { method: 'DELETE' }).catch(() => undefined); await parse(`/users/${linkUserId}`, { method: 'DELETE' }).catch(() => undefined); } } const report = { startedAt: new Date().toISOString(), mode: commit ? 'commit' : 'dry-run', companyId: company.objectId, models: [] }; try { for (const modelId of [...requestedModels].sort((a, b) => a - b)) { const spec = specs[modelId]; process.stderr.write(`\n读取旧系统 ${spec.label}(Model ${modelId})…\n`); const source = await readLegacyModel(modelId); process.stderr.write(`旧系统分片 ${source.rows.length} 条(总计 ${source.total}),页 ${source.selectedPageStart}-${source.selectedPageEnd}/${source.pageCount}\n`); const sliced = source.selectedPageStart > 1 || source.selectedPageEnd < source.pageCount; const sourceGeneralIds = source.rows.map((row) => number(row.GeneralID ?? row.generalId)); const commonRows = sliced ? await parseRowsByValues('CommonModel', 'generalId', { company, modelId }, sourceGeneralIds, 'objectId,generalId,itemId,sourceKey') : await allParseRows('CommonModel', 'generalId', { company, modelId }, 'objectId,generalId,itemId,sourceKey'); const commonByGeneralIdForCandidates = new Map(commonRows.map((row) => [number(row.generalId), row])); const addonCandidateIds = sourceGeneralIds.map((generalId) => number(commonByGeneralIdForCandidates.get(generalId)?.itemId, generalId)); const addonRows = sliced ? await parseRowsByValues(spec.className, 'id', { company }, addonCandidateIds, 'objectId,id,sourceKey') : await allParseRows(spec.className, 'id', { company }, 'objectId,id,sourceKey'); const commonByGeneralId = new Map(commonRows.map((row) => [number(row.generalId), row])); const addonById = new Map(addonRows.map((row) => [number(row.id), row])); const usedAddonIds = new Set(addonById.keys()); const requests = []; const orphanRows = await orphanAddonRows(spec.className, ['objectId', ...legacyAddonFields[modelId]].join(',')); const existingCandidatesBySignature = new Map(); for (const row of source.rows) { const generalId = number(row.GeneralID ?? row.generalId); const common = commonByGeneralId.get(generalId); if (!common) continue; const signature = addonSignature(modelId, addonBody(modelId, row, number(common.itemId), generalId)); if (!existingCandidatesBySignature.has(signature)) existingCandidatesBySignature.set(signature, []); existingCandidatesBySignature.get(signature).push({ generalId, itemId: number(common.itemId), source: row }); } for (const candidates of existingCandidatesBySignature.values()) candidates.sort((a, b) => a.itemId - b.itemId); let repairedOrphans = 0; let unmatchedOrphans = 0; for (const orphan of orphanRows) { const candidates = existingCandidatesBySignature.get(addonSignature(modelId, orphan)) || []; const candidate = candidates.find((item) => !addonById.has(item.itemId)); if (!candidate) { unmatchedOrphans += 1; continue; } const body = addonBody(modelId, candidate.source, candidate.itemId, candidate.generalId); requests.push({ method: 'PUT', path: `/parse/classes/${spec.className}/${orphan.objectId}`, body }); addonById.set(candidate.itemId, { ...orphan, ...body }); usedAddonIds.add(candidate.itemId); repairedOrphans += 1; } let createCommon = 0; let createAddon = 0; let updateCommon = 0; let updateAddon = 0; for (const row of source.rows) { const generalId = number(row.GeneralID ?? row.generalId); const existingCommon = commonByGeneralId.get(generalId); let itemId = existingCommon ? number(existingCommon.itemId) : generalId; if (!existingCommon && usedAddonIds.has(itemId)) itemId = 900_000_000_000 + generalId * 100 + modelId; usedAddonIds.add(itemId); const existingAddon = addonById.get(itemId); if (!existingAddon) { requests.push({ method: 'POST', path: `/parse/classes/${spec.className}`, body: addonBody(modelId, row, itemId, generalId) }); createAddon += 1; } else if (refreshExisting) { requests.push({ method: 'PUT', path: `/parse/classes/${spec.className}/${existingAddon.objectId}`, body: addonBody(modelId, row, itemId, generalId) }); updateAddon += 1; } if (!existingCommon) { requests.push({ method: 'POST', path: '/parse/classes/CommonModel', body: commonBody(modelId, row, itemId) }); createCommon += 1; } else if (refreshExisting) { requests.push({ method: 'PUT', path: `/parse/classes/CommonModel/${existingCommon.objectId}`, body: commonBody(modelId, row, itemId) }); updateCommon += 1; } } const groups = requestGroups(requests); const summary = { modelId, label: spec.label, sourceTotal: source.total, sourceUnique: source.rows.length, sourcePageStart: source.selectedPageStart, sourcePageEnd: source.selectedPageEnd, sourcePageCount: source.pageCount, sourceDuplicateOrShifted: source.duplicateOrShifted, parseCommonBefore: commonRows.length, parseAddonBefore: addonRows.length, orphanRows: orphanRows.length, repairedOrphans, unmatchedOrphans, createCommon, createAddon, updateCommon, updateAddon, requests: requests.length, batches: groups.length, }; report.models.push(summary); process.stderr.write(`${JSON.stringify(summary)}\n`); if (commit && groups.length) { await executeGroups(groups, spec.label); summary.linkedAddonIds = await linkAddonIds(modelId); process.stderr.write(`${spec.label} 附表编号回填 ${summary.linkedAddonIds} 条\n`); } } report.completedAt = new Date().toISOString(); const reportFile = `docs/migration/core-business-backfill-${commit ? 'commit' : 'dry-run'}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`; await writeFile(reportFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); process.stdout.write(`${JSON.stringify({ ...report, reportFile }, null, 2)}\n`); } finally { await cleanupLinkFunction(); }