feat: T6 wave 1 – compliance schema, permissions, lock order, backup
- 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:
1 parent
7a1480138d
commit
8a31c42578
23 files changed
+2028
-84
No files matched your search
@@ -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']);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user