import "server-only"; import { eq, like, or, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { assetMemorySnapshot, canvasDetailArchive, canvasNode, canvasImageTask, canvasProject, conversationWorkingState, externalImageApiRequestLog, generatedImage, mediaAsset, message, messageDeprecated, model3dAsset, } from "@/lib/db/schema"; import { inferOriginalImageUrl } from "@/lib/image-url"; function normalizeMediaUrl(value: string): string { const raw = String(value || "").trim(); if (!raw) return ""; const canonical = inferOriginalImageUrl(raw); try { const parsed = new URL(canonical); return `${parsed.origin}${parsed.pathname}`.replace(/\/+$/, "").toLowerCase(); } catch { return canonical.split("#")[0].split("?")[0].replace(/\/+$/, "").toLowerCase(); } } export function replaceDeepUrls( value: unknown, sourceUrl: string, targetUrl: string, ): { value: unknown; changed: boolean } { if (typeof value === "string") { if (!value.includes(sourceUrl)) { return { value, changed: false }; } return { value: value.split(sourceUrl).join(targetUrl), changed: true }; } if (Array.isArray(value)) { let changed = false; const next = value.map((item) => { const result = replaceDeepUrls(item, sourceUrl, targetUrl); if (result.changed) changed = true; return result.value; }); return { value: changed ? next : value, changed }; } if (value && typeof value === "object") { let changed = false; const next: Record = {}; for (const [key, item] of Object.entries(value as Record)) { const result = replaceDeepUrls(item, sourceUrl, targetUrl); next[key] = result.value; if (result.changed) changed = true; } return { value: changed ? next : value, changed }; } return { value, changed: false }; } export async function replaceImageUrlInDatabase(sourceUrl: string, targetUrl: string) { if (!sourceUrl || !targetUrl || sourceUrl === targetUrl) { return { changed: 0 }; } let changed = 0; const sourceNormalizedUrl = normalizeMediaUrl(sourceUrl); const targetNormalizedUrl = normalizeMediaUrl(targetUrl); const generatedThumb = await db .update(generatedImage) .set({ thumbnailUrl: targetUrl }) .where(eq(generatedImage.thumbnailUrl, sourceUrl)) .returning({ id: generatedImage.id }); changed += generatedThumb.length; const generatedOrig = await db .update(generatedImage) .set({ originalUrl: targetUrl }) .where(eq(generatedImage.originalUrl, sourceUrl)) .returning({ id: generatedImage.id }); changed += generatedOrig.length; const assets = await db .select() .from(mediaAsset) .where( or( eq(mediaAsset.originalUrl, sourceUrl), eq(mediaAsset.previewUrl, sourceUrl), eq(mediaAsset.thumbnailUrl, sourceUrl), eq(mediaAsset.normalizedUrl, sourceNormalizedUrl), sql`${mediaAsset.metadata}::text like ${`%${sourceUrl}%`}`, sql`${mediaAsset.summary}::text like ${`%${sourceUrl}%`}`, ), ) .limit(500); for (const item of assets) { const original = replaceDeepUrls(item.originalUrl, sourceUrl, targetUrl); const preview = replaceDeepUrls(item.previewUrl, sourceUrl, targetUrl); const thumbnail = replaceDeepUrls(item.thumbnailUrl, sourceUrl, targetUrl); const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl); const summary = replaceDeepUrls(item.summary, sourceUrl, targetUrl); const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl || original.changed; const nextOriginal = (original.value as string) || item.originalUrl; if ( !original.changed && !preview.changed && !thumbnail.changed && !metadata.changed && !summary.changed && !shouldNormalize ) { continue; } try { await db .update(mediaAsset) .set({ originalUrl: nextOriginal, normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl, previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl, thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl, metadata: (metadata.value ?? item.metadata) as any, summary: (summary.value ?? item.summary) as any, updatedAt: new Date(), }) .where(eq(mediaAsset.id, item.id)); } catch (error) { await db .update(mediaAsset) .set({ originalUrl: nextOriginal, previewUrl: (preview.value as string | null | undefined) ?? item.previewUrl, thumbnailUrl: (thumbnail.value as string | null | undefined) ?? item.thumbnailUrl, metadata: (metadata.value ?? item.metadata) as any, summary: (summary.value ?? item.summary) as any, updatedAt: new Date(), }) .where(eq(mediaAsset.id, item.id)); } changed += 1; } const snapshots = await db .select() .from(assetMemorySnapshot) .where( or( eq(assetMemorySnapshot.normalizedUrl, sourceNormalizedUrl), sql`${assetMemorySnapshot.metadata}::text like ${`%${sourceUrl}%`}`, like(assetMemorySnapshot.summaryText, `%${sourceUrl}%`), ), ) .limit(500); for (const item of snapshots) { const metadata = replaceDeepUrls(item.metadata, sourceUrl, targetUrl); const summary = replaceDeepUrls(item.summaryText, sourceUrl, targetUrl); const shouldNormalize = item.normalizedUrl === sourceNormalizedUrl; if (!metadata.changed && !summary.changed && !shouldNormalize) continue; await db .update(assetMemorySnapshot) .set({ normalizedUrl: shouldNormalize ? targetNormalizedUrl : item.normalizedUrl, metadata: (metadata.value ?? item.metadata) as any, summaryText: (summary.value as string | null | undefined) ?? item.summaryText, updatedAt: new Date(), }) .where(eq(assetMemorySnapshot.id, item.id)); changed += 1; } const messages = await db .select({ id: message.id, parts: message.parts, attachments: message.attachments, }) .from(message) .where( or( sql`${message.parts}::text like ${`%${sourceUrl}%`}`, sql`${message.attachments}::text like ${`%${sourceUrl}%`}`, ), ) .limit(500); for (const item of messages) { const partsResult = replaceDeepUrls(item.parts, sourceUrl, targetUrl); const attachmentsResult = replaceDeepUrls(item.attachments, sourceUrl, targetUrl); if (!partsResult.changed && !attachmentsResult.changed) continue; await db .update(message) .set({ parts: (partsResult.value ?? item.parts) as any, attachments: (attachmentsResult.value ?? item.attachments) as any, }) .where(eq(message.id, item.id)); changed += 1; } const legacyMessages = await db .select({ id: messageDeprecated.id, content: messageDeprecated.content, }) .from(messageDeprecated) .where(sql`${messageDeprecated.content}::text like ${`%${sourceUrl}%`}`) .limit(500); for (const item of legacyMessages) { const replaced = replaceDeepUrls(item.content, sourceUrl, targetUrl); if (!replaced.changed) continue; await db .update(messageDeprecated) .set({ content: replaced.value as any }) .where(eq(messageDeprecated.id, item.id)); changed += 1; } const escapedSource = JSON.stringify(sourceUrl).slice(1, -1); const escapedTarget = JSON.stringify(targetUrl).slice(1, -1); const projects = await db .update(canvasProject) .set({ canvasData: sql`replace(${canvasProject.canvasData}::text, ${escapedSource}, ${escapedTarget})::jsonb`, thumbnail: sql`case when ${canvasProject.thumbnail} = ${sourceUrl} then ${targetUrl} else ${canvasProject.thumbnail} end`, // URL maintenance is not a content revision; keep autosave conflict detection valid. }) .where( or( sql`position(${escapedSource} in ${canvasProject.canvasData}::text) > 0`, eq(canvasProject.thumbnail, sourceUrl), ), ) .returning({ id: canvasProject.id }); changed += projects.length; const nodes = await db .update(canvasNode) .set({ data: sql`replace(${canvasNode.data}::text, ${escapedSource}, ${escapedTarget})::jsonb`, }) .where(sql`position(${escapedSource} in ${canvasNode.data}::text) > 0`) .returning({ id: canvasNode.id }); changed += nodes.length; const tasks = await db .update(canvasImageTask) .set({ result: sql`replace(${canvasImageTask.result}::text, ${escapedSource}, ${escapedTarget})::jsonb`, }) .where(sql`position(${escapedSource} in ${canvasImageTask.result}::text) > 0`) .returning({ id: canvasImageTask.id }); changed += tasks.length; const workingStates = await db .update(conversationWorkingState) .set({ currentFocusImageUrls: sql`replace(${conversationWorkingState.currentFocusImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`, selectedImageUrls: sql`replace(${conversationWorkingState.selectedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`, lastUploadedImageUrls: sql`replace(${conversationWorkingState.lastUploadedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`, lastGeneratedImageUrls: sql`replace(${conversationWorkingState.lastGeneratedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`, lastReferencedImageUrls: sql`replace(${conversationWorkingState.lastReferencedImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`, lastResolvedTargetImageUrls: sql`replace(${conversationWorkingState.lastResolvedTargetImageUrls}::text, ${escapedSource}, ${escapedTarget})::jsonb`, lastCanvasSnapshot: sql`replace(${conversationWorkingState.lastCanvasSnapshot}::text, ${escapedSource}, ${escapedTarget})::jsonb`, lastTurnSummary: sql`replace(${conversationWorkingState.lastTurnSummary}::text, ${escapedSource}, ${escapedTarget})::jsonb`, updatedAt: new Date(), }) .where( or( sql`position(${escapedSource} in ${conversationWorkingState.currentFocusImageUrls}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.selectedImageUrls}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.lastUploadedImageUrls}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.lastGeneratedImageUrls}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.lastReferencedImageUrls}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.lastResolvedTargetImageUrls}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.lastCanvasSnapshot}::text) > 0`, sql`position(${escapedSource} in ${conversationWorkingState.lastTurnSummary}::text) > 0`, ), ) .returning({ id: conversationWorkingState.id }); changed += workingStates.length; const detailArchives = await db .select({ id: canvasDetailArchive.id, coverUrl: canvasDetailArchive.coverUrl, brief: canvasDetailArchive.brief, slots: canvasDetailArchive.slots, uiSurfaceSnapshot: canvasDetailArchive.uiSurfaceSnapshot, uiSurfaceLatestReview: canvasDetailArchive.uiSurfaceLatestReview, }) .from(canvasDetailArchive) .where( or( like(canvasDetailArchive.coverUrl, `%${sourceUrl}%`), sql`${canvasDetailArchive.brief}::text like ${`%${sourceUrl}%`}`, sql`${canvasDetailArchive.slots}::text like ${`%${sourceUrl}%`}`, sql`${canvasDetailArchive.uiSurfaceSnapshot}::text like ${`%${sourceUrl}%`}`, sql`${canvasDetailArchive.uiSurfaceLatestReview}::text like ${`%${sourceUrl}%`}`, ), ) .limit(500); for (const item of detailArchives) { const cover = replaceDeepUrls(item.coverUrl, sourceUrl, targetUrl); const brief = replaceDeepUrls(item.brief, sourceUrl, targetUrl); const slots = replaceDeepUrls(item.slots, sourceUrl, targetUrl); const snapshot = replaceDeepUrls(item.uiSurfaceSnapshot, sourceUrl, targetUrl); const review = replaceDeepUrls(item.uiSurfaceLatestReview, sourceUrl, targetUrl); if ( !cover.changed && !brief.changed && !slots.changed && !snapshot.changed && !review.changed ) { continue; } await db .update(canvasDetailArchive) .set({ coverUrl: (cover.value as string | null | undefined) ?? item.coverUrl, brief: (brief.value ?? item.brief) as any, slots: (slots.value ?? item.slots) as any, uiSurfaceSnapshot: (snapshot.value ?? item.uiSurfaceSnapshot) as any, uiSurfaceLatestReview: (review.value ?? item.uiSurfaceLatestReview) as any, updatedAt: new Date(), }) .where(eq(canvasDetailArchive.id, item.id)); changed += 1; } const modelAssets = await db .select() .from(model3dAsset) .where( or( like(model3dAsset.previewImageUrl, `%${sourceUrl}%`), sql`${model3dAsset.assetPayload}::text like ${`%${sourceUrl}%`}`, ), ) .limit(500); for (const item of modelAssets) { const preview = replaceDeepUrls(item.previewImageUrl, sourceUrl, targetUrl); const payload = replaceDeepUrls(item.assetPayload, sourceUrl, targetUrl); if (!preview.changed && !payload.changed) continue; await db .update(model3dAsset) .set({ previewImageUrl: (preview.value as string | null | undefined) ?? item.previewImageUrl, assetPayload: (payload.value ?? item.assetPayload) as any, updatedAt: new Date(), }) .where(eq(model3dAsset.id, item.id)); changed += 1; } const apiLogs = await db .select() .from(externalImageApiRequestLog) .where( or( sql`${externalImageApiRequestLog.responsePayload}::text like ${`%${sourceUrl}%`}`, sql`${externalImageApiRequestLog.generatedImageUrls}::text like ${`%${sourceUrl}%`}`, ), ) .limit(500); for (const item of apiLogs) { const responsePayload = replaceDeepUrls(item.responsePayload, sourceUrl, targetUrl); const generatedUrls = replaceDeepUrls(item.generatedImageUrls, sourceUrl, targetUrl); if (!responsePayload.changed && !generatedUrls.changed) continue; await db .update(externalImageApiRequestLog) .set({ responsePayload: (responsePayload.value ?? item.responsePayload) as any, generatedImageUrls: (generatedUrls.value ?? item.generatedImageUrls) as any, }) .where(eq(externalImageApiRequestLog.id, item.id)); changed += 1; } return { changed }; }