398 lines
12 KiB
TypeScript
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);
|
|
});
|