@hybrd/xmtp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cache/tsbuildinfo.json +1 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/README.md +380 -0
- package/biome.jsonc +4 -0
- package/package.json +46 -0
- package/scripts/generate-keys.ts +25 -0
- package/scripts/refresh-identity.ts +119 -0
- package/scripts/register-wallet.ts +95 -0
- package/scripts/revoke-all-installations.ts +91 -0
- package/scripts/revoke-installations.ts +94 -0
- package/src/abi/l2_resolver.ts +699 -0
- package/src/client.ts +940 -0
- package/src/constants.ts +6 -0
- package/src/index.ts +129 -0
- package/src/lib/message-listener.test.ts +369 -0
- package/src/lib/message-listener.ts +343 -0
- package/src/lib/subjects.ts +89 -0
- package/src/localStorage.ts.old +203 -0
- package/src/resolver/address-resolver.ts +221 -0
- package/src/resolver/basename-resolver.ts +585 -0
- package/src/resolver/ens-resolver.ts +324 -0
- package/src/resolver/index.ts +1 -0
- package/src/resolver/resolver.ts +336 -0
- package/src/resolver/xmtp-resolver.ts +436 -0
- package/src/service-client.ts +286 -0
- package/src/transactionMonitor.ts.old +275 -0
- package/src/types.ts +157 -0
- package/tsconfig.json +12 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,940 @@
|
|
|
1
|
+
import { ReactionCodec } from "@xmtp/content-type-reaction"
|
|
2
|
+
import { ReplyCodec } from "@xmtp/content-type-reply"
|
|
3
|
+
import { TransactionReferenceCodec } from "@xmtp/content-type-transaction-reference"
|
|
4
|
+
import { WalletSendCallsCodec } from "@xmtp/content-type-wallet-send-calls"
|
|
5
|
+
import { Client, IdentifierKind, type Signer, XmtpEnv } from "@xmtp/node-sdk"
|
|
6
|
+
import { getRandomValues } from "node:crypto"
|
|
7
|
+
import fs from "node:fs"
|
|
8
|
+
import path from "node:path"
|
|
9
|
+
import { fileURLToPath } from "node:url"
|
|
10
|
+
import { fromString, toString as uint8arraysToString } from "uint8arrays"
|
|
11
|
+
import { createWalletClient, http, toBytes } from "viem"
|
|
12
|
+
import { privateKeyToAccount } from "viem/accounts"
|
|
13
|
+
import { sepolia } from "viem/chains"
|
|
14
|
+
import { revokeOldInstallations } from "../scripts/revoke-installations"
|
|
15
|
+
import { XmtpClient } from "./types"
|
|
16
|
+
|
|
17
|
+
// ===================================================================
|
|
18
|
+
// Module Setup
|
|
19
|
+
// ===================================================================
|
|
20
|
+
// ES module equivalent of __dirname
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
22
|
+
const __dirname = path.dirname(__filename)
|
|
23
|
+
|
|
24
|
+
// ===================================================================
|
|
25
|
+
// Type Definitions
|
|
26
|
+
// ===================================================================
|
|
27
|
+
interface User {
|
|
28
|
+
key: `0x${string}`
|
|
29
|
+
account: ReturnType<typeof privateKeyToAccount>
|
|
30
|
+
wallet: any // Simplified to avoid deep type instantiation
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ===================================================================
|
|
34
|
+
// User and Signer Creation
|
|
35
|
+
// ===================================================================
|
|
36
|
+
export const createUser = (key: string): User => {
|
|
37
|
+
const account = privateKeyToAccount(key as `0x${string}`)
|
|
38
|
+
return {
|
|
39
|
+
key: key as `0x${string}`,
|
|
40
|
+
account,
|
|
41
|
+
wallet: createWalletClient({
|
|
42
|
+
account,
|
|
43
|
+
chain: sepolia,
|
|
44
|
+
transport: http()
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const createSigner = (key: string): Signer => {
|
|
50
|
+
if (!key || typeof key !== "string") {
|
|
51
|
+
throw new Error("XMTP wallet key must be a non-empty string")
|
|
52
|
+
}
|
|
53
|
+
const sanitizedKey = key.startsWith("0x") ? key : `0x${key}`
|
|
54
|
+
const user = createUser(sanitizedKey)
|
|
55
|
+
return {
|
|
56
|
+
type: "EOA",
|
|
57
|
+
getIdentifier: () => ({
|
|
58
|
+
identifierKind: 0 as IdentifierKind.Ethereum, // Use numeric value to avoid ambient const enum issue
|
|
59
|
+
identifier: user.account.address.toLowerCase()
|
|
60
|
+
}),
|
|
61
|
+
signMessage: async (message: string) => {
|
|
62
|
+
const signature = await user.wallet.signMessage({
|
|
63
|
+
message,
|
|
64
|
+
account: user.account
|
|
65
|
+
})
|
|
66
|
+
return toBytes(signature)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// XMTP XmtpClient setup
|
|
72
|
+
// const xmtpClient: XmtpClient | null = null
|
|
73
|
+
|
|
74
|
+
// Function to clear XMTP database when hitting installation limits
|
|
75
|
+
async function clearXMTPDatabase(address: string, env: string) {
|
|
76
|
+
console.log("๐งน Clearing XMTP database to resolve installation limit...")
|
|
77
|
+
|
|
78
|
+
// Get the storage directory using the same logic as getDbPath
|
|
79
|
+
const getStorageDirectory = () => {
|
|
80
|
+
const customStoragePath = process.env.XMTP_STORAGE_PATH
|
|
81
|
+
|
|
82
|
+
if (customStoragePath) {
|
|
83
|
+
return path.isAbsolute(customStoragePath)
|
|
84
|
+
? customStoragePath
|
|
85
|
+
: path.resolve(process.cwd(), customStoragePath)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Use existing logic as fallback
|
|
89
|
+
const projectRoot =
|
|
90
|
+
process.env.PROJECT_ROOT || path.resolve(__dirname, "../../..")
|
|
91
|
+
|
|
92
|
+
return path.join(projectRoot, ".data/xmtp") // Local development
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Clear local database files
|
|
96
|
+
const dbPattern = `${env}-${address}.db3`
|
|
97
|
+
const storageDir = getStorageDirectory()
|
|
98
|
+
|
|
99
|
+
// Primary storage directory
|
|
100
|
+
const possiblePaths = [
|
|
101
|
+
storageDir,
|
|
102
|
+
// Legacy fallback paths for backward compatibility
|
|
103
|
+
path.join(process.cwd(), ".data", "xmtp"),
|
|
104
|
+
path.join(process.cwd(), "..", ".data", "xmtp"),
|
|
105
|
+
path.join(process.cwd(), "..", "..", ".data", "xmtp")
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
for (const dir of possiblePaths) {
|
|
109
|
+
try {
|
|
110
|
+
if (fs.existsSync(dir)) {
|
|
111
|
+
const files = fs.readdirSync(dir)
|
|
112
|
+
const matchingFiles = files.filter(
|
|
113
|
+
(file) =>
|
|
114
|
+
file.includes(dbPattern) ||
|
|
115
|
+
file.includes(address) ||
|
|
116
|
+
file.includes(`xmtp-${env}-${address}`)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for (const file of matchingFiles) {
|
|
120
|
+
const fullPath = path.join(dir, file)
|
|
121
|
+
try {
|
|
122
|
+
fs.unlinkSync(fullPath)
|
|
123
|
+
console.log(`โ
Removed: ${fullPath}`)
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.log(`โ ๏ธ Could not remove ${fullPath}:`, err)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Ignore errors when checking directories
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function createXMTPClient(
|
|
136
|
+
// signer: Signer,
|
|
137
|
+
privateKey: string,
|
|
138
|
+
opts?: {
|
|
139
|
+
persist?: boolean
|
|
140
|
+
maxRetries?: number
|
|
141
|
+
storagePath?: string
|
|
142
|
+
}
|
|
143
|
+
): Promise<XmtpClient> {
|
|
144
|
+
const { persist = true, maxRetries = 3, storagePath } = opts ?? {}
|
|
145
|
+
let attempt = 0
|
|
146
|
+
|
|
147
|
+
// Extract common variables for error handling
|
|
148
|
+
// const actualSigner = signer
|
|
149
|
+
const signer = createSigner(privateKey)
|
|
150
|
+
|
|
151
|
+
if (!signer) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"No signer provided and XMTP_WALLET_KEY environment variable is not set"
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { XMTP_ENCRYPTION_KEY, XMTP_ENV } = process.env
|
|
158
|
+
|
|
159
|
+
// Get the wallet address to use the correct database
|
|
160
|
+
const identifier = await signer.getIdentifier()
|
|
161
|
+
const address = identifier.identifier
|
|
162
|
+
|
|
163
|
+
while (attempt < maxRetries) {
|
|
164
|
+
try {
|
|
165
|
+
console.log(
|
|
166
|
+
`๐ Attempt ${attempt + 1}/${maxRetries} to create XMTP client...`
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
// Always require encryption key and persistence - no stateless mode
|
|
170
|
+
if (!persist) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
"Stateless mode is not supported. XMTP client must run in persistent mode " +
|
|
173
|
+
"to properly receive and process messages. Set persist: true or remove the persist option " +
|
|
174
|
+
"to use the default persistent mode."
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!XMTP_ENCRYPTION_KEY) {
|
|
179
|
+
throw new Error("XMTP_ENCRYPTION_KEY must be set for persistent mode")
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const dbEncryptionKey = getEncryptionKeyFromHex(XMTP_ENCRYPTION_KEY)
|
|
183
|
+
const dbPath = await getDbPath(
|
|
184
|
+
`${XMTP_ENV || "dev"}-${address}`,
|
|
185
|
+
storagePath
|
|
186
|
+
)
|
|
187
|
+
console.log(`๐ Using database path: ${dbPath}`)
|
|
188
|
+
|
|
189
|
+
// Always create a fresh client and sync it
|
|
190
|
+
const client = await Client.create(signer, {
|
|
191
|
+
dbEncryptionKey,
|
|
192
|
+
env: XMTP_ENV as XmtpEnv,
|
|
193
|
+
dbPath,
|
|
194
|
+
codecs: [
|
|
195
|
+
new ReplyCodec(),
|
|
196
|
+
new ReactionCodec(),
|
|
197
|
+
new WalletSendCallsCodec(),
|
|
198
|
+
new TransactionReferenceCodec()
|
|
199
|
+
]
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// Force sync conversations to ensure we have the latest data
|
|
203
|
+
console.log("๐ก Syncing conversations to ensure latest state...")
|
|
204
|
+
await client.conversations.sync()
|
|
205
|
+
|
|
206
|
+
await backupDbToPersistentStorage(
|
|
207
|
+
dbPath,
|
|
208
|
+
`${XMTP_ENV || "dev"}-${address}`
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
console.log("โ
XMTP XmtpClient created")
|
|
212
|
+
console.log(`๐ Wallet address: ${address}`)
|
|
213
|
+
console.log(`๐ Environment: ${XMTP_ENV || "dev"}`)
|
|
214
|
+
console.log(`๐พ Storage mode: persistent`)
|
|
215
|
+
|
|
216
|
+
return client
|
|
217
|
+
} catch (error) {
|
|
218
|
+
attempt++
|
|
219
|
+
|
|
220
|
+
if (
|
|
221
|
+
error instanceof Error &&
|
|
222
|
+
error.message.includes("5/5 installations")
|
|
223
|
+
) {
|
|
224
|
+
console.log(
|
|
225
|
+
`๐ฅ Installation limit reached (attempt ${attempt}/${maxRetries})`
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if (attempt < maxRetries) {
|
|
229
|
+
// Get wallet address for database clearing
|
|
230
|
+
const identifier = await signer.getIdentifier()
|
|
231
|
+
const address = identifier.identifier
|
|
232
|
+
|
|
233
|
+
// Extract inboxId from the error message
|
|
234
|
+
const inboxIdMatch = error.message.match(/InboxID ([a-f0-9]+)/)
|
|
235
|
+
const inboxId = inboxIdMatch ? inboxIdMatch[1] : undefined
|
|
236
|
+
|
|
237
|
+
// First try to revoke old installations
|
|
238
|
+
const revocationSuccess = await revokeOldInstallations(
|
|
239
|
+
signer,
|
|
240
|
+
inboxId
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if (revocationSuccess) {
|
|
244
|
+
console.log("๐ฏ Installations revoked, retrying connection...")
|
|
245
|
+
} else {
|
|
246
|
+
console.log(
|
|
247
|
+
"โ ๏ธ Installation revocation failed or not needed, clearing database..."
|
|
248
|
+
)
|
|
249
|
+
// Clear database as fallback
|
|
250
|
+
await clearXMTPDatabase(address, process.env.XMTP_ENV || "dev")
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Wait a bit before retrying
|
|
254
|
+
const delay = Math.pow(2, attempt) * 1000 // Exponential backoff
|
|
255
|
+
console.log(`โณ Waiting ${delay}ms before retry...`)
|
|
256
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
257
|
+
} else {
|
|
258
|
+
console.error(
|
|
259
|
+
"โ Failed to resolve installation limit after all retries"
|
|
260
|
+
)
|
|
261
|
+
console.error("๐ก Possible solutions:")
|
|
262
|
+
console.error(" 1. Use a different wallet (generate new keys)")
|
|
263
|
+
console.error(" 2. Switch XMTP environments (dev <-> production)")
|
|
264
|
+
console.error(" 3. Wait and try again later")
|
|
265
|
+
console.error(" 4. Contact XMTP support for manual intervention")
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
} else if (
|
|
269
|
+
error instanceof Error &&
|
|
270
|
+
error.message.includes("Association error: Missing identity update")
|
|
271
|
+
) {
|
|
272
|
+
console.log(
|
|
273
|
+
`๐ Identity association error detected (attempt ${attempt}/${maxRetries})`
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if (attempt < maxRetries) {
|
|
277
|
+
console.log("๐ง Attempting automatic identity refresh...")
|
|
278
|
+
|
|
279
|
+
// Try to refresh identity by creating a persistent client first
|
|
280
|
+
try {
|
|
281
|
+
console.log("๐ Creating persistent client to refresh identity...")
|
|
282
|
+
const tempEncryptionKey = XMTP_ENCRYPTION_KEY
|
|
283
|
+
? getEncryptionKeyFromHex(XMTP_ENCRYPTION_KEY)
|
|
284
|
+
: getEncryptionKeyFromHex(generateEncryptionKeyHex())
|
|
285
|
+
const tempClient = await Client.create(signer, {
|
|
286
|
+
dbEncryptionKey: tempEncryptionKey,
|
|
287
|
+
env: XMTP_ENV as XmtpEnv,
|
|
288
|
+
dbPath: await getDbPath(
|
|
289
|
+
`${XMTP_ENV || "dev"}-${address}`,
|
|
290
|
+
storagePath
|
|
291
|
+
),
|
|
292
|
+
codecs: [
|
|
293
|
+
new ReplyCodec(),
|
|
294
|
+
new ReactionCodec(),
|
|
295
|
+
new WalletSendCallsCodec(),
|
|
296
|
+
new TransactionReferenceCodec()
|
|
297
|
+
]
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
console.log("๐ก Syncing identity and conversations...")
|
|
301
|
+
await tempClient.conversations.sync()
|
|
302
|
+
|
|
303
|
+
console.log(
|
|
304
|
+
"โ
Identity refresh successful, retrying original request..."
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
// Wait a bit before retrying
|
|
308
|
+
const delay = Math.pow(2, attempt) * 1000 // Exponential backoff
|
|
309
|
+
console.log(`โณ Waiting ${delay}ms before retry...`)
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, delay))
|
|
311
|
+
} catch (refreshError) {
|
|
312
|
+
console.log(`โ Identity refresh failed:`, refreshError)
|
|
313
|
+
// Continue to the retry logic
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
console.error(
|
|
317
|
+
"โ Failed to resolve identity association error after all retries"
|
|
318
|
+
)
|
|
319
|
+
console.error(
|
|
320
|
+
"๐ก Try running: pnpm with-env pnpm --filter @hybrd/xmtp refresh:identity"
|
|
321
|
+
)
|
|
322
|
+
throw error
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
// For other errors, don't retry
|
|
326
|
+
throw error
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
throw new Error("Max retries exceeded")
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ===================================================================
|
|
335
|
+
// Encryption Key Management
|
|
336
|
+
// ===================================================================
|
|
337
|
+
/**
|
|
338
|
+
* Generate a random encryption key
|
|
339
|
+
* @returns The encryption key as a hex string
|
|
340
|
+
*/
|
|
341
|
+
export const generateEncryptionKeyHex = () => {
|
|
342
|
+
const uint8Array = getRandomValues(new Uint8Array(32))
|
|
343
|
+
return uint8arraysToString(uint8Array, "hex")
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Get the encryption key from a hex string
|
|
348
|
+
* @param hex - The hex string
|
|
349
|
+
* @returns The encryption key as Uint8Array
|
|
350
|
+
*/
|
|
351
|
+
export const getEncryptionKeyFromHex = (hex: string): Uint8Array => {
|
|
352
|
+
return fromString(hex, "hex")
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ===================================================================
|
|
356
|
+
// Database Path Management
|
|
357
|
+
// ===================================================================
|
|
358
|
+
export const getDbPath = async (description = "xmtp", storagePath?: string) => {
|
|
359
|
+
// Allow custom storage path via environment variable
|
|
360
|
+
const customStoragePath = process.env.XMTP_STORAGE_PATH
|
|
361
|
+
|
|
362
|
+
let volumePath: string
|
|
363
|
+
|
|
364
|
+
if (customStoragePath) {
|
|
365
|
+
// Use custom storage path if provided
|
|
366
|
+
volumePath = path.isAbsolute(customStoragePath)
|
|
367
|
+
? customStoragePath
|
|
368
|
+
: path.resolve(process.cwd(), customStoragePath)
|
|
369
|
+
} else if (storagePath) {
|
|
370
|
+
volumePath = path.isAbsolute(storagePath)
|
|
371
|
+
? storagePath
|
|
372
|
+
: path.resolve(process.cwd(), storagePath)
|
|
373
|
+
} else {
|
|
374
|
+
// Use existing logic as fallback
|
|
375
|
+
const projectRoot =
|
|
376
|
+
process.env.PROJECT_ROOT || path.resolve(__dirname, "../../..")
|
|
377
|
+
|
|
378
|
+
// Default storage path for local development
|
|
379
|
+
volumePath = path.join(projectRoot, ".data/xmtp")
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const dbPath = `${volumePath}/${description}.db3`
|
|
383
|
+
|
|
384
|
+
if (typeof globalThis !== "undefined" && "XMTP_STORAGE" in globalThis) {
|
|
385
|
+
try {
|
|
386
|
+
console.log(`๐ฆ Using Cloudflare R2 storage for: ${dbPath}`)
|
|
387
|
+
|
|
388
|
+
const r2Bucket = (globalThis as any).XMTP_STORAGE
|
|
389
|
+
const remotePath = `xmtp-databases/${description}.db3`
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
const existingObject = await r2Bucket.head(remotePath)
|
|
393
|
+
if (existingObject) {
|
|
394
|
+
console.log(`๐ฅ Downloading existing database from R2 storage...`)
|
|
395
|
+
|
|
396
|
+
if (!fs.existsSync(volumePath)) {
|
|
397
|
+
fs.mkdirSync(volumePath, { recursive: true })
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const object = await r2Bucket.get(remotePath)
|
|
401
|
+
if (object) {
|
|
402
|
+
const fileData = await object.arrayBuffer()
|
|
403
|
+
fs.writeFileSync(dbPath, new Uint8Array(fileData))
|
|
404
|
+
console.log(`โ
Database downloaded from R2 storage`)
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
console.log(`๐ No existing database found in R2 storage`)
|
|
408
|
+
}
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.log(`โ ๏ธ Failed to download database from R2 storage:`, error)
|
|
411
|
+
}
|
|
412
|
+
} catch (error) {
|
|
413
|
+
console.log(`โ ๏ธ R2 storage not available:`, error)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (!fs.existsSync(volumePath)) {
|
|
418
|
+
fs.mkdirSync(volumePath, { recursive: true })
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return dbPath
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export const backupDbToPersistentStorage = async (
|
|
425
|
+
dbPath: string,
|
|
426
|
+
description: string
|
|
427
|
+
) => {
|
|
428
|
+
if (
|
|
429
|
+
typeof globalThis !== "undefined" &&
|
|
430
|
+
"XMTP_STORAGE" in globalThis &&
|
|
431
|
+
fs.existsSync(dbPath)
|
|
432
|
+
) {
|
|
433
|
+
try {
|
|
434
|
+
console.log(`๐ฆ Backing up database to R2 storage: ${dbPath}`)
|
|
435
|
+
|
|
436
|
+
const r2Bucket = (globalThis as any).XMTP_STORAGE
|
|
437
|
+
const remotePath = `xmtp-databases/${description}.db3`
|
|
438
|
+
|
|
439
|
+
const fileData = fs.readFileSync(dbPath)
|
|
440
|
+
await r2Bucket.put(remotePath, fileData)
|
|
441
|
+
console.log(`โ
Database backed up to R2 storage: ${remotePath}`)
|
|
442
|
+
} catch (error) {
|
|
443
|
+
console.log(`โ ๏ธ Failed to backup database to R2 storage:`, error)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ===================================================================
|
|
449
|
+
// Logging and Debugging
|
|
450
|
+
// ===================================================================
|
|
451
|
+
export const logAgentDetails = async (
|
|
452
|
+
clients: XmtpClient | XmtpClient[]
|
|
453
|
+
): Promise<void> => {
|
|
454
|
+
const clientsByAddress = Array.isArray(clients)
|
|
455
|
+
? clients.reduce<Record<string, XmtpClient[]>>((acc, XmtpClient) => {
|
|
456
|
+
const address = XmtpClient.accountIdentifier?.identifier ?? ""
|
|
457
|
+
acc[address] = acc[address] ?? []
|
|
458
|
+
acc[address].push(XmtpClient)
|
|
459
|
+
return acc
|
|
460
|
+
}, {})
|
|
461
|
+
: {
|
|
462
|
+
[clients.accountIdentifier?.identifier ?? ""]: [clients]
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
for (const [address, clientGroup] of Object.entries(clientsByAddress)) {
|
|
466
|
+
const firstClient = clientGroup[0]
|
|
467
|
+
const inboxId = firstClient?.inboxId
|
|
468
|
+
const environments = clientGroup
|
|
469
|
+
.map((c) => c.options?.env ?? "dev")
|
|
470
|
+
.join(", ")
|
|
471
|
+
console.log(`\x1b[38;2;252;76;52m
|
|
472
|
+
โโโ โโโโโโโ โโโโโโโโโโโโโโโโโโโโ
|
|
473
|
+
โโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโ
|
|
474
|
+
โโโโโโ โโโโโโโโโโโ โโโ โโโโโโโโ
|
|
475
|
+
โโโโโโ โโโโโโโโโโโ โโโ โโโโโโโ
|
|
476
|
+
โโโโ โโโโโโ โโโ โโโ โโโ โโโ
|
|
477
|
+
โโโ โโโโโโ โโโ โโโ โโโ
|
|
478
|
+
\x1b[0m`)
|
|
479
|
+
|
|
480
|
+
const urls = [`http://xmtp.chat/dm/${address}`]
|
|
481
|
+
|
|
482
|
+
const conversations = await firstClient?.conversations.list()
|
|
483
|
+
|
|
484
|
+
console.log(`
|
|
485
|
+
โ XMTP XmtpClient:
|
|
486
|
+
โข Address: ${address}
|
|
487
|
+
โข Conversations: ${conversations?.length}
|
|
488
|
+
โข InboxId: ${inboxId}
|
|
489
|
+
โข Networks: ${environments}
|
|
490
|
+
${urls.map((url) => `โข URL: ${url}`).join("\n")}`)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ===================================================================
|
|
495
|
+
// Environment Validation
|
|
496
|
+
// ===================================================================
|
|
497
|
+
export function validateEnvironment(vars: string[]): Record<string, string> {
|
|
498
|
+
const missing = vars.filter((v) => !process.env[v])
|
|
499
|
+
|
|
500
|
+
if (missing.length) {
|
|
501
|
+
try {
|
|
502
|
+
const envPath = path.resolve(process.cwd(), ".env")
|
|
503
|
+
if (fs.existsSync(envPath)) {
|
|
504
|
+
const envVars = fs
|
|
505
|
+
.readFileSync(envPath, "utf-8")
|
|
506
|
+
.split("\n")
|
|
507
|
+
.filter((line) => line.trim() && !line.startsWith("#"))
|
|
508
|
+
.reduce<Record<string, string>>((acc, line) => {
|
|
509
|
+
const [key, ...val] = line.split("=")
|
|
510
|
+
if (key && val.length) acc[key.trim()] = val.join("=").trim()
|
|
511
|
+
return acc
|
|
512
|
+
}, {})
|
|
513
|
+
|
|
514
|
+
missing.forEach((v) => {
|
|
515
|
+
if (envVars[v]) process.env[v] = envVars[v]
|
|
516
|
+
})
|
|
517
|
+
}
|
|
518
|
+
} catch (e) {
|
|
519
|
+
console.error(e)
|
|
520
|
+
/* ignore errors */
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const stillMissing = vars.filter((v) => !process.env[v])
|
|
524
|
+
if (stillMissing.length) {
|
|
525
|
+
console.error("Missing env vars:", stillMissing.join(", "))
|
|
526
|
+
process.exit(1)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return vars.reduce<Record<string, string>>((acc, key) => {
|
|
531
|
+
acc[key] = process.env[key] as string
|
|
532
|
+
return acc
|
|
533
|
+
}, {})
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Diagnose XMTP environment and identity issues
|
|
538
|
+
*/
|
|
539
|
+
export async function diagnoseXMTPIdentityIssue(
|
|
540
|
+
client: XmtpClient,
|
|
541
|
+
inboxId: string,
|
|
542
|
+
environment: string
|
|
543
|
+
): Promise<{
|
|
544
|
+
canResolve: boolean
|
|
545
|
+
suggestions: string[]
|
|
546
|
+
details: Record<string, any>
|
|
547
|
+
}> {
|
|
548
|
+
const suggestions: string[] = []
|
|
549
|
+
const details: Record<string, any> = {
|
|
550
|
+
environment,
|
|
551
|
+
inboxId,
|
|
552
|
+
timestamp: new Date().toISOString()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
try {
|
|
556
|
+
// Try to resolve the inbox state
|
|
557
|
+
const inboxState = await client.preferences.inboxStateFromInboxIds([
|
|
558
|
+
inboxId
|
|
559
|
+
])
|
|
560
|
+
|
|
561
|
+
if (inboxState.length === 0) {
|
|
562
|
+
suggestions.push(
|
|
563
|
+
`Inbox ID ${inboxId} not found in ${environment} environment`
|
|
564
|
+
)
|
|
565
|
+
suggestions.push(
|
|
566
|
+
"Try switching XMTP_ENV to 'dev' if currently 'production' or vice versa"
|
|
567
|
+
)
|
|
568
|
+
suggestions.push(
|
|
569
|
+
"Verify the user has created an identity on this XMTP network"
|
|
570
|
+
)
|
|
571
|
+
details.inboxStateFound = false
|
|
572
|
+
return { canResolve: false, suggestions, details }
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const inbox = inboxState[0]
|
|
576
|
+
if (!inbox) {
|
|
577
|
+
suggestions.push("Inbox state returned empty data")
|
|
578
|
+
details.inboxStateFound = false
|
|
579
|
+
return { canResolve: false, suggestions, details }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
details.inboxStateFound = true
|
|
583
|
+
details.identifierCount = inbox.identifiers?.length || 0
|
|
584
|
+
|
|
585
|
+
if (!inbox.identifiers || inbox.identifiers.length === 0) {
|
|
586
|
+
suggestions.push("Inbox found but has no identifiers")
|
|
587
|
+
suggestions.push("This indicates incomplete identity registration")
|
|
588
|
+
suggestions.push("User may need to re-register their identity on XMTP")
|
|
589
|
+
details.hasIdentifiers = false
|
|
590
|
+
return { canResolve: false, suggestions, details }
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Successfully resolved
|
|
594
|
+
details.hasIdentifiers = true
|
|
595
|
+
details.resolvedAddress = inbox.identifiers[0]?.identifier
|
|
596
|
+
return {
|
|
597
|
+
canResolve: true,
|
|
598
|
+
suggestions: ["Identity resolved successfully"],
|
|
599
|
+
details
|
|
600
|
+
}
|
|
601
|
+
} catch (error) {
|
|
602
|
+
const errorMessage = error instanceof Error ? error.message : String(error)
|
|
603
|
+
details.error = errorMessage
|
|
604
|
+
|
|
605
|
+
if (errorMessage.includes("Association error")) {
|
|
606
|
+
suggestions.push("XMTP identity association error detected")
|
|
607
|
+
suggestions.push(
|
|
608
|
+
"Check if user exists on the correct XMTP environment (dev vs production)"
|
|
609
|
+
)
|
|
610
|
+
suggestions.push(
|
|
611
|
+
"Identity may need to be recreated on the current environment"
|
|
612
|
+
)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (errorMessage.includes("Missing identity update")) {
|
|
616
|
+
suggestions.push("Missing identity updates in XMTP network")
|
|
617
|
+
suggestions.push("This can indicate network sync issues")
|
|
618
|
+
suggestions.push("Wait a few minutes and retry, or recreate identity")
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (errorMessage.includes("database") || errorMessage.includes("storage")) {
|
|
622
|
+
suggestions.push("XMTP local database/storage issue")
|
|
623
|
+
suggestions.push("Try clearing XMTP database and resyncing")
|
|
624
|
+
suggestions.push("Check .data/xmtp directory permissions")
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
suggestions.push("Consider testing with a fresh XMTP identity")
|
|
628
|
+
return { canResolve: false, suggestions, details }
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// ===================================================================
|
|
633
|
+
// Enhanced Connection Management & Health Monitoring
|
|
634
|
+
// ===================================================================
|
|
635
|
+
|
|
636
|
+
export interface XMTPConnectionConfig {
|
|
637
|
+
maxRetries?: number
|
|
638
|
+
retryDelayMs?: number
|
|
639
|
+
healthCheckIntervalMs?: number
|
|
640
|
+
connectionTimeoutMs?: number
|
|
641
|
+
reconnectOnFailure?: boolean
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export interface XMTPConnectionHealth {
|
|
645
|
+
isConnected: boolean
|
|
646
|
+
lastHealthCheck: Date
|
|
647
|
+
consecutiveFailures: number
|
|
648
|
+
totalReconnects: number
|
|
649
|
+
avgResponseTime: number
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export class XMTPConnectionManager {
|
|
653
|
+
private client: XmtpClient | null = null
|
|
654
|
+
private privateKey: string
|
|
655
|
+
private config: Required<XMTPConnectionConfig>
|
|
656
|
+
private health: XMTPConnectionHealth
|
|
657
|
+
private healthCheckTimer: NodeJS.Timeout | null = null
|
|
658
|
+
private isReconnecting = false
|
|
659
|
+
|
|
660
|
+
constructor(privateKey: string, config: XMTPConnectionConfig = {}) {
|
|
661
|
+
this.privateKey = privateKey
|
|
662
|
+
this.config = {
|
|
663
|
+
maxRetries: config.maxRetries ?? 5,
|
|
664
|
+
retryDelayMs: config.retryDelayMs ?? 1000,
|
|
665
|
+
healthCheckIntervalMs: config.healthCheckIntervalMs ?? 30000,
|
|
666
|
+
connectionTimeoutMs: config.connectionTimeoutMs ?? 10000,
|
|
667
|
+
reconnectOnFailure: config.reconnectOnFailure ?? true
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
this.health = {
|
|
671
|
+
isConnected: false,
|
|
672
|
+
lastHealthCheck: new Date(),
|
|
673
|
+
consecutiveFailures: 0,
|
|
674
|
+
totalReconnects: 0,
|
|
675
|
+
avgResponseTime: 0
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async connect(persist = false): Promise<XmtpClient> {
|
|
680
|
+
if (this.client && this.health.isConnected) {
|
|
681
|
+
return this.client
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let attempt = 0
|
|
685
|
+
while (attempt < this.config.maxRetries) {
|
|
686
|
+
try {
|
|
687
|
+
console.log(
|
|
688
|
+
`๐ XMTP connection attempt ${attempt + 1}/${this.config.maxRetries}`
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
this.client = await createXMTPClient(this.privateKey, { persist })
|
|
692
|
+
this.health.isConnected = true
|
|
693
|
+
this.health.consecutiveFailures = 0
|
|
694
|
+
|
|
695
|
+
// Start health monitoring
|
|
696
|
+
this.startHealthMonitoring()
|
|
697
|
+
|
|
698
|
+
console.log("โ
XMTP client connected successfully")
|
|
699
|
+
return this.client
|
|
700
|
+
} catch (error) {
|
|
701
|
+
attempt++
|
|
702
|
+
this.health.consecutiveFailures++
|
|
703
|
+
|
|
704
|
+
console.error(`โ XMTP connection attempt ${attempt} failed:`, error)
|
|
705
|
+
|
|
706
|
+
if (attempt < this.config.maxRetries) {
|
|
707
|
+
const delay = this.config.retryDelayMs * Math.pow(2, attempt - 1)
|
|
708
|
+
console.log(`โณ Retrying in ${delay}ms...`)
|
|
709
|
+
await this.sleep(delay)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
throw new Error(
|
|
715
|
+
`Failed to connect to XMTP after ${this.config.maxRetries} attempts`
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// private async createClientWithTimeout(persist: boolean): Promise<XmtpClient> {
|
|
720
|
+
// const timeoutPromise = new Promise<never>((_, reject) => {
|
|
721
|
+
// setTimeout(
|
|
722
|
+
// () => reject(new Error("Connection timeout")),
|
|
723
|
+
// this.config.connectionTimeoutMs
|
|
724
|
+
// )
|
|
725
|
+
// })
|
|
726
|
+
|
|
727
|
+
// const clientPromise = createXMTPClient(this.signer, { persist })
|
|
728
|
+
|
|
729
|
+
// return Promise.race([clientPromise, timeoutPromise])
|
|
730
|
+
// }
|
|
731
|
+
|
|
732
|
+
private startHealthMonitoring(): void {
|
|
733
|
+
if (this.healthCheckTimer) {
|
|
734
|
+
clearInterval(this.healthCheckTimer)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
this.healthCheckTimer = setInterval(() => {
|
|
738
|
+
this.performHealthCheck()
|
|
739
|
+
}, this.config.healthCheckIntervalMs)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private async performHealthCheck(): Promise<void> {
|
|
743
|
+
if (!this.client) return
|
|
744
|
+
|
|
745
|
+
const startTime = Date.now()
|
|
746
|
+
|
|
747
|
+
try {
|
|
748
|
+
// Simple health check: try to list conversations
|
|
749
|
+
await this.client.conversations.list()
|
|
750
|
+
|
|
751
|
+
const responseTime = Date.now() - startTime
|
|
752
|
+
this.health.avgResponseTime =
|
|
753
|
+
(this.health.avgResponseTime + responseTime) / 2
|
|
754
|
+
this.health.lastHealthCheck = new Date()
|
|
755
|
+
this.health.consecutiveFailures = 0
|
|
756
|
+
this.health.isConnected = true
|
|
757
|
+
|
|
758
|
+
console.log(`๐ XMTP health check passed (${responseTime}ms)`)
|
|
759
|
+
} catch (error) {
|
|
760
|
+
this.health.consecutiveFailures++
|
|
761
|
+
this.health.isConnected = false
|
|
762
|
+
|
|
763
|
+
console.error(`๐ XMTP health check failed:`, error)
|
|
764
|
+
|
|
765
|
+
// Trigger reconnection if enabled
|
|
766
|
+
if (this.config.reconnectOnFailure && !this.isReconnecting) {
|
|
767
|
+
this.handleConnectionFailure()
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private async handleConnectionFailure(): Promise<void> {
|
|
773
|
+
if (this.isReconnecting) return
|
|
774
|
+
|
|
775
|
+
this.isReconnecting = true
|
|
776
|
+
this.health.totalReconnects++
|
|
777
|
+
|
|
778
|
+
console.log("๐ XMTP connection lost, attempting to reconnect...")
|
|
779
|
+
|
|
780
|
+
try {
|
|
781
|
+
this.client = null
|
|
782
|
+
await this.connect()
|
|
783
|
+
console.log("โ
XMTP reconnection successful")
|
|
784
|
+
} catch (error) {
|
|
785
|
+
console.error("โ XMTP reconnection failed:", error)
|
|
786
|
+
} finally {
|
|
787
|
+
this.isReconnecting = false
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
private sleep(ms: number): Promise<void> {
|
|
792
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
getHealth(): XMTPConnectionHealth {
|
|
796
|
+
return { ...this.health }
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
getClient(): XmtpClient | null {
|
|
800
|
+
return this.client
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async disconnect(): Promise<void> {
|
|
804
|
+
if (this.healthCheckTimer) {
|
|
805
|
+
clearInterval(this.healthCheckTimer)
|
|
806
|
+
this.healthCheckTimer = null
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
this.client = null
|
|
810
|
+
this.health.isConnected = false
|
|
811
|
+
console.log("๐ XMTP client disconnected")
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Enhanced client creation with connection management
|
|
816
|
+
export async function createXMTPConnectionManager(
|
|
817
|
+
privateKey: string,
|
|
818
|
+
config?: XMTPConnectionConfig
|
|
819
|
+
): Promise<XMTPConnectionManager> {
|
|
820
|
+
const manager = new XMTPConnectionManager(privateKey, config)
|
|
821
|
+
await manager.connect()
|
|
822
|
+
return manager
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ===================================================================
|
|
826
|
+
// User Address Resolution with Auto-Refresh
|
|
827
|
+
// ===================================================================
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Resolve user address from inbox ID with automatic identity refresh on association errors
|
|
831
|
+
*/
|
|
832
|
+
export async function resolveUserAddress(
|
|
833
|
+
client: XmtpClient,
|
|
834
|
+
senderInboxId: string,
|
|
835
|
+
maxRetries = 2
|
|
836
|
+
): Promise<string> {
|
|
837
|
+
let attempt = 0
|
|
838
|
+
|
|
839
|
+
while (attempt < maxRetries) {
|
|
840
|
+
try {
|
|
841
|
+
console.log(
|
|
842
|
+
`๐ Resolving user address (attempt ${attempt + 1}/${maxRetries})...`
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
const inboxState = await client.preferences.inboxStateFromInboxIds([
|
|
846
|
+
senderInboxId
|
|
847
|
+
])
|
|
848
|
+
|
|
849
|
+
const firstInbox = inboxState[0]
|
|
850
|
+
if (
|
|
851
|
+
inboxState.length > 0 &&
|
|
852
|
+
firstInbox?.identifiers &&
|
|
853
|
+
firstInbox.identifiers.length > 0
|
|
854
|
+
) {
|
|
855
|
+
const userAddress = firstInbox.identifiers[0]?.identifier
|
|
856
|
+
if (userAddress) {
|
|
857
|
+
console.log("โ
Resolved user address:", userAddress)
|
|
858
|
+
return userAddress
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
console.log("โ ๏ธ No identifiers found in inbox state")
|
|
863
|
+
return "unknown"
|
|
864
|
+
} catch (error) {
|
|
865
|
+
attempt++
|
|
866
|
+
|
|
867
|
+
if (
|
|
868
|
+
error instanceof Error &&
|
|
869
|
+
error.message.includes("Association error: Missing identity update")
|
|
870
|
+
) {
|
|
871
|
+
console.log(
|
|
872
|
+
`๐ Identity association error during address resolution (attempt ${attempt}/${maxRetries})`
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
if (attempt < maxRetries) {
|
|
876
|
+
console.log(
|
|
877
|
+
"๐ง Attempting automatic identity refresh for address resolution..."
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
try {
|
|
881
|
+
// Force a conversation sync to refresh identity state
|
|
882
|
+
console.log("๐ก Syncing conversations to refresh identity...")
|
|
883
|
+
await client.conversations.sync()
|
|
884
|
+
|
|
885
|
+
// Small delay before retry
|
|
886
|
+
console.log("โณ Waiting 2s before retry...")
|
|
887
|
+
await new Promise((resolve) => setTimeout(resolve, 2000))
|
|
888
|
+
|
|
889
|
+
console.log(
|
|
890
|
+
"โ
Identity sync completed, retrying address resolution..."
|
|
891
|
+
)
|
|
892
|
+
} catch (refreshError) {
|
|
893
|
+
console.log(`โ Identity refresh failed:`, refreshError)
|
|
894
|
+
}
|
|
895
|
+
} else {
|
|
896
|
+
console.error("โ Failed to resolve user address after all retries")
|
|
897
|
+
console.error("๐ก Identity association issue persists")
|
|
898
|
+
|
|
899
|
+
// Run diagnostic
|
|
900
|
+
try {
|
|
901
|
+
const diagnosis = await diagnoseXMTPIdentityIssue(
|
|
902
|
+
client,
|
|
903
|
+
senderInboxId,
|
|
904
|
+
process.env.XMTP_ENV || "dev"
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
console.log("๐ XMTP Identity Diagnosis:")
|
|
908
|
+
diagnosis.suggestions.forEach((suggestion) => {
|
|
909
|
+
console.error(`๐ก ${suggestion}`)
|
|
910
|
+
})
|
|
911
|
+
} catch (diagError) {
|
|
912
|
+
console.warn("โ ๏ธ Could not run XMTP identity diagnosis:", diagError)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return "unknown"
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
// For other errors, don't retry
|
|
919
|
+
console.error("โ Error resolving user address:", error)
|
|
920
|
+
return "unknown"
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return "unknown"
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export const startPeriodicBackup = (
|
|
929
|
+
dbPath: string,
|
|
930
|
+
description: string,
|
|
931
|
+
intervalMs = 300000
|
|
932
|
+
) => {
|
|
933
|
+
return setInterval(async () => {
|
|
934
|
+
try {
|
|
935
|
+
await backupDbToPersistentStorage(dbPath, description)
|
|
936
|
+
} catch (error) {
|
|
937
|
+
console.log(`โ ๏ธ Periodic backup failed:`, error)
|
|
938
|
+
}
|
|
939
|
+
}, intervalMs)
|
|
940
|
+
}
|