Skip to content

Commit fcb63be

Browse files
idoubiclaude
andcommitted
refactor(configs): flatten JSON blobs into single-value configs_kv table
New `configs_kv` table with composite PK (kind, scope, scope_id, name) stores one value per row instead of JSON blobs. Dotted name convention: - provider: deepseek.api_base, deepseek.api_key, ... - setting: agent.model, sandbox.backend, memory.auto_persist.enabled, ... Strategy: dual-write + fallback-read (same as channels refactor). - scope.Setting/Providers read from configs_kv first, fall back to old - scope.SaveSetting/SaveProvider write to both tables - Auto-migration flattens existing JSON data on startup - camelCase keys converted to snake_case in new format - Old configs table untouched for rollback safety Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 51c38fa commit fcb63be

8 files changed

Lines changed: 649 additions & 21 deletions

File tree

internal/scope/scope.go

Lines changed: 361 additions & 0 deletions
Large diffs are not rendered by default.

internal/session/manager.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -689,7 +689,11 @@ type WebSession struct {
689689
// turn of the session, surfaced so the sidebar can show "image +
690690
// text" instead of just the text label for multimodal chats.
691691
// Empty for sessions whose opening message had no image.
692-
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
692+
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
693+
// ChatterUserID is the actual conversation participant (app_user
694+
// for IM channels). Differs from user_id when an IM sender is
695+
// resolved to a per-sender app_user under the channel owner's space.
696+
ChatterUserID string `json:"chatterUserId,omitempty"`
693697
}
694698

695699
// ListWebSessions scans session files for web chat sessions and returns

internal/session/store_adapter.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -298,16 +298,17 @@ func (a *StoreAdapter) ListWebSessions(ctx context.Context, agentID string) ([]W
298298
title = preview
299299
}
300300
sessions = append(sessions, WebSession{
301-
ID: m.Key,
302-
Channel: channel,
303-
AccountID: m.AccountID,
304-
ChatID: m.ChatID,
305-
ProjectID: m.ProjectID,
306-
Title: title,
307-
Preview: preview,
308-
ThumbnailURL: thumb,
309-
CreatedAt: m.UpdatedAt.UnixMilli(),
310-
UpdatedAt: m.UpdatedAt.UnixMilli(),
301+
ID: m.Key,
302+
Channel: channel,
303+
AccountID: m.AccountID,
304+
ChatID: m.ChatID,
305+
ProjectID: m.ProjectID,
306+
Title: title,
307+
Preview: preview,
308+
ThumbnailURL: thumb,
309+
CreatedAt: m.UpdatedAt.UnixMilli(),
310+
UpdatedAt: m.UpdatedAt.UnixMilli(),
311+
ChatterUserID: m.ChatterUserID,
311312
})
312313
}
313314
return sessions, nil

internal/setup/handlers.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,15 @@ func (s *Server) handleChats(w http.ResponseWriter, r *http.Request) {
16141614
"createdAt": ws.CreatedAt,
16151615
"updatedAt": ws.UpdatedAt,
16161616
}
1617+
if ws.ChatterUserID != "" {
1618+
entry["chatterUserId"] = ws.ChatterUserID
1619+
if chatter := resolveOwner(ws.ChatterUserID); chatter != nil {
1620+
entry["chatterExternalId"] = chatter.ExternalID
1621+
if chatter.DisplayName != "" {
1622+
entry["chatterDisplayName"] = chatter.DisplayName
1623+
}
1624+
}
1625+
}
16171626
if owner != nil {
16181627
entry["ownerUsername"] = owner.Username
16191628
entry["ownerEmail"] = owner.Email

internal/setup/handlers_admin.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,15 @@ func (s *Server) handleAdminChats(w http.ResponseWriter, r *http.Request) {
596596
"createdAt": ws.CreatedAt,
597597
"updatedAt": ws.UpdatedAt,
598598
}
599+
if ws.ChatterUserID != "" {
600+
entry["chatterUserId"] = ws.ChatterUserID
601+
if chatter := resolveOwner(ws.ChatterUserID); chatter != nil {
602+
entry["chatterExternalId"] = chatter.ExternalID
603+
if chatter.DisplayName != "" {
604+
entry["chatterDisplayName"] = chatter.DisplayName
605+
}
606+
}
607+
}
599608
if owner != nil {
600609
entry["ownerUsername"] = owner.Username
601610
entry["ownerEmail"] = owner.Email

internal/setup/handlers_scoped.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ func (s *Server) handleDeleteProvider(w http.ResponseWriter, r *http.Request) {
298298
if !s.authorizeScope(w, r, rec.LegacyScope(), rec.LegacyScopeID(), scopeWrite) {
299299
return
300300
}
301+
// Dual-delete from configs_kv.
302+
scope.DualDeleteProviderKV(r.Context(), s.dataStore, rec.UserID, rec.AgentID, rec.Name)
301303
if err := s.dataStore.DeleteConfig(r.Context(), id); err != nil {
302304
jsonResponse(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
303305
return

internal/store/database.go

Lines changed: 223 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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

23062324
func (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+
29663067
func (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

32453466
const 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`

internal/store/store.go

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,26 @@ type Store interface {
216216
DeleteConfig(ctx context.Context, id string) error
217217
LookupChannelByCredential(ctx context.Context, channelType, credKey string) (*ConfigRecord, error)
218218

219+
// --- Configs KV (single-value key-value pairs) ---
220+
//
221+
// configs_kv is the successor to the JSON-blob configs table. Each row
222+
// stores exactly one scalar value; the dotted name encodes the former
223+
// JSON hierarchy. During migration both tables are written (dual-write);
224+
// reads prefer configs_kv when populated.
225+
226+
// GetConfigValue returns a single config value.
227+
GetConfigValue(ctx context.Context, kind, scope, scopeID, name string) (string, error)
228+
// SetConfigValue sets a single config value (upsert).
229+
SetConfigValue(ctx context.Context, kind, scope, scopeID, name, value string) error
230+
// DeleteConfigValue deletes a single config value.
231+
DeleteConfigValue(ctx context.Context, kind, scope, scopeID, name string) error
232+
// ListConfigValues returns all config values matching a prefix.
233+
// Use name="" to get all values for a (kind, scope, scopeID).
234+
// Use name="sandbox." to get all sandbox.* values.
235+
ListConfigValues(ctx context.Context, kind, scope, scopeID, namePrefix string) (map[string]string, error)
236+
// DeleteConfigPrefix deletes all values matching a name prefix.
237+
DeleteConfigPrefix(ctx context.Context, kind, scope, scopeID, namePrefix string) error
238+
219239
// --- Channels (IM bot bindings) ---
220240
ListChannels(ctx context.Context, userID, agentID string) ([]ChannelRecord, error)
221241
ListAllChannels(ctx context.Context) ([]ChannelRecord, error)
@@ -441,14 +461,15 @@ type SessionOwnerPair struct {
441461

442462
// SessionMeta is summary info for a session (for listing).
443463
type SessionMeta struct {
444-
Key string `json:"key"`
445-
Channel string `json:"channel,omitempty"`
446-
AccountID string `json:"accountId,omitempty"`
447-
ChatID string `json:"chatId,omitempty"`
448-
ProjectID string `json:"projectId,omitempty"`
449-
Title string `json:"title,omitempty"`
450-
MessageCount int `json:"messageCount"`
451-
UpdatedAt time.Time `json:"updatedAt"`
464+
Key string `json:"key"`
465+
Channel string `json:"channel,omitempty"`
466+
AccountID string `json:"accountId,omitempty"`
467+
ChatID string `json:"chatId,omitempty"`
468+
ProjectID string `json:"projectId,omitempty"`
469+
Title string `json:"title,omitempty"`
470+
MessageCount int `json:"messageCount"`
471+
UpdatedAt time.Time `json:"updatedAt"`
472+
ChatterUserID string `json:"chatterUserId,omitempty"`
452473
}
453474

454475
// ProjectRecord is a per-(user, agent) named workspace folder. Sessions

0 commit comments

Comments
 (0)