connect.resolver.ts254 lines · main
1import type {
2 ConditionalValue,
3 ConnectSchema,
4 ConnectState,
5 FieldOption,
6 ResolvedField,
7 ResolvedStep,
8 StepDefinition,
9 StepFieldValueMap,
10 StepTree,
11} from './Connect.types'
12
13/**
14 * The order in which state keys are checked during conditional value resolution.
15 * Used for ConditionalValue (value-keyed) resolution, not for step trees.
16 */
17const STATE_KEY_ORDER = [
18 'mode',
19 'framework',
20 'frameworkVariant',
21 'library',
22 'frameworkUi',
23 'orm',
24 'connectionMethod',
25 'connectionType',
26 'mcpClient',
27] as const
28
29/**
30 * Check if a value is a conditional object (has nested state keys or DEFAULT)
31 */
32function isConditionalObject(value: unknown): value is Record<string, unknown> {
33 return typeof value === 'object' && value !== null && !Array.isArray(value)
34}
35
36/**
37 * Resolves a conditional value based on current state.
38 * Walks the tree using stateKeys in order, falling back to DEFAULT at each level.
39 *
40 * Example: Given state { mode: 'mcp', mcpClient: 'codex' }
41 * and stateKeys ['mode', 'framework', ..., 'mcpClient']
42 *
43 * 1. Look up 'mcp' (state.mode value) in tree -> found, continue
44 * 2. At mcp subtree { codex: [...], DEFAULT: [...] }, skip irrelevant keys
45 * until we find a key whose state value matches an entry in the object
46 * 3. Look up 'codex' (state.mcpClient value) in that subtree -> found, return value
47 * 4. If no state key matches, try DEFAULT at that level
48 */
49export function resolveConditional<T>(
50 value: ConditionalValue<T>,
51 state: ConnectState,
52 stateKeys: readonly string[] = STATE_KEY_ORDER
53): T | undefined {
54 // Base case: we've reached a leaf value (string, array, null, boolean, etc.)
55 if (!isConditionalObject(value)) {
56 return value as T
57 }
58
59 const conditionalObj = value as Record<string, ConditionalValue<T>>
60 const objectKeys = Object.keys(conditionalObj).filter((k) => k !== 'DEFAULT')
61
62 // Try each state key in order to find one that matches an entry in the object
63 for (let i = 0; i < stateKeys.length; i++) {
64 const currentKey = stateKeys[i]
65 const stateValue = String(state[currentKey] ?? '')
66
67 // If this state value matches a key in the conditional object, use it
68 if (stateValue && objectKeys.includes(stateValue)) {
69 const nextValue = conditionalObj[stateValue]
70 // Continue resolving with remaining keys (after this one)
71 return resolveConditional(nextValue, state, stateKeys.slice(i + 1))
72 }
73 }
74
75 // No state key matched - use DEFAULT if available
76 if (conditionalObj.DEFAULT !== undefined) {
77 return resolveConditional(conditionalObj.DEFAULT, state, stateKeys)
78 }
79
80 return undefined
81}
82
83/**
84 * Resolves the steps array based on current state.
85 * Returns only steps that have non-null content.
86 */
87export function resolveSteps(schema: ConnectSchema, state: ConnectState): ResolvedStep[] {
88 const steps = resolveStepTree(schema.steps, state)
89 if (steps.length === 0) return []
90
91 return steps
92 .map((step) => {
93 const content = resolveConditional<string | null>(step.content, state)
94 return {
95 id: step.id,
96 title: step.title,
97 description: step.description,
98 content: content ?? '',
99 }
100 })
101 .filter((step) => step.content !== '' && step.content !== null)
102}
103
104/**
105 * Resolves a step tree by evaluating field-specific branches in insertion order.
106 * Each matching branch appends its steps to the final list.
107 */
108function resolveStepTree(tree: StepTree, state: ConnectState): StepDefinition[] {
109 if (Array.isArray(tree)) return tree
110 if (!isConditionalObject(tree)) return []
111
112 const resolved: StepDefinition[] = []
113
114 for (const [fieldId, valueMap] of Object.entries(tree)) {
115 if (fieldId === 'DEFAULT') continue
116 if (!isConditionalObject(valueMap)) continue
117
118 const branch = resolveStepBranch(valueMap as StepFieldValueMap, state[fieldId])
119 if (!branch) continue
120
121 resolved.push(...resolveStepTree(branch, state))
122 }
123
124 return resolved
125}
126
127function resolveStepBranch(
128 valueMap: StepFieldValueMap,
129 stateValue: ConnectState[keyof ConnectState] | undefined
130): StepTree | undefined {
131 const key = String(stateValue ?? '')
132 if (key && Object.prototype.hasOwnProperty.call(valueMap, key)) {
133 return valueMap[key]
134 }
135
136 if (valueMap.DEFAULT !== undefined) {
137 return valueMap.DEFAULT
138 }
139
140 return undefined
141}
142
143/**
144 * Gets the active fields for the current mode, filtering by dependsOn conditions.
145 */
146export function getActiveFields(schema: ConnectSchema, state: ConnectState): ResolvedField[] {
147 const currentMode = schema.modes.find((m) => m.id === state.mode)
148 if (!currentMode) return []
149
150 return currentMode.fields
151 .map((fieldId) => schema.fields[fieldId])
152 .filter((field): field is NonNullable<typeof field> => !!field)
153 .filter((field) => {
154 // Check dependsOn conditions
155 if (!field.dependsOn) return true
156 return Object.entries(field.dependsOn).every(([key, values]) => {
157 const stateValue = String(state[key] ?? '')
158 return values.includes(stateValue)
159 })
160 })
161 .map((field) => ({
162 ...field,
163 resolvedOptions: resolveFieldOptions(field, state),
164 }))
165}
166
167/**
168 * Resolves field options based on current state.
169 */
170function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): FieldOption[] {
171 if (!field.options) return []
172
173 // Static options array
174 if (Array.isArray(field.options)) {
175 return field.options
176 }
177
178 // Reference to data source (handled elsewhere)
179 if (
180 typeof field.options === 'object' &&
181 'source' in field.options &&
182 typeof field.options.source === 'string'
183 ) {
184 // This will be resolved by the component using getFieldOptionsFromSource
185 return []
186 }
187
188 // Conditional options
189 const resolved = resolveConditional<FieldOption[]>(
190 field.options as ConditionalValue<FieldOption[]>,
191 state
192 )
193 return resolved ?? []
194}
195
196/**
197 * Gets default state for the schema, using first mode and default field values.
198 */
199export function getDefaultState({ schema }: { schema: ConnectSchema }): ConnectState {
200 const defaultMode = schema.modes[0]?.id ?? 'direct'
201
202 const state: ConnectState = { mode: defaultMode }
203
204 // Set default values for all fields
205 Object.values(schema.fields).forEach((field) => {
206 if (field.defaultValue !== undefined) {
207 state[field.id] = field.defaultValue
208 }
209 })
210
211 return state
212}
213
214/**
215 * Resets dependent fields when a parent field changes.
216 * For example, changing framework should reset frameworkVariant.
217 */
218export function resetDependentFields(
219 state: ConnectState,
220 changedFieldId: string,
221 schema: ConnectSchema
222): ConnectState {
223 const newState = { ...state }
224
225 // Find fields that depend on the changed field
226 Object.values(schema.fields).forEach((field) => {
227 if (field.dependsOn && changedFieldId in field.dependsOn) {
228 // Only reset if dependency conditions are no longer satisfied
229 const dependencySatisfied = Object.entries(field.dependsOn).every(([key, values]) => {
230 const stateValue = String(newState[key] ?? '')
231 return values.includes(stateValue)
232 })
233
234 if (!dependencySatisfied) {
235 delete newState[field.id]
236 }
237 }
238 })
239
240 // Special case: changing mode resets all mode-specific fields
241 if (changedFieldId === 'mode') {
242 const previousMode = schema.modes.find((m) => m.id !== state.mode)
243 const currentMode = schema.modes.find((m) => m.id === state.mode)
244
245 // Reset fields from previous mode that aren't in current mode
246 previousMode?.fields.forEach((fieldId) => {
247 if (!currentMode?.fields.includes(fieldId)) {
248 delete newState[fieldId]
249 }
250 })
251 }
252
253 return newState
254}