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,265 @@
import { DomainEventsService } from './domain-events.service';
import { domainEventContext } from './domain-event-context';
import {
AnyNewDomainEvent,
DOMAIN_EVENT_TYPES,
dailyDedupeKey,
NewDomainEvent,
} from './domain-event-types';
type CreateManyArg = {
data: {
type: string;
aggregateId: string;
actorUserId: string | null;
correlationId: string;
dedupeKey: string | null;
payload: unknown;
}[];
skipDuplicates: boolean;
};
describe('DomainEventsService', () => {
let service: DomainEventsService;
let tx: {
domainEvent: { createMany: jest.Mock };
$executeRaw: jest.Mock;
};
const event: NewDomainEvent<'InspectionRecorded'> = {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
};
const daily: NewDomainEvent<'TaskDue'> = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
dedupeKey: dailyDedupeKey('TaskDue', 't1', '2026-10-13'),
};
function createManyArg(call = 0): CreateManyArg {
const calls = tx.domainEvent.createMany.mock.calls as [CreateManyArg][];
return calls[call][0];
}
beforeEach(() => {
tx = {
domainEvent: {
createMany: jest
.fn()
.mockImplementation(({ data }: CreateManyArg) =>
Promise.resolve({ count: data.length }),
),
},
$executeRaw: jest.fn().mockResolvedValue(1),
};
service = new DomainEventsService();
});
it('records the event and the wake-up on the same transaction client', async () => {
await service.record(tx as never, {
type: 'InspectionRecorded',
aggregateType: 'Inspection',
aggregateId: 'i1',
payload: { colonyId: 'c1' },
actorUserId: 'u1',
});
expect(tx.domainEvent.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
type: 'InspectionRecorded',
aggregateId: 'i1',
actorUserId: 'u1',
correlationId: expect.any(String) as unknown,
}),
],
skipDuplicates: true,
});
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it("wakes the dispatcher with pg_notify('domain_event', '') - never data in the channel", async () => {
await service.record(tx as never, event);
const [[strings, ...values]] = tx.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['domain_event']);
});
it('refuses the root PrismaService (must run inside the business transaction)', async () => {
const prismaServiceMock = {
...tx,
$connect: jest.fn(),
$transaction: jest.fn(),
};
await expect(
service.record(prismaServiceMock as never, event),
).rejects.toThrow(
'DomainEventsService.record needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('names recordAll when recordAll gets the root client', async () => {
const prismaServiceMock = { ...tx, $connect: jest.fn() };
await expect(
service.recordAll(prismaServiceMock as never, [event]),
).rejects.toThrow(
'DomainEventsService.recordAll needs the transaction client',
);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
});
it('records through a transaction client that can open nested transactions (Prisma 7)', async () => {
const nestingTx = { ...tx, $transaction: jest.fn() };
await service.record(nestingTx as never, event);
expect(tx.domainEvent.createMany).toHaveBeenCalledTimes(1);
});
it('inherits the correlation id of the event being handled', async () => {
await domainEventContext.run({ correlationId: 'corr-1' }, () =>
service.record(tx as never, event),
);
expect(createManyArg().data[0].correlationId).toBe('corr-1');
});
it('starts a new correlation id per call outside a handler, shared by all events of the call', async () => {
await service.recordAll(tx as never, [event, daily]);
await service.record(tx as never, event);
const [first, second] = createManyArg(0).data;
expect(first.correlationId).toBe(second.correlationId);
expect(createManyArg(1).data[0].correlationId).not.toBe(
first.correlationId,
);
});
it('stores missing actor and dedupe key as null and keeps the payload as given', async () => {
await service.recordAll(tx as never, [event, daily]);
const [plain, withKey] = createManyArg().data;
expect(plain.actorUserId).toBeNull();
expect(plain.dedupeKey).toBeNull();
expect(plain.payload).toEqual({ colonyId: 'c1' });
expect(withKey.dedupeKey).toBe('TaskDue:t1:2026-10-13');
});
it('wakes the worker only when something was inserted', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 0 });
await expect(service.recordAll(tx as never, [daily])).resolves.toBe(0);
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
it('returns the number of inserted events', async () => {
tx.domainEvent.createMany.mockResolvedValue({ count: 1 });
await expect(service.recordAll(tx as never, [event, daily])).resolves.toBe(
1,
);
expect(tx.$executeRaw).toHaveBeenCalledTimes(1);
});
it('does nothing for an empty list', async () => {
await expect(service.recordAll(tx as never, [])).resolves.toBe(0);
expect(tx.domainEvent.createMany).not.toHaveBeenCalled();
expect(tx.$executeRaw).not.toHaveBeenCalled();
});
});
describe('domain event catalog', () => {
it('lists every event type exactly once', () => {
expect(DOMAIN_EVENT_TYPES).toHaveLength(23);
expect(new Set(DOMAIN_EVENT_TYPES).size).toBe(DOMAIN_EVENT_TYPES.length);
expect(DOMAIN_EVENT_TYPES).toEqual(
expect.arrayContaining([
'InspectionRecorded',
'StockChanged',
'TaskDue',
'InspectionOverdue',
]),
);
});
it('builds the daily dedupe key as "<type>:<aggregateId>:<YYYY-MM-DD>"', () => {
expect(dailyDedupeKey('QueenAged', 'q1', '2026-10-13')).toBe(
'QueenAged:q1:2026-10-13',
);
});
it.each(['2026-10-13T00:00:00.000Z', '13.10.2026', '2026-1-3', ''])(
'refuses %p as day - a timestamp would make every run a new event',
(day) => {
expect(() => dailyDedupeKey('TaskDue', 't1', day)).toThrow(
'Daily dedupe key needs a YYYY-MM-DD day',
);
},
);
});
// The @ts-expect-error lines are checked by `npm run typecheck`: if the
// type stopped refusing the mismatch, the unused directive fails it.
describe('domain event typing', () => {
const tx = {
domainEvent: { createMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
const service = new DomainEventsService();
it('ties every event type to its payload in recordAll', async () => {
const recorded = service.recordAll(tx as never, [
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { dueOn: '2026-10-13' },
},
{
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
},
]);
await expect(recorded).resolves.toBe(0);
});
it('refuses a stored event value whose payload belongs to another type', () => {
const misfit = {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
payload: { colonyId: 'c1' },
} as const;
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
const wrong: AnyNewDomainEvent = misfit;
expect(wrong.type).toBe('TaskDue');
});
it('keeps record<T> checking the payload against the literal type', async () => {
await service.record(tx as never, {
type: 'TaskDue',
aggregateType: 'Task',
aggregateId: 't1',
// @ts-expect-error - TaskDue carries { dueOn }, not { colonyId }
payload: { colonyId: 'c1' },
});
expect(tx.domainEvent.createMany).toHaveBeenCalled();
});
});