addrkit: deterministic address resolution service (Go)
- domain: address normalization/parsing, matching with explicit statuses - adapters: SQLite registry store, ArcGIS outages client, optional LLM rewriter, HTTP API - seed pipeline with duplicate handling and unparsed reporting - CLI (seed/verify/resolve), systemd unit, OpenAPI contract, tests
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package registrystore
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.totmin.ru/en2zmax/addrkit/internal/domain/address"
|
||||
)
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "registry.db")
|
||||
|
||||
addr := "Станционная улица 1/1"
|
||||
p := address.Parse(addr)
|
||||
entries := []address.Entry{{
|
||||
ID: "a1", Address: addr, District: "Ленинский",
|
||||
Ordinal: p.Ordinal, NameKey: p.NameKey, StreetType: p.StreetType, StreetKey: p.StreetKey,
|
||||
House: p.House, HouseFrac: p.HouseFrac,
|
||||
}}
|
||||
|
||||
if err := Save(path, "2026-10-09", entries); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
|
||||
version, got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if version != "2026-10-09" {
|
||||
t.Errorf("version = %q", version)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("entries = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].Address != addr || got[0].District != "Ленинский" || got[0].StreetKey != p.StreetKey {
|
||||
t.Errorf("round-tripped entry mismatch: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveIsAtomicOverwrite verifies that re-seeding replaces the previous data
|
||||
// rather than merging with it.
|
||||
func TestSaveIsAtomicOverwrite(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "registry.db")
|
||||
|
||||
if err := Save(path, "v1", []address.Entry{{ID: "x", Address: "Первая улица 1"}}); err != nil {
|
||||
t.Fatalf("Save v1: %v", err)
|
||||
}
|
||||
if err := Save(path, "v2", []address.Entry{{ID: "y", Address: "Вторая улица 2"}}); err != nil {
|
||||
t.Fatalf("Save v2: %v", err)
|
||||
}
|
||||
|
||||
version, got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if version != "v2" || len(got) != 1 || got[0].Address != "Вторая улица 2" {
|
||||
t.Fatalf("unexpected state after overwrite: version=%q entries=%+v", version, got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user