import { ConnectionCatalogDocument, type ConnectionCatalogDocument as ConnectionCatalogDocumentType, EMPTY_CONNECTION_CATALOG_DOCUMENT, } from "@t3tools/client-runtime/platform"; import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; import { migrateLegacyConnectionCatalog } from "./migration"; export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, }); } const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { return yield* decodeConnectionCatalogDocument(raw).pipe( Effect.mapError((cause) => catalogError("decode", cause)), ); }); const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( catalog: ConnectionCatalogDocumentType, ) { return yield* encodeConnectionCatalogDocument(catalog).pipe( Effect.mapError((cause) => catalogError("encode", cause)), ); }); interface CatalogStore { readonly read: Effect.Effect; readonly update: ( transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, ) => Effect.Effect; } export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { const storage = yield* MobileSecureStorage.MobileSecureStorage; const getItem = (key: string) => storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); const setItem = (key: string, value: string) => storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); const deleteItem = (key: string) => storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); const state = yield* Ref.make>(Option.none()); const lock = yield* Semaphore.make(1); const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY); const catalog = legacyRaw === null || legacyRaw.trim() === "" ? EMPTY_CONNECTION_CATALOG_DOCUMENT : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( Effect.mapError((cause) => catalogError("migrate", cause)), Effect.catch((error) => Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), ), ), ); if (legacyRaw !== null && legacyRaw.trim() !== "") { const encoded = yield* encodeCatalog(catalog); yield* setItem(CONNECTION_CATALOG_KEY, encoded); yield* deleteItem(LEGACY_CONNECTIONS_KEY); } return catalog; }); const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () { const cached = yield* Ref.get(state); if (Option.isSome(cached)) { return cached.value; } const raw = yield* getItem(CONNECTION_CATALOG_KEY); let catalog: ConnectionCatalogDocumentType; if (raw !== null && raw.trim() !== "") { catalog = yield* decodeCatalog(raw).pipe( Effect.catch((error) => Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)), Effect.andThen(loadLegacyCatalog()), ), ), ); } else { catalog = yield* loadLegacyCatalog(); } yield* Ref.set(state, Option.some(catalog)); return catalog; }); const read = lock.withPermits(1)(loadUnlocked()); const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")( function* (transform) { yield* lock.withPermits(1)( Effect.gen(function* () { const next = transform(yield* loadUnlocked()); const encoded = yield* encodeCatalog(next); yield* setItem(CONNECTION_CATALOG_KEY, encoded); yield* Ref.set(state, Option.some(next)); }), ); }, ); return { read, update } satisfies CatalogStore; });