char-slop/stars.hackery.site
git clone https://git.t4t.associates/char-slop/stars.hackery.site
92004f9
main
1import { DatabaseSync } from "node:sqlite" ; 2import { setTimeout as sleep } from "node:timers/promises" ; 3import * as l from "@cerulea/lexicon" ; 4import { Hydrant } from "./hydrant.ts" ; 5import { 6type AtUri , 7atUri , 8Follow , 9parseAtUri , 10Star , 11starUrl , 12} from "./records.ts" ; 13 14export type IndexedStar = { uri :string ; author :string }; 15const PAGE_SIZE = 25 ; 16 17const EventId = l . object ({ id :l . integerWith ({ minimum :0 }) }); 18const RecordEvent = l . object ({ 19type :l . literal ( "record" ), 20record :l . object ({ did :l . string , collection :l . string , rkey :l . string }), 21}); 22 23// Yields a socket's text messages until it closes or the signal aborts. 24// Messages queue while the consumer is busy, bounded so a flood can't 25// exhaust memory. 26async function * messages ( 27socket :WebSocket , 28signal :AbortSignal , 29) :AsyncGenerator < string > { 30const queue :string [] = []; 31let closed = false ; 32let wake = () => {}; 33const stop = () => { 34closed = true ; 35socket . close (); 36wake (); 37}; 38socket . addEventListener ( "message" , ( event ) => { 39if ( typeof event . data !== "string" || queue . length >= 4096 ) return stop (); 40queue . push ( event . data ); 41wake (); 42}); 43socket . addEventListener ( "error" , stop ); 44socket . addEventListener ( "close" , stop ); 45signal . addEventListener ( "abort" , stop , { once :true }); 46try { 47while ( ! closed || queue . length ) { 48const next = queue . shift (); 49if ( next !== undefined ) yield next ; 50else await new Promise < void >(( resolve ) => wake = resolve ); 51} 52} finally { 53signal . removeEventListener ( "abort" , stop ); 54stop (); 55} 56} 57 58export class Index { 59private readonly viewers = new Set < string >(); 60private readonly pendingViewers = new Map < string , Promise < boolean >>(); 61connected = false ; 62ready = false ; 63 64constructor ( readonly db :DatabaseSync , readonly hydrant :Hydrant ) { 65db . exec ( ` 66CREATE TABLE IF NOT EXISTS stars (uri TEXT PRIMARY KEY, author TEXT NOT NULL, tid TEXT NOT NULL); 67CREATE INDEX IF NOT EXISTS stars_order ON stars (tid DESC, uri DESC); 68CREATE TABLE IF NOT EXISTS follows (uri TEXT PRIMARY KEY, viewer TEXT NOT NULL, subject TEXT NOT NULL); 69CREATE INDEX IF NOT EXISTS follows_viewer ON follows (viewer, subject); 70CREATE TABLE IF NOT EXISTS sync_state (key TEXT PRIMARY KEY, value TEXT NOT NULL); 71` ); 72} 73 74private transaction < T > ( body :() => T ) :T { 75this . db . exec ( "BEGIN IMMEDIATE" ); 76try { 77const result = body (); 78this . db . exec ( "COMMIT" ); 79return result ; 80} catch ( error ) { 81this . db . exec ( "ROLLBACK" ); 82throw error ; 83} 84} 85 86// The last stream event applied. 87private get cursor () :number { 88const row = this . db . prepare ( 89"SELECT value FROM sync_state WHERE key = 'cursor'" , 90). get (); 91return Number ( row ?. value ?? - 1 ); 92} 93private set cursor ( id :number ) { 94this . db . prepare ( "INSERT OR REPLACE INTO sync_state VALUES ('cursor', ?)" ) 95. run ( String ( id )); 96} 97 98// Mirrors a record's current state; a missing or invalid one is removed. 99private mirror ( uri :string , value :unknown ) :void { 100const collection = parseAtUri ( uri )?. collection ; 101if ( collection === Star . id ) this . star ( uri , value ); 102else if ( collection === Follow . id ) this . follow ( uri , value ); 103} 104 105star ( uri :string , value :unknown ) :void { 106const parsed = Star . validate ( value ); 107const at = parseAtUri ( uri ); 108if ( 109parsed . success && at ?. collection === Star . id && starUrl ( parsed . value . url ) 110) { 111this . db . prepare ( "INSERT OR REPLACE INTO stars VALUES (?, ?, ?)" ). run ( 112uri , 113at . did , 114at . rkey , 115); 116} else this . removeStar ( uri ); 117} 118removeStar ( uri :string ) :void { 119this . db . prepare ( "DELETE FROM stars WHERE uri = ?" ). run ( uri ); 120} 121 122follow ( uri :string , value :unknown ) :void { 123const parsed = Follow . validate ( value ); 124const at = parseAtUri ( uri ); 125if ( 126parsed . success && at ?. collection === Follow . id && 127parsed . value . subject !== at . did 128) { 129this . db . prepare ( "INSERT OR REPLACE INTO follows VALUES (?, ?, ?)" ). run ( 130uri , 131at . did , 132parsed . value . subject , 133); 134} else this . removeFollow ( uri ); 135} 136removeFollow ( uri :string ) :void { 137this . db . prepare ( "DELETE FROM follows WHERE uri = ?" ). run ( uri ); 138} 139 140following ( viewer :string , subject :string ) :string [] { 141return this . db . prepare ( 142"SELECT uri FROM follows WHERE viewer = ? AND subject = ?" , 143). all ( viewer , subject ). map (( row ) => String ( row . uri )); 144} 145 146// Fetches one row past the page to learn whether there is a next one. 147page ( 148viewer :string , 149before ?:string , 150) :{ stars :IndexedStar []; next ?:string } { 151const rows = this . db . prepare ( `SELECT uri, author FROM stars WHERE 152(author = ? OR author IN (SELECT subject FROM follows WHERE viewer = ?)) 153AND (? IS NULL OR (tid, uri) < (?, ?)) 154ORDER BY tid DESC, uri DESC LIMIT ${ PAGE_SIZE + 1 } ` ). all ( 155viewer , 156viewer , 157before ?? null , 158before ?. split ( "/" ). at ( - 1 ) ?? "" , 159before ?? "" , 160). map (( row ) => ({ uri :String ( row . uri ), author :String ( row . author ) })); 161const stars = rows . slice ( 0 , PAGE_SIZE ); 162return { 163 stars, 164next :rows . length > PAGE_SIZE ?stars . at ( - 1 )?. uri :undefined , 165}; 166} 167 168// Whether the viewer's follows are known, preparing them on first sight. 169ensureViewer ( did :string ) :Promise < boolean > { 170if ( this . viewers . has ( did )) return Promise . resolve ( true ); 171const pending = this . pendingViewers . get ( did ); 172if ( pending ) return pending ; 173const task = ( async () => { 174try { 175await this . hydrant . track ( did ); 176await this . reconcileFollows ( did ); 177this . viewers . add ( did ); 178return true ; 179} catch ( error ) { 180console . error ( "Could not prepare viewer" , did , error ); 181return false ; 182} finally { 183this . pendingViewers . delete ( did ); 184} 185})(); 186this . pendingViewers . set ( did , task ); 187return task ; 188} 189 190async reconcileFollows ( viewer :string ) :Promise < void > { 191const follows = await Array . fromAsync ( 192this . hydrant . allRecords ( viewer , Follow . id ), 193); 194this . transaction (() => { 195this . db . prepare ( "DELETE FROM follows WHERE viewer = ?" ). run ( viewer ); 196for ( const { uri, value} of follows ) this . follow ( uri , value ); 197}); 198} 199 200async bootstrap () :Promise < void > { 201for await ( const did of this . hydrant . repos ()) { 202for await ( 203const { uri, value} of this . hydrant . allRecords ( did , Star . id ) 204) { 205this . star ( uri , value ); 206} 207} 208const sessions = this . db . prepare ( 209"SELECT DISTINCT did FROM browser_session WHERE expires > ?" , 210). all ( Date . now ()); 211for ( const row of sessions ) await this . ensureViewer ( String ( row . did )); 212this . ready = true ; 213} 214 215// Every star, and the follows of people who use this site. 216private relevant ({ did, collection} :AtUri ) :boolean { 217return collection === Star . id || 218( collection === Follow . id && this . viewers . has ( did )); 219} 220 221async apply ( raw :unknown ) :Promise < void > { 222const event = EventId . validate ( raw ); 223if ( ! event . success ) throw new Error ( "Invalid Hydrant event" ); 224const { id} = event . value ; 225if ( id <= this . cursor ) return ; 226const change = RecordEvent . validate ( raw ); 227const uri = change . success && this . relevant ( change . value . record ) 228 ?atUri ( change . value . record ) 229 :undefined ; 230const current = uri ?await this . hydrant . record ( uri ) :undefined ; 231this . transaction (() => { 232if ( uri ) this . mirror ( uri , current ?. value ); 233this . cursor = id ; 234}); 235} 236 237async run ( signal :AbortSignal ) :Promise < void > { 238while ( ! signal . aborted ) { 239try { 240if ( ! this . ready ) await this . bootstrap (); 241await this . consume ( this . hydrant . stream ( this . cursor + 1 ), signal ); 242} catch ( error ) { 243if ( ! signal . aborted ) console . error ( "Hydrant sync failed" , error ); 244} 245if ( ! signal . aborted ) await sleep ( 1000 ); 246} 247} 248 249private async consume ( url :URL , signal :AbortSignal ) :Promise < void > { 250const socket = new WebSocket ( url ); 251const deadline = setTimeout (() => socket . close (), 15000 ); 252socket . addEventListener ( "open" , () => { 253clearTimeout ( deadline ); 254this . connected = true ; 255}, { once :true }); 256try { 257for await ( const message of messages ( socket , signal )) { 258await this . apply ( JSON . parse ( message )); 259} 260} finally { 261clearTimeout ( deadline ); 262this . connected = false ; 263} 264} 265}