From 60658568f7b495fb50f833f28dd299ac274cd885 Mon Sep 17 00:00:00 2001 From: "DESKTOP-1R2VSQH\\Lenovo ThinkPad E490" Date: Thu, 10 Sep 2026 16:25:14 +0700 Subject: [PATCH] =?UTF-8?q?Auto-migrate=20=E0=B8=95=E0=B8=AD=E0=B8=99=20co?= =?UTF-8?q?ntainer=20start=20(=E0=B9=81=E0=B8=97=E0=B8=99=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=20push=20=E0=B8=A1=E0=B8=B7=E0=B8=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/migrate.mjs: สร้าง database ถ้ายังไม่มี + apply ไฟล์ drizzle/*.sql ที่ยังไม่อยู่ในตาราง _migrations (ทีละไฟล์ใน transaction, แยก statement ด้วย --> statement-breakpoint) — เจอ schema ที่สร้างไว้ก่อนแล้ว (42P07/42710/42701) ถือว่า applied - web.Dockerfile: ฝัง drizzle/ + scripts/ ใน image, CMD รัน migrate ก่อน node server.js - .dockerignore: เอา drizzle ออก (ต้องขึ้น image) - commit ไฟล์ migration แรก drizzle/0000 (จาก drizzle-kit generate) ทดสอบบน PostgreSQL 17 แล้ว 3 เคส: สร้างใหม่ / รันซ้ำ / schema มีอยู่ก่อนแบบไม่มี _migrations (เคส server ปัจจุบัน) — ผ่านทั้งหมด Co-Authored-By: Claude Code --- .dockerignore | 1 - docker/web.Dockerfile | 6 +- drizzle/0000_narrow_quentin_quire.sql | 64 ++++ drizzle/meta/0000_snapshot.json | 468 ++++++++++++++++++++++++++ drizzle/meta/_journal.json | 13 + scripts/migrate.mjs | 71 ++++ 6 files changed, 621 insertions(+), 2 deletions(-) create mode 100644 drizzle/0000_narrow_quentin_quire.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 scripts/migrate.mjs diff --git a/.dockerignore b/.dockerignore index abd248a..cb088f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,6 @@ node_modules .git .forgejo .claude -drizzle dev-server.log npm-debug.log* .env diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 14116ca..26e00ba 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -31,7 +31,11 @@ RUN addgroup -S nodejs && adduser -S nextjs -G nodejs # แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +# migration: ไฟล์ SQL (drizzle-kit generate) + runner — รันตอน container start +COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle +COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts USER nextjs EXPOSE 3000 # DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image) -CMD ["node", "server.js"] +# migrate สร้าง database ถ้ายังไม่มี + apply ไฟล์ที่ยังไม่ได้ apply (idempotent) +CMD ["sh", "-c", "node scripts/migrate.mjs && exec node server.js"] diff --git a/drizzle/0000_narrow_quentin_quire.sql b/drizzle/0000_narrow_quentin_quire.sql new file mode 100644 index 0000000..eaebb75 --- /dev/null +++ b/drizzle/0000_narrow_quentin_quire.sql @@ -0,0 +1,64 @@ +CREATE TYPE "public"."annotation_type" AS ENUM('HIGHLIGHT', 'DRAW', 'COMMENT', 'SHAPE', 'REDACTION', 'TEXTBOX', 'STAMP');--> statement-breakpoint +CREATE TABLE "annotation_replies" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "annotation_id" uuid NOT NULL, + "user_id" text NOT NULL, + "user_name" text NOT NULL, + "message" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "annotation_versions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "annotation_id" uuid NOT NULL, + "file_id" uuid NOT NULL, + "page_index" integer NOT NULL, + "user_id" text NOT NULL, + "user_name" text NOT NULL, + "type" "annotation_type" NOT NULL, + "data" jsonb NOT NULL, + "kind" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "annotations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "file_id" uuid NOT NULL, + "page_index" integer NOT NULL, + "user_id" text NOT NULL, + "user_name" text NOT NULL, + "type" "annotation_type" NOT NULL, + "data" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "resolved" boolean DEFAULT false NOT NULL, + "resolved_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "bookmarks" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "file_id" uuid NOT NULL, + "page_index" integer NOT NULL, + "user_id" text NOT NULL, + "title" text NOT NULL, + "parent_id" uuid, + "note" text, + "y_offset" double precision NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "files" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "title" text NOT NULL, + "description" text, + "pdf_url" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "annotation_replies" ADD CONSTRAINT "annotation_replies_annotation_id_annotations_id_fk" FOREIGN KEY ("annotation_id") REFERENCES "public"."annotations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "annotation_versions" ADD CONSTRAINT "annotation_versions_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "annotations" ADD CONSTRAINT "annotations_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "bookmarks" ADD CONSTRAINT "bookmarks_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "annotation_replies_annotation_idx" ON "annotation_replies" USING btree ("annotation_id");--> statement-breakpoint +CREATE INDEX "annotation_versions_file_idx" ON "annotation_versions" USING btree ("file_id");--> statement-breakpoint +CREATE INDEX "annotations_file_page_idx" ON "annotations" USING btree ("file_id","page_index"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..f9ebfbb --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,468 @@ +{ + "id": "010b9cd2-5118-42d5-94c2-fdbfc3227018", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.annotation_replies": { + "name": "annotation_replies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_replies_annotation_idx": { + "name": "annotation_replies_annotation_idx", + "columns": [ + { + "expression": "annotation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_replies_annotation_id_annotations_id_fk": { + "name": "annotation_replies_annotation_id_annotations_id_fk", + "tableFrom": "annotation_replies", + "tableTo": "annotations", + "columnsFrom": [ + "annotation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotation_versions": { + "name": "annotation_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "annotation_id": { + "name": "annotation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "annotation_versions_file_idx": { + "name": "annotation_versions_file_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotation_versions_file_id_files_id_fk": { + "name": "annotation_versions_file_id_files_id_fk", + "tableFrom": "annotation_versions", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.annotations": { + "name": "annotations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "annotation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved": { + "name": "resolved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "annotations_file_page_idx": { + "name": "annotations_file_page_idx", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "annotations_file_id_files_id_fk": { + "name": "annotations_file_id_files_id_fk", + "tableFrom": "annotations", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bookmarks": { + "name": "bookmarks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_index": { + "name": "page_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "y_offset": { + "name": "y_offset", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_file_id_files_id_fk": { + "name": "bookmarks_file_id_files_id_fk", + "tableFrom": "bookmarks", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pdf_url": { + "name": "pdf_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.annotation_type": { + "name": "annotation_type", + "schema": "public", + "values": [ + "HIGHLIGHT", + "DRAW", + "COMMENT", + "SHAPE", + "REDACTION", + "TEXTBOX", + "STAMP" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..548fbe1 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1789031603082, + "tag": "0000_narrow_quentin_quire", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..f6242ed --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,71 @@ +// Migration runner สำหรับ container start (docker/web.Dockerfile) +// - สร้าง database จาก DATABASE_URL ถ้ายังไม่มี (เชื่อมผ่าน db "postgres" ก่อน) +// - apply ไฟล์ SQL ใน drizzle/*.sql ที่ยังไม่อยู่ในตาราง _migrations (ทีละไฟล์ใน transaction) +// ไฟล์ .sql มาจาก `npx drizzle-kit generate` — แยก statement ด้วย --> statement-breakpoint +import fs from "node:fs"; +import path from "node:path"; +import postgres from "postgres"; + +const MIGRATIONS_DIR = path.join(process.cwd(), "drizzle"); +// 42P07 duplicate table/index · 42710 duplicate object (type/constraint) · 42701 duplicate column +const ALREADY_EXISTS = new Set(["42P07", "42710", "42701"]); + +const dbUrl = process.env.DATABASE_URL; +if (!dbUrl) throw new Error("ต้องตั้ง DATABASE_URL ก่อนรัน migrate"); +const dbName = new URL(dbUrl).pathname.replace(/^\//, ""); +if (!/^[a-zA-Z0-9_-]+$/.test(dbName)) throw new Error(`ชื่อ database ใช้ไม่ได้: ${dbName}`); + +async function ensureDatabase() { + const adminUrl = new URL(dbUrl); + adminUrl.pathname = "/postgres"; + const sql = postgres(adminUrl.toString(), { prepare: false, max: 1 }); + try { + const found = await sql`select 1 from pg_database where datname = ${dbName}`; + if (found.length === 0) { + console.log(`[migrate] สร้าง database "${dbName}"`); + await sql.unsafe(`CREATE DATABASE "${dbName}"`); + } else { + console.log(`[migrate] database "${dbName}" มีอยู่แล้ว`); + } + } finally { + await sql.end(); + } +} + +async function migrate() { + const sql = postgres(dbUrl, { prepare: false, max: 1 }); + try { + await sql`create table if not exists "_migrations" ("name" text primary key, "applied_at" timestamptz not null default now())`; + const applied = new Set((await sql`select name from "_migrations"`).map((r) => r.name)); + const files = fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql")).sort(); + for (const file of files) { + if (applied.has(file)) continue; + const statements = fs + .readFileSync(path.join(MIGRATIONS_DIR, file), "utf8") + .split("--> statement-breakpoint") + .map((s) => s.trim()) + .filter(Boolean); + console.log(`[migrate] apply ${file} (${statements.length} statements)`); + try { + await sql.begin(async (tx) => { + for (const stmt of statements) await tx.unsafe(stmt); + await tx`insert into "_migrations" ("name") values (${file})`; + }); + } catch (err) { + if (ALREADY_EXISTS.has(err.code)) { + // schema ถูกสร้างไปแล้วด้วยวิธีอื่น (เช่น มือ) — ถือว่าไฟล์นี้ applied แล้ว + console.warn(`[migrate] ${file}: มีอยู่แล้ว (สร้างไว้ก่อนหน้า) — บันทึกเป็น applied`); + await sql`insert into "_migrations" ("name") values (${file}) on conflict do nothing`; + } else { + throw err; + } + } + } + console.log("[migrate] schema พร้อมแล้ว"); + } finally { + await sql.end(); + } +} + +await ensureDatabase(); +await migrate();