| 1 | import { |
| 2 | ConnectionCatalogDocument, |
| 3 | type ConnectionCatalogDocument as ConnectionCatalogDocumentType, |
| 4 | EMPTY_CONNECTION_CATALOG_DOCUMENT, |
| 5 | } from "@t3tools/client-runtime/platform"; |
| 6 | import { ConnectionTransientError } from "@t3tools/client-runtime/connection"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as Option from "effect/Option"; |
| 9 | import * as Ref from "effect/Ref"; |
| 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as Semaphore from "effect/Semaphore"; |
| 12 | |
| 13 | import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; |
| 14 | import { migrateLegacyConnectionCatalog } from "./migration"; |
| 15 | |
| 16 | export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; |
| 17 | export const LEGACY_CONNECTIONS_KEY = "t3code.connections"; |
| 18 | |
| 19 | function catalogError(operation: string, cause: unknown) { |
| 20 | return new ConnectionTransientError({ |
| 21 | reason: "remote-unavailable", |
| 22 | detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, |
| 23 | }); |
| 24 | } |
| 25 | |
| 26 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); |
| 27 | const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); |
| 28 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); |
| 29 | |
| 30 | const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { |
| 31 | return yield* decodeConnectionCatalogDocument(raw).pipe( |
| 32 | Effect.mapError((cause) => catalogError("decode", cause)), |
| 33 | ); |
| 34 | }); |
| 35 | |
| 36 | const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( |
| 37 | catalog: ConnectionCatalogDocumentType, |
| 38 | ) { |
| 39 | return yield* encodeConnectionCatalogDocument(catalog).pipe( |
| 40 | Effect.mapError((cause) => catalogError("encode", cause)), |
| 41 | ); |
| 42 | }); |
| 43 | |
| 44 | interface CatalogStore { |
| 45 | readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>; |
| 46 | readonly update: ( |
| 47 | transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType, |
| 48 | ) => Effect.Effect<void, ConnectionTransientError>; |
| 49 | } |
| 50 | |
| 51 | export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { |
| 52 | const storage = yield* MobileSecureStorage.MobileSecureStorage; |
| 53 | const getItem = (key: string) => |
| 54 | storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); |
| 55 | const setItem = (key: string, value: string) => |
| 56 | storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); |
| 57 | const deleteItem = (key: string) => |
| 58 | storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); |
| 59 | const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none()); |
| 60 | const lock = yield* Semaphore.make(1); |
| 61 | |
| 62 | const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { |
| 63 | const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY); |
| 64 | const catalog = |
| 65 | legacyRaw === null || legacyRaw.trim() === "" |
| 66 | ? EMPTY_CONNECTION_CATALOG_DOCUMENT |
| 67 | : yield* migrateLegacyConnectionCatalog(legacyRaw).pipe( |
| 68 | Effect.mapError((cause) => catalogError("migrate", cause)), |
| 69 | Effect.catch((error) => |
| 70 | Effect.logWarning("Discarding corrupt legacy mobile connections", error).pipe( |
| 71 | Effect.as(EMPTY_CONNECTION_CATALOG_DOCUMENT), |
| 72 | ), |
| 73 | ), |
| 74 | ); |
| 75 | if (legacyRaw !== null && legacyRaw.trim() !== "") { |
| 76 | const encoded = yield* encodeCatalog(catalog); |
| 77 | yield* setItem(CONNECTION_CATALOG_KEY, encoded); |
| 78 | yield* deleteItem(LEGACY_CONNECTIONS_KEY); |
| 79 | } |
| 80 | return catalog; |
| 81 | }); |
| 82 | |
| 83 | const loadUnlocked = Effect.fn("mobile.connectionStorage.loadCatalog")(function* () { |
| 84 | const cached = yield* Ref.get(state); |
| 85 | if (Option.isSome(cached)) { |
| 86 | return cached.value; |
| 87 | } |
| 88 | const raw = yield* getItem(CONNECTION_CATALOG_KEY); |
| 89 | let catalog: ConnectionCatalogDocumentType; |
| 90 | if (raw !== null && raw.trim() !== "") { |
| 91 | catalog = yield* decodeCatalog(raw).pipe( |
| 92 | Effect.catch((error) => |
| 93 | Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( |
| 94 | Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)), |
| 95 | Effect.andThen(loadLegacyCatalog()), |
| 96 | ), |
| 97 | ), |
| 98 | ); |
| 99 | } else { |
| 100 | catalog = yield* loadLegacyCatalog(); |
| 101 | } |
| 102 | yield* Ref.set(state, Option.some(catalog)); |
| 103 | return catalog; |
| 104 | }); |
| 105 | |
| 106 | const read = lock.withPermits(1)(loadUnlocked()); |
| 107 | const update: CatalogStore["update"] = Effect.fn("mobile.connectionStorage.updateCatalog")( |
| 108 | function* (transform) { |
| 109 | yield* lock.withPermits(1)( |
| 110 | Effect.gen(function* () { |
| 111 | const next = transform(yield* loadUnlocked()); |
| 112 | const encoded = yield* encodeCatalog(next); |
| 113 | yield* setItem(CONNECTION_CATALOG_KEY, encoded); |
| 114 | yield* Ref.set(state, Option.some(next)); |
| 115 | }), |
| 116 | ); |
| 117 | }, |
| 118 | ); |
| 119 | |
| 120 | return { read, update } satisfies CatalogStore; |
| 121 | }); |