From 4c03784c6861c13848b35db6e970decdecbc1dcd Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 20 Aug 2026 19:57:22 -0400 Subject: [PATCH] chore(db): add migration runner and schema baseline --- reset-seq.sql | 19 ++ src/migrations/20260818_024510.ts | 68 +----- src/migrations/20260820_004157.ts | 345 ++++++++++++++++++++++------- src/scripts/run-migrations.ts | 348 ++++++++++++++++++++++++++++++ 4 files changed, 640 insertions(+), 140 deletions(-) create mode 100644 reset-seq.sql create mode 100644 src/scripts/run-migrations.ts diff --git a/reset-seq.sql b/reset-seq.sql new file mode 100644 index 0000000..6638e13 --- /dev/null +++ b/reset-seq.sql @@ -0,0 +1,19 @@ +SELECT + 'SELECT SETVAL(' || + quote_literal(quote_ident(sequence_namespace.nspname) || '.' || quote_ident(class_sequence.relname)) || + ', COALESCE(MAX(' ||quote_ident(pg_attribute.attname)|| '), 1) ) FROM ' || + quote_ident(table_namespace.nspname)|| '.'||quote_ident(class_table.relname)|| ';' +FROM pg_depend + INNER JOIN pg_class AS class_sequence + ON class_sequence.oid = pg_depend.objid + AND class_sequence.relkind = 'S' + INNER JOIN pg_class AS class_table + ON class_table.oid = pg_depend.refobjid + INNER JOIN pg_attribute + ON pg_attribute.attrelid = class_table.oid + AND pg_depend.refobjsubid = pg_attribute.attnum + INNER JOIN pg_namespace as table_namespace + ON table_namespace.oid = class_table.relnamespace + INNER JOIN pg_namespace AS sequence_namespace + ON sequence_namespace.oid = class_sequence.relnamespace +ORDER BY sequence_namespace.nspname, class_sequence.relname; \ No newline at end of file diff --git a/src/migrations/20260818_024510.ts b/src/migrations/20260818_024510.ts index b39ca07..9ac6d00 100644 --- a/src/migrations/20260818_024510.ts +++ b/src/migrations/20260818_024510.ts @@ -1,65 +1,9 @@ import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' -export async function up({ db, payload, req }: MigrateUpArgs): Promise { - await db.execute(sql` - CREATE TYPE "public"."enum_plugin_ai_instructions_field_type" AS ENUM('text', 'textarea', 'upload', 'richText'); - CREATE TYPE "public"."enum_plugin_ai_instructions_model_id" AS ENUM('unsloth-text'); - CREATE TABLE "plugin_ai_instructions_images" ( - "_order" integer NOT NULL, - "_parent_id" integer NOT NULL, - "id" varchar PRIMARY KEY NOT NULL, - "image_id" integer - ); - - CREATE TABLE "plugin_ai_instructions" ( - "id" serial PRIMARY KEY NOT NULL, - "schema_path" varchar, - "field_type" "enum_plugin_ai_instructions_field_type" DEFAULT 'text', - "relation_to" varchar, - "model_id" "enum_plugin_ai_instructions_model_id", - "disabled" boolean DEFAULT false, - "prompt" varchar, - "system" varchar DEFAULT 'INSTRUCTIONS: - You are a highly skilled and professional blog writer, - renowned for crafting engaging and well-organized articles. - When given a title, you meticulously create blogs that are not only - informative and accurate but also captivating and beautifully structured.', - "layout" varchar DEFAULT '[paragraph] - Write a concise introduction (2-3 sentences) that outlines the main topic. - [horizontalrule] - Insert a horizontal rule to separate the introduction from the main content. - [list] - Create a list with 3-5 items. Each list item should contain: - a. [heading] - A brief, descriptive heading (up to 5 words) - b. [paragraph] - A short explanation or elaboration (1-2 sentences) - [horizontalrule] - Insert another horizontal rule to separate the main content from the conclusion. - [paragraph] - Compose a brief conclusion (2-3 sentences) summarizing the key points. - [quote] - Include a relevant quote from a famous person, directly related to the topic. Format: "Quote text." - Author Name', - "unsloth_text_settings_model" varchar DEFAULT 'unsloth/gemma-4-E4B-it-qat-GGUF', - "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, - "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL - ); - - ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "plugin_ai_instructions_id" integer; - ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_image_id_media_id_fk" FOREIGN KEY ("image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action; - ALTER TABLE "plugin_ai_instructions_images" ADD CONSTRAINT "plugin_ai_instructions_images_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action; - CREATE INDEX "plugin_ai_instructions_images_order_idx" ON "plugin_ai_instructions_images" USING btree ("_order"); - CREATE INDEX "plugin_ai_instructions_images_parent_id_idx" ON "plugin_ai_instructions_images" USING btree ("_parent_id"); - CREATE INDEX "plugin_ai_instructions_images_image_idx" ON "plugin_ai_instructions_images" USING btree ("image_id"); - CREATE UNIQUE INDEX "plugin_ai_instructions_schema_path_idx" ON "plugin_ai_instructions" USING btree ("schema_path"); - CREATE INDEX "plugin_ai_instructions_updated_at_idx" ON "plugin_ai_instructions" USING btree ("updated_at"); - CREATE INDEX "plugin_ai_instructions_created_at_idx" ON "plugin_ai_instructions" USING btree ("created_at"); - ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk" FOREIGN KEY ("plugin_ai_instructions_id") REFERENCES "public"."plugin_ai_instructions"("id") ON DELETE cascade ON UPDATE no action; - CREATE INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx" ON "payload_locked_documents_rels" USING btree ("plugin_ai_instructions_id");`) -} +// No-op migration: the schema this originally created was already applied by the +// archived migration chain (20251206–20260817) that was tracked in payload_migrations +// before the consolidation. Payload needs to see this name registered so it marks +// the migration as "ran" — but no DDL should execute. +export async function up({ db, payload, req }: MigrateUpArgs): Promise {} -export async function down({ db, payload, req }: MigrateDownArgs): Promise { - await db.execute(sql` - ALTER TABLE "plugin_ai_instructions_images" DISABLE ROW LEVEL SECURITY; - ALTER TABLE "plugin_ai_instructions" DISABLE ROW LEVEL SECURITY; - DROP TABLE "plugin_ai_instructions_images" CASCADE; - DROP TABLE "plugin_ai_instructions" CASCADE; - ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_plugin_ai_instructions_fk"; - - DROP INDEX "payload_locked_documents_rels_plugin_ai_instructions_id_idx"; - ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "plugin_ai_instructions_id"; - DROP TYPE "public"."enum_plugin_ai_instructions_field_type"; - DROP TYPE "public"."enum_plugin_ai_instructions_model_id";`) -} +export async function down({ db, payload, req }: MigrateDownArgs): Promise {} diff --git a/src/migrations/20260820_004157.ts b/src/migrations/20260820_004157.ts index 16f2b51..850d8c5 100644 --- a/src/migrations/20260820_004157.ts +++ b/src/migrations/20260820_004157.ts @@ -1,87 +1,276 @@ import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' +// Idempotent migration: safe to run on databases where push: true already applied +// the schema (dev), and on fresh production databases that need these objects. +// Each DDL block is wrapped in exception handlers to skip already-existing objects. export async function up({ db, payload, req }: MigrateUpArgs): Promise { + // --- Enums --- await db.execute(sql` - CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual'); - CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce'); - CREATE TABLE "users_rels" ( - "id" serial PRIMARY KEY NOT NULL, - "order" integer, - "parent_id" integer NOT NULL, - "path" varchar NOT NULL, - "roles_id" integer - ); - - CREATE TABLE "roles_permissions" ( - "order" integer NOT NULL, - "parent_id" integer NOT NULL, - "value" "enum_roles_permissions", - "id" serial PRIMARY KEY NOT NULL - ); - - CREATE TABLE "roles" ( - "id" serial PRIMARY KEY NOT NULL, - "name" varchar NOT NULL, - "slug" varchar NOT NULL, - "description" varchar, - "is_system" boolean DEFAULT false, - "is_superuser" boolean DEFAULT false, - "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, - "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL - ); - - CREATE TABLE "roles_rels" ( - "id" serial PRIMARY KEY NOT NULL, - "order" integer, - "parent_id" integer NOT NULL, - "path" varchar NOT NULL, - "roles_id" integer - ); - - ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead'; - ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true; - ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone NOT NULL; - ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer; - ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; - ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; - ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; - ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; - ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; - CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order"); - CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id"); - CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path"); - CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id"); - CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order"); - CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id"); - CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name"); - CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug"); - CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at"); - CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at"); - CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order"); - CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id"); - CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path"); - CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id"); - ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; - CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id");`) + DO $$ BEGIN + CREATE TYPE "public"."enum_users_preferences_display_lead_or_actual" AS ENUM('lead', 'actual'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TYPE "public"."enum_roles_permissions" AS ENUM('system:admin-access', 'users:create', 'users:read', 'users:update', 'users:delete', 'users:unlock', 'users:assign-roles', 'ranks:create', 'ranks:read', 'ranks:update', 'ranks:delete', 'profiles:create', 'profiles:read', 'profiles:update', 'profiles:delete', 'awards:create', 'awards:read', 'awards:update', 'awards:delete', 'qualifications:create', 'qualifications:read', 'qualifications:update', 'qualifications:delete', 'assignments:create', 'assignments:read', 'assignments:update', 'assignments:delete', 'experience:create', 'experience:read', 'experience:update', 'experience:delete', 'user-notifications:create', 'user-notifications:read', 'user-notifications:update', 'user-notifications:delete', 'missions:create', 'missions:read', 'missions:update', 'missions:delete', 'missions:read-sensitive', 'mission-attendances:create', 'mission-attendances:read', 'mission-attendances:update', 'mission-attendances:delete', 'campaigns:create', 'campaigns:read', 'campaigns:update', 'campaigns:delete', 'factions:create', 'factions:read', 'factions:update', 'factions:delete', 'technologies:create', 'technologies:read', 'technologies:update', 'technologies:delete', 'assets:create', 'assets:read', 'assets:update', 'assets:delete', 'resources:create', 'resources:read', 'resources:update', 'resources:delete', 'vehicles:create', 'vehicles:read', 'vehicles:update', 'vehicles:delete', 'structures:create', 'structures:read', 'structures:update', 'structures:delete', 'shipments:create', 'shipments:read', 'shipments:update', 'shipments:delete', 'bank-accounts:create', 'bank-accounts:read', 'bank-accounts:update', 'bank-accounts:delete', 'bank-transactions:create', 'bank-transactions:read', 'bank-transactions:update', 'bank-transactions:delete', 'ledger-entries:create', 'ledger-entries:read', 'ledger-entries:update', 'ledger-entries:delete', 'locker-storages:create', 'locker-storages:read', 'locker-storages:update', 'locker-storages:delete', 'loadouts:create', 'loadouts:read', 'loadouts:update', 'loadouts:delete', 'market-listings:create', 'market-listings:read', 'market-listings:update', 'market-listings:delete', 'market-negotiations:create', 'market-negotiations:read', 'market-negotiations:update', 'market-negotiations:delete', 'tickets:create', 'tickets:read', 'tickets:update', 'tickets:delete', 'game-structures:create', 'game-structures:read', 'game-structures:update', 'game-structures:delete', 'game-vehicles:create', 'game-vehicles:read', 'game-vehicles:update', 'game-vehicles:delete', 'game-npcs:create', 'game-npcs:read', 'game-npcs:update', 'game-npcs:delete', 'game-hard-resources:create', 'game-hard-resources:read', 'game-hard-resources:update', 'game-hard-resources:delete', 'game-event-logs:create', 'game-event-logs:read', 'game-event-logs:update', 'game-event-logs:delete', 'maps:create', 'maps:read', 'maps:update', 'maps:delete', 'narrative-events:create', 'narrative-events:read', 'narrative-events:update', 'narrative-events:delete', 'mission-files:create', 'mission-files:read', 'mission-files:update', 'mission-files:delete', 'mod-lists:create', 'mod-lists:read', 'mod-lists:update', 'mod-lists:delete', 'game-rules:read', 'game-rules:update', 'shims:read', 'shims:update', 'logistics:manage', 'intelligence:manage', 'profiles:intel_excerpt:update', 'banking:manage', 'tickets:staff', 'discord:staff', 'discord:announce'); + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + // --- Tables --- + await db.execute(sql` + DO $$ BEGIN + CREATE TABLE "users_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "roles_id" integer + ); + EXCEPTION WHEN duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TABLE "roles_permissions" ( + "order" integer NOT NULL, + "parent_id" integer NOT NULL, + "value" "enum_roles_permissions", + "id" serial PRIMARY KEY NOT NULL + ); + EXCEPTION WHEN duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TABLE "roles" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "slug" varchar NOT NULL, + "description" varchar, + "is_system" boolean DEFAULT false, + "is_superuser" boolean DEFAULT false, + "updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL, + "created_at" timestamp(3) with time zone DEFAULT now() NOT NULL + ); + EXCEPTION WHEN duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE TABLE "roles_rels" ( + "id" serial PRIMARY KEY NOT NULL, + "order" integer, + "parent_id" integer NOT NULL, + "path" varchar NOT NULL, + "roles_id" integer + ); + EXCEPTION WHEN duplicate_table THEN null; + END $$; + `) + + // --- Columns (ADD COLUMN with DEFAULT for NOT NULL columns to handle existing rows) --- + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "users" ADD COLUMN "preferences_display_lead_or_actual" "enum_users_preferences_display_lead_or_actual" DEFAULT 'lead'; + EXCEPTION WHEN duplicate_column THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "users" ADD COLUMN "preferences_display_show_version_overlay" boolean DEFAULT true; + EXCEPTION WHEN duplicate_column THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "profiles" ADD COLUMN "dossier_enlistment_date" timestamp(3) with time zone DEFAULT now() NOT NULL; + EXCEPTION WHEN duplicate_column THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "roles_id" integer; + EXCEPTION WHEN duplicate_column THEN null; + END $$; + `) + + // --- Foreign keys --- + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "users_rels" ADD CONSTRAINT "users_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "roles_permissions" ADD CONSTRAINT "roles_permissions_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "roles_rels" ADD CONSTRAINT "roles_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; + EXCEPTION WHEN duplicate_object THEN null; + END $$; + `) + + // --- Indexes (catch duplicate_table OR duplicate_object — PostgreSQL uses 42P07 for indexes) --- + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "users_rels_order_idx" ON "users_rels" USING btree ("order"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "users_rels_parent_idx" ON "users_rels" USING btree ("parent_id"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "users_rels_path_idx" ON "users_rels" USING btree ("path"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "users_rels_roles_id_idx" ON "users_rels" USING btree ("roles_id"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_permissions_order_idx" ON "roles_permissions" USING btree ("order"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_permissions_parent_idx" ON "roles_permissions" USING btree ("parent_id"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE UNIQUE INDEX "roles_name_idx" ON "roles" USING btree ("name"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE UNIQUE INDEX "roles_slug_idx" ON "roles" USING btree ("slug"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_updated_at_idx" ON "roles" USING btree ("updated_at"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_created_at_idx" ON "roles" USING btree ("created_at"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_rels_order_idx" ON "roles_rels" USING btree ("order"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_rels_parent_idx" ON "roles_rels" USING btree ("parent_id"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_rels_path_idx" ON "roles_rels" USING btree ("path"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "roles_rels_roles_id_idx" ON "roles_rels" USING btree ("roles_id"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_roles_fk" FOREIGN KEY ("roles_id") REFERENCES "public"."roles"("id") ON DELETE cascade ON UPDATE no action; + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) + + await db.execute(sql` + DO $$ BEGIN + CREATE INDEX "payload_locked_documents_rels_roles_id_idx" ON "payload_locked_documents_rels" USING btree ("roles_id"); + EXCEPTION WHEN duplicate_object OR duplicate_table THEN null; + END $$; + `) } export async function down({ db, payload, req }: MigrateDownArgs): Promise { await db.execute(sql` - ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY; - ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY; - ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY; - ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY; - DROP TABLE "users_rels" CASCADE; - DROP TABLE "roles_permissions" CASCADE; - DROP TABLE "roles" CASCADE; - DROP TABLE "roles_rels" CASCADE; - ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk"; - - DROP INDEX "payload_locked_documents_rels_roles_id_idx"; - ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual"; - ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay"; - ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date"; - ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id"; - DROP TYPE "public"."enum_users_preferences_display_lead_or_actual"; - DROP TYPE "public"."enum_roles_permissions";`) + ALTER TABLE "users_rels" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "roles_permissions" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "roles" DISABLE ROW LEVEL SECURITY; + ALTER TABLE "roles_rels" DISABLE ROW LEVEL SECURITY; + DROP TABLE "users_rels" CASCADE; + DROP TABLE "roles_permissions" CASCADE; + DROP TABLE "roles" CASCADE; + DROP TABLE "roles_rels" CASCADE; + ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_roles_fk"; + + DROP INDEX "payload_locked_documents_rels_roles_id_idx"; + ALTER TABLE "users" DROP COLUMN "preferences_display_lead_or_actual"; + ALTER TABLE "users" DROP COLUMN "preferences_display_show_version_overlay"; + ALTER TABLE "profiles" DROP COLUMN "dossier_enlistment_date"; + ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "roles_id"; + DROP TYPE "public"."enum_users_preferences_display_lead_or_actual"; + DROP TYPE "public"."enum_roles_permissions"; + `) } diff --git a/src/scripts/run-migrations.ts b/src/scripts/run-migrations.ts new file mode 100644 index 0000000..3202a9a --- /dev/null +++ b/src/scripts/run-migrations.ts @@ -0,0 +1,348 @@ +#!/usr/bin/env bun +/** + * Interactive migration runner — safely run Payload migrations against any + * environment database (dev / stg / prd) from your local machine. + * + * Usage: + * bun run migrate # interactive menu + * bun run migrate dev # skip menu, target dev + * bun run migrate stg # skip menu, target staging + * bun run migrate prd # skip menu, target production + * bun run migrate --status # show migration status only (no changes) + * bun run migrate prd --status # status for a specific env + * bun run migrate --fresh # drop + recreate (dev only!) + * + * Environment files: + * .env — dev (DATABASE_URI for local PostgreSQL) + * .env.stg — staging (DATABASE_URI for staging PostgreSQL) + * .env.prd — production (DATABASE_URI for production PostgreSQL) + * + * These files are gitignored. Copy the DATABASE_URI from Coolify and paste + * into the appropriate file. Template: + * DATABASE_URI=postgres://user:password@host:5432/dbname + */ +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; + +// ── Config ────────────────────────────────────────────────────────────── + +type EnvName = "dev" | "stg" | "prd"; + +interface EnvConfig { + label: string; + file: string; + color: string; +} + +const ENVS: Record = { + dev: { + label: "Development", + file: ".env", + color: "\x1b[36m", + }, + stg: { + label: "Staging", + file: ".env.stg", + color: "\x1b[33m", + }, + prd: { + label: "Production", + file: ".env.prd", + color: "\x1b[31m", + }, +}; + +const ENV_ORDER: EnvName[] = ["dev", "stg", "prd"]; + +// ── ANSI helpers ──────────────────────────────────────────────────────── + +const RESET = "\x1b[0m"; +const BOLD = "\x1b[1m"; +const DIM = "\x1b[2m"; +const RED = "\x1b[31m"; +const GREEN = "\x1b[32m"; +const YELLOW = "\x1b[33m"; + +function log(msg: string) { + console.log(msg); +} + +// ── stdin abstraction (handles both TTY and piped) ───────────────────── + +const inputLines: string[] = []; +let inputReady: Promise; + +function initInput(): Promise { + if (!process.stdin.isTTY) { + // Piped input: buffer everything up front so readline never closes early + inputReady = new Promise((resolve) => { + let buf = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk: string) => { + buf += chunk; + }); + process.stdin.on("end", () => { + for (const line of buf.split("\n")) { + const trimmed = line.trim(); + if (trimmed) inputLines.push(trimmed); + } + resolve(); + }); + }); + return inputReady; + } + inputReady = Promise.resolve(); + return inputReady; +} + +async function ask(question: string): Promise { + if (!process.stdin.isTTY) { + // Consume from buffer + if (inputLines.length === 0) return ""; + const line = inputLines.shift()!; + process.stdout.write(`${question}${line}\n`); + return line; + } + // Interactive: use readline for proper prompt + const { createInterface } = await import("node:readline"); + return new Promise((resolve) => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.question(question, (answer) => { + rl.close(); + resolve(answer.trim()); + }); + }); +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +function redactUri(uri: string): string { + return uri.replace(/(\/\/[^:]+:)[^@]+(@)/, "$1****$2"); +} + +function parseEnvFile(filePath: string): Record { + const raw = readFileSync(filePath, "utf-8"); + const vars: Record = {}; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eqIdx = trimmed.indexOf("="); + if (eqIdx === -1) continue; + const key = trimmed.slice(0, eqIdx).trim(); + const val = trimmed.slice(eqIdx + 1).trim(); + vars[key] = val.replace(/^["']|["']$/g, ""); + } + return vars; +} + +function loadDatabaseUri(envName: EnvName): string | null { + const config = ENVS[envName]; + const filePath = path.resolve(process.cwd(), config.file); + if (!existsSync(filePath)) return null; + const vars = parseEnvFile(filePath); + return vars.DATABASE_URI ?? null; +} + +function run( + cmd: string, + args: string[], + envOverrides: Record, + stdinData?: string, +): Promise<{ code: number; stdout: string; stderr: string }> { + return new Promise((resolve) => { + const child = spawn(cmd, args, { + env: { ...process.env, ...envOverrides }, + stdio: [stdinData != null ? "pipe" : "ignore", "pipe", "pipe"], + }); + if (stdinData != null && child.stdin) { + child.stdin.write(stdinData); + child.stdin.end(); + } + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (d: Buffer) => { + const s = d.toString(); + stdout += s; + process.stdout.write(s); + }); + child.stderr?.on("data", (d: Buffer) => { + const s = d.toString(); + stderr += s; + process.stderr.write(s); + }); + child.on("close", (code) => + resolve({ code: code ?? 1, stdout, stderr }), + ); + }); +} + +// ── Menu ──────────────────────────────────────────────────────────────── + +async function pickEnv(forced?: string): Promise<{ env: EnvName; uri: string }> { + if (forced && forced in ENVS) { + const uri = loadDatabaseUri(forced as EnvName); + if (!uri) { + log( + `${RED}Cannot find DATABASE_URI in ${ENVS[forced as EnvName].file}${RESET}`, + ); + log(`Create the file with: DATABASE_URI=postgres://user:pass@host:5432/db`); + process.exit(1); + } + return { env: forced as EnvName, uri }; + } + + log(`\n${BOLD}⚡ Polaris Task Force — Migration Runner${RESET}\n`); + log("Target environment:\n"); + + for (let i = 0; i < ENV_ORDER.length; i++) { + const key = ENV_ORDER[i]; + const cfg = ENVS[key]; + const exists = loadDatabaseUri(key) !== null; + const status = exists ? `${GREEN}●${RESET}` : `${DIM}○ missing${RESET}`; + log( + ` ${BOLD}${i + 1}${RESET}) ${cfg.color}${cfg.label}${RESET} ${DIM}(${cfg.file})${RESET} ${status}`, + ); + } + + log(""); + const answer = (await ask(` Select (1-${ENV_ORDER.length}): `)).trim(); + const idx = parseInt(answer, 10) - 1; + if (isNaN(idx) || idx < 0 || idx >= ENV_ORDER.length) { + log(`${RED}Invalid selection.${RESET}`); + process.exit(1); + } + + const env = ENV_ORDER[idx]; + const uri = loadDatabaseUri(env); + if (!uri) { + log(`\n${RED}DATABASE_URI not found in ${ENVS[env].file}${RESET}`); + log( + `Create the file with: ${BOLD}DATABASE_URI=postgres://user:pass@host:5432/db${RESET}`, + ); + process.exit(1); + } + + return { env, uri }; +} + +// ── Main ──────────────────────────────────────────────────────────────── + +async function main() { + await initInput(); + + const args = process.argv.slice(2); + const statusOnly = args.includes("--status") || args.includes("-s"); + const freshOnly = args.includes("--fresh"); + const positionalArgs = args.filter((a) => !a.startsWith("-")); + + const forced = positionalArgs[0]; + const { env, uri } = await pickEnv(forced); + const cfg = ENVS[env]; + const redacted = redactUri(uri); + + // Header + log(`\n${cfg.color}${BOLD}━━━ ${cfg.label.toUpperCase()} ━━━${RESET}`); + log(`${DIM} ${redacted}${RESET}\n`); + + if (statusOnly) { + log(`${DIM}Migration status:${RESET}\n`); + await run("bun", ["run", "payload", "migrate:status"], { + DATABASE_URI: uri, + }); + return; + } + + if (freshOnly) { + if (env !== "dev") { + log(`${RED}${BOLD}--fresh is only allowed for dev.${RESET}`); + log( + `${DIM}For staging/production, use migrate (up only) or migrate:fresh manually.${RESET}`, + ); + process.exit(1); + } + const confirm = await ask( + `${YELLOW}This will DROP and recreate all tables on ${cfg.label}. Continue? (y/N): ${RESET}`, + ); + if (confirm.toLowerCase() !== "y") { + log("Aborted."); + return; + } + log(""); + await run("bun", ["run", "payload", "migrate:fresh"], { + DATABASE_URI: uri, + }); + log(`\n${GREEN}${BOLD}✓ Fresh migration complete.${RESET}`); + return; + } + + // Show current status + log(`${DIM}Migration status:${RESET}\n`); + const statusResult = await run( + "bun", + ["run", "payload", "migrate:status"], + { DATABASE_URI: uri }, + ); + + // Check for pending migrations + const hasPending = statusResult.stdout.includes("│ No │"); + if (!hasPending && !statusResult.stderr) { + log(`\n${GREEN}All migrations applied. Nothing to do.${RESET}`); + return; + } + + // Confirm + log(""); + if (env === "prd") { + log( + `${RED}${BOLD} ╔══════════════════════════════════════════╗${RESET}`, + ); + log( + `${RED}${BOLD} ║ YOU ARE ABOUT TO MIGRATE PRODUCTION ║${RESET}`, + ); + log( + `${RED}${BOLD} ╚══════════════════════════════════════════╝${RESET}\n`, + ); + const confirm = await ask( + `${RED}Type "migrate" to confirm: ${RESET}`, + ); + if (confirm.trim() !== "migrate") { + log("Aborted."); + return; + } + } else { + const confirm = await ask( + `${YELLOW}Run migrations on ${cfg.label}? (y/N): ${RESET}`, + ); + if (confirm.toLowerCase() !== "y") { + log("Aborted."); + return; + } + } + + // Run migration — pipe "y\n" so Payload's dev-mode prompt (triggered by + // push:true's batch=-1 record) is auto-accepted instead of blocking on + // non-interactive stdin. + log(`\n${BOLD}Running migrations...${RESET}\n`); + const result = await run( + "bun", + ["run", "payload", "migrate"], + { DATABASE_URI: uri }, + "y\n", + ); + + if (result.code !== 0) { + log(`\n${RED}${BOLD}✗ Migration failed (exit ${result.code})${RESET}`); + process.exit(result.code); + } + + log(`\n${GREEN}${BOLD}✓ Migrations applied successfully.${RESET}`); +} + +main().catch((err) => { + console.error(`${RED}Fatal error:${RESET}`, err); + process.exit(1); +});