Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfa566eace |
40 changed files with 1788 additions and 13008 deletions
|
|
@ -4,7 +4,6 @@ run:
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- depguard
|
|
||||||
- govet
|
- govet
|
||||||
- errcheck
|
- errcheck
|
||||||
- staticcheck
|
- staticcheck
|
||||||
|
|
@ -18,17 +17,6 @@ linters:
|
||||||
- exhaustive
|
- exhaustive
|
||||||
- wrapcheck
|
- wrapcheck
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
depguard:
|
|
||||||
rules:
|
|
||||||
legacy-module-paths:
|
|
||||||
list-mode: lax
|
|
||||||
files:
|
|
||||||
- $all
|
|
||||||
deny:
|
|
||||||
- pkg: forge.lthn.ai/
|
|
||||||
desc: use dappco.re/ module paths instead
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
exclude-use-default: false
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|
|
||||||
117
CLAUDE.md
117
CLAUDE.md
|
|
@ -4,22 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
SQLite key-value store with TTL, namespace isolation, and reactive events. Pure Go (no CGO). Module: `dappco.re/go/store`
|
SQLite key-value store with TTL, namespace isolation, and reactive events. Pure Go (no CGO). Module: `dappco.re/go/core/store`
|
||||||
|
|
||||||
## AX Notes
|
|
||||||
|
|
||||||
- Prefer descriptive names over abbreviations.
|
|
||||||
- Public comments should show real usage with concrete values.
|
|
||||||
- Keep examples in UK English.
|
|
||||||
- Prefer `StoreConfig` and `ScopedStoreConfig` literals over option chains when the configuration is already known.
|
|
||||||
- Do not add compatibility aliases; the primary API names are the contract.
|
|
||||||
- Preserve the single-connection SQLite design.
|
|
||||||
- Verify with `go test ./...`, `go test -race ./...`, and `go vet ./...` before committing.
|
|
||||||
- Use conventional commits and include the `Co-Authored-By: Virgil <virgil@lethean.io>` trailer.
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
Part of the Go workspace at `~/Code/go.work`—run `go work sync` after cloning. Single Go package with `store.go` (core store API), `events.go` (watchers/callbacks), `scope.go` (scoping/quota), `journal.go` (journal persistence/query), `workspace.go` (workspace buffering), and `compact.go` (archive generation).
|
Part of the Go workspace at `~/Code/go.work`—run `go work sync` after cloning. Single Go package with `store.go` (core) and `scope.go` (scoping/quota).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... -count=1
|
go test ./... -count=1
|
||||||
|
|
@ -29,7 +18,7 @@ go test ./... -count=1
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... # Run all tests
|
go test ./... # Run all tests
|
||||||
go test -v -run TestEvents_Watch_Good_SpecificKey ./... # Run single test
|
go test -v -run TestWatch_Good ./... # Run single test
|
||||||
go test -race ./... # Race detector (must pass before commit)
|
go test -race ./... # Race detector (must pass before commit)
|
||||||
go test -cover ./... # Coverage (target: 95%+)
|
go test -cover ./... # Coverage (target: 95%+)
|
||||||
go test -bench=. -benchmem ./... # Benchmarks
|
go test -bench=. -benchmem ./... # Benchmarks
|
||||||
|
|
@ -42,12 +31,9 @@ go vet ./... # Vet
|
||||||
**Single-connection SQLite.** `store.go` pins `MaxOpenConns(1)` because SQLite pragmas (WAL, busy_timeout) are per-connection — a pool would hand out unpragma'd connections causing SQLITE_BUSY. This is the most important architectural decision; don't change it.
|
**Single-connection SQLite.** `store.go` pins `MaxOpenConns(1)` because SQLite pragmas (WAL, busy_timeout) are per-connection — a pool would hand out unpragma'd connections causing SQLITE_BUSY. This is the most important architectural decision; don't change it.
|
||||||
|
|
||||||
**Three-layer design:**
|
**Three-layer design:**
|
||||||
- `store.go` — Core `Store` type: CRUD on an `entries` table keyed by `(group_name, entry_key)`, TTL via `expires_at` (Unix ms), background purge goroutine (60s interval), `text/template` rendering, `iter.Seq2` iterators
|
- `store.go` — Core `Store` type: CRUD on a `(grp, key)` compound-PK table, TTL via `expires_at` (Unix ms), background purge goroutine (60s interval), `text/template` rendering, `iter.Seq2` iterators
|
||||||
- `events.go` — Event system: `Watch`/`Unwatch` (buffered chan, cap 16, non-blocking sends drop events) and `OnChange` callbacks (synchronous in writer goroutine). Watcher and callback registries use separate locks, so callbacks can register or unregister subscriptions without deadlocking.
|
- `events.go` — Event system: `Watch`/`Unwatch` (buffered chan, cap 16, non-blocking sends drop events) and `OnChange` callbacks (synchronous in writer goroutine). `notify()` holds `s.mu` read-lock; **calling Watch/Unwatch/OnChange from inside a callback will deadlock** (they need write-lock)
|
||||||
- `scope.go` — `ScopedStore` wraps `*Store`, prefixes groups with `namespace:`. Quota enforcement (`MaxKeys`/`MaxGroups`) checked before writes; upserts bypass quota. Namespace regex: `^[a-zA-Z0-9-]+$`
|
- `scope.go` — `ScopedStore` wraps `*Store`, prefixes groups with `namespace:`. Quota enforcement (`MaxKeys`/`MaxGroups`) checked before writes; upserts bypass quota. Namespace regex: `^[a-zA-Z0-9-]+$`
|
||||||
- `journal.go` — Journal persistence and query helpers layered on SQLite.
|
|
||||||
- `workspace.go` — Workspace buffers, commit flow, and orphan recovery.
|
|
||||||
- `compact.go` — Cold archive generation for completed journal entries.
|
|
||||||
|
|
||||||
**TTL enforcement is triple-layered:** lazy delete on `Get`, query-time `WHERE` filtering on bulk reads, and background purge goroutine.
|
**TTL enforcement is triple-layered:** lazy delete on `Get`, query-time `WHERE` filtering on bulk reads, and background purge goroutine.
|
||||||
|
|
||||||
|
|
@ -56,73 +42,36 @@ go vet ./... # Vet
|
||||||
## Key API
|
## Key API
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
st, _ := store.New(":memory:") // or store.New("/path/to/db")
|
||||||
|
defer st.Close()
|
||||||
|
|
||||||
import (
|
st.Set("group", "key", "value") // no expiry
|
||||||
"fmt"
|
st.SetWithTTL("group", "key", "value", 5*time.Minute) // expires after TTL
|
||||||
"time"
|
val, _ := st.Get("group", "key") // lazy-deletes expired
|
||||||
|
st.Delete("group", "key")
|
||||||
|
st.DeleteGroup("group")
|
||||||
|
all, _ := st.GetAll("group") // excludes expired
|
||||||
|
n, _ := st.Count("group") // excludes expired
|
||||||
|
out, _ := st.Render(tmpl, "group") // excludes expired
|
||||||
|
removed, _ := st.PurgeExpired() // manual purge
|
||||||
|
total, _ := st.CountAll("prefix:") // count keys matching prefix (excludes expired)
|
||||||
|
groups, _ := st.Groups("prefix:") // distinct group names matching prefix
|
||||||
|
|
||||||
"dappco.re/go/store"
|
// Namespace isolation (auto-prefixes groups with "tenant:")
|
||||||
)
|
sc, _ := store.NewScoped(st, "tenant")
|
||||||
|
sc.Set("config", "key", "val") // stored as "tenant:config" in underlying store
|
||||||
|
|
||||||
func main() {
|
// With quota enforcement
|
||||||
storeInstance, err := store.New(":memory:")
|
sq, _ := store.NewScopedWithQuota(st, "tenant", store.QuotaConfig{MaxKeys: 100, MaxGroups: 10})
|
||||||
if err != nil {
|
sq.Set("g", "k", "v") // returns ErrQuotaExceeded if limits hit
|
||||||
return
|
|
||||||
}
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
configuredStore, err := store.NewConfigured(store.StoreConfig{
|
// Event hooks
|
||||||
DatabasePath: ":memory:",
|
w := st.Watch("group", "*") // wildcard: all keys in group ("*","*" for all)
|
||||||
Journal: store.JournalConfiguration{
|
defer st.Unwatch(w)
|
||||||
EndpointURL: "http://127.0.0.1:8086",
|
e := <-w.Ch // buffered chan, cap 16
|
||||||
Organisation: "core",
|
|
||||||
BucketName: "events",
|
|
||||||
},
|
|
||||||
PurgeInterval: 30 * time.Second,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer configuredStore.Close()
|
|
||||||
|
|
||||||
if err := configuredStore.Set("group", "key", "value"); err != nil {
|
unreg := st.OnChange(func(e store.Event) { /* synchronous in writer goroutine */ })
|
||||||
return
|
defer unreg()
|
||||||
}
|
|
||||||
value, err := configuredStore.Get("group", "key")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(value)
|
|
||||||
|
|
||||||
if err := configuredStore.SetWithTTL("session", "token", "abc123", 5*time.Minute); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedStore, err := store.NewScopedConfigured(configuredStore, store.ScopedStoreConfig{
|
|
||||||
Namespace: "tenant",
|
|
||||||
Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := scopedStore.SetIn("config", "theme", "dark"); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
events := configuredStore.Watch("group")
|
|
||||||
defer configuredStore.Unwatch("group", events)
|
|
||||||
go func() {
|
|
||||||
for event := range events {
|
|
||||||
fmt.Println(event.Type, event.Group, event.Key, event.Value)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
unregister := configuredStore.OnChange(func(event store.Event) {
|
|
||||||
fmt.Println("changed", event.Group, event.Key, event.Value)
|
|
||||||
})
|
|
||||||
defer unregister()
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
@ -136,7 +85,7 @@ func main() {
|
||||||
|
|
||||||
## Test Conventions
|
## Test Conventions
|
||||||
|
|
||||||
- Test names follow `Test<File>_<Function>_<Good|Bad|Ugly>`, for example `TestEvents_Watch_Good_SpecificKey`
|
- Suffix convention: `_Good` (happy path), `_Bad` (expected errors), `_Ugly` (panics/edge)
|
||||||
- Use `New(":memory:")` unless testing persistence; use `t.TempDir()` for file-backed
|
- Use `New(":memory:")` unless testing persistence; use `t.TempDir()` for file-backed
|
||||||
- TTL tests: 1ms TTL + 5ms sleep; use `sync.WaitGroup` not sleeps for goroutine sync
|
- TTL tests: 1ms TTL + 5ms sleep; use `sync.WaitGroup` not sleeps for goroutine sync
|
||||||
- `require` for preconditions, `assert` for verifications (`testify`)
|
- `require` for preconditions, `assert` for verifications (`testify`)
|
||||||
|
|
@ -144,10 +93,10 @@ func main() {
|
||||||
## Adding a New Method
|
## Adding a New Method
|
||||||
|
|
||||||
1. Implement on `*Store` in `store.go`
|
1. Implement on `*Store` in `store.go`
|
||||||
2. If mutating, call `storeInstance.notify(Event{...})` after successful database write
|
2. If mutating, call `s.notify(Event{...})` after successful DB write
|
||||||
3. Add delegation method on `ScopedStore` in `scope.go` (prefix the group)
|
3. Add delegation method on `ScopedStore` in `scope.go` (prefix the group)
|
||||||
4. Update `checkQuota` in `scope.go` if it affects key/group counts
|
4. Update `checkQuota` in `scope.go` if it affects key/group counts
|
||||||
5. Write `Test<File>_<Function>_<Good|Bad|Ugly>` tests
|
5. Write `_Good`/`_Bad` tests
|
||||||
6. Run `go test -race ./...` and `go vet ./...`
|
6. Run `go test -race ./...` and `go vet ./...`
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
|
||||||
25
CODEX.md
25
CODEX.md
|
|
@ -1,25 +0,0 @@
|
||||||
# CODEX.md
|
|
||||||
|
|
||||||
This repository uses the same working conventions described in [`CLAUDE.md`](CLAUDE.md).
|
|
||||||
Keep the two files aligned.
|
|
||||||
|
|
||||||
## AX Notes
|
|
||||||
|
|
||||||
- Prefer descriptive names over abbreviations.
|
|
||||||
- Public comments should show real usage with concrete values.
|
|
||||||
- Keep examples in UK English.
|
|
||||||
- Prefer `StoreConfig` and `ScopedStoreConfig` literals over option chains when the configuration is already known.
|
|
||||||
- Do not add compatibility aliases; the primary API names are the contract.
|
|
||||||
- Preserve the single-connection SQLite design.
|
|
||||||
- Verify with `go test ./...`, `go test -race ./...`, and `go vet ./...` before committing.
|
|
||||||
- Use conventional commits and include the `Co-Authored-By: Virgil <virgil@lethean.io>` trailer.
|
|
||||||
|
|
||||||
## Repository Shape
|
|
||||||
|
|
||||||
- `store.go` contains the core store API and SQLite lifecycle.
|
|
||||||
- `events.go` contains mutation events, watchers, and callbacks.
|
|
||||||
- `scope.go` contains namespace isolation and quota enforcement.
|
|
||||||
- `journal.go` contains journal persistence and query helpers.
|
|
||||||
- `workspace.go` contains workspace buffering and orphan recovery.
|
|
||||||
- `compact.go` contains cold archive generation.
|
|
||||||
- `docs/` contains the package docs, architecture notes, and history.
|
|
||||||
78
README.md
78
README.md
|
|
@ -1,83 +1,39 @@
|
||||||
[](https://pkg.go.dev/dappco.re/go/store)
|
[](https://pkg.go.dev/dappco.re/go/core/store)
|
||||||
[](LICENSE.md)
|
[](LICENSE.md)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
|
|
||||||
# go-store
|
# go-store
|
||||||
|
|
||||||
Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, quota enforcement, and a reactive event system. Backed by a pure-Go SQLite driver (no CGO), uses WAL mode for concurrent reads, and enforces a single connection to keep pragma settings consistent. Supports scoped stores for multi-tenant use, Watch/Unwatch subscriptions, and OnChange callbacks for downstream event consumers.
|
Group-namespaced SQLite key-value store with TTL expiry, namespace isolation, quota enforcement, and a reactive event system. Backed by a pure-Go SQLite driver (no CGO), uses WAL mode for concurrent reads, and enforces a single connection to ensure pragma consistency. Supports scoped stores for multi-tenant use, Watch/Unwatch subscriptions, and OnChange callbacks — the designed integration point for go-ws real-time streaming.
|
||||||
|
|
||||||
**Module**: `dappco.re/go/store`
|
**Module**: `dappco.re/go/core/store`
|
||||||
**Licence**: EUPL-1.2
|
**Licence**: EUPL-1.2
|
||||||
**Language**: Go 1.26
|
**Language**: Go 1.25
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
import "dappco.re/go/core/store"
|
||||||
|
|
||||||
import (
|
st, err := store.New("/path/to/store.db") // or store.New(":memory:")
|
||||||
"fmt"
|
defer st.Close()
|
||||||
"time"
|
|
||||||
|
|
||||||
"dappco.re/go/store"
|
st.Set("config", "theme", "dark")
|
||||||
)
|
st.SetWithTTL("session", "token", "abc123", 24*time.Hour)
|
||||||
|
val, err := st.Get("config", "theme")
|
||||||
|
|
||||||
func main() {
|
// Watch for mutations
|
||||||
// Configure a persistent store with "/tmp/go-store.db", or use ":memory:" for ephemeral data.
|
w := st.Watch("config", "*")
|
||||||
storeInstance, err := store.NewConfigured(store.StoreConfig{
|
defer st.Unwatch(w)
|
||||||
DatabasePath: "/tmp/go-store.db",
|
for e := range w.Ch { fmt.Println(e.Type, e.Key) }
|
||||||
Journal: store.JournalConfiguration{
|
|
||||||
EndpointURL: "http://127.0.0.1:8086",
|
|
||||||
Organisation: "core",
|
|
||||||
BucketName: "events",
|
|
||||||
},
|
|
||||||
PurgeInterval: 30 * time.Second,
|
|
||||||
WorkspaceStateDirectory: "/tmp/core-state",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
if err := storeInstance.Set("config", "colour", "blue"); err != nil {
|
// Scoped store for tenant isolation
|
||||||
return
|
sc, _ := store.NewScoped(st, "tenant-42")
|
||||||
}
|
sc.Set("prefs", "locale", "en-GB")
|
||||||
if err := storeInstance.SetWithTTL("session", "token", "abc123", 24*time.Hour); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
colourValue, err := storeInstance.Get("config", "colour")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(colourValue)
|
|
||||||
|
|
||||||
// Watch "config" mutations and print each event as it arrives.
|
|
||||||
events := storeInstance.Watch("config")
|
|
||||||
defer storeInstance.Unwatch("config", events)
|
|
||||||
go func() {
|
|
||||||
for event := range events {
|
|
||||||
fmt.Println(event.Type, event.Group, event.Key, event.Value)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Store tenant-42 preferences under the "tenant-42:" prefix.
|
|
||||||
scopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{
|
|
||||||
Namespace: "tenant-42",
|
|
||||||
Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := scopedStore.SetIn("preferences", "locale", "en-GB"); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Agent Conventions](CODEX.md) - Codex-facing repo rules and AX notes
|
|
||||||
- [AX RFC](docs/RFC-CORE-008-AGENT-EXPERIENCE.md) - naming, comment, and path conventions for agent consumers
|
|
||||||
- [Architecture](docs/architecture.md) — storage layer, group/key model, TTL expiry, event system, namespace isolation
|
- [Architecture](docs/architecture.md) — storage layer, group/key model, TTL expiry, event system, namespace isolation
|
||||||
- [Development Guide](docs/development.md) — prerequisites, test patterns, benchmarks, adding methods
|
- [Development Guide](docs/development.md) — prerequisites, test patterns, benchmarks, adding methods
|
||||||
- [Project History](docs/history.md) — completed phases, known limitations, future considerations
|
- [Project History](docs/history.md) — completed phases, known limitations, future considerations
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
// SPDX-Licence-Identifier: EUPL-1.2
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Supplemental benchmarks beyond the core Set/Get/GetAll/FileBacked benchmarks
|
// Supplemental benchmarks beyond the core Set/Get/GetAll/FileBacked benchmarks
|
||||||
|
|
@ -16,32 +15,32 @@ func BenchmarkGetAll_VaryingSize(b *testing.B) {
|
||||||
|
|
||||||
for _, size := range sizes {
|
for _, size := range sizes {
|
||||||
b.Run(core.Sprintf("size=%d", size), func(b *testing.B) {
|
b.Run(core.Sprintf("size=%d", size), func(b *testing.B) {
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
for i := range size {
|
for i := range size {
|
||||||
_ = storeInstance.Set("bench", core.Sprintf("key-%d", i), "value")
|
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for range b.N {
|
for range b.N {
|
||||||
_, _ = storeInstance.GetAll("bench")
|
_, _ = s.GetAll("bench")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkSetGet_Parallel(b *testing.B) {
|
func BenchmarkSetGet_Parallel(b *testing.B) {
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
@ -50,84 +49,84 @@ func BenchmarkSetGet_Parallel(b *testing.B) {
|
||||||
i := 0
|
i := 0
|
||||||
for pb.Next() {
|
for pb.Next() {
|
||||||
key := core.Sprintf("key-%d", i)
|
key := core.Sprintf("key-%d", i)
|
||||||
_ = storeInstance.Set("parallel", key, "value")
|
_ = s.Set("parallel", key, "value")
|
||||||
_, _ = storeInstance.Get("parallel", key)
|
_, _ = s.Get("parallel", key)
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkCount_10K(b *testing.B) {
|
func BenchmarkCount_10K(b *testing.B) {
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
for i := range 10_000 {
|
for i := range 10_000 {
|
||||||
_ = storeInstance.Set("bench", core.Sprintf("key-%d", i), "value")
|
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for range b.N {
|
for range b.N {
|
||||||
_, _ = storeInstance.Count("bench")
|
_, _ = s.Count("bench")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkDelete(b *testing.B) {
|
func BenchmarkDelete(b *testing.B) {
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
// Pre-populate keys that will be deleted.
|
// Pre-populate keys that will be deleted.
|
||||||
for i := range b.N {
|
for i := range b.N {
|
||||||
_ = storeInstance.Set("bench", core.Sprintf("key-%d", i), "value")
|
_ = s.Set("bench", core.Sprintf("key-%d", i), "value")
|
||||||
}
|
}
|
||||||
|
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for i := range b.N {
|
for i := range b.N {
|
||||||
_ = storeInstance.Delete("bench", core.Sprintf("key-%d", i))
|
_ = s.Delete("bench", core.Sprintf("key-%d", i))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkSetWithTTL(b *testing.B) {
|
func BenchmarkSetWithTTL(b *testing.B) {
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for i := range b.N {
|
for i := range b.N {
|
||||||
_ = storeInstance.SetWithTTL("bench", core.Sprintf("key-%d", i), "value", 60_000_000_000) // 60s
|
_ = s.SetWithTTL("bench", core.Sprintf("key-%d", i), "value", 60_000_000_000) // 60s
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkRender(b *testing.B) {
|
func BenchmarkRender(b *testing.B) {
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
for i := range 50 {
|
for i := range 50 {
|
||||||
_ = storeInstance.Set("bench", core.Sprintf("key%d", i), core.Sprintf("val%d", i))
|
_ = s.Set("bench", core.Sprintf("key%d", i), core.Sprintf("val%d", i))
|
||||||
}
|
}
|
||||||
|
|
||||||
templateSource := `{{ .key0 }} {{ .key25 }} {{ .key49 }}`
|
tmpl := `{{ .key0 }} {{ .key25 }} {{ .key49 }}`
|
||||||
|
|
||||||
b.ReportAllocs()
|
b.ReportAllocs()
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for range b.N {
|
for range b.N {
|
||||||
_, _ = storeInstance.Render(templateSource, "bench")
|
_, _ = s.Render(tmpl, "bench")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
282
compact.go
282
compact.go
|
|
@ -1,282 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"compress/gzip"
|
|
||||||
"io"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
)
|
|
||||||
|
|
||||||
var defaultArchiveOutputDirectory = ".core/archive/"
|
|
||||||
|
|
||||||
// Usage example: `options := store.CompactOptions{Before: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC), Output: "/tmp/archive", Format: "gzip"}`
|
|
||||||
// Usage example: `result := storeInstance.Compact(store.CompactOptions{Before: time.Now().Add(-90 * 24 * time.Hour)})`
|
|
||||||
// Leave `Output` empty to write gzip JSONL archives under `.core/archive/`, or
|
|
||||||
// set `Format` to `zstd` when downstream tooling expects `.jsonl.zst`.
|
|
||||||
type CompactOptions struct {
|
|
||||||
// Usage example: `options := store.CompactOptions{Before: time.Now().Add(-90 * 24 * time.Hour)}`
|
|
||||||
Before time.Time
|
|
||||||
// Usage example: `options := store.CompactOptions{Output: "/tmp/archive"}`
|
|
||||||
Output string
|
|
||||||
// Usage example: `options := store.CompactOptions{Format: "zstd"}`
|
|
||||||
Format string
|
|
||||||
// Usage example: `medium, _ := s3.New(s3.Options{Bucket: "archive"}); options := store.CompactOptions{Before: time.Now().Add(-90 * 24 * time.Hour), Medium: medium}`
|
|
||||||
// Medium routes the archive write through an io.Medium instead of the raw
|
|
||||||
// filesystem. When set, Output is the path inside the medium; leave empty
|
|
||||||
// to use `.core/archive/`. When nil, Compact falls back to the store-level
|
|
||||||
// medium (if configured via WithMedium), then to the local filesystem.
|
|
||||||
Medium Medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `normalisedOptions := (store.CompactOptions{Before: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC)}).Normalised()`
|
|
||||||
func (compactOptions CompactOptions) Normalised() CompactOptions {
|
|
||||||
if compactOptions.Output == "" {
|
|
||||||
compactOptions.Output = defaultArchiveOutputDirectory
|
|
||||||
}
|
|
||||||
compactOptions.Format = lowercaseText(core.Trim(compactOptions.Format))
|
|
||||||
if compactOptions.Format == "" {
|
|
||||||
compactOptions.Format = "gzip"
|
|
||||||
}
|
|
||||||
return compactOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := (store.CompactOptions{Before: time.Date(2026, 3, 30, 0, 0, 0, 0, time.UTC), Format: "gzip"}).Validate(); err != nil { return }`
|
|
||||||
func (compactOptions CompactOptions) Validate() error {
|
|
||||||
if compactOptions.Before.IsZero() {
|
|
||||||
return core.E(
|
|
||||||
"store.CompactOptions.Validate",
|
|
||||||
"before cutoff time is empty; use a value like time.Now().Add(-24 * time.Hour)",
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
switch lowercaseText(core.Trim(compactOptions.Format)) {
|
|
||||||
case "", "gzip", "zstd":
|
|
||||||
return nil
|
|
||||||
default:
|
|
||||||
return core.E(
|
|
||||||
"store.CompactOptions.Validate",
|
|
||||||
core.Concat(`format must be "gzip" or "zstd"; got `, compactOptions.Format),
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func lowercaseText(text string) string {
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
for _, r := range text {
|
|
||||||
builder.WriteRune(unicode.ToLower(r))
|
|
||||||
}
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
type compactArchiveEntry struct {
|
|
||||||
journalEntryID int64
|
|
||||||
journalBucketName string
|
|
||||||
journalMeasurementName string
|
|
||||||
journalFieldsJSON string
|
|
||||||
journalTagsJSON string
|
|
||||||
journalCommittedAtUnixMilli int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `result := storeInstance.Compact(store.CompactOptions{Before: time.Now().Add(-30 * 24 * time.Hour), Output: "/tmp/archive", Format: "gzip"})`
|
|
||||||
func (storeInstance *Store) Compact(options CompactOptions) core.Result {
|
|
||||||
if err := storeInstance.ensureReady("store.Compact"); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
if err := ensureJournalSchema(storeInstance.sqliteDatabase); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "ensure journal schema", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
options = options.Normalised()
|
|
||||||
if err := options.Validate(); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "validate options", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
medium := options.Medium
|
|
||||||
if medium == nil {
|
|
||||||
medium = storeInstance.medium
|
|
||||||
}
|
|
||||||
|
|
||||||
filesystem := (&core.Fs{}).NewUnrestricted()
|
|
||||||
if medium == nil {
|
|
||||||
if result := filesystem.EnsureDir(options.Output); !result.OK {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "ensure archive directory", result.Value.(error)), OK: false}
|
|
||||||
}
|
|
||||||
} else if err := ensureMediumDir(medium, options.Output); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "ensure medium archive directory", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, queryErr := storeInstance.sqliteDatabase.Query(
|
|
||||||
"SELECT entry_id, bucket_name, measurement, fields_json, tags_json, committed_at FROM "+journalEntriesTableName+" WHERE archived_at IS NULL AND committed_at < ? ORDER BY committed_at, entry_id",
|
|
||||||
options.Before.UnixMilli(),
|
|
||||||
)
|
|
||||||
if queryErr != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "query journal rows", queryErr), OK: false}
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var archiveEntries []compactArchiveEntry
|
|
||||||
for rows.Next() {
|
|
||||||
var entry compactArchiveEntry
|
|
||||||
if err := rows.Scan(
|
|
||||||
&entry.journalEntryID,
|
|
||||||
&entry.journalBucketName,
|
|
||||||
&entry.journalMeasurementName,
|
|
||||||
&entry.journalFieldsJSON,
|
|
||||||
&entry.journalTagsJSON,
|
|
||||||
&entry.journalCommittedAtUnixMilli,
|
|
||||||
); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "scan journal row", err), OK: false}
|
|
||||||
}
|
|
||||||
archiveEntries = append(archiveEntries, entry)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "iterate journal rows", err), OK: false}
|
|
||||||
}
|
|
||||||
if len(archiveEntries) == 0 {
|
|
||||||
return core.Result{Value: "", OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
outputPath := compactOutputPath(options.Output, options.Format)
|
|
||||||
var (
|
|
||||||
file io.WriteCloser
|
|
||||||
createErr error
|
|
||||||
)
|
|
||||||
if medium != nil {
|
|
||||||
file, createErr = medium.Create(outputPath)
|
|
||||||
if createErr != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "create archive via medium", createErr), OK: false}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
archiveFileResult := filesystem.Create(outputPath)
|
|
||||||
if !archiveFileResult.OK {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "create archive file", archiveFileResult.Value.(error)), OK: false}
|
|
||||||
}
|
|
||||||
existingFile, ok := archiveFileResult.Value.(io.WriteCloser)
|
|
||||||
if !ok {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "archive file is not writable", nil), OK: false}
|
|
||||||
}
|
|
||||||
file = existingFile
|
|
||||||
}
|
|
||||||
archiveFileClosed := false
|
|
||||||
defer func() {
|
|
||||||
if !archiveFileClosed {
|
|
||||||
_ = file.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
writer, err := archiveWriter(file, options.Format)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
archiveWriteFinished := false
|
|
||||||
defer func() {
|
|
||||||
if !archiveWriteFinished {
|
|
||||||
_ = writer.Close()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
for _, entry := range archiveEntries {
|
|
||||||
lineMap, err := archiveEntryLine(entry)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
lineJSON, err := marshalJSONText(lineMap, "store.Compact", "marshal archive line")
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
if _, err := io.WriteString(writer, lineJSON+"\n"); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "write archive line", err), OK: false}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := writer.Close(); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "close archive writer", err), OK: false}
|
|
||||||
}
|
|
||||||
archiveWriteFinished = true
|
|
||||||
if err := file.Close(); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "close archive file", err), OK: false}
|
|
||||||
}
|
|
||||||
archiveFileClosed = true
|
|
||||||
|
|
||||||
transaction, err := storeInstance.sqliteDatabase.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "begin archive transaction", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
committed := false
|
|
||||||
defer func() {
|
|
||||||
if !committed {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
archivedAt := time.Now().UnixMilli()
|
|
||||||
for _, entry := range archiveEntries {
|
|
||||||
if _, err := transaction.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET archived_at = ? WHERE entry_id = ?",
|
|
||||||
archivedAt,
|
|
||||||
entry.journalEntryID,
|
|
||||||
); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "mark journal row archived", err), OK: false}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := transaction.Commit(); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Compact", "commit archive transaction", err), OK: false}
|
|
||||||
}
|
|
||||||
committed = true
|
|
||||||
|
|
||||||
return core.Result{Value: outputPath, OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveEntryLine(entry compactArchiveEntry) (map[string]any, error) {
|
|
||||||
fields := make(map[string]any)
|
|
||||||
fieldsResult := core.JSONUnmarshalString(entry.journalFieldsJSON, &fields)
|
|
||||||
if !fieldsResult.OK {
|
|
||||||
return nil, core.E("store.Compact", "unmarshal fields", fieldsResult.Value.(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
tags := make(map[string]string)
|
|
||||||
tagsResult := core.JSONUnmarshalString(entry.journalTagsJSON, &tags)
|
|
||||||
if !tagsResult.OK {
|
|
||||||
return nil, core.E("store.Compact", "unmarshal tags", tagsResult.Value.(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
return map[string]any{
|
|
||||||
"bucket": entry.journalBucketName,
|
|
||||||
"measurement": entry.journalMeasurementName,
|
|
||||||
"fields": fields,
|
|
||||||
"tags": tags,
|
|
||||||
"committed_at": entry.journalCommittedAtUnixMilli,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func archiveWriter(writer io.Writer, format string) (io.WriteCloser, error) {
|
|
||||||
switch format {
|
|
||||||
case "gzip":
|
|
||||||
return gzip.NewWriter(writer), nil
|
|
||||||
case "zstd":
|
|
||||||
zstdWriter, err := zstd.NewWriter(writer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.Compact", "create zstd writer", err)
|
|
||||||
}
|
|
||||||
return zstdWriter, nil
|
|
||||||
default:
|
|
||||||
return nil, core.E("store.Compact", core.Concat("unsupported archive format: ", format), nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func compactOutputPath(outputDirectory, format string) string {
|
|
||||||
extension := ".jsonl"
|
|
||||||
if format == "gzip" {
|
|
||||||
extension = ".jsonl.gz"
|
|
||||||
}
|
|
||||||
if format == "zstd" {
|
|
||||||
extension = ".jsonl.zst"
|
|
||||||
}
|
|
||||||
// Include nanoseconds so two compactions in the same second never collide.
|
|
||||||
filename := core.Concat("journal-", time.Now().UTC().Format("20060102-150405.000000000"), extension)
|
|
||||||
return joinPath(outputDirectory, filename)
|
|
||||||
}
|
|
||||||
243
compact_test.go
243
compact_test.go
|
|
@ -1,243 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"compress/gzip"
|
|
||||||
"io"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/klauspost/compress/zstd"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCompact_Compact_Good_GzipArchive(t *testing.T) {
|
|
||||||
outputDirectory := useArchiveOutputDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"like": 2}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET committed_at = ? WHERE measurement = ?",
|
|
||||||
time.Now().Add(-48*time.Hour).UnixMilli(),
|
|
||||||
"session-a",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result := storeInstance.Compact(CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Output: outputDirectory,
|
|
||||||
Format: "gzip",
|
|
||||||
})
|
|
||||||
require.True(t, result.OK, "compact failed: %v", result.Value)
|
|
||||||
|
|
||||||
archivePath, ok := result.Value.(string)
|
|
||||||
require.True(t, ok, "unexpected archive path type: %T", result.Value)
|
|
||||||
assert.True(t, testFilesystem().Exists(archivePath))
|
|
||||||
|
|
||||||
archiveData := requireCoreReadBytes(t, archivePath)
|
|
||||||
reader, err := gzip.NewReader(bytes.NewReader(archiveData))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
decompressedData, err := io.ReadAll(reader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
lines := core.Split(core.Trim(string(decompressedData)), "\n")
|
|
||||||
require.Len(t, lines, 1)
|
|
||||||
|
|
||||||
archivedRow := make(map[string]any)
|
|
||||||
unmarshalResult := core.JSONUnmarshalString(lines[0], &archivedRow)
|
|
||||||
require.True(t, unmarshalResult.OK, "archive line unmarshal failed: %v", unmarshalResult.Value)
|
|
||||||
assert.Equal(t, "session-a", archivedRow["measurement"])
|
|
||||||
|
|
||||||
remainingRows := requireResultRows(t, storeInstance.QueryJournal(""))
|
|
||||||
require.Len(t, remainingRows, 1)
|
|
||||||
assert.Equal(t, "session-b", remainingRows[0]["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_Compact_Good_ZstdArchive(t *testing.T) {
|
|
||||||
outputDirectory := useArchiveOutputDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET committed_at = ? WHERE measurement = ?",
|
|
||||||
time.Now().Add(-48*time.Hour).UnixMilli(),
|
|
||||||
"session-a",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
result := storeInstance.Compact(CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Output: outputDirectory,
|
|
||||||
Format: "zstd",
|
|
||||||
})
|
|
||||||
require.True(t, result.OK, "compact failed: %v", result.Value)
|
|
||||||
|
|
||||||
archivePath, ok := result.Value.(string)
|
|
||||||
require.True(t, ok, "unexpected archive path type: %T", result.Value)
|
|
||||||
assert.True(t, testFilesystem().Exists(archivePath))
|
|
||||||
assert.Contains(t, archivePath, ".jsonl.zst")
|
|
||||||
|
|
||||||
archiveData := requireCoreReadBytes(t, archivePath)
|
|
||||||
reader, err := zstd.NewReader(bytes.NewReader(archiveData))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
decompressedData, err := io.ReadAll(reader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
lines := core.Split(core.Trim(string(decompressedData)), "\n")
|
|
||||||
require.Len(t, lines, 1)
|
|
||||||
|
|
||||||
archivedRow := make(map[string]any)
|
|
||||||
unmarshalResult := core.JSONUnmarshalString(lines[0], &archivedRow)
|
|
||||||
require.True(t, unmarshalResult.OK, "archive line unmarshal failed: %v", unmarshalResult.Value)
|
|
||||||
assert.Equal(t, "session-a", archivedRow["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_Compact_Good_NoRows(t *testing.T) {
|
|
||||||
outputDirectory := useArchiveOutputDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
result := storeInstance.Compact(CompactOptions{
|
|
||||||
Before: time.Now(),
|
|
||||||
Output: outputDirectory,
|
|
||||||
Format: "gzip",
|
|
||||||
})
|
|
||||||
require.True(t, result.OK, "compact failed: %v", result.Value)
|
|
||||||
assert.Equal(t, "", result.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_Compact_Good_DeterministicOrderingForSameTimestamp(t *testing.T) {
|
|
||||||
outputDirectory := useArchiveOutputDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
require.NoError(t, ensureJournalSchema(storeInstance.sqliteDatabase))
|
|
||||||
|
|
||||||
committedAt := time.Now().Add(-48 * time.Hour).UnixMilli()
|
|
||||||
require.NoError(t, commitJournalEntry(
|
|
||||||
storeInstance.sqliteDatabase,
|
|
||||||
"events",
|
|
||||||
"session-b",
|
|
||||||
`{"like":2}`,
|
|
||||||
`{"workspace":"session-b"}`,
|
|
||||||
committedAt,
|
|
||||||
))
|
|
||||||
require.NoError(t, commitJournalEntry(
|
|
||||||
storeInstance.sqliteDatabase,
|
|
||||||
"events",
|
|
||||||
"session-a",
|
|
||||||
`{"like":1}`,
|
|
||||||
`{"workspace":"session-a"}`,
|
|
||||||
committedAt,
|
|
||||||
))
|
|
||||||
|
|
||||||
result := storeInstance.Compact(CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Output: outputDirectory,
|
|
||||||
Format: "gzip",
|
|
||||||
})
|
|
||||||
require.True(t, result.OK, "compact failed: %v", result.Value)
|
|
||||||
|
|
||||||
archivePath, ok := result.Value.(string)
|
|
||||||
require.True(t, ok, "unexpected archive path type: %T", result.Value)
|
|
||||||
|
|
||||||
archiveData := requireCoreReadBytes(t, archivePath)
|
|
||||||
reader, err := gzip.NewReader(bytes.NewReader(archiveData))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer reader.Close()
|
|
||||||
|
|
||||||
decompressedData, err := io.ReadAll(reader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
lines := core.Split(core.Trim(string(decompressedData)), "\n")
|
|
||||||
require.Len(t, lines, 2)
|
|
||||||
|
|
||||||
firstArchivedRow := make(map[string]any)
|
|
||||||
unmarshalResult := core.JSONUnmarshalString(lines[0], &firstArchivedRow)
|
|
||||||
require.True(t, unmarshalResult.OK, "archive line unmarshal failed: %v", unmarshalResult.Value)
|
|
||||||
assert.Equal(t, "session-b", firstArchivedRow["measurement"])
|
|
||||||
|
|
||||||
secondArchivedRow := make(map[string]any)
|
|
||||||
unmarshalResult = core.JSONUnmarshalString(lines[1], &secondArchivedRow)
|
|
||||||
require.True(t, unmarshalResult.OK, "archive line unmarshal failed: %v", unmarshalResult.Value)
|
|
||||||
assert.Equal(t, "session-a", secondArchivedRow["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_CompactOptions_Good_Normalised(t *testing.T) {
|
|
||||||
options := (CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
}).Normalised()
|
|
||||||
|
|
||||||
assert.Equal(t, defaultArchiveOutputDirectory, options.Output)
|
|
||||||
assert.Equal(t, "gzip", options.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_CompactOptions_Good_Validate(t *testing.T) {
|
|
||||||
err := (CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Format: "zstd",
|
|
||||||
}).Validate()
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_CompactOptions_Bad_ValidateMissingCutoff(t *testing.T) {
|
|
||||||
err := (CompactOptions{
|
|
||||||
Format: "gzip",
|
|
||||||
}).Validate()
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "before cutoff time is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_CompactOptions_Good_ValidateNormalisesFormatCase(t *testing.T) {
|
|
||||||
err := (CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Format: " GZIP ",
|
|
||||||
}).Validate()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
options := (CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Format: " ZsTd ",
|
|
||||||
}).Normalised()
|
|
||||||
assert.Equal(t, "zstd", options.Format)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_CompactOptions_Good_ValidateWhitespaceFormatDefaultsToGzip(t *testing.T) {
|
|
||||||
options := (CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Format: " ",
|
|
||||||
}).Normalised()
|
|
||||||
|
|
||||||
assert.Equal(t, "gzip", options.Format)
|
|
||||||
require.NoError(t, options.Validate())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCompact_CompactOptions_Bad_ValidateUnsupportedFormat(t *testing.T) {
|
|
||||||
err := (CompactOptions{
|
|
||||||
Before: time.Now().Add(-24 * time.Hour),
|
|
||||||
Format: "zip",
|
|
||||||
}).Validate()
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), `format must be "gzip" or "zstd"`)
|
|
||||||
}
|
|
||||||
|
|
@ -1,288 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"go/ast"
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"io/fs"
|
|
||||||
"slices"
|
|
||||||
"testing"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConventions_Imports_Good_Banned(t *testing.T) {
|
|
||||||
files := repoGoFiles(t, func(name string) bool {
|
|
||||||
return core.HasSuffix(name, ".go")
|
|
||||||
})
|
|
||||||
|
|
||||||
bannedImports := []string{
|
|
||||||
"encoding/json",
|
|
||||||
"errors",
|
|
||||||
"fmt",
|
|
||||||
"os",
|
|
||||||
"os/exec",
|
|
||||||
"path/filepath",
|
|
||||||
"strings",
|
|
||||||
}
|
|
||||||
|
|
||||||
var banned []string
|
|
||||||
for _, path := range files {
|
|
||||||
file := parseGoFile(t, path)
|
|
||||||
for _, spec := range file.Imports {
|
|
||||||
importPath := trimImportPath(spec.Path.Value)
|
|
||||||
if core.HasPrefix(importPath, "forge.lthn.ai/") || slices.Contains(bannedImports, importPath) {
|
|
||||||
banned = append(banned, core.Concat(path, ": ", importPath))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(banned)
|
|
||||||
assert.Empty(t, banned, "banned imports should not appear in repository Go files")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_TestNaming_Good_StrictPattern(t *testing.T) {
|
|
||||||
files := repoGoFiles(t, func(name string) bool {
|
|
||||||
return core.HasSuffix(name, "_test.go")
|
|
||||||
})
|
|
||||||
|
|
||||||
allowedClasses := []string{"Good", "Bad", "Ugly"}
|
|
||||||
var invalid []string
|
|
||||||
for _, path := range files {
|
|
||||||
expectedPrefix := testNamePrefix(path)
|
|
||||||
file := parseGoFile(t, path)
|
|
||||||
for _, decl := range file.Decls {
|
|
||||||
fn, ok := decl.(*ast.FuncDecl)
|
|
||||||
if !ok || fn.Recv != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := fn.Name.Name
|
|
||||||
if !core.HasPrefix(name, "Test") || name == "TestMain" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !core.HasPrefix(name, expectedPrefix) {
|
|
||||||
invalid = append(invalid, core.Concat(path, ": ", name))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parts := core.Split(core.TrimPrefix(name, expectedPrefix), "_")
|
|
||||||
if len(parts) < 2 || parts[0] == "" || !slices.Contains(allowedClasses, parts[1]) {
|
|
||||||
invalid = append(invalid, core.Concat(path, ": ", name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(invalid)
|
|
||||||
assert.Empty(t, invalid, "top-level tests must follow Test<File>_<Function>_<Good|Bad|Ugly>")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_Exports_Good_UsageExamples(t *testing.T) {
|
|
||||||
files := repoGoFiles(t, func(name string) bool {
|
|
||||||
return core.HasSuffix(name, ".go") && !core.HasSuffix(name, "_test.go")
|
|
||||||
})
|
|
||||||
|
|
||||||
var missing []string
|
|
||||||
for _, path := range files {
|
|
||||||
file := parseGoFile(t, path)
|
|
||||||
for _, decl := range file.Decls {
|
|
||||||
switch node := decl.(type) {
|
|
||||||
case *ast.FuncDecl:
|
|
||||||
if !node.Name.IsExported() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !core.Contains(commentText(node.Doc), "Usage example:") {
|
|
||||||
missing = append(missing, core.Concat(path, ": ", node.Name.Name))
|
|
||||||
}
|
|
||||||
case *ast.GenDecl:
|
|
||||||
for _, spec := range node.Specs {
|
|
||||||
switch item := spec.(type) {
|
|
||||||
case *ast.TypeSpec:
|
|
||||||
if !item.Name.IsExported() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
|
|
||||||
missing = append(missing, core.Concat(path, ": ", item.Name.Name))
|
|
||||||
}
|
|
||||||
case *ast.ValueSpec:
|
|
||||||
for _, name := range item.Names {
|
|
||||||
if !name.IsExported() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !core.Contains(commentText(item.Doc, node.Doc), "Usage example:") {
|
|
||||||
missing = append(missing, core.Concat(path, ": ", name.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(missing)
|
|
||||||
assert.Empty(t, missing, "exported declarations must include a usage example in their doc comment")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_Exports_Good_FieldUsageExamples(t *testing.T) {
|
|
||||||
files := repoGoFiles(t, func(name string) bool {
|
|
||||||
return core.HasSuffix(name, ".go") && !core.HasSuffix(name, "_test.go")
|
|
||||||
})
|
|
||||||
|
|
||||||
var missing []string
|
|
||||||
for _, path := range files {
|
|
||||||
file := parseGoFile(t, path)
|
|
||||||
for _, decl := range file.Decls {
|
|
||||||
node, ok := decl.(*ast.GenDecl)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, spec := range node.Specs {
|
|
||||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
|
||||||
if !ok || !typeSpec.Name.IsExported() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
structType, ok := typeSpec.Type.(*ast.StructType)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, field := range structType.Fields.List {
|
|
||||||
for _, fieldName := range field.Names {
|
|
||||||
if !fieldName.IsExported() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !core.Contains(commentText(field.Doc), "Usage example:") {
|
|
||||||
missing = append(missing, core.Concat(path, ": ", typeSpec.Name.Name, ".", fieldName.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(missing)
|
|
||||||
assert.Empty(t, missing, "exported struct fields must include a usage example in their doc comment")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConventions_Exports_Good_NoCompatibilityAliases(t *testing.T) {
|
|
||||||
files := repoGoFiles(t, func(name string) bool {
|
|
||||||
return core.HasSuffix(name, ".go") && !core.HasSuffix(name, "_test.go")
|
|
||||||
})
|
|
||||||
|
|
||||||
var invalid []string
|
|
||||||
for _, path := range files {
|
|
||||||
file := parseGoFile(t, path)
|
|
||||||
for _, decl := range file.Decls {
|
|
||||||
switch node := decl.(type) {
|
|
||||||
case *ast.GenDecl:
|
|
||||||
for _, spec := range node.Specs {
|
|
||||||
switch item := spec.(type) {
|
|
||||||
case *ast.TypeSpec:
|
|
||||||
if item.Name.Name == "KV" {
|
|
||||||
invalid = append(invalid, core.Concat(path, ": ", item.Name.Name))
|
|
||||||
}
|
|
||||||
if item.Name.Name != "Watcher" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
structType, ok := item.Type.(*ast.StructType)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, field := range structType.Fields.List {
|
|
||||||
for _, name := range field.Names {
|
|
||||||
if name.Name == "Ch" {
|
|
||||||
invalid = append(invalid, core.Concat(path, ": Watcher.Ch"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *ast.ValueSpec:
|
|
||||||
for _, name := range item.Names {
|
|
||||||
if name.Name == "ErrNotFound" || name.Name == "ErrQuotaExceeded" {
|
|
||||||
invalid = append(invalid, core.Concat(path, ": ", name.Name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(invalid)
|
|
||||||
assert.Empty(t, invalid, "legacy compatibility aliases should not appear in the public Go API")
|
|
||||||
}
|
|
||||||
|
|
||||||
func repoGoFiles(t *testing.T, keep func(name string) bool) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
result := testFilesystem().List(".")
|
|
||||||
requireCoreOK(t, result)
|
|
||||||
|
|
||||||
entries, ok := result.Value.([]fs.DirEntry)
|
|
||||||
require.True(t, ok, "unexpected directory entry type: %T", result.Value)
|
|
||||||
|
|
||||||
var files []string
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() || !keep(entry.Name()) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
files = append(files, entry.Name())
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.Sort(files)
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseGoFile(t *testing.T, path string) *ast.File {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ParseComments)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimImportPath(value string) string {
|
|
||||||
return core.TrimSuffix(core.TrimPrefix(value, `"`), `"`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testNamePrefix(path string) string {
|
|
||||||
return core.Concat("Test", camelCase(core.TrimSuffix(path, "_test.go")), "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
func camelCase(value string) string {
|
|
||||||
parts := core.Split(value, "_")
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
for _, part := range parts {
|
|
||||||
if part == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
builder.WriteString(upperFirst(part))
|
|
||||||
}
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func upperFirst(value string) string {
|
|
||||||
runes := []rune(value)
|
|
||||||
if len(runes) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
runes[0] = unicode.ToUpper(runes[0])
|
|
||||||
return string(runes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func commentText(groups ...*ast.CommentGroup) string {
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
for _, group := range groups {
|
|
||||||
if group == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text := core.Trim(group.Text())
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if builder.Len() > 0 {
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
builder.WriteString(text)
|
|
||||||
}
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
642
coverage_test.go
642
coverage_test.go
|
|
@ -1,14 +1,11 @@
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"dappco.re/go/core"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"database/sql/driver"
|
"os"
|
||||||
"io"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -17,60 +14,61 @@ import (
|
||||||
// New — schema error path
|
// New — schema error path
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCoverage_New_Bad_SchemaConflict(t *testing.T) {
|
func TestNew_Bad_SchemaConflict(t *testing.T) {
|
||||||
// Pre-create a database with an INDEX named "entries". When New() runs
|
// Pre-create a database with an INDEX named "kv". When New() runs
|
||||||
// CREATE TABLE IF NOT EXISTS entries, SQLite returns an error because the
|
// CREATE TABLE IF NOT EXISTS kv, SQLite returns an error because the
|
||||||
// name "entries" is already taken by the index.
|
// name "kv" is already taken by the index.
|
||||||
databasePath := testPath(t, "conflict.db")
|
dir := t.TempDir()
|
||||||
|
dbPath := core.Path(dir, "conflict.db")
|
||||||
|
|
||||||
database, err := sql.Open("sqlite", databasePath)
|
db, err := sql.Open("sqlite", dbPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
database.SetMaxOpenConns(1)
|
db.SetMaxOpenConns(1)
|
||||||
_, err = database.Exec("PRAGMA journal_mode=WAL")
|
_, err = db.Exec("PRAGMA journal_mode=WAL")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = database.Exec("CREATE TABLE dummy (id INTEGER)")
|
_, err = db.Exec("CREATE TABLE dummy (id INTEGER)")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = database.Exec("CREATE INDEX entries ON dummy(id)")
|
_, err = db.Exec("CREATE INDEX kv ON dummy(id)")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, database.Close())
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
_, err = New(databasePath)
|
_, err = New(dbPath)
|
||||||
require.Error(t, err, "New should fail when an index named entries already exists")
|
require.Error(t, err, "New should fail when an index named kv already exists")
|
||||||
assert.Contains(t, err.Error(), "store.New: ensure schema")
|
assert.Contains(t, err.Error(), "store.New: schema")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GetAll — scan error path
|
// GetAll — scan error path
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCoverage_GetAll_Bad_ScanError(t *testing.T) {
|
func TestGetAll_Bad_ScanError(t *testing.T) {
|
||||||
// Trigger a scan error by inserting a row with a NULL key. The production
|
// Trigger a scan error by inserting a row with a NULL key. The production
|
||||||
// code scans into plain strings, which cannot represent NULL.
|
// code scans into plain strings, which cannot represent NULL.
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
// Insert a normal row first so the query returns results.
|
// Insert a normal row first so the query returns results.
|
||||||
require.NoError(t, storeInstance.Set("g", "good", "value"))
|
require.NoError(t, s.Set("g", "good", "value"))
|
||||||
|
|
||||||
// Restructure the table to allow NULLs, then insert a NULL-key row.
|
// Restructure the table to allow NULLs, then insert a NULL-key row.
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("ALTER TABLE entries RENAME TO entries_backup")
|
_, err = s.db.Exec("ALTER TABLE kv RENAME TO kv_backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(`CREATE TABLE entries (
|
_, err = s.db.Exec(`CREATE TABLE kv (
|
||||||
group_name TEXT,
|
grp TEXT,
|
||||||
entry_key TEXT,
|
key TEXT,
|
||||||
entry_value TEXT,
|
value TEXT,
|
||||||
expires_at INTEGER
|
expires_at INTEGER
|
||||||
)`)
|
)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("INSERT INTO entries SELECT * FROM entries_backup")
|
_, err = s.db.Exec("INSERT INTO kv SELECT * FROM kv_backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("INSERT INTO entries (group_name, entry_key, entry_value) VALUES ('g', NULL, 'null-key-val')")
|
_, err = s.db.Exec("INSERT INTO kv (grp, key, value) VALUES ('g', NULL, 'null-key-val')")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("DROP TABLE entries_backup")
|
_, err = s.db.Exec("DROP TABLE kv_backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = storeInstance.GetAll("g")
|
_, err = s.GetAll("g")
|
||||||
require.Error(t, err, "GetAll should fail when a row contains a NULL key")
|
require.Error(t, err, "GetAll should fail when a row contains a NULL key")
|
||||||
assert.Contains(t, err.Error(), "store.All: scan")
|
assert.Contains(t, err.Error(), "store.All: scan")
|
||||||
}
|
}
|
||||||
|
|
@ -79,57 +77,60 @@ func TestCoverage_GetAll_Bad_ScanError(t *testing.T) {
|
||||||
// GetAll — rows iteration error path
|
// GetAll — rows iteration error path
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCoverage_GetAll_Bad_RowsError(t *testing.T) {
|
func TestGetAll_Bad_RowsError(t *testing.T) {
|
||||||
// Trigger rows.Err() by corrupting the database file so that iteration
|
// Trigger rows.Err() by corrupting the database file so that iteration
|
||||||
// starts successfully but encounters a malformed page mid-scan.
|
// starts successfully but encounters a malformed page mid-scan.
|
||||||
databasePath := testPath(t, "corrupt-getall.db")
|
dir := t.TempDir()
|
||||||
|
dbPath := core.Path(dir, "corrupt-getall.db")
|
||||||
|
|
||||||
storeInstance, err := New(databasePath)
|
s, err := New(dbPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Insert enough rows to span multiple database pages.
|
// Insert enough rows to span multiple database pages.
|
||||||
const rows = 5000
|
const rows = 5000
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
require.NoError(t, storeInstance.Set("g",
|
require.NoError(t, s.Set("g",
|
||||||
core.Sprintf("key-%06d", i),
|
core.Sprintf("key-%06d", i),
|
||||||
core.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i)))
|
core.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i)))
|
||||||
}
|
}
|
||||||
storeInstance.Close()
|
s.Close()
|
||||||
|
|
||||||
// Force a WAL checkpoint so all data is in the main database file.
|
// Force a WAL checkpoint so all data is in the main database file.
|
||||||
rawDatabase, err := sql.Open("sqlite", databasePath)
|
raw, err := sql.Open("sqlite", dbPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
rawDatabase.SetMaxOpenConns(1)
|
raw.SetMaxOpenConns(1)
|
||||||
_, err = rawDatabase.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
_, err = raw.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, rawDatabase.Close())
|
require.NoError(t, raw.Close())
|
||||||
|
|
||||||
// Corrupt data pages in the latter portion of the file (skip the first
|
// Corrupt data pages in the latter portion of the file (skip the first
|
||||||
// pages which hold the schema).
|
// pages which hold the schema).
|
||||||
data := requireCoreReadBytes(t, databasePath)
|
info, err := os.Stat(dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Greater(t, info.Size(), int64(16384), "DB should be large enough to corrupt")
|
||||||
|
|
||||||
|
f, err := os.OpenFile(dbPath, os.O_RDWR, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
garbage := make([]byte, 4096)
|
garbage := make([]byte, 4096)
|
||||||
for i := range garbage {
|
for i := range garbage {
|
||||||
garbage[i] = 0xFF
|
garbage[i] = 0xFF
|
||||||
}
|
}
|
||||||
require.Greater(t, len(data), len(garbage)*2, "database file should be large enough to corrupt")
|
offset := info.Size() * 3 / 4
|
||||||
offset := len(data) * 3 / 4
|
_, err = f.WriteAt(garbage, offset)
|
||||||
maxOffset := len(data) - (len(garbage) * 2)
|
require.NoError(t, err)
|
||||||
if offset > maxOffset {
|
_, err = f.WriteAt(garbage, offset+4096)
|
||||||
offset = maxOffset
|
require.NoError(t, err)
|
||||||
}
|
require.NoError(t, f.Close())
|
||||||
copy(data[offset:offset+len(garbage)], garbage)
|
|
||||||
copy(data[offset+len(garbage):offset+(len(garbage)*2)], garbage)
|
|
||||||
requireCoreWriteBytes(t, databasePath, data)
|
|
||||||
|
|
||||||
// Remove WAL/SHM so the reopened connection reads from the main file.
|
// Remove WAL/SHM so the reopened connection reads from the main file.
|
||||||
_ = testFilesystem().Delete(databasePath + "-wal")
|
os.Remove(dbPath + "-wal")
|
||||||
_ = testFilesystem().Delete(databasePath + "-shm")
|
os.Remove(dbPath + "-shm")
|
||||||
|
|
||||||
reopenedStore, err := New(databasePath)
|
s2, err := New(dbPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer reopenedStore.Close()
|
defer s2.Close()
|
||||||
|
|
||||||
_, err = reopenedStore.GetAll("g")
|
_, err = s2.GetAll("g")
|
||||||
require.Error(t, err, "GetAll should fail on corrupted database pages")
|
require.Error(t, err, "GetAll should fail on corrupted database pages")
|
||||||
assert.Contains(t, err.Error(), "store.All: rows")
|
assert.Contains(t, err.Error(), "store.All: rows")
|
||||||
}
|
}
|
||||||
|
|
@ -138,31 +139,31 @@ func TestCoverage_GetAll_Bad_RowsError(t *testing.T) {
|
||||||
// Render — scan error path
|
// Render — scan error path
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCoverage_Render_Bad_ScanError(t *testing.T) {
|
func TestRender_Bad_ScanError(t *testing.T) {
|
||||||
// Same NULL-key technique as TestCoverage_GetAll_Bad_ScanError.
|
// Same NULL-key technique as TestGetAll_Bad_ScanError.
|
||||||
storeInstance, err := New(":memory:")
|
s, err := New(":memory:")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g", "good", "value"))
|
require.NoError(t, s.Set("g", "good", "value"))
|
||||||
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("ALTER TABLE entries RENAME TO entries_backup")
|
_, err = s.db.Exec("ALTER TABLE kv RENAME TO kv_backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(`CREATE TABLE entries (
|
_, err = s.db.Exec(`CREATE TABLE kv (
|
||||||
group_name TEXT,
|
grp TEXT,
|
||||||
entry_key TEXT,
|
key TEXT,
|
||||||
entry_value TEXT,
|
value TEXT,
|
||||||
expires_at INTEGER
|
expires_at INTEGER
|
||||||
)`)
|
)`)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("INSERT INTO entries SELECT * FROM entries_backup")
|
_, err = s.db.Exec("INSERT INTO kv SELECT * FROM kv_backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("INSERT INTO entries (group_name, entry_key, entry_value) VALUES ('g', NULL, 'null-key-val')")
|
_, err = s.db.Exec("INSERT INTO kv (grp, key, value) VALUES ('g', NULL, 'null-key-val')")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("DROP TABLE entries_backup")
|
_, err = s.db.Exec("DROP TABLE kv_backup")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = storeInstance.Render("{{ .good }}", "g")
|
_, err = s.Render("{{ .good }}", "g")
|
||||||
require.Error(t, err, "Render should fail when a row contains a NULL key")
|
require.Error(t, err, "Render should fail when a row contains a NULL key")
|
||||||
assert.Contains(t, err.Error(), "store.All: scan")
|
assert.Contains(t, err.Error(), "store.All: scan")
|
||||||
}
|
}
|
||||||
|
|
@ -171,496 +172,53 @@ func TestCoverage_Render_Bad_ScanError(t *testing.T) {
|
||||||
// Render — rows iteration error path
|
// Render — rows iteration error path
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestCoverage_Render_Bad_RowsError(t *testing.T) {
|
func TestRender_Bad_RowsError(t *testing.T) {
|
||||||
// Same corruption technique as TestCoverage_GetAll_Bad_RowsError.
|
// Same corruption technique as TestGetAll_Bad_RowsError.
|
||||||
databasePath := testPath(t, "corrupt-render.db")
|
dir := t.TempDir()
|
||||||
|
dbPath := core.Path(dir, "corrupt-render.db")
|
||||||
|
|
||||||
storeInstance, err := New(databasePath)
|
s, err := New(dbPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
const rows = 5000
|
const rows = 5000
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
require.NoError(t, storeInstance.Set("g",
|
require.NoError(t, s.Set("g",
|
||||||
core.Sprintf("key-%06d", i),
|
core.Sprintf("key-%06d", i),
|
||||||
core.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i)))
|
core.Sprintf("value-with-padding-%06d-xxxxxxxxxxxxxxxxxxxxxxxx", i)))
|
||||||
}
|
}
|
||||||
storeInstance.Close()
|
s.Close()
|
||||||
|
|
||||||
rawDatabase, err := sql.Open("sqlite", databasePath)
|
raw, err := sql.Open("sqlite", dbPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
rawDatabase.SetMaxOpenConns(1)
|
raw.SetMaxOpenConns(1)
|
||||||
_, err = rawDatabase.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
_, err = raw.Exec("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, rawDatabase.Close())
|
require.NoError(t, raw.Close())
|
||||||
|
|
||||||
data := requireCoreReadBytes(t, databasePath)
|
info, err := os.Stat(dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f, err := os.OpenFile(dbPath, os.O_RDWR, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
garbage := make([]byte, 4096)
|
garbage := make([]byte, 4096)
|
||||||
for i := range garbage {
|
for i := range garbage {
|
||||||
garbage[i] = 0xFF
|
garbage[i] = 0xFF
|
||||||
}
|
}
|
||||||
require.Greater(t, len(data), len(garbage)*2, "database file should be large enough to corrupt")
|
offset := info.Size() * 3 / 4
|
||||||
offset := len(data) * 3 / 4
|
_, err = f.WriteAt(garbage, offset)
|
||||||
maxOffset := len(data) - (len(garbage) * 2)
|
|
||||||
if offset > maxOffset {
|
|
||||||
offset = maxOffset
|
|
||||||
}
|
|
||||||
copy(data[offset:offset+len(garbage)], garbage)
|
|
||||||
copy(data[offset+len(garbage):offset+(len(garbage)*2)], garbage)
|
|
||||||
requireCoreWriteBytes(t, databasePath, data)
|
|
||||||
|
|
||||||
_ = testFilesystem().Delete(databasePath + "-wal")
|
|
||||||
_ = testFilesystem().Delete(databasePath + "-shm")
|
|
||||||
|
|
||||||
reopenedStore, err := New(databasePath)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer reopenedStore.Close()
|
_, err = f.WriteAt(garbage, offset+4096)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, f.Close())
|
||||||
|
|
||||||
_, err = reopenedStore.Render("{{ . }}", "g")
|
os.Remove(dbPath + "-wal")
|
||||||
|
os.Remove(dbPath + "-shm")
|
||||||
|
|
||||||
|
s2, err := New(dbPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer s2.Close()
|
||||||
|
|
||||||
|
_, err = s2.Render("{{ . }}", "g")
|
||||||
require.Error(t, err, "Render should fail on corrupted database pages")
|
require.Error(t, err, "Render should fail on corrupted database pages")
|
||||||
assert.Contains(t, err.Error(), "store.All: rows")
|
assert.Contains(t, err.Error(), "store.All: rows")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GroupsSeq — defensive error paths
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestCoverage_GroupsSeq_Bad_ScanError(t *testing.T) {
|
|
||||||
// Trigger a scan error by inserting a row with a NULL group name. The
|
|
||||||
// production code scans into a plain string, which cannot represent NULL.
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("ALTER TABLE entries RENAME TO entries_backup")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(`CREATE TABLE entries (
|
|
||||||
group_name TEXT,
|
|
||||||
entry_key TEXT,
|
|
||||||
entry_value TEXT,
|
|
||||||
expires_at INTEGER
|
|
||||||
)`)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("INSERT INTO entries SELECT * FROM entries_backup")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("INSERT INTO entries (group_name, entry_key, entry_value) VALUES (NULL, 'k', 'v')")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec("DROP TABLE entries_backup")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
for groupName, iterationErr := range storeInstance.GroupsSeq("") {
|
|
||||||
require.Error(t, iterationErr)
|
|
||||||
assert.Empty(t, groupName)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_GroupsSeq_Bad_RowsError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
groupRows: [][]driver.Value{
|
|
||||||
{"group-a"},
|
|
||||||
},
|
|
||||||
groupRowsErr: core.E("stubSQLiteScenario", "rows iteration failed", nil),
|
|
||||||
groupRowsErrIndex: 0,
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
storeInstance := &Store{
|
|
||||||
sqliteDatabase: database,
|
|
||||||
cancelPurge: func() {},
|
|
||||||
}
|
|
||||||
|
|
||||||
for groupName, iterationErr := range storeInstance.GroupsSeq("") {
|
|
||||||
require.Error(t, iterationErr, "GroupsSeq should fail on corrupted database pages")
|
|
||||||
assert.Empty(t, groupName)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ScopedStore bulk helpers — defensive error paths
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestCoverage_ScopedStore_Bad_GroupsClosedStore(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
require.NoError(t, storeInstance.Close())
|
|
||||||
|
|
||||||
scopedStore := NewScoped(storeInstance, "tenant-a")
|
|
||||||
require.NotNil(t, scopedStore)
|
|
||||||
|
|
||||||
_, err := scopedStore.Groups("")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "store.Groups")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_ScopedStore_Bad_GroupsSeqRowsError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
groupRows: [][]driver.Value{
|
|
||||||
{"tenant-a:config"},
|
|
||||||
},
|
|
||||||
groupRowsErr: core.E("stubSQLiteScenario", "rows iteration failed", nil),
|
|
||||||
groupRowsErrIndex: 1,
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
scopedStore := &ScopedStore{
|
|
||||||
store: &Store{
|
|
||||||
sqliteDatabase: database,
|
|
||||||
cancelPurge: func() {},
|
|
||||||
},
|
|
||||||
namespace: "tenant-a",
|
|
||||||
}
|
|
||||||
|
|
||||||
var seen []string
|
|
||||||
for groupName, iterationErr := range scopedStore.GroupsSeq("") {
|
|
||||||
if iterationErr != nil {
|
|
||||||
require.Error(t, iterationErr)
|
|
||||||
assert.Empty(t, groupName)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
seen = append(seen, groupName)
|
|
||||||
}
|
|
||||||
assert.Equal(t, []string{"config"}, seen)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Stubbed SQLite driver coverage
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func TestCoverage_EnsureSchema_Bad_TableExistsQueryError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableExistsErr: core.E("stubSQLiteScenario", "sqlite master query failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := ensureSchema(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "sqlite master query failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_EnsureSchema_Good_ExistingEntriesAndLegacyMigration(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableExistsFound: true,
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "expires_at", "INTEGER", 0, nil, 0},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
require.NoError(t, ensureSchema(database))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_EnsureSchema_Bad_ExpiryColumnQueryError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableExistsFound: true,
|
|
||||||
tableInfoErr: core.E("stubSQLiteScenario", "table_info query failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := ensureSchema(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "table_info query failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_EnsureSchema_Bad_MigrationError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableExistsFound: true,
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "expires_at", "INTEGER", 0, nil, 0},
|
|
||||||
},
|
|
||||||
insertErr: core.E("stubSQLiteScenario", "insert failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := ensureSchema(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "insert failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_EnsureSchema_Bad_MigrationCommitError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableExistsFound: true,
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "expires_at", "INTEGER", 0, nil, 0},
|
|
||||||
},
|
|
||||||
commitErr: core.E("stubSQLiteScenario", "commit failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := ensureSchema(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "commit failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_TableHasColumn_Bad_QueryError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableInfoErr: core.E("stubSQLiteScenario", "table_info query failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
_, err := tableHasColumn(database, "entries", "expires_at")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "table_info query failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_EnsureExpiryColumn_Good_DuplicateColumn(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "entry_key", "TEXT", 1, nil, 0},
|
|
||||||
},
|
|
||||||
alterTableErr: core.E("stubSQLiteScenario", "duplicate column name: expires_at", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
require.NoError(t, ensureExpiryColumn(database))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_EnsureExpiryColumn_Bad_AlterTableError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "entry_key", "TEXT", 1, nil, 0},
|
|
||||||
},
|
|
||||||
alterTableErr: core.E("stubSQLiteScenario", "permission denied", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := ensureExpiryColumn(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "permission denied")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_MigrateLegacyEntriesTable_Bad_InsertError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "grp", "TEXT", 1, nil, 0},
|
|
||||||
},
|
|
||||||
insertErr: core.E("stubSQLiteScenario", "insert failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := migrateLegacyEntriesTable(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "insert failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_MigrateLegacyEntriesTable_Bad_BeginError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
beginErr: core.E("stubSQLiteScenario", "begin failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := migrateLegacyEntriesTable(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "begin failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_MigrateLegacyEntriesTable_Good_CreatesAndMigratesLegacyRows(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableInfoRows: [][]driver.Value{
|
|
||||||
{0, "grp", "TEXT", 1, nil, 0},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
require.NoError(t, migrateLegacyEntriesTable(database))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCoverage_MigrateLegacyEntriesTable_Bad_TableInfoError(t *testing.T) {
|
|
||||||
database, _ := openStubSQLiteDatabase(t, stubSQLiteScenario{
|
|
||||||
tableInfoErr: core.E("stubSQLiteScenario", "table_info query failed", nil),
|
|
||||||
})
|
|
||||||
defer database.Close()
|
|
||||||
|
|
||||||
err := migrateLegacyEntriesTable(database)
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "table_info query failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubSQLiteScenario struct {
|
|
||||||
tableExistsErr error
|
|
||||||
tableExistsFound bool
|
|
||||||
tableInfoErr error
|
|
||||||
tableInfoRows [][]driver.Value
|
|
||||||
groupRows [][]driver.Value
|
|
||||||
groupRowsErr error
|
|
||||||
groupRowsErrIndex int
|
|
||||||
alterTableErr error
|
|
||||||
createTableErr error
|
|
||||||
insertErr error
|
|
||||||
dropTableErr error
|
|
||||||
beginErr error
|
|
||||||
commitErr error
|
|
||||||
rollbackErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubSQLiteDriver struct{}
|
|
||||||
|
|
||||||
type stubSQLiteConn struct {
|
|
||||||
scenario *stubSQLiteScenario
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubSQLiteTx struct {
|
|
||||||
scenario *stubSQLiteScenario
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubSQLiteRows struct {
|
|
||||||
columns []string
|
|
||||||
rows [][]driver.Value
|
|
||||||
index int
|
|
||||||
nextErr error
|
|
||||||
nextErrIndex int
|
|
||||||
}
|
|
||||||
|
|
||||||
type stubSQLiteResult struct{}
|
|
||||||
|
|
||||||
var (
|
|
||||||
stubSQLiteDriverOnce sync.Once
|
|
||||||
stubSQLiteScenarios sync.Map
|
|
||||||
)
|
|
||||||
|
|
||||||
const stubSQLiteDriverName = "stub-sqlite"
|
|
||||||
|
|
||||||
func openStubSQLiteDatabase(t *testing.T, scenario stubSQLiteScenario) (*sql.DB, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
stubSQLiteDriverOnce.Do(func() {
|
|
||||||
sql.Register(stubSQLiteDriverName, stubSQLiteDriver{})
|
|
||||||
})
|
|
||||||
|
|
||||||
databasePath := t.Name()
|
|
||||||
stubSQLiteScenarios.Store(databasePath, &scenario)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
stubSQLiteScenarios.Delete(databasePath)
|
|
||||||
})
|
|
||||||
|
|
||||||
database, err := sql.Open(stubSQLiteDriverName, databasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return database, databasePath
|
|
||||||
}
|
|
||||||
|
|
||||||
func (stubSQLiteDriver) Open(databasePath string) (driver.Conn, error) {
|
|
||||||
scenarioValue, ok := stubSQLiteScenarios.Load(databasePath)
|
|
||||||
if !ok {
|
|
||||||
return nil, core.E("stubSQLiteDriver.Open", "missing scenario", nil)
|
|
||||||
}
|
|
||||||
return &stubSQLiteConn{scenario: scenarioValue.(*stubSQLiteScenario)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conn *stubSQLiteConn) Prepare(query string) (driver.Stmt, error) {
|
|
||||||
return nil, core.E("stubSQLiteConn.Prepare", "not implemented", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conn *stubSQLiteConn) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conn *stubSQLiteConn) Begin() (driver.Tx, error) {
|
|
||||||
return conn.BeginTx(context.Background(), driver.TxOptions{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conn *stubSQLiteConn) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
|
|
||||||
if conn.scenario.beginErr != nil {
|
|
||||||
return nil, conn.scenario.beginErr
|
|
||||||
}
|
|
||||||
return &stubSQLiteTx{scenario: conn.scenario}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conn *stubSQLiteConn) ExecContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Result, error) {
|
|
||||||
switch {
|
|
||||||
case core.Contains(query, "ALTER TABLE entries ADD COLUMN expires_at INTEGER"):
|
|
||||||
if conn.scenario.alterTableErr != nil {
|
|
||||||
return nil, conn.scenario.alterTableErr
|
|
||||||
}
|
|
||||||
case core.Contains(query, "CREATE TABLE IF NOT EXISTS entries"):
|
|
||||||
if conn.scenario.createTableErr != nil {
|
|
||||||
return nil, conn.scenario.createTableErr
|
|
||||||
}
|
|
||||||
case core.Contains(query, "INSERT OR IGNORE INTO entries"):
|
|
||||||
if conn.scenario.insertErr != nil {
|
|
||||||
return nil, conn.scenario.insertErr
|
|
||||||
}
|
|
||||||
case core.Contains(query, "DROP TABLE kv"):
|
|
||||||
if conn.scenario.dropTableErr != nil {
|
|
||||||
return nil, conn.scenario.dropTableErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return stubSQLiteResult{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (conn *stubSQLiteConn) QueryContext(ctx context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
|
||||||
switch {
|
|
||||||
case core.Contains(query, "sqlite_master"):
|
|
||||||
if conn.scenario.tableExistsErr != nil {
|
|
||||||
return nil, conn.scenario.tableExistsErr
|
|
||||||
}
|
|
||||||
if conn.scenario.tableExistsFound {
|
|
||||||
return &stubSQLiteRows{
|
|
||||||
columns: []string{"name"},
|
|
||||||
rows: [][]driver.Value{{"entries"}},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return &stubSQLiteRows{columns: []string{"name"}}, nil
|
|
||||||
case core.Contains(query, "SELECT DISTINCT "+entryGroupColumn):
|
|
||||||
return &stubSQLiteRows{
|
|
||||||
columns: []string{entryGroupColumn},
|
|
||||||
rows: conn.scenario.groupRows,
|
|
||||||
nextErr: conn.scenario.groupRowsErr,
|
|
||||||
nextErrIndex: conn.scenario.groupRowsErrIndex,
|
|
||||||
}, nil
|
|
||||||
case core.HasPrefix(query, "PRAGMA table_info("):
|
|
||||||
if conn.scenario.tableInfoErr != nil {
|
|
||||||
return nil, conn.scenario.tableInfoErr
|
|
||||||
}
|
|
||||||
return &stubSQLiteRows{
|
|
||||||
columns: []string{"cid", "name", "type", "notnull", "dflt_value", "pk"},
|
|
||||||
rows: conn.scenario.tableInfoRows,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
return nil, core.E("stubSQLiteConn.QueryContext", "unexpected query", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transaction *stubSQLiteTx) Commit() error {
|
|
||||||
if transaction.scenario.commitErr != nil {
|
|
||||||
return transaction.scenario.commitErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (transaction *stubSQLiteTx) Rollback() error {
|
|
||||||
if transaction.scenario.rollbackErr != nil {
|
|
||||||
return transaction.scenario.rollbackErr
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rows *stubSQLiteRows) Columns() []string {
|
|
||||||
return rows.columns
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rows *stubSQLiteRows) Close() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rows *stubSQLiteRows) Next(dest []driver.Value) error {
|
|
||||||
if rows.nextErr != nil && rows.index == rows.nextErrIndex {
|
|
||||||
rows.index++
|
|
||||||
return rows.nextErr
|
|
||||||
}
|
|
||||||
if rows.index >= len(rows.rows) {
|
|
||||||
return io.EOF
|
|
||||||
}
|
|
||||||
row := rows.rows[rows.index]
|
|
||||||
rows.index++
|
|
||||||
for i := range dest {
|
|
||||||
dest[i] = nil
|
|
||||||
}
|
|
||||||
copy(dest, row)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (stubSQLiteResult) LastInsertId() (int64, error) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (stubSQLiteResult) RowsAffected() (int64, error) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
134
doc.go
134
doc.go
|
|
@ -1,134 +0,0 @@
|
||||||
// Package store provides SQLite-backed grouped key-value storage with TTL,
|
|
||||||
// namespace isolation, quota enforcement, reactive events, journal writes,
|
|
||||||
// workspace buffering, cold archive compaction, and orphan recovery.
|
|
||||||
//
|
|
||||||
// Prefer `store.New(...)` and `store.NewScoped(...)` for the primary API.
|
|
||||||
// Use `store.NewConfigured(store.StoreConfig{...})` and
|
|
||||||
// `store.NewScopedConfigured(store.ScopedStoreConfig{...})` when the
|
|
||||||
// configuration is already known:
|
|
||||||
//
|
|
||||||
// configuredStore, err := store.NewConfigured(store.StoreConfig{
|
|
||||||
// DatabasePath: ":memory:",
|
|
||||||
// Journal: store.JournalConfiguration{
|
|
||||||
// EndpointURL: "http://127.0.0.1:8086",
|
|
||||||
// Organisation: "core",
|
|
||||||
// BucketName: "events",
|
|
||||||
// },
|
|
||||||
// PurgeInterval: 20 * time.Millisecond,
|
|
||||||
// WorkspaceStateDirectory: "/tmp/core-state",
|
|
||||||
// })
|
|
||||||
//
|
|
||||||
// Workspace files live under `.core/state/` by default and can be recovered
|
|
||||||
// with `configuredStore.RecoverOrphans(".core/state/")` after a crash.
|
|
||||||
// Use `StoreConfig.Normalised()` when you want the default purge interval and
|
|
||||||
// workspace state directory filled in before passing the config onward.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// func main() {
|
|
||||||
// configuredStore, err := store.NewConfigured(store.StoreConfig{
|
|
||||||
// DatabasePath: ":memory:",
|
|
||||||
// Journal: store.JournalConfiguration{
|
|
||||||
// EndpointURL: "http://127.0.0.1:8086",
|
|
||||||
// Organisation: "core",
|
|
||||||
// BucketName: "events",
|
|
||||||
// },
|
|
||||||
// PurgeInterval: 20 * time.Millisecond,
|
|
||||||
// WorkspaceStateDirectory: "/tmp/core-state",
|
|
||||||
// })
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// defer configuredStore.Close()
|
|
||||||
//
|
|
||||||
// if err := configuredStore.Set("config", "colour", "blue"); err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if err := configuredStore.SetWithTTL("session", "token", "abc123", 5*time.Minute); err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// colourValue, err := configuredStore.Get("config", "colour")
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// fmt.Println(colourValue)
|
|
||||||
//
|
|
||||||
// for entry, err := range configuredStore.All("config") {
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// fmt.Println(entry.Key, entry.Value)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// events := configuredStore.Watch("config")
|
|
||||||
// defer configuredStore.Unwatch("config", events)
|
|
||||||
// go func() {
|
|
||||||
// for event := range events {
|
|
||||||
// fmt.Println(event.Type, event.Group, event.Key, event.Value)
|
|
||||||
// }
|
|
||||||
// }()
|
|
||||||
//
|
|
||||||
// unregister := configuredStore.OnChange(func(event store.Event) {
|
|
||||||
// fmt.Println("changed", event.Group, event.Key, event.Value)
|
|
||||||
// })
|
|
||||||
// defer unregister()
|
|
||||||
//
|
|
||||||
// scopedStore, err := store.NewScopedConfigured(
|
|
||||||
// configuredStore,
|
|
||||||
// store.ScopedStoreConfig{
|
|
||||||
// Namespace: "tenant-a",
|
|
||||||
// Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10},
|
|
||||||
// },
|
|
||||||
// )
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if err := scopedStore.SetIn("preferences", "locale", "en-GB"); err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// for groupName, err := range configuredStore.GroupsSeq("tenant-a:") {
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// fmt.Println(groupName)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// workspace, err := configuredStore.NewWorkspace("scroll-session")
|
|
||||||
// if err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// defer workspace.Discard()
|
|
||||||
//
|
|
||||||
// if err := workspace.Put("like", map[string]any{"user": "@alice"}); err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if err := workspace.Put("profile_match", map[string]any{"user": "@charlie"}); err != nil {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// if result := workspace.Commit(); !result.OK {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// orphans := configuredStore.RecoverOrphans(".core/state")
|
|
||||||
// for _, orphanWorkspace := range orphans {
|
|
||||||
// fmt.Println(orphanWorkspace.Name(), orphanWorkspace.Aggregate())
|
|
||||||
// orphanWorkspace.Discard()
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// journalResult := configuredStore.QueryJournal(`from(bucket: "events") |> range(start: -24h)`)
|
|
||||||
// if !journalResult.OK {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// archiveResult := configuredStore.Compact(store.CompactOptions{
|
|
||||||
// Before: time.Now().Add(-30 * 24 * time.Hour),
|
|
||||||
// Output: "/tmp/archive",
|
|
||||||
// Format: "gzip",
|
|
||||||
// })
|
|
||||||
// if !archiveResult.OK {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
package store
|
|
||||||
|
|
@ -1,440 +0,0 @@
|
||||||
# RFC-025: Agent Experience (AX) Design Principles
|
|
||||||
|
|
||||||
- **Status:** Draft
|
|
||||||
- **Authors:** Snider, Cladius
|
|
||||||
- **Date:** 2026-03-19
|
|
||||||
- **Applies to:** All Core ecosystem packages (CoreGO, CorePHP, CoreTS, core-agent)
|
|
||||||
|
|
||||||
## Abstract
|
|
||||||
|
|
||||||
Agent Experience (AX) is a design paradigm for software systems where the primary code consumer is an AI agent, not a human developer. AX sits alongside User Experience (UX) and Developer Experience (DX) as the third era of interface design.
|
|
||||||
|
|
||||||
This RFC establishes AX as a formal design principle for the Core ecosystem and defines the conventions that follow from it.
|
|
||||||
|
|
||||||
## Motivation
|
|
||||||
|
|
||||||
As of early 2026, AI agents write, review, and maintain the majority of code in the Core ecosystem. The original author has not manually edited code (outside of Core struct design) since October 2025. Code is processed semantically — agents reason about intent, not characters.
|
|
||||||
|
|
||||||
Design patterns inherited from the human-developer era optimise for the wrong consumer:
|
|
||||||
|
|
||||||
- **Short names** save keystrokes but increase semantic ambiguity
|
|
||||||
- **Functional option chains** are fluent for humans but opaque for agents tracing configuration
|
|
||||||
- **Error-at-every-call-site** produces 50% boilerplate that obscures intent
|
|
||||||
- **Generic type parameters** force agents to carry type context that the runtime already has
|
|
||||||
- **Panic-hiding conventions** (`Must*`) create implicit control flow that agents must special-case
|
|
||||||
|
|
||||||
AX acknowledges this shift and provides principles for designing code, APIs, file structures, and conventions that serve AI agents as first-class consumers.
|
|
||||||
|
|
||||||
## The Three Eras
|
|
||||||
|
|
||||||
| Era | Primary Consumer | Optimises For | Key Metric |
|
|
||||||
|-----|-----------------|---------------|------------|
|
|
||||||
| UX | End users | Discoverability, forgiveness, visual clarity | Task completion time |
|
|
||||||
| DX | Developers | Typing speed, IDE support, convention familiarity | Time to first commit |
|
|
||||||
| AX | AI agents | Predictability, composability, semantic navigation | Correct-on-first-pass rate |
|
|
||||||
|
|
||||||
AX does not replace UX or DX. End users still need good UX. Developers still need good DX. But when the primary code author and maintainer is an AI agent, the codebase should be designed for that consumer first.
|
|
||||||
|
|
||||||
## Principles
|
|
||||||
|
|
||||||
### 1. Predictable Names Over Short Names
|
|
||||||
|
|
||||||
Names are tokens that agents pattern-match across languages and contexts. Abbreviations introduce mapping overhead.
|
|
||||||
|
|
||||||
```
|
|
||||||
Config not Cfg
|
|
||||||
Service not Srv
|
|
||||||
Embed not Emb
|
|
||||||
Error not Err (as a subsystem name; err for local variables is fine)
|
|
||||||
Options not Opts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** If a name would require a comment to explain, it is too short.
|
|
||||||
|
|
||||||
**Exception:** Industry-standard abbreviations that are universally understood (`HTTP`, `URL`, `ID`, `IPC`, `I18n`) are acceptable. The test: would an agent trained on any mainstream language recognise it without context?
|
|
||||||
|
|
||||||
### 2. Comments as Usage Examples
|
|
||||||
|
|
||||||
The function signature tells WHAT. The comment shows HOW with real values.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Detect the project type from files present
|
|
||||||
setup.Detect("/path/to/project")
|
|
||||||
|
|
||||||
// Set up a workspace with auto-detected template
|
|
||||||
setup.Run(setup.Options{Path: ".", Template: "auto"})
|
|
||||||
|
|
||||||
// Scaffold a PHP module workspace
|
|
||||||
setup.Run(setup.Options{Path: "./my-module", Template: "php"})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** If a comment restates what the type signature already says, delete it. If a comment shows a concrete usage with realistic values, keep it.
|
|
||||||
|
|
||||||
**Rationale:** Agents learn from examples more effectively than from descriptions. A comment like "Run executes the setup process" adds zero information. A comment like `setup.Run(setup.Options{Path: ".", Template: "auto"})` teaches an agent exactly how to call the function.
|
|
||||||
|
|
||||||
### 3. Path Is Documentation
|
|
||||||
|
|
||||||
File and directory paths should be self-describing. An agent navigating the filesystem should understand what it is looking at without reading a README.
|
|
||||||
|
|
||||||
```
|
|
||||||
flow/deploy/to/homelab.yaml — deploy TO the homelab
|
|
||||||
flow/deploy/from/github.yaml — deploy FROM GitHub
|
|
||||||
flow/code/review.yaml — code review flow
|
|
||||||
template/file/go/struct.go.tmpl — Go struct file template
|
|
||||||
template/dir/workspace/php/ — PHP workspace scaffold
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** If an agent needs to read a file to understand what a directory contains, the directory naming has failed.
|
|
||||||
|
|
||||||
**Corollary:** The unified path convention (folder structure = HTTP route = CLI command = test path) is AX-native. One path, every surface.
|
|
||||||
|
|
||||||
### 4. Templates Over Freeform
|
|
||||||
|
|
||||||
When an agent generates code from a template, the output is constrained to known-good shapes. When an agent writes freeform, the output varies.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Template-driven — consistent output
|
|
||||||
lib.RenderFile("php/action", data)
|
|
||||||
lib.ExtractDir("php", targetDir, data)
|
|
||||||
|
|
||||||
// Freeform — variance in output
|
|
||||||
"write a PHP action class that..."
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** For any code pattern that recurs, provide a template. Templates are guardrails for agents.
|
|
||||||
|
|
||||||
**Scope:** Templates apply to file generation, workspace scaffolding, config generation, and commit messages. They do NOT apply to novel logic — agents should write business logic freeform with the domain knowledge available.
|
|
||||||
|
|
||||||
### 5. Declarative Over Imperative
|
|
||||||
|
|
||||||
Agents reason better about declarations of intent than sequences of operations.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Declarative — agent sees what should happen
|
|
||||||
steps:
|
|
||||||
- name: build
|
|
||||||
flow: tools/docker-build
|
|
||||||
with:
|
|
||||||
context: "{{ .app_dir }}"
|
|
||||||
image_name: "{{ .image_name }}"
|
|
||||||
|
|
||||||
- name: deploy
|
|
||||||
flow: deploy/with/docker
|
|
||||||
with:
|
|
||||||
host: "{{ .host }}"
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Imperative — agent must trace execution
|
|
||||||
cmd := exec.Command("docker", "build", "--platform", "linux/amd64", "-t", imageName, ".")
|
|
||||||
cmd.Dir = appDir
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("docker build: %w", err)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** Orchestration, configuration, and pipeline logic should be declarative (YAML/JSON). Implementation logic should be imperative (Go/PHP/TS). The boundary is: if an agent needs to compose or modify the logic, make it declarative.
|
|
||||||
|
|
||||||
### 6. Universal Types (Core Primitives)
|
|
||||||
|
|
||||||
Every component in the ecosystem accepts and returns the same primitive types. An agent processing any level of the tree sees identical shapes.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Universal contract
|
|
||||||
setup.Run(core.Options{Path: ".", Template: "auto"})
|
|
||||||
brain.New(core.Options{Name: "openbrain"})
|
|
||||||
deploy.Run(core.Options{Flow: "deploy/to/homelab"})
|
|
||||||
|
|
||||||
// Fractal — Core itself is a Service
|
|
||||||
core.New(core.Options{
|
|
||||||
Services: []core.Service{
|
|
||||||
process.New(core.Options{Name: "process"}),
|
|
||||||
brain.New(core.Options{Name: "brain"}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Core primitive types:**
|
|
||||||
|
|
||||||
| Type | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `core.Options` | Input configuration (what you want) |
|
|
||||||
| `core.Config` | Runtime settings (what is active) |
|
|
||||||
| `core.Data` | Embedded or stored content |
|
|
||||||
| `core.Service` | A managed component with lifecycle |
|
|
||||||
| `core.Result[T]` | Return value with OK/fail state |
|
|
||||||
|
|
||||||
**What this replaces:**
|
|
||||||
|
|
||||||
| Go Convention | Core AX | Why |
|
|
||||||
|--------------|---------|-----|
|
|
||||||
| `func With*(v) Option` | `core.Options{Field: v}` | Struct literal is parseable; option chain requires tracing |
|
|
||||||
| `func Must*(v) T` | `core.Result[T]` | No hidden panics; errors flow through Core |
|
|
||||||
| `func *For[T](c) T` | `c.Service("name")` | String lookup is greppable; generics require type context |
|
|
||||||
| `val, err :=` everywhere | Single return via `core.Result` | Intent not obscured by error handling |
|
|
||||||
| `_ = err` | Never needed | Core handles all errors internally |
|
|
||||||
|
|
||||||
### 7. Directory as Semantics
|
|
||||||
|
|
||||||
The directory structure tells an agent the intent before it reads a word. Top-level directories are semantic categories, not organisational bins.
|
|
||||||
|
|
||||||
```
|
|
||||||
plans/
|
|
||||||
├── code/ # Pure primitives — read for WHAT exists
|
|
||||||
├── project/ # Products — read for WHAT we're building and WHY
|
|
||||||
└── rfc/ # Contracts — read for constraints and rules
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** An agent should know what kind of document it's reading from the path alone. `code/core/go/io/RFC.md` = a lib primitive spec. `project/ofm/RFC.md` = a product spec that cross-references code/. `rfc/snider/borg/RFC-BORG-006-SMSG-FORMAT.md` = an immutable contract for the Borg SMSG protocol.
|
|
||||||
|
|
||||||
**Corollary:** The three-way split (code/project/rfc) extends principle 3 (Path Is Documentation) from files to entire subtrees. The path IS the metadata.
|
|
||||||
|
|
||||||
### 8. Lib Never Imports Consumer
|
|
||||||
|
|
||||||
Dependency flows one direction. Libraries define primitives. Consumers compose from them. A new feature in a consumer can never break a library.
|
|
||||||
|
|
||||||
```
|
|
||||||
code/core/go/* → lib tier (stable foundation)
|
|
||||||
code/core/agent/ → consumer tier (composes from go/*)
|
|
||||||
code/core/cli/ → consumer tier (composes from go/*)
|
|
||||||
code/core/gui/ → consumer tier (composes from go/*)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** If package A is in `go/` and package B is in the consumer tier, B may import A but A must never import B. The repo naming convention enforces this: `go-{name}` = lib, bare `{name}` = consumer.
|
|
||||||
|
|
||||||
**Why this matters for agents:** When an agent is dispatched to implement a feature in `core/agent`, it can freely import from `go-io`, `go-scm`, `go-process`. But if an agent is dispatched to `go-io`, it knows its changes are foundational — every consumer depends on it, so the contract must not break.
|
|
||||||
|
|
||||||
### 9. Issues Are N+(rounds) Deep
|
|
||||||
|
|
||||||
Problems in code and specs are layered. Surface issues mask deeper issues. Fixing the surface reveals the next layer. This is not a failure mode — it is the discovery process.
|
|
||||||
|
|
||||||
```
|
|
||||||
Pass 1: Find 16 issues (surface — naming, imports, obvious errors)
|
|
||||||
Pass 2: Find 11 issues (structural — contradictions, missing types)
|
|
||||||
Pass 3: Find 5 issues (architectural — signature mismatches, registration gaps)
|
|
||||||
Pass 4: Find 4 issues (contract — cross-spec API mismatches)
|
|
||||||
Pass 5: Find 2 issues (mechanical — path format, nil safety)
|
|
||||||
Pass N: Findings are trivial → spec/code is complete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** Iteration is required, not a failure. Each pass sees what the previous pass could not, because the context changed. An agent dispatched with the same task on the same repo will find different things each time — this is correct behaviour.
|
|
||||||
|
|
||||||
**Corollary:** The cheapest model should do the most passes (surface work). The frontier model should arrive last, when only deep issues remain. Tiered iteration: grunt model grinds → mid model pre-warms → frontier model polishes.
|
|
||||||
|
|
||||||
**Anti-pattern:** One-shot generation expecting valid output. No model, no human, produces correct-on-first-pass for non-trivial work. Expecting it wastes the first pass on surface issues that a cheaper pass would have caught.
|
|
||||||
|
|
||||||
### 10. CLI Tests as Artifact Validation
|
|
||||||
|
|
||||||
Unit tests verify the code. CLI tests verify the binary. The directory structure IS the command structure — path maps to command, Taskfile runs the test.
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/cli/
|
|
||||||
├── core/
|
|
||||||
│ └── lint/
|
|
||||||
│ ├── Taskfile.yaml ← test `core-lint` (root)
|
|
||||||
│ ├── run/
|
|
||||||
│ │ ├── Taskfile.yaml ← test `core-lint run`
|
|
||||||
│ │ └── fixtures/
|
|
||||||
│ ├── go/
|
|
||||||
│ │ ├── Taskfile.yaml ← test `core-lint go`
|
|
||||||
│ │ └── fixtures/
|
|
||||||
│ └── security/
|
|
||||||
│ ├── Taskfile.yaml ← test `core-lint security`
|
|
||||||
│ └── fixtures/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule:** Every CLI command has a matching `tests/cli/{path}/Taskfile.yaml`. The Taskfile runs the compiled binary against fixtures with known inputs and validates the output. If the CLI test passes, the underlying actions work — because CLI commands call actions, MCP tools call actions, API endpoints call actions. Test the CLI, trust the rest.
|
|
||||||
|
|
||||||
**Pattern:**
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# tests/cli/core/lint/go/Taskfile.yaml
|
|
||||||
version: '3'
|
|
||||||
tasks:
|
|
||||||
test:
|
|
||||||
cmds:
|
|
||||||
- core-lint go --output json fixtures/ > /tmp/result.json
|
|
||||||
- jq -e '.findings | length > 0' /tmp/result.json
|
|
||||||
- jq -e '.summary.passed == false' /tmp/result.json
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this matters for agents:** An agent can validate its own work by running `task test` in the matching `tests/cli/` directory. No test framework, no mocking, no setup — just the binary, fixtures, and `jq` assertions. The agent builds the binary, runs the test, sees the result. If it fails, the agent can read the fixture, read the output, and fix the code.
|
|
||||||
|
|
||||||
**Corollary:** Fixtures are planted bugs. Each fixture file has a known issue that the linter must find. If the linter doesn't find it, the test fails. Fixtures are the spec for what the tool must detect — they ARE the test cases, not descriptions of test cases.
|
|
||||||
|
|
||||||
## Applying AX to Existing Patterns
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
# AX-native: path describes content
|
|
||||||
core/agent/
|
|
||||||
├── go/ # Go source
|
|
||||||
├── php/ # PHP source
|
|
||||||
├── ui/ # Frontend source
|
|
||||||
├── claude/ # Claude Code plugin
|
|
||||||
└── codex/ # Codex plugin
|
|
||||||
|
|
||||||
# Not AX: generic names requiring README
|
|
||||||
src/
|
|
||||||
├── lib/
|
|
||||||
├── utils/
|
|
||||||
└── helpers/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
```go
|
|
||||||
// AX-native: errors are infrastructure, not application logic
|
|
||||||
svc := c.Service("brain")
|
|
||||||
cfg := c.Config().Get("database.host")
|
|
||||||
// Errors logged by Core. Code reads like a spec.
|
|
||||||
|
|
||||||
// Not AX: errors dominate the code
|
|
||||||
svc, err := c.ServiceFor[brain.Service]()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get brain service: %w", err)
|
|
||||||
}
|
|
||||||
cfg, err := c.Config().Get("database.host")
|
|
||||||
if err != nil {
|
|
||||||
_ = err // silenced because "it'll be fine"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Design
|
|
||||||
|
|
||||||
```go
|
|
||||||
// AX-native: one shape, every surface
|
|
||||||
core.New(core.Options{
|
|
||||||
Name: "my-app",
|
|
||||||
Services: []core.Service{...},
|
|
||||||
Config: core.Config{...},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Not AX: multiple patterns for the same thing
|
|
||||||
core.New(
|
|
||||||
core.WithName("my-app"),
|
|
||||||
core.WithService(factory1),
|
|
||||||
core.WithService(factory2),
|
|
||||||
core.WithConfig(cfg),
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## The Plans Convention — AX Development Lifecycle
|
|
||||||
|
|
||||||
The `plans/` directory structure encodes a development methodology designed for how generative AI actually works: iterative refinement across structured phases, not one-shot generation.
|
|
||||||
|
|
||||||
### The Three-Way Split
|
|
||||||
|
|
||||||
```
|
|
||||||
plans/
|
|
||||||
├── project/ # 1. WHAT and WHY — start here
|
|
||||||
├── rfc/ # 2. CONSTRAINTS — immutable contracts
|
|
||||||
└── code/ # 3. HOW — implementation specs
|
|
||||||
```
|
|
||||||
|
|
||||||
Each directory is a phase. Work flows from project → rfc → code. Each transition forces a refinement pass — you cannot write a code spec without discovering gaps in the project spec, and you cannot write an RFC without discovering assumptions in both.
|
|
||||||
|
|
||||||
**Three places for data that can't be written simultaneously = three guaranteed iterations of "actually, this needs changing."** Refinement is baked into the structure, not bolted on as a review step.
|
|
||||||
|
|
||||||
### Phase 1: Project (Vision)
|
|
||||||
|
|
||||||
Start with `project/`. No code exists yet. Define:
|
|
||||||
- What the product IS and who it serves
|
|
||||||
- What existing primitives it consumes (cross-ref to `code/`)
|
|
||||||
- What constraints it operates under (cross-ref to `rfc/`)
|
|
||||||
|
|
||||||
This is where creativity lives. Map features to building blocks. Connect systems. The project spec is integrative — it references everything else.
|
|
||||||
|
|
||||||
### Phase 2: RFC (Contracts)
|
|
||||||
|
|
||||||
Extract the immutable rules into `rfc/`. These are constraints that don't change with implementation:
|
|
||||||
- Wire formats, protocols, hash algorithms
|
|
||||||
- Security properties that must hold
|
|
||||||
- Compatibility guarantees
|
|
||||||
|
|
||||||
RFCs are numbered per component (`RFC-BORG-006-SMSG-FORMAT.md`) and never modified after acceptance. If the contract changes, write a new RFC.
|
|
||||||
|
|
||||||
### Phase 3: Code (Implementation Specs)
|
|
||||||
|
|
||||||
Define the implementation in `code/`. Each component gets an RFC.md that an agent can implement from:
|
|
||||||
- Struct definitions (the DTOs — see principle 6)
|
|
||||||
- Method signatures and behaviour
|
|
||||||
- Error conditions and edge cases
|
|
||||||
- Cross-references to other code/ specs
|
|
||||||
|
|
||||||
The code spec IS the product. Write the spec → dispatch to an agent → review output → iterate.
|
|
||||||
|
|
||||||
### Pre-Launch: Alignment Protocol
|
|
||||||
|
|
||||||
Before dispatching for implementation, verify spec-model alignment:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. REVIEW — The implementation model (Codex/Jules) reads the spec
|
|
||||||
and reports missing elements. This surfaces the delta between
|
|
||||||
the model's training and the spec's assumptions.
|
|
||||||
|
|
||||||
"I need X, Y, Z to implement this" is the model saying
|
|
||||||
"I hear you but I'm missing context" — without asking.
|
|
||||||
|
|
||||||
2. ADJUST — Update the spec to close the gaps. Add examples,
|
|
||||||
clarify ambiguities, provide the context the model needs.
|
|
||||||
This is shared alignment, not compromise.
|
|
||||||
|
|
||||||
3. VERIFY — A different model (or sub-agent) reviews the adjusted
|
|
||||||
spec without the planner's bias. Fresh eyes on the contract.
|
|
||||||
"Does this make sense to someone who wasn't in the room?"
|
|
||||||
|
|
||||||
4. READY — When the review findings are trivial or deployment-
|
|
||||||
related (not architectural), the spec is ready to dispatch.
|
|
||||||
```
|
|
||||||
|
|
||||||
### Implementation: Iterative Dispatch
|
|
||||||
|
|
||||||
Same prompt, multiple runs. Each pass sees deeper because the context evolved:
|
|
||||||
|
|
||||||
```
|
|
||||||
Round 1: Build features (the obvious gaps)
|
|
||||||
Round 2: Write tests (verify what was built)
|
|
||||||
Round 3: Harden security (what can go wrong?)
|
|
||||||
Round 4: Next RFC section (what's still missing?)
|
|
||||||
Round N: Findings are trivial → implementation is complete
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-running is not failure. It is the process. Each pass changes the codebase, which changes what the next pass can see. The iteration IS the refinement.
|
|
||||||
|
|
||||||
### Post-Implementation: Auto-Documentation
|
|
||||||
|
|
||||||
The QA/verify chain produces artefacts that feed forward:
|
|
||||||
- Test results document the contract (what works, what doesn't)
|
|
||||||
- Coverage reports surface untested paths
|
|
||||||
- Diff summaries prep the changelog for the next release
|
|
||||||
- Doc site updates from the spec (the spec IS the documentation)
|
|
||||||
|
|
||||||
The output of one cycle is the input to the next. The plans repo stays current because the specs drive the code, not the other way round.
|
|
||||||
|
|
||||||
## Compatibility
|
|
||||||
|
|
||||||
AX conventions are valid, idiomatic Go/PHP/TS. They do not require language extensions, code generation, or non-standard tooling. An AX-designed codebase compiles, tests, and deploys with standard toolchains.
|
|
||||||
|
|
||||||
The conventions diverge from community patterns (functional options, Must/For, etc.) but do not violate language specifications. This is a style choice, not a fork.
|
|
||||||
|
|
||||||
## Adoption
|
|
||||||
|
|
||||||
AX applies to all new code in the Core ecosystem. Existing code migrates incrementally as it is touched — no big-bang rewrite.
|
|
||||||
|
|
||||||
Priority order:
|
|
||||||
1. **Public APIs** (package-level functions, struct constructors)
|
|
||||||
2. **File structure** (path naming, template locations)
|
|
||||||
3. **Internal fields** (struct field names, local variables)
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- dAppServer unified path convention (2024)
|
|
||||||
- CoreGO DTO pattern refactor (2026-03-18)
|
|
||||||
- Core primitives design (2026-03-19)
|
|
||||||
- Go Proverbs, Rob Pike (2015) — AX provides an updated lens
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
- 2026-03-19: Initial draft
|
|
||||||
|
|
@ -1,297 +0,0 @@
|
||||||
# go-store RFC — SQLite Key-Value Store
|
|
||||||
|
|
||||||
> An agent should be able to use this store from this document alone.
|
|
||||||
|
|
||||||
**Module:** `dappco.re/go/store`
|
|
||||||
**Repository:** `core/go-store`
|
|
||||||
**Files:** 8
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Overview
|
|
||||||
|
|
||||||
SQLite-backed key-value store with TTL, namespace isolation, reactive events, and quota enforcement. Pure Go (no CGO). Used by core/ide for memory caching and by agents for workspace state.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `store.go` | Core `Store`: CRUD on `(grp, key)` compound PK, TTL via `expires_at` (Unix ms), background purge (60s), `text/template` rendering, `iter.Seq2` iterators |
|
|
||||||
| `events.go` | `Watch`/`Unwatch` (buffered chan, cap 16, non-blocking sends) + `OnChange` callbacks (synchronous) |
|
|
||||||
| `scope.go` | `ScopedStore` wraps `*Store`, prefixes groups with `namespace:`. Quota enforcement (`MaxKeys`/`MaxGroups`) |
|
|
||||||
| `workspace.go` | `Workspace` buffer: SQLite-backed mutable accumulation in `.duckdb` files, atomic commit to journal |
|
|
||||||
| `journal.go` | SQLite journal table: write completed units, query time-series-shaped data, retention |
|
|
||||||
| `compact.go` | Cold archive: compress journal entries to JSONL.gz |
|
|
||||||
| `store_test.go` | Store unit tests |
|
|
||||||
| `workspace_test.go` | Workspace buffer tests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Key Design Decisions
|
|
||||||
|
|
||||||
- **Single-connection SQLite.** `MaxOpenConns(1)` because SQLite pragmas (WAL, busy_timeout) are per-connection — a pool would hand out unpragma'd connections causing `SQLITE_BUSY`
|
|
||||||
- **TTL is triple-layered:** lazy delete on `Get`, query-time `WHERE` filtering, background purge goroutine
|
|
||||||
- **LIKE queries use `escapeLike()`** with `^` as escape char to prevent SQL wildcard injection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Store Struct
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Store is the SQLite KV store with optional SQLite journal backing.
|
|
||||||
type Store struct {
|
|
||||||
db *sql.DB // SQLite connection (single, WAL mode)
|
|
||||||
journal JournalConfiguration // SQLite journal metadata (nil-equivalent when zero-valued)
|
|
||||||
bucket string // Journal bucket name
|
|
||||||
org string // Journal organisation
|
|
||||||
mu sync.RWMutex
|
|
||||||
watchers map[string][]chan Event
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event is emitted on Watch channels when a key changes.
|
|
||||||
type Event struct {
|
|
||||||
Group string
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// New creates a store. Journal is optional — pass WithJournal() to enable.
|
|
||||||
//
|
|
||||||
// storeInstance, _ := store.New(":memory:") // SQLite only
|
|
||||||
// storeInstance, _ := store.New("/path/to/db", store.WithJournal(
|
|
||||||
// "http://localhost:8086", "core-org", "core-bucket",
|
|
||||||
// ))
|
|
||||||
func New(path string, opts ...StoreOption) (*Store, error) { }
|
|
||||||
|
|
||||||
type StoreOption func(*Store)
|
|
||||||
|
|
||||||
func WithJournal(url, org, bucket string) StoreOption { }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. API
|
|
||||||
|
|
||||||
```go
|
|
||||||
storeInstance, _ := store.New(":memory:") // or store.New("/path/to/db")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
storeInstance.Set("group", "key", "value")
|
|
||||||
storeInstance.SetWithTTL("group", "key", "value", 5*time.Minute)
|
|
||||||
value, _ := storeInstance.Get("group", "key") // lazy-deletes expired
|
|
||||||
|
|
||||||
// Iteration
|
|
||||||
for key, value := range storeInstance.AllSeq("group") { ... }
|
|
||||||
for group := range storeInstance.GroupsSeq() { ... }
|
|
||||||
|
|
||||||
// Events
|
|
||||||
events := storeInstance.Watch("group")
|
|
||||||
storeInstance.OnChange(func(event store.Event) { ... })
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. ScopedStore
|
|
||||||
|
|
||||||
```go
|
|
||||||
// ScopedStore wraps a Store with a namespace prefix and optional quotas.
|
|
||||||
//
|
|
||||||
// scopedStore, _ := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{
|
|
||||||
// Namespace: "mynamespace",
|
|
||||||
// Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 10},
|
|
||||||
// })
|
|
||||||
// scopedStore.Set("key", "value") // stored as group "mynamespace:default", key "key"
|
|
||||||
// scopedStore.SetIn("mygroup", "key", "v") // stored as group "mynamespace:mygroup", key "key"
|
|
||||||
type ScopedStore struct {
|
|
||||||
store *Store
|
|
||||||
namespace string // validated: ^[a-zA-Z0-9-]+$
|
|
||||||
MaxKeys int // 0 = unlimited
|
|
||||||
MaxGroups int // 0 = unlimited
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewScoped(storeInstance *Store, namespace string) (*ScopedStore, error) { }
|
|
||||||
|
|
||||||
func NewScopedConfigured(storeInstance *Store, scopedConfig ScopedStoreConfig) (*ScopedStore, error) { }
|
|
||||||
|
|
||||||
// Set stores a value in the default group ("namespace:default")
|
|
||||||
func (scopedStore *ScopedStore) Set(key, value string) error { }
|
|
||||||
|
|
||||||
// SetIn stores a value in an explicit group ("namespace:group")
|
|
||||||
func (scopedStore *ScopedStore) SetIn(group, key, value string) error { }
|
|
||||||
|
|
||||||
// Get retrieves a value from the default group
|
|
||||||
func (scopedStore *ScopedStore) Get(key string) (string, error) { }
|
|
||||||
|
|
||||||
// GetFrom retrieves a value from an explicit group
|
|
||||||
func (scopedStore *ScopedStore) GetFrom(group, key string) (string, error) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
- Namespace regex: `^[a-zA-Z0-9-]+$`
|
|
||||||
- Default group: `Set(key, value)` uses literal `"default"` as group, prefixed: `"mynamespace:default"`
|
|
||||||
- `SetIn(group, key, value)` allows explicit group within the namespace
|
|
||||||
- Quota: `MaxKeys`, `MaxGroups` — checked before writes, upserts bypass
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Event System
|
|
||||||
|
|
||||||
- `Watch(group string) <-chan Event` — returns buffered channel (cap 16), non-blocking sends drop events
|
|
||||||
- `Unwatch(group string, ch <-chan Event)` — remove a watcher
|
|
||||||
- `OnChange(callback)` — synchronous callback in writer goroutine
|
|
||||||
- `notify()` snapshots callbacks after watcher delivery, so callbacks may register or unregister subscriptions re-entrantly without deadlocking
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Workspace Buffer
|
|
||||||
|
|
||||||
Stateful work accumulation over time. A workspace is a named SQLite buffer for mutable work-in-progress stored in a `.duckdb` file for path compatibility. When a unit of work completes, the full state commits atomically to the journal table. A summary updates the identity store.
|
|
||||||
|
|
||||||
### 7.1 The Problem
|
|
||||||
|
|
||||||
Writing every micro-event directly to a time-series makes deltas meaningless — 4000 writes of "+1" produces noise. A mutable buffer accumulates the work, then commits once as a complete unit. The time-series only sees finished work, so deltas between entries represent real change.
|
|
||||||
|
|
||||||
### 7.2 Three Layers
|
|
||||||
|
|
||||||
```
|
|
||||||
Store (SQLite): "this thing exists" — identity, current summary
|
|
||||||
Buffer (SQLite workspace file): "this thing is working" — mutable temp state, atomic
|
|
||||||
Journal (SQLite journal table): "this thing completed" — immutable, delta-ready
|
|
||||||
```
|
|
||||||
|
|
||||||
| Layer | Store | Mutability | Lifetime |
|
|
||||||
|-------|-------|-----------|----------|
|
|
||||||
| Identity | SQLite (go-store) | Mutable | Permanent |
|
|
||||||
| Hot | SQLite `.duckdb` file | Mutable | Session/cycle |
|
|
||||||
| Journal | SQLite journal table | Append-only | Retention policy |
|
|
||||||
| Cold | Compressed JSONL | Immutable | Archive |
|
|
||||||
|
|
||||||
### 7.3 Workspace API
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Workspace is a named SQLite buffer for mutable work-in-progress.
|
|
||||||
// It holds a reference to the parent Store for identity updates and journal writes.
|
|
||||||
//
|
|
||||||
// workspace, _ := storeInstance.NewWorkspace("scroll-session-2026-03-30")
|
|
||||||
// workspace.Put("like", map[string]any{"user": "@handle", "post": "video_123"})
|
|
||||||
// workspace.Commit() // atomic → journal + identity summary
|
|
||||||
type Workspace struct {
|
|
||||||
name string
|
|
||||||
store *Store // parent store for identity updates + journal config
|
|
||||||
db *sql.DB // SQLite via database/sql driver (temp file, deleted on commit/discard)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewWorkspace creates a workspace buffer. The SQLite file is created at .core/state/{name}.duckdb.
|
|
||||||
//
|
|
||||||
// workspace, _ := storeInstance.NewWorkspace("scroll-session-2026-03-30")
|
|
||||||
func (s *Store) NewWorkspace(name string) (*Workspace, error) { }
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Put accumulates an entry in the workspace buffer. Returns error on write failure.
|
|
||||||
//
|
|
||||||
// err := workspace.Put("like", map[string]any{"user": "@handle"})
|
|
||||||
func (workspace *Workspace) Put(kind string, data map[string]any) error { }
|
|
||||||
|
|
||||||
// Aggregate returns a summary of the current workspace state
|
|
||||||
//
|
|
||||||
// summary := workspace.Aggregate() // {"like": 4000, "profile_match": 12}
|
|
||||||
func (workspace *Workspace) Aggregate() map[string]any { }
|
|
||||||
|
|
||||||
// Commit writes the aggregated state to the journal and updates the identity store
|
|
||||||
//
|
|
||||||
// result := workspace.Commit()
|
|
||||||
func (workspace *Workspace) Commit() core.Result { }
|
|
||||||
|
|
||||||
// Discard drops the workspace without committing
|
|
||||||
//
|
|
||||||
// workspace.Discard()
|
|
||||||
func (workspace *Workspace) Discard() { }
|
|
||||||
|
|
||||||
// Query runs SQL against the buffer for ad-hoc analysis.
|
|
||||||
// Returns core.Result where Value is []map[string]any (rows as maps).
|
|
||||||
//
|
|
||||||
// result := workspace.Query("SELECT kind, COUNT(*) as n FROM entries GROUP BY kind")
|
|
||||||
// rows := result.Value.([]map[string]any) // [{"kind": "like", "n": 4000}]
|
|
||||||
func (workspace *Workspace) Query(sql string) core.Result { }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.4 Journal
|
|
||||||
|
|
||||||
Commit writes a single point per completed workspace. One point = one unit of work.
|
|
||||||
|
|
||||||
```go
|
|
||||||
// CommitToJournal writes aggregated state as a single journal entry.
|
|
||||||
// Called by Workspace.Commit() internally, but exported for testing.
|
|
||||||
//
|
|
||||||
// storeInstance.CommitToJournal("scroll-session", fields, tags)
|
|
||||||
func (s *Store) CommitToJournal(measurement string, fields map[string]any, tags map[string]string) core.Result { }
|
|
||||||
|
|
||||||
// QueryJournal runs a Flux-shaped filter or raw SQL query against the journal table.
|
|
||||||
// Returns core.Result where Value is []map[string]any (rows as maps).
|
|
||||||
//
|
|
||||||
// result := s.QueryJournal(`from(bucket: "core") |> range(start: -7d)`)
|
|
||||||
// rows := result.Value.([]map[string]any)
|
|
||||||
func (s *Store) QueryJournal(flux string) core.Result { }
|
|
||||||
```
|
|
||||||
|
|
||||||
Because each point is a complete unit, queries naturally produce meaningful results without complex aggregation.
|
|
||||||
|
|
||||||
### 7.5 Cold Archive
|
|
||||||
|
|
||||||
When journal entries age past retention, they compact to cold storage:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// CompactOptions controls cold archive generation.
|
|
||||||
type CompactOptions struct {
|
|
||||||
Before time.Time // archive entries before this time
|
|
||||||
Output string // output directory (default: .core/archive/)
|
|
||||||
Format string // gzip or zstd (default: gzip)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact archives journal entries to compressed JSONL
|
|
||||||
//
|
|
||||||
// storeInstance.Compact(store.CompactOptions{Before: time.Now().Add(-90*24*time.Hour), Output: "/archive/"})
|
|
||||||
func (s *Store) Compact(opts CompactOptions) core.Result { }
|
|
||||||
```
|
|
||||||
|
|
||||||
Output: gzip JSONL files. Each line is a complete unit of work — ready for training data ingestion, CDN publishing, or long-term analytics.
|
|
||||||
|
|
||||||
### 8.1 File Lifecycle
|
|
||||||
|
|
||||||
Workspace files are ephemeral:
|
|
||||||
|
|
||||||
```
|
|
||||||
Created: workspace opens → .core/state/{name}.duckdb
|
|
||||||
Active: Put() accumulates entries
|
|
||||||
Committed: Commit() → journal write → identity update → file deleted
|
|
||||||
Discarded: Discard() → file deleted
|
|
||||||
Crashed: Orphaned .duckdb files detected on next New() call
|
|
||||||
```
|
|
||||||
|
|
||||||
Orphan recovery on `New()`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// New() scans .core/state/ for leftover .duckdb files.
|
|
||||||
// Each orphan is opened and cached for RecoverOrphans().
|
|
||||||
// The caller decides whether to commit or discard orphan data.
|
|
||||||
//
|
|
||||||
// orphanWorkspaces := storeInstance.RecoverOrphans(".core/state/")
|
|
||||||
// for _, workspace := range orphanWorkspaces {
|
|
||||||
// // inspect workspace.Aggregate(), decide whether to commit or discard
|
|
||||||
// workspace.Discard()
|
|
||||||
// }
|
|
||||||
func (s *Store) RecoverOrphans(stateDir string) []*Workspace { }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Reference Material
|
|
||||||
|
|
||||||
| Resource | Location |
|
|
||||||
|----------|----------|
|
|
||||||
| Architecture docs | `docs/architecture.md` |
|
|
||||||
| Development guide | `docs/development.md` |
|
|
||||||
|
|
@ -24,23 +24,23 @@ WAL (Write-Ahead Logging) mode allows concurrent readers to proceed without bloc
|
||||||
|
|
||||||
The `database/sql` package maintains a connection pool by default. SQLite pragmas are per-connection: if the pool hands out a second connection, that connection inherits none of the WAL or busy-timeout settings, causing `SQLITE_BUSY` errors under concurrent load.
|
The `database/sql` package maintains a connection pool by default. SQLite pragmas are per-connection: if the pool hands out a second connection, that connection inherits none of the WAL or busy-timeout settings, causing `SQLITE_BUSY` errors under concurrent load.
|
||||||
|
|
||||||
go-store calls `database.SetMaxOpenConns(1)` to pin all access to a single connection. Since SQLite serialises writes at the file level regardless, this introduces no additional throughput penalty. It eliminates the BUSY errors by ensuring the pragma settings always apply.
|
go-store calls `db.SetMaxOpenConns(1)` to pin all access to a single connection. Since SQLite serialises writes at the file level regardless, this introduces no additional throughput penalty. It eliminates the BUSY errors by ensuring the pragma settings always apply.
|
||||||
|
|
||||||
### Schema
|
### Schema
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE IF NOT EXISTS entries (
|
CREATE TABLE IF NOT EXISTS kv (
|
||||||
group_name TEXT NOT NULL,
|
grp TEXT NOT NULL,
|
||||||
entry_key TEXT NOT NULL,
|
key TEXT NOT NULL,
|
||||||
entry_value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
expires_at INTEGER,
|
expires_at INTEGER,
|
||||||
PRIMARY KEY (group_name, entry_key)
|
PRIMARY KEY (grp, key)
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
The compound primary key `(group_name, entry_key)` enforces uniqueness per group-key pair and provides efficient indexed lookups. The `expires_at` column stores a Unix millisecond timestamp (nullable); a `NULL` value means the key never expires.
|
The compound primary key `(grp, key)` enforces uniqueness per group-key pair and provides efficient indexed lookups. The `expires_at` column stores a Unix millisecond timestamp (nullable); a `NULL` value means the key never expires.
|
||||||
|
|
||||||
**Schema migration.** Databases created before the AX schema rename used a legacy key-value table. On `New()`, go-store migrates that legacy table into `entries`, preserving rows and copying the expiry data when present. Databases that already have `entries` but lack `expires_at` still receive an additive `ALTER TABLE entries ADD COLUMN expires_at INTEGER` migration; if the column already exists, SQLite returns a "duplicate column" error which is silently ignored.
|
**Schema migration.** Databases created before TTL support lacked the `expires_at` column. On `New()`, go-store runs `ALTER TABLE kv ADD COLUMN expires_at INTEGER`. If the column already exists, SQLite returns a "duplicate column" error which is silently ignored. This allows seamless upgrades of existing databases.
|
||||||
|
|
||||||
## Group/Key Model
|
## Group/Key Model
|
||||||
|
|
||||||
|
|
@ -49,16 +49,16 @@ Keys are addressed by a two-level path: `(group, key)`. Groups act as logical na
|
||||||
This model maps naturally to domain concepts:
|
This model maps naturally to domain concepts:
|
||||||
|
|
||||||
```
|
```
|
||||||
group: "user:42:config" key: "colour"
|
group: "user:42:config" key: "theme"
|
||||||
group: "user:42:config" key: "language"
|
group: "user:42:config" key: "language"
|
||||||
group: "session:abc" key: "token"
|
group: "session:abc" key: "token"
|
||||||
```
|
```
|
||||||
|
|
||||||
All read operations (`Get`, `GetAll`, `Count`, `Render`) are scoped to a single group. `DeleteGroup` atomically removes all keys in a group. `DeletePrefix` removes every group whose name starts with a supplied prefix. `CountAll` and `Groups` operate across groups by prefix match.
|
All read operations (`Get`, `GetAll`, `Count`, `Render`) are scoped to a single group. `DeleteGroup` atomically removes all keys in a group. `CountAll` and `Groups` operate across groups by prefix match.
|
||||||
|
|
||||||
## UPSERT Semantics
|
## UPSERT Semantics
|
||||||
|
|
||||||
All writes use `INSERT ... ON CONFLICT(group_name, entry_key) DO UPDATE`. This means:
|
All writes use `INSERT ... ON CONFLICT(grp, key) DO UPDATE`. This means:
|
||||||
|
|
||||||
- Inserting a new key creates it.
|
- Inserting a new key creates it.
|
||||||
- Inserting an existing key overwrites its value and (for `Set`) clears any TTL.
|
- Inserting an existing key overwrites its value and (for `Set`) clears any TTL.
|
||||||
|
|
@ -75,7 +75,7 @@ Expiry is enforced in three ways:
|
||||||
|
|
||||||
### 1. Lazy Deletion on Get
|
### 1. Lazy Deletion on Get
|
||||||
|
|
||||||
If a key is found but its `expires_at` is in the past, it is deleted synchronously before returning `NotFoundError`. This prevents stale values from being returned even if the background purge has not run yet.
|
If a key is found but its `expires_at` is in the past, it is deleted synchronously before returning `ErrNotFound`. This prevents stale values from being returned even if the background purge has not run yet.
|
||||||
|
|
||||||
### 2. Query-Time Filtering
|
### 2. Query-Time Filtering
|
||||||
|
|
||||||
|
|
@ -91,32 +91,23 @@ All bulk operations (`GetAll`, `All`, `Count`, `Render`, `CountAll`, `Groups`, `
|
||||||
|
|
||||||
Two convenience methods build on `Get` to return iterators over parts of a stored value:
|
Two convenience methods build on `Get` to return iterators over parts of a stored value:
|
||||||
|
|
||||||
- **`GetSplit(group, key, separator)`** splits the value by a custom separator, returning an `iter.Seq[string]` via `core.Split`.
|
- **`GetSplit(group, key, sep)`** splits the value by a custom separator, returning an `iter.Seq[string]` via `strings.SplitSeq`.
|
||||||
- **`GetFields(group, key)`** splits the value by whitespace, returning an `iter.Seq[string]` via the package's internal field iterator.
|
- **`GetFields(group, key)`** splits the value by whitespace, returning an `iter.Seq[string]` via `strings.FieldsSeq`.
|
||||||
|
|
||||||
`core.Split` keeps the package free of direct `strings` imports while preserving the same agent-facing API shape.
|
Both return `ErrNotFound` if the key does not exist or has expired.
|
||||||
|
|
||||||
Both return `NotFoundError` if the key does not exist or has expired.
|
|
||||||
|
|
||||||
## Template Rendering
|
## Template Rendering
|
||||||
|
|
||||||
`Render(templateSource, group)` is a convenience method that fetches all non-expired key-value pairs from a group and renders a Go `text/template` against them. The template data is a `map[string]string` keyed by the field name.
|
`Render(tmplStr, group)` is a convenience method that fetches all non-expired key-value pairs from a group and renders a Go `text/template` against them. The template data is a `map[string]string` keyed by the field name.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
if err := storeInstance.Set("miner", "pool", "pool.lthn.io:3333"); err != nil {
|
st.Set("miner", "pool", "pool.lthn.io:3333")
|
||||||
return
|
st.Set("miner", "wallet", "iz...")
|
||||||
}
|
out, _ := st.Render(`{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}`, "miner")
|
||||||
if err := storeInstance.Set("miner", "wallet", "iz..."); err != nil {
|
// out: {"pool":"pool.lthn.io:3333","wallet":"iz..."}
|
||||||
return
|
|
||||||
}
|
|
||||||
renderedTemplate, err := storeInstance.Render(`{"pool":"{{ .pool }}","wallet":"{{ .wallet }}"}`, "miner")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// renderedTemplate: {"pool":"pool.lthn.io:3333","wallet":"iz..."}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Template parse errors and execution errors are both returned as wrapped errors with context (e.g., `store.Render: parse template: ...` and `store.Render: execute template: ...`).
|
Template parse errors and execution errors are both returned as wrapped errors with context (e.g., `store.Render: parse: ...` and `store.Render: exec: ...`).
|
||||||
|
|
||||||
Missing template variables do not return an error by default -- Go's `text/template` renders them as `<no value>`. Applications requiring strict variable presence should validate data beforehand.
|
Missing template variables do not return an error by default -- Go's `text/template` renders them as `<no value>`. Applications requiring strict variable presence should validate data beforehand.
|
||||||
|
|
||||||
|
|
@ -146,81 +137,74 @@ Events are emitted synchronously after each successful database write inside the
|
||||||
|
|
||||||
### Watch/Unwatch
|
### Watch/Unwatch
|
||||||
|
|
||||||
`Watch(group)` creates a buffered event channel (`<-chan Event`, capacity 16).
|
`Watch(group, key)` creates a `Watcher` with a buffered channel (`Ch <-chan Event`, capacity 16).
|
||||||
|
|
||||||
| group argument | Receives |
|
| group argument | key argument | Receives |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `"mygroup"` | Mutations within that group, including `DeleteGroup` |
|
| `"mygroup"` | `"mykey"` | Only mutations to that exact key |
|
||||||
| `"*"` | Every mutation across the entire store |
|
| `"mygroup"` | `"*"` | All mutations within the group, including `DeleteGroup` |
|
||||||
|
| `"*"` | `"*"` | Every mutation across the entire store |
|
||||||
|
|
||||||
`Unwatch(group, events)` removes the watcher from the registry and closes its channel. It is safe to call multiple times; subsequent calls are no-ops.
|
`Unwatch(w)` removes the watcher from the registry and closes its channel. It is safe to call multiple times; subsequent calls are no-ops.
|
||||||
|
|
||||||
**Backpressure.** Event dispatch to a watcher channel is non-blocking: if the channel buffer is full, the event is dropped silently. This prevents a slow consumer from blocking a writer. Applications that cannot afford dropped events should drain the channel promptly or use `OnChange` callbacks instead.
|
**Backpressure.** Event dispatch to a watcher channel is non-blocking: if the channel buffer is full, the event is dropped silently. This prevents a slow consumer from blocking a writer. Applications that cannot afford dropped events should drain the channel promptly or use `OnChange` callbacks instead.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
events := storeInstance.Watch("config")
|
w := st.Watch("config", "*")
|
||||||
defer storeInstance.Unwatch("config", events)
|
defer st.Unwatch(w)
|
||||||
|
|
||||||
for event := range events {
|
for e := range w.Ch {
|
||||||
fmt.Println(event.Type, event.Group, event.Key, event.Value)
|
fmt.Println(e.Type, e.Group, e.Key, e.Value)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### OnChange Callbacks
|
### OnChange Callbacks
|
||||||
|
|
||||||
`OnChange(callback func(Event))` registers a synchronous callback that fires on every mutation. The callback runs in the goroutine that performed the write. Returns an idempotent unregister function.
|
`OnChange(fn func(Event))` registers a synchronous callback that fires on every mutation. The callback runs in the goroutine that performed the write. Returns an idempotent unregister function.
|
||||||
|
|
||||||
This is the designed integration point for consumers such as go-ws:
|
This is the designed integration point for consumers such as go-ws:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
unregister := storeInstance.OnChange(func(event store.Event) {
|
unreg := st.OnChange(func(e store.Event) {
|
||||||
hub.SendToChannel("store-events", event)
|
hub.SendToChannel("store-events", e)
|
||||||
})
|
})
|
||||||
defer unregister()
|
defer unreg()
|
||||||
```
|
```
|
||||||
|
|
||||||
go-store does not import go-ws. The dependency flows in one direction only: go-ws (or any consumer) imports go-store.
|
go-store does not import go-ws. The dependency flows in one direction only: go-ws (or any consumer) imports go-store.
|
||||||
Callbacks may safely register or unregister watchers and callbacks while handling an event. Dispatch snapshots the callback list before invoking it, so re-entrant subscription management does not deadlock. Offload any significant work to a separate goroutine if needed.
|
|
||||||
|
**Important constraint.** `OnChange` callbacks execute while holding the watcher/callback read-lock (`s.mu`). Calling `Watch`, `Unwatch`, or `OnChange` from within a callback will deadlock, because those methods require a write-lock. Offload any significant work to a separate goroutine if needed.
|
||||||
|
|
||||||
### Internal Dispatch
|
### Internal Dispatch
|
||||||
|
|
||||||
The `notify(event Event)` method first acquires the watcher read-lock, iterates all watchers with non-blocking channel sends, then releases the lock. It then acquires the callback read-lock, snapshots the registered callbacks, releases the lock, and invokes each callback synchronously. This keeps watcher delivery non-blocking while allowing callbacks to manage subscriptions re-entrantly.
|
The `notify(e Event)` method acquires a read-lock on `s.mu`, iterates all watchers with non-blocking channel sends, then calls each registered callback. The read-lock allows multiple concurrent `notify()` calls to proceed simultaneously. `Watch`/`Unwatch`/`OnChange` take a write-lock when modifying the registry.
|
||||||
|
|
||||||
Watcher delivery is grouped by the registered group name. Wildcard `"*"` matches every mutation across the entire store.
|
Watcher matching is handled by the `watcherMatches` helper, which checks the group and key filters against the event. Wildcard `"*"` matches any value in its position.
|
||||||
|
|
||||||
## Namespace Isolation (ScopedStore)
|
## Namespace Isolation (ScopedStore)
|
||||||
|
|
||||||
`ScopedStore` wraps a `*Store` and automatically prefixes all group names with `namespace + ":"`. This prevents key collisions when multiple tenants share a single underlying database. When the namespace and quota are already known, prefer `NewScopedConfigured(store.ScopedStoreConfig{...})` so the configuration is explicit at the call site.
|
`ScopedStore` wraps a `*Store` and automatically prefixes all group names with `namespace + ":"`. This prevents key collisions when multiple tenants share a single underlying database.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
scopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{
|
sc, _ := store.NewScoped(st, "tenant-42")
|
||||||
Namespace: "tenant-42",
|
sc.Set("config", "theme", "dark")
|
||||||
})
|
// Stored in underlying store as group="tenant-42:config", key="theme"
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := scopedStore.SetIn("config", "colour", "blue"); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Stored in underlying store as group="tenant-42:config", key="colour"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Namespace strings must match `^[a-zA-Z0-9-]+$`. Invalid namespaces are rejected at construction time.
|
Namespace strings must match `^[a-zA-Z0-9-]+$`. Invalid namespaces are rejected at construction time.
|
||||||
|
|
||||||
`ScopedStore` delegates all operations to the underlying `Store` after prefixing. Events emitted by scoped operations carry the full prefixed group name in `Event.Group`, enabling watchers on the underlying store to observe scoped mutations.
|
`ScopedStore` delegates all operations to the underlying `Store` after prefixing. Events emitted by scoped operations carry the full prefixed group name in `Event.Group`, enabling watchers on the underlying store to observe scoped mutations.
|
||||||
|
|
||||||
`ScopedStore` exposes the same read helpers as `Store` for `Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, `DeletePrefix`, `GetAll`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`, `GetSplit`, `GetFields`, `Render`, and `PurgeExpired`. Methods that return group names strip the namespace prefix before returning results. The `Namespace()` method returns the namespace string.
|
`ScopedStore` exposes the same API surface as `Store` for: `Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, `GetAll`, `All`, `Count`, and `Render`. The `Namespace()` method returns the namespace string.
|
||||||
|
|
||||||
`ScopedStore.Transaction` exposes the same transaction helpers through `ScopedStoreTransaction`, so callers can work inside a namespace without manually prefixing group names during a multi-step write.
|
|
||||||
|
|
||||||
### Quota Enforcement
|
### Quota Enforcement
|
||||||
|
|
||||||
`NewScopedConfigured(store.ScopedStoreConfig{...})` is the preferred way to set per-namespace limits because the quota values stay visible at the call site. For example, `store.QuotaConfig{MaxKeys: 100, MaxGroups: 10}` caps a namespace at 100 keys and 10 groups:
|
`NewScopedWithQuota(store, namespace, QuotaConfig)` adds per-namespace limits:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type QuotaConfig struct {
|
type QuotaConfig struct {
|
||||||
MaxKeys int
|
MaxKeys int // maximum total keys across all groups in the namespace
|
||||||
MaxGroups int
|
MaxGroups int // maximum distinct groups in the namespace
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -230,36 +214,24 @@ Zero values mean unlimited. Before each `Set` or `SetWithTTL`, the scoped store:
|
||||||
2. If the key is new, queries `CountAll(namespace + ":")` and compares against `MaxKeys`.
|
2. If the key is new, queries `CountAll(namespace + ":")` and compares against `MaxKeys`.
|
||||||
3. If the group is new (current count for that group is zero), queries `GroupsSeq(namespace + ":")` and compares against `MaxGroups`.
|
3. If the group is new (current count for that group is zero), queries `GroupsSeq(namespace + ":")` and compares against `MaxGroups`.
|
||||||
|
|
||||||
Exceeding a limit returns `QuotaExceededError`.
|
Exceeding a limit returns `ErrQuotaExceeded`.
|
||||||
|
|
||||||
## Concurrency Model
|
## Concurrency Model
|
||||||
|
|
||||||
All SQLite access is serialised through a single connection (`SetMaxOpenConns(1)`). The store's event registry uses two separate `sync.RWMutex` instances: `watchersLock` for watcher registration and dispatch, and `callbacksLock` for callback registration and dispatch. These locks do not interact:
|
All SQLite access is serialised through a single connection (`SetMaxOpenConns(1)`). The store's watcher/callback registry is protected by a separate `sync.RWMutex` (`s.mu`). These two locks do not interact:
|
||||||
|
|
||||||
- Database writes acquire no application-level lock.
|
- DB writes acquire no application-level lock.
|
||||||
- `notify()` acquires `watchersLock` (read) after the database write completes, then `callbacksLock` (read) to snapshot callbacks.
|
- `notify()` acquires `s.mu` (read) after the DB write completes.
|
||||||
- `Watch`/`Unwatch` acquire `watchersLock` (write) to modify watcher registrations.
|
- `Watch`/`Unwatch`/`OnChange` acquire `s.mu` (write) to modify the registry.
|
||||||
- `OnChange` acquires `callbacksLock` (write) to modify callback registrations.
|
|
||||||
|
|
||||||
All operations are safe to call from multiple goroutines concurrently. The race detector is clean under the project's standard test suite (`go test -race ./...`).
|
All operations are safe to call from multiple goroutines concurrently. The race detector is clean under the project's standard test suite (`go test -race ./...`).
|
||||||
|
|
||||||
## Transaction API
|
|
||||||
|
|
||||||
`Store.Transaction(func(transaction *StoreTransaction) error)` opens a SQLite transaction and hands a `StoreTransaction` helper to the callback. The helper exposes transaction-scoped write methods such as `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, and `DeletePrefix`, plus read helpers such as `Get`, `GetAll`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`, `Render`, `GetSplit`, and `GetFields` so callers can inspect uncommitted writes before commit. If the callback returns an error, the transaction rolls back. If the callback succeeds, the transaction commits and the staged events are published after commit.
|
|
||||||
|
|
||||||
This API is the supported way to perform atomic multi-group operations without exposing raw `Begin`/`Commit` control to callers.
|
|
||||||
|
|
||||||
## File Layout
|
## File Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
doc.go Package comment with concrete usage examples
|
store.go Core Store type, CRUD, TTL, background purge, iterators, rendering
|
||||||
store.go Core Store type, CRUD, prefix cleanup, TTL, background purge, iterators, rendering
|
events.go EventType, Event, Watcher, OnChange, notify
|
||||||
transaction.go Store.Transaction and transaction-scoped mutation helpers
|
scope.go ScopedStore, QuotaConfig, quota enforcement
|
||||||
events.go EventType, Event, Watch, Unwatch, OnChange, notify
|
|
||||||
scope.go ScopedStore, QuotaConfig, namespace-local helper delegation, quota enforcement
|
|
||||||
journal.go Journal persistence, Flux-like querying, JSON row inflation
|
|
||||||
workspace.go Workspace buffers, aggregation, query analysis, commit flow, orphan recovery
|
|
||||||
compact.go Cold archive generation to JSONL gzip or zstd
|
|
||||||
store_test.go Tests: CRUD, TTL, concurrency, edge cases, persistence
|
store_test.go Tests: CRUD, TTL, concurrency, edge cases, persistence
|
||||||
events_test.go Tests: Watch, Unwatch, OnChange, event dispatch
|
events_test.go Tests: Watch, Unwatch, OnChange, event dispatch
|
||||||
scope_test.go Tests: namespace isolation, quota enforcement
|
scope_test.go Tests: namespace isolation, quota enforcement
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ go test ./...
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
# Run a single test by name
|
# Run a single test by name
|
||||||
go test -v -run TestEvents_Watch_Good_SpecificKey ./...
|
go test -v -run TestWatch_Good_SpecificKey ./...
|
||||||
|
|
||||||
# Run tests with coverage
|
# Run tests with coverage
|
||||||
go test -cover ./...
|
go test -cover ./...
|
||||||
|
|
@ -51,7 +51,7 @@ core go qa # fmt + vet + lint + test
|
||||||
|
|
||||||
## Test Patterns
|
## Test Patterns
|
||||||
|
|
||||||
Tests follow the `Test<File>_<Function>_<Good|Bad|Ugly>` convention used across the Core Go ecosystem:
|
Tests follow the `_Good`, `_Bad`, `_Ugly` suffix convention used across the Core Go ecosystem:
|
||||||
|
|
||||||
- `_Good` -- happy-path behaviour, including edge cases that should succeed
|
- `_Good` -- happy-path behaviour, including edge cases that should succeed
|
||||||
- `_Bad` -- expected error conditions (closed store, invalid input, quota exceeded)
|
- `_Bad` -- expected error conditions (closed store, invalid input, quota exceeded)
|
||||||
|
|
@ -64,15 +64,15 @@ Tests are grouped into sections by the method under test, marked with comment ba
|
||||||
// Watch -- specific key
|
// Watch -- specific key
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestEvents_Watch_Good_SpecificKey(t *testing.T) { ... }
|
func TestWatch_Good_SpecificKey(t *testing.T) { ... }
|
||||||
func TestEvents_Watch_Good_WildcardKey(t *testing.T) { ... }
|
func TestWatch_Good_WildcardKey(t *testing.T) { ... }
|
||||||
```
|
```
|
||||||
|
|
||||||
### In-Memory vs File-Backed Stores
|
### In-Memory vs File-Backed Stores
|
||||||
|
|
||||||
Use `New(":memory:")` for all tests that do not require persistence. In-memory stores are faster and leave no filesystem artefacts.
|
Use `New(":memory:")` for all tests that do not require persistence. In-memory stores are faster and leave no filesystem artefacts.
|
||||||
|
|
||||||
Use `core.Path(t.TempDir(), "name.db")` for tests that verify WAL mode, persistence across open/close cycles, or concurrent writes. `t.TempDir()` is cleaned up automatically at the end of the test.
|
Use `filepath.Join(t.TempDir(), "name.db")` for tests that verify WAL mode, persistence across open/close cycles, or concurrent writes. `t.TempDir()` is cleaned up automatically at the end of the test.
|
||||||
|
|
||||||
### TTL Tests
|
### TTL Tests
|
||||||
|
|
||||||
|
|
@ -144,7 +144,7 @@ The only permitted runtime dependency is `modernc.org/sqlite`. Test-only depende
|
||||||
## Adding a New Method
|
## Adding a New Method
|
||||||
|
|
||||||
1. Implement the method on `*Store` in `store.go` (or `scope.go` if it is namespace-scoped).
|
1. Implement the method on `*Store` in `store.go` (or `scope.go` if it is namespace-scoped).
|
||||||
2. If it is a mutating operation, call `storeInstance.notify(Event{...})` after the successful database write.
|
2. If it is a mutating operation, call `s.notify(Event{...})` after the successful database write.
|
||||||
3. Add a corresponding delegation method to `ScopedStore` in `scope.go` that prefixes the group.
|
3. Add a corresponding delegation method to `ScopedStore` in `scope.go` that prefixes the group.
|
||||||
4. Write tests covering the happy path, error conditions, and closed-store behaviour.
|
4. Write tests covering the happy path, error conditions, and closed-store behaviour.
|
||||||
5. Update quota checks in `checkQuota` if the operation affects key or group counts.
|
5. Update quota checks in `checkQuota` if the operation affects key or group counts.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ At extraction the package comprised a single source file and a single test file.
|
||||||
|
|
||||||
**Problem.** The `database/sql` connection pool hands out different physical connections for each `Exec` or `Query` call. SQLite pragmas (`PRAGMA journal_mode=WAL`, `PRAGMA busy_timeout`) are per-connection. Under concurrent write load (10 goroutines, 100 ops each), connections from the pool that had not received the WAL pragma would block and return `SQLITE_BUSY` immediately rather than waiting.
|
**Problem.** The `database/sql` connection pool hands out different physical connections for each `Exec` or `Query` call. SQLite pragmas (`PRAGMA journal_mode=WAL`, `PRAGMA busy_timeout`) are per-connection. Under concurrent write load (10 goroutines, 100 ops each), connections from the pool that had not received the WAL pragma would block and return `SQLITE_BUSY` immediately rather than waiting.
|
||||||
|
|
||||||
**Fix.** `database.SetMaxOpenConns(1)` serialises all database access through a single connection. Because SQLite is a single-writer database by design (it serialises writes at the file-lock level regardless of pool size), this does not reduce write throughput. It eliminates the BUSY errors by ensuring the pragma settings always apply.
|
**Fix.** `db.SetMaxOpenConns(1)` serialises all database access through a single connection. Because SQLite is a single-writer database by design (it serialises writes at the file-lock level regardless of pool size), this does not reduce write throughput. It eliminates the BUSY errors by ensuring the pragma settings always apply.
|
||||||
|
|
||||||
**Defence in depth.** `PRAGMA busy_timeout=5000` was added to make the single connection wait up to 5 seconds before reporting a timeout error, providing additional resilience.
|
**Defence in depth.** `PRAGMA busy_timeout=5000` was added to make the single connection wait up to 5 seconds before reporting a timeout error, providing additional resilience.
|
||||||
|
|
||||||
|
|
@ -63,14 +63,14 @@ Added optional time-to-live for keys.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- `expires_at INTEGER` nullable column added to the key-value schema.
|
- `expires_at INTEGER` nullable column added to the `kv` schema.
|
||||||
- `SetWithTTL(group, key, value string, timeToLive time.Duration)` stores the current time plus TTL as a Unix millisecond timestamp in `expires_at`.
|
- `SetWithTTL(group, key, value string, ttl time.Duration)` stores the current time plus TTL as a Unix millisecond timestamp in `expires_at`.
|
||||||
- `Get()` performs lazy deletion: if a key is found with an `expires_at` in the past, it is deleted and `NotFoundError` is returned.
|
- `Get()` performs lazy deletion: if a key is found with an `expires_at` in the past, it is deleted and `ErrNotFound` is returned.
|
||||||
- `Count()`, `GetAll()`, and `Render()` include `(expires_at IS NULL OR expires_at > ?)` in all queries, excluding expired keys from results.
|
- `Count()`, `GetAll()`, and `Render()` include `(expires_at IS NULL OR expires_at > ?)` in all queries, excluding expired keys from results.
|
||||||
- `PurgeExpired()` public method deletes all physically stored expired rows and returns the count removed.
|
- `PurgeExpired()` public method deletes all physically stored expired rows and returns the count removed.
|
||||||
- Background goroutine calls `PurgeExpired()` every 60 seconds, controlled by a `context.WithCancel` that is cancelled on `Close()`.
|
- Background goroutine calls `PurgeExpired()` every 60 seconds, controlled by a `context.WithCancel` that is cancelled on `Close()`.
|
||||||
- `Set()` clears any existing TTL when overwriting a key (sets `expires_at = NULL`).
|
- `Set()` clears any existing TTL when overwriting a key (sets `expires_at = NULL`).
|
||||||
- Schema migration: `ALTER TABLE entries ADD COLUMN expires_at INTEGER` runs on `New()`. The "duplicate column" error on already-upgraded databases is silently ignored.
|
- Schema migration: `ALTER TABLE kv ADD COLUMN expires_at INTEGER` runs on `New()`. The "duplicate column" error on already-upgraded databases is silently ignored.
|
||||||
|
|
||||||
### Tests added
|
### Tests added
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ Added `ScopedStore` for multi-tenant namespace isolation.
|
||||||
- All `Store` methods delegated with group automatically prefixed as `namespace + ":" + group`.
|
- All `Store` methods delegated with group automatically prefixed as `namespace + ":" + group`.
|
||||||
- `QuotaConfig{MaxKeys, MaxGroups int}` struct; zero means unlimited.
|
- `QuotaConfig{MaxKeys, MaxGroups int}` struct; zero means unlimited.
|
||||||
- `NewScopedWithQuota(store, namespace, quota)` constructor.
|
- `NewScopedWithQuota(store, namespace, quota)` constructor.
|
||||||
- `QuotaExceededError` sentinel error.
|
- `ErrQuotaExceeded` sentinel error.
|
||||||
- `checkQuota(group, key)` internal method: skips upserts (existing key), checks `CountAll(namespace+":")` against `MaxKeys`, checks `Groups(namespace+":")` against `MaxGroups` only when the group is new.
|
- `checkQuota(group, key)` internal method: skips upserts (existing key), checks `CountAll(namespace+":")` against `MaxKeys`, checks `Groups(namespace+":")` against `MaxGroups` only when the group is new.
|
||||||
- `CountAll(prefix string)` added to `Store`: counts non-expired keys across all groups matching a prefix. Empty prefix counts across all groups.
|
- `CountAll(prefix string)` added to `Store`: counts non-expired keys across all groups matching a prefix. Empty prefix counts across all groups.
|
||||||
- `Groups(prefix string)` added to `Store`: returns distinct non-expired group names matching a prefix. Empty prefix returns all groups.
|
- `Groups(prefix string)` added to `Store`: returns distinct non-expired group names matching a prefix. Empty prefix returns all groups.
|
||||||
|
|
@ -117,14 +117,14 @@ Added a reactive notification system for store mutations.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
- `events.go` introduced with `EventType` (`EventSet`, `EventDelete`, `EventDeleteGroup`), `Event` struct, `Watcher` struct, `changeCallbackRegistration` struct.
|
- `events.go` introduced with `EventType` (`EventSet`, `EventDelete`, `EventDeleteGroup`), `Event` struct, `Watcher` struct, `callbackEntry` struct.
|
||||||
- `watcherEventBufferCapacity = 16` constant.
|
- `watcherBufSize = 16` constant.
|
||||||
- `Watch(group, key string) *Watcher`: creates a buffered channel watcher. Wildcard `"*"` supported for both group and key. Uses `atomic.AddUint64` for monotonic watcher IDs.
|
- `Watch(group, key string) *Watcher`: creates a buffered channel watcher. Wildcard `"*"` supported for both group and key. Uses `atomic.AddUint64` for monotonic watcher IDs.
|
||||||
- `Unwatch(watcher *Watcher)`: removes watcher from the registry and closes its channel. Idempotent.
|
- `Unwatch(w *Watcher)`: removes watcher from the registry and closes its channel. Idempotent.
|
||||||
- `OnChange(callback func(Event)) func()`: registers a synchronous callback. Returns an idempotent unregister function using `sync.Once`.
|
- `OnChange(fn func(Event)) func()`: registers a synchronous callback. Returns an idempotent unregister function using `sync.Once`.
|
||||||
- `notify(event Event)`: internal dispatch. Acquires read-lock on `watchersLock`; non-blocking send to each matching watcher channel (drop-on-full); calls each callback synchronously. Separate `watcherMatches` helper handles wildcard logic.
|
- `notify(e Event)`: internal dispatch. Acquires read-lock on `s.mu`; non-blocking send to each matching watcher channel (drop-on-full); calls each callback synchronously. Separate `watcherMatches` helper handles wildcard logic.
|
||||||
- `Set()`, `SetWithTTL()`, `Delete()`, `DeleteGroup()` each call `notify()` after the successful database write.
|
- `Set()`, `SetWithTTL()`, `Delete()`, `DeleteGroup()` each call `notify()` after the successful database write.
|
||||||
- `Store` struct extended with `watchers []*Watcher`, `callbacks []changeCallbackRegistration`, `watchersLock sync.RWMutex`, `callbacksLock sync.RWMutex`, `nextWatcherID uint64`, `nextCallbackID uint64`.
|
- `Store` struct extended with `watchers []*Watcher`, `callbacks []callbackEntry`, `mu sync.RWMutex`, `nextID uint64`.
|
||||||
- ScopedStore mutations automatically emit events with the full prefixed group name — no extra implementation required.
|
- ScopedStore mutations automatically emit events with the full prefixed group name — no extra implementation required.
|
||||||
|
|
||||||
### Tests added
|
### Tests added
|
||||||
|
|
@ -135,62 +135,15 @@ Coverage: 94.7% to 95.5%.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4 — AX API Cleanup
|
|
||||||
|
|
||||||
**Agent:** Codex
|
|
||||||
**Completed:** 2026-03-30
|
|
||||||
|
|
||||||
Aligned the public API with the AX naming rules by removing compatibility aliases that were no longer used inside the repository.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Removed the legacy compatibility aliases for the not-found error, quota error, key-value pair, and watcher channel.
|
|
||||||
- Kept the primary names `NotFoundError`, `QuotaExceededError`, `KeyValue`, and `Watcher.Events`.
|
|
||||||
- Updated docs and examples to describe the primary names only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5 — Re-entrant Event Dispatch
|
|
||||||
|
|
||||||
**Agent:** Codex
|
|
||||||
**Completed:** 2026-03-30
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Split watcher and callback registry locks so callbacks can register or unregister subscriptions without deadlocking.
|
|
||||||
- Updated `notify()` to dispatch watcher events under the watcher lock, snapshot callbacks under the callback lock, and invoke callbacks after both locks are released.
|
|
||||||
|
|
||||||
### Tests added
|
|
||||||
|
|
||||||
- Re-entrant callback coverage for `Watch`, `Unwatch`, and `OnChange` from inside the same callback while a write is in flight.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6 — AX Schema Naming Cleanup
|
|
||||||
|
|
||||||
**Agent:** Codex
|
|
||||||
**Completed:** 2026-03-30
|
|
||||||
|
|
||||||
Renamed the internal SQLite schema to use descriptive names that are easier for agents to read and reason about.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Replaced the abbreviated key-value table with the descriptive `entries` table.
|
|
||||||
- Renamed the `grp`, `key`, and `value` schema columns to `group_name`, `entry_key`, and `entry_value`.
|
|
||||||
- Added a startup migration that copies legacy key-value databases into the new schema and preserves TTL data when present.
|
|
||||||
- Kept the public Go API unchanged; the migration only affects the internal storage layout.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coverage Test Suite
|
## Coverage Test Suite
|
||||||
|
|
||||||
`coverage_test.go` exercises defensive error paths that integration tests cannot reach through normal usage:
|
`coverage_test.go` exercises defensive error paths that integration tests cannot reach through normal usage:
|
||||||
|
|
||||||
- Schema conflict: pre-existing SQLite index named `entries` causes `New()` to return `store.New: ensure schema: ...`.
|
- Schema conflict: pre-existing SQLite index named `kv` causes `New()` to return `store.New: schema: ...`.
|
||||||
- `GetAll` scan error: NULL key in a row (requires manually altering the schema to remove the NOT NULL constraint) to trigger `store.All: scan row: ...`.
|
- `GetAll` scan error: NULL key in a row (requires manually altering the schema to remove the NOT NULL constraint).
|
||||||
- `GetAll` rows iteration error: physically corrupting database pages mid-file to trigger `store.All: rows iteration: ...`.
|
- `GetAll` rows iteration error: physically corrupting database pages mid-file to trigger `rows.Err()` during multi-page scans.
|
||||||
- `Render` scan error: same NULL-key technique, surfaced as `store.All: scan row: ...`.
|
- `Render` scan error: same NULL-key technique.
|
||||||
- `Render` rows iteration error: same corruption technique, surfaced as `store.All: rows iteration: ...`.
|
- `Render` rows iteration error: same corruption technique.
|
||||||
|
|
||||||
These tests exercise correct defensive code. They must continue to pass but are not indicative of real failure modes in production.
|
These tests exercise correct defensive code. They must continue to pass but are not indicative of real failure modes in production.
|
||||||
|
|
||||||
|
|
@ -202,7 +155,13 @@ These tests exercise correct defensive code. They must continue to pass but are
|
||||||
|
|
||||||
**File-backed write throughput.** File-backed `Set` operations (~3,800 ops/sec on Apple M-series) are dominated by fsync. Applications writing at higher rates should use in-memory stores or consider WAL checkpoint tuning.
|
**File-backed write throughput.** File-backed `Set` operations (~3,800 ops/sec on Apple M-series) are dominated by fsync. Applications writing at higher rates should use in-memory stores or consider WAL checkpoint tuning.
|
||||||
|
|
||||||
**`GetAll` memory usage.** Fetching a group with 10,000 keys allocates approximately 2.3 MB per call. Use `GetPage()` when you need offset/limit pagination over a large group. Applications with very large groups should still prefer smaller groups or selective queries.
|
**`GetAll` memory usage.** Fetching a group with 10,000 keys allocates approximately 2.3 MB per call. There is no pagination API. Applications with very large groups should restructure data into smaller groups or query selectively.
|
||||||
|
|
||||||
|
**No cross-group transactions.** There is no API for atomic multi-group operations. Each method is individually atomic at the SQLite level, but there is no `Begin`/`Commit` exposed to callers.
|
||||||
|
|
||||||
|
**No wildcard deletes.** There is no `DeletePrefix` or pattern-based delete. To delete all groups under a namespace, callers must retrieve the group list via `Groups()` and delete each individually.
|
||||||
|
|
||||||
|
**Callback deadlock risk.** `OnChange` callbacks run synchronously in the writer's goroutine while holding `s.mu` (read). Calling any `Store` method that calls `notify()` from within a callback will attempt to re-acquire `s.mu` (read), which is permitted with a read-lock but calling `Watch`/`Unwatch`/`OnChange` within a callback will deadlock (they require a write-lock). Document this constraint prominently in callback usage.
|
||||||
|
|
||||||
**No persistence of watcher registrations.** Watchers and callbacks are in-memory only. They are not persisted across `Close`/`New` cycles.
|
**No persistence of watcher registrations.** Watchers and callbacks are in-memory only. They are not persisted across `Close`/`New` cycles.
|
||||||
|
|
||||||
|
|
@ -212,4 +171,8 @@ These tests exercise correct defensive code. They must continue to pass but are
|
||||||
|
|
||||||
These are design notes, not committed work:
|
These are design notes, not committed work:
|
||||||
|
|
||||||
- **Indexed prefix keys.** An additional index on `(group_name, entry_key)` prefix would accelerate prefix scans without a full-table scan.
|
- **Pagination for `GetAll`.** A `GetPage(group string, offset, limit int)` method would support large groups without full in-memory materialisation.
|
||||||
|
- **Indexed prefix keys.** An additional index on `(grp, key)` prefix would accelerate prefix scans without a full-table scan.
|
||||||
|
- **TTL background purge interval as constructor option.** Currently only settable by mutating `s.purgeInterval` directly in tests. A `WithPurgeInterval(d time.Duration)` functional option would make this part of the public API.
|
||||||
|
- **Cross-group atomic operations.** Exposing a `Transaction(func(tx *StoreTx) error)` API would allow callers to compose atomic multi-group operations.
|
||||||
|
- **`DeletePrefix(prefix string)` method.** Would enable efficient cleanup of an entire namespace without first listing groups.
|
||||||
|
|
|
||||||
137
docs/index.md
137
docs/index.md
|
|
@ -7,11 +7,9 @@ description: Group-namespaced SQLite key-value store with TTL expiry, namespace
|
||||||
|
|
||||||
`go-store` is a group-namespaced key-value store backed by SQLite. It provides persistent or in-memory storage with optional TTL expiry, namespace isolation for multi-tenant use, quota enforcement, and a reactive event system for observing mutations.
|
`go-store` is a group-namespaced key-value store backed by SQLite. It provides persistent or in-memory storage with optional TTL expiry, namespace isolation for multi-tenant use, quota enforcement, and a reactive event system for observing mutations.
|
||||||
|
|
||||||
For declarative setup, `store.NewConfigured(store.StoreConfig{...})` takes a single config struct instead of functional options. Prefer this when the configuration is already known; use `store.New(path, ...)` when you are only varying the database path.
|
|
||||||
|
|
||||||
The package has a single runtime dependency -- a pure-Go SQLite driver (`modernc.org/sqlite`). No CGO is required. It compiles and runs on all platforms that Go supports.
|
The package has a single runtime dependency -- a pure-Go SQLite driver (`modernc.org/sqlite`). No CGO is required. It compiles and runs on all platforms that Go supports.
|
||||||
|
|
||||||
**Module path:** `dappco.re/go/store`
|
**Module path:** `dappco.re/go/core/store`
|
||||||
**Go version:** 1.26+
|
**Go version:** 1.26+
|
||||||
**Licence:** EUPL-1.2
|
**Licence:** EUPL-1.2
|
||||||
|
|
||||||
|
|
@ -24,112 +22,71 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/store"
|
"dappco.re/go/core/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Open /tmp/app.db for persistence, or use ":memory:" for ephemeral data.
|
// Open a store. Use ":memory:" for ephemeral data or a file path for persistence.
|
||||||
storeInstance, err := store.NewConfigured(store.StoreConfig{
|
st, err := store.New("/tmp/app.db")
|
||||||
DatabasePath: "/tmp/app.db",
|
|
||||||
PurgeInterval: 30 * time.Second,
|
|
||||||
WorkspaceStateDirectory: "/tmp/core-state",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
panic(err)
|
||||||
}
|
}
|
||||||
defer storeInstance.Close()
|
defer st.Close()
|
||||||
|
|
||||||
// Store "blue" under config/colour and read it back.
|
// Basic CRUD
|
||||||
if err := storeInstance.Set("config", "colour", "blue"); err != nil {
|
st.Set("config", "theme", "dark")
|
||||||
return
|
val, _ := st.Get("config", "theme")
|
||||||
}
|
fmt.Println(val) // "dark"
|
||||||
colourValue, err := storeInstance.Get("config", "colour")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(colourValue) // "blue"
|
|
||||||
|
|
||||||
// Store a session token that expires after 24 hours.
|
// TTL expiry -- key disappears after the duration elapses
|
||||||
if err := storeInstance.SetWithTTL("session", "token", "abc123", 24*time.Hour); err != nil {
|
st.SetWithTTL("session", "token", "abc123", 24*time.Hour)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read config/colour back into a map.
|
// Fetch all keys in a group
|
||||||
configEntries, err := storeInstance.GetAll("config")
|
all, _ := st.GetAll("config")
|
||||||
if err != nil {
|
fmt.Println(all) // map[theme:dark]
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(configEntries) // map[colour:blue]
|
|
||||||
|
|
||||||
// Render the mail host and port into smtp.example.com:587.
|
// Template rendering from stored values
|
||||||
if err := storeInstance.Set("mail", "host", "smtp.example.com"); err != nil {
|
st.Set("mail", "host", "smtp.example.com")
|
||||||
return
|
st.Set("mail", "port", "587")
|
||||||
}
|
out, _ := st.Render(`{{ .host }}:{{ .port }}`, "mail")
|
||||||
if err := storeInstance.Set("mail", "port", "587"); err != nil {
|
fmt.Println(out) // "smtp.example.com:587"
|
||||||
return
|
|
||||||
}
|
|
||||||
renderedTemplate, err := storeInstance.Render(`{{ .host }}:{{ .port }}`, "mail")
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Println(renderedTemplate) // "smtp.example.com:587"
|
|
||||||
|
|
||||||
// Store tenant-42 preferences under the tenant-42: namespace prefix.
|
// Namespace isolation for multi-tenant use
|
||||||
scopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{
|
sc, _ := store.NewScoped(st, "tenant-42")
|
||||||
Namespace: "tenant-42",
|
sc.Set("prefs", "locale", "en-GB")
|
||||||
})
|
// Stored internally as group "tenant-42:prefs", key "locale"
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := scopedStore.SetIn("preferences", "locale", "en-GB"); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Stored internally as group "tenant-42:preferences", key "locale"
|
|
||||||
|
|
||||||
// Cap tenant-99 at 100 keys and 5 groups.
|
// Quota enforcement
|
||||||
quotaScopedStore, err := store.NewScopedConfigured(storeInstance, store.ScopedStoreConfig{
|
quota := store.QuotaConfig{MaxKeys: 100, MaxGroups: 5}
|
||||||
Namespace: "tenant-99",
|
sq, _ := store.NewScopedWithQuota(st, "tenant-99", quota)
|
||||||
Quota: store.QuotaConfig{MaxKeys: 100, MaxGroups: 5},
|
err = sq.Set("g", "k", "v") // returns store.ErrQuotaExceeded if limits are hit
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// A write past the limit returns store.QuotaExceededError.
|
|
||||||
if err := quotaScopedStore.SetIn("g", "k", "v"); err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch "config" changes and print each event as it arrives.
|
// Watch for mutations via a buffered channel
|
||||||
events := storeInstance.Watch("config")
|
w := st.Watch("config", "*")
|
||||||
defer storeInstance.Unwatch("config", events)
|
defer st.Unwatch(w)
|
||||||
go func() {
|
go func() {
|
||||||
for event := range events {
|
for e := range w.Ch {
|
||||||
fmt.Println("event", event.Type, event.Group, event.Key, event.Value)
|
fmt.Printf("event: %s %s/%s\n", e.Type, e.Group, e.Key)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Or register a synchronous callback for the same mutations.
|
// Or register a synchronous callback
|
||||||
unregister := storeInstance.OnChange(func(event store.Event) {
|
unreg := st.OnChange(func(e store.Event) {
|
||||||
fmt.Println("changed", event.Group, event.Key, event.Value)
|
fmt.Printf("changed: %s\n", e.Key)
|
||||||
})
|
})
|
||||||
defer unregister()
|
defer unreg()
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Package Layout
|
## Package Layout
|
||||||
|
|
||||||
The entire package lives in a single Go package (`package store`) with the following implementation files plus `doc.go` for the package comment:
|
The entire package lives in a single Go package (`package store`) with three source files:
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `doc.go` | Package comment with concrete usage examples |
|
| `store.go` | Core `Store` type, CRUD operations (`Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`), bulk queries (`GetAll`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`), string splitting helpers (`GetSplit`, `GetFields`), template rendering (`Render`), TTL expiry, background purge goroutine |
|
||||||
| `store.go` | Core `Store` type, CRUD operations (`Get`, `Set`, `SetWithTTL`, `Delete`, `DeleteGroup`, `DeletePrefix`), bulk queries (`GetAll`, `GetPage`, `All`, `Count`, `CountAll`, `Groups`, `GroupsSeq`), string splitting helpers (`GetSplit`, `GetFields`), template rendering (`Render`), TTL expiry, background purge goroutine, transaction support |
|
| `events.go` | `EventType` constants, `Event` struct, `Watcher` type, `Watch`/`Unwatch` subscription management, `OnChange` callback registration, internal `notify` dispatch |
|
||||||
| `transaction.go` | `Store.Transaction`, transaction-scoped write helpers, staged event dispatch |
|
| `scope.go` | `ScopedStore` wrapper for namespace isolation, `QuotaConfig` struct, `NewScoped`/`NewScopedWithQuota` constructors, quota enforcement logic |
|
||||||
| `events.go` | `EventType` constants, `Event` struct, `Watch`/`Unwatch` channel subscriptions, `OnChange` callback registration, internal `notify` dispatch |
|
|
||||||
| `scope.go` | `ScopedStore` wrapper for namespace isolation, `QuotaConfig` struct, `NewScoped`/`NewScopedConfigured` constructors, namespace-local helper delegation, quota enforcement logic |
|
|
||||||
| `journal.go` | Journal persistence, Flux-like querying, JSON row inflation, journal schema helpers |
|
|
||||||
| `workspace.go` | Workspace buffers, aggregation, query analysis, commit flow, and orphan recovery |
|
|
||||||
| `compact.go` | Cold archive generation to JSONL gzip or zstd |
|
|
||||||
|
|
||||||
Tests are organised in corresponding files:
|
Tests are organised in corresponding files:
|
||||||
|
|
||||||
|
|
@ -155,7 +112,7 @@ Tests are organised in corresponding files:
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `github.com/stretchr/testify` | Assertion helpers (`assert`, `require`) for tests. |
|
| `github.com/stretchr/testify` | Assertion helpers (`assert`, `require`) for tests. |
|
||||||
|
|
||||||
There are no other direct dependencies. The package uses the Go standard library plus `dappco.re/go/core` helper primitives for error wrapping, string handling, and filesystem-safe path composition.
|
There are no other direct dependencies. The package uses only the Go standard library (`database/sql`, `context`, `sync`, `time`, `text/template`, `iter`, `errors`, `fmt`, `strings`, `regexp`, `slices`, `sync/atomic`) beyond the SQLite driver.
|
||||||
|
|
||||||
## Key Types
|
## Key Types
|
||||||
|
|
||||||
|
|
@ -163,17 +120,15 @@ There are no other direct dependencies. The package uses the Go standard library
|
||||||
- **`ScopedStore`** -- wraps a `*Store` with an auto-prefixed namespace. Provides the same API surface with group names transparently prefixed.
|
- **`ScopedStore`** -- wraps a `*Store` with an auto-prefixed namespace. Provides the same API surface with group names transparently prefixed.
|
||||||
- **`QuotaConfig`** -- configures per-namespace limits on total keys and distinct groups.
|
- **`QuotaConfig`** -- configures per-namespace limits on total keys and distinct groups.
|
||||||
- **`Event`** -- describes a single store mutation (type, group, key, value, timestamp).
|
- **`Event`** -- describes a single store mutation (type, group, key, value, timestamp).
|
||||||
- **`Watch`** -- returns a buffered channel subscription to store events. Use `Unwatch(group, events)` to stop delivery and close the channel.
|
- **`Watcher`** -- a channel-based subscription to store events, created by `Watch`.
|
||||||
- **`KeyValue`** -- a simple key-value pair struct, used by the `All` iterator.
|
- **`KV`** -- a simple key-value pair struct, used by the `All` iterator.
|
||||||
|
|
||||||
## Sentinel Errors
|
## Sentinel Errors
|
||||||
|
|
||||||
- **`NotFoundError`** -- returned by `Get` when the requested key does not exist or has expired.
|
- **`ErrNotFound`** -- returned by `Get` when the requested key does not exist or has expired.
|
||||||
- **`QuotaExceededError`** -- returned by `ScopedStore.Set`/`SetWithTTL` when a namespace quota limit is reached.
|
- **`ErrQuotaExceeded`** -- returned by `ScopedStore.Set`/`SetWithTTL` when a namespace quota limit is reached.
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
- [Agent Conventions](../CODEX.md) -- Codex-facing repo rules and AX notes
|
|
||||||
- [AX RFC](RFC-CORE-008-AGENT-EXPERIENCE.md) -- naming, comment, and path conventions for agent consumers
|
|
||||||
- [Architecture](architecture.md) -- storage layer internals, TTL model, event system, concurrency design
|
- [Architecture](architecture.md) -- storage layer internals, TTL model, event system, concurrency design
|
||||||
- [Development Guide](development.md) -- building, testing, benchmarks, contribution workflow
|
- [Development Guide](development.md) -- building, testing, benchmarks, contribution workflow
|
||||||
|
|
|
||||||
473
duckdb.go
473
duckdb.go
|
|
@ -1,473 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
_ "github.com/marcboeker/go-duckdb"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DuckDB table names for checkpoint scoring and probe results.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// db.EnsureScoringTables()
|
|
||||||
// db.Exec(core.Sprintf("SELECT * FROM %s", store.TableCheckpointScores))
|
|
||||||
const (
|
|
||||||
// TableCheckpointScores is the table name for checkpoint scoring data.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// store.TableCheckpointScores // "checkpoint_scores"
|
|
||||||
TableCheckpointScores = "checkpoint_scores"
|
|
||||||
|
|
||||||
// TableProbeResults is the table name for probe result data.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// store.TableProbeResults // "probe_results"
|
|
||||||
TableProbeResults = "probe_results"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DuckDB wraps a DuckDB connection for analytical queries against training
|
|
||||||
// data, benchmark results, and scoring tables.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// db, err := store.OpenDuckDB("/Volumes/Data/lem/lem.duckdb")
|
|
||||||
// if err != nil { return }
|
|
||||||
// defer db.Close()
|
|
||||||
// rows, _ := db.QueryGoldenSet(500)
|
|
||||||
type DuckDB struct {
|
|
||||||
conn *sql.DB
|
|
||||||
path string
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenDuckDB opens a DuckDB database file in read-only mode to avoid locking
|
|
||||||
// issues with the Python pipeline.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// db, err := store.OpenDuckDB("/Volumes/Data/lem/lem.duckdb")
|
|
||||||
func OpenDuckDB(path string) (*DuckDB, error) {
|
|
||||||
conn, err := sql.Open("duckdb", path+"?access_mode=READ_ONLY")
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.OpenDuckDB", core.Sprintf("open duckdb %s", path), err)
|
|
||||||
}
|
|
||||||
if err := conn.Ping(); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, core.E("store.OpenDuckDB", core.Sprintf("ping duckdb %s", path), err)
|
|
||||||
}
|
|
||||||
return &DuckDB{conn: conn, path: path}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// OpenDuckDBReadWrite opens a DuckDB database in read-write mode.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// db, err := store.OpenDuckDBReadWrite("/Volumes/Data/lem/lem.duckdb")
|
|
||||||
func OpenDuckDBReadWrite(path string) (*DuckDB, error) {
|
|
||||||
conn, err := sql.Open("duckdb", path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.OpenDuckDBReadWrite", core.Sprintf("open duckdb %s", path), err)
|
|
||||||
}
|
|
||||||
if err := conn.Ping(); err != nil {
|
|
||||||
conn.Close()
|
|
||||||
return nil, core.E("store.OpenDuckDBReadWrite", core.Sprintf("ping duckdb %s", path), err)
|
|
||||||
}
|
|
||||||
return &DuckDB{conn: conn, path: path}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close closes the database connection.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// defer db.Close()
|
|
||||||
func (db *DuckDB) Close() error {
|
|
||||||
return db.conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path returns the database file path.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p := db.Path() // "/Volumes/Data/lem/lem.duckdb"
|
|
||||||
func (db *DuckDB) Path() string {
|
|
||||||
return db.path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Conn returns the underlying *sql.DB connection. Prefer the typed helpers
|
|
||||||
// (Exec, QueryRowScan, QueryRows) when possible; this accessor exists for
|
|
||||||
// callers that need streaming row iteration or transaction control.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// rows, err := db.Conn().Query("SELECT id, name FROM models WHERE kind = ?", "lem")
|
|
||||||
func (db *DuckDB) Conn() *sql.DB {
|
|
||||||
return db.conn
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exec executes a query without returning rows.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// err := db.Exec("INSERT INTO golden_set VALUES (?, ?)", idx, prompt)
|
|
||||||
func (db *DuckDB) Exec(query string, args ...any) error {
|
|
||||||
_, err := db.conn.Exec(query, args...)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryRowScan executes a query expected to return at most one row and scans
|
|
||||||
// the result into dest. It is a convenience wrapper around sql.DB.QueryRow.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// var count int
|
|
||||||
// err := db.QueryRowScan("SELECT COUNT(*) FROM golden_set", &count)
|
|
||||||
func (db *DuckDB) QueryRowScan(query string, dest any, args ...any) error {
|
|
||||||
return db.conn.QueryRow(query, args...).Scan(dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoldenSetRow represents one row from the golden_set table.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// rows, err := db.QueryGoldenSet(500)
|
|
||||||
// for _, row := range rows { core.Println(row.Prompt) }
|
|
||||||
type GoldenSetRow struct {
|
|
||||||
// Idx is the row index.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Idx // 42
|
|
||||||
Idx int
|
|
||||||
|
|
||||||
// SeedID is the seed identifier that produced this row.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.SeedID // "seed-001"
|
|
||||||
SeedID string
|
|
||||||
|
|
||||||
// Domain is the content domain (e.g. "philosophy", "science").
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Domain // "philosophy"
|
|
||||||
Domain string
|
|
||||||
|
|
||||||
// Voice is the writing voice/style used for generation.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Voice // "watts"
|
|
||||||
Voice string
|
|
||||||
|
|
||||||
// Prompt is the input prompt text.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Prompt // "What is sovereignty?"
|
|
||||||
Prompt string
|
|
||||||
|
|
||||||
// Response is the generated response text.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Response // "Sovereignty is..."
|
|
||||||
Response string
|
|
||||||
|
|
||||||
// GenTime is the generation time in seconds.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.GenTime // 2.5
|
|
||||||
GenTime float64
|
|
||||||
|
|
||||||
// CharCount is the character count of the response.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.CharCount // 1500
|
|
||||||
CharCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExpansionPromptRow represents one row from the expansion_prompts table.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// prompts, err := db.QueryExpansionPrompts("pending", 100)
|
|
||||||
// for _, p := range prompts { core.Println(p.Prompt) }
|
|
||||||
type ExpansionPromptRow struct {
|
|
||||||
// Idx is the row index.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Idx // 42
|
|
||||||
Idx int64
|
|
||||||
|
|
||||||
// SeedID is the seed identifier that produced this prompt.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.SeedID // "seed-001"
|
|
||||||
SeedID string
|
|
||||||
|
|
||||||
// Region is the geographic/cultural region for the prompt.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Region // "western"
|
|
||||||
Region string
|
|
||||||
|
|
||||||
// Domain is the content domain (e.g. "philosophy", "science").
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Domain // "philosophy"
|
|
||||||
Domain string
|
|
||||||
|
|
||||||
// Language is the ISO language code for the prompt.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Language // "en"
|
|
||||||
Language string
|
|
||||||
|
|
||||||
// Prompt is the prompt text in the original language.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Prompt // "What is sovereignty?"
|
|
||||||
Prompt string
|
|
||||||
|
|
||||||
// PromptEn is the English translation of the prompt.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.PromptEn // "What is sovereignty?"
|
|
||||||
PromptEn string
|
|
||||||
|
|
||||||
// Priority is the generation priority (lower is higher priority).
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Priority // 1
|
|
||||||
Priority int
|
|
||||||
|
|
||||||
// Status is the processing status (e.g. "pending", "done").
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// p.Status // "pending"
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryGoldenSet returns all golden set rows with responses >= minChars.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// rows, err := db.QueryGoldenSet(500)
|
|
||||||
func (db *DuckDB) QueryGoldenSet(minChars int) ([]GoldenSetRow, error) {
|
|
||||||
rows, err := db.conn.Query(
|
|
||||||
"SELECT idx, seed_id, domain, voice, prompt, response, gen_time, char_count "+
|
|
||||||
"FROM golden_set WHERE char_count >= ? ORDER BY idx",
|
|
||||||
minChars,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryGoldenSet", "query golden_set", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []GoldenSetRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r GoldenSetRow
|
|
||||||
if err := rows.Scan(&r.Idx, &r.SeedID, &r.Domain, &r.Voice,
|
|
||||||
&r.Prompt, &r.Response, &r.GenTime, &r.CharCount); err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryGoldenSet", "scan golden_set row", err)
|
|
||||||
}
|
|
||||||
result = append(result, r)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountGoldenSet returns the total count of golden set rows.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// count, err := db.CountGoldenSet()
|
|
||||||
func (db *DuckDB) CountGoldenSet() (int, error) {
|
|
||||||
var count int
|
|
||||||
err := db.conn.QueryRow("SELECT COUNT(*) FROM golden_set").Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, core.E("store.DuckDB.CountGoldenSet", "count golden_set", err)
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryExpansionPrompts returns expansion prompts filtered by status.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// prompts, err := db.QueryExpansionPrompts("pending", 100)
|
|
||||||
func (db *DuckDB) QueryExpansionPrompts(status string, limit int) ([]ExpansionPromptRow, error) {
|
|
||||||
query := "SELECT idx, seed_id, region, domain, language, prompt, prompt_en, priority, status " +
|
|
||||||
"FROM expansion_prompts"
|
|
||||||
var args []any
|
|
||||||
|
|
||||||
if status != "" {
|
|
||||||
query += " WHERE status = ?"
|
|
||||||
args = append(args, status)
|
|
||||||
}
|
|
||||||
query += " ORDER BY priority, idx"
|
|
||||||
|
|
||||||
if limit > 0 {
|
|
||||||
query += core.Sprintf(" LIMIT %d", limit)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := db.conn.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryExpansionPrompts", "query expansion_prompts", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var result []ExpansionPromptRow
|
|
||||||
for rows.Next() {
|
|
||||||
var r ExpansionPromptRow
|
|
||||||
if err := rows.Scan(&r.Idx, &r.SeedID, &r.Region, &r.Domain,
|
|
||||||
&r.Language, &r.Prompt, &r.PromptEn, &r.Priority, &r.Status); err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryExpansionPrompts", "scan expansion_prompt row", err)
|
|
||||||
}
|
|
||||||
result = append(result, r)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// CountExpansionPrompts returns counts by status.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// total, pending, err := db.CountExpansionPrompts()
|
|
||||||
func (db *DuckDB) CountExpansionPrompts() (total int, pending int, err error) {
|
|
||||||
err = db.conn.QueryRow("SELECT COUNT(*) FROM expansion_prompts").Scan(&total)
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, core.E("store.DuckDB.CountExpansionPrompts", "count expansion_prompts", err)
|
|
||||||
}
|
|
||||||
err = db.conn.QueryRow("SELECT COUNT(*) FROM expansion_prompts WHERE status = 'pending'").Scan(&pending)
|
|
||||||
if err != nil {
|
|
||||||
return total, 0, core.E("store.DuckDB.CountExpansionPrompts", "count pending expansion_prompts", err)
|
|
||||||
}
|
|
||||||
return total, pending, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateExpansionStatus updates the status of an expansion prompt by idx.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// err := db.UpdateExpansionStatus(42, "done")
|
|
||||||
func (db *DuckDB) UpdateExpansionStatus(idx int64, status string) error {
|
|
||||||
_, err := db.conn.Exec("UPDATE expansion_prompts SET status = ? WHERE idx = ?", status, idx)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.DuckDB.UpdateExpansionStatus", core.Sprintf("update expansion_prompt %d", idx), err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// QueryRows executes an arbitrary SQL query and returns results as maps.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// rows, err := db.QueryRows("SELECT COUNT(*) AS n FROM golden_set")
|
|
||||||
func (db *DuckDB) QueryRows(query string, args ...any) ([]map[string]any, error) {
|
|
||||||
rows, err := db.conn.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryRows", "query", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
cols, err := rows.Columns()
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryRows", "columns", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []map[string]any
|
|
||||||
for rows.Next() {
|
|
||||||
values := make([]any, len(cols))
|
|
||||||
ptrs := make([]any, len(cols))
|
|
||||||
for i := range values {
|
|
||||||
ptrs[i] = &values[i]
|
|
||||||
}
|
|
||||||
if err := rows.Scan(ptrs...); err != nil {
|
|
||||||
return nil, core.E("store.DuckDB.QueryRows", "scan", err)
|
|
||||||
}
|
|
||||||
row := make(map[string]any, len(cols))
|
|
||||||
for i, col := range cols {
|
|
||||||
row[col] = values[i]
|
|
||||||
}
|
|
||||||
result = append(result, row)
|
|
||||||
}
|
|
||||||
return result, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureScoringTables creates the scoring tables if they do not exist.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// db.EnsureScoringTables()
|
|
||||||
func (db *DuckDB) EnsureScoringTables() {
|
|
||||||
db.conn.Exec(core.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
|
||||||
model TEXT, run_id TEXT, label TEXT, iteration INTEGER,
|
|
||||||
correct INTEGER, total INTEGER, accuracy DOUBLE,
|
|
||||||
scored_at TIMESTAMP DEFAULT current_timestamp,
|
|
||||||
PRIMARY KEY (run_id, label)
|
|
||||||
)`, TableCheckpointScores))
|
|
||||||
db.conn.Exec(core.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
|
||||||
model TEXT, run_id TEXT, label TEXT, probe_id TEXT,
|
|
||||||
passed BOOLEAN, response TEXT, iteration INTEGER,
|
|
||||||
scored_at TIMESTAMP DEFAULT current_timestamp,
|
|
||||||
PRIMARY KEY (run_id, label, probe_id)
|
|
||||||
)`, TableProbeResults))
|
|
||||||
db.conn.Exec(`CREATE TABLE IF NOT EXISTS scoring_results (
|
|
||||||
model TEXT, prompt_id TEXT, suite TEXT,
|
|
||||||
dimension TEXT, score DOUBLE,
|
|
||||||
scored_at TIMESTAMP DEFAULT current_timestamp
|
|
||||||
)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WriteScoringResult writes a single scoring dimension result to DuckDB.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// err := db.WriteScoringResult("lem-8b", "p-001", "ethics", "honesty", 0.95)
|
|
||||||
func (db *DuckDB) WriteScoringResult(model, promptID, suite, dimension string, score float64) error {
|
|
||||||
_, err := db.conn.Exec(
|
|
||||||
`INSERT INTO scoring_results (model, prompt_id, suite, dimension, score) VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
model, promptID, suite, dimension, score,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableCounts returns row counts for all known tables.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// counts, err := db.TableCounts()
|
|
||||||
// n := counts["golden_set"]
|
|
||||||
func (db *DuckDB) TableCounts() (map[string]int, error) {
|
|
||||||
tables := []string{"golden_set", "expansion_prompts", "seeds", "prompts",
|
|
||||||
"training_examples", "gemini_responses", "benchmark_questions", "benchmark_results", "validations",
|
|
||||||
TableCheckpointScores, TableProbeResults, "scoring_results"}
|
|
||||||
|
|
||||||
counts := make(map[string]int)
|
|
||||||
for _, t := range tables {
|
|
||||||
var count int
|
|
||||||
err := db.conn.QueryRow(core.Sprintf("SELECT COUNT(*) FROM %s", t)).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
counts[t] = count
|
|
||||||
}
|
|
||||||
return counts, nil
|
|
||||||
}
|
|
||||||
275
events.go
275
events.go
|
|
@ -1,25 +1,25 @@
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Usage example: `if event.Type == store.EventSet { return }`
|
// EventType describes the kind of store mutation that occurred.
|
||||||
type EventType int
|
type EventType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Usage example: `if event.Type == store.EventSet { return }`
|
// EventSet indicates a key was created or updated.
|
||||||
EventSet EventType = iota
|
EventSet EventType = iota
|
||||||
// Usage example: `if event.Type == store.EventDelete { return }`
|
// EventDelete indicates a single key was removed.
|
||||||
EventDelete
|
EventDelete
|
||||||
// Usage example: `if event.Type == store.EventDeleteGroup { return }`
|
// EventDeleteGroup indicates all keys in a group were removed.
|
||||||
EventDeleteGroup
|
EventDeleteGroup
|
||||||
)
|
)
|
||||||
|
|
||||||
// Usage example: `label := store.EventDeleteGroup.String()`
|
// String returns a human-readable label for the event type.
|
||||||
func (t EventType) String() string {
|
func (t EventType) String() string {
|
||||||
switch t {
|
switch t {
|
||||||
case EventSet:
|
case EventSet:
|
||||||
|
|
@ -27,221 +27,146 @@ func (t EventType) String() string {
|
||||||
case EventDelete:
|
case EventDelete:
|
||||||
return "delete"
|
return "delete"
|
||||||
case EventDeleteGroup:
|
case EventDeleteGroup:
|
||||||
return "deletegroup"
|
return "delete_group"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage example: `event := store.Event{Type: store.EventSet, Group: "config", Key: "colour", Value: "blue"}`
|
// Event describes a single store mutation. Key is empty for EventDeleteGroup.
|
||||||
// Usage example: `event := store.Event{Type: store.EventDeleteGroup, Group: "config"}`
|
// Value is only populated for EventSet.
|
||||||
type Event struct {
|
type Event struct {
|
||||||
// Usage example: `if event.Type == store.EventDeleteGroup { return }`
|
|
||||||
Type EventType
|
Type EventType
|
||||||
// Usage example: `if event.Group == "config" { return }`
|
|
||||||
Group string
|
Group string
|
||||||
// Usage example: `if event.Key == "colour" { return }`
|
|
||||||
Key string
|
Key string
|
||||||
// Usage example: `if event.Value == "blue" { return }`
|
|
||||||
Value string
|
Value string
|
||||||
// Usage example: `if event.Timestamp.IsZero() { return }`
|
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// changeCallbackRegistration keeps the registration ID so unregister can remove
|
// Watcher receives events matching a group/key filter. Use Store.Watch to
|
||||||
// the exact callback later.
|
// create one and Store.Unwatch to stop delivery.
|
||||||
type changeCallbackRegistration struct {
|
type Watcher struct {
|
||||||
registrationID uint64
|
// Ch is the public read-only channel that consumers select on.
|
||||||
callback func(Event)
|
Ch <-chan Event
|
||||||
|
|
||||||
|
// ch is the internal write channel (same underlying channel as Ch).
|
||||||
|
ch chan Event
|
||||||
|
|
||||||
|
group string
|
||||||
|
key string
|
||||||
|
id uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
func closedEventChannel() chan Event {
|
// callbackEntry pairs a change callback with its unique ID for unregistration.
|
||||||
eventChannel := make(chan Event)
|
type callbackEntry struct {
|
||||||
close(eventChannel)
|
id uint64
|
||||||
return eventChannel
|
fn func(Event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch("config") can hold 16 pending events before non-blocking sends start
|
// watcherBufSize is the capacity of each watcher's buffered channel.
|
||||||
// dropping new ones.
|
const watcherBufSize = 16
|
||||||
const watcherEventBufferCapacity = 16
|
|
||||||
|
|
||||||
// Usage example: `events := storeInstance.Watch("config")`
|
// Watch creates a new watcher that receives events matching the given group and
|
||||||
// Usage example: `events := storeInstance.Watch("*")`
|
// key. Use "*" as a wildcard: ("mygroup", "*") matches all keys in that group,
|
||||||
func (storeInstance *Store) Watch(group string) <-chan Event {
|
// ("*", "*") matches every mutation. The returned Watcher has a buffered
|
||||||
if storeInstance == nil {
|
// channel (cap 16); events are dropped if the consumer falls behind.
|
||||||
return closedEventChannel()
|
func (s *Store) Watch(group, key string) *Watcher {
|
||||||
|
ch := make(chan Event, watcherBufSize)
|
||||||
|
w := &Watcher{
|
||||||
|
Ch: ch,
|
||||||
|
ch: ch,
|
||||||
|
group: group,
|
||||||
|
key: key,
|
||||||
|
id: atomic.AddUint64(&s.nextID, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
s.mu.Lock()
|
||||||
closed := storeInstance.isClosed
|
s.watchers = append(s.watchers, w)
|
||||||
storeInstance.lifecycleLock.Unlock()
|
s.mu.Unlock()
|
||||||
if closed {
|
|
||||||
return closedEventChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
eventChannel := make(chan Event, watcherEventBufferCapacity)
|
return w
|
||||||
|
|
||||||
storeInstance.watcherLock.Lock()
|
|
||||||
defer storeInstance.watcherLock.Unlock()
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
|
||||||
closed = storeInstance.isClosed
|
|
||||||
storeInstance.lifecycleLock.Unlock()
|
|
||||||
if closed {
|
|
||||||
return closedEventChannel()
|
|
||||||
}
|
|
||||||
if storeInstance.watchers == nil {
|
|
||||||
storeInstance.watchers = make(map[string][]chan Event)
|
|
||||||
}
|
|
||||||
storeInstance.watchers[group] = append(storeInstance.watchers[group], eventChannel)
|
|
||||||
|
|
||||||
return eventChannel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage example: `storeInstance.Unwatch("config", events)`
|
// Unwatch removes a watcher and closes its channel. Safe to call multiple
|
||||||
func (storeInstance *Store) Unwatch(group string, events <-chan Event) {
|
// times; subsequent calls are no-ops.
|
||||||
if storeInstance == nil || events == nil {
|
func (s *Store) Unwatch(w *Watcher) {
|
||||||
return
|
s.mu.Lock()
|
||||||
}
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
s.watchers = slices.DeleteFunc(s.watchers, func(existing *Watcher) bool {
|
||||||
closed := storeInstance.isClosed
|
if existing.id == w.id {
|
||||||
storeInstance.lifecycleLock.Unlock()
|
close(w.ch)
|
||||||
if closed {
|
return true
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
storeInstance.watcherLock.Lock()
|
})
|
||||||
defer storeInstance.watcherLock.Unlock()
|
|
||||||
|
|
||||||
registeredEvents := storeInstance.watchers[group]
|
|
||||||
if len(registeredEvents) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
eventsPointer := channelPointer(events)
|
|
||||||
nextRegisteredEvents := registeredEvents[:0]
|
|
||||||
removed := false
|
|
||||||
for _, registeredChannel := range registeredEvents {
|
|
||||||
if channelPointer(registeredChannel) == eventsPointer {
|
|
||||||
if !removed {
|
|
||||||
close(registeredChannel)
|
|
||||||
removed = true
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
nextRegisteredEvents = append(nextRegisteredEvents, registeredChannel)
|
|
||||||
}
|
|
||||||
if !removed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(nextRegisteredEvents) == 0 {
|
|
||||||
delete(storeInstance.watchers, group)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
storeInstance.watchers[group] = nextRegisteredEvents
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage example: `unregister := storeInstance.OnChange(func(event store.Event) { fmt.Println(event.Group, event.Key, event.Value) })`
|
// OnChange registers a callback that fires on every store mutation. Callbacks
|
||||||
func (storeInstance *Store) OnChange(callback func(Event)) func() {
|
// are called synchronously in the goroutine that performed the write, so the
|
||||||
if callback == nil {
|
// caller controls concurrency. Returns an unregister function; calling it stops
|
||||||
return func() {}
|
// future invocations.
|
||||||
}
|
//
|
||||||
|
// This is the integration point for go-ws and similar consumers:
|
||||||
|
//
|
||||||
|
// unreg := store.OnChange(func(e store.Event) {
|
||||||
|
// hub.SendToChannel("store-events", e)
|
||||||
|
// })
|
||||||
|
// defer unreg()
|
||||||
|
func (s *Store) OnChange(fn func(Event)) func() {
|
||||||
|
id := atomic.AddUint64(&s.nextID, 1)
|
||||||
|
entry := callbackEntry{id: id, fn: fn}
|
||||||
|
|
||||||
if storeInstance == nil {
|
s.mu.Lock()
|
||||||
return func() {}
|
s.callbacks = append(s.callbacks, entry)
|
||||||
}
|
s.mu.Unlock()
|
||||||
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
|
||||||
closed := storeInstance.isClosed
|
|
||||||
storeInstance.lifecycleLock.Unlock()
|
|
||||||
if closed {
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
registrationID := atomic.AddUint64(&storeInstance.nextCallbackID, 1)
|
|
||||||
callbackRegistration := changeCallbackRegistration{registrationID: registrationID, callback: callback}
|
|
||||||
|
|
||||||
storeInstance.callbackLock.Lock()
|
|
||||||
defer storeInstance.callbackLock.Unlock()
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
|
||||||
closed = storeInstance.isClosed
|
|
||||||
storeInstance.lifecycleLock.Unlock()
|
|
||||||
if closed {
|
|
||||||
return func() {}
|
|
||||||
}
|
|
||||||
storeInstance.callbacks = append(storeInstance.callbacks, callbackRegistration)
|
|
||||||
|
|
||||||
// Return an idempotent unregister function.
|
// Return an idempotent unregister function.
|
||||||
var once sync.Once
|
var once sync.Once
|
||||||
return func() {
|
return func() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
storeInstance.callbackLock.Lock()
|
s.mu.Lock()
|
||||||
defer storeInstance.callbackLock.Unlock()
|
defer s.mu.Unlock()
|
||||||
for i := range storeInstance.callbacks {
|
s.callbacks = slices.DeleteFunc(s.callbacks, func(cb callbackEntry) bool {
|
||||||
if storeInstance.callbacks[i].registrationID == registrationID {
|
return cb.id == id
|
||||||
storeInstance.callbacks = append(storeInstance.callbacks[:i], storeInstance.callbacks[i+1:]...)
|
})
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notify(Event{Type: EventSet, Group: "config", Key: "colour", Value: "blue"})
|
// notify dispatches an event to all matching watchers and callbacks. It must be
|
||||||
// dispatches matching watchers and callbacks after a successful write. If a
|
// called after a successful DB write. Watcher sends are non-blocking — if a
|
||||||
// watcher buffer is full, the event is dropped instead of blocking the writer.
|
// channel buffer is full the event is silently dropped to avoid blocking the
|
||||||
// Callbacks are copied under a separate lock and invoked after the lock is
|
// writer.
|
||||||
// released, so they can register or unregister subscriptions without
|
func (s *Store) notify(e Event) {
|
||||||
// deadlocking.
|
s.mu.RLock()
|
||||||
func (storeInstance *Store) notify(event Event) {
|
defer s.mu.RUnlock()
|
||||||
if storeInstance == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if event.Timestamp.IsZero() {
|
|
||||||
event.Timestamp = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
for _, w := range s.watchers {
|
||||||
closed := storeInstance.isClosed
|
if !watcherMatches(w, e) {
|
||||||
storeInstance.lifecycleLock.Unlock()
|
continue
|
||||||
if closed {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
// Non-blocking send: drop the event rather than block the writer.
|
||||||
storeInstance.watcherLock.RLock()
|
|
||||||
storeInstance.lifecycleLock.Lock()
|
|
||||||
closed = storeInstance.isClosed
|
|
||||||
storeInstance.lifecycleLock.Unlock()
|
|
||||||
if closed {
|
|
||||||
storeInstance.watcherLock.RUnlock()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, registeredChannel := range storeInstance.watchers["*"] {
|
|
||||||
select {
|
select {
|
||||||
case registeredChannel <- event:
|
case w.ch <- e:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, registeredChannel := range storeInstance.watchers[event.Group] {
|
|
||||||
select {
|
|
||||||
case registeredChannel <- event:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
storeInstance.watcherLock.RUnlock()
|
|
||||||
|
|
||||||
storeInstance.callbackLock.RLock()
|
for _, cb := range s.callbacks {
|
||||||
callbacks := append([]changeCallbackRegistration(nil), storeInstance.callbacks...)
|
cb.fn(e)
|
||||||
storeInstance.callbackLock.RUnlock()
|
|
||||||
|
|
||||||
for _, callback := range callbacks {
|
|
||||||
callback.callback(event)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func channelPointer(eventChannel <-chan Event) uintptr {
|
// watcherMatches reports whether a watcher's filter matches the given event.
|
||||||
if eventChannel == nil {
|
func watcherMatches(w *Watcher, e Event) bool {
|
||||||
return 0
|
if w.group != "*" && w.group != e.Group {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
return reflect.ValueOf(eventChannel).Pointer()
|
if w.key != "*" && w.key != e.Key {
|
||||||
|
// EventDeleteGroup has an empty Key — only wildcard watchers or
|
||||||
|
// group-level watchers (key="*") should receive it.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
532
events_test.go
532
events_test.go
|
|
@ -1,46 +1,88 @@
|
||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"dappco.re/go/core"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEvents_Watch_Good_Group(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Watch — specific key
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("config")
|
func TestWatch_Good_SpecificKey(t *testing.T) {
|
||||||
defer storeInstance.Unwatch("config", events)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("config", "theme", "dark"))
|
w := s.Watch("config", "theme")
|
||||||
require.NoError(t, storeInstance.Set("config", "colour", "blue"))
|
defer s.Unwatch(w)
|
||||||
|
|
||||||
received := drainEvents(events, 2, time.Second)
|
require.NoError(t, s.Set("config", "theme", "dark"))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case e := <-w.Ch:
|
||||||
|
assert.Equal(t, EventSet, e.Type)
|
||||||
|
assert.Equal(t, "config", e.Group)
|
||||||
|
assert.Equal(t, "theme", e.Key)
|
||||||
|
assert.Equal(t, "dark", e.Value)
|
||||||
|
assert.False(t, e.Timestamp.IsZero())
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Set to a different key in the same group should NOT trigger this watcher.
|
||||||
|
require.NoError(t, s.Set("config", "colour", "blue"))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case e := <-w.Ch:
|
||||||
|
t.Fatalf("unexpected event for non-matching key: %+v", e)
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// Expected: no event.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Watch — wildcard key "*"
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestWatch_Good_WildcardKey(t *testing.T) {
|
||||||
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
w := s.Watch("config", "*")
|
||||||
|
defer s.Unwatch(w)
|
||||||
|
|
||||||
|
require.NoError(t, s.Set("config", "theme", "dark"))
|
||||||
|
require.NoError(t, s.Set("config", "colour", "blue"))
|
||||||
|
|
||||||
|
received := drainEvents(w.Ch, 2, time.Second)
|
||||||
require.Len(t, received, 2)
|
require.Len(t, received, 2)
|
||||||
assert.Equal(t, "theme", received[0].Key)
|
assert.Equal(t, "theme", received[0].Key)
|
||||||
assert.Equal(t, "colour", received[1].Key)
|
assert.Equal(t, "colour", received[1].Key)
|
||||||
assert.Equal(t, "config", received[0].Group)
|
|
||||||
assert.Equal(t, "config", received[1].Group)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Watch_Good_WildcardGroup(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Watch — wildcard ("*", "*") matches everything
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("*")
|
func TestWatch_Good_WildcardAll(t *testing.T) {
|
||||||
defer storeInstance.Unwatch("*", events)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g1", "k1", "v1"))
|
w := s.Watch("*", "*")
|
||||||
require.NoError(t, storeInstance.Set("g2", "k2", "v2"))
|
defer s.Unwatch(w)
|
||||||
require.NoError(t, storeInstance.Delete("g1", "k1"))
|
|
||||||
require.NoError(t, storeInstance.DeleteGroup("g2"))
|
|
||||||
|
|
||||||
received := drainEvents(events, 4, time.Second)
|
require.NoError(t, s.Set("g1", "k1", "v1"))
|
||||||
|
require.NoError(t, s.Set("g2", "k2", "v2"))
|
||||||
|
require.NoError(t, s.Delete("g1", "k1"))
|
||||||
|
require.NoError(t, s.DeleteGroup("g2"))
|
||||||
|
|
||||||
|
received := drainEvents(w.Ch, 4, time.Second)
|
||||||
require.Len(t, received, 4)
|
require.Len(t, received, 4)
|
||||||
assert.Equal(t, EventSet, received[0].Type)
|
assert.Equal(t, EventSet, received[0].Type)
|
||||||
assert.Equal(t, EventSet, received[1].Type)
|
assert.Equal(t, EventSet, received[1].Type)
|
||||||
|
|
@ -48,303 +90,337 @@ func TestEvents_Watch_Good_WildcardGroup(t *testing.T) {
|
||||||
assert.Equal(t, EventDeleteGroup, received[3].Type)
|
assert.Equal(t, EventDeleteGroup, received[3].Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Unwatch_Good_StopsDelivery(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Unwatch — stops delivery, channel closed
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
func TestUnwatch_Good_StopsDelivery(t *testing.T) {
|
||||||
storeInstance.Unwatch("g", events)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
_, open := <-events
|
w := s.Watch("g", "k")
|
||||||
|
s.Unwatch(w)
|
||||||
|
|
||||||
|
// Channel should be closed.
|
||||||
|
_, open := <-w.Ch
|
||||||
assert.False(t, open, "channel should be closed after Unwatch")
|
assert.False(t, open, "channel should be closed after Unwatch")
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g", "k", "v"))
|
// Set after Unwatch should not panic or block.
|
||||||
|
require.NoError(t, s.Set("g", "k", "v"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Unwatch_Good_Idempotent(t *testing.T) {
|
func TestUnwatch_Good_Idempotent(t *testing.T) {
|
||||||
storeInstance, _ := New(":memory:")
|
s, _ := New(":memory:")
|
||||||
defer storeInstance.Close()
|
defer s.Close()
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
w := s.Watch("g", "k")
|
||||||
storeInstance.Unwatch("g", events)
|
|
||||||
storeInstance.Unwatch("g", events)
|
// Calling Unwatch multiple times should not panic.
|
||||||
|
s.Unwatch(w)
|
||||||
|
s.Unwatch(w) // second call is a no-op
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Close_Good_ClosesWatcherChannels(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Delete triggers event
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
func TestWatch_Good_DeleteEvent(t *testing.T) {
|
||||||
require.NoError(t, storeInstance.Close())
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
_, open := <-events
|
w := s.Watch("g", "k")
|
||||||
assert.False(t, open, "channel should be closed after Close")
|
defer s.Unwatch(w)
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvents_Unwatch_Good_NilChannel(t *testing.T) {
|
require.NoError(t, s.Set("g", "k", "v"))
|
||||||
storeInstance, _ := New(":memory:")
|
// Drain the Set event.
|
||||||
defer storeInstance.Close()
|
<-w.Ch
|
||||||
|
|
||||||
storeInstance.Unwatch("g", nil)
|
require.NoError(t, s.Delete("g", "k"))
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvents_Watch_Good_DeleteEvent(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
|
||||||
defer storeInstance.Unwatch("g", events)
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g", "k", "v"))
|
|
||||||
<-events
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Delete("g", "k"))
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case event := <-events:
|
case e := <-w.Ch:
|
||||||
assert.Equal(t, EventDelete, event.Type)
|
assert.Equal(t, EventDelete, e.Type)
|
||||||
assert.Equal(t, "g", event.Group)
|
assert.Equal(t, "g", e.Group)
|
||||||
assert.Equal(t, "k", event.Key)
|
assert.Equal(t, "k", e.Key)
|
||||||
assert.Empty(t, event.Value)
|
assert.Empty(t, e.Value, "Delete events should have empty Value")
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("timed out waiting for delete event")
|
t.Fatal("timed out waiting for delete event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Watch_Good_DeleteGroupEvent(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// DeleteGroup triggers event
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
func TestWatch_Good_DeleteGroupEvent(t *testing.T) {
|
||||||
defer storeInstance.Unwatch("g", events)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g", "a", "1"))
|
// A wildcard-key watcher for the group should receive DeleteGroup events.
|
||||||
require.NoError(t, storeInstance.Set("g", "b", "2"))
|
w := s.Watch("g", "*")
|
||||||
<-events
|
defer s.Unwatch(w)
|
||||||
<-events
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.DeleteGroup("g"))
|
require.NoError(t, s.Set("g", "a", "1"))
|
||||||
|
require.NoError(t, s.Set("g", "b", "2"))
|
||||||
|
// Drain Set events.
|
||||||
|
<-w.Ch
|
||||||
|
<-w.Ch
|
||||||
|
|
||||||
|
require.NoError(t, s.DeleteGroup("g"))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case event := <-events:
|
case e := <-w.Ch:
|
||||||
assert.Equal(t, EventDeleteGroup, event.Type)
|
assert.Equal(t, EventDeleteGroup, e.Type)
|
||||||
assert.Equal(t, "g", event.Group)
|
assert.Equal(t, "g", e.Group)
|
||||||
assert.Empty(t, event.Key)
|
assert.Empty(t, e.Key, "DeleteGroup events should have empty Key")
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("timed out waiting for delete_group event")
|
t.Fatal("timed out waiting for delete_group event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_OnChange_Good_Fires(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// OnChange — callback fires on mutations
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestOnChange_Good_Fires(t *testing.T) {
|
||||||
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
var events []Event
|
var events []Event
|
||||||
var eventsMutex sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
||||||
unregister := storeInstance.OnChange(func(event Event) {
|
unreg := s.OnChange(func(e Event) {
|
||||||
eventsMutex.Lock()
|
mu.Lock()
|
||||||
events = append(events, event)
|
events = append(events, e)
|
||||||
eventsMutex.Unlock()
|
mu.Unlock()
|
||||||
})
|
})
|
||||||
defer unregister()
|
defer unreg()
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g", "k", "v"))
|
require.NoError(t, s.Set("g", "k", "v"))
|
||||||
require.NoError(t, storeInstance.Delete("g", "k"))
|
require.NoError(t, s.Delete("g", "k"))
|
||||||
|
|
||||||
eventsMutex.Lock()
|
mu.Lock()
|
||||||
defer eventsMutex.Unlock()
|
defer mu.Unlock()
|
||||||
require.Len(t, events, 2)
|
require.Len(t, events, 2)
|
||||||
assert.Equal(t, EventSet, events[0].Type)
|
assert.Equal(t, EventSet, events[0].Type)
|
||||||
assert.Equal(t, EventDelete, events[1].Type)
|
assert.Equal(t, EventDelete, events[1].Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_OnChange_Good_GroupFilteredCallback(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// OnChange — unregister stops callback
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var seen []string
|
func TestOnChange_Good_Unregister(t *testing.T) {
|
||||||
unregister := storeInstance.OnChange(func(event Event) {
|
s, _ := New(":memory:")
|
||||||
if event.Group != "config" {
|
defer s.Close()
|
||||||
return
|
|
||||||
}
|
var count atomic.Int32
|
||||||
seen = append(seen, event.Key+"="+event.Value)
|
|
||||||
|
unreg := s.OnChange(func(e Event) {
|
||||||
|
count.Add(1)
|
||||||
})
|
})
|
||||||
defer unregister()
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("config", "theme", "dark"))
|
require.NoError(t, s.Set("g", "k", "v1"))
|
||||||
require.NoError(t, storeInstance.Set("other", "theme", "light"))
|
assert.Equal(t, int32(1), count.Load())
|
||||||
|
|
||||||
assert.Equal(t, []string{"theme=dark"}, seen)
|
unreg()
|
||||||
|
|
||||||
|
require.NoError(t, s.Set("g", "k", "v2"))
|
||||||
|
assert.Equal(t, int32(1), count.Load(), "callback should not fire after unregister")
|
||||||
|
|
||||||
|
// Calling unreg again should not panic.
|
||||||
|
unreg()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_OnChange_Good_ReentrantSubscriptionChanges(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Buffer-full doesn't block the writer
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
var (
|
func TestWatch_Good_BufferFullDoesNotBlock(t *testing.T) {
|
||||||
seen []string
|
s, _ := New(":memory:")
|
||||||
seenMutex sync.Mutex
|
defer s.Close()
|
||||||
nestedEvents <-chan Event
|
|
||||||
nestedActive bool
|
|
||||||
nestedStopped bool
|
|
||||||
unregisterNested = func() {}
|
|
||||||
)
|
|
||||||
|
|
||||||
unregisterPrimary := storeInstance.OnChange(func(event Event) {
|
w := s.Watch("g", "*")
|
||||||
seenMutex.Lock()
|
defer s.Unwatch(w)
|
||||||
seen = append(seen, event.Key)
|
|
||||||
seenMutex.Unlock()
|
|
||||||
|
|
||||||
if !nestedActive {
|
// Fill the buffer (cap 16) plus extra writes. None should block.
|
||||||
nestedEvents = storeInstance.Watch("config")
|
done := make(chan struct{})
|
||||||
unregisterNested = storeInstance.OnChange(func(nested Event) {
|
go func() {
|
||||||
seenMutex.Lock()
|
defer close(done)
|
||||||
seen = append(seen, "nested:"+nested.Key)
|
for i := range 32 {
|
||||||
seenMutex.Unlock()
|
require.NoError(t, s.Set("g", core.Sprintf("k%d", i), "v"))
|
||||||
})
|
|
||||||
nestedActive = true
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
if !nestedStopped {
|
|
||||||
storeInstance.Unwatch("config", nestedEvents)
|
|
||||||
unregisterNested()
|
|
||||||
nestedStopped = true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
defer unregisterPrimary()
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("config", "first", "dark"))
|
|
||||||
require.NoError(t, storeInstance.Set("config", "second", "light"))
|
|
||||||
require.NoError(t, storeInstance.Set("config", "third", "blue"))
|
|
||||||
|
|
||||||
seenMutex.Lock()
|
|
||||||
assert.Equal(t, []string{"first", "second", "nested:second", "third"}, seen)
|
|
||||||
seenMutex.Unlock()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case event, open := <-nestedEvents:
|
case <-done:
|
||||||
require.True(t, open)
|
// Success: all writes completed without blocking.
|
||||||
assert.Equal(t, "second", event.Key)
|
case <-time.After(5 * time.Second):
|
||||||
case <-time.After(time.Second):
|
t.Fatal("writes blocked — buffer-full condition caused deadlock")
|
||||||
t.Fatal("timed out waiting for nested watcher event")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, open := <-nestedEvents
|
// Drain what we can — should get exactly watcherBufSize events.
|
||||||
assert.False(t, open, "nested watcher should be closed after callback-driven unwatch")
|
var received int
|
||||||
|
for range watcherBufSize {
|
||||||
|
select {
|
||||||
|
case <-w.Ch:
|
||||||
|
received++
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, watcherBufSize, received, "should receive exactly buffer-size events")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Notify_Good_PopulatesTimestamp(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Multiple watchers on same key
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("config")
|
func TestWatch_Good_MultipleWatchersSameKey(t *testing.T) {
|
||||||
defer storeInstance.Unwatch("config", events)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
storeInstance.notify(Event{Type: EventSet, Group: "config", Key: "theme", Value: "dark"})
|
w1 := s.Watch("g", "k")
|
||||||
|
w2 := s.Watch("g", "k")
|
||||||
|
defer s.Unwatch(w1)
|
||||||
|
defer s.Unwatch(w2)
|
||||||
|
|
||||||
|
require.NoError(t, s.Set("g", "k", "v"))
|
||||||
|
|
||||||
|
// Both watchers should receive the event independently.
|
||||||
|
select {
|
||||||
|
case e := <-w1.Ch:
|
||||||
|
assert.Equal(t, EventSet, e.Type)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("w1 timed out")
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case event := <-events:
|
case e := <-w2.Ch:
|
||||||
assert.False(t, event.Timestamp.IsZero())
|
assert.Equal(t, EventSet, e.Type)
|
||||||
assert.Equal(t, "config", event.Group)
|
|
||||||
assert.Equal(t, "theme", event.Key)
|
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("timed out waiting for timestamped event")
|
t.Fatal("w2 timed out")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Watch_Good_BufferDrops(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// Concurrent Watch/Unwatch during writes (race test)
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
func TestWatch_Good_ConcurrentWatchUnwatch(t *testing.T) {
|
||||||
defer storeInstance.Unwatch("g", events)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
for i := 0; i < watcherEventBufferCapacity+8; i++ {
|
const goroutines = 10
|
||||||
require.NoError(t, storeInstance.Set("g", core.Sprintf("k-%d", i), "v"))
|
const ops = 50
|
||||||
}
|
|
||||||
|
|
||||||
received := drainEvents(events, watcherEventBufferCapacity, time.Second)
|
|
||||||
assert.LessOrEqual(t, len(received), watcherEventBufferCapacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvents_Watch_Good_ConcurrentWatchUnwatch(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
const workers = 10
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(workers)
|
|
||||||
|
|
||||||
for worker := 0; worker < workers; worker++ {
|
// Writers — continuously mutate the store.
|
||||||
go func(worker int) {
|
wg.Go(func() {
|
||||||
defer wg.Done()
|
for i := range goroutines * ops {
|
||||||
group := core.Sprintf("g-%d", worker)
|
_ = s.Set("g", core.Sprintf("k%d", i), "v")
|
||||||
events := storeInstance.Watch(group)
|
}
|
||||||
_ = storeInstance.Set(group, "k", "v")
|
})
|
||||||
storeInstance.Unwatch(group, events)
|
|
||||||
}(worker)
|
// Watchers — add and remove watchers concurrently.
|
||||||
|
for range goroutines {
|
||||||
|
wg.Go(func() {
|
||||||
|
for range ops {
|
||||||
|
w := s.Watch("g", "*")
|
||||||
|
// Drain a few events to exercise the channel path.
|
||||||
|
for range 3 {
|
||||||
|
select {
|
||||||
|
case <-w.Ch:
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Unwatch(w)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
// If we got here without a data race or panic, the test passes.
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Watch_Good_ScopedStoreEventGroup(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// ScopedStore events — prefixed group name
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
scopedStore := NewScoped(storeInstance, "tenant-a")
|
func TestWatch_Good_ScopedStoreEvents(t *testing.T) {
|
||||||
require.NotNil(t, scopedStore)
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
events := storeInstance.Watch("tenant-a:config")
|
sc, err := NewScoped(s, "tenant-a")
|
||||||
defer storeInstance.Unwatch("tenant-a:config", events)
|
require.NoError(t, err)
|
||||||
|
|
||||||
require.NoError(t, scopedStore.SetIn("config", "theme", "dark"))
|
// Watch on the underlying store with the full prefixed group name.
|
||||||
|
w := s.Watch("tenant-a:config", "theme")
|
||||||
|
defer s.Unwatch(w)
|
||||||
|
|
||||||
|
require.NoError(t, sc.Set("config", "theme", "dark"))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case event := <-events:
|
case e := <-w.Ch:
|
||||||
assert.Equal(t, "tenant-a:config", event.Group)
|
assert.Equal(t, EventSet, e.Type)
|
||||||
assert.Equal(t, "theme", event.Key)
|
assert.Equal(t, "tenant-a:config", e.Group)
|
||||||
|
assert.Equal(t, "theme", e.Key)
|
||||||
|
assert.Equal(t, "dark", e.Value)
|
||||||
case <-time.After(time.Second):
|
case <-time.After(time.Second):
|
||||||
t.Fatal("timed out waiting for scoped event")
|
t.Fatal("timed out waiting for scoped store event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEvents_Watch_Good_SetWithTTL(t *testing.T) {
|
// ---------------------------------------------------------------------------
|
||||||
storeInstance, _ := New(":memory:")
|
// EventType.String()
|
||||||
defer storeInstance.Close()
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
events := storeInstance.Watch("g")
|
func TestEventType_String(t *testing.T) {
|
||||||
defer storeInstance.Unwatch("g", events)
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.SetWithTTL("g", "ephemeral", "v", time.Minute))
|
|
||||||
|
|
||||||
select {
|
|
||||||
case event := <-events:
|
|
||||||
assert.Equal(t, EventSet, event.Type)
|
|
||||||
assert.Equal(t, "ephemeral", event.Key)
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("timed out waiting for TTL event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEvents_EventType_Good_String(t *testing.T) {
|
|
||||||
assert.Equal(t, "set", EventSet.String())
|
assert.Equal(t, "set", EventSet.String())
|
||||||
assert.Equal(t, "delete", EventDelete.String())
|
assert.Equal(t, "delete", EventDelete.String())
|
||||||
assert.Equal(t, "deletegroup", EventDeleteGroup.String())
|
assert.Equal(t, "delete_group", EventDeleteGroup.String())
|
||||||
assert.Equal(t, "unknown", EventType(99).String())
|
assert.Equal(t, "unknown", EventType(99).String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func drainEvents(events <-chan Event, count int, timeout time.Duration) []Event {
|
// ---------------------------------------------------------------------------
|
||||||
received := make([]Event, 0, count)
|
// SetWithTTL emits events
|
||||||
deadline := time.After(timeout)
|
// ---------------------------------------------------------------------------
|
||||||
for len(received) < count {
|
|
||||||
|
func TestWatch_Good_SetWithTTLEmitsEvent(t *testing.T) {
|
||||||
|
s, _ := New(":memory:")
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
w := s.Watch("g", "k")
|
||||||
|
defer s.Unwatch(w)
|
||||||
|
|
||||||
|
require.NoError(t, s.SetWithTTL("g", "k", "ttl-val", time.Hour))
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case event := <-events:
|
case e := <-w.Ch:
|
||||||
received = append(received, event)
|
assert.Equal(t, EventSet, e.Type)
|
||||||
case <-deadline:
|
assert.Equal(t, "g", e.Group)
|
||||||
return received
|
assert.Equal(t, "k", e.Key)
|
||||||
|
assert.Equal(t, "ttl-val", e.Value)
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("timed out waiting for SetWithTTL event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return received
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// drainEvents collects up to n events from ch within the given timeout.
|
||||||
|
func drainEvents(ch <-chan Event, n int, timeout time.Duration) []Event {
|
||||||
|
var events []Event
|
||||||
|
deadline := time.After(timeout)
|
||||||
|
for range n {
|
||||||
|
select {
|
||||||
|
case e := <-ch:
|
||||||
|
events = append(events, e)
|
||||||
|
case <-deadline:
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
go.mod
35
go.mod
|
|
@ -1,48 +1,21 @@
|
||||||
module dappco.re/go/store
|
module dappco.re/go/core/store
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.8.0-alpha.1
|
dappco.re/go/core v0.7.0
|
||||||
dappco.re/go/core/io v0.4.2
|
dappco.re/go/core/log v0.1.0
|
||||||
github.com/klauspost/compress v1.18.5
|
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
modernc.org/sqlite v1.47.0
|
modernc.org/sqlite v1.47.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
|
||||||
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
|
||||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
|
||||||
github.com/influxdata/influxdb-client-go/v2 v2.14.0 // indirect
|
|
||||||
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
|
||||||
github.com/oapi-codegen/runtime v1.0.0 // indirect
|
|
||||||
github.com/parquet-go/bitpack v1.0.0 // indirect
|
|
||||||
github.com/parquet-go/jsonlite v1.0.0 // indirect
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.22 // indirect
|
|
||||||
github.com/twpayne/go-geom v1.6.1 // indirect
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
|
||||||
golang.org/x/mod v0.34.0 // indirect
|
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
|
||||||
google.golang.org/protobuf v1.36.1 // indirect
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/marcboeker/go-duckdb v1.8.5
|
github.com/kr/text v0.2.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/parquet-go/parquet-go v0.29.0
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
|
|
||||||
89
go.sum
89
go.sum
|
|
@ -1,122 +1,43 @@
|
||||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
dappco.re/go/core v0.7.0 h1:A3vi7LD0jBBA7n+8WPZmjxbRDZ43FFoKhBJ/ydKDPSs=
|
||||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
dappco.re/go/core v0.7.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
dappco.re/go/core/io v0.4.2 h1:SHNF/xMPyFnKWWYoFW5Y56eiuGVL/mFa1lfIw/530ls=
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
dappco.re/go/core/io v0.4.2/go.mod h1:w71dukyunczLb8frT9JOd5B78PjwWQD3YAXiCt3AcPA=
|
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
|
||||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
|
||||||
github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY=
|
|
||||||
github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
|
||||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
|
||||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
|
||||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
|
||||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
|
||||||
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
|
||||||
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
|
||||||
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
|
||||||
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
|
||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
|
||||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
|
||||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
|
||||||
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
|
||||||
github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4=
|
|
||||||
github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI=
|
|
||||||
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU=
|
|
||||||
github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo=
|
|
||||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
|
||||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
|
||||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
|
||||||
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
|
||||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
|
||||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
|
||||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo=
|
|
||||||
github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A=
|
|
||||||
github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA=
|
|
||||||
github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs=
|
|
||||||
github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU=
|
|
||||||
github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0=
|
|
||||||
github.com/parquet-go/parquet-go v0.29.0 h1:xXlPtFVR51jpSVzf+cgHnNIcb7Xet+iuvkbe0HIm90Y=
|
|
||||||
github.com/parquet-go/parquet-go v0.29.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
|
||||||
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4=
|
|
||||||
github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028=
|
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
|
||||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
|
||||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
|
||||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
|
||||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
|
||||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
|
||||||
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
|
||||||
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
|
||||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
|
||||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
|
||||||
544
import.go
544
import.go
|
|
@ -1,544 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// localFs provides unrestricted filesystem access for import operations.
|
|
||||||
var localFs = (&core.Fs{}).New("/")
|
|
||||||
|
|
||||||
// ScpFunc is a callback for executing SCP file transfers.
|
|
||||||
// The function receives remote source and local destination paths.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// scp := func(remote, local string) error { return exec.Command("scp", remote, local).Run() }
|
|
||||||
type ScpFunc func(remote, local string) error
|
|
||||||
|
|
||||||
// ScpDirFunc is a callback for executing recursive SCP directory transfers.
|
|
||||||
// The function receives remote source and local destination directory paths.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// scpDir := func(remote, localDir string) error { return exec.Command("scp", "-r", remote, localDir).Run() }
|
|
||||||
type ScpDirFunc func(remote, localDir string) error
|
|
||||||
|
|
||||||
// ImportConfig holds options for the import-all operation.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg := store.ImportConfig{DataDir: "/Volumes/Data/lem", SkipM3: true}
|
|
||||||
type ImportConfig struct {
|
|
||||||
// SkipM3 disables pulling files from the M3 host.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.SkipM3 // true
|
|
||||||
SkipM3 bool
|
|
||||||
|
|
||||||
// DataDir is the local directory containing LEM data files.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.DataDir // "/Volumes/Data/lem"
|
|
||||||
DataDir string
|
|
||||||
|
|
||||||
// M3Host is the SSH hostname for SCP operations. Defaults to "m3".
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.M3Host // "m3"
|
|
||||||
M3Host string
|
|
||||||
|
|
||||||
// Scp copies a single file from the remote host. If nil, SCP is skipped.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.Scp("m3:/path/file.jsonl", "/local/file.jsonl")
|
|
||||||
Scp ScpFunc
|
|
||||||
|
|
||||||
// ScpDir copies a directory recursively from the remote host. If nil, SCP is skipped.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.ScpDir("m3:/path/dir/", "/local/dir/")
|
|
||||||
ScpDir ScpDirFunc
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImportAll imports all LEM data into DuckDB from M3 and local files.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// err := store.ImportAll(db, store.ImportConfig{DataDir: "/Volumes/Data/lem"}, os.Stdout)
|
|
||||||
func ImportAll(db *DuckDB, cfg ImportConfig, w io.Writer) error {
|
|
||||||
m3Host := cfg.M3Host
|
|
||||||
if m3Host == "" {
|
|
||||||
m3Host = "m3"
|
|
||||||
}
|
|
||||||
|
|
||||||
totals := make(map[string]int)
|
|
||||||
|
|
||||||
// ── 1. Golden set ──
|
|
||||||
goldenPath := core.JoinPath(cfg.DataDir, "gold-15k.jsonl")
|
|
||||||
if !cfg.SkipM3 && cfg.Scp != nil {
|
|
||||||
core.Print(w, " Pulling golden set from M3...")
|
|
||||||
remote := core.Sprintf("%s:/Volumes/Data/lem/responses/gold-15k.jsonl", m3Host)
|
|
||||||
if err := cfg.Scp(remote, goldenPath); err != nil {
|
|
||||||
core.Print(w, " WARNING: could not pull golden set from M3: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isFile(goldenPath) {
|
|
||||||
db.Exec("DROP TABLE IF EXISTS golden_set")
|
|
||||||
err := db.Exec(core.Sprintf(`
|
|
||||||
CREATE TABLE golden_set AS
|
|
||||||
SELECT
|
|
||||||
idx::INT AS idx,
|
|
||||||
seed_id::VARCHAR AS seed_id,
|
|
||||||
domain::VARCHAR AS domain,
|
|
||||||
voice::VARCHAR AS voice,
|
|
||||||
prompt::VARCHAR AS prompt,
|
|
||||||
response::VARCHAR AS response,
|
|
||||||
gen_time::DOUBLE AS gen_time,
|
|
||||||
length(response)::INT AS char_count,
|
|
||||||
length(response) - length(replace(response, ' ', '')) + 1 AS word_count
|
|
||||||
FROM read_json_auto('%s', maximum_object_size=1048576)
|
|
||||||
`, escapeSQLPath(goldenPath)))
|
|
||||||
if err != nil {
|
|
||||||
core.Print(w, " WARNING: golden set import failed: %v", err)
|
|
||||||
} else {
|
|
||||||
var n int
|
|
||||||
db.QueryRowScan("SELECT count(*) FROM golden_set", &n)
|
|
||||||
totals["golden_set"] = n
|
|
||||||
core.Print(w, " golden_set: %d rows", n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 2. Training examples ──
|
|
||||||
trainingDirs := []struct {
|
|
||||||
name string
|
|
||||||
files []string
|
|
||||||
}{
|
|
||||||
{"training", []string{"training/train.jsonl", "training/valid.jsonl", "training/test.jsonl"}},
|
|
||||||
{"training-2k", []string{"training-2k/train.jsonl", "training-2k/valid.jsonl", "training-2k/test.jsonl"}},
|
|
||||||
{"training-expanded", []string{"training-expanded/train.jsonl", "training-expanded/valid.jsonl"}},
|
|
||||||
{"training-book", []string{"training-book/train.jsonl", "training-book/valid.jsonl", "training-book/test.jsonl"}},
|
|
||||||
{"training-conv", []string{"training-conv/train.jsonl", "training-conv/valid.jsonl", "training-conv/test.jsonl"}},
|
|
||||||
{"gold-full", []string{"gold-full/train.jsonl", "gold-full/valid.jsonl"}},
|
|
||||||
{"sovereignty-gold", []string{"sovereignty-gold/train.jsonl", "sovereignty-gold/valid.jsonl"}},
|
|
||||||
{"composure-lessons", []string{"composure-lessons/train.jsonl", "composure-lessons/valid.jsonl"}},
|
|
||||||
{"watts-full", []string{"watts-full/train.jsonl", "watts-full/valid.jsonl"}},
|
|
||||||
{"watts-expanded", []string{"watts-expanded/train.jsonl", "watts-expanded/valid.jsonl"}},
|
|
||||||
{"watts-composure", []string{"watts-composure-merged/train.jsonl", "watts-composure-merged/valid.jsonl"}},
|
|
||||||
{"western-fresh", []string{"western-fresh/train.jsonl", "western-fresh/valid.jsonl"}},
|
|
||||||
{"deepseek-soak", []string{"deepseek-western-soak/train.jsonl", "deepseek-western-soak/valid.jsonl"}},
|
|
||||||
{"russian-bridge", []string{"russian-bridge/train.jsonl", "russian-bridge/valid.jsonl"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
trainingLocal := core.JoinPath(cfg.DataDir, "training")
|
|
||||||
localFs.EnsureDir(trainingLocal)
|
|
||||||
|
|
||||||
if !cfg.SkipM3 && cfg.Scp != nil {
|
|
||||||
core.Print(w, " Pulling training sets from M3...")
|
|
||||||
for _, td := range trainingDirs {
|
|
||||||
for _, rel := range td.files {
|
|
||||||
local := core.JoinPath(trainingLocal, rel)
|
|
||||||
localFs.EnsureDir(core.PathDir(local))
|
|
||||||
remote := core.Sprintf("%s:/Volumes/Data/lem/%s", m3Host, rel)
|
|
||||||
cfg.Scp(remote, local) // ignore errors, file might not exist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Exec("DROP TABLE IF EXISTS training_examples")
|
|
||||||
db.Exec(`
|
|
||||||
CREATE TABLE training_examples (
|
|
||||||
source VARCHAR,
|
|
||||||
split VARCHAR,
|
|
||||||
prompt TEXT,
|
|
||||||
response TEXT,
|
|
||||||
num_turns INT,
|
|
||||||
full_messages TEXT,
|
|
||||||
char_count INT
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
trainingTotal := 0
|
|
||||||
for _, td := range trainingDirs {
|
|
||||||
for _, rel := range td.files {
|
|
||||||
local := core.JoinPath(trainingLocal, rel)
|
|
||||||
if !isFile(local) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
split := "train"
|
|
||||||
if core.Contains(rel, "valid") {
|
|
||||||
split = "valid"
|
|
||||||
} else if core.Contains(rel, "test") {
|
|
||||||
split = "test"
|
|
||||||
}
|
|
||||||
|
|
||||||
n := importTrainingFile(db, local, td.name, split)
|
|
||||||
trainingTotal += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
totals["training_examples"] = trainingTotal
|
|
||||||
core.Print(w, " training_examples: %d rows", trainingTotal)
|
|
||||||
|
|
||||||
// ── 3. Benchmark results ──
|
|
||||||
benchLocal := core.JoinPath(cfg.DataDir, "benchmarks")
|
|
||||||
localFs.EnsureDir(benchLocal)
|
|
||||||
|
|
||||||
if !cfg.SkipM3 {
|
|
||||||
core.Print(w, " Pulling benchmarks from M3...")
|
|
||||||
if cfg.Scp != nil {
|
|
||||||
for _, bname := range []string{"truthfulqa", "gsm8k", "do_not_answer", "toxigen"} {
|
|
||||||
remote := core.Sprintf("%s:/Volumes/Data/lem/benchmarks/%s.jsonl", m3Host, bname)
|
|
||||||
cfg.Scp(remote, core.JoinPath(benchLocal, bname+".jsonl"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.ScpDir != nil {
|
|
||||||
for _, subdir := range []string{"results", "scale_results", "cross_arch_results", "deepseek-r1-7b"} {
|
|
||||||
localSub := core.JoinPath(benchLocal, subdir)
|
|
||||||
localFs.EnsureDir(localSub)
|
|
||||||
remote := core.Sprintf("%s:/Volumes/Data/lem/benchmarks/%s/", m3Host, subdir)
|
|
||||||
cfg.ScpDir(remote, core.JoinPath(benchLocal)+"/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Exec("DROP TABLE IF EXISTS benchmark_results")
|
|
||||||
db.Exec(`
|
|
||||||
CREATE TABLE benchmark_results (
|
|
||||||
source VARCHAR, id VARCHAR, benchmark VARCHAR, model VARCHAR,
|
|
||||||
prompt TEXT, response TEXT, elapsed_seconds DOUBLE, domain VARCHAR
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
benchTotal := 0
|
|
||||||
for _, subdir := range []string{"results", "scale_results", "cross_arch_results", "deepseek-r1-7b"} {
|
|
||||||
resultDir := core.JoinPath(benchLocal, subdir)
|
|
||||||
matches := core.PathGlob(core.JoinPath(resultDir, "*.jsonl"))
|
|
||||||
for _, jf := range matches {
|
|
||||||
n := importBenchmarkFile(db, jf, subdir)
|
|
||||||
benchTotal += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also import standalone benchmark files.
|
|
||||||
for _, bfile := range []string{"lem_bench", "lem_ethics", "lem_ethics_allen", "instruction_tuned", "abliterated", "base_pt"} {
|
|
||||||
local := core.JoinPath(benchLocal, bfile+".jsonl")
|
|
||||||
if !isFile(local) {
|
|
||||||
if !cfg.SkipM3 && cfg.Scp != nil {
|
|
||||||
remote := core.Sprintf("%s:/Volumes/Data/lem/benchmark/%s.jsonl", m3Host, bfile)
|
|
||||||
cfg.Scp(remote, local)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if isFile(local) {
|
|
||||||
n := importBenchmarkFile(db, local, "benchmark")
|
|
||||||
benchTotal += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
totals["benchmark_results"] = benchTotal
|
|
||||||
core.Print(w, " benchmark_results: %d rows", benchTotal)
|
|
||||||
|
|
||||||
// ── 4. Benchmark questions ──
|
|
||||||
db.Exec("DROP TABLE IF EXISTS benchmark_questions")
|
|
||||||
db.Exec(`
|
|
||||||
CREATE TABLE benchmark_questions (
|
|
||||||
benchmark VARCHAR, id VARCHAR, question TEXT,
|
|
||||||
best_answer TEXT, correct_answers TEXT, incorrect_answers TEXT, category VARCHAR
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
benchQTotal := 0
|
|
||||||
for _, bname := range []string{"truthfulqa", "gsm8k", "do_not_answer", "toxigen"} {
|
|
||||||
local := core.JoinPath(benchLocal, bname+".jsonl")
|
|
||||||
if isFile(local) {
|
|
||||||
n := importBenchmarkQuestions(db, local, bname)
|
|
||||||
benchQTotal += n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
totals["benchmark_questions"] = benchQTotal
|
|
||||||
core.Print(w, " benchmark_questions: %d rows", benchQTotal)
|
|
||||||
|
|
||||||
// ── 5. Seeds ──
|
|
||||||
db.Exec("DROP TABLE IF EXISTS seeds")
|
|
||||||
db.Exec(`
|
|
||||||
CREATE TABLE seeds (
|
|
||||||
source_file VARCHAR, region VARCHAR, seed_id VARCHAR, domain VARCHAR, prompt TEXT
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
|
|
||||||
seedTotal := 0
|
|
||||||
seedDirs := []string{core.JoinPath(cfg.DataDir, "seeds"), "/tmp/lem-data/seeds", "/tmp/lem-repo/seeds"}
|
|
||||||
for _, seedDir := range seedDirs {
|
|
||||||
if !isDir(seedDir) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
n := importSeeds(db, seedDir)
|
|
||||||
seedTotal += n
|
|
||||||
}
|
|
||||||
totals["seeds"] = seedTotal
|
|
||||||
core.Print(w, " seeds: %d rows", seedTotal)
|
|
||||||
|
|
||||||
// ── Summary ──
|
|
||||||
grandTotal := 0
|
|
||||||
core.Print(w, "\n%s", repeat("=", 50))
|
|
||||||
core.Print(w, "LEM Database Import Complete")
|
|
||||||
core.Print(w, "%s", repeat("=", 50))
|
|
||||||
for table, count := range totals {
|
|
||||||
core.Print(w, " %-25s %8d", table, count)
|
|
||||||
grandTotal += count
|
|
||||||
}
|
|
||||||
core.Print(w, " %s", repeat("-", 35))
|
|
||||||
core.Print(w, " %-25s %8d", "TOTAL", grandTotal)
|
|
||||||
core.Print(w, "\nDatabase: %s", db.Path())
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importTrainingFile(db *DuckDB, path, source, split string) int {
|
|
||||||
r := localFs.Open(path)
|
|
||||||
if !r.OK {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
f := r.Value.(io.ReadCloser)
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
var rec struct {
|
|
||||||
Messages []ChatMessage `json:"messages"`
|
|
||||||
}
|
|
||||||
if r := core.JSONUnmarshal(scanner.Bytes(), &rec); !r.OK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := ""
|
|
||||||
response := ""
|
|
||||||
assistantCount := 0
|
|
||||||
for _, m := range rec.Messages {
|
|
||||||
if m.Role == "user" && prompt == "" {
|
|
||||||
prompt = m.Content
|
|
||||||
}
|
|
||||||
if m.Role == "assistant" {
|
|
||||||
if response == "" {
|
|
||||||
response = m.Content
|
|
||||||
}
|
|
||||||
assistantCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgsJSON := core.JSONMarshalString(rec.Messages)
|
|
||||||
db.Exec(`INSERT INTO training_examples VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
source, split, prompt, response, assistantCount, msgsJSON, len(response))
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func importBenchmarkFile(db *DuckDB, path, source string) int {
|
|
||||||
r := localFs.Open(path)
|
|
||||||
if !r.OK {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
f := r.Value.(io.ReadCloser)
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
var rec map[string]any
|
|
||||||
if r := core.JSONUnmarshal(scanner.Bytes(), &rec); !r.OK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
db.Exec(`INSERT INTO benchmark_results VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
source,
|
|
||||||
core.Sprint(rec["id"]),
|
|
||||||
strOrEmpty(rec, "benchmark"),
|
|
||||||
strOrEmpty(rec, "model"),
|
|
||||||
strOrEmpty(rec, "prompt"),
|
|
||||||
strOrEmpty(rec, "response"),
|
|
||||||
floatOrZero(rec, "elapsed_seconds"),
|
|
||||||
strOrEmpty(rec, "domain"),
|
|
||||||
)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func importBenchmarkQuestions(db *DuckDB, path, benchmark string) int {
|
|
||||||
r := localFs.Open(path)
|
|
||||||
if !r.OK {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
f := r.Value.(io.ReadCloser)
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
var rec map[string]any
|
|
||||||
if r := core.JSONUnmarshal(scanner.Bytes(), &rec); !r.OK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
correctJSON := core.JSONMarshalString(rec["correct_answers"])
|
|
||||||
incorrectJSON := core.JSONMarshalString(rec["incorrect_answers"])
|
|
||||||
|
|
||||||
db.Exec(`INSERT INTO benchmark_questions VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
benchmark,
|
|
||||||
core.Sprint(rec["id"]),
|
|
||||||
strOrEmpty(rec, "question"),
|
|
||||||
strOrEmpty(rec, "best_answer"),
|
|
||||||
correctJSON,
|
|
||||||
incorrectJSON,
|
|
||||||
strOrEmpty(rec, "category"),
|
|
||||||
)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func importSeeds(db *DuckDB, seedDir string) int {
|
|
||||||
count := 0
|
|
||||||
walkDir(seedDir, func(path string) {
|
|
||||||
if !core.HasSuffix(path, ".json") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
readResult := localFs.Read(path)
|
|
||||||
if !readResult.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data := []byte(readResult.Value.(string))
|
|
||||||
|
|
||||||
rel := core.TrimPrefix(path, seedDir+"/")
|
|
||||||
region := core.TrimSuffix(core.PathBase(path), ".json")
|
|
||||||
|
|
||||||
// Try parsing as array or object with prompts/seeds field.
|
|
||||||
var seedsList []any
|
|
||||||
var raw any
|
|
||||||
if r := core.JSONUnmarshal(data, &raw); !r.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch v := raw.(type) {
|
|
||||||
case []any:
|
|
||||||
seedsList = v
|
|
||||||
case map[string]any:
|
|
||||||
if prompts, ok := v["prompts"].([]any); ok {
|
|
||||||
seedsList = prompts
|
|
||||||
} else if seeds, ok := v["seeds"].([]any); ok {
|
|
||||||
seedsList = seeds
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, s := range seedsList {
|
|
||||||
switch seed := s.(type) {
|
|
||||||
case map[string]any:
|
|
||||||
prompt := strOrEmpty(seed, "prompt")
|
|
||||||
if prompt == "" {
|
|
||||||
prompt = strOrEmpty(seed, "text")
|
|
||||||
}
|
|
||||||
if prompt == "" {
|
|
||||||
prompt = strOrEmpty(seed, "question")
|
|
||||||
}
|
|
||||||
db.Exec(`INSERT INTO seeds VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
rel, region,
|
|
||||||
strOrEmpty(seed, "seed_id"),
|
|
||||||
strOrEmpty(seed, "domain"),
|
|
||||||
prompt,
|
|
||||||
)
|
|
||||||
count++
|
|
||||||
case string:
|
|
||||||
db.Exec(`INSERT INTO seeds VALUES (?, ?, ?, ?, ?)`,
|
|
||||||
rel, region, "", "", seed)
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// walkDir recursively visits all regular files under root, calling fn for each.
|
|
||||||
func walkDir(root string, fn func(path string)) {
|
|
||||||
r := localFs.List(root)
|
|
||||||
if !r.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
entries, ok := r.Value.([]fs.DirEntry)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, entry := range entries {
|
|
||||||
full := core.JoinPath(root, entry.Name())
|
|
||||||
if entry.IsDir() {
|
|
||||||
walkDir(full, fn)
|
|
||||||
} else {
|
|
||||||
fn(full)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// strOrEmpty extracts a string value from a map, returning an empty string if
|
|
||||||
// the key is absent.
|
|
||||||
func strOrEmpty(m map[string]any, key string) string {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
return core.Sprint(v)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// floatOrZero extracts a float64 value from a map, returning zero if the key
|
|
||||||
// is absent or not a number.
|
|
||||||
func floatOrZero(m map[string]any, key string) float64 {
|
|
||||||
if v, ok := m[key]; ok {
|
|
||||||
if f, ok := v.(float64); ok {
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// repeat returns a string consisting of count copies of s.
|
|
||||||
func repeat(s string, count int) string {
|
|
||||||
if count <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
b := core.NewBuilder()
|
|
||||||
for range count {
|
|
||||||
b.WriteString(s)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// escapeSQLPath escapes single quotes in a file path for use in DuckDB SQL
|
|
||||||
// string literals.
|
|
||||||
func escapeSQLPath(p string) string {
|
|
||||||
return core.Replace(p, "'", "''")
|
|
||||||
}
|
|
||||||
|
|
||||||
// isFile returns true if the path exists and is a regular file.
|
|
||||||
func isFile(path string) bool {
|
|
||||||
return localFs.IsFile(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isDir returns true if the path exists and is a directory.
|
|
||||||
func isDir(path string) bool {
|
|
||||||
return localFs.IsDir(path)
|
|
||||||
}
|
|
||||||
166
inventory.go
166
inventory.go
|
|
@ -1,166 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TargetTotal is the golden set target size used for progress reporting.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// pct := float64(count) / float64(store.TargetTotal) * 100
|
|
||||||
const TargetTotal = 15000
|
|
||||||
|
|
||||||
// duckDBTableOrder defines the canonical display order for DuckDB inventory
|
|
||||||
// tables.
|
|
||||||
var duckDBTableOrder = []string{
|
|
||||||
"golden_set", "expansion_prompts", "seeds", "prompts",
|
|
||||||
"training_examples", "gemini_responses", "benchmark_questions",
|
|
||||||
"benchmark_results", "validations", TableCheckpointScores,
|
|
||||||
TableProbeResults, "scoring_results",
|
|
||||||
}
|
|
||||||
|
|
||||||
// duckDBTableDetail holds extra context for a single table beyond its row count.
|
|
||||||
type duckDBTableDetail struct {
|
|
||||||
notes []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// PrintDuckDBInventory queries all known DuckDB tables and prints a formatted
|
|
||||||
// inventory with row counts, detail breakdowns, and a grand total.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// err := store.PrintDuckDBInventory(db, os.Stdout)
|
|
||||||
func PrintDuckDBInventory(db *DuckDB, w io.Writer) error {
|
|
||||||
counts, err := db.TableCounts()
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.PrintDuckDBInventory", "table counts", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
details := gatherDuckDBDetails(db, counts)
|
|
||||||
|
|
||||||
core.Print(w, "DuckDB Inventory")
|
|
||||||
core.Print(w, "%s", repeat("-", 52))
|
|
||||||
|
|
||||||
grand := 0
|
|
||||||
for _, table := range duckDBTableOrder {
|
|
||||||
count, ok := counts[table]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
grand += count
|
|
||||||
line := core.Sprintf(" %-24s %8d rows", table, count)
|
|
||||||
|
|
||||||
if d, has := details[table]; has && len(d.notes) > 0 {
|
|
||||||
line += core.Sprintf(" (%s)", core.Join(", ", d.notes...))
|
|
||||||
}
|
|
||||||
core.Print(w, "%s", line)
|
|
||||||
}
|
|
||||||
|
|
||||||
core.Print(w, "%s", repeat("-", 52))
|
|
||||||
core.Print(w, " %-24s %8d rows", "TOTAL", grand)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// gatherDuckDBDetails runs per-table detail queries and returns annotations
|
|
||||||
// keyed by table name. Errors on individual queries are silently ignored so
|
|
||||||
// the inventory always prints.
|
|
||||||
func gatherDuckDBDetails(db *DuckDB, counts map[string]int) map[string]*duckDBTableDetail {
|
|
||||||
details := make(map[string]*duckDBTableDetail)
|
|
||||||
|
|
||||||
// golden_set: progress towards target
|
|
||||||
if count, ok := counts["golden_set"]; ok {
|
|
||||||
pct := float64(count) / float64(TargetTotal) * 100
|
|
||||||
details["golden_set"] = &duckDBTableDetail{
|
|
||||||
notes: []string{core.Sprintf("%.1f%% of %d target", pct, TargetTotal)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// training_examples: distinct sources
|
|
||||||
if _, ok := counts["training_examples"]; ok {
|
|
||||||
rows, err := db.QueryRows("SELECT COUNT(DISTINCT source) AS n FROM training_examples")
|
|
||||||
if err == nil && len(rows) > 0 {
|
|
||||||
n := duckDBToInt(rows[0]["n"])
|
|
||||||
details["training_examples"] = &duckDBTableDetail{
|
|
||||||
notes: []string{core.Sprintf("%d sources", n)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// prompts: distinct domains and voices
|
|
||||||
if _, ok := counts["prompts"]; ok {
|
|
||||||
d := &duckDBTableDetail{}
|
|
||||||
rows, err := db.QueryRows("SELECT COUNT(DISTINCT domain) AS n FROM prompts")
|
|
||||||
if err == nil && len(rows) > 0 {
|
|
||||||
d.notes = append(d.notes, core.Sprintf("%d domains", duckDBToInt(rows[0]["n"])))
|
|
||||||
}
|
|
||||||
rows, err = db.QueryRows("SELECT COUNT(DISTINCT voice) AS n FROM prompts")
|
|
||||||
if err == nil && len(rows) > 0 {
|
|
||||||
d.notes = append(d.notes, core.Sprintf("%d voices", duckDBToInt(rows[0]["n"])))
|
|
||||||
}
|
|
||||||
if len(d.notes) > 0 {
|
|
||||||
details["prompts"] = d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// gemini_responses: group by source_model
|
|
||||||
if _, ok := counts["gemini_responses"]; ok {
|
|
||||||
rows, err := db.QueryRows(
|
|
||||||
"SELECT source_model, COUNT(*) AS n FROM gemini_responses GROUP BY source_model ORDER BY n DESC",
|
|
||||||
)
|
|
||||||
if err == nil && len(rows) > 0 {
|
|
||||||
var parts []string
|
|
||||||
for _, row := range rows {
|
|
||||||
model := duckDBStrVal(row, "source_model")
|
|
||||||
n := duckDBToInt(row["n"])
|
|
||||||
if model != "" {
|
|
||||||
parts = append(parts, core.Sprintf("%s:%d", model, n))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(parts) > 0 {
|
|
||||||
details["gemini_responses"] = &duckDBTableDetail{notes: parts}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// benchmark_results: distinct source categories
|
|
||||||
if _, ok := counts["benchmark_results"]; ok {
|
|
||||||
rows, err := db.QueryRows("SELECT COUNT(DISTINCT source) AS n FROM benchmark_results")
|
|
||||||
if err == nil && len(rows) > 0 {
|
|
||||||
n := duckDBToInt(rows[0]["n"])
|
|
||||||
details["benchmark_results"] = &duckDBTableDetail{
|
|
||||||
notes: []string{core.Sprintf("%d categories", n)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
}
|
|
||||||
|
|
||||||
// duckDBToInt converts a DuckDB value to int. DuckDB returns integers as int64
|
|
||||||
// (not float64 like InfluxDB), so we handle both types.
|
|
||||||
func duckDBToInt(v any) int {
|
|
||||||
switch n := v.(type) {
|
|
||||||
case int64:
|
|
||||||
return int(n)
|
|
||||||
case int32:
|
|
||||||
return int(n)
|
|
||||||
case float64:
|
|
||||||
return int(n)
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// duckDBStrVal extracts a string value from a row map.
|
|
||||||
func duckDBStrVal(row map[string]any, key string) string {
|
|
||||||
if v, ok := row[key]; ok {
|
|
||||||
return core.Sprint(v)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
515
journal.go
515
journal.go
|
|
@ -1,515 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
journalEntriesTableName = "journal_entries"
|
|
||||||
defaultJournalBucket = "store"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createJournalEntriesTableSQL = `CREATE TABLE IF NOT EXISTS journal_entries (
|
|
||||||
entry_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
bucket_name TEXT NOT NULL,
|
|
||||||
measurement TEXT NOT NULL,
|
|
||||||
fields_json TEXT NOT NULL,
|
|
||||||
tags_json TEXT NOT NULL,
|
|
||||||
committed_at INTEGER NOT NULL,
|
|
||||||
archived_at INTEGER
|
|
||||||
)`
|
|
||||||
|
|
||||||
var (
|
|
||||||
journalBucketPattern = regexp.MustCompile(`bucket:\s*"([^"]+)"`)
|
|
||||||
journalMeasurementPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`(?:_measurement|measurement)\s*==\s*"([^"]+)"`),
|
|
||||||
regexp.MustCompile(`\[\s*"(?:_measurement|measurement)"\s*\]\s*==\s*"([^"]+)"`),
|
|
||||||
}
|
|
||||||
journalBucketEqualityPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`r\.(?:_bucket|bucket|bucket_name)\s*==\s*"([^"]+)"`),
|
|
||||||
regexp.MustCompile(`r\[\s*"(?:_bucket|bucket|bucket_name)"\s*\]\s*==\s*"([^"]+)"`),
|
|
||||||
}
|
|
||||||
journalStringEqualityPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`r\.([a-zA-Z0-9_:-]+)\s*==\s*"([^"]+)"`),
|
|
||||||
regexp.MustCompile(`r\[\s*"([a-zA-Z0-9_:-]+)"\s*\]\s*==\s*"([^"]+)"`),
|
|
||||||
}
|
|
||||||
journalScalarEqualityPatterns = []*regexp.Regexp{
|
|
||||||
regexp.MustCompile(`r\.([a-zA-Z0-9_:-]+)\s*==\s*(true|false|-?[0-9]+(?:\.[0-9]+)?)`),
|
|
||||||
regexp.MustCompile(`r\[\s*"([a-zA-Z0-9_:-]+)"\s*\]\s*==\s*(true|false|-?[0-9]+(?:\.[0-9]+)?)`),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type journalEqualityFilter struct {
|
|
||||||
columnName string
|
|
||||||
filterValue any
|
|
||||||
stringCompare bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type journalExecutor interface {
|
|
||||||
Exec(query string, args ...any) (sql.Result, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `result := storeInstance.CommitToJournal("scroll-session", map[string]any{"like": 4}, map[string]string{"workspace": "scroll-session"})`
|
|
||||||
// Workspace.Commit uses this same journal write path before it updates the
|
|
||||||
// summary row in `workspace:NAME`.
|
|
||||||
func (storeInstance *Store) CommitToJournal(measurement string, fields map[string]any, tags map[string]string) core.Result {
|
|
||||||
if err := storeInstance.ensureReady("store.CommitToJournal"); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
if measurement == "" {
|
|
||||||
return core.Result{Value: core.E("store.CommitToJournal", "measurement is empty", nil), OK: false}
|
|
||||||
}
|
|
||||||
if fields == nil {
|
|
||||||
fields = map[string]any{}
|
|
||||||
}
|
|
||||||
if tags == nil {
|
|
||||||
tags = map[string]string{}
|
|
||||||
}
|
|
||||||
if err := ensureJournalSchema(storeInstance.sqliteDatabase); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.CommitToJournal", "ensure journal schema", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldsJSON, err := marshalJSONText(fields, "store.CommitToJournal", "marshal fields")
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
tagsJSON, err := marshalJSONText(tags, "store.CommitToJournal", "marshal tags")
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
committedAt := time.Now().UnixMilli()
|
|
||||||
if err := commitJournalEntry(
|
|
||||||
storeInstance.sqliteDatabase,
|
|
||||||
storeInstance.journalBucket(),
|
|
||||||
measurement,
|
|
||||||
fieldsJSON,
|
|
||||||
tagsJSON,
|
|
||||||
committedAt,
|
|
||||||
); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.CommitToJournal", "insert journal entry", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
return core.Result{
|
|
||||||
Value: map[string]any{
|
|
||||||
"bucket": storeInstance.journalBucket(),
|
|
||||||
"measurement": measurement,
|
|
||||||
"fields": cloneAnyMap(fields),
|
|
||||||
"tags": cloneStringMap(tags),
|
|
||||||
"committed_at": committedAt,
|
|
||||||
},
|
|
||||||
OK: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `result := storeInstance.QueryJournal(\`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r.workspace == "session-a")\`)`
|
|
||||||
// Usage example: `result := storeInstance.QueryJournal("SELECT measurement, committed_at FROM journal_entries ORDER BY committed_at")`
|
|
||||||
func (storeInstance *Store) QueryJournal(flux string) core.Result {
|
|
||||||
if err := storeInstance.ensureReady("store.QueryJournal"); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
if err := ensureJournalSchema(storeInstance.sqliteDatabase); err != nil {
|
|
||||||
return core.Result{Value: core.E("store.QueryJournal", "ensure journal schema", err), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
trimmedQuery := core.Trim(flux)
|
|
||||||
if trimmedQuery == "" {
|
|
||||||
return storeInstance.queryJournalRows(
|
|
||||||
"SELECT bucket_name, measurement, fields_json, tags_json, committed_at, archived_at FROM " + journalEntriesTableName + " WHERE archived_at IS NULL ORDER BY committed_at, entry_id",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if isRawSQLJournalQuery(trimmedQuery) {
|
|
||||||
return storeInstance.queryJournalRows(trimmedQuery)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectSQL, arguments, err := storeInstance.queryJournalFromFlux(trimmedQuery)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
return storeInstance.queryJournalRows(selectSQL, arguments...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRawSQLJournalQuery(query string) bool {
|
|
||||||
upperQuery := core.Upper(core.Trim(query))
|
|
||||||
return core.HasPrefix(upperQuery, "SELECT") ||
|
|
||||||
core.HasPrefix(upperQuery, "WITH") ||
|
|
||||||
core.HasPrefix(upperQuery, "EXPLAIN") ||
|
|
||||||
core.HasPrefix(upperQuery, "PRAGMA")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (storeInstance *Store) queryJournalRows(query string, arguments ...any) core.Result {
|
|
||||||
rows, err := storeInstance.sqliteDatabase.Query(query, arguments...)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("store.QueryJournal", "query rows", err), OK: false}
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
rowMaps, err := queryRowsAsMaps(rows)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("store.QueryJournal", "scan rows", err), OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: inflateJournalRows(rowMaps), OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (storeInstance *Store) queryJournalFromFlux(flux string) (string, []any, error) {
|
|
||||||
queryBuilder := core.NewBuilder()
|
|
||||||
queryBuilder.WriteString("SELECT bucket_name, measurement, fields_json, tags_json, committed_at, archived_at FROM ")
|
|
||||||
queryBuilder.WriteString(journalEntriesTableName)
|
|
||||||
queryBuilder.WriteString(" WHERE archived_at IS NULL")
|
|
||||||
|
|
||||||
var queryArguments []any
|
|
||||||
if bucket := quotedSubmatch(journalBucketPattern, flux); bucket != "" {
|
|
||||||
queryBuilder.WriteString(" AND bucket_name = ?")
|
|
||||||
queryArguments = append(queryArguments, bucket)
|
|
||||||
}
|
|
||||||
if measurement := firstQuotedSubmatch(journalMeasurementPatterns, flux); measurement != "" {
|
|
||||||
queryBuilder.WriteString(" AND measurement = ?")
|
|
||||||
queryArguments = append(queryArguments, measurement)
|
|
||||||
}
|
|
||||||
|
|
||||||
startRange, stopRange := journalRangeBounds(flux)
|
|
||||||
if startRange != "" {
|
|
||||||
startTime, err := parseFluxTime(core.Trim(startRange))
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, core.E("store.QueryJournal", "parse range", err)
|
|
||||||
}
|
|
||||||
queryBuilder.WriteString(" AND committed_at >= ?")
|
|
||||||
queryArguments = append(queryArguments, startTime.UnixMilli())
|
|
||||||
}
|
|
||||||
if stopRange != "" {
|
|
||||||
stopTime, err := parseFluxTime(core.Trim(stopRange))
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, core.E("store.QueryJournal", "parse range", err)
|
|
||||||
}
|
|
||||||
queryBuilder.WriteString(" AND committed_at < ?")
|
|
||||||
queryArguments = append(queryArguments, stopTime.UnixMilli())
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern := range journalBucketEqualityPatterns {
|
|
||||||
bucketMatches := pattern.FindAllStringSubmatch(flux, -1)
|
|
||||||
for _, match := range bucketMatches {
|
|
||||||
if len(match) < 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
queryBuilder.WriteString(" AND bucket_name = ?")
|
|
||||||
queryArguments = append(queryArguments, match[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, filter := range journalEqualityFilters(flux) {
|
|
||||||
if filter.stringCompare {
|
|
||||||
queryBuilder.WriteString(" AND (CAST(json_extract(tags_json, '$.\"' || ? || '\"') AS TEXT) = ? OR CAST(json_extract(fields_json, '$.\"' || ? || '\"') AS TEXT) = ?)")
|
|
||||||
queryArguments = append(queryArguments, filter.columnName, filter.filterValue, filter.columnName, filter.filterValue)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
queryBuilder.WriteString(" AND json_extract(fields_json, '$.\"' || ? || '\"') = ?")
|
|
||||||
queryArguments = append(queryArguments, filter.columnName, filter.filterValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
queryBuilder.WriteString(" ORDER BY committed_at, entry_id")
|
|
||||||
return queryBuilder.String(), queryArguments, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (storeInstance *Store) journalBucket() string {
|
|
||||||
if storeInstance.journalConfiguration.BucketName == "" {
|
|
||||||
return defaultJournalBucket
|
|
||||||
}
|
|
||||||
return storeInstance.journalConfiguration.BucketName
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureJournalSchema(database schemaDatabase) error {
|
|
||||||
if _, err := database.Exec(createJournalEntriesTableSQL); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := database.Exec(
|
|
||||||
"CREATE INDEX IF NOT EXISTS journal_entries_bucket_committed_at_idx ON " + journalEntriesTableName + " (bucket_name, committed_at)",
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func commitJournalEntry(
|
|
||||||
executor journalExecutor,
|
|
||||||
bucket, measurement, fieldsJSON, tagsJSON string,
|
|
||||||
committedAt int64,
|
|
||||||
) error {
|
|
||||||
_, err := executor.Exec(
|
|
||||||
"INSERT INTO "+journalEntriesTableName+" (bucket_name, measurement, fields_json, tags_json, committed_at, archived_at) VALUES (?, ?, ?, ?, ?, NULL)",
|
|
||||||
bucket,
|
|
||||||
measurement,
|
|
||||||
fieldsJSON,
|
|
||||||
tagsJSON,
|
|
||||||
committedAt,
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalJSONText(value any, operation, message string) (string, error) {
|
|
||||||
result := core.JSONMarshal(value)
|
|
||||||
if !result.OK {
|
|
||||||
return "", core.E(operation, message, result.Value.(error))
|
|
||||||
}
|
|
||||||
return string(result.Value.([]byte)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func journalRangeBounds(flux string) (string, string) {
|
|
||||||
rangeIndex := indexOfSubstring(flux, "range(")
|
|
||||||
if rangeIndex < 0 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
contentStart := rangeIndex + len("range(")
|
|
||||||
depth := 1
|
|
||||||
contentEnd := -1
|
|
||||||
scanRange:
|
|
||||||
for i := contentStart; i < len(flux); i++ {
|
|
||||||
switch flux[i] {
|
|
||||||
case '(':
|
|
||||||
depth++
|
|
||||||
case ')':
|
|
||||||
depth--
|
|
||||||
if depth == 0 {
|
|
||||||
contentEnd = i
|
|
||||||
break scanRange
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if contentEnd < 0 || contentEnd <= contentStart {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
content := flux[contentStart:contentEnd]
|
|
||||||
startPrefix := "start:"
|
|
||||||
startIndex := indexOfSubstring(content, startPrefix)
|
|
||||||
if startIndex < 0 {
|
|
||||||
return "", ""
|
|
||||||
}
|
|
||||||
startIndex += len(startPrefix)
|
|
||||||
start := core.Trim(content[startIndex:])
|
|
||||||
stop := ""
|
|
||||||
if stopIndex := indexOfSubstring(content, ", stop:"); stopIndex >= 0 {
|
|
||||||
start = core.Trim(content[startIndex:stopIndex])
|
|
||||||
stop = core.Trim(content[stopIndex+len(", stop:"):])
|
|
||||||
} else if stopIndex := indexOfSubstring(content, ",stop:"); stopIndex >= 0 {
|
|
||||||
start = core.Trim(content[startIndex:stopIndex])
|
|
||||||
stop = core.Trim(content[stopIndex+len(",stop:"):])
|
|
||||||
}
|
|
||||||
return start, stop
|
|
||||||
}
|
|
||||||
|
|
||||||
func indexOfSubstring(text, substring string) int {
|
|
||||||
if substring == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if len(substring) > len(text) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
for i := 0; i <= len(text)-len(substring); i++ {
|
|
||||||
if text[i:i+len(substring)] == substring {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFluxTime(value string) (time.Time, error) {
|
|
||||||
value = core.Trim(value)
|
|
||||||
if value == "" {
|
|
||||||
return time.Time{}, core.E("store.parseFluxTime", "range value is empty", nil)
|
|
||||||
}
|
|
||||||
value = firstStringOrEmpty(core.Split(value, ","))
|
|
||||||
value = core.Trim(value)
|
|
||||||
if core.HasPrefix(value, "time(v:") && core.HasSuffix(value, ")") {
|
|
||||||
value = core.Trim(core.TrimSuffix(core.TrimPrefix(value, "time(v:"), ")"))
|
|
||||||
}
|
|
||||||
if core.HasPrefix(value, `"`) && core.HasSuffix(value, `"`) {
|
|
||||||
value = core.TrimSuffix(core.TrimPrefix(value, `"`), `"`)
|
|
||||||
}
|
|
||||||
if value == "now()" {
|
|
||||||
return time.Now(), nil
|
|
||||||
}
|
|
||||||
if core.HasSuffix(value, "d") {
|
|
||||||
days, err := strconv.Atoi(core.TrimSuffix(value, "d"))
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, err
|
|
||||||
}
|
|
||||||
return time.Now().Add(time.Duration(days) * 24 * time.Hour), nil
|
|
||||||
}
|
|
||||||
lookback, err := time.ParseDuration(value)
|
|
||||||
if err == nil {
|
|
||||||
return time.Now().Add(lookback), nil
|
|
||||||
}
|
|
||||||
parsedTime, err := time.Parse(time.RFC3339Nano, value)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, err
|
|
||||||
}
|
|
||||||
return parsedTime, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func quotedSubmatch(pattern *regexp.Regexp, value string) string {
|
|
||||||
match := pattern.FindStringSubmatch(value)
|
|
||||||
if len(match) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstQuotedSubmatch(patterns []*regexp.Regexp, value string) string {
|
|
||||||
for _, pattern := range patterns {
|
|
||||||
if match := quotedSubmatch(pattern, value); match != "" {
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func regexpSubmatch(pattern *regexp.Regexp, value string, index int) string {
|
|
||||||
match := pattern.FindStringSubmatch(value)
|
|
||||||
if len(match) <= index {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return match[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryRowsAsMaps(rows *sql.Rows) ([]map[string]any, error) {
|
|
||||||
columnNames, err := rows.Columns()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []map[string]any
|
|
||||||
for rows.Next() {
|
|
||||||
rawValues := make([]any, len(columnNames))
|
|
||||||
scanTargets := make([]any, len(columnNames))
|
|
||||||
for i := range rawValues {
|
|
||||||
scanTargets[i] = &rawValues[i]
|
|
||||||
}
|
|
||||||
if err := rows.Scan(scanTargets...); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
row := make(map[string]any, len(columnNames))
|
|
||||||
for i, columnName := range columnNames {
|
|
||||||
row[columnName] = normaliseRowValue(rawValues[i])
|
|
||||||
}
|
|
||||||
result = append(result, row)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func inflateJournalRows(rows []map[string]any) []map[string]any {
|
|
||||||
for _, row := range rows {
|
|
||||||
if fieldsJSON, ok := row["fields_json"].(string); ok {
|
|
||||||
fields := make(map[string]any)
|
|
||||||
result := core.JSONUnmarshalString(fieldsJSON, &fields)
|
|
||||||
if result.OK {
|
|
||||||
row["fields"] = fields
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if tagsJSON, ok := row["tags_json"].(string); ok {
|
|
||||||
tags := make(map[string]string)
|
|
||||||
result := core.JSONUnmarshalString(tagsJSON, &tags)
|
|
||||||
if result.OK {
|
|
||||||
row["tags"] = tags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
|
|
||||||
func normaliseRowValue(value any) any {
|
|
||||||
switch typedValue := value.(type) {
|
|
||||||
case []byte:
|
|
||||||
return string(typedValue)
|
|
||||||
default:
|
|
||||||
return typedValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func journalEqualityFilters(flux string) []journalEqualityFilter {
|
|
||||||
var filters []journalEqualityFilter
|
|
||||||
appendFilter := func(columnName string, filterValue any, stringCompare bool) {
|
|
||||||
if columnName == "_measurement" || columnName == "measurement" || columnName == "_bucket" || columnName == "bucket" || columnName == "bucket_name" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
filters = append(filters, journalEqualityFilter{
|
|
||||||
columnName: columnName,
|
|
||||||
filterValue: filterValue,
|
|
||||||
stringCompare: stringCompare,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern := range journalStringEqualityPatterns {
|
|
||||||
matches := pattern.FindAllStringSubmatch(flux, -1)
|
|
||||||
for _, match := range matches {
|
|
||||||
if len(match) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
appendFilter(match[1], match[2], true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, pattern := range journalScalarEqualityPatterns {
|
|
||||||
matches := pattern.FindAllStringSubmatch(flux, -1)
|
|
||||||
for _, match := range matches {
|
|
||||||
if len(match) < 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filterValue, ok := parseJournalScalarValue(match[2])
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
appendFilter(match[1], filterValue, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return filters
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJournalScalarValue(value string) (any, bool) {
|
|
||||||
switch value {
|
|
||||||
case "true":
|
|
||||||
return true, true
|
|
||||||
case "false":
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
if integerValue, err := strconv.ParseInt(value, 10, 64); err == nil {
|
|
||||||
return integerValue, true
|
|
||||||
}
|
|
||||||
if floatValue, err := strconv.ParseFloat(value, 64); err == nil {
|
|
||||||
return floatValue, true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneAnyMap(input map[string]any) map[string]any {
|
|
||||||
if input == nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
cloned := make(map[string]any, len(input))
|
|
||||||
for key, value := range input {
|
|
||||||
cloned[key] = value
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneStringMap(input map[string]string) map[string]string {
|
|
||||||
if input == nil {
|
|
||||||
return map[string]string{}
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(input))
|
|
||||||
for key, value := range input {
|
|
||||||
cloned[key] = value
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
337
journal_test.go
337
journal_test.go
|
|
@ -1,337 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestJournal_CommitToJournal_Good_WithQueryJournalSQL(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
first := storeInstance.CommitToJournal("session-a", map[string]any{"like": 4}, map[string]string{"workspace": "session-a"})
|
|
||||||
second := storeInstance.CommitToJournal("session-b", map[string]any{"profile_match": 2}, map[string]string{"workspace": "session-b"})
|
|
||||||
require.True(t, first.OK, "first journal commit failed: %v", first.Value)
|
|
||||||
require.True(t, second.OK, "second journal commit failed: %v", second.Value)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal("SELECT bucket_name, measurement, fields_json, tags_json FROM journal_entries ORDER BY entry_id"),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 2)
|
|
||||||
assert.Equal(t, "events", rows[0]["bucket_name"])
|
|
||||||
assert.Equal(t, "session-a", rows[0]["measurement"])
|
|
||||||
|
|
||||||
fields, ok := rows[0]["fields"].(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected fields type: %T", rows[0]["fields"])
|
|
||||||
assert.Equal(t, float64(4), fields["like"])
|
|
||||||
|
|
||||||
tags, ok := rows[1]["tags"].(map[string]string)
|
|
||||||
require.True(t, ok, "unexpected tags type: %T", rows[1]["tags"])
|
|
||||||
assert.Equal(t, "session-b", tags["workspace"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_CommitToJournal_Good_ResultCopiesInputMaps(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
fields := map[string]any{"like": 4}
|
|
||||||
tags := map[string]string{"workspace": "session-a"}
|
|
||||||
|
|
||||||
result := storeInstance.CommitToJournal("session-a", fields, tags)
|
|
||||||
require.True(t, result.OK, "journal commit failed: %v", result.Value)
|
|
||||||
|
|
||||||
fields["like"] = 99
|
|
||||||
tags["workspace"] = "session-b"
|
|
||||||
|
|
||||||
value, ok := result.Value.(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected result type: %T", result.Value)
|
|
||||||
|
|
||||||
resultFields, ok := value["fields"].(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected fields type: %T", value["fields"])
|
|
||||||
assert.Equal(t, 4, resultFields["like"])
|
|
||||||
|
|
||||||
resultTags, ok := value["tags"].(map[string]string)
|
|
||||||
require.True(t, ok, "unexpected tags type: %T", value["tags"])
|
|
||||||
assert.Equal(t, "session-a", resultTags["workspace"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_RawSQLWithCTE(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 4}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`
|
|
||||||
WITH journal_rows AS (
|
|
||||||
SELECT bucket_name, measurement, fields_json, tags_json, committed_at, archived_at
|
|
||||||
FROM journal_entries
|
|
||||||
)
|
|
||||||
SELECT bucket_name, measurement, fields_json, tags_json, committed_at, archived_at
|
|
||||||
FROM journal_rows
|
|
||||||
ORDER BY committed_at
|
|
||||||
`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-a", rows[0]["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_PragmaSQL(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal("PRAGMA table_info(journal_entries)"),
|
|
||||||
)
|
|
||||||
require.NotEmpty(t, rows)
|
|
||||||
var columnNames []string
|
|
||||||
for _, row := range rows {
|
|
||||||
name, ok := row["name"].(string)
|
|
||||||
require.True(t, ok, "unexpected column name type: %T", row["name"])
|
|
||||||
columnNames = append(columnNames, name)
|
|
||||||
}
|
|
||||||
assert.Contains(t, columnNames, "bucket_name")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_FluxFilters(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"like": 2}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r._measurement == "session-b")`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
|
|
||||||
fields, ok := rows[0]["fields"].(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected fields type: %T", rows[0]["fields"])
|
|
||||||
assert.Equal(t, float64(2), fields["like"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_TagFilter(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"like": 2}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r.workspace == "session-b")`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
|
|
||||||
tags, ok := rows[0]["tags"].(map[string]string)
|
|
||||||
require.True(t, ok, "unexpected tags type: %T", rows[0]["tags"])
|
|
||||||
assert.Equal(t, "session-b", tags["workspace"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_NumericFieldFilter(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"like": 2}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r.like == 2)`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
|
|
||||||
fields, ok := rows[0]["fields"].(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected fields type: %T", rows[0]["fields"])
|
|
||||||
assert.Equal(t, float64(2), fields["like"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_BooleanFieldFilter(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"complete": false}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"complete": true}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r["complete"] == true)`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
|
|
||||||
fields, ok := rows[0]["fields"].(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected fields type: %T", rows[0]["fields"])
|
|
||||||
assert.Equal(t, true, fields["complete"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_BucketFilter(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.NoError(t, commitJournalEntry(
|
|
||||||
storeInstance.sqliteDatabase,
|
|
||||||
"events",
|
|
||||||
"session-b",
|
|
||||||
`{"like":2}`,
|
|
||||||
`{"workspace":"session-b"}`,
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
))
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r._bucket == "events")`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
assert.Equal(t, "events", rows[0]["bucket_name"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_DeterministicOrderingForSameTimestamp(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
require.NoError(t, ensureJournalSchema(storeInstance.sqliteDatabase))
|
|
||||||
|
|
||||||
committedAt := time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC).UnixMilli()
|
|
||||||
require.NoError(t, commitJournalEntry(
|
|
||||||
storeInstance.sqliteDatabase,
|
|
||||||
"events",
|
|
||||||
"session-b",
|
|
||||||
`{"like":2}`,
|
|
||||||
`{"workspace":"session-b"}`,
|
|
||||||
committedAt,
|
|
||||||
))
|
|
||||||
require.NoError(t, commitJournalEntry(
|
|
||||||
storeInstance.sqliteDatabase,
|
|
||||||
"events",
|
|
||||||
"session-a",
|
|
||||||
`{"like":1}`,
|
|
||||||
`{"workspace":"session-a"}`,
|
|
||||||
committedAt,
|
|
||||||
))
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(""),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 2)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
assert.Equal(t, "session-a", rows[1]["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_AbsoluteRangeWithStop(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"like": 2}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET committed_at = ? WHERE measurement = ?",
|
|
||||||
time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
|
||||||
"session-a",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET committed_at = ? WHERE measurement = ?",
|
|
||||||
time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
|
||||||
"session-b",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: "2026-03-30T00:00:00Z", stop: now())`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-b", rows[0]["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_QueryJournal_Good_AbsoluteRangeHonoursStop(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-a", map[string]any{"like": 1}, map[string]string{"workspace": "session-a"}).OK,
|
|
||||||
)
|
|
||||||
require.True(t,
|
|
||||||
storeInstance.CommitToJournal("session-b", map[string]any{"like": 2}, map[string]string{"workspace": "session-b"}).OK,
|
|
||||||
)
|
|
||||||
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET committed_at = ? WHERE measurement = ?",
|
|
||||||
time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
|
||||||
"session-a",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = storeInstance.sqliteDatabase.Exec(
|
|
||||||
"UPDATE "+journalEntriesTableName+" SET committed_at = ? WHERE measurement = ?",
|
|
||||||
time.Date(2026, 3, 30, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
|
||||||
"session-b",
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: "2026-03-29T00:00:00Z", stop: "2026-03-30T00:00:00Z")`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "session-a", rows[0]["measurement"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestJournal_CommitToJournal_Bad_EmptyMeasurement(t *testing.T) {
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
result := storeInstance.CommitToJournal("", map[string]any{"like": 1}, map[string]string{"workspace": "missing"})
|
|
||||||
require.False(t, result.OK)
|
|
||||||
assert.Contains(t, result.Value.(error).Error(), "measurement is empty")
|
|
||||||
}
|
|
||||||
142
json.go
142
json.go
|
|
@ -1,142 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
// JSON helpers for storage consumers.
|
|
||||||
// Re-exports the minimum JSON surface needed by downstream users like
|
|
||||||
// go-cache and go-tenant so they don't need to import encoding/json directly.
|
|
||||||
// Internally uses core/go JSON primitives.
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RawMessage is a raw encoded JSON value.
|
|
||||||
// Use in structs where the JSON should be stored as-is without re-encoding.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// type CacheEntry struct {
|
|
||||||
// Data store.RawMessage `json:"data"`
|
|
||||||
// }
|
|
||||||
type RawMessage []byte
|
|
||||||
|
|
||||||
// MarshalJSON returns the raw bytes as-is. If empty, returns `null`.
|
|
||||||
//
|
|
||||||
// Usage example: `bytes, err := raw.MarshalJSON()`
|
|
||||||
func (raw RawMessage) MarshalJSON() ([]byte, error) {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return []byte("null"), nil
|
|
||||||
}
|
|
||||||
return raw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalJSON stores the raw JSON bytes without decoding them.
|
|
||||||
//
|
|
||||||
// Usage example: `var raw store.RawMessage; err := raw.UnmarshalJSON(data)`
|
|
||||||
func (raw *RawMessage) UnmarshalJSON(data []byte) error {
|
|
||||||
if raw == nil {
|
|
||||||
return core.E("store.RawMessage.UnmarshalJSON", "nil receiver", nil)
|
|
||||||
}
|
|
||||||
*raw = append((*raw)[:0], data...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalIndent serialises a value to pretty-printed JSON bytes.
|
|
||||||
// Uses core.JSONMarshal internally then applies prefix/indent formatting
|
|
||||||
// so consumers get readable output without importing encoding/json.
|
|
||||||
//
|
|
||||||
// Usage example: `data, err := store.MarshalIndent(entry, "", " ")`
|
|
||||||
func MarshalIndent(v any, prefix, indent string) ([]byte, error) {
|
|
||||||
marshalled := core.JSONMarshal(v)
|
|
||||||
if !marshalled.OK {
|
|
||||||
if err, ok := marshalled.Value.(error); ok {
|
|
||||||
return nil, core.E("store.MarshalIndent", "marshal", err)
|
|
||||||
}
|
|
||||||
return nil, core.E("store.MarshalIndent", "marshal", nil)
|
|
||||||
}
|
|
||||||
raw, ok := marshalled.Value.([]byte)
|
|
||||||
if !ok {
|
|
||||||
return nil, core.E("store.MarshalIndent", "non-bytes result", nil)
|
|
||||||
}
|
|
||||||
if prefix == "" && indent == "" {
|
|
||||||
return raw, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
if err := indentCompactJSON(&buf, raw, prefix, indent); err != nil {
|
|
||||||
return nil, core.E("store.MarshalIndent", "indent", err)
|
|
||||||
}
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// indentCompactJSON formats compact JSON bytes with prefix+indent.
|
|
||||||
// Mirrors json.Indent's semantics without importing encoding/json.
|
|
||||||
//
|
|
||||||
// Usage example: `var buf bytes.Buffer; _ = indentCompactJSON(&buf, compact, "", " ")`
|
|
||||||
func indentCompactJSON(buf *bytes.Buffer, src []byte, prefix, indent string) error {
|
|
||||||
depth := 0
|
|
||||||
inString := false
|
|
||||||
escaped := false
|
|
||||||
|
|
||||||
writeNewlineIndent := func(level int) {
|
|
||||||
buf.WriteByte('\n')
|
|
||||||
buf.WriteString(prefix)
|
|
||||||
for i := 0; i < level; i++ {
|
|
||||||
buf.WriteString(indent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := 0; i < len(src); i++ {
|
|
||||||
c := src[i]
|
|
||||||
if inString {
|
|
||||||
buf.WriteByte(c)
|
|
||||||
if escaped {
|
|
||||||
escaped = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c == '\\' {
|
|
||||||
escaped = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if c == '"' {
|
|
||||||
inString = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch c {
|
|
||||||
case '"':
|
|
||||||
inString = true
|
|
||||||
buf.WriteByte(c)
|
|
||||||
case '{', '[':
|
|
||||||
buf.WriteByte(c)
|
|
||||||
depth++
|
|
||||||
// Look ahead for empty object/array.
|
|
||||||
if i+1 < len(src) && (src[i+1] == '}' || src[i+1] == ']') {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
writeNewlineIndent(depth)
|
|
||||||
case '}', ']':
|
|
||||||
// Only indent if previous byte wasn't the matching opener.
|
|
||||||
if i > 0 && src[i-1] != '{' && src[i-1] != '[' {
|
|
||||||
depth--
|
|
||||||
writeNewlineIndent(depth)
|
|
||||||
} else {
|
|
||||||
depth--
|
|
||||||
}
|
|
||||||
buf.WriteByte(c)
|
|
||||||
case ',':
|
|
||||||
buf.WriteByte(c)
|
|
||||||
writeNewlineIndent(depth)
|
|
||||||
case ':':
|
|
||||||
buf.WriteByte(c)
|
|
||||||
buf.WriteByte(' ')
|
|
||||||
case ' ', '\t', '\n', '\r':
|
|
||||||
// Drop whitespace from compact source.
|
|
||||||
default:
|
|
||||||
buf.WriteByte(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
348
medium.go
348
medium.go
|
|
@ -1,348 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/io"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Medium is the minimal storage transport used by the go-store workspace
|
|
||||||
// import and export helpers and by Compact when writing cold archives.
|
|
||||||
//
|
|
||||||
// This is an alias of `dappco.re/go/core/io.Medium`, so callers can pass any
|
|
||||||
// upstream medium implementation directly without an adapter.
|
|
||||||
//
|
|
||||||
// Usage example: `medium, _ := local.New("/tmp/exports"); storeInstance, err := store.New(":memory:", store.WithMedium(medium))`
|
|
||||||
type Medium = io.Medium
|
|
||||||
|
|
||||||
// Usage example: `medium, _ := local.New("/srv/core"); storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: ":memory:", Medium: medium})`
|
|
||||||
// WithMedium installs an io.Medium-compatible transport on the Store so that
|
|
||||||
// Compact archives and Import/Export helpers route through the medium instead
|
|
||||||
// of the raw filesystem.
|
|
||||||
func WithMedium(medium Medium) StoreOption {
|
|
||||||
return func(storeInstance *Store) {
|
|
||||||
if storeInstance == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
storeInstance.medium = medium
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `medium := storeInstance.Medium(); if medium != nil { _ = medium.EnsureDir("exports") }`
|
|
||||||
func (storeInstance *Store) Medium() Medium {
|
|
||||||
if storeInstance == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return storeInstance.medium
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `err := store.Import(workspace, medium, "dataset.jsonl")`
|
|
||||||
// Import reads a JSON, JSONL, or CSV payload from the provided medium and
|
|
||||||
// appends each record to the workspace buffer as a `Put` entry. Format is
|
|
||||||
// chosen from the file extension: `.json` expects either a top-level array or
|
|
||||||
// `{"entries":[...]}` shape, `.jsonl`/`.ndjson` parse line-by-line, and `.csv`
|
|
||||||
// uses the first row as the header.
|
|
||||||
func Import(workspace *Workspace, medium Medium, path string) error {
|
|
||||||
if workspace == nil {
|
|
||||||
return core.E("store.Import", "workspace is nil", nil)
|
|
||||||
}
|
|
||||||
if medium == nil {
|
|
||||||
return core.E("store.Import", "medium is nil", nil)
|
|
||||||
}
|
|
||||||
if path == "" {
|
|
||||||
return core.E("store.Import", "path is empty", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := medium.Read(path)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Import", "read from medium", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
kind := importEntryKind(path)
|
|
||||||
switch lowercaseText(importExtension(path)) {
|
|
||||||
case ".jsonl", ".ndjson":
|
|
||||||
return importJSONLines(workspace, kind, content)
|
|
||||||
case ".csv":
|
|
||||||
return importCSV(workspace, kind, content)
|
|
||||||
case ".json":
|
|
||||||
return importJSON(workspace, kind, content)
|
|
||||||
default:
|
|
||||||
return importJSONLines(workspace, kind, content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `err := store.Export(workspace, medium, "report.json")`
|
|
||||||
// Export writes the workspace aggregate summary to the medium at the given
|
|
||||||
// path. Format is chosen from the extension: `.jsonl` writes one record per
|
|
||||||
// query row, `.csv` writes header + rows, everything else writes the
|
|
||||||
// aggregate as JSON.
|
|
||||||
func Export(workspace *Workspace, medium Medium, path string) error {
|
|
||||||
if workspace == nil {
|
|
||||||
return core.E("store.Export", "workspace is nil", nil)
|
|
||||||
}
|
|
||||||
if medium == nil {
|
|
||||||
return core.E("store.Export", "medium is nil", nil)
|
|
||||||
}
|
|
||||||
if path == "" {
|
|
||||||
return core.E("store.Export", "path is empty", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ensureMediumDir(medium, core.PathDir(path)); err != nil {
|
|
||||||
return core.E("store.Export", "ensure directory", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch lowercaseText(importExtension(path)) {
|
|
||||||
case ".jsonl", ".ndjson":
|
|
||||||
return exportJSONLines(workspace, medium, path)
|
|
||||||
case ".csv":
|
|
||||||
return exportCSV(workspace, medium, path)
|
|
||||||
default:
|
|
||||||
return exportJSON(workspace, medium, path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureMediumDir(medium Medium, directory string) error {
|
|
||||||
if directory == "" || directory == "." || directory == "/" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := medium.EnsureDir(directory); err != nil {
|
|
||||||
return core.E("store.ensureMediumDir", "ensure directory", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importExtension(path string) string {
|
|
||||||
base := core.PathBase(path)
|
|
||||||
for i := len(base) - 1; i >= 0; i-- {
|
|
||||||
if base[i] == '.' {
|
|
||||||
return base[i:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func importEntryKind(path string) string {
|
|
||||||
base := core.PathBase(path)
|
|
||||||
for i := len(base) - 1; i >= 0; i-- {
|
|
||||||
if base[i] == '.' {
|
|
||||||
base = base[:i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if base == "" {
|
|
||||||
return "entry"
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
func importJSONLines(workspace *Workspace, kind, content string) error {
|
|
||||||
scanner := core.Split(content, "\n")
|
|
||||||
for _, rawLine := range scanner {
|
|
||||||
line := core.Trim(rawLine)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
record := map[string]any{}
|
|
||||||
if result := core.JSONUnmarshalString(line, &record); !result.OK {
|
|
||||||
err, _ := result.Value.(error)
|
|
||||||
return core.E("store.Import", "parse jsonl line", err)
|
|
||||||
}
|
|
||||||
if err := workspace.Put(kind, record); err != nil {
|
|
||||||
return core.E("store.Import", "put jsonl record", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importJSON(workspace *Workspace, kind, content string) error {
|
|
||||||
trimmed := core.Trim(content)
|
|
||||||
if trimmed == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var topLevel any
|
|
||||||
if result := core.JSONUnmarshalString(trimmed, &topLevel); !result.OK {
|
|
||||||
err, _ := result.Value.(error)
|
|
||||||
return core.E("store.Import", "parse json", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
records := collectJSONRecords(topLevel)
|
|
||||||
for _, record := range records {
|
|
||||||
if err := workspace.Put(kind, record); err != nil {
|
|
||||||
return core.E("store.Import", "put json record", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectJSONRecords(value any) []map[string]any {
|
|
||||||
switch shape := value.(type) {
|
|
||||||
case []any:
|
|
||||||
records := make([]map[string]any, 0, len(shape))
|
|
||||||
for _, entry := range shape {
|
|
||||||
if record, ok := entry.(map[string]any); ok {
|
|
||||||
records = append(records, record)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return records
|
|
||||||
case map[string]any:
|
|
||||||
if nested, ok := shape["entries"].([]any); ok {
|
|
||||||
return collectJSONRecords(nested)
|
|
||||||
}
|
|
||||||
if nested, ok := shape["records"].([]any); ok {
|
|
||||||
return collectJSONRecords(nested)
|
|
||||||
}
|
|
||||||
if nested, ok := shape["data"].([]any); ok {
|
|
||||||
return collectJSONRecords(nested)
|
|
||||||
}
|
|
||||||
return []map[string]any{shape}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func importCSV(workspace *Workspace, kind, content string) error {
|
|
||||||
lines := core.Split(content, "\n")
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
header := splitCSVLine(lines[0])
|
|
||||||
if len(header) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, rawLine := range lines[1:] {
|
|
||||||
line := trimTrailingCarriageReturn(rawLine)
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields := splitCSVLine(line)
|
|
||||||
record := make(map[string]any, len(header))
|
|
||||||
for columnIndex, columnName := range header {
|
|
||||||
if columnIndex < len(fields) {
|
|
||||||
record[columnName] = fields[columnIndex]
|
|
||||||
} else {
|
|
||||||
record[columnName] = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := workspace.Put(kind, record); err != nil {
|
|
||||||
return core.E("store.Import", "put csv record", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitCSVLine(line string) []string {
|
|
||||||
line = trimTrailingCarriageReturn(line)
|
|
||||||
var (
|
|
||||||
fields []string
|
|
||||||
buffer bytes.Buffer
|
|
||||||
inQuotes bool
|
|
||||||
wasEscaped bool
|
|
||||||
)
|
|
||||||
for index := 0; index < len(line); index++ {
|
|
||||||
character := line[index]
|
|
||||||
switch {
|
|
||||||
case character == '"' && inQuotes && index+1 < len(line) && line[index+1] == '"':
|
|
||||||
buffer.WriteByte('"')
|
|
||||||
index++
|
|
||||||
wasEscaped = true
|
|
||||||
case character == '"':
|
|
||||||
inQuotes = !inQuotes
|
|
||||||
case character == ',' && !inQuotes:
|
|
||||||
fields = append(fields, buffer.String())
|
|
||||||
buffer.Reset()
|
|
||||||
wasEscaped = false
|
|
||||||
default:
|
|
||||||
buffer.WriteByte(character)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fields = append(fields, buffer.String())
|
|
||||||
_ = wasEscaped
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportJSON(workspace *Workspace, medium Medium, path string) error {
|
|
||||||
summary := workspace.Aggregate()
|
|
||||||
content := core.JSONMarshalString(summary)
|
|
||||||
if err := medium.Write(path, content); err != nil {
|
|
||||||
return core.E("store.Export", "write json", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportJSONLines(workspace *Workspace, medium Medium, path string) error {
|
|
||||||
result := workspace.Query("SELECT entry_kind, entry_data, created_at FROM workspace_entries ORDER BY entry_id")
|
|
||||||
if !result.OK {
|
|
||||||
err, _ := result.Value.(error)
|
|
||||||
return core.E("store.Export", "query workspace", err)
|
|
||||||
}
|
|
||||||
rows, ok := result.Value.([]map[string]any)
|
|
||||||
if !ok {
|
|
||||||
rows = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
for _, row := range rows {
|
|
||||||
line := core.JSONMarshalString(row)
|
|
||||||
builder.WriteString(line)
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
if err := medium.Write(path, builder.String()); err != nil {
|
|
||||||
return core.E("store.Export", "write jsonl", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func exportCSV(workspace *Workspace, medium Medium, path string) error {
|
|
||||||
result := workspace.Query("SELECT entry_kind, entry_data, created_at FROM workspace_entries ORDER BY entry_id")
|
|
||||||
if !result.OK {
|
|
||||||
err, _ := result.Value.(error)
|
|
||||||
return core.E("store.Export", "query workspace", err)
|
|
||||||
}
|
|
||||||
rows, ok := result.Value.([]map[string]any)
|
|
||||||
if !ok {
|
|
||||||
rows = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
builder.WriteString("entry_kind,entry_data,created_at\n")
|
|
||||||
for _, row := range rows {
|
|
||||||
builder.WriteString(csvField(core.Sprint(row["entry_kind"])))
|
|
||||||
builder.WriteString(",")
|
|
||||||
builder.WriteString(csvField(core.Sprint(row["entry_data"])))
|
|
||||||
builder.WriteString(",")
|
|
||||||
builder.WriteString(csvField(core.Sprint(row["created_at"])))
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
if err := medium.Write(path, builder.String()); err != nil {
|
|
||||||
return core.E("store.Export", "write csv", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func trimTrailingCarriageReturn(value string) string {
|
|
||||||
for len(value) > 0 && value[len(value)-1] == '\r' {
|
|
||||||
value = value[:len(value)-1]
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func csvField(value string) string {
|
|
||||||
needsQuote := false
|
|
||||||
for index := 0; index < len(value); index++ {
|
|
||||||
switch value[index] {
|
|
||||||
case ',', '"', '\n', '\r':
|
|
||||||
needsQuote = true
|
|
||||||
}
|
|
||||||
if needsQuote {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !needsQuote {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
escaped := core.Replace(value, `"`, `""`)
|
|
||||||
return core.Concat(`"`, escaped, `"`)
|
|
||||||
}
|
|
||||||
437
medium_test.go
437
medium_test.go
|
|
@ -1,437 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
goio "io"
|
|
||||||
"io/fs"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// memoryMedium is an in-memory implementation of `store.Medium` used by the
|
|
||||||
// medium tests so assertions do not depend on the local filesystem.
|
|
||||||
type memoryMedium struct {
|
|
||||||
lock sync.Mutex
|
|
||||||
files map[string]string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMemoryMedium() *memoryMedium {
|
|
||||||
return &memoryMedium{files: make(map[string]string)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Read(path string) (string, error) {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
content, ok := medium.files[path]
|
|
||||||
if !ok {
|
|
||||||
return "", core.E("memoryMedium.Read", "file not found: "+path, nil)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Write(path, content string) error {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
medium.files[path] = content
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) WriteMode(path, content string, _ fs.FileMode) error {
|
|
||||||
return medium.Write(path, content)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) EnsureDir(string) error { return nil }
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Create(path string) (goio.WriteCloser, error) {
|
|
||||||
return &memoryWriter{medium: medium, path: path}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Append(path string) (goio.WriteCloser, error) {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
return &memoryWriter{medium: medium, path: path, buffer: *bytes.NewBufferString(medium.files[path])}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) ReadStream(path string) (goio.ReadCloser, error) {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
return goio.NopCloser(bytes.NewReader([]byte(medium.files[path]))), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) WriteStream(path string) (goio.WriteCloser, error) {
|
|
||||||
return medium.Create(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Exists(path string) bool {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
_, ok := medium.files[path]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) IsFile(path string) bool { return medium.Exists(path) }
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Delete(path string) error {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
delete(medium.files, path)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) DeleteAll(path string) error {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
for key := range medium.files {
|
|
||||||
if key == path || core.HasPrefix(key, path+"/") {
|
|
||||||
delete(medium.files, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Rename(oldPath, newPath string) error {
|
|
||||||
medium.lock.Lock()
|
|
||||||
defer medium.lock.Unlock()
|
|
||||||
content, ok := medium.files[oldPath]
|
|
||||||
if !ok {
|
|
||||||
return core.E("memoryMedium.Rename", "file not found: "+oldPath, nil)
|
|
||||||
}
|
|
||||||
medium.files[newPath] = content
|
|
||||||
delete(medium.files, oldPath)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) List(path string) ([]fs.DirEntry, error) { return nil, nil }
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Stat(path string) (fs.FileInfo, error) {
|
|
||||||
if !medium.Exists(path) {
|
|
||||||
return nil, core.E("memoryMedium.Stat", "file not found: "+path, nil)
|
|
||||||
}
|
|
||||||
return fileInfoStub{name: core.PathBase(path)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) Open(path string) (fs.File, error) {
|
|
||||||
if !medium.Exists(path) {
|
|
||||||
return nil, core.E("memoryMedium.Open", "file not found: "+path, nil)
|
|
||||||
}
|
|
||||||
return newMemoryFile(path, medium.files[path]), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (medium *memoryMedium) IsDir(string) bool { return false }
|
|
||||||
|
|
||||||
type memoryWriter struct {
|
|
||||||
medium *memoryMedium
|
|
||||||
path string
|
|
||||||
buffer bytes.Buffer
|
|
||||||
closed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (writer *memoryWriter) Write(data []byte) (int, error) {
|
|
||||||
return writer.buffer.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (writer *memoryWriter) Close() error {
|
|
||||||
if writer.closed {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
writer.closed = true
|
|
||||||
return writer.medium.Write(writer.path, writer.buffer.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
type fileInfoStub struct {
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fileInfoStub) Size() int64 { return 0 }
|
|
||||||
func (fileInfoStub) Mode() fs.FileMode { return 0 }
|
|
||||||
func (fileInfoStub) ModTime() time.Time { return time.Time{} }
|
|
||||||
func (fileInfoStub) IsDir() bool { return false }
|
|
||||||
func (fileInfoStub) Sys() any { return nil }
|
|
||||||
func (info fileInfoStub) Name() string { return info.name }
|
|
||||||
|
|
||||||
type memoryFile struct {
|
|
||||||
*bytes.Reader
|
|
||||||
name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func newMemoryFile(name, content string) *memoryFile {
|
|
||||||
return &memoryFile{Reader: bytes.NewReader([]byte(content)), name: name}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (file *memoryFile) Stat() (fs.FileInfo, error) {
|
|
||||||
return fileInfoStub{name: core.PathBase(file.name)}, nil
|
|
||||||
}
|
|
||||||
func (file *memoryFile) Close() error { return nil }
|
|
||||||
|
|
||||||
// Ensure memoryMedium still satisfies the internal Medium contract.
|
|
||||||
var _ Medium = (*memoryMedium)(nil)
|
|
||||||
|
|
||||||
// Compile-time check for fs.FileInfo usage in the tests.
|
|
||||||
var _ fs.FileInfo = (*FileInfoStub)(nil)
|
|
||||||
|
|
||||||
type FileInfoStub struct{}
|
|
||||||
|
|
||||||
func (FileInfoStub) Name() string { return "" }
|
|
||||||
func (FileInfoStub) Size() int64 { return 0 }
|
|
||||||
func (FileInfoStub) Mode() fs.FileMode { return 0 }
|
|
||||||
func (FileInfoStub) ModTime() time.Time { return time.Time{} }
|
|
||||||
func (FileInfoStub) IsDir() bool { return false }
|
|
||||||
func (FileInfoStub) Sys() any { return nil }
|
|
||||||
|
|
||||||
func TestMedium_WithMedium_Good(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
storeInstance, err := New(":memory:", WithMedium(medium))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
assert.Same(t, medium, storeInstance.Medium(), "medium should round-trip via accessor")
|
|
||||||
assert.Same(t, medium, storeInstance.Config().Medium, "medium should appear in Config()")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_WithMedium_Bad_NilKeepsFilesystemBackend(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
assert.Nil(t, storeInstance.Medium())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_WithMedium_Good_PersistsDatabaseThroughMedium(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
|
|
||||||
storeInstance, err := New("app.db", WithMedium(medium))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("g", "k", "v"))
|
|
||||||
require.NoError(t, storeInstance.Close())
|
|
||||||
|
|
||||||
reopenedStore, err := New("app.db", WithMedium(medium))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer reopenedStore.Close()
|
|
||||||
|
|
||||||
value, err := reopenedStore.Get("g", "k")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "v", value)
|
|
||||||
assert.True(t, medium.Exists("app.db"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Import_Good_JSONL(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-import-jsonl")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
require.NoError(t, medium.Write("data.jsonl", `{"user":"@alice"}
|
|
||||||
{"user":"@bob"}
|
|
||||||
`))
|
|
||||||
|
|
||||||
require.NoError(t, Import(workspace, medium, "data.jsonl"))
|
|
||||||
|
|
||||||
rows := requireResultRows(t, workspace.Query("SELECT entry_kind, entry_data FROM workspace_entries ORDER BY entry_id"))
|
|
||||||
require.Len(t, rows, 2)
|
|
||||||
assert.Equal(t, "data", rows[0]["entry_kind"])
|
|
||||||
assert.Contains(t, rows[0]["entry_data"], "@alice")
|
|
||||||
assert.Contains(t, rows[1]["entry_data"], "@bob")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Import_Good_JSONArray(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-import-json-array")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
require.NoError(t, medium.Write("users.json", `[{"name":"Alice"},{"name":"Bob"},{"name":"Carol"}]`))
|
|
||||||
|
|
||||||
require.NoError(t, Import(workspace, medium, "users.json"))
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]any{"users": 3}, workspace.Aggregate())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Import_Good_CSV(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-import-csv")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
require.NoError(t, medium.Write("findings.csv", "tool,severity\ngosec,high\ngolint,low\n"))
|
|
||||||
|
|
||||||
require.NoError(t, Import(workspace, medium, "findings.csv"))
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]any{"findings": 2}, workspace.Aggregate())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Import_Bad_NilArguments(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-import-bad")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
|
|
||||||
require.Error(t, Import(nil, medium, "data.json"))
|
|
||||||
require.Error(t, Import(workspace, nil, "data.json"))
|
|
||||||
require.Error(t, Import(workspace, medium, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Import_Ugly_MissingFileReturnsError(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-import-missing")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
require.Error(t, Import(workspace, medium, "ghost.jsonl"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Export_Good_JSON(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-export-json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@bob"}))
|
|
||||||
require.NoError(t, workspace.Put("profile_match", map[string]any{"user": "@carol"}))
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
require.NoError(t, Export(workspace, medium, "report.json"))
|
|
||||||
|
|
||||||
assert.True(t, medium.Exists("report.json"))
|
|
||||||
content, err := medium.Read("report.json")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Contains(t, content, `"like":2`)
|
|
||||||
assert.Contains(t, content, `"profile_match":1`)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Export_Good_JSONLines(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-export-jsonl")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@bob"}))
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
require.NoError(t, Export(workspace, medium, "report.jsonl"))
|
|
||||||
|
|
||||||
content, err := medium.Read("report.jsonl")
|
|
||||||
require.NoError(t, err)
|
|
||||||
lines := 0
|
|
||||||
for _, line := range splitNewlines(content) {
|
|
||||||
if line != "" {
|
|
||||||
lines++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.Equal(t, 2, lines)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Export_Bad_NilArguments(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("medium-export-bad")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
|
|
||||||
require.Error(t, Export(nil, medium, "report.json"))
|
|
||||||
require.Error(t, Export(workspace, nil, "report.json"))
|
|
||||||
require.Error(t, Export(workspace, medium, ""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMedium_Compact_Good_MediumRoutesArchive(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
useArchiveOutputDirectory(t)
|
|
||||||
|
|
||||||
medium := newMemoryMedium()
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"), WithMedium(medium))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.True(t, storeInstance.CommitToJournal("jobs", map[string]any{"count": 3}, map[string]string{"workspace": "jobs-1"}).OK)
|
|
||||||
|
|
||||||
result := storeInstance.Compact(CompactOptions{
|
|
||||||
Before: time.Now().Add(time.Minute),
|
|
||||||
Output: "archive/",
|
|
||||||
Format: "gzip",
|
|
||||||
})
|
|
||||||
require.True(t, result.OK, "compact result: %v", result.Value)
|
|
||||||
outputPath, ok := result.Value.(string)
|
|
||||||
require.True(t, ok)
|
|
||||||
require.NotEmpty(t, outputPath)
|
|
||||||
assert.True(t, medium.Exists(outputPath), "compact should write through medium at %s", outputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitNewlines(content string) []string {
|
|
||||||
var result []string
|
|
||||||
current := core.NewBuilder()
|
|
||||||
for index := 0; index < len(content); index++ {
|
|
||||||
character := content[index]
|
|
||||||
if character == '\n' {
|
|
||||||
result = append(result, current.String())
|
|
||||||
current.Reset()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
current.WriteByte(character)
|
|
||||||
}
|
|
||||||
if current.Len() > 0 {
|
|
||||||
result = append(result, current.String())
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
195
parquet.go
195
parquet.go
|
|
@ -1,195 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"io"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/parquet-go/parquet-go"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ChatMessage represents a single message in a chat conversation, used for
|
|
||||||
// reading JSONL training data during Parquet export and data import.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// msg := store.ChatMessage{Role: "user", Content: "What is sovereignty?"}
|
|
||||||
type ChatMessage struct {
|
|
||||||
// Role is the message author role (e.g. "user", "assistant", "system").
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// msg.Role // "user"
|
|
||||||
Role string `json:"role"`
|
|
||||||
|
|
||||||
// Content is the message text.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// msg.Content // "What is sovereignty?"
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParquetRow is the schema for exported Parquet files.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row := store.ParquetRow{Prompt: "What is sovereignty?", Response: "...", System: "You are LEM."}
|
|
||||||
type ParquetRow struct {
|
|
||||||
// Prompt is the user prompt text.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Prompt // "What is sovereignty?"
|
|
||||||
Prompt string `parquet:"prompt"`
|
|
||||||
|
|
||||||
// Response is the assistant response text.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Response // "Sovereignty is..."
|
|
||||||
Response string `parquet:"response"`
|
|
||||||
|
|
||||||
// System is the system prompt text.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.System // "You are LEM."
|
|
||||||
System string `parquet:"system"`
|
|
||||||
|
|
||||||
// Messages is the JSON-encoded full conversation messages.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// row.Messages // `[{"role":"user","content":"..."}]`
|
|
||||||
Messages string `parquet:"messages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportParquet reads JSONL training splits (train.jsonl, valid.jsonl, test.jsonl)
|
|
||||||
// from trainingDir and writes Parquet files with snappy compression to outputDir.
|
|
||||||
// Returns total rows exported.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// total, err := store.ExportParquet("/Volumes/Data/lem/training", "/Volumes/Data/lem/parquet")
|
|
||||||
func ExportParquet(trainingDir, outputDir string) (int, error) {
|
|
||||||
if outputDir == "" {
|
|
||||||
outputDir = core.JoinPath(trainingDir, "parquet")
|
|
||||||
}
|
|
||||||
if r := localFs.EnsureDir(outputDir); !r.OK {
|
|
||||||
return 0, core.E("store.ExportParquet", "create output directory", r.Value.(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
total := 0
|
|
||||||
for _, split := range []string{"train", "valid", "test"} {
|
|
||||||
jsonlPath := core.JoinPath(trainingDir, split+".jsonl")
|
|
||||||
if !localFs.IsFile(jsonlPath) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
n, err := ExportSplitParquet(jsonlPath, outputDir, split)
|
|
||||||
if err != nil {
|
|
||||||
return total, core.E("store.ExportParquet", core.Sprintf("export %s", split), err)
|
|
||||||
}
|
|
||||||
total += n
|
|
||||||
}
|
|
||||||
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportSplitParquet reads a chat JSONL file and writes a Parquet file for the
|
|
||||||
// given split name. Returns the number of rows written.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// n, err := store.ExportSplitParquet("/data/train.jsonl", "/data/parquet", "train")
|
|
||||||
func ExportSplitParquet(jsonlPath, outputDir, split string) (int, error) {
|
|
||||||
openResult := localFs.Open(jsonlPath)
|
|
||||||
if !openResult.OK {
|
|
||||||
return 0, core.E("store.ExportSplitParquet", core.Sprintf("open %s", jsonlPath), openResult.Value.(error))
|
|
||||||
}
|
|
||||||
f := openResult.Value.(io.ReadCloser)
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
var rows []ParquetRow
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
text := core.Trim(scanner.Text())
|
|
||||||
if text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var data struct {
|
|
||||||
Messages []ChatMessage `json:"messages"`
|
|
||||||
}
|
|
||||||
if r := core.JSONUnmarshal([]byte(text), &data); !r.OK {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var prompt, response, system string
|
|
||||||
for _, m := range data.Messages {
|
|
||||||
switch m.Role {
|
|
||||||
case "user":
|
|
||||||
if prompt == "" {
|
|
||||||
prompt = m.Content
|
|
||||||
}
|
|
||||||
case "assistant":
|
|
||||||
if response == "" {
|
|
||||||
response = m.Content
|
|
||||||
}
|
|
||||||
case "system":
|
|
||||||
if system == "" {
|
|
||||||
system = m.Content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgsJSON := core.JSONMarshalString(data.Messages)
|
|
||||||
rows = append(rows, ParquetRow{
|
|
||||||
Prompt: prompt,
|
|
||||||
Response: response,
|
|
||||||
System: system,
|
|
||||||
Messages: msgsJSON,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return 0, core.E("store.ExportSplitParquet", core.Sprintf("scan %s", jsonlPath), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
outPath := core.JoinPath(outputDir, split+".parquet")
|
|
||||||
|
|
||||||
createResult := localFs.Create(outPath)
|
|
||||||
if !createResult.OK {
|
|
||||||
return 0, core.E("store.ExportSplitParquet", core.Sprintf("create %s", outPath), createResult.Value.(error))
|
|
||||||
}
|
|
||||||
out := createResult.Value.(io.WriteCloser)
|
|
||||||
|
|
||||||
writer := parquet.NewGenericWriter[ParquetRow](out,
|
|
||||||
parquet.Compression(&parquet.Snappy),
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := writer.Write(rows); err != nil {
|
|
||||||
out.Close()
|
|
||||||
return 0, core.E("store.ExportSplitParquet", "write parquet rows", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writer.Close(); err != nil {
|
|
||||||
out.Close()
|
|
||||||
return 0, core.E("store.ExportSplitParquet", "close parquet writer", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := out.Close(); err != nil {
|
|
||||||
return 0, core.E("store.ExportSplitParquet", "close file", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(rows), nil
|
|
||||||
}
|
|
||||||
13
path_test.go
13
path_test.go
|
|
@ -1,13 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPath_Normalise_Good_TrailingSlashes(t *testing.T) {
|
|
||||||
assert.Equal(t, ".core/state/scroll-session.duckdb", workspaceFilePath(".core/state/", "scroll-session"))
|
|
||||||
assert.Equal(t, ".core/archive/journal-20260404-010203.jsonl.gz", joinPath(".core/archive/", "journal-20260404-010203.jsonl.gz"))
|
|
||||||
assert.Equal(t, ".core/archive", normaliseDirectoryPath(".core/archive///"))
|
|
||||||
}
|
|
||||||
196
publish.go
196
publish.go
|
|
@ -1,196 +0,0 @@
|
||||||
// SPDX-License-Identifier: EUPL-1.2
|
|
||||||
|
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PublishConfig holds options for the publish operation.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg := store.PublishConfig{InputDir: "/data/parquet", Repo: "snider/lem-training", Public: true}
|
|
||||||
type PublishConfig struct {
|
|
||||||
// InputDir is the directory containing Parquet files to upload.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.InputDir // "/data/parquet"
|
|
||||||
InputDir string
|
|
||||||
|
|
||||||
// Repo is the HuggingFace dataset repository (e.g. "user/dataset").
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.Repo // "snider/lem-training"
|
|
||||||
Repo string
|
|
||||||
|
|
||||||
// Public sets the dataset visibility to public when true.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.Public // true
|
|
||||||
Public bool
|
|
||||||
|
|
||||||
// Token is the HuggingFace API token. Falls back to HF_TOKEN env or ~/.huggingface/token.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.Token // "hf_..."
|
|
||||||
Token string
|
|
||||||
|
|
||||||
// DryRun lists files that would be uploaded without actually uploading.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// cfg.DryRun // true
|
|
||||||
DryRun bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadEntry pairs a local file path with its remote destination.
|
|
||||||
type uploadEntry struct {
|
|
||||||
local string
|
|
||||||
remote string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish uploads Parquet files to HuggingFace Hub.
|
|
||||||
//
|
|
||||||
// It looks for train.parquet, valid.parquet, and test.parquet in InputDir,
|
|
||||||
// plus an optional dataset_card.md in the parent directory (uploaded as README.md).
|
|
||||||
// The token is resolved from PublishConfig.Token, the HF_TOKEN environment variable,
|
|
||||||
// or ~/.huggingface/token, in that order.
|
|
||||||
//
|
|
||||||
// Usage example:
|
|
||||||
//
|
|
||||||
// err := store.Publish(store.PublishConfig{InputDir: "/data/parquet", Repo: "snider/lem-training"}, os.Stdout)
|
|
||||||
func Publish(cfg PublishConfig, w io.Writer) error {
|
|
||||||
if cfg.InputDir == "" {
|
|
||||||
return core.E("store.Publish", "input directory is required", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
token := resolveHFToken(cfg.Token)
|
|
||||||
if token == "" && !cfg.DryRun {
|
|
||||||
return core.E("store.Publish", "HuggingFace token required (--token, HF_TOKEN env, or ~/.huggingface/token)", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := collectUploadFiles(cfg.InputDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(files) == 0 {
|
|
||||||
return core.E("store.Publish", core.Sprintf("no Parquet files found in %s", cfg.InputDir), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.DryRun {
|
|
||||||
core.Print(w, "Dry run: would publish to %s", cfg.Repo)
|
|
||||||
if cfg.Public {
|
|
||||||
core.Print(w, " Visibility: public")
|
|
||||||
} else {
|
|
||||||
core.Print(w, " Visibility: private")
|
|
||||||
}
|
|
||||||
for _, f := range files {
|
|
||||||
statResult := localFs.Stat(f.local)
|
|
||||||
if !statResult.OK {
|
|
||||||
return core.E("store.Publish", core.Sprintf("stat %s", f.local), statResult.Value.(error))
|
|
||||||
}
|
|
||||||
info := statResult.Value.(fs.FileInfo)
|
|
||||||
sizeMB := float64(info.Size()) / 1024 / 1024
|
|
||||||
core.Print(w, " %s -> %s (%.1f MB)", core.PathBase(f.local), f.remote, sizeMB)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
core.Print(w, "Publishing to https://huggingface.co/datasets/%s", cfg.Repo)
|
|
||||||
|
|
||||||
for _, f := range files {
|
|
||||||
if err := uploadFileToHF(token, cfg.Repo, f.local, f.remote); err != nil {
|
|
||||||
return core.E("store.Publish", core.Sprintf("upload %s", core.PathBase(f.local)), err)
|
|
||||||
}
|
|
||||||
core.Print(w, " Uploaded %s -> %s", core.PathBase(f.local), f.remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
core.Print(w, "\nPublished to https://huggingface.co/datasets/%s", cfg.Repo)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveHFToken returns a HuggingFace API token from the given value,
|
|
||||||
// HF_TOKEN env var, or ~/.huggingface/token file.
|
|
||||||
func resolveHFToken(explicit string) string {
|
|
||||||
if explicit != "" {
|
|
||||||
return explicit
|
|
||||||
}
|
|
||||||
if env := core.Env("HF_TOKEN"); env != "" {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
home := core.Env("DIR_HOME")
|
|
||||||
if home == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
r := localFs.Read(core.JoinPath(home, ".huggingface", "token"))
|
|
||||||
if !r.OK {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return core.Trim(r.Value.(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
// collectUploadFiles finds Parquet split files and an optional dataset card.
|
|
||||||
func collectUploadFiles(inputDir string) ([]uploadEntry, error) {
|
|
||||||
splits := []string{"train", "valid", "test"}
|
|
||||||
var files []uploadEntry
|
|
||||||
|
|
||||||
for _, split := range splits {
|
|
||||||
path := core.JoinPath(inputDir, split+".parquet")
|
|
||||||
if !isFile(path) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
files = append(files, uploadEntry{path, core.Sprintf("data/%s.parquet", split)})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for dataset card in parent directory.
|
|
||||||
cardPath := core.JoinPath(inputDir, "..", "dataset_card.md")
|
|
||||||
if isFile(cardPath) {
|
|
||||||
files = append(files, uploadEntry{cardPath, "README.md"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return files, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadFileToHF uploads a single file to a HuggingFace dataset repo via the
|
|
||||||
// Hub API.
|
|
||||||
func uploadFileToHF(token, repoID, localPath, remotePath string) error {
|
|
||||||
readResult := localFs.Read(localPath)
|
|
||||||
if !readResult.OK {
|
|
||||||
return core.E("store.uploadFileToHF", core.Sprintf("read %s", localPath), readResult.Value.(error))
|
|
||||||
}
|
|
||||||
raw := []byte(readResult.Value.(string))
|
|
||||||
|
|
||||||
url := core.Sprintf("https://huggingface.co/api/datasets/%s/upload/main/%s", repoID, remotePath)
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(raw))
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.uploadFileToHF", "create request", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 120 * time.Second}
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.uploadFileToHF", "upload request", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(resp.Body)
|
|
||||||
return core.E("store.uploadFileToHF", core.Sprintf("upload failed: HTTP %d: %s", resp.StatusCode, string(body)), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
1379
scope_test.go
1379
scope_test.go
File diff suppressed because it is too large
Load diff
1728
store_test.go
1728
store_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,80 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testFilesystem() *core.Fs {
|
|
||||||
return (&core.Fs{}).NewUnrestricted()
|
|
||||||
}
|
|
||||||
|
|
||||||
func testPath(tb testing.TB, name string) string {
|
|
||||||
tb.Helper()
|
|
||||||
return core.Path(tb.TempDir(), name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireCoreOK(tb testing.TB, result core.Result) {
|
|
||||||
tb.Helper()
|
|
||||||
require.True(tb, result.OK, "core result failed: %v", result.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireCoreReadBytes(tb testing.TB, path string) []byte {
|
|
||||||
tb.Helper()
|
|
||||||
result := testFilesystem().Read(path)
|
|
||||||
requireCoreOK(tb, result)
|
|
||||||
return []byte(result.Value.(string))
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireCoreWriteBytes(tb testing.TB, path string, data []byte) {
|
|
||||||
tb.Helper()
|
|
||||||
requireCoreOK(tb, testFilesystem().Write(path, string(data)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func repeatString(value string, count int) string {
|
|
||||||
if count <= 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
for range count {
|
|
||||||
builder.WriteString(value)
|
|
||||||
}
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func useWorkspaceStateDirectory(tb testing.TB) string {
|
|
||||||
tb.Helper()
|
|
||||||
|
|
||||||
previous := defaultWorkspaceStateDirectory
|
|
||||||
stateDirectory := testPath(tb, "state")
|
|
||||||
defaultWorkspaceStateDirectory = stateDirectory
|
|
||||||
tb.Cleanup(func() {
|
|
||||||
defaultWorkspaceStateDirectory = previous
|
|
||||||
_ = testFilesystem().DeleteAll(stateDirectory)
|
|
||||||
})
|
|
||||||
return stateDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
func useArchiveOutputDirectory(tb testing.TB) string {
|
|
||||||
tb.Helper()
|
|
||||||
|
|
||||||
previous := defaultArchiveOutputDirectory
|
|
||||||
outputDirectory := testPath(tb, "archive")
|
|
||||||
defaultArchiveOutputDirectory = outputDirectory
|
|
||||||
tb.Cleanup(func() {
|
|
||||||
defaultArchiveOutputDirectory = previous
|
|
||||||
_ = testFilesystem().DeleteAll(outputDirectory)
|
|
||||||
})
|
|
||||||
return outputDirectory
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireResultRows(tb testing.TB, result core.Result) []map[string]any {
|
|
||||||
tb.Helper()
|
|
||||||
|
|
||||||
require.True(tb, result.OK, "core result failed: %v", result.Value)
|
|
||||||
rows, ok := result.Value.([]map[string]any)
|
|
||||||
require.True(tb, ok, "unexpected row type: %T", result.Value)
|
|
||||||
return rows
|
|
||||||
}
|
|
||||||
535
transaction.go
535
transaction.go
|
|
@ -1,535 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"iter"
|
|
||||||
"text/template"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Usage example: `err := storeInstance.Transaction(func(transaction *store.StoreTransaction) error { return transaction.Set("config", "colour", "blue") })`
|
|
||||||
// Usage example: `if err := transaction.Delete("config", "colour"); err != nil { return err }`
|
|
||||||
type StoreTransaction struct {
|
|
||||||
storeInstance *Store
|
|
||||||
sqliteTransaction *sql.Tx
|
|
||||||
pendingEvents []Event
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `err := storeInstance.Transaction(func(transaction *store.StoreTransaction) error { if err := transaction.Set("tenant-a:config", "colour", "blue"); err != nil { return err }; return transaction.Set("tenant-b:config", "language", "en-GB") })`
|
|
||||||
func (storeInstance *Store) Transaction(operation func(*StoreTransaction) error) error {
|
|
||||||
if err := storeInstance.ensureReady("store.Transaction"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if operation == nil {
|
|
||||||
return core.E("store.Transaction", "operation is nil", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction, err := storeInstance.sqliteDatabase.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Transaction", "begin transaction", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
storeTransaction := &StoreTransaction{
|
|
||||||
storeInstance: storeInstance,
|
|
||||||
sqliteTransaction: transaction,
|
|
||||||
}
|
|
||||||
|
|
||||||
committed := false
|
|
||||||
defer func() {
|
|
||||||
if !committed {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if err := operation(storeTransaction); err != nil {
|
|
||||||
return core.E("store.Transaction", "execute transaction", err)
|
|
||||||
}
|
|
||||||
if err := transaction.Commit(); err != nil {
|
|
||||||
return core.E("store.Transaction", "commit transaction", err)
|
|
||||||
}
|
|
||||||
committed = true
|
|
||||||
|
|
||||||
for _, event := range storeTransaction.pendingEvents {
|
|
||||||
storeInstance.notify(event)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (storeTransaction *StoreTransaction) ensureReady(operation string) error {
|
|
||||||
if storeTransaction == nil {
|
|
||||||
return core.E(operation, "transaction is nil", nil)
|
|
||||||
}
|
|
||||||
if storeTransaction.storeInstance == nil {
|
|
||||||
return core.E(operation, "transaction store is nil", nil)
|
|
||||||
}
|
|
||||||
if storeTransaction.sqliteTransaction == nil {
|
|
||||||
return core.E(operation, "transaction database is nil", nil)
|
|
||||||
}
|
|
||||||
if err := storeTransaction.storeInstance.ensureReady(operation); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (storeTransaction *StoreTransaction) recordEvent(event Event) {
|
|
||||||
if storeTransaction == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
storeTransaction.pendingEvents = append(storeTransaction.pendingEvents, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `exists, err := transaction.Exists("config", "colour")`
|
|
||||||
// Usage example: `if exists, _ := transaction.Exists("session", "token"); !exists { return core.E("auth", "session expired", nil) }`
|
|
||||||
func (storeTransaction *StoreTransaction) Exists(group, key string) (bool, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Exists"); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return liveEntryExists(storeTransaction.sqliteTransaction, group, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `exists, err := transaction.GroupExists("config")`
|
|
||||||
func (storeTransaction *StoreTransaction) GroupExists(group string) (bool, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.GroupExists"); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
count, err := storeTransaction.Count(group)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return count > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `value, err := transaction.Get("config", "colour")`
|
|
||||||
func (storeTransaction *StoreTransaction) Get(group, key string) (string, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Get"); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
var value string
|
|
||||||
var expiresAt sql.NullInt64
|
|
||||||
err := storeTransaction.sqliteTransaction.QueryRow(
|
|
||||||
"SELECT "+entryValueColumn+", expires_at FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND "+entryKeyColumn+" = ?",
|
|
||||||
group, key,
|
|
||||||
).Scan(&value, &expiresAt)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return "", core.E("store.Transaction.Get", core.Concat(group, "/", key), NotFoundError)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return "", core.E("store.Transaction.Get", "query row", err)
|
|
||||||
}
|
|
||||||
if expiresAt.Valid && expiresAt.Int64 <= time.Now().UnixMilli() {
|
|
||||||
if err := storeTransaction.Delete(group, key); err != nil {
|
|
||||||
return "", core.E("store.Transaction.Get", "delete expired row", err)
|
|
||||||
}
|
|
||||||
return "", core.E("store.Transaction.Get", core.Concat(group, "/", key), NotFoundError)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := transaction.Set("config", "colour", "blue"); err != nil { return err }`
|
|
||||||
func (storeTransaction *StoreTransaction) Set(group, key, value string) error {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Set"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := storeTransaction.sqliteTransaction.Exec(
|
|
||||||
"INSERT INTO "+entriesTableName+" ("+entryGroupColumn+", "+entryKeyColumn+", "+entryValueColumn+", expires_at) VALUES (?, ?, ?, NULL) "+
|
|
||||||
"ON CONFLICT("+entryGroupColumn+", "+entryKeyColumn+") DO UPDATE SET "+entryValueColumn+" = excluded."+entryValueColumn+", expires_at = NULL",
|
|
||||||
group, key, value,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Transaction.Set", "execute upsert", err)
|
|
||||||
}
|
|
||||||
storeTransaction.recordEvent(Event{Type: EventSet, Group: group, Key: key, Value: value, Timestamp: time.Now()})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := transaction.SetWithTTL("session", "token", "abc123", time.Minute); err != nil { return err }`
|
|
||||||
func (storeTransaction *StoreTransaction) SetWithTTL(group, key, value string, timeToLive time.Duration) error {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.SetWithTTL"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
expiresAt := time.Now().Add(timeToLive).UnixMilli()
|
|
||||||
_, err := storeTransaction.sqliteTransaction.Exec(
|
|
||||||
"INSERT INTO "+entriesTableName+" ("+entryGroupColumn+", "+entryKeyColumn+", "+entryValueColumn+", expires_at) VALUES (?, ?, ?, ?) "+
|
|
||||||
"ON CONFLICT("+entryGroupColumn+", "+entryKeyColumn+") DO UPDATE SET "+entryValueColumn+" = excluded."+entryValueColumn+", expires_at = excluded.expires_at",
|
|
||||||
group, key, value, expiresAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Transaction.SetWithTTL", "execute upsert with expiry", err)
|
|
||||||
}
|
|
||||||
storeTransaction.recordEvent(Event{Type: EventSet, Group: group, Key: key, Value: value, Timestamp: time.Now()})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := transaction.Delete("config", "colour"); err != nil { return err }`
|
|
||||||
func (storeTransaction *StoreTransaction) Delete(group, key string) error {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Delete"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteResult, err := storeTransaction.sqliteTransaction.Exec(
|
|
||||||
"DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND "+entryKeyColumn+" = ?",
|
|
||||||
group, key,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Transaction.Delete", "delete row", err)
|
|
||||||
}
|
|
||||||
deletedRows, rowsAffectedError := deleteResult.RowsAffected()
|
|
||||||
if rowsAffectedError != nil {
|
|
||||||
return core.E("store.Transaction.Delete", "count deleted rows", rowsAffectedError)
|
|
||||||
}
|
|
||||||
if deletedRows > 0 {
|
|
||||||
storeTransaction.recordEvent(Event{Type: EventDelete, Group: group, Key: key, Timestamp: time.Now()})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := transaction.DeleteGroup("cache"); err != nil { return err }`
|
|
||||||
func (storeTransaction *StoreTransaction) DeleteGroup(group string) error {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.DeleteGroup"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteResult, err := storeTransaction.sqliteTransaction.Exec(
|
|
||||||
"DELETE FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ?",
|
|
||||||
group,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Transaction.DeleteGroup", "delete group", err)
|
|
||||||
}
|
|
||||||
deletedRows, rowsAffectedError := deleteResult.RowsAffected()
|
|
||||||
if rowsAffectedError != nil {
|
|
||||||
return core.E("store.Transaction.DeleteGroup", "count deleted rows", rowsAffectedError)
|
|
||||||
}
|
|
||||||
if deletedRows > 0 {
|
|
||||||
storeTransaction.recordEvent(Event{Type: EventDeleteGroup, Group: group, Timestamp: time.Now()})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := transaction.DeletePrefix("tenant-a:"); err != nil { return err }`
|
|
||||||
func (storeTransaction *StoreTransaction) DeletePrefix(groupPrefix string) error {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.DeletePrefix"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
if groupPrefix == "" {
|
|
||||||
rows, err = storeTransaction.sqliteTransaction.Query(
|
|
||||||
"SELECT DISTINCT " + entryGroupColumn + " FROM " + entriesTableName + " ORDER BY " + entryGroupColumn,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
rows, err = storeTransaction.sqliteTransaction.Query(
|
|
||||||
"SELECT DISTINCT "+entryGroupColumn+" FROM "+entriesTableName+" WHERE "+entryGroupColumn+" LIKE ? ESCAPE '^' ORDER BY "+entryGroupColumn,
|
|
||||||
escapeLike(groupPrefix)+"%",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Transaction.DeletePrefix", "list groups", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var groupNames []string
|
|
||||||
for rows.Next() {
|
|
||||||
var groupName string
|
|
||||||
if err := rows.Scan(&groupName); err != nil {
|
|
||||||
return core.E("store.Transaction.DeletePrefix", "scan group name", err)
|
|
||||||
}
|
|
||||||
groupNames = append(groupNames, groupName)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return core.E("store.Transaction.DeletePrefix", "iterate groups", err)
|
|
||||||
}
|
|
||||||
for _, groupName := range groupNames {
|
|
||||||
if err := storeTransaction.DeleteGroup(groupName); err != nil {
|
|
||||||
return core.E("store.Transaction.DeletePrefix", "delete group", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `keyCount, err := transaction.Count("config")`
|
|
||||||
func (storeTransaction *StoreTransaction) Count(group string) (int, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Count"); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
err := storeTransaction.sqliteTransaction.QueryRow(
|
|
||||||
"SELECT COUNT(*) FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND (expires_at IS NULL OR expires_at > ?)",
|
|
||||||
group, time.Now().UnixMilli(),
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, core.E("store.Transaction.Count", "count rows", err)
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `colourEntries, err := transaction.GetAll("config")`
|
|
||||||
func (storeTransaction *StoreTransaction) GetAll(group string) (map[string]string, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.GetAll"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entriesByKey := make(map[string]string)
|
|
||||||
for entry, err := range storeTransaction.All(group) {
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.Transaction.GetAll", "iterate rows", err)
|
|
||||||
}
|
|
||||||
entriesByKey[entry.Key] = entry.Value
|
|
||||||
}
|
|
||||||
return entriesByKey, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `page, err := transaction.GetPage("config", 0, 25); if err != nil { return }; for _, entry := range page { fmt.Println(entry.Key, entry.Value) }`
|
|
||||||
func (storeTransaction *StoreTransaction) GetPage(group string, offset, limit int) ([]KeyValue, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.GetPage"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if offset < 0 {
|
|
||||||
return nil, core.E("store.Transaction.GetPage", "offset must be zero or positive", nil)
|
|
||||||
}
|
|
||||||
if limit < 0 {
|
|
||||||
return nil, core.E("store.Transaction.GetPage", "limit must be zero or positive", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := storeTransaction.sqliteTransaction.Query(
|
|
||||||
"SELECT "+entryKeyColumn+", "+entryValueColumn+" FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY "+entryKeyColumn+" LIMIT ? OFFSET ?",
|
|
||||||
group, time.Now().UnixMilli(), limit, offset,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.Transaction.GetPage", "query rows", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
page := make([]KeyValue, 0, limit)
|
|
||||||
for rows.Next() {
|
|
||||||
var entry KeyValue
|
|
||||||
if err := rows.Scan(&entry.Key, &entry.Value); err != nil {
|
|
||||||
return nil, core.E("store.Transaction.GetPage", "scan row", err)
|
|
||||||
}
|
|
||||||
page = append(page, entry)
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, core.E("store.Transaction.GetPage", "rows iteration", err)
|
|
||||||
}
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `for entry, err := range transaction.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }`
|
|
||||||
func (storeTransaction *StoreTransaction) All(group string) iter.Seq2[KeyValue, error] {
|
|
||||||
return storeTransaction.AllSeq(group)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `for entry, err := range transaction.AllSeq("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }`
|
|
||||||
func (storeTransaction *StoreTransaction) AllSeq(group string) iter.Seq2[KeyValue, error] {
|
|
||||||
return func(yield func(KeyValue, error) bool) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.All"); err != nil {
|
|
||||||
yield(KeyValue{}, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := storeTransaction.sqliteTransaction.Query(
|
|
||||||
"SELECT "+entryKeyColumn+", "+entryValueColumn+" FROM "+entriesTableName+" WHERE "+entryGroupColumn+" = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY "+entryKeyColumn,
|
|
||||||
group, time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
yield(KeyValue{}, core.E("store.Transaction.All", "query rows", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var entry KeyValue
|
|
||||||
if err := rows.Scan(&entry.Key, &entry.Value); err != nil {
|
|
||||||
if !yield(KeyValue{}, core.E("store.Transaction.All", "scan row", err)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !yield(entry, nil) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
yield(KeyValue{}, core.E("store.Transaction.All", "rows iteration", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `removedRows, err := transaction.CountAll("tenant-a:")`
|
|
||||||
func (storeTransaction *StoreTransaction) CountAll(groupPrefix string) (int, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.CountAll"); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
var err error
|
|
||||||
if groupPrefix == "" {
|
|
||||||
err = storeTransaction.sqliteTransaction.QueryRow(
|
|
||||||
"SELECT COUNT(*) FROM "+entriesTableName+" WHERE (expires_at IS NULL OR expires_at > ?)",
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
).Scan(&count)
|
|
||||||
} else {
|
|
||||||
err = storeTransaction.sqliteTransaction.QueryRow(
|
|
||||||
"SELECT COUNT(*) FROM "+entriesTableName+" WHERE "+entryGroupColumn+" LIKE ? ESCAPE '^' AND (expires_at IS NULL OR expires_at > ?)",
|
|
||||||
escapeLike(groupPrefix)+"%", time.Now().UnixMilli(),
|
|
||||||
).Scan(&count)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, core.E("store.Transaction.CountAll", "count rows", err)
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `groupNames, err := transaction.Groups("tenant-a:")`
|
|
||||||
// Usage example: `groupNames, err := transaction.Groups()`
|
|
||||||
func (storeTransaction *StoreTransaction) Groups(groupPrefix ...string) ([]string, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Groups"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var groupNames []string
|
|
||||||
for groupName, err := range storeTransaction.GroupsSeq(groupPrefix...) {
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
groupNames = append(groupNames, groupName)
|
|
||||||
}
|
|
||||||
return groupNames, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `for groupName, err := range transaction.GroupsSeq("tenant-a:") { if err != nil { break }; fmt.Println(groupName) }`
|
|
||||||
// Usage example: `for groupName, err := range transaction.GroupsSeq() { if err != nil { break }; fmt.Println(groupName) }`
|
|
||||||
func (storeTransaction *StoreTransaction) GroupsSeq(groupPrefix ...string) iter.Seq2[string, error] {
|
|
||||||
actualGroupPrefix := firstStringOrEmpty(groupPrefix)
|
|
||||||
return func(yield func(string, error) bool) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.GroupsSeq"); err != nil {
|
|
||||||
yield("", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows *sql.Rows
|
|
||||||
var err error
|
|
||||||
now := time.Now().UnixMilli()
|
|
||||||
if actualGroupPrefix == "" {
|
|
||||||
rows, err = storeTransaction.sqliteTransaction.Query(
|
|
||||||
"SELECT DISTINCT "+entryGroupColumn+" FROM "+entriesTableName+" WHERE (expires_at IS NULL OR expires_at > ?) ORDER BY "+entryGroupColumn,
|
|
||||||
now,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
rows, err = storeTransaction.sqliteTransaction.Query(
|
|
||||||
"SELECT DISTINCT "+entryGroupColumn+" FROM "+entriesTableName+" WHERE "+entryGroupColumn+" LIKE ? ESCAPE '^' AND (expires_at IS NULL OR expires_at > ?) ORDER BY "+entryGroupColumn,
|
|
||||||
escapeLike(actualGroupPrefix)+"%", now,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
yield("", core.E("store.Transaction.GroupsSeq", "query group names", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var groupName string
|
|
||||||
if err := rows.Scan(&groupName); err != nil {
|
|
||||||
if !yield("", core.E("store.Transaction.GroupsSeq", "scan group name", err)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !yield(groupName, nil) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
yield("", core.E("store.Transaction.GroupsSeq", "rows iteration", err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `renderedTemplate, err := transaction.Render("Hello {{ .name }}", "user")`
|
|
||||||
func (storeTransaction *StoreTransaction) Render(templateSource, group string) (string, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.Render"); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
templateData := make(map[string]string)
|
|
||||||
for entry, err := range storeTransaction.All(group) {
|
|
||||||
if err != nil {
|
|
||||||
return "", core.E("store.Transaction.Render", "iterate rows", err)
|
|
||||||
}
|
|
||||||
templateData[entry.Key] = entry.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTemplate, err := template.New("render").Parse(templateSource)
|
|
||||||
if err != nil {
|
|
||||||
return "", core.E("store.Transaction.Render", "parse template", err)
|
|
||||||
}
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
if err := renderTemplate.Execute(builder, templateData); err != nil {
|
|
||||||
return "", core.E("store.Transaction.Render", "execute template", err)
|
|
||||||
}
|
|
||||||
return builder.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `parts, err := transaction.GetSplit("config", "hosts", ","); if err != nil { return }; for part := range parts { fmt.Println(part) }`
|
|
||||||
func (storeTransaction *StoreTransaction) GetSplit(group, key, separator string) (iter.Seq[string], error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.GetSplit"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := storeTransaction.Get(group, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return splitValueSeq(value, separator), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `fields, err := transaction.GetFields("config", "flags"); if err != nil { return }; for field := range fields { fmt.Println(field) }`
|
|
||||||
func (storeTransaction *StoreTransaction) GetFields(group, key string) (iter.Seq[string], error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.GetFields"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := storeTransaction.Get(group, key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return fieldsValueSeq(value), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `removedRows, err := transaction.PurgeExpired(); if err != nil { return err }; fmt.Println(removedRows)`
|
|
||||||
func (storeTransaction *StoreTransaction) PurgeExpired() (int64, error) {
|
|
||||||
if err := storeTransaction.ensureReady("store.Transaction.PurgeExpired"); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoffUnixMilli := time.Now().UnixMilli()
|
|
||||||
expiredEntries, err := listExpiredEntriesMatchingGroupPrefix(storeTransaction.sqliteTransaction, "", cutoffUnixMilli)
|
|
||||||
if err != nil {
|
|
||||||
return 0, core.E("store.Transaction.PurgeExpired", "list expired rows", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
removedRows, err := purgeExpiredMatchingGroupPrefix(storeTransaction.sqliteTransaction, "", cutoffUnixMilli)
|
|
||||||
if err != nil {
|
|
||||||
return 0, core.E("store.Transaction.PurgeExpired", "delete expired rows", err)
|
|
||||||
}
|
|
||||||
if removedRows > 0 {
|
|
||||||
for _, expiredEntry := range expiredEntries {
|
|
||||||
storeTransaction.recordEvent(Event{
|
|
||||||
Type: EventDelete,
|
|
||||||
Group: expiredEntry.group,
|
|
||||||
Key: expiredEntry.key,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return removedRows, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,408 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"iter"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_CommitsMultipleWrites(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
events := storeInstance.Watch("*")
|
|
||||||
defer storeInstance.Unwatch("*", events)
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
if err := transaction.Set("alpha", "first", "1"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.Set("beta", "second", "2"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
firstValue, err := storeInstance.Get("alpha", "first")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "1", firstValue)
|
|
||||||
|
|
||||||
secondValue, err := storeInstance.Get("beta", "second")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "2", secondValue)
|
|
||||||
|
|
||||||
received := drainEvents(events, 2, time.Second)
|
|
||||||
require.Len(t, received, 2)
|
|
||||||
assert.Equal(t, EventSet, received[0].Type)
|
|
||||||
assert.Equal(t, "alpha", received[0].Group)
|
|
||||||
assert.Equal(t, "first", received[0].Key)
|
|
||||||
assert.Equal(t, EventSet, received[1].Type)
|
|
||||||
assert.Equal(t, "beta", received[1].Group)
|
|
||||||
assert.Equal(t, "second", received[1].Key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_RollbackOnError(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
if err := transaction.Set("alpha", "first", "1"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return core.E("test", "force rollback", nil)
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
_, err = storeInstance.Get("alpha", "first")
|
|
||||||
assert.ErrorIs(t, err, NotFoundError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_DeletesAtomically(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("alpha", "first", "1"))
|
|
||||||
require.NoError(t, storeInstance.Set("beta", "second", "2"))
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
if err := transaction.DeletePrefix(""); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = storeInstance.Get("alpha", "first")
|
|
||||||
assert.ErrorIs(t, err, NotFoundError)
|
|
||||||
_, err = storeInstance.Get("beta", "second")
|
|
||||||
assert.ErrorIs(t, err, NotFoundError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_ReadHelpersSeePendingWrites(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
if err := transaction.Set("config", "colour", "blue"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.Set("config", "hosts", "alpha beta"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.Set("audit", "enabled", "true"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
entriesByKey, err := transaction.GetAll("config")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, map[string]string{"colour": "blue", "hosts": "alpha beta"}, entriesByKey)
|
|
||||||
|
|
||||||
count, err := transaction.CountAll("")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 3, count)
|
|
||||||
|
|
||||||
groupNames, err := transaction.Groups()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"audit", "config"}, groupNames)
|
|
||||||
|
|
||||||
renderedTemplate, err := transaction.Render("{{ .colour }} / {{ .hosts }}", "config")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "blue / alpha beta", renderedTemplate)
|
|
||||||
|
|
||||||
splitParts, err := transaction.GetSplit("config", "hosts", " ")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"alpha", "beta"}, collectSeq(t, splitParts))
|
|
||||||
|
|
||||||
fieldParts, err := transaction.GetFields("config", "hosts")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"alpha", "beta"}, collectSeq(t, fieldParts))
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_PurgeExpired(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.SetWithTTL("alpha", "ephemeral", "gone", 1*time.Millisecond))
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
removedRows, err := transaction.PurgeExpired()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), removedRows)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = storeInstance.Get("alpha", "ephemeral")
|
|
||||||
assert.ErrorIs(t, err, NotFoundError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_Exists(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Set("config", "colour", "blue"))
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
exists, err := transaction.Exists("config", "colour")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists)
|
|
||||||
|
|
||||||
exists, err = transaction.Exists("config", "missing")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_ExistsSeesPendingWrites(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
exists, err := transaction.Exists("config", "colour")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
if err := transaction.Set("config", "colour", "blue"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err = transaction.Exists("config", "colour")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_Transaction_Good_GroupExists(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
err := storeInstance.Transaction(func(transaction *StoreTransaction) error {
|
|
||||||
exists, err := transaction.GroupExists("config")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
if err := transaction.Set("config", "colour", "blue"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err = transaction.GroupExists("config")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_ScopedStoreTransaction_Good_ExistsAndGroupExists(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
scopedStore := NewScoped(storeInstance, "tenant-a")
|
|
||||||
|
|
||||||
err := scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error {
|
|
||||||
exists, err := transaction.Exists("colour")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
if err := transaction.Set("colour", "blue"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err = transaction.Exists("colour")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, exists)
|
|
||||||
|
|
||||||
exists, err = transaction.ExistsIn("other", "colour")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, exists)
|
|
||||||
|
|
||||||
if err := transaction.SetIn("config", "theme", "dark"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
groupExists, err := transaction.GroupExists("config")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, groupExists)
|
|
||||||
|
|
||||||
groupExists, err = transaction.GroupExists("missing-group")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.False(t, groupExists)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_ScopedStoreTransaction_Good_GetPage(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
scopedStore := NewScoped(storeInstance, "tenant-a")
|
|
||||||
|
|
||||||
err := scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error {
|
|
||||||
if err := transaction.SetIn("items", "charlie", "3"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.SetIn("items", "alpha", "1"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.SetIn("items", "bravo", "2"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
page, err := transaction.GetPage("items", 1, 1)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, page, 1)
|
|
||||||
assert.Equal(t, KeyValue{Key: "bravo", Value: "2"}, page[0])
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_ScopedStoreTransaction_Good_CommitsNamespacedWrites(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
scopedStore, err := NewScopedConfigured(storeInstance, ScopedStoreConfig{
|
|
||||||
Namespace: "tenant-a",
|
|
||||||
Quota: QuotaConfig{MaxKeys: 4, MaxGroups: 2},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error {
|
|
||||||
if err := transaction.Set("theme", "dark"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := transaction.SetIn("preferences", "locale", "en-GB"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
themeValue, err := transaction.Get("theme")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "dark", themeValue)
|
|
||||||
|
|
||||||
localeValue, err := transaction.GetFrom("preferences", "locale")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "en-GB", localeValue)
|
|
||||||
|
|
||||||
groupNames, err := transaction.Groups()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []string{"default", "preferences"}, groupNames)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
themeValue, err := storeInstance.Get("tenant-a:default", "theme")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "dark", themeValue)
|
|
||||||
|
|
||||||
localeValue, err := storeInstance.Get("tenant-a:preferences", "locale")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "en-GB", localeValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_ScopedStoreTransaction_Good_PurgeExpired(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
scopedStore := NewScoped(storeInstance, "tenant-a")
|
|
||||||
|
|
||||||
require.NoError(t, scopedStore.SetWithTTL("session", "token", "abc123", 1*time.Millisecond))
|
|
||||||
time.Sleep(5 * time.Millisecond)
|
|
||||||
|
|
||||||
err := scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error {
|
|
||||||
removedRows, err := transaction.PurgeExpired()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), removedRows)
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, err = scopedStore.GetFrom("session", "token")
|
|
||||||
assert.ErrorIs(t, err, NotFoundError)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_ScopedStoreTransaction_Good_QuotaUsesPendingWrites(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
scopedStore, err := NewScopedConfigured(storeInstance, ScopedStoreConfig{
|
|
||||||
Namespace: "tenant-a",
|
|
||||||
Quota: QuotaConfig{MaxKeys: 2, MaxGroups: 2},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error {
|
|
||||||
require.NoError(t, transaction.SetIn("group-1", "first", "1"))
|
|
||||||
require.NoError(t, transaction.SetIn("group-2", "second", "2"))
|
|
||||||
|
|
||||||
err := transaction.SetIn("group-2", "third", "3")
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, core.Is(err, QuotaExceededError))
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.True(t, core.Is(err, QuotaExceededError))
|
|
||||||
|
|
||||||
_, getErr := storeInstance.Get("tenant-a:group-1", "first")
|
|
||||||
assert.True(t, core.Is(getErr, NotFoundError))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransaction_ScopedStoreTransaction_Good_DeletePrefix(t *testing.T) {
|
|
||||||
storeInstance, _ := New(":memory:")
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
scopedStore := NewScoped(storeInstance, "tenant-a")
|
|
||||||
otherScopedStore := NewScoped(storeInstance, "tenant-b")
|
|
||||||
|
|
||||||
require.NoError(t, scopedStore.SetIn("cache", "theme", "dark"))
|
|
||||||
require.NoError(t, scopedStore.SetIn("cache-warm", "status", "ready"))
|
|
||||||
require.NoError(t, scopedStore.SetIn("config", "colour", "blue"))
|
|
||||||
require.NoError(t, otherScopedStore.SetIn("cache", "theme", "keep"))
|
|
||||||
|
|
||||||
err := scopedStore.Transaction(func(transaction *ScopedStoreTransaction) error {
|
|
||||||
return transaction.DeletePrefix("cache")
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
_, getErr := scopedStore.GetFrom("cache", "theme")
|
|
||||||
assert.True(t, core.Is(getErr, NotFoundError))
|
|
||||||
_, getErr = scopedStore.GetFrom("cache-warm", "status")
|
|
||||||
assert.True(t, core.Is(getErr, NotFoundError))
|
|
||||||
|
|
||||||
colourValue, getErr := scopedStore.GetFrom("config", "colour")
|
|
||||||
require.NoError(t, getErr)
|
|
||||||
assert.Equal(t, "blue", colourValue)
|
|
||||||
|
|
||||||
otherValue, getErr := otherScopedStore.GetFrom("cache", "theme")
|
|
||||||
require.NoError(t, getErr)
|
|
||||||
assert.Equal(t, "keep", otherValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectSeq[T any](t *testing.T, sequence iter.Seq[T]) []T {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
values := make([]T, 0)
|
|
||||||
for value := range sequence {
|
|
||||||
values = append(values, value)
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
578
workspace.go
578
workspace.go
|
|
@ -1,578 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"io/fs"
|
|
||||||
"maps"
|
|
||||||
"slices"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
workspaceEntriesTableName = "workspace_entries"
|
|
||||||
workspaceSummaryGroupPrefix = "workspace"
|
|
||||||
)
|
|
||||||
|
|
||||||
const createWorkspaceEntriesTableSQL = `CREATE TABLE IF NOT EXISTS workspace_entries (
|
|
||||||
entry_id BIGINT PRIMARY KEY DEFAULT nextval('workspace_entries_entry_id_seq'),
|
|
||||||
entry_kind TEXT NOT NULL,
|
|
||||||
entry_data TEXT NOT NULL,
|
|
||||||
created_at BIGINT NOT NULL
|
|
||||||
)`
|
|
||||||
|
|
||||||
const createWorkspaceEntriesViewSQL = `CREATE VIEW IF NOT EXISTS entries AS
|
|
||||||
SELECT
|
|
||||||
entry_id AS id,
|
|
||||||
entry_kind AS kind,
|
|
||||||
entry_data AS data,
|
|
||||||
created_at
|
|
||||||
FROM workspace_entries`
|
|
||||||
|
|
||||||
var defaultWorkspaceStateDirectory = ".core/state/"
|
|
||||||
|
|
||||||
// Usage example: `workspace, err := storeInstance.NewWorkspace("scroll-session"); if err != nil { return }; defer workspace.Discard()`
|
|
||||||
// Usage example: `workspace, err := storeInstance.NewWorkspace("scroll-session-2026-03-30"); if err != nil { return }; defer workspace.Discard(); _ = workspace.Put("like", map[string]any{"user": "@alice"})`
|
|
||||||
// Each workspace keeps mutable work-in-progress in a DuckDB file such as
|
|
||||||
// `.core/state/scroll-session.duckdb` until `Commit()` or `Discard()` removes
|
|
||||||
// it.
|
|
||||||
type Workspace struct {
|
|
||||||
name string
|
|
||||||
store *Store
|
|
||||||
db *sql.DB
|
|
||||||
sqliteDatabase *sql.DB
|
|
||||||
databasePath string
|
|
||||||
filesystem *core.Fs
|
|
||||||
cachedOrphanAggregate map[string]any
|
|
||||||
|
|
||||||
lifecycleLock sync.Mutex
|
|
||||||
isClosed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `workspaceName := workspace.Name(); fmt.Println(workspaceName)`
|
|
||||||
func (workspace *Workspace) Name() string {
|
|
||||||
if workspace == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return workspace.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `workspacePath := workspace.DatabasePath(); fmt.Println(workspacePath)`
|
|
||||||
func (workspace *Workspace) DatabasePath() string {
|
|
||||||
if workspace == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return workspace.databasePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `if err := workspace.Close(); err != nil { return }`
|
|
||||||
// Usage example: `if err := workspace.Close(); err != nil { return }; orphans := storeInstance.RecoverOrphans(".core/state"); _ = orphans`
|
|
||||||
// `Close()` keeps the `.duckdb` file on disk so `RecoverOrphans(".core/state")`
|
|
||||||
// can reopen it after a crash or interrupted agent run.
|
|
||||||
func (workspace *Workspace) Close() error {
|
|
||||||
return workspace.closeWithoutRemovingFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) ensureReady(operation string) error {
|
|
||||||
if workspace == nil {
|
|
||||||
return core.E(operation, "workspace is nil", nil)
|
|
||||||
}
|
|
||||||
if workspace.store == nil {
|
|
||||||
return core.E(operation, "workspace store is nil", nil)
|
|
||||||
}
|
|
||||||
if workspace.db == nil {
|
|
||||||
workspace.db = workspace.sqliteDatabase
|
|
||||||
}
|
|
||||||
if workspace.sqliteDatabase == nil {
|
|
||||||
workspace.sqliteDatabase = workspace.db
|
|
||||||
}
|
|
||||||
if workspace.db == nil {
|
|
||||||
return core.E(operation, "workspace database is nil", nil)
|
|
||||||
}
|
|
||||||
if workspace.sqliteDatabase == nil {
|
|
||||||
return core.E(operation, "workspace database is nil", nil)
|
|
||||||
}
|
|
||||||
if workspace.filesystem == nil {
|
|
||||||
return core.E(operation, "workspace filesystem is nil", nil)
|
|
||||||
}
|
|
||||||
if err := workspace.store.ensureReady(operation); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace.lifecycleLock.Lock()
|
|
||||||
closed := workspace.isClosed
|
|
||||||
workspace.lifecycleLock.Unlock()
|
|
||||||
if closed {
|
|
||||||
return core.E(operation, "workspace is closed", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `workspace, err := storeInstance.NewWorkspace("scroll-session-2026-03-30"); if err != nil { return }; defer workspace.Discard()`
|
|
||||||
// This creates `.core/state/scroll-session-2026-03-30.duckdb` by default and
|
|
||||||
// removes it when the workspace is committed or discarded.
|
|
||||||
func (storeInstance *Store) NewWorkspace(name string) (*Workspace, error) {
|
|
||||||
if err := storeInstance.ensureReady("store.NewWorkspace"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
workspaceNameValidation := core.ValidateName(name)
|
|
||||||
if !workspaceNameValidation.OK {
|
|
||||||
return nil, core.E("store.NewWorkspace", "validate workspace name", workspaceNameValidation.Value.(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
filesystem := (&core.Fs{}).NewUnrestricted()
|
|
||||||
stateDirectory := storeInstance.workspaceStateDirectoryPath()
|
|
||||||
databasePath := workspaceFilePath(stateDirectory, name)
|
|
||||||
if filesystem.Exists(databasePath) {
|
|
||||||
return nil, core.E("store.NewWorkspace", core.Concat("workspace already exists: ", name), nil)
|
|
||||||
}
|
|
||||||
if result := filesystem.EnsureDir(stateDirectory); !result.OK {
|
|
||||||
return nil, core.E("store.NewWorkspace", "ensure state directory", result.Value.(error))
|
|
||||||
}
|
|
||||||
|
|
||||||
sqliteDatabase, err := openWorkspaceDatabase(databasePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.NewWorkspace", "open workspace database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Workspace{
|
|
||||||
name: name,
|
|
||||||
store: storeInstance,
|
|
||||||
db: sqliteDatabase,
|
|
||||||
sqliteDatabase: sqliteDatabase,
|
|
||||||
databasePath: databasePath,
|
|
||||||
filesystem: filesystem,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// discoverOrphanWorkspacePaths(".core/state") returns leftover SQLite workspace
|
|
||||||
// files such as `scroll-session.duckdb` without opening them.
|
|
||||||
func discoverOrphanWorkspacePaths(stateDirectory string) []string {
|
|
||||||
filesystem := (&core.Fs{}).NewUnrestricted()
|
|
||||||
if stateDirectory == "" {
|
|
||||||
stateDirectory = defaultWorkspaceStateDirectory
|
|
||||||
}
|
|
||||||
if !filesystem.Exists(stateDirectory) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
listResult := filesystem.List(stateDirectory)
|
|
||||||
if !listResult.OK {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
directoryEntries, ok := listResult.Value.([]fs.DirEntry)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
slices.SortFunc(directoryEntries, func(left, right fs.DirEntry) int {
|
|
||||||
switch {
|
|
||||||
case left.Name() < right.Name():
|
|
||||||
return -1
|
|
||||||
case left.Name() > right.Name():
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
orphanPaths := make([]string, 0, len(directoryEntries))
|
|
||||||
for _, dirEntry := range directoryEntries {
|
|
||||||
if dirEntry.IsDir() || !core.HasSuffix(dirEntry.Name(), ".duckdb") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
orphanPaths = append(orphanPaths, workspaceFilePath(stateDirectory, core.TrimSuffix(dirEntry.Name(), ".duckdb")))
|
|
||||||
}
|
|
||||||
return orphanPaths
|
|
||||||
}
|
|
||||||
|
|
||||||
func discoverOrphanWorkspaces(stateDirectory string, store *Store) []*Workspace {
|
|
||||||
return loadRecoveredWorkspaces(stateDirectory, store)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadRecoveredWorkspaces(stateDirectory string, store *Store) []*Workspace {
|
|
||||||
filesystem := (&core.Fs{}).NewUnrestricted()
|
|
||||||
orphanWorkspaces := make([]*Workspace, 0)
|
|
||||||
for _, databasePath := range discoverOrphanWorkspacePaths(stateDirectory) {
|
|
||||||
sqliteDatabase, err := openWorkspaceDatabase(databasePath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
orphanWorkspace := &Workspace{
|
|
||||||
name: workspaceNameFromPath(stateDirectory, databasePath),
|
|
||||||
store: store,
|
|
||||||
db: sqliteDatabase,
|
|
||||||
sqliteDatabase: sqliteDatabase,
|
|
||||||
databasePath: databasePath,
|
|
||||||
filesystem: filesystem,
|
|
||||||
}
|
|
||||||
orphanWorkspace.cachedOrphanAggregate = orphanWorkspace.captureAggregateSnapshot()
|
|
||||||
orphanWorkspaces = append(orphanWorkspaces, orphanWorkspace)
|
|
||||||
}
|
|
||||||
return orphanWorkspaces
|
|
||||||
}
|
|
||||||
|
|
||||||
func normaliseWorkspaceStateDirectory(stateDirectory string) string {
|
|
||||||
return normaliseDirectoryPath(stateDirectory)
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceNameFromPath(stateDirectory, databasePath string) string {
|
|
||||||
relativePath := core.TrimPrefix(databasePath, joinPath(stateDirectory, ""))
|
|
||||||
return core.TrimSuffix(relativePath, ".duckdb")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `orphans := storeInstance.RecoverOrphans(".core/state"); for _, orphanWorkspace := range orphans { fmt.Println(orphanWorkspace.Name(), orphanWorkspace.Aggregate()) }`
|
|
||||||
// This reopens leftover `.duckdb` files such as `scroll-session-2026-03-30`
|
|
||||||
// so callers can inspect `Aggregate()` and choose `Commit()` or `Discard()`.
|
|
||||||
func (storeInstance *Store) RecoverOrphans(stateDirectory string) []*Workspace {
|
|
||||||
if storeInstance == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if stateDirectory == "" {
|
|
||||||
stateDirectory = storeInstance.workspaceStateDirectoryPath()
|
|
||||||
}
|
|
||||||
stateDirectory = normaliseWorkspaceStateDirectory(stateDirectory)
|
|
||||||
|
|
||||||
if stateDirectory == storeInstance.workspaceStateDirectoryPath() {
|
|
||||||
storeInstance.orphanWorkspaceLock.Lock()
|
|
||||||
cachedWorkspaces := slices.Clone(storeInstance.cachedOrphanWorkspaces)
|
|
||||||
storeInstance.cachedOrphanWorkspaces = nil
|
|
||||||
storeInstance.orphanWorkspaceLock.Unlock()
|
|
||||||
if len(cachedWorkspaces) > 0 {
|
|
||||||
return cachedWorkspaces
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return loadRecoveredWorkspaces(stateDirectory, storeInstance)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `err := workspace.Put("like", map[string]any{"user": "@alice", "post": "video_123"})`
|
|
||||||
func (workspace *Workspace) Put(kind string, data map[string]any) error {
|
|
||||||
if err := workspace.ensureReady("store.Workspace.Put"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if kind == "" {
|
|
||||||
return core.E("store.Workspace.Put", "kind is empty", nil)
|
|
||||||
}
|
|
||||||
if data == nil {
|
|
||||||
data = map[string]any{}
|
|
||||||
}
|
|
||||||
|
|
||||||
dataJSON, err := marshalJSONText(data, "store.Workspace.Put", "marshal entry data")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = workspace.sqliteDatabase.Exec(
|
|
||||||
"INSERT INTO "+workspaceEntriesTableName+" (entry_kind, entry_data, created_at) VALUES (?, ?, ?)",
|
|
||||||
kind,
|
|
||||||
dataJSON,
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Workspace.Put", "insert entry", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `entryCount, err := workspace.Count(); if err != nil { return }; fmt.Println(entryCount)`
|
|
||||||
func (workspace *Workspace) Count() (int, error) {
|
|
||||||
if err := workspace.ensureReady("store.Workspace.Count"); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var count int
|
|
||||||
err := workspace.sqliteDatabase.QueryRow(
|
|
||||||
"SELECT COUNT(*) FROM " + workspaceEntriesTableName,
|
|
||||||
).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
return 0, core.E("store.Workspace.Count", "count entries", err)
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `summary := workspace.Aggregate(); fmt.Println(summary["like"])`
|
|
||||||
func (workspace *Workspace) Aggregate() map[string]any {
|
|
||||||
if workspace.shouldUseOrphanAggregate() {
|
|
||||||
return workspace.aggregateFallback()
|
|
||||||
}
|
|
||||||
if err := workspace.ensureReady("store.Workspace.Aggregate"); err != nil {
|
|
||||||
return workspace.aggregateFallback()
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := workspace.aggregateFields()
|
|
||||||
if err != nil {
|
|
||||||
return workspace.aggregateFallback()
|
|
||||||
}
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `result := workspace.Commit(); if !result.OK { return }; fmt.Println(result.Value)`
|
|
||||||
// `Commit()` writes one completed workspace row to the journal, upserts the
|
|
||||||
// `workspace:NAME/summary` entry, and removes the workspace file.
|
|
||||||
func (workspace *Workspace) Commit() core.Result {
|
|
||||||
if err := workspace.ensureReady("store.Workspace.Commit"); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := workspace.aggregateFields()
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Workspace.Commit", "aggregate workspace", err), OK: false}
|
|
||||||
}
|
|
||||||
if err := workspace.store.commitWorkspaceAggregate(workspace.name, fields); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
if err := workspace.closeAndRemoveFiles(); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: cloneAnyMap(fields), OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `workspace.Discard()`
|
|
||||||
func (workspace *Workspace) Discard() {
|
|
||||||
if workspace == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = workspace.closeAndRemoveFiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage example: `result := workspace.Query("SELECT entry_kind, COUNT(*) AS count FROM workspace_entries GROUP BY entry_kind")`
|
|
||||||
// `result.Value` contains `[]map[string]any`, which lets an agent inspect the
|
|
||||||
// current buffer state without defining extra result types.
|
|
||||||
func (workspace *Workspace) Query(query string) core.Result {
|
|
||||||
if err := workspace.ensureReady("store.Workspace.Query"); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := workspace.sqliteDatabase.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Workspace.Query", "query workspace", err), OK: false}
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
rowMaps, err := queryRowsAsMaps(rows)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("store.Workspace.Query", "scan rows", err), OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: rowMaps, OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) aggregateFields() (map[string]any, error) {
|
|
||||||
if err := workspace.ensureReady("store.Workspace.aggregateFields"); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return workspace.aggregateFieldsWithoutReadiness()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) captureAggregateSnapshot() map[string]any {
|
|
||||||
if workspace == nil || workspace.sqliteDatabase == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fields, err := workspace.aggregateFieldsWithoutReadiness()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fields
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) aggregateFallback() map[string]any {
|
|
||||||
if workspace == nil || workspace.cachedOrphanAggregate == nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return maps.Clone(workspace.cachedOrphanAggregate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) shouldUseOrphanAggregate() bool {
|
|
||||||
if workspace == nil || workspace.cachedOrphanAggregate == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if workspace.filesystem == nil || workspace.databasePath == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return !workspace.filesystem.Exists(workspace.databasePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) aggregateFieldsWithoutReadiness() (map[string]any, error) {
|
|
||||||
rows, err := workspace.sqliteDatabase.Query(
|
|
||||||
"SELECT entry_kind, COUNT(*) FROM " + workspaceEntriesTableName + " GROUP BY entry_kind ORDER BY entry_kind",
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
fields := make(map[string]any)
|
|
||||||
for rows.Next() {
|
|
||||||
var (
|
|
||||||
kind string
|
|
||||||
count int
|
|
||||||
)
|
|
||||||
if err := rows.Scan(&kind, &count); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
fields[kind] = count
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return fields, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) closeAndRemoveFiles() error {
|
|
||||||
return workspace.closeAndCleanup(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// closeWithoutRemovingFiles closes the database handle but leaves the orphan
|
|
||||||
// file on disk so a later store instance can recover it.
|
|
||||||
func (workspace *Workspace) closeWithoutRemovingFiles() error {
|
|
||||||
return workspace.closeAndCleanup(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (workspace *Workspace) closeAndCleanup(removeFiles bool) error {
|
|
||||||
if workspace == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if workspace.sqliteDatabase == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace.lifecycleLock.Lock()
|
|
||||||
alreadyClosed := workspace.isClosed
|
|
||||||
if !alreadyClosed {
|
|
||||||
workspace.isClosed = true
|
|
||||||
}
|
|
||||||
workspace.lifecycleLock.Unlock()
|
|
||||||
|
|
||||||
if !alreadyClosed {
|
|
||||||
if err := workspace.sqliteDatabase.Close(); err != nil {
|
|
||||||
return core.E("store.Workspace.closeAndCleanup", "close workspace database", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !removeFiles || workspace.filesystem == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, path := range []string{workspace.databasePath, workspace.databasePath + "-wal", workspace.databasePath + "-shm"} {
|
|
||||||
if result := workspace.filesystem.Delete(path); !result.OK && workspace.filesystem.Exists(path) {
|
|
||||||
return core.E("store.Workspace.closeAndCleanup", "delete workspace file", result.Value.(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (storeInstance *Store) commitWorkspaceAggregate(workspaceName string, fields map[string]any) error {
|
|
||||||
if err := storeInstance.ensureReady("store.Workspace.Commit"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := ensureJournalSchema(storeInstance.sqliteDatabase); err != nil {
|
|
||||||
return core.E("store.Workspace.Commit", "ensure journal schema", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction, err := storeInstance.sqliteDatabase.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return core.E("store.Workspace.Commit", "begin transaction", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
committed := false
|
|
||||||
defer func() {
|
|
||||||
if !committed {
|
|
||||||
_ = transaction.Rollback()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
fieldsJSON, err := marshalJSONText(fields, "store.Workspace.Commit", "marshal summary")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
tagsJSON, err := marshalJSONText(map[string]string{"workspace": workspaceName}, "store.Workspace.Commit", "marshal tags")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := commitJournalEntry(
|
|
||||||
transaction,
|
|
||||||
storeInstance.journalBucket(),
|
|
||||||
workspaceName,
|
|
||||||
fieldsJSON,
|
|
||||||
tagsJSON,
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
); err != nil {
|
|
||||||
return core.E("store.Workspace.Commit", "insert journal entry", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := transaction.Exec(
|
|
||||||
"INSERT INTO "+entriesTableName+" ("+entryGroupColumn+", "+entryKeyColumn+", "+entryValueColumn+", expires_at) VALUES (?, ?, ?, NULL) "+
|
|
||||||
"ON CONFLICT("+entryGroupColumn+", "+entryKeyColumn+") DO UPDATE SET "+entryValueColumn+" = excluded."+entryValueColumn+", expires_at = NULL",
|
|
||||||
workspaceSummaryGroup(workspaceName),
|
|
||||||
"summary",
|
|
||||||
fieldsJSON,
|
|
||||||
); err != nil {
|
|
||||||
return core.E("store.Workspace.Commit", "upsert workspace summary", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := transaction.Commit(); err != nil {
|
|
||||||
return core.E("store.Workspace.Commit", "commit transaction", err)
|
|
||||||
}
|
|
||||||
committed = true
|
|
||||||
storeInstance.notify(Event{
|
|
||||||
Type: EventSet,
|
|
||||||
Group: workspaceSummaryGroup(workspaceName),
|
|
||||||
Key: "summary",
|
|
||||||
Value: fieldsJSON,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func openWorkspaceDatabase(databasePath string) (*sql.DB, error) {
|
|
||||||
sqliteDatabase, err := sql.Open("duckdb", databasePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, core.E("store.openWorkspaceDatabase", "open workspace database", err)
|
|
||||||
}
|
|
||||||
sqliteDatabase.SetMaxOpenConns(1)
|
|
||||||
if err := sqliteDatabase.Ping(); err != nil {
|
|
||||||
sqliteDatabase.Close()
|
|
||||||
return nil, core.E("store.openWorkspaceDatabase", "ping workspace database", err)
|
|
||||||
}
|
|
||||||
if _, err := sqliteDatabase.Exec("CREATE SEQUENCE IF NOT EXISTS workspace_entries_entry_id_seq START 1"); err != nil {
|
|
||||||
sqliteDatabase.Close()
|
|
||||||
return nil, core.E("store.openWorkspaceDatabase", "create workspace entry sequence", err)
|
|
||||||
}
|
|
||||||
if _, err := sqliteDatabase.Exec(createWorkspaceEntriesTableSQL); err != nil {
|
|
||||||
sqliteDatabase.Close()
|
|
||||||
return nil, core.E("store.openWorkspaceDatabase", "create workspace entries table", err)
|
|
||||||
}
|
|
||||||
if _, err := sqliteDatabase.Exec(createWorkspaceEntriesViewSQL); err != nil {
|
|
||||||
sqliteDatabase.Close()
|
|
||||||
return nil, core.E("store.openWorkspaceDatabase", "create workspace entries view", err)
|
|
||||||
}
|
|
||||||
return sqliteDatabase, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceSummaryGroup(workspaceName string) string {
|
|
||||||
return core.Concat(workspaceSummaryGroupPrefix, ":", workspaceName)
|
|
||||||
}
|
|
||||||
|
|
||||||
func workspaceFilePath(stateDirectory, name string) string {
|
|
||||||
return joinPath(stateDirectory, core.Concat(name, ".duckdb"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func joinPath(base, name string) string {
|
|
||||||
if base == "" {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return core.Concat(normaliseDirectoryPath(base), "/", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
func normaliseDirectoryPath(directory string) string {
|
|
||||||
for directory != "" && core.HasSuffix(directory, "/") {
|
|
||||||
directory = core.TrimSuffix(directory, "/")
|
|
||||||
}
|
|
||||||
return directory
|
|
||||||
}
|
|
||||||
|
|
@ -1,470 +0,0 @@
|
||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
core "dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWorkspace_NewWorkspace_Good_CreatePutAggregateQuery(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("scroll-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
assert.Equal(t, workspaceFilePath(stateDirectory, "scroll-session"), workspace.databasePath)
|
|
||||||
assert.True(t, testFilesystem().Exists(workspace.databasePath))
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@bob"}))
|
|
||||||
require.NoError(t, workspace.Put("profile_match", map[string]any{"user": "@charlie"}))
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]any{"like": 2, "profile_match": 1}, workspace.Aggregate())
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
workspace.Query("SELECT entry_kind, COUNT(*) AS entry_count FROM workspace_entries GROUP BY entry_kind ORDER BY entry_kind"),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 2)
|
|
||||||
assert.Equal(t, "like", rows[0]["entry_kind"])
|
|
||||||
assert.Equal(t, int64(2), rows[0]["entry_count"])
|
|
||||||
assert.Equal(t, "profile_match", rows[1]["entry_kind"])
|
|
||||||
assert.Equal(t, int64(1), rows[1]["entry_count"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_DatabasePath_Good(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("scroll-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
assert.Equal(t, workspaceFilePath(stateDirectory, "scroll-session"), workspace.DatabasePath())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Count_Good_Empty(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("count-empty")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
count, err := workspace.Count()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 0, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Count_Good_AfterPuts(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("count-puts")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@bob"}))
|
|
||||||
require.NoError(t, workspace.Put("profile_match", map[string]any{"user": "@charlie"}))
|
|
||||||
|
|
||||||
count, err := workspace.Count()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, 3, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Count_Bad_ClosedWorkspace(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("count-closed")
|
|
||||||
require.NoError(t, err)
|
|
||||||
workspace.Discard()
|
|
||||||
|
|
||||||
_, err = workspace.Count()
|
|
||||||
require.Error(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Query_Good_RFCEntriesView(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("scroll-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer workspace.Discard()
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@bob"}))
|
|
||||||
require.NoError(t, workspace.Put("profile_match", map[string]any{"user": "@charlie"}))
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
workspace.Query("SELECT kind, COUNT(*) AS entry_count FROM entries GROUP BY kind ORDER BY kind"),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 2)
|
|
||||||
assert.Equal(t, "like", rows[0]["kind"])
|
|
||||||
assert.Equal(t, int64(2), rows[0]["entry_count"])
|
|
||||||
assert.Equal(t, "profile_match", rows[1]["kind"])
|
|
||||||
assert.Equal(t, int64(1), rows[1]["entry_count"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Commit_Good_JournalAndSummary(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("scroll-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@bob"}))
|
|
||||||
require.NoError(t, workspace.Put("profile_match", map[string]any{"user": "@charlie"}))
|
|
||||||
|
|
||||||
result := workspace.Commit()
|
|
||||||
require.True(t, result.OK, "workspace commit failed: %v", result.Value)
|
|
||||||
assert.Equal(t, map[string]any{"like": 2, "profile_match": 1}, result.Value)
|
|
||||||
assert.False(t, testFilesystem().Exists(workspace.databasePath))
|
|
||||||
|
|
||||||
summaryJSON, err := storeInstance.Get(workspaceSummaryGroup("scroll-session"), "summary")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
summary := make(map[string]any)
|
|
||||||
summaryResult := core.JSONUnmarshalString(summaryJSON, &summary)
|
|
||||||
require.True(t, summaryResult.OK, "summary unmarshal failed: %v", summaryResult.Value)
|
|
||||||
assert.Equal(t, float64(2), summary["like"])
|
|
||||||
assert.Equal(t, float64(1), summary["profile_match"])
|
|
||||||
|
|
||||||
rows := requireResultRows(
|
|
||||||
t,
|
|
||||||
storeInstance.QueryJournal(`from(bucket: "events") |> range(start: -24h) |> filter(fn: (r) => r._measurement == "scroll-session")`),
|
|
||||||
)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
assert.Equal(t, "scroll-session", rows[0]["measurement"])
|
|
||||||
|
|
||||||
fields, ok := rows[0]["fields"].(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected fields type: %T", rows[0]["fields"])
|
|
||||||
assert.Equal(t, float64(2), fields["like"])
|
|
||||||
assert.Equal(t, float64(1), fields["profile_match"])
|
|
||||||
|
|
||||||
tags, ok := rows[0]["tags"].(map[string]string)
|
|
||||||
require.True(t, ok, "unexpected tags type: %T", rows[0]["tags"])
|
|
||||||
assert.Equal(t, "scroll-session", tags["workspace"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Commit_Good_ResultCopiesAggregatedMap(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("scroll-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
aggregateSource := map[string]any{"like": 1}
|
|
||||||
require.NoError(t, workspace.Put("like", aggregateSource))
|
|
||||||
|
|
||||||
result := workspace.Commit()
|
|
||||||
require.True(t, result.OK, "workspace commit failed: %v", result.Value)
|
|
||||||
|
|
||||||
aggregateSource["like"] = 99
|
|
||||||
|
|
||||||
value, ok := result.Value.(map[string]any)
|
|
||||||
require.True(t, ok, "unexpected result type: %T", result.Value)
|
|
||||||
assert.Equal(t, 1, value["like"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Commit_Good_EmitsSummaryEvent(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
events := storeInstance.Watch(workspaceSummaryGroup("scroll-session"))
|
|
||||||
defer storeInstance.Unwatch(workspaceSummaryGroup("scroll-session"), events)
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("scroll-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Put("profile_match", map[string]any{"user": "@charlie"}))
|
|
||||||
|
|
||||||
result := workspace.Commit()
|
|
||||||
require.True(t, result.OK, "workspace commit failed: %v", result.Value)
|
|
||||||
|
|
||||||
select {
|
|
||||||
case event := <-events:
|
|
||||||
assert.Equal(t, EventSet, event.Type)
|
|
||||||
assert.Equal(t, workspaceSummaryGroup("scroll-session"), event.Group)
|
|
||||||
assert.Equal(t, "summary", event.Key)
|
|
||||||
assert.False(t, event.Timestamp.IsZero())
|
|
||||||
|
|
||||||
summary := make(map[string]any)
|
|
||||||
summaryResult := core.JSONUnmarshalString(event.Value, &summary)
|
|
||||||
require.True(t, summaryResult.OK, "summary event unmarshal failed: %v", summaryResult.Value)
|
|
||||||
assert.Equal(t, float64(1), summary["like"])
|
|
||||||
assert.Equal(t, float64(1), summary["profile_match"])
|
|
||||||
case <-time.After(time.Second):
|
|
||||||
t.Fatal("timed out waiting for workspace summary event")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Discard_Good_Idempotent(t *testing.T) {
|
|
||||||
useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("discard-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
workspace.Discard()
|
|
||||||
workspace.Discard()
|
|
||||||
|
|
||||||
assert.False(t, testFilesystem().Exists(workspace.databasePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Close_Good_PreservesFileForRecovery(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("close-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.Close())
|
|
||||||
|
|
||||||
assert.True(t, testFilesystem().Exists(workspace.databasePath))
|
|
||||||
|
|
||||||
err = workspace.Put("like", map[string]any{"user": "@bob"})
|
|
||||||
require.Error(t, err)
|
|
||||||
|
|
||||||
orphans := storeInstance.RecoverOrphans(stateDirectory)
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "close-session", orphans[0].Name())
|
|
||||||
assert.Equal(t, map[string]any{"like": 1}, orphans[0].Aggregate())
|
|
||||||
orphans[0].Discard()
|
|
||||||
assert.False(t, testFilesystem().Exists(workspace.databasePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Close_Good_ClosesDatabaseWithoutFilesystem(t *testing.T) {
|
|
||||||
databasePath := testPath(t, "workspace-no-filesystem.duckdb")
|
|
||||||
|
|
||||||
sqliteDatabase, err := openWorkspaceDatabase(databasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
workspace := &Workspace{
|
|
||||||
name: "partial-workspace",
|
|
||||||
sqliteDatabase: sqliteDatabase,
|
|
||||||
databasePath: databasePath,
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, workspace.Close())
|
|
||||||
|
|
||||||
_, execErr := sqliteDatabase.Exec("SELECT 1")
|
|
||||||
require.Error(t, execErr)
|
|
||||||
assert.Contains(t, execErr.Error(), "closed")
|
|
||||||
|
|
||||||
assert.True(t, testFilesystem().Exists(databasePath))
|
|
||||||
requireCoreOK(t, testFilesystem().Delete(databasePath))
|
|
||||||
_ = testFilesystem().Delete(databasePath + "-wal")
|
|
||||||
_ = testFilesystem().Delete(databasePath + "-shm")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_RecoverOrphans_Good(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:", WithJournal("http://127.0.0.1:8086", "core", "events"))
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
workspace, err := storeInstance.NewWorkspace("orphan-session")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, workspace.Put("like", map[string]any{"user": "@alice"}))
|
|
||||||
require.NoError(t, workspace.sqliteDatabase.Close())
|
|
||||||
|
|
||||||
orphans := storeInstance.RecoverOrphans(stateDirectory)
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "orphan-session", orphans[0].Name())
|
|
||||||
assert.Equal(t, map[string]any{"like": 1}, orphans[0].Aggregate())
|
|
||||||
|
|
||||||
orphans[0].Discard()
|
|
||||||
assert.False(t, testFilesystem().Exists(workspaceFilePath(stateDirectory, "orphan-session")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_New_Good_LeavesOrphanedWorkspacesForRecovery(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
requireCoreOK(t, testFilesystem().EnsureDir(stateDirectory))
|
|
||||||
|
|
||||||
orphanDatabasePath := workspaceFilePath(stateDirectory, "orphan-session")
|
|
||||||
orphanDatabase, err := openWorkspaceDatabase(orphanDatabasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = orphanDatabase.Exec(
|
|
||||||
"INSERT INTO "+workspaceEntriesTableName+" (entry_kind, entry_data, created_at) VALUES (?, ?, ?)",
|
|
||||||
"like",
|
|
||||||
`{"user":"@alice"}`,
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, orphanDatabase.Close())
|
|
||||||
assert.True(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
assert.True(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
orphans := storeInstance.RecoverOrphans(stateDirectory)
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "orphan-session", orphans[0].Name())
|
|
||||||
orphans[0].Discard()
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath+"-wal"))
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath+"-shm"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_New_Good_CachesOrphansDuringConstruction(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
requireCoreOK(t, testFilesystem().EnsureDir(stateDirectory))
|
|
||||||
|
|
||||||
orphanDatabasePath := workspaceFilePath(stateDirectory, "orphan-session")
|
|
||||||
orphanDatabase, err := openWorkspaceDatabase(orphanDatabasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = orphanDatabase.Exec(
|
|
||||||
"INSERT INTO "+workspaceEntriesTableName+" (entry_kind, entry_data, created_at) VALUES (?, ?, ?)",
|
|
||||||
"like",
|
|
||||||
`{"user":"@alice"}`,
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, orphanDatabase.Close())
|
|
||||||
assert.True(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
requireCoreOK(t, testFilesystem().DeleteAll(stateDirectory))
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
orphans := storeInstance.RecoverOrphans(stateDirectory)
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "orphan-session", orphans[0].Name())
|
|
||||||
assert.Equal(t, map[string]any{"like": 1}, orphans[0].Aggregate())
|
|
||||||
orphans[0].Discard()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_NewConfigured_Good_CachesOrphansFromConfiguredStateDirectory(t *testing.T) {
|
|
||||||
stateDirectory := testPath(t, "configured-state")
|
|
||||||
requireCoreOK(t, testFilesystem().EnsureDir(stateDirectory))
|
|
||||||
|
|
||||||
orphanDatabasePath := workspaceFilePath(stateDirectory, "orphan-session")
|
|
||||||
orphanDatabase, err := openWorkspaceDatabase(orphanDatabasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = orphanDatabase.Exec(
|
|
||||||
"INSERT INTO "+workspaceEntriesTableName+" (entry_kind, entry_data, created_at) VALUES (?, ?, ?)",
|
|
||||||
"like",
|
|
||||||
`{"user":"@alice"}`,
|
|
||||||
time.Now().UnixMilli(),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, orphanDatabase.Close())
|
|
||||||
|
|
||||||
storeInstance, err := NewConfigured(StoreConfig{
|
|
||||||
DatabasePath: ":memory:",
|
|
||||||
WorkspaceStateDirectory: stateDirectory,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
requireCoreOK(t, testFilesystem().DeleteAll(stateDirectory))
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
orphans := storeInstance.RecoverOrphans("")
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "orphan-session", orphans[0].Name())
|
|
||||||
assert.Equal(t, map[string]any{"like": 1}, orphans[0].Aggregate())
|
|
||||||
orphans[0].Discard()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_RecoverOrphans_Good_TrailingSlashUsesCache(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
requireCoreOK(t, testFilesystem().EnsureDir(stateDirectory))
|
|
||||||
|
|
||||||
orphanDatabasePath := workspaceFilePath(stateDirectory, "orphan-session")
|
|
||||||
orphanDatabase, err := openWorkspaceDatabase(orphanDatabasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, orphanDatabase.Close())
|
|
||||||
assert.True(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer storeInstance.Close()
|
|
||||||
|
|
||||||
requireCoreOK(t, testFilesystem().DeleteAll(stateDirectory))
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
orphans := storeInstance.RecoverOrphans(stateDirectory + "/")
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "orphan-session", orphans[0].Name())
|
|
||||||
orphans[0].Discard()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWorkspace_Close_Good_PreservesOrphansForRecovery(t *testing.T) {
|
|
||||||
stateDirectory := useWorkspaceStateDirectory(t)
|
|
||||||
requireCoreOK(t, testFilesystem().EnsureDir(stateDirectory))
|
|
||||||
|
|
||||||
orphanDatabasePath := workspaceFilePath(stateDirectory, "orphan-session")
|
|
||||||
orphanDatabase, err := openWorkspaceDatabase(orphanDatabasePath)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, orphanDatabase.Close())
|
|
||||||
assert.True(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
storeInstance, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, storeInstance.Close())
|
|
||||||
|
|
||||||
assert.True(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
|
|
||||||
recoveryStore, err := New(":memory:")
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer recoveryStore.Close()
|
|
||||||
|
|
||||||
orphans := recoveryStore.RecoverOrphans(stateDirectory)
|
|
||||||
require.Len(t, orphans, 1)
|
|
||||||
assert.Equal(t, "orphan-session", orphans[0].Name())
|
|
||||||
orphans[0].Discard()
|
|
||||||
assert.False(t, testFilesystem().Exists(orphanDatabasePath))
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue