feat: initial release v0.3.0
This commit is contained in:
310
scripts/migrations/migrate-capability-selections.ts
Normal file
310
scripts/migrations/migrate-capability-selections.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
parseModelKeyStrict,
|
||||
type CapabilitySelections,
|
||||
type CapabilityValue,
|
||||
} from '@/lib/model-config-contract'
|
||||
import { findBuiltinCapabilities } from '@/lib/model-capabilities/catalog'
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
|
||||
const USER_IMAGE_MODEL_FIELDS = [
|
||||
'characterModel',
|
||||
'locationModel',
|
||||
'storyboardModel',
|
||||
'editModel',
|
||||
] as const
|
||||
|
||||
const PROJECT_IMAGE_MODEL_FIELDS = [
|
||||
'characterModel',
|
||||
'locationModel',
|
||||
'storyboardModel',
|
||||
'editModel',
|
||||
] as const
|
||||
|
||||
type UserImageModelField = typeof USER_IMAGE_MODEL_FIELDS[number]
|
||||
type ProjectImageModelField = typeof PROJECT_IMAGE_MODEL_FIELDS[number]
|
||||
|
||||
interface UserPreferenceRow {
|
||||
id: string
|
||||
userId: string
|
||||
imageResolution: string
|
||||
capabilityDefaults: string | null
|
||||
characterModel: string | null
|
||||
locationModel: string | null
|
||||
storyboardModel: string | null
|
||||
editModel: string | null
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string
|
||||
projectId: string
|
||||
imageResolution: string
|
||||
videoResolution: string
|
||||
capabilityOverrides: string | null
|
||||
characterModel: string | null
|
||||
locationModel: string | null
|
||||
storyboardModel: string | null
|
||||
editModel: string | null
|
||||
videoModel: string | null
|
||||
}
|
||||
|
||||
interface MigrationSummary {
|
||||
mode: 'dry-run' | 'apply'
|
||||
userPreference: {
|
||||
scanned: number
|
||||
updated: number
|
||||
migratedImageResolution: number
|
||||
}
|
||||
novelPromotionProject: {
|
||||
scanned: number
|
||||
updated: number
|
||||
migratedImageResolution: number
|
||||
migratedVideoResolution: number
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isCapabilityValue(value: unknown): value is CapabilityValue {
|
||||
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||
}
|
||||
|
||||
function normalizeSelections(raw: unknown): CapabilitySelections {
|
||||
if (!isRecord(raw)) return {}
|
||||
|
||||
const normalized: CapabilitySelections = {}
|
||||
for (const [modelKey, rawSelection] of Object.entries(raw)) {
|
||||
if (!isRecord(rawSelection)) continue
|
||||
|
||||
const nextSelection: Record<string, CapabilityValue> = {}
|
||||
for (const [field, value] of Object.entries(rawSelection)) {
|
||||
if (isCapabilityValue(value)) {
|
||||
nextSelection[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
normalized[modelKey] = nextSelection
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseSelections(raw: string | null): CapabilitySelections {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
return normalizeSelections(JSON.parse(raw) as unknown)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeSelections(selections: CapabilitySelections): string | null {
|
||||
if (Object.keys(selections).length === 0) return null
|
||||
return JSON.stringify(selections)
|
||||
}
|
||||
|
||||
function getCapabilityResolutionOptions(
|
||||
modelType: 'image' | 'video',
|
||||
modelKey: string,
|
||||
): string[] {
|
||||
const parsed = parseModelKeyStrict(modelKey)
|
||||
if (!parsed) return []
|
||||
|
||||
const capabilities = findBuiltinCapabilities(modelType, parsed.provider, parsed.modelId)
|
||||
const namespace = capabilities?.[modelType]
|
||||
if (!namespace || !isRecord(namespace)) return []
|
||||
|
||||
const resolutionOptions = namespace.resolutionOptions
|
||||
if (!Array.isArray(resolutionOptions)) return []
|
||||
|
||||
return resolutionOptions.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||||
}
|
||||
|
||||
function ensureModelResolutionSelection(input: {
|
||||
modelType: 'image' | 'video'
|
||||
modelKey: string
|
||||
resolution: string
|
||||
selections: CapabilitySelections
|
||||
}): boolean {
|
||||
const options = getCapabilityResolutionOptions(input.modelType, input.modelKey)
|
||||
if (options.length === 0) return false
|
||||
if (!options.includes(input.resolution)) return false
|
||||
|
||||
const current = input.selections[input.modelKey]
|
||||
if (current && current.resolution !== undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
input.selections[input.modelKey] = {
|
||||
...(current || {}),
|
||||
resolution: input.resolution,
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function collectModelKeys<RowType>(
|
||||
row: RowType,
|
||||
fields: readonly (keyof RowType)[],
|
||||
): string[] {
|
||||
const modelKeys: string[] = []
|
||||
for (const field of fields) {
|
||||
const value = row[field]
|
||||
if (typeof value !== 'string') continue
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) continue
|
||||
modelKeys.push(trimmed)
|
||||
}
|
||||
return modelKeys
|
||||
}
|
||||
|
||||
async function migrateUserPreferences(summary: MigrationSummary) {
|
||||
const rows = await prisma.userPreference.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
imageResolution: true,
|
||||
capabilityDefaults: true,
|
||||
characterModel: true,
|
||||
locationModel: true,
|
||||
storyboardModel: true,
|
||||
editModel: true,
|
||||
},
|
||||
}) as UserPreferenceRow[]
|
||||
|
||||
summary.userPreference.scanned = rows.length
|
||||
|
||||
for (const row of rows) {
|
||||
const nextSelections = parseSelections(row.capabilityDefaults)
|
||||
const modelKeys = collectModelKeys<UserPreferenceRow>(row, USER_IMAGE_MODEL_FIELDS)
|
||||
let changed = false
|
||||
|
||||
for (const modelKey of modelKeys) {
|
||||
if (ensureModelResolutionSelection({
|
||||
modelType: 'image',
|
||||
modelKey,
|
||||
resolution: row.imageResolution,
|
||||
selections: nextSelections,
|
||||
})) {
|
||||
changed = true
|
||||
summary.userPreference.migratedImageResolution += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) continue
|
||||
summary.userPreference.updated += 1
|
||||
|
||||
if (APPLY) {
|
||||
await prisma.userPreference.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
capabilityDefaults: serializeSelections(nextSelections),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateProjects(summary: MigrationSummary) {
|
||||
const rows = await prisma.novelPromotionProject.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
imageResolution: true,
|
||||
videoResolution: true,
|
||||
capabilityOverrides: true,
|
||||
characterModel: true,
|
||||
locationModel: true,
|
||||
storyboardModel: true,
|
||||
editModel: true,
|
||||
videoModel: true,
|
||||
},
|
||||
}) as ProjectRow[]
|
||||
|
||||
summary.novelPromotionProject.scanned = rows.length
|
||||
|
||||
for (const row of rows) {
|
||||
const nextSelections = parseSelections(row.capabilityOverrides)
|
||||
const imageModelKeys = collectModelKeys<ProjectRow>(row, PROJECT_IMAGE_MODEL_FIELDS)
|
||||
let changed = false
|
||||
|
||||
for (const modelKey of imageModelKeys) {
|
||||
if (ensureModelResolutionSelection({
|
||||
modelType: 'image',
|
||||
modelKey,
|
||||
resolution: row.imageResolution,
|
||||
selections: nextSelections,
|
||||
})) {
|
||||
changed = true
|
||||
summary.novelPromotionProject.migratedImageResolution += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof row.videoModel === 'string' && row.videoModel.trim()) {
|
||||
if (ensureModelResolutionSelection({
|
||||
modelType: 'video',
|
||||
modelKey: row.videoModel.trim(),
|
||||
resolution: row.videoResolution,
|
||||
selections: nextSelections,
|
||||
})) {
|
||||
changed = true
|
||||
summary.novelPromotionProject.migratedVideoResolution += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) continue
|
||||
summary.novelPromotionProject.updated += 1
|
||||
|
||||
if (APPLY) {
|
||||
await prisma.novelPromotionProject.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
capabilityOverrides: serializeSelections(nextSelections),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const summary: MigrationSummary = {
|
||||
mode: APPLY ? 'apply' : 'dry-run',
|
||||
userPreference: {
|
||||
scanned: 0,
|
||||
updated: 0,
|
||||
migratedImageResolution: 0,
|
||||
},
|
||||
novelPromotionProject: {
|
||||
scanned: 0,
|
||||
updated: 0,
|
||||
migratedImageResolution: 0,
|
||||
migratedVideoResolution: 0,
|
||||
},
|
||||
}
|
||||
|
||||
await migrateUserPreferences(summary)
|
||||
await migrateProjects(summary)
|
||||
|
||||
process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const missingColumn =
|
||||
message.includes('capabilityDefaults') || message.includes('capabilityOverrides')
|
||||
if (missingColumn && message.includes('does not exist')) {
|
||||
process.stderr.write(
|
||||
'[migrate-capability-selections] FAILED: required DB columns are missing. ' +
|
||||
'Apply SQL migration `prisma/migrations/20260215_add_capability_selection_columns.sql` first.\n',
|
||||
)
|
||||
} else {
|
||||
process.stderr.write(`[migrate-capability-selections] FAILED: ${message}\n`)
|
||||
}
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
152
scripts/migrations/migrate-custom-pricing-v2.ts
Normal file
152
scripts/migrations/migrate-custom-pricing-v2.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
|
||||
type PreferenceRow = {
|
||||
id: string
|
||||
userId: string
|
||||
customModels: string | null
|
||||
}
|
||||
|
||||
type MigrationSummary = {
|
||||
mode: 'dry-run' | 'apply'
|
||||
scanned: number
|
||||
updatedRows: number
|
||||
migratedModels: number
|
||||
skippedInvalidRows: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function parseCustomModels(raw: string | null): unknown[] | null {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return null
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLegacyCustomPricing(raw: unknown): {
|
||||
changed: boolean
|
||||
next: unknown
|
||||
} {
|
||||
if (!isRecord(raw)) {
|
||||
return { changed: false, next: raw }
|
||||
}
|
||||
|
||||
const hasLegacyInput = typeof raw.input === 'number' && Number.isFinite(raw.input) && raw.input >= 0
|
||||
const hasLegacyOutput = typeof raw.output === 'number' && Number.isFinite(raw.output) && raw.output >= 0
|
||||
if (!hasLegacyInput && !hasLegacyOutput) {
|
||||
return { changed: false, next: raw }
|
||||
}
|
||||
|
||||
const llmRaw = isRecord(raw.llm) ? raw.llm : {}
|
||||
const llmInput = typeof llmRaw.inputPerMillion === 'number' && Number.isFinite(llmRaw.inputPerMillion) && llmRaw.inputPerMillion >= 0
|
||||
? llmRaw.inputPerMillion
|
||||
: (hasLegacyInput ? raw.input as number : undefined)
|
||||
const llmOutput = typeof llmRaw.outputPerMillion === 'number' && Number.isFinite(llmRaw.outputPerMillion) && llmRaw.outputPerMillion >= 0
|
||||
? llmRaw.outputPerMillion
|
||||
: (hasLegacyOutput ? raw.output as number : undefined)
|
||||
|
||||
const nextPricing: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (key === 'input' || key === 'output') continue
|
||||
nextPricing[key] = value
|
||||
}
|
||||
|
||||
nextPricing.llm = {
|
||||
...(llmInput !== undefined ? { inputPerMillion: llmInput } : {}),
|
||||
...(llmOutput !== undefined ? { outputPerMillion: llmOutput } : {}),
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
next: nextPricing,
|
||||
}
|
||||
}
|
||||
|
||||
function migrateCustomModel(rawModel: unknown): { changed: boolean; next: unknown } {
|
||||
if (!isRecord(rawModel)) {
|
||||
return { changed: false, next: rawModel }
|
||||
}
|
||||
|
||||
const migratedPricing = migrateLegacyCustomPricing(rawModel.customPricing)
|
||||
if (!migratedPricing.changed) {
|
||||
return { changed: false, next: rawModel }
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
next: {
|
||||
...rawModel,
|
||||
customPricing: migratedPricing.next,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const summary: MigrationSummary = {
|
||||
mode: APPLY ? 'apply' : 'dry-run',
|
||||
scanned: 0,
|
||||
updatedRows: 0,
|
||||
migratedModels: 0,
|
||||
skippedInvalidRows: 0,
|
||||
}
|
||||
|
||||
const rows = await prisma.userPreference.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
customModels: true,
|
||||
},
|
||||
}) as PreferenceRow[]
|
||||
|
||||
summary.scanned = rows.length
|
||||
|
||||
for (const row of rows) {
|
||||
const parsedModels = parseCustomModels(row.customModels)
|
||||
if (parsedModels === null) {
|
||||
summary.skippedInvalidRows += 1
|
||||
continue
|
||||
}
|
||||
|
||||
let rowChanged = false
|
||||
const nextModels = parsedModels.map((model) => {
|
||||
const migrated = migrateCustomModel(model)
|
||||
if (migrated.changed) {
|
||||
rowChanged = true
|
||||
summary.migratedModels += 1
|
||||
}
|
||||
return migrated.next
|
||||
})
|
||||
|
||||
if (!rowChanged) continue
|
||||
summary.updatedRows += 1
|
||||
|
||||
if (APPLY) {
|
||||
await prisma.userPreference.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
customModels: JSON.stringify(nextModels),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error('[migrate-custom-pricing-v2] failed', error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
80
scripts/migrations/migrate-gateway-route-openai-compat.ts
Normal file
80
scripts/migrations/migrate-gateway-route-openai-compat.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { migrateGatewayRoutePayload } from '@/lib/migrations/gateway-route-openai-compat'
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
|
||||
type PreferenceRow = {
|
||||
id: string
|
||||
userId: string
|
||||
customProviders: string | null
|
||||
}
|
||||
|
||||
type MigrationSummary = {
|
||||
mode: 'dry-run' | 'apply'
|
||||
scanned: number
|
||||
updatedRows: number
|
||||
migratedProviders: number
|
||||
routeLitellmToOpenaiCompat: number
|
||||
routeForcedOfficial: number
|
||||
geminiApiModeCorrected: number
|
||||
skippedInvalidRows: number
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const summary: MigrationSummary = {
|
||||
mode: APPLY ? 'apply' : 'dry-run',
|
||||
scanned: 0,
|
||||
updatedRows: 0,
|
||||
migratedProviders: 0,
|
||||
routeLitellmToOpenaiCompat: 0,
|
||||
routeForcedOfficial: 0,
|
||||
geminiApiModeCorrected: 0,
|
||||
skippedInvalidRows: 0,
|
||||
}
|
||||
|
||||
const rows = await prisma.userPreference.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
customProviders: true,
|
||||
},
|
||||
}) as PreferenceRow[]
|
||||
summary.scanned = rows.length
|
||||
|
||||
for (const row of rows) {
|
||||
const result = migrateGatewayRoutePayload(row.customProviders)
|
||||
if (result.status === 'invalid') {
|
||||
summary.skippedInvalidRows += 1
|
||||
continue
|
||||
}
|
||||
|
||||
summary.migratedProviders += result.summary.providersChanged
|
||||
summary.routeLitellmToOpenaiCompat += result.summary.routeLitellmToOpenaiCompat
|
||||
summary.routeForcedOfficial += result.summary.routeForcedOfficial
|
||||
summary.geminiApiModeCorrected += result.summary.geminiApiModeCorrected
|
||||
|
||||
if (!result.changed) continue
|
||||
summary.updatedRows += 1
|
||||
|
||||
if (APPLY) {
|
||||
await prisma.userPreference.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
customProviders: result.nextRaw ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error('[migrate-gateway-route-openai-compat] failed', error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
145
scripts/migrations/migrate-graph-artifacts-unique-index.ts
Normal file
145
scripts/migrations/migrate-graph-artifacts-unique-index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
const REQUIRED_INDEX_NAME = 'graph_artifacts_runId_stepKey_artifactType_refId_key'
|
||||
const REQUIRED_COLUMNS = ['runId', 'stepKey', 'artifactType', 'refId'] as const
|
||||
|
||||
type IndexRow = {
|
||||
Key_name: string
|
||||
Non_unique: number | string
|
||||
Seq_in_index: number | string
|
||||
Column_name: string
|
||||
}
|
||||
|
||||
type DuplicateRow = {
|
||||
runId: string
|
||||
stepKey: string
|
||||
artifactType: string
|
||||
refId: string
|
||||
c: bigint | number
|
||||
}
|
||||
|
||||
type MigrationSummary = {
|
||||
mode: 'dry-run' | 'apply'
|
||||
hasRequiredIndexBefore: boolean
|
||||
duplicateGroupCount: number
|
||||
duplicateSamples: Array<{
|
||||
runId: string
|
||||
stepKey: string
|
||||
artifactType: string
|
||||
refId: string
|
||||
count: number
|
||||
}>
|
||||
altered: boolean
|
||||
hasRequiredIndexAfter: boolean
|
||||
}
|
||||
|
||||
function parseIntSafe(value: number | string) {
|
||||
if (typeof value === 'number') return value
|
||||
return Number.parseInt(value, 10)
|
||||
}
|
||||
|
||||
function hasRequiredUniqueIndex(rows: IndexRow[]) {
|
||||
const grouped = new Map<string, Array<{ seq: number; column: string; nonUnique: number }>>()
|
||||
for (const row of rows) {
|
||||
const seq = parseIntSafe(row.Seq_in_index)
|
||||
const nonUnique = parseIntSafe(row.Non_unique)
|
||||
if (!Number.isFinite(seq) || !Number.isFinite(nonUnique)) continue
|
||||
const key = row.Key_name
|
||||
const items = grouped.get(key) || []
|
||||
items.push({
|
||||
seq,
|
||||
column: row.Column_name,
|
||||
nonUnique,
|
||||
})
|
||||
grouped.set(key, items)
|
||||
}
|
||||
|
||||
for (const [key, entries] of grouped.entries()) {
|
||||
if (entries.length !== REQUIRED_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 isTarget = columns.every((column, index) => column === REQUIRED_COLUMNS[index])
|
||||
if (isTarget && key === REQUIRED_INDEX_NAME) return true
|
||||
if (isTarget) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function toNumber(value: bigint | number) {
|
||||
if (typeof value === 'bigint') return Number(value)
|
||||
return value
|
||||
}
|
||||
|
||||
async function loadIndexRows() {
|
||||
return await prisma.$queryRawUnsafe<IndexRow[]>('SHOW INDEX FROM graph_artifacts')
|
||||
}
|
||||
|
||||
async function loadDuplicateGroups() {
|
||||
return await prisma.$queryRawUnsafe<DuplicateRow[]>(
|
||||
`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 20`,
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const beforeRows = await loadIndexRows()
|
||||
const hasBefore = hasRequiredUniqueIndex(beforeRows)
|
||||
const duplicates = await loadDuplicateGroups()
|
||||
|
||||
const summary: MigrationSummary = {
|
||||
mode: APPLY ? 'apply' : 'dry-run',
|
||||
hasRequiredIndexBefore: hasBefore,
|
||||
duplicateGroupCount: duplicates.length,
|
||||
duplicateSamples: duplicates.map((row) => ({
|
||||
runId: row.runId,
|
||||
stepKey: row.stepKey,
|
||||
artifactType: row.artifactType,
|
||||
refId: row.refId,
|
||||
count: toNumber(row.c),
|
||||
})),
|
||||
altered: false,
|
||||
hasRequiredIndexAfter: hasBefore,
|
||||
}
|
||||
|
||||
if (hasBefore) {
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
return
|
||||
}
|
||||
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error(
|
||||
`cannot add unique index; found ${duplicates.length} duplicate groups in graph_artifacts (stepKey IS NOT NULL)`,
|
||||
)
|
||||
}
|
||||
|
||||
if (APPLY) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`ALTER TABLE graph_artifacts
|
||||
ADD UNIQUE INDEX ${REQUIRED_INDEX_NAME} (runId, stepKey, artifactType, refId)`,
|
||||
)
|
||||
summary.altered = true
|
||||
const afterRows = await loadIndexRows()
|
||||
summary.hasRequiredIndexAfter = hasRequiredUniqueIndex(afterRows)
|
||||
if (!summary.hasRequiredIndexAfter) {
|
||||
throw new Error('unique index create verification failed')
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error('[migrate-graph-artifacts-unique-index] failed', error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
498
scripts/migrations/migrate-model-config-contract.ts
Normal file
498
scripts/migrations/migrate-model-config-contract.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
composeModelKey,
|
||||
parseModelKeyStrict,
|
||||
validateModelCapabilities,
|
||||
type ModelCapabilities,
|
||||
type UnifiedModelType,
|
||||
} from '@/lib/model-config-contract'
|
||||
|
||||
type ModelField =
|
||||
| 'analysisModel'
|
||||
| 'characterModel'
|
||||
| 'locationModel'
|
||||
| 'storyboardModel'
|
||||
| 'editModel'
|
||||
| 'videoModel'
|
||||
|
||||
type PreferenceRow = {
|
||||
id: string
|
||||
userId: string
|
||||
customModels: string | null
|
||||
analysisModel: string | null
|
||||
characterModel: string | null
|
||||
locationModel: string | null
|
||||
storyboardModel: string | null
|
||||
editModel: string | null
|
||||
videoModel: string | null
|
||||
}
|
||||
|
||||
type ProjectRow = {
|
||||
id: string
|
||||
projectId: string
|
||||
analysisModel: string | null
|
||||
characterModel: string | null
|
||||
locationModel: string | null
|
||||
storyboardModel: string | null
|
||||
editModel: string | null
|
||||
videoModel: string | null
|
||||
project: {
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
|
||||
type MigrationIssue = {
|
||||
table: 'userPreference' | 'novelPromotionProject'
|
||||
rowId: string
|
||||
userId?: string
|
||||
field: string
|
||||
kind:
|
||||
| 'CUSTOM_MODELS_JSON_INVALID'
|
||||
| 'MODEL_SHAPE_INVALID'
|
||||
| 'MODEL_TYPE_INVALID'
|
||||
| 'MODEL_KEY_INCOMPLETE'
|
||||
| 'MODEL_KEY_MISMATCH'
|
||||
| 'MODEL_CAPABILITY_INVALID'
|
||||
| 'LEGACY_MODEL_ID_NOT_FOUND'
|
||||
| 'LEGACY_MODEL_ID_AMBIGUOUS'
|
||||
rawValue?: string | null
|
||||
candidates?: string[]
|
||||
message: string
|
||||
}
|
||||
|
||||
type MigrationReport = {
|
||||
generatedAt: string
|
||||
mode: 'dry-run' | 'apply'
|
||||
userPreference: {
|
||||
scanned: number
|
||||
updated: number
|
||||
updatedCustomModels: number
|
||||
updatedDefaultFields: number
|
||||
}
|
||||
novelPromotionProject: {
|
||||
scanned: number
|
||||
updated: number
|
||||
updatedFields: number
|
||||
}
|
||||
issues: MigrationIssue[]
|
||||
}
|
||||
|
||||
type NormalizedModel = {
|
||||
provider: string
|
||||
modelId: string
|
||||
modelKey: string
|
||||
name: string
|
||||
type: UnifiedModelType
|
||||
price: number
|
||||
resolution?: '2K' | '4K'
|
||||
capabilities?: ModelCapabilities
|
||||
}
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
const MAX_ISSUES = 500
|
||||
const MODEL_FIELDS: readonly ModelField[] = [
|
||||
'analysisModel',
|
||||
'characterModel',
|
||||
'locationModel',
|
||||
'storyboardModel',
|
||||
'editModel',
|
||||
'videoModel',
|
||||
]
|
||||
|
||||
const LEGACY_MODEL_ID_MAP = new Map<string, string>([
|
||||
['anthropic/claude-sonnet-4.5', 'openrouter::anthropic/claude-sonnet-4.5'],
|
||||
['google/gemini-3-pro-preview', 'openrouter::google/gemini-3-pro-preview'],
|
||||
['openai/gpt-5.2', 'openrouter::openai/gpt-5.2'],
|
||||
['banana', 'fal::banana'],
|
||||
['banana-2k', 'fal::banana'],
|
||||
['seedream', 'ark::doubao-seedream-4-0-250828'],
|
||||
['seedream4.5', 'ark::doubao-seedream-4-5-251128'],
|
||||
['gemini-3-pro-image-preview', 'google::gemini-3-pro-image-preview'],
|
||||
['gemini-3-pro-image-preview-batch', 'google::gemini-3-pro-image-preview-batch'],
|
||||
['nano-banana-pro', 'google::gemini-3-pro-image-preview'],
|
||||
['gemini-3.0-pro-image-portrait', 'flow2api::gemini-3.0-pro-image-portrait'],
|
||||
['imagen-4.0-ultra-generate-001', 'google::imagen-4.0-ultra-generate-001'],
|
||||
['doubao-seedance-1-0-pro-250528', 'ark::doubao-seedance-1-0-pro-250528'],
|
||||
['doubao-seedance-1-0-pro-fast-251015', 'ark::doubao-seedance-1-0-pro-fast-251015'],
|
||||
['doubao-seedance-1-0-pro-fast-251015-batch', 'ark::doubao-seedance-1-0-pro-fast-251015-batch'],
|
||||
])
|
||||
|
||||
function parseReportPathArg(): string {
|
||||
const flagPrefix = '--report='
|
||||
const inline = process.argv.find((arg) => arg.startsWith(flagPrefix))
|
||||
if (inline) return inline.slice(flagPrefix.length)
|
||||
const flagIndex = process.argv.findIndex((arg) => arg === '--report')
|
||||
if (flagIndex !== -1 && process.argv[flagIndex + 1]) {
|
||||
return process.argv[flagIndex + 1]
|
||||
}
|
||||
return 'scripts/migrations/reports/model-config-migration-report.json'
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function isUnifiedModelType(value: unknown): value is UnifiedModelType {
|
||||
return value === 'llm'
|
||||
|| value === 'image'
|
||||
|| value === 'video'
|
||||
|| value === 'audio'
|
||||
|| value === 'lipsync'
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function parseCustomModels(raw: string | null): { ok: true; value: unknown[] } | { ok: false } {
|
||||
if (!raw) return { ok: true, value: [] }
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return { ok: false }
|
||||
return { ok: true, value: parsed }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModel(
|
||||
raw: unknown,
|
||||
): { normalized: NormalizedModel | null; changed: boolean; issue?: Omit<MigrationIssue, 'table' | 'rowId'> } {
|
||||
if (!isRecord(raw)) {
|
||||
return {
|
||||
normalized: null,
|
||||
changed: false,
|
||||
issue: {
|
||||
field: 'customModels',
|
||||
kind: 'MODEL_SHAPE_INVALID',
|
||||
message: 'customModels item must be object',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const modelType = raw.type
|
||||
if (!isUnifiedModelType(modelType)) {
|
||||
return {
|
||||
normalized: null,
|
||||
changed: false,
|
||||
issue: {
|
||||
field: 'customModels.type',
|
||||
kind: 'MODEL_TYPE_INVALID',
|
||||
rawValue: String(raw.type ?? ''),
|
||||
message: 'custom model type must be llm/image/video/audio/lipsync',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const providerField = toTrimmedString(raw.provider)
|
||||
const modelIdField = toTrimmedString(raw.modelId)
|
||||
const parsedModelKey = parseModelKeyStrict(toTrimmedString(raw.modelKey))
|
||||
|
||||
const provider = providerField || parsedModelKey?.provider || ''
|
||||
const modelId = modelIdField || parsedModelKey?.modelId || ''
|
||||
const modelKey = composeModelKey(provider, modelId)
|
||||
if (!modelKey) {
|
||||
return {
|
||||
normalized: null,
|
||||
changed: false,
|
||||
issue: {
|
||||
field: 'customModels.modelKey',
|
||||
kind: 'MODEL_KEY_INCOMPLETE',
|
||||
rawValue: toTrimmedString(raw.modelKey),
|
||||
message: 'provider/modelId/modelKey cannot compose a valid model_key',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedModelKey && parsedModelKey.modelKey !== modelKey) {
|
||||
return {
|
||||
normalized: null,
|
||||
changed: false,
|
||||
issue: {
|
||||
field: 'customModels.modelKey',
|
||||
kind: 'MODEL_KEY_MISMATCH',
|
||||
rawValue: toTrimmedString(raw.modelKey),
|
||||
message: 'modelKey conflicts with provider/modelId',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const rawResolution = toTrimmedString(raw.resolution)
|
||||
const resolution = rawResolution === '2K' || rawResolution === '4K' ? rawResolution : undefined
|
||||
const capabilities = isRecord(raw.capabilities)
|
||||
? ({ ...(raw.capabilities as ModelCapabilities) })
|
||||
: undefined
|
||||
const capabilityIssues = validateModelCapabilities(modelType, capabilities)
|
||||
if (capabilityIssues.length > 0) {
|
||||
const firstIssue = capabilityIssues[0]
|
||||
return {
|
||||
normalized: null,
|
||||
changed: false,
|
||||
issue: {
|
||||
field: firstIssue.field,
|
||||
kind: 'MODEL_CAPABILITY_INVALID',
|
||||
message: `${firstIssue.code}: ${firstIssue.message}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const name = toTrimmedString(raw.name) || modelId
|
||||
const price = typeof raw.price === 'number' && Number.isFinite(raw.price) ? raw.price : 0
|
||||
|
||||
const normalized: NormalizedModel = {
|
||||
provider,
|
||||
modelId,
|
||||
modelKey,
|
||||
name,
|
||||
type: modelType,
|
||||
price,
|
||||
...(resolution ? { resolution } : {}),
|
||||
...(capabilities ? { capabilities } : {}),
|
||||
}
|
||||
|
||||
const changed = stableStringify(raw) !== stableStringify(normalized)
|
||||
return { normalized, changed }
|
||||
}
|
||||
|
||||
function addIssue(report: MigrationReport, issue: MigrationIssue) {
|
||||
if (report.issues.length >= MAX_ISSUES) return
|
||||
report.issues.push(issue)
|
||||
}
|
||||
|
||||
function normalizeModelFieldValue(
|
||||
rawValue: string | null,
|
||||
field: ModelField,
|
||||
mappingByModelId: Map<string, string[]>,
|
||||
): { nextValue: string | null; changed: boolean; issue?: Omit<MigrationIssue, 'table' | 'rowId'> } {
|
||||
if (!rawValue || !rawValue.trim()) return { nextValue: null, changed: rawValue !== null }
|
||||
const trimmed = rawValue.trim()
|
||||
const parsed = parseModelKeyStrict(trimmed)
|
||||
if (parsed) {
|
||||
return { nextValue: parsed.modelKey, changed: parsed.modelKey !== rawValue }
|
||||
}
|
||||
|
||||
const candidates = mappingByModelId.get(trimmed) || []
|
||||
if (candidates.length === 1) {
|
||||
return { nextValue: candidates[0], changed: candidates[0] !== rawValue }
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
const mappedModelKey = LEGACY_MODEL_ID_MAP.get(trimmed)
|
||||
if (mappedModelKey) {
|
||||
return { nextValue: mappedModelKey, changed: mappedModelKey !== rawValue }
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
nextValue: rawValue,
|
||||
changed: false,
|
||||
issue: {
|
||||
field,
|
||||
kind: 'LEGACY_MODEL_ID_NOT_FOUND',
|
||||
rawValue,
|
||||
message: `${field} legacy modelId cannot be mapped`,
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
nextValue: rawValue,
|
||||
changed: false,
|
||||
issue: {
|
||||
field,
|
||||
kind: 'LEGACY_MODEL_ID_AMBIGUOUS',
|
||||
rawValue,
|
||||
candidates,
|
||||
message: `${field} legacy modelId maps to multiple providers`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const reportPath = parseReportPathArg()
|
||||
const report: MigrationReport = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode: APPLY ? 'apply' : 'dry-run',
|
||||
userPreference: {
|
||||
scanned: 0,
|
||||
updated: 0,
|
||||
updatedCustomModels: 0,
|
||||
updatedDefaultFields: 0,
|
||||
},
|
||||
novelPromotionProject: {
|
||||
scanned: 0,
|
||||
updated: 0,
|
||||
updatedFields: 0,
|
||||
},
|
||||
issues: [],
|
||||
}
|
||||
|
||||
const userPrefs = await prisma.userPreference.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
customModels: true,
|
||||
analysisModel: true,
|
||||
characterModel: true,
|
||||
locationModel: true,
|
||||
storyboardModel: true,
|
||||
editModel: true,
|
||||
videoModel: true,
|
||||
},
|
||||
})
|
||||
|
||||
const userMappings = new Map<string, Map<string, string[]>>()
|
||||
|
||||
for (const pref of userPrefs) {
|
||||
report.userPreference.scanned += 1
|
||||
const updateData: Partial<Record<ModelField | 'customModels', string | null>> = {}
|
||||
|
||||
const parsedCustomModels = parseCustomModels(pref.customModels)
|
||||
const normalizedModels: NormalizedModel[] = []
|
||||
let customModelsChanged = false
|
||||
|
||||
if (!parsedCustomModels.ok) {
|
||||
addIssue(report, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
userId: pref.userId,
|
||||
field: 'customModels',
|
||||
kind: 'CUSTOM_MODELS_JSON_INVALID',
|
||||
rawValue: pref.customModels,
|
||||
message: 'customModels JSON is invalid',
|
||||
})
|
||||
} else {
|
||||
for (let index = 0; index < parsedCustomModels.value.length; index += 1) {
|
||||
const normalizedResult = normalizeModel(parsedCustomModels.value[index])
|
||||
if (normalizedResult.issue) {
|
||||
addIssue(report, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
userId: pref.userId,
|
||||
...normalizedResult.issue,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (normalizedResult.normalized) {
|
||||
normalizedModels.push(normalizedResult.normalized)
|
||||
if (normalizedResult.changed) customModelsChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mappingByModelId = new Map<string, string[]>()
|
||||
for (const model of normalizedModels) {
|
||||
const existing = mappingByModelId.get(model.modelId) || []
|
||||
if (!existing.includes(model.modelKey)) existing.push(model.modelKey)
|
||||
mappingByModelId.set(model.modelId, existing)
|
||||
}
|
||||
userMappings.set(pref.userId, mappingByModelId)
|
||||
|
||||
if (customModelsChanged) {
|
||||
updateData.customModels = JSON.stringify(normalizedModels)
|
||||
report.userPreference.updatedCustomModels += 1
|
||||
}
|
||||
|
||||
for (const field of MODEL_FIELDS) {
|
||||
const normalizedField = normalizeModelFieldValue(pref[field], field, mappingByModelId)
|
||||
if (normalizedField.issue) {
|
||||
addIssue(report, {
|
||||
table: 'userPreference',
|
||||
rowId: pref.id,
|
||||
userId: pref.userId,
|
||||
...normalizedField.issue,
|
||||
})
|
||||
}
|
||||
if (normalizedField.changed) {
|
||||
updateData[field] = normalizedField.nextValue
|
||||
report.userPreference.updatedDefaultFields += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
report.userPreference.updated += 1
|
||||
if (APPLY) {
|
||||
await prisma.userPreference.update({
|
||||
where: { id: pref.id },
|
||||
data: updateData,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const projects = await prisma.novelPromotionProject.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
analysisModel: true,
|
||||
characterModel: true,
|
||||
locationModel: true,
|
||||
storyboardModel: true,
|
||||
editModel: true,
|
||||
videoModel: true,
|
||||
project: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
for (const row of projects as ProjectRow[]) {
|
||||
report.novelPromotionProject.scanned += 1
|
||||
const mappingByModelId = userMappings.get(row.project.userId) || new Map<string, string[]>()
|
||||
const updateData: Partial<Record<ModelField, string | null>> = {}
|
||||
|
||||
for (const field of MODEL_FIELDS) {
|
||||
const normalizedField = normalizeModelFieldValue(row[field], field, mappingByModelId)
|
||||
if (normalizedField.issue) {
|
||||
addIssue(report, {
|
||||
table: 'novelPromotionProject',
|
||||
rowId: row.id,
|
||||
userId: row.project.userId,
|
||||
...normalizedField.issue,
|
||||
})
|
||||
}
|
||||
if (normalizedField.changed) {
|
||||
updateData[field] = normalizedField.nextValue
|
||||
report.novelPromotionProject.updatedFields += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
report.novelPromotionProject.updated += 1
|
||||
if (APPLY) {
|
||||
await prisma.novelPromotionProject.update({
|
||||
where: { id: row.id },
|
||||
data: updateData,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const absoluteReportPath = path.isAbsolute(reportPath)
|
||||
? reportPath
|
||||
: path.join(process.cwd(), reportPath)
|
||||
fs.mkdirSync(path.dirname(absoluteReportPath), { recursive: true })
|
||||
fs.writeFileSync(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||
|
||||
process.stdout.write(
|
||||
`[migrate-model-config-contract] mode=${report.mode} ` +
|
||||
`prefs=${report.userPreference.scanned}/${report.userPreference.updated} ` +
|
||||
`projects=${report.novelPromotionProject.scanned}/${report.novelPromotionProject.updated} ` +
|
||||
`issues=${report.issues.length} report=${absoluteReportPath}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
process.stderr.write(`[migrate-model-config-contract] failed: ${String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
351
scripts/migrations/migrate-qwen-to-bailian.ts
Normal file
351
scripts/migrations/migrate-qwen-to-bailian.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { composeModelKey, parseModelKeyStrict, type CapabilitySelections } from '@/lib/model-config-contract'
|
||||
|
||||
const APPLY = process.argv.includes('--apply')
|
||||
|
||||
type PreferenceRow = {
|
||||
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
|
||||
lipSyncModel: string | null
|
||||
capabilityDefaults: string | null
|
||||
}
|
||||
|
||||
type StoredProvider = {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
apiMode?: 'gemini-sdk' | 'openai-official'
|
||||
gatewayRoute?: 'official' | 'litellm'
|
||||
}
|
||||
|
||||
type StoredModel = {
|
||||
modelId: string
|
||||
modelKey: string
|
||||
name: string
|
||||
type: string
|
||||
provider: string
|
||||
price: number
|
||||
}
|
||||
|
||||
type MigrationConflict = {
|
||||
userId: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
type MigrationSummary = {
|
||||
mode: 'dry-run' | 'apply'
|
||||
scanned: number
|
||||
updatedRows: number
|
||||
updatedProviders: number
|
||||
updatedModels: number
|
||||
updatedDefaults: number
|
||||
updatedCapabilityDefaults: number
|
||||
invalidRows: number
|
||||
conflicts: MigrationConflict[]
|
||||
}
|
||||
|
||||
type DefaultModelField =
|
||||
| 'analysisModel'
|
||||
| 'characterModel'
|
||||
| 'locationModel'
|
||||
| 'storyboardModel'
|
||||
| 'editModel'
|
||||
| 'videoModel'
|
||||
| 'lipSyncModel'
|
||||
|
||||
const DEFAULT_MODEL_FIELDS: readonly DefaultModelField[] = [
|
||||
'analysisModel',
|
||||
'characterModel',
|
||||
'locationModel',
|
||||
'storyboardModel',
|
||||
'editModel',
|
||||
'videoModel',
|
||||
'lipSyncModel',
|
||||
]
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function parseProviders(raw: string | null): StoredProvider[] | null {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return null
|
||||
const providers: StoredProvider[] = []
|
||||
for (const item of parsed) {
|
||||
if (!isRecord(item)) return null
|
||||
const id = readTrimmedString(item.id)
|
||||
const name = readTrimmedString(item.name)
|
||||
if (!id || !name) return null
|
||||
const provider: StoredProvider = { id, name }
|
||||
if (typeof item.baseUrl === 'string' && item.baseUrl.trim()) provider.baseUrl = item.baseUrl.trim()
|
||||
if (typeof item.apiKey === 'string' && item.apiKey.trim()) provider.apiKey = item.apiKey.trim()
|
||||
if (item.apiMode === 'gemini-sdk' || item.apiMode === 'openai-official') provider.apiMode = item.apiMode
|
||||
if (item.gatewayRoute === 'official' || item.gatewayRoute === 'litellm') provider.gatewayRoute = item.gatewayRoute
|
||||
providers.push(provider)
|
||||
}
|
||||
return providers
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseModels(raw: string | null): StoredModel[] | null {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!Array.isArray(parsed)) return null
|
||||
const models: StoredModel[] = []
|
||||
for (const item of parsed) {
|
||||
if (!isRecord(item)) return null
|
||||
const modelId = readTrimmedString(item.modelId)
|
||||
const modelKey = readTrimmedString(item.modelKey)
|
||||
const provider = readTrimmedString(item.provider)
|
||||
const name = readTrimmedString(item.name)
|
||||
const type = readTrimmedString(item.type)
|
||||
const price = typeof item.price === 'number' && Number.isFinite(item.price) ? item.price : 0
|
||||
if (!modelId || !provider || !type) return null
|
||||
const normalizedModelKey = modelKey || composeModelKey(provider, modelId)
|
||||
if (!normalizedModelKey) return null
|
||||
models.push({
|
||||
modelId,
|
||||
modelKey: normalizedModelKey,
|
||||
provider,
|
||||
name: name || modelId,
|
||||
type,
|
||||
price,
|
||||
})
|
||||
}
|
||||
return models
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseCapabilityDefaults(raw: string | null): CapabilitySelections | null {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (!isRecord(parsed)) return null
|
||||
const selections: CapabilitySelections = {}
|
||||
for (const [modelKey, value] of Object.entries(parsed)) {
|
||||
if (!isRecord(value)) continue
|
||||
const nextSelection: Record<string, string | number | boolean> = {}
|
||||
for (const [field, option] of Object.entries(value)) {
|
||||
if (typeof option === 'string' || typeof option === 'number' || typeof option === 'boolean') {
|
||||
nextSelection[field] = option
|
||||
}
|
||||
}
|
||||
selections[modelKey] = nextSelection
|
||||
}
|
||||
return selections
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function migrateProviderId(providerId: string): string {
|
||||
if (providerId === 'qwen') return 'bailian'
|
||||
const parsed = parseModelKeyStrict(providerId)
|
||||
if (parsed) return providerId
|
||||
const marker = providerId.indexOf(':')
|
||||
if (marker === -1) return providerId
|
||||
const providerKey = providerId.slice(0, marker)
|
||||
if (providerKey !== 'qwen') return providerId
|
||||
return `bailian${providerId.slice(marker)}`
|
||||
}
|
||||
|
||||
function migrateModelKey(rawModelKey: string): string {
|
||||
const parsed = parseModelKeyStrict(rawModelKey)
|
||||
if (!parsed) return rawModelKey
|
||||
if (parsed.provider !== 'qwen') return parsed.modelKey
|
||||
return composeModelKey('bailian', parsed.modelId)
|
||||
}
|
||||
|
||||
function migrateDefaultModel(rawValue: string | null): string | null {
|
||||
if (!rawValue) return rawValue
|
||||
const value = rawValue.trim()
|
||||
if (!value) return null
|
||||
return migrateModelKey(value)
|
||||
}
|
||||
|
||||
function hasProviderByKey(providers: StoredProvider[], providerKey: string): boolean {
|
||||
return providers.some((provider) => {
|
||||
const marker = provider.id.indexOf(':')
|
||||
const key = marker === -1 ? provider.id : provider.id.slice(0, marker)
|
||||
return key === providerKey
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const summary: MigrationSummary = {
|
||||
mode: APPLY ? 'apply' : 'dry-run',
|
||||
scanned: 0,
|
||||
updatedRows: 0,
|
||||
updatedProviders: 0,
|
||||
updatedModels: 0,
|
||||
updatedDefaults: 0,
|
||||
updatedCapabilityDefaults: 0,
|
||||
invalidRows: 0,
|
||||
conflicts: [],
|
||||
}
|
||||
|
||||
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,
|
||||
lipSyncModel: true,
|
||||
capabilityDefaults: true,
|
||||
},
|
||||
}) as PreferenceRow[]
|
||||
|
||||
summary.scanned = rows.length
|
||||
|
||||
for (const row of rows) {
|
||||
const providers = parseProviders(row.customProviders)
|
||||
const models = parseModels(row.customModels)
|
||||
const capabilityDefaults = parseCapabilityDefaults(row.capabilityDefaults)
|
||||
if (!providers || !models || !capabilityDefaults) {
|
||||
summary.invalidRows += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const hasQwenProvider = hasProviderByKey(providers, 'qwen')
|
||||
const hasBailianProvider = hasProviderByKey(providers, 'bailian')
|
||||
if (hasQwenProvider && hasBailianProvider) {
|
||||
summary.conflicts.push({
|
||||
userId: row.userId,
|
||||
reason: 'both qwen and bailian providers exist',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
let rowChanged = false
|
||||
|
||||
const nextProviders = providers.map((provider) => {
|
||||
const nextId = migrateProviderId(provider.id)
|
||||
if (nextId !== provider.id) {
|
||||
rowChanged = true
|
||||
summary.updatedProviders += 1
|
||||
}
|
||||
return {
|
||||
...provider,
|
||||
id: nextId,
|
||||
...(nextId === 'bailian' ? { name: 'Alibaba Bailian' } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
const nextModels = models.map((model) => {
|
||||
const nextProvider = migrateProviderId(model.provider)
|
||||
const nextModelKey = migrateModelKey(model.modelKey)
|
||||
const changed = nextProvider !== model.provider || nextModelKey !== model.modelKey
|
||||
if (changed) {
|
||||
rowChanged = true
|
||||
summary.updatedModels += 1
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
provider: nextProvider,
|
||||
modelKey: nextModelKey,
|
||||
}
|
||||
})
|
||||
const modelKeySet = new Set<string>()
|
||||
let hasModelConflict = false
|
||||
for (const model of nextModels) {
|
||||
if (!modelKeySet.has(model.modelKey)) {
|
||||
modelKeySet.add(model.modelKey)
|
||||
continue
|
||||
}
|
||||
hasModelConflict = true
|
||||
break
|
||||
}
|
||||
if (hasModelConflict) {
|
||||
summary.conflicts.push({
|
||||
userId: row.userId,
|
||||
reason: 'model key collision after qwen -> bailian migration',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const nextDefaults: Partial<Record<DefaultModelField, string | null>> = {}
|
||||
for (const field of DEFAULT_MODEL_FIELDS) {
|
||||
const current = row[field]
|
||||
const next = migrateDefaultModel(current)
|
||||
nextDefaults[field] = next
|
||||
if ((current || null) !== (next || null)) {
|
||||
rowChanged = true
|
||||
summary.updatedDefaults += 1
|
||||
}
|
||||
}
|
||||
|
||||
const nextCapabilityDefaults: CapabilitySelections = {}
|
||||
for (const [modelKey, selection] of Object.entries(capabilityDefaults)) {
|
||||
const nextModelKey = migrateModelKey(modelKey)
|
||||
nextCapabilityDefaults[nextModelKey] = selection
|
||||
if (nextModelKey !== modelKey) {
|
||||
rowChanged = true
|
||||
summary.updatedCapabilityDefaults += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!rowChanged) continue
|
||||
summary.updatedRows += 1
|
||||
|
||||
if (APPLY) {
|
||||
await prisma.userPreference.update({
|
||||
where: { id: row.id },
|
||||
data: {
|
||||
customProviders: JSON.stringify(nextProviders),
|
||||
customModels: JSON.stringify(nextModels),
|
||||
analysisModel: nextDefaults.analysisModel || null,
|
||||
characterModel: nextDefaults.characterModel || null,
|
||||
locationModel: nextDefaults.locationModel || null,
|
||||
storyboardModel: nextDefaults.storyboardModel || null,
|
||||
editModel: nextDefaults.editModel || null,
|
||||
videoModel: nextDefaults.videoModel || null,
|
||||
lipSyncModel: nextDefaults.lipSyncModel || null,
|
||||
capabilityDefaults: Object.keys(nextCapabilityDefaults).length > 0
|
||||
? JSON.stringify(nextCapabilityDefaults)
|
||||
: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
if (summary.conflicts.length > 0) {
|
||||
process.exitCode = 2
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error('[migrate-qwen-to-bailian] failed', error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
868
scripts/migrations/migrate-release-blockers.ts
Normal file
868
scripts/migrations/migrate-release-blockers.ts
Normal file
@@ -0,0 +1,868 @@
|
||||
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<T> = {
|
||||
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<Record<DefaultModelField, string | null>> & {
|
||||
customProviders?: string | null
|
||||
customModels?: string | null
|
||||
capabilityDefaults?: string | null
|
||||
}
|
||||
|
||||
type NovelProjectUpdateData = Partial<Record<ProjectModelField, string | null>> & {
|
||||
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<string, unknown> {
|
||||
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<unknown[]> {
|
||||
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<Record<string, unknown>> {
|
||||
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<string, { provider: StoredProvider; priority: number }>()
|
||||
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<string, { model: StoredModel; priority: number }>()
|
||||
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<string, number>()
|
||||
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<string, string | number | boolean> = {}
|
||||
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<string, Array<{ seq: number; column: string; nonUnique: number }>>()
|
||||
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<MysqlIndexRow[]> {
|
||||
return await prisma.$queryRawUnsafe<MysqlIndexRow[]>('SHOW INDEX FROM graph_artifacts')
|
||||
}
|
||||
|
||||
async function countGraphArtifactDuplicateGroups(): Promise<number> {
|
||||
const rows = await prisma.$queryRawUnsafe<CountRow[]>(
|
||||
`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<DuplicateGroupRow[]> {
|
||||
return await prisma.$queryRawUnsafe<DuplicateGroupRow[]>(
|
||||
`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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
})
|
||||
1187
scripts/migrations/reports/model-config-migration-report.apply.json
Normal file
1187
scripts/migrations/reports/model-config-migration-report.apply.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"generatedAt": "2026-02-12T12:53:18.381Z",
|
||||
"mode": "apply",
|
||||
"userPreference": {
|
||||
"scanned": 7,
|
||||
"updated": 4,
|
||||
"updatedCustomModels": 0,
|
||||
"updatedDefaultFields": 24
|
||||
},
|
||||
"novelPromotionProject": {
|
||||
"scanned": 70,
|
||||
"updated": 40,
|
||||
"updatedFields": 106
|
||||
},
|
||||
"issues": []
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"generatedAt": "2026-02-12T12:53:12.288Z",
|
||||
"mode": "dry-run",
|
||||
"userPreference": {
|
||||
"scanned": 7,
|
||||
"updated": 4,
|
||||
"updatedCustomModels": 0,
|
||||
"updatedDefaultFields": 24
|
||||
},
|
||||
"novelPromotionProject": {
|
||||
"scanned": 70,
|
||||
"updated": 40,
|
||||
"updatedFields": 106
|
||||
},
|
||||
"issues": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user