Skip to content

The big shift — AWS/NoSQL → Supabase/Postgres

Read this one

Most of Tonder's finances complexity exists to work around DynamoDB's lack of multi-item ACID transactions and MongoDB's role as a query mirror. Postgres removes the need for almost all of that machinery. Do not port the workarounds — port the intent.

What Tonder does, and why

Tonder's engine is event-driven on AWS:

  • DynamoDB holds accounts, journals, ledger entries, fee rules, config, snapshots, idempotency.
  • MongoDB mirrors journals / fee-rules / config for rich queries and aggregation pipelines.
  • EventBridge → SQS FIFO (TransactionEventsQueue, MessageGroupId = businessId) delivers transaction events in per-business order.
  • AccountUpdateQueue (FIFO, MessageGroupId = account.id) serializes balance updates because DynamoDB can only do atomic single-item ADD, and running balances must be applied in order. A version counter provides optimistic concurrency. maxReceiveCount = 160 because losing a balance update permanently corrupts the ledger.
  • An IdempotencyTable (120s TTL) + a process_id-index dedup guard + a 2-day cutoff prevent double-processing of redelivered SQS messages.
  • CloudWatch crons drive rolling-reserve release (02:00 UTC) and daily ledger (10:00 UTC).
  • Step Functions drive bulk reprocess.

What Vecnet does instead

Tonder primitiveReason it existsVecnet (Supabase) replacement
DynamoDB single-table (PK/SK)NoSQL key accessPostgres relational tables — proper PKs, FKs, indexes
MongoDB query mirror + pipelinesRich querying DynamoDB can't doGone. Postgres is the query engine; aggregations are SQL
AccountUpdateQueue FIFO + ADD + versionAtomic multi-row updateA single Postgres transaction — insert journals + entries, UPDATE accounts SET balance = balance + :delta, all-or-nothing, row-locked
IdempotencyTable + process_id-index + 120s TTLGuard SQS redeliveryA UNIQUE (process_id, category) constraint + INSERT … ON CONFLICT DO NOTHING. Idempotency is a schema property
TransactionEventsQueue (EventBridge→SQS FIFO)Ordered async ingestionpgmq or a transaction_events table consumed with SELECT … FOR UPDATE SKIP LOCKED; per-entity ordering via pg_advisory_xact_lock(hashtext(entity_id))
CloudWatch cronsScheduled jobspg_cron invoking SQL / Edge Functions
Step Functions (bulk reprocess)Long orchestrationWorker off a bulk_jobs table + pgmq; or an Edge Function loop
S3 (XLSX reports)File storageSupabase Storage bucket
Lambda + Middy + InversifyJS + RxJSAWS runtime + wiringSupabase Edge Functions (Deno/TS) + Postgres functions; keep hexagonal layering, drop RxJS — plain async/await
Balance via FIFO serializationAvoid lost updatesRow-level locking (SELECT … FOR UPDATE); balance_before/balance_after/seq from the locked row

The core simplification, stated plainly

The Vecnet path is one transaction:

sql
BEGIN;
  -- idempotent: skip if already processed
  INSERT INTO journals (...) VALUES (...) ON CONFLICT (process_id, category) DO NOTHING;
  -- if no row inserted, the event was already processed → COMMIT and return.
  INSERT INTO ledger_entries (...) VALUES (...);     -- the DEBIT/CREDIT pair(s)
  -- apply balances atomically, locking each account row
  UPDATE accounts SET balance = balance + :delta, version = version + 1, modified_at = now()
    WHERE id = :account_id;                          -- repeated per affected account
  -- (balance_before / balance_after / seq derived from the locked rows)
COMMIT;

Stricter than Tonder

Double-entry balance (sum(debits) = sum(credits)) is enforced, not merely warned about, via a check in the posting function. Tonder only logs a warning — Vecnet should do better, because Postgres lets us. See the posting function.

Idempotency, in turn, collapses to three constraints — there is no idempotency table at all.

Vecnet — Build Spec v0.2 · Obsidian Terminal