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
@@ -0,0 +1,219 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as http from 'node:http';
|
||||
import { AddressInfo } from 'node:net';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Prisma } from '../generated/prisma';
|
||||
import { CalendarSyncService } from '../src/calendar-sync/calendar-sync.service';
|
||||
import { notifyWorker, WAKE_CHANNELS } from '../src/common/worker-wakeup';
|
||||
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 { DomainEventsService } from '../src/domain-events/domain-events.service';
|
||||
import { OnDomainEvent } from '../src/domain-events/on-domain-event.decorator';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { WorkerModule } from '../src/worker/worker.module';
|
||||
import { DOMAIN_EVENT_POLL_MS } from '../src/worker/worker-runtime.service';
|
||||
|
||||
const ENTITY_TYPE = 'WorkerRuntimeE2E';
|
||||
/** Every event of this file carries it; the worker below sees no other. */
|
||||
const CORRELATION_ID = randomUUID();
|
||||
/** Well below the 5 s poll: only a NOTIFY wake-up can be this fast. */
|
||||
const WAKE_UP_WITHIN_MS = 1_500;
|
||||
|
||||
@Injectable()
|
||||
class CountingHandler {
|
||||
@OnDomainEvent('TaskDue', 'test.worker-counter')
|
||||
async count(
|
||||
event: DomainEventEnvelope,
|
||||
tx: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
entityType: ENTITY_TYPE,
|
||||
entityId: event.aggregateId,
|
||||
action: 'CREATE',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The real dispatcher, limited to this file's correlation: e2e files run in
|
||||
* parallel against one database, and a worker draining all of it would
|
||||
* take over (and release the stale claims of) their events.
|
||||
*/
|
||||
@Injectable()
|
||||
class IsolatedDispatcher extends DomainEventDispatcher {
|
||||
override drain(
|
||||
_filter?: DispatchFilter,
|
||||
maxRounds?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
return super.drain({ correlationIds: [CORRELATION_ID] }, maxRounds, signal);
|
||||
}
|
||||
|
||||
override releaseStaleClaims(): Promise<number> {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Assigning undefined would store the string "undefined". */
|
||||
function restoreEnv(name: string, value: string | undefined): void {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
const server = http.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
function getJson(
|
||||
port: number,
|
||||
path: string,
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
http
|
||||
.get({ port, path }, (res) => {
|
||||
let raw = '';
|
||||
res.on('data', (chunk: Buffer) => (raw += chunk.toString()));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: raw ? (JSON.parse(raw) as unknown) : undefined,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** Polls `read` until it returns `expected`, for at most `ms`. */
|
||||
async function expectWithin<T>(
|
||||
ms: number,
|
||||
read: () => Promise<T> | T,
|
||||
expected: T,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + ms;
|
||||
let actual = await read();
|
||||
while (actual !== expected && Date.now() < deadline) {
|
||||
await sleep(25);
|
||||
actual = await read();
|
||||
}
|
||||
expect(actual).toBe(expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* The worker process as dist/worker.js starts it (WorkerModule, LISTEN,
|
||||
* loops, health endpoint) against the test database.
|
||||
*/
|
||||
describe('Worker runtime (e2e)', () => {
|
||||
const suffix = `worker-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
|
||||
const originalHealthPort = process.env.WORKER_HEALTH_PORT;
|
||||
let worker: TestingModule;
|
||||
let prisma: PrismaService;
|
||||
let processOutbox: jest.SpyInstance;
|
||||
let healthPort: number;
|
||||
|
||||
async function recordTaskDueInTx(): Promise<void> {
|
||||
const events = worker.get(DomainEventsService);
|
||||
await domainEventContext.run({ correlationId: CORRELATION_ID }, () =>
|
||||
prisma.$transaction((tx) =>
|
||||
events.record(tx, {
|
||||
type: 'TaskDue',
|
||||
aggregateType: 'Task',
|
||||
aggregateId: `${suffix}-0`,
|
||||
payload: { dueOn: '2026-10-13' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const countAuditRows = () =>
|
||||
prisma.auditLog.count({
|
||||
where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
healthPort = await freePort();
|
||||
process.env.WORKER_HEALTH_PORT = String(healthPort);
|
||||
worker = await Test.createTestingModule({
|
||||
imports: [WorkerModule],
|
||||
providers: [CountingHandler],
|
||||
})
|
||||
.overrideProvider(DomainEventDispatcher)
|
||||
.useClass(IsolatedDispatcher)
|
||||
.compile();
|
||||
prisma = worker.get(PrismaService);
|
||||
// The outbox itself is covered by calendar-sync.e2e-spec.ts; here only
|
||||
// whether the loop runs and wakes up counts.
|
||||
processOutbox = jest
|
||||
.spyOn(worker.get(CalendarSyncService), 'processOutbox')
|
||||
.mockResolvedValue(undefined);
|
||||
await worker.init();
|
||||
// The first ticks run right after bootstrap; wait for the dispatcher's.
|
||||
const dispatcher = worker.get(DomainEventDispatcher);
|
||||
await expectWithin(5_000, () => dispatcher.lastTickAt !== null, true);
|
||||
}, 15_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.auditLog.deleteMany({
|
||||
where: { entityType: ENTITY_TYPE, entityId: { startsWith: suffix } },
|
||||
});
|
||||
await prisma.domainEvent.deleteMany({
|
||||
where: { aggregateId: { startsWith: suffix } },
|
||||
});
|
||||
await worker.close();
|
||||
restoreEnv('WORKER_HEALTH_PORT', originalHealthPort);
|
||||
});
|
||||
|
||||
it('wakes up on NOTIFY and processes a new event well before the 5 s poll', async () => {
|
||||
expect(WAKE_UP_WITHIN_MS).toBeLessThan(DOMAIN_EVENT_POLL_MS);
|
||||
|
||||
await recordTaskDueInTx();
|
||||
|
||||
await expectWithin(WAKE_UP_WITHIN_MS, countAuditRows, 1);
|
||||
const event = await prisma.domainEvent.findFirstOrThrow({
|
||||
where: { aggregateId: `${suffix}-0` },
|
||||
include: { deliveries: true },
|
||||
});
|
||||
expect(event).toMatchObject({
|
||||
status: 'DONE',
|
||||
deliveries: [{ handler: 'test.worker-counter', status: 'DONE' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('wakes the calendar outbox loop on a calendar_sync notification', async () => {
|
||||
expect(processOutbox).toHaveBeenCalled();
|
||||
const before = processOutbox.mock.calls.length;
|
||||
|
||||
await notifyWorker(prisma, WAKE_CHANNELS.calendarSync);
|
||||
|
||||
await expectWithin(
|
||||
WAKE_UP_WITHIN_MS,
|
||||
() => processOutbox.mock.calls.length > before,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('answers GET /health with 200 {"status":"ok"}', async () => {
|
||||
await expect(getJson(healthPort, '/health')).resolves.toEqual({
|
||||
status: 200,
|
||||
body: { status: 'ok' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user