Skip to main content

webspec_index/db/
write.rs

1// Write operations on the database
2use crate::model::{ParsedIdlDefinition, ParsedReference, ParsedSection};
3use anyhow::Result;
4use rusqlite::{Connection, OptionalExtension};
5
6/// Insert or get a spec, returning its ID
7/// Uses INSERT OR IGNORE to avoid duplicates
8pub fn insert_or_get_spec(
9    conn: &Connection,
10    name: &str,
11    base_url: &str,
12    provider: &str,
13) -> Result<i64> {
14    // Try to insert (will be ignored if already exists)
15    conn.execute(
16        "INSERT OR IGNORE INTO specs (name, base_url, provider) VALUES (?1, ?2, ?3)",
17        (name, base_url, provider),
18    )?;
19
20    // Get the ID (whether we just inserted it or it already existed)
21    let id: i64 = conn.query_row("SELECT id FROM specs WHERE name = ?1", [name], |row| {
22        row.get(0)
23    })?;
24
25    Ok(id)
26}
27
28/// Upsert spec metadata from the authoritative spec list.
29///
30/// Updates base_url and provider if they changed, and clears any stale indexed
31/// data so the spec gets re-fetched from the correct URL.
32pub fn seed_spec(conn: &Connection, name: &str, base_url: &str, provider: &str) -> Result<()> {
33    let existing: Option<(i64, String)> = conn
34        .query_row(
35            "SELECT id, base_url FROM specs WHERE name = ?1",
36            [name],
37            |row| Ok((row.get(0)?, row.get(1)?)),
38        )
39        .optional()?;
40
41    match existing {
42        None => {
43            conn.execute(
44                "INSERT INTO specs (name, base_url, provider) VALUES (?1, ?2, ?3)",
45                (name, base_url, provider),
46            )?;
47        }
48        Some((id, old_url)) if old_url != base_url => {
49            conn.execute(
50                "UPDATE specs SET base_url = ?1, provider = ?2 WHERE id = ?3",
51                (base_url, provider, id),
52            )?;
53            delete_spec_data(conn, id)?;
54        }
55        _ => {}
56    }
57    Ok(())
58}
59
60/// Insert a snapshot, returning its ID
61pub fn insert_snapshot(
62    conn: &Connection,
63    spec_id: i64,
64    sha: &str,
65    commit_date: &str,
66) -> Result<i64> {
67    // Get current timestamp for indexed_at
68    let indexed_at = chrono::Utc::now().to_rfc3339();
69
70    // Insert the snapshot
71    conn.execute(
72        "INSERT INTO snapshots (spec_id, sha, commit_date, indexed_at)
73         VALUES (?1, ?2, ?3, ?4)",
74        (spec_id, sha, commit_date, &indexed_at),
75    )?;
76
77    // Get the ID
78    let id = conn.last_insert_rowid();
79
80    Ok(id)
81}
82
83/// Bulk insert sections for a snapshot
84pub fn insert_sections_bulk(
85    conn: &Connection,
86    snapshot_id: i64,
87    sections: &[ParsedSection],
88) -> Result<()> {
89    let tx = conn.unchecked_transaction()?;
90
91    {
92        let mut stmt = tx.prepare(
93            "INSERT OR IGNORE INTO sections
94             (snapshot_id, anchor, title, content_text, section_type, parent_anchor, prev_anchor, next_anchor, depth)
95             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
96        )?;
97
98        for section in sections {
99            stmt.execute((
100                snapshot_id,
101                &section.anchor,
102                &section.title,
103                &section.content_text,
104                section.section_type.as_str(),
105                &section.parent_anchor,
106                &section.prev_anchor,
107                &section.next_anchor,
108                section.depth,
109            ))?;
110        }
111    }
112
113    tx.commit()?;
114    Ok(())
115}
116
117/// Bulk insert references for a snapshot
118pub fn insert_refs_bulk(
119    conn: &Connection,
120    snapshot_id: i64,
121    refs: &[ParsedReference],
122) -> Result<()> {
123    let tx = conn.unchecked_transaction()?;
124
125    {
126        let mut stmt = tx.prepare(
127            "INSERT INTO refs (snapshot_id, from_anchor, to_spec, to_anchor)
128             VALUES (?1, ?2, ?3, ?4)",
129        )?;
130
131        for reference in refs {
132            stmt.execute((
133                snapshot_id,
134                &reference.from_anchor,
135                &reference.to_spec,
136                &reference.to_anchor,
137            ))?;
138        }
139    }
140
141    tx.commit()?;
142    Ok(())
143}
144
145/// Bulk insert IDL definitions for a snapshot
146pub fn insert_idl_defs_bulk(
147    conn: &Connection,
148    snapshot_id: i64,
149    defs: &[ParsedIdlDefinition],
150) -> Result<()> {
151    let tx = conn.unchecked_transaction()?;
152
153    {
154        let mut stmt = tx.prepare(
155            "INSERT INTO idl_defs (snapshot_id, anchor, name, owner, kind, canonical_name, idl_text)
156             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
157        )?;
158
159        for def in defs {
160            stmt.execute((
161                snapshot_id,
162                &def.anchor,
163                &def.name,
164                &def.owner,
165                &def.kind,
166                &def.canonical_name,
167                &def.idl_text,
168            ))?;
169        }
170    }
171
172    tx.commit()?;
173    Ok(())
174}
175
176/// Insert a PR snapshot, returning its ID.
177/// Sets pr_number, merge_base_sha, and pr_pages in addition to the standard snapshot fields.
178pub fn insert_pr_snapshot(
179    conn: &Connection,
180    spec_id: i64,
181    sha: &str,
182    commit_date: &str,
183    pr_number: i64,
184    merge_base_sha: &str,
185    pr_pages: &[String],
186) -> Result<i64> {
187    let indexed_at = chrono::Utc::now().to_rfc3339();
188    let pages_str = pr_pages.join(",");
189    conn.execute(
190        "INSERT OR REPLACE INTO snapshots (spec_id, sha, commit_date, indexed_at, pr_number, merge_base_sha, pr_pages)
191         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
192        (spec_id, sha, commit_date, &indexed_at, pr_number, merge_base_sha, &pages_str),
193    )?;
194    Ok(conn.last_insert_rowid())
195}
196
197/// Delete all indexed data for a specific PR number.
198pub fn delete_pr_data(conn: &Connection, spec_id: i64, pr_number: i64) -> Result<()> {
199    let tx = conn.unchecked_transaction()?;
200    tx.execute(
201        "DELETE FROM refs WHERE snapshot_id IN \
202         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2)",
203        (spec_id, pr_number),
204    )?;
205    tx.execute(
206        "DELETE FROM idl_defs WHERE snapshot_id IN \
207         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2)",
208        (spec_id, pr_number),
209    )?;
210    tx.execute(
211        "DELETE FROM sections WHERE snapshot_id IN \
212         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2)",
213        (spec_id, pr_number),
214    )?;
215    tx.execute(
216        "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number = ?2",
217        (spec_id, pr_number),
218    )?;
219    tx.commit()?;
220    Ok(())
221}
222
223/// Delete all PR data for a spec (all PR snapshots + orphaned commit snapshots).
224/// Returns the number of PR snapshots deleted.
225pub fn delete_all_pr_data_for_spec(conn: &Connection, spec_id: i64) -> Result<usize> {
226    let tx = conn.unchecked_transaction()?;
227    let pr_count: i64 = tx.query_row(
228        "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL",
229        [spec_id],
230        |row| row.get(0),
231    )?;
232    tx.execute(
233        "DELETE FROM refs WHERE snapshot_id IN \
234         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
235        [spec_id],
236    )?;
237    tx.execute(
238        "DELETE FROM idl_defs WHERE snapshot_id IN \
239         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
240        [spec_id],
241    )?;
242    tx.execute(
243        "DELETE FROM sections WHERE snapshot_id IN \
244         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
245        [spec_id],
246    )?;
247    tx.execute(
248        "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL",
249        [spec_id],
250    )?;
251    // Delete orphaned commit snapshots (merge bases no longer referenced)
252    tx.execute(
253        "DELETE FROM refs WHERE snapshot_id IN \
254         (SELECT s.id FROM snapshots s WHERE s.spec_id = ?1 AND s.pr_number IS NULL \
255          AND s.sha NOT LIKE 'hash:%' \
256          AND s.sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL))",
257        [spec_id],
258    )?;
259    tx.execute(
260        "DELETE FROM idl_defs WHERE snapshot_id IN \
261         (SELECT s.id FROM snapshots s WHERE s.spec_id = ?1 AND s.pr_number IS NULL \
262          AND s.sha NOT LIKE 'hash:%' \
263          AND s.sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL))",
264        [spec_id],
265    )?;
266    tx.execute(
267        "DELETE FROM sections WHERE snapshot_id IN \
268         (SELECT s.id FROM snapshots s WHERE s.spec_id = ?1 AND s.pr_number IS NULL \
269          AND s.sha NOT LIKE 'hash:%' \
270          AND s.sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL))",
271        [spec_id],
272    )?;
273    tx.execute(
274        "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL \
275         AND sha NOT LIKE 'hash:%' \
276         AND sha NOT IN (SELECT merge_base_sha FROM snapshots WHERE spec_id = ?1 AND pr_number IS NOT NULL)",
277        [spec_id],
278    )?;
279    tx.commit()?;
280    Ok(pr_count as usize)
281}
282
283/// Delete trunk indexed data for a spec (snapshot, sections, refs).
284/// Only deletes snapshots with `sha LIKE 'hash:%'` and `pr_number IS NULL`,
285/// preserving PR snapshots and commit snapshots (merge bases).
286/// Used before re-indexing to avoid clobbering PR data.
287pub fn delete_spec_data(conn: &Connection, spec_id: i64) -> Result<()> {
288    let tx = conn.unchecked_transaction()?;
289
290    tx.execute(
291        "DELETE FROM refs WHERE snapshot_id IN \
292         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%')",
293        [spec_id],
294    )?;
295    tx.execute(
296        "DELETE FROM idl_defs WHERE snapshot_id IN \
297         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%')",
298        [spec_id],
299    )?;
300    tx.execute(
301        "DELETE FROM sections WHERE snapshot_id IN \
302         (SELECT id FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%')",
303        [spec_id],
304    )?;
305    tx.execute(
306        "DELETE FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL AND sha LIKE 'hash:%'",
307        [spec_id],
308    )?;
309
310    tx.commit()?;
311    Ok(())
312}
313
314/// Record spec sync metadata for freshness/content-hash based updates.
315pub fn record_update_check(
316    conn: &Connection,
317    spec_id: i64,
318    last_checked: &str,
319    last_indexed: Option<&str>,
320    content_hash: Option<&str>,
321) -> Result<()> {
322    conn.execute(
323        "INSERT OR REPLACE INTO update_checks (spec_id, last_checked, last_indexed, content_hash)
324         VALUES (?1, ?2, ?3, ?4)",
325        (spec_id, last_checked, last_indexed, content_hash),
326    )?;
327
328    Ok(())
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::db;
335    use crate::model::SectionType;
336
337    #[test]
338    fn test_insert_or_get_spec() {
339        let conn = db::open_test_db().unwrap();
340
341        // First insert
342        let id1 =
343            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
344        assert!(id1 > 0);
345
346        // Second insert should return same ID
347        let id2 =
348            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
349        assert_eq!(id1, id2);
350
351        // Different spec should get different ID
352        let id3 =
353            insert_or_get_spec(&conn, "DOM", "https://dom.spec.whatwg.org", "whatwg").unwrap();
354        assert_ne!(id1, id3);
355    }
356
357    #[test]
358    fn test_insert_snapshot() {
359        let conn = db::open_test_db().unwrap();
360
361        let spec_id =
362            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
363
364        let snapshot_id =
365            insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
366
367        assert!(snapshot_id > 0);
368
369        // Verify it was inserted
370        let count: i64 = conn
371            .query_row(
372                "SELECT COUNT(*) FROM snapshots WHERE id = ?1",
373                [snapshot_id],
374                |row| row.get(0),
375            )
376            .unwrap();
377        assert_eq!(count, 1);
378    }
379
380    #[test]
381    fn test_insert_sections_bulk() {
382        let conn = db::open_test_db().unwrap();
383
384        let spec_id =
385            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
386        let snapshot_id =
387            insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
388
389        let sections = vec![
390            ParsedSection {
391                anchor: "intro".to_string(),
392                title: Some("Introduction".to_string()),
393                content_text: Some("This is the intro".to_string()),
394                section_type: SectionType::Heading,
395                parent_anchor: None,
396                prev_anchor: None,
397                next_anchor: None,
398                depth: Some(2),
399            },
400            ParsedSection {
401                anchor: "details".to_string(),
402                title: Some("Details".to_string()),
403                content_text: Some("More details here".to_string()),
404                section_type: SectionType::Heading,
405                parent_anchor: Some("intro".to_string()),
406                prev_anchor: None,
407                next_anchor: None,
408                depth: Some(3),
409            },
410        ];
411
412        insert_sections_bulk(&conn, snapshot_id, &sections).unwrap();
413
414        // Verify sections were inserted
415        let count: i64 = conn
416            .query_row(
417                "SELECT COUNT(*) FROM sections WHERE snapshot_id = ?1",
418                [snapshot_id],
419                |row| row.get(0),
420            )
421            .unwrap();
422        assert_eq!(count, 2);
423
424        // Verify FTS index was updated
425        let fts_count: i64 = conn
426            .query_row("SELECT COUNT(*) FROM sections_fts", [], |row| row.get(0))
427            .unwrap();
428        assert_eq!(fts_count, 2);
429    }
430
431    #[test]
432    fn test_insert_refs_bulk() {
433        let conn = db::open_test_db().unwrap();
434
435        let spec_id =
436            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
437        let snapshot_id =
438            insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
439
440        let refs = vec![
441            ParsedReference {
442                from_anchor: "intro".to_string(),
443                to_spec: "DOM".to_string(),
444                to_anchor: "concept-tree".to_string(),
445            },
446            ParsedReference {
447                from_anchor: "intro".to_string(),
448                to_spec: "HTML".to_string(),
449                to_anchor: "details".to_string(),
450            },
451        ];
452
453        insert_refs_bulk(&conn, snapshot_id, &refs).unwrap();
454
455        // Verify refs were inserted
456        let count: i64 = conn
457            .query_row(
458                "SELECT COUNT(*) FROM refs WHERE snapshot_id = ?1",
459                [snapshot_id],
460                |row| row.get(0),
461            )
462            .unwrap();
463        assert_eq!(count, 2);
464    }
465
466    #[test]
467    fn test_delete_spec_data() {
468        let conn = db::open_test_db().unwrap();
469
470        let spec_id =
471            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
472        // Use hash: prefix — delete_spec_data only removes trunk (hash:) snapshots
473        let snapshot_id =
474            insert_snapshot(&conn, spec_id, "hash:abc123", "2026-01-01T00:00:00Z").unwrap();
475
476        let sections = vec![crate::model::ParsedSection {
477            anchor: "intro".to_string(),
478            title: Some("Introduction".to_string()),
479            content_text: None,
480            section_type: SectionType::Heading,
481            parent_anchor: None,
482            prev_anchor: None,
483            next_anchor: None,
484            depth: Some(2),
485        }];
486        insert_sections_bulk(&conn, snapshot_id, &sections).unwrap();
487
488        // Verify data exists
489        let count: i64 = conn
490            .query_row("SELECT COUNT(*) FROM sections", [], |row| row.get(0))
491            .unwrap();
492        assert_eq!(count, 1);
493
494        // Delete spec data
495        delete_spec_data(&conn, spec_id).unwrap();
496
497        // Verify trunk snapshot is gone
498        let snap_count: i64 = conn
499            .query_row("SELECT COUNT(*) FROM snapshots", [], |row| row.get(0))
500            .unwrap();
501        assert_eq!(snap_count, 0);
502
503        let sec_count: i64 = conn
504            .query_row("SELECT COUNT(*) FROM sections", [], |row| row.get(0))
505            .unwrap();
506        assert_eq!(sec_count, 0);
507    }
508
509    #[test]
510    fn test_insert_pr_snapshot() {
511        let conn = db::open_test_db().unwrap();
512        let spec_id =
513            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
514
515        let snapshot_id = insert_pr_snapshot(
516            &conn,
517            spec_id,
518            "hash:abc123",
519            "2026-01-01T00:00:00Z",
520            12345,
521            "def456full",
522            &[],
523        )
524        .unwrap();
525        assert!(snapshot_id > 0);
526
527        let (pr, base): (Option<i64>, Option<String>) = conn
528            .query_row(
529                "SELECT pr_number, merge_base_sha FROM snapshots WHERE id = ?1",
530                [snapshot_id],
531                |row| Ok((row.get(0)?, row.get(1)?)),
532            )
533            .unwrap();
534        assert_eq!(pr, Some(12345));
535        assert_eq!(base.as_deref(), Some("def456full"));
536    }
537
538    #[test]
539    fn test_delete_pr_data() {
540        let conn = db::open_test_db().unwrap();
541        let spec_id =
542            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
543
544        // Insert trunk snapshot
545        let trunk_id = insert_snapshot(&conn, spec_id, "trunk1", "2026-01-01T00:00:00Z").unwrap();
546        insert_sections_bulk(
547            &conn,
548            trunk_id,
549            &[ParsedSection {
550                anchor: "intro".into(),
551                title: Some("Intro".into()),
552                content_text: None,
553                section_type: SectionType::Heading,
554                parent_anchor: None,
555                prev_anchor: None,
556                next_anchor: None,
557                depth: Some(2),
558            }],
559        )
560        .unwrap();
561
562        // Insert PR snapshot
563        let pr_id = insert_pr_snapshot(
564            &conn,
565            spec_id,
566            "pr1",
567            "2026-01-01T00:00:00Z",
568            123,
569            "base1",
570            &[],
571        )
572        .unwrap();
573        insert_sections_bulk(
574            &conn,
575            pr_id,
576            &[ParsedSection {
577                anchor: "new-section".into(),
578                title: Some("New".into()),
579                content_text: None,
580                section_type: SectionType::Heading,
581                parent_anchor: None,
582                prev_anchor: None,
583                next_anchor: None,
584                depth: Some(2),
585            }],
586        )
587        .unwrap();
588
589        // Delete only PR data
590        delete_pr_data(&conn, spec_id, 123).unwrap();
591
592        // Trunk still exists
593        let trunk_count: i64 = conn
594            .query_row(
595                "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1 AND pr_number IS NULL",
596                [spec_id],
597                |row| row.get(0),
598            )
599            .unwrap();
600        assert_eq!(trunk_count, 1);
601
602        // PR is gone
603        let pr_count: i64 = conn
604            .query_row(
605                "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1 AND pr_number = 123",
606                [spec_id],
607                |row| row.get(0),
608            )
609            .unwrap();
610        assert_eq!(pr_count, 0);
611    }
612
613    #[test]
614    fn test_delete_spec_data_preserves_pr_and_commit_snapshots() {
615        let conn = db::open_test_db().unwrap();
616        let spec_id =
617            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
618
619        // Insert trunk (hash: prefix), commit snapshot (real SHA), and PR snapshot
620        insert_snapshot(&conn, spec_id, "hash:abc123", "2026-01-01T00:00:00Z").unwrap();
621        insert_snapshot(&conn, spec_id, "74cbe0af38fee8a0", "2026-01-01T00:00:00Z").unwrap();
622        insert_pr_snapshot(
623            &conn,
624            spec_id,
625            "pr:123:def",
626            "2026-01-01T00:00:00Z",
627            123,
628            "74cbe0af38fee8a0",
629            &[],
630        )
631        .unwrap();
632
633        // delete_spec_data should only delete trunk (hash: prefix)
634        delete_spec_data(&conn, spec_id).unwrap();
635
636        let total: i64 = conn
637            .query_row(
638                "SELECT COUNT(*) FROM snapshots WHERE spec_id = ?1",
639                [spec_id],
640                |row| row.get(0),
641            )
642            .unwrap();
643        assert_eq!(total, 2); // PR snapshot + commit snapshot remain
644    }
645
646    #[test]
647    fn test_insert_idl_defs_bulk() {
648        let conn = db::open_test_db().unwrap();
649        let spec_id =
650            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
651        let snapshot_id =
652            insert_snapshot(&conn, spec_id, "abc123", "2026-01-01T00:00:00Z").unwrap();
653
654        let defs = vec![
655            ParsedIdlDefinition {
656                anchor: "dom-window".to_string(),
657                name: "Window".to_string(),
658                owner: None,
659                kind: "interface".to_string(),
660                canonical_name: "Window".to_string(),
661                idl_text: Some("interface Window {};".to_string()),
662            },
663            ParsedIdlDefinition {
664                anchor: "dom-window-navigation".to_string(),
665                name: "navigation".to_string(),
666                owner: Some("Window".to_string()),
667                kind: "attribute".to_string(),
668                canonical_name: "Window.navigation".to_string(),
669                idl_text: Some(
670                    "interface Window { attribute Navigation navigation; };".to_string(),
671                ),
672            },
673        ];
674
675        insert_idl_defs_bulk(&conn, snapshot_id, &defs).unwrap();
676
677        let count: i64 = conn
678            .query_row(
679                "SELECT COUNT(*) FROM idl_defs WHERE snapshot_id = ?1",
680                [snapshot_id],
681                |row| row.get(0),
682            )
683            .unwrap();
684        assert_eq!(count, 2);
685    }
686
687    #[test]
688    fn test_record_update_check() {
689        let conn = db::open_test_db().unwrap();
690
691        let spec_id =
692            insert_or_get_spec(&conn, "HTML", "https://html.spec.whatwg.org", "whatwg").unwrap();
693
694        record_update_check(
695            &conn,
696            spec_id,
697            "2026-01-01T00:00:00Z",
698            Some("2026-01-01T00:00:00Z"),
699            Some("deadbeef"),
700        )
701        .unwrap();
702
703        // Verify it was recorded
704        let count: i64 = conn
705            .query_row(
706                "SELECT COUNT(*) FROM update_checks WHERE spec_id = ?1",
707                [spec_id],
708                |row| row.get(0),
709            )
710            .unwrap();
711        assert_eq!(count, 1);
712
713        // Record again (should update, not insert new row)
714        record_update_check(
715            &conn,
716            spec_id,
717            "2026-01-02T00:00:00Z",
718            None,
719            Some("beadfeed"),
720        )
721        .unwrap();
722
723        let count: i64 = conn
724            .query_row(
725                "SELECT COUNT(*) FROM update_checks WHERE spec_id = ?1",
726                [spec_id],
727                |row| row.get(0),
728            )
729            .unwrap();
730        assert_eq!(count, 1);
731
732        let (checked, indexed, hash): (String, Option<String>, Option<String>) = conn
733            .query_row(
734                "SELECT last_checked, last_indexed, content_hash FROM update_checks WHERE spec_id = ?1",
735                [spec_id],
736                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
737            )
738            .unwrap();
739        assert_eq!(checked, "2026-01-02T00:00:00Z");
740        assert_eq!(indexed, None);
741        assert_eq!(hash.as_deref(), Some("beadfeed"));
742    }
743}