import { pgTable, text, timestamp, integer, index, uniqueIndex, serial, varchar, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; /** * 数据库模型。 * * 设计要点: * - `slug` 唯一且带索引:它是文章的唯一对外标识(URL 的一部分)。 * - `status` 用枚举字符串而非布尔:草稿/已发布之外,未来可能加归档。 * 这里用 varchar + CHECK 约束保证取值合法(Drizzle 的 pgEnum 也可, * 但 enum 类型后续增删值需要 ALTER TYPE,字符串 + CHECK 更灵活)。 * - `tags` 使用 text[] 而非独立关联表:v0.1 的标签只需按包含查询, * 数组 + GIN 索引即可满足;引入 tags 关联表会让读写都多一次 join。 * - 保留 `created_at` / `updated_at`,便于排序与增量同步。 * - `published_at` 单独存:草稿转发布时,发布时间不等于创建时间。 */ export const posts = pgTable( "posts", { id: serial("id").primaryKey(), /** URL 标识,全局唯一,仅允许小写字母/数字/连字符 */ slug: varchar("slug", { length: 200 }).notNull(), /** 标题 */ title: text("title").notNull(), /** 摘要,用于卡片与 SEO description */ summary: text("summary").notNull(), /** 正文(Markdown / MDX 源码) */ content: text("content").notNull(), /** 标签数组 */ tags: text("tags") .array() .notNull() .default(sql`ARRAY[]::text[]`), /** 状态:draft(草稿)| published(已发布) */ status: varchar("status", { length: 20 }).notNull().default("draft"), /** 阅读时长(分钟),写入时计算并缓存,避免列表页重复解析正文 */ readingMinutes: integer("reading_minutes").notNull().default(1), /** 封面图路径 */ cover: text("cover"), /** 覆盖默认 SEO 描述 */ description: text("description"), /** 发布时间;草稿为 null */ publishedAt: timestamp("published_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ // slug 是 URL 标识,必须唯一;唯一索引同时承担查询加速 uniqueIndex("posts_slug_unique_idx").on(table.slug), // 列表页主查询:按状态过滤 + 按发布时间倒序 index("posts_status_published_at_idx").on( table.status, table.publishedAt.desc(), ), // 标签筛选:GIN 索引支持 `tags @> ARRAY['x']` 与 `'x' = ANY(tags)` index("posts_tags_gin_idx").using("gin", table.tags), ], ); /** * 数据完整性约束(在迁移 SQL 中手工维护)。 * * Drizzle 的 `pgTable` 第三个回调只支持索引类定义,`sql` 模板不会被生成为 * CHECK 约束。因此以下约束直接写在迁移文件里,并在应用层用 Zod 做同样的校验: * * - `posts_status_check` : status IN ('draft', 'published') * - `posts_slug_format_check` : slug 匹配 ^[a-z0-9]+(-[a-z0-9]+)*$ * * 注意:约束同时存在于数据库与应用层是有意为之——应用层给出友好报错, * 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。 */ export const postsRelations = relations(posts, () => ({})); /** 由 schema 推导的插入类型 */ export type NewPost = typeof posts.$inferInsert; /** 由 schema 推导的查询类型 */ export type PostRow = typeof posts.$inferSelect;