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