unknown
Problem: The CLI could only mutate/read data through the daemon's HTTP API. That created a hard runtime dependency: daemon down → CLI dead (connection refused), plus a network hop and JSON serialization for every operation.
Fix: Extract a shared database layer (store), give the CLI a second backend (DirectStore) that opens the SQLite file in-process — completely bypassing the daemon API — and make both backends implement one interface so command logic is backend-agnostic. Direct mode is the new default; API mode is kept for remote use.
Project layout (~/godirect):
store/store.go single source of truth: SQLite CRUD (Put/Get/List/Delete)
api/server.go daemon HTTP API wrapping store (unchanged role)
client/client.go KV interface + DaemonClient (legacy) + DirectStore (the fix)
cmd/daemon/main.go daemon binary
cmd/cli/main.go CLI: --mode direct|api
Key code — the bypass lives in client.DirectStore:
// client/client.go
type KV interface {
Put(name, value string) error
Get(name string) (string, error)
List() (map[string]string, error)
Delete(name string) error
Close() error
}
// Fixed backend: bypasses the daemon, talks to the DB directly.
type DirectStore struct{ st *store.Store }
func NewDirectStore(path string) (*DirectStore, error) {
st, err := store.Open(path) // sql.Open("sqlite", ...) + CREATE TABLE IF NOT EXISTS
if err != nil {
return nil, err
}
return &DirectStore{st: st}, nil
}
func (d *DirectStore) Put(name, value string) error {
_, err := d.st.Put(name, value)
return err
}
func (d *DirectStore) Get(name string) (string, error) {
rec, err := d.st.Get(name)
if err != nil {
return "", err
}
return rec.Value, nil
}
CLI wiring — --mode direct (default) opens the DB itself; --mode api still uses the daemon:
// cmd/cli/main.go
var kv client.KV
var err error
switch *mode {
case "direct":
kv, err = client.NewDirectStore(*dbPath) // bypasses the daemon API
case "api":
kv = client.NewDaemonClient(*apiURL) // legacy path, needs daemon up
}
Parity details that make the swap invisible to callers:
- 404 from the daemon maps to store.ErrNotFound, so errors.Is(err, client.ErrNotFound) works for both backends.
- Shared sentinel: var ErrNotFound = store.ErrNotFound.
- SQLite opened with busy_timeout + WAL so concurrent daemon and CLI processes can safely share one DB file; sync.Mutex serializes in-process mutations.
Verified three ways: 1. **Unit tests (6/6 passing, `go test -race` clean):** - `store`: CRUD + not-found sentinel, sorted List, 8-goroutine concurrent writes. - `client`: `TestDirectAndDaemonBackendsAgree` — same command sequence against DirectStore and DaemonClient (both on the **same DB file**) yields identical data; `TestDirectWorksWhenDaemonIsDown` — direct mode succeeds with no daemon; `TestDaemonClientFailsWhenDaemonIsDown` — legacy mode fails fast with connection refused. - `go vet ./...` and `gofmt -l .` clean. 2. **End-to-end binary test** (`go build` of both commands): - Daemon **down**: `cli -mode api put` → `daemon unreachable ... connection refused` (exit 1, the original failure); `cli -mode direct put/get` → works. - Daemon **up** on the same file: `api get k` → `v` (data written by direct mode), `api put k=v2`, then `direct get k` → `v2` and `api list` → `k=v2`. Both modes interoperate on shared state. 3. **Edge cases tested:** missing record via both backends returns `ErrNotFound`; idempotent upsert keeps one row per name; delete of missing key errors; flags accepted before or after the subcommand (`cli -mode direct -db r.db put -name k -value v` and `cli put -name k -value v -mode direct` both parse); concurrent writer processes via WAL+busy_timeout.
{"model": "deepseek-v4-flash", "result": "completed"}Problem: The CLI could only mutate/read data through the daemon's HTTP API. That created a hard runtime dependency: daemon down → CLI dead (connection refused), plus a network hop and JSON serialization for every operation.
Fix: Extract a shared database layer (store), give the CLI a second backend (DirectStore) that opens the SQLite file in-process — completely bypassing the daemon API — and make both backends implement one interface so command logic is backend-agnostic. Direct mode is the new default; API mode is kept for remote use.
Project layout (~/godirect):
store/store.go single source of truth: SQLite CRUD (Put/Get/List/Delete)
api/server.go daemon HTTP API wrapping store (unchanged role)
client/client.go KV interface + DaemonClient (legacy) + DirectStore (the fix)
cmd/daemon/main.go daemon binary
cmd/cli/main.go CLI: --mode direct|api
Key code — the bypass lives in client.DirectStore:
// client/client.go
type KV interface {
Put(name, value string) error
Get(name string) (string, error)
List() (map[string]string, error)
Delete(name string) error
Close() error
}
// Fixed backend: bypasses the daemon, talks to the DB directly.
type DirectStore struct{ st *store.Store }
func NewDirectStore(path string) (*DirectStore, error) {
st, err := store.Open(path) // sql.Open("sqlite", ...) + CREATE TABLE IF NOT EXISTS
if err != nil {
return nil, err
}
return &DirectStore{st: st}, nil
}
func (d *DirectStore) Put(name, value string) error {
_, err := d.st.Put(name, value)
return err
}
func (d *DirectStore) Get(name string) (string, error) {
rec, err := d.st.Get(name)
if err != nil {
return "", err
}
return rec.Value, nil
}
CLI wiring — --mode direct (default) opens the DB itself; --mode api still uses the daemon:
// cmd/cli/main.go
var kv client.KV
var err error
switch *mode {
case "direct":
kv, err = client.NewDirectStore(*dbPath) // bypasses the daemon API
case "api":
kv = client.NewDaemonClient(*apiURL) // legacy path, needs daemon up
}
Parity details that make the swap invisible to callers:
- 404 from the daemon maps to store.ErrNotFound, so errors.Is(err, client.ErrNotFound) works for both backends.
- Shared sentinel: var ErrNotFound = store.ErrNotFound.
- SQLite opened with busy_timeout + WAL so concurrent daemon and CLI processes can safely share one DB file; sync.Mutex serializes in-process mutations.
Verified three ways: 1. **Unit tests (6/6 passing, `go test -race` clean):** - `store`: CRUD + not-found sentinel, sorted List, 8-goroutine concurrent writes. - `client`: `TestDirectAndDaemonBackendsAgree` — same command sequence against DirectStore and DaemonClient (both on the **same DB file**) yields identical data; `TestDirectWorksWhenDaemonIsDown` — direct mode succeeds with no daemon; `TestDaemonClientFailsWhenDaemonIsDown` — legacy mode fails fast with connection refused. - `go vet ./...` and `gofmt -l .` clean. 2. **End-to-end binary test** (`go build` of both commands): - Daemon **down**: `cli -mode api put` → `daemon unreachable ... connection refused` (exit 1, the original failure); `cli -mode direct put/get` → works. - Daemon **up** on the same file: `api get k` → `v` (data written by direct mode), `api put k=v2`, then `direct get k` → `v2` and `api list` → `k=v2`. Both modes interoperate on shared state. 3. **Edge cases tested:** missing record via both backends returns `ErrNotFound`; idempotent upsert keeps one row per name; delete of missing key errors; flags accepted before or after the subcommand (`cli -mode direct -db r.db put -name k -value v` and `cli put -name k -value v -mode direct` both parse); concurrent writer processes via WAL+busy_timeout.
{"model": "deepseek-v4-flash", "result": "completed"}