import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { SQLiteDatabase } from "expo-sqlite"; const DATABASE_NAME = "t3code-client.db"; const DATABASE_SCHEMA_VERSION = 1; const LEGACY_CACHE_DIRECTORIES = [ "connection-shell-snapshots", "shell-snapshots", "connection-thread-snapshots", "connection-server-configs", "connection-vcs-refs", ] as const; export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { readonly environmentId: EnvironmentId; readonly kind: ClientCacheKind; readonly recordCount: number; readonly payloadBytes: number; } export interface StoredPreferencesJson { readonly payload: string; readonly updatedAt: number; } const ClientCacheSummaryRows = Schema.Array( Schema.Struct({ environmentId: Schema.String, kind: ClientCacheKind, recordCount: Schema.Number, payloadBytes: Schema.Number, }), ); const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", "save-cache", "remove-cache", "clear-environment-cache", "clear-all-caches", "inspect-caches", "load-preferences", "save-preferences", ]); export class MobileDatabaseError extends Schema.TaggedErrorClass()( "MobileDatabaseError", { operation: MobileDatabaseOperation, cause: Schema.Defect(), }, ) { override get message(): string { return `Mobile database operation failed: ${this.operation}.`; } } function databaseError(operation: typeof MobileDatabaseOperation.Type) { return (cause: unknown) => new MobileDatabaseError({ operation, cause }); } interface LegacyCacheRecord { readonly environmentId: string; readonly kind: ClientCacheKind; readonly cacheKey: string; readonly schemaVersion: number; readonly payload: string; } function objectRecord(value: unknown): Record | null { return typeof value === "object" && value !== null ? (value as Record) : null; } export function decodeLegacyCacheRecord( directoryName: (typeof LEGACY_CACHE_DIRECTORIES)[number], payload: string, ): LegacyCacheRecord | null { let parsed: Record | null; try { parsed = objectRecord(JSON.parse(payload)); } catch { return null; } if ( parsed === null || typeof parsed.environmentId !== "string" || typeof parsed.schemaVersion !== "number" ) { return null; } switch (directoryName) { case "connection-shell-snapshots": case "shell-snapshots": return { environmentId: parsed.environmentId, kind: "shell", cacheKey: "snapshot", schemaVersion: parsed.schemaVersion, payload, }; case "connection-thread-snapshots": return typeof parsed.threadId === "string" ? { environmentId: parsed.environmentId, kind: "thread", cacheKey: parsed.threadId, schemaVersion: parsed.schemaVersion, payload, } : null; case "connection-server-configs": return { environmentId: parsed.environmentId, kind: "server-config", cacheKey: "config", schemaVersion: parsed.schemaVersion, payload, }; case "connection-vcs-refs": return typeof parsed.cwd === "string" ? { environmentId: parsed.environmentId, kind: "vcs-refs", cacheKey: parsed.cwd, schemaVersion: parsed.schemaVersion, payload, } : null; } } async function migrateLegacyFileCaches(database: SQLiteDatabase): Promise { try { const { Directory, File, Paths } = await import("expo-file-system"); let complete = true; const listFiles = ( directory: InstanceType, ): Array> => directory.list().flatMap((entry) => (entry instanceof File ? [entry] : listFiles(entry))); for (const directoryName of LEGACY_CACHE_DIRECTORIES) { try { const directory = new Directory(Paths.document, directoryName); if (!directory.exists) continue; for (const file of listFiles(directory)) { const payload = await file.text(); const record = decodeLegacyCacheRecord(directoryName, payload); if (record === null) continue; await database.runAsync( `INSERT INTO client_cache (environment_id, kind, cache_key, schema_version, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (environment_id, kind, cache_key) DO NOTHING`, record.environmentId, record.kind, record.cacheKey, record.schemaVersion, record.payload, Date.now(), ); } directory.delete(); } catch (cause) { complete = false; console.warn(`[mobile-database] could not migrate legacy cache ${directoryName}`, cause); } } return complete; } catch (cause) { console.warn("[mobile-database] could not load legacy cache migration", cause); return false; } } export class MobileDatabase extends Context.Service< MobileDatabase, { readonly loadCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string, schemaVersion: number, payload: string, ) => Effect.Effect; readonly removeCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect; readonly clearEnvironmentCache: ( environmentId: EnvironmentId, ) => Effect.Effect; readonly clearAllCaches: Effect.Effect; readonly inspectCaches: Effect.Effect< ReadonlyArray, MobileDatabaseError >; readonly loadPreferencesJson: Effect.Effect< Option.Option, MobileDatabaseError >; readonly savePreferencesJson: ( payload: string, updatedAt: number, ) => Effect.Effect; } >()("@t3tools/mobile/persistence/MobileDatabase") {} const makeAvailable = Effect.gen(function* () { const database = yield* Effect.acquireRelease( Effect.tryPromise({ try: async () => { const SQLite = await import("expo-sqlite"); return SQLite.openDatabaseAsync(DATABASE_NAME); }, catch: databaseError("open"), }), (openDatabase) => Effect.promise(() => openDatabase.closeAsync()).pipe(Effect.ignore), ); yield* Effect.tryPromise({ try: async () => { await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); const schema = await database.getFirstAsync<{ readonly user_version: number }>( "PRAGMA user_version", ); await database.withExclusiveTransactionAsync(async (transaction) => { await transaction.execAsync(` CREATE TABLE IF NOT EXISTS client_cache ( environment_id TEXT NOT NULL, kind TEXT NOT NULL, cache_key TEXT NOT NULL, schema_version INTEGER NOT NULL, payload TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (environment_id, kind, cache_key) ) WITHOUT ROWID; CREATE INDEX IF NOT EXISTS client_cache_environment_updated ON client_cache (environment_id, updated_at DESC); CREATE TABLE IF NOT EXISTS client_preferences ( singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), payload TEXT NOT NULL, updated_at INTEGER NOT NULL ); `); }); if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) { const migrated = await migrateLegacyFileCaches(database); if (migrated) { await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`); } } }, catch: databaseError("migrate"), }); return MobileDatabase.of({ loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) => Effect.tryPromise({ try: () => database.getFirstAsync<{ readonly payload: string }>( `SELECT payload FROM client_cache WHERE environment_id = ? AND kind = ? AND cache_key = ?`, environmentId, kind, cacheKey, ), catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ try: () => database.runAsync( `INSERT INTO client_cache (environment_id, kind, cache_key, schema_version, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET schema_version = excluded.schema_version, payload = excluded.payload, updated_at = excluded.updated_at`, environmentId, kind, cacheKey, schemaVersion, payload, Date.now(), ), catch: databaseError("save-cache"), }).pipe(Effect.asVoid), ), removeCache: Effect.fn("MobileDatabase.removeCache")((environmentId, kind, cacheKey) => Effect.tryPromise({ try: () => database.runAsync( `DELETE FROM client_cache WHERE environment_id = ? AND kind = ? AND cache_key = ?`, environmentId, kind, cacheKey, ), catch: databaseError("remove-cache"), }).pipe(Effect.asVoid), ), clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) => Effect.tryPromise({ try: () => database.runAsync("DELETE FROM client_cache WHERE environment_id = ?", environmentId), catch: databaseError("clear-environment-cache"), }).pipe(Effect.asVoid), ), clearAllCaches: Effect.tryPromise({ try: () => database.runAsync("DELETE FROM client_cache"), catch: databaseError("clear-all-caches"), }).pipe(Effect.asVoid), inspectCaches: Effect.tryPromise({ try: () => database.getAllAsync(` SELECT environment_id AS environmentId, kind, COUNT(*) AS recordCount, COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS payloadBytes FROM client_cache GROUP BY environment_id, kind ORDER BY environment_id, kind `), catch: databaseError("inspect-caches"), }).pipe( Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)), Effect.mapError(databaseError("inspect-caches")), Effect.map( (rows): ReadonlyArray => rows.map((row) => ({ environmentId: row.environmentId as EnvironmentId, kind: row.kind, recordCount: row.recordCount, payloadBytes: row.payloadBytes, })), ), ), loadPreferencesJson: Effect.tryPromise({ try: () => database.getFirstAsync( `SELECT payload, updated_at AS updatedAt FROM client_preferences WHERE singleton = 1`, ), catch: databaseError("load-preferences"), }).pipe(Effect.map(Option.fromNullishOr)), savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) => Effect.tryPromise({ try: () => database.runAsync( `INSERT INTO client_preferences (singleton, payload, updated_at) VALUES (1, ?, ?) ON CONFLICT (singleton) DO UPDATE SET payload = excluded.payload, updated_at = excluded.updated_at`, payload, updatedAt, ), catch: databaseError("save-preferences"), }).pipe(Effect.asVoid), ), }); }); function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] { const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearEnvironmentCache: () => fail, clearAllCaches: fail, inspectCaches: fail, loadPreferencesJson: fail, savePreferencesJson: () => fail, }); } export const make = Effect.result(makeAvailable).pipe( Effect.map((result) => result._tag === "Success" ? result.success : makeUnavailable(result.failure), ), ); export const layer = Layer.effect(MobileDatabase, make);