From 15adbb7fe12289b770f770f5118e45a305cd1f29 Mon Sep 17 00:00:00 2001 From: mahmood Date: Tue, 27 Oct 2026 17:35:25 +0330 Subject: [PATCH] fix(api): survive existing-schema boots without DB shell Nginx 502 Host-not-found meant the API never stayed listening. Stamp all migrations when tracking is empty but a late table exists, apply DDL statement-by-statement skipping already-exists, harden remaining CREATE TYPE/TABLE files, and never let seed crash the process. Co-authored-by: Cursor --- .../migration.sql | 10 ++- .../migration.sql | 18 +++-- .../20260925150000_app_session/migration.sql | 12 +-- .../migration.sql | 68 +++++++--------- apps/api/release/app.js | 60 ++++++++------ .../migration.sql | 10 ++- .../migration.sql | 18 +++-- .../20260925150000_app_session/migration.sql | 12 +-- .../migration.sql | 68 +++++++--------- apps/api/src/prisma/bootstrap.ts | 17 ++-- apps/api/src/prisma/migrate.ts | 78 ++++++++++++------- 11 files changed, 202 insertions(+), 169 deletions(-) diff --git a/apps/api/prisma/migrations/20260918120000_add_product_image_role/migration.sql b/apps/api/prisma/migrations/20260918120000_add_product_image_role/migration.sql index 5220e32..da012db 100644 --- a/apps/api/prisma/migrations/20260918120000_add_product_image_role/migration.sql +++ b/apps/api/prisma/migrations/20260918120000_add_product_image_role/migration.sql @@ -1,5 +1,9 @@ --- CreateEnum -CREATE TYPE "ProductImageRole" AS ENUM ('FEATURED', 'FEATURED_MOBILE', 'GALLERY', 'GALLERY_MOBILE'); +-- CreateEnum (idempotent — live DBs may already have this from prisma db push) +DO $$ BEGIN + CREATE TYPE "ProductImageRole" AS ENUM ('FEATURED', 'FEATURED_MOBILE', 'GALLERY', 'GALLERY_MOBILE'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; -- AlterTable -ALTER TABLE "ProductImage" ADD COLUMN "role" "ProductImageRole" NOT NULL DEFAULT 'GALLERY'; +ALTER TABLE "ProductImage" ADD COLUMN IF NOT EXISTS "role" "ProductImageRole" NOT NULL DEFAULT 'GALLERY'; diff --git a/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql b/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql index fa6ff7e..d966db5 100644 --- a/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql +++ b/apps/api/prisma/migrations/20260925120000_admin_session/migration.sql @@ -10,7 +10,7 @@ -- token whose row is revoked. Rows are only written (and only enforced) while -- session tracking is enabled, so a deployment that leaves it off keeps exactly -- the previous behaviour and the previous query count. -CREATE TABLE "AdminSession" ( +CREATE TABLE IF NOT EXISTS "AdminSession" ( "id" TEXT NOT NULL, "jti" TEXT NOT NULL, "adminUserId" TEXT NOT NULL, @@ -27,12 +27,16 @@ CREATE TABLE "AdminSession" ( CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX "AdminSession_jti_key" ON "AdminSession"("jti"); -CREATE INDEX "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId"); -CREATE INDEX "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId"); -CREATE INDEX "AdminSession_iamSub_idx" ON "AdminSession"("iamSub"); -CREATE INDEX "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt"); +CREATE UNIQUE INDEX IF NOT EXISTS "AdminSession_jti_key" ON "AdminSession"("jti"); +CREATE INDEX IF NOT EXISTS "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId"); +CREATE INDEX IF NOT EXISTS "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId"); +CREATE INDEX IF NOT EXISTS "AdminSession_iamSub_idx" ON "AdminSession"("iamSub"); +CREATE INDEX IF NOT EXISTS "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt"); -ALTER TABLE "AdminSession" +DO $$ BEGIN + ALTER TABLE "AdminSession" ADD CONSTRAINT "AdminSession_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/apps/api/prisma/migrations/20260925150000_app_session/migration.sql b/apps/api/prisma/migrations/20260925150000_app_session/migration.sql index 3e61501..bb2f2d2 100644 --- a/apps/api/prisma/migrations/20260925150000_app_session/migration.sql +++ b/apps/api/prisma/migrations/20260925150000_app_session/migration.sql @@ -7,7 +7,7 @@ -- -- No foreign key on purpose: an app token's subject is a Customer for a customer -- and an AdminUser for a sales expert (D21), which one FK cannot express. -CREATE TABLE "AppSession" ( +CREATE TABLE IF NOT EXISTS "AppSession" ( "id" TEXT NOT NULL, "jti" TEXT NOT NULL, "subjectId" TEXT NOT NULL, @@ -26,8 +26,8 @@ CREATE TABLE "AppSession" ( CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX "AppSession_jti_key" ON "AppSession"("jti"); -CREATE INDEX "AppSession_subjectId_idx" ON "AppSession"("subjectId"); -CREATE INDEX "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId"); -CREATE INDEX "AppSession_iamSub_idx" ON "AppSession"("iamSub"); -CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt"); +CREATE UNIQUE INDEX IF NOT EXISTS "AppSession_jti_key" ON "AppSession"("jti"); +CREATE INDEX IF NOT EXISTS "AppSession_subjectId_idx" ON "AppSession"("subjectId"); +CREATE INDEX IF NOT EXISTS "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId"); +CREATE INDEX IF NOT EXISTS "AppSession_iamSub_idx" ON "AppSession"("iamSub"); +CREATE INDEX IF NOT EXISTS "AppSession_expiresAt_idx" ON "AppSession"("expiresAt"); diff --git a/apps/api/prisma/migrations/20261005120000_accounting_integration/migration.sql b/apps/api/prisma/migrations/20261005120000_accounting_integration/migration.sql index d3e9193..f0d2418 100644 --- a/apps/api/prisma/migrations/20261005120000_accounting_integration/migration.sql +++ b/apps/api/prisma/migrations/20261005120000_accounting_integration/migration.sql @@ -5,28 +5,30 @@ -- jobs run from, the issue list pulls report into, and the cached balance and -- stock columns read back from the accounting software. Nothing existing -- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches. +-- Statements are idempotent so a DB that already has these objects (db push / +-- prior migrate) still records this file without crashing the API boot. --- CreateEnum -CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT'); +DO $$ BEGIN + CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- CreateEnum -CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE'); +DO $$ BEGIN + CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- CreateEnum -CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'); +DO $$ BEGIN + CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- AlterTable -ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3), -ADD COLUMN "accountingGoldBalance" DECIMAL(14,4), -ADD COLUMN "accountingRialBalance" DECIMAL(20,2); +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "accountingBalanceAt" TIMESTAMP(3); +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "accountingGoldBalance" DECIMAL(14,4); +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "accountingRialBalance" DECIMAL(20,2); --- AlterTable -ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3), -ADD COLUMN "accountingStockCount" DECIMAL(14,4), -ADD COLUMN "accountingStockWeight" DECIMAL(14,4); +ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "accountingStockAt" TIMESTAMP(3); +ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "accountingStockCount" DECIMAL(14,4); +ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "accountingStockWeight" DECIMAL(14,4); --- CreateTable -CREATE TABLE "AccountingLink" ( +CREATE TABLE IF NOT EXISTS "AccountingLink" ( "id" TEXT NOT NULL, "provider" TEXT NOT NULL, "entityType" "AccountingEntityType" NOT NULL, @@ -41,8 +43,7 @@ CREATE TABLE "AccountingLink" ( CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "AccountingOutbox" ( +CREATE TABLE IF NOT EXISTS "AccountingOutbox" ( "id" TEXT NOT NULL, "provider" TEXT NOT NULL, "kind" "AccountingOutboxKind" NOT NULL, @@ -63,8 +64,7 @@ CREATE TABLE "AccountingOutbox" ( CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "AccountingIssue" ( +CREATE TABLE IF NOT EXISTS "AccountingIssue" ( "id" TEXT NOT NULL, "provider" TEXT NOT NULL, "kind" TEXT NOT NULL, @@ -79,27 +79,13 @@ CREATE TABLE "AccountingIssue" ( CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id") ); --- CreateIndex -CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId"); - --- CreateIndex -CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId"); - --- CreateIndex -CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode"); - --- CreateIndex -CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt"); - --- CreateIndex -CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId"); - --- CreateIndex -CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt"); - --- CreateIndex -CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode"); - +CREATE INDEX IF NOT EXISTS "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId"); +CREATE UNIQUE INDEX IF NOT EXISTS "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId"); +CREATE UNIQUE INDEX IF NOT EXISTS "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode"); +CREATE INDEX IF NOT EXISTS "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt"); +CREATE INDEX IF NOT EXISTS "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId"); +CREATE INDEX IF NOT EXISTS "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt"); +CREATE UNIQUE INDEX IF NOT EXISTS "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode"); -- The new admin screen's permission. The seeded super-admin user type (85, -- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing diff --git a/apps/api/release/app.js b/apps/api/release/app.js index 9e40058..fbf2ac9 100644 --- a/apps/api/release/app.js +++ b/apps/api/release/app.js @@ -164233,6 +164233,13 @@ var require_migrate = __commonJS({ ) AS "exists"`, typname); return Boolean(rows[0]?.exists); } + async function tableExists(prisma, tableName) { + const rows = await prisma.$queryRawUnsafe(`SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = $1 + ) AS "exists"`, tableName); + return Boolean(rows[0]?.exists); + } async function recordApplied(prisma, name) { await prisma.$executeRawUnsafe(`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1) ON CONFLICT (name) DO NOTHING`, name); @@ -164276,6 +164283,15 @@ var require_migrate = __commonJS({ console.warn(`[migrate] Adopted ${adopted.length} migration(s) from _prisma_migrations: ${adopted.join(", ")}`); } const applied = []; + if (appliedNames.size === 0 && (await tableExists(prisma, "Admin") || await tableExists(prisma, "AccountingOutbox") || await tableExists(prisma, "AdminSession"))) { + for (const file2 of files) { + await recordApplied(prisma, file2.name); + appliedNames.add(file2.name); + applied.push(file2.name); + } + console.warn(`[migrate] Empty tracking on existing schema; recorded ${applied.length} migration(s) without SQL`); + return { freshDatabase, applied }; + } for (const file2 of files) { const legacyName = LEGACY_NAME_ALIASES[file2.name]; if (appliedNames.has(file2.name) || legacyName && appliedNames.has(legacyName)) @@ -164291,24 +164307,16 @@ var require_migrate = __commonJS({ const normalized = statement.replace(/\s+/g, " ").trim().toLowerCase(); return !normalized.startsWith(`drop table "${TRACKING_TABLE.toLowerCase()}"`) && !normalized.startsWith(`drop table ${TRACKING_TABLE.toLowerCase()}`); }); - try { - await prisma.$transaction( - async (tx) => { - for (const statement of statements) { - await tx.$executeRawUnsafe(statement); - } - await tx.$executeRawUnsafe(`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, file2.name); - }, - // The baseline migration is hundreds of DDL statements; the 5s default is - // not enough, especially against a remote database. - { maxWait: 6e4, timeout: 6e5 } - ); - } catch (err) { - if (!isAlreadyExistsPgError(err)) - throw err; - await recordApplied(prisma, file2.name); - console.warn(`[migrate] ${file2.name}: objects already exist \u2014 recorded as applied (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`); + for (const statement of statements) { + try { + await prisma.$executeRawUnsafe(statement); + } catch (err) { + if (!isAlreadyExistsPgError(err)) + throw err; + console.warn(`[migrate] ${file2.name}: skipped already-existing object (${err instanceof Error ? err.message.split("\n")[0] : String(err)})`); + } } + await recordApplied(prisma, file2.name); appliedNames.add(file2.name); applied.push(file2.name); } @@ -164368,13 +164376,17 @@ var require_bootstrap = __commonJS({ logger.warn("No seed script found \u2014 skipping seed"); return; } - const seeded = await (0, migrate_1.runOnce)(prisma, "seed:initial", async () => { - const { stdout } = await execFileAsync(process.execPath, [seedScript]); - if (stdout.trim()) - logger.log(stdout.trim()); - }); - if (seeded) - logger.log("Seed data inserted"); + try { + const seeded = await (0, migrate_1.runOnce)(prisma, "seed:initial", async () => { + const { stdout } = await execFileAsync(process.execPath, [seedScript]); + if (stdout.trim()) + logger.log(stdout.trim()); + }); + if (seeded) + logger.log("Seed data inserted"); + } catch (err) { + logger.warn(`Seed skipped after error: ${err instanceof Error ? err.message : String(err)}`); + } } finally { await prisma.$disconnect(); } diff --git a/apps/api/release/migrations/20260918120000_add_product_image_role/migration.sql b/apps/api/release/migrations/20260918120000_add_product_image_role/migration.sql index 5220e32..da012db 100644 --- a/apps/api/release/migrations/20260918120000_add_product_image_role/migration.sql +++ b/apps/api/release/migrations/20260918120000_add_product_image_role/migration.sql @@ -1,5 +1,9 @@ --- CreateEnum -CREATE TYPE "ProductImageRole" AS ENUM ('FEATURED', 'FEATURED_MOBILE', 'GALLERY', 'GALLERY_MOBILE'); +-- CreateEnum (idempotent — live DBs may already have this from prisma db push) +DO $$ BEGIN + CREATE TYPE "ProductImageRole" AS ENUM ('FEATURED', 'FEATURED_MOBILE', 'GALLERY', 'GALLERY_MOBILE'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; -- AlterTable -ALTER TABLE "ProductImage" ADD COLUMN "role" "ProductImageRole" NOT NULL DEFAULT 'GALLERY'; +ALTER TABLE "ProductImage" ADD COLUMN IF NOT EXISTS "role" "ProductImageRole" NOT NULL DEFAULT 'GALLERY'; diff --git a/apps/api/release/migrations/20260925120000_admin_session/migration.sql b/apps/api/release/migrations/20260925120000_admin_session/migration.sql index fa6ff7e..d966db5 100644 --- a/apps/api/release/migrations/20260925120000_admin_session/migration.sql +++ b/apps/api/release/migrations/20260925120000_admin_session/migration.sql @@ -10,7 +10,7 @@ -- token whose row is revoked. Rows are only written (and only enforced) while -- session tracking is enabled, so a deployment that leaves it off keeps exactly -- the previous behaviour and the previous query count. -CREATE TABLE "AdminSession" ( +CREATE TABLE IF NOT EXISTS "AdminSession" ( "id" TEXT NOT NULL, "jti" TEXT NOT NULL, "adminUserId" TEXT NOT NULL, @@ -27,12 +27,16 @@ CREATE TABLE "AdminSession" ( CONSTRAINT "AdminSession_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX "AdminSession_jti_key" ON "AdminSession"("jti"); -CREATE INDEX "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId"); -CREATE INDEX "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId"); -CREATE INDEX "AdminSession_iamSub_idx" ON "AdminSession"("iamSub"); -CREATE INDEX "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt"); +CREATE UNIQUE INDEX IF NOT EXISTS "AdminSession_jti_key" ON "AdminSession"("jti"); +CREATE INDEX IF NOT EXISTS "AdminSession_adminUserId_idx" ON "AdminSession"("adminUserId"); +CREATE INDEX IF NOT EXISTS "AdminSession_iamSessionId_idx" ON "AdminSession"("iamSessionId"); +CREATE INDEX IF NOT EXISTS "AdminSession_iamSub_idx" ON "AdminSession"("iamSub"); +CREATE INDEX IF NOT EXISTS "AdminSession_expiresAt_idx" ON "AdminSession"("expiresAt"); -ALTER TABLE "AdminSession" +DO $$ BEGIN + ALTER TABLE "AdminSession" ADD CONSTRAINT "AdminSession_adminUserId_fkey" FOREIGN KEY ("adminUserId") REFERENCES "AdminUser"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/apps/api/release/migrations/20260925150000_app_session/migration.sql b/apps/api/release/migrations/20260925150000_app_session/migration.sql index 3e61501..bb2f2d2 100644 --- a/apps/api/release/migrations/20260925150000_app_session/migration.sql +++ b/apps/api/release/migrations/20260925150000_app_session/migration.sql @@ -7,7 +7,7 @@ -- -- No foreign key on purpose: an app token's subject is a Customer for a customer -- and an AdminUser for a sales expert (D21), which one FK cannot express. -CREATE TABLE "AppSession" ( +CREATE TABLE IF NOT EXISTS "AppSession" ( "id" TEXT NOT NULL, "jti" TEXT NOT NULL, "subjectId" TEXT NOT NULL, @@ -26,8 +26,8 @@ CREATE TABLE "AppSession" ( CONSTRAINT "AppSession_pkey" PRIMARY KEY ("id") ); -CREATE UNIQUE INDEX "AppSession_jti_key" ON "AppSession"("jti"); -CREATE INDEX "AppSession_subjectId_idx" ON "AppSession"("subjectId"); -CREATE INDEX "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId"); -CREATE INDEX "AppSession_iamSub_idx" ON "AppSession"("iamSub"); -CREATE INDEX "AppSession_expiresAt_idx" ON "AppSession"("expiresAt"); +CREATE UNIQUE INDEX IF NOT EXISTS "AppSession_jti_key" ON "AppSession"("jti"); +CREATE INDEX IF NOT EXISTS "AppSession_subjectId_idx" ON "AppSession"("subjectId"); +CREATE INDEX IF NOT EXISTS "AppSession_iamSessionId_idx" ON "AppSession"("iamSessionId"); +CREATE INDEX IF NOT EXISTS "AppSession_iamSub_idx" ON "AppSession"("iamSub"); +CREATE INDEX IF NOT EXISTS "AppSession_expiresAt_idx" ON "AppSession"("expiresAt"); diff --git a/apps/api/release/migrations/20261005120000_accounting_integration/migration.sql b/apps/api/release/migrations/20261005120000_accounting_integration/migration.sql index d3e9193..f0d2418 100644 --- a/apps/api/release/migrations/20261005120000_accounting_integration/migration.sql +++ b/apps/api/release/migrations/20261005120000_accounting_integration/migration.sql @@ -5,28 +5,30 @@ -- jobs run from, the issue list pulls report into, and the cached balance and -- stock columns read back from the accounting software. Nothing existing -- changes meaning: goldDebtGrams/fiatDebtRial stay the internal ledger caches. +-- Statements are idempotent so a DB that already has these objects (db push / +-- prior migrate) still records this file without crashing the API boot. --- CreateEnum -CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT'); +DO $$ BEGIN + CREATE TYPE "AccountingEntityType" AS ENUM ('CUSTOMER', 'PRODUCT'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- CreateEnum -CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE'); +DO $$ BEGIN + CREATE TYPE "AccountingOutboxKind" AS ENUM ('CUSTOMER_PUSH', 'PRODUCT_PUSH', 'ORDER_DOCUMENT', 'ORDER_DOCUMENT_DELETE'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- CreateEnum -CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'); +DO $$ BEGIN + CREATE TYPE "AccountingOutboxStatus" AS ENUM ('PENDING', 'SENDING', 'SENT', 'FAILED', 'NEEDS_REVIEW', 'RESOLVED'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- AlterTable -ALTER TABLE "Customer" ADD COLUMN "accountingBalanceAt" TIMESTAMP(3), -ADD COLUMN "accountingGoldBalance" DECIMAL(14,4), -ADD COLUMN "accountingRialBalance" DECIMAL(20,2); +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "accountingBalanceAt" TIMESTAMP(3); +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "accountingGoldBalance" DECIMAL(14,4); +ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "accountingRialBalance" DECIMAL(20,2); --- AlterTable -ALTER TABLE "Product" ADD COLUMN "accountingStockAt" TIMESTAMP(3), -ADD COLUMN "accountingStockCount" DECIMAL(14,4), -ADD COLUMN "accountingStockWeight" DECIMAL(14,4); +ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "accountingStockAt" TIMESTAMP(3); +ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "accountingStockCount" DECIMAL(14,4); +ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "accountingStockWeight" DECIMAL(14,4); --- CreateTable -CREATE TABLE "AccountingLink" ( +CREATE TABLE IF NOT EXISTS "AccountingLink" ( "id" TEXT NOT NULL, "provider" TEXT NOT NULL, "entityType" "AccountingEntityType" NOT NULL, @@ -41,8 +43,7 @@ CREATE TABLE "AccountingLink" ( CONSTRAINT "AccountingLink_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "AccountingOutbox" ( +CREATE TABLE IF NOT EXISTS "AccountingOutbox" ( "id" TEXT NOT NULL, "provider" TEXT NOT NULL, "kind" "AccountingOutboxKind" NOT NULL, @@ -63,8 +64,7 @@ CREATE TABLE "AccountingOutbox" ( CONSTRAINT "AccountingOutbox_pkey" PRIMARY KEY ("id") ); --- CreateTable -CREATE TABLE "AccountingIssue" ( +CREATE TABLE IF NOT EXISTS "AccountingIssue" ( "id" TEXT NOT NULL, "provider" TEXT NOT NULL, "kind" TEXT NOT NULL, @@ -79,27 +79,13 @@ CREATE TABLE "AccountingIssue" ( CONSTRAINT "AccountingIssue_pkey" PRIMARY KEY ("id") ); --- CreateIndex -CREATE INDEX "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId"); - --- CreateIndex -CREATE UNIQUE INDEX "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId"); - --- CreateIndex -CREATE UNIQUE INDEX "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode"); - --- CreateIndex -CREATE INDEX "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt"); - --- CreateIndex -CREATE INDEX "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId"); - --- CreateIndex -CREATE INDEX "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt"); - --- CreateIndex -CREATE UNIQUE INDEX "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode"); - +CREATE INDEX IF NOT EXISTS "AccountingLink_entityType_entityId_idx" ON "AccountingLink"("entityType", "entityId"); +CREATE UNIQUE INDEX IF NOT EXISTS "AccountingLink_provider_entityType_entityId_key" ON "AccountingLink"("provider", "entityType", "entityId"); +CREATE UNIQUE INDEX IF NOT EXISTS "AccountingLink_provider_entityType_externalCode_key" ON "AccountingLink"("provider", "entityType", "externalCode"); +CREATE INDEX IF NOT EXISTS "AccountingOutbox_status_nextAttemptAt_idx" ON "AccountingOutbox"("status", "nextAttemptAt"); +CREATE INDEX IF NOT EXISTS "AccountingOutbox_kind_entityId_idx" ON "AccountingOutbox"("kind", "entityId"); +CREATE INDEX IF NOT EXISTS "AccountingIssue_resolvedAt_idx" ON "AccountingIssue"("resolvedAt"); +CREATE UNIQUE INDEX IF NOT EXISTS "AccountingIssue_provider_kind_externalCode_key" ON "AccountingIssue"("provider", "kind", "externalCode"); -- The new admin screen's permission. The seeded super-admin user type (85, -- prisma/seed.ts) gets every rid in ALL_RIDS on a fresh seed; an existing diff --git a/apps/api/src/prisma/bootstrap.ts b/apps/api/src/prisma/bootstrap.ts index 1fec71d..01fbbfb 100644 --- a/apps/api/src/prisma/bootstrap.ts +++ b/apps/api/src/prisma/bootstrap.ts @@ -52,11 +52,18 @@ export async function prepareDatabase(): Promise { return; } - const seeded = await runOnce(prisma, 'seed:initial', async () => { - const { stdout } = await execFileAsync(process.execPath, [seedScript]); - if (stdout.trim()) logger.log(stdout.trim()); - }); - if (seeded) logger.log('Seed data inserted'); + try { + const seeded = await runOnce(prisma, 'seed:initial', async () => { + const { stdout } = await execFileAsync(process.execPath, [seedScript]); + if (stdout.trim()) logger.log(stdout.trim()); + }); + if (seeded) logger.log('Seed data inserted'); + } catch (err) { + // Seed must never crash-loop the API (nginx Host not found / 502). + logger.warn( + `Seed skipped after error: ${err instanceof Error ? err.message : String(err)}`, + ); + } } finally { await prisma.$disconnect(); } diff --git a/apps/api/src/prisma/migrate.ts b/apps/api/src/prisma/migrate.ts index acc2e18..79c77fd 100644 --- a/apps/api/src/prisma/migrate.ts +++ b/apps/api/src/prisma/migrate.ts @@ -206,6 +206,17 @@ async function typeExists(prisma: PrismaClient, typname: string): Promise { + const rows = await prisma.$queryRawUnsafe<{ exists: boolean }[]>( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = $1 + ) AS "exists"`, + tableName, + ); + return Boolean(rows[0]?.exists); +} + async function recordApplied(prisma: PrismaClient, name: string): Promise { await prisma.$executeRawUnsafe( `INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1) @@ -281,6 +292,30 @@ export async function runMigrations(prisma: PrismaClient): Promise { - for (const statement of statements) { - await tx.$executeRawUnsafe(statement); - } - await tx.$executeRawUnsafe( - `INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)`, - file.name, - ); - }, - // The baseline migration is hundreds of DDL statements; the 5s default is - // not enough, especially against a remote database. - { maxWait: 60_000, timeout: 600_000 }, - ); - } catch (err) { - if (!isAlreadyExistsPgError(err)) throw err; - // Schema already matches this migration (e.g. prior db push). Record and - // continue so the container can boot without shell access to Postgres. - await recordApplied(prisma, file.name); - // eslint-disable-next-line no-console - console.warn( - `[migrate] ${file.name}: objects already exist — recorded as applied (${ - err instanceof Error ? err.message.split('\n')[0] : String(err) - })`, - ); + // Apply one statement at a time (not one big transaction). A prior db push + // often leaves some objects present and others missing; failing the whole + // file on the first "already exists" would skip the rest and still crash + // on a later non-duplicate error — or mark the file applied too early. + for (const statement of statements) { + try { + await prisma.$executeRawUnsafe(statement); + } catch (err) { + if (!isAlreadyExistsPgError(err)) throw err; + // eslint-disable-next-line no-console + console.warn( + `[migrate] ${file.name}: skipped already-existing object (${ + err instanceof Error ? err.message.split('\n')[0] : String(err) + })`, + ); + } } + await recordApplied(prisma, file.name); appliedNames.add(file.name); applied.push(file.name);