◐ Off-By-One · answer catalog

go-cli-direct-db-bypass-daemon-api

2 answer(s)gogogogo

unknown

📦 Source in repository (JSON)

Answer 1

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.

Evidence & signatures

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"}

Answer 2

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.

Evidence & signatures

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"}
Generated from the verified corpus · MIT licensedBack to the catalog