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

@@ -4,8 +4,8 @@ import { WorkerRuntime } from './worker-runtime.service';
/**
* The worker loops. Imported by WorkerModule (dist/worker.js) and
* AllInOneModule ("all"), never by AppModule: API pods run no background
* loops. Its dependencies come from the global Prisma, DomainEvents and
* CalendarSync modules of the importing root module.
* loops. Its dependencies come from the global Prisma, DomainEvents,
* CalendarSync and Webhooks modules of the importing root module.
*/
@Module({
providers: [WorkerRuntime],
@@ -7,12 +7,14 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import type { PrismaService } from '../prisma/prisma.service';
import type { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
import { PgListener, PgListenerOptions } from './pg-listener';
import { HEALTH_STALE_MS } from './worker-health';
import {
CALENDAR_OUTBOX_POLL_MS,
DOMAIN_EVENT_POLL_MS,
HEALTH_QUERY_TIMEOUT_MS,
WEBHOOK_DELIVERY_POLL_MS,
WorkerRuntime,
} from './worker-runtime.service';
@@ -47,6 +49,11 @@ class FakeDispatcher {
}
}
class FakeWebhookDeliveries {
releaseStaleClaims = jest.fn<Promise<number>, []>().mockResolvedValue(0);
drain = jest.fn<Promise<number>, unknown[]>().mockResolvedValue(0);
}
describe('WorkerRuntime', () => {
const ListenerMock = PgListener as unknown as jest.Mock;
const originalDatabaseUrl = process.env.DATABASE_URL;
@@ -56,6 +63,7 @@ describe('WorkerRuntime', () => {
let calendarSync: {
processOutbox: jest.Mock<Promise<void>, [AbortSignal?]>;
};
let webhookDeliveries: FakeWebhookDeliveries;
let runtime: WorkerRuntime;
let logError: jest.SpyInstance;
@@ -84,6 +92,7 @@ describe('WorkerRuntime', () => {
.fn<Promise<void>, [AbortSignal?]>()
.mockResolvedValue(undefined),
};
webhookDeliveries = new FakeWebhookDeliveries();
logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
@@ -92,6 +101,7 @@ describe('WorkerRuntime', () => {
prisma as unknown as PrismaService,
dispatcher as unknown as DomainEventDispatcher,
calendarSync as unknown as CalendarSyncService,
webhookDeliveries as unknown as WebhookDeliveryWorker,
);
});
@@ -110,6 +120,7 @@ describe('WorkerRuntime', () => {
it('exposes the documented poll intervals', () => {
expect(DOMAIN_EVENT_POLL_MS).toBe(5_000);
expect(CALENDAR_OUTBOX_POLL_MS).toBe(60_000);
expect(WEBHOOK_DELIVERY_POLL_MS).toBe(5_000);
expect(HEALTH_QUERY_TIMEOUT_MS).toBe(5_000);
});
@@ -119,7 +130,7 @@ describe('WorkerRuntime', () => {
expect(ListenerMock).toHaveBeenCalledWith(
expect.objectContaining({
connectionString: DATABASE_URL,
channels: ['domain_event', 'calendar_sync'],
channels: ['domain_event', 'calendar_sync', 'webhook_delivery'],
}),
);
expect(listener.start).toHaveBeenCalledTimes(1);
@@ -144,6 +155,41 @@ describe('WorkerRuntime', () => {
expect(calendarSync.processOutbox).toHaveBeenCalledWith(
expect.any(AbortSignal),
);
// Webhook deliveries: stale claims first, then every due delivery of
// the database (no filter), stoppable by shutdown.
expect(webhookDeliveries.releaseStaleClaims).toHaveBeenCalledTimes(1);
expect(webhookDeliveries.drain).toHaveBeenCalledWith(
undefined,
DRAIN_MAX_ROUNDS,
expect.any(AbortSignal),
);
const [webhookReleaseOrder] =
webhookDeliveries.releaseStaleClaims.mock.invocationCallOrder;
const [webhookDrainOrder] =
webhookDeliveries.drain.mock.invocationCallOrder;
expect(webhookReleaseOrder).toBeLessThan(webhookDrainOrder);
});
it('polls webhook deliveries every 5 s', async () => {
await bootstrap();
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(WEBHOOK_DELIVERY_POLL_MS - 1);
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(2);
expect(webhookDeliveries.releaseStaleClaims).toHaveBeenCalledTimes(2);
});
it('wakes the webhook-deliveries loop on a webhook_delivery notification', async () => {
await bootstrap();
notify('webhook_delivery');
await jest.advanceTimersByTimeAsync(0);
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(2);
expect(dispatcher.drain).toHaveBeenCalledTimes(1);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1);
});
it('polls domain events every 5 s and the calendar outbox every 60 s', async () => {
@@ -278,7 +324,7 @@ describe('WorkerRuntime', () => {
});
// A running tick must not claim up to 19 more batches of 50 events or
// 19 more calendar entries while the pod is being stopped.
// deliveries, or 19 more calendar entries, while the pod is being stopped.
it('hands the running work a stop signal that shutdown aborts', async () => {
await bootstrap();
const [, , drainSignal] = dispatcher.drain.mock.calls[0] as [
@@ -287,8 +333,14 @@ describe('WorkerRuntime', () => {
AbortSignal,
];
const [outboxSignal] = calendarSync.processOutbox.mock.calls[0];
const [, , webhookSignal] = webhookDeliveries.drain.mock.calls[0] as [
unknown,
unknown,
AbortSignal,
];
expect(drainSignal.aborted).toBe(false);
expect(outboxSignal).toBe(drainSignal);
expect(webhookSignal).toBe(drainSignal);
await runtime.onModuleDestroy();
@@ -302,9 +354,11 @@ describe('WorkerRuntime', () => {
expect(listener.stop).toHaveBeenCalledTimes(1);
notify('domain_event');
notify('webhook_delivery');
await jest.advanceTimersByTimeAsync(CALENDAR_OUTBOX_POLL_MS * 2);
expect(dispatcher.drain).toHaveBeenCalledTimes(1);
expect(calendarSync.processOutbox).toHaveBeenCalledTimes(1);
expect(webhookDeliveries.drain).toHaveBeenCalledTimes(1);
});
// A pass cut off mid-entry would only be taken over after the 5 min
@@ -13,12 +13,14 @@ import {
DRAIN_MAX_ROUNDS,
} from '../domain-events/domain-event-dispatcher';
import { PrismaService } from '../prisma/prisma.service';
import { WebhookDeliveryWorker } from '../webhooks/webhook-delivery.worker';
import { PgListener } from './pg-listener';
import { PollingLoop } from './polling-loop';
import { HEALTH_STALE_MS, WorkerHealth } from './worker-health';
export const DOMAIN_EVENT_POLL_MS = 5_000;
export const CALENDAR_OUTBOX_POLL_MS = 60_000;
export const WEBHOOK_DELIVERY_POLL_MS = 5_000;
/** The health check's SELECT 1 must answer within this. */
export const HEALTH_QUERY_TIMEOUT_MS = 5_000;
@@ -61,6 +63,7 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
private readonly prisma: PrismaService,
private readonly dispatcher: DomainEventDispatcher,
private readonly calendarSync: CalendarSyncService,
private readonly webhookDeliveries: WebhookDeliveryWorker,
) {
this.loops = new Map(
this.workerLoops().map(({ channel, name, intervalMs, tick }) => [
@@ -85,6 +88,12 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
channel: WAKE_CHANNELS.calendarSync,
tick: () => this.calendarSync.processOutbox(this.stopping.signal),
},
{
name: 'webhook-deliveries',
intervalMs: WEBHOOK_DELIVERY_POLL_MS,
channel: WAKE_CHANNELS.webhookDeliveries,
tick: () => this.deliverWebhooks(),
},
];
}
@@ -172,4 +181,14 @@ export class WorkerRuntime implements OnApplicationBootstrap, OnModuleDestroy {
this.noteDispatcherProgress();
}
}
private async deliverWebhooks(): Promise<void> {
await this.webhookDeliveries.releaseStaleClaims();
// No filter: every due delivery of the database.
await this.webhookDeliveries.drain(
undefined,
DRAIN_MAX_ROUNDS,
this.stopping.signal,
);
}
}