A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

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

Add database migrations

+184
+5
internal/db/db.go
··· 103 103 return nil, err 104 104 } 105 105 106 + if err := runMigrations(d); err != nil { 107 + d.Close() 108 + return nil, err 109 + } 110 + 106 111 if err := initArticlesSchema(d); err != nil { 107 112 d.Close() 108 113 return nil, err
+179
internal/db/migrations.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + ) 8 + 9 + func init() { 10 + if len(migrations) != SchemaVersion { 11 + panic(fmt.Sprintf("SchemaVersion (%d) does not match number of migrations (%d)", SchemaVersion, len(migrations))) 12 + } 13 + } 14 + 15 + // SchemaVersion must be incremented each time a migration is added to the migrations slice (used so that fresh dbs skip running migrations). 16 + const SchemaVersion = 2 17 + 18 + type migration struct { 19 + id int 20 + name string 21 + run func(db *DB) error 22 + } 23 + 24 + var migrations = []migration{ 25 + { 26 + id: 1, 27 + name: "add_person_target_type", 28 + run: migrateAddPersonTargetType, 29 + }, 30 + { 31 + id: 2, 32 + name: "feed_type_atproto", 33 + run: migrateFeedTypeATProto, 34 + }, 35 + } 36 + 37 + func runMigrations(db *DB) error { 38 + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( 39 + id INTEGER PRIMARY KEY, 40 + name TEXT NOT NULL, 41 + run_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 42 + )`); err != nil { 43 + return fmt.Errorf("create schema_migrations table: %w", err) 44 + } 45 + 46 + var count int 47 + if err := db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil { 48 + return fmt.Errorf("count migrations: %w", err) 49 + } 50 + if count == 0 { 51 + for _, m := range migrations { 52 + if _, err := db.Exec("INSERT INTO schema_migrations (id, name) VALUES (?, ?)", m.id, m.name); err != nil { 53 + return fmt.Errorf("record migration %d: %w", m.id, err) 54 + } 55 + } 56 + return nil 57 + } 58 + 59 + for _, m := range migrations { 60 + var id int 61 + err := db.QueryRow("SELECT id FROM schema_migrations WHERE id = ?", m.id).Scan(&id) 62 + if err == nil { 63 + continue 64 + } 65 + if err != sql.ErrNoRows { 66 + return fmt.Errorf("check migration %d: %w", m.id, err) 67 + } 68 + 69 + if err := m.run(db); err != nil { 70 + return fmt.Errorf("migration %d (%s): %w", m.id, m.name, err) 71 + } 72 + 73 + if _, err := db.Exec("INSERT INTO schema_migrations (id, name) VALUES (?, ?)", m.id, m.name); err != nil { 74 + return fmt.Errorf("record migration %d: %w", m.id, err) 75 + } 76 + } 77 + 78 + return nil 79 + } 80 + 81 + func migrateFeedTypeATProto(db *DB) error { 82 + var checkClause string 83 + err := db.QueryRow("SELECT sql FROM articles.sqlite_master WHERE type='table' AND name='feeds'").Scan(&checkClause) 84 + if err != nil { 85 + if err == sql.ErrNoRows { 86 + return nil 87 + } 88 + return fmt.Errorf("read feeds schema: %w", err) 89 + } 90 + if strings.Contains(checkClause, "'atproto'") { 91 + return nil 92 + } 93 + 94 + for _, stmt := range []string{ 95 + `CREATE TABLE articles.feeds_new ( 96 + feed_url TEXT PRIMARY KEY, 97 + title TEXT, 98 + site_url TEXT, 99 + description TEXT, 100 + feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json', 'atproto')), 101 + last_fetched_at DATETIME, 102 + last_error TEXT, 103 + subscriber_count INTEGER NOT NULL DEFAULT 0, 104 + consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, 105 + error_count INTEGER NOT NULL DEFAULT 0, 106 + favicon_url TEXT 107 + )`, 108 + `INSERT INTO articles.feeds_new SELECT * FROM articles.feeds`, 109 + `DROP TABLE articles.feeds`, 110 + `ALTER TABLE articles.feeds_new RENAME TO feeds`, 111 + } { 112 + if _, err := db.Exec(stmt); err != nil { 113 + return fmt.Errorf("migrate feed_type atproto: %w", err) 114 + } 115 + } 116 + 117 + return nil 118 + } 119 + 120 + func migrateAddPersonTargetType(db *DB) error { 121 + for _, m := range []struct { 122 + table string 123 + create string 124 + index string 125 + }{ 126 + { 127 + table: "dismissed_recommendations", 128 + create: `CREATE TABLE dismissed_recommendations_new ( 129 + user_did TEXT NOT NULL, 130 + target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')), 131 + target_id TEXT NOT NULL, 132 + reason TEXT, 133 + dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 134 + PRIMARY KEY (user_did, target_type, target_id) 135 + )`, 136 + index: "idx_dismissed_user_type", 137 + }, 138 + { 139 + table: "recommendation_impressions", 140 + create: `CREATE TABLE recommendation_impressions_new ( 141 + user_did TEXT NOT NULL, 142 + target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')), 143 + target_id TEXT NOT NULL, 144 + first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 145 + last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 146 + shown_count INTEGER NOT NULL DEFAULT 1, 147 + acted BOOLEAN NOT NULL DEFAULT 0, 148 + PRIMARY KEY (user_did, target_type, target_id) 149 + )`, 150 + index: "idx_impressions_user_unacted", 151 + }, 152 + } { 153 + var schema string 154 + _ = db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", m.table).Scan(&schema) 155 + if strings.Contains(schema, "'person'") { 156 + continue 157 + } 158 + 159 + db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s_new", m.table)) 160 + 161 + for _, stmt := range []string{ 162 + m.create, 163 + fmt.Sprintf("INSERT INTO %s_new SELECT * FROM %s", m.table, m.table), 164 + fmt.Sprintf("DROP TABLE %s", m.table), 165 + fmt.Sprintf("ALTER TABLE %s_new RENAME TO %s", m.table, m.table), 166 + fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s(user_did, target_type)", m.index, m.table), 167 + } { 168 + if _, err := db.Exec(stmt); err != nil { 169 + return fmt.Errorf("migrating %s: %w", m.table, err) 170 + } 171 + } 172 + } 173 + 174 + if _, err := db.Exec("CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)"); err != nil { 175 + return err 176 + } 177 + 178 + return nil 179 + }