Skip to content

Commit 8f8b862

Browse files
committed
docs(adr): add ADR-0004 for event history storage
1 parent 10f827c commit 8f8b862

1 file changed

Lines changed: 136 additions & 0 deletions

File tree

adr/0004-event-history-storage.md

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# 0004 - Event History Storage: SQLite vs Append-Only Log vs Redis Streams
2+
3+
Status: Accepted
4+
5+
## Context
6+
7+
The backend records every significant campaign lifecycle event (campaign created, pledge received,
8+
pledge reconciled, vault claimed, refund issued, metadata updated, deadline extension voted on)
9+
so that the frontend history panel and the `/api/campaigns/:id/history` endpoint can replay the
10+
audit trail for a campaign.
11+
12+
Three storage strategies were evaluated:
13+
14+
1. **SQLite table (current approach)** — events are rows in an `event_history` table alongside the
15+
existing `campaigns` and `pledges` tables in the same database file.
16+
2. **Append-only log file** — events are serialised (e.g. as newline-delimited JSON) and appended to
17+
a flat file on disk. Reads scan or tail the file; compaction or rotation is handled out-of-band.
18+
3. **Redis Streams** — events are published to a Redis stream keyed by campaign ID. Consumers
19+
read the stream via `XREAD` / `XRANGE`; retention is controlled by `MAXLEN`.
20+
21+
The project is an open-source MVP intended for local development and testnet demos. Any event
22+
storage decision must account for:
23+
24+
- **Query flexibility** — filtering and joining events with campaign/pledge state
25+
- **Durability** — surviving process restarts without a separate infrastructure dependency
26+
- **Simplicity** — low setup friction for contributors and CI
27+
28+
## Decision
29+
30+
Use **SQLite** to store event history in the same database file as campaigns and pledges.
31+
32+
Each event is a row in `event_history` with columns for `campaign_id`, `event_type`, `payload`
33+
(JSON), and `created_at`. The `eventHistory` service (`backend/src/services/eventHistory.ts`)
34+
handles inserts and queries; mutation tests live in
35+
`backend/src/services/__tests__/mutation.test.ts`.
36+
37+
## Evaluation of Alternatives
38+
39+
### Option 1 — SQLite (chosen)
40+
41+
Advantages:
42+
- Zero additional infrastructure: the same SQLite file already holds campaigns and pledges.
43+
- Full SQL query power: events can be filtered by type, joined to campaigns or pledges, ordered,
44+
paginated, and aggregated without custom parsing logic.
45+
- Transactional writes: inserting an event in the same transaction as the state change it records
46+
eliminates the class of bugs where a pledge is saved but its event is lost (or vice versa).
47+
- Durable by default: SQLite WAL mode survives crashes; the file is portable and easy to back up
48+
or snapshot.
49+
- Consistent with ADR-0001: keeps all persistent state co-located, simplifying backup and restore.
50+
51+
Disadvantages:
52+
- Write throughput is bounded by SQLite's single-writer model. At high pledge volume, event inserts
53+
could contend with campaign and pledge writes.
54+
- Not a natural fit for streaming consumers or fan-out to multiple downstream services.
55+
56+
### Option 2 — Append-Only Log File
57+
58+
Advantages:
59+
- Conceptually simple write path: `fs.appendFileSync` is hard to get wrong.
60+
- Log files are easy to tail, ship to an aggregator, or feed into a stream processor later.
61+
- No schema migrations needed.
62+
63+
Disadvantages:
64+
- No built-in query capability. Filtering by campaign ID or event type requires scanning the entire
65+
file or maintaining a secondary index, adding significant complexity as the log grows.
66+
- Transactional consistency with the SQLite state is not guaranteed. A crash between the SQLite
67+
commit and the file append leaves state and log out of sync with no automatic recovery.
68+
- File rotation, compaction, and concurrent-write safety (multiple processes) require additional
69+
tooling that does not exist in the MVP.
70+
- Debugging and ad-hoc inspection are harder than running a SQL query.
71+
72+
### Option 3 — Redis Streams
73+
74+
Advantages:
75+
- Purpose-built for ordered event streams with consumer groups and replay from an offset.
76+
- Natural fan-out: multiple consumers (indexer, websocket push, analytics) can read the same stream
77+
independently.
78+
- High write throughput with sub-millisecond append latency.
79+
80+
Disadvantages:
81+
- Requires running and operating a Redis instance, adding infrastructure overhead for contributors
82+
who only want to run `npm run dev`.
83+
- In-memory by default: without `appendonly yes` + persistence configuration, events are lost on
84+
restart. Configuring durable Redis correctly is non-trivial.
85+
- No relational query capability: fetching "all refund events for campaign 7 joined with pledge
86+
amounts" requires either a secondary SQLite store or application-level joins.
87+
- Significantly increases the setup surface for CI and Docker Compose.
88+
- Premature for an MVP where event volume is low and there are no streaming consumers yet.
89+
90+
## Consequences
91+
92+
- Contributors can run the full stack with a single `npm run dev:backend` command and zero
93+
additional services.
94+
- Event records and campaign/pledge state are always consistent because they share the same
95+
SQLite transaction boundary.
96+
- The history panel and `/api/campaigns/:id/history` endpoint can use parameterised SQL queries
97+
with `WHERE`, `ORDER BY`, and `LIMIT` without custom parsing.
98+
- The mutation test suite (`mutation.test.ts`) validates event insert and query logic at the
99+
boundary conditions that coverage alone would miss.
100+
- Scaling beyond a single SQLite writer will eventually require rethinking this design (see
101+
migration path below).
102+
103+
## Migration Path to Append-Only Log
104+
105+
If event volume grows to the point where SQLite write contention becomes measurable — a practical
106+
signal would be p99 pledge latency exceeding ~100 ms under load testing — the recommended
107+
migration path is:
108+
109+
1. **Add a write abstraction.** Introduce an `EventStore` interface with `append(event)` and
110+
`query(filter)` methods. The current `eventHistory.ts` service becomes the SQLite
111+
implementation. This boundary can be added before any migration is necessary and costs nothing
112+
at runtime.
113+
114+
2. **Implement a log-backed store.** Write a second implementation that appends newline-delimited
115+
JSON to a configurable file path and builds a lightweight in-memory index (campaign ID →
116+
byte offsets) on startup. The index enables O(1) campaign lookups without full scans.
117+
118+
3. **Dual-write for validation.** Before cutting over, run both implementations in parallel for a
119+
release cycle. Compare query results to catch discrepancies before removing the SQLite path.
120+
121+
4. **Ship and decouple.** Once confidence is established, remove the SQLite `event_history` table.
122+
The `campaigns` and `pledges` tables remain in SQLite; only the event stream moves to the log.
123+
124+
5. **Redis Streams as a further step.** If fan-out to multiple consumers (on-chain event indexer,
125+
real-time WebSocket push) becomes a requirement, Redis Streams can replace the log-backed store
126+
by implementing the same `EventStore` interface. The application layer does not change.
127+
128+
This staged approach avoids a big-bang migration and keeps the system functional throughout.
129+
130+
## References
131+
132+
- `backend/src/services/eventHistory.ts` — current SQLite implementation
133+
- `backend/src/services/__tests__/mutation.test.ts` — mutation-killing tests for event history
134+
- `adr/0001-sqlite-off-chain-mvp.md` — SQLite off-chain persistence decision
135+
- `adr/0002-react-express-mvp.md` — overall backend architecture
136+
- [Stryker Mutator](https://stryker-mutator.io/) — mutation testing framework used in CI

0 commit comments

Comments
 (0)