ADR: Postgres-First Write Order with Firestore as Downstream
Status
Provisionally Accepted / In Development
Context
Our stack uses both PostgreSQL (relational, calculation engine) and Firestore (real-time transport, offline cache). We need both because:
- Firestore provides real-time listeners, offline-first support, and direct client access — eliminating complex UI re-query logic when data changes across sessions
- Postgres provides the relational query power we need for accounting: window functions, aggregations, joins, and period-based calculations that Firestore cannot express
The question is: which store do we write to first?
The Two Approaches
Option A: Firestore-First (write to Firestore, sync to Postgres)
- Client writes to Firestore
- A trigger/function syncs the document to Postgres asynchronously
Option B: Postgres-First (write to Postgres, sync to Firestore)
- Client writes to Postgres via REST API
pg_notifyfires on commit- A listener picks up the notification and writes the document to Firestore
Decision
We are migrating to Option B: Postgres-first.
Option A (Firestore-first) was our original implementation and that code remains in the codebase.
Option B is actively in development and is proving more stable — particularly around balance consistency and failure recovery.
Reasoning
1. The sync window problem favours Postgres-first
The register view fetches running balances from Postgres whenever the Firestore snapshot fires. The question is: when the snapshot fires, is Postgres ready?
- Firestore-first: Firestore fires immediately. Postgres hasn't received the sync yet. The balance re-fetch returns a stale result that doesn't include the new entry. The UI briefly shows the new entry with a wrong balance.
- Postgres-first: Postgres is committed before
pg_notifyfires.pg_notifytriggers the Firestore write. By the time the Firestore snapshot reaches the client, Postgres already has the entry. The balance re-fetch is always consistent.
This is a fundamental sequencing guarantee. Firestore-first cannot provide it without polling or a secondary confirmation step.
2. Postgres is the source of truth for financial data
Accounting data requires ACID guarantees. Journal entries must balance. Foreign key constraints must hold. Status transitions must be controlled. Postgres enforces all of this at the database level.
Firestore is a document store with eventual consistency. It has no notion of referential integrity, no constraints, and no transactions that span the document graph the way a relational DB does. Treating Firestore as the write-primary for financial data means your source of truth has weaker guarantees than your reporting layer — which is backwards.
3. Failure modes are easier to reason about
- Postgres-first failure: Postgres write succeeds, Firestore sync fails. Firestore is stale until the sync retries. The fix is a replay of
pg_notifyevents or a one-time reconciliation job. Postgres is correct; Firestore catches up. - Firestore-first failure: Firestore write succeeds, Postgres sync fails. Postgres is missing data. Your reporting, balance calculations, and audit trail are now wrong. The fix requires detecting the gap and backfilling — harder to detect, harder to recover from.
In accounting, a wrong balance is worse than a delayed real-time update.
4. Business logic belongs at the API layer
Firestore security rules are not a substitute for business logic. The REST API layer (Fastify/Node) is where we enforce:
- Entry status rules (draft → posted → voided)
- Fiscal period validation
- Balanced journal line requirements
- Permission checks via Firebase token + Postgres RBAC
Firestore-first would require duplicating these rules in Firestore security rules or Cloud Functions — two places to keep in sync instead of one.
Tradeoffs Accepted
| Concern | Resolution |
|---|---|
| Offline writes can't go to Postgres | Offline writes go to Firestore only. On reconnect, Postgres receives the sync and recalculates. Balances shown offline are explicitly provisional — flagged as such in the UI. |
| Added latency for Firestore update | Acceptable. The pg_notify → Firestore path is fast (sub-second in normal operation). Real-time feel is preserved. |
| Dual-write operational complexity | Manageable. The sync is one-directional (Postgres → Firestore), which is easier to monitor, replay, and debug than a two-way sync. |
Consequences
- All client writes go through the REST API, not directly to Firestore (except offline drafts)
- Firestore documents are always a downstream reflection of Postgres state
- Balance calculations, reports, and aggregations are always served from Postgres
- The TypeScript
createAccountRegisterfunction is retained as an offline fallback only — it is not the authoritative calculation path - Running balances use a SQL window function query and are never stored as derived state in either Firestore or a separate table