1import {
2 applyServerProviderDeltas,
3 type ServerConfig,
4 type ServerConfigStreamEvent,
5} from "@t3tools/contracts";
6import * as Equal from "effect/Equal";
7import * as Option from "effect/Option";
8
9export interface ServerConfigProjection {
10 readonly config: ServerConfig;
11 readonly latestEvent: ServerConfigStreamEvent;
12 readonly source: "cache" | "live";
13}
14
15/**
16 * Published themes and usage-limit sources are current machine state, so a
17 * cache could restore a set the machine no longer reports.
18 */
19export function withoutEnvironmentThemes(config: ServerConfig): ServerConfig {
20 if (config.environmentThemes === undefined && config.usageLimitSources === undefined) {
21 return config;
22 }
23 const { environmentThemes: _themes, usageLimitSources: _sources, ...rest } = config;
24 return rest;
25}
26
27/**
28 * Provider pushes, reconnects, and settings writes resend unchanged data.
29 * Reuse deep-equal objects so memoized UI can skip unchanged providers, models, and skills.
30 */
31export function reuseUnchanged<T>(previous: unknown, next: T): T {
32 if (Object.is(previous, next)) return next;
33 if (
34 typeof previous !== "object" ||
35 typeof next !== "object" ||
36 previous === null ||
37 next === null ||
38 Array.isArray(previous) !== Array.isArray(next)
39 ) {
40 return next;
41 }
42 if (Array.isArray(next)) {
43 const before = previous as ReadonlyArray<unknown>;
44 let same = before.length === next.length;
45 const merged = next.map((item, index) => {
46 const reused = reuseUnchanged(before[index], item);
47 if (reused !== before[index]) same = false;
48 return reused;
49 });
50 return (same ? previous : merged) as T;
51 }
52 // Decoded settings carry Durations, which are fresh instances on every push.
53 if (Equal.isEqual(next)) return (Equal.equals(previous, next) ? previous : next) as T;
54 if (Object.getPrototypeOf(next) !== Object.prototype) return next;
55 const before = previous as Record<string, unknown>;
56 const after = next as Record<string, unknown>;
57 const keys = Object.keys(after);
58 let same = keys.length === Object.keys(before).length;
59 const merged: Record<string, unknown> = {};
60 for (const key of keys) {
61 const reused = reuseUnchanged(before[key], after[key]);
62 if (reused !== before[key] || !(key in before)) same = false;
63 merged[key] = reused;
64 }
65 return (same ? previous : merged) as T;
66}
67
68type ServerProviders = ServerConfig["providers"];
69
70/**
71 * A provider sent with a catalog digest comes without its slash commands and skills. Keep the
72 * lists this client holds, stale ones too, until it loads the lists the digest stands for.
73 */
74function withHeldProviderCatalogs(
75 previous: ServerProviders | undefined,
76 next: ServerProviders,
77): ServerProviders {
78 if (previous === undefined) return next;
79 return next.map((provider) => {
80 if (provider.catalogDigest === undefined || provider.loadedCatalogDigest !== undefined) {
81 return provider;
82 }
83 const held = previous.find((candidate) => candidate.instanceId === provider.instanceId);
84 if (held === undefined) return provider;
85 return {
86 ...provider,
87 slashCommands: held.slashCommands,
88 skills: held.skills,
89 ...(held.loadedCatalogDigest === undefined
90 ? {}
91 : { loadedCatalogDigest: held.loadedCatalogDigest }),
92 };
93 });
94}
95
96export function applyServerConfigProjection(
97 current: Option.Option<ServerConfigProjection>,
98 event: ServerConfigStreamEvent,
99): Option.Option<ServerConfigProjection> {
100 switch (event.type) {
101 case "snapshot": {
102 // Wire snapshots never contain published themes. Keep the previous set
103 // until the server sends its authoritative theme event.
104 const carriedThemes = Option.isSome(current)
105 ? current.value.config.environmentThemes
106 : undefined;
107 const carriedSources = Option.isSome(current)
108 ? current.value.config.usageLimitSources
109 : undefined;
110 const config: ServerConfig = {
111 ...event.config,
112 providers: withHeldProviderCatalogs(
113 Option.getOrUndefined(current)?.config.providers,
114 event.config.providers,
115 ),
116 ...(carriedThemes === undefined ? {} : { environmentThemes: carriedThemes }),
117 ...(carriedSources === undefined ? {} : { usageLimitSources: carriedSources }),
118 };
119 return Option.some({
120 // Every reconnect re-sends the whole config, usually unchanged apart from timestamps.
121 config: Option.isSome(current) ? reuseUnchanged(current.value.config, config) : config,
122 latestEvent: event,
123 source: "live" as const,
124 });
125 }
126 case "keybindingsUpdated":
127 return Option.map(current, (projection) => ({
128 config: {
129 ...projection.config,
130 keybindings: event.payload.keybindings,
131 issues: event.payload.issues,
132 },
133 latestEvent: event,
134 source: "live",
135 }));
136 case "providerStatuses":
137 case "providerStatusDeltas":
138 return Option.map(current, (projection) => {
139 const previous = projection.config.providers;
140 const providers =
141 event.type === "providerStatuses"
142 ? event.payload.providers
143 : applyServerProviderDeltas(previous, event.payload.providers);
144 return {
145 config: {
146 ...projection.config,
147 providers: reuseUnchanged(previous, withHeldProviderCatalogs(previous, providers)),
148 },
149 latestEvent: event,
150 source: "live",
151 };
152 });
153 case "settingsUpdated":
154 return Option.map(current, (projection) => {
155 const settings = reuseUnchanged(projection.config.settings, event.payload.settings);
156 return {
157 config:
158 settings === projection.config.settings
159 ? projection.config
160 : { ...projection.config, settings },
161 latestEvent: event,
162 source: "live",
163 };
164 });
165 case "environmentThemesUpdated":
166 return Option.map(current, (projection) => ({
167 config: {
168 ...projection.config,
169 environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined,
170 },
171 latestEvent: event,
172 source: "live",
173 }));
174 case "usageLimitSourcesUpdated":
175 return Option.map(current, (projection) => ({
176 config: {
177 ...projection.config,
178 usageLimitSources: event.payload.sources.length > 0 ? event.payload.sources : undefined,
179 },
180 latestEvent: event,
181 source: "live",
182 }));
183 }
184}