| ... | @@ -166,6 +166,8 @@ interface ScopedServerWrite { | ... | @@ -166,6 +166,8 @@ interface ScopedServerWrite { |
| 166 | readonly environmentId: EnvironmentId; | 166 | readonly environmentId: EnvironmentId; |
| 167 | readonly label: string; | 167 | readonly label: string; |
| 168 | readonly patch: ServerSettingsPatch; | 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; |
| 169 | } | 171 | } |
| 170 | | 172 | |
| 171 | function projectOverrideWrites( | 173 | function projectOverrideWrites( |
| ... | @@ -194,6 +196,7 @@ function projectOverrideWrites( | ... | @@ -194,6 +196,7 @@ function projectOverrideWrites( |
| 194 | writes.set(member.environmentId, { | 196 | writes.set(member.environmentId, { |
| 195 | environmentId: member.environmentId, | 197 | environmentId: member.environmentId, |
| 196 | label: environment.label, | 198 | label: environment.label, |
| | 199 | previous: settings, |
| 197 | patch: { | 200 | patch: { |
| 198 | projectSettingsOverrides: { | 201 | projectSettingsOverrides: { |
| 199 | ...existing?.patch.projectSettingsOverrides, | 202 | ...existing?.patch.projectSettingsOverrides, |
| ... | @@ -266,29 +269,38 @@ export function planScopedSettingsPatch( | ... | @@ -266,29 +269,38 @@ export function planScopedSettingsPatch( |
| 266 | return next as ProjectSettingsOverrides; | 269 | return next as ProjectSettingsOverrides; |
| 267 | }) | 270 | }) |
| 268 | : scope.kind === "all" || scope.kind === "environment" | 271 | : scope.kind === "all" || scope.kind === "environment" |
| 269 | ? connectedEnvironments.map((environment) => ({ | 272 | ? connectedEnvironments.flatMap((environment) => { |
| 270 | environmentId: environment.environmentId, | 273 | const previous = environment.serverConfig?.settings; |
| 271 | label: environment.label, | 274 | if (!previous) return []; |
| 272 | patch: | 275 | return [ |
| 273 | environment.serverConfig?.settings.worktreeCleanup != null && | 276 | { |
| 274 | serverPatch.storageCleanup && | 277 | environmentId: environment.environmentId, |
| 275 | Object.keys(serverPatch.storageCleanup).some((key) => key.startsWith("worktree")) | 278 | label: environment.label, |
| 276 | ? { | 279 | previous, |
| 277 | ...serverPatch, | 280 | patch: |
| 278 | worktreeCleanup: { | 281 | previous.worktreeCleanup != null && |
| 279 | mode: "custom", | 282 | serverPatch.storageCleanup && |
| 280 | rules: { | 283 | Object.keys(serverPatch.storageCleanup).some((key) => |
| 281 | ...resolveWorktreeCleanup(environment.serverConfig.settings, null), | 284 | key.startsWith("worktree"), |
| 282 | ...Object.fromEntries( | 285 | ) |
| 283 | Object.entries(serverPatch.storageCleanup).filter(([key]) => | 286 | ? { |
| 284 | key.startsWith("worktree"), | 287 | ...serverPatch, |
| 285 | ), | 288 | worktreeCleanup: { |
| 286 | ), | 289 | mode: "custom", |
| 287 | }, | 290 | rules: { |
| 288 | }, | 291 | ...resolveWorktreeCleanup(previous, null), |
| 289 | } | 292 | ...Object.fromEntries( |
| 290 | : serverPatch, | 293 | Object.entries(serverPatch.storageCleanup).filter(([key]) => |
| 291 | })) | 294 | key.startsWith("worktree"), |
| | 295 | ), |
| | 296 | ), |
| | 297 | }, |
| | 298 | }, |
| | 299 | } |
| | 300 | : serverPatch, |
| | 301 | }, |
| | 302 | ]; |
| | 303 | }) |
| 292 | : []; | 304 | : []; |
| 293 | const hasClientWrite = Object.keys(clientPatch).length > 0; | 305 | const hasClientWrite = Object.keys(clientPatch).length > 0; |
| 294 | const hasWrite = hasClientWrite || serverWrites.length > 0; | 306 | const hasWrite = hasClientWrite || serverWrites.length > 0; |
| ... | @@ -371,6 +383,7 @@ export function planProjectOverridesClear( | ... | @@ -371,6 +383,7 @@ export function planProjectOverridesClear( |
| 371 | writes.set(environmentId, { | 383 | writes.set(environmentId, { |
| 372 | environmentId, | 384 | environmentId, |
| 373 | label: environment.label, | 385 | label: environment.label, |
| | 386 | previous: settings, |
| 374 | patch: { | 387 | patch: { |
| 375 | projectSettingsOverrides: { | 388 | projectSettingsOverrides: { |
| 376 | ...existing?.patch.projectSettingsOverrides, | 389 | ...existing?.patch.projectSettingsOverrides, |
| ... | @@ -389,13 +402,56 @@ export function planProjectOverridesClear( | ... | @@ -389,13 +402,56 @@ export function planProjectOverridesClear( |
| 389 | }; | 402 | }; |
| 390 | } | 403 | } |
| 391 | | 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 | |
| 392 | /** Wait for every target so a failed environment does not hide successful or later writes. */ | 444 | /** Wait for every target so a failed environment does not hide successful or later writes. */ |
| 393 | export async function persistScopedSettingsPatch( | 445 | export async function persistScopedSettingsPatch( |
| 394 | plan: ReturnType<typeof planScopedSettingsPatch>, | 446 | plan: ReturnType<typeof planScopedSettingsPatch>, |
| 395 | persistServer: (input: { | 447 | persistServer: (input: { |
| 396 | environmentId: EnvironmentId; | 448 | environmentId: EnvironmentId; |
| 397 | input: { patch: ServerSettingsPatch }; | 449 | input: { patch: ServerSettingsPatch }; |
| 398 | }) => Promise<{ readonly _tag: "Success" | "Failure"; readonly cause?: Cause.Cause<unknown> }>, | 450 | }) => Promise<{ |
| | 451 | readonly _tag: "Success" | "Failure"; |
| | 452 | readonly cause?: Cause.Cause<unknown>; |
| | 453 | readonly value?: ServerSettings; |
| | 454 | }>, |
| 399 | persistClient: (patch: ClientSettingsPatch) => void, | 455 | persistClient: (patch: ClientSettingsPatch) => void, |
| 400 | ) { | 456 | ) { |
| 401 | if (plan.hasClientWrite) persistClient(plan.clientPatch); | 457 | if (plan.hasClientWrite) persistClient(plan.clientPatch); |
| ... | @@ -404,9 +460,25 @@ export async function persistScopedSettingsPatch( | ... | @@ -404,9 +460,25 @@ export async function persistScopedSettingsPatch( |
| 404 | persistServer({ environmentId, input: { patch } }), | 460 | persistServer({ environmentId, input: { patch } }), |
| 405 | ), | 461 | ), |
| 406 | ); | 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 | }; |
| 407 | const failedEnvironments = plan.serverWrites.flatMap((environment, index) => { | 471 | const failedEnvironments = plan.serverWrites.flatMap((environment, index) => { |
| | 472 | if (saved(index)) return []; |
| 408 | const result = results[index]; | 473 | const result = results[index]; |
| 409 | if (result?.status === "fulfilled" && result.value._tag === "Success") return []; | 474 | if (result?.status === "fulfilled" && result.value._tag === "Success") { |
| | 475 | return [ |
| | 476 | { |
| | 477 | ...environment, |
| | 478 | message: "This environment does not support this setting. Update T3 Code on it.", |
| | 479 | }, |
| | 480 | ]; |
| | 481 | } |
| 410 | const error: unknown = | 482 | const error: unknown = |
| 411 | result?.status === "rejected" | 483 | result?.status === "rejected" |
| 412 | ? result.reason | 484 | ? result.reason |
| ... | @@ -423,10 +495,7 @@ export async function persistScopedSettingsPatch( | ... | @@ -423,10 +495,7 @@ export async function persistScopedSettingsPatch( |
| 423 | }, | 495 | }, |
| 424 | ]; | 496 | ]; |
| 425 | }); | 497 | }); |
| 426 | const savedEnvironments = plan.serverWrites.filter((_, index) => { | 498 | const savedEnvironments = plan.serverWrites.filter((_, index) => saved(index)); |
| 427 | const result = results[index]; | | |
| 428 | return result?.status === "fulfilled" && result.value._tag === "Success"; | | |
| 429 | }); | | |
| 430 | return { | 499 | return { |
| 431 | failedEnvironments, | 500 | failedEnvironments, |
| 432 | savedEnvironments, | 501 | savedEnvironments, |