fix(alerts): отклонять запись устаревшего снимка правил

This commit is contained in:
Denozordec committed 2026-10-03 18:41:48 +07:00
1 parent c5c1c2812d
commit 382e318b97
23 files changed
+126 -43

No files matched your search

+4 -2
View File
@@ -1,3 +1,5 @@
import {alertsConfigRevision} from "../services/alerts-revision.js"
const revision=alertsConfigRevision({groups:[],rules:[]})
import assert from "node:assert/strict"
import Fastify from "fastify"
import {SignJWT} from "jose"
@@ -6,10 +8,10 @@ const {default:authPlugin,requireAuth}=await import("./auth.js")
const {createAlertsRoutes,defaultAlertsDependencies}=await import("../routes/alerts.js")
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
let writes=0,sends=0,reads=0
await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>({telegram:{chatId:"123",tokenConfigured:true,connected:true,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++;return {ok:true as const,groups:[],rules:[]}}}),{prefix:"/api"})
await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>({revision,telegram:{chatId:"123",tokenConfigured:true,connected:true,messageThreadId:null},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),writeTelegram:async()=>{writes++;return {chatId:"123",tokenConfigured:true,messageThreadId:null}},readTelegram:async()=>{reads++;return {botToken:"fixture-only",chatId:"123",messageThreadId:null}},send:async()=>{sends++;return {ok:true}},replace:async()=>{writes++;return {ok:true as const,revision,groups:[],rules:[]}}}),{prefix:"/api"})
for(const permission of ["mm:alerts:read","mm:alerts:write","mm:alerts:admin","mm:settings:admin"]){
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
for(const [method,url] of [["GET","/api/alerts"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)}
for(const [method,url] of [["GET","/api/alerts"],["PUT","/api/alerts/telegram"],["POST","/api/alerts/telegram/test"],["PUT","/api/alerts/rules"]] as const){const permitted=permission==="mm:alerts:write"||permission==="mm:alerts:admin"||method==="GET"&&permission==="mm:alerts:read";const response=await app.inject({method,url,headers:{authorization:"Bearer "+token,"if-match":'"'+revision+'"'},...(method==="PUT"?{payload:url.endsWith("rules")?{rules:[],groups:[]}:{chatId:"123"}}:{})});assert.equal(response.statusCode,permitted?200:403);assert.equal(response.body.includes("fixture-only"),false)}
}
assert.equal(writes,4);assert.equal(sends,2);assert.equal(reads,2)
for(const [method,url] of [["GET","/api/alerts"],["POST","/api/alerts/telegram/test"]] as const)assert.equal((await app.inject({method,url})).statusCode,401)
+7 -3
View File
@@ -1,10 +1,12 @@
import {alertsConfigRevision} from "../services/alerts-revision.js"
const revision=alertsConfigRevision({groups:[],rules:[]})
import assert from "node:assert/strict"
import Fastify from "fastify"
import {createAlertsRoutes,defaultAlertsDependencies} from "./alerts.js"
import {executeTelegramSettingsUpdate} from "../services/alerts-telegram-settings.js"
let replacements=0,replaceConflict=false,overviewMalformed=false
let replacements=0,replaceConflict=false,replaceStale=false,overviewMalformed=false
let stored={botToken:"fixture-secret",chatId:"123",messageThreadId:10 as number|null},reads=0,writes=0,sends=0,failSend=false,failWrite=false
const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>overviewMalformed?{}:({telegram:{chatId:stored.chatId,tokenConfigured:!!stored.botToken,connected:!!stored.botToken&&!!stored.chatId.trim(),messageThreadId:stored.messageThreadId},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),replace:async()=>{replacements++;if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,groups:[],rules:[]}},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
const app=Fastify();await app.register(createAlertsRoutes({...defaultAlertsDependencies,overview:async()=>overviewMalformed?{}:({revision,telegram:{chatId:stored.chatId,tokenConfigured:!!stored.botToken,connected:!!stored.botToken&&!!stored.chatId.trim(),messageThreadId:stored.messageThreadId},groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}),replace:async()=>{replacements++;if(replaceStale)throw Error("Правила изменились на сервере. Обновите снимок.");if(replaceConflict)throw Error("Сохранение правил уже выполняется");return {ok:true as const,revision,groups:[],rules:[]}},writeTelegram:async patch=>executeTelegramSettingsUpdate(patch,{ensure:async()=>{},update:async values=>{writes++;if(failWrite)return undefined;stored={...stored,...values};return {...stored}}}),readTelegram:async()=>{reads++;return {...stored}},send:async input=>{sends++;assert.equal(input.token,"fixture-secret");assert.equal(input.chatId,"123");assert.equal(input.messageThreadId,10);return failSend?{ok:false,error:"send unknown"}:{ok:true}}}),{prefix:"/api"})
const test=()=>app.inject({method:"POST",url:"/api/alerts/telegram/test",payload:{}})
let response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,200);assert.equal(response.body.includes("fixture-secret"),false);assert.deepEqual(response.json().rules,[]);assert.equal(response.headers["cache-control"],"no-store");overviewMalformed=true;response=await app.inject({method:"GET",url:"/api/alerts"});assert.equal(response.statusCode,500);assert.equal(response.json().ok,undefined);overviewMalformed=false
response=await test();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{ok:true});assert.equal(reads,1);assert.equal(sends,1)
@@ -16,5 +18,7 @@ failWrite=true;response=await app.inject({method:"PUT",url:"/api/alerts/telegram
stored={...stored,botToken:""};response=await test();assert.equal(response.statusCode,400);assert.equal(sends,2)
const invalidRule={id:"r",name:"Server",type:"server",target:"host",condition:"down",severity:"critical",enabled:false,cooldown:"5м",chatId:""}
for(const payload of [{rules:[invalidRule,invalidRule],groups:[]},{rules:[{...invalidRule,targets:[" "]}],groups:[]},{rules:[{...invalidRule,groupId:"missing"}],groups:[]}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload});assert.equal(response.statusCode,400)}assert.equal(replacements,0)
replaceConflict=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",payload:{rules:[],groups:[]}});assert.equal(response.statusCode,409);assert.equal(replacements,1);assert.equal(response.json().ok,undefined)
replaceConflict=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers:{"if-match":'"'+revision+'"'},payload:{rules:[],groups:[]}});assert.equal(response.statusCode,409);assert.equal(replacements,1);assert.equal(response.json().ok,undefined)
replaceConflict=false;for(const headers of [{},{"if-match":"*"},{"if-match":revision}]){response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers,payload:{groups:[],rules:[]}});assert.equal(response.statusCode,Object.keys(headers).length?400:428)}assert.equal(replacements,1)
replaceStale=true;response=await app.inject({method:"PUT",url:"/api/alerts/rules",headers:{"if-match":'"'+revision+'"'},payload:{groups:[],rules:[]}});assert.equal(response.statusCode,412);assert.equal(replacements,2);assert.equal(response.json().ok,undefined)
await app.close();console.log("Alerts HTTP: one stored test snapshot, strict numeric thread, selective save, unknown send/no retry, missing returning/no success, no token echo")
+6 -2
View File
@@ -34,6 +34,10 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
try { normalizeAlertsConfig(parsed.data) } catch { return reply.status(400).send({error:"Проверьте объекты, условия и группы правил"}) }
const ifMatch=req.headers["if-match"]
if(ifMatch===undefined)return reply.status(428).send({error:"Обновите снимок правил перед сохранением"})
if(typeof ifMatch!=="string"||!/^"[a-f0-9]{64}"$/.test(ifMatch))return reply.status(400).send({error:"Некорректная ревизия правил"})
const expectedRevision=ifMatch.slice(1,-1)
const groups = parsed.data.groups
const rules = parsed.data.rules.map((r) => ({
id: r.id,
@@ -57,8 +61,8 @@ export function createAlertsRoutes(deps:Dependencies):FastifyPluginAsyncZod {ret
recoveryStabilitySec: r.recoveryStabilitySec ?? null,
chatId: r.chatId,
}))
try {return reply.send(await deps.replace({ groups, rules }))}
catch(error){if(error instanceof Error&&error.message.includes("уже выполняется"))return reply.status(409).send({error:error.message});throw error}
try {return reply.send(await deps.replace({ groups, rules,expectedRevision }))}
catch(error){if(error instanceof Error&&error.message.includes("Правила изменились"))return reply.status(412).send({error:error.message});if(error instanceof Error&&error.message.includes("уже выполняется"))return reply.status(409).send({error:error.message});throw error}
})
app.put("/alerts/telegram", async (req, reply) => {
@@ -1,11 +1,19 @@
import {alertsConfigRevision} from "./alerts-revision.js"
import assert from "node:assert/strict"
import {test} from "node:test"
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
import {normalizeAlertsConfig} from "@mmapp/contracts/alerts"
const initial={groups:[{id:"g",name:"Group",combineMode:"all" as const,enabled:false,cooldownOverride:null}],rules:[]}
function fixture(){let state=structuredClone(initial),locks:boolean|undefined=true,failWrite=false,badRead=false,wrongRead=false;const calls:string[]=[];return {calls,state:()=>state,conflict:()=>locks=false,unknown:()=>locks=undefined,fail:()=>failWrite=true,bad:()=>badRead=true,wrong:()=>wrongRead=true,deps:{transaction:async(work:(context:typeof initial)=>Promise<unknown>)=>{calls.push("begin");const context=structuredClone(state);try{const receipt=await work(context);state=context;calls.push("commit");return receipt as never}catch(error){calls.push("rollback");throw error}},lock:async()=>{calls.push("lock");return locks},groups:async(context:typeof initial)=>{calls.push("groups");return context.groups},write:async(context:typeof initial,value:ReturnType<typeof normalizeAlertsConfig>)=>{calls.push("write");context.groups=value.groups!;if(failWrite)throw Error("insert failed")},read:async(context:typeof initial)=>{calls.push("read");return badRead?{ok:false}:wrongRead?{ok:true,...context,groups:initial.groups}:{ok:true,...context}}}}}
function fixture(){let state=structuredClone(initial),locks:boolean|undefined=true,failWrite=false,badRead=false,wrongRead=false;const calls:string[]=[];return {calls,state:()=>state,conflict:()=>locks=false,unknown:()=>locks=undefined,fail:()=>failWrite=true,bad:()=>badRead=true,wrong:()=>wrongRead=true,deps:{transaction:async(work:(context:typeof initial)=>Promise<unknown>)=>{calls.push("begin");const context=structuredClone(state);try{const receipt=await work(context);state=context;calls.push("commit");return receipt as never}catch(error){calls.push("rollback");throw error}},lock:async()=>{calls.push("lock");return locks},revision:async(context:typeof initial)=>{calls.push("revision");return alertsConfigRevision(context)},groups:async(context:typeof initial)=>{calls.push("groups");return context.groups},write:async(context:typeof initial,value:ReturnType<typeof normalizeAlertsConfig>)=>{calls.push("write");context.groups=value.groups!;if(failWrite)throw Error("insert failed")},read:async(context:typeof initial)=>{calls.push("read");return badRead?{ok:false}:wrongRead?{ok:true,revision:alertsConfigRevision({...context,groups:initial.groups}),...context,groups:initial.groups}:{ok:true,revision:alertsConfigRevision(context),...context}}}}}
test("omitted groups are read and confirmed inside one locked transaction",async()=>{const f=fixture();const receipt=await executeAlertsConfigReplacement({rules:[]},f.deps);assert.equal(receipt.ok,true);assert.deepEqual(receipt.groups,initial.groups);assert.deepEqual(f.calls,["begin","lock","groups","write","read","commit"])})
test("write/readback failures roll back and never repeat",async()=>{for(const step of ["write","read"]){const f=fixture();if(step==="write")f.fail();else f.bad();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps));assert.deepEqual(f.state(),initial);assert.equal(f.calls.filter(s=>s==="write").length,1);assert.equal(f.calls.includes("commit"),false);assert.equal(f.calls.at(-1),"rollback")}})
test("false or unknown lock prevents every destructive write",async()=>{for(const unknown of [false,true]){const f=fixture();if(unknown)f.unknown();else f.conflict();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/выполняется|не подтверждена/);assert.equal(f.calls.includes("write"),false);assert.deepEqual(f.state(),initial)}})
test("literal success with another config rolls back instead of confirming unrelated data",async()=>{const f=fixture();f.wrong();await assert.rejects(executeAlertsConfigReplacement({groups:[],rules:[]},f.deps),/не совпадают/);assert.deepEqual(f.state(),initial);assert.equal(f.calls.at(-1),"rollback")})
test("stale revision is rejected after a preceding commit before every destructive write",async()=>{
const f=fixture(),expectedRevision=alertsConfigRevision(f.state())
const receipt=await executeAlertsConfigReplacement({groups:[],rules:[],expectedRevision},f.deps);assert.equal(receipt.revision,alertsConfigRevision(f.state()));assert.notEqual(receipt.revision,expectedRevision)
const previousWrites=f.calls.filter(call=>call==="write").length
await assert.rejects(executeAlertsConfigReplacement({...initial,expectedRevision},f.deps),/Правила изменились/);assert.equal(f.calls.filter(call=>call==="write").length,previousWrites);assert.deepEqual(f.state(),{groups:[],rules:[]});assert.deepEqual(f.calls.slice(-4),["begin","lock","revision","rollback"])
})
@@ -1,18 +1,22 @@
import {alertsConfigRevision} from "./alerts-revision.js"
import {normalizeAlertsConfig,alertsRulesReceiptSchema,type PutRulesBody} from "@mmapp/contracts/alerts"
type Receipt=ReturnType<typeof alertsRulesReceiptSchema.parse>
type Normalized=ReturnType<typeof normalizeAlertsConfig>&{groups:NonNullable<ReturnType<typeof normalizeAlertsConfig>["groups"]>}
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<Receipt>)=>Promise<Receipt>;lock:(context:C)=>Promise<boolean|undefined>;groups:(context:C)=>Promise<Normalized["groups"]>;write:(context:C,value:Normalized)=>Promise<void>;read:(context:C)=>Promise<unknown>}
export async function executeAlertsConfigReplacement<C>(input:PutRulesBody,deps:Dependencies<C>):Promise<Receipt>{
normalizeAlertsConfig(input)
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<Receipt>)=>Promise<Receipt>;lock:(context:C)=>Promise<boolean|undefined>;revision:(context:C)=>Promise<string>;groups:(context:C)=>Promise<Normalized["groups"]>;write:(context:C,value:Normalized)=>Promise<void>;read:(context:C)=>Promise<unknown>}
export async function executeAlertsConfigReplacement<C>(input:PutRulesBody&{expectedRevision?:string},deps:Dependencies<C>):Promise<Receipt>{
const {expectedRevision,...body}=input
normalizeAlertsConfig(body)
return deps.transaction(async context=>{
const locked=await deps.lock(context)
if(locked===false)throw Error("Сохранение правил уже выполняется")
if(locked!==true)throw Error("Блокировка правил не подтверждена")
const normalized=normalizeAlertsConfig({...input,groups:input.groups??await deps.groups(context)}) as Normalized
if(expectedRevision!==undefined&&await deps.revision(context)!==expectedRevision)throw Error("Правила изменились на сервере. Обновите снимок и сравните его с черновиком перед сохранением.")
const normalized=normalizeAlertsConfig({...body,groups:body.groups??await deps.groups(context)}) as Normalized
await deps.write(context,normalized)
const parsed=alertsRulesReceiptSchema.safeParse(await deps.read(context))
if(!parsed.success)throw Error("Сохранённая конфигурация оповещений не подтверждена")
const receipt=parsed.data
if(receipt.revision!==alertsConfigRevision(receipt))throw Error("Ревизия сохранённой конфигурации не подтверждена")
const match=(expected:Record<string,unknown>,actual:Record<string,unknown>|undefined)=>actual!==undefined&&Object.entries(expected).every(([key,value])=>JSON.stringify(actual[key])===JSON.stringify(value))
if(receipt.rules.length!==normalized.rules.length||receipt.groups.length!==normalized.groups.length||normalized.rules.some(rule=>!match(rule,receipt.rules.find(row=>row.id===rule.id)))||normalized.groups.some(group=>!match(group,receipt.groups.find(row=>row.id===group.id))))throw Error("Сохранённые правила или группы не совпадают с отправленными")
return receipt
+2 -1
View File
@@ -1,8 +1,9 @@
import {alertsConfigRevision} from "./alerts-revision.js"
import assert from "node:assert/strict"
import {test} from "node:test"
import type {AlertsResponse} from "@mmapp/contracts/alerts"
import {readAlertsOverview,alertsOverviewTransaction,buildAlertsMeta} from "./alerts-overview.js"
const empty:AlertsResponse={telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:buildAlertsMeta([],[])}
const empty:AlertsResponse={revision:alertsConfigRevision({groups:[],rules:[]}),telegram:{chatId:"",tokenConfigured:false,connected:false,messageThreadId:null},groups:[],rules:[],history:[],meta:buildAlertsMeta([],[])}
test("Alerts overview uses one context with repeatable read/read only and rejects partial snapshots",async()=>{
const context={snapshot:structuredClone(empty)},calls:string[]=[];let transactions=0
const read=<K extends keyof typeof empty>(key:K)=>async(value:typeof context)=>{assert.equal(value,context);calls.push(key);return value.snapshot[key]}
+5 -1
View File
@@ -1,10 +1,14 @@
import {alertsConfigRevision} from "./alerts-revision.js"
import {alertsResponseSchema,type AlertsResponse} from "@mmapp/contracts/alerts"
export const alertsOverviewTransaction={isolationLevel:"repeatable read",accessMode:"read only"} as const
interface Dependencies<C>{transaction:(work:(context:C)=>Promise<AlertsResponse>,options:typeof alertsOverviewTransaction)=>Promise<AlertsResponse>;telegram:(context:C)=>Promise<unknown>;groups:(context:C)=>Promise<unknown>;rules:(context:C)=>Promise<unknown>;history:(context:C)=>Promise<unknown>;meta:(context:C)=>Promise<unknown>}
export async function readAlertsOverview<C>(deps:Dependencies<C>):Promise<AlertsResponse>{
return deps.transaction(async context=>{
const telegram=await deps.telegram(context)
const parsed=alertsResponseSchema.safeParse({telegram,groups:await deps.groups(context),rules:await deps.rules(context),history:await deps.history(context),meta:await deps.meta(context)})
const groups=await deps.groups(context),rules=await deps.rules(context)
let revision:string
try{revision=alertsConfigRevision({groups,rules})}catch{throw Error("Снимок оповещений не подтверждён")}
const parsed=alertsResponseSchema.safeParse({revision,telegram,groups,rules,history:await deps.history(context),meta:await deps.meta(context)})
if(!parsed.success)throw Error("Снимок оповещений не подтверждён")
return parsed.data
},alertsOverviewTransaction)
@@ -0,0 +1,9 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {alertsConfigRevision} from "./alerts-revision.js"
const rule={id:"r",name:"Router",type:"server",targets:["router"],conditions:["offline"],severity:"critical",enabled:true,cooldown:"5м",chatId:"",recoveryMode:"conditional",recoveryStabilitySec:17}
test("Alerts revision is deterministic across row/property order and ignores notification history",()=>{
const first={groups:[],rules:[{...rule,id:"b"},{...rule,id:"a"}]};const revision=alertsConfigRevision(first)
assert.match(revision,/^[a-f0-9]{64}$/);assert.equal(alertsConfigRevision({rules:[{lastFiredAt:"2026-10-03",...rule,id:"a"},{...rule,id:"b"}],groups:[]}),revision)
for(const patch of [{enabled:false},{targets:["other"]},{conditions:["online"]},{recoveryMode:"never"},{recoveryStabilitySec:18}])assert.notEqual(alertsConfigRevision({groups:[],rules:[{...rule,...patch,id:"a"},{...rule,id:"b"}]}),revision)
})
+9
View File
@@ -0,0 +1,9 @@
import {createHash} from "node:crypto"
import {putRulesSchema,normalizeAlertsConfig} from "@mmapp/contracts/alerts"
export function alertsConfigRevision(input:unknown){
const parsed=putRulesSchema.parse(input)
if(parsed.groups===undefined)throw Error("Для ревизии нужен полный снимок групп")
const normalized=normalizeAlertsConfig(parsed)
const sort=<T extends {id:string}>(rows:T[])=>[...rows].sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0)
return createHash("sha256").update(JSON.stringify({groups:sort(normalized.groups!),rules:sort(normalized.rules)})).digest("hex")
}
+4 -2
View File
@@ -1,3 +1,4 @@
import {alertsConfigRevision} from "./alerts-revision.js"
import {readAlertsOverview,buildAlertsMeta} from "./alerts-overview.js"
import {executeAlertsConfigReplacement} from "./alerts-config-execution.js"
import {sendTelegramRequest} from "./alerts-telegram-send.js"
@@ -205,11 +206,12 @@ export type ReplaceAlertGroupInput = {
/** Атомарно заменяет группы, правила и строки targets. */
type AlertsWriteContext=Parameters<Parameters<typeof db.transaction>[0]>[0]
export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[] }) {
export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupInput[]; rules: ReplaceAlertRuleInput[];expectedRevision?:string }) {
const now=sql`now()`
return executeAlertsConfigReplacement<AlertsWriteContext>(payload, {
transaction:work=>db.transaction(work),
lock:async tx=>{const result=await tx.execute(sql`SELECT pg_try_advisory_xact_lock(8723103) AS acquired`);return typeof result.rows[0]?.acquired==="boolean"?result.rows[0].acquired:undefined},
revision:async tx=>alertsConfigRevision({groups:await listAlertGroups(tx),rules:await listAlertRules(tx)}),
groups:tx=>listAlertGroups(tx),
write:async(tx,{groups,rules})=>{
await tx.delete(alertRuleConditions)
@@ -285,7 +287,7 @@ export async function replaceAlertsConfig(payload: { groups?: ReplaceAlertGroupI
}
}
},
read:async tx=>({ok:true,groups:await listAlertGroups(tx),rules:await listAlertRules(tx)}),
read:async tx=>{const groups=await listAlertGroups(tx),rules=await listAlertRules(tx);return {ok:true,revision:alertsConfigRevision({groups,rules}),groups,rules}},
})
}