1- import fs from 'node:fs'
2- import path from 'node:path'
31// @ts -expect-error pg has no type declarations in this project
42import pg from 'pg'
53
64import type { Severity , Topic } from '@notify/const'
75
8- import type { AlertRule , FilterExpression } from './types'
6+ import type { AlertRule , FilterExpression , RendererRecord } from './types'
97
10- const { Client , Pool } = pg
8+ const { Pool } = pg
119
1210let pool : InstanceType < typeof Pool > | null = null
1311let cachedRules : AlertRule [ ] = [ ]
12+ let cachedRenderers : RendererRecord [ ] = [ ]
1413let lastRuleRefresh = 0
1514
1615const REFRESH_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes
@@ -24,77 +23,6 @@ const getPool = (): InstanceType<typeof Pool> => {
2423 return pool
2524}
2625
27- function readSqlFile ( filename : string ) : string {
28- const localPath = path . resolve ( __dirname , filename )
29- if ( fs . existsSync ( localPath ) ) return fs . readFileSync ( localPath , 'utf-8' )
30- return fs . readFileSync ( path . resolve ( __dirname , '..' , '..' , 'src' , 'alert-config' , filename ) , 'utf-8' )
31- }
32-
33- function splitStatements ( sql : string ) : string [ ] {
34- return sql
35- . split ( ';\n' )
36- . map ( ( s ) =>
37- s
38- . split ( '\n' )
39- . filter ( ( line ) => ! line . trimStart ( ) . startsWith ( '--' ) )
40- . join ( '\n' )
41- . trim ( ) ,
42- )
43- . filter ( ( s ) => s . length > 0 )
44- }
45-
46- /**
47- * Initialize the alert config database.
48- * Creates the database if needed, runs migration, and seeds data.
49- * Call once at startup before using any other alert-config functions.
50- * Only runs when ALERT_CONFIG_DB_MIGRATION=true.
51- */
52- export const initAlertConfigDb = async ( ) : Promise < void > => {
53- if ( process . env . ALERT_CONFIG_DB_MIGRATION !== 'true' ) return
54-
55- const url = process . env . ALERT_CONFIG_DB_URL
56- if ( ! url ) return
57-
58- // Parse the DB name from the URL to create it if needed
59- const parsed = new URL ( url )
60- const dbName = parsed . pathname . slice ( 1 ) // remove leading /
61- const adminUrl = `${ parsed . protocol } //${ parsed . username } :${ parsed . password } @${ parsed . host } /postgres`
62-
63- // Create database if it doesn't exist
64- const adminClient = new Client ( { connectionString : adminUrl } )
65- try {
66- await adminClient . connect ( )
67- const { rows } = await adminClient . query ( 'SELECT 1 FROM pg_database WHERE datname = $1' , [ dbName ] )
68- if ( rows . length === 0 ) {
69- await adminClient . query ( `CREATE DATABASE "${ dbName } "` )
70- console . log ( `Alert config: created database "${ dbName } "` )
71- }
72- } finally {
73- await adminClient . end ( )
74- }
75-
76- // Check if schema already exists
77- const p = getPool ( )
78- const { rows } = await p . query ( "SELECT 1 FROM information_schema.tables WHERE table_name = 'alert_rule'" )
79- if ( rows . length > 0 ) return // Already migrated
80-
81- // Run migration
82- await p . query ( readSqlFile ( 'migration.sql' ) )
83- console . log ( 'Alert config: migration complete' )
84-
85- // Seed rules
86- for ( const stmt of splitStatements ( readSqlFile ( 'seed-rules.sql' ) ) ) {
87- await p . query ( stmt )
88- }
89- console . log ( 'Alert config: seed rules loaded' )
90-
91- // Seed ABIs
92- for ( const stmt of splitStatements ( readSqlFile ( 'seed-abis.sql' ) ) ) {
93- await p . query ( stmt )
94- }
95- console . log ( 'Alert config: ABI seed loaded' )
96- }
97-
9826const loadRules = async ( ) : Promise < AlertRule [ ] > => {
9927 const { rows } = await getPool ( ) . query ( 'SELECT * FROM alert_rule WHERE enabled = true' )
10028 return rows . map (
@@ -125,6 +53,26 @@ const loadRules = async (): Promise<AlertRule[]> => {
12553 )
12654}
12755
56+ const loadRenderers = async ( ) : Promise < RendererRecord [ ] > => {
57+ const tableCheck = await getPool ( ) . query ( "SELECT 1 FROM information_schema.tables WHERE table_name = 'renderer'" )
58+ if ( tableCheck . rows . length === 0 ) return [ ]
59+ const { rows } = await getPool ( ) . query ( 'SELECT * FROM renderer ORDER BY name ASC' )
60+ return rows . map (
61+ ( row : any ) : RendererRecord => ( {
62+ id : row . id ,
63+ name : row . name ,
64+ mode : row . mode ,
65+ chainId : row . chain_id ,
66+ matchType : row . match_type ,
67+ abiNames : row . abi_names ,
68+ contractAddresses : row . contract_addresses ,
69+ topic0 : row . topic0 ,
70+ sighash : row . sighash ,
71+ configJson : row . config_json ?? { } ,
72+ } ) ,
73+ )
74+ }
75+
12876/**
12977 * Get all enabled alert rules. Refreshes every 5 minutes.
13078 */
@@ -134,6 +82,20 @@ export const getAlertRules = async (): Promise<AlertRule[]> => {
13482 return cachedRules
13583}
13684
85+ export const getRenderers = async ( ) : Promise < RendererRecord [ ] > => {
86+ if ( ! process . env . ALERT_CONFIG_DB_URL ) return [ ]
87+ await refreshRulesIfStale ( )
88+ return cachedRenderers
89+ }
90+
91+ export const refreshAlertConfig = async ( ) : Promise < void > => {
92+ if ( ! process . env . ALERT_CONFIG_DB_URL ) return
93+ cachedRules = await loadRules ( )
94+ cachedRenderers = await loadRenderers ( )
95+ lastRuleRefresh = Date . now ( )
96+ console . log ( `Alert config: force refreshed ${ cachedRules . length } rules and ${ cachedRenderers . length } renderers` )
97+ }
98+
13799/**
138100 * Refresh alert rules periodically. Changes take effect without redeploy.
139101 * First load must succeed or the process exits — we can't run without rules.
@@ -144,8 +106,9 @@ const refreshRulesIfStale = async (): Promise<void> => {
144106 const isFirstLoad = lastRuleRefresh === 0
145107 try {
146108 cachedRules = await loadRules ( )
109+ cachedRenderers = await loadRenderers ( )
147110 lastRuleRefresh = Date . now ( )
148- console . log ( `Alert config: loaded ${ cachedRules . length } rules` )
111+ console . log ( `Alert config: loaded ${ cachedRules . length } rules and ${ cachedRenderers . length } renderers ` )
149112 } catch ( err ) {
150113 if ( isFirstLoad ) {
151114 console . error ( 'FATAL: Failed to load alert rules on startup:' , err )
@@ -155,6 +118,47 @@ const refreshRulesIfStale = async (): Promise<void> => {
155118 }
156119}
157120
121+ export const findMatchingRenderer = ( params : {
122+ chainId : number
123+ matchType : 'event' | 'trace'
124+ contractAddress ?: string | null
125+ topic0 ?: string | null
126+ sighash ?: string | null
127+ } ) : RendererRecord | null => {
128+ const contractAddress = params . contractAddress ?. toLowerCase ( ) ?? null
129+ const topic0 = params . topic0 ?. toLowerCase ( ) ?? null
130+ const sighash = params . sighash ?. toLowerCase ( ) ?? null
131+
132+ const candidates = cachedRenderers . filter ( ( renderer ) => {
133+ if ( renderer . matchType !== params . matchType ) return false
134+ if ( renderer . chainId != null && renderer . chainId !== params . chainId ) return false
135+
136+ if ( renderer . contractAddresses ?. length ) {
137+ if ( ! contractAddress ) return false
138+ if ( ! renderer . contractAddresses . some ( ( value ) => value . toLowerCase ( ) === contractAddress ) ) return false
139+ }
140+
141+ if ( params . matchType === 'event' && renderer . topic0 ) {
142+ if ( ! topic0 || renderer . topic0 . toLowerCase ( ) !== topic0 ) return false
143+ }
144+
145+ if ( params . matchType === 'trace' && renderer . sighash ) {
146+ if ( ! sighash || renderer . sighash . toLowerCase ( ) !== sighash ) return false
147+ }
148+
149+ return true
150+ } )
151+
152+ if ( candidates . length === 0 ) return null
153+
154+ const score = ( renderer : RendererRecord ) =>
155+ ( renderer . chainId != null ? 4 : 0 ) +
156+ ( renderer . contractAddresses ?. length ? 2 : 0 ) +
157+ ( renderer . topic0 || renderer . sighash ? 1 : 0 )
158+
159+ return candidates . sort ( ( a , b ) => score ( b ) - score ( a ) || a . name . localeCompare ( b . name ) ) [ 0 ] ?? null
160+ }
161+
158162/**
159163 * Find all rules matching an event record.
160164 */
0 commit comments