feat: T6 wave 1 – compliance schema, permissions, lock order, backup
Backend Image / build-and-push (push) Successful in 6m39s
Frontend Image / build-and-push (push) Successful in 11m25s

- Migrations (additive): veterinary medicines with lots and movement
  ledger, treatment medicine/lot/dose links, label particulars, authority
  notices, apiary restrictions, compliance settings and acknowledgements;
  notification category COMPLIANCE, added to non-empty Telegram lists.
- Permissions veterinary-medicines:* and compliance:* with role grants.
- LOCKABLE_TABLES: Apiary locks before Colony; T6 tables appended.
- Backup exports and restores the new tables in FK order; unsealed files
  skip them and report dropped medicine links; an open TSK draft in the
  target wins.
- describeNullContracts shared helper; Telegram backlog e2e no longer
  depends on the time of day.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-15 13:04:13 +02:00
1 parent 7a1480138d
commit 8a31c42578
23 files changed
+2028 -84

No files matched your search

+53 -2
View File
@@ -199,7 +199,14 @@ describe('lock keys per table', () => {
'StockItem',
'Treatment',
'VarroaMeasurement',
'Apiary',
'NotificationDelivery',
'LabelTemplate',
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
] as const)('locks %s by "id"', async (table) => {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
@@ -219,8 +226,9 @@ describe('lock keys per table', () => {
expect(tx.$queryRaw).not.toHaveBeenCalled();
});
it('keeps the map in lock order: Colony, Suggestion, Task, Queen, AutomationRule, then the rest', () => {
it('keeps the map in lock order: Apiary before Colony, the T6 tables last', () => {
expect(LOCK_ORDER).toEqual([
'Apiary',
'Colony',
'Suggestion',
'Task',
@@ -237,11 +245,54 @@ describe('lock keys per table', () => {
'Treatment',
'VarroaMeasurement',
'NotificationPreference',
'Apiary',
'PhenologyReport',
'Extraction',
'HoneyBatch',
'NotificationDelivery',
'LabelTemplate',
'SiteSettings',
'VeterinaryMedicine',
'VeterinaryMedicineLot',
'ComplianceSettings',
'AuthorityNotice',
'ApiaryRestriction',
'ComplianceAcknowledgement',
]);
});
it('every table of the map is lockable by id and each appears once', async () => {
expect(new Set(LOCK_ORDER).size).toBe(LOCK_ORDER.length);
const keyed = new Map([['NotificationPreference', 'userId']]);
for (const table of LOCK_ORDER) {
const tx = { $queryRaw: jest.fn().mockResolvedValue([]) };
await lockRowForUpdate(tx as never, table, 'x1');
const key = keyed.get(table) ?? 'id';
const column = key === 'id' ? 'id' : `"${key}"`;
const [[query]] = tx.$queryRaw.mock.calls as [[Prisma.Sql]];
expect(query.sql).toBe(
`SELECT ${column} FROM "${table}" WHERE ${column} = ? FOR UPDATE`,
);
}
});
it.each(['SiteSettings', 'ComplianceSettings'] as const)(
'locks the %s singleton row by "id"',
async (table) => {
const tx = {
$queryRaw: jest.fn().mockResolvedValue([{ id: 'singleton' }]),
};
await expect(
lockRowForUpdate(tx as never, table, 'singleton'),
).resolves.toBe(true);
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(['singleton']);
},
);
});
+18 -4
View File
@@ -13,8 +13,11 @@ function lockable(table: string, key = 'id') {
// 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.
// of them cannot wait for each other. Apiary comes first: the paths that lock
// an apiary and a colony together (syncApiaryIn, moving, merging, creating a
// colony) take the apiary rows before the colony rows.
const LOCKABLE_TABLES = {
Apiary: lockable('Apiary'),
Colony: lockable('Colony'),
Suggestion: lockable('Suggestion'),
Task: lockable('Task'),
@@ -32,14 +35,25 @@ const LOCKABLE_TABLES = {
VarroaMeasurement: lockable('VarroaMeasurement'),
// One row per user, keyed by the user (the table arrives with Task 3).
NotificationPreference: lockable('NotificationPreference', 'userId'),
// T4b tables (Task 23): the apiary season state, phenology reports and the
// honey chain (extraction, honey batch ledger).
Apiary: lockable('Apiary'),
// T4b tables (Task 23): phenology reports and the honey chain (extraction,
// honey batch ledger).
PhenologyReport: lockable('PhenologyReport'),
Extraction: lockable('Extraction'),
HoneyBatch: lockable('HoneyBatch'),
// T4c (Task 34): the Telegram delivery outbox.
NotificationDelivery: lockable('NotificationDelivery'),
// T6 (Task 1): label templates, the settings singleton (row "singleton"),
// the veterinary medicine catalogue and its packs, the compliance settings
// singleton (row "singleton", the mutex of the authority notices), the
// notices, the apiary restrictions and the readiness acknowledgements.
LabelTemplate: lockable('LabelTemplate'),
SiteSettings: lockable('SiteSettings'),
VeterinaryMedicine: lockable('VeterinaryMedicine'),
VeterinaryMedicineLot: lockable('VeterinaryMedicineLot'),
ComplianceSettings: lockable('ComplianceSettings'),
AuthorityNotice: lockable('AuthorityNotice'),
ApiaryRestriction: lockable('ApiaryRestriction'),
ComplianceAcknowledgement: lockable('ComplianceAcknowledgement'),
} as const;
export type LockableTable = keyof typeof LOCKABLE_TABLES;