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:
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user