Skip to content

Commit 6d287a8

Browse files
committed
doctor: report DB dialect/DSN validity and cover empty durable-state tables
1 parent de59e2f commit 6d287a8

6 files changed

Lines changed: 172 additions & 17 deletions

File tree

ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ The **client** stores `config.json`, `profiles.json`, keystore (unless legacy `k
209209

210210
### Diagnostics (`internal/doctor`)
211211

212-
Shared package invoked by **`marchat-client`** and **`marchat-server`** when passed **`-doctor`** (human-readable report) or **`-doctor-json`** (JSON on stdout). It summarizes Go/OS, resolved config directories, known `MARCHAT_*` variables with secrets masked, role-specific checks (client: profiles, clipboard, TTY; server: `.env`, validation, DB/TLS/sqlite ping), and optionally compares the embedded version to the latest GitHub release. Set **`MARCHAT_DOCTOR_NO_NETWORK=1`** to skip the release check (e.g. air-gapped environments).
212+
Shared package invoked by **`marchat-client`** and **`marchat-server`** when passed **`-doctor`** (human-readable report) or **`-doctor-json`** (JSON on stdout). It summarizes Go/OS, resolved config directories, known `MARCHAT_*` variables with secrets masked, role-specific checks (client: profiles, clipboard, TTY; server: `.env`, validation, detected DB dialect, DB connection-string format validation, DB/TLS ping checks), and optionally compares the embedded version to the latest GitHub release. Set **`MARCHAT_DOCTOR_NO_NETWORK=1`** to skip the release check (e.g. air-gapped environments).
213213

214214
### Command Line Tools (`cmd/`)
215215

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ The repository’s `config/` directory holds **server** runtime files and the **
270270

271271
### Diagnostics (`-doctor`)
272272

273-
Run **`./marchat-client -doctor`** or **`./marchat-server -doctor`** for a text report (paths, masked `MARCHAT_*` env, sanity checks). Use **`-doctor-json`** for machine-readable output. If both flags were passed, `-doctor-json` wins. Exits without starting the TUI or listening on a port. See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
273+
Run **`./marchat-client -doctor`** or **`./marchat-server -doctor`** for a text report (paths, masked `MARCHAT_*` env, sanity checks). Server doctor also reports the detected DB dialect, validates the configured DB connection string format, and attempts a DB ping. Use **`-doctor-json`** for machine-readable output. If both flags were passed, `-doctor-json` wins. Exits without starting the TUI or listening on a port. See [ARCHITECTURE.md](ARCHITECTURE.md) for details.
274274

275275
## Admin Commands
276276

internal/doctor/db_checks.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package doctor
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/go-sql-driver/mysql"
8+
"github.com/jackc/pgx/v5/pgconn"
9+
)
10+
11+
type dbTarget struct {
12+
driver string
13+
dialect string
14+
dsn string
15+
}
16+
17+
func detectDBTarget(conn string) dbTarget {
18+
v := strings.TrimSpace(conn)
19+
switch {
20+
case strings.HasPrefix(v, "postgres://"), strings.HasPrefix(v, "postgresql://"):
21+
return dbTarget{driver: "pgx", dialect: "postgres", dsn: v}
22+
case strings.HasPrefix(v, "mysql://"):
23+
return dbTarget{driver: "mysql", dialect: "mysql", dsn: strings.TrimPrefix(v, "mysql://")}
24+
case strings.HasPrefix(v, "mysql:"):
25+
return dbTarget{driver: "mysql", dialect: "mysql", dsn: strings.TrimPrefix(v, "mysql:")}
26+
default:
27+
return dbTarget{driver: "sqlite", dialect: "sqlite", dsn: v}
28+
}
29+
}
30+
31+
func validateConnectionString(t dbTarget) error {
32+
switch t.dialect {
33+
case "postgres":
34+
_, err := pgconn.ParseConfig(t.dsn)
35+
if err != nil {
36+
return fmt.Errorf("invalid Postgres DSN: %w", err)
37+
}
38+
return nil
39+
case "mysql":
40+
_, err := mysql.ParseDSN(t.dsn)
41+
if err != nil {
42+
return fmt.Errorf("invalid MySQL DSN: %w", err)
43+
}
44+
return nil
45+
default:
46+
if strings.TrimSpace(t.dsn) == "" {
47+
return fmt.Errorf("sqlite path is empty")
48+
}
49+
return nil
50+
}
51+
}

internal/doctor/db_checks_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package doctor
2+
3+
import "testing"
4+
5+
func TestDetectDBTarget(t *testing.T) {
6+
t.Parallel()
7+
cases := []struct {
8+
in string
9+
dialect string
10+
driver string
11+
dsn string
12+
}{
13+
{"postgres://u:p@localhost:5432/marchat?sslmode=disable", "postgres", "pgx", "postgres://u:p@localhost:5432/marchat?sslmode=disable"},
14+
{"mysql://u:p@tcp(localhost:3306)/marchat?parseTime=true", "mysql", "mysql", "u:p@tcp(localhost:3306)/marchat?parseTime=true"},
15+
{"mysql:u:p@tcp(localhost:3306)/marchat?parseTime=true", "mysql", "mysql", "u:p@tcp(localhost:3306)/marchat?parseTime=true"},
16+
{"./config/marchat.db", "sqlite", "sqlite", "./config/marchat.db"},
17+
}
18+
for _, tc := range cases {
19+
tc := tc
20+
t.Run(tc.in, func(t *testing.T) {
21+
t.Parallel()
22+
got := detectDBTarget(tc.in)
23+
if got.dialect != tc.dialect || got.driver != tc.driver || got.dsn != tc.dsn {
24+
t.Fatalf("detectDBTarget(%q) = %+v", tc.in, got)
25+
}
26+
})
27+
}
28+
}
29+
30+
func TestValidateConnectionString(t *testing.T) {
31+
t.Parallel()
32+
valid := []dbTarget{
33+
{dialect: "sqlite", dsn: "./config/marchat.db"},
34+
{dialect: "postgres", dsn: "postgres://u:p@localhost:5432/marchat?sslmode=disable"},
35+
{dialect: "mysql", dsn: "u:p@tcp(localhost:3306)/marchat?parseTime=true"},
36+
}
37+
for _, tc := range valid {
38+
tc := tc
39+
t.Run(tc.dialect+"_valid", func(t *testing.T) {
40+
t.Parallel()
41+
if err := validateConnectionString(tc); err != nil {
42+
t.Fatalf("expected valid connection string, got error: %v", err)
43+
}
44+
})
45+
}
46+
47+
invalid := []dbTarget{
48+
{dialect: "sqlite", dsn: " "},
49+
{dialect: "postgres", dsn: "postgres://%"},
50+
{dialect: "mysql", dsn: "not-a-dsn"},
51+
}
52+
for _, tc := range invalid {
53+
tc := tc
54+
t.Run(tc.dialect+"_invalid", func(t *testing.T) {
55+
t.Parallel()
56+
if err := validateConnectionString(tc); err == nil {
57+
t.Fatal("expected invalid connection string error")
58+
}
59+
})
60+
}
61+
}

internal/doctor/doctor.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
"github.com/Cod-e-Codes/marchat/shared"
1919
"github.com/atotto/clipboard"
2020
"github.com/charmbracelet/x/term"
21+
_ "github.com/go-sql-driver/mysql"
22+
_ "github.com/jackc/pgx/v5/stdlib"
2123
_ "modernc.org/sqlite"
2224
)
2325

@@ -144,19 +146,31 @@ func RunServer(o Options) error {
144146

145147
dbPath := cfg.DBPath
146148
appendCheck(&checks, "db_path", "ok", fmt.Sprintf("database path: %s", dbPath))
147-
parent := filepath.Dir(dbPath)
148-
if fi, err := os.Stat(parent); err != nil {
149-
appendCheck(&checks, "db_parent", "warn", fmt.Sprintf("database parent dir: %v", err))
150-
} else if !fi.IsDir() {
151-
appendCheck(&checks, "db_parent", "error", "database parent path is not a directory")
149+
150+
target := detectDBTarget(dbPath)
151+
appendCheck(&checks, "db_dialect", "ok", fmt.Sprintf("detected DB dialect: %s (driver: %s)", target.dialect, target.driver))
152+
153+
if err := validateConnectionString(target); err != nil {
154+
appendCheck(&checks, "db_connection_string", "error", err.Error())
152155
} else {
153-
f, err := os.CreateTemp(parent, "marchat-doctor-*.tmp")
154-
if err != nil {
155-
appendCheck(&checks, "db_parent_writable", "warn", fmt.Sprintf("cannot create temp file in DB parent: %v", err))
156+
appendCheck(&checks, "db_connection_string", "ok", "connection string format is valid")
157+
}
158+
159+
if target.dialect == "sqlite" {
160+
parent := filepath.Dir(dbPath)
161+
if fi, err := os.Stat(parent); err != nil {
162+
appendCheck(&checks, "db_parent", "warn", fmt.Sprintf("database parent dir: %v", err))
163+
} else if !fi.IsDir() {
164+
appendCheck(&checks, "db_parent", "error", "database parent path is not a directory")
156165
} else {
157-
_ = f.Close()
158-
_ = os.Remove(f.Name())
159-
appendCheck(&checks, "db_parent_writable", "ok", "database parent directory is writable")
166+
f, err := os.CreateTemp(parent, "marchat-doctor-*.tmp")
167+
if err != nil {
168+
appendCheck(&checks, "db_parent_writable", "warn", fmt.Sprintf("cannot create temp file in DB parent: %v", err))
169+
} else {
170+
_ = f.Close()
171+
_ = os.Remove(f.Name())
172+
appendCheck(&checks, "db_parent_writable", "ok", "database parent directory is writable")
173+
}
160174
}
161175
}
162176

@@ -181,15 +195,15 @@ func RunServer(o Options) error {
181195

182196
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
183197
defer cancel()
184-
db, err := sql.Open("sqlite", dbPath)
198+
db, err := sql.Open(target.driver, target.dsn)
185199
if err != nil {
186-
appendCheck(&checks, "sqlite_ping", "warn", fmt.Sprintf("open sqlite: %v", err))
200+
appendCheck(&checks, "db_ping", "warn", fmt.Sprintf("open %s: %v", target.dialect, err))
187201
} else {
188202
defer db.Close()
189203
if err := db.PingContext(ctx); err != nil {
190-
appendCheck(&checks, "sqlite_ping", "warn", fmt.Sprintf("sqlite ping: %v", err))
204+
appendCheck(&checks, "db_ping", "warn", fmt.Sprintf("%s ping: %v", target.dialect, err))
191205
} else {
192-
appendCheck(&checks, "sqlite_ping", "ok", "sqlite database reachable")
206+
appendCheck(&checks, "db_ping", "ok", fmt.Sprintf("%s database reachable", target.dialect))
193207
}
194208
}
195209
}

server/message_state_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,32 @@ func TestPersistReaction_RemovalUsesReactionTargetID(t *testing.T) {
7474
t.Fatalf("expected 0 replayed reactions after removal, got %d", len(replayed))
7575
}
7676
}
77+
78+
func TestDurableStateLoaders_HandleEmptyTablesOnFirstBoot(t *testing.T) {
79+
db, err := InitDB(":memory:")
80+
if err != nil {
81+
t.Fatalf("InitDB failed: %v", err)
82+
}
83+
defer db.Close()
84+
CreateSchema(db)
85+
86+
id, err := InsertMessage(db, shared.Message{Sender: "alice", Content: "hello", CreatedAt: time.Now()})
87+
if err != nil {
88+
t.Fatalf("InsertMessage failed: %v", err)
89+
}
90+
91+
reactions := LoadReactionsForMessages(db, []int64{id})
92+
if len(reactions) != 0 {
93+
t.Fatalf("expected no reactions from empty message_reactions table, got %d", len(reactions))
94+
}
95+
96+
receipts := LoadReadReceiptsForMessages(db, "alice", []int64{id})
97+
if len(receipts) != 0 {
98+
t.Fatalf("expected no receipts from empty read_receipts table, got %d", len(receipts))
99+
}
100+
101+
channel := LoadUserChannel(db, "alice")
102+
if channel != "" {
103+
t.Fatalf("expected empty channel from empty user_channels table, got %q", channel)
104+
}
105+
}

0 commit comments

Comments
 (0)