feat: platform backbone and field capture, first part (checkpoint)

Platform backbone (plan 2026-10-13-plattform-rueckgrat), reviewed tasks:
- run roles api/worker/job/all with shared startup checks (Task 1)
- schema and migration for DomainEvent, deliveries, JobRun, ThrottlerHit (Task 2)
- audit, notifications and domain events written in the same transaction (Task 3)
- dispatcher with SKIP LOCKED claims, retries and stale release (Task 4)
- worker building blocks: tick, LISTEN/NOTIFY wake-up, health (Task 5)
- worker process and entry points (Task 6)
- throttler counters in Postgres (Task 19)
- separate keys per purpose with key ring (Task 20)
- public origin page without exact location (Task 22)
- shop keys split into read and write (Task 23)
- Kustomize base, components, overlay encry, CI validation (Task 26)

Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), reviewed tasks:
- brood frames, read endpoints, apiary overview and offline pack
- network basics, offline queue v3, navigation sheets, form building blocks
- new inspection with last-time hints and weather, varroa per day,
  treatment prefill, apiary page with round
- inspection photo (Task 10) is implemented, review pending

Not rolled out. Remaining tasks follow in later commits.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-13 17:57:54 +02:00
1 parent 10cbe9bf2d
commit c01a38835a
340 files changed
+25695 -1808

No files matched your search

@@ -0,0 +1,538 @@
import { Injectable, Logger } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { Prisma } from '../generated/prisma';
import * as outboxClaims from '../src/common/outbox/outbox-claims';
import { domainEventContext } from '../src/domain-events/domain-event-context';
import {
DispatchFilter,
DomainEventDispatcher,
} from '../src/domain-events/domain-event-dispatcher';
import type { DomainEventEnvelope } from '../src/domain-events/domain-event-types';
import { DomainEventsModule } from '../src/domain-events/domain-events.module';
import { DomainEventsService } from '../src/domain-events/domain-events.service';
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
import { PrismaModule } from '../src/prisma/prisma.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { drainDomainEvents } from './utils/domain-events';
const ENTITY_TYPE = 'DispatcherE2E';
/** `entered` resolves when a handler reaches the gate; `open()` lets it on. */
class Gate {
private signalEntered!: () => void;
private release!: () => void;
readonly entered = new Promise<void>((resolve) => {
this.signalEntered = resolve;
});
private readonly opened = new Promise<void>((resolve) => {
this.release = resolve;
});
pass(): Promise<void> {
this.signalEntered();
return this.opened;
}
open(): void {
this.release();
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** True if `promise` has not settled within `ms`. */
function isPending(promise: Promise<unknown>, ms = 300): Promise<boolean> {
return Promise.race([promise.then(() => false), sleep(ms).then(() => true)]);
}
/** Waits until a run reaches `gate`; fails if the run ends without it. */
async function enteredBeforeDone(
gate: Gate,
run: Promise<number>,
): Promise<void> {
await Promise.race([
gate.entered,
run.then((claimed) => {
throw new Error(`Run ended (${claimed} claimed) before the gate`);
}),
]);
}
/**
* TaskDue, QueenAged and InspectionOverdue have no real handlers in the
* plan, so these test handlers are the only ones that react - and only to
* this file's events, because every dispatch below is filtered by
* correlation id.
*/
@Injectable()
class DispatcherTestHandlers {
readonly seen: { event: DomainEventEnvelope; correlationId?: string }[] = [];
@OnDomainEvent('TaskDue', 'test.counter')
async count(
event: DomainEventEnvelope,
tx: Prisma.TransactionClient,
): Promise<void> {
this.seen.push({
event,
correlationId: domainEventContext.getStore()?.correlationId,
});
await tx.auditLog.create({
data: {
entityType: ENTITY_TYPE,
entityId: event.aggregateId,
action: 'CREATE',
},
});
}
@OnDomainEvent('QueenAged', 'test.failing')
fail(): Promise<void> {
return Promise.reject(new Error('boom'));
}
/** Aggregates this instance ran 'test.gated' for, in order. */
readonly gatedRuns: string[] = [];
private readonly gates = new Map<string, Gate>();
/** Holds 'test.gated' for one aggregate inside its delivery transaction. */
hold(aggregateId: string): Gate {
const gate = new Gate();
this.gates.set(aggregateId, gate);
return gate;
}
@OnDomainEvent('InspectionOverdue', 'test.gated')
async gated(
event: DomainEventEnvelope,
tx: Prisma.TransactionClient,
): Promise<void> {
this.gatedRuns.push(event.aggregateId);
await this.gates.get(event.aggregateId)?.pass();
await tx.auditLog.create({
data: {
entityType: ENTITY_TYPE,
entityId: event.aggregateId,
action: 'CREATE',
},
});
}
}
async function createDispatcherContext(): Promise<TestingModule> {
const moduleRef = await Test.createTestingModule({
imports: [PrismaModule, DomainEventsModule],
providers: [DispatcherTestHandlers],
}).compile();
await moduleRef.init();
return moduleRef;
}
describe('DomainEventDispatcher (e2e)', () => {
const suffix = `dispatch-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
let appA: TestingModule;
let appB: TestingModule;
let prisma: PrismaService;
/** Records n TaskDue events in one transaction; returns their filter. */
async function recordTaskDue(
n: number,
group: string,
): Promise<DispatchFilter> {
const events = appA.get(DomainEventsService);
await prisma.$transaction((tx) =>
events.recordAll(
tx,
Array.from({ length: n }, (_, i) => ({
type: 'TaskDue' as const,
aggregateType: 'Task' as const,
aggregateId: `${suffix}-${group}-${i}`,
payload: { dueOn: '2026-10-13' },
})),
),
);
return filterFor(group);
}
async function filterFor(group: string): Promise<DispatchFilter> {
const rows = await prisma.domainEvent.findMany({
where: { aggregateId: { startsWith: `${suffix}-${group}-` } },
select: { correlationId: true },
distinct: ['correlationId'],
});
return { correlationIds: rows.map((r) => r.correlationId) };
}
async function waitForStatus(id: string, status: string): Promise<void> {
for (let i = 0; i < 250; i++) {
const event = await prisma.domainEvent.findUnique({
where: { id },
select: { status: true },
});
if (event?.status === status) {
return;
}
await sleep(20);
}
throw new Error(`Domain event ${id} never reached ${status}`);
}
function effects(prefix: string): Promise<number> {
return prisma.auditLog.count({
where: { entityType: ENTITY_TYPE, entityId: { startsWith: prefix } },
});
}
beforeAll(async () => {
[appA, appB] = await Promise.all([
createDispatcherContext(),
createDispatcherContext(),
]);
prisma = appA.get(PrismaService);
});
afterEach(() => jest.restoreAllMocks());
afterAll(async () => {
await prisma.auditLog.deleteMany({
where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } },
});
await prisma.domainEvent.deleteMany({
where: { aggregateId: { startsWith: suffix } },
});
await Promise.all([appA.close(), appB.close()]);
});
it('two dispatchers in parallel produce exactly one effect per event and handler', async () => {
const filter = await recordTaskDue(20, 'parallel');
const [a, b] = await Promise.all([
appA.get(DomainEventDispatcher).drain(filter),
appB.get(DomainEventDispatcher).drain(filter),
]);
expect(a + b).toBe(20);
expect(await effects(`${suffix}-parallel-`)).toBe(20);
const events = await prisma.domainEvent.findMany({
where: { aggregateId: { startsWith: `${suffix}-parallel-` } },
include: { deliveries: true },
});
expect(events).toHaveLength(20);
for (const event of events) {
expect(event).toMatchObject({
status: 'DONE',
attempts: 0,
claimedAt: null,
lastError: null,
});
expect(event.processedAt).toBeInstanceOf(Date);
expect(event.deliveries).toEqual([
expect.objectContaining({
handler: 'test.counter',
status: 'DONE',
attempts: 1,
}),
]);
}
});
it('hands the handler the stored event and its correlation id', async () => {
const filter = await recordTaskDue(1, 'envelope');
const handlers = appA.get(DispatcherTestHandlers);
await appA.get(DomainEventDispatcher).drain(filter);
const seen = handlers.seen.find(
(entry) => entry.event.aggregateId === `${suffix}-envelope-0`,
);
expect(seen?.correlationId).toBe(filter.correlationIds?.[0]);
expect(seen?.event).toMatchObject({
type: 'TaskDue',
aggregateType: 'Task',
payload: { dueOn: '2026-10-13' },
actorUserId: null,
correlationId: filter.correlationIds?.[0],
attempts: 0,
});
expect(seen?.event.occurredAt).toBeInstanceOf(Date);
});
it('skips an event another worker holds locked instead of waiting for it', async () => {
const filter = await recordTaskDue(2, 'locked');
const { id } = await prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: `${suffix}-locked-0` },
});
const lock = new Gate();
// Another worker in the middle of its claim statement.
const holder = appB.get(PrismaService).$transaction(
async (tx) => {
await tx.$queryRaw`SELECT "id" FROM "DomainEvent" WHERE "id" = ${id} FOR UPDATE`;
await lock.pass();
},
{ timeout: 15_000 },
);
await lock.entered;
const dispatcher = appA.get(DomainEventDispatcher);
try {
const run = dispatcher.runOnce(filter);
// Generous window against slow CI; without SKIP LOCKED the claim
// blocks until the lock opens, so it would still be pending here.
expect(await isPending(run, 4_000)).toBe(false);
await expect(run).resolves.toBe(1);
expect(await effects(`${suffix}-locked-1`)).toBe(1);
expect(await effects(`${suffix}-locked-0`)).toBe(0);
} finally {
lock.open();
await holder;
}
await expect(dispatcher.runOnce(filter)).resolves.toBe(1);
expect(await effects(`${suffix}-locked-0`)).toBe(1);
}, 20_000);
it('a stale release that ends in FAILED also fails the open deliveries', async () => {
const filter = await recordTaskDue(1, 'stale-failed');
const { id } = await prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: `${suffix}-stale-failed-0` },
});
// Seven attempts used up, the worker of the eighth died mid-event.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "status" = 'PROCESSING', "attempts" = 7, "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`;
await prisma.domainEventDelivery.create({
data: { eventId: id, handler: 'test.counter', attempts: 7 },
});
await expect(
appA.get(DomainEventDispatcher).releaseStaleClaims(),
).resolves.toBeGreaterThanOrEqual(1);
expect(
await prisma.domainEvent.findUnique({
where: { id },
include: { deliveries: true },
}),
).toMatchObject({
status: 'FAILED',
attempts: 8,
claimedAt: null,
lastError: 'claim expired (worker restarted?)',
deliveries: [{ handler: 'test.counter', status: 'FAILED', attempts: 7 }],
});
await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe(
0,
);
});
it('drains a backlog of 120 events in batches of at most 50', async () => {
const filter = await recordTaskDue(120, 'backlog');
const dispatcher = appA.get(DomainEventDispatcher);
const rounds: number[] = [];
for (let i = 0; i < 4; i++) {
rounds.push(await dispatcher.runOnce(filter));
}
expect(rounds).toEqual([50, 50, 20, 0]);
expect(await effects(`${suffix}-backlog-`)).toBe(120);
});
it('drainDomainEvents processes only the events of the given root aggregates', async () => {
await recordTaskDue(2, 'root');
await recordTaskDue(1, 'other');
await drainDomainEvents(appA, [`${suffix}-root-0`]);
expect(await effects(`${suffix}-root-`)).toBe(2);
expect(await effects(`${suffix}-other-`)).toBe(0);
await drainDomainEvents(appA, [`${suffix}-other-0`]);
expect(await effects(`${suffix}-other-`)).toBe(1);
});
it('a stale-released event does not repeat a handler that already committed', async () => {
const filter = await recordTaskDue(1, 'stale');
const dispatcher = appA.get(DomainEventDispatcher);
// The worker dies after the handler committed, before settling the event.
jest
.spyOn(outboxClaims, 'settleSuccess')
.mockRejectedValueOnce(new Error('worker killed'));
const logError = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
await dispatcher.runOnce(filter);
expect(logError).toHaveBeenCalledWith(
expect.stringContaining('not settled: worker killed'),
);
const id = (
await prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: `${suffix}-stale-0` },
})
).id;
expect(
await prisma.domainEvent.findUnique({ where: { id } }),
).toMatchObject({ status: 'PROCESSING' });
expect(await effects(`${suffix}-stale-`)).toBe(1);
await prisma.$executeRaw`UPDATE "DomainEvent" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${id}`;
await expect(
dispatcher.releaseStaleClaims(),
).resolves.toBeGreaterThanOrEqual(1);
expect(
await prisma.domainEvent.findUnique({ where: { id } }),
).toMatchObject({
status: 'PENDING',
attempts: 1,
claimedAt: null,
lastError: 'claim expired (worker restarted?)',
});
await appB.get(DomainEventDispatcher).drain(filter);
expect(await effects(`${suffix}-stale-`)).toBe(1);
expect(
await prisma.domainEvent.findUnique({
where: { id },
include: { deliveries: true },
}),
).toMatchObject({
status: 'DONE',
attempts: 1,
deliveries: [{ handler: 'test.counter', status: 'DONE', attempts: 1 }],
});
});
it('a worker whose claim expired waits for the delivery lock and skips the handler the new owner committed', async () => {
const events = appA.get(DomainEventsService);
for (const i of [0, 1]) {
await prisma.$transaction((tx) =>
events.record(tx, {
type: 'InspectionOverdue',
aggregateType: 'Colony',
aggregateId: `${suffix}-gated-${i}`,
payload: { dueOn: '2026-10-13', lastInspectedOn: null },
}),
);
}
const filter = await filterFor('gated');
const [e0, e1] = await Promise.all(
[0, 1].map((i) =>
prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: `${suffix}-gated-${i}` },
}),
),
);
// Worker A handles e0 before e1.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "occurredAt" = "occurredAt" - interval '1 second' WHERE "id" = ${e0.id}`;
// e1's handler failed once before, so its delivery row exists: only
// the row lock (SELECT ... FOR UPDATE), not the unique insert, can
// make a second worker wait.
await prisma.domainEventDelivery.create({
data: {
eventId: e1.id,
handler: 'test.gated',
attempts: 1,
lastError: 'earlier failure',
},
});
const handlersA = appA.get(DispatcherTestHandlers);
const handlersB = appB.get(DispatcherTestHandlers);
const gateA = handlersA.hold(e0.aggregateId);
const gateB = handlersB.hold(e1.aggregateId);
const logWarn = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined);
const runs: Promise<number>[] = [];
try {
// A claims both events and is held inside the handler for e0 ...
const runA = appA.get(DomainEventDispatcher).runOnce(filter);
runs.push(runA);
await enteredBeforeDone(gateA, runA);
// ... while its claim on e1 expires: B claims e1 and is held inside
// e1's delivery transaction, the delivery row locked.
await prisma.$executeRaw`UPDATE "DomainEvent" SET "claimedAt" = now() - interval '6 minutes' WHERE "id" = ${e1.id}`;
await appB.get(DomainEventDispatcher).releaseStaleClaims();
const runB = appB.get(DomainEventDispatcher).runOnce(filter);
runs.push(runB);
await enteredBeforeDone(gateB, runB);
// A finishes e0, moves on to e1 and has to wait for B's delivery row.
gateA.open();
await waitForStatus(e0.id, 'DONE');
expect(await isPending(runA)).toBe(true);
// B commits; A then finds e1's delivery DONE and skips the handler.
gateB.open();
await expect(runB).resolves.toBe(1);
await expect(runA).resolves.toBe(2);
} finally {
gateA.open();
gateB.open();
await Promise.allSettled(runs);
}
expect(handlersA.gatedRuns).toEqual([e0.aggregateId]);
expect(handlersB.gatedRuns).toEqual([e1.aggregateId]);
expect(await effects(`${suffix}-gated-`)).toBe(2);
const delivered = (attempts: number) => ({
deliveries: [{ handler: 'test.gated', status: 'DONE', attempts }],
});
expect(
await prisma.domainEvent.findMany({
where: { id: { in: [e0.id, e1.id] } },
include: { deliveries: true },
orderBy: { aggregateId: 'asc' },
}),
).toMatchObject([
{ id: e0.id, status: 'DONE', attempts: 0, ...delivered(1) },
{ id: e1.id, status: 'DONE', attempts: 1, ...delivered(2) },
]);
// A's claim on e1 was gone, so B - the owner - settled it.
expect(logWarn).toHaveBeenCalledWith(
expect.stringContaining(`${e1.id} (InspectionOverdue): claim lost`),
);
});
it('schedules a failed event about 30 s ahead by the database clock', async () => {
const events = appA.get(DomainEventsService);
await prisma.$transaction((tx) =>
events.record(tx, {
type: 'QueenAged',
aggregateType: 'Queen',
aggregateId: `${suffix}-failing-0`,
payload: { hatchYear: 2024, colonyId: null },
}),
);
const filter = await filterFor('failing');
await expect(appA.get(DomainEventDispatcher).drain(filter)).resolves.toBe(
1,
);
const event = await prisma.domainEvent.findFirstOrThrow({
where: { aggregateId: `${suffix}-failing-0` },
include: { deliveries: true },
});
expect(event).toMatchObject({
status: 'PENDING',
attempts: 1,
claimedAt: null,
lastError: 'test.failing: boom',
deliveries: [
{
handler: 'test.failing',
status: 'PENDING',
attempts: 1,
lastError: 'boom',
},
],
});
const [{ s }] = await prisma.$queryRaw<
{ s: number }[]
>`SELECT EXTRACT(EPOCH FROM "availableAt" - now())::float AS s FROM "DomainEvent" WHERE id = ${event.id}`;
expect(s).toBeGreaterThan(25);
expect(s).toBeLessThanOrEqual(30.5);
});
});