import { createHash } from "node:crypto"; import { config as loadEnv } from "dotenv"; import postgres from "postgres"; import { generateHashedPassword } from "../../lib/db/utils"; loadEnv({ path: ".env.local" }); const TEST_USER = { id: "00000000-0000-4000-8000-000000000101", email: "canvas-test@local.dev", password: "CanvasTest2026!", projectId: "00000000-0000-4000-8000-000000000201", } as const; const VIDEO_CANVAS_SETTING_KEYS = [ "ai_detail_generator_enabled", "angle_edit_config", "canvas_agent_mode_enabled", "canvas_detail_agent_moonshot_config", "canvas_toolbar_config", "enhance_api_config", "enhance_pro_krea_config", "extend_api_config", "external_image_api_config", "layer_split_config", "realtime_canvas_config", "realtime_canvas_mode_icon_config", "rmbg_api_config", "scene_fusion_config", "storage_failover_config", "video_canvas_enabled", "video_canvas_function_config", "video_canvas_sidebar_enabled", "watermark_remove_config", "worldlabs_key_pool_config", ] as const; const PRESERVED_VIDEO_CANVAS_SETTING_KEYS = [ "video_canvas_function_config", ] as const; type DataRow = Record; function getDatabaseUrls() { const localValue = process.env.POSTGRES_URL; if (!localValue) { throw new Error("POSTGRES_URL is required in .env.local"); } const localUrl = new URL(localValue); if (!new Set(["127.0.0.1", "localhost", "::1"]).has(localUrl.hostname)) { throw new Error("Refusing to replace data unless POSTGRES_URL points to localhost"); } const remoteUrl = process.env.VIDEO_CANVAS_REMOTE_POSTGRES_URL ? new URL(process.env.VIDEO_CANVAS_REMOTE_POSTGRES_URL) : new URL(localUrl.toString()); if (!process.env.VIDEO_CANVAS_REMOTE_POSTGRES_URL) { remoteUrl.port = process.env.VIDEO_CANVAS_REMOTE_POSTGRES_PORT || "15433"; } if (remoteUrl.host === localUrl.host) { throw new Error("Remote source and local destination must use different endpoints"); } return { localUrl, remoteUrl }; } function mutableRows(rows: readonly DataRow[]) { return rows.map((row) => ({ ...row })); } function canonicalize(value: unknown): unknown { if (value instanceof Date) { return value.toISOString(); } if (Array.isArray(value)) { return value.map(canonicalize); } if (value && typeof value === "object") { return Object.fromEntries( Object.entries(value as Record) .filter(([key]) => key !== "updatedBy") .sort(([left], [right]) => left.localeCompare(right)) .map(([key, child]) => [key, canonicalize(child)]) ); } return value; } function digestRows(rows: readonly DataRow[]) { return createHash("sha256") .update(JSON.stringify(canonicalize(rows))) .digest("hex"); } async function insertRows( sql: postgres.Sql>, tableName: string, rows: DataRow[] ) { if (rows.length > 0) { await sql`insert into ${sql(tableName)} ${sql(rows)}`; } } async function main() { const { localUrl, remoteUrl } = getDatabaseUrls(); const preserveVideoCanvasConfig = process.argv.includes( "--preserve-video-canvas-config" ); const local = postgres(localUrl.toString(), { max: 1, prepare: false }); const remote = postgres(remoteUrl.toString(), { max: 1, prepare: false }); try { const [localMeta] = await local`select version() as version`; const [remoteMeta] = await remote`select version() as version`; if (!String(localMeta?.version || "").includes("PGlite")) { throw new Error("Destination is not the isolated local PGlite database"); } if (String(remoteMeta?.version || "").includes("PGlite")) { throw new Error("Remote source unexpectedly points to the local PGlite database"); } console.log("Reading model and video-canvas configuration from the server..."); const providers = await remote` select * from "AiProvider" order by "id" `; const models = await remote`select * from "AiModel" order by "id"`; const settings = await remote` select * from "SystemSettings" where "key" in ${remote([...VIDEO_CANVAS_SETTING_KEYS])} order by "key" `; const tools = await remote` select * from "ToolConfig" order by "toolName" `; const storageConfigs = await remote` select * from "StorageConfig" order by "id" `; const siteRows = await remote` select * from "SiteSettings" order by "id" limit 1 `; const preservedSettings = preserveVideoCanvasConfig ? await local` select * from "SystemSettings" where "key" in ${local([...PRESERVED_VIDEO_CANVAS_SETTING_KEYS])} order by "key" ` : []; const preservedSettingsByKey = new Map( preservedSettings.map((row) => [String(row.key), row]) ); const providerRows = mutableRows(providers).map((row) => ({ ...row, updatedBy: null, })); const modelRows = mutableRows(models); const effectiveSettings: DataRow[] = mutableRows(settings).map( (row): DataRow => ({ ...row, updatedBy: null, }) ); if (preserveVideoCanvasConfig) { for (const [key, row] of preservedSettingsByKey) { const index = effectiveSettings.findIndex( (setting) => setting.key === key ); const preservedRow = { ...row, updatedBy: null }; if (index >= 0) { effectiveSettings[index] = preservedRow; } else { effectiveSettings.push(preservedRow); } } effectiveSettings.sort((left, right) => String(left.key).localeCompare(String(right.key)) ); } const settingRows = effectiveSettings.map((row) => ({ ...row, value: local.json(row.value as postgres.JSONValue), })); const toolRows = mutableRows(tools).map((row) => ({ ...row, updatedBy: null, })); const storageRows = mutableRows(storageConfigs).map((row) => ({ ...row, updatedBy: null, })); const localSiteRows = mutableRows(siteRows).map((row) => ({ ...row, updatedBy: null, })); // PGlite's socket multiplexer does not reliably support a long explicit // transaction. Every step is idempotent, so an interrupted sync can be rerun. console.log("Replacing the isolated local configuration..."); await local`delete from "CanvasProject" where "userId" = ${TEST_USER.id}`; await local` delete from "User" where "id" <> ${TEST_USER.id} and "email" = ${TEST_USER.email} `; await local`delete from "AiModel"`; await local`delete from "AiProvider"`; await insertRows(local, "AiProvider", providerRows); await insertRows(local, "AiModel", modelRows); await local` delete from "SystemSettings" where "key" in ${local([...VIDEO_CANVAS_SETTING_KEYS])} `; await insertRows(local, "SystemSettings", settingRows); await local`delete from "ToolConfig"`; await insertRows(local, "ToolConfig", toolRows); await local`delete from "StorageConfig"`; await insertRows(local, "StorageConfig", storageRows); await local`delete from "SiteSettings"`; await insertRows(local, "SiteSettings", localSiteRows); await local`delete from "AuthSettings"`; await local` insert into "AuthSettings" ( "isDingTalkEnabled", "isGoogleEnabled", "isRegistrationEnabled", "isEmailVerificationEnabled", "emailVerificationExpiresMinutes", "updatedAt", "updatedBy" ) values (false, false, true, false, 10, now(), null) `; await local` insert into "User" ( "id", "email", "password", "name", "role", "isHighVip", "isActive", "monthlyImageLimit", "pointsBalance", "pointsGiftedTotal", "createdAt", "updatedAt" ) values ( ${TEST_USER.id}, ${TEST_USER.email}, ${generateHashedPassword(TEST_USER.password)}, ${"视频画布本地测试"}, ${"admin"}, true, true, null, 100000, 100000, now(), now() ) on conflict ("id") do update set "email" = excluded."email", "password" = excluded."password", "name" = excluded."name", "role" = excluded."role", "isHighVip" = excluded."isHighVip", "isActive" = excluded."isActive", "monthlyImageLimit" = excluded."monthlyImageLimit", "pointsBalance" = excluded."pointsBalance", "pointsGiftedTotal" = excluded."pointsGiftedTotal", "updatedAt" = excluded."updatedAt" `; await local` insert into "CanvasProject" ( "id", "userId", "title", "description", "workspaceType", "canvasData", "createdAt", "updatedAt" ) values ( ${TEST_USER.projectId}, ${TEST_USER.id}, ${"视频画布竞品对照"}, ${"用于逐功能对照 LibLib 视频画布的本地隔离工程"}, ${"video"}, ${local.json({ nodes: [], edges: [], selectedNodeIds: [], viewport: { x: 0, y: 0, zoom: 1 }, tool: "select", })}, now(), now() ) `; const copiedProviders = await local` select * from "AiProvider" order by "id" `; const copiedModels = await local` select * from "AiModel" order by "id" `; const copiedSettings = await local` select * from "SystemSettings" where "key" in ${local([...VIDEO_CANVAS_SETTING_KEYS])} order by "key" `; const copiedTools = await local` select * from "ToolConfig" order by "toolName" `; const copiedStorage = await local` select * from "StorageConfig" order by "id" `; const copiedSite = await local` select * from "SiteSettings" order by "id" limit 1 `; const comparisons = [ ["AiProvider", providers, copiedProviders], ["AiModel", models, copiedModels], ["SystemSettings", effectiveSettings, copiedSettings], ["ToolConfig", tools, copiedTools], ["StorageConfig", storageConfigs, copiedStorage], ["SiteSettings", siteRows, copiedSite], ] as const; for (const [tableName, sourceRows, copiedRows] of comparisons) { if (digestRows(sourceRows) !== digestRows(copiedRows)) { throw new Error(`${tableName} verification failed after local copy`); } } const [testUser] = await local` select "email", "role", "pointsBalance", "isActive" from "User" where "id" = ${TEST_USER.id} `; const [testProject] = await local` select "id", "userId", "workspaceType" from "CanvasProject" where "id" = ${TEST_USER.projectId} `; if ( testUser?.email !== TEST_USER.email || testUser.role !== "admin" || testUser.pointsBalance !== 100000 || testUser.isActive !== true || testProject?.userId !== TEST_USER.id || testProject.workspaceType !== "video" ) { throw new Error("Local video-canvas test account verification failed"); } console.log( JSON.stringify( { copied: { providers: providerRows.length, models: modelRows.length, systemSettings: settingRows.length, tools: toolRows.length, storageConfigs: storageRows.length, siteSettings: localSiteRows.length, }, preservedSettings: preserveVideoCanvasConfig ? [...preservedSettingsByKey.keys()] : [], testAccount: { email: TEST_USER.email, role: "admin", pointsBalance: 100000, projectId: TEST_USER.projectId, }, verified: true, }, null, 2 ) ); } finally { await Promise.allSettled([local.end(), remote.end()]); } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); });