From eb976d2c8cb183841f415361ada6609ad1a9fb88 Mon Sep 17 00:00:00 2001 From: mihir Date: Thu, 10 Sep 2026 11:23:22 +0530 Subject: [PATCH] feat(data): add timestamped commerce migrations and history checks --- .gitignore | 1 + package.json | 6 +- prisma.config.ts | 2 +- prisma/addresses.prisma | 20 +++ prisma/catalog.prisma | 67 ++++++++++ prisma/inventory.prisma | 66 ++++++++++ prisma/migration-checksums.json | 9 ++ .../migration.sql | 122 ++++++++++++++++++ .../20260909141549_inventory/migration.sql | 98 ++++++++++++++ .../migration.sql | 27 ++++ .../migration.sql | 5 + prisma/schema.prisma | 6 + scripts/check-migrations.mjs | 42 ++++++ scripts/verify-migrations.mjs | 2 +- test/migration-history.spec.ts | 48 +++++++ 15 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 prisma/addresses.prisma create mode 100644 prisma/catalog.prisma create mode 100644 prisma/inventory.prisma create mode 100644 prisma/migration-checksums.json create mode 100644 prisma/migrations/20260909141447_catalog_addresses/migration.sql create mode 100644 prisma/migrations/20260909141549_inventory/migration.sql create mode 100644 prisma/migrations/20260909141622_commerce_integrity/migration.sql create mode 100644 prisma/migrations/20260909153911_inventory_actor_scope/migration.sql create mode 100644 scripts/check-migrations.mjs create mode 100644 test/migration-history.spec.ts diff --git a/.gitignore b/.gitignore index b3c88ba..229d0fc 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ coverage/ !.env.example src/generated/ *.log +.tmp/ diff --git a/package.json b/package.json index ff86e3b..98ad812 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma.config.ts b/prisma.config.ts index 74ba9ee..434c91d 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -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 }, }); diff --git a/prisma/addresses.prisma b/prisma/addresses.prisma new file mode 100644 index 0000000..97c2393 --- /dev/null +++ b/prisma/addresses.prisma @@ -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") +} + diff --git a/prisma/catalog.prisma b/prisma/catalog.prisma new file mode 100644 index 0000000..235a786 --- /dev/null +++ b/prisma/catalog.prisma @@ -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") +} + + diff --git a/prisma/inventory.prisma b/prisma/inventory.prisma new file mode 100644 index 0000000..58a53ac --- /dev/null +++ b/prisma/inventory.prisma @@ -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") +} + + diff --git a/prisma/migration-checksums.json b/prisma/migration-checksums.json new file mode 100644 index 0000000..587b350 --- /dev/null +++ b/prisma/migration-checksums.json @@ -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" +} diff --git a/prisma/migrations/20260909141447_catalog_addresses/migration.sql b/prisma/migrations/20260909141447_catalog_addresses/migration.sql new file mode 100644 index 0000000..a8885cb --- /dev/null +++ b/prisma/migrations/20260909141447_catalog_addresses/migration.sql @@ -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; diff --git a/prisma/migrations/20260909141549_inventory/migration.sql b/prisma/migrations/20260909141549_inventory/migration.sql new file mode 100644 index 0000000..96c4c59 --- /dev/null +++ b/prisma/migrations/20260909141549_inventory/migration.sql @@ -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; diff --git a/prisma/migrations/20260909141622_commerce_integrity/migration.sql b/prisma/migrations/20260909141622_commerce_integrity/migration.sql new file mode 100644 index 0000000..8e798d2 --- /dev/null +++ b/prisma/migrations/20260909141622_commerce_integrity/migration.sql @@ -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; + diff --git a/prisma/migrations/20260909153911_inventory_actor_scope/migration.sql b/prisma/migrations/20260909153911_inventory_actor_scope/migration.sql new file mode 100644 index 0000000..4baa5c2 --- /dev/null +++ b/prisma/migrations/20260909153911_inventory_actor_scope/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3bd0f72..d697f8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) diff --git a/scripts/check-migrations.mjs b/scripts/check-migrations.mjs new file mode 100644 index 0000000..73653c2 --- /dev/null +++ b/scripts/check-migrations.mjs @@ -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`); diff --git a/scripts/verify-migrations.mjs b/scripts/verify-migrations.mjs index e941259..e912311 100644 --- a/scripts/verify-migrations.mjs +++ b/scripts/verify-migrations.mjs @@ -10,7 +10,7 @@ const commands = [ 'diff', '--from-config-datasource', '--to-schema', - 'prisma/schema.prisma', + 'prisma', '--exit-code', ], ]; diff --git a/test/migration-history.spec.ts b/test/migration-history.spec.ts new file mode 100644 index 0000000..e68fdf0 --- /dev/null +++ b/test/migration-history.spec.ts @@ -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); + } + }); +});