chore: TypeORM 마이그레이션 셋업 + 초기 스키마 마이그레이션

운영(synchronize 비활성, migrationsRun 활성)에서 스키마를 구성할 마이그레이션이
전무해 신규 운영 DB 배포 시 테이블이 생성되지 않던 문제를 해결한다.

- data-source.ts(CLI 전용 DataSource) + package.json 마이그레이션 스크립트
  (typeorm/migration:generate/create/run/revert) 추가
- 초기 마이그레이션(CreateInitialSchema) 생성 — 전체 스키마(users·projects·
  project_members·tasks·task_assignees·checklist_items·comments·attachments·
  activities·notifications·refresh_sessions·ai_conversations·ai_messages,
  인덱스·FK 포함), uuid-ossp 확장 생성 보장
- 임시 빈 DB에 up/down 적용 검증, 현재 dev 스키마와 드리프트 없음(No changes) 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:54:15 +09:00
parent 9e29d0d0b0
commit 0d453fc426
3 changed files with 246 additions and 1 deletions
+19
View File
@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
// TypeORM CLI 전용 DataSource — 마이그레이션 생성/실행/되돌리기(generate/run/revert)에 사용한다.
// 런타임(앱)은 app.module 의 TypeOrmModule 설정을 사용하며, 운영에서는 migrationsRun=true 로
// 부팅 시 이 폴더(dist/migrations)의 마이그레이션을 자동 실행한다.
// DB 접속값은 환경변수에서 읽는다(dev/CI/운영에서 주입). app.module 과 동일한 키를 사용.
export default new DataSource({
type: 'postgres',
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
username: process.env.DB_USER || process.env.DB_USERNAME || 'postgres',
password: process.env.DB_PASSWORD || 'password',
database: process.env.DB_NAME || process.env.DB_DATABASE || 'project_db',
// CLI 는 절대 스키마 자동 동기화를 하지 않는다(마이그레이션만 사용)
synchronize: false,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'],
});
@@ -0,0 +1,221 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateInitialSchema1782060613058 implements MigrationInterface {
name = 'CreateInitialSchema1782060613058';
public async up(queryRunner: QueryRunner): Promise<void> {
// uuid_generate_v4() 기본값에 필요한 확장 — 신규(빈) 운영 DB 에서도 보장
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
await queryRunner.query(
`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "email" character varying NOT NULL, "password_hash" character varying, "provider" character varying NOT NULL DEFAULT 'local', "email_verified" boolean NOT NULL DEFAULT false, "email_verify_token_hash" character varying, "email_verify_expires_at" TIMESTAMP WITH TIME ZONE, "password_reset_token_hash" character varying, "password_reset_expires_at" TIMESTAMP WITH TIME ZONE, "name" character varying NOT NULL, "role" character varying, "avatar_url" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "project_members" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role_type" character varying NOT NULL DEFAULT 'member', "is_owner" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "projectId" uuid, "userId" uuid, CONSTRAINT "UQ_326b2a901eb18ac24eabc9b0581" UNIQUE ("projectId", "userId"), CONSTRAINT "PK_0b2f46f804be4aea9234c78bcc9" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "projects" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "description" character varying, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_6271df0a7aed1d6c0691ce6ac50" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "checklist_items" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" character varying NOT NULL, "category" character varying NOT NULL DEFAULT '필수', "done" boolean NOT NULL DEFAULT false, "review_note" text, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "taskId" uuid, CONSTRAINT "PK_bae00945a1d4789bd648e583e29" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "seq" integer NOT NULL, "title" character varying NOT NULL, "content" jsonb NOT NULL DEFAULT '[]', "status" character varying NOT NULL DEFAULT 'todo', "due_date" TIMESTAMP WITH TIME ZONE, "approval_note" text, "approval_requested_at" TIMESTAMP WITH TIME ZONE, "changes_note" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "issuerId" uuid, "approval_requested_by" uuid, CONSTRAINT "UQ_011566614ffa01ec7281ee29754" UNIQUE ("project_id", "seq"), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_73f95adb41a77c1bfd7e19dca7" ON "tasks" ("seq") `,
);
await queryRunner.query(
`CREATE TABLE "attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_5e1f050bcff31e3084a1d662412" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "comments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "text" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "author_id" uuid, "parent_id" uuid, "attachment_id" uuid, CONSTRAINT "PK_8bf68bc960f2b69e818bdb90dcb" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "notifications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "read" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "recipient_id" uuid, CONSTRAINT "PK_6a72c3c0f683f6462415e653c3a" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_92be92313f09987bc6ad70ab41" ON "notifications" ("recipient_id", "created_at") `,
);
await queryRunner.query(
`CREATE TABLE "refresh_sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "current_jti" uuid NOT NULL, "prev_jti" uuid, "prev_at" TIMESTAMP WITH TIME ZONE, "expires_at" TIMESTAMP WITH TIME ZONE NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_9190032f6967b7971dca07d69f3" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "ai_conversations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(120) NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "user_id" uuid NOT NULL, CONSTRAINT "PK_60db12765b82858ba00c8aa4ae2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "activities" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "task_seq" integer, "type" character varying NOT NULL, "payload" jsonb NOT NULL DEFAULT '{}', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "actor_id" uuid, CONSTRAINT "PK_7f4004429f731ffb9c88eb486a8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_7297db78eb572fa29db5aaf815" ON "activities" ("project_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_8291258c0c575bdab03443ef47" ON "activities" ("task_seq") `,
);
await queryRunner.query(
`CREATE TABLE "ai_messages" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "role" character varying NOT NULL, "content" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "conversation_id" uuid NOT NULL, CONSTRAINT "PK_a390434d4a515ba18a41bc996c2" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "task_assignees" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_7ae8012667c1cc4ca8266002afc" PRIMARY KEY ("task_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_0141288f2306f20da9a60ec8d6" ON "task_assignees" ("task_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_bb8051e376a2b083e074678cb6" ON "task_assignees" ("user_id") `,
);
await queryRunner.query(
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_d19892d8f03928e5bfc7313780c" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "project_members" ADD CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checklist_items" ADD CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578" FOREIGN KEY ("taskId") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_950b234a262e85a895b9f9e634e" FOREIGN KEY ("issuerId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd" FOREIGN KEY ("approval_requested_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_e62fd181b97caa6b150b09220b1" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "attachments" ADD CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_18c2493067c11f44efb35ca0e03" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_e6d38899c31997c45d128a8973b" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_d6f93329801a93536da4241e386" FOREIGN KEY ("parent_id") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "comments" ADD CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351" FOREIGN KEY ("attachment_id") REFERENCES "attachments"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "notifications" ADD CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d" FOREIGN KEY ("recipient_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "refresh_sessions" ADD CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" ADD CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "activities" ADD CONSTRAINT "FK_7297db78eb572fa29db5aaf8158" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "activities" ADD CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285" FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" ADD CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d" FOREIGN KEY ("conversation_id") REFERENCES "ai_conversations"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_0141288f2306f20da9a60ec8d69" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" ADD CONSTRAINT "FK_bb8051e376a2b083e074678cb60" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_bb8051e376a2b083e074678cb60"`,
);
await queryRunner.query(
`ALTER TABLE "task_assignees" DROP CONSTRAINT "FK_0141288f2306f20da9a60ec8d69"`,
);
await queryRunner.query(
`ALTER TABLE "ai_messages" DROP CONSTRAINT "FK_de21fcb2d1df7fd6ca70f555b6d"`,
);
await queryRunner.query(
`ALTER TABLE "activities" DROP CONSTRAINT "FK_78e06b2e7a3bd9b9239de4aa285"`,
);
await queryRunner.query(
`ALTER TABLE "activities" DROP CONSTRAINT "FK_7297db78eb572fa29db5aaf8158"`,
);
await queryRunner.query(
`ALTER TABLE "ai_conversations" DROP CONSTRAINT "FK_12fdbf99ca0da93085d61edd3bb"`,
);
await queryRunner.query(
`ALTER TABLE "refresh_sessions" DROP CONSTRAINT "FK_a7ab4fd82c654c85b9de53d971a"`,
);
await queryRunner.query(
`ALTER TABLE "notifications" DROP CONSTRAINT "FK_5332a4daa46fd3f4e6625dd275d"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_45d9ed1dd52f601ae9ec8034351"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_d6f93329801a93536da4241e386"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_e6d38899c31997c45d128a8973b"`,
);
await queryRunner.query(
`ALTER TABLE "comments" DROP CONSTRAINT "FK_18c2493067c11f44efb35ca0e03"`,
);
await queryRunner.query(
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_73407cf2d2a0e64546bacf309a7"`,
);
await queryRunner.query(
`ALTER TABLE "attachments" DROP CONSTRAINT "FK_e62fd181b97caa6b150b09220b1"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_60f1ed134a6425431bc66b0eefd"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_950b234a262e85a895b9f9e634e"`,
);
await queryRunner.query(
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9eecdb5b1ed8c7c2a1b392c28d4"`,
);
await queryRunner.query(
`ALTER TABLE "checklist_items" DROP CONSTRAINT "FK_1c0abaeeca0b213ddd07675c578"`,
);
await queryRunner.query(
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_08d1346ff91abba68e5a637cfdb"`,
);
await queryRunner.query(
`ALTER TABLE "project_members" DROP CONSTRAINT "FK_d19892d8f03928e5bfc7313780c"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_bb8051e376a2b083e074678cb6"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_0141288f2306f20da9a60ec8d6"`,
);
await queryRunner.query(`DROP TABLE "task_assignees"`);
await queryRunner.query(`DROP TABLE "ai_messages"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_8291258c0c575bdab03443ef47"`,
);
await queryRunner.query(
`DROP INDEX "public"."IDX_7297db78eb572fa29db5aaf815"`,
);
await queryRunner.query(`DROP TABLE "activities"`);
await queryRunner.query(`DROP TABLE "ai_conversations"`);
await queryRunner.query(`DROP TABLE "refresh_sessions"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_92be92313f09987bc6ad70ab41"`,
);
await queryRunner.query(`DROP TABLE "notifications"`);
await queryRunner.query(`DROP TABLE "comments"`);
await queryRunner.query(`DROP TABLE "attachments"`);
await queryRunner.query(
`DROP INDEX "public"."IDX_73f95adb41a77c1bfd7e19dca7"`,
);
await queryRunner.query(`DROP TABLE "tasks"`);
await queryRunner.query(`DROP TABLE "checklist_items"`);
await queryRunner.query(`DROP TABLE "projects"`);
await queryRunner.query(`DROP TABLE "project_members"`);
await queryRunner.query(`DROP TABLE "users"`);
}
}