diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 86ad4a0..d0b45c1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,8 +40,30 @@ jobs:
with:
go-version-file: go.mod
- name: Test (race detector)
+ env:
+ # libSQL/Turso integration tests run only when these are set, and
+ # skip cleanly otherwise. Forks / external PRs don't get repository
+ # secrets, so the pipeline still passes for them.
+ WATCHWORD_TEST_TURSO_URL: ${{ secrets.WATCHWORD_TEST_TURSO_URL }}
+ WATCHWORD_TEST_TURSO_AUTH_TOKEN: ${{ secrets.WATCHWORD_TEST_TURSO_AUTH_TOKEN }}
run: go test -race -timeout 5m ./...
+ turso:
+ # Optional integration job — runs Turso tests in isolation. Uses
+ # `continue-on-error` so a flaky/unavailable test DB never blocks merges.
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ - name: Turso integration tests
+ env:
+ WATCHWORD_TEST_TURSO_URL: ${{ secrets.WATCHWORD_TEST_TURSO_URL }}
+ WATCHWORD_TEST_TURSO_AUTH_TOKEN: ${{ secrets.WATCHWORD_TEST_TURSO_AUTH_TOKEN }}
+ run: go test -v -timeout 5m -run TestLibSQL ./internal/repository/...
+
concurrency:
runs-on: ubuntu-latest
steps:
diff --git a/README.md b/README.md
index 4368222..a3b92df 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,9 @@
An [MCP](https://modelcontextprotocol.io/) server that lets AI assistants store and retrieve prompts, snippets, and arbitrary text keyed by short, human-readable code words. Think of it as a shared clipboard between you and your LLM — say "save this as *falcon*", then later "show me *falcon*".
-Built in Go. Supports SQLite and PostgreSQL. Runs over stdio, SSE, or Streamable HTTP.
+Built in Go. Supports SQLite, PostgreSQL, and libSQL/Turso. Runs over stdio, SSE, or Streamable HTTP.
+
+
**Now with libSQL/Turso support** — point Watchword at a hosted Turso database for SQLite-compatible storage at the edge. See [libSQL / Turso](#libsql--turso) below.
## Why
@@ -13,7 +15,7 @@ LLM conversations are ephemeral. Watchword gives your assistant persistent, name
- **10 MCP tools** for storing, retrieving, searching, listing, restoring, deleting entries, and file upload/download
- **S3 file storage** — upload/download files up to 1GB via presigned URLs (works with AWS S3 and Cloudflare R2)
- **Collision resolution** — if a keyword is taken, the server auto-appends a suffix (`rabbit` -> `rabbit2`)
-- **SQLite or PostgreSQL** backends
+- **SQLite, PostgreSQL, or libSQL/Turso** backends
- **Automatic expiration** — entries expire after a configurable TTL (or never, with `ttl_hours: 0`)
- **Bearer token and JWT/JWKS authentication** — with optional named tokens for service accounts
- **Per-entry `created_by` tracking** — populated from a JWT identity claim or a named static token, surfaced on read/list/search responses
@@ -143,9 +145,11 @@ All settings live in `config.yaml`. Every value can be overridden with environme
| Setting | Env var | Default | Description |
|---------|---------|---------|-------------|
-| `database.driver` | `WORDSTORE_DATABASE_DRIVER` | `sqlite` | `sqlite` or `postgres` |
+| `database.driver` | `WORDSTORE_DATABASE_DRIVER` | `sqlite` | `sqlite`, `postgres`, or `libsql` |
| `database.sqlite.path` | `WORDSTORE_DATABASE_SQLITE_PATH` | `./data/word-store.db` | SQLite file path |
| `database.postgres.dsn` | `WORDSTORE_DATABASE_POSTGRES_DSN` | | PostgreSQL connection string |
+| `database.libsql.url` | `WORDSTORE_DATABASE_LIBSQL_URL` | | libSQL/Turso DB URL (e.g. `libsql://my-db-org.turso.io`) |
+| `database.libsql.auth_token` | `WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN` | | Turso auth token (keep out of `config.yaml`; pass via env) |
#### SQLite concurrency
@@ -186,6 +190,44 @@ The directory in `path` is created on startup, and migrations run on first boot.
**3. Migrating data (optional)** — switching driver starts from an empty database. If you need to carry entries across, dump the `entries` table from PostgreSQL (`COPY entries TO STDOUT (FORMAT csv, HEADER)`) and load it into SQLite with `.import`; the schemas are equivalent, but PostgreSQL `timestamptz` columns must be converted to RFC3339 strings for SQLite during the dump.
+#### libSQL / Turso
+
+
+
+[Turso](https://turso.tech/) is a managed libSQL service — SQLite-compatible, but accessed remotely over HTTP/WebSocket. Watchword talks to it through the pure-Go [`libsql-client-go`](https://github.com/tursodatabase/libsql-client-go) driver, so no CGO is required.
+
+The same `migrations/sqlite/*.sql` migrations run unchanged; the schema and SQL are identical to a local SQLite file. The only behavioral differences vs. the local SQLite backend:
+
+- **No client-side pragmas.** `journal_mode`, `busy_timeout`, `synchronous`, `foreign_keys` are server-controlled on Turso — the URI-pragma format used by the local backend isn't applied.
+- **Deferred transactions.** Turso doesn't expose `_txlock=immediate`; collision resolution still relies on the `(word, status)` unique constraint as the source of truth.
+
+**Config (env-driven, recommended for secrets):**
+
+```bash
+export WORDSTORE_DATABASE_DRIVER=libsql
+export WORDSTORE_DATABASE_LIBSQL_URL='libsql://your-db-org.turso.io'
+export WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN=''
+./watchword
+```
+
+Or in `config.yaml`:
+
+```yaml
+database:
+ driver: "libsql"
+ libsql:
+ url: "libsql://your-db-org.turso.io"
+ # auth_token: prefer WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN env to keep tokens out of source control
+```
+
+Get a URL + auth token via the Turso CLI:
+
+```bash
+turso db create watchword
+turso db show watchword --url # → libsql://watchword-.turso.io
+turso db tokens create watchword # → eyJhbGciOi...
+```
+
### Authentication
| Setting | Env var | Default | Description |
diff --git a/cmd/server/main.go b/cmd/server/main.go
index f141e79..af5518b 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -299,6 +299,9 @@ func initRepo(ctx context.Context, cfg *config.Config, logger *slog.Logger) (rep
case "postgres":
logger.Info("initializing PostgreSQL")
return repository.NewPostgresRepo(ctx, cfg.Database.Postgres.DSN)
+ case "libsql":
+ logger.Info("initializing libSQL/Turso", "url", cfg.Database.LibSQL.URL)
+ return repository.NewLibSQLRepo(cfg.Database.LibSQL.URL, cfg.Database.LibSQL.AuthToken)
default:
return nil, fmt.Errorf("unsupported database driver: %s", cfg.Database.Driver)
}
diff --git a/go.mod b/go.mod
index 7a6e9ab..732a9b7 100644
--- a/go.mod
+++ b/go.mod
@@ -12,12 +12,14 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/mark3labs/mcp-go v0.45.0
+ github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.34.5
)
require (
github.com/MicahParks/jwkset v0.11.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
@@ -35,6 +37,7 @@ require (
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/coder/websocket v1.8.12 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -48,6 +51,7 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
diff --git a/go.sum b/go.sum
index 6fa1c17..de648a6 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +2,8 @@ github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOh
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
+github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
+github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
@@ -44,6 +46,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
+github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -95,12 +99,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc h1:lzi/5fg2EfinRlh3v//YyIhnc4tY7BTqazQGwb1ar+0=
+github.com/tursodatabase/libsql-client-go v0.0.0-20251219100830-236aa1ff8acc/go.mod h1:08inkKyguB6CGGssc/JzhmQWwBgFQBgjlYFjxjRh7nU=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
+golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
diff --git a/internal/config/config.go b/internal/config/config.go
index 0593895..9d2a924 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -156,6 +156,7 @@ type DatabaseConfig struct {
Driver string `yaml:"driver"`
SQLite SQLiteConfig `yaml:"sqlite"`
Postgres PostgresConfig `yaml:"postgres"`
+ LibSQL LibSQLConfig `yaml:"libsql"`
}
type SQLiteConfig struct {
@@ -166,6 +167,14 @@ type PostgresConfig struct {
DSN string `yaml:"dsn"`
}
+// LibSQLConfig points at a remote libSQL/Turso database. URL is the canonical
+// connection URL (e.g. "libsql://my-db-org.turso.io"). AuthToken is the Turso
+// auth token; usually injected via env in production.
+type LibSQLConfig struct {
+ URL string `yaml:"url"`
+ AuthToken string `yaml:"auth_token"`
+}
+
type AuthConfig struct {
Enabled bool `yaml:"enabled"`
Tokens []string `yaml:"tokens"`
@@ -287,6 +296,12 @@ func applyEnvOverrides(cfg *Config) {
if v := os.Getenv("WORDSTORE_DATABASE_POSTGRES_DSN"); v != "" {
cfg.Database.Postgres.DSN = v
}
+ if v := os.Getenv("WORDSTORE_DATABASE_LIBSQL_URL"); v != "" {
+ cfg.Database.LibSQL.URL = v
+ }
+ if v := os.Getenv("WORDSTORE_DATABASE_LIBSQL_AUTH_TOKEN"); v != "" {
+ cfg.Database.LibSQL.AuthToken = v
+ }
if v := os.Getenv("WORDSTORE_AUTH_ENABLED"); v != "" {
cfg.Auth.Enabled = v == "true" || v == "1"
}
@@ -510,9 +525,9 @@ func validate(cfg *Config) error {
return fmt.Errorf("invalid server.transport: %q (must be stdio, sse, streamable-http, or http)", cfg.Server.Transport)
}
switch cfg.Database.Driver {
- case "sqlite", "postgres":
+ case "sqlite", "postgres", "libsql":
default:
- return fmt.Errorf("invalid database.driver: %q (must be sqlite or postgres)", cfg.Database.Driver)
+ return fmt.Errorf("invalid database.driver: %q (must be sqlite, postgres, or libsql)", cfg.Database.Driver)
}
if cfg.Database.Driver == "sqlite" && cfg.Database.SQLite.Path == "" {
return fmt.Errorf("database.sqlite.path is required when driver is sqlite")
@@ -520,6 +535,9 @@ func validate(cfg *Config) error {
if cfg.Database.Driver == "postgres" && cfg.Database.Postgres.DSN == "" {
return fmt.Errorf("database.postgres.dsn is required when driver is postgres")
}
+ if cfg.Database.Driver == "libsql" && cfg.Database.LibSQL.URL == "" {
+ return fmt.Errorf("database.libsql.url is required when driver is libsql")
+ }
if cfg.Auth.JWT != nil && cfg.Auth.JWT.JWKSURL == "" {
return fmt.Errorf("auth.jwt.jwks_url is required when jwt is configured")
}
diff --git a/internal/repository/libsql.go b/internal/repository/libsql.go
new file mode 100644
index 0000000..e0fca9c
--- /dev/null
+++ b/internal/repository/libsql.go
@@ -0,0 +1,73 @@
+package repository
+
+import (
+ "database/sql"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ _ "github.com/tursodatabase/libsql-client-go/libsql"
+)
+
+// NewLibSQLRepo opens a connection to a remote libSQL/Turso database.
+//
+// The libsql client driver registers itself under the name "libsql". The DSN
+// is the database URL (typically "libsql://-.turso.io"); the
+// auth token is appended as an `authToken=` query parameter.
+//
+// Unlike the local SQLite path, no `journal_mode`, `busy_timeout`, or
+// `_txlock` URI pragmas are applied — those are either server-controlled on
+// Turso or unsupported by this driver. Transactions begin as deferred; the
+// `(word, status)` unique constraint remains the source of truth for
+// collision resolution.
+//
+// The schema and SQL are SQLite-compatible, so we reuse SQLiteRepo for all
+// query/scanning logic and the embedded `migrations/sqlite/*.sql` migrations.
+func NewLibSQLRepo(dbURL, authToken string) (*SQLiteRepo, error) {
+ dsn, err := buildLibSQLDSN(dbURL, authToken)
+ if err != nil {
+ return nil, err
+ }
+
+ db, err := sql.Open("libsql", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("opening libsql: %w", err)
+ }
+
+ // libSQL HTTP/WS connections are cheap to multiplex but each holds an
+ // outbound socket; cap the pool conservatively. Writes are serialized
+ // server-side regardless of pool depth.
+ db.SetMaxOpenConns(8)
+ db.SetMaxIdleConns(8)
+ db.SetConnMaxIdleTime(5 * time.Minute)
+
+ return &SQLiteRepo{db: db}, nil
+}
+
+// buildLibSQLDSN normalizes the URL and merges the auth token into the query
+// string. Accepts libsql://, wss://, ws://, https://, http://, or file: URLs
+// (the last is mostly useful for local libsql-server testing).
+func buildLibSQLDSN(dbURL, authToken string) (string, error) {
+ if dbURL == "" {
+ return "", fmt.Errorf("libsql url is empty")
+ }
+
+ if strings.HasPrefix(dbURL, "file:") || dbURL == ":memory:" {
+ return dbURL, nil
+ }
+
+ u, err := url.Parse(dbURL)
+ if err != nil {
+ return "", fmt.Errorf("parsing libsql url: %w", err)
+ }
+
+ if authToken != "" {
+ q := u.Query()
+ if q.Get("authToken") == "" {
+ q.Set("authToken", authToken)
+ }
+ u.RawQuery = q.Encode()
+ }
+ return u.String(), nil
+}
diff --git a/internal/repository/libsql_test.go b/internal/repository/libsql_test.go
new file mode 100644
index 0000000..3668dda
--- /dev/null
+++ b/internal/repository/libsql_test.go
@@ -0,0 +1,160 @@
+package repository
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/watchword/watchword/internal/domain"
+)
+
+// libsqlTestEnv is the env var pair used to point integration tests at a
+// real Turso/libSQL database. Both must be set; otherwise the tests skip so
+// CI builds without the secret continue to pass.
+const (
+ envLibSQLURL = "WATCHWORD_TEST_TURSO_URL"
+ envLibSQLToken = "WATCHWORD_TEST_TURSO_AUTH_TOKEN"
+)
+
+// newLibSQLTestRepo opens a repo against a real Turso DB. Each test uses a
+// unique word prefix so concurrent runs (and re-runs of failing tests) don't
+// step on each other's rows. The teardown deletes only rows this test wrote.
+func newLibSQLTestRepo(t *testing.T) (*SQLiteRepo, string) {
+ t.Helper()
+
+ dbURL := os.Getenv(envLibSQLURL)
+ token := os.Getenv(envLibSQLToken)
+ if dbURL == "" {
+ t.Skipf("libSQL integration tests skipped: set %s (and %s) to enable", envLibSQLURL, envLibSQLToken)
+ }
+
+ repo, err := NewLibSQLRepo(dbURL, token)
+ if err != nil {
+ t.Fatalf("NewLibSQLRepo: %v", err)
+ }
+ t.Cleanup(func() { repo.Close() })
+
+ if err := repo.Migrate(context.Background()); err != nil {
+ t.Fatalf("Migrate: %v", err)
+ }
+
+ prefix := testPrefix(t)
+ t.Cleanup(func() {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ _, _ = repo.db.ExecContext(ctx, `DELETE FROM entries WHERE word LIKE ?`, prefix+"%")
+ })
+ return repo, prefix
+}
+
+func testPrefix(t *testing.T) string {
+ t.Helper()
+ var b [4]byte
+ _, _ = rand.Read(b[:])
+ return "wwtest_" + hex.EncodeToString(b[:]) + "_"
+}
+
+func TestLibSQL_StoreAndGetByID(t *testing.T) {
+ repo, prefix := newLibSQLTestRepo(t)
+ ctx := context.Background()
+
+ expires := time.Now().UTC().Add(24 * time.Hour)
+ created, err := repo.Store(ctx, &domain.Entry{
+ Word: prefix + "rabbit",
+ Payload: "test payload",
+ ExpiresAt: &expires,
+ })
+ if err != nil {
+ t.Fatalf("Store: %v", err)
+ }
+
+ fetched, err := repo.GetByID(ctx, created.ID)
+ if err != nil {
+ t.Fatalf("GetByID: %v", err)
+ }
+ if fetched.Payload != "test payload" {
+ t.Errorf("payload mismatch: got %q", fetched.Payload)
+ }
+}
+
+func TestLibSQL_GetByWordAndDelete(t *testing.T) {
+ repo, prefix := newLibSQLTestRepo(t)
+ ctx := context.Background()
+
+ word := prefix + "cat"
+ created, err := repo.Store(ctx, &domain.Entry{Word: word, Payload: "meow"})
+ if err != nil {
+ t.Fatalf("Store: %v", err)
+ }
+
+ got, err := repo.GetByWord(ctx, word, false)
+ if err != nil {
+ t.Fatalf("GetByWord: %v", err)
+ }
+ if got.ID != created.ID {
+ t.Errorf("id mismatch")
+ }
+
+ if err := repo.Delete(ctx, created.ID); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+ if _, err := repo.GetByID(ctx, created.ID); err != domain.ErrNotFound {
+ t.Errorf("expected ErrNotFound after delete, got %v", err)
+ }
+}
+
+func TestLibSQL_UniqueConstraint(t *testing.T) {
+ repo, prefix := newLibSQLTestRepo(t)
+ ctx := context.Background()
+
+ word := prefix + "dog"
+ if _, err := repo.Store(ctx, &domain.Entry{Word: word, Payload: "woof"}); err != nil {
+ t.Fatalf("first store: %v", err)
+ }
+ if _, err := repo.Store(ctx, &domain.Entry{Word: word, Payload: "bark"}); err == nil {
+ t.Error("expected unique-constraint error on duplicate word+status, got nil")
+ }
+}
+
+func TestLibSQL_SearchAndList(t *testing.T) {
+ repo, prefix := newLibSQLTestRepo(t)
+ ctx := context.Background()
+
+ for _, w := range []string{"rabbit", "raccoon", "cat"} {
+ if _, err := repo.Store(ctx, &domain.Entry{Word: prefix + w, Payload: "p"}); err != nil {
+ t.Fatalf("Store %s: %v", w, err)
+ }
+ }
+
+ entries, total, err := repo.SearchByLike(ctx, prefix+"ra%", "active", 20, 0)
+ if err != nil {
+ t.Fatalf("SearchByLike: %v", err)
+ }
+ if total != 2 || len(entries) != 2 {
+ t.Errorf("expected 2 ra-prefix matches, got total=%d len=%d", total, len(entries))
+ }
+}
+
+func TestLibSQL_WithTx(t *testing.T) {
+ repo, prefix := newLibSQLTestRepo(t)
+ ctx := context.Background()
+
+ word := prefix + "txtest"
+ err := repo.WithTx(ctx, func(txRepo Repository) error {
+ _, err := txRepo.Store(ctx, &domain.Entry{Word: word, Payload: "p1"})
+ return err
+ })
+ if err != nil {
+ t.Fatalf("WithTx: %v", err)
+ }
+ exists, err := repo.WordExistsActive(ctx, word)
+ if err != nil {
+ t.Fatalf("WordExistsActive: %v", err)
+ }
+ if !exists {
+ t.Error("expected word to exist after tx commit")
+ }
+}