Compare commits

..

No commits in common. "906aa751c716a93b317a374456586079fd5ae9b0" and "a17e2e3f4b479e480b316f6abdfe69634c91c961" have entirely different histories.

11 changed files with 67 additions and 412 deletions

View file

@ -1,17 +1,10 @@
FROM node:24 FROM node:22
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,29 +62,15 @@ docker compose logs --follow
docker compose down docker compose down
``` ```
`compose.yaml` startet den Produktionsstand: gebautes `dist/` und `next start`, Der Compose-Stack startet Entwicklungsserver mit Quellcode-Mounts. Er ist kein
ohne Quellcode-Mounts und ohne Datei-Watcher. Details stehen in Produktionsdeployment. 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 24 - Node.js 22
- npm - npm
```powershell ```powershell

View file

@ -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

View file

@ -1,73 +1,82 @@
name: leistungsbilanz name: leistungsbilanz
x-build: &build services:
api:
build:
context: . context: .
args: command:
# Baked into .next/routes-manifest.json by next build; see Dockerfile. - sh
API_INTERNAL_URL: http://api:3000 - -c
- npm run db:migrate && npm run db:verify:circuit-schema && npm run dev:api
x-logging: &logging environment:
PORT: "3000"
CHOKIDAR_USEPOLLING: "true"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging:
driver: json-file driver: json-file
options: options:
max-size: "20m" max-size: "20m"
max-file: "10" max-file: "10"
services:
api:
build: *build
command:
- sh
- -c
- node scripts/run-migrations.js && node scripts/db-verify-circuit-schema.js && node dist/server/index.js
environment:
NODE_ENV: production
PORT: "3000"
LOG_LEVEL: "${LOG_LEVEL:-info}"
init: true
restart: unless-stopped
logging: *logging
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: 30s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 12
start_period: 20s start_period: 20s
web: web:
build: *build build:
context: .
command: command:
- node_modules/.bin/next - npm
- start - run
- -p - dev:web
- "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/web-health').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1)) - fetch('http://localhost:3001/').then(response=>{if(!response.ok)process.exit(1)}).catch(()=>process.exit(1))
interval: 30s interval: 5s
timeout: 3s timeout: 3s
retries: 5 retries: 12
start_period: 20s start_period: 20s

View file

@ -2,27 +2,19 @@
## Aktueller Status ## 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 `compose.yaml` ist ausschließlich für lokale Entwicklung vorgesehen. Es startet
`next start`, ohne Quellcode-Mounts und ohne Datei-Watcher. Das ist der Stack `tsx watch` und `next dev`, bindet Quellcode vom Host ein und enthält weder TLS,
für einen Server. 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 ## Entwicklungs-Topologie
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 /web-health` | keine | | Next.js Web | 3001 | `GET /` | 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` |
@ -32,22 +24,15 @@ 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` nur in - `CHOKIDAR_USEPOLLING=true` und `WATCHPACK_POLLING=true` für lokale
`compose.dev.yaml`, für Dateibeobachtung über Bind-Mounts hinweg Dateibeobachtung in Docker
- `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 die Migrationen und die Schemaprüfung Beim API-Start laufen zuerst `npm run db:migrate` und
(`scripts/run-migrations.js` und `scripts/db-verify-circuit-schema.js`, im `npm run db:verify:circuit-schema`.
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,15 +22,12 @@
"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": "^24.10.1", "@types/node": "^25.6.0",
"@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": {
@ -1517,13 +1514,12 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.13.3", "version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"devOptional": true, "devOptional": true,
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.19.0"
} }
}, },
"node_modules/@types/qs": { "node_modules/@types/qs": {
@ -3692,11 +3688,10 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.18.2", "version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"devOptional": true, "devOptional": true
"license": "MIT"
}, },
"node_modules/unpipe": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",

View file

@ -3,9 +3,6 @@
"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",
@ -13,9 +10,6 @@
"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",
@ -46,7 +40,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": "^24.10.1", "@types/node": "^25.6.0",
"@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

@ -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 });
}

View file

@ -14,7 +14,6 @@ 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";
@ -120,32 +119,9 @@ 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(values) .set(assignment.target)
.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,9 +5,8 @@ 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) {
// Probes hit "/web-health" and are excluded by the matcher below. The // The Docker healthcheck hits "/" every few seconds with no User-Agent
// User-Agent guard stays as a fallback for anything else that polls // header; skip it so real navigation isn't drowned out in the logs.
// 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,
@ -18,7 +17,5 @@ export function proxy(request: NextRequest) {
} }
export const config = { export const config = {
matcher: [ matcher: ["/((?!_next/static|_next/image|favicon.ico|api).*)"],
"/((?!_next/static|_next/image|favicon.ico|api|web-health).*)",
],
}; };

View file

@ -13,13 +13,9 @@ 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,
@ -167,79 +163,6 @@ 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
@ -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", () => { it("rolls back synchronized rows for a stale project revision", () => {
const fixture = createTestDatabase(); const fixture = createTestDatabase();
try { try {