WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at main 301 lines 13 kB view raw
1import { 2 sqliteTable, 3 text, 4 integer, 5 uniqueIndex, 6 index, 7 primaryKey, 8} from "drizzle-orm/sqlite-core"; 9 10// ── forums ────────────────────────────────────────────── 11// Singleton forum metadata record, owned by Forum DID. 12// Key: literal:self (rkey is always "self"). 13export const forums = sqliteTable( 14 "forums", 15 { 16 id: integer("id").primaryKey({ autoIncrement: true }), 17 did: text("did").notNull(), 18 rkey: text("rkey").notNull(), 19 cid: text("cid").notNull(), 20 name: text("name").notNull(), 21 description: text("description"), 22 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 23 }, 24 (table) => [uniqueIndex("forums_did_rkey_idx").on(table.did, table.rkey)] 25); 26 27// ── categories ────────────────────────────────────────── 28// Subforum / category definitions, owned by Forum DID. 29export const categories = sqliteTable( 30 "categories", 31 { 32 id: integer("id").primaryKey({ autoIncrement: true }), 33 did: text("did").notNull(), 34 rkey: text("rkey").notNull(), 35 cid: text("cid").notNull(), 36 name: text("name").notNull(), 37 description: text("description"), 38 slug: text("slug"), 39 sortOrder: integer("sort_order"), 40 forumId: integer("forum_id").references(() => forums.id), 41 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 42 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 43 }, 44 (table) => [ 45 uniqueIndex("categories_did_rkey_idx").on(table.did, table.rkey), 46 ] 47); 48 49// ── boards ────────────────────────────────────────────── 50// Board (subforum) definitions within categories, owned by Forum DID. 51export const boards = sqliteTable( 52 "boards", 53 { 54 id: integer("id").primaryKey({ autoIncrement: true }), 55 did: text("did").notNull(), 56 rkey: text("rkey").notNull(), 57 cid: text("cid").notNull(), 58 name: text("name").notNull(), 59 description: text("description"), 60 slug: text("slug"), 61 sortOrder: integer("sort_order"), 62 categoryId: integer("category_id").references(() => categories.id), 63 categoryUri: text("category_uri").notNull(), 64 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 65 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 66 }, 67 (table) => [ 68 uniqueIndex("boards_did_rkey_idx").on(table.did, table.rkey), 69 index("boards_category_id_idx").on(table.categoryId), 70 ] 71); 72 73// ── users ─────────────────────────────────────────────── 74// Known AT Proto identities. Populated when any record 75// from a DID is indexed. DID is the primary key. 76export const users = sqliteTable("users", { 77 did: text("did").primaryKey(), 78 handle: text("handle"), 79 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 80}); 81 82// ── memberships ───────────────────────────────────────── 83// User membership in a forum. Owned by user DID. 84// `did` is both the record owner and the member. 85export const memberships = sqliteTable( 86 "memberships", 87 { 88 id: integer("id").primaryKey({ autoIncrement: true }), 89 did: text("did") 90 .notNull() 91 .references(() => users.did), 92 rkey: text("rkey").notNull(), 93 cid: text("cid").notNull(), 94 forumId: integer("forum_id").references(() => forums.id), 95 forumUri: text("forum_uri").notNull(), 96 role: text("role"), 97 roleUri: text("role_uri"), 98 joinedAt: integer("joined_at", { mode: "timestamp" }), 99 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 100 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 101 }, 102 (table) => [ 103 uniqueIndex("memberships_did_rkey_idx").on(table.did, table.rkey), 104 index("memberships_did_idx").on(table.did), 105 ] 106); 107 108// ── posts ─────────────────────────────────────────────── 109// Unified post model. NULL root/parent = thread starter (topic). 110// Non-null root/parent = reply. Mirrors app.bsky.feed.post pattern. 111// Owned by user DID. 112export const posts = sqliteTable( 113 "posts", 114 { 115 id: integer("id").primaryKey({ autoIncrement: true }), 116 did: text("did") 117 .notNull() 118 .references(() => users.did), 119 rkey: text("rkey").notNull(), 120 cid: text("cid").notNull(), 121 title: text("title"), 122 text: text("text").notNull(), 123 forumUri: text("forum_uri"), 124 boardUri: text("board_uri"), 125 boardId: integer("board_id").references(() => boards.id), 126 rootPostId: integer("root_post_id").references((): any => posts.id), 127 parentPostId: integer("parent_post_id").references((): any => posts.id), 128 rootUri: text("root_uri"), 129 parentUri: text("parent_uri"), 130 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 131 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 132 bannedByMod: integer("banned_by_mod", { mode: "boolean" }) 133 .notNull() 134 .default(false), 135 deletedByUser: integer("deleted_by_user", { mode: "boolean" }) 136 .notNull() 137 .default(false), 138 }, 139 (table) => [ 140 uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey), 141 index("posts_forum_uri_idx").on(table.forumUri), 142 index("posts_board_id_idx").on(table.boardId), 143 index("posts_board_uri_idx").on(table.boardUri), 144 index("posts_root_post_id_idx").on(table.rootPostId), 145 ] 146); 147 148// ── mod_actions ───────────────────────────────────────── 149// Moderation actions, owned by Forum DID. Written by AppView 150// on behalf of authorized moderators after role verification. 151export const modActions = sqliteTable( 152 "mod_actions", 153 { 154 id: integer("id").primaryKey({ autoIncrement: true }), 155 did: text("did").notNull(), 156 rkey: text("rkey").notNull(), 157 cid: text("cid").notNull(), 158 action: text("action").notNull(), 159 subjectDid: text("subject_did"), 160 subjectPostUri: text("subject_post_uri"), 161 forumId: integer("forum_id").references(() => forums.id), 162 reason: text("reason"), 163 createdBy: text("created_by").notNull(), 164 expiresAt: integer("expires_at", { mode: "timestamp" }), 165 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 166 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 167 }, 168 (table) => [ 169 uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey), 170 index("mod_actions_subject_did_idx").on(table.subjectDid), 171 index("mod_actions_subject_post_uri_idx").on(table.subjectPostUri), 172 ] 173); 174 175// ── firehose_cursor ───────────────────────────────────── 176// Tracks the last processed event from the Jetstream firehose. 177// Singleton table (service is primary key). 178export const firehoseCursor = sqliteTable("firehose_cursor", { 179 service: text("service").primaryKey().default("jetstream"), 180 cursor: integer("cursor").notNull(), // time_us value from Jetstream 181 updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(), 182}); 183 184// ── roles ─────────────────────────────────────────────── 185// Role definitions, owned by Forum DID. 186// Note: permissions are stored in the role_permissions join table (not as an array column). 187export const roles = sqliteTable( 188 "roles", 189 { 190 id: integer("id").primaryKey({ autoIncrement: true }), 191 did: text("did").notNull(), 192 rkey: text("rkey").notNull(), 193 cid: text("cid").notNull(), 194 name: text("name").notNull(), 195 description: text("description"), 196 priority: integer("priority").notNull(), 197 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 198 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 199 }, 200 (table) => [ 201 uniqueIndex("roles_did_rkey_idx").on(table.did, table.rkey), 202 index("roles_did_idx").on(table.did), 203 index("roles_did_name_idx").on(table.did, table.name), 204 ] 205); 206 207// ── role_permissions ──────────────────────────────────── 208// Many-to-many join table for role permissions. 209// Replaces the permissions text[] array column from the Postgres schema. 210export const rolePermissions = sqliteTable( 211 "role_permissions", 212 { 213 roleId: integer("role_id") 214 .notNull() 215 .references(() => roles.id, { onDelete: "cascade" }), 216 permission: text("permission").notNull(), 217 }, 218 (t) => [primaryKey({ columns: [t.roleId, t.permission] })] 219); 220 221// ── backfill_progress ─────────────────────────────────── 222// Tracks backfill job state for crash-resilient resume. 223export const backfillProgress = sqliteTable("backfill_progress", { 224 id: integer("id").primaryKey({ autoIncrement: true }), 225 status: text("status").notNull(), // 'in_progress', 'completed', 'failed' 226 backfillType: text("backfill_type").notNull(), // 'full_sync', 'catch_up' 227 lastProcessedDid: text("last_processed_did"), 228 didsTotal: integer("dids_total").notNull().default(0), 229 didsProcessed: integer("dids_processed").notNull().default(0), 230 recordsIndexed: integer("records_indexed").notNull().default(0), 231 startedAt: integer("started_at", { mode: "timestamp" }).notNull(), 232 completedAt: integer("completed_at", { mode: "timestamp" }), 233 errorMessage: text("error_message"), 234}); 235 236// ── backfill_errors ───────────────────────────────────── 237// Per-DID error log for failed backfill syncs. 238export const backfillErrors = sqliteTable( 239 "backfill_errors", 240 { 241 id: integer("id").primaryKey({ autoIncrement: true }), 242 backfillId: integer("backfill_id") 243 .notNull() 244 .references(() => backfillProgress.id), 245 did: text("did").notNull(), 246 collection: text("collection").notNull(), 247 errorMessage: text("error_message").notNull(), 248 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 249 }, 250 (table) => [index("backfill_errors_backfill_id_idx").on(table.backfillId)] 251); 252 253// ── themes ────────────────────────────────────────────── 254export const themes = sqliteTable( 255 "themes", 256 { 257 id: integer("id").primaryKey({ autoIncrement: true }), 258 did: text("did").notNull(), 259 rkey: text("rkey").notNull(), 260 cid: text("cid").notNull(), 261 name: text("name").notNull(), 262 colorScheme: text("color_scheme").notNull(), 263 tokens: text("tokens", { mode: "json" }).notNull(), // auto JSON parse/stringify 264 cssOverrides: text("css_overrides"), 265 fontUrls: text("font_urls", { mode: "json" }), // auto JSON parse/stringify 266 createdAt: integer("created_at", { mode: "timestamp" }).notNull(), 267 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 268 }, 269 (table) => [uniqueIndex("themes_did_rkey_idx").on(table.did, table.rkey)] 270); 271 272// ── theme_policies ─────────────────────────────────────── 273export const themePolicies = sqliteTable( 274 "theme_policies", 275 { 276 id: integer("id").primaryKey({ autoIncrement: true }), 277 did: text("did").notNull(), 278 rkey: text("rkey").notNull(), 279 cid: text("cid").notNull(), 280 defaultLightThemeUri: text("default_light_theme_uri").notNull(), 281 defaultDarkThemeUri: text("default_dark_theme_uri").notNull(), 282 allowUserChoice: integer("allow_user_choice", { mode: "boolean" }).notNull(), 283 indexedAt: integer("indexed_at", { mode: "timestamp" }).notNull(), 284 }, 285 (table) => [ 286 uniqueIndex("theme_policies_did_rkey_idx").on(table.did, table.rkey), 287 ] 288); 289 290// ── theme_policy_available_themes ──────────────────────── 291export const themePolicyAvailableThemes = sqliteTable( 292 "theme_policy_available_themes", 293 { 294 policyId: integer("policy_id") 295 .notNull() 296 .references(() => themePolicies.id, { onDelete: "cascade" }), 297 themeUri: text("theme_uri").notNull(), 298 themeCid: text("theme_cid").notNull(), 299 }, 300 (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 301);