diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 821b0e1b..cdef79df 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -301,8 +301,27 @@ jobs: export NODE_ENV=production export SKIP_ENV_VALIDATION=1 pnpm install --frozen-lockfile + # prisma generate only needs a resolvable URL — no live DB connection. export DATABASE_URL="mysql://placeholder:please@localhost/placeholder" pnpm prisma:generate + unset DATABASE_URL + # Tag releases must apply CMS SQL migrations against the live DB + # (same path as push-to-main deploy), using the production .env. + LIVE="/var/www/atom-nexst" + ln -sfn "${LIVE}/.env" "${WORK}/.env" + MIGRATE_OK=0 + for i in $(seq 1 10); do + if pnpm db:migrate; then + MIGRATE_OK=1 + break + fi + echo "migrate attempt ${i}/10 failed (likely DB connections), retrying..." + sleep 5 + done + if [ "${MIGRATE_OK}" != "1" ]; then + echo "ERROR: db:migrate failed after retries" >&2 + exit 1 + fi pnpm build pm2 restart next --update-env pm2 save diff --git a/README.md b/README.md index a4184a1e..3cb7efbe 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,7 @@ The CMS runs behind an nginx reverse proxy on the default port 3000. Static asse | `pnpm test` | Run all tests (Vitest) | | `pnpm db:migrate` | Apply pending SQL migrations | | `pnpm db:migrate:status` | Show migration status | +| `pnpm db:schema:generate` | Regen `src/db/schema.ts` from Prisma + live DB (needs `DATABASE_URL`) | | `pnpm prisma:generate` | Regenerate Prisma type stubs (dev only, not prod) | | `pnpm analyze` | Build + open bundle analyzer | | `pnpm jobs:worker` | Start background task worker (systemd / PM2) | diff --git a/package.json b/package.json index ab5ea6b8..984e82bd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "test": "vitest run", "db:migrate": "tsx scripts/apply-migrations.ts", "db:migrate:status": "tsx scripts/apply-migrations.ts --status", + "db:schema:generate": "node scripts/generate-drizzle-schema.mjs", "jobs:worker": "tsx scripts/jobs-worker.ts", "prepare": "husky" }, diff --git a/src/lib/ci-workflow-contract.test.ts b/src/lib/ci-workflow-contract.test.ts index 37a74260..a6f1225f 100644 --- a/src/lib/ci-workflow-contract.test.ts +++ b/src/lib/ci-workflow-contract.test.ts @@ -31,4 +31,14 @@ describe("CI workflow", () => { expect(workflow).toContain("Creating release for"); expect(workflow).toContain("startsWith(gitea.ref_name, 'v')"); }); + + it("applies CMS migrations on tag release before build", () => { + const release = workflow.slice(workflow.indexOf("\n release:")); + expect(release).toContain("pnpm db:migrate"); + expect(release).toContain("unset DATABASE_URL"); + const migrateAt = release.indexOf("pnpm db:migrate"); + const buildAt = release.indexOf("pnpm build"); + expect(migrateAt).toBeGreaterThan(-1); + expect(buildAt).toBeGreaterThan(migrateAt); + }); }); diff --git a/src/lib/prisma-facade-include-contract.test.ts b/src/lib/prisma-facade-include-contract.test.ts new file mode 100644 index 00000000..4822b6b4 --- /dev/null +++ b/src/lib/prisma-facade-include-contract.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("prisma-facade include contract", () => { + const src = readFileSync( + resolve(process.cwd(), "src/lib/prisma-facade.ts"), + "utf8", + ); + + it("groups many-relations as arrays (polls/tickets/events)", () => { + expect(src).toContain('mode: "one" | "many"'); + expect(src).toContain('mode === "many"'); + expect(src).toContain("list.push(cleaned)"); + expect(src).toContain('"many"'); + expect(src).toMatch(/queryRelationRows\([\s\S]*?"many"/); + }); + + it("recurses nested include on related rows", () => { + expect(src).toContain("opts.include && related.length > 0"); + expect(src).toContain( + "await attachIncludes(client, rel.referencedTable, related, opts.include)", + ); + }); + + it("applies relation where filters when present", () => { + expect(src).toContain("buildCondition(rel.referencedTable, opts.where)"); + }); + + it("ships poll/ticket/event drizzle relations used by admin includes", () => { + const relations = readFileSync( + resolve(process.cwd(), "src/db/relations.ts"), + "utf8", + ); + expect(relations).toContain("questions: many(WebsitePollQuestion)"); + expect(relations).toContain("votes: many(WebsitePollVote)"); + expect(relations).toContain("messages: many(WebsiteTicketMessage)"); + expect(relations).toContain("prizes: many(WebsiteEventPrize)"); + expect(relations).toContain( + "registrations: many(WebsiteEventRegistration)", + ); + }); +}); + +describe("drizzle schema generate script", () => { + it("is wired as pnpm db:schema:generate", () => { + const pkg = JSON.parse( + readFileSync(resolve(process.cwd(), "package.json"), "utf8"), + ) as { scripts: Record }; + expect(pkg.scripts["db:schema:generate"]).toContain( + "generate-drizzle-schema.mjs", + ); + expect( + readFileSync( + resolve(process.cwd(), "scripts/generate-drizzle-schema.mjs"), + "utf8", + ), + ).toContain("Usage: pnpm db:schema:generate"); + }); +});