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

@@ -22,6 +22,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.Mock };
harvest: { findUnique: jest.Mock };
queenColonyHistory: { findUnique: jest.Mock };
$executeRaw: jest.Mock;
};
let settingsService: { get: jest.Mock };
let graphClient: {
@@ -93,6 +94,7 @@ describe('CalendarSyncService', () => {
feeding: { findUnique: jest.fn() },
harvest: { findUnique: jest.fn() },
queenColonyHistory: { findUnique: jest.fn() },
$executeRaw: jest.fn().mockResolvedValue(1),
};
settingsService = { get: jest.fn().mockResolvedValue(enabledSettings) };
graphClient = {
@@ -284,6 +286,44 @@ describe('CalendarSyncService', () => {
);
});
// Worker shutdown: the entry in progress is finished and settled (a cut
// off one is only taken over after the stale-claim window), but no
// further entry is claimed.
it('stops between entries once the signal aborts', async () => {
const stop = new AbortController();
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
pendingEntry({ id: 'entry-2', sourceId: 'task-2' }),
]);
prisma.task.findUnique.mockResolvedValue(dueTask);
graphClient.createEvent.mockImplementation(() => {
stop.abort();
return Promise.resolve('graph-event-1');
});
await service.processOutbox(stop.signal);
expect(graphClient.createEvent).toHaveBeenCalledTimes(1);
expectSettled({
status: 'SYNCED',
graphEventId: 'graph-event-1',
lastError: null,
});
expect(updateManyCalls().map((call) => call.where.id)).not.toContain(
'entry-2',
);
});
it('reads nothing once the signal has aborted', async () => {
const stop = new AbortController();
stop.abort();
await service.processOutbox(stop.signal);
expect(settingsService.get).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('claims a row with a conditional update before working on it', async () => {
prisma.calendarSyncOutboxEntry.findMany.mockResolvedValue([
pendingEntry(),
@@ -626,22 +666,42 @@ describe('CalendarSyncService', () => {
});
});
describe('runNow', () => {
it('gives FAILED entries a fresh set of attempts before processing', async () => {
await service.runNow();
// "Jetzt synchronisieren" only queues the pass: the worker runs it, so the
// API request neither talks to Graph nor waits for up to 20 entries.
describe('requestRun', () => {
it('requestRun resets FAILED entries and wakes the worker without calling Graph', async () => {
await service.requestRun();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [readOrder] =
prisma.calendarSyncOutboxEntry.findMany.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(readOrder);
expect(prisma.$executeRaw).toHaveBeenCalled(); // pg_notify('calendar_sync')
expect(graphClient.createEvent).not.toHaveBeenCalled();
expect(graphClient.updateEvent).not.toHaveBeenCalled();
expect(prisma.calendarSyncOutboxEntry.findMany).not.toHaveBeenCalled();
});
it('leaves FAILED entries alone on a regular cron tick', async () => {
it("wakes the calendar outbox with pg_notify('calendar_sync', '') - never data in the channel", async () => {
await service.requestRun();
const [[strings, ...values]] = prisma.$executeRaw.mock.calls as [
[TemplateStringsArray, ...unknown[]],
];
expect(strings.join('$')).toBe("SELECT pg_notify($, '')");
expect(values).toEqual(['calendar_sync']);
});
it('resets before waking, so the woken worker finds the entries PENDING', async () => {
await service.requestRun();
const [resetOrder] =
prisma.calendarSyncOutboxEntry.updateMany.mock.invocationCallOrder;
const [wakeOrder] = prisma.$executeRaw.mock.invocationCallOrder;
expect(resetOrder).toBeLessThan(wakeOrder);
});
it('leaves FAILED entries alone on a regular worker tick', async () => {
await service.processOutbox();
expect(
@@ -649,127 +709,7 @@ describe('CalendarSyncService', () => {
).not.toHaveBeenCalledWith(
expect.objectContaining({ where: { status: 'FAILED' } }),
);
});
});
// "Jetzt synchronisieren" must not hold the HTTP request for a whole pass
// (up to 20 entries with several Graph calls each): it only starts it.
describe('startRun', () => {
const flush = async () => {
for (let i = 0; i < 5; i++) {
await new Promise((resolve) => setImmediate(resolve));
}
};
/** Lets the pass hang on reading the outbox until released. */
const holdOutbox = () => {
let release: () => void = () => undefined;
prisma.calendarSyncOutboxEntry.findMany.mockReturnValue(
new Promise((resolve) => {
release = () => resolve([]);
}),
);
return () => release();
};
it('returns while the pass is still running and reports it as running', async () => {
const release = holdOutbox();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.updateMany).toHaveBeenCalledWith({
where: { status: 'FAILED' },
data: { status: 'PENDING', attempts: 0, lastError: null },
});
expect((await service.getStatus()).running).toBe(true);
release();
await flush();
expect((await service.getStatus()).running).toBe(false);
});
it('starts only one pass per pod at a time', async () => {
const release = holdOutbox();
service.startRun();
service.startRun();
await flush();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
release();
await flush();
});
it('logs a failed pass without query values and clears the indicator', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
service.startRun();
await flush();
expect(errorSpy).toHaveBeenCalledTimes(1);
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
expect((await service.getStatus()).running).toBe(false);
errorSpy.mockRestore();
});
it('lets shutdown wait for a running pass', async () => {
const release = holdOutbox();
service.startRun();
let destroyed = false;
const shutdown = service.onModuleDestroy().then(() => {
destroyed = true;
});
await flush();
expect(destroyed).toBe(false);
release();
await shutdown;
expect(destroyed).toBe(true);
});
});
// The cron tick's own errors would otherwise reach the scheduler's logger
// with the full Prisma message and stack.
describe('scheduledRun (cron tick)', () => {
it('runs the regular pass', async () => {
await service.scheduledRun();
expect(prisma.calendarSyncOutboxEntry.findMany).toHaveBeenCalledTimes(1);
});
it('never throws and logs a failed tick without the Prisma message', async () => {
const errorSpy = jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined);
prisma.calendarSyncOutboxEntry.findMany.mockRejectedValue(
new Prisma.PrismaClientKnownRequestError('title "Max Mustermann"', {
code: 'P2010',
clientVersion: '7.9.1',
meta: { modelName: 'CalendarSyncOutboxEntry' },
}),
);
await expect(service.scheduledRun()).resolves.toBeUndefined();
const [message, stack] = errorSpy.mock.calls[0] as [string, unknown];
expect(message).toContain('P2010');
expect(message).not.toContain('Mustermann');
expect(stack).toBeUndefined();
errorSpy.mockRestore();
expect(prisma.$executeRaw).not.toHaveBeenCalled();
});
});
@@ -794,8 +734,8 @@ describe('CalendarSyncService', () => {
});
});
// Another pod (its cron tick or its "Jetzt synchronisieren") may be the
// one working - its live claims show that across replicas.
// The pass runs in a worker process, never in the API that answers -
// only the worker's live claims can show it, across replicas.
it('reports running while any worker holds an entry', async () => {
prisma.calendarSyncOutboxEntry.count
.mockResolvedValueOnce(0)