diff --git a/DEVLOG.md b/DEVLOG.md index 0a9c386a..0ee32088 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -99,6 +99,30 @@ with an explicit, keyboard-accessible Show more / Show fewer affordance. Asks no longer expands without limit, and Run recovery no longer hides additional cards in a clipped nested scroll area. Responsive Playwright coverage now guards both the dashboard rail fill and the attention overflow contract. +## 2026-07-20 — AXI-138 faster and safer local iteration + +Scoped Turbopack to the Forge repository root so unrelated parent lockfiles no +longer expand module resolution or file watching in workstation and Codex +worktrees. Prisma query output is now quiet by default and opt-in through +`FORGE_LOG_PRISMA_QUERIES=1`, preserving warnings and errors without flooding +the terminal on production-sized local snapshots. Restoring the frozen pnpm +dependency tree repaired the reported `nodemailer` resolution failure. + +Local CI now owns a disposable `forge_test` database and Redis database 13, +guarded by exact local-target validation and a machine-local quality lock. Each +quality run resets and migrates only that test state; it never executes the +unit/integration suite against the shared `forge` development database. This +was verified against a copied production snapshot whose legitimate legacy pin +previously collided with a static test fixture. + +Focused workflow, target-guard, and logging tests passed 17/17. Typecheck and +lint passed with existing warnings. The complete guarded quality gate passed +all 1,512 Vitest tests with one intentional skip against `forge_test`. Cold +route compilation remains expected in Next development mode; warm sign-in +requests measured 0.28–0.35 seconds with no Prisma query-log lines or +Turbopack workspace-root warning. The isolated production build passed and the +split E2E-only gate passed 56 Playwright tests with one intentional scenario +skip, without repeating the quality phase. ## 2026-07-20 — AXI-137 delivery provenance and lifecycle truth diff --git a/docs/guide/local-development.md b/docs/guide/local-development.md index ec6d0865..ff4fcce3 100644 --- a/docs/guide/local-development.md +++ b/docs/guide/local-development.md @@ -26,8 +26,6 @@ Every command prints what it started, skipped, migrated, generated, seeded, or replaced plus the selected local database and endpoints. The TypeScript orchestrator works when invoked from Windows PowerShell or Git Bash. -Sign in with the stable bootstrap credentials it prints: - Sign in with the bootstrap credentials it prints: ``` @@ -36,6 +34,26 @@ owner@forge.local / forge-dev (Override via `ADMIN_EMAIL` / `ADMIN_PASSWORD` env vars before running.) +The first visit to a route is slower than later refreshes because Next compiles +that route on demand in development. Forge pins Turbopack to the repository +root so unrelated lockfiles elsewhere on the workstation do not expand module +resolution or file watching. The `.next` cache is preserved between normal +restarts; avoid deleting it unless you are diagnosing a corrupt build cache. + +Prisma query text is quiet by default so a production-sized local snapshot +does not flood the terminal. Enable it for a focused database investigation: + +```bash +FORGE_LOG_PRISMA_QUERIES=1 pnpm dev:app +``` + +In PowerShell, use +`$env:FORGE_LOG_PRISMA_QUERIES = "1"; pnpm dev:app` and remove the variable +afterward. When iterating on a copied production snapshot, keep background +workers disabled with `FORGE_DISABLE_IN_PROCESS_WORKER=1`; this preserves +realtime browser updates while preventing local maintenance jobs from acting +on cloned production state. + ::: tip The base seed (`prisma/seed.ts`) is deliberately simple and idempotent. Re-running `pnpm dev` on a populated database skips it; use `--fresh` @@ -178,10 +196,18 @@ Use `pnpm ci:local:quality` during implementation and run the complete gate once before release readiness. `pnpm ci:local:e2e` runs only Playwright when a focused E2E repeat is needed. The cross-platform runner verifies or starts the guarded local services, supplies the known local endpoints even in an isolated -worktree, serializes E2E runs with a machine-local lock, selects an available -port, resets only the dedicated `forge_e2e` database, and starts a fresh server. Pass -`pnpm ci:local:e2e -- --reuse-e2e-build` only when `.next` was built from the -current source. Then append a line to `DEVLOG.md` and commit. +worktree, and resets the disposable `forge_test` database plus Redis database +13 before unit/integration tests. A machine-local quality lock prevents two +worktrees from resetting that disposable state concurrently. The gate never +runs those tests against the shared `forge` development database, so an +imported production snapshot remains available for UI iteration. Playwright +separately owns disposable `forge_e2e`. +On Windows, stop a dev server running from the same worktree before the gate so +Prisma can replace its generated engine DLL; restart with `pnpm dev:app` after. +The E2E phase serializes runs with a machine-local lock, selects an available +port, resets only the dedicated `forge_e2e` database, and starts a fresh server. +Pass `pnpm ci:local:e2e -- --reuse-e2e-build` only when `.next` was built from +the current source. Then append a line to `DEVLOG.md` and commit. ## Measuring issue UI load diff --git a/next.config.ts b/next.config.ts index f49ef46d..60408524 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,4 +1,8 @@ import type { NextConfig } from "next"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = dirname(fileURLToPath(import.meta.url)); const isolatedBuild = !!process.env.NEXT_DIST_DIR && process.env.NEXT_DIST_DIR !== ".next"; const devOriginHosts = Array.from( @@ -20,6 +24,11 @@ const devOriginHosts = Array.from( const config: NextConfig = { reactStrictMode: true, poweredByHeader: false, + // Multiple lockfiles may exist above a Codex worktree on developer + // machines. Keep Turbopack resolution and file watching scoped to Forge. + turbopack: { + root: projectRoot, + }, allowedDevOrigins: devOriginHosts.length > 0 ? devOriginHosts : undefined, // `standalone` is for the prod Docker image. The E2E build (NEXT_DIST_DIR= // .next-e2e) is served with plain `next start`, which doesn't support diff --git a/scripts/ci-local.ts b/scripts/ci-local.ts index e682a740..a9d54ba5 100644 --- a/scripts/ci-local.ts +++ b/scripts/ci-local.ts @@ -12,6 +12,7 @@ import { type LocalCiStep, } from "./lib/ci-local-workflow"; import { LOCAL_DEV, assertSafeLocalEnvironment, parseWindowsPnpmEntry } from "./lib/dev-workflow"; +import { LOCAL_TEST_DATABASE_URL } from "./lib/local-data-target"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); @@ -33,8 +34,8 @@ function formatStep(step: LocalCiStep): string { } const localCiEnv: Record = { - DATABASE_URL: LOCAL_DEV.databaseUrl, - REDIS_URL: LOCAL_DEV.redisUrl, + DATABASE_URL: LOCAL_TEST_DATABASE_URL, + REDIS_URL: "redis://localhost:56379/13", S3_ENDPOINT: LOCAL_DEV.s3Endpoint, S3_PUBLIC_ENDPOINT: LOCAL_DEV.s3Endpoint, S3_REGION: process.env.S3_REGION ?? "us-east-1", @@ -89,8 +90,20 @@ async function main(): Promise { const quality = buildLocalCiPlan(options).filter( (candidate) => candidate.label !== "Playwright E2E", ); - for (const step of quality) { - runStep(step, pnpm); + if (quality.length > 0) { + const qualityLockPath = join(tmpdir(), "forge-quality.lock"); + const releaseQualityLock = await acquireDirectoryLock(qualityLockPath, { + onWait: (owner) => { + const detail = owner ? ` (PID ${owner.pid} on ${owner.host})` : ""; + console.log(`[ci:local] Waiting for another Forge quality run${detail}…`); + }, + }); + try { + console.log("[ci:local] Acquired quality lock for disposable test state."); + for (const step of quality) runStep(step, pnpm); + } finally { + await releaseQualityLock(); + } } if (!needsE2e) return; diff --git a/scripts/lib/ci-local-workflow.ts b/scripts/lib/ci-local-workflow.ts index 31d41e25..5e58d424 100644 --- a/scripts/lib/ci-local-workflow.ts +++ b/scripts/lib/ci-local-workflow.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; import { hostname } from "node:os"; +import { LOCAL_DATABASE_URL } from "./local-data-target"; export type LocalCiMode = "quality" | "full" | "e2e-only"; @@ -51,7 +52,22 @@ export function parseLocalCiOptions(argv: string[]): LocalCiOptions { export function buildLocalCiPlan(options: LocalCiOptions, e2ePort = 3200): LocalCiStep[] { const quality: LocalCiStep[] = [ - { label: "Ensure local services", args: ["dev:services"] }, + { + label: "Ensure local services", + args: ["dev:services"], + env: { + DATABASE_URL: LOCAL_DATABASE_URL, + REDIS_URL: "redis://localhost:56379", + }, + }, + { + label: "Reset disposable test database", + args: ["exec", "tsx", "scripts/reset-test-db.ts"], + }, + { + label: "Apply test database migrations", + args: ["exec", "prisma", "migrate", "deploy"], + }, { label: "Generate Prisma client", args: ["prisma:generate"] }, { label: "Lint", args: ["lint"] }, { label: "Typecheck", args: ["typecheck"] }, diff --git a/scripts/lib/local-data-target.ts b/scripts/lib/local-data-target.ts index e362a102..dfead50e 100644 --- a/scripts/lib/local-data-target.ts +++ b/scripts/lib/local-data-target.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from "node:url"; export const LOCAL_DATABASE_URL = "postgresql://forge:forge@localhost:55432/forge?schema=public"; +export const LOCAL_TEST_DATABASE_URL = + "postgresql://forge:forge@localhost:55432/forge_test?schema=public"; export const LOCAL_DATABASE_CONTAINER = "forge-dev-postgres"; export type LocalTarget = { @@ -72,6 +74,30 @@ export function validateLocalScenarioTarget(databaseUrl: string): void { } } +export function validateLocalTestTarget(databaseUrl: string): LocalTarget { + const url = new URL(databaseUrl); + const target = { + host: url.hostname, + port: url.port, + database: url.pathname.replace(/^\//, ""), + user: decodeURIComponent(url.username), + schema: url.searchParams.get("schema") ?? "", + container: LOCAL_DATABASE_CONTAINER, + }; + const valid = + url.protocol === "postgresql:" && + target.host === "localhost" && + target.port === "55432" && + target.database === "forge_test" && + target.user === "forge" && + decodeURIComponent(url.password) === "forge" && + target.schema === "public"; + if (!valid) { + throw new Error("Local CI requires the exact disposable forge_test database target"); + } + return target; +} + if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { console.log(formatLocalTarget(validateLocalTarget(process.argv[2] ?? ""))); diff --git a/scripts/reset-test-db.ts b/scripts/reset-test-db.ts new file mode 100644 index 00000000..ae5ced4a --- /dev/null +++ b/scripts/reset-test-db.ts @@ -0,0 +1,36 @@ +import { spawnSync } from "node:child_process"; +import { + formatLocalTarget, + LOCAL_TEST_DATABASE_URL, + validateLocalTestTarget, +} from "./lib/local-data-target"; + +const databaseUrl = process.env.DATABASE_URL ?? LOCAL_TEST_DATABASE_URL; +const target = validateLocalTestTarget(databaseUrl); + +function docker(args: string[]) { + const result = spawnSync("docker", args, { encoding: "utf8", stdio: "inherit", shell: false }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`docker failed with exit ${result.status}`); +} + +console.log(`[test-db] Resetting disposable target: ${formatLocalTarget(target)}`); +docker([ + "exec", + target.container, + "psql", + "-U", + target.user, + "-d", + "forge", + "-v", + "ON_ERROR_STOP=1", + "-c", + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'forge_test' AND pid <> pg_backend_pid();", +]); +docker(["exec", target.container, "dropdb", "-U", target.user, "--if-exists", target.database]); +docker(["exec", target.container, "createdb", "-U", target.user, target.database]); +docker(["exec", "forge-dev-redis", "redis-cli", "-n", "13", "FLUSHDB"]); +console.log( + "[test-db] Disposable forge_test database and Redis DB 13 are empty and ready for tests.", +); diff --git a/src/server/db-log.ts b/src/server/db-log.ts new file mode 100644 index 00000000..84492e6b --- /dev/null +++ b/src/server/db-log.ts @@ -0,0 +1,7 @@ +export type ForgePrismaLogLevel = "query" | "warn" | "error"; + +export function prismaLogLevels(env: NodeJS.ProcessEnv): ForgePrismaLogLevel[] { + if (env.FORGE_LOG_PRISMA_QUERIES === "1") return ["query", "warn", "error"]; + if (env.NODE_ENV === "development") return ["warn", "error"]; + return ["error"]; +} diff --git a/src/server/db.ts b/src/server/db.ts index a8adf5e5..3c7ceac3 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1,12 +1,13 @@ import "server-only"; import { PrismaClient } from "@prisma/client"; +import { prismaLogLevels } from "./db-log"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }; export const db = globalForPrisma.prisma ?? new PrismaClient({ - log: process.env.NODE_ENV === "development" ? ["query", "warn", "error"] : ["error"], + log: prismaLogLevels(process.env), }); if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db; diff --git a/tests/unit/ci-local-workflow.test.ts b/tests/unit/ci-local-workflow.test.ts index 4c338763..54927592 100644 --- a/tests/unit/ci-local-workflow.test.ts +++ b/tests/unit/ci-local-workflow.test.ts @@ -31,10 +31,12 @@ describe("local CI workflow", () => { expect(() => parseLocalCiOptions(["--quick"])).toThrow(/Unknown/); }); - it("generates Prisma before one quality pass and adds E2E only for full mode", () => { + it("resets a dedicated test database before one quality pass and adds E2E only for full mode", () => { const quality = buildLocalCiPlan(parseLocalCiOptions(["--quality"])); expect(quality.map((step) => step.label)).toEqual([ "Ensure local services", + "Reset disposable test database", + "Apply test database migrations", "Generate Prisma client", "Lint", "Typecheck", @@ -43,8 +45,8 @@ describe("local CI workflow", () => { expect(quality.filter((step) => step.label === "Unit and integration tests")).toHaveLength(1); const full = buildLocalCiPlan(parseLocalCiOptions([]), 3277); - expect(full).toHaveLength(6); - expect(full[5]).toMatchObject({ + expect(full).toHaveLength(8); + expect(full[7]).toMatchObject({ label: "Playwright E2E", env: { E2E_PORT: "3277", diff --git a/tests/unit/db-log.test.ts b/tests/unit/db-log.test.ts new file mode 100644 index 00000000..cbf76892 --- /dev/null +++ b/tests/unit/db-log.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { prismaLogLevels } from "@/server/db-log"; + +describe("prismaLogLevels", () => { + it("keeps normal development output concise", () => { + expect(prismaLogLevels({ NODE_ENV: "development" })).toEqual(["warn", "error"]); + }); + + it("enables query diagnostics explicitly", () => { + expect(prismaLogLevels({ NODE_ENV: "development", FORGE_LOG_PRISMA_QUERIES: "1" })).toEqual([ + "query", + "warn", + "error", + ]); + }); + + it("logs only errors in production", () => { + expect(prismaLogLevels({ NODE_ENV: "production" })).toEqual(["error"]); + }); +}); diff --git a/tests/unit/local-data-target.test.ts b/tests/unit/local-data-target.test.ts index 1144dd3c..1c86b4ef 100644 --- a/tests/unit/local-data-target.test.ts +++ b/tests/unit/local-data-target.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { LOCAL_DATABASE_CONTAINER, LOCAL_DATABASE_URL, + LOCAL_TEST_DATABASE_URL, formatLocalTarget, validateLocalScenarioTarget, validateLocalTarget, + validateLocalTestTarget, } from "../../scripts/lib/local-data-target"; describe("local production-data refresh target guard", () => { @@ -49,4 +51,17 @@ describe("local production-data refresh target guard", () => { validateLocalScenarioTarget("postgresql://forge:forge@localhost:55432/forge?schema=private"), ).toThrow(/exact Forge local docker/); }); + + it("accepts only the disposable local CI database", () => { + expect(validateLocalTestTarget(LOCAL_TEST_DATABASE_URL)).toMatchObject({ + database: "forge_test", + container: LOCAL_DATABASE_CONTAINER, + }); + expect(() => validateLocalTestTarget(LOCAL_DATABASE_URL)).toThrow(/forge_test/); + expect(() => + validateLocalTestTarget( + "postgresql://forge:forge@forge.axiom-labs.dev:5432/forge_test?schema=public", + ), + ).toThrow(/forge_test/); + }); });