@@ -73,6 +73,12 @@ func NewDB(path string) (*DB, error) {
7373 return nil , err
7474 }
7575
76+ // Migrate from old schema if needed
77+ if err := migrateSchema (db ); err != nil {
78+ db .Close ()
79+ return nil , fmt .Errorf ("failed to migrate schema: %w" , err )
80+ }
81+
7682 return & DB {db : db }, nil
7783}
7884
@@ -87,15 +93,24 @@ func createTables(db *sql.DB) error {
8793 UNIQUE(name, type)
8894 );
8995
90- CREATE TABLE IF NOT EXISTS test_results (
96+ CREATE TABLE IF NOT EXISTS stamps (
9197 id INTEGER PRIMARY KEY AUTOINCREMENT,
9298 resolver_id INTEGER NOT NULL,
9399 stamp TEXT NOT NULL,
100+ UNIQUE(resolver_id, stamp),
101+ FOREIGN KEY (resolver_id) REFERENCES resolvers(id)
102+ );
103+
104+ CREATE TABLE IF NOT EXISTS test_results (
105+ id INTEGER PRIMARY KEY AUTOINCREMENT,
106+ resolver_id INTEGER NOT NULL,
107+ stamp_id INTEGER NOT NULL,
94108 success INTEGER NOT NULL,
95109 rtt_ms INTEGER,
96110 error TEXT,
97111 tested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
98- FOREIGN KEY (resolver_id) REFERENCES resolvers(id)
112+ FOREIGN KEY (resolver_id) REFERENCES resolvers(id),
113+ FOREIGN KEY (stamp_id) REFERENCES stamps(id)
99114 );
100115
101116 -- For GetTestCount: MAX(tested_at)
@@ -130,6 +145,103 @@ func createTables(db *sql.DB) error {
130145 return err
131146}
132147
148+ // migrateSchema migrates from the old schema (stamp column in test_results)
149+ // to the new normalized schema (stamps table with stamp_id reference).
150+ func migrateSchema (db * sql.DB ) error {
151+ // Check if migration is needed by looking for stamp column in test_results
152+ var hasStampColumn bool
153+ rows , err := db .Query ("PRAGMA table_info(test_results)" )
154+ if err != nil {
155+ return err
156+ }
157+ for rows .Next () {
158+ var cid int
159+ var name , typ string
160+ var notnull , pk int
161+ var dflt sql.NullString
162+ if err := rows .Scan (& cid , & name , & typ , & notnull , & dflt , & pk ); err != nil {
163+ rows .Close ()
164+ return err
165+ }
166+ if name == "stamp" {
167+ hasStampColumn = true
168+ break
169+ }
170+ }
171+ rows .Close ()
172+
173+ if ! hasStampColumn {
174+ return nil // Already migrated
175+ }
176+
177+ // Check if there's any data to migrate
178+ var count int
179+ if err := db .QueryRow ("SELECT COUNT(*) FROM test_results" ).Scan (& count ); err != nil {
180+ return err
181+ }
182+ if count == 0 {
183+ // No data, just recreate the table with new schema
184+ if _ , err := db .Exec ("DROP TABLE IF EXISTS test_results" ); err != nil {
185+ return err
186+ }
187+ return nil
188+ }
189+
190+ // Migration needed - populate stamps table and update test_results
191+ if _ , err := db .Exec (`
192+ INSERT OR IGNORE INTO stamps (resolver_id, stamp)
193+ SELECT DISTINCT resolver_id, stamp FROM test_results WHERE stamp IS NOT NULL
194+ ` ); err != nil {
195+ return fmt .Errorf ("failed to populate stamps table: %w" , err )
196+ }
197+
198+ // Create new test_results table with stamp_id
199+ if _ , err := db .Exec (`
200+ CREATE TABLE test_results_new (
201+ id INTEGER PRIMARY KEY AUTOINCREMENT,
202+ resolver_id INTEGER NOT NULL,
203+ stamp_id INTEGER NOT NULL,
204+ success INTEGER NOT NULL,
205+ rtt_ms INTEGER,
206+ error TEXT,
207+ tested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
208+ FOREIGN KEY (resolver_id) REFERENCES resolvers(id),
209+ FOREIGN KEY (stamp_id) REFERENCES stamps(id)
210+ )
211+ ` ); err != nil {
212+ return fmt .Errorf ("failed to create new test_results table: %w" , err )
213+ }
214+
215+ // Copy data from old table to new, joining with stamps to get stamp_id
216+ if _ , err := db .Exec (`
217+ INSERT INTO test_results_new (resolver_id, stamp_id, success, rtt_ms, error, tested_at)
218+ SELECT t.resolver_id, s.id, t.success, t.rtt_ms, t.error, t.tested_at
219+ FROM test_results t
220+ JOIN stamps s ON t.resolver_id = s.resolver_id AND t.stamp = s.stamp
221+ ` ); err != nil {
222+ return fmt .Errorf ("failed to migrate test_results data: %w" , err )
223+ }
224+
225+ // Drop old table and rename new one
226+ if _ , err := db .Exec ("DROP TABLE test_results" ); err != nil {
227+ return fmt .Errorf ("failed to drop old test_results table: %w" , err )
228+ }
229+ if _ , err := db .Exec ("ALTER TABLE test_results_new RENAME TO test_results" ); err != nil {
230+ return fmt .Errorf ("failed to rename test_results table: %w" , err )
231+ }
232+
233+ // Recreate indexes
234+ if _ , err := db .Exec (`
235+ CREATE INDEX IF NOT EXISTS idx_test_results_tested_at ON test_results(tested_at);
236+ CREATE INDEX IF NOT EXISTS idx_test_results_resolver_tested_at ON test_results(resolver_id, tested_at DESC);
237+ CREATE INDEX IF NOT EXISTS idx_test_results_resolver_success ON test_results(resolver_id, success, tested_at DESC);
238+ ` ); err != nil {
239+ return fmt .Errorf ("failed to recreate indexes: %w" , err )
240+ }
241+
242+ return nil
243+ }
244+
133245func (d * DB ) Close () error {
134246 return d .db .Close ()
135247}
@@ -155,11 +267,29 @@ func (d *DB) UpsertResolver(name, typ, description, sourceFile string) (int64, e
155267 return id , err
156268}
157269
270+ func (d * DB ) GetOrCreateStamp (resolverID int64 , stamp string ) (int64 , error ) {
271+ _ , err := d .db .Exec (`
272+ INSERT OR IGNORE INTO stamps (resolver_id, stamp) VALUES (?, ?)
273+ ` , resolverID , stamp )
274+ if err != nil {
275+ return 0 , err
276+ }
277+
278+ var id int64
279+ err = d .db .QueryRow ("SELECT id FROM stamps WHERE resolver_id = ? AND stamp = ?" , resolverID , stamp ).Scan (& id )
280+ return id , err
281+ }
282+
158283func (d * DB ) RecordTest (resolverID int64 , stamp string , success bool , rttMs int64 , errMsg string ) error {
284+ stampID , err := d .GetOrCreateStamp (resolverID , stamp )
285+ if err != nil {
286+ return fmt .Errorf ("failed to get stamp id: %w" , err )
287+ }
288+
159289 result , err := d .db .Exec (`
160- INSERT INTO test_results (resolver_id, stamp , success, rtt_ms, error)
290+ INSERT INTO test_results (resolver_id, stamp_id , success, rtt_ms, error)
161291 VALUES (?, ?, ?, ?, ?)
162- ` , resolverID , stamp , success , rttMs , errMsg )
292+ ` , resolverID , stampID , success , rttMs , errMsg )
163293 if err != nil {
164294 return err
165295 }
@@ -335,7 +465,7 @@ func (d *DB) RebuildStats() error {
335465 )
336466 SELECT
337467 r.id,
338- (SELECT stamp FROM test_results t3 WHERE t3.resolver_id = r.id ORDER BY t3.tested_at DESC LIMIT 1),
468+ (SELECT s. stamp FROM test_results t3 JOIN stamps s ON t3.stamp_id = s.id WHERE t3.resolver_id = r.id ORDER BY t3.tested_at DESC LIMIT 1),
339469 COUNT(t.id),
340470 SUM(CASE WHEN t.success = 1 THEN 1 ELSE 0 END),
341471 SUM(CASE WHEN t.success = 0 THEN 1 ELSE 0 END),
@@ -401,11 +531,14 @@ func (d *DB) RemoveStaleResolvers(noSuccessSince time.Duration) ([]string, error
401531 return nil , nil
402532 }
403533
404- // Delete test results, stats, and resolvers
534+ // Delete test results, stamps, stats, and resolvers
405535 for _ , id := range idsToDelete {
406536 if _ , err := d .db .Exec ("DELETE FROM test_results WHERE resolver_id = ?" , id ); err != nil {
407537 return names , err
408538 }
539+ if _ , err := d .db .Exec ("DELETE FROM stamps WHERE resolver_id = ?" , id ); err != nil {
540+ return names , err
541+ }
409542 if _ , err := d .db .Exec ("DELETE FROM resolver_stats WHERE resolver_id = ?" , id ); err != nil {
410543 return names , err
411544 }
@@ -438,6 +571,14 @@ func (d *DB) PruneOldTests(maxAge time.Duration) (int64, error) {
438571 return deleted , fmt .Errorf ("failed to clean orphaned stats: %w" , err )
439572 }
440573
574+ // Remove orphaned stamps (no remaining test results)
575+ if _ , err := d .db .Exec (`
576+ DELETE FROM stamps
577+ WHERE id NOT IN (SELECT DISTINCT stamp_id FROM test_results)
578+ ` ); err != nil {
579+ return deleted , fmt .Errorf ("failed to clean orphaned stamps: %w" , err )
580+ }
581+
441582 // Remove orphaned resolvers (no remaining test results)
442583 if _ , err := d .db .Exec (`
443584 DELETE FROM resolvers
@@ -486,7 +627,7 @@ func (d *DB) GetTestCount() (count int64, lastTest time.Time, err error) {
486627 return count , lastTest , nil
487628}
488629
489- // RemoveResolver removes a resolver by name along with all its test results and stats.
630+ // RemoveResolver removes a resolver by name along with all its test results, stamps, and stats.
490631// Returns an error if the resolver is not found.
491632func (d * DB ) RemoveResolver (name string ) error {
492633 var id int64
@@ -498,10 +639,13 @@ func (d *DB) RemoveResolver(name string) error {
498639 return err
499640 }
500641
501- // Delete in order: test_results, resolver_stats, resolvers
642+ // Delete in order: test_results, stamps, resolver_stats, resolvers
502643 if _ , err := d .db .Exec ("DELETE FROM test_results WHERE resolver_id = ?" , id ); err != nil {
503644 return fmt .Errorf ("failed to delete test results: %w" , err )
504645 }
646+ if _ , err := d .db .Exec ("DELETE FROM stamps WHERE resolver_id = ?" , id ); err != nil {
647+ return fmt .Errorf ("failed to delete stamps: %w" , err )
648+ }
505649 if _ , err := d .db .Exec ("DELETE FROM resolver_stats WHERE resolver_id = ?" , id ); err != nil {
506650 return fmt .Errorf ("failed to delete stats: %w" , err )
507651 }
@@ -547,6 +691,9 @@ func (d *DB) RemoveUnreliableResolvers(minReliability float64) ([]string, error)
547691 if _ , err := d .db .Exec ("DELETE FROM test_results WHERE resolver_id = ?" , id ); err != nil {
548692 return names , err
549693 }
694+ if _ , err := d .db .Exec ("DELETE FROM stamps WHERE resolver_id = ?" , id ); err != nil {
695+ return names , err
696+ }
550697 if _ , err := d .db .Exec ("DELETE FROM resolver_stats WHERE resolver_id = ?" , id ); err != nil {
551698 return names , err
552699 }
0 commit comments