| 1 | import { |
| 2 | ORCHESTRATION_CACHE_SCHEMA_VERSION, |
| 3 | ORCHESTRATION_THREAD_CACHE_SCHEMA_VERSION, |
| 4 | StoredOrchestrationThreadSnapshot, |
| 5 | Persistence, |
| 6 | } from "@t3tools/client-runtime/platform"; |
| 7 | import { type EnvironmentId, ServerConfig, VcsListRefsResult } from "@t3tools/contracts"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Layer from "effect/Layer"; |
| 10 | import * as Option from "effect/Option"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | |
| 13 | import * as MobileDatabase from "../persistence/mobile-database"; |
| 14 | import { makeStoredShellSnapshotEncoder } from "./shell-cache-encoding"; |
| 15 | import { |
| 16 | attachProjectFaviconDatabase, |
| 17 | projectFaviconDatabaseCache, |
| 18 | } from "../lib/projectFaviconDatabaseCache"; |
| 19 | import { decodeStoredShellSnapshot } from "./shell-cache-decoding"; |
| 20 | |
| 21 | const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; |
| 22 | const VCS_REFS_CACHE_SCHEMA_VERSION = 1; |
| 23 | |
| 24 | const StoredServerConfig = Schema.Struct({ |
| 25 | schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), |
| 26 | environmentId: Schema.String, |
| 27 | config: ServerConfig, |
| 28 | }); |
| 29 | const 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 | |
| 36 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( |
| 37 | Schema.fromJsonString(StoredOrchestrationThreadSnapshot), |
| 38 | ); |
| 39 | const encodeStoredThreadSnapshot = Schema.encodeEffect( |
| 40 | Schema.fromJsonString(StoredOrchestrationThreadSnapshot), |
| 41 | ); |
| 42 | const decodeStoredServerConfig = Schema.decodeUnknownEffect( |
| 43 | Schema.fromJsonString(StoredServerConfig), |
| 44 | ); |
| 45 | const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig)); |
| 46 | const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs)); |
| 47 | const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs)); |
| 48 | |
| 49 | type CacheOperation = Persistence.ConnectionPersistenceError["operation"]; |
| 50 | |
| 51 | function persistenceError(operation: CacheOperation, cause: unknown) { |
| 52 | return new Persistence.ConnectionPersistenceError({ |
| 53 | operation, |
| 54 | message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, |
| 55 | }); |
| 56 | } |
| 57 | |
| 58 | function mapDatabaseError(operation: CacheOperation) { |
| 59 | return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); |
| 60 | } |
| 61 | |
| 62 | function 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 | |
| 100 | export 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 | |
| 248 | export const layer = Layer.effect(Persistence.EnvironmentCacheStore, make()); |