feat: T4 waves 1-2 - rule configs, schema, suggestions core, season, rule forms

- Rule configs with typed parsers and registry; Berlin day helpers
- Schema: T4 enums, Suggestion table, feed vocabulary (feedStatus as
  ColonyCondition with legacy text mapping), split queen source,
  varroa sample size; migration harness for data migrations
- SuggestionsService: propose/refresh/resolve/obsolete/expire,
  accept/dismiss/reopen incl. bulk, lock order, concurrency e2e
- SeasonService: phenology-based segments, inspection/varroa due dates,
  queen age; TimeEventsStep with JobPartialFailure
- Frontend: per-rule settings forms with visible value sources

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 13:42:32 +02:00
1 parent 7097fcd97c
commit d99a697739
116 files changed
+11239 -801

No files matched your search

@@ -1,5 +1,6 @@
import { Prisma } from '../../generated/prisma';
import {
LOCK_ORDER,
lockRowForUpdate,
lockRowsForShare,
lockRowsForUpdate,
@@ -143,3 +144,98 @@ describe('lockRowsForUpdate', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
});
describe('lock keys per table', () => {
const lockers = [
[
'lockRowForUpdate',
(tx: unknown) =>
lockRowForUpdate(tx as never, 'NotificationPreference', 'u1'),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ? FOR UPDATE',
['u1'],
],
[
'lockRowsForUpdate',
(tx: unknown) =>
lockRowsForUpdate(tx as never, 'NotificationPreference', ['u2', 'u1']),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR UPDATE',
[['u1', 'u2']],
],
[
'lockRowsForShare',
(tx: unknown) =>
lockRowsForShare(tx as never, 'NotificationPreference', ['u2', 'u1']),
'SELECT "userId" FROM "NotificationPreference" WHERE "userId" = ANY(?) ORDER BY "userId" FOR SHARE',
[['u1', 'u2']],
],
] as const;
it.each(lockers)(
'%s locks NotificationPreference by "userId" with the id as parameter',
async (_name, lock, sql, values) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lock(tx);
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(sql);
expect(query.values).toEqual(values);
},
);
it.each([
'Colony',
'Suggestion',
'Task',
'Queen',
'AutomationRule',
'BottlingBatch',
'Feeding',
'Harvest',
'Inspection',
'Product',
'ProductionBatch',
'RawMaterialLot',
'StockItem',
'Treatment',
'VarroaMeasurement',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, table, 'x1');
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(`SELECT id FROM "${table}" WHERE id = ? FOR UPDATE`);
expect(query.values).toEqual(['x1']);
});
it('rejects a table outside the map', async () => {
const tx = { $queryRaw: jest.fn() };
await expect(
lockRowsForShare(tx as never, 'Notification' as never, ['n1']),
).rejects.toThrow('lockRowsForShare: table not lockable');
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
expect(LOCK_ORDER).toEqual([
'Colony',
'Suggestion',
'Task',
'Queen',
'AutomationRule',
'BottlingBatch',
'Feeding',
'Harvest',
'Inspection',
'Product',
'ProductionBatch',
'RawMaterialLot',
'StockItem',
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
]);
});
});
+54 -32
View File
@@ -1,29 +1,57 @@
import { Prisma } from '../../generated/prisma';
import { assertTransactionClient } from './db-client';
// Fixed identifier map: a table name is never taken from input.
// `key` is the SQL identifier of the key column: plain id, quoted for a
// camelCase name.
function lockable(table: string, key = 'id') {
return {
table: Prisma.raw(`"${table}"`),
key: Prisma.raw(key === 'id' ? 'id' : `"${key}"`),
};
}
// Fixed identifier map: a table or key column name is never taken from input.
// The order is the lock order: a transaction that locks rows of several
// tables takes them in this order (and ascending key within a table), so two
// of them cannot wait for each other.
const LOCKABLE_TABLES = {
Colony: Prisma.raw('"Colony"'),
Task: Prisma.raw('"Task"'),
BottlingBatch: Prisma.raw('"BottlingBatch"'),
Feeding: Prisma.raw('"Feeding"'),
Harvest: Prisma.raw('"Harvest"'),
Inspection: Prisma.raw('"Inspection"'),
Product: Prisma.raw('"Product"'),
ProductionBatch: Prisma.raw('"ProductionBatch"'),
RawMaterialLot: Prisma.raw('"RawMaterialLot"'),
StockItem: Prisma.raw('"StockItem"'),
Treatment: Prisma.raw('"Treatment"'),
VarroaMeasurement: Prisma.raw('"VarroaMeasurement"'),
Colony: lockable('Colony'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
Queen: lockable('Queen'),
AutomationRule: lockable('AutomationRule'),
BottlingBatch: lockable('BottlingBatch'),
Feeding: lockable('Feeding'),
Harvest: lockable('Harvest'),
Inspection: lockable('Inspection'),
Product: lockable('Product'),
ProductionBatch: lockable('ProductionBatch'),
RawMaterialLot: lockable('RawMaterialLot'),
StockItem: lockable('StockItem'),
Treatment: lockable('Treatment'),
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;
/** The tables in the order a transaction must lock them. */
export const LOCK_ORDER = Object.keys(LOCKABLE_TABLES) as LockableTable[];
function entryOf(table: LockableTable, caller: string) {
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error(`${caller}: table not lockable`);
}
return LOCKABLE_TABLES[table];
}
/**
* Locks one row (SELECT ... FOR UPDATE) until the transaction ends, so a
* read-modify-write on it is serialised with concurrent writers. Re-read the
* row after locking and use that copy as "before". Resolves to whether the
* row exists.
* row exists. `id` is the value of the table's key column ("id", for
* NotificationPreference "userId").
*/
export async function lockRowForUpdate(
tx: Prisma.TransactionClient,
@@ -31,20 +59,18 @@ export async function lockRowForUpdate(
id: string,
): Promise<boolean> {
assertTransactionClient(tx, 'lockRowForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowForUpdate: table not lockable');
}
const locked = await tx.$queryRaw<{ id: string }[]>(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ${id} FOR UPDATE`,
const entry = entryOf(table, 'lockRowForUpdate');
const locked = await tx.$queryRaw<unknown[]>(
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ${id} FOR UPDATE`,
);
return locked.length > 0;
}
/**
* Locks several rows (SELECT ... FOR UPDATE) until the transaction ends, with
* ONE statement in ascending id order: it meets lockRowsForShare on the same
* ONE statement in ascending key order: it meets lockRowsForShare on the same
* order, so two transactions locking overlapping sets cannot deadlock. Unknown
* ids lock nothing; re-read the rows after locking.
* keys lock nothing; re-read the rows after locking.
*/
export async function lockRowsForUpdate(
tx: Prisma.TransactionClient,
@@ -52,24 +78,22 @@ export async function lockRowsForUpdate(
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForUpdate');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForUpdate: table not lockable');
}
const entry = entryOf(table, 'lockRowsForUpdate');
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR UPDATE`,
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR UPDATE`,
);
}
/**
* Locks several rows (SELECT ... FOR SHARE) until the transaction ends: they
* cannot be changed or deleted meanwhile, other readers are not blocked. The
* rows are locked in ascending id order, so two transactions locking
* overlapping sets cannot deadlock. Unknown ids lock nothing; re-read the rows
* after locking.
* rows are locked in ascending key order, so two transactions locking
* overlapping sets cannot deadlock. Unknown keys lock nothing; re-read the
* rows after locking.
*/
export async function lockRowsForShare(
tx: Prisma.TransactionClient,
@@ -77,14 +101,12 @@ export async function lockRowsForShare(
ids: readonly string[],
): Promise<void> {
assertTransactionClient(tx, 'lockRowsForShare');
if (!Object.hasOwn(LOCKABLE_TABLES, table)) {
throw new Error('lockRowsForShare: table not lockable');
}
const entry = entryOf(table, 'lockRowsForShare');
if (ids.length === 0) {
return;
}
const sortedIds = [...ids].sort();
await tx.$queryRaw(
Prisma.sql`SELECT id FROM ${LOCKABLE_TABLES[table]} WHERE id = ANY(${sortedIds}) ORDER BY id FOR SHARE`,
Prisma.sql`SELECT ${entry.key} FROM ${entry.table} WHERE ${entry.key} = ANY(${sortedIds}) ORDER BY ${entry.key} FOR SHARE`,
);
}