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. + +Powered by Turso   **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 + +[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") + } +}