// Package registrystore persists the canonical registry as a single, // self-contained SQLite file. It is intentionally boring: one file, no server, // built once by the seed pipeline and opened read-only by the service. package registrystore import ( "database/sql" "fmt" "os" _ "modernc.org/sqlite" // registers the "sqlite" driver "git.totmin.ru/en2zmax/addrkit/internal/domain/address" ) const schema = ` CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS address ( id TEXT PRIMARY KEY, address TEXT NOT NULL, district TEXT NOT NULL DEFAULT '', ordinal TEXT NOT NULL DEFAULT '', name_key TEXT NOT NULL DEFAULT '', name_display TEXT NOT NULL DEFAULT '', street_type TEXT NOT NULL DEFAULT '', street_key TEXT NOT NULL, house TEXT NOT NULL DEFAULT '', house_frac TEXT NOT NULL DEFAULT '', house_letter TEXT NOT NULL DEFAULT '', building TEXT NOT NULL DEFAULT '', building_frac TEXT NOT NULL DEFAULT '', building_letter TEXT NOT NULL DEFAULT '', structure TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_address_street ON address(street_key); ` // Save writes entries to path atomically: it builds a temporary file and // renames it into place, so readers never observe a half-written index. // // The build uses the rollback journal (not WAL) on purpose, so that the result // is a single file with no companion -wal/-shm that a rename would orphan. func Save(path, version string, entries []address.Entry) error { tmp := path + ".tmp" removeDBFiles(tmp) db, err := sql.Open("sqlite", "file:"+tmp+ "?_pragma=journal_mode(DELETE)"+ "&_pragma=synchronous(FULL)"+ "&_pragma=busy_timeout(5000)") if err != nil { return fmt.Errorf("open temp db: %w", err) } if _, err := db.Exec(schema); err != nil { db.Close() return fmt.Errorf("create schema: %w", err) } if err := insertAll(db, version, entries); err != nil { db.Close() return err } if err := db.Close(); err != nil { return fmt.Errorf("close temp db: %w", err) } if err := os.Rename(tmp, path); err != nil { return fmt.Errorf("publish db: %w", err) } // Drop only the temp file's journal siblings; the published base file must // survive. removeSidecars(tmp) return nil } func insertAll(db *sql.DB, version string, entries []address.Entry) error { tx, err := db.Begin() if err != nil { return fmt.Errorf("begin: %w", err) } defer tx.Rollback() if _, err := tx.Exec(`INSERT INTO meta(key, value) VALUES ('version', ?)`, version); err != nil { return fmt.Errorf("insert meta: %w", err) } stmt, err := tx.Prepare(`INSERT INTO address ( id, address, district, ordinal, name_key, name_display, street_type, street_key, house, house_frac, house_letter, building, building_frac, building_letter, structure ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) if err != nil { return fmt.Errorf("prepare insert: %w", err) } defer stmt.Close() for i, e := range entries { id := e.ID if id == "" { // Guarantee a primary key even for sources without ids. id = fmt.Sprintf("row-%d", i) } if _, err := stmt.Exec( id, e.Address, e.District, e.Ordinal, e.NameKey, e.NameDisplay, e.StreetType, e.StreetKey, e.House, e.HouseFrac, e.HouseLetter, e.Building, e.BuildingFrac, e.BuildingLetter, e.Structure, ); err != nil { return fmt.Errorf("insert %q: %w", e.Address, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("commit: %w", err) } return nil } // Load opens path read-only and returns the data version and all entries. func Load(path string) (version string, entries []address.Entry, err error) { if _, err := os.Stat(path); err != nil { return "", nil, fmt.Errorf("stat db: %w", err) } db, err := sql.Open("sqlite", "file:"+path+ "?mode=ro"+ "&_pragma=query_only(1)"+ "&_pragma=busy_timeout(5000)") if err != nil { return "", nil, fmt.Errorf("open db: %w", err) } defer db.Close() if err := db.QueryRow(`SELECT value FROM meta WHERE key='version'`).Scan(&version); err != nil { return "", nil, fmt.Errorf("read version: %w", err) } rows, err := db.Query(`SELECT id, address, district, ordinal, name_key, name_display, street_type, street_key, house, house_frac, house_letter, building, building_frac, building_letter, structure FROM address`) if err != nil { return "", nil, fmt.Errorf("query: %w", err) } defer rows.Close() for rows.Next() { var e address.Entry if err := rows.Scan( &e.ID, &e.Address, &e.District, &e.Ordinal, &e.NameKey, &e.NameDisplay, &e.StreetType, &e.StreetKey, &e.House, &e.HouseFrac, &e.HouseLetter, &e.Building, &e.BuildingFrac, &e.BuildingLetter, &e.Structure, ); err != nil { return "", nil, fmt.Errorf("scan: %w", err) } entries = append(entries, e) } if err := rows.Err(); err != nil { return "", nil, fmt.Errorf("rows: %w", err) } return version, entries, nil } // removeDBFiles deletes a database file and its journal siblings. func removeDBFiles(base string) { _ = os.Remove(base) removeSidecars(base) } // removeSidecars deletes only the -wal/-shm companions of a database file. func removeSidecars(base string) { _ = os.Remove(base + "-wal") _ = os.Remove(base + "-shm") }