diff --git a/src/plugin/Makefile b/src/plugin/Makefile index db36899..53dd58b 100644 --- a/src/plugin/Makefile +++ b/src/plugin/Makefile @@ -44,17 +44,17 @@ help: all: build build-mcp ## build: Build the main plugin binary -build: $(BUILD_DIR) +build: + @mkdir -p $(BUILD_DIR) @echo "Building $(BINARY_NAME) $(VERSION)..." $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/ ## build-mcp: Build the MCP server binary -build-mcp: $(BUILD_DIR) +build-mcp: + @mkdir -p $(BUILD_DIR) @echo "Building $(MCP_SERVER_BINARY)..." $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(MCP_SERVER_BINARY) ./cmd/mcp-server/ -$(BUILD_DIR): - mkdir -p $(BUILD_DIR) ## install: Install binaries to GOBIN install: @@ -79,7 +79,8 @@ test-verbose: $(GO) test -race -cover -v ./... ## test-coverage: Run tests with coverage report -test-coverage: $(BUILD_DIR) +test-coverage: + @mkdir -p $(BUILD_DIR) @echo "Running tests with coverage..." $(GO) test -race -coverprofile=$(BUILD_DIR)/coverage.out ./... $(GO) tool cover -html=$(BUILD_DIR)/coverage.out -o $(BUILD_DIR)/coverage.html @@ -124,7 +125,8 @@ run: build-mcp ./$(BUILD_DIR)/$(MCP_SERVER_BINARY) ## dist: Build distribution binaries for all platforms -dist: $(DIST_DIR) +dist: + @mkdir -p $(DIST_DIR) @echo "Building distribution binaries..." GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/ GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/ @@ -133,8 +135,6 @@ dist: $(DIST_DIR) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/ @echo "Distribution binaries created in $(DIST_DIR)/" -$(DIST_DIR): - mkdir -p $(DIST_DIR) ## docker: Build Docker image docker: diff --git a/src/plugin/internal/db/libsql.go b/src/plugin/internal/db/libsql.go index 273c136..7611387 100644 --- a/src/plugin/internal/db/libsql.go +++ b/src/plugin/internal/db/libsql.go @@ -319,7 +319,7 @@ func (d *DB) cleanupExpired(ctx context.Context) error { d.mu.RUnlock() _, err := d.db.ExecContext(ctx, - `DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`, + `DELETE FROM memories WHERE expires_at IS NOT NULL AND datetime(expires_at) < datetime('now')`, ) return err } @@ -423,7 +423,7 @@ func (d *DB) Retrieve(ctx context.Context, namespace, key string) (*Memory, erro SELECT id, namespace, key, value, embedding, metadata, tags, created_at, updated_at, ttl, expires_at FROM memories WHERE namespace = ? AND key = ? - AND (expires_at IS NULL OR expires_at > datetime('now')) + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) ` row := d.db.QueryRowContext(ctx, query, namespace, key) @@ -444,7 +444,7 @@ func (d *DB) RetrieveByID(ctx context.Context, id string) (*Memory, error) { SELECT id, namespace, key, value, embedding, metadata, tags, created_at, updated_at, ttl, expires_at FROM memories WHERE id = ? - AND (expires_at IS NULL OR expires_at > datetime('now')) + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) ` row := d.db.QueryRowContext(ctx, query, id) @@ -588,7 +588,7 @@ func (d *DB) List(ctx context.Context, namespace string, limit, offset int) ([]* SELECT id, namespace, key, value, embedding, metadata, tags, created_at, updated_at, ttl, expires_at FROM memories WHERE namespace = ? - AND (expires_at IS NULL OR expires_at > datetime('now')) + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) ORDER BY created_at DESC LIMIT ? OFFSET ? ` @@ -697,7 +697,7 @@ func (d *DB) Search(ctx context.Context, namespace string, queryEmbedding []floa FROM memories WHERE namespace = ? AND embedding IS NOT NULL - AND (expires_at IS NULL OR expires_at > datetime('now')) + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) ` rows, err := d.db.QueryContext(ctx, query, namespace) @@ -766,7 +766,7 @@ func (d *DB) SearchAll(ctx context.Context, queryEmbedding []float32, limit int, SELECT id, namespace, key, value, embedding, metadata, tags, created_at, updated_at, ttl, expires_at FROM memories WHERE embedding IS NOT NULL - AND (expires_at IS NULL OR expires_at > datetime('now')) + AND (expires_at IS NULL OR datetime(expires_at) > datetime('now')) ` rows, err := d.db.QueryContext(ctx, query) @@ -821,7 +821,7 @@ func (d *DB) Count(ctx context.Context, namespace string) (int64, error) { var count int64 err := d.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM memories WHERE namespace = ? AND (expires_at IS NULL OR expires_at > datetime('now'))`, + `SELECT COUNT(*) FROM memories WHERE namespace = ? AND (expires_at IS NULL OR datetime(expires_at) > datetime('now'))`, namespace, ).Scan(&count) if err != nil { @@ -843,7 +843,7 @@ func (d *DB) CountAll(ctx context.Context) (int64, error) { var count int64 err := d.db.QueryRowContext(ctx, - `SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR expires_at > datetime('now')`, + `SELECT COUNT(*) FROM memories WHERE expires_at IS NULL OR datetime(expires_at) > datetime('now')`, ).Scan(&count) if err != nil { return 0, fmt.Errorf("failed to count memories: %w", err) diff --git a/src/plugin/internal/db/libsql_test.go b/src/plugin/internal/db/libsql_test.go index f5616f6..b60d2a5 100644 --- a/src/plugin/internal/db/libsql_test.go +++ b/src/plugin/internal/db/libsql_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "testing" + "time" ) func TestDBOperations(t *testing.T) { @@ -419,3 +420,137 @@ func TestCount(t *testing.T) { t.Errorf("expected 5 total memories, got %d", countAll) } } + +func TestExpirationBehavior(t *testing.T) { + // Create temp database + tmpFile, err := os.CreateTemp("", "test-repro-*.db") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + tmpPath := tmpFile.Name() + tmpFile.Close() + defer os.Remove(tmpPath) + defer os.Remove(tmpPath + "-shm") + defer os.Remove(tmpPath + "-wal") + + ctx := context.Background() + + // Create DB instance + cfg := Config{ + Path: tmpPath, + VectorDimensions: 384, + } + db, err := New(ctx, cfg) + if err != nil { + t.Fatalf("failed to create DB: %v", err) + } + defer db.Close() + + t.Run("AlreadyExpired", func(t *testing.T) { + negTTL := -1 * time.Hour + mem := &Memory{ + ID: "expired-mem", + Namespace: "default", + Key: "expired-key", + Value: "This should be expired", + TTL: &negTTL, + Embedding: make([]float32, 384), + } + + if err := db.Store(ctx, mem); err != nil { + t.Fatalf("store failed: %v", err) + } + + // Try to Retrieve it. It should NOT be returned. + _, err = db.Retrieve(ctx, "default", "expired-key") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound for expired memory, got: %v", err) + } + + // Verify it exists in the DB if we query without expiration check (manual query) + var count int + err = db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM memories WHERE key = 'expired-key'").Scan(&count) + if err != nil { + t.Fatalf("manual count failed: %v", err) + } + if count != 1 { + t.Errorf("memory should exist in DB but be hidden, count: %d", count) + } + + // Run cleanup + if err := db.cleanupExpired(ctx); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + // Verify it is GONE from the DB + err = db.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM memories WHERE key = 'expired-key'").Scan(&count) + if err != nil { + t.Fatalf("manual count failed: %v", err) + } + if count != 0 { + t.Errorf("memory should have been cleaned up, count: %d", count) + } + }) + + t.Run("ImmediateExpiration", func(t *testing.T) { + // Test precise expiration boundary (small TTL) + // We use a small TTL instead of 0 because 0 might be interpreted as no TTL in some systems (though not this one) + // And we sleep to ensure it expires. + smallTTL := 10 * time.Millisecond + mem := &Memory{ + ID: "immediate-mem", + Namespace: "default", + Key: "immediate-key", + Value: "This should expire immediately", + TTL: &smallTTL, + Embedding: make([]float32, 384), + } + + if err := db.Store(ctx, mem); err != nil { + t.Fatalf("store failed: %v", err) + } + + // Wait for expiration + time.Sleep(20 * time.Millisecond) + + // Should be treated as expired + _, err = db.Retrieve(ctx, "default", "immediate-key") + if err != ErrNotFound { + t.Errorf("expected ErrNotFound for expired memory, got: %v", err) + } + }) + + t.Run("FutureExpiration", func(t *testing.T) { + // Test memory that expires in the future + futureTTL := 1 * time.Hour + mem := &Memory{ + ID: "future-mem", + Namespace: "default", + Key: "future-key", + Value: "This should exist", + TTL: &futureTTL, + Embedding: make([]float32, 384), + } + + if err := db.Store(ctx, mem); err != nil { + t.Fatalf("store failed: %v", err) + } + + // Should be retrievable + _, err := db.Retrieve(ctx, "default", "future-key") + if err != nil { + t.Errorf("expected memory to be found, got error: %v", err) + } + + // Run cleanup + if err := db.cleanupExpired(ctx); err != nil { + t.Fatalf("cleanup failed: %v", err) + } + + // Should STILL be retrievable + _, err = db.Retrieve(ctx, "default", "future-key") + if err != nil { + t.Errorf("expected memory to persist after cleanup, got error: %v", err) + } + }) +}