@@ -160,6 +160,9 @@ func (d *DBStore) Migrate(ctx context.Context) error {
160160 if err := d .migrateChannelsFromConfigs (ctx ); err != nil {
161161 return fmt .Errorf ("migrate channels from configs: %w" , err )
162162 }
163+ if err := d .migrateConfigsToKV (ctx ); err != nil {
164+ return fmt .Errorf ("migrate configs to kv: %w" , err )
165+ }
163166 return nil
164167}
165168
@@ -1753,6 +1756,21 @@ func (d *DBStore) migrationSQL() []string {
17531756 UNIQUE (type, account_id)
17541757 )` ,
17551758 `CREATE INDEX IF NOT EXISTS idx_channels_user ON channels (user_id, agent_id)` ,
1759+ // configs_kv is the single-value key-value successor to the JSON-blob
1760+ // configs table. Each row stores exactly one scalar; the dotted name
1761+ // encodes the hierarchy that the old JSON blob carried. The old
1762+ // configs table is kept for backward compatibility (dual-write during
1763+ // migration); this table is the read-preferred source of truth once
1764+ // populated.
1765+ `CREATE TABLE IF NOT EXISTS configs_kv (
1766+ kind TEXT NOT NULL,
1767+ scope TEXT NOT NULL,
1768+ scope_id TEXT NOT NULL DEFAULT '',
1769+ name TEXT NOT NULL,
1770+ value TEXT NOT NULL DEFAULT '',
1771+ PRIMARY KEY (kind, scope, scope_id, name)
1772+ )` ,
1773+ `CREATE INDEX IF NOT EXISTS idx_configs_kv_prefix ON configs_kv (kind, scope, scope_id)` ,
17561774 }
17571775}
17581776
@@ -2305,7 +2323,7 @@ func (d *DBStore) SaveSession(ctx context.Context, userID, agentID, sessionKey s
23052323
23062324func (d * DBStore ) ListSessions (ctx context.Context , userID , agentID string ) ([]SessionMeta , error ) {
23072325 rows , err := d .db .QueryContext (ctx ,
2308- fmt .Sprintf (`SELECT session_key, channel, account_id, chat_id, project_id, title, message_count, updated_at FROM sessions
2326+ fmt .Sprintf (`SELECT session_key, channel, account_id, chat_id, project_id, title, message_count, updated_at, COALESCE(chatter_user_id,'') FROM sessions
23092327 WHERE user_id = %s AND agent_id = %s ORDER BY updated_at DESC` , d .ph (1 ), d .ph (2 )),
23102328 userID , agentID )
23112329 if err != nil {
@@ -2315,7 +2333,7 @@ func (d *DBStore) ListSessions(ctx context.Context, userID, agentID string) ([]S
23152333 var metas []SessionMeta
23162334 for rows .Next () {
23172335 var m SessionMeta
2318- if err := rows .Scan (& m .Key , & m .Channel , & m .AccountID , & m .ChatID , & m .ProjectID , & m .Title , & m .MessageCount , & m .UpdatedAt ); err != nil {
2336+ if err := rows .Scan (& m .Key , & m .Channel , & m .AccountID , & m .ChatID , & m .ProjectID , & m .Title , & m .MessageCount , & m .UpdatedAt , & m . ChatterUserID ); err != nil {
23192337 return nil , err
23202338 }
23212339 metas = append (metas , m )
@@ -2963,6 +2981,89 @@ func (d *DBStore) DeleteConfig(ctx context.Context, id string) error {
29632981 return err
29642982}
29652983
2984+ // --- Configs KV (single-value key-value pairs) ---
2985+
2986+ func (d * DBStore ) GetConfigValue (ctx context.Context , kind , scope , scopeID , name string ) (string , error ) {
2987+ var value string
2988+ err := d .db .QueryRowContext (ctx ,
2989+ fmt .Sprintf (`SELECT value FROM configs_kv WHERE kind = %s AND scope = %s AND scope_id = %s AND name = %s` ,
2990+ d .ph (1 ), d .ph (2 ), d .ph (3 ), d .ph (4 )),
2991+ kind , scope , scopeID , name ).Scan (& value )
2992+ if err != nil {
2993+ return "" , scanErr (err )
2994+ }
2995+ return value , nil
2996+ }
2997+
2998+ func (d * DBStore ) SetConfigValue (ctx context.Context , kind , scope , scopeID , name , value string ) error {
2999+ if d .dialect == "postgres" {
3000+ _ , err := d .db .ExecContext (ctx ,
3001+ `INSERT INTO configs_kv (kind, scope, scope_id, name, value)
3002+ VALUES ($1, $2, $3, $4, $5)
3003+ ON CONFLICT (kind, scope, scope_id, name) DO UPDATE SET value=$5` ,
3004+ kind , scope , scopeID , name , value )
3005+ return err
3006+ }
3007+ _ , err := d .db .ExecContext (ctx ,
3008+ `INSERT INTO configs_kv (kind, scope, scope_id, name, value)
3009+ VALUES (?, ?, ?, ?, ?)
3010+ ON CONFLICT (kind, scope, scope_id, name) DO UPDATE SET value=excluded.value` ,
3011+ kind , scope , scopeID , name , value )
3012+ return err
3013+ }
3014+
3015+ func (d * DBStore ) DeleteConfigValue (ctx context.Context , kind , scope , scopeID , name string ) error {
3016+ _ , err := d .db .ExecContext (ctx ,
3017+ fmt .Sprintf (`DELETE FROM configs_kv WHERE kind = %s AND scope = %s AND scope_id = %s AND name = %s` ,
3018+ d .ph (1 ), d .ph (2 ), d .ph (3 ), d .ph (4 )),
3019+ kind , scope , scopeID , name )
3020+ return err
3021+ }
3022+
3023+ func (d * DBStore ) ListConfigValues (ctx context.Context , kind , scope , scopeID , namePrefix string ) (map [string ]string , error ) {
3024+ var rows * sql.Rows
3025+ var err error
3026+ if namePrefix == "" {
3027+ rows , err = d .db .QueryContext (ctx ,
3028+ fmt .Sprintf (`SELECT name, value FROM configs_kv WHERE kind = %s AND scope = %s AND scope_id = %s ORDER BY name` ,
3029+ d .ph (1 ), d .ph (2 ), d .ph (3 )),
3030+ kind , scope , scopeID )
3031+ } else {
3032+ rows , err = d .db .QueryContext (ctx ,
3033+ fmt .Sprintf (`SELECT name, value FROM configs_kv WHERE kind = %s AND scope = %s AND scope_id = %s AND name LIKE %s ORDER BY name` ,
3034+ d .ph (1 ), d .ph (2 ), d .ph (3 ), d .ph (4 )),
3035+ kind , scope , scopeID , namePrefix + "%" )
3036+ }
3037+ if err != nil {
3038+ return nil , err
3039+ }
3040+ defer rows .Close ()
3041+ out := map [string ]string {}
3042+ for rows .Next () {
3043+ var name , value string
3044+ if err := rows .Scan (& name , & value ); err != nil {
3045+ return nil , err
3046+ }
3047+ out [name ] = value
3048+ }
3049+ return out , rows .Err ()
3050+ }
3051+
3052+ func (d * DBStore ) DeleteConfigPrefix (ctx context.Context , kind , scope , scopeID , namePrefix string ) error {
3053+ if namePrefix == "" {
3054+ _ , err := d .db .ExecContext (ctx ,
3055+ fmt .Sprintf (`DELETE FROM configs_kv WHERE kind = %s AND scope = %s AND scope_id = %s` ,
3056+ d .ph (1 ), d .ph (2 ), d .ph (3 )),
3057+ kind , scope , scopeID )
3058+ return err
3059+ }
3060+ _ , err := d .db .ExecContext (ctx ,
3061+ fmt .Sprintf (`DELETE FROM configs_kv WHERE kind = %s AND scope = %s AND scope_id = %s AND name LIKE %s` ,
3062+ d .ph (1 ), d .ph (2 ), d .ph (3 ), d .ph (4 )),
3063+ kind , scope , scopeID , namePrefix + "%" )
3064+ return err
3065+ }
3066+
29663067func (d * DBStore ) LookupChannelByCredential (ctx context.Context , channelType , credKey string ) (* ConfigRecord , error ) {
29673068 row := d .db .QueryRowContext (ctx ,
29683069 fmt .Sprintf (`SELECT ` + configSelectCols + `
@@ -3240,6 +3341,126 @@ func (d *DBStore) migrateChannelsFromConfigs(ctx context.Context) error {
32403341 return nil
32413342}
32423343
3344+ // --- Configs KV migration ---
3345+
3346+ // camelToSnake converts a camelCase string to snake_case.
3347+ func camelToSnake (s string ) string {
3348+ var result strings.Builder
3349+ for i , r := range s {
3350+ if r >= 'A' && r <= 'Z' {
3351+ if i > 0 {
3352+ result .WriteByte ('_' )
3353+ }
3354+ result .WriteByte (byte (r + 32 ))
3355+ } else {
3356+ result .WriteRune (r )
3357+ }
3358+ }
3359+ return result .String ()
3360+ }
3361+
3362+ // flattenJSON recursively flattens a map into dotted-key → string pairs.
3363+ // Arrays and nested objects that aren't maps are serialized as JSON strings.
3364+ func flattenJSON (prefix string , data map [string ]interface {}, out map [string ]string ) {
3365+ for k , v := range data {
3366+ snakeKey := camelToSnake (k )
3367+ fullKey := prefix + snakeKey
3368+ switch val := v .(type ) {
3369+ case map [string ]interface {}:
3370+ flattenJSON (fullKey + "." , val , out )
3371+ case string :
3372+ out [fullKey ] = val
3373+ case bool :
3374+ if val {
3375+ out [fullKey ] = "true"
3376+ } else {
3377+ out [fullKey ] = "false"
3378+ }
3379+ case float64 :
3380+ // Use %g to avoid trailing zeros for integers.
3381+ out [fullKey ] = fmt .Sprintf ("%g" , val )
3382+ case nil :
3383+ // skip nil values
3384+ default :
3385+ // Arrays and complex values: store as JSON string.
3386+ blob , _ := json .Marshal (val )
3387+ out [fullKey ] = string (blob )
3388+ }
3389+ }
3390+ }
3391+
3392+ // migrateConfigsToKV reads all provider/setting rows from configs and
3393+ // flattens them into configs_kv single-value rows. Skipped when configs_kv
3394+ // already has data (idempotent). Old configs rows are kept for rollback.
3395+ func (d * DBStore ) migrateConfigsToKV (ctx context.Context ) error {
3396+ exists , err := d .tableExists (ctx , "configs_kv" )
3397+ if err != nil || ! exists {
3398+ return err
3399+ }
3400+ // Skip if configs_kv already has data.
3401+ var count int
3402+ if err := d .db .QueryRowContext (ctx , `SELECT COUNT(*) FROM configs_kv` ).Scan (& count ); err != nil {
3403+ return err
3404+ }
3405+ if count > 0 {
3406+ return nil
3407+ }
3408+ // Read all provider + setting rows from configs.
3409+ configRows , err := d .db .QueryContext (ctx ,
3410+ `SELECT ` + configSelectCols + ` FROM configs WHERE kind IN ('provider', 'setting')` )
3411+ if err != nil {
3412+ return err
3413+ }
3414+ defer configRows .Close ()
3415+ configs , err := scanConfigs (configRows )
3416+ if err != nil {
3417+ return err
3418+ }
3419+ inserted := 0
3420+ for _ , cfg := range configs {
3421+ if len (cfg .Data ) == 0 {
3422+ continue
3423+ }
3424+ // Derive scope and scope_id from (user_id, agent_id).
3425+ kvScope := "system"
3426+ kvScopeID := ""
3427+ switch {
3428+ case cfg .UserID != "" :
3429+ kvScope = "user"
3430+ kvScopeID = cfg .UserID
3431+ case cfg .AgentID != "" :
3432+ kvScope = "agent"
3433+ kvScopeID = cfg .AgentID
3434+ }
3435+ // Flatten JSON data into key-value pairs.
3436+ flat := map [string ]string {}
3437+ switch {
3438+ case cfg .Kind == KindProvider :
3439+ // name becomes "{provider_name}.{json_key_snake_case}"
3440+ flattenJSON (cfg .Name + "." , cfg .Data , flat )
3441+ case cfg .Kind == KindSetting && cfg .Name == "agents.defaults" :
3442+ // namespace becomes "agent." prefix
3443+ flattenJSON ("agent." , cfg .Data , flat )
3444+ default :
3445+ // Other settings: name becomes "{namespace}.{json_key_snake_case}"
3446+ flattenJSON (cfg .Name + "." , cfg .Data , flat )
3447+ }
3448+ for name , value := range flat {
3449+ if err := d .SetConfigValue (ctx , cfg .Kind , kvScope , kvScopeID , name , value ); err != nil {
3450+ slog .Warn ("migrate config to kv failed" ,
3451+ "kind" , cfg .Kind , "scope" , kvScope , "scope_id" , kvScopeID ,
3452+ "name" , name , "error" , err )
3453+ } else {
3454+ inserted ++
3455+ }
3456+ }
3457+ }
3458+ if inserted > 0 {
3459+ slog .Info ("migrated configs to configs_kv" , "rows" , inserted )
3460+ }
3461+ return nil
3462+ }
3463+
32433464// --- Cron jobs ---
32443465
32453466const cronSelectCols = `id, user_id, agent_id, name, type, schedule, message, channel, chat_id, account_id, timezone, enabled, last_run, next_run, failure_count, created_at`
0 commit comments