| 1 | import { |
| 2 | ClientSettingsSchema, |
| 3 | type ClientSettingsPatch, |
| 4 | type EnvironmentId, |
| 5 | isNullableProjectSettingsOverride, |
| 6 | PROJECT_SCOPED_SERVER_SETTING_KEYS, |
| 7 | type ProjectId, |
| 8 | type ProjectScopedServerSettingKey, |
| 9 | type ProjectSettingsOverrides, |
| 10 | ServerSettings, |
| 11 | type T3ProjectFile, |
| 12 | type ServerSettingsPatch, |
| 13 | } from "@t3tools/contracts"; |
| 14 | import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; |
| 15 | import { |
| 16 | clearProjectSettingsOverrides, |
| 17 | resolveProjectSettings, |
| 18 | resolveWorktreeCleanup, |
| 19 | type ProjectSettingSource, |
| 20 | } from "@t3tools/shared/projectSettings"; |
| 21 | import * as Equal from "effect/Equal"; |
| 22 | import * as Cause from "effect/Cause"; |
| 23 | |
| 24 | import type { ResolvedSettingsScope } from "./settingsScope"; |
| 25 | |
| 26 | export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; |
| 27 | |
| 28 | interface ScopedSettingsEnvironment { |
| 29 | readonly environmentId: EnvironmentId; |
| 30 | readonly label: string; |
| 31 | readonly connection: { readonly phase: EnvironmentConnectionPhase }; |
| 32 | readonly serverConfig: { |
| 33 | readonly settings: ServerSettings; |
| 34 | readonly environment?: { |
| 35 | readonly capabilities: { readonly projectSettingsOverrides?: boolean | undefined }; |
| 36 | }; |
| 37 | } | null; |
| 38 | } |
| 39 | |
| 40 | const SERVER_KEYS = new Set<string>(Object.keys(ServerSettings.fields)); |
| 41 | const CLIENT_KEYS = new Set<string>(Object.keys(ClientSettingsSchema.fields)); |
| 42 | const PROJECT_SCOPED_KEYS = new Set<string>(PROJECT_SCOPED_SERVER_SETTING_KEYS); |
| 43 | |
| 44 | export function isProjectScopedSettingKey(key: string): key is ProjectScopedServerSettingKey { |
| 45 | return PROJECT_SCOPED_KEYS.has(key); |
| 46 | } |
| 47 | |
| 48 | function isPlainObject(value: unknown): value is Record<string, unknown> { |
| 49 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 50 | } |
| 51 | |
| 52 | /** The representative supplies display values, never the set of write targets. */ |
| 53 | export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnvironment>( |
| 54 | scope: ResolvedSettingsScope, |
| 55 | available: readonly T[], |
| 56 | primaryEnvironmentId: EnvironmentId | null, |
| 57 | ) { |
| 58 | const selectedIds = new Set(scope.environmentIds); |
| 59 | const environments = available.filter((environment) => |
| 60 | selectedIds.has(environment.environmentId), |
| 61 | ); |
| 62 | const connectedEnvironments = environments.filter( |
| 63 | (environment) => |
| 64 | environment.connection.phase === "connected" && environment.serverConfig !== null, |
| 65 | ); |
| 66 | const environment = |
| 67 | connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ?? |
| 68 | connectedEnvironments[0] ?? |
| 69 | null; |
| 70 | return { environments, connectedEnvironments, environment }; |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * One (environment, project) pair the scope writes to, with that project's |
| 75 | * effective settings. Environment scopes have no member and read the |
| 76 | * environment settings directly. |
| 77 | */ |
| 78 | export interface ScopedSettingsTarget { |
| 79 | readonly environmentId: EnvironmentId; |
| 80 | /** The environment's label; a project is the same project on every environment. */ |
| 81 | readonly label: string; |
| 82 | readonly projectId: ProjectId | null; |
| 83 | readonly settings: ServerSettings; |
| 84 | readonly sources: Readonly<Record<ProjectScopedServerSettingKey, ProjectSettingSource>>; |
| 85 | } |
| 86 | |
| 87 | /** Effective settings per connected target: members at project scope, environments otherwise. */ |
| 88 | export function resolveScopedSettingsTargets( |
| 89 | scope: ResolvedSettingsScope, |
| 90 | connectedEnvironments: readonly ScopedSettingsEnvironment[], |
| 91 | // Each member's decoded t3.json, keyed by physical project key, once read. |
| 92 | // A member absent here has no file tier yet; null is a missing or invalid file. |
| 93 | projectFiles?: ReadonlyMap<string, T3ProjectFile | null>, |
| 94 | ): readonly ScopedSettingsTarget[] { |
| 95 | const byId = new Map( |
| 96 | connectedEnvironments.map((environment) => [environment.environmentId, environment]), |
| 97 | ); |
| 98 | if (scope.kind === "project" || scope.kind === "checkout") { |
| 99 | return scope.members.flatMap((member) => { |
| 100 | const environment = byId.get(member.environmentId); |
| 101 | if (!environment?.serverConfig) return []; |
| 102 | const projectFile = projectFiles?.get(member.physicalProjectKey); |
| 103 | const resolved = |
| 104 | projectFile === undefined |
| 105 | ? resolveProjectSettings(environment.serverConfig.settings, member.id) |
| 106 | : resolveProjectSettings(environment.serverConfig.settings, member.id, null, projectFile); |
| 107 | return [ |
| 108 | { |
| 109 | environmentId: member.environmentId, |
| 110 | label: environment.label, |
| 111 | projectId: member.id, |
| 112 | settings: resolved.settings, |
| 113 | sources: resolved.sources, |
| 114 | }, |
| 115 | ]; |
| 116 | }); |
| 117 | } |
| 118 | return connectedEnvironments.flatMap((environment) => |
| 119 | environment.serverConfig |
| 120 | ? [ |
| 121 | { |
| 122 | environmentId: environment.environmentId, |
| 123 | label: environment.label, |
| 124 | projectId: null, |
| 125 | settings: environment.serverConfig.settings, |
| 126 | sources: resolveProjectSettings(environment.serverConfig.settings, null).sources, |
| 127 | }, |
| 128 | ] |
| 129 | : [], |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | export function scopedSettingsAreMixed( |
| 134 | targets: readonly Pick<ScopedSettingsTarget, "settings">[], |
| 135 | keys: readonly (keyof ServerSettings)[], |
| 136 | ): boolean { |
| 137 | const first = targets[0]; |
| 138 | return ( |
| 139 | first !== undefined && |
| 140 | targets.some((candidate) => |
| 141 | keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])), |
| 142 | ) |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | export type ScopedSettingSource = ProjectSettingSource | "mixed"; |
| 147 | |
| 148 | /** Whether the keys are overridden on every target, inherited on every target, or split. */ |
| 149 | export function scopedSettingsSource( |
| 150 | targets: readonly Pick<ScopedSettingsTarget, "sources">[], |
| 151 | keys: readonly (keyof ServerSettings)[], |
| 152 | ): ScopedSettingSource { |
| 153 | const scoped = keys.filter(isProjectScopedSettingKey); |
| 154 | if (scoped.length === 0 || targets.length === 0) return "environment"; |
| 155 | const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key]))); |
| 156 | return sources.size > 1 |
| 157 | ? "mixed" |
| 158 | : sources.has("project") |
| 159 | ? "project" |
| 160 | : sources.has("t3.json") |
| 161 | ? "t3.json" |
| 162 | : "environment"; |
| 163 | } |
| 164 | |
| 165 | interface ScopedServerWrite { |
| 166 | readonly environmentId: EnvironmentId; |
| 167 | readonly label: string; |
| 168 | readonly patch: ServerSettingsPatch; |
| 169 | /** The environment's settings before the write, to tell a dropped key from a saved one. */ |
| 170 | readonly previous: ServerSettings; |
| 171 | } |
| 172 | |
| 173 | function projectOverrideWrites( |
| 174 | scope: Extract<ResolvedSettingsScope, { kind: "project" | "checkout" }>, |
| 175 | environments: readonly ScopedSettingsEnvironment[], |
| 176 | update: ( |
| 177 | current: ProjectSettingsOverrides, |
| 178 | settings: ServerSettings, |
| 179 | projectId: ProjectId, |
| 180 | ) => ProjectSettingsOverrides | null, |
| 181 | ): ScopedServerWrite[] { |
| 182 | const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); |
| 183 | const writes = new Map<EnvironmentId, ScopedServerWrite>(); |
| 184 | for (const member of scope.members) { |
| 185 | const environment = byId.get(member.environmentId); |
| 186 | if ( |
| 187 | !environment?.serverConfig || |
| 188 | environment.connection.phase !== "connected" || |
| 189 | environment.serverConfig.environment?.capabilities.projectSettingsOverrides !== true |
| 190 | ) { |
| 191 | continue; |
| 192 | } |
| 193 | const settings = environment.serverConfig.settings; |
| 194 | const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id); |
| 195 | const existing = writes.get(member.environmentId); |
| 196 | writes.set(member.environmentId, { |
| 197 | environmentId: member.environmentId, |
| 198 | label: environment.label, |
| 199 | previous: settings, |
| 200 | patch: { |
| 201 | projectSettingsOverrides: { |
| 202 | ...existing?.patch.projectSettingsOverrides, |
| 203 | [member.id]: entry, |
| 204 | }, |
| 205 | }, |
| 206 | }); |
| 207 | } |
| 208 | return [...writes.values()]; |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Environment scopes write the patch to every connected environment; project |
| 213 | * and checkout scopes write the scopable keys into each member's override |
| 214 | * entry on its environment. Client keys always persist locally. |
| 215 | */ |
| 216 | export function planScopedSettingsPatch( |
| 217 | scope: ResolvedSettingsScope, |
| 218 | environments: readonly ScopedSettingsEnvironment[], |
| 219 | patch: ScopedSettingsPatch, |
| 220 | ) { |
| 221 | const clientPatch = Object.fromEntries( |
| 222 | Object.entries(patch).filter(([key]) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key)), |
| 223 | ) as ClientSettingsPatch; |
| 224 | const serverPatch = Object.fromEntries( |
| 225 | Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)), |
| 226 | ) as ServerSettingsPatch; |
| 227 | const serverKeys = Object.keys(serverPatch); |
| 228 | const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null); |
| 229 | const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; |
| 230 | const unscopableKeys = isProjectScope |
| 231 | ? serverKeys.filter((key) => !isProjectScopedSettingKey(key)) |
| 232 | : []; |
| 233 | const serverWrites: ScopedServerWrite[] = |
| 234 | serverKeys.length === 0 |
| 235 | ? [] |
| 236 | : isProjectScope |
| 237 | ? unscopableKeys.length > 0 |
| 238 | ? [] |
| 239 | : projectOverrideWrites(scope, environments, (current, settings, projectId) => { |
| 240 | // Object-valued keys arrive as partial patches (the writing style |
| 241 | // rows send one field); an override entry stores the whole value, |
| 242 | // so complete the patch from the target's effective value. |
| 243 | const effective = resolveProjectSettings(settings, projectId).settings; |
| 244 | const next: Record<string, unknown> = { ...current }; |
| 245 | for (const [key, value] of Object.entries(serverPatch)) { |
| 246 | if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") { |
| 247 | next[key] = { |
| 248 | mode: "custom", |
| 249 | rules: { |
| 250 | ...resolveWorktreeCleanup(settings, projectId), |
| 251 | ...serverPatch.worktreeCleanup.rules, |
| 252 | }, |
| 253 | }; |
| 254 | continue; |
| 255 | } |
| 256 | // A picker's "Inherit" sends null; for keys whose override |
| 257 | // cannot store null that means remove the override. |
| 258 | if ( |
| 259 | value === null && |
| 260 | !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey) |
| 261 | ) { |
| 262 | delete next[key]; |
| 263 | continue; |
| 264 | } |
| 265 | const base = effective[key as keyof ServerSettings]; |
| 266 | next[key] = |
| 267 | isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value; |
| 268 | } |
| 269 | return next as ProjectSettingsOverrides; |
| 270 | }) |
| 271 | : scope.kind === "all" || scope.kind === "environment" |
| 272 | ? connectedEnvironments.flatMap((environment) => { |
| 273 | const previous = environment.serverConfig?.settings; |
| 274 | if (!previous) return []; |
| 275 | return [ |
| 276 | { |
| 277 | environmentId: environment.environmentId, |
| 278 | label: environment.label, |
| 279 | previous, |
| 280 | patch: |
| 281 | previous.worktreeCleanup != null && |
| 282 | serverPatch.storageCleanup && |
| 283 | Object.keys(serverPatch.storageCleanup).some((key) => |
| 284 | key.startsWith("worktree"), |
| 285 | ) |
| 286 | ? { |
| 287 | ...serverPatch, |
| 288 | worktreeCleanup: { |
| 289 | mode: "custom", |
| 290 | rules: { |
| 291 | ...resolveWorktreeCleanup(previous, null), |
| 292 | ...Object.fromEntries( |
| 293 | Object.entries(serverPatch.storageCleanup).filter(([key]) => |
| 294 | key.startsWith("worktree"), |
| 295 | ), |
| 296 | ), |
| 297 | }, |
| 298 | }, |
| 299 | } |
| 300 | : serverPatch, |
| 301 | }, |
| 302 | ]; |
| 303 | }) |
| 304 | : []; |
| 305 | const hasClientWrite = Object.keys(clientPatch).length > 0; |
| 306 | const hasWrite = hasClientWrite || serverWrites.length > 0; |
| 307 | const unavailableReason = |
| 308 | hasWrite || Object.keys(patch).length === 0 |
| 309 | ? null |
| 310 | : scope.kind === "unavailable" |
| 311 | ? scope.message |
| 312 | : unscopableKeys.length > 0 |
| 313 | ? "This setting is environment-wide and cannot be overridden by a project." |
| 314 | : isProjectScope |
| 315 | ? "Connect the selected checkouts, or update their environments, to save a project override." |
| 316 | : `Connect ${scope.kind === "environment" ? scope.label : "an environment"} to save this setting.`; |
| 317 | return { clientPatch, hasClientWrite, serverWrites, unavailableReason }; |
| 318 | } |
| 319 | |
| 320 | /** Remove the keys' project overrides so each member inherits its environment value again. */ |
| 321 | export function planScopedSettingsClear( |
| 322 | scope: ResolvedSettingsScope, |
| 323 | environments: readonly ScopedSettingsEnvironment[], |
| 324 | keys: readonly ProjectScopedServerSettingKey[], |
| 325 | ) { |
| 326 | const serverWrites = |
| 327 | scope.kind === "project" || scope.kind === "checkout" |
| 328 | ? projectOverrideWrites(scope, environments, (_current, settings, projectId) => |
| 329 | clearProjectSettingsOverrides(settings, projectId, keys), |
| 330 | ) |
| 331 | : []; |
| 332 | return { |
| 333 | clientPatch: {} as ClientSettingsPatch, |
| 334 | hasClientWrite: false, |
| 335 | serverWrites, |
| 336 | unavailableReason: |
| 337 | serverWrites.length > 0 |
| 338 | ? null |
| 339 | : "Connect the selected checkouts, or update their environments, to reset this override.", |
| 340 | }; |
| 341 | } |
| 342 | |
| 343 | export interface ProjectOverrideEntry { |
| 344 | readonly environmentId: EnvironmentId; |
| 345 | readonly projectId: ProjectId; |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * The projects on the selected environments that override `keys`. An |
| 350 | * environment edit leaves these untouched, so the row can name them and |
| 351 | * offer to clear them. |
| 352 | */ |
| 353 | export function listProjectOverrides( |
| 354 | environments: readonly ScopedSettingsEnvironment[], |
| 355 | keys: readonly (keyof ServerSettings)[], |
| 356 | ): readonly ProjectOverrideEntry[] { |
| 357 | const scoped = keys.filter(isProjectScopedSettingKey); |
| 358 | if (scoped.length === 0) return []; |
| 359 | return environments.flatMap((environment) => { |
| 360 | const overrides = environment.serverConfig?.settings.projectSettingsOverrides; |
| 361 | if (!overrides) return []; |
| 362 | return Object.entries(overrides).flatMap(([projectId, entry]) => |
| 363 | scoped.some((key) => Object.hasOwn(entry, key)) |
| 364 | ? [{ environmentId: environment.environmentId, projectId: projectId as ProjectId }] |
| 365 | : [], |
| 366 | ); |
| 367 | }); |
| 368 | } |
| 369 | |
| 370 | /** Drop `keys` from the named project entries so they follow the environment again. */ |
| 371 | export function planProjectOverridesClear( |
| 372 | environments: readonly ScopedSettingsEnvironment[], |
| 373 | entries: readonly ProjectOverrideEntry[], |
| 374 | keys: readonly ProjectScopedServerSettingKey[], |
| 375 | ) { |
| 376 | const byId = new Map(environments.map((environment) => [environment.environmentId, environment])); |
| 377 | const writes = new Map<EnvironmentId, ScopedServerWrite>(); |
| 378 | for (const { environmentId, projectId } of entries) { |
| 379 | const environment = byId.get(environmentId); |
| 380 | if (!environment?.serverConfig || environment.connection.phase !== "connected") continue; |
| 381 | const settings = environment.serverConfig.settings; |
| 382 | const existing = writes.get(environmentId); |
| 383 | writes.set(environmentId, { |
| 384 | environmentId, |
| 385 | label: environment.label, |
| 386 | previous: settings, |
| 387 | patch: { |
| 388 | projectSettingsOverrides: { |
| 389 | ...existing?.patch.projectSettingsOverrides, |
| 390 | [projectId]: clearProjectSettingsOverrides(settings, projectId, keys), |
| 391 | }, |
| 392 | }, |
| 393 | }); |
| 394 | } |
| 395 | const serverWrites = [...writes.values()]; |
| 396 | return { |
| 397 | clientPatch: {} as ClientSettingsPatch, |
| 398 | hasClientWrite: false, |
| 399 | serverWrites, |
| 400 | unavailableReason: |
| 401 | serverWrites.length > 0 ? null : "Connect the environments to reset these overrides.", |
| 402 | }; |
| 403 | } |
| 404 | |
| 405 | /** |
| 406 | * Whether a server kept `before` where `value` asked for a change. Objects are |
| 407 | * compared field by field, so a nested key an older server dropped is caught |
| 408 | * even when a sibling field saved. A null clears the field; strings may come |
| 409 | * back trimmed. |
| 410 | */ |
| 411 | function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean { |
| 412 | if (value === null) return before != null && Equal.equals(saved, before); |
| 413 | if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) { |
| 414 | const field = (source: unknown, key: string) => |
| 415 | isPlainObject(source) ? source[key] : undefined; |
| 416 | return Object.entries(value).some(([key, next]) => |
| 417 | writeWasDropped(next, field(before, key), field(saved, key)), |
| 418 | ); |
| 419 | } |
| 420 | const unchanged = |
| 421 | typeof value === "string" && typeof before === "string" |
| 422 | ? value.trim() === before |
| 423 | : Equal.equals(value, before); |
| 424 | return !unchanged && Equal.equals(saved, before); |
| 425 | } |
| 426 | |
| 427 | /** |
| 428 | * An older server drops keys it does not know, top-level or nested, and still |
| 429 | * succeeds. Left unreported, the row stays mixed forever. |
| 430 | */ |
| 431 | function droppedSettingKeys(write: ScopedServerWrite, saved: ServerSettings | undefined) { |
| 432 | if (saved === undefined) return []; |
| 433 | return Object.entries(write.patch).flatMap(([key, value]) => |
| 434 | writeWasDropped( |
| 435 | value, |
| 436 | write.previous[key as keyof ServerSettings], |
| 437 | saved[key as keyof ServerSettings], |
| 438 | ) |
| 439 | ? [key] |
| 440 | : [], |
| 441 | ); |
| 442 | } |
| 443 | |
| 444 | /** Wait for every target so a failed environment does not hide successful or later writes. */ |
| 445 | export async function persistScopedSettingsPatch( |
| 446 | plan: ReturnType<typeof planScopedSettingsPatch>, |
| 447 | persistServer: (input: { |
| 448 | environmentId: EnvironmentId; |
| 449 | input: { patch: ServerSettingsPatch }; |
| 450 | }) => Promise<{ |
| 451 | readonly _tag: "Success" | "Failure"; |
| 452 | readonly cause?: Cause.Cause<unknown>; |
| 453 | readonly value?: ServerSettings; |
| 454 | }>, |
| 455 | persistClient: (patch: ClientSettingsPatch) => void, |
| 456 | ) { |
| 457 | if (plan.hasClientWrite) persistClient(plan.clientPatch); |
| 458 | const results = await Promise.allSettled( |
| 459 | plan.serverWrites.map(({ environmentId, patch }) => |
| 460 | persistServer({ environmentId, input: { patch } }), |
| 461 | ), |
| 462 | ); |
| 463 | const saved = (index: number) => { |
| 464 | const result = results[index]; |
| 465 | return ( |
| 466 | result?.status === "fulfilled" && |
| 467 | result.value._tag === "Success" && |
| 468 | droppedSettingKeys(plan.serverWrites[index]!, result.value.value).length === 0 |
| 469 | ); |
| 470 | }; |
| 471 | const failedEnvironments = plan.serverWrites.flatMap((environment, index) => { |
| 472 | if (saved(index)) return []; |
| 473 | const result = results[index]; |
| 474 | if (result?.status === "fulfilled" && result.value._tag === "Success") { |
| 475 | return [ |
| 476 | { |
| 477 | ...environment, |
| 478 | message: "This environment does not support this setting. Update The Knife on it.", |
| 479 | }, |
| 480 | ]; |
| 481 | } |
| 482 | const error: unknown = |
| 483 | result?.status === "rejected" |
| 484 | ? result.reason |
| 485 | : result?.status === "fulfilled" && result.value.cause |
| 486 | ? Cause.squash(result.value.cause) |
| 487 | : undefined; |
| 488 | return [ |
| 489 | { |
| 490 | ...environment, |
| 491 | message: |
| 492 | error instanceof Error |
| 493 | ? error.message |
| 494 | : "The save failed. Try reconnecting and saving again.", |
| 495 | }, |
| 496 | ]; |
| 497 | }); |
| 498 | const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index)); |
| 499 | return { |
| 500 | failedEnvironments, |
| 501 | savedEnvironments, |
| 502 | savedEnvironmentCount: savedEnvironments.length, |
| 503 | }; |
| 504 | } |