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:
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user