1import {
2 ConnectionCatalogDocument,
3 type ConnectionCatalogDocument as ConnectionCatalogDocumentType,
4 EMPTY_CONNECTION_CATALOG_DOCUMENT,
5} from "@t3tools/client-runtime/platform";
6import { ConnectionTransientError } from "@t3tools/client-runtime/connection";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import * as Semaphore from "effect/Semaphore";
12
13import * as MobileSecureStorage from "../persistence/mobile-secure-storage";
14import { migrateLegacyConnectionCatalog } from "./migration";
15
16export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";
17export const LEGACY_CONNECTIONS_KEY = "t3code.connections";
18
19function 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
26const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
27const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson);
28const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
29
30const 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
36const 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
44interface CatalogStore {
45 readonly read: Effect.Effect<ConnectionCatalogDocumentType, ConnectionTransientError>;
46 readonly update: (
47 transform: (catalog: ConnectionCatalogDocumentType) => ConnectionCatalogDocumentType,
48 ) => Effect.Effect<void, ConnectionTransientError>;
49}
50
51export 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});