feat(blockchain): Replace in-memory Maps with SQLite persistence - #157
Merged
BarryArinze merged 1 commit intoAug 27, 2026
Merged
Conversation
Replace the three module-level Map stores (txStore, eventStore, trackerStore) in repository.ts with a durable SQLite backend via better-sqlite3. Key changes: - src/blockchain/db.ts: new SQLite singleton with WAL mode, schema DDL, and test-isolation helpers (__resetDb, __closeDb). Database path configurable via BLOCKCHAIN_DB_PATH env var (defaults to .data/blockchain.db; ':memory:' in tests). - src/blockchain/repository.ts: all three repos rewritten to use prepared SQL statements. Public API surface is unchanged — every method signature (upsert, find, findByHash, findByLedger, orphanByLedger, findUnprocessed, count, __clear, __all) is identical. __clearAllStores() now delegates to __resetDb(). - jest.setup.js: sets BLOCKCHAIN_DB_PATH=':memory:' so all Jest workers get isolated in-process SQLite with no on-disk artefacts. - package.json: adds better-sqlite3@11.10.0 and @types/better-sqlite3@7.6.13. New integration tests (repository.persistence.test.ts): - AC1: cursor survives DB close+reopen (cold-start simulation) - AC2: transaction count identical before/after restart - AC3: committed batches survive mid-scan crash; WAL mode confirmed - AC4: two concurrent fork workers x 1000 inserts = exactly 2000 rows - AC5: 10 000 sequential upserts complete in under 5 seconds All 81 existing tests continue to pass. 12 new tests added (93 total for the blockchain package). Zero new TypeScript errors.
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #138
Summary
This PR replaces the three module-level
Mapobjects insrc/blockchain/repository.ts(
txStore,eventStore,trackerStore) with a durable SQLite backend powered bybetter-sqlite3. All state written by theSoroban ledger indexer now survives process restarts, Vercel cold starts, container
restarts, and
pm2recycling.The public API surface of
blockchainTransactionRepo,contractEventRepo, androllupTrackerRepois 100% unchanged — every method signature is identical to theprevious in-memory implementation. No callers required modification.
Problem
The original implementation stored all ledger indexer state in plain in-process
Mapobjects. This caused several production issues:
GET /api/v1/admin/healthalways reported "behind by N ledgers" after a restartlastProcessedLedgerreset to0on every cold startcontractEventRepo.findUnprocessed()returned[]after restartContractEventrows lost on restart — fraud-detection pipeline silently stalledblockHashreset to''on restart — every stored ledger looked like a reorgContractEventrows on re-scan after restartThe comment in the original
repository.tsexplicitly acknowledged this:Solution
New file:
src/blockchain/db.tsA singleton
Databaseinstance backed bybetter-sqlite3(synchronous, embeddable,no network dependency). Key properties:
PRAGMA journal_mode=WAL— writersproceed concurrently with readers; an fsync only occurs at checkpoint, not on every
write. A mid-scan crash loses at most the rows in the in-flight batch.
BLOCKCHAIN_DB_PATHenvironment variable controls the database file path.Defaults to
.data/blockchain.dbin production. Set to:memory:injest.setup.jsso every Jest worker gets a fully isolated, zero-disk-I/O store.
__resetDb()drops and recreates all three tables — used by__clearAllStores()in
beforeEachhooks, giving each test the same clean-slate guarantee thatMap.clear()previously provided.
__closeDb()closes the singleton connection and clears the module-level reference,enabling cold-start simulation in integration tests without re-spawning a process.
.data/directory is created automatically on first use — no manual setup required.Schema:
Modified file:
src/blockchain/repository.tsRewrote all three repository objects to use prepared SQL statements. Behavioural
contracts preserved exactly:
blockchainTransactionRepo.upsert—INSERT OR IGNOREfollowed byUPDATEin asingle SQLite transaction. The
idandcreatedAtcolumns are written only on thefirst insert and never overwritten on subsequent upserts, matching the original
existing?.id ?? data.id ?? randomUUID()andexisting?.createdAt ?? data.createdAtlogic.
contractEventRepo.upsert—INSERT OR IGNOREagainst the composite primary key.Duplicate events silently no-op and the existing row is returned, identical to the
original
if (existing) return existingguard.contractEventRepo.updateTxHash— wrapped in a SQLite transaction: delete thesentinel row, check whether the real-hash row already exists (parallel resolution
race), and insert the updated row preserving
id,parameters,createdAt, andprocessed.rollupTrackerRepo.upsert—INSERT ... ON CONFLICT(type) DO UPDATE SET ....Partial updates (supplying only
lastProcessedLedgeror onlylastEventCursor) arepreserved by reading the existing row first and merging the supplied fields.
__clearAllStores()— now delegates to__resetDb()(DROP + CREATE), which givesidentical per-test isolation to the previous
Map.clear()calls.eventCompositeKey()— function is unchanged and still exported; callers that usethe string key directly are unaffected.
Modified file:
jest.setup.jsOne line added at the top of the global Jest setup. Every test file automatically gets an
in-memory SQLite database with zero disk I/O and per-test isolation via
__resetDb().New file:
src/blockchain/concurrent-writer-helper.jsA plain CommonJS script executed by
child_process.forkin the AC4 concurrent-writerintegration test. It opens its own
better-sqlite3connection to a shared file-based DBand inserts 1 000 rows inside a single transaction. Sets
busy_timeout=30000so theSQLite write-lock retry loop handles contention transparently without
SQLITE_BUSYerrors.
New file:
src/blockchain/__tests__/repository.persistence.test.tsTwelve integration tests covering the acceptance criteria for the migration:
rollupTrackerRepo.find()returns the persisted cursor after__closeDb()+getDb()(cold-start simulation)blockchainTransactionRepo.count()= 50 both before and after__closeDb()blockNumber,blockHash,memo,processed) survive close+reopenPRAGMA journal_modereturnswalchild_process.fork× 2 × 1 000 inserts =count() === 2000parametersJSON round-trips correctlyfindUnprocessed()returns all 5 rows after restartTest results
No previously passing tests were broken. The pre-existing failure in
src/app/campaigns/[id]/page.test.tsx(a Next.js SWC transform issue in UI code) isunrelated to this PR and was present on
masterbefore this branch.Acceptance criteria checklist
rollupTrackerRepo.find('soroban_indexer')returns the persisted cursor after asimulated cold-start — verified by AC1 tests
blockchainTransactionRepo.count()returns the same value before and after aprocess restart — verified by AC2 tests
lost — verified by AC3 test
txHashvalues eachproduce exactly 2 000 distinct rows — verified by AC4 test (84 ms, no deadlocks)
repository.test.tsandsoroban.indexer.test.tspasswithout modification
npm run type-checksucceeds with zero new errors in blockchain filesPRAGMA journal_mode = wal) on file-based databasesupsert,find,findByHash,findByLedger,orphanByLedger,findUnprocessed,count,__clear,__all) are unchangedConfiguration
Production
Set
BLOCKCHAIN_DB_PATHto the desired file path (or omit to use the default.data/blockchain.db):# .env.local or container environment BLOCKCHAIN_DB_PATH=/var/data/blockchain.dbThe directory is created automatically on first start. WAL mode and
synchronous=NORMALare applied automatically on file-based databases.
Concurrent instances (Vercel / Node cluster)
SQLite WAL mode serialises concurrent writers at the OS level using file locking. Each
serverless function instance opens its own
better-sqlite3connection; thebusy_timeoutsetting (inherited from the WAL pragma path) causes writers to retry for up to 30 seconds
before failing. For deployments with high write concurrency against a network-mounted
volume, an external store (e.g. Redis, Postgres) remains the recommended upgrade path —
this PR removes the in-memory-only blocker.
Files changed
src/blockchain/db.ts__resetDb,__closeDbsrc/blockchain/repository.tssrc/blockchain/concurrent-writer-helper.jssrc/blockchain/__tests__/repository.persistence.test.tsjest.setup.jsBLOCKCHAIN_DB_PATH=':memory:'for test isolationpackage.jsonbetter-sqlite3@11.10.0,@types/better-sqlite3@7.6.13package-lock.jsonOut of scope (not changed in this PR)
src/blockchain/src/blockchain/types.tsfield shapes