From e160d7f217da1085c1fcc5bae4e3bcc6141e073d Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 29 May 2026 16:52:40 +0800 Subject: [PATCH] fix: guard agent workflow config rollback --- .../agent-workflow-settings-client.tsx | 134 ++++++++++++++---- app/api/site-settings/route.ts | 124 +++++++++++++++- lib/agents/workflow-ui-config.ts | 133 +++++++++++++++++ .../0085_add_system_setting_snapshots.sql | 23 +++ lib/db/migrations/meta/_journal.json | 7 + lib/db/schema.ts | 12 ++ lib/system-settings/snapshot.ts | 66 +++++++++ package.json | 2 + scripts/system-settings/create-snapshot.ts | 83 +++++++++++ scripts/system-settings/restore-snapshot.ts | 103 ++++++++++++++ 10 files changed, 658 insertions(+), 29 deletions(-) create mode 100644 lib/db/migrations/0085_add_system_setting_snapshots.sql create mode 100644 lib/system-settings/snapshot.ts create mode 100644 scripts/system-settings/create-snapshot.ts create mode 100644 scripts/system-settings/restore-snapshot.ts diff --git a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx index ad4363b03..a7201415f 100644 --- a/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx +++ b/app/(admin)/settings/agent-workflow/agent-workflow-settings-client.tsx @@ -1811,6 +1811,52 @@ interface AgentWorkflowSettingsPayload { agentPsdTemplateSecondaryCategoryConfig?: AgentPsdTemplateSecondaryCategoryConfig; } +interface AgentWorkflowDestructiveChangePayload { + totalBeforeOptionCount: number; + totalAfterOptionCount: number; + removedOptionCount: number; + removedEnabledOptionCount: number; + promptClearedCount: number; + previewClearedCount: number; + removedOptionSamples: string[]; + promptClearedSamples: string[]; + previewClearedSamples: string[]; + approvalToken: string; +} + +function formatAgentWorkflowDestructiveChangeConfirmText( + payload: AgentWorkflowDestructiveChangePayload +) { + const lines = [ + "这次保存会对风格化工作流做大规模删改,系统已拦截。", + "", + `删除节点:${payload.removedOptionCount}(其中启用中 ${payload.removedEnabledOptionCount})`, + `清空提示词:${payload.promptClearedCount}`, + `清空预览图:${payload.previewClearedCount}`, + "", + ]; + + const sampleGroups: Array<[string, string[]]> = [ + ["将删除的分支示例", payload.removedOptionSamples], + ["将清空提示词的示例", payload.promptClearedSamples], + ["将清空预览图的示例", payload.previewClearedSamples], + ]; + + for (const [label, samples] of sampleGroups) { + if (samples.length === 0) { + continue; + } + lines.push(`${label}:`); + for (const sample of samples.slice(0, 5)) { + lines.push(`- ${sample}`); + } + lines.push(""); + } + + lines.push("如果这是你确认要执行的结构性删除,请点击“确定”再次保存。"); + return lines.join("\n"); +} + interface AdminAiModelOption { id: string; modelId?: string | null; @@ -3899,39 +3945,77 @@ export function AgentWorkflowSettingsClient({ changedPayload.agentWorkflowSettingsBaseSignatures[field] = baseSignatures[field]; } - const response = await fetchAgentWorkflowSettingsWithTimeout( - "/api/site-settings", - { + const submitChangedPayload = async (payload: Record) => + fetchAgentWorkflowSettingsWithTimeout("/api/site-settings", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(changedPayload), + body: JSON.stringify(payload), + }); + + let response = await submitChangedPayload(changedPayload as Record); + let responsePayload = (await response.json().catch(() => null)) as + | { + error?: string; + details?: Array<{ message?: string; path?: Array }>; + requiresDestructiveChangeApproval?: boolean; + destructiveChange?: AgentWorkflowDestructiveChangePayload; + } + | null; + + if ( + !response.ok && + responsePayload?.requiresDestructiveChangeApproval && + responsePayload?.destructiveChange?.approvalToken + ) { + const shouldContinue = window.confirm( + formatAgentWorkflowDestructiveChangeConfirmText( + responsePayload.destructiveChange + ) + ); + + if (!shouldContinue) { + toast.info("已取消本次高风险保存"); + return; } - ); + + response = await submitChangedPayload({ + ...changedPayload, + agentWorkflowDestructiveChangeApproval: { + token: responsePayload.destructiveChange.approvalToken, + }, + }); + responsePayload = (await response.json().catch(() => null)) as + | { + error?: string; + details?: Array<{ message?: string; path?: Array }>; + } + | null; + } if (!response.ok) { let message = "保存失败"; - try { - const payload = await response.json(); - if (Array.isArray(payload?.details) && payload.details.length > 0) { - const firstIssue = payload.details[0] as { - message?: string; - path?: Array; - }; - const pathText = - Array.isArray(firstIssue.path) && firstIssue.path.length > 0 - ? firstIssue.path - .filter((segment) => segment !== undefined && segment !== null && segment !== "") - .join(" / ") - : ""; - const reasonText = String(firstIssue.message || "").trim(); + if (Array.isArray(responsePayload?.details) && responsePayload.details.length > 0) { + const firstIssue = responsePayload.details[0] as { + message?: string; + path?: Array; + }; + const pathText = + Array.isArray(firstIssue.path) && firstIssue.path.length > 0 + ? firstIssue.path + .filter((segment) => segment !== undefined && segment !== null && segment !== "") + .join(" / ") + : ""; + const reasonText = String(firstIssue.message || "").trim(); - message = [payload?.error, pathText, reasonText].filter(Boolean).join(":"); - } else if (typeof payload?.error === "string" && payload.error.trim()) { - message = payload.error.trim(); - } - } catch { - // Ignore JSON parsing issues and keep the generic message. + message = [responsePayload?.error, pathText, reasonText] + .filter(Boolean) + .join(":"); + } else if ( + typeof responsePayload?.error === "string" && + responsePayload.error.trim() + ) { + message = responsePayload.error.trim(); } throw new Error(message); diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 46136fefc..c00320735 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -1,7 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; +import { createHash } from "node:crypto"; import { z } from "zod"; import { db } from "@/lib/db"; -import { aiProvider, siteSettings, systemSettingHistory, systemSettings } from "@/lib/db/schema"; +import { + aiProvider, + siteSettings, + systemSettingHistory, + systemSettingSnapshot, + systemSettings, +} from "@/lib/db/schema"; import { and, eq, inArray } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; import { isAgentWorkflowAdminType, isSiteAdminType } from "@/lib/middleware/admin"; @@ -35,8 +42,10 @@ import { AGENT_WORKFLOW_UI_CONFIG_KEY, AGENT_WORKFLOW_PROMPT_FRAGMENT_MAX_LENGTH, defaultAgentWorkflowUiConfig, + isAgentWorkflowDestructiveChangeSignificant, normalizeAgentWorkflowStepOrdering, normalizeAgentWorkflowUiConfig, + summarizeAgentWorkflowDestructiveChange, } from "@/lib/agents/workflow-ui-config"; import { AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY, @@ -105,6 +114,11 @@ import { PUBLIC_SITE_SETTINGS_CACHE_TTL_MS, setScopedSiteSettingsCache, } from "@/lib/site-settings/public-cache"; +import { + AGENT_WORKFLOW_SNAPSHOT_SCOPE, + createSystemSettingSnapshotData, + summarizeSystemSettingSnapshotData, +} from "@/lib/system-settings/snapshot"; import { getHomeChatAnimationHtmlBytes, HOME_CHAT_ANIMATION_CONFIG_KEY, @@ -1612,6 +1626,11 @@ const updateSchema = z.object({ agentWorkflowSettingsBaseSignatures: z .record(z.string().trim().min(1).max(128)) .optional(), + agentWorkflowDestructiveChangeApproval: z + .object({ + token: z.string().trim().min(16).max(128), + }) + .optional(), agentWorkflowUiConfig: z .object({ inputOptions: z @@ -1965,6 +1984,7 @@ export async function PUT(request: NextRequest) { "agentPresetLibraryConfig", "agentWorkflowSettingsBaseSignature", "agentWorkflowSettingsBaseSignatures", + "agentWorkflowDestructiveChangeApproval", "agentWorkflowUiConfig", "agentWorkflowMaterialApiConfig", "agentWorkflowGoodsInfoApiConfig", @@ -1992,6 +2012,7 @@ export async function PUT(request: NextRequest) { agentSecondaryTypeConfig, agentWorkflowSettingsBaseSignature, agentWorkflowSettingsBaseSignatures, + agentWorkflowDestructiveChangeApproval, agentWorkflowUiConfig, agentWorkflowMaterialApiConfig, agentWorkflowGoodsInfoApiConfig, @@ -2033,6 +2054,29 @@ export async function PUT(request: NextRequest) { .map(([field]) => field); const isUpdatingAgentWorkflowSettings = updatedAgentWorkflowFields.length > 0; + let currentWorkflowValues = new Map(); + let currentWorkflowSectionSignatures: + | ReturnType + | null = null; + let workflowDestructiveChangeSummary: + | ReturnType + | null = null; + let agentWorkflowSnapshotCreated = false; + + const createAgentWorkflowDestructiveChangeApprovalToken = (params: { + currentSignature: string; + nextConfig: unknown; + summary: ReturnType; + }) => + createHash("sha256") + .update( + JSON.stringify({ + currentSignature: params.currentSignature, + nextConfig: params.nextConfig, + summary: params.summary, + }) + ) + .digest("hex"); if (isUpdatingAgentWorkflowSettings) { if (!agentWorkflowSettingsBaseSignatures && !agentWorkflowSettingsBaseSignature) { @@ -2052,10 +2096,10 @@ export async function PUT(request: NextRequest) { }) .from(systemSettings) .where(inArray(systemSettings.key, [...AGENT_WORKFLOW_SETTINGS_CONFLICT_KEYS])); - const currentWorkflowValues = new Map( + currentWorkflowValues = new Map( currentWorkflowRows.map((row) => [row.key, row.value]) ); - const currentSectionSignatures = + currentWorkflowSectionSignatures = createAgentWorkflowSettingsSectionSignatures(currentWorkflowValues); const currentWorkflowSignature = agentWorkflowSettingsBaseSignature && !agentWorkflowSettingsBaseSignatures @@ -2064,7 +2108,8 @@ export async function PUT(request: NextRequest) { const hasWorkflowConflict = agentWorkflowSettingsBaseSignatures ? updatedAgentWorkflowFields.some( (field) => - currentSectionSignatures[field] !== agentWorkflowSettingsBaseSignatures[field] + currentWorkflowSectionSignatures![field] !== + agentWorkflowSettingsBaseSignatures[field] ) : currentWorkflowSignature !== agentWorkflowSettingsBaseSignature; @@ -2077,6 +2122,44 @@ export async function PUT(request: NextRequest) { { status: 409 } ); } + + if (agentWorkflowUiConfig !== undefined) { + const currentWorkflowUiConfig = normalizeAgentWorkflowUiConfig( + currentWorkflowValues.get(AGENT_WORKFLOW_UI_CONFIG_KEY) + ); + const nextWorkflowUiConfig = normalizeAgentWorkflowUiConfig(agentWorkflowUiConfig); + workflowDestructiveChangeSummary = summarizeAgentWorkflowDestructiveChange( + currentWorkflowUiConfig, + nextWorkflowUiConfig + ); + + if (isAgentWorkflowDestructiveChangeSignificant(workflowDestructiveChangeSummary)) { + const approvalToken = createAgentWorkflowDestructiveChangeApprovalToken({ + currentSignature: + currentWorkflowSectionSignatures.agentWorkflowUiConfig || + createAgentWorkflowSettingsSignature( + new Map([[AGENT_WORKFLOW_UI_CONFIG_KEY, currentWorkflowUiConfig]]) + ), + nextConfig: nextWorkflowUiConfig, + summary: workflowDestructiveChangeSummary, + }); + + if (agentWorkflowDestructiveChangeApproval?.token !== approvalToken) { + return NextResponse.json( + { + error: + "本次保存会删除大量风格化工作流节点或清空关键内容,已被系统拦截。请确认后再次提交。", + requiresDestructiveChangeApproval: true, + destructiveChange: { + ...workflowDestructiveChangeSummary, + approvalToken, + }, + }, + { status: 409 } + ); + } + } + } } const persistSystemSettingWithHistory = async ( @@ -2136,6 +2219,37 @@ export async function PUT(request: NextRequest) { }); }; + const createAgentWorkflowScopeSnapshot = async () => { + if (!isUpdatingAgentWorkflowSettings || agentWorkflowSnapshotCreated) { + return null; + } + + const snapshotData = createSystemSettingSnapshotData( + AGENT_WORKFLOW_SNAPSHOT_SCOPE, + currentWorkflowValues + ); + const [snapshot] = await db + .insert(systemSettingSnapshot) + .values({ + scope: AGENT_WORKFLOW_SNAPSHOT_SCOPE, + label: workflowDestructiveChangeSummary + ? "before-destructive-agent-workflow-save" + : "before-agent-workflow-save", + summary: { + ...summarizeSystemSettingSnapshotData(snapshotData), + updatedFields: updatedAgentWorkflowFields, + destructiveChange: workflowDestructiveChangeSummary, + }, + data: snapshotData, + createdAt: new Date(), + createdBy: session.user.id, + }) + .returning({ id: systemSettingSnapshot.id }); + + agentWorkflowSnapshotCreated = true; + return snapshot?.id ?? null; + }; + const persistHomeAnimationConfig = async () => { if (homeAnimationConfig === undefined) { return; @@ -2175,6 +2289,8 @@ export async function PUT(request: NextRequest) { }, }); }; + + await createAgentWorkflowScopeSnapshot(); // 查找现有设置 const [existing] = await db.select().from(siteSettings).limit(1); diff --git a/lib/agents/workflow-ui-config.ts b/lib/agents/workflow-ui-config.ts index b40837810..d54a02019 100644 --- a/lib/agents/workflow-ui-config.ts +++ b/lib/agents/workflow-ui-config.ts @@ -798,6 +798,139 @@ export function countAgentWorkflowOptionTree( return flattenAgentWorkflowOptionTree(options).length; } +export interface AgentWorkflowDestructiveChangeSummary { + totalBeforeOptionCount: number; + totalAfterOptionCount: number; + removedOptionCount: number; + removedEnabledOptionCount: number; + promptClearedCount: number; + previewClearedCount: number; + removedOptionSamples: string[]; + promptClearedSamples: string[]; + previewClearedSamples: string[]; +} + +const AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT = 12; + +function collectAgentWorkflowAuditEntries( + steps: AgentWorkflowStepUiConfig[], + stepKeyPath: string[] = [], + stepLabelPath: string[] = [], + output: Array<{ + identityPath: string; + displayPath: string; + enabled: boolean; + hasPrompt: boolean; + hasPreview: boolean; + }> = [] +) { + for (const step of [...(steps || [])].sort((left, right) => left.sortOrder - right.sortOrder)) { + const nextStepKeyPath = [...stepKeyPath, `s:${String(step.key || "").trim()}`]; + const nextStepLabelPath = [ + ...stepLabelPath, + String(step.title || step.key || "未命名模块").trim() || "未命名模块", + ]; + + const visitOptions = ( + options: AgentWorkflowOptionUiConfig[], + parentKeyPath: string[], + parentLabelPath: string[] + ) => { + for (const option of [...(options || [])].sort((left, right) => left.sortOrder - right.sortOrder)) { + const optionKey = String(option.key || "").trim() || "option"; + const optionLabel = + String(option.buttonName || option.key || "未命名卡片").trim() || "未命名卡片"; + const nextKeyPath = [...parentKeyPath, `o:${optionKey}`]; + const nextLabelPath = [...parentLabelPath, optionLabel]; + output.push({ + identityPath: nextKeyPath.join(">"), + displayPath: nextLabelPath.join(" > "), + enabled: option.enabled !== false, + hasPrompt: Boolean(String(option.promptFragment || "").trim()), + hasPreview: hasAgentWorkflowOptionOwnPreview(option), + }); + visitOptions(option.children || [], nextKeyPath, nextLabelPath); + } + }; + + visitOptions(step.options || [], nextStepKeyPath, nextStepLabelPath); + collectAgentWorkflowAuditEntries( + step.children || [], + nextStepKeyPath, + nextStepLabelPath, + output + ); + } + + return output; +} + +export function summarizeAgentWorkflowDestructiveChange( + beforeConfig: AgentWorkflowUiConfig, + afterConfig: AgentWorkflowUiConfig +): AgentWorkflowDestructiveChangeSummary { + const beforeEntries = collectAgentWorkflowAuditEntries(beforeConfig.steps || []); + const afterEntries = collectAgentWorkflowAuditEntries(afterConfig.steps || []); + const afterByIdentityPath = new Map( + afterEntries.map((entry) => [entry.identityPath, entry]) + ); + + const removedEntries = beforeEntries.filter( + (entry) => !afterByIdentityPath.has(entry.identityPath) + ); + const promptClearedEntries = beforeEntries.filter((entry) => { + const nextEntry = afterByIdentityPath.get(entry.identityPath); + return Boolean(nextEntry && entry.hasPrompt && !nextEntry.hasPrompt); + }); + const previewClearedEntries = beforeEntries.filter((entry) => { + const nextEntry = afterByIdentityPath.get(entry.identityPath); + return Boolean(nextEntry && entry.hasPreview && !nextEntry.hasPreview); + }); + + return { + totalBeforeOptionCount: beforeEntries.length, + totalAfterOptionCount: afterEntries.length, + removedOptionCount: removedEntries.length, + removedEnabledOptionCount: removedEntries.filter((entry) => entry.enabled).length, + promptClearedCount: promptClearedEntries.length, + previewClearedCount: previewClearedEntries.length, + removedOptionSamples: removedEntries + .slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT) + .map((entry) => entry.displayPath), + promptClearedSamples: promptClearedEntries + .slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT) + .map((entry) => entry.displayPath), + previewClearedSamples: previewClearedEntries + .slice(0, AGENT_WORKFLOW_DESTRUCTIVE_CHANGE_SAMPLE_LIMIT) + .map((entry) => entry.displayPath), + }; +} + +export function isAgentWorkflowDestructiveChangeSignificant( + summary: AgentWorkflowDestructiveChangeSummary +) { + if (summary.removedEnabledOptionCount >= 10) { + return true; + } + + if (summary.removedOptionCount >= 20) { + return true; + } + + if ( + summary.totalBeforeOptionCount > 0 && + summary.removedOptionCount / summary.totalBeforeOptionCount >= 0.03 + ) { + return true; + } + + if (summary.promptClearedCount >= 10) { + return true; + } + + return summary.previewClearedCount >= 5; +} + export function hasAgentWorkflowOptionOwnPreview( option: AgentWorkflowOptionUiConfig ): boolean { diff --git a/lib/db/migrations/0085_add_system_setting_snapshots.sql b/lib/db/migrations/0085_add_system_setting_snapshots.sql new file mode 100644 index 000000000..9310ac513 --- /dev/null +++ b/lib/db/migrations/0085_add_system_setting_snapshots.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS "SystemSettingSnapshot" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "scope" varchar(64) NOT NULL, + "label" varchar(128), + "summary" jsonb, + "data" jsonb NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL, + "createdBy" uuid +); + +DO $$ +BEGIN + ALTER TABLE "SystemSettingSnapshot" + ADD CONSTRAINT "SystemSettingSnapshot_createdBy_User_id_fk" + FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") + ON DELETE NO ACTION + ON UPDATE NO ACTION; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE INDEX IF NOT EXISTS "SystemSettingSnapshot_scope_createdAt_idx" + ON "SystemSettingSnapshot" ("scope", "createdAt" DESC); diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index f644a8abf..22ee22139 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -498,6 +498,13 @@ "when": 1779797137842, "tag": "0084_optimize_featured_home_query", "breakpoints": true + }, + { + "idx": 71, + "version": "7", + "when": 1780075200000, + "tag": "0085_add_system_setting_snapshots", + "breakpoints": true } ] } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 1dcde85d1..3ab6d044a 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -260,6 +260,18 @@ export const systemSettingHistory = pgTable("SystemSettingHistory", { export type SystemSettingHistory = InferSelectModel; +export const systemSettingSnapshot = pgTable("SystemSettingSnapshot", { + id: uuid("id").primaryKey().notNull().defaultRandom(), + scope: varchar("scope", { length: 64 }).notNull(), + label: varchar("label", { length: 128 }), + summary: jsonb("summary"), + data: jsonb("data").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), + createdBy: uuid("createdBy").references(() => user.id), +}); + +export type SystemSettingSnapshot = InferSelectModel; + export const dingTalkNotificationTask = pgTable("DingTalkNotificationTask", { id: uuid("id").primaryKey().notNull().defaultRandom(), title: varchar("title", { length: 100 }).notNull(), diff --git a/lib/system-settings/snapshot.ts b/lib/system-settings/snapshot.ts new file mode 100644 index 000000000..7efe68b5f --- /dev/null +++ b/lib/system-settings/snapshot.ts @@ -0,0 +1,66 @@ +import { + AGENT_PRESET_LIBRARY_CONFIG_KEY, +} from "@/lib/agents/preset-library-config"; +import { + AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY, +} from "@/lib/agents/psd-template-secondary-category-config"; +import { + AGENT_PSD_TEXT_COLOR_CONFIG_KEY, +} from "@/lib/agents/psd-text-color-config"; +import { + AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY, +} from "@/lib/agents/workflow-brand-model-config"; +import { + AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY, +} from "@/lib/agents/workflow-goods-info-config"; +import { + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, +} from "@/lib/agents/workflow-material-config"; +import { + AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY, +} from "@/lib/agents/workflow-showcase-config"; +import { + AGENT_WORKFLOW_UI_CONFIG_KEY, +} from "@/lib/agents/workflow-ui-config"; + +export const AGENT_WORKFLOW_SNAPSHOT_SCOPE = "agent-workflow"; + +export const SYSTEM_SETTING_SNAPSHOT_SCOPES = { + [AGENT_WORKFLOW_SNAPSHOT_SCOPE]: [ + AGENT_PRESET_LIBRARY_CONFIG_KEY, + AGENT_WORKFLOW_UI_CONFIG_KEY, + AGENT_WORKFLOW_MATERIAL_API_CONFIG_KEY, + AGENT_WORKFLOW_GOODS_INFO_API_CONFIG_KEY, + AGENT_WORKFLOW_BRAND_MODEL_CONFIG_KEY, + AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY, + AGENT_PSD_TEXT_COLOR_CONFIG_KEY, + AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY, + ], +} as const; + +export type SystemSettingSnapshotScope = keyof typeof SYSTEM_SETTING_SNAPSHOT_SCOPES; + +export function getSystemSettingSnapshotScopeKeys( + scope: SystemSettingSnapshotScope +) { + return SYSTEM_SETTING_SNAPSHOT_SCOPES[scope]; +} + +export function createSystemSettingSnapshotData( + scope: SystemSettingSnapshotScope, + valueByKey: ReadonlyMap +) { + const keys = getSystemSettingSnapshotScopeKeys(scope); + return Object.fromEntries(keys.map((key) => [key, valueByKey.get(key) ?? null])); +} + +export function summarizeSystemSettingSnapshotData( + data: Record +) { + const entries = Object.entries(data); + return { + keyCount: entries.length, + nonNullKeyCount: entries.filter(([, value]) => value !== null && value !== undefined).length, + byteLength: Buffer.byteLength(JSON.stringify(data), "utf8"), + }; +} diff --git a/package.json b/package.json index 8ec41a46d..bac85331e 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ "db:pull": "drizzle-kit pull", "db:check": "drizzle-kit check", "db:up": "drizzle-kit up", + "db:snapshot:system-settings": "pnpm exec tsx scripts/system-settings/create-snapshot.ts", + "db:restore:system-settings-snapshot": "pnpm exec tsx scripts/system-settings/restore-snapshot.ts", "poc:nanobanana": "pnpm exec tsx scripts/nanobanana-browser-poc.ts", "test": "export PLAYWRIGHT=True && pnpm exec playwright test", "storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts", diff --git a/scripts/system-settings/create-snapshot.ts b/scripts/system-settings/create-snapshot.ts new file mode 100644 index 000000000..b1dd22c83 --- /dev/null +++ b/scripts/system-settings/create-snapshot.ts @@ -0,0 +1,83 @@ +import { config as loadEnv } from "dotenv"; +import { inArray } from "drizzle-orm"; + +loadEnv({ path: ".env.local" }); + +function readArg(name: string) { + const prefix = `${name}=`; + const match = process.argv.find((arg) => arg.startsWith(prefix)); + return match ? match.slice(prefix.length) : null; +} + +async function main() { + const scopeArg = readArg("--scope"); + const label = readArg("--label"); + + if (!scopeArg) { + throw new Error("Usage: pnpm tsx scripts/system-settings/create-snapshot.ts --scope= [--label=