diff --git a/.dockerignore b/.dockerignore index 5d16fd5..5e09bba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,5 +6,11 @@ README.md .docker docker-compose.dev.yml docker/ -drizzle/ -*.log \ No newline at end of file +*.log +dist + +# NOTE: drizzle/ is intentionally NOT ignored — the migration SQL must be +# in the build context so the Dockerfile can COPY it into the runner image. +# Only the non-shipping sub-folders are excluded: +drizzle/_archived_pre_baseline_2026-05-19 +drizzle/_introspected diff --git a/.gitignore b/.gitignore index f23a570..9a7f715 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ /.next/ /out/ +# migrator build output (generated by scripts/build-migrator.mjs) +/dist/ + # production /build diff --git a/Dockerfile b/Dockerfile index 51c8e0b..a64c097 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,10 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . RUN corepack enable pnpm RUN pnpm run build +# Bundle the standalone migration runner. This produces dist/migrate.mjs +# with drizzle-orm + postgres inlined, so the runner stage needs no extra +# node_modules. Runs here because the builder has full dependencies. +RUN pnpm run db:build-migrator # Stage 3: Production runner FROM node:22-alpine AS runner @@ -24,8 +28,15 @@ RUN adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder /app/.next/static .next/static +# Migration runner + migration SQL. The migrator runs on container start +# (see CMD) BEFORE the Next.js server boots — see drizzle/README.md. +COPY --from=builder --chown=nextjs:nodejs /app/dist/migrate.mjs ./migrate.mjs +COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] \ No newline at end of file +# Apply pending migrations, THEN start the server. If migration fails the +# process exits non-zero and the container crashes — a loud, safe failure +# that prevents the app from serving against a half-migrated schema. +CMD ["sh", "-c", "node migrate.mjs && node server.js"] diff --git a/package.json b/package.json index 2eecb39..4509466 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,12 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start" + "start": "next start", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:studio": "drizzle-kit studio", + "db:pull": "drizzle-kit pull", + "db:build-migrator": "node scripts/build-migrator.mjs" }, "dependencies": { "@auth/drizzle-adapter": "^1.11.2", @@ -41,6 +46,7 @@ "@types/react-dom": "^19", "@types/sharp": "^0.32.0", "drizzle-kit": "^0.31.10", + "esbuild": "^0.25.12", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2576188..5b41714 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 5.0.0-beta.31(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(nodemailer@7.0.13)(react@19.2.4) next-pwa: specifier: ^5.6.0 - version: 5.6.0(@babel/core@7.29.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2) + version: 5.6.0(@babel/core@7.29.0)(esbuild@0.25.12)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2(esbuild@0.25.12)) nodemailer: specifier: ^7.0.13 version: 7.0.13 @@ -102,6 +102,9 @@ importers: drizzle-kit: specifier: ^0.31.10 version: 0.31.10 + esbuild: + specifier: ^0.25.12 + version: 0.25.12 tailwindcss: specifier: ^4 version: 4.3.0 @@ -6302,14 +6305,14 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2): + babel-loader@8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12)): dependencies: '@babel/core': 7.29.0 find-cache-dir: 3.3.2 loader-utils: 2.0.4 make-dir: 3.1.0 schema-utils: 2.7.1 - webpack: 5.106.2 + webpack: 5.106.2(esbuild@0.25.12) babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): dependencies: @@ -6417,10 +6420,10 @@ snapshots: chrome-trace-event@1.0.4: {} - clean-webpack-plugin@4.0.0(webpack@5.106.2): + clean-webpack-plugin@4.0.0(webpack@5.106.2(esbuild@0.25.12)): dependencies: del: 4.1.1 - webpack: 5.106.2 + webpack: 5.106.2(esbuild@0.25.12) client-only@0.0.1: {} @@ -7318,14 +7321,14 @@ snapshots: optionalDependencies: nodemailer: 7.0.13 - next-pwa@5.6.0(@babel/core@7.29.0)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2): + next-pwa@5.6.0(@babel/core@7.29.0)(esbuild@0.25.12)(next@16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(webpack@5.106.2(esbuild@0.25.12)): dependencies: - babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2) - clean-webpack-plugin: 4.0.0(webpack@5.106.2) + babel-loader: 8.4.1(@babel/core@7.29.0)(webpack@5.106.2(esbuild@0.25.12)) + clean-webpack-plugin: 4.0.0(webpack@5.106.2(esbuild@0.25.12)) globby: 11.1.0 next: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - terser-webpack-plugin: 5.6.0(webpack@5.106.2) - workbox-webpack-plugin: 6.6.0(webpack@5.106.2) + terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12)) + workbox-webpack-plugin: 6.6.0(webpack@5.106.2(esbuild@0.25.12)) workbox-window: 6.6.0 transitivePeerDependencies: - '@babel/core' @@ -7885,13 +7888,15 @@ snapshots: type-fest: 0.16.0 unique-string: 2.0.0 - terser-webpack-plugin@5.6.0(webpack@5.106.2): + terser-webpack-plugin@5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.47.1 - webpack: 5.106.2 + webpack: 5.106.2(esbuild@0.25.12) + optionalDependencies: + esbuild: 0.25.12 terser@5.47.1: dependencies: @@ -8049,7 +8054,7 @@ snapshots: webpack-sources@3.4.1: {} - webpack@5.106.2: + webpack@5.106.2(esbuild@0.25.12): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.9 @@ -8072,7 +8077,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 - terser-webpack-plugin: 5.6.0(webpack@5.106.2) + terser-webpack-plugin: 5.6.0(esbuild@0.25.12)(webpack@5.106.2(esbuild@0.25.12)) watchpack: 2.5.1 webpack-sources: 3.4.1 transitivePeerDependencies: @@ -8244,12 +8249,12 @@ snapshots: workbox-sw@6.6.0: {} - workbox-webpack-plugin@6.6.0(webpack@5.106.2): + workbox-webpack-plugin@6.6.0(webpack@5.106.2(esbuild@0.25.12)): dependencies: fast-json-stable-stringify: 2.1.0 pretty-bytes: 5.6.0 upath: 1.2.0 - webpack: 5.106.2 + webpack: 5.106.2(esbuild@0.25.12) webpack-sources: 1.4.3 workbox-build: 6.6.0 transitivePeerDependencies: diff --git a/scripts/build-migrator.mjs b/scripts/build-migrator.mjs new file mode 100644 index 0000000..0439e61 --- /dev/null +++ b/scripts/build-migrator.mjs @@ -0,0 +1,30 @@ +/** + * Bundles src/db/migrate.ts into a single self-contained dist/migrate.mjs. + * + * Why: the Next.js standalone production image does NOT keep drizzle-orm or + * postgres as separate node_modules packages (Next traces them straight into + * server.js). The migration runner is a separate entrypoint, so it needs its + * own bundle with those deps inlined. esbuild does exactly that. + * + * esbuild is already available as a transitive dependency of drizzle-kit/tsx, + * so this adds no new package to the project. + * + * Run via: pnpm db:build-migrator (invoked automatically inside the build). + */ +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/db/migrate.ts"], + bundle: true, // inline drizzle-orm + postgres into the output + platform: "node", + target: "node22", + format: "esm", + outfile: "dist/migrate.mjs", + // postgres ships optional native bits; keep it bundled but let node resolve + // built-ins normally. No externals — we want a fully standalone file. + banner: { + js: "// AUTO-GENERATED by scripts/build-migrator.mjs — do not edit.", + }, +}); + +console.log("[build-migrator] dist/migrate.mjs written."); diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..258519d --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,43 @@ +/** + * Standalone database migration runner. + * + * This runs as its OWN process on container startup, before the Next.js + * server boots (see Dockerfile CMD). It is intentionally separate from + * src/db/index.ts: that module configures a long-lived connection POOL for + * the running app; migrations want a single short-lived connection that is + * closed the moment they finish. + * + * Build note: this file is bundled by scripts/build-migrator.mjs into a + * self-contained dist/migrate.mjs (drizzle-orm + postgres inlined) so the + * production image needs no extra runtime dependencies. + */ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + +async function main() { + const url = process.env.DATABASE_URL; + if (!url) { + console.error("[migrate] DATABASE_URL is not set — aborting."); + process.exit(1); + } + + // max: 1 — a migration run is sequential; a single connection is correct + // and avoids holding pool slots. The connection is closed in finally{}. + const client = postgres(url, { max: 1 }); + + try { + console.log("[migrate] applying pending migrations..."); + await migrate(drizzle(client), { migrationsFolder: "./drizzle" }); + console.log("[migrate] done — schema is up to date."); + } catch (err) { + // Fail LOUD. If migration fails we must NOT let the app boot against a + // half-migrated schema — a crashed container is a visible, safe failure. + console.error("[migrate] FAILED:", err); + process.exit(1); + } finally { + await client.end(); + } +} + +main();