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