Skip to content

Commit b51a320

Browse files
committed
Change store API and add migrations
1 parent 6a0d1bb commit b51a320

File tree

6 files changed

+243
-130
lines changed

6 files changed

+243
-130
lines changed

.changeset/old-signs-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@3loop/transaction-decoder': minor
3+
---
4+
5+
BREAKING! This version changes the public API for ABI store. If you use built in stores evrything should work out of the box. When using SQL store ensure that migrations complete on start.

packages/transaction-decoder/src/abi-store.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,34 @@ export interface AbiParams {
99
signature?: string | undefined
1010
}
1111

12-
export interface ContractAbiSuccess {
13-
status: 'success'
14-
result: ContractABI
15-
}
12+
export type CachedContractABIStatus = 'success' | 'invalid' | 'not-found'
1613

17-
export interface ContractAbiNotFound {
18-
status: 'not-found'
19-
result: null
14+
export type CachedContractABI = ContractABI & {
15+
id: string
16+
source?: string
17+
status: CachedContractABIStatus
18+
timestamp?: string
2019
}
2120

22-
export interface ContractAbiEmpty {
23-
status: 'empty'
24-
result: null
21+
export type CacheContractABIParam = ContractABI & {
22+
source?: string
23+
status: CachedContractABIStatus
24+
timestamp?: string
2525
}
2626

27-
export type ContractAbiResult = ContractAbiSuccess | ContractAbiNotFound | ContractAbiEmpty
27+
export type ContractAbiResult = CachedContractABI[]
2828

2929
type ChainOrDefault = number | 'default'
3030

3131
export interface AbiStore {
3232
readonly strategies: Record<ChainOrDefault, readonly ContractAbiResolverStrategy[]>
33-
readonly set: (key: AbiParams, value: ContractAbiResult) => Effect.Effect<void, never>
33+
readonly set: (key: AbiParams, value: CacheContractABIParam) => Effect.Effect<void, never>
3434
readonly get: (arg: AbiParams) => Effect.Effect<ContractAbiResult, never>
3535
readonly getMany?: (arg: Array<AbiParams>) => Effect.Effect<Array<ContractAbiResult>, never>
36+
readonly updateStatus?: (
37+
id: string | number,
38+
status: 'success' | 'invalid' | 'not-found',
39+
) => Effect.Effect<void, never>
3640
readonly circuitBreaker: CircuitBreaker.CircuitBreaker<unknown>
3741
readonly requestPool: RequestPool.RequestPool
3842
}

packages/transaction-decoder/src/in-memory/abi-store.ts

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,59 @@ import { Effect, Layer } from 'effect'
22
import * as AbiStore from '../abi-store.js'
33
import { ContractABI } from '../abi-strategy/request-model.js'
44

5+
// Keyed by composite: kind|key|source to allow per-strategy replacement
56
const abiCache = new Map<string, ContractABI>()
67

78
export const make = (strategies: AbiStore.AbiStore['strategies']) =>
89
Layer.scoped(
910
AbiStore.AbiStore,
1011
AbiStore.make({
1112
strategies,
12-
set: (_key, value) =>
13+
set: (_key, abi) =>
1314
Effect.sync(() => {
14-
if (value.status === 'success') {
15-
if (value.result.type === 'address') {
16-
abiCache.set(value.result.address, value.result)
17-
} else if (value.result.type === 'event') {
18-
abiCache.set(value.result.event, value.result)
19-
} else if (value.result.type === 'func') {
20-
abiCache.set(value.result.signature, value.result)
21-
}
15+
const source = abi.source ?? 'unknown'
16+
if (abi.type === 'address') {
17+
abiCache.set(`addr|${abi.address}|${source}`.toLowerCase(), abi)
18+
} else if (abi.type === 'event') {
19+
abiCache.set(`event|${abi.event}|${source}`, abi)
20+
} else if (abi.type === 'func') {
21+
abiCache.set(`sig|${abi.signature}|${source}`, abi)
2222
}
2323
}),
2424
get: (key) =>
2525
Effect.sync(() => {
26-
if (abiCache.has(key.address)) {
27-
return {
28-
status: 'success',
29-
result: abiCache.get(key.address)!,
30-
}
31-
}
26+
const results: ContractABI[] = []
3227

33-
if (key.event && abiCache.has(key.event)) {
34-
return {
35-
status: 'success',
36-
result: abiCache.get(key.event)!,
37-
}
38-
}
28+
// If a specific strategy is requested via mark on keys in future, we return union of all strategies for that key
29+
const prefixAddr = `addr|${key.address}|`.toLowerCase()
30+
const prefixSig = key.signature ? `sig|${key.signature}|` : undefined
31+
const prefixEvt = key.event ? `event|${key.event}|` : undefined
3932

40-
if (key.signature && abiCache.has(key.signature)) {
41-
return {
42-
status: 'success',
43-
result: abiCache.get(key.signature)!,
33+
for (const [k, v] of abiCache.entries()) {
34+
if (
35+
k.startsWith(prefixAddr) ||
36+
(prefixSig && k.startsWith(prefixSig)) ||
37+
(prefixEvt && k.startsWith(prefixEvt))
38+
) {
39+
results.push(v)
4440
}
4541
}
4642

47-
return {
48-
status: 'empty',
49-
result: null,
43+
return results
44+
}),
45+
updateStatus: (id, status) =>
46+
Effect.sync(() => {
47+
// For in-memory store, we need to find the ABI by ID and update its status
48+
// Since we don't have ID-based lookup in memory, we'll iterate through cache
49+
for (const [key, abi] of abiCache.entries()) {
50+
if (abi.id === id) {
51+
// Create a new ABI object with updated status
52+
// Note: For in-memory, we can't actually change the status of the result
53+
// since it's used in ContractAbiResult. This is a limitation of the in-memory approach.
54+
// In practice, you'd want to remove invalid ABIs from cache or mark them differently.
55+
abiCache.delete(key)
56+
break
57+
}
5058
}
5159
}),
5260
}),

packages/transaction-decoder/src/sql/abi-store.ts

Lines changed: 118 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as AbiStore from '../abi-store.js'
22
import { Effect, Layer } from 'effect'
33
import { SqlClient } from '@effect/sql'
4+
import { runMigrations, migration } from './migrations.js'
45

56
// Utility function to build query conditions for a single key
67
const buildQueryForKey = (
@@ -21,39 +22,20 @@ const buildQueryForKey = (
2122
: sql.or([addressQuery, signatureQuery, eventQuery].filter(Boolean))
2223
}
2324

24-
// Convert database items to result format
25+
// Convert database items to result format - returns all ABIs with their individual status
2526
const createResult = (items: readonly any[], address: string, chainID: number): AbiStore.ContractAbiResult => {
26-
const successItems = items.filter((item) => item.status === 'success')
27-
28-
const item =
29-
successItems.find((item) => {
30-
// Prioritize address over fragments
31-
return item.type === 'address'
32-
}) ?? successItems[0]
33-
34-
if (item != null) {
35-
return {
36-
status: 'success',
37-
result: {
38-
type: item.type,
39-
event: item.event,
40-
signature: item.signature,
41-
address,
42-
chainID,
43-
abi: item.abi,
44-
},
45-
} as AbiStore.ContractAbiResult
46-
} else if (items[0] != null && items[0].status === 'not-found') {
47-
return {
48-
status: 'not-found',
49-
result: null,
50-
}
51-
}
52-
53-
return {
54-
status: 'empty',
55-
result: null,
56-
}
27+
return items.map((item) => ({
28+
type: item.type,
29+
event: item.event,
30+
signature: item.signature,
31+
address,
32+
chainID,
33+
abi: item.abi,
34+
id: item.id,
35+
source: item.source || 'unknown',
36+
status: item.status as 'success' | 'invalid' | 'not-found',
37+
timestamp: item.timestamp,
38+
}))
5739
}
5840

5941
// Build single lookup map with prefixed keys
@@ -94,77 +76,113 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) =>
9476
Effect.gen(function* () {
9577
const sql = yield* SqlClient.SqlClient
9678

97-
const table = sql('_loop_decoder_contract_abi_v2')
79+
const tableV3 = sql('_loop_decoder_contract_abi_v3')
80+
const tableV2 = sql('_loop_decoder_contract_abi_v2')
9881
const id = sql.onDialectOrElse({
9982
sqlite: () => sql`id INTEGER PRIMARY KEY AUTOINCREMENT,`,
10083
pg: () => sql`id SERIAL PRIMARY KEY,`,
10184
mysql: () => sql`id INT NOT NULL AUTO_INCREMENT, PRIMARY KEY (id),`,
10285
orElse: () => sql``,
10386
})
10487

105-
// TODO; add timestamp to the table
106-
yield* sql`
107-
CREATE TABLE IF NOT EXISTS ${table} (
108-
${id}
109-
type TEXT NOT NULL,
110-
address TEXT,
111-
event TEXT,
112-
signature TEXT,
113-
chain INTEGER,
114-
abi TEXT,
115-
status TEXT NOT NULL,
116-
timestamp TEXT DEFAULT CURRENT_TIMESTAMP
117-
)
118-
`.pipe(
119-
Effect.tapError(Effect.logError),
120-
Effect.catchAll(() => Effect.dieMessage('Failed to create contractAbi table')),
121-
)
88+
// TODO: Allow skipping migrations if users want to apply it manually
89+
// Run structured migrations (idempotent, transactional)
90+
yield* runMigrations([
91+
migration('001_create_contract_abi_v3', (q) =>
92+
Effect.gen(function* () {
93+
yield* q`CREATE TABLE IF NOT EXISTS ${tableV3} (
94+
${id}
95+
type TEXT NOT NULL,
96+
address TEXT,
97+
event TEXT,
98+
signature TEXT,
99+
chain INTEGER,
100+
abi TEXT,
101+
status TEXT NOT NULL,
102+
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
103+
source TEXT DEFAULT 'unknown'
104+
)`
105+
106+
const tsCoalesce = q.onDialectOrElse({
107+
sqlite: () => q`COALESCE(timestamp, CURRENT_TIMESTAMP)`,
108+
pg: () => q`COALESCE(timestamp, CURRENT_TIMESTAMP)`,
109+
mysql: () => q`IFNULL(timestamp, CURRENT_TIMESTAMP)`,
110+
orElse: () => q`CURRENT_TIMESTAMP`,
111+
})
112+
113+
yield* q`
114+
INSERT INTO ${tableV3} (type, address, chain, abi, status, timestamp, source)
115+
SELECT 'address' as type, v.address, v.chain, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source
116+
FROM ${tableV2} as v
117+
WHERE v.type = 'address'
118+
AND v.address IS NOT NULL AND v.chain IS NOT NULL
119+
AND NOT EXISTS (
120+
SELECT 1 FROM ${tableV3} t
121+
WHERE t.type = 'address' AND t.address = v.address AND t.chain = v.chain
122+
)
123+
`.pipe(Effect.catchAll(Effect.logError))
124+
125+
yield* q`
126+
INSERT INTO ${tableV3} (type, signature, abi, status, timestamp, source)
127+
SELECT 'func' as type, v.signature, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source
128+
FROM ${tableV2} as v
129+
WHERE v.type = 'func' AND v.signature IS NOT NULL
130+
AND NOT EXISTS (
131+
SELECT 1 FROM ${tableV3} t
132+
WHERE t.type = 'func' AND t.signature = v.signature
133+
)
134+
`.pipe(Effect.catchAll(Effect.logError))
135+
136+
yield* q`
137+
INSERT INTO ${tableV3} (type, event, abi, status, timestamp, source)
138+
SELECT 'event' as type, v.event, v.abi, v.status, ${tsCoalesce} as timestamp, 'unknown' as source
139+
FROM ${tableV2} as v
140+
WHERE v.type = 'event' AND v.event IS NOT NULL
141+
AND NOT EXISTS (
142+
SELECT 1 FROM ${tableV3} t
143+
WHERE t.type = 'event' AND t.event = v.event
144+
)
145+
`.pipe(Effect.catchAll(Effect.logError))
146+
}),
147+
),
148+
])
149+
150+
const table = tableV3
122151

123152
return yield* AbiStore.make({
124153
strategies,
125-
set: (key, value) =>
154+
set: (key, abi) =>
126155
Effect.gen(function* () {
127156
const normalizedAddress = key.address.toLowerCase()
128-
if (value.status === 'success' && value.result.type === 'address') {
129-
const result = value.result
130-
yield* sql`
131-
INSERT INTO ${table}
132-
${sql.insert([
133-
{
134-
type: result.type,
135-
address: normalizedAddress,
136-
chain: key.chainID,
137-
abi: result.abi,
138-
status: 'success',
139-
},
140-
])}
141-
`
142-
} else if (value.status === 'success') {
143-
const result = value.result
157+
158+
if (abi.type === 'address') {
144159
yield* sql`
145-
INSERT INTO ${table}
146-
${sql.insert([
147-
{
148-
type: result.type,
149-
event: 'event' in result ? result.event : null,
150-
signature: 'signature' in result ? result.signature : null,
151-
abi: result.abi,
152-
status: 'success',
153-
},
154-
])}
155-
`
160+
INSERT INTO ${table}
161+
${sql.insert([
162+
{
163+
type: abi.type,
164+
address: normalizedAddress,
165+
chain: key.chainID,
166+
abi: abi.abi,
167+
status: abi.status,
168+
source: abi.source || 'unknown',
169+
},
170+
])}
171+
`
156172
} else {
157173
yield* sql`
158-
INSERT INTO ${table}
159-
${sql.insert([
160-
{
161-
type: 'address',
162-
address: normalizedAddress,
163-
chain: key.chainID,
164-
status: 'not-found',
165-
},
166-
])}
167-
`
174+
INSERT INTO ${table}
175+
${sql.insert([
176+
{
177+
type: abi.type,
178+
event: 'event' in abi ? abi.event : null,
179+
signature: 'signature' in abi ? abi.signature : null,
180+
abi: abi.abi,
181+
status: abi.status,
182+
source: abi.source || 'unknown',
183+
},
184+
])}
185+
`
168186
}
169187
}).pipe(
170188
Effect.tapError(Effect.logError),
@@ -226,6 +244,18 @@ export const make = (strategies: AbiStore.AbiStore['strategies']) =>
226244
return createResult(keyItems, address, chainID)
227245
})
228246
}),
247+
248+
updateStatus: (id, status) =>
249+
Effect.gen(function* () {
250+
yield* sql`
251+
UPDATE ${table}
252+
SET status = ${status}
253+
WHERE id = ${id}
254+
`.pipe(
255+
Effect.tapError(Effect.logError),
256+
Effect.catchAll(() => Effect.succeed(null)),
257+
)
258+
}),
229259
})
230260
}),
231261
)

0 commit comments

Comments
 (0)