我如何构建 FeedLog:三个 Repo,一个产品
Source: Dev.to
(请提供您希望翻译的正文内容,我将为您翻译成简体中文,并保持原有的格式、Markdown 语法以及技术术语不变。)
仓库布局
| Repo | 可见性 | 目的 |
|---|---|---|
| feedlog-api | 私有 | Node 后端:webhooks、AI 处理、公共 API |
| feedlog-app | 私有 | Web 仪表盘:OAuth、设置、变更日志管理 |
| feedlog-toolkit | 公开 (MIT) | 可嵌入的 SDK,客户将其放入站点即可渲染变更日志小部件 |
将项目拆分为三个仓库是有意为之的: toolkit 是唯一直接供客户集成的部分,因此保持公开、独立版本(通过 Changesets),并且可以在不触及内部代码的情况下发布。API 和 app 也可以独立部署——前端部署不会强制重启 API,反之亦然。
工具包
- 技术栈: Stencil monorepo → 真正的 Web Components + 自动生成的 React 与 Vue 包装器。
- 输出: 单一组件源代码 → 三个框架目标。
API
- 运行时: Node 与 Fastify。
- 为什么 Fastify? 它的插件系统和内置模式验证非常适合小团队。
- 类型系统:
fastify-type-provider-zod→ 每个路由从 Zod 模式到处理函数都实现端到端的类型定义(无需单独的 OpenAPI 规范来保持同步)。
Database
- ORM: Drizzle ORM 基于 Neon Postgres。
- Neon benefits: Serverless Postgres 并支持分支(非常适合预览迁移)。
- Migrations:
// scripts/migrate.ts
// run with tsx
Drizzle Kit 根据 TypeScript 架构生成 SQL 迁移;迁移作为独立脚本运行,not 在启动时执行。
异步工作
| Component | Role |
|---|---|
| BullMQ + Redis | 队列 GitHub webhook 事件和 AI 草稿生成。Webhook 端点立即返回;工作进程处理任务。 |
| Croner (in‑process) | - Webhook 恢复任务(每 15 分钟重新投递失败的负载) - Sentry cron 监控心跳,覆盖所有三个进程(API、events worker、external worker) |
| opossum | 将 Postgres 连接池包装为断路器 → 在数据库波动时实现优雅降级。 |
| @fastify/rate-limit | 基于 API‑key 的速率限制,存储在 Redis 中(可跨实例、重启后仍然有效)。 |
Dashboard
- 框架: TanStack Start (React SSR) 与 TanStack Router 和 TanStack Query.
- UI: Tailwind CSS v4 + Radix UI 原语(shadcn 模式)。
- 部署: 通过 Wrangler 的 Cloudflare Workers → 边缘部署的 SSR,无冷启动成本。
主键 – UUID v7
每个表都使用 UUIDv7(通过 Postgres 扩展 uuidv7() 生成,作为列的默认值)。
优势
- 时间有序、单调递增 → 插入始终落在 B‑tree 的末端(无页面分裂,无碎片)。
- 编码创建时间戳 → 不需要单独的
created_at列。
缺点
Neon 控制台和 Drizzle Studio 将 UUID 显示为不透明值,而不是可读的时间戳。
解决方案
-- Helper to extract timestamp from a UUIDv7
SELECT extractCreatedAtFromUuid7(uuid_column) AS created_at FROM table;
公共 ID
内部主键保持内部。每个公开的表格也都有一个 public_id 列:一个短小、URL 安全的字符串,带有有意义的前缀。
usr_a3b7kx9m2p1z ← user
ins_q8tnrfw4j6yd ← installation
rep_c2mh5vp0xk3a ← repository
iss_e9rz1db7yt4n ← issue
pk_lw6gc8nu0fqj ← API key
生成
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 12);
const publicId = `${prefix}_${nanoid()}`;
为什么? 前缀在日志、工单或 URL 中提供即时的类型提示——这种做法由 Stripe 推广。
软删除
所有表都包含一个 deleted_at 时间戳列。每一次删除(即使是最小的删除)都会成为 软删除(deleted_at 被设为当前时间)。
好处
| ✅ | 好处 |
|---|---|
| 意外恢复 | 只需一次简单的 UPDATE 即可恢复。 |
| 审计追踪 | 始终知道曾经存在的记录以及它们何时被移除。 |
| 撤销流程 | 例如,点赞切换:在 deleted_at 与 NULL 之间切换。 |
| 更安全的调试 | 将软删除的行与活跃行一起查询,以便了解问题所在。 |
权衡与缓解
- 软删除行的累计 随时间增长。
- 解决方案: 为每个表设置一个清理 cron,硬删除
deleted_at超过可配置阈值的行。 - 由于我们已经在进程中运行
croner,添加清理任务非常直接——每个表可以自行配置保留窗口。
未决问题 / 未来考虑
- Cron 位置: 当前,
croner在 API 服务器内部进程中运行。- 优点: 启动更简单。
- 缺点: 每个 API 实例都会竞争运行相同的 cron,需要分布式锁。
- 可能的方向: 将长期运行或资源密集型的清理任务迁移到专用的计划任务进程。
TL;DR
- Three repos (API, app, toolkit) keep public surface minimal and independent.
→ 三个仓库(API、app、toolkit)保持公共接口最小且相互独立。 - Fastify + Zod gives type‑safe routes without extra specs.
→ Fastify + Zod 提供类型安全的路由,无需额外规范。 - Neon + Drizzle provides serverless Postgres with TypeScript‑driven migrations.
→ Neon + Drizzle 提供无服务器的 Postgres,并使用 TypeScript 驱动的迁移。 - BullMQ + Redis handles async work; opossum protects the DB.
→ BullMQ + Redis 处理异步工作;opossum 保护数据库。 - UUIDv7 + public_id scheme balances internal efficiency with external readability.
→ UUIDv7 + public_id 方案在内部效率与外部可读性之间取得平衡。 - 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.
→ 所有决策迄今为止都表现良好,架构保持对未来扩展的灵活性。当前的任务已经足够幂等,重复执行不会产生影响,但随着系统规模扩大,这一点需要重新评估。