Auto-migrate ตอน container start (แทนการ push มือ)
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 44s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 3s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s

- 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 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-1R2VSQH\Lenovo ThinkPad E490 2026-09-10 16:25:14 +07:00
parent f6f4ed9129
commit 60658568f7
6 changed files with 621 additions and 2 deletions

View file

@ -3,7 +3,6 @@ node_modules
.git .git
.forgejo .forgejo
.claude .claude
drizzle
dev-server.log dev-server.log
npm-debug.log* npm-debug.log*
.env .env

View file

@ -31,7 +31,11 @@ RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
# แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS # แต่ไม่รวม .next/static (JS chunks) — ต้องคัดลอกเพิ่ม ไม่งั้นหน้าเว็บไม่มี JS
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 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 USER nextjs
EXPOSE 3000 EXPOSE 3000
# DATABASE_URL ฉีดตอนรันผ่าน env ของ container (อย่าฝังใน image) # 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"]

View file

@ -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");

View file

@ -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": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1789031603082,
"tag": "0000_narrow_quentin_quire",
"breakpoints": true
}
]
}

71
scripts/migrate.mjs Normal file
View file

@ -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();