import { ConnectionPersistenceError, EnvironmentCacheStore, } from "@t3tools/client-runtime/platform"; import { type EnvironmentId, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, ServerConfig, VcsListRefsResult, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump // makes pre-pagination clients discard the record instead of decoding a // partial thread as complete (rollback safety). const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 3; const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; const VCS_REFS_CACHE_SCHEMA_VERSION = 1; const StoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), environmentId: Schema.String, snapshot: OrchestrationShellSnapshot, }); const StoredThreadSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), environmentId: Schema.String, threadId: Schema.String, snapshot: OrchestrationThreadDetailSnapshot, }); const StoredServerConfig = Schema.Struct({ schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), environmentId: Schema.String, config: ServerConfig, }); const StoredVcsRefs = Schema.Struct({ schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), environmentId: Schema.String, cwd: Schema.String, refs: VcsListRefsResult, }); const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredShellSnapshot), ); const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredThreadSnapshot), ); const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot)); const decodeStoredServerConfig = Schema.decodeUnknownEffect( Schema.fromJsonString(StoredServerConfig), ); const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig)); const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs)); const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs)); type CacheOperation = ConnectionPersistenceError["operation"]; function persistenceError(operation: CacheOperation, cause: unknown) { return new ConnectionPersistenceError({ operation, message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, }); } function mapDatabaseError(operation: CacheOperation) { return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); } function loadDecodedCache(input: { readonly database: MobileDatabase.MobileDatabase["Service"]; readonly environmentId: EnvironmentId; readonly kind: MobileDatabase.ClientCacheKind; readonly cacheKey: string; readonly operation: CacheOperation; readonly decode: (raw: string) => Effect.Effect; readonly select: (value: A) => Option.Option; }): Effect.Effect, ConnectionPersistenceError> { return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe( Effect.mapError(mapDatabaseError(input.operation)), Effect.flatMap( Option.match({ onNone: () => Effect.succeed(Option.none()), onSome: (raw) => input.decode(raw).pipe( Effect.map(input.select), Effect.catch((cause) => Effect.logWarning("Discarding corrupt mobile client cache record.", { environmentId: input.environmentId, kind: input.kind, cacheKey: input.cacheKey, cause: String(cause), }).pipe( Effect.andThen( input.database .removeCache(input.environmentId, input.kind, input.cacheKey) .pipe(Effect.catch(() => Effect.void)), ), Effect.as(Option.none()), ), ), ), }), ), ); } export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ database, environmentId, kind: "shell", cacheKey: "snapshot", operation: "load-shell", decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), }), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); yield* database .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-shell"))); }), loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) => loadDecodedCache({ database, environmentId, kind: "thread", cacheKey: threadId, operation: "load-thread", decode: decodeStoredThreadSnapshot, select: (stored) => stored.environmentId === environmentId && stored.threadId === threadId ? Option.some(stored.snapshot) : Option.none(), }), ), saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) { const threadId = snapshot.thread.id; const payload = yield* encodeStoredThreadSnapshot({ schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, threadId, snapshot, }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); yield* database .saveCache(environmentId, "thread", threadId, THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-thread"))); }), removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) => database .removeCache(environmentId, "thread", threadId) .pipe(Effect.mapError(mapDatabaseError("remove-thread"))), ), loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) => loadDecodedCache({ database, environmentId, kind: "server-config", cacheKey: "config", operation: "load-server-config", decode: decodeStoredServerConfig, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(), }), ), saveServerConfig: Effect.fn("MobileEnvironmentCache.saveServerConfig")( function* (environmentId, config) { const payload = yield* encodeStoredServerConfig({ schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, environmentId, config, }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); yield* database .saveCache( environmentId, "server-config", "config", SERVER_CONFIG_CACHE_SCHEMA_VERSION, payload, ) .pipe(Effect.mapError(mapDatabaseError("save-server-config"))); }, ), loadVcsRefs: Effect.fn("MobileEnvironmentCache.loadVcsRefs")((environmentId, cwd) => loadDecodedCache({ database, environmentId, kind: "vcs-refs", cacheKey: cwd, operation: "load-vcs-refs", decode: decodeStoredVcsRefs, select: (stored) => stored.environmentId === environmentId && stored.cwd === cwd ? Option.some(stored.refs) : Option.none(), }), ), saveVcsRefs: Effect.fn("MobileEnvironmentCache.saveVcsRefs")( function* (environmentId, cwd, refs) { const payload = yield* encodeStoredVcsRefs({ schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, environmentId, cwd, refs, }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause))); yield* database .saveCache(environmentId, "vcs-refs", cwd, VCS_REFS_CACHE_SCHEMA_VERSION, payload) .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs"))); }, ), removeVcsRefs: Effect.fn("MobileEnvironmentCache.removeVcsRefs")((environmentId, cwd) => database .removeCache(environmentId, "vcs-refs", cwd) .pipe(Effect.mapError(mapDatabaseError("remove-vcs-refs"))), ), clearVcsRefs: Effect.fn("MobileEnvironmentCache.clearVcsRefs")((environmentId) => database .clearCacheKind(environmentId, "vcs-refs") .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => database .clearEnvironmentCache(environmentId) .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), ), }); }); export const layer = Layer.effect(EnvironmentCacheStore, make());