Skip to content
Source
Sign inSign up
Open History to see the last commit

mobile-storage.ts

201 lines6.6 KB

1import * as Arr from "effect/Array";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import { pipe } from "effect/Function";
5import * as Layer from "effect/Layer";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8​
9import * as MobileSecureStorage from "./mobile-secure-storage";
10​
11const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
12const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
13const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts";
14const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
15​
16export class MobileStorageDecodeError extends Schema.TaggedError<MobileStorageDecodeError>()(
17 "MobileStorageDecodeError",
18 {
19 key: Schema.String,
20 cause: Schema.Defect(),
21 },
22) {
23 override get message(): string {
24 return `Failed to decode mobile storage value for key ${this.key}.`;
25 }
27​
28export class MobileStorageEncodeError extends Schema.TaggedError<MobileStorageEncodeError>()(
29 "MobileStorageEncodeError",
30 {
31 key: Schema.String,
32 cause: Schema.Defect(),
33 },
34) {
35 override get message(): string {
36 return `Failed to encode mobile storage value for key ${this.key}.`;
37 }
39​
40export class MobileDeviceIdGenerationError extends Schema.TaggedError<MobileDeviceIdGenerationError>()(
41 "MobileDeviceIdGenerationError",
42 { cause: Schema.Defect() },
43) {
44 override get message(): string {
45 return "Failed to generate the mobile agent-awareness device id.";
46 }
48​
49export interface AgentAwarenessRegistrationRecord {
50 readonly identity: string;
51 readonly signature: string;
52 readonly pushToStartToken?: string;
54​
55export interface RecentThreadShortcut {
56 readonly environmentId: string;
57 readonly threadId: string;
58 readonly title: string;
60​
61export class MobileStorage extends Context.Service<
62 MobileStorage,
63 {
64 readonly loadOrCreateAgentAwarenessDeviceId: Effect.Effect<
65 string,
66 MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError
67 >;
68 readonly loadAgentAwarenessDeviceId: Effect.Effect<
69 string | null,
70 MobileSecureStorage.MobileSecureStorageError
71 >;
72 readonly loadAgentAwarenessRegistrationRecord: Effect.Effect<
73 AgentAwarenessRegistrationRecord | null,
74 MobileSecureStorage.MobileSecureStorageError
75 >;
76 readonly saveAgentAwarenessRegistrationRecord: (
77 record: AgentAwarenessRegistrationRecord,
78 ) => Effect.Effect<
79 void,
80 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
81 >;
82 readonly clearAgentAwarenessRegistrationRecord: Effect.Effect<
83 void,
84 MobileSecureStorage.MobileSecureStorageError
85 >;
86 readonly loadRecentThreadShortcuts: Effect.Effect<
87 ReadonlyArray<RecentThreadShortcut>,
88 MobileSecureStorage.MobileSecureStorageError
89 >;
90 readonly saveRecentThreadShortcuts: (
91 threads: ReadonlyArray<RecentThreadShortcut>,
92 ) => Effect.Effect<
93 void,
94 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
95 >;
96 }
97>()("@t3tools/mobile/persistence/mobile-storage/MobileStorage") {}
98​
99export const make = Effect.fn("MobileStorage.make")(function* () {
100 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
101 const deviceIdLock = yield* Semaphore.make(1);
102​
103 const parseJson = <A>(key: string, raw: string): A | null => {
104 if (!raw.trim()) return null;
105 try {
106 return JSON.parse(raw) as A;
107 } catch (cause) {
108 console.warn(
109 "[mobile-storage] ignored invalid JSON",
110 new MobileStorageDecodeError({ key, cause }),
111 );
112 return null;
113 }
114 };
115​
116 const readJson = Effect.fn("MobileStorage.readJson")(function* <A>(key: string) {
117 const raw = (yield* secureStorage.getItem(key)) ?? "";
118 return parseJson<A>(key, raw);
119 });
120​
121 const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) {
122 const encoded = yield* encodeJson(value).pipe(
123 Effect.mapError((cause) => new MobileStorageEncodeError({ key, cause })),
124 );
125 yield* secureStorage.setItem(key, encoded);
126 });
127​
128 const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () {
129 const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY);
130 if (existing?.trim()) return existing;
131 const deviceId = yield* Effect.tryPromise({
132 try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()),
133 catch: (cause) => new MobileDeviceIdGenerationError({ cause }),
134 });
135 yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
136 return deviceId;
137 }).pipe(deviceIdLock.withPermits(1));
138​
139 const loadAgentAwarenessDeviceId = secureStorage
140 .getItem(AGENT_AWARENESS_DEVICE_ID_KEY)
141 .pipe(Effect.map((existing) => (existing?.trim() ? existing : null)));
142​
143 const loadAgentAwarenessRegistrationRecord = readJson<AgentAwarenessRegistrationRecord>(
144 AGENT_AWARENESS_REGISTRATION_KEY,
145 ).pipe(
146 Effect.map((parsed) => {
147 if (
148 !parsed ||
149 typeof parsed !== "object" ||
150 typeof parsed.identity !== "string" ||
151 typeof parsed.signature !== "string"
152 ) {
153 return null;
154 }
155 return {
156 identity: parsed.identity,
157 signature: parsed.signature,
158 ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken
159 ? { pushToStartToken: parsed.pushToStartToken }
160 : {}),
161 };
162 }),
163 );
164​
165 const loadRecentThreadShortcuts = readJson<{
166 readonly threads?: ReadonlyArray<RecentThreadShortcut>;
167 }>(RECENT_THREAD_SHORTCUTS_KEY).pipe(
168 Effect.map((parsed) =>
169 pipe(
170 parsed?.threads ?? [],
171 Arr.filter(
172 (thread) =>
173 typeof thread?.environmentId === "string" &&
174 thread.environmentId.length > 0 &&
175 typeof thread.threadId === "string" &&
176 thread.threadId.length > 0 &&
177 typeof thread.title === "string",
178 ),
179 ),
180 ),
181 );
182​
183 return MobileStorage.of({
184 loadOrCreateAgentAwarenessDeviceId,
185 loadAgentAwarenessDeviceId,
186 loadAgentAwarenessRegistrationRecord,
187 saveAgentAwarenessRegistrationRecord: Effect.fn(
188 "MobileStorage.saveAgentAwarenessRegistrationRecord",
189 )((record) => writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record)),
190 clearAgentAwarenessRegistrationRecord: secureStorage.setItem(
191 AGENT_AWARENESS_REGISTRATION_KEY,
192 "",
193 ),
194 loadRecentThreadShortcuts,
195 saveRecentThreadShortcuts: Effect.fn("MobileStorage.saveRecentThreadShortcuts")((threads) =>
196 writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }),
197 ),
198 });
199});
200​
201export const layer = Layer.effect(MobileStorage, make());