Video auctions with bids escrowed in credits, anti-sniping, the creator's decision or an automatic sale — and the broker-free realtime behind them: PostgreSQL LISTEN/NOTIFY, Server-Sent Events, a SKIP LOCKED closer.
🔨 Auctions
A creator puts one of their videos up for auction between a start and an end. Members bid in Orochia credits; the price moves live for everyone watching; when the auction ends, the highest bidder — and only them — gets the video: to watch, or to watch and download, as the creator chose. The creator either decides (accepts or declines the best bid within 48 hours) or lets it sell to the highest bid automatically.
1. The rules
The rules are pure functions shared by the server, the UI and the unit tests (auction-rules.test.ts).
2. Lifecycle
OPEN covers both upcoming and live; the UI phase (UPCOMING, LIVE, ENDING…) is derived from the clock
(auctionPhase). Every transition runs in one transaction under the auction's row lock (SELECT … FOR UPDATE),
so concurrent bids, the closer and the creator's decision serialise per auction and nothing happens twice.
3. Money: escrow in credits
Bids are paid in credits so that the winner has always paid — no unpaid wins, no chargeback race at the end.
- A hold is taken under the bidder's wallet advisory lock — the same lock as every credit spend — so two bids (or a bid and an unlock) can never spend the same credits.
- At most one bid holds credits per auction (
auction_bids_one_leader_idx). - Every reference is unique: a retry can never move money twice. Balances stay computed from append-only ledgers.
- The winner's
video_access_grantsrow (granted_via = AUCTION,can_download) is written in the same transaction. - The wallet shows what is held behind leading bids (
heldCentson/api/me/wallet).
4. Realtime without a broker
No RabbitMQ, no Redis. PostgreSQL — already paid for — does the three jobs a broker would do here:
lib/realtime.ts is the one bus of the web app: publish(topic, event) / subscribe(topic, cb) / sseResponse(topics).
Topics: auction:<id> (public feed: amount, alias, new end, next minimum) and user:<id> (messages, notifications —
the messaging stream moved to the same bus, so it now works across instances too). Browsers receive Server-Sent Events
(/api/auctions/[id]/stream), reconnect by themselves, and reload the snapshot on reconnection; countdowns are aligned
on the server clock (serverNow). Reading an auction past its end closes it on the spot, so a stopped loop only
delays a notification, never a result.
Why not RabbitMQ (yet). A broker adds a service to run, secure, monitor and pay for (≈ $15–50/month managed, more
with high availability) for guarantees we already get transactionally from PostgreSQL: the bid and its money commit
together, and NOTIFY is delivered at commit. The current design holds comfortably to thousands of concurrent
viewers per instance and tens of bids per second per auction. Revisit when one of these is true: several services
(not just the web app) must react to auction events; sustained fan-out beyond ~10k concurrent streams; or a need for
durable replay of events. The natural next step then is a managed Redis/Valkey pub-sub for fan-out — the realtime.ts
interface stays the same.
Operations note. LISTEN needs a direct (session) connection: keep DATABASE_URL pointing at the database, not
at a transaction-mode pooler (PgBouncer). If the listener drops, it reconnects with back-off and events are delivered
to the local instance meanwhile.
5. Privacy
Bidders are shown under a per-auction alias — Bidder 1, Bidder 2… by order of first bid. A viewer sees You for their own bids; only the creator sees the leader's username (to decide). Nobody's bidding history is public; the live feed carries no identity.
6. API
A takedown of the video or a suspension of its creator cancels its open auctions and releases the bids.
7. Notifications
auctionAnnounced (followers), auctionNewBid, auctionDecision, auctionSold, auctionUnsold (creator),
auctionOutbid, auctionWon, auctionDeclined (bidders) — in the bell, live, and by e-mail, each one switchable in
the notification settings; e-mails about one auction are throttled.
8. Where things are
Shared UI comes from @krizaka/orochia-design-system ≥ 2.2: Countdown (one ticking clock for the page) and
LiveBadge.