1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import type { SQLiteDatabase } from "expo-sqlite";
8
9const DATABASE_NAME = "t3code-client.db";
10const DATABASE_SCHEMA_VERSION = 1;
11const LEGACY_CACHE_DIRECTORIES = [
12 "connection-shell-snapshots",
13 "shell-snapshots",
14 "connection-thread-snapshots",
15 "connection-server-configs",
16 "connection-vcs-refs",
17] as const;
18
19export const ClientCacheKind = Schema.Literals([
20 "shell",
21 "thread",
22 "server-config",
23 "vcs-refs",
24 "project-favicon",
25 "pull-request",
26]);
27export type ClientCacheKind = typeof ClientCacheKind.Type;
28
29export interface ClientCacheSummaryRow {
30 readonly environmentId: EnvironmentId;
31 readonly kind: ClientCacheKind;
32 readonly recordCount: number;
33 readonly payloadBytes: number;
34}
35
36export interface StoredPreferencesJson {
37 readonly payload: string;
38 readonly updatedAt: number;
39}
40
41const ClientCacheSummaryRows = Schema.Array(
42 Schema.Struct({
43 environmentId: Schema.String,
44 kind: ClientCacheKind,
45 recordCount: Schema.Number,
46 payloadBytes: Schema.Number,
47 }),
48);
49
50const MobileDatabaseOperation = Schema.Literals([
51 "open",
52 "migrate",
53 "load-cache",
54 "list-cache",
55 "save-cache",
56 "remove-cache",
57 "clear-cache-kind",
58 "clear-environment-cache",
59 "clear-all-caches",
60 "inspect-caches",
61 "load-preferences",
62 "save-preferences",
63]);
64
65export class MobileDatabaseError extends Schema.TaggedError<MobileDatabaseError>()(
66 "MobileDatabaseError",
67 {
68 operation: MobileDatabaseOperation,
69 cause: Schema.Defect(),
70 },
71) {
72 override get message(): string {
73 return `Mobile database operation failed: ${this.operation}.`;
74 }
75}
76
77function databaseError(operation: typeof MobileDatabaseOperation.Type) {
78 return (cause: unknown) => new MobileDatabaseError({ operation, cause });
79}
80
81interface LegacyCacheRecord {
82 readonly environmentId: string;
83 readonly kind: ClientCacheKind;
84 readonly cacheKey: string;
85 readonly schemaVersion: number;
86 readonly payload: string;
87}
88
89function objectRecord(value: unknown): Record<string, unknown> | null {
90 return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
91}
92
93export function decodeLegacyCacheRecord(
94 directoryName: (typeof LEGACY_CACHE_DIRECTORIES)[number],
95 payload: string,
96): LegacyCacheRecord | null {
97 let parsed: Record<string, unknown> | null;
98 try {
99 parsed = objectRecord(JSON.parse(payload));
100 } catch {
101 return null;
102 }
103 if (
104 parsed === null ||
105 typeof parsed.environmentId !== "string" ||
106 typeof parsed.schemaVersion !== "number"
107 ) {
108 return null;
109 }
110
111 switch (directoryName) {
112 case "connection-shell-snapshots":
113 case "shell-snapshots":
114 return {
115 environmentId: parsed.environmentId,
116 kind: "shell",
117 cacheKey: "snapshot",
118 schemaVersion: parsed.schemaVersion,
119 payload,
120 };
121 case "connection-thread-snapshots":
122 return typeof parsed.threadId === "string"
123 ? {
124 environmentId: parsed.environmentId,
125 kind: "thread",
126 cacheKey: parsed.threadId,
127 schemaVersion: parsed.schemaVersion,
128 payload,
129 }
130 : null;
131 case "connection-server-configs":
132 return {
133 environmentId: parsed.environmentId,
134 kind: "server-config",
135 cacheKey: "config",
136 schemaVersion: parsed.schemaVersion,
137 payload,
138 };
139 case "connection-vcs-refs":
140 return typeof parsed.cwd === "string"
141 ? {
142 environmentId: parsed.environmentId,
143 kind: "vcs-refs",
144 cacheKey: parsed.cwd,
145 schemaVersion: parsed.schemaVersion,
146 payload,
147 }
148 : null;
149 }
150}
151
152async function migrateLegacyFileCaches(database: SQLiteDatabase): Promise<boolean> {
153 try {
154 const { Directory, File, Paths } = await import("expo-file-system");
155 let complete = true;
156 const listFiles = (
157 directory: InstanceType<typeof Directory>,
158 ): Array<InstanceType<typeof File>> =>
159 directory.list().flatMap((entry) => (entry instanceof File ? [entry] : listFiles(entry)));
160
161 for (const directoryName of LEGACY_CACHE_DIRECTORIES) {
162 try {
163 const directory = new Directory(Paths.document, directoryName);
164 if (!directory.exists) continue;
165 for (const file of listFiles(directory)) {
166 const payload = await file.text();
167 const record = decodeLegacyCacheRecord(directoryName, payload);
168 if (record === null) continue;
169 await database.runAsync(
170 `INSERT INTO client_cache
171 (environment_id, kind, cache_key, schema_version, payload, updated_at)
172 VALUES (?, ?, ?, ?, ?, ?)
173 ON CONFLICT (environment_id, kind, cache_key) DO NOTHING`,
174 record.environmentId,
175 record.kind,
176 record.cacheKey,
177 record.schemaVersion,
178 record.payload,
179 Date.now(),
180 );
181 }
182 directory.delete();
183 } catch (cause) {
184 complete = false;
185 console.warn(`[mobile-database] could not migrate legacy cache ${directoryName}`, cause);
186 }
187 }
188 return complete;
189 } catch (cause) {
190 console.warn("[mobile-database] could not load legacy cache migration", cause);
191 return false;
192 }
193}
194
195export class MobileDatabase extends Context.Service<
196 MobileDatabase,
197 {
198 readonly loadCache: (
199 environmentId: EnvironmentId,
200 kind: ClientCacheKind,
201 cacheKey: string,
202 ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
203 readonly listCache: (
204 kind: ClientCacheKind,
205 ) => Effect.Effect<ReadonlyArray<string>, MobileDatabaseError>;
206 readonly saveCache: (
207 environmentId: EnvironmentId,
208 kind: ClientCacheKind,
209 cacheKey: string,
210 schemaVersion: number,
211 payload: string,
212 ) => Effect.Effect<void, MobileDatabaseError>;
213 readonly removeCache: (
214 environmentId: EnvironmentId,
215 kind: ClientCacheKind,
216 cacheKey: string,
217 ) => Effect.Effect<void, MobileDatabaseError>;
218 readonly clearCacheKind: (
219 environmentId: EnvironmentId,
220 kind: ClientCacheKind,
221 ) => Effect.Effect<void, MobileDatabaseError>;
222 readonly clearEnvironmentCache: (
223 environmentId: EnvironmentId,
224 ) => Effect.Effect<void, MobileDatabaseError>;
225 readonly clearAllCaches: Effect.Effect<void, MobileDatabaseError>;
226 readonly inspectCaches: Effect.Effect<
227 ReadonlyArray<ClientCacheSummaryRow>,
228 MobileDatabaseError
229 >;
230 readonly loadPreferencesJson: Effect.Effect<
231 Option.Option<StoredPreferencesJson>,
232 MobileDatabaseError
233 >;
234 readonly savePreferencesJson: (
235 payload: string,
236 updatedAt: number,
237 ) => Effect.Effect<void, MobileDatabaseError>;
238 }
239>()("@t3tools/mobile/persistence/MobileDatabase") {}
240
241const makeAvailable = Effect.gen(function* () {
242 const database = yield* Effect.acquireRelease(
243 Effect.tryPromise({
244 try: async () => {
245 const SQLite = await import("expo-sqlite");
246 return SQLite.openDatabaseAsync(DATABASE_NAME);
247 },
248 catch: databaseError("open"),
249 }),
250 (openDatabase) => Effect.promise(() => openDatabase.closeAsync()).pipe(Effect.ignore),
251 );
252
253 yield* Effect.tryPromise({
254 try: async () => {
255 await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
256 const schema = await database.getFirstAsync<{ readonly user_version: number }>(
257 "PRAGMA user_version",
258 );
259 await database.withExclusiveTransactionAsync(async (transaction) => {
260 await transaction.execAsync(`
261 CREATE TABLE IF NOT EXISTS client_cache (
262 environment_id TEXT NOT NULL,
263 kind TEXT NOT NULL,
264 cache_key TEXT NOT NULL,
265 schema_version INTEGER NOT NULL,
266 payload TEXT NOT NULL,
267 updated_at INTEGER NOT NULL,
268 PRIMARY KEY (environment_id, kind, cache_key)
269 ) WITHOUT ROWID;
270
271 CREATE INDEX IF NOT EXISTS client_cache_environment_updated
272 ON client_cache (environment_id, updated_at DESC);
273
274 CREATE TABLE IF NOT EXISTS client_preferences (
275 singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1),
276 payload TEXT NOT NULL,
277 updated_at INTEGER NOT NULL
278 );
279 `);
280 });
281 if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) {
282 const migrated = await migrateLegacyFileCaches(database);
283 if (migrated) {
284 await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`);
285 }
286 }
287 },
288 catch: databaseError("migrate"),
289 });
290
291 return MobileDatabase.of({
292 loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) =>
293 Effect.tryPromise({
294 try: () =>
295 database.getFirstAsync<{ readonly payload: string }>(
296 `SELECT payload
297 FROM client_cache
298 WHERE environment_id = ? AND kind = ? AND cache_key = ?
299 AND (kind != 'pull-request' OR updated_at >= ?)`,
300 environmentId,
301 kind,
302 cacheKey,
303 Date.now() - 30 * 24 * 60 * 60_000,
304 ),
305 catch: databaseError("load-cache"),
306 }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),
307 ),
308 listCache: Effect.fn("MobileDatabase.listCache")((kind) =>
309 Effect.tryPromise({
310 try: () =>
311 database.getAllAsync<{ readonly payload: string }>(
312 "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at",
313 kind,
314 ),
315 catch: databaseError("list-cache"),
316 }).pipe(Effect.map((rows) => rows.map((row) => row.payload))),
317 ),
318 saveCache: Effect.fn("MobileDatabase.saveCache")(
319 (environmentId, kind, cacheKey, schemaVersion, payload) =>
320 Effect.tryPromise({
321 try: async () => {
322 // Diffs can be large. Keep the offline PR cache within 32 MiB and 256
323 // responses per environment, and don't let one huge diff evict it all.
324 if (
325 kind === "pull-request" &&
326 new TextEncoder().encode(payload).length > 4 * 1024 * 1024
327 )
328 return;
329 const save = async (transaction: SQLiteDatabase) => {
330 await transaction.runAsync(
331 `INSERT INTO client_cache
332 (environment_id, kind, cache_key, schema_version, payload, updated_at)
333 VALUES (?, ?, ?, ?, ?, ?)
334 ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET
335 schema_version = excluded.schema_version,
336 payload = excluded.payload,
337 updated_at = excluded.updated_at`,
338 environmentId,
339 kind,
340 cacheKey,
341 schemaVersion,
342 payload,
343 Date.now(),
344 );
345 if (kind === "pull-request") {
346 await transaction.runAsync(
347 `DELETE FROM client_cache WHERE environment_id = ? AND kind = 'pull-request'
348 AND cache_key IN (
349 SELECT cache_key FROM (
350 SELECT cache_key, updated_at,
351 ROW_NUMBER() OVER (ORDER BY updated_at DESC, cache_key) AS position,
352 SUM(LENGTH(CAST(payload AS BLOB))) OVER (
353 ORDER BY updated_at DESC, cache_key
354 ) AS total_bytes
355 FROM client_cache WHERE environment_id = ? AND kind = 'pull-request'
356 ) WHERE position > 256 OR total_bytes > 33554432 OR updated_at < ?
357 )`,
358 environmentId,
359 environmentId,
360 Date.now() - 30 * 24 * 60 * 60_000,
361 );
362 }
363 };
364 await save(database);
365 },
366 catch: databaseError("save-cache"),
367 }).pipe(Effect.asVoid),
368 ),
369 removeCache: Effect.fn("MobileDatabase.removeCache")((environmentId, kind, cacheKey) =>
370 Effect.tryPromise({
371 try: () =>
372 database.runAsync(
373 `DELETE FROM client_cache
374 WHERE environment_id = ? AND kind = ? AND cache_key = ?`,
375 environmentId,
376 kind,
377 cacheKey,
378 ),
379 catch: databaseError("remove-cache"),
380 }).pipe(Effect.asVoid),
381 ),
382 clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")((environmentId, kind) =>
383 Effect.tryPromise({
384 try: () =>
385 database.runAsync(
386 "DELETE FROM client_cache WHERE environment_id = ? AND kind = ?",
387 environmentId,
388 kind,
389 ),
390 catch: databaseError("clear-cache-kind"),
391 }).pipe(Effect.asVoid),
392 ),
393 clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) =>
394 Effect.tryPromise({
395 try: () =>
396 database.runAsync("DELETE FROM client_cache WHERE environment_id = ?", environmentId),
397 catch: databaseError("clear-environment-cache"),
398 }).pipe(Effect.asVoid),
399 ),
400 clearAllCaches: Effect.tryPromise({
401 try: () => database.runAsync("DELETE FROM client_cache"),
402 catch: databaseError("clear-all-caches"),
403 }).pipe(Effect.asVoid),
404 inspectCaches: Effect.tryPromise({
405 try: () =>
406 database.getAllAsync<unknown>(`
407 SELECT
408 environment_id AS environmentId,
409 kind,
410 COUNT(*) AS recordCount,
411 COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS payloadBytes
412 FROM client_cache
413 GROUP BY environment_id, kind
414 ORDER BY environment_id, kind
415 `),
416 catch: databaseError("inspect-caches"),
417 }).pipe(
418 Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)),
419 Effect.mapError(databaseError("inspect-caches")),
420 Effect.map((rows): ReadonlyArray<ClientCacheSummaryRow> =>
421 rows.map((row) => ({
422 environmentId: row.environmentId as EnvironmentId,
423 kind: row.kind,
424 recordCount: row.recordCount,
425 payloadBytes: row.payloadBytes,
426 })),
427 ),
428 ),
429 loadPreferencesJson: Effect.tryPromise({
430 try: () =>
431 database.getFirstAsync<StoredPreferencesJson>(
432 `SELECT payload, updated_at AS updatedAt
433 FROM client_preferences
434 WHERE singleton = 1`,
435 ),
436 catch: databaseError("load-preferences"),
437 }).pipe(Effect.map(Option.fromNullishOr)),
438 savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) =>
439 Effect.tryPromise({
440 try: () =>
441 database.runAsync(
442 `INSERT INTO client_preferences (singleton, payload, updated_at)
443 VALUES (1, ?, ?)
444 ON CONFLICT (singleton) DO UPDATE SET
445 payload = excluded.payload,
446 updated_at = excluded.updated_at`,
447 payload,
448 updatedAt,
449 ),
450 catch: databaseError("save-preferences"),
451 }).pipe(Effect.asVoid),
452 ),
453 });
454});
455
456function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] {
457 const fail = Effect.fail(error);
458 return MobileDatabase.of({
459 loadCache: () => fail,
460 listCache: () => fail,
461 saveCache: () => fail,
462 removeCache: () => fail,
463 clearCacheKind: () => fail,
464 clearEnvironmentCache: () => fail,
465 clearAllCaches: fail,
466 inspectCaches: fail,
467 loadPreferencesJson: fail,
468 savePreferencesJson: () => fail,
469 });
470}
471
472export const make = Effect.result(makeAvailable).pipe(
473 Effect.map((result) =>
474 result._tag === "Success" ? result.success : makeUnavailable(result.failure),
475 ),
476);
477
478export const layer = Layer.effect(MobileDatabase, make);