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,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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user