Skip to content

Commit e1ef8dd

Browse files
Add SQLite store and tests
Introduce an internal SQLite-backed store for persisting processed HL7/FHIR RoutedPayloads. Adds internal/store/sqlite with schema, Record/Store types and methods: Open, Save, Get, List, UpdateStatus, Close, and helpers for scanning and time injection. Includes comprehensive unit tests covering save/get, quality metrics, duplicate handling, status updates, timestamps, ordering and listing. Update go.mod (Go 1.25.0) and add go.sum for new indirect dependencies required by the SQLite driver.
1 parent 1af1f1b commit e1ef8dd

4 files changed

Lines changed: 527 additions & 1 deletion

File tree

go.mod

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
11
module github.com/vagnercazarotto/verifhir-gateway
22

3-
go 1.22
3+
go 1.25.0
4+
5+
require (
6+
github.com/dustin/go-humanize v1.0.1 // indirect
7+
github.com/google/uuid v1.6.0 // indirect
8+
github.com/mattn/go-isatty v0.0.20 // indirect
9+
github.com/ncruces/go-strftime v1.0.0 // indirect
10+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
11+
golang.org/x/sys v0.42.0 // indirect
12+
modernc.org/libc v1.72.0 // indirect
13+
modernc.org/mathutil v1.7.1 // indirect
14+
modernc.org/memory v1.11.0 // indirect
15+
modernc.org/sqlite v1.50.0 // indirect
16+
)

go.sum

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2+
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3+
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4+
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
5+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
6+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
7+
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
8+
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
9+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
10+
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
11+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
12+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
13+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
14+
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
15+
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
16+
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
17+
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
18+
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
19+
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
20+
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
21+
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

internal/store/sqlite/store.go

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
// Package sqlite persists processed HL7/FHIR payloads and their delivery
2+
// outcomes in a local SQLite database.
3+
//
4+
// Each processed message is stored as a row in the messages table. The row
5+
// tracks quality metrics, current delivery status, attempt count, and the
6+
// full JSON-encoded RoutedPayload for audit and replay purposes.
7+
package sqlite
8+
9+
import (
10+
"context"
11+
"database/sql"
12+
"encoding/json"
13+
"fmt"
14+
"time"
15+
16+
_ "modernc.org/sqlite" // registers the "sqlite" driver
17+
18+
"github.com/vagnercazarotto/verifhir-gateway/internal/model"
19+
)
20+
21+
// Delivery status constants.
22+
const (
23+
StatusPending = "pending"
24+
StatusSent = "sent"
25+
StatusFailed = "failed"
26+
StatusDeadLettered = "dead_lettered"
27+
)
28+
29+
const schema = `
30+
CREATE TABLE IF NOT EXISTS messages (
31+
id TEXT PRIMARY KEY,
32+
resource_type TEXT NOT NULL,
33+
quality_score REAL NOT NULL DEFAULT 0,
34+
completeness REAL NOT NULL DEFAULT 0,
35+
conformity REAL NOT NULL DEFAULT 0,
36+
confidence REAL NOT NULL DEFAULT 0,
37+
status TEXT NOT NULL DEFAULT 'pending',
38+
attempts INTEGER NOT NULL DEFAULT 0,
39+
last_error TEXT NOT NULL DEFAULT '',
40+
payload TEXT NOT NULL,
41+
created_at TEXT NOT NULL,
42+
updated_at TEXT NOT NULL
43+
);`
44+
45+
// Record is a row in the messages table.
46+
type Record struct {
47+
ID string
48+
ResourceType string
49+
QualityScore float64
50+
Completeness float64
51+
Conformity float64
52+
Confidence float64
53+
Status string
54+
Attempts int
55+
LastError string
56+
Payload model.RoutedPayload
57+
CreatedAt time.Time
58+
UpdatedAt time.Time
59+
}
60+
61+
// Store persists RoutedPayloads to a SQLite database.
62+
type Store struct {
63+
db *sql.DB
64+
now func() time.Time
65+
}
66+
67+
// Open opens (or creates) a SQLite database at dsn and applies the schema.
68+
// Use ":memory:" for in-process testing.
69+
func Open(dsn string) (*Store, error) {
70+
db, err := sql.Open("sqlite", dsn)
71+
if err != nil {
72+
return nil, fmt.Errorf("sqlite: open %q: %w", dsn, err)
73+
}
74+
if _, err := db.Exec(schema); err != nil {
75+
db.Close()
76+
return nil, fmt.Errorf("sqlite: migrate: %w", err)
77+
}
78+
return &Store{db: db, now: time.Now}, nil
79+
}
80+
81+
// SetNow replaces the time source. Used only in tests.
82+
func (s *Store) SetNow(fn func() time.Time) { s.now = fn }
83+
84+
// Close closes the underlying database connection.
85+
func (s *Store) Close() error { return s.db.Close() }
86+
87+
// Save inserts a new message record with status "pending".
88+
// If a record with the same ID already exists the call is a no-op (INSERT OR IGNORE).
89+
func (s *Store) Save(ctx context.Context, payload model.RoutedPayload) error {
90+
raw, err := json.Marshal(payload)
91+
if err != nil {
92+
return fmt.Errorf("sqlite: marshal payload: %w", err)
93+
}
94+
now := s.now().UTC().Format(time.RFC3339)
95+
_, err = s.db.ExecContext(ctx, `
96+
INSERT OR IGNORE INTO messages
97+
(id, resource_type, quality_score, completeness, conformity, confidence,
98+
status, attempts, last_error, payload, created_at, updated_at)
99+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
100+
payload.Resource.ID,
101+
payload.Resource.ResourceType,
102+
payload.Quality.Score,
103+
payload.Quality.Completeness,
104+
payload.Quality.Conformity,
105+
payload.Quality.Confidence,
106+
StatusPending,
107+
0,
108+
"",
109+
string(raw),
110+
now,
111+
now,
112+
)
113+
if err != nil {
114+
return fmt.Errorf("sqlite: save %q: %w", payload.Resource.ID, err)
115+
}
116+
return nil
117+
}
118+
119+
// UpdateStatus changes the delivery status, attempt count, and last error for
120+
// the message identified by id. It returns an error if the record is not found.
121+
func (s *Store) UpdateStatus(ctx context.Context, id, status string, attempts int, lastErr string) error {
122+
now := s.now().UTC().Format(time.RFC3339)
123+
res, err := s.db.ExecContext(ctx, `
124+
UPDATE messages
125+
SET status = ?, attempts = ?, last_error = ?, updated_at = ?
126+
WHERE id = ?`,
127+
status, attempts, lastErr, now, id,
128+
)
129+
if err != nil {
130+
return fmt.Errorf("sqlite: update status %q: %w", id, err)
131+
}
132+
n, _ := res.RowsAffected()
133+
if n == 0 {
134+
return fmt.Errorf("sqlite: message %q not found", id)
135+
}
136+
return nil
137+
}
138+
139+
// Get retrieves a single record by ID. Returns an error wrapping sql.ErrNoRows
140+
// if not found.
141+
func (s *Store) Get(ctx context.Context, id string) (*Record, error) {
142+
row := s.db.QueryRowContext(ctx, `
143+
SELECT id, resource_type, quality_score, completeness, conformity, confidence,
144+
status, attempts, last_error, payload, created_at, updated_at
145+
FROM messages WHERE id = ?`, id)
146+
return scanRecord(row)
147+
}
148+
149+
// List returns up to limit records, filtered by status when status is non-empty
150+
// (pass "" to list all). Records are ordered by created_at ascending.
151+
func (s *Store) List(ctx context.Context, status string, limit int) ([]Record, error) {
152+
var (
153+
rows *sql.Rows
154+
err error
155+
)
156+
if status == "" {
157+
rows, err = s.db.QueryContext(ctx, `
158+
SELECT id, resource_type, quality_score, completeness, conformity, confidence,
159+
status, attempts, last_error, payload, created_at, updated_at
160+
FROM messages ORDER BY created_at ASC LIMIT ?`, limit)
161+
} else {
162+
rows, err = s.db.QueryContext(ctx, `
163+
SELECT id, resource_type, quality_score, completeness, conformity, confidence,
164+
status, attempts, last_error, payload, created_at, updated_at
165+
FROM messages WHERE status = ? ORDER BY created_at ASC LIMIT ?`, status, limit)
166+
}
167+
if err != nil {
168+
return nil, fmt.Errorf("sqlite: list: %w", err)
169+
}
170+
defer rows.Close()
171+
172+
var out []Record
173+
for rows.Next() {
174+
rec, err := scanRecord(rows)
175+
if err != nil {
176+
return nil, err
177+
}
178+
out = append(out, *rec)
179+
}
180+
return out, rows.Err()
181+
}
182+
183+
// scanner abstracts *sql.Row and *sql.Rows for scanRecord.
184+
type scanner interface {
185+
Scan(dest ...any) error
186+
}
187+
188+
func scanRecord(s scanner) (*Record, error) {
189+
var (
190+
rec Record
191+
rawPayload string
192+
createdAt string
193+
updatedAt string
194+
)
195+
err := s.Scan(
196+
&rec.ID, &rec.ResourceType, &rec.QualityScore,
197+
&rec.Completeness, &rec.Conformity, &rec.Confidence,
198+
&rec.Status, &rec.Attempts, &rec.LastError,
199+
&rawPayload, &createdAt, &updatedAt,
200+
)
201+
if err == sql.ErrNoRows {
202+
return nil, fmt.Errorf("sqlite: record not found: %w", sql.ErrNoRows)
203+
}
204+
if err != nil {
205+
return nil, fmt.Errorf("sqlite: scan: %w", err)
206+
}
207+
if err := json.Unmarshal([]byte(rawPayload), &rec.Payload); err != nil {
208+
return nil, fmt.Errorf("sqlite: unmarshal payload: %w", err)
209+
}
210+
rec.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
211+
rec.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
212+
return &rec, nil
213+
}

0 commit comments

Comments
 (0)