an app to share curated trails
sidetrail.app
atproto
nextjs
react
rsc
1import { pgTable, text, timestamp, jsonb, bigint, integer } from "drizzle-orm/pg-core";
2
3// ============================================================================
4// Record types stored as JSONB
5// ============================================================================
6
7export type TrailRecord = {
8 $type: "app.sidetrail.trail";
9 title: string;
10 description: string;
11 stops: Array<{
12 tid: string;
13 title: string;
14 content: string;
15 buttonText?: string;
16 external?: {
17 uri: string;
18 title?: string;
19 description?: string;
20 thumb?: { $type: "blob"; ref: { $link: string }; mimeType: string };
21 };
22 }>;
23 accentColor: string;
24 backgroundColor: string;
25 createdAt: string;
26};
27
28export type WalkRecord = {
29 $type: "app.sidetrail.walk";
30 trail: { uri: string; cid: string };
31 visitedStops: string[];
32 createdAt: string;
33 updatedAt?: string;
34};
35
36export type CompletionRecord = {
37 $type: "app.sidetrail.completion";
38 trail: { uri: string; cid: string };
39 createdAt: string;
40};
41
42export type DraftRecord = {
43 $type: "app.sidetrail.draft";
44 title: string;
45 description: string;
46 stops: Array<{
47 tid: string;
48 title: string;
49 content: string;
50 buttonText?: string;
51 external?: {
52 uri: string;
53 title?: string;
54 description?: string;
55 thumb?: string; // URL string (not blob ref - uploaded on publish)
56 };
57 }>;
58 accentColor: string;
59 backgroundColor: string;
60 createdAt: string;
61 updatedAt: string;
62};
63
64// ============================================================================
65// Tables
66// ============================================================================
67
68export const trails = pgTable("trails", {
69 uri: text("uri").primaryKey(),
70 cid: text("cid").notNull(),
71 authorDid: text("author_did").notNull(),
72 rkey: text("rkey").notNull(),
73 record: jsonb("record").notNull().$type<TrailRecord>(),
74 createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
75 indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull().defaultNow(),
76});
77
78export const walks = pgTable("walks", {
79 uri: text("uri").primaryKey(),
80 cid: text("cid").notNull(),
81 authorDid: text("author_did").notNull(),
82 rkey: text("rkey").notNull(),
83 trailUri: text("trail_uri").notNull(),
84 record: jsonb("record").notNull().$type<WalkRecord>(),
85 createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
86 indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull().defaultNow(),
87});
88
89export const completions = pgTable("completions", {
90 uri: text("uri").primaryKey(),
91 cid: text("cid").notNull(),
92 authorDid: text("author_did").notNull(),
93 rkey: text("rkey").notNull(),
94 trailUri: text("trail_uri").notNull(),
95 record: jsonb("record").notNull().$type<CompletionRecord>(),
96 createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
97 indexedAt: timestamp("indexed_at", { withTimezone: true }).notNull().defaultNow(),
98});
99
100export const drafts = pgTable("drafts", {
101 id: text("id").primaryKey(), // Composite key: "author_did:rkey"
102 authorDid: text("author_did").notNull(),
103 rkey: text("rkey").notNull(),
104 record: jsonb("record").notNull().$type<DraftRecord>(),
105 createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
106 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
107 version: integer("version").notNull().default(1),
108});
109
110export const ingestionCursor = pgTable("ingestion_cursor", {
111 id: integer("id").primaryKey().default(1),
112 cursorUs: bigint("cursor_us", { mode: "number" }).notNull(),
113 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
114});
115
116// Account status tracking for takedown/suspension handling
117// Status values follow AT Protocol account lifecycle:
118// - null: active account (default)
119// - 'deactivated': user temporarily paused account (hide content, keep data)
120// - 'suspended': host temporarily paused account (hide content, keep data)
121// - 'takendown': host took down account (delete content)
122// - 'deleted': account deleted (delete content)
123export const accounts = pgTable("accounts", {
124 did: text("did").primaryKey(),
125 active: integer("active").notNull().default(1), // 1 = active, 0 = inactive
126 status: text("status"), // null, 'deactivated', 'suspended', 'takendown', 'deleted'
127 seq: bigint("seq", { mode: "number" }).notNull(),
128 shadowban: integer("shadowban").notNull().default(0), // 1 = hide from homepage
129 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
130});
131
132// Auth tables
133export const authState = pgTable("auth_state", {
134 key: text("key").primaryKey(),
135 state: text("state").notNull(),
136 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
137});
138
139export const authSession = pgTable("auth_session", {
140 key: text("key").primaryKey(),
141 session: text("session").notNull(),
142 createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
143 updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
144});