nandi/gleanpublic⑂ Fork 0
⑂ eaea87c
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add database migrationsUnverified

Julien Robert committed 2026-05-01T22:28:45+02:00 Browse files
eaea87c parent: 8861211
modified internal/db/db.go +5 -0
@@ -103,6 +103,11 @@ func Open(basePath string) (*Store, error) {
103103 return nil, err
104104 }
105105
106+ if err := runMigrations(d); err != nil {
107+ d.Close()
108+ return nil, err
109+ }
110+
106111 if err := initArticlesSchema(d); err != nil {
107112 d.Close()
108113 return nil, err
@@ -103,6 +103,11 @@ func Open(basePath string) (*Store, error) {
103 return nil, err103 return nil, err
104 }104 }
105 105
106+ if err := runMigrations(d); err != nil {
107+ d.Close()
108+ return nil, err
109+ }
110+
106 if err := initArticlesSchema(d); err != nil {111 if err := initArticlesSchema(d); err != nil {
107 d.Close()112 d.Close()
108 return nil, err113 return nil, err
added internal/db/migrations.go +179 -0
new file mode 100644
@@ -0,0 +1,179 @@
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+}
new file mode 100644
@@ -0,0 +1,179 @@
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+}