diff --git a/Dockerfile b/Dockerfile index 31d28c4..41a08fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,10 @@ -FROM node:24 +FROM node:22 WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . - -# next build writes the rewrite destinations from next.config.mjs into -# .next/routes-manifest.json, so "next start" cannot pick up a different -# API URL later. The value has to be known here, not just at runtime. -ARG API_INTERNAL_URL=http://localhost:3000 -ENV API_INTERNAL_URL=$API_INTERNAL_URL - RUN npm run build:api && npm run build:web RUN mkdir -p data && chmod +x scripts/docker-start.sh diff --git a/README.md b/README.md index fc82fca..c333917 100644 --- a/README.md +++ b/README.md @@ -62,29 +62,15 @@ docker compose logs --follow docker compose down ``` -`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, -ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in +Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein +Produktionsdeployment. Details stehen in [Deployment und Betrieb](docs/deployment.md). -Für die Entwicklung mit Hot Reload gibt es einen eigenen Stack mit -Quellcode-Mounts und Watchern: - -```powershell -docker compose -f compose.dev.yaml up --build --detach -docker compose -f compose.dev.yaml logs --follow -docker compose -f compose.dev.yaml down -``` - -Die Watcher darin laufen im Polling-Modus, weil Bind-Mounts unter Windows und -macOS keine inotify-Events durchreichen. Das kostet dauerhaft CPU, auch wenn -niemand die Anwendung benutzt — deshalb gehört dieser Stack nicht auf einen -Server. - ## Direkte lokale Entwicklung Voraussetzungen: -- Node.js 24 +- Node.js 22 - npm ```powershell diff --git a/compose.dev.yaml b/compose.dev.yaml deleted file mode 100644 index 86e42e2..0000000 --- a/compose.dev.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# Development stack: source mounts, watching dev servers, hot reload. -# docker compose -f compose.dev.yaml up --build -# -# The polling watchers below are needed for bind mounts on Windows and -# macOS, where inotify events do not cross the VM boundary. They cost -# continuous CPU, which is why the production stack in compose.yaml does -# not run watchers at all. -name: leistungsbilanz-dev - -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" - -services: - api: - build: - context: . - command: - - sh - - -c - - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api - environment: - PORT: "3000" - CHOKIDAR_USEPOLLING: "true" - LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging - ports: - - "3000:3000" - volumes: - - ./src:/app/src - - ./scripts:/app/scripts - - ./data:/app/data - - ./drizzle.config.ts:/app/drizzle.config.ts:ro - - ./tsconfig.json:/app/tsconfig.json:ro - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s - timeout: 3s - retries: 5 - start_period: 20s - - web: - build: - context: . - command: - - npm - - run - - dev:web - - -- - - --hostname - - 0.0.0.0 - environment: - API_INTERNAL_URL: http://api:3000 - WATCHPACK_POLLING: "true" - NEXT_TELEMETRY_DISABLED: "1" - LOG_LEVEL: "${LOG_LEVEL:-debug}" - init: true - restart: unless-stopped - logging: *logging - depends_on: - api: - condition: service_healthy - ports: - - "3001:3001" - volumes: - - ./src:/app/src - - ./next.config.mjs:/app/next.config.mjs:ro - - ./tsconfig.json:/app/tsconfig.json:ro - - ./tsconfig.next.json:/app/tsconfig.next.json:ro - healthcheck: - test: - - CMD - - node - - -e - - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s - timeout: 3s - retries: 5 - start_period: 20s diff --git a/compose.yaml b/compose.yaml index 503a4b2..6a5ec7c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,73 +1,82 @@ name: leistungsbilanz -x-build: &build - context: . - args: - # Baked into .next/routes-manifest.json by next build; see Dockerfile. - API_INTERNAL_URL: http://api:3000 - -x-logging: &logging - driver: json-file - options: - max-size: "20m" - max-file: "10" - services: api: - build: *build + build: + context: . command: - sh - -c - - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js + - npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api environment: - NODE_ENV: production PORT: "3000" + CHOKIDAR_USEPOLLING: "true" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: *logging + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" ports: - "3000:3000" volumes: + - ./src:/app/src + - ./scripts:/app/scripts - ./data:/app/data + - ./drizzle.config.ts:/app/drizzle.config.ts:ro + - ./tsconfig.json:/app/tsconfig.json:ro healthcheck: test: - CMD - node - -e - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s + interval: 5s timeout: 3s - retries: 5 + retries: 12 start_period: 20s web: - build: *build + build: + context: . command: - - node_modules/.bin/next - - start - - -p - - "3001" + - npm + - run + - dev:web + - -- + - --hostname + - 0.0.0.0 environment: - NODE_ENV: production API_INTERNAL_URL: http://api:3000 + WATCHPACK_POLLING: "true" NEXT_TELEMETRY_DISABLED: "1" LOG_LEVEL: "${LOG_LEVEL:-info}" init: true restart: unless-stopped - logging: *logging + logging: + driver: json-file + options: + max-size: "20m" + max-file: "10" depends_on: api: condition: service_healthy ports: - "3001:3001" + volumes: + - ./src:/app/src + - ./next.config.mjs:/app/next.config.mjs:ro + - ./tsconfig.json:/app/tsconfig.json:ro + - ./tsconfig.next.json:/app/tsconfig.next.json:ro healthcheck: test: - CMD - node - -e - - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - interval: 30s + - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 5s timeout: 3s - retries: 5 + retries: 12 start_period: 20s diff --git a/docs/deployment.md b/docs/deployment.md index 06289a7..dfbb3f1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -2,27 +2,19 @@ ## Aktueller Status -Es gibt zwei Compose-Stacks. +Es gibt derzeit kein unterstütztes Produktionsdeployment. -`compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und -`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack -für einen Server. +`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet +`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, +Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes +Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder +öffentlich erreichbar gemacht werden. -`compose.dev.yaml` startet `tsx watch` und `next dev` und bindet Quellcode vom -Host ein. Die Watcher laufen im Polling-Modus, weil Bind-Mounts unter Windows -und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch -ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen -Entwicklungsrechner. - -Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch -ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich -erreichbar gemacht werden. - -## Topologie +## Entwicklungs-Topologie | Komponente | Port | Healthcheck | Persistenz | | --- | ---: | --- | --- | -| Next.js Web | 3001 | `GET /web-health` | keine | +| Next.js Web | 3001 | `GET /` | keine | | Express API | 3000 | `GET /health` | `./data:/app/data` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | @@ -32,22 +24,15 @@ Verwendete Umgebungsvariablen: - `API_INTERNAL_URL` – internes API-Ziel des Next.js-Rewrites, im Compose-Netz `http://api:3000` - `NEXT_TELEMETRY_DISABLED=1` -- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` – nur in - `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg +- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale + Dateibeobachtung in Docker - `LOG_LEVEL` – steuert für beide Dienste die Ausgabestufe des strukturierten JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. Setzbar über eine `.env`-Datei neben `compose.yaml` oder `LOG_LEVEL=verbose docker compose up`. -Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung -(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im -Entwicklungsstack über `npm run db:migrate` und -`npm run db:verify:circuit-schema`). - -`API_INTERNAL_URL` wirkt für `next start` zur **Build-Zeit**: `next build` -schreibt die Rewrite-Ziele aus `next.config.mjs` fest in -`.next/routes-manifest.json`. `compose.yaml` reicht den Wert deshalb als -Build-Argument an das Image durch, nicht nur als Laufzeit-Variable. +Beim API-Start laufen zuerst `npm run db:migrate` und +`npm run db:verify:circuit-schema`. ## Logging diff --git a/package-lock.json b/package-lock.json index 417421b..b0abb41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,15 +22,12 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^24.10.1", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", "tsx": "^4.21.0", "typescript": "^6.0.3" - }, - "engines": { - "node": "24.x" } }, "node_modules/@drizzle-team/brocli": { @@ -1517,13 +1514,12 @@ "dev": true }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "devOptional": true, - "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/qs": { @@ -3692,11 +3688,10 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "devOptional": true, - "license": "MIT" + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "devOptional": true }, "node_modules/unpipe": { "version": "1.0.0", diff --git a/package.json b/package.json index 414eb53..9bc5ae3 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,6 @@ "version": "1.0.0", "description": "Spreadsheet-style circuit list editor for electrical distribution planning", "main": "dist/server/index.js", - "engines": { - "node": "24.x" - }, "scripts": { "dev": "npm run dev:api", "dev:api": "tsx watch src/server/index.ts", @@ -13,9 +10,6 @@ "docker:up": "docker compose up --build --detach", "docker:down": "docker compose down", "docker:logs": "docker compose logs --follow", - "docker:dev:up": "docker compose -f compose.dev.yaml up --build --detach", - "docker:dev:down": "docker compose -f compose.dev.yaml down", - "docker:dev:logs": "docker compose -f compose.dev.yaml logs --follow", "build": "npm run build:api", "build:api": "tsc -p tsconfig.json", "build:web": "next build", @@ -46,7 +40,7 @@ "devDependencies": { "@types/better-sqlite3": "^7.6.13", "@types/express": "^5.0.6", - "@types/node": "^24.10.1", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "drizzle-kit": "^0.31.10", diff --git a/src/app/web-health/route.ts b/src/app/web-health/route.ts deleted file mode 100644 index ce6f2c4..0000000 --- a/src/app/web-health/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { NextResponse } from "next/server"; - -// Liveness probe for the web container itself. The "/health" path is -// rewritten to the API in next.config.mjs, so it cannot answer for this -// process. Kept as a route handler so a probe does not render a page. -export const dynamic = "force-dynamic"; - -export function GET() { - return NextResponse.json({ ok: true }); -} diff --git a/src/db/repositories/project-device-row-sync-project-command.repository.ts b/src/db/repositories/project-device-row-sync-project-command.repository.ts index eb722d7..da17458 100644 --- a/src/db/repositories/project-device-row-sync-project-command.repository.ts +++ b/src/db/repositories/project-device-row-sync-project-command.repository.ts @@ -14,7 +14,6 @@ import type { AppDatabase } from "../database-context.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitLists } from "../schema/circuit-lists.js"; import { circuits } from "../schema/circuits.js"; -import { externalModelObjects } from "../schema/external-model-objects.js"; import { projectDevices } from "../schema/project-devices.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; @@ -120,32 +119,9 @@ export class ProjectDeviceRowSyncProjectCommandRepository ); for (const assignment of input.command.payload.rows) { - const values: typeof assignment.target & { - manualQuantity?: number; - } = { ...assignment.target }; - if (assignment.target.quantity !== assignment.expected.quantity) { - const externalTotal = tx - .select({ planningValues: externalModelObjects.planningValues }) - .from(externalModelObjects) - .where( - eq(externalModelObjects.circuitDeviceRowId, assignment.rowId) - ) - .all() - .reduce( - (sum, object) => sum + object.planningValues.effectiveQuantity, - 0 - ); - const manualQuantity = assignment.target.quantity - externalTotal; - if (manualQuantity < 0) { - throw new Error( - "Synchronized quantity is below the total quantity of linked external objects." - ); - } - values.manualQuantity = manualQuantity; - } const updated = tx .update(circuitDeviceRows) - .set(values) + .set(assignment.target) .where(eq(circuitDeviceRows.id, assignment.rowId)) .run(); if (updated.changes !== 1) { diff --git a/src/proxy.ts b/src/proxy.ts index 3f4b83a..4607ec0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -5,9 +5,8 @@ import { createLogger } from "./shared/logging/logger"; const logger = createLogger("web:navigation"); export function proxy(request: NextRequest) { - // Probes hit "/web-health" and are excluded by the matcher below. The - // User-Agent guard stays as a fallback for anything else that polls - // without one, so real navigation isn't drowned out in the logs. + // The Docker healthcheck hits "/" every few seconds with no User-Agent + // header; skip it so real navigation isn't drowned out in the logs. if (request.headers.get("user-agent")) { logger.info("page request", { method: request.method, @@ -18,7 +17,5 @@ export function proxy(request: NextRequest) { } export const config = { - matcher: [ - "/((?!_next/static|_next/image|favicon.ico|api|web-health).*)", - ], + matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], }; diff --git a/tests/project-device-row-sync-project-command.repository.test.ts b/tests/project-device-row-sync-project-command.repository.test.ts index 593a7f6..75d56a8 100644 --- a/tests/project-device-row-sync-project-command.repository.test.ts +++ b/tests/project-device-row-sync-project-command.repository.test.ts @@ -13,13 +13,9 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuits } from "../src/db/schema/circuits.js"; -import { externalImportBatches } from "../src/db/schema/external-import-batches.js"; -import { externalModelObjects } from "../src/db/schema/external-model-objects.js"; -import { externalModelSources } from "../src/db/schema/external-model-sources.js"; import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projects } from "../src/db/schema/projects.js"; -import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js"; import { createProjectDeviceRowSyncProjectCommand, type ProjectDeviceSyncRowSnapshot, @@ -167,79 +163,6 @@ function getRow(context: DatabaseContext, rowId: string) { return row; } -function linkExternalObject( - context: DatabaseContext, - rowId: string, - effectiveQuantity: number -) { - context.db - .insert(externalModelSources) - .values({ - id: "source-1", - projectId: "project-1", - name: "Revit", - sourceType: "revit_csv", - }) - .run(); - context.db - .insert(externalImportBatches) - .values({ - id: "batch-1", - projectId: "project-1", - sourceId: "source-1", - importKind: "initial", - importedAtIso: "2026-08-02T16:00:00.000Z", - fileName: "revit.csv", - sha256: "a".repeat(64), - appliedProjectRevision: 0, - configurationVersion: 1, - configurationSnapshot: externalCsvTestConfiguration, - originalBytes: Buffer.from("test"), - document: { delimiter: ";", encoding: "utf-8", headers: [], rows: [] }, - }) - .run(); - context.db - .insert(externalModelObjects) - .values({ - id: "object-1", - projectId: "project-1", - sourceId: "source-1", - ifcGuid: "ifc-1", - lastSeenImportBatchId: "batch-1", - lastAcceptedImportBatchId: "batch-1", - acceptedSourceValues: { - rowNumber: 2, - roomNumber: "101", - roomName: "Büro", - familyAndType: "Leuchte: Standard", - selectionMarker: "Leuchte", - circuitIdentifier: "-1F1", - power: "30", - quantity: String(effectiveQuantity), - additionalSourceValues: {}, - }, - planningValues: { - displayName: "Leuchte", - internalDeviceType: "luminaire", - category: "single_phase", - connectionKind: "fixed", - effectiveQuantity, - powerPerUnitW: 30, - simultaneityFactor: 1, - cosPhi: null, - costGroup: null, - remark: null, - }, - overriddenFields: [], - externalRoomMappingId: null, - distributionBoardId: null, - linkedProjectDeviceId: null, - circuitDeviceRowId: rowId, - presenceStatus: "present", - }) - .run(); -} - function snapshot( context: DatabaseContext, rowId: string @@ -551,112 +474,6 @@ describe("project-device row sync project-command repository", () => { } }); - it("keeps manualQuantity from exceeding quantity when a synced quantity shrinks", () => { - const fixture = createTestDatabase(); - try { - fixture.context.db - .update(circuitDeviceRows) - .set({ quantity: 5, manualQuantity: 5 }) - .where(eq(circuitDeviceRows.id, "row-1")) - .run(); - const store = new ProjectDeviceRowSyncProjectCommandRepository( - fixture.context.db - ); - const expected = snapshot(fixture.context, "row-1"); - store.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createProjectDeviceRowSyncProjectCommand( - "project-device-1", - "synchronize", - [ - { - rowId: "row-1", - expected, - target: { ...expected, quantity: 2 }, - }, - ] - ), - }); - const row = getRow(fixture.context, "row-1"); - assert.equal(row.quantity, 2); - assert.equal(row.manualQuantity, 2); - } finally { - fixture.context.close(); - } - }); - - it("subtracts linked external objects when a synced quantity shrinks", () => { - const fixture = createTestDatabase(); - try { - fixture.context.db - .update(circuitDeviceRows) - .set({ quantity: 5, manualQuantity: 2 }) - .where(eq(circuitDeviceRows.id, "row-1")) - .run(); - linkExternalObject(fixture.context, "row-1", 3); - const store = new ProjectDeviceRowSyncProjectCommandRepository( - fixture.context.db - ); - const expected = snapshot(fixture.context, "row-1"); - store.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createProjectDeviceRowSyncProjectCommand( - "project-device-1", - "synchronize", - [{ rowId: "row-1", expected, target: { ...expected, quantity: 4 } }] - ), - }); - const row = getRow(fixture.context, "row-1"); - assert.equal(row.quantity, 4); - assert.equal(row.manualQuantity, 1); - } finally { - fixture.context.close(); - } - }); - - it("rejects a synced quantity below the linked external total", () => { - const fixture = createTestDatabase(); - try { - fixture.context.db - .update(circuitDeviceRows) - .set({ quantity: 5, manualQuantity: 2 }) - .where(eq(circuitDeviceRows.id, "row-1")) - .run(); - linkExternalObject(fixture.context, "row-1", 3); - const store = new ProjectDeviceRowSyncProjectCommandRepository( - fixture.context.db - ); - const expected = snapshot(fixture.context, "row-1"); - assert.throws( - () => - store.execute({ - projectId: "project-1", - expectedRevision: 0, - source: "user", - command: createProjectDeviceRowSyncProjectCommand( - "project-device-1", - "synchronize", - [{ rowId: "row-1", expected, target: { ...expected, quantity: 2 } }] - ), - }), - /below the total quantity of linked external objects/ - ); - const row = getRow(fixture.context, "row-1"); - assert.equal(row.quantity, 5); - assert.equal(row.manualQuantity, 2); - assert.equal( - fixture.context.db.select().from(projectRevisions).all().length, - 0 - ); - } finally { - fixture.context.close(); - } - }); - it("rolls back synchronized rows for a stale project revision", () => { const fixture = createTestDatabase(); try {