1import {
2 ORCHESTRATION_CACHE_SCHEMA_VERSION,
3 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
4 StoredOrchestrationThreadSnapshot,
5 Persistence,
6} from "@t3tools/client-runtime/platform";
7import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Schema from "effect/Schema";
12
13import * as MobileDatabase from "../persistence/mobile-database";
14import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding";
15import {
16 attachProjectFaviconDatabase,
17 projectFaviconDatabaseCache,
18} from "../lib/projectFaviconDatabaseCache";
19import { decodeStoredShellSnapshot } from "./shell-cache-decoding";
20
21const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
22const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
23
24const StoredServerConfig = Schema.Struct({
25 schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION),
26 environmentId: Schema.String,
27 config: ServerConfig,
28});
29const StoredVcsRefs = Schema.Struct({
30 schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION),
31 environmentId: Schema.String,
32 cwd: Schema.String,
33 refs: VcsListRefsResult,
34});
35
36const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(
37 Schema.fromJsonString(StoredOrchestrationThreadSnapshot),
38);
39const encodeStoredThreadSnapshot = Schema.encodeEffect(
40 Schema.fromJsonString(StoredOrchestrationThreadSnapshot),
41);
42const decodeStoredServerConfig = Schema.decodeUnknownEffect(
43 Schema.fromJsonString(StoredServerConfig),
44);
45const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig));
46const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs));
47const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs));
48
49type CacheOperation = Persistence.ConnectionPersistenceError["operation"];
50
51function persistenceError(operation: CacheOperation, cause: unknown) {
52 return new Persistence.ConnectionPersistenceError({
53 operation,
54 message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,
55 });
56}
57
58function mapDatabaseError(operation: CacheOperation) {
59 return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error);
60}
61
62function loadDecodedCache<A, B>(input: {
63 readonly database: MobileDatabase.MobileDatabase["Service"];
64 readonly environmentId: EnvironmentId;
65 readonly kind: MobileDatabase.ClientCacheKind;
66 readonly cacheKey: string;
67 readonly operation: CacheOperation;
68 readonly decode: (raw: string) => Effect.Effect<A, unknown>;
69 readonly select: (value: A) => Option.Option<B>;
70}): Effect.Effect<Option.Option<B>, Persistence.ConnectionPersistenceError> {
71 return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe(
72 Effect.mapError(mapDatabaseError(input.operation)),
73 Effect.flatMap(
74 Option.match({
75 onNone: () => Effect.succeed(Option.none<B>()),
76 onSome: (raw) =>
77 input.decode(raw).pipe(
78 Effect.map(input.select),
79 Effect.catch((cause) =>
80 Effect.logWarning("Discarding corrupt mobile client cache record.", {
81 environmentId: input.environmentId,
82 kind: input.kind,
83 cacheKey: input.cacheKey,
84 cause: String(cause),
85 }).pipe(
86 Effect.andThen(
87 input.database
88 .removeCache(input.environmentId, input.kind, input.cacheKey)
89 .pipe(Effect.catch(() => Effect.void)),
90 ),
91 Effect.as(Option.none<B>()),
92 ),
93 ),
94 ),
95 }),
96 ),
97 );
98}
99
100export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
101 const database = yield* MobileDatabase.MobileDatabase;
102 attachProjectFaviconDatabase(database);
103 const encodeStoredShellSnapshot = makeStoredShellSnapshotEncoder();
104 return Persistence.EnvironmentCacheStore.of({
105 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
106 loadDecodedCache({
107 database,
108 environmentId,
109 kind: "shell",
110 cacheKey: "snapshot",
111 operation: "load-shell",
112 decode: decodeStoredShellSnapshot,
113 select: (stored) =>
114 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
115 }).pipe(
116 // Read cached project icons in parallel with the shell so the first rows can show
117 // them, instead of making the thread list wait for the icon read after the decode.
118 Effect.zipWith(
119 Effect.promise(() => projectFaviconDatabaseCache.hydrate()),
120 (snapshot) => snapshot,
121 { concurrent: true },
122 ),
123 ),
124 ),
125 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
126 const payload = yield* encodeStoredShellSnapshot({
127 schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION,
128 environmentId,
129 snapshot,
130 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
131 yield* database
132 .saveCache(environmentId, "shell", "snapshot", ORCHESTRATION_CACHE_SCHEMA_VERSION, payload)
133 .pipe(Effect.mapError(mapDatabaseError("save-shell")));
134 }),
135 loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) =>
136 loadDecodedCache({
137 database,
138 environmentId,
139 kind: "thread",
140 cacheKey: threadId,
141 operation: "load-thread",
142 decode: decodeStoredThreadSnapshot,
143 select: (stored) =>
144 stored.environmentId === environmentId && stored.threadId === threadId
145 ? Option.some(stored.snapshot)
146 : Option.none(),
147 }),
148 ),
149 saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) {
150 const threadId = snapshot.projection.thread.id;
151 const payload = yield* encodeStoredThreadSnapshot({
152 schemaVersion: ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
153 environmentId,
154 threadId,
155 snapshot,
156 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
157 yield* database
158 .saveCache(
159 environmentId,
160 "thread",
161 threadId,
162 ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION,
163 payload,
164 )
165 .pipe(Effect.mapError(mapDatabaseError("save-thread")));
166 }),
167 removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) =>
168 database
169 .removeCache(environmentId, "thread", threadId)
170 .pipe(Effect.mapError(mapDatabaseError("remove-thread"))),
171 ),
172 loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) =>
173 loadDecodedCache({
174 database,
175 environmentId,
176 kind: "server-config",
177 cacheKey: "config",
178 operation: "load-server-config",
179 decode: decodeStoredServerConfig,
180 select: (stored) =>
181 stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(),
182 }),
183 ),
184 saveServerConfig: Effect.fn("MobileEnvironmentCache.saveServerConfig")(
185 function* (environmentId, config) {
186 const payload = yield* encodeStoredServerConfig({
187 schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION,
188 environmentId,
189 config,
190 }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause)));
191 yield* database
192 .saveCache(
193 environmentId,
194 "server-config",
195 "config",
196 SERVER_CONFIG_CACHE_SCHEMA_VERSION,
197 payload,
198 )
199 .pipe(Effect.mapError(mapDatabaseError("save-server-config")));
200 },
201 ),
202 loadVcsRefs: Effect.fn("MobileEnvironmentCache.loadVcsRefs")((environmentId, cwd) =>
203 loadDecodedCache({
204 database,
205 environmentId,
206 kind: "vcs-refs",
207 cacheKey: cwd,
208 operation: "load-vcs-refs",
209 decode: decodeStoredVcsRefs,
210 select: (stored) =>
211 stored.environmentId === environmentId && stored.cwd === cwd
212 ? Option.some(stored.refs)
213 : Option.none(),
214 }),
215 ),
216 saveVcsRefs: Effect.fn("MobileEnvironmentCache.saveVcsRefs")(
217 function* (environmentId, cwd, refs) {
218 const payload = yield* encodeStoredVcsRefs({
219 schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION,
220 environmentId,
221 cwd,
222 refs,
223 }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause)));
224 yield* database
225 .saveCache(environmentId, "vcs-refs", cwd, VCS_REFS_CACHE_SCHEMA_VERSION, payload)
226 .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs")));
227 },
228 ),
229 removeVcsRefs: Effect.fn("MobileEnvironmentCache.removeVcsRefs")((environmentId, cwd) =>
230 database
231 .removeCache(environmentId, "vcs-refs", cwd)
232 .pipe(Effect.mapError(mapDatabaseError("remove-vcs-refs"))),
233 ),
234 clearVcsRefs: Effect.fn("MobileEnvironmentCache.clearVcsRefs")((environmentId) =>
235 database
236 .clearCacheKind(environmentId, "vcs-refs")
237 .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))),
238 ),
239 clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
240 Effect.promise(() => projectFaviconDatabaseCache.clearEnvironment(environmentId)).pipe(
241 Effect.andThen(database.clearEnvironmentCache(environmentId)),
242 Effect.mapError(mapDatabaseError("clear-environment")),
243 ),
244 ),
245 });
246});
247
248export const layer = Layer.effect(Persistence.EnvironmentCacheStore, make());