435 lines · 15.6 KB · TypeScript
RawDownload
import {  ConnectionPersistenceError,  ConnectionRegistrationStore,  ConnectionTargetStore,  EnvironmentCacheStore,  registerConnectionInCatalog,  removeConnectionFromCatalog,  removeCatalogValue,  replaceCatalogValue,} from "@t3tools/client-runtime/platform";import { TokenStore } from "@t3tools/client-runtime/authorization";import {  ConnectionTransientError,  CredentialStore,  ProfileStore,} from "@t3tools/client-runtime/connection";import {  EnvironmentId,  OrchestrationShellSnapshot,  OrchestrationThreadDetailSnapshot,  ThreadId,} from "@t3tools/contracts";import * as Context from "effect/Context";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 SecureStore from "expo-secure-store"; import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";// v2 stores the snapshot sequence alongside the thread so a warm cache can// resume via `afterSequence` instead of re-downloading the full thread body.// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; const StoredShellSnapshot = Schema.Struct({  schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),  environmentId: EnvironmentId,  snapshot: OrchestrationShellSnapshot,}); const StoredThreadSnapshot = Schema.Struct({  schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),  environmentId: EnvironmentId,  threadId: ThreadId,  snapshot: OrchestrationThreadDetailSnapshot,}); const LegacyStoredShellSnapshot = Schema.Struct({  schemaVersion: Schema.Literal(1),  environmentId: EnvironmentId,  snapshotReceivedAt: Schema.String,  snapshot: OrchestrationShellSnapshot,}); function catalogError(operation: string, cause: unknown) {  return new ConnectionTransientError({    reason: "remote-unavailable",    detail: `Could not ${operation} the local connection catalog: ${String(cause)}`,  });} function shellPersistenceError(  operation:    | "load-shell"    | "save-shell"    | "load-thread"    | "save-thread"    | "remove-thread"    | "clear-environment",  cause: unknown,) {  return new ConnectionPersistenceError({    operation,    message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,  });} function threadSnapshotFileName(threadId: ThreadId): string {  return `${encodeURIComponent(threadId)}.json`;} const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")(  function* (    environmentId: EnvironmentId,    operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment",  ) {    return yield* Effect.tryPromise({      try: async () => {        const { Directory, Paths } = await import("expo-file-system");        const directory = new Directory(          Paths.document,          THREAD_SNAPSHOT_CACHE_DIRECTORY,          encodeURIComponent(environmentId),        );        if (operation !== "clear-environment") {          directory.create({ idempotent: true, intermediates: true });        }        return directory;      },      catch: (cause) => shellPersistenceError(operation, cause),    });  },); const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* (  environmentId: EnvironmentId,  threadId: ThreadId,  operation: "load-thread" | "save-thread" | "remove-thread",) {  const { File } = yield* Effect.promise(() => import("expo-file-system"));  return new File(    yield* threadSnapshotDirectory(environmentId, operation),    threadSnapshotFileName(threadId),  );}); function targetPersistenceError(  operation: "list-targets" | "register-connection" | "remove-connection",  error: ConnectionTransientError,) {  return new ConnectionPersistenceError({    operation,    message: error.message,  });} const secureCatalogStorage: SecureCatalogStorage = {  getItem: (key) =>    Effect.tryPromise({      try: () => SecureStore.getItemAsync(key),      catch: (cause) => catalogError("load", cause),    }),  setItem: (key, value) =>    Effect.tryPromise({      try: () => SecureStore.setItemAsync(key, value),      catch: (cause) => catalogError("save", cause),    }),  deleteItem: (key) =>    Effect.tryPromise({      try: () => SecureStore.deleteItemAsync(key),      catch: (cause) => catalogError("delete", cause),    }),}; function shellSnapshotFileName(environmentId: EnvironmentId): string {  return `${encodeURIComponent(environmentId)}.json`;} const shellSnapshotFileInDirectory = Effect.fn(  "mobile.connectionStorage.shellSnapshotFileInDirectory",)(function* (  environmentId: EnvironmentId,  operation: "load-shell" | "save-shell" | "clear-environment",  directoryName: string,) {  return yield* Effect.tryPromise({    try: async () => {      const { Directory, File, Paths } = await import("expo-file-system");      const directory = new Directory(Paths.document, directoryName);      directory.create({ idempotent: true, intermediates: true });      return new File(directory, shellSnapshotFileName(environmentId));    },    catch: (cause) => shellPersistenceError(operation, cause),  });}); const shellSnapshotFile = (  environmentId: EnvironmentId,  operation: "load-shell" | "save-shell" | "clear-environment",) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); const legacyShellSnapshotFile = (  environmentId: EnvironmentId,  operation: "load-shell" | "clear-environment",) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); export const connectionStorageLayer = Layer.effectContext(  Effect.gen(function* () {    const catalog = yield* makeCatalogStore(secureCatalogStorage);     const targetStore = ConnectionTargetStore.of({      list: catalog.read.pipe(        Effect.map((document) => document.targets),        Effect.mapError((error) => targetPersistenceError("list-targets", error)),      ),    });    const registrationStore = ConnectionRegistrationStore.of({      register: (registration) =>        catalog          .update((document) => registerConnectionInCatalog(document, registration))          .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))),      remove: (target) =>        catalog          .update((document) => removeConnectionFromCatalog(document, target))          .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))),    });    const profileStore = ProfileStore.make({      get: (connectionId) =>        catalog.read.pipe(          Effect.map((document) =>            Option.fromUndefinedOr(              document.profiles.find((candidate) => candidate.connectionId === connectionId),            ),          ),        ),      put: (profile) =>        catalog.update((document) => ({          ...document,          profiles: replaceCatalogValue(document.profiles, (value) => value.connectionId, profile),        })),      remove: (connectionId) =>        catalog.update((document) => ({          ...document,          profiles: removeCatalogValue(            document.profiles,            (value) => value.connectionId,            connectionId,          ),        })),    });    const credentialStore = CredentialStore.make({      get: (connectionId) =>        catalog.read.pipe(          Effect.map((document) =>            Option.fromUndefinedOr(              document.credentials.find((entry) => entry.connectionId === connectionId)?.credential,            ),          ),        ),      put: (connectionId, credential) =>        catalog.update((document) => ({          ...document,          credentials: replaceCatalogValue(document.credentials, (value) => value.connectionId, {            connectionId,            credential,          }),        })),      remove: (connectionId) =>        catalog.update((document) => ({          ...document,          credentials: removeCatalogValue(            document.credentials,            (value) => value.connectionId,            connectionId,          ),        })),    });    const remoteTokenStore = TokenStore.make({      get: (environmentId) =>        catalog.read.pipe(          Effect.map((document) =>            Option.fromUndefinedOr(              document.remoteDpopTokens.find((token) => token.environmentId === environmentId),            ),          ),        ),      put: (token) =>        catalog.update((document) => ({          ...document,          remoteDpopTokens: replaceCatalogValue(            document.remoteDpopTokens,            (value) => value.environmentId,            token,          ),        })),      remove: (environmentId) =>        catalog.update((document) => ({          ...document,          remoteDpopTokens: removeCatalogValue(            document.remoteDpopTokens,            (value) => value.environmentId,            environmentId,          ),        })),    });    const cacheStore = EnvironmentCacheStore.of({      loadShell: (environmentId) =>        Effect.gen(function* () {          const file = yield* shellSnapshotFile(environmentId, "load-shell");          if (file.exists) {            const raw = yield* Effect.tryPromise({              try: () => file.text(),              catch: (cause) => shellPersistenceError("load-shell", cause),            });            const parsed = yield* Effect.try({              try: () => JSON.parse(raw) as unknown,              catch: (cause) => shellPersistenceError("load-shell", cause),            });            const stored = yield* Effect.fromResult(              Schema.decodeUnknownResult(StoredShellSnapshot)(parsed),            ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause)));            return stored.environmentId === environmentId              ? Option.some(stored.snapshot)              : Option.none();          }           const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell");          if (!legacyFile.exists) {            return Option.none();          }          const legacyRaw = yield* Effect.tryPromise({            try: () => legacyFile.text(),            catch: (cause) => shellPersistenceError("load-shell", cause),          });          const legacyParsed = yield* Effect.try({            try: () => JSON.parse(legacyRaw) as unknown,            catch: (cause) => shellPersistenceError("load-shell", cause),          });          const legacyStored = yield* Effect.fromResult(            Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed),          ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause)));          return legacyStored.environmentId === environmentId            ? Option.some(legacyStored.snapshot)            : Option.none();        }),      saveShell: (environmentId, snapshot) =>        Effect.gen(function* () {          const file = yield* shellSnapshotFile(environmentId, "save-shell");          const stored = {            schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,            environmentId,            snapshot,          } as const;          const encoded = yield* Effect.fromResult(            Schema.encodeUnknownResult(StoredShellSnapshot)(stored),          ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause)));          yield* Effect.try({            try: () => {              if (!file.exists) {                file.create({ intermediates: true, overwrite: true });              }              file.write(JSON.stringify(encoded));            },            catch: (cause) => shellPersistenceError("save-shell", cause),          });        }),      loadThread: (environmentId, threadId) =>        Effect.gen(function* () {          const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread");          if (!file.exists) {            return Option.none();          }          const raw = yield* Effect.tryPromise({            try: () => file.text(),            catch: (cause) => shellPersistenceError("load-thread", cause),          });          const parsed = yield* Effect.try({            try: () => JSON.parse(raw) as unknown,            catch: (cause) => shellPersistenceError("load-thread", cause),          });          const stored = yield* Effect.fromResult(            Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),          ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));          return stored.environmentId === environmentId && stored.threadId === threadId            ? Option.some(stored.snapshot)            : Option.none();        }),      saveThread: (environmentId, snapshot) =>        Effect.gen(function* () {          const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread");          const encoded = yield* Effect.fromResult(            Schema.encodeUnknownResult(StoredThreadSnapshot)({              schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,              environmentId,              threadId: snapshot.thread.id,              snapshot,            }),          ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));          yield* Effect.try({            try: () => {              if (!file.exists) {                file.create({ intermediates: true, overwrite: true });              }              file.write(JSON.stringify(encoded));            },            catch: (cause) => shellPersistenceError("save-thread", cause),          });        }),      removeThread: (environmentId, threadId) =>        Effect.gen(function* () {          const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread");          if (file.exists) {            file.delete();          }        }).pipe(          Effect.mapError((cause) =>            cause._tag === "ConnectionPersistenceError"              ? cause              : shellPersistenceError("remove-thread", cause),          ),        ),      clear: (environmentId) =>        Effect.gen(function* () {          const file = yield* shellSnapshotFile(environmentId, "clear-environment");          if (file.exists) {            yield* Effect.try({              try: () => file.delete(),              catch: (cause) => shellPersistenceError("clear-environment", cause),            });          }          const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment");          if (legacyFile.exists) {            yield* Effect.try({              try: () => legacyFile.delete(),              catch: (cause) => shellPersistenceError("clear-environment", cause),            });          }          const threadDirectory = yield* threadSnapshotDirectory(            environmentId,            "clear-environment",          );          if (threadDirectory.exists) {            yield* Effect.try({              try: () => threadDirectory.delete(),              catch: (cause) => shellPersistenceError("clear-environment", cause),            });          }        }),    });     return Context.make(ConnectionTargetStore, targetStore).pipe(      Context.add(ConnectionRegistrationStore, registrationStore),      Context.add(ProfileStore.ConnectionProfileStore, profileStore),      Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),      Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),      Context.add(EnvironmentCacheStore, cacheStore),    );  }),);