Compare commits

...

3 commits

Author SHA1 Message Date
906aa751c7 Run the Docker stack on built output instead of dev servers
compose.yaml overrode the image's own entrypoint to start "tsx watch" and
"next dev" with CHOKIDAR_USEPOLLING and WATCHPACK_POLLING enabled. The
built dist/ and .next from the image were never used, and the polling
watchers kept both containers busy around the clock whether or not
anyone used the application.

Split the two use cases: compose.yaml now runs node dist/server/index.js
and next start with no source mounts and no watchers, and the watching
setup moves to compose.dev.yaml.

next build writes the rewrite destinations from next.config.mjs into
.next/routes-manifest.json, so next start cannot pick up API_INTERNAL_URL
at runtime the way next dev does. Pass it as a build argument.

Give the web container its own probe. The healthcheck hit "/", which
redirects to "/projects", and fetch follows redirects, so every probe
rendered two pages every five seconds. "/health" could not serve as the
probe because next.config.mjs rewrites it to the API. Add "/web-health"
as a route handler, exclude it from the proxy matcher, and relax both
intervals to 30s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:25:06 +02:00
c6cdfc42d5 Keep manual quantity consistent when synchronizing device rows
Synchronizing a project device pushes its quantity onto every linked
device row, but manualQuantity is not part of the sync snapshot and was
left untouched. A device whose quantity is lower than the row's left the
row at manualQuantity > quantity, violating the snapshot invariant.

The violation surfaced far from its cause: the invariant is only checked
when a full state snapshot is read, and the automatic snapshot runs every
25 revisions. A project could therefore accumulate the broken row
silently and then reject every subsequent command, because the failing
validation rolls back the whole transaction including the revision bump.

Recompute manualQuantity as the synchronized quantity minus the total of
the linked external objects, matching the invariant that
assertCircuitDeviceRowQuantity enforces elsewhere, and reject a
synchronized quantity that falls below that total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00
9504905c8f Upgrade project to Node 24
Node 22 leaves active LTS at the end of October. Move the Docker base
image and the documented local requirement to Node 24.

Pull @types/node from ^25 down to ^24 so the type definitions no longer
describe a newer runtime than the one in use. Types ahead of the runtime
let TypeScript accept APIs that are not there at execution time; the
opposite direction is harmless.

Add an engines field to record the supported version. No .nvmrc, since
the project is not used with a Node version manager.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:05:50 +02:00
11 changed files with 412 additions and 67 deletions

View file

@ -1,10 +1,17 @@
FROM node:22 FROM node:24
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . 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 npm run build:api && npm run build:web
RUN mkdir -p data && chmod +x scripts/docker-start.sh RUN mkdir -p data && chmod +x scripts/docker-start.sh

View file

@ -62,15 +62,29 @@ docker compose logs --follow
docker compose down docker compose down
``` ```
Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein `compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`,
Produktionsdeployment. Details stehen in ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in
[Deployment und Betrieb](docs/deployment.md). [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 ## Direkte lokale Entwicklung
Voraussetzungen: Voraussetzungen:
- Node.js 22 - Node.js 24
- npm - npm
```powershell ```powershell

87
compose.dev.yaml Normal file
View file

@ -0,0 +1,87 @@
# 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

View file

@ -1,82 +1,73 @@
name: leistungsbilanz 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: services:
api: api:
build: build: *build
context: .
command: command:
- sh - sh
- -c - -c
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api - node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
environment: environment:
NODE_ENV: production
PORT: "3000" PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true init: true
restart: unless-stopped restart: unless-stopped
logging: logging: *logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./src:/app/src
- ./scripts:/app/scripts
- ./data:/app/data - ./data:/app/data
- ./drizzle.config.ts:/app/drizzle.config.ts:ro
- ./tsconfig.json:/app/tsconfig.json:ro
healthcheck: healthcheck:
test: test:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://localhost:3000/health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s interval: 30s
timeout: 3s timeout: 3s
retries: 12 retries: 5
start_period: 20s start_period: 20s
web: web:
build: build: *build
context: .
command: command:
- npm - node_modules/.bin/next
- run - start
- dev:web - -p
- -- - "3001"
- --hostname
- 0.0.0.0
environment: environment:
NODE_ENV: production
API_INTERNAL_URL: http://api:3000 API_INTERNAL_URL: http://api:3000
WATCHPACK_POLLING: "true"
NEXT_TELEMETRY_DISABLED: "1" NEXT_TELEMETRY_DISABLED: "1"
LOG_LEVEL: "${LOG_LEVEL:-info}" LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true init: true
restart: unless-stopped restart: unless-stopped
logging: logging: *logging
driver: json-file
options:
max-size: "20m"
max-file: "10"
depends_on: depends_on:
api: api:
condition: service_healthy condition: service_healthy
ports: ports:
- "3001:3001" - "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: healthcheck:
test: test:
- CMD - CMD
- node - node
- -e - -e
- fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://localhost:3001/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 5s interval: 30s
timeout: 3s timeout: 3s
retries: 12 retries: 5
start_period: 20s start_period: 20s

View file

@ -2,19 +2,27 @@
## Aktueller Status ## Aktueller Status
Es gibt derzeit kein unterstütztes Produktionsdeployment. Es gibt zwei Compose-Stacks.
`compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet `compose.yaml` startet den gebauten Stand: `node dist/server/index.js` und
`tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS, `next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack
Authentifizierung, Reverse Proxy, Prozesshärtung noch ein zentral betriebenes für einen Server.
Datenbanksystem. Der Stack darf deshalb nicht als produktionsreif bezeichnet oder
öffentlich erreichbar gemacht werden.
## Entwicklungs-Topologie `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
| Komponente | Port | Healthcheck | Persistenz | | Komponente | Port | Healthcheck | Persistenz |
| --- | ---: | --- | --- | | --- | ---: | --- | --- |
| Next.js Web | 3001 | `GET /` | keine | | Next.js Web | 3001 | `GET /web-health` | keine |
| Express API | 3000 | `GET /health` | `./data:/app/data` | | Express API | 3000 | `GET /health` | `./data:/app/data` |
| SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` | | SQLite | Datei | Integritäts-/FK-Prüfung via Backup und Skript | `data/leistungsbilanz.db` |
@ -24,15 +32,22 @@ Verwendete Umgebungsvariablen:
- `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz - `API_INTERNAL_URL` internes API-Ziel des Next.js-Rewrites, im Compose-Netz
`http://api:3000` `http://api:3000`
- `NEXT_TELEMETRY_DISABLED=1` - `NEXT_TELEMETRY_DISABLED=1`
- `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale - `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` nur in
Dateibeobachtung in Docker `compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg
- `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten - `LOG_LEVEL` steuert für beide Dienste die Ausgabestufe des strukturierten
JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`. JSON-Loggers (`error`, `warn`, `info`, `verbose`, `debug`), Standard `info`.
Setzbar über eine `.env`-Datei neben `compose.yaml` oder Setzbar über eine `.env`-Datei neben `compose.yaml` oder
`LOG_LEVEL=verbose docker compose up`. `LOG_LEVEL=verbose docker compose up`.
Beim API-Start laufen zuerst `npm run db:migrate` und Beim API-Start laufen zuerst die Migrationen und die Schemaprüfung
`npm run db:verify:circuit-schema`. (`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.
## Logging ## Logging

23
package-lock.json generated
View file

@ -22,12 +22,15 @@
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.6.0", "@types/node": "^24.10.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"engines": {
"node": "24.x"
} }
}, },
"node_modules/@drizzle-team/brocli": { "node_modules/@drizzle-team/brocli": {
@ -1514,12 +1517,13 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.6.0", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"devOptional": true, "devOptional": true,
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.19.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/qs": { "node_modules/@types/qs": {
@ -3688,10 +3692,11 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.19.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"devOptional": true "devOptional": true,
"license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",

View file

@ -3,6 +3,9 @@
"version": "1.0.0", "version": "1.0.0",
"description": "Spreadsheet-style circuit list editor for electrical distribution planning", "description": "Spreadsheet-style circuit list editor for electrical distribution planning",
"main": "dist/server/index.js", "main": "dist/server/index.js",
"engines": {
"node": "24.x"
},
"scripts": { "scripts": {
"dev": "npm run dev:api", "dev": "npm run dev:api",
"dev:api": "tsx watch src/server/index.ts", "dev:api": "tsx watch src/server/index.ts",
@ -10,6 +13,9 @@
"docker:up": "docker compose up --build --detach", "docker:up": "docker compose up --build --detach",
"docker:down": "docker compose down", "docker:down": "docker compose down",
"docker:logs": "docker compose logs --follow", "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": "npm run build:api",
"build:api": "tsc -p tsconfig.json", "build:api": "tsc -p tsconfig.json",
"build:web": "next build", "build:web": "next build",
@ -40,7 +46,7 @@
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13", "@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.6.0", "@types/node": "^24.10.1",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",

View file

@ -0,0 +1,10 @@
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 });
}

View file

@ -14,6 +14,7 @@ import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js"; import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js"; import { circuits } from "../schema/circuits.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js"; import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
@ -119,9 +120,32 @@ export class ProjectDeviceRowSyncProjectCommandRepository
); );
for (const assignment of input.command.payload.rows) { 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 const updated = tx
.update(circuitDeviceRows) .update(circuitDeviceRows)
.set(assignment.target) .set(values)
.where(eq(circuitDeviceRows.id, assignment.rowId)) .where(eq(circuitDeviceRows.id, assignment.rowId))
.run(); .run();
if (updated.changes !== 1) { if (updated.changes !== 1) {

View file

@ -5,8 +5,9 @@ import { createLogger } from "./shared/logging/logger";
const logger = createLogger("web:navigation"); const logger = createLogger("web:navigation");
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
// The Docker healthcheck hits "/" every few seconds with no User-Agent // Probes hit "/web-health" and are excluded by the matcher below. The
// header; skip it so real navigation isn't drowned out in the logs. // User-Agent guard stays as a fallback for anything else that polls
// without one, so real navigation isn't drowned out in the logs.
if (request.headers.get("user-agent")) { if (request.headers.get("user-agent")) {
logger.info("page request", { logger.info("page request", {
method: request.method, method: request.method,
@ -17,5 +18,7 @@ export function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"], matcher: [
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
],
}; };

View file

@ -13,9 +13,13 @@ import { ProjectHistoryRepository } from "../src/db/repositories/project-history
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
import { circuitSections } from "../src/db/schema/circuit-sections.js"; import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.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 { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { externalCsvTestConfiguration } from "./fixtures/revit-csv-fixtures.js";
import { import {
createProjectDeviceRowSyncProjectCommand, createProjectDeviceRowSyncProjectCommand,
type ProjectDeviceSyncRowSnapshot, type ProjectDeviceSyncRowSnapshot,
@ -163,6 +167,79 @@ function getRow(context: DatabaseContext, rowId: string) {
return row; 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( function snapshot(
context: DatabaseContext, context: DatabaseContext,
rowId: string rowId: string
@ -474,6 +551,112 @@ 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", () => { it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase(); const fixture = createTestDatabase();
try { try {