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

@@ -0,0 +1,204 @@
import { Prisma } from '../../../generated/prisma';
import { DbClient } from '../../prisma/db-client';
import { PrismaService } from '../../prisma/prisma.service';
import { MAX_ATTEMPTS, retryDelayMs, STALE_CLAIM_MS } from '../retry-policy';
/**
* Claim, settle and stale release for every outbox table. One worker owns
* an entry from the claim (status PROCESSING, claimedAt = now()) until it
* settles it; settling is a compare-and-set on id + claimedAt, so a worker
* whose claim was released in the meantime changes nothing. Every time
* comparison and calculation runs in SQL against now() - the database
* clock - never against a JS Date.
*/
export type OutboxTable = 'DomainEvent' | 'WebhookDelivery';
export type OutboxOrderColumn = 'occurredAt' | 'createdAt';
export interface ClaimOptions {
limit: number;
/** Extra condition, ANDed to the claim (e.g. a correlation id filter). */
filter?: Prisma.Sql;
orderBy: OutboxOrderColumn;
}
interface OutboxTableSql {
table: Prisma.Sql;
statusType: Prisma.Sql;
settledAtColumn: Prisma.Sql;
successStatus: 'DONE' | 'DELIVERED';
}
/**
* Table, enum and column names cannot be bound as parameters. They come
* only from these closed maps - never from caller input - which is what
* makes Prisma.raw safe here.
*/
const OUTBOX_TABLES: Readonly<Record<OutboxTable, OutboxTableSql>> = {
DomainEvent: {
table: Prisma.raw('"DomainEvent"'),
statusType: Prisma.raw('"DomainEventStatus"'),
settledAtColumn: Prisma.raw('"processedAt"'),
successStatus: 'DONE',
},
WebhookDelivery: {
table: Prisma.raw('"WebhookDelivery"'),
statusType: Prisma.raw('"WebhookDeliveryStatus"'),
settledAtColumn: Prisma.raw('"deliveredAt"'),
successStatus: 'DELIVERED',
},
};
const ORDER_COLUMNS: Readonly<Record<OutboxOrderColumn, Prisma.Sql>> = {
occurredAt: Prisma.raw('"occurredAt"'),
createdAt: Prisma.raw('"createdAt"'),
};
const STALE_CLAIM_ERROR = 'claim expired (worker restarted?)';
function outboxTable(table: OutboxTable): OutboxTableSql {
if (!Object.hasOwn(OUTBOX_TABLES, table)) {
throw new Error(`Unknown outbox table "${String(table)}"`);
}
return OUTBOX_TABLES[table];
}
function orderColumn(column: OutboxOrderColumn): Prisma.Sql {
if (!Object.hasOwn(ORDER_COLUMNS, column)) {
throw new Error(`Unknown outbox order column "${String(column)}"`);
}
return ORDER_COLUMNS[column];
}
/**
* Claims up to `limit` due PENDING entries, oldest first. SKIP LOCKED lets
* several workers claim side by side without waiting for or overlapping
* each other. Returns the claimed rows (all columns) in claim order.
* Uses the root client: a claim must commit on its own.
*/
export async function claimBatch<Row>(
db: PrismaService,
table: OutboxTable,
options: ClaimOptions,
): Promise<Row[]> {
if (!Number.isInteger(options.limit) || options.limit < 1) {
throw new RangeError(
`Claim limit must be a positive integer, got ${options.limit}`,
);
}
const { table: name } = outboxTable(table);
const orderBy = orderColumn(options.orderBy);
const filter = options.filter
? Prisma.sql`AND ${options.filter}`
: Prisma.empty;
return db.$queryRaw<Row[]>(Prisma.sql`
WITH claimed AS (
UPDATE ${name} SET "status" = 'PROCESSING', "claimedAt" = now()
WHERE "id" IN (
SELECT "id" FROM ${name}
WHERE "status" = 'PENDING' AND "availableAt" <= now() ${filter}
ORDER BY ${orderBy}
LIMIT ${options.limit}
FOR UPDATE SKIP LOCKED
)
RETURNING *
)
SELECT * FROM claimed ORDER BY ${orderBy}`);
}
/** An entry a stale release put back (PENDING) or gave up on (FAILED). */
export interface ReleasedClaim {
id: string;
status: 'PENDING' | 'FAILED';
}
/**
* Puts entries whose claim is older than STALE_CLAIM_MS (their worker died)
* back to PENDING right away, with attempts + 1 - or to FAILED once that
* reaches MAX_ATTEMPTS, so an entry that kills its worker cannot loop
* forever. Returns the released entries, for follow-up work on FAILED ones.
*/
export async function releaseStaleClaimRows(
db: PrismaService,
table: OutboxTable,
): Promise<ReleasedClaim[]> {
const { table: name, statusType } = outboxTable(table);
return db.$queryRaw<ReleasedClaim[]>(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(CASE WHEN "attempts" + 1 >= ${MAX_ATTEMPTS} THEN 'FAILED' ELSE 'PENDING' END AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"availableAt" = now(),
"lastError" = ${STALE_CLAIM_ERROR}
WHERE "status" = 'PROCESSING'
AND "claimedAt" < now() - (${STALE_CLAIM_MS} * interval '1 millisecond')
RETURNING "id", "status"`);
}
/** releaseStaleClaimRows for callers that only need the count. */
export async function releaseStaleClaims(
db: PrismaService,
table: OutboxTable,
): Promise<number> {
return (await releaseStaleClaimRows(db, table)).length;
}
/**
* Settles a claimed entry as done. Returns false if the claim was lost
* (released as stale, maybe claimed again) - the current owner settles it.
* lastError stays as history of earlier failures.
*/
export async function settleSuccess(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
status: 'DONE' | 'DELIVERED',
): Promise<boolean> {
const sql = outboxTable(table);
if (status !== sql.successStatus) {
throw new Error(`${table} settles as ${sql.successStatus}, not ${status}`);
}
const count = await db.$executeRaw(Prisma.sql`
UPDATE ${sql.table}
SET "status" = CAST(${status} AS ${sql.statusType}),
"claimedAt" = NULL,
${sql.settledAtColumn} = now()
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return count === 1;
}
/**
* Settles a claimed entry after a failed attempt: back to PENDING after
* retryDelayMs(attempts) by the database clock, or FAILED once attempts
* reaches MAX_ATTEMPTS. `lastError` must come from loggableError().
*
* The outcome also holds if the claim was lost: only a stale release
* changes a claimed row, and it applies the same attempts + 1 >=
* MAX_ATTEMPTS rule to the same attempt count.
*/
export async function settleFailure(
db: DbClient,
table: OutboxTable,
id: string,
claimedAt: Date,
attemptsBefore: number,
lastError: string,
): Promise<'retry' | 'failed'> {
const { table: name, statusType } = outboxTable(table);
const attempts = attemptsBefore + 1;
const outcome = attempts >= MAX_ATTEMPTS ? 'failed' : 'retry';
const nextAttempt =
outcome === 'retry'
? Prisma.sql`, "availableAt" = now() + (${retryDelayMs(attempts)} * interval '1 millisecond')`
: Prisma.empty;
await db.$executeRaw(Prisma.sql`
UPDATE ${name}
SET "status" = CAST(${outcome === 'retry' ? 'PENDING' : 'FAILED'} AS ${statusType}),
"attempts" = "attempts" + 1,
"claimedAt" = NULL,
"lastError" = ${lastError}
${nextAttempt}
WHERE "id" = ${id} AND "status" = 'PROCESSING' AND "claimedAt" = ${claimedAt}`);
return outcome;
}