How I Built FeedLog: Three Repos, One Product
Source: Dev.to
Repository Layout
| Repo | Visibility | Purpose |
|---|---|---|
| feedlog-api | Private | Node backend: webhooks, AI processing, public API |
| feedlog-app | Private | Web dashboard: OAuth, settings, changelog management |
| feedlog-toolkit | Public (MIT) | Embeddable SDK that customers drop into their site to render the changelog widget |
Splitting into three repos was intentional: the toolkit is the only piece customers integrate directly, so it stays public, independently versioned (via Changesets), and releasable without touching internal code. The API and app ship independently as well—frontend deploys don’t force an API restart, and vice‑versa.
Toolkit
- Tech stack: Stencil monorepo → true Web Components + auto‑generated React & Vue wrappers.
- Output: One component source → three framework targets.
API
- Runtime: Node with Fastify.
- Why Fastify? Its plugin system and built‑in schema validation fit a small team.
- Typing:
fastify-type-provider-zod→ every route is typed end‑to‑end from Zod schema to handler (no separate OpenAPI spec to keep in sync).
Database
- ORM: Drizzle ORM on top of Neon Postgres.
- Neon benefits: Serverless Postgres with branching (great for previewing migrations).
- Migrations:
// scripts/migrate.ts
// run with tsx
Drizzle Kit generates SQL migrations from TypeScript schema; migrations run as a separate script, not at startup.
Asynchronous Work
| Component | Role |
|---|---|
| BullMQ + Redis | Queue GitHub webhook events and AI draft generation. The webhook endpoint returns instantly; a worker processes the job. |
| Croner (in‑process) | - Webhook recovery job (redeliver failed payloads every 15 min) - Sentry cron monitor heartbeats for all three processes (API, events worker, external worker) |
| opossum | Wraps the Postgres pool as a circuit breaker → graceful degradation on DB hiccups. |
| @fastify/rate-limit | Per‑API‑key rate limiting stored in Redis (survives restarts, works across instances). |
Dashboard
- Framework: TanStack Start (React SSR) with TanStack Router & TanStack Query.
- UI: Tailwind CSS v4 + Radix UI primitives (shadcn pattern).
- Deployment: Cloudflare Workers via Wrangler → edge‑deployed SSR with no cold‑start tax.
Primary Keys – UUID v7
Every table uses UUIDv7 (generated by the Postgres extension uuidv7() as the column default).
Advantages
- Time‑ordered, monotonically increasing → inserts always land at the end of the B‑tree (no page splits, no fragmentation).
- Encodes creation timestamp → no separate
created_atcolumn needed.
Downside
Neon console & Drizzle Studio display the UUID as an opaque value, not a readable timestamp.
Work‑around
-- Helper to extract timestamp from a UUIDv7
SELECT extractCreatedAtFromUuid7(uuid_column) AS created_at FROM table;
Public IDs
Internal primary keys stay internal. Every exposed table also has a public_id column: a short, URL‑safe string with a meaningful prefix.
usr_a3b7kx9m2p1z ← user
ins_q8tnrfw4j6yd ← installation
rep_c2mh5vp0xk3a ← repository
iss_e9rz1db7yt4n ← issue
pk_lw6gc8nu0fqj ← API key
Generation
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 12);
const publicId = `${prefix}_${nanoid()}`;
Why? The prefix gives an immediate type hint in logs, tickets, or URLs—an approach popularized by Stripe.
Soft Deletes
All tables have a deleted_at timestamp column. Every delete (even trivial ones) becomes a soft delete (deleted_at set).
Benefits
| ✅ | Benefit |
|---|---|
| Accidental recovery | Restore with a simple UPDATE. |
| Audit trail | Always know what existed and when it was removed. |
| Undo flows | E.g., upvote toggle: set deleted_at ↔ NULL. |
| Safer debugging | Query soft‑deleted rows alongside live ones to understand issues. |
Trade‑off & Mitigation
- Accumulation of soft‑deleted rows over time.
- Solution: Per‑table cleanup cron that hard‑deletes rows where
deleted_atis older than a configurable threshold. - Since we already run
cronerin‑process, adding a cleanup job is straightforward—each table can configure its own retention window.
Open Questions / Future Considerations
- Cron location: Currently,
cronerruns in‑process within the API server.- Pros: Simpler to start.
- Cons: Every API instance races to run the same cron, requiring a distributed lock.
- Potential direction: Move long‑running or heavy cleanup jobs to a dedicated scheduled‑job process.
TL;DR
- Three repos (API, app, toolkit) keep public surface minimal and independent.
- Fastify + Zod gives type‑safe routes without extra specs.
- Neon + Drizzle provides serverless Postgres with TypeScript‑driven migrations.
- BullMQ + Redis handles async work; opossum protects the DB.
- UUIDv7 + public_id scheme balances internal efficiency with external readability.
- Soft deletes simplify recovery, auditing, and toggles, with periodic hard‑delete cleanup.
All decisions have held up well so far, and the architecture remains flexible for future growth. The jobs are currently idempotent enough that duplicate runs are harmless, but this is something to revisit as the system scales.