import "server-only"; import { eq, like, or, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { assetMemorySnapshot, canvasDetailArchive, canvasNode, 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 projects = await db .select({ id: canvasProject.id, canvasData: canvasProject.canvasData, }) .from(canvasProject) .where(sql`${canvasProject.canvasData}::text like ${`%${sourceUrl}%`}`) .limit(500); for (const item of projects) { const replaced = replaceDeepUrls(item.canvasData, sourceUrl, targetUrl); if (!replaced.changed) continue; await db .update(canvasProject) .set({ canvasData: replaced.value as any, updatedAt: new Date(), }) .where(eq(canvasProject.id, item.id)); changed += 1; } const nodes = await db .select({ id: canvasNode.id, data: canvasNode.data, }) .from(canvasNode) .where(sql`${canvasNode.data}::text like ${`%${sourceUrl}%`}`) .limit(500); for (const item of nodes) { const replaced = replaceDeepUrls(item.data, sourceUrl, targetUrl); if (!replaced.changed) continue; await db .update(canvasNode) .set({ data: replaced.value as any, updatedAt: new Date(), }) .where(eq(canvasNode.id, item.id)); changed += 1; } const workingStates = await db .select() .from(conversationWorkingState) .where( or( sql`${conversationWorkingState.currentFocusImageUrls}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.selectedImageUrls}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.lastUploadedImageUrls}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.lastGeneratedImageUrls}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.lastReferencedImageUrls}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.lastResolvedTargetImageUrls}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.lastCanvasSnapshot}::text like ${`%${sourceUrl}%`}`, sql`${conversationWorkingState.lastTurnSummary}::text like ${`%${sourceUrl}%`}` ) ) .limit(500); for (const item of workingStates) { const currentFocus = replaceDeepUrls( item.currentFocusImageUrls, sourceUrl, targetUrl ); const selected = replaceDeepUrls(item.selectedImageUrls, sourceUrl, targetUrl); const uploaded = replaceDeepUrls(item.lastUploadedImageUrls, sourceUrl, targetUrl); const generated = replaceDeepUrls(item.lastGeneratedImageUrls, sourceUrl, targetUrl); const referenced = replaceDeepUrls(item.lastReferencedImageUrls, sourceUrl, targetUrl); const resolved = replaceDeepUrls( item.lastResolvedTargetImageUrls, sourceUrl, targetUrl ); const canvasSnapshot = replaceDeepUrls( item.lastCanvasSnapshot, sourceUrl, targetUrl ); const turnSummary = replaceDeepUrls(item.lastTurnSummary, sourceUrl, targetUrl); if ( !currentFocus.changed && !selected.changed && !uploaded.changed && !generated.changed && !referenced.changed && !resolved.changed && !canvasSnapshot.changed && !turnSummary.changed ) { continue; } await db .update(conversationWorkingState) .set({ currentFocusImageUrls: currentFocus.value as any, selectedImageUrls: selected.value as any, lastUploadedImageUrls: uploaded.value as any, lastGeneratedImageUrls: generated.value as any, lastReferencedImageUrls: referenced.value as any, lastResolvedTargetImageUrls: resolved.value as any, lastCanvasSnapshot: (canvasSnapshot.value ?? item.lastCanvasSnapshot) as any, lastTurnSummary: (turnSummary.value ?? item.lastTurnSummary) as any, updatedAt: new Date(), }) .where(eq(conversationWorkingState.id, item.id)); changed += 1; } 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 }; }