409 lines · 13.3 KB · TypeScript
RawDownload
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>()(  "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<string, unknown> | null {  return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;} export function decodeLegacyCacheRecord(  directoryName: (typeof LEGACY_CACHE_DIRECTORIES)[number],  payload: string,): LegacyCacheRecord | null {  let parsed: Record<string, unknown> | 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<boolean> {  try {    const { Directory, File, Paths } = await import("expo-file-system");    let complete = true;    const listFiles = (      directory: InstanceType<typeof Directory>,    ): Array<InstanceType<typeof File>> =>      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<Option.Option<string>, MobileDatabaseError>;    readonly saveCache: (      environmentId: EnvironmentId,      kind: ClientCacheKind,      cacheKey: string,      schemaVersion: number,      payload: string,    ) => Effect.Effect<void, MobileDatabaseError>;    readonly removeCache: (      environmentId: EnvironmentId,      kind: ClientCacheKind,      cacheKey: string,    ) => Effect.Effect<void, MobileDatabaseError>;    readonly clearEnvironmentCache: (      environmentId: EnvironmentId,    ) => Effect.Effect<void, MobileDatabaseError>;    readonly clearAllCaches: Effect.Effect<void, MobileDatabaseError>;    readonly inspectCaches: Effect.Effect<      ReadonlyArray<ClientCacheSummaryRow>,      MobileDatabaseError    >;    readonly loadPreferencesJson: Effect.Effect<      Option.Option<StoredPreferencesJson>,      MobileDatabaseError    >;    readonly savePreferencesJson: (      payload: string,      updatedAt: number,    ) => Effect.Effect<void, MobileDatabaseError>;  }>()("@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<unknown>(`                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<ClientCacheSummaryRow> =>          rows.map((row) => ({            environmentId: row.environmentId as EnvironmentId,            kind: row.kind,            recordCount: row.recordCount,            payloadBytes: row.payloadBytes,          })),      ),    ),    loadPreferencesJson: Effect.tryPromise({      try: () =>        database.getFirstAsync<StoredPreferencesJson>(          `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);