diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index b4319fb..5dcc345 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -21,7 +21,6 @@ import { import { flattenObject, unflattenObject, - serializeKVPairs, deepSerializeKVPairs, normalizeImportConfig, hasConfigCapability, @@ -31,7 +30,7 @@ import { } from '@/utils'; import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; -import { mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; +import { applyConfigEdit, mergeIndexedArrayEdits, partitionScopeResetPaths } from './utils'; import { validateMcpCrossField } from './sections/McpServersRenderer'; import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; import { StickyActionBar } from '@/components/shared'; @@ -385,51 +384,14 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi return next; }); setEditedValues((prev) => { - const baseline = scopeBaseline[path]; - const match = - value === baseline || - (typeof value === 'object' && - typeof baseline === 'object' && - JSON.stringify(value) === JSON.stringify(baseline)); - /** A container-path undefined write must survive; baseline only stores leaves, so it would otherwise match `undefined === undefined` and get pruned. baselineContainerPaths catches the orphaned empty-object case where leaf-derived intermediates miss the entry. */ - const isContainerDelete = - value === undefined && - (baselineIntermediates.has(path) || baselineContainerPaths.has(path)); - /** When the user deleted a container entry and is now writing descendants under it (delete-then-recreate of an MCP server), the new leaf must persist even if it matches baseline so the post-DELETE recreate is not missing required fields, and the ancestor-undefined must outlive the descendant write so handleConfirmSave can DELETE the entry before PATCHing the new leaves. Walk ancestors directly instead of scanning every pending edit; rename/remove emit one onChange per leaf and the prior O(n)-per-call scan compounded to O(n*m) work per event. */ - const hasPendingAncestorDelete = (() => { - let lastDot = path.lastIndexOf('.'); - while (lastDot > 0) { - const ancestor = path.slice(0, lastDot); - if (ancestor in prev && prev[ancestor] === undefined) return true; - lastDot = ancestor.lastIndexOf('.'); - } - return false; - })(); - if (match && !isContainerDelete && !hasPendingAncestorDelete) { - const next = { ...prev }; - delete next[path]; - return next; - } - const next = { ...prev, [path]: value }; - if (Array.isArray(value)) { - const prefix = `${path}.`; - for (const k of Object.keys(next)) { - if (k.startsWith(prefix) && /\.\d+$/.test(k)) delete next[k]; - } - } - const indexMatch = /^(.+)\.\d+$/.exec(path); - if (indexMatch) delete next[indexMatch[1]]; - /** Two-way dedup: drop ancestors that the new leaf supersedes AND descendants that the new parent supersedes. An ancestor whose value is `undefined` expresses "delete this whole subtree" and must outlive subsequent descendant writes so DELETE-then-PATCH ordering at save time can fully replace the entry instead of leaking stale fields. */ - for (const existing of Object.keys(next)) { - if (existing === path) continue; - const newIsDescendant = path.startsWith(`${existing}.`); - const newIsAncestor = existing.startsWith(`${path}.`); - if (newIsDescendant && next[existing] === undefined) continue; - if (newIsDescendant || newIsAncestor) { - delete next[existing]; - } - } - return next; + return applyConfigEdit( + prev, + path, + value, + scopeBaseline, + baselineIntermediates, + baselineContainerPaths, + ); }); }, [scopeBaseline, baselineIntermediates, baselineContainerPaths], @@ -574,9 +536,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi .filter((p) => editedValues[p] !== undefined) .map((p) => ({ fieldPath: p, - value: /\.\d+$/.test(p) - ? deepSerializeKVPairs(editedValues[p]) - : serializeKVPairs(editedValues[p]), + value: deepSerializeKVPairs(editedValues[p]), })); const resets = touched.filter((p) => editedValues[p] === undefined); const inheritedMcpKeys = (() => { @@ -668,7 +628,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const serializedEditedValues = useMemo(() => { const result: t.FlatConfigMap = {}; for (const [k, v] of Object.entries(editedValues)) { - result[k] = /\.\d+$/.test(k) ? deepSerializeKVPairs(v) : serializeKVPairs(v); + result[k] = deepSerializeKVPairs(v); } return result; }, [editedValues]); diff --git a/src/components/configuration/utils.test.ts b/src/components/configuration/utils.test.ts index 8ec0398..7921d2a 100644 --- a/src/components/configuration/utils.test.ts +++ b/src/components/configuration/utils.test.ts @@ -6,6 +6,7 @@ import { splitUnionTypes, partitionScopeResetPaths, mergeIndexedArrayEdits, + applyConfigEdit, } from './utils'; import { createField } from '@/test/fixtures'; @@ -270,10 +271,9 @@ describe('mergeIndexedArrayEdits', () => { }); it('merges into an existing parent without clobbering its keys', () => { - const merged = mergeIndexedArrayEdits( - { modelSpecs: { enforce: true, prioritize: false } }, - [['modelSpecs.list.0', { name: 'a' }]], - ); + const merged = mergeIndexedArrayEdits({ modelSpecs: { enforce: true, prioritize: false } }, [ + ['modelSpecs.list.0', { name: 'a' }], + ]); expect(merged.modelSpecs).toEqual({ enforce: true, prioritize: false, @@ -325,23 +325,68 @@ describe('mergeIndexedArrayEdits', () => { }); it('walks deep parent chains, creating each missing level', () => { - const merged = mergeIndexedArrayEdits({}, [ - ['endpoints.custom.deep.list.0', { name: 'x' }], - ]); + const merged = mergeIndexedArrayEdits({}, [['endpoints.custom.deep.list.0', { name: 'x' }]]); expect(merged).toEqual({ endpoints: { custom: { deep: { list: [{ name: 'x' }] } } }, }); }); }); +describe('applyConfigEdit', () => { + it('updates a pending whole-array edit when a newly-added entry is typed into', () => { + const prev = { + 'modelSpecs.list': [{}, { name: 'smart-assistant' }], + }; + const result = applyConfigEdit( + prev, + 'modelSpecs.list.0', + { name: 'TEST1' }, + {}, + new Set(), + new Set(), + ); + expect(result).toEqual({ + 'modelSpecs.list': [{ name: 'TEST1' }, { name: 'smart-assistant' }], + }); + expect(result).not.toHaveProperty('modelSpecs.list.0'); + }); + + it('keeps per-index edits when no parent array edit is pending', () => { + const result = applyConfigEdit( + {}, + 'modelSpecs.list.0', + { name: 'TEST1' }, + {}, + new Set(), + new Set(), + ); + expect(result).toEqual({ + 'modelSpecs.list.0': { name: 'TEST1' }, + }); + }); + + it('drops stale indexed edits when a whole-array edit is queued', () => { + const result = applyConfigEdit( + { 'modelSpecs.list.0': { name: 'old' } }, + 'modelSpecs.list', + [{ name: 'new' }], + {}, + new Set(), + new Set(), + ); + expect(result).toEqual({ + 'modelSpecs.list': [{ name: 'new' }], + }); + }); +}); + describe('partitionScopeResetPaths', () => { it('routes whole MCP entry resets to tombstones', () => { expect( - partitionScopeResetPaths([ - 'mcpServers.github', - 'mcpServers.github.url', - 'interface.modelSelect', - ], new Set(['github'])), + partitionScopeResetPaths( + ['mcpServers.github', 'mcpServers.github.url', 'interface.modelSelect'], + new Set(['github']), + ), ).toEqual({ resetPaths: ['mcpServers.github.url', 'interface.modelSelect'], tombstonePaths: ['mcpServers.github'], @@ -350,10 +395,10 @@ describe('partitionScopeResetPaths', () => { it('routes whole MCP entry resets to unsets when the entry is scope-local', () => { expect( - partitionScopeResetPaths([ - 'mcpServers.scopeOnly', - 'mcpServers.inherited', - ], new Set(['inherited'])), + partitionScopeResetPaths( + ['mcpServers.scopeOnly', 'mcpServers.inherited'], + new Set(['inherited']), + ), ).toEqual({ resetPaths: ['mcpServers.scopeOnly'], tombstonePaths: ['mcpServers.inherited'], @@ -362,12 +407,10 @@ describe('partitionScopeResetPaths', () => { it('preserves input order within reset and tombstone groups', () => { expect( - partitionScopeResetPaths([ - 'mcpServers.alpha', - 'registration.enabled', - 'mcpServers.beta', - 'endpoints.custom.0', - ], new Set(['alpha', 'beta'])), + partitionScopeResetPaths( + ['mcpServers.alpha', 'registration.enabled', 'mcpServers.beta', 'endpoints.custom.0'], + new Set(['alpha', 'beta']), + ), ).toEqual({ resetPaths: ['registration.enabled', 'endpoints.custom.0'], tombstonePaths: ['mcpServers.alpha', 'mcpServers.beta'], diff --git a/src/components/configuration/utils.ts b/src/components/configuration/utils.ts index a9ca17d..729159b 100644 --- a/src/components/configuration/utils.ts +++ b/src/components/configuration/utils.ts @@ -1,5 +1,7 @@ import type * as t from '@/types'; +const INDEXED_ARRAY_PATH_RE = /^(.+)\.(\d+)$/; + export function inferKVType(v: t.ConfigValue): t.KVValueType { if (typeof v === 'boolean') return 'boolean'; if (typeof v === 'number') return 'number'; @@ -249,6 +251,72 @@ export function partitionScopeResetPaths( return { resetPaths, tombstonePaths }; } +export function applyConfigEdit( + prev: t.FlatConfigMap, + path: string, + value: t.ConfigValue, + baseline: t.FlatConfigMap, + baselineIntermediates: Set, + baselineContainerPaths: Set, +): t.FlatConfigMap { + const indexMatch = INDEXED_ARRAY_PATH_RE.exec(path); + if (indexMatch) { + const [, arrayPath, indexStr] = indexMatch; + const pendingArray = prev[arrayPath]; + if (Array.isArray(pendingArray)) { + const next = { ...prev }; + const arr = [...pendingArray]; + arr[Number(indexStr)] = value; + next[arrayPath] = arr; + for (const existing of Object.keys(next)) { + if (existing.startsWith(`${arrayPath}.`)) delete next[existing]; + } + return next; + } + } + + const baselineValue = baseline[path]; + const match = + value === baselineValue || + (typeof value === 'object' && + typeof baselineValue === 'object' && + JSON.stringify(value) === JSON.stringify(baselineValue)); + const isContainerDelete = + value === undefined && (baselineIntermediates.has(path) || baselineContainerPaths.has(path)); + const hasPendingAncestorDelete = (() => { + let lastDot = path.lastIndexOf('.'); + while (lastDot > 0) { + const ancestor = path.slice(0, lastDot); + if (ancestor in prev && prev[ancestor] === undefined) return true; + lastDot = ancestor.lastIndexOf('.'); + } + return false; + })(); + if (match && !isContainerDelete && !hasPendingAncestorDelete) { + const next = { ...prev }; + delete next[path]; + return next; + } + const next = { ...prev, [path]: value }; + if (Array.isArray(value)) { + const prefix = `${path}.`; + for (const k of Object.keys(next)) { + if (k.startsWith(prefix) && INDEXED_ARRAY_PATH_RE.test(k)) delete next[k]; + } + } + if (indexMatch) delete next[indexMatch[1]]; + for (const existing of Object.keys(next)) { + if (existing === path) continue; + const newIsDescendant = path.startsWith(`${existing}.`); + const newIsAncestor = existing.startsWith(`${path}.`); + if (newIsDescendant && next[existing] === undefined) continue; + if (newIsDescendant || newIsAncestor) { + delete next[existing]; + } + } + return next; +} + /** * Merge indexed-array edits (entries whose flat path ends in `.`) into * a config tree. Each indexed edit's value replaces the array element at that diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 67a04a1..ec25c06 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -14,6 +14,11 @@ import { flattenTree, resolveSubSchema, validateFieldValue, + parseIndexedArrayPath, + toConfigArraySource, + normalizeAppServiceKeys, + mergeConfigArraySources, + mergeIndexedArrayEntriesIntoBase, } from './config'; interface ZodV3Schema extends t.ZodSchemaLike { @@ -677,9 +682,9 @@ describe('validateFieldValue', () => { }); it('validates a nested field reached through a union (header value must be string)', () => { - expect( - validateFieldValue('mcpServers.foo.headers.Authorization', 'Bearer xyz'), - ).toEqual({ success: true }); + expect(validateFieldValue('mcpServers.foo.headers.Authorization', 'Bearer xyz')).toEqual({ + success: true, + }); const bad = validateFieldValue('mcpServers.foo.headers.Authorization', 42); expect(bad.success).toBe(false); }); @@ -1034,6 +1039,169 @@ describe('resolveSubSchema for endpoints', () => { }); }); +describe('parseIndexedArrayPath', () => { + it('accepts numeric suffixes when the parent path is an array', () => { + expect(parseIndexedArrayPath('endpoints.custom.0')).toEqual({ + arrayPath: 'endpoints.custom', + index: 0, + }); + }); + + it('rejects numeric suffixes when the parent path is a record', () => { + expect(parseIndexedArrayPath('mcpServers.foo.headers.2024')).toBeNull(); + }); +}); + +describe('toConfigArraySource', () => { + it('converts legacy numeric-key array objects to arrays', () => { + expect( + toConfigArraySource({ + 0: { name: 'first' }, + 2: { name: 'third' }, + }), + ).toEqual([{ name: 'first' }, undefined, { name: 'third' }]); + }); + + it('rejects non-index object keys', () => { + expect(toConfigArraySource({ 0: 'zero', current: 'not-array' })).toBeUndefined(); + }); +}); + +describe('mergeConfigArraySources', () => { + it('overlays legacy numeric-key overrides onto inherited arrays', () => { + expect( + mergeConfigArraySources( + [ + { name: 'base-a', baseURL: 'https://a.example.com' }, + { name: 'base-b', baseURL: 'https://b.example.com' }, + { name: 'base-c', baseURL: 'https://c.example.com' }, + ], + { + 1: { name: 'scope-b', baseURL: 'https://scope.example.com' }, + }, + undefined, + ), + ).toEqual([ + { name: 'base-a', baseURL: 'https://a.example.com' }, + { name: 'scope-b', baseURL: 'https://scope.example.com' }, + { name: 'base-c', baseURL: 'https://c.example.com' }, + ]); + }); + + it('treats real arrays as complete overrides', () => { + expect(mergeConfigArraySources(['base-a', 'base-b'], ['scope-only'], undefined)).toEqual([ + 'scope-only', + ]); + }); + + it('applies pending numeric-key entries after scoped overlays', () => { + expect( + mergeConfigArraySources(['base-a', 'base-b', 'base-c'], { 1: 'scope-b' }, { 2: 'pending-c' }), + ).toEqual(['base-a', 'scope-b', 'pending-c']); + }); +}); + +describe('normalizeAppServiceKeys', () => { + it('maps MCP AppService output to canonical mcpServers records', () => { + const normalized = normalizeAppServiceKeys({ + mcpConfig: { + filesystem: { + type: 'stdio', + args: ['server.js', '--root', '/tmp'], + }, + }, + }); + expect(normalized).not.toHaveProperty('mcpConfig'); + expect(normalized.mcpServers).toEqual({ + filesystem: { + type: 'stdio', + args: ['server.js', '--root', '/tmp'], + }, + }); + }); + + it('maps Azure groupMap output to canonical groups arrays', () => { + const normalized = normalizeAppServiceKeys({ + endpoints: { + azureOpenAI: { + isValid: true, + groupMap: { + default: { apiKey: 'key-a', instanceName: 'instance-a' }, + fallback: { apiKey: 'key-b', instanceName: 'instance-b' }, + }, + errors: ['ignored'], + modelNames: ['ignored'], + }, + }, + }); + expect(normalized.endpoints).toEqual({ + azureOpenAI: { + groups: [ + { group: 'default', apiKey: 'key-a', instanceName: 'instance-a' }, + { group: 'fallback', apiKey: 'key-b', instanceName: 'instance-b' }, + ], + }, + }); + }); +}); + +describe('mergeIndexedArrayEntriesIntoBase', () => { + it('merges indexed MCP array edits from AppService fallback aliases', () => { + const mergedPaths = new Set(); + const result = mergeIndexedArrayEntriesIntoBase( + [{ fieldPath: 'mcpServers.filesystem.args.1', value: '--workspace' }], + { + mcpConfig: { + filesystem: { + type: 'stdio', + args: ['server.js', '--root', '/tmp'], + }, + }, + }, + mergedPaths, + ); + + expect(result).toEqual([ + { + fieldPath: 'mcpServers.filesystem.args', + value: ['server.js', '--workspace', '/tmp'], + }, + ]); + expect(mergedPaths.has('mcpServers.filesystem.args')).toBe(true); + }); + + it('preserves legacy numeric-key array objects while merging', () => { + const result = mergeIndexedArrayEntriesIntoBase( + [ + { + fieldPath: 'endpoints.custom.1', + value: { name: 'edited', baseURL: 'https://edited.example.com' }, + }, + ], + { + endpoints: { + custom: { + 0: { name: 'first', baseURL: 'https://first.example.com' }, + 1: { name: 'second', baseURL: 'https://second.example.com' }, + 2: { name: 'third', baseURL: 'https://third.example.com' }, + }, + }, + }, + ); + + expect(result).toEqual([ + { + fieldPath: 'endpoints.custom', + value: [ + { name: 'first', baseURL: 'https://first.example.com' }, + { name: 'edited', baseURL: 'https://edited.example.com' }, + { name: 'third', baseURL: 'https://third.example.com' }, + ], + }, + ]); + }); +}); + describe('validateFieldValue for endpoints', () => { const validEndpoint = { name: 'TestEndpoint', diff --git a/src/server/config.ts b/src/server/config.ts index 9acf71d..b568b1e 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -30,6 +30,9 @@ const WRAPPER_TYPES = new Set([ 'ZodPipeline', ]); +const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; +const ARRAY_INDEX_KEY_RE = /^(0|[1-9]\d*)$/; + function unwrapSchema(schema: t.ZodSchemaLike): t.ZodSchemaLike { const seen = new Set(); let current = schema; @@ -583,6 +586,62 @@ export function validateFieldValue( return { success: true }; } +export function parseIndexedArrayPath( + fieldPath: string, +): { arrayPath: string; index: number } | null { + const match = INDEXED_ARRAY_RE.exec(fieldPath); + if (!match) return null; + const [, arrayPath, indexStr] = match; + const schema = resolveSubSchema(configSchema as t.ZodSchemaLike, arrayPath.split('.')); + if (!schema) return null; + const unwrapped = unwrapSchema(schema); + if (unwrapped?._def?.typeName !== 'ZodArray') return null; + return { arrayPath, index: Number(indexStr) }; +} + +export function toConfigArraySource(value: unknown): unknown[] | undefined { + if (Array.isArray(value)) return [...value]; + return toIndexedArrayObjectSource(value); +} + +export function toIndexedArrayObjectSource(value: unknown): unknown[] | undefined { + if (!value || typeof value !== 'object') return undefined; + if (Array.isArray(value)) return undefined; + const arrayValue: unknown[] = []; + for (const [key, entryValue] of Object.entries(value as Record)) { + if (!ARRAY_INDEX_KEY_RE.test(key)) return undefined; + const index = Number(key); + if (!Number.isSafeInteger(index)) return undefined; + arrayValue[index] = entryValue; + } + return arrayValue; +} + +function overlayArraySource(source: unknown[], overlay: unknown[]): unknown[] { + const result = [...source]; + for (const key of Object.keys(overlay)) { + const index = Number(key); + result[index] = overlay[index]; + } + return result; +} + +function mergeConfigArraySource(source: unknown[], value: unknown): unknown[] { + if (Array.isArray(value)) return [...value]; + const overlay = toIndexedArrayObjectSource(value); + return overlay ? overlayArraySource(source, overlay) : source; +} + +export function mergeConfigArraySources( + baseValue: unknown, + overrideValue: unknown, + pendingValue: unknown, +): unknown[] { + const baseSource = toConfigArraySource(baseValue) ?? []; + const overrideSource = mergeConfigArraySource(baseSource, overrideValue); + return mergeConfigArraySource(overrideSource, pendingValue); +} + /** Shared queryOptions for the schema tree used by command palette search. */ export const configSchemaTreeOptions = queryOptions({ queryKey: ['configSchemaTree'], @@ -777,7 +836,7 @@ function normalizeEndpointValue(value: t.ConfigValue): t.ConfigValue { return value; } -function normalizeAppServiceKeys( +export function normalizeAppServiceKeys( raw: Record, ): Record { const result: Record = {}; @@ -871,37 +930,31 @@ export const baseConfigOptions = queryOptions({ staleTime: 30_000, }); -const INDEXED_ARRAY_RE = /^(.+)\.(\d+)$/; - -async function mergeIndexedArrayEntries( +export function mergeIndexedArrayEntriesIntoBase( entries: Array<{ fieldPath: string; value: unknown }>, + baseConfig: Record, mergedPaths?: Set, -): Promise> { +): Array<{ fieldPath: string; value: unknown }> { const indexed = new Map>(); const rest: Array<{ fieldPath: string; value: unknown }> = []; for (const entry of entries) { - const match = INDEXED_ARRAY_RE.exec(entry.fieldPath); - if (match) { - const [, arrayPath, indexStr] = match; + const parsed = parseIndexedArrayPath(entry.fieldPath); + if (parsed) { + const { arrayPath, index } = parsed; if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map()); - indexed.get(arrayPath)!.set(Number(indexStr), entry.value); + indexed.get(arrayPath)!.set(index, entry.value); } else { rest.push(entry); } } if (indexed.size === 0) return entries; - - const baseResponse = await apiFetch('/api/admin/config/base'); - if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`); - const { config: baseConfig } = (await baseResponse.json()) as { - config: Record; - }; + const normalizedBaseConfig = normalizeAppServiceKeys(baseConfig); for (const [arrayPath, updates] of indexed) { const segments = arrayPath.split('.'); - let current: unknown = baseConfig; + let current: unknown = normalizedBaseConfig; for (const seg of segments) { if (current == null || typeof current !== 'object') { current = undefined; @@ -909,7 +962,7 @@ async function mergeIndexedArrayEntries( } current = (current as Record)[seg]; } - const arr = Array.isArray(current) ? [...current] : []; + const arr = toConfigArraySource(current) ?? []; for (const [idx, value] of updates) { arr[idx] = value; } @@ -920,6 +973,26 @@ async function mergeIndexedArrayEntries( return rest; } +function hasIndexedArrayEntry(entries: Array<{ fieldPath: string; value: unknown }>): boolean { + for (const entry of entries) { + if (parseIndexedArrayPath(entry.fieldPath)) return true; + } + return false; +} + +async function mergeIndexedArrayEntries( + entries: Array<{ fieldPath: string; value: unknown }>, + mergedPaths?: Set, +): Promise> { + if (!hasIndexedArrayEntry(entries)) return entries; + const baseResponse = await apiFetch('/api/admin/config/base'); + if (!baseResponse.ok) throw new Error(`Failed to fetch base config: ${baseResponse.status}`); + const { config: baseConfig } = (await baseResponse.json()) as { + config: Record; + }; + return mergeIndexedArrayEntriesIntoBase(entries, baseConfig, mergedPaths); +} + export const saveBaseConfigFn = createServerFn({ method: 'POST' }) .inputValidator( z.object({ diff --git a/src/server/scopes.ts b/src/server/scopes.ts index 6d970bf..12a2e93 100644 --- a/src/server/scopes.ts +++ b/src/server/scopes.ts @@ -21,6 +21,7 @@ import { BASE_CONFIG_PRINCIPAL_ID } from './constants'; import { requireAnyCapability } from './capabilities'; import { safeFieldPath } from './utils/validation'; import { apiFetch } from './utils/api'; +import { normalizeAppServiceKeys, parseIndexedArrayPath, mergeConfigArraySources } from './config'; // ── Dot-path helpers ───────────────────────────────────────────────── @@ -34,6 +35,75 @@ function deepGet(obj: object, path: string): unknown { return current; } +async function getScopeOverrides( + apiType: PrincipalType, + principalId: string, +): Promise> { + const response = await apiFetch( + `/api/admin/config/${apiType}/${encodeURIComponent(principalId)}`, + ); + if (response.status === 404) return {}; + if (!response.ok) throw new Error(`Failed to fetch config: ${response.status}`); + const { config } = (await response.json()) as AdminConfigResponse; + return normalizeAppServiceKeys((config.overrides ?? {}) as Record); +} + +async function getBaseConfig(): Promise> { + const response = await apiFetch('/api/admin/config/base'); + if (!response.ok) throw new Error(`Failed to fetch base config: ${response.status}`); + const { config } = (await response.json()) as { config: Record }; + return normalizeAppServiceKeys(config); +} + +async function mergeIndexedArrayEntriesForScope( + apiType: PrincipalType, + principalId: string, + entries: Array<{ fieldPath: string; value: unknown }>, +): Promise> { + const indexed = new Map>(); + const rest: Array<{ fieldPath: string; value: unknown }> = []; + const restByPath = new Map(); + + for (const entry of entries) { + const parsed = parseIndexedArrayPath(entry.fieldPath); + if (!parsed) { + restByPath.set(entry.fieldPath, rest.length); + rest.push(entry); + continue; + } + const { arrayPath, index } = parsed; + if (!indexed.has(arrayPath)) indexed.set(arrayPath, new Map()); + indexed.get(arrayPath)!.set(index, entry.value); + } + + if (indexed.size === 0) return entries; + + const [scopeOverrides, baseConfig] = await Promise.all([ + getScopeOverrides(apiType, principalId), + getBaseConfig(), + ]); + + for (const [arrayPath, updates] of indexed) { + const restIndex = restByPath.get(arrayPath); + const pending = restIndex === undefined ? undefined : rest[restIndex]?.value; + const scopeValue = deepGet(scopeOverrides, arrayPath); + const baseValue = deepGet(baseConfig, arrayPath); + const arr = mergeConfigArraySources(baseValue, scopeValue, pending); + for (const [idx, value] of updates) { + arr[idx] = value; + } + const merged = { fieldPath: arrayPath, value: arr }; + if (restIndex === undefined) { + restByPath.set(arrayPath, rest.length); + rest.push(merged); + } else { + rest[restIndex] = merged; + } + } + + return rest; +} + // ── API helpers ────────────────────────────────────────────────────── function apiConfigToScope(config: AdminConfig, nameMap?: Map): t.ConfigScope { @@ -209,13 +279,14 @@ export const saveFieldProfileValueFn = createServerFn({ method: 'POST' }) ]); if (isInterfacePermissionPath(data.fieldPath)) return { success: true }; const apiType = data.principalType; + const entries = await mergeIndexedArrayEntriesForScope(apiType, data.principalId, [ + { fieldPath: data.fieldPath, value: data.value }, + ]); const response = await apiFetch( `/api/admin/config/${apiType}/${encodeURIComponent(data.principalId)}/fields`, { method: 'PATCH', - body: JSON.stringify({ - entries: [{ fieldPath: data.fieldPath, value: data.value }], - }), + body: JSON.stringify({ entries }), }, ); @@ -263,11 +334,12 @@ export const bulkSaveProfileValuesFn = createServerFn({ method: 'POST' }) const filtered = data.entries.filter((e) => !isInterfacePermissionPath(e.fieldPath)); if (filtered.length === 0) return { success: true, count: 0 }; const apiType = data.principalType; + const entries = await mergeIndexedArrayEntriesForScope(apiType, data.principalId, filtered); const response = await apiFetch( `/api/admin/config/${apiType}/${encodeURIComponent(data.principalId)}/fields`, { method: 'PATCH', - body: JSON.stringify({ entries: filtered }), + body: JSON.stringify({ entries }), }, ); @@ -277,7 +349,7 @@ export const bulkSaveProfileValuesFn = createServerFn({ method: 'POST' }) (err as { error?: string }).error ?? `Failed to save fields: ${response.status}`, ); } - return { success: true, count: filtered.length }; + return { success: true, count: entries.length }; }, ); diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts index 8dd0d32..19843bc 100644 --- a/src/utils/format.test.ts +++ b/src/utils/format.test.ts @@ -12,16 +12,12 @@ describe('serializeKVPairs', () => { }); it('handles json valueType by parsing JSON string', () => { - const pairs = [ - { key: 'config', value: '{"nested": true}', valueType: 'json' as const }, - ]; + const pairs = [{ key: 'config', value: '{"nested": true}', valueType: 'json' as const }]; expect(serializeKVPairs(pairs)).toEqual({ config: { nested: true } }); }); it('falls back to string for invalid json', () => { - const pairs = [ - { key: 'bad', value: '{not json', valueType: 'json' as const }, - ]; + const pairs = [{ key: 'bad', value: '{not json', valueType: 'json' as const }]; expect(serializeKVPairs(pairs)).toEqual({ bad: '{not json' }); }); @@ -77,9 +73,7 @@ describe('deepSerializeKVPairs', () => { it('serializes KV pairs with json type in nested objects', () => { const value = { name: 'Test', - addParams: [ - { key: 'config', value: '{"nested": {"deep": true}}', valueType: 'json' }, - ], + addParams: [{ key: 'config', value: '{"nested": {"deep": true}}', valueType: 'json' }], }; const result = deepSerializeKVPairs(value) as Record; expect(result.addParams).toEqual({ config: { nested: { deep: true } } }); @@ -95,11 +89,24 @@ describe('deepSerializeKVPairs', () => { expect(models.fetch).toBe(true); }); + it('serializes KV pairs inside array object entries', () => { + const value = [ + { + name: 'TestAPI', + headers: [{ key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }], + }, + ]; + expect(deepSerializeKVPairs(value)).toEqual([ + { + name: 'TestAPI', + headers: { Authorization: 'Bearer ${TOKEN}' }, + }, + ]); + }); + it('handles headers (string-only record) correctly', () => { const value = { - headers: [ - { key: 'x-api-key', value: '${KEY}', valueType: 'string' }, - ], + headers: [{ key: 'x-api-key', value: '${KEY}', valueType: 'string' }], }; const result = deepSerializeKVPairs(value) as Record; expect(result.headers).toEqual({ 'x-api-key': '${KEY}' }); @@ -120,9 +127,7 @@ describe('deepSerializeKVPairs', () => { models: { default: ['gpt-4'], fetch: true }, titleConvo: true, titleModel: 'current_model', - headers: [ - { key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }, - ], + headers: [{ key: 'Authorization', value: 'Bearer ${TOKEN}', valueType: 'string' }], addParams: [ { key: 'stream', value: 'true', valueType: 'boolean' }, { key: 'config', value: '{"key": "value"}', valueType: 'json' },