feat: platform backbone and field capture complete (checkpoint)
Platform backbone (plan 2026-10-13-plattform-rueckgrat), all tasks reviewed: - webhooks with delivery table, retries and stable delivery id - calendar handlers; a finished task removes its Outlook event - tasks, automations, inspections, varroa, treatments, feedings, harvests, colonies, wintering, stock and bottling record domain events in the same transaction; legacy post-commit calls removed - row locks through one helper (lockRowForUpdate, lockRowsForShare, lockRowsForUpdate) - job framework with JobRun, cli exit codes, cleanup, task templates, daily evaluation, hourly app-backup and the rekey job - system API and page "Verwaltung > System" with retry - @nestjs/schedule removed, guard tests against in-process schedulers Field capture T3 (plan 2026-10-13-t3-erfassung-am-stand), all tasks reviewed: - inspection photo offline without metadata - bulk capture for treatments, feedings and varroa (backend + frontend) - offline stand mode with service worker, offline pages and packs - offline session, cold start and logout on shared devices Docs: ARCHITECTURE.md assumptions and sections updated, TASKS.md. Gates: backend lint, typecheck, 1770 unit, 420 e2e, build, no schema drift; frontend lint, typecheck, 913 tests, build. Not rolled out. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c01a38835a
commit
cdd30cf3ee
288 files changed
+39165
-2853
No files matched your search
@@ -0,0 +1,111 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { assertLivingColonies } from './assert-living-colonies';
|
||||
|
||||
describe('assertLivingColonies', () => {
|
||||
let tx: { $queryRaw: jest.Mock; colony: { findMany: jest.Mock } };
|
||||
|
||||
beforeEach(() => {
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
colony: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
};
|
||||
});
|
||||
|
||||
async function rejection(colonyIds: string[]): Promise<BadRequestException> {
|
||||
const error: unknown = await assertLivingColonies(
|
||||
tx as never,
|
||||
colonyIds,
|
||||
).catch((caught: unknown) => caught);
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
return error as BadRequestException;
|
||||
}
|
||||
|
||||
it('locks the colonies for share before it reads them through the transaction', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'c2', status: 'ACTIVE', apiaryId: 'a1' },
|
||||
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
|
||||
]);
|
||||
|
||||
await assertLivingColonies(tx as never, ['c2', 'c1']);
|
||||
|
||||
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
|
||||
expect(query.sql).toBe(
|
||||
'SELECT id FROM "Colony" WHERE id = ANY(?) ORDER BY id FOR SHARE',
|
||||
);
|
||||
expect(query.values).toEqual([['c1', 'c2']]);
|
||||
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
tx.colony.findMany.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(tx.colony.findMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['c2', 'c1'] } },
|
||||
select: { id: true, status: true, apiaryId: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the apiary of every colony that still lives', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
|
||||
{ id: 'c2', status: 'WEAK', apiaryId: 'a2' },
|
||||
{ id: 'c3', status: 'CONSPICUOUS', apiaryId: 'a1' },
|
||||
{ id: 'c4', status: 'QUEENLESS', apiaryId: 'a2' },
|
||||
]);
|
||||
|
||||
const colonies = await assertLivingColonies(tx as never, [
|
||||
'c1',
|
||||
'c2',
|
||||
'c3',
|
||||
'c4',
|
||||
]);
|
||||
|
||||
expect(colonies).toEqual(
|
||||
new Map([
|
||||
['c1', { apiaryId: 'a1' }],
|
||||
['c2', { apiaryId: 'a2' }],
|
||||
['c3', { apiaryId: 'a1' }],
|
||||
['c4', { apiaryId: 'a2' }],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['DISSOLVED', 'SOLD', 'DECEASED', 'MERGED'])(
|
||||
'rejects a colony with the status %s',
|
||||
async (status) => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'c1', status: 'ACTIVE', apiaryId: 'a1' },
|
||||
{ id: 'c2', status, apiaryId: 'a1' },
|
||||
]);
|
||||
|
||||
const error = await rejection(['c1', 'c2']);
|
||||
|
||||
expect(error.getResponse()).toEqual({
|
||||
message: 'Einige Völker sind nicht (mehr) erfassbar',
|
||||
invalidColonyIds: ['c2'],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('names every ended and unknown colony in request order', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'c1', status: 'SOLD', apiaryId: 'a1' },
|
||||
{ id: 'c2', status: 'ACTIVE', apiaryId: 'a1' },
|
||||
]);
|
||||
|
||||
const error = await rejection(['missing', 'c2', 'c1']);
|
||||
|
||||
expect(error.getResponse()).toEqual({
|
||||
message: 'Einige Völker sind nicht (mehr) erfassbar',
|
||||
invalidColonyIds: ['missing', 'c1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses the root client before it locks or reads anything', async () => {
|
||||
const root = { $connect: jest.fn(), ...tx };
|
||||
|
||||
await expect(assertLivingColonies(root as never, ['c1'])).rejects.toThrow(
|
||||
'needs the transaction client',
|
||||
);
|
||||
expect(tx.$queryRaw).not.toHaveBeenCalled();
|
||||
expect(tx.colony.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { isLivingColonyStatus } from '../../colonies/colony-status';
|
||||
import { lockRowsForShare } from '../../prisma/row-lock';
|
||||
|
||||
export interface LivingColony {
|
||||
apiaryId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks, inside the bulk transaction, that every colony exists and still
|
||||
* lives, and returns where each one stands. The colonies are locked FOR
|
||||
* SHARE first: none can be ended or moved before the bulk commits, so the
|
||||
* apiary returned is the one kept on the records.
|
||||
*
|
||||
* Unknown and ended colonies are named together in `invalidColonyIds` - the
|
||||
* client can drop them and send the rest again; nothing is stored.
|
||||
*/
|
||||
export async function assertLivingColonies(
|
||||
tx: Prisma.TransactionClient,
|
||||
colonyIds: readonly string[],
|
||||
): Promise<Map<string, LivingColony>> {
|
||||
await lockRowsForShare(tx, 'Colony', colonyIds);
|
||||
const colonies = await tx.colony.findMany({
|
||||
where: { id: { in: [...colonyIds] } },
|
||||
select: { id: true, status: true, apiaryId: true },
|
||||
});
|
||||
const living = new Map<string, LivingColony>(
|
||||
colonies
|
||||
.filter((colony) => isLivingColonyStatus(colony.status))
|
||||
.map((colony) => [colony.id, { apiaryId: colony.apiaryId }]),
|
||||
);
|
||||
const invalidColonyIds = colonyIds.filter((id) => !living.has(id));
|
||||
if (invalidColonyIds.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'Einige Völker sind nicht (mehr) erfassbar',
|
||||
invalidColonyIds,
|
||||
});
|
||||
}
|
||||
return living;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { RecordedForColony } from '../../domain-events/domain-event-types';
|
||||
|
||||
/**
|
||||
* Answer of a bulk entry: one record per colony, in request order. `bulkId`
|
||||
* groups them - it is also in the payload of each record's domain event.
|
||||
*/
|
||||
export interface BulkCreateResult<T> {
|
||||
bulkId: string;
|
||||
records: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload of a *Recorded event: `bulkId` only when the record comes from a
|
||||
* bulk entry, so a single create's payload stays `{ colonyId }`.
|
||||
*/
|
||||
export function recordedForColony(
|
||||
colonyId: string,
|
||||
bulkId?: string,
|
||||
): RecordedForColony {
|
||||
return bulkId === undefined ? { colonyId } : { colonyId, bulkId };
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Most colonies one bulk entry (POST /treatments/bulk, /feedings/bulk,
|
||||
* /varroa-measurements/bulk) may cover - more than any apiary holds, and it
|
||||
* keeps one request from becoming an unbounded transaction.
|
||||
*/
|
||||
export const BULK_MAX_COLONIES = 100;
|
||||
|
||||
/**
|
||||
* A bulk entry writes up to BULK_MAX_COLONIES records, each with its audit
|
||||
* entry and domain event, in one transaction - Prisma's 5 s default is too
|
||||
* short for the upper end.
|
||||
*/
|
||||
export const BULK_TRANSACTION_TIMEOUT_MS = 15_000;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BULK_MAX_COLONIES } from './bulk-limits';
|
||||
|
||||
const DUPLICATE_COLONY_MESSAGE = '$property enthält ein Volk doppelt';
|
||||
|
||||
// The size rule every bulk list shares: at least one colony, at most
|
||||
// BULK_MAX_COLONIES.
|
||||
function bulkListSize(): PropertyDecorator[] {
|
||||
return [
|
||||
IsArray({ message: '$property muss eine Liste sein' }),
|
||||
ArrayMinSize(1, { message: '$property braucht mindestens ein Volk' }),
|
||||
ArrayMaxSize(BULK_MAX_COLONIES, {
|
||||
message: `$property darf höchstens ${BULK_MAX_COLONIES} Völker enthalten`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// The rule for one colony id - in colonyIds (each) and in every entry - so an
|
||||
// empty id is invalid input on both paths, never an "unknown colony".
|
||||
function colonyIdChecks(each: boolean): PropertyDecorator[] {
|
||||
return [
|
||||
IsString({ each, message: '$property enthält eine ungültige Volk-ID' }),
|
||||
IsNotEmpty({ each, message: '$property enthält eine leere Volk-ID' }),
|
||||
];
|
||||
}
|
||||
|
||||
/** `colonyIds` of a bulk entry with shared values: distinct, non-empty ids. */
|
||||
export function IsBulkColonyIds(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
type: [String],
|
||||
minItems: 1,
|
||||
maxItems: BULK_MAX_COLONIES,
|
||||
uniqueItems: true,
|
||||
description: `Die Völker, für die derselbe Eintrag gilt (1 bis ${BULK_MAX_COLONIES}, jedes nur einmal)`,
|
||||
}),
|
||||
...bulkListSize(),
|
||||
ArrayUnique({ message: DUPLICATE_COLONY_MESSAGE }),
|
||||
...colonyIdChecks(true),
|
||||
);
|
||||
}
|
||||
|
||||
/** `colonyId` of one entry in `entries`: a non-empty id. */
|
||||
export function IsBulkEntryColonyId(): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
ApiProperty({ description: 'Das Volk dieses Eintrags' }),
|
||||
...colonyIdChecks(false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `entries` of a bulk entry with one value set per colony: each entry is
|
||||
* validated as its class, and each colony appears once.
|
||||
*/
|
||||
export function IsBulkEntries(
|
||||
entryType: new () => { colonyId: string },
|
||||
): PropertyDecorator {
|
||||
return applyDecorators(
|
||||
ApiProperty({
|
||||
type: [entryType],
|
||||
minItems: 1,
|
||||
maxItems: BULK_MAX_COLONIES,
|
||||
description: `Ein Eintrag je Volk (1 bis ${BULK_MAX_COLONIES}, jedes Volk nur einmal)`,
|
||||
}),
|
||||
...bulkListSize(),
|
||||
// class-validator skips null entries before calling the selector.
|
||||
ArrayUnique((entry: { colonyId?: unknown }) => entry.colonyId, {
|
||||
message: DUPLICATE_COLONY_MESSAGE,
|
||||
}),
|
||||
ValidateNested({ each: true }),
|
||||
Type(() => entryType),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits';
|
||||
import { BulkEntryWork, runBulk } from './run-bulk';
|
||||
|
||||
describe('runBulk', () => {
|
||||
let tx: {
|
||||
$queryRaw: jest.Mock;
|
||||
colony: { findMany: jest.Mock };
|
||||
write: jest.Mock;
|
||||
};
|
||||
let prisma: { $transaction: jest.Mock };
|
||||
// Writes of the fake transaction that survived its commit.
|
||||
let committed: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
committed = [];
|
||||
tx = {
|
||||
$queryRaw: jest.fn().mockResolvedValue([]),
|
||||
colony: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||
{ id: 'colony-2', status: 'WEAK', apiaryId: 'apiary-2' },
|
||||
{ id: 'colony-3', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||
]),
|
||||
},
|
||||
write: jest.fn(),
|
||||
};
|
||||
// Keeps the writes only when the work succeeds - a rollback otherwise.
|
||||
prisma = {
|
||||
$transaction: jest.fn(
|
||||
async (work: (txArg: typeof tx) => Promise<unknown>) => {
|
||||
const pending: string[] = [];
|
||||
tx.write.mockImplementation((value: string) => {
|
||||
pending.push(value);
|
||||
});
|
||||
const result = await work(tx);
|
||||
committed.push(...pending);
|
||||
return result;
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const run = <E extends { colonyId: string }, T>(
|
||||
entries: E[],
|
||||
perEntry: BulkEntryWork<E, T>,
|
||||
) => runBulk(prisma as unknown as PrismaService, entries, perEntry);
|
||||
|
||||
it('runs every entry in request order in one transaction with the bulk timeout', async () => {
|
||||
const result = await run(
|
||||
[{ colonyId: 'colony-3' }, { colonyId: 'colony-1' }],
|
||||
(txArg, entry) => {
|
||||
(txArg as unknown as typeof tx).write(entry.colonyId);
|
||||
return Promise.resolve(`record-of-${entry.colonyId}`);
|
||||
},
|
||||
);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), {
|
||||
timeout: BULK_TRANSACTION_TIMEOUT_MS,
|
||||
});
|
||||
expect(result.records).toEqual([
|
||||
'record-of-colony-3',
|
||||
'record-of-colony-1',
|
||||
]);
|
||||
expect(committed).toEqual(['colony-3', 'colony-1']);
|
||||
});
|
||||
|
||||
it('hands every entry the transaction, its colony and the one bulkId it returns', async () => {
|
||||
const perEntry = jest.fn().mockResolvedValue('record');
|
||||
|
||||
const { bulkId } = await run(
|
||||
[{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
|
||||
perEntry,
|
||||
);
|
||||
|
||||
expect(bulkId).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(perEntry.mock.calls).toEqual([
|
||||
[tx, { colonyId: 'colony-1' }, { apiaryId: 'apiary-1' }, bulkId],
|
||||
[tx, { colonyId: 'colony-2' }, { apiaryId: 'apiary-2' }, bulkId],
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives each bulk its own bulkId', async () => {
|
||||
const perEntry = jest.fn().mockResolvedValue('record');
|
||||
|
||||
const first = await run([{ colonyId: 'colony-1' }], perEntry);
|
||||
const second = await run([{ colonyId: 'colony-1' }], perEntry);
|
||||
|
||||
expect(first.bulkId).not.toBe(second.bulkId);
|
||||
});
|
||||
|
||||
it('checks every colony under the share lock before the first entry runs', async () => {
|
||||
tx.colony.findMany.mockResolvedValue([
|
||||
{ id: 'colony-1', status: 'ACTIVE', apiaryId: 'apiary-1' },
|
||||
{ id: 'colony-2', status: 'DISSOLVED', apiaryId: 'apiary-1' },
|
||||
]);
|
||||
const perEntry = jest.fn();
|
||||
|
||||
const error: unknown = await run(
|
||||
[{ colonyId: 'colony-1' }, { colonyId: 'colony-2' }],
|
||||
perEntry,
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toBeInstanceOf(BadRequestException);
|
||||
expect((error as BadRequestException).getResponse()).toEqual(
|
||||
expect.objectContaining({ invalidColonyIds: ['colony-2'] }),
|
||||
);
|
||||
expect(tx.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(perEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back every record when one entry fails', async () => {
|
||||
const perEntry = jest.fn((txArg: unknown, entry: { colonyId: string }) => {
|
||||
(txArg as typeof tx).write(entry.colonyId);
|
||||
return entry.colonyId === 'colony-2'
|
||||
? Promise.reject(new Error('audit write failed'))
|
||||
: Promise.resolve(entry.colonyId);
|
||||
});
|
||||
|
||||
await expect(
|
||||
run(
|
||||
[
|
||||
{ colonyId: 'colony-1' },
|
||||
{ colonyId: 'colony-2' },
|
||||
{ colonyId: 'colony-3' },
|
||||
],
|
||||
perEntry,
|
||||
),
|
||||
).rejects.toThrow('audit write failed');
|
||||
|
||||
expect(committed).toEqual([]);
|
||||
// The entries after the failure are not started.
|
||||
expect(perEntry).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Prisma } from '../../../generated/prisma';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { assertLivingColonies, LivingColony } from './assert-living-colonies';
|
||||
import { BulkCreateResult } from './bulk-create';
|
||||
import { BULK_TRANSACTION_TIMEOUT_MS } from './bulk-limits';
|
||||
|
||||
/**
|
||||
* Writes the record of one entry - record, audit entry and domain event,
|
||||
* all through `tx`. `colony` is the entry's colony as read under the share
|
||||
* lock; `bulkId` goes into the event payload.
|
||||
*/
|
||||
export type BulkEntryWork<E, T> = (
|
||||
tx: Prisma.TransactionClient,
|
||||
entry: E,
|
||||
colony: LivingColony,
|
||||
bulkId: string,
|
||||
) => Promise<T>;
|
||||
|
||||
/**
|
||||
* The frame of every bulk entry, all or none: one transaction with
|
||||
* BULK_TRANSACTION_TIMEOUT_MS, first assertLivingColonies for all colonies,
|
||||
* then `perEntry` once per entry in request order. The first failure rolls
|
||||
* back every record; the records come back in request order with a new
|
||||
* bulkId.
|
||||
*/
|
||||
export async function runBulk<E extends { colonyId: string }, T>(
|
||||
prisma: PrismaService,
|
||||
entries: readonly E[],
|
||||
perEntry: BulkEntryWork<E, T>,
|
||||
): Promise<BulkCreateResult<T>> {
|
||||
const bulkId = randomUUID();
|
||||
const records = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const colonies = await assertLivingColonies(
|
||||
tx,
|
||||
entries.map((entry) => entry.colonyId),
|
||||
);
|
||||
const created: T[] = [];
|
||||
for (const entry of entries) {
|
||||
// Every requested id is in the map - assertLivingColonies threw
|
||||
// otherwise.
|
||||
const colony = colonies.get(entry.colonyId)!;
|
||||
created.push(await perEntry(tx, entry, colony, bulkId));
|
||||
}
|
||||
return created;
|
||||
},
|
||||
{ timeout: BULK_TRANSACTION_TIMEOUT_MS },
|
||||
);
|
||||
return { bulkId, records };
|
||||
}
|
||||
Reference in new issue
Block a user