From aaab744b152e256cc2cb50bcfd511a679e857015 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Sun, 21 Jun 2026 23:37:11 +0100 Subject: [PATCH] fix(store): pin connection pool so foreign_keys pragma sticks SQLite PRAGMAs are connection-scoped, but database/sql uses a connection pool. Without pinning to one connection, new pooled connections won't have foreign_keys enabled, breaking ON DELETE CASCADE enforcement. Also mark modernc.org/sqlite as a direct dependency in go.mod. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 3 ++- go.sum | 30 ++++++++++++++++++++++++ internal/store/store.go | 4 ++++ internal/store/store_test.go | 45 ++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 369acf1..4b6d471 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module git.dcglab.co.uk/steve/emcli go 1.25.0 +require modernc.org/sqlite v1.53.0 + require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect @@ -12,5 +14,4 @@ require ( modernc.org/libc v1.73.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.53.0 // indirect ) diff --git a/go.sum b/go.sum index 0569467..b054032 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,51 @@ 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= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +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.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/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.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= 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.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/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.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +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= diff --git a/internal/store/store.go b/internal/store/store.go index 4380b49..2e5092b 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -27,6 +27,10 @@ func Open(path string, key []byte) (*Store, error) { if err != nil { return nil, err } + // Pin pool to a single connection so PRAGMA foreign_keys = ON sticks. + // SQLite PRAGMAs are connection-scoped; pool would otherwise create + // new connections without the pragma set. + db.SetMaxOpenConns(1) if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil { db.Close() return nil, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index eae0b48..4a4dc45 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -63,3 +63,48 @@ func TestSettingsRoundTrip(t *testing.T) { t.Fatalf("upsert failed: %q", got) } } + +func TestForeignKeyCascade(t *testing.T) { + s := openTemp(t) + + // Insert an account directly via raw SQL. + _, err := s.db.Exec(` + INSERT INTO accounts(name, mode, imap_host, imap_port, imap_security, auth_type, username) + VALUES('test_account', 'RO', 'imap.example.com', 993, 'tls', 'password', 'user@example.com') + `) + if err != nil { + t.Fatalf("insert account: %v", err) + } + + // Get the inserted account ID. + var accountID int64 + err = s.db.QueryRow("SELECT id FROM accounts WHERE name = 'test_account'").Scan(&accountID) + if err != nil { + t.Fatalf("query account id: %v", err) + } + + // Insert a whitelist_in row referencing the account. + _, err = s.db.Exec("INSERT INTO whitelist_in(account_id, address) VALUES(?, 'test@example.com')", accountID) + if err != nil { + t.Fatalf("insert whitelist_in: %v", err) + } + + // Verify the whitelist_in row exists. + var count int + err = s.db.QueryRow("SELECT COUNT(*) FROM whitelist_in WHERE account_id = ?", accountID).Scan(&count) + if err != nil || count != 1 { + t.Fatalf("whitelist_in row not found: count=%d err=%v", count, err) + } + + // Delete the account (should cascade and delete whitelist_in row). + _, err = s.db.Exec("DELETE FROM accounts WHERE name = 'test_account'") + if err != nil { + t.Fatalf("delete account: %v", err) + } + + // Verify the whitelist_in row was cascade-deleted. + err = s.db.QueryRow("SELECT COUNT(*) FROM whitelist_in WHERE account_id = ?", accountID).Scan(&count) + if err != nil || count != 0 { + t.Fatalf("whitelist_in row not cascade-deleted: count=%d err=%v", count, err) + } +}