Statusphere, but in atcute and SvelteKit
atproto svelte sveltekit drizzle atcute typescript
20
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: make drizzle handle json

mary.my.id f5c75edf fe14f8f1

verified
+85 -99
+6 -7
drizzle/0000_absurd_kitty_pryde.sql drizzle/0000_boring_exiles.sql
··· 8 8 --> statement-breakpoint 9 9 CREATE TABLE `oauth_session` ( 10 10 `did` text PRIMARY KEY NOT NULL, 11 - `session_json` text NOT NULL, 11 + `session` text NOT NULL, 12 12 `updated_at` integer NOT NULL 13 13 ); 14 14 --> statement-breakpoint 15 15 CREATE INDEX `oauth_session_updated_at_idx` ON `oauth_session` (`updated_at`);--> statement-breakpoint 16 16 CREATE TABLE `oauth_state` ( 17 17 `key` text PRIMARY KEY NOT NULL, 18 - `state_json` text NOT NULL, 18 + `state` text NOT NULL, 19 19 `expires_at` integer NOT NULL 20 20 ); 21 21 --> statement-breakpoint 22 22 CREATE INDEX `oauth_state_expires_at_idx` ON `oauth_state` (`expires_at`);--> statement-breakpoint 23 23 CREATE TABLE `profile` ( 24 24 `did` text PRIMARY KEY NOT NULL, 25 - `display_name` text, 26 - `record_json` text NOT NULL, 25 + `record` text NOT NULL, 27 26 `indexed_at` integer NOT NULL 28 27 ); 29 28 --> statement-breakpoint ··· 31 30 `uri` text PRIMARY KEY NOT NULL, 32 31 `author_did` text NOT NULL, 33 32 `rkey` text NOT NULL, 34 - `status` text NOT NULL, 35 - `created_at` text NOT NULL, 33 + `record` text NOT NULL, 34 + `sort_at` integer NOT NULL, 36 35 `indexed_at` integer NOT NULL 37 36 ); 38 37 --> statement-breakpoint 39 - CREATE INDEX `status_indexed_at_idx` ON `status` (`indexed_at`); 38 + CREATE INDEX `status_sort_at_idx` ON `status` (`sort_at`);
+15 -22
drizzle/meta/0000_snapshot.json
··· 1 1 { 2 2 "version": "6", 3 3 "dialect": "sqlite", 4 - "id": "78dc97fb-2759-4aba-b927-e6b7d1d2ac73", 4 + "id": "ca02a5fe-d4e6-42ad-a03e-3b5cd85f3302", 5 5 "prevId": "00000000-0000-0000-0000-000000000000", 6 6 "tables": { 7 7 "identity": { ··· 59 59 "notNull": true, 60 60 "autoincrement": false 61 61 }, 62 - "session_json": { 63 - "name": "session_json", 62 + "session": { 63 + "name": "session", 64 64 "type": "text", 65 65 "primaryKey": false, 66 66 "notNull": true, ··· 98 98 "notNull": true, 99 99 "autoincrement": false 100 100 }, 101 - "state_json": { 102 - "name": "state_json", 101 + "state": { 102 + "name": "state", 103 103 "type": "text", 104 104 "primaryKey": false, 105 105 "notNull": true, ··· 137 137 "notNull": true, 138 138 "autoincrement": false 139 139 }, 140 - "display_name": { 141 - "name": "display_name", 142 - "type": "text", 143 - "primaryKey": false, 144 - "notNull": false, 145 - "autoincrement": false 146 - }, 147 - "record_json": { 148 - "name": "record_json", 140 + "record": { 141 + "name": "record", 149 142 "type": "text", 150 143 "primaryKey": false, 151 144 "notNull": true, ··· 189 182 "notNull": true, 190 183 "autoincrement": false 191 184 }, 192 - "status": { 193 - "name": "status", 185 + "record": { 186 + "name": "record", 194 187 "type": "text", 195 188 "primaryKey": false, 196 189 "notNull": true, 197 190 "autoincrement": false 198 191 }, 199 - "created_at": { 200 - "name": "created_at", 201 - "type": "text", 192 + "sort_at": { 193 + "name": "sort_at", 194 + "type": "integer", 202 195 "primaryKey": false, 203 196 "notNull": true, 204 197 "autoincrement": false ··· 212 205 } 213 206 }, 214 207 "indexes": { 215 - "status_indexed_at_idx": { 216 - "name": "status_indexed_at_idx", 208 + "status_sort_at_idx": { 209 + "name": "status_sort_at_idx", 217 210 "columns": [ 218 - "indexed_at" 211 + "sort_at" 219 212 ], 220 213 "isUnique": false 221 214 }
+2 -2
drizzle/meta/_journal.json
··· 5 5 { 6 6 "idx": 0, 7 7 "version": "6", 8 - "when": 1765706992228, 9 - "tag": "0000_absurd_kitty_pryde", 8 + "when": 1765708606190, 9 + "tag": "0000_boring_exiles", 10 10 "breakpoints": true 11 11 } 12 12 ]
+5 -1
src/lib/components/status-picker.svelte
··· 26 26 handle: user.handle, 27 27 displayName: user.displayName, 28 28 }, 29 - status: data.status, 29 + record: { 30 + $type: 'xyz.statusphere.status', 31 + status: data.status, 32 + createdAt: new Date().toISOString(), 33 + }, 30 34 indexedAt: new Date().toISOString(), 31 35 }, 32 36 ...current.statuses,
+1 -1
src/lib/components/timeline.svelte
··· 52 52 <div class="timeline"> 53 53 {#each statuses as item} 54 54 <div class="item"> 55 - <div class="emoji">{item.status}</div> 55 + <div class="emoji">{item.record.status}</div> 56 56 <div class="content"> 57 57 <a 58 58 href={getBskyProfileUrl(item.author.handle)}
+11 -7
src/lib/server/db/schema.ts
··· 1 1 import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; 2 2 3 + import type { AppBskyActorProfile } from '@atcute/bluesky'; 4 + import type { StoredSession, StoredState } from '@atcute/oauth-node-client'; 5 + 6 + import type { XyzStatusphereStatus } from '$lib/lexicons'; 7 + 3 8 export const oauthState = sqliteTable( 4 9 'oauth_state', 5 10 { 6 11 key: text('key').primaryKey(), 7 - stateJson: text('state_json').notNull(), 12 + state: text('state', { mode: 'json' }).$type<StoredState>().notNull(), 8 13 expiresAt: integer('expires_at').notNull(), 9 14 }, 10 15 (table) => [index('oauth_state_expires_at_idx').on(table.expiresAt)], ··· 14 19 'oauth_session', 15 20 { 16 21 did: text('did').primaryKey(), 17 - sessionJson: text('session_json').notNull(), 22 + session: text('session', { mode: 'json' }).$type<StoredSession>().notNull(), 18 23 updatedAt: integer('updated_at').notNull(), 19 24 }, 20 25 (table) => [index('oauth_session_updated_at_idx').on(table.updatedAt)], ··· 30 35 31 36 export const profile = sqliteTable('profile', { 32 37 did: text('did').primaryKey(), 33 - displayName: text('display_name'), 34 - recordJson: text('record_json').notNull(), 38 + record: text('record', { mode: 'json' }).$type<AppBskyActorProfile.Main>().notNull(), 35 39 indexedAt: integer('indexed_at').notNull(), 36 40 }); 37 41 ··· 41 45 uri: text('uri').primaryKey(), 42 46 authorDid: text('author_did').notNull(), 43 47 rkey: text('rkey').notNull(), 44 - status: text('status').notNull(), 45 - createdAt: text('created_at').notNull(), 48 + record: text('record', { mode: 'json' }).$type<XyzStatusphereStatus.Main>().notNull(), 49 + sortAt: integer('sort_at').notNull(), 46 50 indexedAt: integer('indexed_at').notNull(), 47 51 }, 48 - (table) => [index('status_indexed_at_idx').on(table.indexedAt)], 52 + (table) => [index('status_sort_at_idx').on(table.sortAt)], 49 53 );
+9 -15
src/lib/server/oauth/stores.ts
··· 10 10 sessions: { 11 11 async get(did: Did) { 12 12 const row = await db.select().from(oauthSession).where(eq(oauthSession.did, did)).get(); 13 - if (!row) { 14 - return; 15 - } 16 - 17 - return JSON.parse(row.sessionJson) as StoredSession; 13 + return row?.session; 18 14 }, 19 - async set(did: Did, value: StoredSession) { 20 - const sessionJson = JSON.stringify(value); 15 + async set(did: Did, session: StoredSession) { 21 16 const updatedAt = Date.now(); 22 17 23 18 await db 24 19 .insert(oauthSession) 25 - .values({ did, sessionJson, updatedAt }) 20 + .values({ did, session, updatedAt }) 26 21 .onConflictDoUpdate({ 27 22 target: oauthSession.did, 28 - set: { sessionJson, updatedAt }, 23 + set: { session, updatedAt }, 29 24 }) 30 25 .run(); 31 26 }, ··· 49 44 return; 50 45 } 51 46 52 - return JSON.parse(row.stateJson) as StoredState; 47 + return row.state; 53 48 }, 54 - async set(key: string, value: StoredState) { 55 - const stateJson = JSON.stringify(value); 56 - const expiresAt = value.expiresAt; 49 + async set(key: string, state: StoredState) { 50 + const expiresAt = state.expiresAt; 57 51 58 52 await db 59 53 .insert(oauthState) 60 - .values({ key, stateJson, expiresAt }) 54 + .values({ key, state, expiresAt }) 61 55 .onConflictDoUpdate({ 62 56 target: oauthState.key, 63 - set: { stateJson, expiresAt }, 57 + set: { state, expiresAt }, 64 58 }) 65 59 .run(); 66 60 },
+16 -26
src/lib/server/tap/ingest.ts
··· 7 7 import { db } from '$lib/server/db'; 8 8 import { identity, profile, status } from '$lib/server/db/schema'; 9 9 10 - const now = () => Date.now(); 11 - 12 10 const toAtUri = (did: string, collection: string, rkey: string): string => { 13 11 return `at://${did}/${collection}/${rkey}`; 14 12 }; ··· 20 18 */ 21 19 export const ingestTapEvent = async (event: TapEvent): Promise<void> => { 22 20 if (event.type === 'identity') { 21 + const updatedAt = Date.now(); 22 + 23 23 await db 24 24 .insert(identity) 25 25 .values({ ··· 27 27 handle: event.handle, 28 28 isActive: event.isActive, 29 29 status: event.status, 30 - updatedAt: now(), 30 + updatedAt, 31 31 }) 32 32 .onConflictDoUpdate({ 33 33 target: identity.did, ··· 35 35 handle: event.handle, 36 36 isActive: event.isActive, 37 37 status: event.status, 38 - updatedAt: now(), 38 + updatedAt, 39 39 }, 40 40 }) 41 41 .run(); ··· 53 53 return; 54 54 } 55 55 56 - const record = event.record; 57 - if (!record) { 58 - return; 59 - } 60 - 61 - const parsed = safeParse(AppBskyActorProfile.mainSchema, record); 56 + const parsed = safeParse(AppBskyActorProfile.mainSchema, event.record); 62 57 if (!parsed.ok) { 63 58 return; 64 59 } 65 60 66 - const indexedAt = now(); 67 - const recordJson = JSON.stringify(parsed.value); 61 + const record = parsed.value; 62 + const indexedAt = Date.now(); 68 63 69 64 await db 70 65 .insert(profile) 71 66 .values({ 72 67 did: event.did, 73 - displayName: parsed.value.displayName ?? null, 74 - recordJson, 68 + record, 75 69 indexedAt, 76 70 }) 77 71 .onConflictDoUpdate({ 78 72 target: profile.did, 79 73 set: { 80 - displayName: parsed.value.displayName ?? null, 81 - recordJson, 74 + record, 82 75 indexedAt, 83 76 }, 84 77 }) ··· 95 88 return; 96 89 } 97 90 98 - const record = event.record; 99 - if (!record) { 100 - return; 101 - } 102 - 103 - const parsed = safeParse(XyzStatusphereStatus.mainSchema, record); 91 + const parsed = safeParse(XyzStatusphereStatus.mainSchema, event.record); 104 92 if (!parsed.ok) { 105 93 return; 106 94 } 107 95 108 - const indexedAt = now(); 96 + const record = parsed.value; 97 + const indexedAt = Date.now(); 98 + const sortAt = Math.min(Date.parse(record.createdAt), indexedAt); 109 99 110 100 await db 111 101 .insert(status) ··· 113 103 uri, 114 104 authorDid: event.did, 115 105 rkey: event.rkey, 116 - status: parsed.value.status, 117 - createdAt: parsed.value.createdAt, 106 + record, 107 + sortAt, 118 108 indexedAt, 119 109 }) 120 110 .onConflictDoUpdate({ 121 111 target: status.uri, 122 112 set: { 123 - status: parsed.value.status, 113 + record, 124 114 indexedAt, 125 115 }, 126 116 })
+20 -18
src/lib/status.remote.ts
··· 39 39 return { 40 40 did, 41 41 handle: (identity?.handle ?? 'handle.invalid') as Handle, 42 - displayName: profile?.displayName ?? undefined, 42 + displayName: profile?.record.displayName ?? undefined, 43 43 }; 44 44 }); 45 45 46 - const encodeCursor = (indexedAt: number, uri: string): string => { 47 - return `${indexedAt}:${uri}`; 46 + const encodeCursor = (sortAt: number, uri: string): string => { 47 + return `${sortAt}:${uri}`; 48 48 }; 49 49 50 50 const cursorSchema = v.pipe( ··· 58 58 return NEVER; 59 59 } 60 60 61 - const indexedAt = parseInt(input.slice(0, idx), 10); 61 + const sortAt = parseInt(input.slice(0, idx), 10); 62 62 const uri = input.slice(idx + 1); 63 63 64 - if (Number.isNaN(indexedAt) || !uri) { 64 + if (Number.isNaN(sortAt) || !uri) { 65 65 addIssue({ message: 'invalid cursor format' }); 66 66 return NEVER; 67 67 } 68 68 69 - return { indexedAt, uri }; 69 + return { sortAt, uri }; 70 70 }), 71 71 ); 72 72 ··· 110 110 // insert locally so we don't have to wait for ingester 111 111 { 112 112 const uri: CanonicalResourceUri = `at://${session.did}/xyz.statusphere.status/${rkey}`; 113 + const indexedAt = Date.now(); 114 + const sortAt = Math.min(Date.parse(createdAt), indexedAt); 115 + 113 116 await db 114 117 .insert(schema.status) 115 118 .values({ 116 119 uri, 117 120 authorDid: session.did, 118 121 rkey, 119 - status, 120 - createdAt, 121 - indexedAt: Date.now(), 122 + record, 123 + sortAt, 124 + indexedAt, 122 125 }) 123 126 .onConflictDoNothing() 124 127 .run(); ··· 135 138 136 139 export interface StatusView { 137 140 author: AuthorView; 138 - status: string; 141 + record: XyzStatusphereStatus.Main; 139 142 indexedAt: string; 140 143 } 141 144 ··· 157 160 .where( 158 161 cursor 159 162 ? or( 160 - lt(schema.status.indexedAt, cursor.indexedAt), 161 - and(eq(schema.status.indexedAt, cursor.indexedAt), lt(schema.status.uri, cursor.uri)), 163 + lt(schema.status.sortAt, cursor.sortAt), 164 + and(eq(schema.status.sortAt, cursor.sortAt), lt(schema.status.uri, cursor.uri)), 162 165 ) 163 166 : undefined, 164 167 ) 165 - .orderBy(desc(schema.status.indexedAt), desc(schema.status.uri)) 168 + .orderBy(desc(schema.status.sortAt), desc(schema.status.uri)) 166 169 .limit(limit + 1) 167 170 .all(); 168 171 ··· 182 185 const statuses = items.map((s): StatusView => { 183 186 const identity = identityMap.get(s.authorDid); 184 187 const profile = profileMap.get(s.authorDid); 185 - const indexedAt = Math.min(Date.parse(s.createdAt), s.indexedAt); 186 188 187 189 return { 188 190 author: { 189 191 did: s.authorDid as Did, 190 192 handle: (identity?.handle ?? 'handle.invalid') as Handle, 191 - displayName: profile?.displayName ?? undefined, 193 + displayName: profile?.record.displayName ?? undefined, 192 194 }, 193 - status: s.status, 194 - indexedAt: new Date(indexedAt).toISOString(), 195 + record: s.record, 196 + indexedAt: new Date(s.sortAt).toISOString(), 195 197 }; 196 198 }); 197 199 198 200 const last = items[items.length - 1]; 199 201 200 202 return { 201 - cursor: hasMore && last ? encodeCursor(last.indexedAt, last.uri) : undefined, 203 + cursor: hasMore && last ? encodeCursor(last.sortAt, last.uri) : undefined, 202 204 statuses, 203 205 }; 204 206 },