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

feat(appview): define database schema for all 6 tables

+148
+148
packages/appview/src/db/schema.ts
··· 1 + import { 2 + pgTable, 3 + bigserial, 4 + text, 5 + timestamp, 6 + integer, 7 + boolean, 8 + bigint, 9 + uniqueIndex, 10 + index, 11 + } from "drizzle-orm/pg-core"; 12 + 13 + // ── forums ────────────────────────────────────────────── 14 + // Singleton forum metadata record, owned by Forum DID. 15 + // Key: literal:self (rkey is always "self"). 16 + export const forums = pgTable( 17 + "forums", 18 + { 19 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 20 + did: text("did").notNull(), 21 + rkey: text("rkey").notNull(), 22 + cid: text("cid").notNull(), 23 + name: text("name").notNull(), 24 + description: text("description"), 25 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 26 + }, 27 + (table) => [uniqueIndex("forums_did_rkey_idx").on(table.did, table.rkey)] 28 + ); 29 + 30 + // ── categories ────────────────────────────────────────── 31 + // Subforum / category definitions, owned by Forum DID. 32 + export const categories = pgTable( 33 + "categories", 34 + { 35 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 36 + did: text("did").notNull(), 37 + rkey: text("rkey").notNull(), 38 + cid: text("cid").notNull(), 39 + name: text("name").notNull(), 40 + description: text("description"), 41 + slug: text("slug"), 42 + sortOrder: integer("sort_order"), 43 + forumId: bigint("forum_id", { mode: "bigint" }).references(() => forums.id), 44 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 45 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 46 + }, 47 + (table) => [ 48 + uniqueIndex("categories_did_rkey_idx").on(table.did, table.rkey), 49 + ] 50 + ); 51 + 52 + // ── users ─────────────────────────────────────────────── 53 + // Known AT Proto identities. Populated when any record 54 + // from a DID is indexed. DID is the primary key. 55 + export const users = pgTable("users", { 56 + did: text("did").primaryKey(), 57 + handle: text("handle"), 58 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 59 + }); 60 + 61 + // ── memberships ───────────────────────────────────────── 62 + // User membership in a forum. Owned by user DID. 63 + // `did` is both the record owner and the member. 64 + export const memberships = pgTable( 65 + "memberships", 66 + { 67 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 68 + did: text("did") 69 + .notNull() 70 + .references(() => users.did), 71 + rkey: text("rkey").notNull(), 72 + cid: text("cid").notNull(), 73 + forumId: bigint("forum_id", { mode: "bigint" }).references( 74 + () => forums.id 75 + ), 76 + forumUri: text("forum_uri").notNull(), 77 + role: text("role"), 78 + roleUri: text("role_uri"), 79 + joinedAt: timestamp("joined_at", { withTimezone: true }), 80 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 81 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 82 + }, 83 + (table) => [ 84 + uniqueIndex("memberships_did_rkey_idx").on(table.did, table.rkey), 85 + index("memberships_did_idx").on(table.did), 86 + ] 87 + ); 88 + 89 + // ── posts ─────────────────────────────────────────────── 90 + // Unified post model. NULL root/parent = thread starter (topic). 91 + // Non-null root/parent = reply. Mirrors app.bsky.feed.post pattern. 92 + // Owned by user DID. 93 + export const posts = pgTable( 94 + "posts", 95 + { 96 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 97 + did: text("did") 98 + .notNull() 99 + .references(() => users.did), 100 + rkey: text("rkey").notNull(), 101 + cid: text("cid").notNull(), 102 + text: text("text").notNull(), 103 + forumUri: text("forum_uri"), 104 + rootPostId: bigint("root_post_id", { mode: "bigint" }).references( 105 + (): any => posts.id 106 + ), 107 + parentPostId: bigint("parent_post_id", { mode: "bigint" }).references( 108 + (): any => posts.id 109 + ), 110 + rootUri: text("root_uri"), 111 + parentUri: text("parent_uri"), 112 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 113 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 114 + deleted: boolean("deleted").notNull().default(false), 115 + }, 116 + (table) => [ 117 + uniqueIndex("posts_did_rkey_idx").on(table.did, table.rkey), 118 + index("posts_forum_uri_idx").on(table.forumUri), 119 + index("posts_root_post_id_idx").on(table.rootPostId), 120 + ] 121 + ); 122 + 123 + // ── mod_actions ───────────────────────────────────────── 124 + // Moderation actions, owned by Forum DID. Written by AppView 125 + // on behalf of authorized moderators after role verification. 126 + export const modActions = pgTable( 127 + "mod_actions", 128 + { 129 + id: bigserial("id", { mode: "bigint" }).primaryKey(), 130 + did: text("did").notNull(), 131 + rkey: text("rkey").notNull(), 132 + cid: text("cid").notNull(), 133 + action: text("action").notNull(), 134 + subjectDid: text("subject_did"), 135 + subjectPostUri: text("subject_post_uri"), 136 + forumId: bigint("forum_id", { mode: "bigint" }).references( 137 + () => forums.id 138 + ), 139 + reason: text("reason"), 140 + createdBy: text("created_by").notNull(), 141 + expiresAt: timestamp("expires_at", { withTimezone: true }), 142 + createdAt: timestamp("created_at", { withTimezone: true }).notNull(), 143 + indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull(), 144 + }, 145 + (table) => [ 146 + uniqueIndex("mod_actions_did_rkey_idx").on(table.did, table.rkey), 147 + ] 148 + );