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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
+36
-24
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 $$;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,11 +52,18 @@ export async function prepareDatabase(): Promise<void> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -206,6 +206,17 @@ async function typeExists(prisma: PrismaClient, typname: string): Promise<boolea
|
||||
return Boolean(rows[0]?.exists);
|
||||
}
|
||||
|
||||
async function tableExists(prisma: PrismaClient, tableName: string): Promise<boolean> {
|
||||
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<void> {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO "${TRACKING_TABLE}" (name) VALUES ($1)
|
||||
@@ -281,6 +292,30 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
|
||||
}
|
||||
|
||||
const applied: string[] = [];
|
||||
|
||||
// Empty goldbank ledger on a DB that already has a late table = previous
|
||||
// prisma migrate / db push. Stamping every known file lets the API listen
|
||||
// without replaying non-idempotent DDL (nginx 502 / Host not found loop).
|
||||
// A later release that adds a new migration still runs it: appliedNames
|
||||
// will be non-empty, so this shortcut does not fire again.
|
||||
if (
|
||||
appliedNames.size === 0
|
||||
&& ((await tableExists(prisma, 'Admin'))
|
||||
|| (await tableExists(prisma, 'AccountingOutbox'))
|
||||
|| (await tableExists(prisma, 'AdminSession')))
|
||||
) {
|
||||
for (const file of files) {
|
||||
await recordApplied(prisma, file.name);
|
||||
appliedNames.add(file.name);
|
||||
applied.push(file.name);
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[migrate] Empty tracking on existing schema; recorded ${applied.length} migration(s) without SQL`,
|
||||
);
|
||||
return { freshDatabase, applied };
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const legacyName = LEGACY_NAME_ALIASES[file.name];
|
||||
if (appliedNames.has(file.name) || (legacyName && appliedNames.has(legacyName))) continue;
|
||||
@@ -310,33 +345,24 @@ export async function runMigrations(prisma: PrismaClient): Promise<MigrationResu
|
||||
},
|
||||
);
|
||||
|
||||
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)`,
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user