398 lines
12 KiB
TypeScript

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<string, unknown>;
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<string, unknown>)
.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<Record<string, unknown>>,
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<DataRow[]>`
select * from "AiProvider" order by "id"
`;
const models = await remote<DataRow[]>`select * from "AiModel" order by "id"`;
const settings = await remote<DataRow[]>`
select * from "SystemSettings"
where "key" in ${remote([...VIDEO_CANVAS_SETTING_KEYS])}
order by "key"
`;
const tools = await remote<DataRow[]>`
select * from "ToolConfig" order by "toolName"
`;
const storageConfigs = await remote<DataRow[]>`
select * from "StorageConfig" order by "id"
`;
const siteRows = await remote<DataRow[]>`
select * from "SiteSettings" order by "id" limit 1
`;
const preservedSettings = preserveVideoCanvasConfig
? await local<DataRow[]>`
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<DataRow[]>`
select * from "AiProvider" order by "id"
`;
const copiedModels = await local<DataRow[]>`
select * from "AiModel" order by "id"
`;
const copiedSettings = await local<DataRow[]>`
select * from "SystemSettings"
where "key" in ${local([...VIDEO_CANVAS_SETTING_KEYS])}
order by "key"
`;
const copiedTools = await local<DataRow[]>`
select * from "ToolConfig" order by "toolName"
`;
const copiedStorage = await local<DataRow[]>`
select * from "StorageConfig" order by "id"
`;
const copiedSite = await local<DataRow[]>`
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<DataRow[]>`
select "email", "role", "pointsBalance", "isActive"
from "User"
where "id" = ${TEST_USER.id}
`;
const [testProject] = await local<DataRow[]>`
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);
});