import { prisma } from '@/lib/prisma' import { composeModelKey, parseModelKeyStrict, type CapabilitySelections } from '@/lib/model-config-contract' type Mode = 'dry-run' | 'apply' type UserPreferenceRow = { id: string userId: string customProviders: string | null customModels: string | null analysisModel: string | null characterModel: string | null locationModel: string | null storyboardModel: string | null editModel: string | null videoModel: string | null audioModel: string | null lipSyncModel: string | null capabilityDefaults: string | null } type NovelProjectRow = { id: string projectId: string analysisModel: string | null characterModel: string | null locationModel: string | null storyboardModel: string | null editModel: string | null videoModel: string | null capabilityOverrides: string | null } type StoredProvider = { id: string name: string baseUrl?: string apiKey?: string apiMode?: 'gemini-sdk' | 'openai-official' gatewayRoute?: 'official' | 'openai-compat' } type StoredModel = { modelId: string modelKey: string provider: string [key: string]: unknown } type ParseResult = { ok: boolean value: T } type MigrationSummary = { mode: Mode userPreference: { scanned: number updated: number dirtyClearedProviders: number dirtyClearedModels: number dirtyClearedCapabilityDefaults: number migratedProviders: number migratedModels: number migratedDefaultModelFields: number migratedCapabilityDefaultKeys: number modelCollisionsResolvedByBailian: number providerCollisionsResolvedByBailian: number invalidModelFieldsCleared: number } novelPromotionProject: { scanned: number updated: number migratedModelFields: number migratedCapabilityOverrideKeys: number invalidModelFieldsCleared: number dirtyClearedCapabilityOverrides: number } graphArtifacts: { hasRequiredUniqueIndexBefore: boolean duplicateGroupsBefore: number duplicateGroupSamples: Array<{ runId: string stepKey: string artifactType: string refId: string count: number }> deletedRowsForDedup: number duplicateGroupsAfter: number indexAdded: boolean hasRequiredUniqueIndexAfter: boolean } } type MysqlIndexRow = { Key_name: string Non_unique: number | string Seq_in_index: number | string Column_name: string } type DuplicateGroupRow = { runId: string stepKey: string artifactType: string refId: string c: bigint | number } type CountRow = { c: bigint | number } type DefaultModelField = | 'analysisModel' | 'characterModel' | 'locationModel' | 'storyboardModel' | 'editModel' | 'videoModel' | 'audioModel' | 'lipSyncModel' type ProjectModelField = | 'analysisModel' | 'characterModel' | 'locationModel' | 'storyboardModel' | 'editModel' | 'videoModel' type UserPreferenceUpdateData = Partial> & { customProviders?: string | null customModels?: string | null capabilityDefaults?: string | null } type NovelProjectUpdateData = Partial> & { capabilityOverrides?: string | null } const MODE: Mode = process.argv.includes('--dry-run') ? 'dry-run' : 'apply' const APPLY = MODE === 'apply' const OFFICIAL_ONLY_PROVIDER_KEYS = new Set(['bailian', 'siliconflow']) const DEFAULT_MODEL_FIELDS: readonly DefaultModelField[] = [ 'analysisModel', 'characterModel', 'locationModel', 'storyboardModel', 'editModel', 'videoModel', 'audioModel', 'lipSyncModel', ] const PROJECT_MODEL_FIELDS: readonly ProjectModelField[] = [ 'analysisModel', 'characterModel', 'locationModel', 'storyboardModel', 'editModel', 'videoModel', ] const REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS = ['runId', 'stepKey', 'artifactType', 'refId'] as const function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } function readTrimmedString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } function toNullableModelField(raw: string | null | undefined): string | null { const trimmed = readTrimmedString(raw) return trimmed || null } function getProviderKey(providerId: string): string { const index = providerId.indexOf(':') return index === -1 ? providerId : providerId.slice(0, index) } function migrateProviderId(providerId: string): string { const trimmed = providerId.trim() if (!trimmed) return trimmed if (trimmed === 'qwen') return 'bailian' const providerKey = getProviderKey(trimmed) if (providerKey !== 'qwen') return trimmed return `bailian${trimmed.slice(providerKey.length)}` } function migrateModelKey(rawModelKey: string): string { const parsed = parseModelKeyStrict(rawModelKey) if (!parsed) return rawModelKey if (getProviderKey(parsed.provider) !== 'qwen') return parsed.modelKey const nextProvider = migrateProviderId(parsed.provider) return composeModelKey(nextProvider, parsed.modelId) } function providerPriorityByOriginalKey(originalProviderId: string): number { const key = getProviderKey(originalProviderId) if (key === 'bailian') return 2 if (key === 'qwen') return 1 return 0 } function normalizeGatewayRoute( providerId: string, rawGatewayRoute: unknown, ): 'official' | 'openai-compat' { const providerKey = getProviderKey(providerId) if (providerKey === 'openai-compatible') return 'openai-compat' if (providerKey === 'gemini-compatible') return 'official' if (OFFICIAL_ONLY_PROVIDER_KEYS.has(providerKey)) return 'official' return rawGatewayRoute === 'openai-compat' ? 'openai-compat' : 'official' } function parseJsonArray(raw: string | null): ParseResult { if (!raw) return { ok: true, value: [] } try { const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return { ok: false, value: [] } return { ok: true, value: parsed } } catch { return { ok: false, value: [] } } } function parseJsonRecord(raw: string | null): ParseResult> { if (!raw) return { ok: true, value: {} } try { const parsed = JSON.parse(raw) as unknown if (!isRecord(parsed)) return { ok: false, value: {} } return { ok: true, value: parsed } } catch { return { ok: false, value: {} } } } function migrateProviders( rawProviders: string | null, ): { ok: boolean nextRaw: string | null changed: boolean migratedProviders: number collisionsResolvedByBailian: number } { const parsed = parseJsonArray(rawProviders) if (!parsed.ok) { return { ok: false, nextRaw: null, changed: rawProviders !== null, migratedProviders: 0, collisionsResolvedByBailian: 0, } } const deduped = new Map() let migratedProviders = 0 let collisionsResolvedByBailian = 0 for (const item of parsed.value) { if (!isRecord(item)) { return { ok: false, nextRaw: null, changed: true, migratedProviders: 0, collisionsResolvedByBailian: 0, } } const id = readTrimmedString(item.id) const name = readTrimmedString(item.name) if (!id || !name) { return { ok: false, nextRaw: null, changed: true, migratedProviders: 0, collisionsResolvedByBailian: 0, } } const nextId = migrateProviderId(id) if (nextId !== id) migratedProviders += 1 const apiModeRaw = readTrimmedString(item.apiMode) let apiMode: 'gemini-sdk' | 'openai-official' | undefined if (apiModeRaw === 'gemini-sdk' || apiModeRaw === 'openai-official') { apiMode = apiModeRaw } if (getProviderKey(nextId) === 'gemini-compatible' && apiMode === 'openai-official') { apiMode = 'gemini-sdk' } const nextProvider: StoredProvider = { id: nextId, name: getProviderKey(nextId) === 'bailian' ? 'Alibaba Bailian' : name, baseUrl: readTrimmedString(item.baseUrl) || undefined, apiKey: typeof item.apiKey === 'string' ? item.apiKey.trim() : undefined, apiMode, gatewayRoute: normalizeGatewayRoute(nextId, item.gatewayRoute), } const dedupeKey = nextProvider.id.toLowerCase() const nextPriority = providerPriorityByOriginalKey(id) const existing = deduped.get(dedupeKey) if (!existing) { deduped.set(dedupeKey, { provider: nextProvider, priority: nextPriority }) continue } if (nextPriority > existing.priority) { deduped.set(dedupeKey, { provider: nextProvider, priority: nextPriority }) collisionsResolvedByBailian += 1 } } const nextProviders = Array.from(deduped.values()).map((entry) => entry.provider) const nextRaw = nextProviders.length > 0 ? JSON.stringify(nextProviders) : null return { ok: true, nextRaw, changed: (rawProviders || null) !== (nextRaw || null), migratedProviders, collisionsResolvedByBailian, } } function migrateModels( rawModels: string | null, ): { ok: boolean nextRaw: string | null changed: boolean migratedModels: number collisionsResolvedByBailian: number } { const parsed = parseJsonArray(rawModels) if (!parsed.ok) { return { ok: false, nextRaw: null, changed: rawModels !== null, migratedModels: 0, collisionsResolvedByBailian: 0, } } const deduped = new Map() let migratedModels = 0 let collisionsResolvedByBailian = 0 for (const item of parsed.value) { if (!isRecord(item)) { return { ok: false, nextRaw: null, changed: true, migratedModels: 0, collisionsResolvedByBailian: 0, } } const providerRaw = readTrimmedString(item.provider) const modelIdRaw = readTrimmedString(item.modelId) const modelKeyRaw = readTrimmedString(item.modelKey) const parsedModelKey = parseModelKeyStrict(modelKeyRaw) const sourceProvider = providerRaw || parsedModelKey?.provider || '' const sourceModelId = modelIdRaw || parsedModelKey?.modelId || '' if (!sourceProvider || !sourceModelId) { return { ok: false, nextRaw: null, changed: true, migratedModels: 0, collisionsResolvedByBailian: 0, } } const nextProvider = migrateProviderId(sourceProvider) const nextModelKey = composeModelKey(nextProvider, sourceModelId) if (!nextModelKey) { return { ok: false, nextRaw: null, changed: true, migratedModels: 0, collisionsResolvedByBailian: 0, } } if (nextProvider !== sourceProvider || nextModelKey !== modelKeyRaw) migratedModels += 1 const nextModel: StoredModel = { ...item, provider: nextProvider, modelId: sourceModelId, modelKey: nextModelKey, } const dedupeKey = nextModelKey.toLowerCase() const nextPriority = providerPriorityByOriginalKey(sourceProvider) const existing = deduped.get(dedupeKey) if (!existing) { deduped.set(dedupeKey, { model: nextModel, priority: nextPriority }) continue } if (nextPriority > existing.priority) { deduped.set(dedupeKey, { model: nextModel, priority: nextPriority }) collisionsResolvedByBailian += 1 } } const nextModels = Array.from(deduped.values()).map((entry) => entry.model) const nextRaw = nextModels.length > 0 ? JSON.stringify(nextModels) : null return { ok: true, nextRaw, changed: (rawModels || null) !== (nextRaw || null), migratedModels, collisionsResolvedByBailian, } } function migrateModelField( raw: string | null, ): { nextValue: string | null changed: boolean migrated: boolean clearedInvalid: boolean } { const current = toNullableModelField(raw) if (!current) { return { nextValue: null, changed: current !== raw, migrated: false, clearedInvalid: false, } } const parsed = parseModelKeyStrict(current) if (!parsed) { return { nextValue: null, changed: true, migrated: false, clearedInvalid: true, } } const nextProvider = migrateProviderId(parsed.provider) const nextKey = composeModelKey(nextProvider, parsed.modelId) return { nextValue: nextKey || null, changed: (nextKey || null) !== (raw || null), migrated: parsed.provider !== nextProvider, clearedInvalid: false, } } function migrateCapabilitySelections( raw: string | null, ): { ok: boolean nextRaw: string | null changed: boolean migratedKeys: number } { const parsed = parseJsonRecord(raw) if (!parsed.ok) { return { ok: false, nextRaw: null, changed: raw !== null, migratedKeys: 0, } } const deduped: CapabilitySelections = {} const priorities = new Map() let migratedKeys = 0 for (const [modelKey, rawSelection] of Object.entries(parsed.value)) { if (!isRecord(rawSelection)) { return { ok: false, nextRaw: null, changed: raw !== null, migratedKeys: 0, } } const parsedKey = parseModelKeyStrict(modelKey) if (!parsedKey) { return { ok: false, nextRaw: null, changed: raw !== null, migratedKeys: 0, } } const nextKey = migrateModelKey(parsedKey.modelKey) if (nextKey !== parsedKey.modelKey) migratedKeys += 1 const nextSelection: Record = {} for (const [field, value] of Object.entries(rawSelection)) { if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { return { ok: false, nextRaw: null, changed: raw !== null, migratedKeys: 0, } } nextSelection[field] = value } const sourcePriority = providerPriorityByOriginalKey(parsedKey.provider) const existingPriority = priorities.get(nextKey) if (existingPriority === undefined || sourcePriority > existingPriority) { deduped[nextKey] = nextSelection priorities.set(nextKey, sourcePriority) } } const nextRaw = Object.keys(deduped).length > 0 ? JSON.stringify(deduped) : null return { ok: true, nextRaw, changed: (raw || null) !== (nextRaw || null), migratedKeys, } } function toIndexNumber(value: number | string): number { if (typeof value === 'number') return value return Number.parseInt(value, 10) } function hasRequiredGraphArtifactUniqueIndex(rows: MysqlIndexRow[]): boolean { const indexColumns = new Map>() for (const row of rows) { const seq = toIndexNumber(row.Seq_in_index) const nonUnique = toIndexNumber(row.Non_unique) if (!Number.isFinite(seq) || !Number.isFinite(nonUnique)) continue const key = row.Key_name const list = indexColumns.get(key) || [] list.push({ seq, column: row.Column_name, nonUnique, }) indexColumns.set(key, list) } for (const entries of indexColumns.values()) { if (entries.length !== REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS.length) continue const sorted = entries.sort((a, b) => a.seq - b.seq) if (sorted[0]?.nonUnique !== 0) continue const columns = sorted.map((entry) => entry.column) const match = columns.every((column, index) => column === REQUIRED_GRAPH_ARTIFACT_UNIQUE_COLUMNS[index]) if (match) return true } return false } function toNumber(value: bigint | number): number { if (typeof value === 'bigint') return Number(value) return value } async function loadGraphArtifactIndexes(): Promise { return await prisma.$queryRawUnsafe('SHOW INDEX FROM graph_artifacts') } async function countGraphArtifactDuplicateGroups(): Promise { const rows = await prisma.$queryRawUnsafe( `SELECT COUNT(*) AS c FROM ( SELECT 1 FROM graph_artifacts WHERE stepKey IS NOT NULL GROUP BY runId, stepKey, artifactType, refId HAVING COUNT(*) > 1 ) duplicate_groups`, ) return rows.length > 0 ? toNumber(rows[0].c) : 0 } async function sampleGraphArtifactDuplicateGroups(limit: number): Promise { return await prisma.$queryRawUnsafe( `SELECT runId, stepKey, artifactType, refId, COUNT(*) AS c FROM graph_artifacts WHERE stepKey IS NOT NULL GROUP BY runId, stepKey, artifactType, refId HAVING c > 1 LIMIT ${limit}`, ) } async function dedupeGraphArtifacts(): Promise { return await prisma.$executeRawUnsafe( `DELETE ga1 FROM graph_artifacts ga1 JOIN graph_artifacts ga2 ON ga1.runId = ga2.runId AND ga1.stepKey = ga2.stepKey AND ga1.artifactType = ga2.artifactType AND ga1.refId = ga2.refId AND ( ga1.createdAt < ga2.createdAt OR (ga1.createdAt = ga2.createdAt AND ga1.id < ga2.id) ) WHERE ga1.stepKey IS NOT NULL`, ) } async function addGraphArtifactUniqueIndex(): Promise { await prisma.$executeRawUnsafe( 'ALTER TABLE graph_artifacts ADD UNIQUE INDEX graph_artifacts_runId_stepKey_artifactType_refId_key (runId, stepKey, artifactType, refId)', ) } async function migrateUserPreferences(summary: MigrationSummary): Promise { const rows = await prisma.userPreference.findMany({ select: { id: true, userId: true, customProviders: true, customModels: true, analysisModel: true, characterModel: true, locationModel: true, storyboardModel: true, editModel: true, videoModel: true, audioModel: true, lipSyncModel: true, capabilityDefaults: true, }, }) as UserPreferenceRow[] summary.userPreference.scanned = rows.length for (const row of rows) { const updateData: UserPreferenceUpdateData = {} let changed = false const providerResult = migrateProviders(row.customProviders) if (!providerResult.ok) { updateData.customProviders = null changed = changed || row.customProviders !== null summary.userPreference.dirtyClearedProviders += 1 } else if (providerResult.changed) { updateData.customProviders = providerResult.nextRaw changed = true summary.userPreference.migratedProviders += providerResult.migratedProviders summary.userPreference.providerCollisionsResolvedByBailian += providerResult.collisionsResolvedByBailian } const modelResult = migrateModels(row.customModels) if (!modelResult.ok) { updateData.customModels = null changed = changed || row.customModels !== null summary.userPreference.dirtyClearedModels += 1 } else if (modelResult.changed) { updateData.customModels = modelResult.nextRaw changed = true summary.userPreference.migratedModels += modelResult.migratedModels summary.userPreference.modelCollisionsResolvedByBailian += modelResult.collisionsResolvedByBailian } const capabilityResult = migrateCapabilitySelections(row.capabilityDefaults) if (!capabilityResult.ok) { updateData.capabilityDefaults = null changed = changed || row.capabilityDefaults !== null summary.userPreference.dirtyClearedCapabilityDefaults += 1 } else if (capabilityResult.changed) { updateData.capabilityDefaults = capabilityResult.nextRaw changed = true summary.userPreference.migratedCapabilityDefaultKeys += capabilityResult.migratedKeys } for (const field of DEFAULT_MODEL_FIELDS) { const fieldResult = migrateModelField(row[field]) if (!fieldResult.changed) continue updateData[field] = fieldResult.nextValue changed = true if (fieldResult.migrated) { summary.userPreference.migratedDefaultModelFields += 1 } if (fieldResult.clearedInvalid) { summary.userPreference.invalidModelFieldsCleared += 1 } } if (!changed) continue summary.userPreference.updated += 1 if (APPLY) { await prisma.userPreference.update({ where: { id: row.id }, data: updateData, }) } } } async function migrateNovelProjects(summary: MigrationSummary): Promise { const rows = await prisma.novelPromotionProject.findMany({ select: { id: true, projectId: true, analysisModel: true, characterModel: true, locationModel: true, storyboardModel: true, editModel: true, videoModel: true, capabilityOverrides: true, }, }) as NovelProjectRow[] summary.novelPromotionProject.scanned = rows.length for (const row of rows) { const updateData: NovelProjectUpdateData = {} let changed = false for (const field of PROJECT_MODEL_FIELDS) { const fieldResult = migrateModelField(row[field]) if (!fieldResult.changed) continue updateData[field] = fieldResult.nextValue changed = true if (fieldResult.migrated) { summary.novelPromotionProject.migratedModelFields += 1 } if (fieldResult.clearedInvalid) { summary.novelPromotionProject.invalidModelFieldsCleared += 1 } } const capabilityResult = migrateCapabilitySelections(row.capabilityOverrides) if (!capabilityResult.ok) { updateData.capabilityOverrides = null changed = changed || row.capabilityOverrides !== null summary.novelPromotionProject.dirtyClearedCapabilityOverrides += 1 } else if (capabilityResult.changed) { updateData.capabilityOverrides = capabilityResult.nextRaw changed = true summary.novelPromotionProject.migratedCapabilityOverrideKeys += capabilityResult.migratedKeys } if (!changed) continue summary.novelPromotionProject.updated += 1 if (APPLY) { await prisma.novelPromotionProject.update({ where: { id: row.id }, data: updateData, }) } } } async function migrateGraphArtifacts(summary: MigrationSummary): Promise { const beforeIndexes = await loadGraphArtifactIndexes() const hasRequiredBefore = hasRequiredGraphArtifactUniqueIndex(beforeIndexes) const duplicateGroupsBefore = await countGraphArtifactDuplicateGroups() const duplicateGroupSamples = await sampleGraphArtifactDuplicateGroups(20) summary.graphArtifacts.hasRequiredUniqueIndexBefore = hasRequiredBefore summary.graphArtifacts.duplicateGroupsBefore = duplicateGroupsBefore summary.graphArtifacts.duplicateGroupSamples = duplicateGroupSamples.map((row) => ({ runId: row.runId, stepKey: row.stepKey, artifactType: row.artifactType, refId: row.refId, count: toNumber(row.c), })) if (APPLY && duplicateGroupsBefore > 0) { const deleted = await dedupeGraphArtifacts() summary.graphArtifacts.deletedRowsForDedup = deleted } const duplicateGroupsAfter = APPLY ? await countGraphArtifactDuplicateGroups() : duplicateGroupsBefore summary.graphArtifacts.duplicateGroupsAfter = duplicateGroupsAfter if (APPLY && !hasRequiredBefore) { if (duplicateGroupsAfter > 0) { throw new Error( `GRAPH_ARTIFACT_DEDUPE_INCOMPLETE: still has ${duplicateGroupsAfter} duplicate groups, unique index not added`, ) } await addGraphArtifactUniqueIndex() summary.graphArtifacts.indexAdded = true } const afterIndexes = await loadGraphArtifactIndexes() summary.graphArtifacts.hasRequiredUniqueIndexAfter = hasRequiredGraphArtifactUniqueIndex(afterIndexes) if (APPLY && !summary.graphArtifacts.hasRequiredUniqueIndexAfter) { throw new Error('GRAPH_ARTIFACT_UNIQUE_INDEX_MISSING_AFTER_MIGRATION') } } async function main() { const summary: MigrationSummary = { mode: MODE, userPreference: { scanned: 0, updated: 0, dirtyClearedProviders: 0, dirtyClearedModels: 0, dirtyClearedCapabilityDefaults: 0, migratedProviders: 0, migratedModels: 0, migratedDefaultModelFields: 0, migratedCapabilityDefaultKeys: 0, modelCollisionsResolvedByBailian: 0, providerCollisionsResolvedByBailian: 0, invalidModelFieldsCleared: 0, }, novelPromotionProject: { scanned: 0, updated: 0, migratedModelFields: 0, migratedCapabilityOverrideKeys: 0, invalidModelFieldsCleared: 0, dirtyClearedCapabilityOverrides: 0, }, graphArtifacts: { hasRequiredUniqueIndexBefore: false, duplicateGroupsBefore: 0, duplicateGroupSamples: [], deletedRowsForDedup: 0, duplicateGroupsAfter: 0, indexAdded: false, hasRequiredUniqueIndexAfter: false, }, } await migrateUserPreferences(summary) await migrateNovelProjects(summary) await migrateGraphArtifacts(summary) console.log(JSON.stringify(summary, null, 2)) } main() .then(async () => { await prisma.$disconnect() }) .catch(async (error: unknown) => { console.error('[migrate-release-blockers] failed', error) await prisma.$disconnect() process.exit(1) })