2026-02-19 16:09:15 +00:00
package store
import (
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
"context"
2026-02-19 16:09:15 +00:00
"database/sql"
2026-02-23 05:21:39 +00:00
"iter"
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
"sync"
2026-02-19 16:09:15 +00:00
"text/template"
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
"time"
2026-03-26 19:17:11 +00:00
"unicode"
2026-02-19 16:09:15 +00:00
2026-03-26 13:58:50 +00:00
core "dappco.re/go/core"
2026-02-19 16:09:15 +00:00
_ "modernc.org/sqlite"
)
2026-04-04 08:57:06 +00:00
// Usage example: `if core.Is(err, store.NotFoundError) { fmt.Println("config/colour is missing") }`
2026-03-30 14:22:49 +00:00
var NotFoundError = core . E ( "store" , "not found" , nil )
2026-04-04 08:57:06 +00:00
// Usage example: `if core.Is(err, store.QuotaExceededError) { fmt.Println("tenant-a is at quota") }`
2026-03-30 14:22:49 +00:00
var QuotaExceededError = core . E ( "store" , "quota exceeded" , nil )
2026-02-19 16:09:15 +00:00
2026-03-30 15:37:49 +00:00
const (
2026-03-30 17:45:39 +00:00
entriesTableName = "entries"
legacyKeyValueTableName = "kv"
entryGroupColumn = "group_name"
entryKeyColumn = "entry_key"
entryValueColumn = "entry_value"
2026-04-04 19:07:18 +00:00
defaultPurgeInterval = 60 * time . Second
2026-03-30 15:37:49 +00:00
)
2026-04-04 13:37:27 +00:00
// Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: "/tmp/go-store.db", Journal: store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}, PurgeInterval: 30 * time.Second})`
2026-04-04 20:46:40 +00:00
// Prefer `store.NewConfigured(store.StoreConfig{...})` when the full
// configuration is already known. Use `StoreOption` only when values need to
// be assembled incrementally, such as when a caller receives them from
2026-04-04 13:25:19 +00:00
// different sources.
2026-04-03 07:27:04 +00:00
type StoreOption func ( * StoreConfig )
2026-03-30 20:46:43 +00:00
2026-04-03 06:47:39 +00:00
// Usage example: `config := store.StoreConfig{DatabasePath: ":memory:", PurgeInterval: 30 * time.Second}`
type StoreConfig struct {
// Usage example: `config := store.StoreConfig{DatabasePath: "/tmp/go-store.db"}`
DatabasePath string
// Usage example: `config := store.StoreConfig{Journal: store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}}`
Journal JournalConfiguration
// Usage example: `config := store.StoreConfig{PurgeInterval: 30 * time.Second}`
PurgeInterval time . Duration
2026-04-04 14:43:42 +00:00
// Usage example: `config := store.StoreConfig{WorkspaceStateDirectory: "/tmp/core-state"}`
WorkspaceStateDirectory string
2026-04-03 06:47:39 +00:00
}
2026-04-04 19:07:18 +00:00
// Usage example: `config := (store.StoreConfig{DatabasePath: ":memory:"}).Normalised(); fmt.Println(config.PurgeInterval, config.WorkspaceStateDirectory)`
func ( storeConfig StoreConfig ) Normalised ( ) StoreConfig {
if storeConfig . PurgeInterval == 0 {
storeConfig . PurgeInterval = defaultPurgeInterval
}
if storeConfig . WorkspaceStateDirectory == "" {
storeConfig . WorkspaceStateDirectory = normaliseWorkspaceStateDirectory ( defaultWorkspaceStateDirectory )
} else {
storeConfig . WorkspaceStateDirectory = normaliseWorkspaceStateDirectory ( storeConfig . WorkspaceStateDirectory )
}
return storeConfig
}
2026-04-04 10:46:46 +00:00
// Usage example: `if err := (store.StoreConfig{DatabasePath: ":memory:", PurgeInterval: 30 * time.Second}).Validate(); err != nil { return }`
2026-04-04 11:26:16 +00:00
func ( storeConfig StoreConfig ) Validate ( ) error {
if storeConfig . DatabasePath == "" {
2026-04-04 11:22:50 +00:00
return core . E (
"store.StoreConfig.Validate" ,
"database path is empty" ,
nil ,
)
}
2026-04-04 15:47:56 +00:00
if storeConfig . Journal != ( JournalConfiguration { } ) {
if err := storeConfig . Journal . Validate ( ) ; err != nil {
return core . E ( "store.StoreConfig.Validate" , "journal config" , err )
}
2026-04-04 10:46:46 +00:00
}
2026-04-04 11:26:16 +00:00
if storeConfig . PurgeInterval < 0 {
2026-04-04 10:46:46 +00:00
return core . E ( "store.StoreConfig.Validate" , "purge interval must be zero or positive" , nil )
}
return nil
}
2026-04-04 21:13:50 +00:00
// Usage example: `config := store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}`
// JournalConfiguration keeps the journal connection details in one literal so
// agents can pass a single struct to `StoreConfig.Journal` or `WithJournal`.
2026-04-03 06:15:45 +00:00
// Usage example: `config := storeInstance.JournalConfiguration(); fmt.Println(config.EndpointURL, config.Organisation, config.BucketName)`
type JournalConfiguration struct {
// Usage example: `config := store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086"}`
EndpointURL string
// Usage example: `config := store.JournalConfiguration{Organisation: "core"}`
Organisation string
// Usage example: `config := store.JournalConfiguration{BucketName: "events"}`
BucketName string
}
2026-04-04 15:47:56 +00:00
// Usage example: `if err := (store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}).Validate(); err != nil { return }`
func ( journalConfig JournalConfiguration ) Validate ( ) error {
switch {
case journalConfig . EndpointURL == "" :
return core . E (
"store.JournalConfiguration.Validate" ,
` endpoint URL is empty; use values like "http://127.0.0.1:8086" ` ,
nil ,
)
case journalConfig . Organisation == "" :
return core . E (
"store.JournalConfiguration.Validate" ,
` organisation is empty; use values like "core" ` ,
nil ,
)
case journalConfig . BucketName == "" :
return core . E (
"store.JournalConfiguration.Validate" ,
` bucket name is empty; use values like "events" ` ,
nil ,
)
default :
return nil
}
}
2026-04-04 09:43:27 +00:00
func ( journalConfig JournalConfiguration ) isConfigured ( ) bool {
return journalConfig . EndpointURL != "" &&
journalConfig . Organisation != "" &&
journalConfig . BucketName != ""
}
2026-04-04 17:43:55 +00:00
// Store is the SQLite key-value store with TTL expiry, namespace isolation,
2026-04-04 14:36:40 +00:00
// reactive events, SQLite journal writes, and orphan recovery.
//
2026-04-04 11:33:32 +00:00
// Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: ":memory:", Journal: store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}, PurgeInterval: 30 * time.Second})`
2026-04-04 14:23:29 +00:00
// Usage example: `value, err := storeInstance.Get("config", "colour")`
2026-02-19 16:09:15 +00:00
type Store struct {
2026-04-04 14:43:42 +00:00
sqliteDatabase * sql . DB
databasePath string
workspaceStateDirectory string
purgeContext context . Context
cancelPurge context . CancelFunc
purgeWaitGroup sync . WaitGroup
purgeInterval time . Duration // interval between background purge cycles
journalConfiguration JournalConfiguration
2026-04-04 21:09:20 +00:00
lifecycleLock sync . Mutex
isClosed bool
2026-02-20 08:25:03 +00:00
2026-03-30 14:22:49 +00:00
// Event dispatch state.
2026-04-04 21:09:20 +00:00
watchers map [ string ] [ ] chan Event
callbacks [ ] changeCallbackRegistration
watcherLock sync . RWMutex // protects watcher registration and dispatch
callbackLock sync . RWMutex // protects callback registration and dispatch
nextCallbackID uint64 // monotonic ID for callback registrations
orphanWorkspaceLock sync . Mutex
cachedOrphanWorkspaces [ ] * Workspace
2026-02-19 16:09:15 +00:00
}
2026-04-03 06:31:35 +00:00
func ( storeInstance * Store ) ensureReady ( operation string ) error {
if storeInstance == nil {
return core . E ( operation , "store is nil" , nil )
}
2026-04-04 09:04:56 +00:00
if storeInstance . sqliteDatabase == nil {
2026-04-03 06:31:35 +00:00
return core . E ( operation , "store is not initialised" , nil )
}
2026-04-04 21:09:20 +00:00
storeInstance . lifecycleLock . Lock ( )
closed := storeInstance . isClosed
storeInstance . lifecycleLock . Unlock ( )
2026-04-03 06:31:35 +00:00
if closed {
return core . E ( operation , "store is closed" , nil )
}
return nil
}
2026-04-04 12:08:14 +00:00
// Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: "/tmp/go-store.db", Journal: store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}})`
2026-04-03 05:28:57 +00:00
func WithJournal ( endpointURL , organisation , bucketName string ) StoreOption {
2026-04-04 11:26:16 +00:00
return func ( storeConfig * StoreConfig ) {
if storeConfig == nil {
2026-04-03 07:27:04 +00:00
return
}
2026-04-04 11:26:16 +00:00
storeConfig . Journal = JournalConfiguration {
2026-04-03 07:27:04 +00:00
EndpointURL : endpointURL ,
Organisation : organisation ,
BucketName : bucketName ,
2026-04-03 05:28:57 +00:00
}
2026-03-30 20:46:43 +00:00
}
}
2026-04-04 16:49:48 +00:00
// Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: ":memory:", WorkspaceStateDirectory: "/tmp/core-state"})`
2026-04-04 18:52:33 +00:00
// Use this when the workspace state directory is being assembled
// incrementally; otherwise prefer a StoreConfig literal.
2026-04-04 16:24:27 +00:00
func WithWorkspaceStateDirectory ( directory string ) StoreOption {
return func ( storeConfig * StoreConfig ) {
if storeConfig == nil {
return
}
storeConfig . WorkspaceStateDirectory = directory
}
}
2026-04-03 06:15:45 +00:00
// Usage example: `config := storeInstance.JournalConfiguration(); fmt.Println(config.EndpointURL, config.Organisation, config.BucketName)`
func ( storeInstance * Store ) JournalConfiguration ( ) JournalConfiguration {
if storeInstance == nil {
return JournalConfiguration { }
}
2026-04-04 14:19:28 +00:00
return storeInstance . journalConfiguration
2026-04-03 06:15:45 +00:00
}
2026-04-04 09:43:27 +00:00
// Usage example: `if storeInstance.JournalConfigured() { fmt.Println("journal is fully configured") }`
2026-04-04 08:41:28 +00:00
func ( storeInstance * Store ) JournalConfigured ( ) bool {
if storeInstance == nil {
return false
}
2026-04-04 09:43:27 +00:00
return storeInstance . journalConfiguration . isConfigured ( )
2026-04-04 08:41:28 +00:00
}
2026-04-03 08:45:44 +00:00
// Usage example: `config := storeInstance.Config(); fmt.Println(config.DatabasePath, config.PurgeInterval)`
func ( storeInstance * Store ) Config ( ) StoreConfig {
if storeInstance == nil {
return StoreConfig { }
}
return StoreConfig {
2026-04-04 14:43:42 +00:00
DatabasePath : storeInstance . databasePath ,
Journal : storeInstance . JournalConfiguration ( ) ,
PurgeInterval : storeInstance . purgeInterval ,
2026-04-04 16:24:27 +00:00
WorkspaceStateDirectory : storeInstance . WorkspaceStateDirectory ( ) ,
2026-04-03 08:45:44 +00:00
}
}
2026-04-04 08:23:23 +00:00
// Usage example: `databasePath := storeInstance.DatabasePath(); fmt.Println(databasePath)`
func ( storeInstance * Store ) DatabasePath ( ) string {
if storeInstance == nil {
return ""
}
return storeInstance . databasePath
}
2026-04-04 16:24:27 +00:00
// Usage example: `stateDirectory := storeInstance.WorkspaceStateDirectory(); fmt.Println(stateDirectory)`
func ( storeInstance * Store ) WorkspaceStateDirectory ( ) string {
if storeInstance == nil {
return normaliseWorkspaceStateDirectory ( defaultWorkspaceStateDirectory )
}
return storeInstance . workspaceStateDirectoryPath ( )
}
2026-04-04 08:32:55 +00:00
// Usage example: `if storeInstance.IsClosed() { return }`
func ( storeInstance * Store ) IsClosed ( ) bool {
if storeInstance == nil {
return true
}
2026-04-04 21:09:20 +00:00
storeInstance . lifecycleLock . Lock ( )
closed := storeInstance . isClosed
storeInstance . lifecycleLock . Unlock ( )
2026-04-04 08:32:55 +00:00
return closed
}
2026-04-04 13:37:27 +00:00
// Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: ":memory:", PurgeInterval: 20 * time.Millisecond})`
2026-04-04 18:52:33 +00:00
// Use this when the purge interval is being assembled incrementally; otherwise
// prefer a StoreConfig literal.
2026-04-03 05:35:22 +00:00
func WithPurgeInterval ( interval time . Duration ) StoreOption {
2026-04-04 11:26:16 +00:00
return func ( storeConfig * StoreConfig ) {
if storeConfig == nil {
2026-04-03 07:27:04 +00:00
return
}
2026-04-03 05:35:22 +00:00
if interval > 0 {
2026-04-04 11:26:16 +00:00
storeConfig . PurgeInterval = interval
2026-04-03 05:35:22 +00:00
}
}
}
2026-04-03 06:47:39 +00:00
// Usage example: `storeInstance, 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})`
2026-04-04 11:26:16 +00:00
func NewConfigured ( storeConfig StoreConfig ) ( * Store , error ) {
return openConfiguredStore ( "store.NewConfigured" , storeConfig )
2026-04-03 07:27:04 +00:00
}
2026-04-04 11:26:16 +00:00
func openConfiguredStore ( operation string , storeConfig StoreConfig ) ( * Store , error ) {
if err := storeConfig . Validate ( ) ; err != nil {
2026-04-04 10:46:46 +00:00
return nil , core . E ( operation , "validate config" , err )
}
2026-04-04 19:07:18 +00:00
storeConfig = storeConfig . Normalised ( )
2026-04-04 10:46:46 +00:00
2026-04-04 11:26:16 +00:00
storeInstance , err := openSQLiteStore ( operation , storeConfig . DatabasePath )
2026-04-03 06:47:39 +00:00
if err != nil {
return nil , err
}
2026-04-04 11:26:16 +00:00
if storeConfig . Journal != ( JournalConfiguration { } ) {
2026-04-04 14:19:28 +00:00
storeInstance . journalConfiguration = storeConfig . Journal
2026-04-03 06:47:39 +00:00
}
2026-04-04 19:07:18 +00:00
storeInstance . purgeInterval = storeConfig . PurgeInterval
storeInstance . workspaceStateDirectory = storeConfig . WorkspaceStateDirectory
2026-04-03 06:47:39 +00:00
2026-04-03 08:41:20 +00:00
// New() performs a non-destructive orphan scan so callers can discover
// leftover workspaces via RecoverOrphans().
2026-04-04 21:09:20 +00:00
storeInstance . cachedOrphanWorkspaces = discoverOrphanWorkspaces ( storeInstance . workspaceStateDirectoryPath ( ) , storeInstance )
2026-04-03 06:47:39 +00:00
storeInstance . startBackgroundPurge ( )
return storeInstance , nil
}
2026-04-04 13:37:27 +00:00
// Usage example: `storeInstance, err := store.NewConfigured(store.StoreConfig{DatabasePath: "/tmp/go-store.db", Journal: store.JournalConfiguration{EndpointURL: "http://127.0.0.1:8086", Organisation: "core", BucketName: "events"}})`
2026-03-30 20:46:43 +00:00
func New ( databasePath string , options ... StoreOption ) ( * Store , error ) {
2026-04-04 11:26:16 +00:00
storeConfig := StoreConfig { DatabasePath : databasePath }
2026-04-03 06:47:39 +00:00
for _ , option := range options {
if option != nil {
2026-04-04 11:26:16 +00:00
option ( & storeConfig )
2026-04-03 06:47:39 +00:00
}
}
2026-04-04 11:26:16 +00:00
return openConfiguredStore ( "store.New" , storeConfig )
2026-04-03 06:47:39 +00:00
}
2026-04-03 08:54:19 +00:00
func openSQLiteStore ( operation , databasePath string ) ( * Store , error ) {
2026-03-30 15:48:33 +00:00
sqliteDatabase , err := sql . Open ( "sqlite" , databasePath )
2026-02-19 16:09:15 +00:00
if err != nil {
2026-04-03 06:47:39 +00:00
return nil , core . E ( operation , "open database" , err )
2026-02-19 16:09:15 +00:00
}
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
// Serialise all access through a single connection. SQLite only supports
// one writer at a time; using a pool causes SQLITE_BUSY under contention
// because pragmas (journal_mode, busy_timeout) are per-connection and the
// pool hands out different connections for each call.
2026-03-30 15:48:33 +00:00
sqliteDatabase . SetMaxOpenConns ( 1 )
if _ , err := sqliteDatabase . Exec ( "PRAGMA journal_mode=WAL" ) ; err != nil {
sqliteDatabase . Close ( )
2026-04-03 06:47:39 +00:00
return nil , core . E ( operation , "set WAL journal mode" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 15:48:33 +00:00
if _ , err := sqliteDatabase . Exec ( "PRAGMA busy_timeout=5000" ) ; err != nil {
sqliteDatabase . Close ( )
2026-04-03 06:47:39 +00:00
return nil , core . E ( operation , "set busy timeout" , err )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
2026-03-30 15:48:33 +00:00
if err := ensureSchema ( sqliteDatabase ) ; err != nil {
sqliteDatabase . Close ( )
2026-04-03 06:47:39 +00:00
return nil , core . E ( operation , "ensure schema" , err )
2026-03-09 08:20:38 +00:00
}
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
2026-03-30 15:48:33 +00:00
purgeContext , cancel := context . WithCancel ( context . Background ( ) )
2026-04-03 06:47:39 +00:00
return & Store {
2026-04-04 14:43:42 +00:00
sqliteDatabase : sqliteDatabase ,
databasePath : databasePath ,
workspaceStateDirectory : normaliseWorkspaceStateDirectory ( defaultWorkspaceStateDirectory ) ,
purgeContext : purgeContext ,
cancelPurge : cancel ,
2026-04-04 19:07:18 +00:00
purgeInterval : defaultPurgeInterval ,
2026-04-04 14:43:42 +00:00
watchers : make ( map [ string ] [ ] chan Event ) ,
2026-04-03 06:47:39 +00:00
} , nil
2026-02-19 16:09:15 +00:00
}
2026-04-04 14:43:42 +00:00
func ( storeInstance * Store ) workspaceStateDirectoryPath ( ) string {
if storeInstance == nil || storeInstance . workspaceStateDirectory == "" {
return normaliseWorkspaceStateDirectory ( defaultWorkspaceStateDirectory )
}
return normaliseWorkspaceStateDirectory ( storeInstance . workspaceStateDirectory )
}
2026-03-30 16:41:56 +00:00
// Usage example: `storeInstance, err := store.New(":memory:"); if err != nil { return }; defer storeInstance.Close()`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) Close ( ) error {
2026-04-03 06:31:35 +00:00
if storeInstance == nil {
return nil
}
2026-04-04 21:09:20 +00:00
storeInstance . lifecycleLock . Lock ( )
if storeInstance . isClosed {
storeInstance . lifecycleLock . Unlock ( )
2026-04-03 05:00:56 +00:00
return nil
}
2026-04-04 21:09:20 +00:00
storeInstance . isClosed = true
storeInstance . lifecycleLock . Unlock ( )
2026-04-03 05:00:56 +00:00
2026-04-03 06:31:35 +00:00
if storeInstance . cancelPurge != nil {
storeInstance . cancelPurge ( )
}
2026-03-30 15:02:28 +00:00
storeInstance . purgeWaitGroup . Wait ( )
2026-04-03 07:30:50 +00:00
2026-04-04 21:09:20 +00:00
storeInstance . watcherLock . Lock ( )
2026-04-03 07:30:50 +00:00
for groupName , registeredEvents := range storeInstance . watchers {
for _ , registeredEventChannel := range registeredEvents {
close ( registeredEventChannel )
}
delete ( storeInstance . watchers , groupName )
}
2026-04-04 21:09:20 +00:00
storeInstance . watcherLock . Unlock ( )
2026-04-03 07:30:50 +00:00
2026-04-04 21:09:20 +00:00
storeInstance . callbackLock . Lock ( )
2026-04-03 07:30:50 +00:00
storeInstance . callbacks = nil
2026-04-04 21:09:20 +00:00
storeInstance . callbackLock . Unlock ( )
2026-04-03 07:30:50 +00:00
2026-04-04 21:09:20 +00:00
storeInstance . orphanWorkspaceLock . Lock ( )
2026-04-04 08:12:29 +00:00
var orphanCleanupErr error
2026-04-04 21:09:20 +00:00
for _ , orphanWorkspace := range storeInstance . cachedOrphanWorkspaces {
2026-04-04 08:12:29 +00:00
if err := orphanWorkspace . closeWithoutRemovingFiles ( ) ; err != nil && orphanCleanupErr == nil {
orphanCleanupErr = err
}
2026-04-04 07:57:24 +00:00
}
2026-04-04 21:09:20 +00:00
storeInstance . cachedOrphanWorkspaces = nil
storeInstance . orphanWorkspaceLock . Unlock ( )
2026-04-04 07:57:24 +00:00
2026-04-04 09:04:56 +00:00
if storeInstance . sqliteDatabase == nil {
2026-04-04 08:12:29 +00:00
return orphanCleanupErr
2026-04-03 06:31:35 +00:00
}
2026-04-04 09:04:56 +00:00
if err := storeInstance . sqliteDatabase . Close ( ) ; err != nil {
2026-03-30 16:27:54 +00:00
return core . E ( "store.Close" , "database close" , err )
}
2026-04-04 12:01:39 +00:00
if orphanCleanupErr != nil {
return core . E ( "store.Close" , "close orphan workspaces" , orphanCleanupErr )
}
2026-04-04 08:12:29 +00:00
return orphanCleanupErr
2026-02-19 16:09:15 +00:00
}
2026-03-30 18:49:17 +00:00
// Usage example: `colourValue, err := storeInstance.Get("config", "colour")`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) Get ( group , key string ) ( string , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.Get" ) ; err != nil {
return "" , err
}
2026-03-30 14:54:34 +00:00
var value string
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
var expiresAt sql . NullInt64
2026-04-04 09:04:56 +00:00
err := storeInstance . sqliteDatabase . QueryRow (
2026-03-30 15:37:49 +00:00
"SELECT " + entryValueColumn + ", expires_at FROM " + entriesTableName + " WHERE " + entryGroupColumn + " = ? AND " + entryKeyColumn + " = ?" ,
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
group , key ,
2026-03-30 14:54:34 +00:00
) . Scan ( & value , & expiresAt )
2026-02-19 16:09:15 +00:00
if err == sql . ErrNoRows {
2026-03-30 14:22:49 +00:00
return "" , core . E ( "store.Get" , core . Concat ( group , "/" , key ) , NotFoundError )
2026-02-19 16:09:15 +00:00
}
if err != nil {
2026-03-30 18:37:07 +00:00
return "" , core . E ( "store.Get" , "query row" , err )
2026-02-19 16:09:15 +00:00
}
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
if expiresAt . Valid && expiresAt . Int64 <= time . Now ( ) . UnixMilli ( ) {
2026-03-30 21:07:30 +00:00
if err := storeInstance . Delete ( group , key ) ; err != nil {
2026-03-30 18:37:07 +00:00
return "" , core . E ( "store.Get" , "delete expired row" , err )
2026-03-30 16:41:56 +00:00
}
2026-03-30 14:22:49 +00:00
return "" , core . E ( "store.Get" , core . Concat ( group , "/" , key ) , NotFoundError )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
2026-03-30 14:54:34 +00:00
return value , nil
2026-02-19 16:09:15 +00:00
}
2026-03-30 18:49:17 +00:00
// Usage example: `if err := storeInstance.Set("config", "colour", "blue"); err != nil { return }`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) Set ( group , key , value string ) error {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.Set" ) ; err != nil {
return err
}
2026-04-04 09:04:56 +00:00
_ , err := storeInstance . sqliteDatabase . Exec (
2026-03-30 15:37:49 +00:00
"INSERT INTO " + entriesTableName + " (" + entryGroupColumn + ", " + entryKeyColumn + ", " + entryValueColumn + ", expires_at) VALUES (?, ?, ?, NULL) " +
"ON CONFLICT(" + entryGroupColumn + ", " + entryKeyColumn + ") DO UPDATE SET " + entryValueColumn + " = excluded." + entryValueColumn + ", expires_at = NULL" ,
2026-02-19 16:09:15 +00:00
group , key , value ,
)
if err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.Set" , "execute upsert" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 15:02:28 +00:00
storeInstance . notify ( Event { Type : EventSet , Group : group , Key : key , Value : value , Timestamp : time . Now ( ) } )
2026-02-19 16:09:15 +00:00
return nil
}
2026-03-30 16:41:56 +00:00
// Usage example: `if err := storeInstance.SetWithTTL("session", "token", "abc123", time.Minute); err != nil { return }`
2026-03-30 18:37:07 +00:00
func ( storeInstance * Store ) SetWithTTL ( group , key , value string , timeToLive time . Duration ) error {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.SetWithTTL" ) ; err != nil {
return err
}
2026-03-30 18:37:07 +00:00
expiresAt := time . Now ( ) . Add ( timeToLive ) . UnixMilli ( )
2026-04-04 09:04:56 +00:00
_ , err := storeInstance . sqliteDatabase . Exec (
2026-03-30 15:37:49 +00:00
"INSERT INTO " + entriesTableName + " (" + entryGroupColumn + ", " + entryKeyColumn + ", " + entryValueColumn + ", expires_at) VALUES (?, ?, ?, ?) " +
"ON CONFLICT(" + entryGroupColumn + ", " + entryKeyColumn + ") DO UPDATE SET " + entryValueColumn + " = excluded." + entryValueColumn + ", expires_at = excluded.expires_at" ,
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
group , key , value , expiresAt ,
)
if err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.SetWithTTL" , "execute upsert with expiry" , err )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
2026-03-30 15:02:28 +00:00
storeInstance . notify ( Event { Type : EventSet , Group : group , Key : key , Value : value , Timestamp : time . Now ( ) } )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
return nil
}
2026-03-30 18:49:17 +00:00
// Usage example: `if err := storeInstance.Delete("config", "colour"); err != nil { return }`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) Delete ( group , key string ) error {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.Delete" ) ; err != nil {
return err
}
2026-04-04 09:04:56 +00:00
deleteResult , err := storeInstance . sqliteDatabase . Exec ( "DELETE FROM " + entriesTableName + " WHERE " + entryGroupColumn + " = ? AND " + entryKeyColumn + " = ?" , group , key )
2026-02-19 16:09:15 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.Delete" , "delete row" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 20:03:19 +00:00
deletedRows , rowsAffectedError := deleteResult . RowsAffected ( )
if rowsAffectedError != nil {
return core . E ( "store.Delete" , "count deleted rows" , rowsAffectedError )
}
if deletedRows > 0 {
storeInstance . notify ( Event { Type : EventDelete , Group : group , Key : key , Timestamp : time . Now ( ) } )
}
2026-02-19 16:09:15 +00:00
return nil
}
2026-04-05 08:58:26 +01:00
// Usage example: `exists, err := storeInstance.Exists("config", "colour")`
// Usage example: `if exists, _ := storeInstance.Exists("session", "token"); !exists { fmt.Println("session expired") }`
func ( storeInstance * Store ) Exists ( group , key string ) ( bool , error ) {
if err := storeInstance . ensureReady ( "store.Exists" ) ; err != nil {
return false , err
}
return liveEntryExists ( storeInstance . sqliteDatabase , group , key )
}
// Usage example: `exists, err := storeInstance.GroupExists("config")`
// Usage example: `if exists, _ := storeInstance.GroupExists("tenant-a:config"); !exists { fmt.Println("group is empty") }`
func ( storeInstance * Store ) GroupExists ( group string ) ( bool , error ) {
if err := storeInstance . ensureReady ( "store.GroupExists" ) ; err != nil {
return false , err
}
count , err := storeInstance . Count ( group )
if err != nil {
return false , err
}
return count > 0 , nil
}
2026-03-30 16:13:55 +00:00
// Usage example: `keyCount, err := storeInstance.Count("config")`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) Count ( group string ) ( int , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.Count" ) ; err != nil {
return 0 , err
}
2026-03-30 14:54:34 +00:00
var count int
2026-04-04 09:04:56 +00:00
err := storeInstance . sqliteDatabase . QueryRow (
2026-03-30 15:37:49 +00:00
"SELECT COUNT(*) FROM " + entriesTableName + " WHERE " + entryGroupColumn + " = ? AND (expires_at IS NULL OR expires_at > ?)" ,
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
group , time . Now ( ) . UnixMilli ( ) ,
2026-03-30 14:54:34 +00:00
) . Scan ( & count )
2026-02-19 16:09:15 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return 0 , core . E ( "store.Count" , "count rows" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 14:54:34 +00:00
return count , nil
2026-02-19 16:09:15 +00:00
}
2026-03-30 16:41:56 +00:00
// Usage example: `if err := storeInstance.DeleteGroup("cache"); err != nil { return }`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) DeleteGroup ( group string ) error {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.DeleteGroup" ) ; err != nil {
return err
}
2026-04-04 09:04:56 +00:00
deleteResult , err := storeInstance . sqliteDatabase . Exec ( "DELETE FROM " + entriesTableName + " WHERE " + entryGroupColumn + " = ?" , group )
2026-02-19 16:09:15 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.DeleteGroup" , "delete group" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 20:03:19 +00:00
deletedRows , rowsAffectedError := deleteResult . RowsAffected ( )
if rowsAffectedError != nil {
return core . E ( "store.DeleteGroup" , "count deleted rows" , rowsAffectedError )
}
if deletedRows > 0 {
storeInstance . notify ( Event { Type : EventDeleteGroup , Group : group , Timestamp : time . Now ( ) } )
}
2026-02-19 16:09:15 +00:00
return nil
}
2026-04-03 07:14:22 +00:00
// Usage example: `if err := storeInstance.DeletePrefix("tenant-a:"); err != nil { return }`
func ( storeInstance * Store ) DeletePrefix ( groupPrefix string ) error {
if err := storeInstance . ensureReady ( "store.DeletePrefix" ) ; err != nil {
return err
}
var rows * sql . Rows
var err error
if groupPrefix == "" {
2026-04-04 09:04:56 +00:00
rows , err = storeInstance . sqliteDatabase . Query (
2026-04-03 07:14:22 +00:00
"SELECT DISTINCT " + entryGroupColumn + " FROM " + entriesTableName + " ORDER BY " + entryGroupColumn ,
)
} else {
2026-04-04 09:04:56 +00:00
rows , err = storeInstance . sqliteDatabase . Query (
2026-04-03 07:14:22 +00:00
"SELECT DISTINCT " + entryGroupColumn + " FROM " + entriesTableName + " WHERE " + entryGroupColumn + " LIKE ? ESCAPE '^' ORDER BY " + entryGroupColumn ,
escapeLike ( groupPrefix ) + "%" ,
)
}
if err != nil {
return core . E ( "store.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.DeletePrefix" , "scan group name" , err )
}
groupNames = append ( groupNames , groupName )
}
if err := rows . Err ( ) ; err != nil {
return core . E ( "store.DeletePrefix" , "iterate groups" , err )
}
for _ , groupName := range groupNames {
if err := storeInstance . DeleteGroup ( groupName ) ; err != nil {
return core . E ( "store.DeletePrefix" , "delete group" , err )
}
}
return nil
}
2026-03-30 17:32:09 +00:00
// Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }`
2026-03-30 14:22:49 +00:00
type KeyValue struct {
2026-03-30 18:49:17 +00:00
// Usage example: `if entry.Key == "colour" { return }`
2026-03-30 16:50:04 +00:00
Key string
2026-03-30 18:49:17 +00:00
// Usage example: `if entry.Value == "blue" { return }`
2026-03-30 16:50:04 +00:00
Value string
2026-02-23 05:21:39 +00:00
}
2026-03-30 18:49:17 +00:00
// Usage example: `colourEntries, err := storeInstance.GetAll("config")`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) GetAll ( group string ) ( map [ string ] string , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.GetAll" ) ; err != nil {
return nil , err
}
2026-03-30 14:54:34 +00:00
entriesByKey := make ( map [ string ] string )
2026-03-30 15:02:28 +00:00
for entry , err := range storeInstance . All ( group ) {
2026-02-23 05:21:39 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return nil , core . E ( "store.GetAll" , "iterate rows" , err )
2026-02-23 05:21:39 +00:00
}
2026-03-30 14:54:34 +00:00
entriesByKey [ entry . Key ] = entry . Value
2026-02-19 16:09:15 +00:00
}
2026-03-30 14:54:34 +00:00
return entriesByKey , nil
2026-02-23 05:21:39 +00:00
}
2026-02-19 16:09:15 +00:00
2026-04-03 08:32:35 +00:00
// Usage example: `page, err := storeInstance.GetPage("config", 0, 25); if err != nil { return }; for _, entry := range page { fmt.Println(entry.Key, entry.Value) }`
func ( storeInstance * Store ) GetPage ( group string , offset , limit int ) ( [ ] KeyValue , error ) {
if err := storeInstance . ensureReady ( "store.GetPage" ) ; err != nil {
return nil , err
}
if offset < 0 {
return nil , core . E ( "store.GetPage" , "offset must be zero or positive" , nil )
}
if limit < 0 {
return nil , core . E ( "store.GetPage" , "limit must be zero or positive" , nil )
}
2026-04-04 09:04:56 +00:00
rows , err := storeInstance . sqliteDatabase . Query (
2026-04-03 08:32:35 +00:00
"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.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.GetPage" , "scan row" , err )
}
page = append ( page , entry )
}
if err := rows . Err ( ) ; err != nil {
return nil , core . E ( "store.GetPage" , "rows iteration" , err )
}
return page , nil
}
2026-04-03 05:13:46 +00:00
// Usage example: `for entry, err := range storeInstance.AllSeq("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }`
func ( storeInstance * Store ) AllSeq ( group string ) iter . Seq2 [ KeyValue , error ] {
2026-03-30 14:22:49 +00:00
return func ( yield func ( KeyValue , error ) bool ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.All" ) ; err != nil {
yield ( KeyValue { } , err )
return
}
2026-04-04 09:04:56 +00:00
rows , err := storeInstance . sqliteDatabase . Query (
2026-03-30 19:39:54 +00:00
"SELECT " + entryKeyColumn + ", " + entryValueColumn + " FROM " + entriesTableName + " WHERE " + entryGroupColumn + " = ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY " + entryKeyColumn ,
2026-02-23 05:21:39 +00:00
group , time . Now ( ) . UnixMilli ( ) ,
)
if err != nil {
2026-03-30 18:37:07 +00:00
yield ( KeyValue { } , core . E ( "store.All" , "query rows" , err ) )
2026-02-23 05:21:39 +00:00
return
}
defer rows . Close ( )
for rows . Next ( ) {
2026-03-30 14:54:34 +00:00
var entry KeyValue
if err := rows . Scan ( & entry . Key , & entry . Value ) ; err != nil {
2026-03-30 18:37:07 +00:00
if ! yield ( KeyValue { } , core . E ( "store.All" , "scan row" , err ) ) {
2026-02-23 05:21:39 +00:00
return
}
continue
}
2026-03-30 14:54:34 +00:00
if ! yield ( entry , nil ) {
2026-02-23 05:21:39 +00:00
return
}
}
if err := rows . Err ( ) ; err != nil {
2026-03-30 18:37:07 +00:00
yield ( KeyValue { } , core . E ( "store.All" , "rows iteration" , err ) )
2026-02-19 16:09:15 +00:00
}
}
2026-02-23 05:21:39 +00:00
}
2026-04-03 05:13:46 +00:00
// Usage example: `for entry, err := range storeInstance.All("config") { if err != nil { break }; fmt.Println(entry.Key, entry.Value) }`
func ( storeInstance * Store ) All ( group string ) iter . Seq2 [ KeyValue , error ] {
return storeInstance . AllSeq ( group )
}
2026-03-30 17:32:09 +00:00
// Usage example: `parts, err := storeInstance.GetSplit("config", "hosts", ","); if err != nil { return }; for part := range parts { fmt.Println(part) }`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) GetSplit ( group , key , separator string ) ( iter . Seq [ string ] , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.GetSplit" ) ; err != nil {
return nil , err
}
2026-03-30 15:02:28 +00:00
value , err := storeInstance . Get ( group , key )
2026-02-23 05:21:39 +00:00
if err != nil {
return nil , err
2026-02-19 16:09:15 +00:00
}
2026-03-30 17:37:50 +00:00
return splitValueSeq ( value , separator ) , nil
2026-02-19 16:09:15 +00:00
}
2026-03-30 17:32:09 +00:00
// Usage example: `fields, err := storeInstance.GetFields("config", "flags"); if err != nil { return }; for field := range fields { fmt.Println(field) }`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) GetFields ( group , key string ) ( iter . Seq [ string ] , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.GetFields" ) ; err != nil {
return nil , err
}
2026-03-30 15:02:28 +00:00
value , err := storeInstance . Get ( group , key )
2026-02-19 16:09:15 +00:00
if err != nil {
2026-02-23 05:21:39 +00:00
return nil , err
2026-02-19 16:09:15 +00:00
}
2026-03-30 17:37:50 +00:00
return fieldsValueSeq ( value ) , nil
2026-02-23 05:21:39 +00:00
}
2026-02-19 16:09:15 +00:00
2026-03-30 16:13:55 +00:00
// Usage example: `renderedTemplate, err := storeInstance.Render("Hello {{ .name }}", "user")`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) Render ( templateSource , group string ) ( string , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.Render" ) ; err != nil {
return "" , err
}
2026-03-30 14:54:34 +00:00
templateData := make ( map [ string ] string )
2026-03-30 15:02:28 +00:00
for entry , err := range storeInstance . All ( group ) {
2026-02-23 05:21:39 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return "" , core . E ( "store.Render" , "iterate rows" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 14:54:34 +00:00
templateData [ entry . Key ] = entry . Value
2026-02-19 16:09:15 +00:00
}
2026-03-30 15:02:28 +00:00
renderTemplate , err := template . New ( "render" ) . Parse ( templateSource )
2026-02-19 16:09:15 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return "" , core . E ( "store.Render" , "parse template" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 14:54:34 +00:00
builder := core . NewBuilder ( )
2026-03-30 15:02:28 +00:00
if err := renderTemplate . Execute ( builder , templateData ) ; err != nil {
2026-03-30 18:37:07 +00:00
return "" , core . E ( "store.Render" , "execute template" , err )
2026-02-19 16:09:15 +00:00
}
2026-03-30 14:54:34 +00:00
return builder . String ( ) , nil
2026-02-19 16:09:15 +00:00
}
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
2026-03-30 16:13:55 +00:00
// Usage example: `tenantKeyCount, err := storeInstance.CountAll("tenant-a:")`
2026-03-30 15:02:28 +00:00
func ( storeInstance * Store ) CountAll ( groupPrefix string ) ( int , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.CountAll" ) ; err != nil {
return 0 , err
}
2026-03-30 14:54:34 +00:00
var count int
2026-02-20 08:19:11 +00:00
var err error
2026-03-30 14:54:34 +00:00
if groupPrefix == "" {
2026-04-04 09:04:56 +00:00
err = storeInstance . sqliteDatabase . QueryRow (
2026-03-30 15:37:49 +00:00
"SELECT COUNT(*) FROM " + entriesTableName + " WHERE (expires_at IS NULL OR expires_at > ?)" ,
2026-02-20 08:19:11 +00:00
time . Now ( ) . UnixMilli ( ) ,
2026-03-30 14:54:34 +00:00
) . Scan ( & count )
2026-02-20 08:19:11 +00:00
} else {
2026-04-04 09:04:56 +00:00
err = storeInstance . sqliteDatabase . QueryRow (
2026-03-30 15:37:49 +00:00
"SELECT COUNT(*) FROM " + entriesTableName + " WHERE " + entryGroupColumn + " LIKE ? ESCAPE '^' AND (expires_at IS NULL OR expires_at > ?)" ,
2026-03-30 14:54:34 +00:00
escapeLike ( groupPrefix ) + "%" , time . Now ( ) . UnixMilli ( ) ,
) . Scan ( & count )
2026-02-20 08:19:11 +00:00
}
if err != nil {
2026-03-30 18:37:07 +00:00
return 0 , core . E ( "store.CountAll" , "count rows" , err )
2026-02-20 08:19:11 +00:00
}
2026-03-30 14:54:34 +00:00
return count , nil
2026-02-20 08:19:11 +00:00
}
2026-03-30 16:13:55 +00:00
// Usage example: `tenantGroupNames, err := storeInstance.Groups("tenant-a:")`
2026-03-30 20:46:43 +00:00
// Usage example: `allGroupNames, err := storeInstance.Groups()`
func ( storeInstance * Store ) Groups ( groupPrefix ... string ) ( [ ] string , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.Groups" ) ; err != nil {
return nil , err
}
2026-03-30 14:54:34 +00:00
var groupNames [ ] string
2026-03-30 20:46:43 +00:00
for groupName , err := range storeInstance . GroupsSeq ( groupPrefix ... ) {
2026-02-23 05:21:39 +00:00
if err != nil {
return nil , err
2026-02-20 08:19:11 +00:00
}
2026-03-30 14:54:34 +00:00
groupNames = append ( groupNames , groupName )
2026-02-20 08:19:11 +00:00
}
2026-03-30 14:54:34 +00:00
return groupNames , nil
2026-02-20 08:19:11 +00:00
}
2026-03-30 17:32:09 +00:00
// Usage example: `for tenantGroupName, err := range storeInstance.GroupsSeq("tenant-a:") { if err != nil { break }; fmt.Println(tenantGroupName) }`
2026-03-30 20:46:43 +00:00
// Usage example: `for groupName, err := range storeInstance.GroupsSeq() { if err != nil { break }; fmt.Println(groupName) }`
func ( storeInstance * Store ) GroupsSeq ( groupPrefix ... string ) iter . Seq2 [ string , error ] {
2026-04-04 21:29:27 +00:00
actualGroupPrefix := firstStringOrEmpty ( groupPrefix )
2026-02-23 05:21:39 +00:00
return func ( yield func ( string , error ) bool ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.GroupsSeq" ) ; err != nil {
yield ( "" , err )
return
}
2026-02-23 05:21:39 +00:00
var rows * sql . Rows
var err error
now := time . Now ( ) . UnixMilli ( )
2026-03-30 20:46:43 +00:00
if actualGroupPrefix == "" {
2026-04-04 09:04:56 +00:00
rows , err = storeInstance . sqliteDatabase . Query (
2026-03-30 19:39:54 +00:00
"SELECT DISTINCT " + entryGroupColumn + " FROM " + entriesTableName + " WHERE (expires_at IS NULL OR expires_at > ?) ORDER BY " + entryGroupColumn ,
2026-02-23 05:21:39 +00:00
now ,
)
} else {
2026-04-04 09:04:56 +00:00
rows , err = storeInstance . sqliteDatabase . Query (
2026-03-30 19:39:54 +00:00
"SELECT DISTINCT " + entryGroupColumn + " FROM " + entriesTableName + " WHERE " + entryGroupColumn + " LIKE ? ESCAPE '^' AND (expires_at IS NULL OR expires_at > ?) ORDER BY " + entryGroupColumn ,
2026-03-30 20:46:43 +00:00
escapeLike ( actualGroupPrefix ) + "%" , now ,
2026-02-23 05:21:39 +00:00
)
}
if err != nil {
2026-03-30 18:37:07 +00:00
yield ( "" , core . E ( "store.GroupsSeq" , "query group names" , err ) )
2026-02-23 05:21:39 +00:00
return
}
defer rows . Close ( )
for rows . Next ( ) {
2026-03-30 14:54:34 +00:00
var groupName string
if err := rows . Scan ( & groupName ) ; err != nil {
2026-03-30 18:37:07 +00:00
if ! yield ( "" , core . E ( "store.GroupsSeq" , "scan group name" , err ) ) {
2026-02-23 05:21:39 +00:00
return
}
continue
}
2026-03-30 14:54:34 +00:00
if ! yield ( groupName , nil ) {
2026-02-23 05:21:39 +00:00
return
}
}
if err := rows . Err ( ) ; err != nil {
2026-03-30 18:37:07 +00:00
yield ( "" , core . E ( "store.GroupsSeq" , "rows iteration" , err ) )
2026-02-23 05:21:39 +00:00
}
}
}
2026-04-04 21:29:27 +00:00
func firstStringOrEmpty ( values [ ] string ) string {
2026-03-30 20:46:43 +00:00
if len ( values ) == 0 {
return ""
}
return values [ 0 ]
}
2026-03-30 16:33:07 +00:00
// escapeLike("tenant_%") returns "tenant^_^%" so LIKE queries treat wildcards
// literally.
2026-03-30 15:02:28 +00:00
func escapeLike ( text string ) string {
text = core . Replace ( text , "^" , "^^" )
text = core . Replace ( text , "%" , "^%" )
text = core . Replace ( text , "_" , "^_" )
return text
2026-03-09 08:20:38 +00:00
}
2026-03-30 15:02:28 +00:00
// Usage example: `removed, err := storeInstance.PurgeExpired()`
func ( storeInstance * Store ) PurgeExpired ( ) ( int64 , error ) {
2026-04-03 06:31:35 +00:00
if err := storeInstance . ensureReady ( "store.PurgeExpired" ) ; err != nil {
return 0 , err
}
2026-04-04 18:20:52 +00:00
removedRows , err := purgeExpiredMatchingGroupPrefix ( storeInstance . sqliteDatabase , "" )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return 0 , core . E ( "store.PurgeExpired" , "delete expired rows" , err )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
2026-03-30 16:27:54 +00:00
return removedRows , nil
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
2026-04-04 08:57:06 +00:00
// New(":memory:", store.WithPurgeInterval(20*time.Millisecond)) starts a
// background goroutine that calls PurgeExpired on that interval until Close
// stops the store.
2026-04-03 06:47:39 +00:00
func ( storeInstance * Store ) startBackgroundPurge ( ) {
2026-04-03 06:31:35 +00:00
if storeInstance == nil {
return
}
2026-04-03 06:47:39 +00:00
if storeInstance . purgeContext == nil {
return
}
2026-04-03 07:35:20 +00:00
if storeInstance . purgeInterval <= 0 {
2026-04-04 19:07:18 +00:00
storeInstance . purgeInterval = defaultPurgeInterval
2026-04-03 07:35:20 +00:00
}
purgeInterval := storeInstance . purgeInterval
2026-04-03 06:31:35 +00:00
2026-03-30 15:02:28 +00:00
storeInstance . purgeWaitGroup . Go ( func ( ) {
2026-04-03 07:35:20 +00:00
ticker := time . NewTicker ( purgeInterval )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
defer ticker . Stop ( )
for {
select {
2026-04-03 06:47:39 +00:00
case <- storeInstance . purgeContext . Done ( ) :
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
return
case <- ticker . C :
2026-03-30 15:02:28 +00:00
if _ , err := storeInstance . PurgeExpired ( ) ; err != nil {
2026-03-30 18:17:07 +00:00
// For example, a logger could record the failure here. The loop
// keeps running so the next tick can retry.
2026-03-09 08:20:38 +00:00
}
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
}
2026-02-22 21:00:17 +00:00
} )
feat(store): add TTL support and harden test coverage to 90.9%
Phase 0 -- Hardening:
- Fix SQLITE_BUSY under concurrent writes by setting SetMaxOpenConns(1)
and PRAGMA busy_timeout=5000
- Add comprehensive tests: concurrent read/write (10 goroutines),
edge cases (unicode, null bytes, SQL injection, long keys), error
paths (closed store, invalid paths, corrupt files), group isolation,
upsert verification, WAL mode verification, ErrNotFound wrapping
- Add benchmarks: Set, Get, GetAll (10K keys), file-backed Set
Phase 1 -- TTL Support:
- Add expires_at nullable column with schema migration for pre-TTL DBs
- SetWithTTL(group, key, value, duration) stores keys that auto-expire
- Lazy deletion on Get for expired keys
- Background purge goroutine (configurable interval, default 60s)
- Public PurgeExpired() method for manual cleanup
- Count, GetAll, Render all exclude expired entries
- Set clears TTL when overwriting a TTL key
Coverage: 73.1% -> 90.9% (48 tests, 0 races)
Co-Authored-By: Charon <developers@lethean.io>
2026-02-20 01:14:08 +00:00
}
2026-03-26 19:17:11 +00:00
2026-03-30 17:37:50 +00:00
// splitValueSeq("red,green,blue", ",") yields "red", "green", "blue" without
2026-03-30 16:33:07 +00:00
// importing strings directly.
2026-03-30 17:37:50 +00:00
func splitValueSeq ( value , separator string ) iter . Seq [ string ] {
2026-03-26 19:17:11 +00:00
return func ( yield func ( string ) bool ) {
2026-03-30 15:02:28 +00:00
for _ , part := range core . Split ( value , separator ) {
2026-03-26 19:17:11 +00:00
if ! yield ( part ) {
return
}
}
}
}
2026-03-30 17:37:50 +00:00
// fieldsValueSeq("alpha beta\tgamma") yields "alpha", "beta", "gamma" without
2026-03-30 16:33:07 +00:00
// importing strings directly.
2026-03-30 17:37:50 +00:00
func fieldsValueSeq ( value string ) iter . Seq [ string ] {
2026-03-26 19:17:11 +00:00
return func ( yield func ( string ) bool ) {
start := - 1
for i , r := range value {
if unicode . IsSpace ( r ) {
if start >= 0 {
if ! yield ( value [ start : i ] ) {
return
}
start = - 1
}
continue
}
if start < 0 {
start = i
}
}
if start >= 0 {
yield ( value [ start : ] )
}
}
}
2026-03-30 15:37:49 +00:00
2026-03-30 20:03:19 +00:00
// purgeExpiredMatchingGroupPrefix deletes expired rows globally when
// groupPrefix is empty, otherwise only rows whose group starts with the given
// prefix.
2026-04-04 18:20:52 +00:00
func purgeExpiredMatchingGroupPrefix ( database schemaDatabase , groupPrefix string ) ( int64 , error ) {
2026-03-30 20:03:19 +00:00
var (
deleteResult sql . Result
err error
)
now := time . Now ( ) . UnixMilli ( )
if groupPrefix == "" {
2026-04-04 18:20:52 +00:00
deleteResult , err = database . Exec (
2026-03-30 20:03:19 +00:00
"DELETE FROM " + entriesTableName + " WHERE expires_at IS NOT NULL AND expires_at <= ?" ,
now ,
)
} else {
2026-04-04 18:20:52 +00:00
deleteResult , err = database . Exec (
2026-03-30 20:03:19 +00:00
"DELETE FROM " + entriesTableName + " WHERE expires_at IS NOT NULL AND expires_at <= ? AND " + entryGroupColumn + " LIKE ? ESCAPE '^'" ,
now , escapeLike ( groupPrefix ) + "%" ,
)
}
if err != nil {
return 0 , err
}
removedRows , rowsAffectedErr := deleteResult . RowsAffected ( )
if rowsAffectedErr != nil {
return 0 , rowsAffectedErr
}
return removedRows , nil
}
2026-03-30 15:37:49 +00:00
type schemaDatabase interface {
Exec ( query string , args ... any ) ( sql . Result , error )
QueryRow ( query string , args ... any ) * sql . Row
Query ( query string , args ... any ) ( * sql . Rows , error )
}
const createEntriesTableSQL = ` CREATE TABLE IF NOT EXISTS entries (
group_name TEXT NOT NULL ,
entry_key TEXT NOT NULL ,
entry_value TEXT NOT NULL ,
expires_at INTEGER ,
PRIMARY KEY ( group_name , entry_key )
) `
2026-03-30 17:45:39 +00:00
// ensureSchema creates the current entries table and migrates the legacy
// key-value table when present.
2026-03-30 15:37:49 +00:00
func ensureSchema ( database * sql . DB ) error {
entriesTableExists , err := tableExists ( database , entriesTableName )
if err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.ensureSchema" , "schema" , err )
2026-03-30 15:37:49 +00:00
}
2026-03-30 17:45:39 +00:00
legacyEntriesTableExists , err := tableExists ( database , legacyKeyValueTableName )
2026-03-30 15:37:49 +00:00
if err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.ensureSchema" , "schema" , err )
2026-03-30 15:37:49 +00:00
}
if entriesTableExists {
if err := ensureExpiryColumn ( database ) ; err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.ensureSchema" , "migration" , err )
2026-03-30 15:37:49 +00:00
}
if legacyEntriesTableExists {
if err := migrateLegacyEntriesTable ( database ) ; err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.ensureSchema" , "migration" , err )
2026-03-30 15:37:49 +00:00
}
}
return nil
}
if legacyEntriesTableExists {
if err := migrateLegacyEntriesTable ( database ) ; err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.ensureSchema" , "migration" , err )
2026-03-30 15:37:49 +00:00
}
return nil
}
if _ , err := database . Exec ( createEntriesTableSQL ) ; err != nil {
2026-03-30 18:37:07 +00:00
return core . E ( "store.ensureSchema" , "schema" , err )
2026-03-30 15:37:49 +00:00
}
return nil
}
// ensureExpiryColumn adds the expiry column to the current entries table when
// it was created before TTL support.
func ensureExpiryColumn ( database schemaDatabase ) error {
hasExpiryColumn , err := tableHasColumn ( database , entriesTableName , "expires_at" )
if err != nil {
return err
}
if hasExpiryColumn {
return nil
}
if _ , err := database . Exec ( "ALTER TABLE " + entriesTableName + " ADD COLUMN expires_at INTEGER" ) ; err != nil {
if ! core . Contains ( err . Error ( ) , "duplicate column name" ) {
return err
}
}
return nil
}
2026-03-30 17:45:39 +00:00
// migrateLegacyEntriesTable copies rows from the old key-value table into the
2026-03-30 15:37:49 +00:00
// descriptive entries schema and then removes the legacy table.
func migrateLegacyEntriesTable ( database * sql . DB ) error {
transaction , err := database . Begin ( )
if err != nil {
return err
}
committed := false
defer func ( ) {
if ! committed {
2026-03-30 16:41:56 +00:00
if rollbackErr := transaction . Rollback ( ) ; rollbackErr != nil {
// Ignore rollback failures; the original error is already being returned.
}
2026-03-30 15:37:49 +00:00
}
} ( )
entriesTableExists , err := tableExists ( transaction , entriesTableName )
if err != nil {
return err
}
if ! entriesTableExists {
if _ , err := transaction . Exec ( createEntriesTableSQL ) ; err != nil {
return err
}
}
2026-03-30 17:45:39 +00:00
legacyHasExpiryColumn , err := tableHasColumn ( transaction , legacyKeyValueTableName , "expires_at" )
2026-03-30 15:37:49 +00:00
if err != nil {
return err
}
2026-03-30 17:45:39 +00:00
insertSQL := "INSERT OR IGNORE INTO " + entriesTableName + " (" + entryGroupColumn + ", " + entryKeyColumn + ", " + entryValueColumn + ", expires_at) SELECT grp, key, value, NULL FROM " + legacyKeyValueTableName
2026-03-30 15:37:49 +00:00
if legacyHasExpiryColumn {
2026-03-30 17:45:39 +00:00
insertSQL = "INSERT OR IGNORE INTO " + entriesTableName + " (" + entryGroupColumn + ", " + entryKeyColumn + ", " + entryValueColumn + ", expires_at) SELECT grp, key, value, expires_at FROM " + legacyKeyValueTableName
2026-03-30 15:37:49 +00:00
}
if _ , err := transaction . Exec ( insertSQL ) ; err != nil {
return err
}
2026-03-30 17:45:39 +00:00
if _ , err := transaction . Exec ( "DROP TABLE " + legacyKeyValueTableName ) ; err != nil {
2026-03-30 15:37:49 +00:00
return err
}
if err := transaction . Commit ( ) ; err != nil {
return err
}
committed = true
return nil
}
func tableExists ( database schemaDatabase , tableName string ) ( bool , error ) {
var existingTableName string
err := database . QueryRow (
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?" ,
tableName ,
) . Scan ( & existingTableName )
if err == sql . ErrNoRows {
return false , nil
}
if err != nil {
return false , err
}
return true , nil
}
func tableHasColumn ( database schemaDatabase , tableName , columnName string ) ( bool , error ) {
rows , err := database . Query ( "PRAGMA table_info(" + tableName + ")" )
if err != nil {
return false , err
}
defer rows . Close ( )
for rows . Next ( ) {
var (
columnID int
name string
columnType string
notNull int
defaultValue sql . NullString
primaryKey int
)
if err := rows . Scan ( & columnID , & name , & columnType , & notNull , & defaultValue , & primaryKey ) ; err != nil {
return false , err
}
if name == columnName {
return true , nil
}
}
if err := rows . Err ( ) ; err != nil {
return false , err
}
return false , nil
}