Skip to content
Source
Sign inSign up
Open History to see the last commit

scopedSettings.ts

689 lines25.9 KB

1import {
2 ClientSettingsSchema,
3 type ClientSettingsPatch,
4 type EnvironmentId,
5 globalIdleCompaction,
6 idleCompactionPatch,
7 isNullableProjectSettingsOverride,
8 readsIdleCompactionModes,
9 PROJECT_SCOPED_SERVER_SETTING_KEYS,
10 type ProjectId,
11 type ProjectScopedServerSettingKey,
12 type ProjectSettingsOverrides,
13 ServerSettings,
14 type T3ProjectFile,
15 type ServerSettingsPatch,
16} from "@t3tools/contracts";
17import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection";
18import {
19 clearProjectSettingsOverrides,
20 resolveProjectSettings,
21 resolveWorktreeCleanup,
22 type ProjectSettingSource,
23} from "@t3tools/shared/projectSettings";
24import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
25import * as Equal from "effect/Equal";
26import * as Cause from "effect/Cause";
27import { Atom } from "effect/reactivity";
28​
29import type { ResolvedSettingsScope } from "./settingsScope";
30​
31export type ScopedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch;
32​
33interface ScopedSettingsEnvironment {
34 readonly environmentId: EnvironmentId;
35 readonly label: string;
36 readonly connection: { readonly phase: EnvironmentConnectionPhase };
37 readonly serverConfig: {
38 readonly settings: ServerSettings;
39 } | null;
41​
42const SERVER_KEYS = new Set<string>(Object.keys(ServerSettings.fields));
43const CLIENT_KEYS = new Set<string>(Object.keys(ClientSettingsSchema.fields));
44const PROJECT_SCOPED_KEYS = new Set<string>(PROJECT_SCOPED_SERVER_SETTING_KEYS);
45​
46export function isProjectScopedSettingKey(key: string): key is ProjectScopedServerSettingKey {
47 return PROJECT_SCOPED_KEYS.has(key);
49​
50function isPlainObject(value: unknown): value is Record<string, unknown> {
51 return typeof value === "object" && value !== null && !Array.isArray(value);
53​
54export function isConnectedWithConfig(
55 environment: Pick<ScopedSettingsEnvironment, "connection" | "serverConfig">,
56) {
57 return environment.connection.phase === "connected" && environment.serverConfig !== null;
59​
60/**
61 * A server save shown before its environment pushes the new settings back. It applies while in
62 * flight, and after success only until that environment's settings change from `settledOver`.
63 */
64export interface PendingScopedWrite {
65 readonly save: object;
66 readonly environmentId: EnvironmentId;
67 readonly patch: ServerSettingsPatch;
68 readonly settledOver?: ServerSettings;
70​
71export const pendingScopedWritesAtom = Atom.make<readonly PendingScopedWrite[]>([]).pipe(
72 Atom.keepAlive,
73 Atom.withLabel("web-settings:pending-scoped-writes"),
74);
75​
76const pendingWriteApplies = (write: PendingScopedWrite, settings: ServerSettings | undefined) =>
77 settings !== undefined && (write.settledOver === undefined || write.settledOver === settings);
78​
79/** Settles one environment's write: a failure or an already-pushed result drops it. */
80export function settlePendingScopedWrites(
81 writes: readonly PendingScopedWrite[],
82 target: Pick<PendingScopedWrite, "save" | "environmentId">,
83 readSettings: (environmentId: EnvironmentId) => ServerSettings | undefined,
84 saved?: ServerSettings,
85): readonly PendingScopedWrite[] {
86 return writes.flatMap((write) => {
87 const current = readSettings(write.environmentId);
88 if (write.save !== target.save || write.environmentId !== target.environmentId) {
89 return pendingWriteApplies(write, current) ? [write] : [];
90 }
91 if (saved === undefined || current === undefined || Equal.equals(current, saved)) return [];
92 return [{ ...write, settledOver: current }];
93 });
95​
96export function applyPendingScopedWrites<T extends ScopedSettingsEnvironment>(
97 environments: readonly T[],
98 writes: readonly PendingScopedWrite[],
99): readonly T[] {
100 if (writes.length === 0) return environments;
101 return environments.map((environment) => {
102 const config = environment.serverConfig;
103 if (config === null) return environment;
104 const settings = writes.reduce(
105 (current, write) =>
106 write.environmentId === environment.environmentId &&
107 pendingWriteApplies(write, config.settings)
108 ? applyServerSettingsPatch(current, write.patch)
109 : current,
110 config.settings,
111 );
112 if (settings === config.settings) return environment;
113 // Legacy servers read `compactIdleThreads`, so project the mode back onto it.
114 const { idleCompaction: _mode, ...legacy } = settings;
115 const projected =
116 config.settings.idleCompaction === undefined
117 ? { ...legacy, ...idleCompactionPatch(config.settings, globalIdleCompaction(settings)) }
118 : settings;
119 return { ...environment, serverConfig: { ...config, settings: projected } };
120 });
122​
123/** The representative supplies display values, never the set of write targets. */
124export function selectScopedSettingsEnvironments<T extends ScopedSettingsEnvironment>(
125 scope: ResolvedSettingsScope,
126 available: readonly T[],
127 primaryEnvironmentId: EnvironmentId | null,
128) {
129 const selectedIds = new Set(scope.environmentIds);
130 const environments = available.filter((environment) =>
131 selectedIds.has(environment.environmentId),
132 );
133 const connectedEnvironments = environments.filter(isConnectedWithConfig);
134 const environment =
135 connectedEnvironments.find((candidate) => candidate.environmentId === primaryEnvironmentId) ??
136 connectedEnvironments[0] ??
137 null;
138 return { environments, connectedEnvironments, environment };
140​
141export interface ScopedSettingsTarget {
142 readonly environmentId: EnvironmentId;
143 readonly label: string;
144 readonly projectId: ProjectId | null;
145 readonly settings: ServerSettings;
146 readonly sources: Readonly<Record<ProjectScopedServerSettingKey, ProjectSettingSource>>;
148​
149export function resolveScopedSettingsTargets(
150 scope: ResolvedSettingsScope,
151 connectedEnvironments: readonly ScopedSettingsEnvironment[],
152 // A member absent here has no file tier yet; null is a missing or invalid file.
153 projectFiles?: ReadonlyMap<string, T3ProjectFile | null>,
154): readonly ScopedSettingsTarget[] {
155 const byId = new Map(
156 connectedEnvironments.map((environment) => [environment.environmentId, environment]),
157 );
158 if (scope.kind === "project" || scope.kind === "checkout") {
159 return scope.members.flatMap((member) => {
160 const environment = byId.get(member.environmentId);
161 if (!environment?.serverConfig) return [];
162 const projectFile = projectFiles?.get(member.physicalProjectKey);
163 const resolved =
164 projectFile === undefined
165 ? resolveProjectSettings(environment.serverConfig.settings, member.id)
166 : resolveProjectSettings(environment.serverConfig.settings, member.id, null, projectFile);
167 return [
168 {
169 environmentId: member.environmentId,
170 label: environment.label,
171 projectId: member.id,
172 settings: resolved.settings,
173 sources: resolved.sources,
174 },
175 ];
176 });
177 }
178 return connectedEnvironments.flatMap((environment) =>
179 environment.serverConfig
180 ? [
181 {
182 environmentId: environment.environmentId,
183 label: environment.label,
184 projectId: null,
185 settings: environment.serverConfig.settings,
186 sources: resolveProjectSettings(environment.serverConfig.settings, null).sources,
187 },
188 ]
189 : [],
190 );
192​
193export function scopedSettingsAreMixed(
194 targets: readonly Pick<ScopedSettingsTarget, "settings">[],
195 keys: readonly (keyof ServerSettings)[],
196): boolean {
197 const first = targets[0];
198 return (
199 first !== undefined &&
200 targets.some((candidate) =>
201 keys.some((key) => !Equal.equals(first.settings[key], candidate.settings[key])),
202 )
203 );
205​
206/**
207 * Servers store the idle compaction mode differently by version, so selected
208 * environments are compared by the mode each resolves to, and "Never" is offered
209 * only when every one of them can store it.
210 */
211export function scopedIdleCompaction(targets: readonly Pick<ScopedSettingsTarget, "settings">[]) {
212 const modes = targets.map((candidate) => globalIdleCompaction(candidate.settings));
213 return {
214 mixed: modes.some((mode) => mode !== modes[0]),
215 neverAvailable: targets.every((candidate) => readsIdleCompactionModes(candidate.settings)),
216 };
218​
219export type ScopedSettingSource = ProjectSettingSource | "mixed";
220​
221export function scopedSettingsSource(
222 targets: readonly Pick<ScopedSettingsTarget, "sources">[],
223 keys: readonly (keyof ServerSettings)[],
224): ScopedSettingSource {
225 const scoped = keys.filter(isProjectScopedSettingKey);
226 if (scoped.length === 0 || targets.length === 0) return "environment";
227 const sources = new Set(targets.flatMap((target) => scoped.map((key) => target.sources[key])));
228 return sources.size > 1
229 ? "mixed"
230 : sources.has("project")
231 ? "project"
232 : sources.has("t3.json")
233 ? "t3.json"
234 : "environment";
236​
237interface ScopedServerWrite {
238 readonly environmentId: EnvironmentId;
239 readonly label: string;
240 readonly patch: ServerSettingsPatch;
241 readonly previous: ServerSettings;
243​
244function projectOverrideWrites(
245 scope: Extract<ResolvedSettingsScope, { kind: "project" | "checkout" }>,
246 environments: readonly ScopedSettingsEnvironment[],
247 update: (
248 current: ProjectSettingsOverrides,
249 settings: ServerSettings,
250 projectId: ProjectId,
251 ) => ProjectSettingsOverrides | null,
252): ScopedServerWrite[] {
253 const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
254 const writes = new Map<EnvironmentId, ScopedServerWrite>();
255 for (const member of scope.members) {
256 const environment = byId.get(member.environmentId);
257 if (!environment?.serverConfig || environment.connection.phase !== "connected") {
258 continue;
259 }
260 const settings = environment.serverConfig.settings;
261 const entry = update(settings.projectSettingsOverrides[member.id] ?? {}, settings, member.id);
262 const existing = writes.get(member.environmentId);
263 writes.set(member.environmentId, {
264 environmentId: member.environmentId,
265 label: environment.label,
266 previous: settings,
267 patch: {
268 projectSettingsOverrides: {
269 ...existing?.patch.projectSettingsOverrides,
270 [member.id]: entry,
271 },
272 },
273 });
274 }
275 return [...writes.values()];
277​
278/** Puts an idle compaction mode in the form each destination server reads. */
279function forDestination(previous: ServerSettings, patch: ServerSettingsPatch): ServerSettingsPatch {
280 if (patch.idleCompaction === undefined) return patch;
281 const { idleCompaction, compactIdleThreads: _legacy, ...rest } = patch;
282 return { ...rest, ...idleCompactionPatch(previous, idleCompaction) };
284​
285export function planScopedSettingsPatch(
286 scope: ResolvedSettingsScope,
287 environments: readonly ScopedSettingsEnvironment[],
288 patch: ScopedSettingsPatch,
289 options?: { readonly deviceOnly?: boolean },
290) {
291 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
292 const refusesClientKeys = isProjectScope && options?.deviceOnly !== true;
293 const clientKeys = Object.keys(patch).filter(
294 (key) => CLIENT_KEYS.has(key) && !SERVER_KEYS.has(key),
295 );
296 const serverPatch = Object.fromEntries(
297 Object.entries(patch).filter(([key]) => SERVER_KEYS.has(key)),
298 ) as ServerSettingsPatch;
299 const serverKeys = Object.keys(serverPatch);
300 const { connectedEnvironments } = selectScopedSettingsEnvironments(scope, environments, null);
301 // Client settings are stored per device, so a project has nowhere to keep an override.
302 const unscopableKeys = isProjectScope
303 ? [
304 ...(refusesClientKeys ? clientKeys : []),
305 ...serverKeys.filter((key) => !isProjectScopedSettingKey(key)),
306 ]
307 : [];
308 const clientPatch = Object.fromEntries(
309 refusesClientKeys ? [] : Object.entries(patch).filter(([key]) => clientKeys.includes(key)),
310 ) as ClientSettingsPatch;
311 const serverWrites: ScopedServerWrite[] =
312 serverKeys.length === 0
313 ? []
314 : isProjectScope
315 ? unscopableKeys.length > 0
316 ? []
317 : projectOverrideWrites(scope, environments, (current, settings, projectId) => {
318 // Override entries store whole objects; complete partial patches from each target's effective
319 // value.
320 const effective = resolveProjectSettings(settings, projectId).settings;
321 const next: Record<string, unknown> = { ...current };
322 for (const [key, value] of Object.entries<unknown>(serverPatch)) {
323 if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") {
324 next[key] = {
325 mode: "custom",
326 rules: {
327 ...resolveWorktreeCleanup(settings, projectId),
328 ...serverPatch.worktreeCleanup.rules,
329 },
330 };
331 continue;
332 }
333 // Inherit sends null; remove overrides whose keys cannot store null.
334 if (
335 value === null &&
336 !isNullableProjectSettingsOverride(key as ProjectScopedServerSettingKey)
337 ) {
338 delete next[key];
339 continue;
340 }
341 const base: unknown = effective[key as keyof ServerSettings];
342 // A new model must not inherit the previous model's options.
343 if (
344 isPlainObject(value) &&
345 isPlainObject(base) &&
346 (key === "defaultModelSelection" ||
347 key === "sourceControlWriterModelSelection" ||
348 key === "reviewGuideModelSelection" ||
349 key === "textGenerationModelSelection") &&
350 (value.instanceId !== undefined || value.model !== undefined)
351 ) {
352 next[key] = { instanceId: base.instanceId, model: base.model, ...value };
353 continue;
354 }
355 next[key] =
356 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
357 }
358 return next as ProjectSettingsOverrides;
359 })
360 : scope.kind === "all" || scope.kind === "environment"
361 ? connectedEnvironments.flatMap((environment) => {
362 const previous = environment.serverConfig?.settings;
363 if (!previous) return [];
364 return [
365 {
366 environmentId: environment.environmentId,
367 label: environment.label,
368 previous,
369 patch: forDestination(
370 previous,
371 previous.worktreeCleanup != null &&
372 serverPatch.storageCleanup &&
373 Object.keys(serverPatch.storageCleanup).some((key) =>
374 key.startsWith("worktree"),
375 )
376 ? {
377 ...serverPatch,
378 worktreeCleanup: {
379 mode: "custom",
380 rules: {
381 ...resolveWorktreeCleanup(previous, null),
382 ...Object.fromEntries(
383 Object.entries(serverPatch.storageCleanup).filter(([key]) =>
384 key.startsWith("worktree"),
385 ),
386 ),
387 },
388 },
389 }
390 : serverPatch,
391 ),
392 },
393 ];
394 })
395 : [];
396 const hasClientWrite = Object.keys(clientPatch).length > 0;
397 const hasWrite = hasClientWrite || serverWrites.length > 0;
398 const unavailableReason =
399 hasWrite || Object.keys(patch).length === 0
400 ? null
401 : scope.kind === "unavailable"
402 ? scope.message
403 : unscopableKeys.length > 0
404 ? "This setting is environment-wide and cannot be overridden by a project."
405 : isProjectScope
406 ? "Connect the selected checkouts, or update their environments, to save a project override."
407 : `Connect ${scope.kind === "environment" ? scope.label : "an environment"} to save this setting.`;
408 return { clientPatch, hasClientWrite, serverWrites, unavailableReason };
410​
411export function planScopedSettingsClear(
412 scope: ResolvedSettingsScope,
413 environments: readonly ScopedSettingsEnvironment[],
414 keys: readonly ProjectScopedServerSettingKey[],
415) {
416 const serverWrites =
417 scope.kind === "project" || scope.kind === "checkout"
418 ? projectOverrideWrites(scope, environments, (_current, settings, projectId) =>
419 clearProjectSettingsOverrides(settings, projectId, keys),
420 )
421 : [];
422 return {
423 clientPatch: {} as ClientSettingsPatch,
424 hasClientWrite: false,
425 serverWrites,
426 unavailableReason:
427 serverWrites.length > 0
428 ? null
429 : "Connect the selected checkouts, or update their environments, to reset this override.",
430 };
432​
433export interface ProjectOverrideEntry {
434 readonly environmentId: EnvironmentId;
435 readonly projectId: ProjectId;
437​
438export function listProjectOverrides(
439 environments: readonly ScopedSettingsEnvironment[],
440 keys: readonly (keyof ServerSettings)[],
441): readonly ProjectOverrideEntry[] {
442 const scoped = keys.filter(isProjectScopedSettingKey);
443 if (scoped.length === 0) return [];
444 return environments.flatMap((environment) => {
445 const overrides = environment.serverConfig?.settings.projectSettingsOverrides;
446 if (!overrides) return [];
447 return Object.entries(overrides).flatMap(([projectId, entry]) =>
448 scoped.some((key) => Object.hasOwn(entry, key))
449 ? [{ environmentId: environment.environmentId, projectId: projectId as ProjectId }]
450 : [],
451 );
452 });
454​
455export function planProjectOverridesClear(
456 environments: readonly ScopedSettingsEnvironment[],
457 entries: readonly ProjectOverrideEntry[],
458 keys: readonly ProjectScopedServerSettingKey[],
459) {
460 const byId = new Map(environments.map((environment) => [environment.environmentId, environment]));
461 const writes = new Map<EnvironmentId, ScopedServerWrite>();
462 for (const { environmentId, projectId } of entries) {
463 const environment = byId.get(environmentId);
464 if (!environment?.serverConfig || environment.connection.phase !== "connected") continue;
465 const settings = environment.serverConfig.settings;
466 const existing = writes.get(environmentId);
467 writes.set(environmentId, {
468 environmentId,
469 label: environment.label,
470 previous: settings,
471 patch: {
472 projectSettingsOverrides: {
473 ...existing?.patch.projectSettingsOverrides,
474 [projectId]: clearProjectSettingsOverrides(settings, projectId, keys),
475 },
476 },
477 });
478 }
479 const serverWrites = [...writes.values()];
480 return {
481 clientPatch: {} as ClientSettingsPatch,
482 hasClientWrite: false,
483 serverWrites,
484 unavailableReason:
485 serverWrites.length > 0 ? null : "Connect the environments to reset these overrides.",
486 };
488​
489function writeWasDropped(value: unknown, before: unknown, saved: unknown): boolean {
490 if (value === null) return before != null && Equal.equals(saved, before);
491 if (isPlainObject(value) && !Array.isArray(before) && !Array.isArray(saved)) {
492 const field = (source: unknown, key: string) =>
493 isPlainObject(source) ? source[key] : undefined;
494 return Object.entries(value).some(([key, next]) =>
495 writeWasDropped(next, field(before, key), field(saved, key)),
496 );
497 }
498 const unchanged =
499 typeof value === "string" && typeof before === "string"
500 ? value.trim() === before
501 : Equal.equals(value, before);
502 return !unchanged && Equal.equals(saved, before);
504​
505/** Older servers silently drop unknown keys; report them so rows cannot stay mixed forever. */
506function droppedSettingKeys(write: ScopedServerWrite, saved: ServerSettings | undefined) {
507 if (saved === undefined) return [];
508 return Object.entries(write.patch).flatMap(([key, value]) =>
509 writeWasDropped(
510 value,
511 write.previous[key as keyof ServerSettings],
512 saved[key as keyof ServerSettings],
513 )
514 ? [key]
515 : [],
516 );
518​
519type ScopedSettingsPlan = ReturnType<typeof planScopedSettingsPatch>;
520​
521type ScopedSettingsPersistServer = (input: {
522 environmentId: EnvironmentId;
523 input: { patch: ServerSettingsPatch };
524}) => Promise<{
525 readonly _tag: "Success" | "Failure";
526 readonly cause?: Cause.Cause<unknown>;
527 readonly value?: ServerSettings;
528}>;
529​
530export async function persistScopedSettingsPatch(
531 plan: ScopedSettingsPlan,
532 persistServer: ScopedSettingsPersistServer,
533 persistClient: (patch: ClientSettingsPatch) => void,
534) {
535 return persistScopedSettingsPlan(
536 plan,
537 async (write) => ({
538 write,
539 result: await persistServer({
540 environmentId: write.environmentId,
541 input: { patch: write.patch },
542 }),
543 }),
544 persistClient,
545 );
547​
548const OWN_ECHO_LIMIT = 8;
549​
550/**
551 * Project overrides are whole entries built from this client's copy of the settings, and that
552 * copy hears about a save only when the server pushes it back. So saves to one environment run
553 * one at a time, each planned as it starts from the newest settings this client holds: the
554 * previous save's response, unless an independently changed copy has arrived.
555 */
556export function createScopedSettingsWriter(
557 readSettings: (environmentId: EnvironmentId) => ServerSettings | null,
558) {
559 const queues = new Map<EnvironmentId, Promise<unknown>>();
560 const responses = new Map<
561 EnvironmentId,
562 {
563 readonly settings: ServerSettings;
564 readonly copy: ServerSettings;
565 readonly saved: readonly ServerSettings[];
566 }
567 >();
568 return (
569 environments: readonly ScopedSettingsEnvironment[],
570 plan: (environments: readonly ScopedSettingsEnvironment[]) => ScopedSettingsPlan,
571 persistServer: ScopedSettingsPersistServer,
572 persistClient: (patch: ClientSettingsPatch) => void,
573 ) =>
574 persistScopedSettingsPlan(
575 plan(environments),
576 (planned) => {
577 const { environmentId } = planned;
578 const save = (queues.get(environmentId) ?? Promise.resolve()).then(async () => {
579 const copy = readSettings(environmentId);
580 const response = responses.get(environmentId);
581 // A copy equal to one of our earlier responses is a delayed echo, older than the latest.
582 const isOwnEcho = (settings: ServerSettings) =>
583 response?.saved.some((saved) => Equal.equals(saved, settings)) === true;
584 const continues =
585 copy !== null && response !== undefined && (response.copy === copy || isOwnEcho(copy));
586 const settings = continues ? response.settings : copy;
587 const replanned =
588 settings === null ? null : plan(withSettings(environments, environmentId, settings));
589 const write =
590 replanned?.serverWrites.find((next) => next.environmentId === environmentId) ?? planned;
591 const result = await persistServer({ environmentId, input: { patch: write.patch } });
592 const savedCopy = readSettings(environmentId);
593 if (result._tag === "Success" && result.value !== undefined && savedCopy !== null) {
594 // A changed copy may be a newer remote push or the delayed echo of our previous save.
595 if (
596 savedCopy === copy ||
597 Equal.equals(savedCopy, settings) ||
598 Equal.equals(savedCopy, result.value) ||
599 isOwnEcho(savedCopy)
600 ) {
601 responses.set(environmentId, {
602 settings: result.value,
603 copy: savedCopy,
604 saved: [...(continues ? response.saved : []), result.value].slice(-OWN_ECHO_LIMIT),
605 });
606 } else {
607 responses.delete(environmentId);
608 }
609 }
610 return { write, result };
611 });
612 queues.set(
613 environmentId,
614 save.catch(() => undefined),
615 );
616 return save;
617 },
618 persistClient,
619 );
621​
622function withSettings(
623 environments: readonly ScopedSettingsEnvironment[],
624 environmentId: EnvironmentId,
625 settings: ServerSettings,
626) {
627 return environments.map((environment) =>
628 environment.environmentId === environmentId && environment.serverConfig
629 ? { ...environment, serverConfig: { ...environment.serverConfig, settings } }
630 : environment,
631 );
633​
634async function persistScopedSettingsPlan(
635 plan: ScopedSettingsPlan,
636 persistWrite: (write: ScopedServerWrite) => Promise<{
637 readonly write: ScopedServerWrite;
638 readonly result: Awaited<ReturnType<ScopedSettingsPersistServer>>;
639 }>,
640 persistClient: (patch: ClientSettingsPatch) => void,
641) {
642 if (plan.hasClientWrite) persistClient(plan.clientPatch);
643 const results = await Promise.allSettled(plan.serverWrites.map(persistWrite));
644 // A queued write is replanned before it is sent, so judge the write that was sent.
645 const writes = results.map((result, index) =>
646 result.status === "fulfilled" ? result.value.write : plan.serverWrites[index]!,
647 );
648 const saved = (index: number) => {
649 const result = results[index];
650 return (
651 result?.status === "fulfilled" &&
652 result.value.result._tag === "Success" &&
653 droppedSettingKeys(writes[index]!, result.value.result.value).length === 0
654 );
655 };
656 const failedEnvironments = writes.flatMap((environment, index) => {
657 if (saved(index)) return [];
658 const result = results[index];
659 if (result?.status === "fulfilled" && result.value.result._tag === "Success") {
660 return [
661 {
662 ...environment,
663 message: "This environment does not support this setting. Update T3 Code on it.",
664 },
665 ];
666 }
667 const error: unknown =
668 result?.status === "rejected"
669 ? result.reason
670 : result?.status === "fulfilled" && result.value.result.cause
671 ? Cause.squash(result.value.result.cause)
672 : undefined;
673 return [
674 {
675 ...environment,
676 message:
677 error instanceof Error
678 ? error.message
679 : "The save failed. Try reconnecting and saving again.",
680 },
681 ];
682 });
683 const savedEnvironments = writes.filter((_, index) => saved(index));
684 return {
685 failedEnvironments,
686 savedEnvironments,
687 savedEnvironmentCount: savedEnvironments.length,
688 };