feat: platform backbone and field capture complete (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed:
- webhooks with delivery table, retries and stable delivery id
- calendar handlers; a finished task removes its Outlook event
- tasks, automations, inspections, varroa, treatments, feedings,
  harvests, colonies, wintering, stock and bottling record domain events
  in the same transaction; legacy post-commit calls removed
- row locks through one helper (lockRowForUpdate, lockRowsForShare,
  lockRowsForUpdate)
- job framework with JobRun, cli exit codes, cleanup, task templates,
  daily evaluation, hourly app-backup and the rekey job
- system API and page "Verwaltung > System" with retry
- @nestjs/schedule removed, guard tests against in-process schedulers

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed:
- inspection photo offline without metadata
- bulk capture for treatments, feedings and varroa (backend + frontend)
- offline stand mode with service worker, offline pages and packs
- offline session, cold start and logout on shared devices

Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md.
Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema
drift; frontend lint, typecheck, 913 tests, build. Not rolled out.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:36:38 +02:00
1 parent c01a38835a
commit cdd30cf3ee
288 files changed
+39165 -2853

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
import { Prisma } from '../../generated/prisma';
import {
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
} from './row-lock';
describe('lockRowForUpdate', () => {
it('locks the row by id with a parameterised FOR UPDATE on the mapped table', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, 'Colony', 'c1');
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe('SELECT id FROM "Colony" WHERE id = ? FOR UPDATE');
expect(query.values).toEqual(['c1']);
});
it('refuses the root client', async () => {
const root = { $connect: jest.fn(), $queryRaw: jest.fn() };
await expect(
lockRowForUpdate(root as never, 'Colony', 'c1'),
).rejects.toThrow('lockRowForUpdate needs the transaction client');
expect(root.$queryRaw).not.toHaveBeenCalled();
});
it('refuses a table outside the fixed map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowForUpdate(tx as never, 'User"; DROP TABLE x; --' as never, 'c1'),
).rejects.toThrow('lockRowForUpdate: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lockRowsForShare', () => {
it('locks every row with one parameterised FOR SHARE in ascending id order', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
const ids = ['c3', 'c1', 'c2'];
await lockRowsForShare(tx as never, 'Colony', ids);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR SHARE',
);
expect(query.values).toEqual([['c1', 'c2', 'c3']]);
// The caller's list stays as it was.
expect(ids).toEqual(['c3', 'c1', 'c2']);
});
it('sends no statement for an empty list', async () => {
const tx = { $queryRaw: jest.fn() };
await lockRowsForShare(tx as never, 'Colony', []);
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('refuses the root client', async () => {
const root = { $connect: jest.fn(), $queryRaw: jest.fn() };
await expect(
lockRowsForShare(root as never, 'Colony', ['c1']),
).rejects.toThrow('lockRowsForShare needs the transaction client');
expect(root.$queryRaw).not.toHaveBeenCalled();
});
it('refuses a table outside the fixed map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForShare(tx as never, 'User"; DROP TABLE x; --' as never, ['c1']),
).rejects.toThrow('lockRowsForShare: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lockRowForUpdate result', () => {
it('resolves to whether the row exists', async () => {
const tx = {
$queryRaw: jest
.fn()
.mockResolvedValueOnce([{ id: 'p1' }])
.mockResolvedValueOnce([]),
};
await expect(lockRowForUpdate(tx as never, 'Product', 'p1')).resolves.toBe(
true,
);
await expect(lockRowForUpdate(tx as never, 'Product', 'p2')).resolves.toBe(
false,
);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe('SELECT id FROM "Product" WHERE id = ? FOR UPDATE');
});
});
describe('lockRowsForUpdate', () => {
it('locks every row with one parameterised FOR UPDATE in ascending id order', async () => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
const ids = ['c3', 'c1', 'c2'];
await lockRowsForUpdate(tx as never, 'Colony', ids);
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR UPDATE',
);
expect(query.values).toEqual([['c1', 'c2', 'c3']]);
expect(ids).toEqual(['c3', 'c1', 'c2']);
});
it('sends no statement for an empty list', async () => {
const tx = { $queryRaw: jest.fn() };
await lockRowsForUpdate(tx as never, 'Colony', []);
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('refuses the root client', async () => {
const root = { $connect: jest.fn(), $queryRaw: jest.fn() };
await expect(
lockRowsForUpdate(root as never, 'Colony', ['c1']),
).rejects.toThrow('lockRowsForUpdate needs the transaction client');
expect(root.$queryRaw).not.toHaveBeenCalled();
});
it('refuses a table outside the fixed map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForUpdate(tx as never, 'User"; DROP TABLE x; --' as never, [
'c1',
]),
).rejects.toThrow('lockRowsForUpdate: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
+90
View File
@@ -0,0 +1,90 @@
import { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from './db-client';
// Fixed identifier map: a table name is never taken from input.
const LOCKABLE_TABLES = {
Colony: Prisma.raw('"Colony"'),
Task: Prisma.raw('"Task"'),
BottlingBatch: Prisma.raw('"BottlingBatch"'),
Feeding: Prisma.raw('"Feeding"'),
Harvest: Prisma.raw('"Harvest"'),
Inspection: Prisma.raw('"Inspection"'),
Product: Prisma.raw('"Product"'),
ProductionBatch: Prisma.raw('"ProductionBatch"'),
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
StockItem: Prisma.raw('"StockItem"'),
Treatment: Prisma.raw('"Treatment"'),
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
/**
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
* read-modify-write on it is serialised with concurrent writers. Re-read the
* row after locking and use that copy as "before". Resolves to whether the
* row exists.
*/
export async function lockRowForUpdate(
tx: Prisma.TransactionClient,
table: LockableTable,
id: string,
): Promise<boolean> {
assertTransactionClient(tx, 'lockRowForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowForUpdate: table not lockable');
}
const locked = await tx.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
);
return locked.length > 0;
}
/**
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
* ONE statement in ascending id order: it meets lockRowsForShare on the same
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
* ids lock nothing; re-read the rows after locking.
*/
export async function lockRowsForUpdate(
tx: Prisma.TransactionClient,
table: LockableTable,
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForUpdate: table not lockable');
}
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`,
);
}
/**
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
* cannot be changed or deleted meanwhile, other readers are not blocked. The
* rows are locked in ascending id order, so two transactions locking
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
* after locking.
*/
export async function lockRowsForShare(
tx: Prisma.TransactionClient,
table: LockableTable,
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForShare');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForShare: table not lockable');
}
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`,
);
}