feat: sqlite storage draft

This commit is contained in:
2026-02-03 09:15:29 +01:00
parent d6617cec02
commit 3f5cf0d673
7 changed files with 501 additions and 21 deletions

View File

@ -5,6 +5,8 @@ import (
"flag" "flag"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"donniemarko/internal/render" "donniemarko/internal/render"
"donniemarko/internal/scanner" "donniemarko/internal/scanner"
@ -18,6 +20,7 @@ func main() {
var help bool var help bool
rootFolder := flag.String("root", ".", "Root folder to serve files from") rootFolder := flag.String("root", ".", "Root folder to serve files from")
listenAddr := flag.String("addr", "localhost:5555", "Address to listen on") listenAddr := flag.String("addr", "localhost:5555", "Address to listen on")
dbPath := flag.String("db", "", "SQLite database path (empty uses ~/.local/share/donniemarko/notes.db)")
flag.BoolVar(&help, "help", false, "display this program usage") flag.BoolVar(&help, "help", false, "display this program usage")
flag.Parse() flag.Parse()
@ -27,7 +30,32 @@ func main() {
} }
// Initialize storage // Initialize storage
noteStorage := storage.NewNoteStorage() var noteStorage storage.Storage
var sqliteStorage *storage.SQLiteStorage
resolvedDBPath := *dbPath
if resolvedDBPath == "" {
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatalf("failed to resolve home directory: %v", err)
}
resolvedDBPath = filepath.Join(homeDir, ".local", "share", "donniemarko", "notes.db")
}
if err := os.MkdirAll(filepath.Dir(resolvedDBPath), 0o700); err != nil {
log.Fatalf("failed to create database directory: %v", err)
}
var err error
sqliteStorage, err = storage.NewSQLiteStorage(resolvedDBPath)
if err != nil {
log.Fatalf("failed to open sqlite db: %v", err)
}
defer func() {
if err := sqliteStorage.Close(); err != nil {
log.Printf("failed to close sqlite db: %v", err)
}
}()
noteStorage = sqliteStorage
// Initialize scanner // Initialize scanner
monitor := scanner.NewScanner(*rootFolder) monitor := scanner.NewScanner(*rootFolder)

15
go.mod
View File

@ -6,5 +6,18 @@ toolchain go1.24.12
require ( require (
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
golang.org/x/net v0.49.0 modernc.org/sqlite v1.44.3
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.40.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

55
go.sum
View File

@ -1,4 +1,55 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -5,8 +5,8 @@ import (
"donniemarko/internal/note" "donniemarko/internal/note"
"donniemarko/internal/storage" "donniemarko/internal/storage"
"os"
"log" "log"
"os"
) )
type NotesHandler struct { type NotesHandler struct {
@ -55,6 +55,7 @@ func ParseNoteFile(path string) (*note.Note, error) {
id := note.GenerateNoteID(path) id := note.GenerateNoteID(path)
nn := note.NewNote() nn := note.NewNote()
nn.ID = id nn.ID = id
nn.Path = path
nn.Content = string(content) nn.Content = string(content)
nn.Title = note.ExtractTitle(nn.Content) nn.Title = note.ExtractTitle(nn.Content)
// Use filename as title if no heading found // Use filename as title if no heading found
@ -62,6 +63,6 @@ func ParseNoteFile(path string) (*note.Note, error) {
nn.Title = filepath.Base(path) nn.Title = filepath.Base(path)
} }
nn.UpdatedAt = fileInfo.ModTime() nn.UpdatedAt = fileInfo.ModTime()
nn.CreatedAt = fileInfo.ModTime() nn.Size = fileInfo.Size()
return nn, nil return nn, nil
} }

View File

@ -98,6 +98,31 @@ func (s *ScannerService) Monitor(ctx context.Context) error {
ticker := time.NewTicker(s.Interval) ticker := time.NewTicker(s.Interval)
defer ticker.Stop() defer ticker.Stop()
applyChanges := func(changes []Change) {
for _, change := range changes {
var err error
switch change.Type {
case Created:
err = s.handler.HandleCreate(change.Path)
case Modified:
err = s.handler.HandleModify(change.Path)
case Deleted:
err = s.handler.HandleDelete(change.Path)
}
if err != nil {
log.Printf("handler error for %s: %v", change.Path, err)
}
}
}
changes, err := s.Scan()
if err != nil {
log.Printf("scan error: %v", err)
} else {
applyChanges(changes)
}
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
@ -107,21 +132,7 @@ func (s *ScannerService) Monitor(ctx context.Context) error {
continue continue
} }
for _, change := range changes { applyChanges(changes)
var err error
switch change.Type {
case Created:
err = s.handler.HandleCreate(change.Path)
case Modified:
err = s.handler.HandleModify(change.Path)
case Deleted:
err = s.handler.HandleDelete(change.Path)
}
if err != nil {
log.Printf("handler error for %s: %v", change.Path, err)
}
}
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

217
internal/storage/sqlite.go Normal file
View File

@ -0,0 +1,217 @@
package storage
import (
"database/sql"
"fmt"
"time"
"donniemarko/internal/note"
_ "modernc.org/sqlite"
)
type SQLiteStorage struct {
db *sql.DB
}
const sqliteSchema = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
content TEXT NOT NULL,
updated_at INTEGER NOT NULL,
size INTEGER NOT NULL,
published INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS notes_path_idx ON notes(path);
`
func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := db.Exec(sqliteSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("init schema: %w", err)
}
return &SQLiteStorage{db: db}, nil
}
func (s *SQLiteStorage) Close() error {
if s.db == nil {
return nil
}
return s.db.Close()
}
func (s *SQLiteStorage) GetAll() []*note.Note {
rows, err := s.db.Query(`
SELECT id, path, title, content, updated_at, size, published
FROM notes
`)
if err != nil {
return []*note.Note{}
}
defer rows.Close()
var notes []*note.Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
continue
}
notes = append(notes, n)
}
return notes
}
func (s *SQLiteStorage) Get(id string) (*note.Note, error) {
row := s.db.QueryRow(`
SELECT id, path, title, content, updated_at, size, published
FROM notes
WHERE id = ?
`, id)
n, err := scanNote(row)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("No note with id '%s'", id)
}
return nil, err
}
return n, nil
}
func (s *SQLiteStorage) Create(n *note.Note) error {
_, err := s.db.Exec(`
INSERT INTO notes (id, path, title, content, updated_at, size, published)
VALUES (?, ?, ?, ?, ?, ?, ?)
`,
n.ID,
n.Path,
n.Title,
n.Content,
toUnix(n.UpdatedAt),
n.Size,
boolToInt(n.Published),
)
if err != nil {
return fmt.Errorf("insert note: %w", err)
}
return nil
}
func (s *SQLiteStorage) Delete(id string) {
_, _ = s.db.Exec(`DELETE FROM notes WHERE id = ?`, id)
}
func (s *SQLiteStorage) Update(id string, n *note.Note) {
_, _ = s.db.Exec(`
UPDATE notes
SET path = ?, title = ?, content = ?, updated_at = ?, size = ?, published = ?
WHERE id = ?
`,
n.Path,
n.Title,
n.Content,
toUnix(n.UpdatedAt),
n.Size,
boolToInt(n.Published),
id,
)
}
func (s *SQLiteStorage) Search(query string) []*note.Note {
pattern := "%" + query + "%"
rows, err := s.db.Query(`
SELECT id, path, title, content, updated_at, size, published
FROM notes
WHERE lower(content) LIKE lower(?)
`, pattern)
if err != nil {
return []*note.Note{}
}
defer rows.Close()
var notes []*note.Note
for rows.Next() {
n, err := scanNote(rows)
if err != nil {
continue
}
notes = append(notes, n)
}
return notes
}
func (s *SQLiteStorage) Count() int {
row := s.db.QueryRow(`SELECT COUNT(*) FROM notes`)
var count int
if err := row.Scan(&count); err != nil {
return 0
}
return count
}
type scanner interface {
Scan(dest ...interface{}) error
}
func scanNote(row scanner) (*note.Note, error) {
var (
id string
path string
title string
content string
updated int64
size int64
published int
)
if err := row.Scan(&id, &path, &title, &content, &updated, &size, &published); err != nil {
return nil, err
}
return &note.Note{
ID: id,
Path: path,
Title: title,
Content: content,
UpdatedAt: fromUnix(updated),
Size: size,
Published: published != 0,
}, nil
}
func toUnix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.UTC().Unix()
}
func fromUnix(v int64) time.Time {
if v == 0 {
return time.Time{}
}
return time.Unix(v, 0).UTC()
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}

View File

@ -0,0 +1,159 @@
package storage
import (
"path/filepath"
"testing"
"time"
"donniemarko/internal/note"
)
func newSQLiteStorage(t *testing.T) *SQLiteStorage {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "notes.db")
st, err := NewSQLiteStorage(dbPath)
if err != nil {
t.Fatalf("new sqlite storage: %v", err)
}
t.Cleanup(func() {
_ = st.Close()
})
return st
}
func sampleNote(id, path, title, content string, tstamp time.Time) *note.Note {
return &note.Note{
ID: id,
Path: path,
Title: title,
Content: content,
UpdatedAt: tstamp.Add(2 * time.Hour),
Size: int64(len(content)),
Published: true,
}
}
func TestSQLiteStorage_CreateGet(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
n := sampleNote("n1", "notes/alpha.md", "Alpha", "# Alpha", ts)
if err := st.Create(n); err != nil {
t.Fatalf("create note: %v", err)
}
got, err := st.Get("n1")
if err != nil {
t.Fatalf("get note: %v", err)
}
if got.Title != n.Title || got.Path != n.Path || got.Content != n.Content {
t.Fatalf("unexpected note fields: %+v", got)
}
if !got.UpdatedAt.Equal(n.UpdatedAt) || got.Size != n.Size {
t.Fatalf("unexpected time fields: %+v", got)
}
if !got.Published {
t.Fatalf("expected published to be true")
}
}
func TestSQLiteStorage_GetAll(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
all := st.GetAll()
if len(all) != 2 {
t.Fatalf("expected 2 notes, got %d", len(all))
}
}
func TestSQLiteStorage_Update(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
n := sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)
if err := st.Create(n); err != nil {
t.Fatalf("create note: %v", err)
}
updated := sampleNote("n1", "notes/alpha.md", "Alpha Updated", "two", ts.Add(24*time.Hour))
st.Update("n1", updated)
got, err := st.Get("n1")
if err != nil {
t.Fatalf("get note: %v", err)
}
if got.Title != "Alpha Updated" || got.Content != "two" {
t.Fatalf("update did not persist: %+v", got)
}
}
func TestSQLiteStorage_Delete(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
st.Delete("n1")
if st.Count() != 0 {
t.Fatalf("expected count 0 after delete")
}
if _, err := st.Get("n1"); err == nil {
t.Fatalf("expected error for missing note")
}
}
func TestSQLiteStorage_Search(t *testing.T) {
st := newSQLiteStorage(t)
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "Rust tips", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "Golang tutorial", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
results := st.Search("rust")
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].ID != "n1" {
t.Fatalf("expected rust match to be n1")
}
}
func TestSQLiteStorage_Count(t *testing.T) {
st := newSQLiteStorage(t)
if st.Count() != 0 {
t.Fatalf("expected empty count to be 0")
}
ts := time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
if err := st.Create(sampleNote("n1", "notes/alpha.md", "Alpha", "one", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if err := st.Create(sampleNote("n2", "notes/beta.md", "Beta", "two", ts)); err != nil {
t.Fatalf("create note: %v", err)
}
if st.Count() != 2 {
t.Fatalf("expected count 2")
}
}