diff --git a/ecosystem.config.js b/ecosystem.config.js index 8bb4c07..bd44dac 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -9,7 +9,7 @@ module.exports = { env: { NODE_ENV: 'production', }, - max_memory_restart: '512M', + max_memory_restart: '2G', time: true, }, ], diff --git a/src/business-admin/legacy-migrate.service.ts b/src/business-admin/legacy-migrate.service.ts index b0da1c3..b4888c4 100644 --- a/src/business-admin/legacy-migrate.service.ts +++ b/src/business-admin/legacy-migrate.service.ts @@ -19,6 +19,11 @@ import { StorageService } from '../storage/storage.service'; import { PrismaService } from '../prisma/prisma.service'; import { MigrateFromOldEntity } from './dto/migrate-from-old.dto'; +// Legacy product images are large; Sharp's default cache plus PM2's 512MB cap +// was restarting the API mid-migrate (nginx 502, UI "Unable to migrate"). +sharp.cache(false); +sharp.concurrency(1); + export type MigrateEntityCounts = { created: number; skipped: number; @@ -2482,144 +2487,160 @@ export class LegacyMigrateService { continue; } - const titleEn = - (row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product'; - const nameFa = (row.name ?? '').trim() || null; - const baseSlug = slugify(titleEn); - const slug = await this.ensureUniqueProductSlug( - businessId, - baseSlug, - row.id, - ); + try { + const titleEn = + (row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product'; + const nameFa = (row.name ?? '').trim() || null; + const baseSlug = slugify(titleEn); + const slug = await this.ensureUniqueProductSlug( + businessId, + baseSlug, + row.id, + ); - // Re-check in case slug already exists from a prior partial run. - const existingBySlug = await this.prisma.product.findFirst({ - where: { businessId, slug }, - select: { id: true }, - }); - if (existingBySlug) { - existingByOldId.set(row.id, existingBySlug.id); - skipped += 1; - continue; - } - - const productMedia = mediaByProduct.get(row.id) ?? []; - let featuredMediaId: bigint | null = null; - const galleryMediaIds: bigint[] = []; - - for (const media of productMedia) { - const size = Number(media.size) || 0; - if (size <= 0) { - imagesFailed += 1; + // Re-check in case slug already exists from a prior partial run. + const existingBySlug = await this.prisma.product.findFirst({ + where: { businessId, slug }, + select: { id: true }, + }); + if (existingBySlug) { + existingByOldId.set(row.id, existingBySlug.id); + skipped += 1; continue; } - let newMediaId = mediaOldToNew.get(media.id) ?? null; - if (!newMediaId) { - try { - const copied = await this.copyLegacyMedia({ - businessId, - oldMedia: media, - storagePrefix, - }); - newMediaId = copied.id; - mediaOldToNew.set(media.id, newMediaId); - imagesCopied += 1; - if (copied.resized) { - imagesResized += 1; - } - } catch { + const productMedia = mediaByProduct.get(row.id) ?? []; + let featuredMediaId: bigint | null = null; + const galleryMediaIds: bigint[] = []; + + for (const media of productMedia) { + const size = Number(media.size) || 0; + if (size <= 0) { imagesFailed += 1; continue; } + + let newMediaId = mediaOldToNew.get(media.id) ?? null; + if (!newMediaId) { + try { + const copied = await this.copyLegacyMedia({ + businessId, + oldMedia: media, + storagePrefix, + }); + newMediaId = copied.id; + mediaOldToNew.set(media.id, newMediaId); + imagesCopied += 1; + if (copied.resized) { + imagesResized += 1; + } + } catch { + imagesFailed += 1; + continue; + } + } + + if (media.collection_name === 'main_image' && !featuredMediaId) { + featuredMediaId = newMediaId; + } else if (media.collection_name === 'images') { + galleryMediaIds.push(newMediaId); + } else if ( + media.collection_name === 'main_image' && + featuredMediaId && + featuredMediaId !== newMediaId + ) { + galleryMediaIds.push(newMediaId); + } } - if (media.collection_name === 'main_image' && !featuredMediaId) { - featuredMediaId = newMediaId; - } else if (media.collection_name === 'images') { - galleryMediaIds.push(newMediaId); - } else if ( - media.collection_name === 'main_image' && - featuredMediaId && - featuredMediaId !== newMediaId - ) { - galleryMediaIds.push(newMediaId); - } - } + const createdAt = row.created_at ? new Date(row.created_at) : new Date(); + const updatedAt = row.updated_at ? new Date(row.updated_at) : createdAt; + const sku = + row.product_code != null && String(row.product_code).trim() + ? String(row.product_code).trim() + : null; - const createdAt = row.created_at ? new Date(row.created_at) : new Date(); - const updatedAt = row.updated_at ? new Date(row.updated_at) : createdAt; - const sku = - row.product_code != null && String(row.product_code).trim() - ? String(row.product_code).trim() - : null; - - const product = await this.prisma.product.create({ - data: { - businessId, - title: titleEn, - slug, - description: null, - content: { - nameFa, - html: row.description ?? '', - } as Prisma.InputJsonValue, - sku, - price: null, - stockQuantity: null, - status: ContentStatus.published, - featuredMediaId, - sortOrder: index, - publishedAt: createdAt, - metadata: { - migratedFromOldId: row.id, - legacyBrandId: row.brand_id ?? null, - } as Prisma.InputJsonValue, - createdAt, - updatedAt, - }, - }); - - existingByOldId.set(row.id, product.id); - - const oldCategoryIds = new Set( - categoriesByProduct.get(row.id) ?? [], - ); - if (row.product_category_id != null) { - oldCategoryIds.add(row.product_category_id); - } - - for (const oldCategoryId of oldCategoryIds) { - const newCategoryId = categoryMap.get(oldCategoryId); - if (!newCategoryId) continue; - try { - await this.prisma.categoryAssignment.create({ - data: { - businessId, - categoryId: newCategoryId, - entityType: MediaEntityType.product, - entityId: product.id, - }, - }); - } catch { - // ignore duplicate assignment - } - } - - for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) { - await this.prisma.mediaAttachment.create({ + const product = await this.prisma.product.create({ data: { businessId, - mediaId, - entityType: MediaEntityType.product, - entityId: product.id, - sortOrder: galleryIndex, - isFeatured: false, + title: titleEn, + slug, + description: null, + content: { + nameFa, + html: row.description ?? '', + } as Prisma.InputJsonValue, + sku, + price: null, + stockQuantity: null, + status: ContentStatus.published, + featuredMediaId, + sortOrder: index, + publishedAt: createdAt, + metadata: { + migratedFromOldId: row.id, + legacyBrandId: row.brand_id ?? null, + } as Prisma.InputJsonValue, + createdAt, + updatedAt, }, }); + + existingByOldId.set(row.id, product.id); + + const oldCategoryIds = new Set( + categoriesByProduct.get(row.id) ?? [], + ); + if (row.product_category_id != null) { + oldCategoryIds.add(row.product_category_id); + } + + for (const oldCategoryId of oldCategoryIds) { + const newCategoryId = categoryMap.get(oldCategoryId); + if (!newCategoryId) continue; + try { + await this.prisma.categoryAssignment.create({ + data: { + businessId, + categoryId: newCategoryId, + entityType: MediaEntityType.product, + entityId: product.id, + }, + }); + } catch { + // ignore duplicate assignment + } + } + + for (const [galleryIndex, mediaId] of galleryMediaIds.entries()) { + await this.prisma.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.product, + entityId: product.id, + sortOrder: galleryIndex, + isFeatured: false, + }, + }); + } + + created += 1; + } catch (err) { + console.error( + `Legacy migrate: product old id ${row.id} failed`, + err instanceof Error ? err.message : err, + ); + skipped += 1; } - created += 1; + if ((index + 1) % 25 === 0) { + const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024); + console.log( + `Legacy migrate: products ${index + 1}/${products.length} created=${created} skipped=${skipped} rss=${rssMb}MB`, + ); + await new Promise((resolve) => setImmediate(resolve)); + } } return {