|
| 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