feat(data): add timestamped commerce migrations and history checks

This commit is contained in:
mihir 2026-09-10 11:23:22 +05:30
parent a27a9d87a7
commit eb976d2c8c
15 changed files with 517 additions and 4 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ coverage/
!.env.example
src/generated/
*.log
.tmp/

View File

@ -19,11 +19,13 @@
"db:validate": "prisma validate",
"db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy",
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"check": "pnpm migrations:check && pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
"start:watch": "node --watch dist/main.js",
"db:status": "prisma migrate status",
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
"db:test": "node scripts/test-migrations.mjs"
"db:test": "node scripts/test-migrations.mjs",
"migrations:check": "node scripts/check-migrations.mjs",
"security:audit": "pnpm audit --prod --audit-level=high"
},
"dependencies": {
"@nestjs/common": "^11.1.0",

View File

@ -2,7 +2,7 @@ import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
schema: 'prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL },
});

20
prisma/addresses.prisma Normal file
View File

@ -0,0 +1,20 @@
model Address {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
recipient String @db.VarChar(160)
line1 String @db.VarChar(200)
line2 String @default("") @db.VarChar(200)
city String @db.VarChar(100)
region String @db.VarChar(100)
postalCode String @map("postal_code") @db.VarChar(20)
countryCode String @map("country_code") @db.Char(2)
phone String @db.VarChar(16)
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade)
@@index([userId, organizationId, createdAt, id])
@@map("addresses")
}

67
prisma/catalog.prisma Normal file
View File

@ -0,0 +1,67 @@
enum ProductStatus {
DRAFT
PUBLISHED
ARCHIVED
}
enum GroupKind {
CATEGORY
COLLECTION
}
model Product {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(160)
slug String @db.VarChar(160)
description String @default("") @db.VarChar(5000)
status ProductStatus @default(DRAFT)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
variants ProductVariant[]
groups ProductGroup[]
@@unique([organizationId, slug])
@@unique([id, organizationId])
@@index([organizationId, status, createdAt, id])
@@map("products")
}
model ProductVariant {
id String @id @default(uuid()) @db.Uuid
productId String @map("product_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
sku String @db.VarChar(64)
name String @db.VarChar(160)
price Decimal @db.Decimal(12,2)
currency String @default("INR") @db.Char(3)
attributes Json @default("{}")
active Boolean @default(true)
stockItems StockItem[]
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, sku])
@@unique([id, organizationId])
@@index([productId, active])
@@map("product_variants")
}
model CatalogGroup {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
slug String @db.VarChar(100)
kind GroupKind
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
products ProductGroup[]
@@unique([organizationId, kind, slug])
@@unique([id, organizationId])
@@map("catalog_groups")
}
model ProductGroup {
productId String @map("product_id") @db.Uuid
groupId String @map("group_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Cascade)
group CatalogGroup @relation(fields: [groupId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@id([productId, groupId])
@@index([groupId, organizationId])
@@map("product_groups")
}

66
prisma/inventory.prisma Normal file
View File

@ -0,0 +1,66 @@
enum ReservationStatus {
ACTIVE
RELEASED
COMMITTED
}
model Warehouse {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
name String @db.VarChar(100)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
items StockItem[]
@@unique([organizationId, name])
@@unique([id, organizationId])
@@map("warehouses")
}
model StockItem {
id String @id @default(uuid()) @db.Uuid
organizationId String @map("organization_id") @db.Uuid
variantId String @map("variant_id") @db.Uuid
warehouseId String @map("warehouse_id") @db.Uuid
onHand Int @default(0) @map("on_hand")
variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict)
warehouse Warehouse @relation(fields: [warehouseId, organizationId], references: [id, organizationId], onDelete: Restrict)
entries StockLedger[]
reservations StockReservation[]
@@unique([variantId, warehouseId])
@@unique([id, organizationId])
@@index([organizationId, id])
@@map("stock_items")
}
model StockLedger {
id String @id @default(uuid()) @db.Uuid
stockItemId String @map("stock_item_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
actorId String @map("actor_id") @db.Uuid
actor User @relation(fields: [actorId, organizationId], references: [id, organizationId], onDelete: Restrict)
delta Int
reason String @db.VarChar(200)
idempotencyKey String @map("idempotency_key") @db.Uuid
requestHash String @map("request_hash") @db.Char(64)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, idempotencyKey])
@@index([stockItemId, createdAt, id])
@@map("stock_ledger")
}
model StockReservation {
id String @id @default(uuid()) @db.Uuid
stockItemId String @map("stock_item_id") @db.Uuid
organizationId String @map("organization_id") @db.Uuid
userId String @map("user_id") @db.Uuid
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict)
quantity Int
status ReservationStatus @default(ACTIVE)
idempotencyKey String @map("idempotency_key") @db.Uuid
requestHash String @map("request_hash") @db.Char(64)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
@@unique([organizationId, idempotencyKey])
@@index([stockItemId, status, expiresAt])
@@index([userId, organizationId])
@@map("stock_reservations")
}

View File

@ -0,0 +1,9 @@
{
"202609080003_identity_integrity": "872ee89b0b4a45f574d024d37b7f7d2536ce61df19d2ecefe59594edef06fc14",
"202609080001_create_organizations": "7ca0a406b175865c54d026df616c82f60c4fb276f37e6dd243602621ff8618f6",
"202609080002_identity_access": "e74393574bc5924497a59e71f36ad48310b401268560656102ed24fba84ff902",
"20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84",
"20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36",
"20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac",
"20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e"
}

View File

@ -0,0 +1,122 @@
-- CreateEnum
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "GroupKind" AS ENUM ('CATEGORY', 'COLLECTION');
-- CreateTable
CREATE TABLE "addresses" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"recipient" VARCHAR(160) NOT NULL,
"line1" VARCHAR(200) NOT NULL,
"line2" VARCHAR(200) NOT NULL DEFAULT '',
"city" VARCHAR(100) NOT NULL,
"region" VARCHAR(100) NOT NULL,
"postal_code" VARCHAR(20) NOT NULL,
"country_code" CHAR(2) NOT NULL,
"phone" VARCHAR(16) NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"slug" VARCHAR(160) NOT NULL,
"description" VARCHAR(5000) NOT NULL DEFAULT '',
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_variants" (
"id" UUID NOT NULL,
"product_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"sku" VARCHAR(64) NOT NULL,
"name" VARCHAR(160) NOT NULL,
"price" DECIMAL(12,2) NOT NULL,
"currency" CHAR(3) NOT NULL DEFAULT 'INR',
"attributes" JSONB NOT NULL DEFAULT '{}',
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "catalog_groups" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
"slug" VARCHAR(100) NOT NULL,
"kind" "GroupKind" NOT NULL,
CONSTRAINT "catalog_groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_groups" (
"product_id" UUID NOT NULL,
"group_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
CONSTRAINT "product_groups_pkey" PRIMARY KEY ("product_id","group_id")
);
-- CreateIndex
CREATE INDEX "addresses_user_id_organization_id_created_at_id_idx" ON "addresses"("user_id", "organization_id", "created_at", "id");
-- CreateIndex
CREATE INDEX "products_organization_id_status_created_at_id_idx" ON "products"("organization_id", "status", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "products_organization_id_slug_key" ON "products"("organization_id", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "products_id_organization_id_key" ON "products"("id", "organization_id");
-- CreateIndex
CREATE INDEX "product_variants_product_id_active_idx" ON "product_variants"("product_id", "active");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_organization_id_sku_key" ON "product_variants"("organization_id", "sku");
-- CreateIndex
CREATE UNIQUE INDEX "product_variants_id_organization_id_key" ON "product_variants"("id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "catalog_groups_organization_id_kind_slug_key" ON "catalog_groups"("organization_id", "kind", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "catalog_groups_id_organization_id_key" ON "catalog_groups"("id", "organization_id");
-- CreateIndex
CREATE INDEX "product_groups_group_id_organization_id_idx" ON "product_groups"("group_id", "organization_id");
-- AddForeignKey
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "products" ADD CONSTRAINT "products_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "catalog_groups" ADD CONSTRAINT "catalog_groups_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_group_id_organization_id_fkey" FOREIGN KEY ("group_id", "organization_id") REFERENCES "catalog_groups"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,98 @@
-- CreateEnum
CREATE TYPE "ReservationStatus" AS ENUM ('ACTIVE', 'RELEASED', 'COMMITTED');
-- CreateTable
CREATE TABLE "warehouses" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"name" VARCHAR(100) NOT NULL,
CONSTRAINT "warehouses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_items" (
"id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"variant_id" UUID NOT NULL,
"warehouse_id" UUID NOT NULL,
"on_hand" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "stock_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_ledger" (
"id" UUID NOT NULL,
"stock_item_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"actor_id" UUID NOT NULL,
"delta" INTEGER NOT NULL,
"reason" VARCHAR(200) NOT NULL,
"idempotency_key" UUID NOT NULL,
"request_hash" CHAR(64) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stock_ledger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "stock_reservations" (
"id" UUID NOT NULL,
"stock_item_id" UUID NOT NULL,
"organization_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"quantity" INTEGER NOT NULL,
"status" "ReservationStatus" NOT NULL DEFAULT 'ACTIVE',
"idempotency_key" UUID NOT NULL,
"request_hash" CHAR(64) NOT NULL,
"expires_at" TIMESTAMPTZ(3) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stock_reservations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "warehouses_organization_id_name_key" ON "warehouses"("organization_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "warehouses_id_organization_id_key" ON "warehouses"("id", "organization_id");
-- CreateIndex
CREATE INDEX "stock_items_organization_id_id_idx" ON "stock_items"("organization_id", "id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_items_variant_id_warehouse_id_key" ON "stock_items"("variant_id", "warehouse_id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_items_id_organization_id_key" ON "stock_items"("id", "organization_id");
-- CreateIndex
CREATE INDEX "stock_ledger_stock_item_id_created_at_id_idx" ON "stock_ledger"("stock_item_id", "created_at", "id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_ledger_organization_id_idempotency_key_key" ON "stock_ledger"("organization_id", "idempotency_key");
-- CreateIndex
CREATE INDEX "stock_reservations_stock_item_id_status_expires_at_idx" ON "stock_reservations"("stock_item_id", "status", "expires_at");
-- CreateIndex
CREATE INDEX "stock_reservations_user_id_organization_id_idx" ON "stock_reservations"("user_id", "organization_id");
-- CreateIndex
CREATE UNIQUE INDEX "stock_reservations_organization_id_idempotency_key_key" ON "stock_reservations"("organization_id", "idempotency_key");
-- AddForeignKey
ALTER TABLE "warehouses" ADD CONSTRAINT "warehouses_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_warehouse_id_organization_id_fkey" FOREIGN KEY ("warehouse_id", "organization_id") REFERENCES "warehouses"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,27 @@
-- Positive prices, valid stock arithmetic and private-address defaults are
-- enforced in PostgreSQL in addition to request validation.
ALTER TABLE product_variants ADD CONSTRAINT variant_positive_price CHECK (price > 0);
ALTER TABLE stock_items ADD CONSTRAINT stock_nonnegative CHECK (on_hand >= 0);
ALTER TABLE stock_ledger ADD CONSTRAINT ledger_nonzero_delta CHECK (delta <> 0);
ALTER TABLE stock_reservations ADD CONSTRAINT reservation_positive_quantity CHECK (quantity > 0);
CREATE UNIQUE INDEX addresses_one_default ON addresses (user_id) WHERE is_default = true;
CREATE FUNCTION reject_stock_ledger_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
RAISE EXCEPTION 'Stock ledger entries are append-only';
END;
$$;
CREATE TRIGGER stock_ledger_append_only
BEFORE UPDATE OR DELETE ON stock_ledger
FOR EACH ROW EXECUTE FUNCTION reject_stock_ledger_mutation();
-- Existing system-owner roles need the new permissions. Custom roles retain
-- their explicit grants and must be updated through the authorized API.
UPDATE roles SET permissions = ARRAY(
SELECT DISTINCT permission FROM unnest(permissions || ARRAY[
'catalog.read', 'catalog.manage', 'catalog.publish',
'inventory.read', 'inventory.manage', 'inventory.adjust',
'inventory.reserve', 'inventory.commit'
]::text[]) AS permission ORDER BY permission
) WHERE is_system = true;

View File

@ -0,0 +1,5 @@
-- AddForeignKey
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_actor_id_organization_id_fkey" FOREIGN KEY ("actor_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -19,6 +19,9 @@ model Organization {
users User[]
roles Role[]
auditEvents AuditEvent[]
products Product[]
catalogGroups CatalogGroup[]
warehouses Warehouse[]
@@map("organizations")
}
model User {
@ -35,6 +38,9 @@ model User {
roles UserRole[]
sessions Session[]
recoveryTokens RecoveryToken[]
addresses Address[]
stockEntries StockLedger[]
stockReservations StockReservation[]
@@unique([organizationId, email])
@@unique([id, organizationId])
@@index([organizationId, createdAt, id])

View File

@ -0,0 +1,42 @@
import { createHash } from 'node:crypto';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
const root = resolve(
process.argv.find((value) => value.startsWith('--root='))?.slice(7) ?? '.',
);
const folder = join(root, 'prisma', 'migrations');
const manifest = join(root, 'prisma', 'migration-checksums.json');
const hashes = JSON.parse(await readFile(manifest, 'utf8'));
const canonicalHash = (sql) =>
createHash('sha256').update(sql.replaceAll('\r\n', '\n')).digest('hex');
for (const [name, expected] of Object.entries(hashes)) {
const actual = canonicalHash(
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
);
if (actual !== expected)
throw new Error(`Immutable migration changed: ${name}`);
}
const names = (await readdir(folder, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const stamps = new Set();
for (const name of names) {
const stamp = name.split('_')[0];
if (stamps.has(stamp))
throw new Error(`Duplicate migration timestamp: ${stamp}`);
stamps.add(stamp);
if (hashes[name]) continue;
if (!/^\d{14}_[a-z0-9_]+$/.test(name))
throw new Error(`New migration requires YYYYMMDDHHmmss timestamp: ${name}`);
if (!process.argv.includes('--record-new'))
throw new Error(`New migration checksum not recorded: ${name}`);
hashes[name] = canonicalHash(
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
);
}
if (process.argv.includes('--record-new'))
await writeFile(manifest, JSON.stringify(hashes, null, 2) + '\n');
console.log(`Verified ${Object.keys(hashes).length} immutable migrations`);

View File

@ -10,7 +10,7 @@ const commands = [
'diff',
'--from-config-datasource',
'--to-schema',
'prisma/schema.prisma',
'prisma',
'--exit-code',
],
];

View File

@ -0,0 +1,48 @@
import {
mkdtempSync,
mkdirSync,
writeFileSync,
unlinkSync,
rmdirSync,
} from 'node:fs';
import { join, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
describe('migration history protection', () => {
it('detects edits to a recorded migration', () => {
mkdirSync('.tmp', { recursive: true });
const root = mkdtempSync(resolve('.tmp/migration-policy-'));
const name = '20260909000000_example';
const directory = join(root, 'prisma', 'migrations', name);
mkdirSync(directory, { recursive: true });
const file = join(directory, 'migration.sql');
const manifest = join(root, 'prisma', 'migration-checksums.json');
const sql = 'SELECT 1;\n';
writeFileSync(file, sql);
writeFileSync(
manifest,
JSON.stringify({
[name]: createHash('sha256').update(sql).digest('hex'),
}),
);
const run = () =>
execFileSync(
process.execPath,
['scripts/check-migrations.mjs', '--root=' + root],
{ stdio: 'pipe' },
);
try {
expect(run().toString()).toContain('Verified 1');
writeFileSync(file, 'SELECT 2;\n');
expect(run).toThrow();
} finally {
unlinkSync(file);
unlinkSync(manifest);
rmdirSync(directory);
rmdirSync(join(root, 'prisma', 'migrations'));
rmdirSync(join(root, 'prisma'));
rmdirSync(root);
}
});
});