diff --git a/.macroscope/check-run-agents/effect-service-conventions.md b/.macroscope/check-run-agents/effect-service-conventions.md index afbdc55ba60..09f4db07965 100644 --- a/.macroscope/check-run-agents/effect-service-conventions.md +++ b/.macroscope/check-run-agents/effect-service-conventions.md @@ -43,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b - Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. - `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. +## Dependency acquisition and runtime boundaries + +- Production service construction must acquire Effect service dependencies from the environment with `yield* Foo.Foo`, and its `make`/`layer` types must expose those requirements. Flag factories or constructors that accept `Foo["Service"]` (or a plain object whose methods return `Effect`) when that value is an implementation dependency owned by the service. Passing service instances explicitly is acceptable in tests and integration harnesses; passing pure configuration, immutable domain values, or deliberate callback strategies is not service injection. +- Do not hide dependencies in module globals, closures over singleton services, or `Layer.succeed` implementations that call runtime-backed or imperative APIs. Trace helpers used by a supposedly synchronous layer far enough to verify that asynchronous services are represented in the Effect environment. +- `ManagedRuntime.make`, `runPromise`, and `runPromiseExit` belong at explicit application/framework boundaries such as React, native callback, CLI, or HTTP adapters. Flag their use in domain services, repositories, persistence implementations, and service constructors. A clearly named imperative adapter may bridge an Effect service into a Promise API, but it must not become a dependency of another Effect service. +- Do not create per-feature managed runtimes or Atom runtimes to smuggle the same owned resource into multiple consumers. Compose the resource once in an application-owned layer/runtime and provide its context to integration runtimes. +- When acquisition can fail but a caller must retain fallback behavior, keep the failure typed in Effect rather than bypassing the layer through an imperative runtime. Model unavailability in service operations or with an explicit optional-service layer so downstream recovery remains visible and testable. +- During review, search touched code and affected call sites for service-instance parameters, `Layer.succeed`, `ManagedRuntime.make`, and `.runPromise`/`.runPromiseExit`. Verify that each occurrence is a legitimate test seam, pure value injection, or application boundary—not fake dependency injection or a hidden runtime. + ## Errors and predicates - Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 098b830aca3..3597e0161cd 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -123,6 +123,7 @@ const config: ExpoConfig = { plugins: [ "expo-font", "expo-secure-store", + "expo-sqlite", ["@clerk/expo", { theme: "./clerk-theme.json" }], "expo-web-browser", [ diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 82c707eac12..a1d315b7e14 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -8,7 +8,8 @@ "development": { "corepack": true, "env": { - "APP_VARIANT": "development" + "APP_VARIANT": "development", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "development", "developmentClient": true, @@ -17,7 +18,8 @@ "preview": { "corepack": true, "env": { - "APP_VARIANT": "preview" + "APP_VARIANT": "preview", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -27,7 +29,8 @@ "corepack": true, "env": { "APP_VARIANT": "preview", - "MOBILE_VERSION_POLICY": "fingerprint" + "MOBILE_VERSION_POLICY": "fingerprint", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -40,7 +43,8 @@ "production": { "corepack": true, "env": { - "APP_VARIANT": "production" + "APP_VARIANT": "production", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "production", "environment": "production", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7455f65b348..ae09235519e 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "expo start --clear", "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", + "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear'", "start": "expo start", "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", @@ -86,6 +87,7 @@ "expo-paste-input": "^0.1.15", "expo-secure-store": "~56.0.4", "expo-splash-screen": "~56.0.10", + "expo-sqlite": "~56.0.5", "expo-symbols": "~56.0.6", "expo-updates": "~56.0.19", "expo-web-browser": "~56.0.5", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d68d6f2b97b..9c8eb0f18d4 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; +import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; @@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Appearance", }, }), + SettingsClientStorage: createNativeStackScreen({ + screen: SettingsClientStorageRouteScreen, + linking: "client-storage", + options: { + title: "Client Storage", + }, + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index b5bda400670..6a4fcd35f6d 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; import { migrateLegacyConnectionCatalog } from "./migration"; export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) { }); } +const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); +const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); +const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); + const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => catalogError("decode", cause), - }); - return yield* Effect.fromResult( - Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), - ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); + return yield* decodeConnectionCatalogDocument(raw).pipe( + Effect.mapError((cause) => catalogError("decode", cause)), + ); }); const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( catalog: ConnectionCatalogDocumentType, ) { - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), - ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); - return JSON.stringify(encoded); + return yield* encodeConnectionCatalogDocument(catalog).pipe( + Effect.mapError((cause) => catalogError("encode", cause)), + ); }); interface CatalogStore { @@ -48,20 +48,19 @@ interface CatalogStore { ) => Effect.Effect; } -export interface SecureCatalogStorage { - readonly getItem: (key: string) => Effect.Effect; - readonly setItem: (key: string, value: string) => Effect.Effect; - readonly deleteItem: (key: string) => Effect.Effect; -} - -export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( - storage: SecureCatalogStorage, -) { +export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { + const storage = yield* MobileSecureStorage.MobileSecureStorage; + const getItem = (key: string) => + storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); + const setItem = (key: string, value: string) => + storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); + const deleteItem = (key: string) => + storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); const state = yield* Ref.make>(Option.none()); const lock = yield* Semaphore.make(1); const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { - const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); + const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY); const catalog = legacyRaw === null || legacyRaw.trim() === "" ? EMPTY_CONNECTION_CATALOG_DOCUMENT @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS ); if (legacyRaw !== null && legacyRaw.trim() !== "") { const encoded = yield* encodeCatalog(catalog); - yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); - yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); + yield* setItem(CONNECTION_CATALOG_KEY, encoded); + yield* deleteItem(LEGACY_CONNECTIONS_KEY); } return catalog; }); @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS if (Option.isSome(cached)) { return cached.value; } - const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); + const raw = yield* getItem(CONNECTION_CATALOG_KEY); let catalog: ConnectionCatalogDocumentType; if (raw !== null && raw.trim() !== "") { catalog = yield* decodeCatalog(raw).pipe( Effect.catch((error) => Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( - Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), + Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)), Effect.andThen(loadLegacyCatalog()), ), ), @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS Effect.gen(function* () { const next = transform(yield* loadUnlocked()); const encoded = yield* encodeCatalog(next); - yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); + yield* setItem(CONNECTION_CATALOG_KEY, encoded); yield* Ref.set(state, Option.some(next)); }), ); diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts new file mode 100644 index 00000000000..0caf2b41592 --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -0,0 +1,97 @@ +import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; + +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { make } from "./environment-cache-store"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); +const REFS: VcsListRefsResult = { + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, +}; + +function cacheId(environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string) { + return `${environmentId}:${kind}:${cacheKey}`; +} + +function makeDatabase() { + const values = new Map(); + const removed: Array = []; + const database = MobileDatabase.of({ + loadCache: (environmentId, kind, cacheKey) => + Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => + Effect.sync(() => { + values.set(cacheId(environmentId, kind, cacheKey), payload); + }), + removeCache: (environmentId, kind, cacheKey) => + Effect.sync(() => { + const id = cacheId(environmentId, kind, cacheKey); + removed.push(id); + values.delete(id); + }), + clearEnvironmentCache: (environmentId) => + Effect.sync(() => { + for (const key of values.keys()) { + if (key.startsWith(`${environmentId}:`)) values.delete(key); + } + }), + clearAllCaches: Effect.sync(() => values.clear()), + inspectCaches: Effect.succeed([]), + loadPreferencesJson: Effect.succeed(Option.none()), + savePreferencesJson: () => Effect.void, + }); + return { database, removed, values }; +} + +describe("mobile SQLite environment cache store", () => { + it.effect("round-trips schema-validated VCS refs", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS)); + }), + ); + + it.effect("deletes a corrupt cache record and treats it as a miss", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo"); + memory.values.set(id, "{not-json"); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(memory.removed).toEqual([id]); + }), + ); + + it.effect("clears one environment without touching another", () => + Effect.gen(function* () { + const memory = makeDatabase(); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); + const otherEnvironmentId = EnvironmentId.make("environment-2"); + yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); + yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS); + + yield* store.clear(ENVIRONMENT_ID); + + expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none()); + expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS)); + }), + ); +}); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts new file mode 100644 index 00000000000..04729ad21df --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -0,0 +1,234 @@ +import { + ConnectionPersistenceError, + EnvironmentCacheStore, +} from "@t3tools/client-runtime/platform"; +import { + type EnvironmentId, + OrchestrationShellSnapshot, + OrchestrationThreadDetailSnapshot, + ServerConfig, + VcsListRefsResult, +} from "@t3tools/contracts"; +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 * as MobileDatabase from "../persistence/mobile-database"; + +const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; +const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; +const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; +const VCS_REFS_CACHE_SCHEMA_VERSION = 1; + +const StoredShellSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + snapshot: OrchestrationShellSnapshot, +}); +const StoredThreadSnapshot = Schema.Struct({ + schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + threadId: Schema.String, + snapshot: OrchestrationThreadDetailSnapshot, +}); +const StoredServerConfig = Schema.Struct({ + schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + config: ServerConfig, +}); +const StoredVcsRefs = Schema.Struct({ + schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), + environmentId: Schema.String, + cwd: Schema.String, + refs: VcsListRefsResult, +}); + +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredShellSnapshot), +); +const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredThreadSnapshot), +); +const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot)); +const decodeStoredServerConfig = Schema.decodeUnknownEffect( + Schema.fromJsonString(StoredServerConfig), +); +const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig)); +const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs)); +const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs)); + +type CacheOperation = ConnectionPersistenceError["operation"]; + +function persistenceError(operation: CacheOperation, cause: unknown) { + return new ConnectionPersistenceError({ + operation, + message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, + }); +} + +function mapDatabaseError(operation: CacheOperation) { + return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); +} + +function loadDecodedCache(input: { + readonly database: MobileDatabase.MobileDatabase["Service"]; + readonly environmentId: EnvironmentId; + readonly kind: MobileDatabase.ClientCacheKind; + readonly cacheKey: string; + readonly operation: CacheOperation; + readonly decode: (raw: string) => Effect.Effect; + readonly select: (value: A) => Option.Option; +}): Effect.Effect, ConnectionPersistenceError> { + return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe( + Effect.mapError(mapDatabaseError(input.operation)), + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (raw) => + input.decode(raw).pipe( + Effect.map(input.select), + Effect.catch((cause) => + Effect.logWarning("Discarding corrupt mobile client cache record.", { + environmentId: input.environmentId, + kind: input.kind, + cacheKey: input.cacheKey, + cause: String(cause), + }).pipe( + Effect.andThen( + input.database + .removeCache(input.environmentId, input.kind, input.cacheKey) + .pipe(Effect.catch(() => Effect.void)), + ), + Effect.as(Option.none()), + ), + ), + ), + }), + ), + ); +} + +export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; + return EnvironmentCacheStore.of({ + loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => + loadDecodedCache({ + database, + environmentId, + kind: "shell", + cacheKey: "snapshot", + operation: "load-shell", + decode: decodeStoredShellSnapshot, + select: (stored) => + stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), + }), + ), + saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { + const payload = yield* encodeStoredShellSnapshot({ + schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + yield* database + .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-shell"))); + }), + loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) => + loadDecodedCache({ + database, + environmentId, + kind: "thread", + cacheKey: threadId, + operation: "load-thread", + decode: decodeStoredThreadSnapshot, + select: (stored) => + stored.environmentId === environmentId && stored.threadId === threadId + ? Option.some(stored.snapshot) + : Option.none(), + }), + ), + saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) { + const threadId = snapshot.thread.id; + const payload = yield* encodeStoredThreadSnapshot({ + schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, + environmentId, + threadId, + snapshot, + }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + yield* database + .saveCache(environmentId, "thread", threadId, THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-thread"))); + }), + removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) => + database + .removeCache(environmentId, "thread", threadId) + .pipe(Effect.mapError(mapDatabaseError("remove-thread"))), + ), + loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) => + loadDecodedCache({ + database, + environmentId, + kind: "server-config", + cacheKey: "config", + operation: "load-server-config", + decode: decodeStoredServerConfig, + select: (stored) => + stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(), + }), + ), + saveServerConfig: Effect.fn("MobileEnvironmentCache.saveServerConfig")( + function* (environmentId, config) { + const payload = yield* encodeStoredServerConfig({ + schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, + environmentId, + config, + }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); + yield* database + .saveCache( + environmentId, + "server-config", + "config", + SERVER_CONFIG_CACHE_SCHEMA_VERSION, + payload, + ) + .pipe(Effect.mapError(mapDatabaseError("save-server-config"))); + }, + ), + loadVcsRefs: Effect.fn("MobileEnvironmentCache.loadVcsRefs")((environmentId, cwd) => + loadDecodedCache({ + database, + environmentId, + kind: "vcs-refs", + cacheKey: cwd, + operation: "load-vcs-refs", + decode: decodeStoredVcsRefs, + select: (stored) => + stored.environmentId === environmentId && stored.cwd === cwd + ? Option.some(stored.refs) + : Option.none(), + }), + ), + saveVcsRefs: Effect.fn("MobileEnvironmentCache.saveVcsRefs")( + function* (environmentId, cwd, refs) { + const payload = yield* encodeStoredVcsRefs({ + schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, + environmentId, + cwd, + refs, + }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause))); + yield* database + .saveCache(environmentId, "vcs-refs", cwd, VCS_REFS_CACHE_SCHEMA_VERSION, payload) + .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs"))); + }, + ), + clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => + database + .clearEnvironmentCache(environmentId) + .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + ), + }); +}); + +export const layer = Layer.effect(EnvironmentCacheStore, make()); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index fd3c1530392..cc51b56ee70 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -25,7 +25,8 @@ import * as Network from "expo-network"; import { AppState } from "react-native"; import { authClientMetadata } from "../lib/authClientMetadata"; -import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import * as Runtime from "../lib/runtime"; +import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; @@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({ ), }); -const capabilitiesLayer = Layer.succeedContext( - Context.make( - CloudSession, - CloudSession.of({ - clerkToken: Effect.gen(function* () { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "Sign in to T3 Connect to connect this environment.", - }); - } - const token = yield* session.readClerkToken().pipe( - Effect.mapError( - (error) => - new ConnectionTransientError({ - reason: "network", - detail: error.message, - }), - ), - ); - if (token === null) { - return yield* new ConnectionBlockedError({ - reason: "authentication", - detail: "The T3 Connect session is unavailable.", - }); - } - return token; - }), - }), - ).pipe( - Context.add( - PrimaryEnvironmentAuth, - PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), - ), - Context.add( - RelayDeviceIdentity, - RelayDeviceIdentity.of({ - deviceId: Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: (cause) => - new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not load the mobile device identity: ${String(cause)}`, - }), - }).pipe(Effect.map(Option.some)), - }), - ), - Context.add( - ClientPresentation, - ClientPresentation.of({ - metadata: authClientMetadata(), - scopes: AuthStandardClientScopes, +const capabilitiesLayer = Layer.effectContext( + Effect.gen(function* () { + const storage = yield* MobileStorage.MobileStorage; + return Context.make( + CloudSession, + CloudSession.of({ + clerkToken: Effect.gen(function* () { + const session = appAtomRegistry.get(managedRelaySessionAtom); + if (session === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "Sign in to T3 Connect to connect this environment.", + }); + } + const token = yield* session.readClerkToken().pipe( + Effect.mapError( + (error) => + new ConnectionTransientError({ + reason: "network", + detail: error.message, + }), + ), + ); + if (token === null) { + return yield* new ConnectionBlockedError({ + reason: "authentication", + detail: "The T3 Connect session is unavailable.", + }); + } + return token; + }), }), - ), - Context.add( - SshEnvironmentGateway, - SshEnvironmentGateway.of({ - provision: () => - Effect.fail( - new ConnectionBlockedError({ - reason: "unsupported", - detail: "SSH environments are only available in the desktop app.", - }), - ), - prepare: () => - Effect.fail( - new ConnectionBlockedError({ - reason: "unsupported", - detail: "SSH environments are only available in the desktop app.", - }), + ).pipe( + Context.add( + PrimaryEnvironmentAuth, + PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), + ), + Context.add( + RelayDeviceIdentity, + RelayDeviceIdentity.of({ + deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError( + (cause) => + new ConnectionTransientError({ + reason: "remote-unavailable", + detail: `Could not load the mobile device identity: ${String(cause)}`, + }), + ), + Effect.map(Option.some), ), - disconnect: () => Effect.void, - }), - ), - ), + }), + ), + Context.add( + ClientPresentation, + ClientPresentation.of({ + metadata: authClientMetadata(), + scopes: AuthStandardClientScopes, + }), + ), + Context.add( + SshEnvironmentGateway, + SshEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "SSH environments are only available in the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), + ); + }), ); const platformConnectionSourceLayer = Layer.succeed( @@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed( }), ); +const providedConnectionStorageLayer = connectionStorageLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); +const providedCapabilitiesLayer = capabilitiesLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), +); + const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ @@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ); type ConnectionPlatformLayerSource = - | typeof connectionStorageLayer + | typeof providedConnectionStorageLayer + | typeof Runtime.runtimeContextLayer | typeof connectivityLayer | typeof wakeupsLayer - | typeof capabilitiesLayer + | typeof providedCapabilitiesLayer | typeof platformConnectionSourceLayer | typeof environmentOwnedDataCleanupLayer; @@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer< Layer.Error, Layer.Services > = Layer.mergeAll( - connectionStorageLayer, + providedConnectionStorageLayer, + Runtime.runtimeContextLayer, connectivityLayer, wakeupsLayer, - capabilitiesLayer, + providedCapabilitiesLayer, platformConnectionSourceLayer, environmentOwnedDataCleanupLayer, ); diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index 031c152e659..34ba6ed850a 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -1,28 +1,35 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; -import { - CONNECTION_CATALOG_KEY, - LEGACY_CONNECTIONS_KEY, - makeCatalogStore, - type SecureCatalogStorage, -} from "./catalog-store"; +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, +})); + +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; +import { MobileSecureStorage } from "../persistence/mobile-secure-storage"; function makeStorage(initial: Readonly>) { const values = new Map(Object.entries(initial)); const deleted: Array = []; - const storage: SecureCatalogStorage = { + const storage = MobileSecureStorage.of({ getItem: (key) => Effect.sync(() => values.get(key) ?? null), setItem: (key, value) => Effect.sync(() => { values.set(key, value); }), - deleteItem: (key) => + removeItem: (key) => Effect.sync(() => { deleted.push(key); values.delete(key); }), - }; + }); return { deleted, storage, values }; } @@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => { const memory = makeStorage({ [CONNECTION_CATALOG_KEY]: "{not-json", }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toEqual([]); expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); @@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => { const memory = makeStorage({ [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toEqual([]); expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); @@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => { ], }), }); - const catalog = yield* makeCatalogStore(memory.storage); + const catalog = yield* make().pipe( + Effect.provideService(MobileSecureStorage, memory.storage), + ); expect((yield* catalog.read).targets).toHaveLength(1); expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 73f4307fb23..e844181673d 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -2,7 +2,6 @@ import { ConnectionPersistenceError, ConnectionRegistrationStore, ConnectionTargetStore, - EnvironmentCacheStore, registerConnectionInCatalog, removeConnectionFromCatalog, removeCatalogValue, @@ -14,111 +13,11 @@ import { CredentialStore, ProfileStore, } from "@t3tools/client-runtime/connection"; -import { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationThreadDetailSnapshot, - ThreadId, -} 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 * as SecureStore from "expo-secure-store"; - -import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; - -const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; -const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; -const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; -// v2 stores the snapshot sequence alongside the thread so a warm cache can -// resume via `afterSequence` instead of re-downloading the full thread body. -// Older v1 entries (no sequence) fail to decode and are treated as a cold cache. -const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; -const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; - -const StoredShellSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - snapshot: OrchestrationShellSnapshot, -}); - -const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - threadId: ThreadId, - snapshot: OrchestrationThreadDetailSnapshot, -}); - -const LegacyStoredShellSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(1), - environmentId: EnvironmentId, - snapshotReceivedAt: Schema.String, - snapshot: OrchestrationShellSnapshot, -}); - -function catalogError(operation: string, cause: unknown) { - return new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, - }); -} - -function shellPersistenceError( - operation: - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} - -function threadSnapshotFileName(threadId: ThreadId): string { - return `${encodeURIComponent(threadId)}.json`; -} - -const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( - function* ( - environmentId: EnvironmentId, - operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment", - ) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory( - Paths.document, - THREAD_SNAPSHOT_CACHE_DIRECTORY, - encodeURIComponent(environmentId), - ); - if (operation !== "clear-environment") { - directory.create({ idempotent: true, intermediates: true }); - } - return directory; - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); - }, -); - -const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* ( - environmentId: EnvironmentId, - threadId: ThreadId, - operation: "load-thread" | "save-thread" | "remove-thread", -) { - const { File } = yield* Effect.promise(() => import("expo-file-system")); - return new File( - yield* threadSnapshotDirectory(environmentId, operation), - threadSnapshotFileName(threadId), - ); -}); +import * as CatalogStore from "./catalog-store"; function targetPersistenceError( operation: "list-targets" | "register-connection" | "remove-connection", @@ -130,59 +29,9 @@ function targetPersistenceError( }); } -const secureCatalogStorage: SecureCatalogStorage = { - getItem: (key) => - Effect.tryPromise({ - try: () => SecureStore.getItemAsync(key), - catch: (cause) => catalogError("load", cause), - }), - setItem: (key, value) => - Effect.tryPromise({ - try: () => SecureStore.setItemAsync(key, value), - catch: (cause) => catalogError("save", cause), - }), - deleteItem: (key) => - Effect.tryPromise({ - try: () => SecureStore.deleteItemAsync(key), - catch: (cause) => catalogError("delete", cause), - }), -}; - -function shellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; -} - -const shellSnapshotFileInDirectory = Effect.fn( - "mobile.connectionStorage.shellSnapshotFileInDirectory", -)(function* ( - environmentId: EnvironmentId, - operation: "load-shell" | "save-shell" | "clear-environment", - directoryName: string, -) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, File, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, directoryName); - directory.create({ idempotent: true, intermediates: true }); - return new File(directory, shellSnapshotFileName(environmentId)); - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); -}); - -const shellSnapshotFile = ( - environmentId: EnvironmentId, - operation: "load-shell" | "save-shell" | "clear-environment", -) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); - -const legacyShellSnapshotFile = ( - environmentId: EnvironmentId, - operation: "load-shell" | "clear-environment", -) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); - export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { - const catalog = yield* makeCatalogStore(secureCatalogStorage); + const catalog = yield* CatalogStore.make(); const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( @@ -279,157 +128,11 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const cacheStore = EnvironmentCacheStore.of({ - loadShell: (environmentId) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "load-shell"); - if (file.exists) { - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const stored = yield* Effect.fromResult( - Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); - return stored.environmentId === environmentId - ? Option.some(stored.snapshot) - : Option.none(); - } - - const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); - if (!legacyFile.exists) { - return Option.none(); - } - const legacyRaw = yield* Effect.tryPromise({ - try: () => legacyFile.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const legacyParsed = yield* Effect.try({ - try: () => JSON.parse(legacyRaw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), - }); - const legacyStored = yield* Effect.fromResult( - Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); - return legacyStored.environmentId === environmentId - ? Option.some(legacyStored.snapshot) - : Option.none(); - }), - saveShell: (environmentId, snapshot) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "save-shell"); - const stored = { - schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - snapshot, - } as const; - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredShellSnapshot)(stored), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encoded)); - }, - catch: (cause) => shellPersistenceError("save-shell", cause), - }); - }), - loadThread: (environmentId, threadId) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); - if (!file.exists) { - return Option.none(); - } - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-thread", cause), - }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-thread", cause), - }); - const stored = yield* Effect.fromResult( - Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); - return stored.environmentId === environmentId && stored.threadId === threadId - ? Option.some(stored.snapshot) - : Option.none(); - }), - saveThread: (environmentId, snapshot) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread"); - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredThreadSnapshot)({ - schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - threadId: snapshot.thread.id, - snapshot, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(JSON.stringify(encoded)); - }, - catch: (cause) => shellPersistenceError("save-thread", cause), - }); - }), - removeThread: (environmentId, threadId) => - Effect.gen(function* () { - const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); - if (file.exists) { - file.delete(); - } - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : shellPersistenceError("remove-thread", cause), - ), - ), - clear: (environmentId) => - Effect.gen(function* () { - const file = yield* shellSnapshotFile(environmentId, "clear-environment"); - if (file.exists) { - yield* Effect.try({ - try: () => file.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); - if (legacyFile.exists) { - yield* Effect.try({ - try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - const threadDirectory = yield* threadSnapshotDirectory( - environmentId, - "clear-environment", - ); - if (threadDirectory.exists) { - yield* Effect.try({ - try: () => threadDirectory.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - }), - }); - return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), Context.add(ProfileStore.ConnectionProfileStore, profileStore), Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), - Context.add(EnvironmentCacheStore, cacheStore), ); }), ); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 5de14ea76fc..9796a8ec242 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,21 +7,30 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; +import { MobileStorage } from "../../persistence/mobile-storage"; +import { + CloudEnvironmentLinkError, + linkEnvironmentToCloudWithPreference, +} from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; -vi.mock("../../lib/storage", () => ({ - savePreferencesPatch: vi.fn(() => Promise.resolve()), +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, })); vi.mock("../cloud/linkEnvironment", () => ({ - linkEnvironmentToCloud: vi.fn(() => Effect.void), + linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), })); vi.mock("./remoteRegistration", () => ({ - refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), + updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), })); const connection: SavedRemoteConnection = { @@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), ), + Layer.succeed( + MobileStorage, + MobileStorage.of({ + loadSavedConnections: Effect.succeed([]), + saveConnection: () => Effect.void, + clearSavedConnection: () => Effect.void, + loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessRegistrationRecord: Effect.succeed(null), + saveAgentAwarenessRegistrationRecord: () => Effect.void, + clearAgentAwarenessRegistrationRecord: Effect.void, + }), + ), ); describe("liveActivityPreferences", () => { @@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: "clerk-token", connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ clerkToken: "clerk-token", connection, + liveActivitiesEnabled: false, }); }).pipe(Effect.provide(testLayer)), ); @@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ clerkToken: "clerk-token", connection, + liveActivitiesEnabled: true, }); }).pipe(Effect.provide(testLayer)), ); @@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: null, connections: [connection], }); - expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); - expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); }).pipe(Effect.provide(testLayer)), ); @@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => { return Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection, managedConnection], }); - expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); - expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: true, + }); + }).pipe(Effect.provide(testLayer)); + }); + + it.effect("restores relay preferences when an environment update fails", () => { + vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => + Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), + ); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + setLiveActivityUpdatesEnabled({ + enabled: false, + previousEnabled: true, + clerkToken: "clerk-token", + connections: [connection], + }), + ); + + expect(exit._tag).toBe("Failure"); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { + liveActivitiesEnabled: false, + }); + expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { + liveActivitiesEnabled: true, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { + clerkToken: "clerk-token", + connection, + liveActivitiesEnabled: false, + }); + expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { clerkToken: "clerk-token", connection, + liveActivitiesEnabled: true, }); }).pipe(Effect.provide(testLayer)); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 932376e8bce..1b30769e7bc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,47 +1,73 @@ import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/unstable/http"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; - -export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass()( - "LiveActivityPreferenceSaveError", - { - enabled: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; - } -} - -export function setLiveActivityUpdatesEnabled(input: { - readonly enabled: boolean; - readonly clerkToken: string | null; - readonly connections: ReadonlyArray; -}): Effect.Effect { - return Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; + +export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( + function* (input: { + readonly enabled: boolean; + readonly previousEnabled: boolean; + readonly clerkToken: string | null; + readonly connections: ReadonlyArray; + }) { + const linkedConnections = input.connections.filter( + (connection) => connection.bearerToken !== null, + ); + + const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: enabled, + }); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; + + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: enabled, + }), + { concurrency: "unbounded" }, + ); }); - yield* refreshAgentAwarenessRegistration(); + const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not restore Live Activity device preference.", cause), + ), + ); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; - const clerkToken = input.clerkToken; - if (!clerkToken) { - return; - } + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + `Could not restore Live Activity preference for environment ${connection.environmentId}.`, + cause, + ), + ), + ), + { concurrency: "unbounded" }, + ); + }); - yield* Effect.forEach( - input.connections.filter((connection) => connection.bearerToken !== null), - (connection) => linkEnvironmentToCloud({ clerkToken, connection }), - { concurrency: "unbounded" }, + yield* updateRelayPreference(input.enabled).pipe( + Effect.onError(() => restoreRelayPreference()), ); - }); -} + }, +); diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index cd2e36a403c..a3843095732 100644 --- a/apps/mobile/src/features/agent-awareness/registrationPayload.ts +++ b/apps/mobile/src/features/agent-awareness/registrationPayload.ts @@ -1,6 +1,6 @@ import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; -import type { Preferences } from "../../lib/storage"; +import type { Preferences } from "../../persistence/mobile-preferences"; // Development builds are Xcode-signed and receive sandbox APNs tokens; // preview and production builds are distribution-signed and use production diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index e296825c7ee..01284450725 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -21,12 +21,13 @@ import { loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, + mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, normalizeAgentAwarenessRelayBaseUrl, @@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({ }, })); -vi.mock("../../lib/storage", () => ({ +vi.mock("../../persistence/imperative", () => ({ loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), @@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); }); + it("overrides persisted preferences for an in-flight registration", () => { + expect( + mergeAgentAwarenessRegistrationPreferences( + { liveActivitiesEnabled: false, baseFontSize: 18 }, + { liveActivitiesEnabled: true }, + ), + ).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 }); + }); + it.effect("registers at most one listener while a Live Activity push token is pending", () => { registerAgentAwarenessConnection(savedConnection()); const addPushTokenListener = vi.fn(); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index e9de93c1dcb..14663647d54 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -20,6 +20,7 @@ import { import type { SavedRemoteConnection } from "../../lib/connection"; import { runtime } from "../../lib/runtime"; +import type { Preferences } from "../../persistence/mobile-preferences"; import { clearAgentAwarenessRegistrationRecord, loadAgentAwarenessDeviceId, @@ -27,7 +28,7 @@ import { loadOrCreateAgentAwarenessDeviceId, loadPreferences, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; @@ -124,6 +125,17 @@ interface DeviceRegistrationInput { readonly observedPushToken?: string; } +interface RegisterDeviceInput extends DeviceRegistrationInput { + readonly preferencesOverride?: Partial; +} + +export function mergeAgentAwarenessRegistrationPreferences( + stored: Preferences, + override: Partial | undefined, +): Preferences { + return { ...stored, ...override }; +} + export function normalizeAgentAwarenessRelayBaseUrl( value: string | null | undefined, ): string | null { @@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri } function registerDevice( - input: DeviceRegistrationInput = {}, + input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, ): Effect.Effect { return Effect.gen(function* () { @@ -665,7 +677,7 @@ function registerDevice( } logRegistrationDebug("device registration loading local state", { expectedGeneration }); - const [deviceId, preferences] = yield* Effect.all([ + const [deviceId, storedPreferences] = yield* Effect.all([ Effect.tryPromise({ try: () => loadOrCreateAgentAwarenessDeviceId(), catch: (cause) => @@ -683,6 +695,10 @@ function registerDevice( }), }), ]); + const preferences = mergeAgentAwarenessRegistrationPreferences( + storedPreferences, + input.preferencesOverride, + ); const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); logRegistrationDebug("device registration local state ready", { expectedGeneration, @@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< ); } +export function updateAgentAwarenessRegistrationPreferences( + preferencesOverride: Partial, +): Effect.Effect { + return registerDevice({ preferencesOverride }).pipe( + Effect.tapError((error) => + Effect.sync(() => { + if (registrationStatus !== "registered") { + setRegistrationStatus("failed"); + } + logRegistrationError("device preference registration refresh failed", error); + }), + ), + ); +} + export function __resetAgentAwarenessRemoteRegistrationForTest(): void { environmentConnections.clear(); pushTokenSubscription?.remove(); diff --git a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts index 8042bd0aa6c..ff74863b614 100644 --- a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts +++ b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts @@ -1,7 +1,7 @@ -import { loadPreferences, updatePreferences } from "../../lib/storage"; +import { loadPreferences, updatePreferences } from "../../persistence/imperative"; // Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports -// the request signal) never pulls lib/storage — expo-secure-store — into its +// the request signal) never pulls the persistence adapter into its // module graph; that breaks CloudAuthProvider.test.ts suite loading. /** Whether the account chose "Don't show this again". */ diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b9ab3aeab05..246eb3f34fa 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -7,10 +7,13 @@ import { RelayMobileClientId } from "@t3tools/contracts/relay"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; +import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; +import { MobileStorage } from "../../persistence/mobile-storage"; import { cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, + linkEnvironmentToCloudWithPreference, connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, @@ -36,11 +39,14 @@ vi.mock("react-native", () => ({ }, })); -vi.mock("../../lib/storage", () => ({ - loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), - loadPreferences: vi.fn(() => Promise.resolve({})), +vi.mock("expo-secure-store", () => ({ + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), })); +const loadPreferences = vi.fn(() => Effect.succeed({})); + const savedConnection = { environmentId: EnvironmentId.make("env-1"), environmentLabel: "Desktop", @@ -69,6 +75,27 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, + Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: loadPreferences(), + savePatch: (patch) => Effect.succeed(patch), + update: () => Effect.succeed({}), + }), + ), + Layer.succeed( + MobileStorage, + MobileStorage.of({ + loadSavedConnections: Effect.succeed([]), + saveConnection: () => Effect.void, + clearSavedConnection: () => Effect.void, + loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessDeviceId: Effect.succeed("device-1"), + loadAgentAwarenessRegistrationRecord: Effect.succeed(null), + saveAgentAwarenessRegistrationRecord: () => Effect.void, + clearAgentAwarenessRegistrationRecord: Effect.void, + }), + ), ManagedRelay.layer({ relayUrl: "https://relay.example.test", clientId: RelayMobileClientId, @@ -80,7 +107,11 @@ const withCloudServices = ( effect: Effect.Effect< A, E, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobilePreferencesStore + | MobileStorage >, ) => effect.pipe(Effect.provide(cloudClientLayer())); @@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => { beforeEach(() => { vi.restoreAllMocks(); createProofMock.mockClear(); + loadPreferences.mockClear(); }); it("normalizes configured relay base URLs before building DPoP-bound requests", () => { @@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => { it.effect("preserves disabled Live Activity preferences when linking an environment", () => Effect.gen(function* () { - const storage = yield* Effect.promise(() => import("../../lib/storage")); - vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ - liveActivitiesEnabled: false, - }); + loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false })); const bodies: Array = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { @@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => { }), ); + it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () => + Effect.gen(function* () { + loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read")); + const bodies: Array> = []; + const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { + if (init?.body) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + bodies.push(JSON.parse(requestBodyText(init.body)) as Record); + } + if (String(url).endsWith("/v1/client/environment-link-challenges")) { + return Promise.resolve(Response.json(validLinkChallengeResponse())); + } + if (String(url).endsWith("/api/connect/link-proof")) { + return Promise.resolve(Response.json(validLinkProof())); + } + if (String(url).endsWith("/v1/client/environment-links")) { + return Promise.resolve(Response.json(validLinkResponse())); + } + return Promise.resolve( + Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + yield* withCloudServices( + linkEnvironmentToCloudWithPreference({ + clerkToken: "clerk-token", + connection: savedConnection, + liveActivitiesEnabled: true, + }), + ); + + expect(bodies.filter((body) => "liveActivitiesEnabled" in body)).toEqual([ + expect.objectContaining({ liveActivitiesEnabled: true }), + expect.objectContaining({ liveActivitiesEnabled: true }), + ]); + }), + ); + it.effect( "does not persist cloud connect bootstrap credentials in saved connection records", () => diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..5423686e048 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; +import * as MobilePreferences from "../../persistence/mobile-preferences"; +import * as MobileStorage from "../../persistence/mobile-storage"; import { resolveCloudPublicConfig } from "./publicConfig"; const RELAY_STATUS_AND_CONNECT_SCOPES = [ @@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { return Effect.void; } -export function linkEnvironmentToCloud(input: { +interface LinkEnvironmentToCloudInput { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect< - void, - CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient -> { +} + +type LinkEnvironmentToCloudRequirements = + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | MobileStorage.MobileStorage; + +export function linkEnvironmentToCloudWithPreference( + input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, +): Effect.Effect { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: { const localBearerToken = input.connection.bearerToken; const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; - const deviceId = yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); - const preferences = yield* Effect.tryPromise({ - try: () => loadPreferences(), - catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), - }); - const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; + const storage = yield* MobileStorage.MobileStorage; + const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); + const liveActivitiesEnabled = input.liveActivitiesEnabled; const challenge = yield* relayClient .createEnvironmentLinkChallenge({ clerkToken: input.clerkToken, @@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: { }); } +export function linkEnvironmentToCloud( + input: LinkEnvironmentToCloudInput, +): Effect.Effect< + void, + CloudEnvironmentLinkError, + LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore +> { + return MobilePreferences.MobilePreferencesStore.pipe( + Effect.flatMap((preferencesStore) => preferencesStore.load), + Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")), + Effect.flatMap((preferences) => + linkEnvironmentToCloudWithPreference({ + ...input, + liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false, + }), + ), + ); +} + export function listCloudEnvironments(input: { readonly clerkToken: string; }): Effect.Effect< @@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: { const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( function* () { - return yield* Effect.tryPromise({ - try: () => loadOrCreateAgentAwarenessDeviceId(), - catch: cloudEnvironmentLinkError("Could not load the mobile device id."), - }); + const storage = yield* MobileStorage.MobileStorage; + return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); }, ); @@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobileStorage.MobileStorage > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, @@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: { }): Effect.Effect< SavedRemoteConnection, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | ManagedRelay.ManagedRelayDpopSigner + | MobileStorage.MobileStorage > { return connectRelayManagedEnvironment({ clerkToken: input.clerkToken, diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d3b069176fd..aa4d94ff267 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,6 +12,8 @@ import type { SidebarProjectGroupingMode, SidebarThreadSortOrder, } from "@t3tools/contracts"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useMemo, useRef, useState } from "react"; import { ActivityIndicator, Platform, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { PendingTaskListRow, @@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) { const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); const insets = useSafeAreaInsets(); const accentColor = useThemeColor("--color-icon-muted"); - const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { - setGroupDisplayStates((previous) => { - const next = new Map(previous); - next.set( - key, - nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), - ); + const effectiveGroupDisplayStates = useMemo(() => { + const next = new Map(groupDisplayStates); + if (!AsyncResult.isSuccess(preferencesResult)) { return next; - }); - }, []); + } + for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { + const existing = next.get(key); + next.set(key, { + ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), + collapsed: true, + }); + } + return next; + }, [groupDisplayStates, preferencesResult]); + const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); + effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; + + const updateGroupDisplay = useCallback( + (key: string, action: HomeGroupDisplayAction) => { + const next = new Map(effectiveGroupDisplayStatesRef.current); + next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); + effectiveGroupDisplayStatesRef.current = next; + setGroupDisplayStates(next); + if (action === "toggle-collapsed") { + const collapsedProjectGroups: string[] = []; + for (const [groupKey, state] of next) { + if (state.collapsed) { + collapsedProjectGroups.push(groupKey); + } + } + savePreferences({ collapsedProjectGroups }); + } + }, + [savePreferences], + ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { @@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) { () => buildHomeListLayout({ groups: projectGroups, - displayStates: groupDisplayStates, + displayStates: effectiveGroupDisplayStates, showAllThreads: hasSearchQuery, }), - [projectGroups, groupDisplayStates, hasSearchQuery], + [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); const projectCwdByKey = useMemo(() => { diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx new file mode 100644 index 00000000000..4b5cd9f8414 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -0,0 +1,225 @@ +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { SymbolView } from "expo-symbols"; +import { useMemo } from "react"; +import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + clearClientCacheAtom, + clientCacheSummaryAtom, + type EnvironmentClientCacheSummary, +} from "../../state/client-cache-state"; +import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; +import { SettingsSection } from "./components/SettingsSection"; + +export function SettingsClientStorageRouteScreen() { + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); + const summaryResult = useAtomValue(clientCacheSummaryAtom); + const clearResult = useAtomValue(clearClientCacheAtom); + const clearCache = useAtomSet(clearClientCacheAtom); + const { savedConnectionsById } = useSavedRemoteConnections(); + const isClearing = clearResult.waiting; + const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null; + const environmentSummaries = useMemo( + () => + [...(summary?.environments ?? [])].sort((left, right) => { + const leftLabel = savedConnectionsById[left.environmentId]?.environmentLabel ?? ""; + const rightLabel = savedConnectionsById[right.environmentId]?.environmentLabel ?? ""; + return leftLabel.localeCompare(rightLabel); + }), + [savedConnectionsById, summary?.environments], + ); + + const confirmClearEnvironment = (environment: EnvironmentClientCacheSummary) => { + const label = + savedConnectionsById[environment.environmentId]?.environmentLabel ?? + environment.environmentId; + Alert.alert( + `Clear cache for ${label}?`, + "This removes offline threads, server metadata, and cached branches for this environment. The saved connection and credentials stay intact.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear Cache", + style: "destructive", + onPress: () => + clearCache({ type: "environment", environmentId: environment.environmentId }), + }, + ], + ); + }; + + const confirmClearAll = () => { + Alert.alert( + "Clear all client caches?", + "This removes offline data for every environment. Connections, credentials, account data, and app preferences stay intact.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear All Caches", + style: "destructive", + onPress: () => clearCache({ type: "all" }), + }, + ], + ); + }; + + return ( + + + + {AsyncResult.isFailure(summaryResult) ? ( + + + Storage unavailable + + Restart the app and try again. + + + ) : !summary ? ( + + + + Inspecting cached data… + + + ) : environmentSummaries.length > 0 ? ( + environmentSummaries.map((environment, index) => ( + confirmClearEnvironment(environment)} + /> + )) + ) : ( + + + No cached data + + Offline cache records will appear here after environments are used. + + + )} + + + + + + + + {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} + + {isClearing ? : null} + + + + Clearing caches never removes environment connections, credentials, account data, or + appearance preferences. + + {AsyncResult.isFailure(summaryResult) || AsyncResult.isFailure(clearResult) ? ( + + Client storage is temporarily unavailable. Try again after restarting the app. + + ) : null} + + + + ); +} + +function CacheEnvironmentRow(props: { + readonly environment: EnvironmentClientCacheSummary; + readonly environmentLabel: string; + readonly disabled: boolean; + readonly first: boolean; + readonly onClear: () => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); + return ( + + + + {props.environmentLabel} + + + + Clear {formatBytes(props.environment.payloadBytes)} + + + + ); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`; +} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index a4a5499eeee..6f9d5bc85d0 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,4 +1,5 @@ import { useAuth, useUser } from "@clerk/expo"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Updates from "expo-updates"; @@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "expo-symbols"; import * as Effect from "effect/Effect"; +import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { Alert, Linking, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; -import { loadPreferences } from "../../lib/storage"; import { useThemeColor } from "../../lib/useThemeColor"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() { } function ConfiguredSettingsRouteScreen() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); @@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() { const [notificationStatus, setNotificationStatus] = useState("checking"); const [liveActivityStatus, setLiveActivityStatus] = useState("checking"); const deviceRegistered = useDeviceRegistered(); + const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.liveActivitiesEnabled !== false + : true; const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); const environmentCount = connections.length; @@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() { setLiveActivityStatus("signed-out"); return; } - void (async () => { - const result = await settlePromise(() => loadPreferences()); - if (result._tag === "Failure") { - reportAtomCommandResult(result, { label: "live activity preference load" }); + if (!AsyncResult.isSuccess(preferencesResult)) { + if (AsyncResult.isFailure(preferencesResult)) { + reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); setLiveActivityStatus("enabled"); - return; + } else { + setLiveActivityStatus("checking"); } - setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); - })(); - }, [isLoaded, isSignedIn]); + return; + } + setLiveActivityStatus( + preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", + ); + }, [isLoaded, isSignedIn, preferencesResult]); const requestNotifications = useCallback(async () => { const result = await settleAsyncResult(() => @@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, connections, }), @@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() { return; } + savePreferences({ liveActivitiesEnabled: true }); refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); // The environment link can succeed while this device's own registration @@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() { "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", ); } - }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); + }, [ + connections, + environmentCount, + getToken, + isSignedIn, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { @@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, connections, }), ), ); if (updateResult._tag === "Failure") { + setLiveActivityStatus("enabled"); reportAtomCommandResult(updateResult, { label: "live activity disable", }); return; } + savePreferences({ liveActivitiesEnabled: false }); refreshManagedRelayEnvironments(); })(); return; @@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() { void linkEnvironments(); }, - [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], + [ + connections, + getToken, + isSignedIn, + linkEnvironments, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ], ); const openAccount = useCallback(() => { @@ -488,6 +519,7 @@ function AppSettingsSection() { return ( + (() => - resolveAppearancePreferences(null), + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const preferences = useMemo( + () => + resolveAppearancePreferences( + AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, + ), + [preferencesResult], ); - const [isReady, setIsReady] = useState(false); - - useEffect(() => { - let cancelled = false; - - void loadPreferences() - .then((stored) => { - if (cancelled) { - return; - } - - const resolved = resolveAppearancePreferences(stored); - setPreferences(resolved); - cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); - setIsReady(true); - }) - .catch(() => { - if (cancelled) { - return; - } - - setIsReady(true); - }); - - return () => { - cancelled = true; - }; - }, []); + const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; useEffect(() => { applyTextScaleVariables(preferences.baseFontSize); - }, [preferences.baseFontSize]); - - const updatePreferences = useCallback((patch: Partial) => { - setPreferences((current) => { - const next = resolveAppearancePreferences({ ...current, ...patch }); - cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); - void savePreferencesPatch({ - baseFontSize: next.baseFontSize, - terminalFontSize: next.terminalFontSize, - codeFontSize: next.codeFontSize, - codeWordBreak: next.codeWordBreak, - }).catch(() => undefined); - return next; - }); - }, []); + cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); + }, [preferences]); + + const updatePreferences = useCallback( + (patch: Partial) => { + savePreferences(patch); + }, + [savePreferences], + ); const setBaseFontSize = useCallback( (value: number) => { @@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN } export function useAppearancePreferences(): AppearancePreferencesContextValue { - const context = useContext(AppearancePreferencesContext); + const context = use(AppearancePreferencesContext); if (!context) { throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); } diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7de54bceee5..677dd5e4ed7 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -1 +1,5 @@ -export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; +export type SettingsSheetTarget = + | "SettingsEnvironments" + | "SettingsArchive" + | "SettingsAppearance" + | "SettingsClientStorage"; diff --git a/apps/mobile/src/lib/runtime.ts b/apps/mobile/src/lib/runtime.ts index 51a4885562c..98730edfbfc 100644 --- a/apps/mobile/src/lib/runtime.ts +++ b/apps/mobile/src/lib/runtime.ts @@ -8,6 +8,7 @@ import { cryptoLayer } from "../features/cloud/dpop"; import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; import { tracingLayer } from "../features/observability/tracing"; +import * as Persistence from "../persistence/layer"; function configuredRelayUrl(): string { return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; @@ -20,6 +21,7 @@ type RuntimeLayerSource = | typeof Socket.layerWebSocketConstructorGlobal | typeof cryptoLayer | typeof httpClientLayer + | typeof Persistence.layer | typeof tracingLayer; const runtimeLayer = Layer.merge( @@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge( Layer.provideMerge(cryptoLayer), Layer.provideMerge(httpClientLayer), Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), + Layer.provideMerge(Persistence.layer), ); export const runtime: ManagedRuntime.ManagedRuntime< diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 084f9430d08..a97252c7b72 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -3,34 +3,103 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); + let preferencesJson: string | null = null; + let preferencesUpdatedAt = 0; + let loadPreferencesFails = false; + let savePreferencesFails = false; return { - clear: () => values.clear(), + clear: () => { + values.clear(); + preferencesJson = null; + preferencesUpdatedAt = 0; + loadPreferencesFails = false; + savePreferencesFails = false; + }, + getStoredValue: (key: string) => values.get(key) ?? null, + getPreferencesJson: () => preferencesJson, + setPreferencesJson: (value: string, updatedAt: number) => { + preferencesJson = value; + preferencesUpdatedAt = updatedAt; + }, + setDatabaseFailures: (load: boolean, save: boolean) => { + loadPreferencesFails = load; + savePreferencesFails = save; + }, getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), setItemAsync: vi.fn((key: string, value: string) => { values.set(key, value); return Promise.resolve(); }), + deleteItemAsync: vi.fn((key: string) => { + values.delete(key); + return Promise.resolve(); + }), + database: { + closeAsync: vi.fn(() => Promise.resolve()), + execAsync: vi.fn(() => Promise.resolve()), + withExclusiveTransactionAsync: vi.fn( + (run: (transaction: { execAsync: () => Promise }) => Promise) => + run({ execAsync: () => Promise.resolve() }), + ), + getFirstAsync: vi.fn((sql: string) => { + if (sql.includes("PRAGMA user_version")) { + return Promise.resolve({ user_version: 1 }); + } + if (loadPreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + return Promise.resolve( + preferencesJson === null + ? null + : { payload: preferencesJson, updatedAt: preferencesUpdatedAt }, + ); + }), + runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => { + if (savePreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + if (typeof payload === "string") { + preferencesJson = payload; + } + if (typeof updatedAt === "number") { + preferencesUpdatedAt = updatedAt; + } + return Promise.resolve(); + }), + }, }; }); vi.mock("expo-secure-store", () => ({ + deleteItemAsync: mocks.deleteItemAsync, getItemAsync: mocks.getItemAsync, setItemAsync: mocks.setItemAsync, })); +vi.mock("expo-sqlite", () => ({ + openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), +})); + +vi.mock("expo-crypto", () => ({ + getRandomBytes: vi.fn(() => new Uint8Array(16)), +})); + +vi.mock("expo-constants", () => ({ + default: { expoConfig: { extra: {} } }, +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", }, })); -vi.mock("./runtime", () => ({ - runtime: { - runPromise: vi.fn(), - }, -})); - -import { loadSavedConnections, saveConnection } from "./storage"; +import { + loadPreferences, + loadSavedConnections, + saveConnection, + savePreferencesPatch, +} from "../persistence/imperative"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -100,4 +169,75 @@ describe("mobile connection storage", () => { warn.mockRestore(); }); + + it("loads legacy preferences when SQLite is unavailable", async () => { + mocks.setDatabaseFailures(true, true); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + }); + + it("falls back to secure storage when SQLite cannot save preferences", async () => { + mocks.setDatabaseFailures(true, true); + await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); + const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { + readonly payload: string; + readonly updatedAt: number; + }; + expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 }); + expect(fallback.updatedAt).toEqual(expect.any(Number)); + }); + + it("reconciles fallback preferences after SQLite recovers", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ + payload: JSON.stringify({ baseFontSize: 19 }), + updatedAt: 20, + }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + }); + + it("ignores a stale fallback when its previous deletion failed", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ + payload: JSON.stringify({ baseFontSize: 19 }), + updatedAt: 20, + }), + ); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + }); + + it("ignores an invalid fallback even when it has a newer timestamp", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync( + "t3code.preferences.fallback", + JSON.stringify({ payload: "{", updatedAt: 40 }), + ); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); + + warn.mockRestore(); + }); + + it("keeps SQLite authoritative when stale legacy preferences remain", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts deleted file mode 100644 index ae73ac7b4d9..00000000000 --- a/apps/mobile/src/lib/storage.ts +++ /dev/null @@ -1,291 +0,0 @@ -import * as Arr from "effect/Array"; -import { pipe } from "effect/Function"; -import * as Schema from "effect/Schema"; -import * as SecureStore from "expo-secure-store"; -import { EnvironmentId } from "@t3tools/contracts"; - -import { - isRelayManagedConnection, - type SavedRemoteConnection, - toStableSavedRemoteConnection, -} from "./connection"; - -const CONNECTIONS_KEY = "t3code.connections"; -const PREFERENCES_KEY = "t3code.preferences"; -const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; -const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; -const MobileStorageKey = Schema.Literals([ - CONNECTIONS_KEY, - PREFERENCES_KEY, - AGENT_AWARENESS_DEVICE_ID_KEY, - AGENT_AWARENESS_REGISTRATION_KEY, -]); -type MobileStorageKeyValue = typeof MobileStorageKey.Type; - -export class MobileSecureStorageError extends Schema.TaggedErrorClass()( - "MobileSecureStorageError", - { - operation: Schema.Literals(["read", "write", "generate-device-id"]), - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; - } -} - -export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( - "MobileStorageDecodeError", - { - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to decode mobile storage value for key ${this.key}.`; - } -} - -export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( - "MobileStorageEncodeError", - { - key: MobileStorageKey, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Failed to encode mobile storage value for key ${this.key}.`; - } -} - -export interface Preferences { - readonly liveActivitiesEnabled?: boolean; - readonly baseFontSize?: number; - /** Terminal font size override; null/absent means derived from baseFontSize. */ - readonly terminalFontSize?: number | null; - /** Legacy key predating baseFontSize; read once for migration. */ - readonly markdownFontSize?: number; - /** Code/diff font size override; null/absent means derived from baseFontSize. */ - readonly codeFontSize?: number | null; - readonly codeWordBreak?: boolean; - /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ - readonly connectOnboardingOptOutAccounts?: ReadonlyArray; -} - -async function readStorageItem(key: MobileStorageKeyValue): Promise { - try { - return await SecureStore.getItemAsync(key); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "read", key, cause }); - } -} - -async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise { - try { - await SecureStore.setItemAsync(key, value); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "write", key, cause }); - } -} - -async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { - const raw = (await readStorageItem(key)) ?? ""; - if (!raw.trim()) { - return null; - } - - try { - return JSON.parse(raw) as T; - } catch (cause) { - console.warn( - "[mobile-storage] ignored invalid JSON", - new MobileStorageDecodeError({ key, cause }), - ); - return null; - } -} - -async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { - let encoded: string; - try { - encoded = JSON.stringify(value); - } catch (cause) { - throw new MobileStorageEncodeError({ key, cause }); - } - await writeStorageItem(key, encoded); -} - -export async function loadSavedConnections(): Promise> { - const parsed = await readJsonStorageItem<{ - readonly connections?: ReadonlyArray; - }>(CONNECTIONS_KEY); - if (!parsed) { - return []; - } - - return pipe( - parsed.connections ?? [], - Arr.filter( - (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)), - ), - ); -} - -export async function saveConnection(connection: SavedRemoteConnection): Promise { - const current = await loadSavedConnections(); - const stableConnection = toStableSavedRemoteConnection(connection); - const next = current.some((entry) => entry.environmentId === connection.environmentId) - ? pipe( - current, - Arr.map((entry) => - entry.environmentId === connection.environmentId ? stableConnection : entry, - ), - ) - : pipe(current, Arr.append(stableConnection)); - - await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); -} - -export async function clearSavedConnection(environmentId: EnvironmentId): Promise { - const current = await loadSavedConnections(); - const next = pipe( - current, - Arr.filter((entry) => entry.environmentId !== environmentId), - ); - await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); -} - -export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); - if (!parsed || typeof parsed !== "object") { - return {}; - } - - const preferences: { - liveActivitiesEnabled?: boolean; - baseFontSize?: number; - terminalFontSize?: number | null; - markdownFontSize?: number; - codeFontSize?: number | null; - codeWordBreak?: boolean; - connectOnboardingOptOutAccounts?: ReadonlyArray; - } = {}; - - if (typeof parsed.liveActivitiesEnabled === "boolean") { - preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; - } - if (typeof parsed.baseFontSize === "number") { - preferences.baseFontSize = parsed.baseFontSize; - } - if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { - preferences.terminalFontSize = parsed.terminalFontSize; - } - if (typeof parsed.markdownFontSize === "number") { - preferences.markdownFontSize = parsed.markdownFontSize; - } - if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { - preferences.codeFontSize = parsed.codeFontSize; - } - if (typeof parsed.codeWordBreak === "boolean") { - preferences.codeWordBreak = parsed.codeWordBreak; - } - if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { - preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( - (account): account is string => typeof account === "string", - ); - } - - return preferences; -} - -// Preference writes are read-modify-write over one JSON blob; concurrent -// writers would drop each other's fields, so all writes are serialized here. -let preferencesWriteQueue: Promise = Promise.resolve(); - -export async function updatePreferences( - update: (current: Preferences) => Partial, -): Promise { - const task = preferencesWriteQueue.then(async () => { - const current = await loadPreferences(); - const next: Preferences = { - ...current, - ...update(current), - }; - await writeJsonStorageItem(PREFERENCES_KEY, next); - return next; - }); - preferencesWriteQueue = task.catch(() => undefined); - return task; -} - -export async function savePreferencesPatch(patch: Partial): Promise { - return updatePreferences(() => patch); -} - -export async function loadOrCreateAgentAwarenessDeviceId(): Promise { - const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); - if (existing?.trim()) { - return existing; - } - - const deviceId = await import("./uuid") - .then(({ uuidv4 }) => uuidv4()) - .catch((cause) => { - throw new MobileSecureStorageError({ - operation: "generate-device-id", - key: AGENT_AWARENESS_DEVICE_ID_KEY, - cause, - }); - }); - await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); - return deviceId; -} - -export async function loadAgentAwarenessDeviceId(): Promise { - const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); - return existing?.trim() ? existing : null; -} - -export interface AgentAwarenessRegistrationRecord { - readonly identity: string; - readonly signature: string; - // Last push-to-start token the relay accepted. Registrations triggered - // without a token event merge it back in so token absence never reads as a - // change (which would defeat the register-once skip every launch). - readonly pushToStartToken?: string; -} - -// Remembers the account identity and payload signature the relay last accepted -// so the app does not re-register on every launch while nothing has changed. -// Cleared only on sign-out. -export async function loadAgentAwarenessRegistrationRecord(): Promise { - const parsed = await readJsonStorageItem( - AGENT_AWARENESS_REGISTRATION_KEY, - ); - if ( - !parsed || - typeof parsed !== "object" || - typeof parsed.identity !== "string" || - typeof parsed.signature !== "string" - ) { - return null; - } - return { - identity: parsed.identity, - signature: parsed.signature, - ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken - ? { pushToStartToken: parsed.pushToStartToken } - : {}), - }; -} - -export async function saveAgentAwarenessRegistrationRecord( - record: AgentAwarenessRegistrationRecord, -): Promise { - await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record); -} - -export async function clearAgentAwarenessRegistrationRecord(): Promise { - await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, ""); -} diff --git a/apps/mobile/src/persistence/imperative.ts b/apps/mobile/src/persistence/imperative.ts new file mode 100644 index 00000000000..f2c8b0b5352 --- /dev/null +++ b/apps/mobile/src/persistence/imperative.ts @@ -0,0 +1,47 @@ +import * as Effect from "effect/Effect"; + +import { runtime } from "../lib/runtime"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileStorage from "./mobile-storage"; + +export type { Preferences } from "./mobile-preferences"; +export type { AgentAwarenessRegistrationRecord } from "./mobile-storage"; +export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences"; +export { + MobileDeviceIdGenerationError, + MobileStorageDecodeError, + MobileStorageEncodeError, +} from "./mobile-storage"; +export { MobileSecureStorageError } from "./mobile-secure-storage"; + +const runStorage = ( + use: (storage: MobileStorage.MobileStorage["Service"]) => Effect.Effect, +) => runtime.runPromise(MobileStorage.MobileStorage.pipe(Effect.flatMap(use))); + +const runPreferences = ( + use: (store: MobilePreferences.MobilePreferencesStore["Service"]) => Effect.Effect, +) => runtime.runPromise(MobilePreferences.MobilePreferencesStore.pipe(Effect.flatMap(use))); + +export const loadSavedConnections = () => runStorage((storage) => storage.loadSavedConnections); +export const saveConnection = ( + connection: Parameters[0], +) => runStorage((storage) => storage.saveConnection(connection)); + +export const loadPreferences = () => runPreferences((store) => store.load); +export const savePreferencesPatch = (patch: Partial) => + runPreferences((store) => store.savePatch(patch)); +export const updatePreferences = ( + transform: (current: MobilePreferences.Preferences) => Partial, +) => runPreferences((store) => store.update(transform)); + +export const loadOrCreateAgentAwarenessDeviceId = () => + runStorage((storage) => storage.loadOrCreateAgentAwarenessDeviceId); +export const loadAgentAwarenessDeviceId = () => + runStorage((storage) => storage.loadAgentAwarenessDeviceId); +export const loadAgentAwarenessRegistrationRecord = () => + runStorage((storage) => storage.loadAgentAwarenessRegistrationRecord); +export const saveAgentAwarenessRegistrationRecord = ( + record: MobileStorage.AgentAwarenessRegistrationRecord, +) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record)); +export const clearAgentAwarenessRegistrationRecord = () => + runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord); diff --git a/apps/mobile/src/persistence/layer.ts b/apps/mobile/src/persistence/layer.ts new file mode 100644 index 00000000000..5a9d1dacff3 --- /dev/null +++ b/apps/mobile/src/persistence/layer.ts @@ -0,0 +1,16 @@ +import * as Layer from "effect/Layer"; + +import * as EnvironmentCacheStore from "../connection/environment-cache-store"; +import * as MobileDatabase from "./mobile-database"; +import * as MobilePreferences from "./mobile-preferences"; +import * as MobileSecureStorage from "./mobile-secure-storage"; +import * as MobileStorage from "./mobile-storage"; + +const baseLayer = Layer.merge(MobileDatabase.layer, MobileSecureStorage.layer); +const dependentLayer = Layer.mergeAll( + MobilePreferences.layer, + MobileStorage.layer, + EnvironmentCacheStore.layer, +).pipe(Layer.provide(baseLayer)); + +export const layer = Layer.merge(baseLayer, dependentLayer); diff --git a/apps/mobile/src/persistence/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts new file mode 100644 index 00000000000..56c3e31657e --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +const openDatabaseAsync = vi.hoisted(() => vi.fn()); + +vi.mock("expo-sqlite", () => ({ openDatabaseAsync })); + +import { decodeLegacyCacheRecord, make } from "./mobile-database"; + +describe("mobile database legacy cache migration", () => { + it.effect("keeps acquisition failures typed on database operations", () => + Effect.scoped( + Effect.gen(function* () { + openDatabaseAsync.mockRejectedValueOnce(new Error("SQLite unavailable")); + + const database = yield* make; + const result = yield* Effect.result(database.loadPreferencesJson); + + expect(result).toMatchObject({ + _tag: "Failure", + failure: { _tag: "MobileDatabaseError", operation: "open" }, + }); + }), + ), + ); + + it("maps legacy thread records to their SQLite identity", () => { + const payload = JSON.stringify({ + schemaVersion: 2, + environmentId: "environment-1", + threadId: "thread-1", + snapshot: {}, + }); + + expect(decodeLegacyCacheRecord("connection-thread-snapshots", payload)).toEqual({ + environmentId: "environment-1", + kind: "thread", + cacheKey: "thread-1", + schemaVersion: 2, + payload, + }); + }); + + it("preserves the old shell payload for schema decoding after migration", () => { + const payload = JSON.stringify({ + schemaVersion: 1, + environmentId: "environment-1", + snapshotReceivedAt: "2026-07-01T00:00:00.000Z", + snapshot: {}, + }); + + expect(decodeLegacyCacheRecord("shell-snapshots", payload)).toEqual({ + environmentId: "environment-1", + kind: "shell", + cacheKey: "snapshot", + schemaVersion: 1, + payload, + }); + }); + + it("skips malformed legacy records", () => { + expect(decodeLegacyCacheRecord("connection-vcs-refs", "{not-json")).toBeNull(); + expect( + decodeLegacyCacheRecord( + "connection-vcs-refs", + JSON.stringify({ schemaVersion: 1, environmentId: "environment-1" }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts new file mode 100644 index 00000000000..33eb50f640c --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -0,0 +1,409 @@ +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); diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts new file mode 100644 index 00000000000..6971570b0e6 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -0,0 +1,305 @@ +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as MobileDatabase from "./mobile-database"; +import * as MobileSecureStorage from "./mobile-secure-storage"; +import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; + +const PREFERENCES_KEY = "t3code.preferences"; +const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; + +export interface Preferences { + readonly liveActivitiesEnabled?: boolean; + readonly baseFontSize?: number; + readonly terminalFontSize?: number | null; + readonly markdownFontSize?: number; + readonly codeFontSize?: number | null; + readonly codeWordBreak?: boolean; + readonly connectOnboardingOptOutAccounts?: ReadonlyArray; + readonly collapsedProjectGroups?: readonly string[]; +} + +export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( + "MobilePreferencesLoadError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to load mobile preferences."; + } +} + +export class MobilePreferencesSaveError extends Schema.TaggedErrorClass()( + "MobilePreferencesSaveError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to save mobile preferences."; + } +} + +interface PreferencesFallback { + readonly payload: string; + readonly updatedAt: number; + readonly preferences: Preferences; +} + +export class MobilePreferencesStore extends Context.Service< + MobilePreferencesStore, + { + readonly load: Effect.Effect; + readonly savePatch: ( + patch: Partial, + ) => Effect.Effect; + readonly update: ( + transform: (current: Preferences) => Partial, + ) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobilePreferencesStore") {} + +function sanitizePreferences(parsed: Preferences): Preferences { + const preferences: { + liveActivitiesEnabled?: boolean; + baseFontSize?: number; + terminalFontSize?: number | null; + markdownFontSize?: number; + codeFontSize?: number | null; + codeWordBreak?: boolean; + connectOnboardingOptOutAccounts?: ReadonlyArray; + collapsedProjectGroups?: readonly string[]; + } = {}; + + if (typeof parsed.liveActivitiesEnabled === "boolean") { + preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled; + } + if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize; + if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) { + preferences.terminalFontSize = parsed.terminalFontSize; + } + if (typeof parsed.markdownFontSize === "number") { + preferences.markdownFontSize = parsed.markdownFontSize; + } + if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) { + preferences.codeFontSize = parsed.codeFontSize; + } + if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak; + if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) { + preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter( + (account): account is string => typeof account === "string", + ); + } + if (Array.isArray(parsed.collapsedProjectGroups)) { + preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter( + (key): key is string => typeof key === "string", + ); + } + return preferences; +} + +export const make = Effect.fn("MobilePreferencesStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; + const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; + const lock = yield* Semaphore.make(1); + const lastUpdatedAt = yield* Ref.make(0); + + const parsePayload = (raw: string | null): Preferences | null => { + if (raw === null || !raw.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }), + ); + return null; + } + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Preferences) + : null; + }; + + const parseFallback = (raw: string | null): PreferencesFallback | null => { + if (raw === null || !raw.trim()) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }), + ); + return null; + } + if ( + typeof parsed !== "object" || + parsed === null || + !("payload" in parsed) || + typeof parsed.payload !== "string" || + !("updatedAt" in parsed) || + typeof parsed.updatedAt !== "number" + ) { + return null; + } + const preferences = parsePayload(parsed.payload); + return preferences === null + ? null + : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences }; + }; + + const encode = Effect.fn("MobilePreferencesStore.encode")(function* ( + key: string, + value: unknown, + ) { + return yield* Effect.try({ + try: () => JSON.stringify(value), + catch: (cause) => new MobileStorageEncodeError({ key, cause }), + }); + }); + + const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => { + const next = Math.max(Date.now(), last + 1); + return [next, next] as const; + }); + + const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* ( + payload: string, + updatedAt?: number, + ) { + const timestamp = updatedAt ?? (yield* nextUpdatedAt); + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp)); + const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp)); + if (databaseResult._tag === "Failure") { + yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe( + Effect.annotateLogs({ cause: databaseResult.failure }), + ); + const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp }); + yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback); + return; + } + yield* secureStorage + .removeItem(PREFERENCES_FALLBACK_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove the mobile preferences fallback.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + }); + + const loadUnlocked = Effect.gen(function* () { + const databaseResult = yield* Effect.result(database.loadPreferencesJson); + const databaseAvailable = databaseResult._tag === "Success"; + const storedJson = databaseAvailable + ? databaseResult.success + : Option.none(); + if (databaseResult._tag === "Failure") { + yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe( + Effect.annotateLogs({ cause: databaseResult.failure }), + ); + } + + const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY)); + let fallbackJson: string | null = null; + if (fallbackResult._tag === "Success") { + fallbackJson = fallbackResult.success; + } else if (Option.isNone(storedJson)) { + return yield* fallbackResult.failure; + } else { + yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe( + Effect.annotateLogs({ error: fallbackResult.failure }), + ); + } + + const fallback = parseFallback(fallbackJson); + const storedPreferences = Option.isSome(storedJson) + ? parsePayload(storedJson.value.payload) + : null; + const fallbackIsNewer = + fallback !== null && + (storedPreferences === null || + (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt)); + + let parsed: Preferences | null = null; + if (fallbackIsNewer) { + parsed = fallback.preferences; + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt)); + if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt); + } else if (storedPreferences !== null && Option.isSome(storedJson)) { + parsed = storedPreferences; + yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt)); + if (fallbackJson !== null) { + yield* secureStorage + .removeItem(PREFERENCES_FALLBACK_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + } + } + + if (parsed === null) { + const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY); + const legacyPreferences = parsePayload(legacyJson); + parsed = legacyPreferences; + if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) { + yield* saveJson(legacyJson); + yield* secureStorage + .removeItem(PREFERENCES_KEY) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not remove migrated mobile preferences.").pipe( + Effect.annotateLogs({ error }), + ), + ), + ); + } + } + + return parsed === null ? {} : sanitizePreferences(parsed); + }); + + const load = lock + .withPermits(1)(loadUnlocked) + .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause }))); + + const update = Effect.fn("MobilePreferencesStore.update")((transform) => + lock + .withPermits(1)( + Effect.gen(function* () { + const current = yield* loadUnlocked; + const patch = yield* Effect.try({ + try: () => transform(current), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }); + const next: Preferences = { ...current, ...patch }; + const payload = yield* encode(PREFERENCES_KEY, next); + yield* saveJson(payload); + return next; + }), + ) + .pipe( + Effect.mapError((cause) => + cause instanceof MobilePreferencesSaveError + ? cause + : new MobilePreferencesSaveError({ cause }), + ), + ), + ); + + return MobilePreferencesStore.of({ + load, + update, + savePatch: (patch) => update(() => patch), + }); +}); + +export const layer = Layer.effect(MobilePreferencesStore, make()); diff --git a/apps/mobile/src/persistence/mobile-secure-storage.ts b/apps/mobile/src/persistence/mobile-secure-storage.ts new file mode 100644 index 00000000000..0ab7c9e4d96 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-secure-storage.ts @@ -0,0 +1,52 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SecureStore from "expo-secure-store"; + +const MobileSecureStorageOperation = Schema.Literals(["read", "write", "delete"]); + +export class MobileSecureStorageError extends Schema.TaggedErrorClass()( + "MobileSecureStorageError", + { + operation: MobileSecureStorageOperation, + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`; + } +} + +export class MobileSecureStorage extends Context.Service< + MobileSecureStorage, + { + readonly getItem: (key: string) => Effect.Effect; + readonly setItem: (key: string, value: string) => Effect.Effect; + readonly removeItem: (key: string) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobileSecureStorage") {} + +export const make = MobileSecureStorage.of({ + getItem: Effect.fn("MobileSecureStorage.getItem")((key) => + Effect.tryPromise({ + try: () => SecureStore.getItemAsync(key), + catch: (cause) => new MobileSecureStorageError({ operation: "read", key, cause }), + }), + ), + setItem: Effect.fn("MobileSecureStorage.setItem")((key, value) => + Effect.tryPromise({ + try: () => SecureStore.setItemAsync(key, value), + catch: (cause) => new MobileSecureStorageError({ operation: "write", key, cause }), + }), + ), + removeItem: Effect.fn("MobileSecureStorage.removeItem")((key) => + Effect.tryPromise({ + try: () => SecureStore.deleteItemAsync(key), + catch: (cause) => new MobileSecureStorageError({ operation: "delete", key, cause }), + }), + ), +}); + +export const layer = Layer.succeed(MobileSecureStorage, make); diff --git a/apps/mobile/src/persistence/mobile-storage.ts b/apps/mobile/src/persistence/mobile-storage.ts new file mode 100644 index 00000000000..3f55435b1e6 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-storage.ts @@ -0,0 +1,227 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Arr from "effect/Array"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import { pipe } from "effect/Function"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { + isRelayManagedConnection, + type SavedRemoteConnection, + toStableSavedRemoteConnection, +} from "../lib/connection"; +import * as MobileSecureStorage from "./mobile-secure-storage"; + +const CONNECTIONS_KEY = "t3code.connections"; +const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; +const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; + +export class MobileStorageDecodeError extends Schema.TaggedErrorClass()( + "MobileStorageDecodeError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to decode mobile storage value for key ${this.key}.`; + } +} + +export class MobileStorageEncodeError extends Schema.TaggedErrorClass()( + "MobileStorageEncodeError", + { + key: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode mobile storage value for key ${this.key}.`; + } +} + +export class MobileDeviceIdGenerationError extends Schema.TaggedErrorClass()( + "MobileDeviceIdGenerationError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Failed to generate the mobile agent-awareness device id."; + } +} + +export interface AgentAwarenessRegistrationRecord { + readonly identity: string; + readonly signature: string; + readonly pushToStartToken?: string; +} + +export class MobileStorage extends Context.Service< + MobileStorage, + { + readonly loadSavedConnections: Effect.Effect< + ReadonlyArray, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveConnection: ( + connection: SavedRemoteConnection, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly clearSavedConnection: ( + environmentId: EnvironmentId, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly loadOrCreateAgentAwarenessDeviceId: Effect.Effect< + string, + MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError + >; + readonly loadAgentAwarenessDeviceId: Effect.Effect< + string | null, + MobileSecureStorage.MobileSecureStorageError + >; + readonly loadAgentAwarenessRegistrationRecord: Effect.Effect< + AgentAwarenessRegistrationRecord | null, + MobileSecureStorage.MobileSecureStorageError + >; + readonly saveAgentAwarenessRegistrationRecord: ( + record: AgentAwarenessRegistrationRecord, + ) => Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError + >; + readonly clearAgentAwarenessRegistrationRecord: Effect.Effect< + void, + MobileSecureStorage.MobileSecureStorageError + >; + } +>()("@t3tools/mobile/persistence/MobileStorage") {} + +export const make = Effect.fn("MobileStorage.make")(function* () { + const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; + + const parseJson = (key: string, raw: string): A | null => { + if (!raw.trim()) return null; + try { + return JSON.parse(raw) as A; + } catch (cause) { + console.warn( + "[mobile-storage] ignored invalid JSON", + new MobileStorageDecodeError({ key, cause }), + ); + return null; + } + }; + + const readJson = Effect.fn("MobileStorage.readJson")(function* (key: string) { + const raw = (yield* secureStorage.getItem(key)) ?? ""; + return parseJson(key, raw); + }); + + const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) { + const encoded = yield* Effect.try({ + try: () => JSON.stringify(value), + catch: (cause) => new MobileStorageEncodeError({ key, cause }), + }); + yield* secureStorage.setItem(key, encoded); + }); + + const loadSavedConnections = readJson<{ + readonly connections?: ReadonlyArray; + }>(CONNECTIONS_KEY).pipe( + Effect.map((parsed) => + pipe( + parsed?.connections ?? [], + Arr.filter( + (connection) => + !!connection.environmentId && + (!!connection.bearerToken?.trim() || isRelayManagedConnection(connection)), + ), + ), + ), + ); + + const saveConnection = Effect.fn("MobileStorage.saveConnection")(function* ( + connection: SavedRemoteConnection, + ) { + const current = yield* loadSavedConnections; + const stableConnection = toStableSavedRemoteConnection(connection); + const next = current.some((entry) => entry.environmentId === connection.environmentId) + ? pipe( + current, + Arr.map((entry) => + entry.environmentId === connection.environmentId ? stableConnection : entry, + ), + ) + : pipe(current, Arr.append(stableConnection)); + yield* writeJson(CONNECTIONS_KEY, { connections: next }); + }); + + const clearSavedConnection = Effect.fn("MobileStorage.clearSavedConnection")(function* ( + environmentId: EnvironmentId, + ) { + const current = yield* loadSavedConnections; + const next = pipe( + current, + Arr.filter((entry) => entry.environmentId !== environmentId), + ); + yield* writeJson(CONNECTIONS_KEY, { connections: next }); + }); + + const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () { + const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY); + if (existing?.trim()) return existing; + const deviceId = yield* Effect.tryPromise({ + try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()), + catch: (cause) => new MobileDeviceIdGenerationError({ cause }), + }); + yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId); + return deviceId; + }); + + const loadAgentAwarenessDeviceId = secureStorage + .getItem(AGENT_AWARENESS_DEVICE_ID_KEY) + .pipe(Effect.map((existing) => (existing?.trim() ? existing : null))); + + const loadAgentAwarenessRegistrationRecord = readJson( + AGENT_AWARENESS_REGISTRATION_KEY, + ).pipe( + Effect.map((parsed) => { + if ( + !parsed || + typeof parsed !== "object" || + typeof parsed.identity !== "string" || + typeof parsed.signature !== "string" + ) { + return null; + } + return { + identity: parsed.identity, + signature: parsed.signature, + ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken + ? { pushToStartToken: parsed.pushToStartToken } + : {}), + }; + }), + ); + + return MobileStorage.of({ + loadSavedConnections, + saveConnection, + clearSavedConnection, + loadOrCreateAgentAwarenessDeviceId, + loadAgentAwarenessDeviceId, + loadAgentAwarenessRegistrationRecord, + saveAgentAwarenessRegistrationRecord: (record) => + writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record), + clearAgentAwarenessRegistrationRecord: secureStorage.setItem( + AGENT_AWARENESS_REGISTRATION_KEY, + "", + ), + }); +}); + +export const layer = Layer.effect(MobileStorage, make()); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts new file mode 100644 index 00000000000..3912857b575 --- /dev/null +++ b/apps/mobile/src/state/client-cache-state.ts @@ -0,0 +1,87 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import * as Runtime from "../lib/runtime"; + +export interface EnvironmentClientCacheSummary { + readonly environmentId: EnvironmentId; + readonly recordCount: number; + readonly payloadBytes: number; + readonly kinds: Readonly>>; +} + +export interface ClientCacheSummary { + readonly recordCount: number; + readonly payloadBytes: number; + readonly environments: ReadonlyArray; +} + +export type ClientCacheClearScope = + | { readonly type: "all" } + | { readonly type: "environment"; readonly environmentId: EnvironmentId }; + +function aggregateCacheSummary( + rows: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly kind: ClientCacheKind; + readonly recordCount: number; + readonly payloadBytes: number; + }>, +): ClientCacheSummary { + const environments = new Map(); + let recordCount = 0; + let payloadBytes = 0; + + for (const row of rows) { + recordCount += row.recordCount; + payloadBytes += row.payloadBytes; + const current = environments.get(row.environmentId) ?? { + environmentId: row.environmentId, + recordCount: 0, + payloadBytes: 0, + kinds: {}, + }; + environments.set(row.environmentId, { + environmentId: row.environmentId, + recordCount: current.recordCount + row.recordCount, + payloadBytes: current.payloadBytes + row.payloadBytes, + kinds: { ...current.kinds, [row.kind]: row.recordCount }, + }); + } + + return { + recordCount, + payloadBytes, + environments: [...environments.values()], + }; +} + +const clientCacheRuntime = Atom.runtime(Runtime.runtimeContextLayer); + +export const clientCacheSummaryAtom = clientCacheRuntime + .atom( + MobileDatabase.pipe( + Effect.flatMap((database) => database.inspectCaches), + Effect.map(aggregateCacheSummary), + ), + ) + .pipe(Atom.withLabel("mobile:client-cache:summary")); + +export const clearClientCacheAtom = clientCacheRuntime + .fn((scope: ClientCacheClearScope, get) => + MobileDatabase.pipe( + Effect.flatMap((database) => + scope.type === "all" + ? database.clearAllCaches + : database.clearEnvironmentCache(scope.environmentId), + ), + Effect.tap(() => + Effect.sync(() => { + get.refresh(clientCacheSummaryAtom); + }), + ), + ), + ) + .pipe(Atom.withLabel("mobile:client-cache:clear")); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts new file mode 100644 index 00000000000..c53594eb230 --- /dev/null +++ b/apps/mobile/src/state/preferences.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { vi } from "vite-plus/test"; + +vi.mock("expo-secure-store", () => ({ + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, +})); + +vi.mock("../lib/runtime", async () => { + const Layer = await import("effect/Layer"); + return { + runtime: { runPromise: vi.fn() }, + runtimeContextLayer: Layer.empty, + }; +}); + +import type { Preferences } from "../persistence/mobile-preferences"; +import { + createMobilePreferencesState, + MobilePreferencesLoadError, + MobilePreferencesSaveError, + MobilePreferencesStore, +} from "./preferences"; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((resume) => { + resolve = resume; + }); + return { promise, resolve } as const; +} + +function makePreferencesState( + service: Omit & + Partial>, +) { + const completeService = MobilePreferencesStore.of({ + ...service, + update: + service.update ?? + ((transform) => + service.load.pipe( + Effect.flatMap((current) => service.savePatch(transform(current))), + Effect.mapError((cause) => + cause._tag === "MobilePreferencesSaveError" + ? cause + : new MobilePreferencesSaveError({ cause }), + ), + )), + }); + return createMobilePreferencesState( + Atom.runtime(Layer.succeed(MobilePreferencesStore, completeService)), + ); +} + +describe("mobile preferences state", () => { + it.effect("shares one preference load across consumers", () => + Effect.gen(function* () { + const load = vi.fn(() => Promise.resolve({ baseFontSize: 17 })); + const state = makePreferencesState({ + load: Effect.promise(load), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmountFirst = registry.mount(state.preferencesAtom); + const unmountSecond = registry.mount(state.preferencesAtom); + + expect( + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }), + ).toEqual({ baseFontSize: 17 }); + expect(load).toHaveBeenCalledTimes(1); + + unmountSecond(); + unmountFirst(); + registry.dispose(); + }), + ); + + it.effect("preserves an optimistic patch when the initial load finishes later", () => + Effect.gen(function* () { + const pendingLoad = deferred(); + const savePatch = vi.fn((patch: Partial) => Effect.succeed(patch)); + const state = makePreferencesState({ + load: Effect.promise(() => pendingLoad.promise), + savePatch, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + registry.set(state.updatePreferencesAtom, { + collapsedProjectGroups: ["project:new"], + }); + pendingLoad.resolve({ + baseFontSize: 18, + collapsedProjectGroups: ["project:old"], + }); + + const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + expect(preferences).toEqual({ + baseFontSize: 18, + collapsedProjectGroups: ["project:new"], + }); + expect(savePatch).toHaveBeenCalledWith({ + collapsedProjectGroups: ["project:new"], + }); + expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("falls back to empty preferences when secure storage cannot be read", () => + Effect.gen(function* () { + const state = makePreferencesState({ + load: Effect.fail( + new MobilePreferencesLoadError({ + cause: new Error("secure storage unavailable"), + }), + ), + savePatch: (patch) => Effect.succeed(patch), + }); + const registry = AtomRegistry.make(); + const unmount = registry.mount(state.preferencesAtom); + + expect( + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }), + ).toEqual({}); + + unmount(); + registry.dispose(); + }), + ); + + it.effect("does not roll back a newer optimistic write with the same value", () => + Effect.gen(function* () { + let saveCount = 0; + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }), + savePatch: (patch) => { + saveCount += 1; + return saveCount === 1 + ? Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })) + : Effect.succeed(patch); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 18, + codeFontSize: 15, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("rolls back an optimistic field when its save fails", () => + Effect.gen(function* () { + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16 }), + savePatch: () => + Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })), + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + + yield* Effect.promise(() => + vi.waitFor(() => { + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 16, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); + + it.effect("rolls back to the last confirmed value after a later save fails", () => + Effect.gen(function* () { + let saveCount = 0; + const state = makePreferencesState({ + load: Effect.succeed({ baseFontSize: 16 }), + savePatch: () => { + saveCount += 1; + return saveCount === 1 + ? Effect.succeed({ baseFontSize: 14 }) + : Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })); + }, + }); + const registry = AtomRegistry.make(); + const unmountPreferences = registry.mount(state.preferencesAtom); + const unmountUpdate = registry.mount(state.updatePreferencesAtom); + + yield* AtomRegistry.getResult(registry, state.preferencesAtom, { + suspendOnWaiting: true, + }); + registry.set(state.updatePreferencesAtom, { baseFontSize: 14 }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(1); + expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 14, + }, + ); + }), + ); + + registry.set(state.updatePreferencesAtom, { baseFontSize: 18 }); + yield* Effect.promise(() => + vi.waitFor(() => { + expect(saveCount).toBe(2); + expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual( + { + baseFontSize: 14, + }, + ); + }), + ); + + unmountUpdate(); + unmountPreferences(); + registry.dispose(); + }), + ); +}); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts new file mode 100644 index 00000000000..d173cf55be5 --- /dev/null +++ b/apps/mobile/src/state/preferences.ts @@ -0,0 +1,124 @@ +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; +import * as Runtime from "../lib/runtime"; + +export { + MobilePreferencesLoadError, + MobilePreferencesSaveError, + MobilePreferencesStore, +} from "../persistence/mobile-preferences"; + +interface OptimisticPreferences { + readonly values: Partial; + readonly versions: Partial>; +} + +/** + * Owns the device preference blob for the lifetime of the app registry. + * Optimistic patches are kept separately so writes made while persistence is + * still loading cannot be replaced by the eventual read result. + */ +export function createMobilePreferencesState(runtime: Atom.AtomRuntime) { + const storedPreferencesAtom = runtime + .atom( + MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.load), + Effect.catch((error) => + Effect.logWarning("Could not load mobile preferences.", error).pipe( + Effect.as({}), + ), + ), + ), + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored")); + + const optimisticPatchAtom = Atom.make({ values: {}, versions: {} }).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:optimistic-patch"), + ); + const confirmedPreferencesAtom = Atom.make({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:confirmed"), + ); + let nextPatchVersion = 0; + + const preferencesAtom = Atom.make((get) => { + const stored = get(storedPreferencesAtom); + const confirmed = get(confirmedPreferencesAtom); + const optimistic = get(optimisticPatchAtom); + return AsyncResult.map(stored, (preferences) => ({ + ...preferences, + ...confirmed, + ...optimistic.values, + })); + }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); + + const updatePreferencesAtom = runtime + .fn( + (patch: Partial, get) => { + const version = ++nextPatchVersion; + const current = get(optimisticPatchAtom); + const versions = { ...current.versions }; + for (const key of Object.keys(patch) as Array) { + versions[key] = version; + } + get.set(optimisticPatchAtom, { + values: { ...current.values, ...patch }, + versions, + }); + return MobilePreferencesStore.pipe( + Effect.flatMap((store) => store.savePatch(patch)), + Effect.tap((saved) => + Effect.sync(() => { + get.set(confirmedPreferencesAtom, saved); + const optimistic = get(optimisticPatchAtom); + const values = { ...optimistic.values } as Record; + const currentVersions = { ...optimistic.versions } as Record; + for (const key of Object.keys(patch) as Array) { + if (optimistic.versions[key] === version) { + delete values[key]; + delete currentVersions[key]; + } + } + get.set(optimisticPatchAtom, { + values: values as Partial, + versions: currentVersions as Partial>, + }); + }), + ), + Effect.tapError(() => + Effect.sync(() => { + const optimistic = get(optimisticPatchAtom); + const values = { ...optimistic.values } as Record; + const currentVersions = { ...optimistic.versions } as Record; + for (const key of Object.keys(patch) as Array) { + if (optimistic.versions[key] === version) { + delete values[key]; + delete currentVersions[key]; + } + } + get.set(optimisticPatchAtom, { + values: values as Partial, + versions: currentVersions as Partial>, + }); + }), + ), + ); + }, + // The storage layer serializes preference read-modify-write operations. + // Keep every invocation alive so one preference update cannot interrupt + // another update to a different field in the shared blob. + { concurrent: true }, + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update")); + + return { preferencesAtom, updatePreferencesAtom } as const; +} + +const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); +export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime); + +export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; +export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 89e9c56eb8a..8e5c004b459 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1030,10 +1030,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { entry.result.outcome !== null && "outcome" in entry.result.outcome && entry.result.outcome.outcome === "cancelled"; - yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel"); - const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse); - assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); - assert.isTrue(requests.some(isCancelledApprovalResponse)); + const cancelRequests = yield* waitForJsonLogMatch( + requestLogPath, + (entry) => entry.method === "session/cancel", + ); + const approvalResponses = yield* waitForJsonLogMatch( + requestLogPath, + isCancelledApprovalResponse, + ); + assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); + assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); yield* adapter.stopSession(threadId); }), diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index df311d07615..04d237a5030 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -21,7 +21,9 @@ import { EnvironmentId, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, + ServerConfig, ThreadId, + VcsListRefsResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -32,10 +34,12 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; const DATABASE_NAME = "t3code:connection-runtime"; -const DATABASE_VERSION = 2; +const DATABASE_VERSION = 4; const CATALOG_STORE_NAME = "catalog"; const SHELL_STORE_NAME = "shell"; const THREAD_STORE_NAME = "thread"; +const SERVER_CONFIG_STORE_NAME = "server-config"; +const VCS_REFS_STORE_NAME = "vcs-refs"; const CATALOG_KEY = "document"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; @@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({ snapshot: OrchestrationThreadDetailSnapshot, }); const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); +const StoredServerConfig = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + config: ServerConfig, +}); +const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig); +const StoredVcsRefs = Schema.Struct({ + schemaVersion: Schema.Literal(1), + environmentId: EnvironmentId, + cwd: Schema.String, + refs: VcsListRefsResult, +}); +const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); @@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); +const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); +const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); +const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson); +const encodeStoredVcsRefs = Schema.encodeEffect(StoredVcsRefsJson); function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ @@ -80,6 +101,10 @@ function persistenceError( | "load-thread" | "save-thread" | "remove-thread" + | "load-server-config" + | "save-server-config" + | "load-vcs-refs" + | "save-vcs-refs" | "clear-environment", cause: unknown, ) { @@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { request.result.createObjectStore(THREAD_STORE_NAME); } + if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { + request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); + } + if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { + request.result.createObjectStore(VCS_REFS_STORE_NAME); + } }); request.addEventListener("error", () => { resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); @@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { return `${environmentId}:${threadId}`; } +function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { + return `${environmentId}:${cwd}`; +} + const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { return yield* decodeConnectionCatalogDocument(raw).pipe( Effect.mapError((cause) => catalogError("decode", cause)), @@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext( : persistenceError("save-shell", cause), ), ), + loadServerConfig: (environmentId) => + readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredServerConfig(raw).pipe( + Effect.mapError((cause) => persistenceError("load-server-config", cause)), + Effect.map((stored) => + stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-server-config", cause), + ), + ), + saveServerConfig: (environmentId, config) => + Effect.gen(function* () { + const encoded = yield* encodeStoredServerConfig({ + schemaVersion: 1, + environmentId, + config, + }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); + yield* writeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId, encoded); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-server-config", cause), + ), + ), loadThread: (environmentId, threadId) => readDatabaseValue( database, @@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext( : persistenceError("save-thread", cause), ), ), + loadVcsRefs: (environmentId, cwd) => + readDatabaseValue(database, VCS_REFS_STORE_NAME, vcsRefsCacheKey(environmentId, cwd)).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "string") { + return Effect.succeed(Option.none()); + } + return decodeStoredVcsRefs(raw).pipe( + Effect.mapError((cause) => persistenceError("load-vcs-refs", cause)), + Effect.map((stored) => + stored.environmentId === environmentId && stored.cwd === cwd + ? Option.some(stored.refs) + : Option.none(), + ), + ); + }), + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("load-vcs-refs", cause), + ), + ), + saveVcsRefs: (environmentId, cwd, refs) => + Effect.gen(function* () { + const encoded = yield* encodeStoredVcsRefs({ + schemaVersion: 1, + environmentId, + cwd, + refs, + }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause))); + yield* writeDatabaseValue( + database, + VCS_REFS_STORE_NAME, + vcsRefsCacheKey(environmentId, cwd), + encoded, + ); + }).pipe( + Effect.mapError((cause) => + cause._tag === "ConnectionPersistenceError" + ? cause + : persistenceError("save-vcs-refs", cause), + ), + ), removeThread: (environmentId, threadId) => removeDatabaseValue( database, @@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext( THREAD_STORE_NAME, IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), ), + removeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId), + removeDatabaseValuesInRange( + database, + VCS_REFS_STORE_NAME, + IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), + ), ], { concurrency: "unbounded", discard: true }, ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 885ba4cb781..c75cc2fad4b 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -247,6 +247,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), saveThread: (_environmentId, _thread) => Effect.void, removeThread: (_environmentId, _threadId) => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, clear: (environmentId) => Ref.update(shellCache, (current) => { const next = new Map(current); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 047e7793611..8723ac06939 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -2,7 +2,9 @@ import { type EnvironmentId, type OrchestrationShellSnapshot, type OrchestrationThreadDetailSnapshot, + type ServerConfig, type ThreadId, + type VcsListRefsResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass Effect.Effect; + /** + * The last complete server configuration. This deliberately includes provider + * metadata so offline task creation can still offer the models a user last saw. + */ + readonly loadServerConfig: ( + environmentId: EnvironmentId, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveServerConfig: ( + environmentId: EnvironmentId, + config: ServerConfig, + ) => Effect.Effect; + /** + * The unfiltered branch list for a workspace. Query-specific lists are not + * cached because they are incomplete and unsafe to present as a full picker. + */ + readonly loadVcsRefs: ( + environmentId: EnvironmentId, + cwd: string, + ) => Effect.Effect, ConnectionPersistenceError>; + readonly saveVcsRefs: ( + environmentId: EnvironmentId, + cwd: string, + refs: VcsListRefsResult, + ) => Effect.Effect; readonly clear: ( environmentId: EnvironmentId, ) => Effect.Effect; diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 4b9564e031c..7ced26f0ff1 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,8 +1,32 @@ -import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; +import { + EnvironmentId, + type ServerConfig, + type ServerConfigStreamEvent, + type ServerLifecycleWelcomePayload, + WS_METHODS, +} from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; -import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { + applyServerConfigProjection, + makeEnvironmentServerConfigState, + projectServerWelcome, + resolveServerConfigValue, +} from "./server.ts"; const CONFIG = { availableEditors: [], @@ -14,6 +38,29 @@ const CONFIG = { settings: {}, } as unknown as ServerConfig; +const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ + version: 1, + type: "snapshot", + config, +}); + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.succeed(CONFIG), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + describe("server state projection", () => { it("applies every config category to the projected snapshot", () => { const snapshot = applyServerConfigProjection(Option.none(), { @@ -51,4 +98,130 @@ describe("server state projection", () => { expect(Option.getOrThrow(afterReady)).toBe(welcome); expect(emitted).toEqual([]); }); + + it("prefers an active session config over cache until a live event arrives", () => { + const cached = { ...CONFIG, settings: { source: "cache" } } as unknown as ServerConfig; + const initial = { ...CONFIG, settings: { source: "session" } } as unknown as ServerConfig; + const live = { ...CONFIG, settings: { source: "live" } } as unknown as ServerConfig; + + expect( + resolveServerConfigValue( + { + config: cached, + latestEvent: snapshotEvent(cached), + source: "cache", + }, + initial, + ), + ).toBe(initial); + expect( + resolveServerConfigValue( + { + config: live, + latestEvent: snapshotEvent(live), + source: "live", + }, + initial, + ), + ).toBe(live); + }); + + it.effect("starts from cached configuration and persists the live projection", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.fromQueue(events), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const savedConfigs = yield* Queue.unbounded(); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.some(CONFIG)), + saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerConfigState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + expect(Option.getOrThrow(yield* SubscriptionRef.get(state)).config).toBe(CONFIG); + + const providers: ServerConfig["providers"] = []; + yield* Queue.offer(events, { + version: 1, + type: "providerStatuses", + payload: { providers }, + }); + const projected = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => + Option.match(value, { + onNone: () => false, + onSome: (projection) => projection.latestEvent.type === "providerStatuses", + }), + ), + Stream.runHead, + ); + expect(Option.getOrThrow(Option.getOrThrow(projected)).config.providers).toBe(providers); + }), + ); + + expect((yield* Queue.take(savedConfigs)).providers).toEqual([]); + }), + ); + + it.effect("does not rewrite cached configuration when no live update arrives", () => + Effect.gen(function* () { + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.empty, + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const savedConfigs = yield* Queue.unbounded(); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.some(CONFIG)), + saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + + yield* Effect.scoped( + makeEnvironmentServerConfigState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ), + ); + + expect(yield* Queue.poll(savedConfigs)).toEqual(Option.none()); + }), + ); }); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index eb784183793..7306a0a1071 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -5,8 +5,13 @@ import { type ServerLifecycleWelcomePayload, WS_METHODS, } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { @@ -16,10 +21,16 @@ import { createEnvironmentRpcSubscriptionAtomFamily, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; export interface ServerConfigProjection { readonly config: ServerConfig; readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; } export function applyServerConfigProjection( @@ -31,6 +42,7 @@ export function applyServerConfigProjection( return Option.some({ config: event.config, latestEvent: event, + source: "live", }); case "keybindingsUpdated": return Option.map(current, (projection) => ({ @@ -40,6 +52,7 @@ export function applyServerConfigProjection( issues: event.payload.issues, }, latestEvent: event, + source: "live", })); case "providerStatuses": return Option.map(current, (projection) => ({ @@ -48,6 +61,7 @@ export function applyServerConfigProjection( providers: event.payload.providers, }, latestEvent: event, + source: "live", })); case "settingsUpdated": return Option.map(current, (projection) => ({ @@ -56,6 +70,7 @@ export function applyServerConfigProjection( settings: event.payload.settings, }, latestEvent: event, + source: "live", })); } } @@ -68,6 +83,127 @@ export function projectServerConfig( return [next, Option.toArray(next)]; } +const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ + version: 1, + type: "snapshot", + config, +}); + +/** + * Keeps a complete server configuration available during reconnects. Server + * config carries the provider/model catalogue used by task creation, so it is + * useful—and safe—to retain after a transport session ends. + */ +export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( + function* () { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const cachedConfig = yield* cache.loadServerConfig(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached server configuration.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.as(Option.none()), + ), + ), + ); + const state = yield* SubscriptionRef.make>( + Option.map(cachedConfig, (config) => ({ + config, + latestEvent: cachedConfigSnapshotEvent(config), + source: "cache" as const, + })), + ); + const persistence = yield* Queue.sliding(1); + const pendingPersistence = yield* Ref.make>(Option.none()); + + const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( + config: ServerConfig, + ) { + return yield* cache.saveServerConfig(environmentId, config).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("Could not persist cached server configuration.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + Effect.as(false), + ), + ), + ); + }); + + const persistPending = Effect.fn("EnvironmentServerConfigState.persistPending")(function* ( + config: ServerConfig, + ) { + if (!(yield* persist(config))) { + return; + } + yield* Ref.update(pendingPersistence, (pending) => + Option.isSome(pending) && pending.value === config ? Option.none() : pending, + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persistPending), + Effect.forkScoped, + ); + + yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe( + Stream.runForEach((event) => + Effect.gen(function* () { + const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event); + if (Option.isNone(next)) { + return; + } + yield* Ref.set(pendingPersistence, Option.some(next.value.config)); + yield* SubscriptionRef.set(state, next); + yield* Queue.offer(persistence, next.value.config); + }), + ), + Effect.forkScoped, + ); + + yield* Effect.addFinalizer(() => + Ref.get(pendingPersistence).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (config) => persist(config).pipe(Effect.asVoid), + }), + ), + ), + ); + + return state; + }, +); + +export function serverConfigStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentServerConfigState().pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe( + Stream.filterMap((projection) => + Option.match(projection, { + onNone: () => Result.failVoid, + onSome: (value) => Result.succeed(value), + }), + ), + ), + ), + ), + ), + ); +} + export function projectServerWelcome( current: Option.Option, event: { @@ -85,8 +221,16 @@ export function projectServerWelcome( return [Option.some(welcome), [welcome]]; } +export function resolveServerConfigValue( + projection: ServerConfigProjection | null, + initialConfig: ServerConfig | null, +): ServerConfig | null { + if (projection?.source === "live") return projection.config; + return initialConfig ?? projection?.config ?? null; +} + export function createServerEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, options: { readonly initialConfigValueAtom: ( environmentId: EnvironmentId, @@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms( mode: "serial" as const, key: ({ environmentId }: { readonly environmentId: string }) => environmentId, }; - const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:server:config-projection", - tag: WS_METHODS.subscribeServerConfig, - transform: (stream) => - stream.pipe(Stream.mapAccum(Option.none, projectServerConfig)), - }); + const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => + runtime + .atom(serverConfigStateChanges(environmentId)) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), + ), + ); + const configProjection = (target: { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; + }) => configProjectionFamily(target.environmentId); const emptyConfigAtom = Atom.make(null).pipe( Atom.withLabel("environment-data:server:config:empty"), ); @@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms( const projection = Option.getOrNull( AsyncResult.value(get(configProjection({ environmentId, input: {} }))), ); - return projection?.config ?? get(options.initialConfigValueAtom(environmentId)); + return resolveServerConfigValue( + projection, + get(options.initialConfigValueAtom(environmentId)), + ); }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); }); const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index de240d58bbc..b6eb4e9f710 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -81,6 +81,10 @@ describe("environment shell synchronization", () => { loadThread: () => Effect.succeed(Option.none()), saveThread: () => Effect.void, removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, clear: () => Effect.void, }); // Cold cache with no HTTP snapshot available → falls back to the @@ -171,6 +175,10 @@ describe("environment shell synchronization", () => { loadThread: () => Effect.succeed(Option.none()), saveThread: () => Effect.void, removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, clear: () => Effect.void, }); const snapshotLoader = ShellSnapshotLoader.of({ diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index b4548995d1f..8e982723d22 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -170,6 +170,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Ref.update(savedThreads, (current) => [...current, thread]), removeThread: (_environmentId, threadId) => Ref.update(removedThreads, (current) => [...current, threadId]), + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, clear: () => Effect.void, }); const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts new file mode 100644 index 00000000000..60a128a5a75 --- /dev/null +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -0,0 +1,232 @@ +import { EnvironmentId, WS_METHODS, type VcsListRefsResult } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import * as Persistence from "../platform/persistence.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { makeCachedVcsRefsChanges } from "./vcs.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const CONNECTED_CONNECTION_STATE: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, +}; + +const CACHED_REFS: VcsListRefsResult = { + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, +}; + +const LIVE_REFS: VcsListRefsResult = { + ...CACHED_REFS, + refs: [ + { + name: "release", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + ], +}; + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +function cacheWithRefs(refs: Option.Option) { + return Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(refs), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); +} + +describe("cached VCS refs", () => { + it.effect("loads an unfiltered branch list without a connection", () => + Effect.scoped( + Effect.gen(function* () { + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.none()), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const refs = yield* Stream.unwrap( + makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + Persistence.EnvironmentCacheStore, + cacheWithRefs(Option.some(CACHED_REFS)), + ), + ), + ).pipe(Stream.runHead); + + expect(Option.getOrThrow(refs)).toEqual(CACHED_REFS); + }), + ), + ); + + it.effect("continues polling after a transient live failure", () => + Effect.scoped( + Effect.gen(function* () { + const expectedError = new Error("Could not list Git refs."); + const calls = yield* Ref.make(0); + const client = { + [WS_METHODS.vcsListRefs]: () => + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 1 ? Effect.fail(expectedError) : Effect.succeed(LIVE_REFS), + ), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + const result = Stream.unwrap( + makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + Persistence.EnvironmentCacheStore, + cacheWithRefs(Option.some(CACHED_REFS)), + ), + ), + ).pipe(Stream.runHead); + const fiber = yield* Effect.forkChild(result); + + for (let attempt = 0; attempt < 100 && (yield* Ref.get(calls)) < 1; attempt += 1) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(calls)).toBe(1); + + yield* TestClock.adjust("5 seconds"); + expect(Option.getOrThrow(yield* Fiber.join(fiber))).toEqual(LIVE_REFS); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("revalidates connected refs every five seconds", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const client = { + [WS_METHODS.vcsListRefs]: () => + Ref.updateAndGet(calls, (count) => count + 1).pipe( + Effect.map((count) => (count === 1 ? CACHED_REFS : LIVE_REFS)), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const results = Stream.unwrap( + makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ).pipe(Stream.take(2), Stream.runCollect); + const fiber = yield* Effect.forkChild(results); + + for (let attempt = 0; attempt < 100 && (yield* Ref.get(calls)) < 1; attempt += 1) { + yield* Effect.yieldNow; + } + expect(yield* Ref.get(calls)).toBe(1); + + yield* TestClock.adjust("5 seconds"); + expect(Array.from(yield* Fiber.join(fiber))).toEqual([CACHED_REFS, LIVE_REFS]); + }).pipe(Effect.provide(TestClock.layer())), + ), + ); + + it.effect("does not emit persisted refs before a live refresh", () => + Effect.scoped( + Effect.gen(function* () { + const client = { + [WS_METHODS.vcsListRefs]: () => Effect.succeed(LIVE_REFS), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + const refs = yield* Stream.unwrap( + makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService( + Persistence.EnvironmentCacheStore, + cacheWithRefs(Option.some(CACHED_REFS)), + ), + ), + ).pipe(Stream.runHead); + + expect(Option.getOrThrow(refs)).toEqual(LIVE_REFS); + }), + ), + ); +}); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 846d0d50609..4ef94c2619f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -1,26 +1,168 @@ -import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts"; +import { + type EnvironmentId, + type VcsListRefsInput, + type VcsListRefsResult, + type VcsStatusResult, + WS_METHODS, +} from "@t3tools/contracts"; import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import { Atom } from "effect/unstable/reactivity"; -import { - createEnvironmentRpcCommand, - createEnvironmentRpcQueryAtomFamily, - createEnvironmentSubscriptionAtomFamily, -} from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { safeErrorLogAttributes } from "../errors/safeLog.ts"; +import { EnvironmentCacheStore } from "../platform/persistence.ts"; +import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; +import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; +const OFFLINE_BRANCH_LIST_LIMIT = 100; +const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; + +function canUseVcsRefsCache(input: VcsListRefsInput): boolean { + return ( + input.query === undefined && + input.cursor === undefined && + input.includeMatchingRemoteRefs === undefined && + input.refKind === undefined && + input.limit === OFFLINE_BRANCH_LIST_LIMIT + ); +} + +/** + * Retains the last unfiltered branch-list response for the new-task picker. + * Filtered or paginated lists intentionally stay live-only: treating a + * partial result as a complete offline list would make branch selection + * misleading. + */ +export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChanges")(function* ( + input: VcsListRefsInput, +) { + const supervisor = yield* EnvironmentSupervisor; + const cache = yield* EnvironmentCacheStore; + const environmentId = supervisor.target.environmentId; + const useCache = canUseVcsRefsCache(input); + const cached = useCache + ? yield* cache.loadVcsRefs(environmentId, input.cwd).pipe( + Effect.catch((error) => + Effect.logWarning("Could not load cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(Option.none()), + ), + ), + ) + : Option.none(); + const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { + const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + ); + if (useCache) { + yield* cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist cached Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + } + return refs; + }); + + const cachedRefs = Stream.fromEffect( + SubscriptionRef.get(supervisor.state).pipe( + Effect.flatMap((connection) => + connection.phase === "connected" + ? Effect.succeed(Option.none()) + : Effect.succeed(cached), + ), + ), + ).pipe( + Stream.filterMap((refs) => + Option.match(refs, { + onNone: () => Result.failVoid, + onSome: Result.succeed, + }), + ), + ); + const refreshedRefs = Stream.concat( + Stream.fromEffect(SubscriptionRef.get(supervisor.state)), + SubscriptionRef.changes(supervisor.state), + ).pipe( + Stream.map((connection) => (connection.phase === "connected" ? connection.generation : null)), + Stream.changes, + Stream.switchMap((generation) => + generation === null + ? Stream.empty + : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( + Stream.mapEffect( + () => + refresh().pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.logWarning("Could not refresh Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + Effect.as(Option.none()), + ), + ), + ), + { concurrency: 1 }, + ), + Stream.filterMap((refs) => + Option.match(refs, { + onNone: () => Result.failVoid, + onSome: Result.succeed, + }), + ), + ), + ), + ); + + return Stream.concat(cachedRefs, refreshedRefs); +}); + +export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { + return followStreamInEnvironment(environmentId, Stream.unwrap(makeCachedVcsRefsChanges(input))); +} + export function createVcsEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, ) { - return { - listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:vcs:list-refs", - tag: WS_METHODS.vcsListRefs, - staleTimeMs: 5_000, + const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) => + Atom.family((inputKey: string) => { + const input = JSON.parse(inputKey) as VcsListRefsInput; + return runtime + .atom(cachedVcsRefsChanges(environmentId, input)) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), + ); }), + ); + const listRefs = (target: { + readonly environmentId: EnvironmentId; + readonly input: VcsListRefsInput; + }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input)); + + return { + listRefs, status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", subscribe: (input: EnvironmentRpcInput) => diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57dcc9e3be4..d53bf5cd70e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -327,6 +327,9 @@ importers: expo-splash-screen: specifier: ~56.0.10 version: 56.0.10(expo@56.0.12)(typescript@6.0.3) + expo-sqlite: + specifier: ~56.0.5 + version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-symbols: specifier: ~56.0.6 version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -662,10 +665,10 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab) + version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) drizzle-orm: specifier: 1.0.0-rc.3 - version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) @@ -5445,6 +5448,9 @@ packages: resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + await-lock@2.2.2: + resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} + aws-ssl-profiles@1.1.2: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} @@ -6661,6 +6667,13 @@ packages: peerDependencies: expo: '*' + expo-sqlite@56.0.5: + resolution: {integrity: sha512-wHYRVLS5nUFEtli45wHaO+RjlRY8sQXyOSgENVk6I4zq7+FgySqjOk3YOYW6IKIMwhj5XzjMJO+pY8xKUy73Kw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-structured-headers@56.0.0: resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} @@ -12056,6 +12069,83 @@ snapshots: - typescript - utf-8-validate + '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6)': + dependencies: + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@expo/devcert': 1.2.1 + '@expo/env': 2.3.0 + '@expo/image-utils': 0.10.1(typescript@6.0.3) + '@expo/inline-modules': 0.0.12(typescript@6.0.3) + '@expo/json-file': 10.2.0 + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/metro-file-map': 56.0.3 + '@expo/osascript': 2.6.0 + '@expo/package-manager': 1.12.1 + '@expo/plist': 0.7.0 + '@expo/prebuild-config': 56.0.16(typescript@6.0.3) + '@expo/require-utils': 56.1.3(typescript@6.0.3) + '@expo/router-server': 56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@expo/schema-utils': 56.0.1 + '@expo/spawn-async': 1.8.0 + '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@expo/xcpretty': 4.4.4 + '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) + accepts: 1.3.8 + arg: 5.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1 + connect: 3.7.0 + debug: 4.4.3 + dnssd-advertise: 1.1.4 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-server: 56.0.5 + fetch-nodeshim: 0.4.10 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + multitars: 1.0.0 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.4 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + resolve-from: 5.0.0 + semver: 7.8.5 + send: 0.19.2 + slugify: 1.6.9 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + terminal-link: 2.1.1 + toqr: 0.1.1 + wrap-ansi: 7.0.0 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + zod: 3.25.76 + optionalDependencies: + expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@expo/dom-webview' + - '@expo/metro-runtime' + - bufferutil + - expo-constants + - expo-font + - react + - react-dom + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + optional: true + '@expo/code-signing-certificates@0.0.6': dependencies: node-forge: 1.4.0 @@ -12132,12 +12222,27 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + chalk: 4.1.2 + optionalDependencies: + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + '@expo/env@2.3.0': dependencies: chalk: 4.1.2 @@ -12206,6 +12311,16 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 + '@expo/log-box@56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + anser: 1.4.10 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + stacktrace-parser: 0.1.11 + optional: true + '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@babel/code-frame': 7.29.7 @@ -12263,6 +12378,20 @@ snapshots: optionalDependencies: react-dom: 19.2.3(react@19.2.3) + '@expo/metro-runtime@56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + anser: 1.4.10 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + pretty-format: 29.7.0 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + stacktrace-parser: 0.1.11 + whatwg-fetch: 3.6.20 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + optional: true + '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -12344,6 +12473,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + debug: 4.4.3 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-server: 56.0.5 + react: 19.2.6 + optionalDependencies: + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - supports-color + optional: true + '@expo/schema-utils@56.0.1': {} '@expo/sdk-runtime-versions@1.0.0': {} @@ -12354,6 +12499,23 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} + '@expo/ui@56.0.18(32843e0c0883df8bccfa0b8323659df5)': + dependencies: + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + sf-symbols-typescript: 2.2.0 + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + optionalDependencies: + '@babel/core': 7.29.7 + react-dom: 19.2.6(react@19.2.6) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + optional: true + '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)': dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -13286,18 +13448,45 @@ snapshots: '@types/react-dom': 19.2.3(@types/react@19.2.16) optional: true + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13320,6 +13509,29 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 @@ -13327,6 +13539,13 @@ snapshots: '@types/react': 19.2.16 optional: true + '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13340,12 +13559,33 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) @@ -13357,6 +13597,18 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) @@ -13364,6 +13616,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -13374,6 +13634,17 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) @@ -13384,6 +13655,17 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3) @@ -13393,6 +13675,16 @@ snapshots: '@types/react': 19.2.16 '@types/react-dom': 19.2.3(@types/react@19.2.16) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13411,6 +13703,24 @@ snapshots: '@types/react-dom': 19.2.3(@types/react@19.2.16) optional: true + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) @@ -13418,6 +13728,14 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) @@ -13426,6 +13744,14 @@ snapshots: '@types/react': 19.2.16 optional: true + '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.6)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -13443,12 +13769,36 @@ snapshots: '@types/react-dom': 19.2.3(@types/react@19.2.16) optional: true + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + '@types/react-dom': 19.2.3(@types/react@19.2.16) + optional: true + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)': dependencies: '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3) @@ -13457,12 +13807,29 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 - '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.3)': + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.6)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) - react: 19.2.3 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 optionalDependencies: '@types/react': 19.2.16 + optional: true + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.3)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) + react: 19.2.3 + optionalDependencies: + '@types/react': 19.2.16 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: @@ -13471,12 +13838,27 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.6)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6) + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)': dependencies: react: 19.2.3 optionalDependencies: '@types/react': 19.2.16 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.6)': + dependencies: + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@react-grab/cli@0.1.44': dependencies: agent-install: 0.0.5 @@ -13494,6 +13876,12 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optional: true + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 @@ -13638,6 +14026,16 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)': + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optionalDependencies: + '@types/react': 19.2.16 + optional: true + '@react-navigation/core@7.21.2(react@19.2.3)': dependencies: '@react-navigation/routers': 7.6.0 @@ -14797,7 +15195,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab): + alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -14840,7 +15238,7 @@ snapshots: '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) drizzle-kit: 1.0.0-rc.3 - drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: @@ -15083,6 +15481,8 @@ snapshots: auto-bind@5.0.1: {} + await-lock@2.2.2: {} + aws-ssl-profiles@1.1.2: {} aws4@1.13.2: {} @@ -15856,13 +16256,14 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) bun-types: 1.3.14 effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 zod: 4.4.3 @@ -16142,6 +16543,18 @@ snapshots: - supports-color - typescript + expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3): + dependencies: + '@expo/image-utils': 0.10.1(typescript@6.0.3) + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - supports-color + - typescript + optional: true + expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.12) @@ -16186,6 +16599,15 @@ snapshots: transitivePeerDependencies: - supports-color + expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): + dependencies: + '@expo/env': 2.3.0 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - supports-color + optional: true + expo-crypto@56.0.4(expo@56.0.12): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16226,6 +16648,12 @@ snapshots: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): + dependencies: + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16233,12 +16661,27 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + fontfaceobserver: 2.3.0 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + expo-haptics@56.0.3(expo@56.0.12): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16259,6 +16702,12 @@ snapshots: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) react: 19.2.3 + expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.6): + dependencies: + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react: 19.2.6 + optional: true + expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -16269,6 +16718,17 @@ snapshots: - expo - supports-color + expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - expo + - supports-color + optional: true + expo-manifests@56.0.4(expo@56.0.12): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16294,10 +16754,26 @@ snapshots: optionalDependencies: react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + '@expo/expo-modules-macros-plugin': 0.2.2 + expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optionalDependencies: + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + optional: true + expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)): + dependencies: + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + expo-network@56.0.5(expo@56.0.12)(react@19.2.3): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16374,6 +16850,57 @@ snapshots: - supports-color optional: true + expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f): + dependencies: + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/schema-utils': 56.0.1 + '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5) + '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + client-only: 0.0.1 + color: 4.2.3 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-server: 56.0.5 + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + fast-deep-equal: 3.1.3 + invariant: 2.2.4 + nanoid: 3.3.12 + query-string: 7.1.3 + react: 19.2.6 + react-fast-compare: 3.2.2 + react-is: 19.2.7 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + server-only: 0.0.1 + sf-symbols-typescript: 2.2.0 + shallowequal: 1.1.0 + standard-navigation: 0.0.5 + vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - '@testing-library/dom' + - '@types/react' + - '@types/react-dom' + - expo-font + - react-native-worklets + - supports-color + optional: true + expo-secure-store@56.0.4(expo@56.0.12): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16390,6 +16917,21 @@ snapshots: - supports-color - typescript + expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + dependencies: + await-lock: 2.2.2 + expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + react: 19.2.3 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + + expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + await-lock: 2.2.2 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + expo-structured-headers@56.0.0: {} expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): @@ -16401,6 +16943,16 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 + expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + '@expo-google-fonts/material-symbols': 0.4.38 + expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + sf-symbols-typescript: 2.2.0 + optional: true + expo-updates-interface@56.0.2(expo@56.0.12): dependencies: expo: 56.0.12(8895228379997a2a064f9644cda56ed0) @@ -16492,6 +17044,49 @@ snapshots: - typescript - utf-8-validate + expo@56.0.12(deb8cbf3e0f411b34ba85a995c9982ba): + dependencies: + '@babel/runtime': 7.29.7 + '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/config': 56.0.9(typescript@6.0.3) + '@expo/config-plugins': 56.0.9(typescript@6.0.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/fingerprint': 0.19.4 + '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@ungap/structured-clone': 1.3.1 + babel-preset-expo: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) + expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)(typescript@6.0.3) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.6) + expo-modules-autolinking: 56.0.16(typescript@6.0.3) + expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + pretty-format: 29.7.0 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-refresh: 0.14.2 + whatwg-url-minimum: 0.1.2 + optionalDependencies: + '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-dom: 19.2.6(react@19.2.6) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - expo-widgets + - react-native-worklets + - react-server-dom-webpack + - supports-color + - typescript + - utf-8-validate + optional: true + exponential-backoff@3.1.3: {} express-rate-limit@8.5.2(express@5.2.1): @@ -18878,6 +19473,11 @@ snapshots: dependencies: react: 19.2.3 + react-freeze@1.0.4(react@19.2.6): + dependencies: + react: 19.2.6 + optional: true + react-grab@0.1.44(react@19.2.6): dependencies: '@react-grab/cli': 0.1.44 @@ -18921,6 +19521,16 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.3) optional: true + react-native-drawer-layout@4.2.4(de9b2f2dc96a3557fdc0df187a8417ee): + dependencies: + color: 4.2.3 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + use-latest-callback: 0.2.6(react@19.2.6) + optional: true + react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 @@ -18930,6 +19540,16 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + '@egjs/hammerjs': 2.0.17 + '@types/react-test-renderer': 19.1.0 + hoist-non-react-statics: 3.3.2 + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 @@ -18940,6 +19560,12 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 @@ -18968,11 +19594,26 @@ snapshots: react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.5 + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + semver: 7.8.5 + optional: true + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 @@ -18980,6 +19621,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 + react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-freeze: 1.0.4(react@19.2.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + warn-once: 0.1.1 + optional: true + react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.3.0 @@ -19007,6 +19656,14 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + optional: true + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 @@ -19027,6 +19684,27 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + convert-source-map: 2.0.0 + react: 19.2.6 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + optional: true + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 @@ -19072,6 +19750,52 @@ snapshots: - supports-color - utf-8-validate + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6): + dependencies: + '@react-native/assets-registry': 0.85.3 + '@react-native/codegen': 0.85.3(@babel/core@7.29.7) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/gradle-plugin': 0.85.3 + '@react-native/js-polyfills': 0.85.3 + '@react-native/normalize-colors': 0.85.3 + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + babel-plugin-syntax-hermes-parser: 0.33.3 + base64-js: 1.5.1 + commander: 12.1.0 + flow-enums-runtime: 0.0.6 + hermes-compiler: 250829098.0.10 + invariant: 2.2.4 + memoize-one: 5.2.1 + metro-runtime: 0.84.4 + metro-source-map: 0.84.4 + nullthrows: 1.1.1 + pretty-format: 29.7.0 + promise: 8.3.0 + react: 19.2.6 + react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6) + react-refresh: 0.14.2 + regenerator-runtime: 0.13.11 + scheduler: 0.27.0 + semver: 7.8.5 + stacktrace-parser: 0.1.11 + tinyglobby: 0.2.17 + whatwg-fetch: 3.6.20 + ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6) + yargs: 17.7.2 + optionalDependencies: + '@types/react': 19.2.16 + transitivePeerDependencies: + - '@babel/core' + - '@react-native-community/cli' + - '@react-native/metro-config' + - bufferutil + - supports-color + - utf-8-validate + optional: true + react-reconciler@0.33.0(react@19.2.6): dependencies: react: 19.2.6 @@ -19087,6 +19811,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.6): + dependencies: + react: 19.2.6 + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.6) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3): dependencies: react: 19.2.3 @@ -19098,6 +19831,18 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.6): + dependencies: + react: 19.2.6 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.6) + react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.6) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.6) + use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.16 + optional: true + react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3): dependencies: get-nonce: 1.0.1 @@ -19106,6 +19851,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.6): + dependencies: + get-nonce: 1.0.1 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + react@19.2.3: {} react@19.2.6: {} @@ -20149,10 +20903,23 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.6): + dependencies: + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + use-latest-callback@0.2.6(react@19.2.3): dependencies: react: 19.2.3 + use-latest-callback@0.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + optional: true + use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3): dependencies: detect-node-es: 1.1.0 @@ -20161,6 +20928,15 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.6): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.6 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.16 + optional: true + use-sync-external-store@1.6.0(react@19.2.3): dependencies: react: 19.2.3 @@ -20197,6 +20973,16 @@ snapshots: - '@types/react' - '@types/react-dom' + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + optional: true + vfile-location@5.0.3: dependencies: '@types/unist': 3.0.3