| 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; |
| 6 | import * as Schema from "effect/Schema"; |
| 7 | import type { SQLiteDatabase } from "expo-sqlite"; |
| 8 | |
| 9 | const DATABASE_NAME = "t3code-client.db"; |
| 10 | const DATABASE_SCHEMA_VERSION = 1; |
| 11 | const 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 | |
| 19 | export const ClientCacheKind = Schema.Literals([ |
| 20 | "shell", |
| 21 | "thread", |
| 22 | "server-config", |
| 23 | "vcs-refs", |
| 24 | "project-favicon", |
| 25 | "pull-request", |
| 26 | ]); |
| 27 | export type ClientCacheKind = typeof ClientCacheKind.Type; |
| 28 | |
| 29 | export interface ClientCacheSummaryRow { |
| 30 | readonly environmentId: EnvironmentId; |
| 31 | readonly kind: ClientCacheKind; |
| 32 | readonly recordCount: number; |
| 33 | readonly payloadBytes: number; |
| 34 | } |
| 35 | |
| 36 | export interface StoredPreferencesJson { |
| 37 | readonly payload: string; |
| 38 | readonly updatedAt: number; |
| 39 | } |
| 40 | |
| 41 | const ClientCacheSummaryRows = Schema.Array( |
| 42 | Schema.Struct({ |
| 43 | environmentId: Schema.String, |
| 44 | kind: ClientCacheKind, |
| 45 | recordCount: Schema.Number, |
| 46 | payloadBytes: Schema.Number, |
| 47 | }), |
| 48 | ); |
| 49 | |
| 50 | const 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 | |
| 65 | export 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 | |
| 77 | function databaseError(operation: typeof MobileDatabaseOperation.Type) { |
| 78 | return (cause: unknown) => new MobileDatabaseError({ operation, cause }); |
| 79 | } |
| 80 | |
| 81 | interface LegacyCacheRecord { |
| 82 | readonly environmentId: string; |
| 83 | readonly kind: ClientCacheKind; |
| 84 | readonly cacheKey: string; |
| 85 | readonly schemaVersion: number; |
| 86 | readonly payload: string; |
| 87 | } |
| 88 | |
| 89 | function objectRecord(value: unknown): Record<string, unknown> | null { |
| 90 | return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null; |
| 91 | } |
| 92 | |
| 93 | export 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 | |
| 152 | async 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 | |
| 195 | export 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 | |
| 241 | const 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 | |
| 456 | function 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 | |
| 472 | export const make = Effect.result(makeAvailable).pipe( |
| 473 | Effect.map((result) => |
| 474 | result._tag === "Success" ? result.success : makeUnavailable(result.failure), |
| 475 | ), |
| 476 | ); |
| 477 | |
| 478 | export const layer = Layer.effect(MobileDatabase, make); |