From 8a7f7110f2550ff6487604628820b27c5a3f3d24 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 04:38:31 -0700 Subject: [PATCH 01/28] Persist offline environment metadata --- apps/mobile/src/connection/storage.ts | 207 +++++++++++++++++- apps/web/src/connection/storage.ts | 119 +++++++++- .../src/connection/registry.test.ts | 4 + .../src/platform/persistence.ts | 30 +++ .../client-runtime/src/state/server.test.ts | 104 ++++++++- packages/client-runtime/src/state/server.ts | 134 +++++++++++- .../src/state/shell-sync.test.ts | 8 + .../src/state/threads-sync.test.ts | 4 + packages/client-runtime/src/state/vcs.test.ts | 73 ++++++ packages/client-runtime/src/state/vcs.ts | 155 +++++++++++-- 10 files changed, 803 insertions(+), 35 deletions(-) create mode 100644 packages/client-runtime/src/state/vcs.test.ts diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 73f4307fb23..4eb687c8000 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -18,7 +18,9 @@ import { EnvironmentId, OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, + ServerConfig, ThreadId, + VcsListRefsResult, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -37,6 +39,10 @@ const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots"; // 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 SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; +const SERVER_CONFIG_CACHE_DIRECTORY = "connection-server-configs"; +const VCS_REFS_CACHE_SCHEMA_VERSION = 1; +const VCS_REFS_CACHE_DIRECTORY = "connection-vcs-refs"; const StoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), @@ -51,6 +57,19 @@ const StoredThreadSnapshot = Schema.Struct({ snapshot: OrchestrationThreadDetailSnapshot, }); +const StoredServerConfig = Schema.Struct({ + schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + config: ServerConfig, +}); + +const StoredVcsRefs = Schema.Struct({ + schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), + environmentId: EnvironmentId, + cwd: Schema.String, + refs: VcsListRefsResult, +}); + const LegacyStoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(1), environmentId: EnvironmentId, @@ -58,6 +77,16 @@ const LegacyStoredShellSnapshot = Schema.Struct({ snapshot: OrchestrationShellSnapshot, }); +const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot); +const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot); +const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot); +const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot); +const decodeStoredServerConfig = Schema.decodeUnknownResult(StoredServerConfig); +const encodeStoredServerConfig = Schema.encodeUnknownResult(StoredServerConfig); +const decodeStoredVcsRefs = Schema.decodeUnknownResult(StoredVcsRefs); +const encodeStoredVcsRefs = Schema.encodeUnknownResult(StoredVcsRefs); +const decodeLegacyStoredShellSnapshot = Schema.decodeUnknownResult(LegacyStoredShellSnapshot); + function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ reason: "remote-unavailable", @@ -72,6 +101,10 @@ function shellPersistenceError( | "load-thread" | "save-thread" | "remove-thread" + | "load-server-config" + | "save-server-config" + | "load-vcs-refs" + | "save-vcs-refs" | "clear-environment", cause: unknown, ) { @@ -85,6 +118,14 @@ function threadSnapshotFileName(threadId: ThreadId): string { return `${encodeURIComponent(threadId)}.json`; } +function environmentCacheFileName(environmentId: EnvironmentId): string { + return `${encodeURIComponent(environmentId)}.json`; +} + +function vcsRefsFileName(cwd: string): string { + return `${encodeURIComponent(cwd)}.json`; +} + const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( function* ( environmentId: EnvironmentId, @@ -149,9 +190,54 @@ const secureCatalogStorage: SecureCatalogStorage = { }; function shellSnapshotFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.json`; + return environmentCacheFileName(environmentId); } +const serverConfigFile = Effect.fn("mobile.connectionStorage.serverConfigFile")(function* ( + environmentId: EnvironmentId, + operation: "load-server-config" | "save-server-config" | "clear-environment", +) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, File, Paths } = await import("expo-file-system"); + const directory = new Directory(Paths.document, SERVER_CONFIG_CACHE_DIRECTORY); + directory.create({ idempotent: true, intermediates: true }); + return new File(directory, environmentCacheFileName(environmentId)); + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); +}); + +const vcsRefsDirectory = Effect.fn("mobile.connectionStorage.vcsRefsDirectory")(function* ( + environmentId: EnvironmentId, + operation: "load-vcs-refs" | "save-vcs-refs" | "clear-environment", +) { + return yield* Effect.tryPromise({ + try: async () => { + const { Directory, Paths } = await import("expo-file-system"); + const directory = new Directory( + Paths.document, + VCS_REFS_CACHE_DIRECTORY, + encodeURIComponent(environmentId), + ); + if (operation !== "clear-environment") { + directory.create({ idempotent: true, intermediates: true }); + } + return directory; + }, + catch: (cause) => shellPersistenceError(operation, cause), + }); +}); + +const vcsRefsFile = Effect.fn("mobile.connectionStorage.vcsRefsFile")(function* ( + environmentId: EnvironmentId, + cwd: string, + operation: "load-vcs-refs" | "save-vcs-refs", +) { + const { File } = yield* Effect.promise(() => import("expo-file-system")); + return new File(yield* vcsRefsDirectory(environmentId, operation), vcsRefsFileName(cwd)); +}); + const shellSnapshotFileInDirectory = Effect.fn( "mobile.connectionStorage.shellSnapshotFileInDirectory", )(function* ( @@ -292,9 +378,9 @@ export const connectionStorageLayer = Layer.effectContext( 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))); + const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe( + Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), + ); return stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(); @@ -313,7 +399,7 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("load-shell", cause), }); const legacyStored = yield* Effect.fromResult( - Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), + decodeLegacyStoredShellSnapshot(legacyParsed), ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); return legacyStored.environmentId === environmentId ? Option.some(legacyStored.snapshot) @@ -327,9 +413,9 @@ export const connectionStorageLayer = Layer.effectContext( environmentId, snapshot, } as const; - const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredShellSnapshot)(stored), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe( + Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), + ); yield* Effect.try({ try: () => { if (!file.exists) { @@ -340,6 +426,47 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("save-shell", cause), }); }), + loadServerConfig: (environmentId) => + Effect.gen(function* () { + const file = yield* serverConfigFile(environmentId, "load-server-config"); + if (!file.exists) { + return Option.none(); + } + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-server-config", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-server-config", cause), + }); + const stored = yield* Effect.fromResult(decodeStoredServerConfig(parsed)).pipe( + Effect.mapError((cause) => shellPersistenceError("load-server-config", cause)), + ); + return stored.environmentId === environmentId + ? Option.some(stored.config) + : Option.none(); + }), + saveServerConfig: (environmentId, config) => + Effect.gen(function* () { + const file = yield* serverConfigFile(environmentId, "save-server-config"); + const encoded = yield* Effect.fromResult( + encodeStoredServerConfig({ + schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, + environmentId, + config, + }), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-server-config", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-server-config", cause), + }); + }), loadThread: (environmentId, threadId) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); @@ -354,9 +481,9 @@ export const connectionStorageLayer = Layer.effectContext( 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))); + const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe( + Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), + ); return stored.environmentId === environmentId && stored.threadId === threadId ? Option.some(stored.snapshot) : Option.none(); @@ -365,7 +492,7 @@ export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread"); const encoded = yield* Effect.fromResult( - Schema.encodeUnknownResult(StoredThreadSnapshot)({ + encodeStoredThreadSnapshot({ schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, threadId: snapshot.thread.id, @@ -382,6 +509,48 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("save-thread", cause), }); }), + loadVcsRefs: (environmentId, cwd) => + Effect.gen(function* () { + const file = yield* vcsRefsFile(environmentId, cwd, "load-vcs-refs"); + if (!file.exists) { + return Option.none(); + } + const raw = yield* Effect.tryPromise({ + try: () => file.text(), + catch: (cause) => shellPersistenceError("load-vcs-refs", cause), + }); + const parsed = yield* Effect.try({ + try: () => JSON.parse(raw) as unknown, + catch: (cause) => shellPersistenceError("load-vcs-refs", cause), + }); + const stored = yield* Effect.fromResult(decodeStoredVcsRefs(parsed)).pipe( + Effect.mapError((cause) => shellPersistenceError("load-vcs-refs", cause)), + ); + return stored.environmentId === environmentId && stored.cwd === cwd + ? Option.some(stored.refs) + : Option.none(); + }), + saveVcsRefs: (environmentId, cwd, refs) => + Effect.gen(function* () { + const file = yield* vcsRefsFile(environmentId, cwd, "save-vcs-refs"); + const encoded = yield* Effect.fromResult( + encodeStoredVcsRefs({ + schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, + environmentId, + cwd, + refs, + }), + ).pipe(Effect.mapError((cause) => shellPersistenceError("save-vcs-refs", cause))); + yield* Effect.try({ + try: () => { + if (!file.exists) { + file.create({ intermediates: true, overwrite: true }); + } + file.write(JSON.stringify(encoded)); + }, + catch: (cause) => shellPersistenceError("save-vcs-refs", cause), + }); + }), removeThread: (environmentId, threadId) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); @@ -411,6 +580,13 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("clear-environment", cause), }); } + const configFile = yield* serverConfigFile(environmentId, "clear-environment"); + if (configFile.exists) { + yield* Effect.try({ + try: () => configFile.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } const threadDirectory = yield* threadSnapshotDirectory( environmentId, "clear-environment", @@ -421,6 +597,13 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("clear-environment", cause), }); } + const refsDirectory = yield* vcsRefsDirectory(environmentId, "clear-environment"); + if (refsDirectory.exists) { + yield* Effect.try({ + try: () => refsDirectory.delete(), + catch: (cause) => shellPersistenceError("clear-environment", cause), + }); + } }), }); 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..d600c736834 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -1,8 +1,31 @@ -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, +} from "./server.ts"; const CONFIG = { availableEditors: [], @@ -14,6 +37,23 @@ const CONFIG = { settings: {}, } as unknown as ServerConfig; +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 +91,64 @@ describe("server state projection", () => { expect(Option.getOrThrow(afterReady)).toBe(welcome); expect(emitted).toEqual([]); }); + + 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([]); + }), + ); }); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index eb784183793..200e2d587af 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -5,8 +5,12 @@ 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 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,6 +20,11 @@ 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; @@ -68,6 +77,111 @@ 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), + })), + ); + const persistence = yield* Queue.sliding(1); + + const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( + config: ServerConfig, + ) { + yield* cache.saveServerConfig(environmentId, config).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist cached server configuration.").pipe( + Effect.annotateLogs({ + environmentId, + ...safeErrorLogAttributes(error), + }), + ), + ), + ); + }); + + yield* Stream.fromQueue(persistence).pipe( + Stream.debounce("500 millis"), + Stream.runForEach(persist), + 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* SubscriptionRef.set(state, next); + yield* Queue.offer(persistence, next.value.config); + }), + ), + Effect.forkScoped, + ); + + yield* Effect.addFinalizer(() => + SubscriptionRef.get(state).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (projection) => persist(projection.config), + }), + ), + ), + ); + + 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: { @@ -86,7 +200,7 @@ export function projectServerWelcome( } export function createServerEnvironmentAtoms( - runtime: Atom.AtomRuntime, + runtime: Atom.AtomRuntime, options: { readonly initialConfigValueAtom: ( environmentId: EnvironmentId, @@ -98,12 +212,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"), ); 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..212882b541e --- /dev/null +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -0,0 +1,73 @@ +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 * as SubscriptionRef from "effect/SubscriptionRef"; + +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 { RpcSession } from "../rpc/session.ts"; +import { makeCachedVcsRefsState } 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 CACHED_REFS: VcsListRefsResult = { + refs: [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: "/repo", + }, + ], + isRepo: true, + hasPrimaryRemote: true, + nextCursor: null, + totalCount: 1, +}; + +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 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.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.some(CACHED_REFS)), + saveVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const state = yield* makeCachedVcsRefsState({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + ); + + expect(Option.getOrThrow(yield* SubscriptionRef.get(state))).toEqual(CACHED_REFS); + }), + ), + ); +}); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 846d0d50609..e88fab9830e 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -1,26 +1,155 @@ -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; + +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 makeCachedVcsRefsState = Effect.fn("CachedVcsRefsState.make")(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 state = yield* SubscriptionRef.make(cached); + + const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { + const refs = yield* request(WS_METHODS.vcsListRefs, input); + yield* SubscriptionRef.set(state, Option.some(refs)); + if (!useCache) { + return; + } + 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), + }), + ), + ), + ); + }); + + yield* Stream.concat( + Stream.fromEffect(SubscriptionRef.get(supervisor.state)), + SubscriptionRef.changes(supervisor.state), + ).pipe( + Stream.filterMap((connection) => + connection.phase === "connected" ? Result.succeed(connection.generation) : Result.failVoid, + ), + Stream.changes, + Stream.runForEach(() => + refresh().pipe( + Effect.catch((error) => + Effect.logWarning("Could not refresh Git refs.").pipe( + Effect.annotateLogs({ + environmentId, + cwd: input.cwd, + ...safeErrorLogAttributes(error), + }), + ), + ), + ), + ), + Effect.forkScoped, + ); + + return state; +}); + +export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeCachedVcsRefsState(input).pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe( + Stream.filterMap((result) => + Option.match(result, { + onNone: () => Result.failVoid, + onSome: (value) => Result.succeed(value), + }), + ), + ), + ), + ), + ), + ); +} + 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) => From cec0ff7fc60ad940048bf107ec4b9751f12e433c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 04:45:21 -0700 Subject: [PATCH 02/28] Refine mobile cache schema effects --- apps/mobile/src/connection/storage.ts | 74 ++++++++++++--------------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 4eb687c8000..407469f02f3 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -77,15 +77,15 @@ const LegacyStoredShellSnapshot = Schema.Struct({ snapshot: OrchestrationShellSnapshot, }); -const decodeStoredShellSnapshot = Schema.decodeUnknownResult(StoredShellSnapshot); -const encodeStoredShellSnapshot = Schema.encodeUnknownResult(StoredShellSnapshot); -const decodeStoredThreadSnapshot = Schema.decodeUnknownResult(StoredThreadSnapshot); -const encodeStoredThreadSnapshot = Schema.encodeUnknownResult(StoredThreadSnapshot); -const decodeStoredServerConfig = Schema.decodeUnknownResult(StoredServerConfig); -const encodeStoredServerConfig = Schema.encodeUnknownResult(StoredServerConfig); -const decodeStoredVcsRefs = Schema.decodeUnknownResult(StoredVcsRefs); -const encodeStoredVcsRefs = Schema.encodeUnknownResult(StoredVcsRefs); -const decodeLegacyStoredShellSnapshot = Schema.decodeUnknownResult(LegacyStoredShellSnapshot); +const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot); +const encodeStoredShellSnapshot = Schema.encodeUnknownEffect(StoredShellSnapshot); +const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshot); +const encodeStoredThreadSnapshot = Schema.encodeUnknownEffect(StoredThreadSnapshot); +const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfig); +const encodeStoredServerConfig = Schema.encodeUnknownEffect(StoredServerConfig); +const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefs); +const encodeStoredVcsRefs = Schema.encodeUnknownEffect(StoredVcsRefs); +const decodeLegacyStoredShellSnapshot = Schema.decodeUnknownEffect(LegacyStoredShellSnapshot); function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ @@ -378,7 +378,7 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(raw) as unknown, catch: (cause) => shellPersistenceError("load-shell", cause), }); - const stored = yield* Effect.fromResult(decodeStoredShellSnapshot(parsed)).pipe( + const stored = yield* decodeStoredShellSnapshot(parsed).pipe( Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), ); return stored.environmentId === environmentId @@ -398,9 +398,9 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(legacyRaw) as unknown, catch: (cause) => shellPersistenceError("load-shell", cause), }); - const legacyStored = yield* Effect.fromResult( - decodeLegacyStoredShellSnapshot(legacyParsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + const legacyStored = yield* decodeLegacyStoredShellSnapshot(legacyParsed).pipe( + Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), + ); return legacyStored.environmentId === environmentId ? Option.some(legacyStored.snapshot) : Option.none(); @@ -413,7 +413,7 @@ export const connectionStorageLayer = Layer.effectContext( environmentId, snapshot, } as const; - const encoded = yield* Effect.fromResult(encodeStoredShellSnapshot(stored)).pipe( + const encoded = yield* encodeStoredShellSnapshot(stored).pipe( Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), ); yield* Effect.try({ @@ -440,7 +440,7 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(raw) as unknown, catch: (cause) => shellPersistenceError("load-server-config", cause), }); - const stored = yield* Effect.fromResult(decodeStoredServerConfig(parsed)).pipe( + const stored = yield* decodeStoredServerConfig(parsed).pipe( Effect.mapError((cause) => shellPersistenceError("load-server-config", cause)), ); return stored.environmentId === environmentId @@ -450,13 +450,11 @@ export const connectionStorageLayer = Layer.effectContext( saveServerConfig: (environmentId, config) => Effect.gen(function* () { const file = yield* serverConfigFile(environmentId, "save-server-config"); - const encoded = yield* Effect.fromResult( - encodeStoredServerConfig({ - schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, - environmentId, - config, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-server-config", cause))); + const encoded = yield* encodeStoredServerConfig({ + schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, + environmentId, + config, + }).pipe(Effect.mapError((cause) => shellPersistenceError("save-server-config", cause))); yield* Effect.try({ try: () => { if (!file.exists) { @@ -481,7 +479,7 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(raw) as unknown, catch: (cause) => shellPersistenceError("load-thread", cause), }); - const stored = yield* Effect.fromResult(decodeStoredThreadSnapshot(parsed)).pipe( + const stored = yield* decodeStoredThreadSnapshot(parsed).pipe( Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), ); return stored.environmentId === environmentId && stored.threadId === threadId @@ -491,14 +489,12 @@ export const connectionStorageLayer = Layer.effectContext( saveThread: (environmentId, snapshot) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread"); - const encoded = yield* Effect.fromResult( - encodeStoredThreadSnapshot({ - schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, - environmentId, - threadId: snapshot.thread.id, - snapshot, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); + const encoded = yield* encodeStoredThreadSnapshot({ + 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) { @@ -523,7 +519,7 @@ export const connectionStorageLayer = Layer.effectContext( try: () => JSON.parse(raw) as unknown, catch: (cause) => shellPersistenceError("load-vcs-refs", cause), }); - const stored = yield* Effect.fromResult(decodeStoredVcsRefs(parsed)).pipe( + const stored = yield* decodeStoredVcsRefs(parsed).pipe( Effect.mapError((cause) => shellPersistenceError("load-vcs-refs", cause)), ); return stored.environmentId === environmentId && stored.cwd === cwd @@ -533,14 +529,12 @@ export const connectionStorageLayer = Layer.effectContext( saveVcsRefs: (environmentId, cwd, refs) => Effect.gen(function* () { const file = yield* vcsRefsFile(environmentId, cwd, "save-vcs-refs"); - const encoded = yield* Effect.fromResult( - encodeStoredVcsRefs({ - schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, - environmentId, - cwd, - refs, - }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-vcs-refs", cause))); + const encoded = yield* encodeStoredVcsRefs({ + schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, + environmentId, + cwd, + refs, + }).pipe(Effect.mapError((cause) => shellPersistenceError("save-vcs-refs", cause))); yield* Effect.try({ try: () => { if (!file.exists) { From 018c39f43af7c6f527eeec1f6aa021fc002b2ac1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 04:57:32 -0700 Subject: [PATCH 03/28] Use schema JSON codecs for mobile storage --- apps/mobile/src/connection/catalog-store.ts | 21 ++++--- apps/mobile/src/connection/storage.ts | 61 ++++++++------------- 2 files changed, 33 insertions(+), 49 deletions(-) diff --git a/apps/mobile/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index b5bda400670..7f99d4058b3 100644 --- a/apps/mobile/src/connection/catalog-store.ts +++ b/apps/mobile/src/connection/catalog-store.ts @@ -22,23 +22,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 { diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 407469f02f3..279f6fd5789 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -49,6 +49,7 @@ const StoredShellSnapshot = Schema.Struct({ environmentId: EnvironmentId, snapshot: OrchestrationShellSnapshot, }); +const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); const StoredThreadSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), @@ -56,12 +57,14 @@ const StoredThreadSnapshot = Schema.Struct({ threadId: ThreadId, snapshot: OrchestrationThreadDetailSnapshot, }); +const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); const StoredServerConfig = Schema.Struct({ schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), environmentId: EnvironmentId, config: ServerConfig, }); +const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig); const StoredVcsRefs = Schema.Struct({ schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), @@ -69,6 +72,7 @@ const StoredVcsRefs = Schema.Struct({ cwd: Schema.String, refs: VcsListRefsResult, }); +const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); const LegacyStoredShellSnapshot = Schema.Struct({ schemaVersion: Schema.Literal(1), @@ -76,16 +80,17 @@ const LegacyStoredShellSnapshot = Schema.Struct({ snapshotReceivedAt: Schema.String, snapshot: OrchestrationShellSnapshot, }); +const LegacyStoredShellSnapshotJson = Schema.fromJsonString(LegacyStoredShellSnapshot); -const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot); -const encodeStoredShellSnapshot = Schema.encodeUnknownEffect(StoredShellSnapshot); -const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshot); -const encodeStoredThreadSnapshot = Schema.encodeUnknownEffect(StoredThreadSnapshot); -const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfig); -const encodeStoredServerConfig = Schema.encodeUnknownEffect(StoredServerConfig); -const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefs); -const encodeStoredVcsRefs = Schema.encodeUnknownEffect(StoredVcsRefs); -const decodeLegacyStoredShellSnapshot = Schema.decodeUnknownEffect(LegacyStoredShellSnapshot); +const decodeStoredShellSnapshot = Schema.decodeEffect(StoredShellSnapshotJson); +const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); +const decodeStoredThreadSnapshot = Schema.decodeEffect(StoredThreadSnapshotJson); +const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); +const decodeStoredServerConfig = Schema.decodeEffect(StoredServerConfigJson); +const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); +const decodeStoredVcsRefs = Schema.decodeEffect(StoredVcsRefsJson); +const encodeStoredVcsRefs = Schema.encodeEffect(StoredVcsRefsJson); +const decodeLegacyStoredShellSnapshot = Schema.decodeEffect(LegacyStoredShellSnapshotJson); function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ @@ -374,11 +379,7 @@ export const connectionStorageLayer = Layer.effectContext( 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* decodeStoredShellSnapshot(parsed).pipe( + const stored = yield* decodeStoredShellSnapshot(raw).pipe( Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), ); return stored.environmentId === environmentId @@ -394,11 +395,7 @@ export const connectionStorageLayer = Layer.effectContext( 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* decodeLegacyStoredShellSnapshot(legacyParsed).pipe( + const legacyStored = yield* decodeLegacyStoredShellSnapshot(legacyRaw).pipe( Effect.mapError((cause) => shellPersistenceError("load-shell", cause)), ); return legacyStored.environmentId === environmentId @@ -421,7 +418,7 @@ export const connectionStorageLayer = Layer.effectContext( if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } - file.write(JSON.stringify(encoded)); + file.write(encoded); }, catch: (cause) => shellPersistenceError("save-shell", cause), }); @@ -436,11 +433,7 @@ export const connectionStorageLayer = Layer.effectContext( try: () => file.text(), catch: (cause) => shellPersistenceError("load-server-config", cause), }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-server-config", cause), - }); - const stored = yield* decodeStoredServerConfig(parsed).pipe( + const stored = yield* decodeStoredServerConfig(raw).pipe( Effect.mapError((cause) => shellPersistenceError("load-server-config", cause)), ); return stored.environmentId === environmentId @@ -460,7 +453,7 @@ export const connectionStorageLayer = Layer.effectContext( if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } - file.write(JSON.stringify(encoded)); + file.write(encoded); }, catch: (cause) => shellPersistenceError("save-server-config", cause), }); @@ -475,11 +468,7 @@ export const connectionStorageLayer = Layer.effectContext( 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* decodeStoredThreadSnapshot(parsed).pipe( + const stored = yield* decodeStoredThreadSnapshot(raw).pipe( Effect.mapError((cause) => shellPersistenceError("load-thread", cause)), ); return stored.environmentId === environmentId && stored.threadId === threadId @@ -500,7 +489,7 @@ export const connectionStorageLayer = Layer.effectContext( if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } - file.write(JSON.stringify(encoded)); + file.write(encoded); }, catch: (cause) => shellPersistenceError("save-thread", cause), }); @@ -515,11 +504,7 @@ export const connectionStorageLayer = Layer.effectContext( try: () => file.text(), catch: (cause) => shellPersistenceError("load-vcs-refs", cause), }); - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-vcs-refs", cause), - }); - const stored = yield* decodeStoredVcsRefs(parsed).pipe( + const stored = yield* decodeStoredVcsRefs(raw).pipe( Effect.mapError((cause) => shellPersistenceError("load-vcs-refs", cause)), ); return stored.environmentId === environmentId && stored.cwd === cwd @@ -540,7 +525,7 @@ export const connectionStorageLayer = Layer.effectContext( if (!file.exists) { file.create({ intermediates: true, overwrite: true }); } - file.write(JSON.stringify(encoded)); + file.write(encoded); }, catch: (cause) => shellPersistenceError("save-vcs-refs", cause), }); From 996034637e0c9b7d35b89703cbb2f0687e186fad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 21:20:46 -0700 Subject: [PATCH 04/28] Handle offline cache refresh failures --- apps/mobile/src/connection/storage.ts | 28 +++++- packages/client-runtime/src/state/vcs.test.ts | 97 ++++++++++++++---- packages/client-runtime/src/state/vcs.ts | 99 ++++++++++--------- 3 files changed, 155 insertions(+), 69 deletions(-) diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 279f6fd5789..c1a62a6a9f0 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -434,11 +434,31 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("load-server-config", cause), }); const stored = yield* decodeStoredServerConfig(raw).pipe( - Effect.mapError((cause) => shellPersistenceError("load-server-config", cause)), + Effect.map(Option.some), + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("Discarding corrupt cached server configuration.", { + environmentId, + error: String(error), + }); + yield* Effect.try({ + try: () => file.delete(), + catch: (cause) => shellPersistenceError("load-server-config", cause), + }).pipe( + Effect.catch((deleteError) => + Effect.logWarning("Could not delete corrupt cached server configuration.", { + environmentId, + error: String(deleteError), + }), + ), + ); + return Option.none(); + }), + ), + ); + return Option.flatMap(stored, (value) => + value.environmentId === environmentId ? Option.some(value.config) : Option.none(), ); - return stored.environmentId === environmentId - ? Option.some(stored.config) - : Option.none(); }), saveServerConfig: (environmentId, config) => Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 212882b541e..4eb5dae083e 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -1,18 +1,21 @@ -import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; +import { EnvironmentId, WS_METHODS, 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 * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; 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 { makeCachedVcsRefsState } from "./vcs.ts"; +import { makeCachedVcsRefsChanges } from "./vcs.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -21,6 +24,15 @@ const TARGET = new PrimaryConnectionTarget({ 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: [ { @@ -36,6 +48,31 @@ const CACHED_REFS: VcsListRefsResult = { totalCount: 1, }; +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( @@ -49,24 +86,46 @@ describe("cached VCS refs", () => { disconnect: Effect.void, retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); - 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.none()), - saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.some(CACHED_REFS)), - saveVcsRefs: () => Effect.void, - clear: () => Effect.void, - }); - const state = yield* makeCachedVcsRefsState({ cwd: "/repo", limit: 100 }).pipe( - Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(Persistence.EnvironmentCacheStore, cache), - ); + 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("propagates a live failure when no cached refs are available", () => + Effect.scoped( + Effect.gen(function* () { + const expectedError = new Error("Could not list Git refs."); + const client = { + [WS_METHODS.vcsListRefs]: () => Effect.fail(expectedError), + } 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 error = yield* Stream.unwrap( + makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ).pipe(Stream.runHead, Effect.flip); - expect(Option.getOrThrow(yield* SubscriptionRef.get(state))).toEqual(CACHED_REFS); + expect(error).toBe(expectedError); }), ), ); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index e88fab9830e..f62b1a99b2f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -40,7 +40,7 @@ function canUseVcsRefsCache(input: VcsListRefsInput): boolean { * partial result as a complete offline list would make branch selection * misleading. */ -export const makeCachedVcsRefsState = Effect.fn("CachedVcsRefsState.make")(function* ( +export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChanges")(function* ( input: VcsListRefsInput, ) { const supervisor = yield* EnvironmentSupervisor; @@ -64,35 +64,30 @@ export const makeCachedVcsRefsState = Effect.fn("CachedVcsRefsState.make")(funct const state = yield* SubscriptionRef.make(cached); const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { - const refs = yield* request(WS_METHODS.vcsListRefs, input); + const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + ); yield* SubscriptionRef.set(state, Option.some(refs)); - if (!useCache) { - return; - } - 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), - }), + 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; }); - yield* Stream.concat( - Stream.fromEffect(SubscriptionRef.get(supervisor.state)), - SubscriptionRef.changes(supervisor.state), - ).pipe( - Stream.filterMap((connection) => - connection.phase === "connected" ? Result.succeed(connection.generation) : Result.failVoid, - ), - Stream.changes, - Stream.runForEach(() => - refresh().pipe( - Effect.catch((error) => + const refreshOrReuseKnownRefs = Effect.fn("CachedVcsRefsState.refreshOrReuseKnownRefs")( + function* () { + return yield* refresh().pipe( + Effect.tapError((error) => Effect.logWarning("Could not refresh Git refs.").pipe( Effect.annotateLogs({ environmentId, @@ -101,32 +96,44 @@ export const makeCachedVcsRefsState = Effect.fn("CachedVcsRefsState.make")(funct }), ), ), - ), - ), - Effect.forkScoped, - ); - - return state; -}); - -export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { - return followStreamInEnvironment( - environmentId, - Stream.unwrap( - makeCachedVcsRefsState(input).pipe( - Effect.map((state) => - SubscriptionRef.changes(state).pipe( - Stream.filterMap((result) => - Option.match(result, { - onNone: () => Result.failVoid, - onSome: (value) => Result.succeed(value), + Effect.catch((error) => + SubscriptionRef.get(state).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(error), + onSome: Effect.succeed, }), ), ), ), - ), + ); + }, + ); + + const cachedRefs = Stream.fromEffect(SubscriptionRef.get(state)).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.filterMap((connection) => + connection.phase === "connected" ? Result.succeed(connection.generation) : Result.failVoid, ), + Stream.changes, + Stream.mapEffect(refreshOrReuseKnownRefs, { concurrency: 1 }), ); + + return Stream.concat(cachedRefs, refreshedRefs); +}); + +export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { + return followStreamInEnvironment(environmentId, Stream.unwrap(makeCachedVcsRefsChanges(input))); } export function createVcsEnvironmentAtoms( From a2a34c3d42bab875cefb36c305e1da487efabf45 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 21:52:52 -0700 Subject: [PATCH 05/28] Avoid stale VCS cache fallback --- packages/client-runtime/src/state/vcs.test.ts | 41 +++++++++++++++++++ packages/client-runtime/src/state/vcs.ts | 30 +++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 4eb5dae083e..c0f501827a1 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -48,6 +48,16 @@ const CACHED_REFS: VcsListRefsResult = { totalCount: 1, }; +const LIVE_REFS: VcsListRefsResult = { + ...CACHED_REFS, + refs: [ + { + ...CACHED_REFS.refs[0], + name: "release", + }, + ], +}; + function session(client: WsRpcProtocolClient): RpcSession { return { client, @@ -129,4 +139,35 @@ describe("cached VCS refs", () => { }), ), ); + + 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 f62b1a99b2f..1135607583d 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -67,8 +67,8 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( Effect.provideService(EnvironmentSupervisor, supervisor), ); - yield* SubscriptionRef.set(state, Option.some(refs)); if (useCache) { + yield* SubscriptionRef.set(state, Option.some(refs)); yield* cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( Effect.catch((error) => Effect.logWarning("Could not persist cached Git refs.").pipe( @@ -97,20 +97,30 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange ), ), Effect.catch((error) => - SubscriptionRef.get(state).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(error), - onSome: Effect.succeed, - }), - ), - ), + useCache + ? SubscriptionRef.get(state).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(error), + onSome: Effect.succeed, + }), + ), + ) + : Effect.fail(error), ), ); }, ); - const cachedRefs = Stream.fromEffect(SubscriptionRef.get(state)).pipe( + const cachedRefs = Stream.fromEffect( + SubscriptionRef.get(supervisor.state).pipe( + Effect.flatMap((connection) => + connection.phase === "connected" + ? Effect.succeed(Option.none()) + : SubscriptionRef.get(state), + ), + ), + ).pipe( Stream.filterMap((refs) => Option.match(refs, { onNone: () => Result.failVoid, From c676a65b2123e6ef10d82350da4246bd8d0a474e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 22:01:14 -0700 Subject: [PATCH 06/28] Fix live VCS refs test fixture --- packages/client-runtime/src/state/vcs.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index c0f501827a1..0092dd2605d 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -52,8 +52,10 @@ const LIVE_REFS: VcsListRefsResult = { ...CACHED_REFS, refs: [ { - ...CACHED_REFS.refs[0], name: "release", + current: true, + isDefault: true, + worktreePath: "/repo", }, ], }; From 779b2bcedbcc468d073166a563b912fc6979efd6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 8 Jul 2026 22:11:19 -0700 Subject: [PATCH 07/28] Fix VCS ref refresh behavior --- packages/client-runtime/src/state/vcs.test.ts | 48 ++++++++++++++++++- packages/client-runtime/src/state/vcs.ts | 46 ++++-------------- 2 files changed, 56 insertions(+), 38 deletions(-) diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index 0092dd2605d..b8f20f61b98 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -1,9 +1,12 @@ 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, @@ -113,7 +116,7 @@ describe("cached VCS refs", () => { ), ); - it.effect("propagates a live failure when no cached refs are available", () => + it.effect("propagates a live failure instead of reusing cached refs", () => Effect.scoped( Effect.gen(function* () { const expectedError = new Error("Could not list Git refs."); @@ -133,7 +136,10 @@ describe("cached VCS refs", () => { const error = yield* Stream.unwrap( makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), - Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + Effect.provideService( + Persistence.EnvironmentCacheStore, + cacheWithRefs(Option.some(CACHED_REFS)), + ), ), ).pipe(Stream.runHead, Effect.flip); @@ -142,6 +148,44 @@ describe("cached VCS refs", () => { ), ); + 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* () { diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 1135607583d..2f395236aa0 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -23,6 +23,7 @@ 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 ( @@ -61,14 +62,11 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange ), ) : Option.none(); - const state = yield* SubscriptionRef.make(cached); - const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () { const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe( Effect.provideService(EnvironmentSupervisor, supervisor), ); if (useCache) { - yield* SubscriptionRef.set(state, Option.some(refs)); yield* cache.saveVcsRefs(environmentId, input.cwd, refs).pipe( Effect.catch((error) => Effect.logWarning("Could not persist cached Git refs.").pipe( @@ -84,40 +82,12 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange return refs; }); - const refreshOrReuseKnownRefs = Effect.fn("CachedVcsRefsState.refreshOrReuseKnownRefs")( - function* () { - return yield* refresh().pipe( - Effect.tapError((error) => - Effect.logWarning("Could not refresh Git refs.").pipe( - Effect.annotateLogs({ - environmentId, - cwd: input.cwd, - ...safeErrorLogAttributes(error), - }), - ), - ), - Effect.catch((error) => - useCache - ? SubscriptionRef.get(state).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.fail(error), - onSome: Effect.succeed, - }), - ), - ) - : Effect.fail(error), - ), - ); - }, - ); - const cachedRefs = Stream.fromEffect( SubscriptionRef.get(supervisor.state).pipe( Effect.flatMap((connection) => connection.phase === "connected" ? Effect.succeed(Option.none()) - : SubscriptionRef.get(state), + : Effect.succeed(cached), ), ), ).pipe( @@ -132,11 +102,15 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange Stream.fromEffect(SubscriptionRef.get(supervisor.state)), SubscriptionRef.changes(supervisor.state), ).pipe( - Stream.filterMap((connection) => - connection.phase === "connected" ? Result.succeed(connection.generation) : Result.failVoid, - ), + Stream.map((connection) => (connection.phase === "connected" ? connection.generation : null)), Stream.changes, - Stream.mapEffect(refreshOrReuseKnownRefs, { concurrency: 1 }), + Stream.switchMap((generation) => + generation === null + ? Stream.empty + : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( + Stream.mapEffect(refresh, { concurrency: 1 }), + ), + ), ); return Stream.concat(cachedRefs, refreshedRefs); From 1578199acc8e2dcc9e182b7123785e7392f9117b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 07:22:28 +0200 Subject: [PATCH 08/28] Centralize persisted mobile preferences Move home collapse persistence and shared mobile preference state onto the persistence-focused branch. Model preference storage as an Effect service and expose hydration through keep-alive atoms. Co-authored-by: codex --- apps/mobile/src/features/home/HomeScreen.tsx | 49 +++++-- .../AppearancePreferencesProvider.tsx | 74 ++++------ apps/mobile/src/lib/storage.ts | 8 ++ apps/mobile/src/state/preferences.test.ts | 127 ++++++++++++++++++ apps/mobile/src/state/preferences.ts | 88 ++++++++++++ 5 files changed, 285 insertions(+), 61 deletions(-) create mode 100644 apps/mobile/src/state/preferences.test.ts create mode 100644 apps/mobile/src/state/preferences.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d3b069176fd..d8ab9b5e6f5 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,45 @@ 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 updateGroupDisplay = useCallback( + (key: string, action: HomeGroupDisplayAction) => { + const next = new Map(effectiveGroupDisplayStates); + next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); + setGroupDisplayStates(next); + if (action === "toggle-collapsed") { + const collapsedProjectGroups: string[] = []; + for (const [groupKey, state] of next) { + if (state.collapsed) { + collapsedProjectGroups.push(groupKey); + } + } + savePreferences({ collapsedProjectGroups }); + } + }, + [effectiveGroupDisplayStates, savePreferences], + ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { if (openSwipeableRef.current !== methods) { @@ -219,10 +246,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/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 8d8ae322e13..058178e9ec6 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,12 +1,7 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, - type ReactNode, -} from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, type ReactNode } from "react"; + +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { AsyncResult } from "effect/unstable/reactivity"; import { Uniwind } from "uniwind"; @@ -17,7 +12,7 @@ import { type AppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; -import { loadPreferences, savePreferencesPatch } from "../../../lib/storage"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { @@ -52,55 +47,34 @@ function applyTextScaleVariables(baseFontSize: number) { } export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { - const [preferences, setPreferences] = useState(() => - 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]); + cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); + }, [preferences]); - const updatePreferences = useCallback((patch: Partial) => { - setPreferences((current) => { - const next = resolveAppearancePreferences({ ...current, ...patch }); - cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); - void savePreferencesPatch({ + const updatePreferences = useCallback( + (patch: Partial) => { + const next = resolveAppearancePreferences({ ...preferences, ...patch }); + savePreferences({ baseFontSize: next.baseFontSize, terminalFontSize: next.terminalFontSize, codeFontSize: next.codeFontSize, codeWordBreak: next.codeWordBreak, - }).catch(() => undefined); - return next; - }); - }, []); + }); + }, + [preferences, savePreferences], + ); const setBaseFontSize = useCallback( (value: number) => { diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index ae73ac7b4d9..6b2437aa7bc 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -71,6 +71,8 @@ export interface Preferences { readonly codeWordBreak?: boolean; /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */ readonly connectOnboardingOptOutAccounts?: ReadonlyArray; + /** Home-screen project groups the user collapsed, by group key. */ + readonly collapsedProjectGroups?: readonly string[]; } async function readStorageItem(key: MobileStorageKeyValue): Promise { @@ -170,6 +172,7 @@ export async function loadPreferences(): Promise { codeFontSize?: number | null; codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; + collapsedProjectGroups?: readonly string[]; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -195,6 +198,11 @@ export async function loadPreferences(): Promise { (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; } diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts new file mode 100644 index 00000000000..16d4d4954e3 --- /dev/null +++ b/apps/mobile/src/state/preferences.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +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", () => ({ + runtime: { runPromise: vi.fn() }, +})); + +import type { Preferences } from "../lib/storage"; +import { + createMobilePreferencesState, + MobilePreferencesLoadError, + 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: MobilePreferencesStore["Service"]) { + return createMobilePreferencesState( + Atom.runtime(Layer.succeed(MobilePreferencesStore, MobilePreferencesStore.of(service))), + ); +} + +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(); + }), + ); +}); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts new file mode 100644 index 00000000000..8c663a722cd --- /dev/null +++ b/apps/mobile/src/state/preferences.ts @@ -0,0 +1,88 @@ +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { loadPreferences, savePreferencesPatch, type Preferences } from "../lib/storage"; + +export class MobilePreferencesLoadError extends Data.TaggedError("MobilePreferencesLoadError")<{ + readonly cause: unknown; +}> {} + +export class MobilePreferencesSaveError extends Data.TaggedError("MobilePreferencesSaveError")<{ + readonly cause: unknown; +}> {} + +export class MobilePreferencesStore extends Context.Service< + MobilePreferencesStore, + { + readonly load: Effect.Effect; + readonly savePatch: ( + patch: Partial, + ) => Effect.Effect; + } +>()("@t3tools/mobile/state/preferences/MobilePreferencesStore") {} + +const mobilePreferencesStoreLayer = Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: Effect.tryPromise({ + try: loadPreferences, + catch: (cause) => new MobilePreferencesLoadError({ cause }), + }), + savePatch: (patch) => + Effect.tryPromise({ + try: () => savePreferencesPatch(patch), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }), + }), +); + +/** + * Owns the device preference blob for the lifetime of the app registry. + * Optimistic patches are kept separately so writes made while SecureStore 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(() => Effect.succeed({})), + ), + ) + .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored")); + + const optimisticPatchAtom = Atom.make>({}).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:preferences:optimistic-patch"), + ); + + const preferencesAtom = Atom.make((get) => { + const stored = get(storedPreferencesAtom); + const optimisticPatch = get(optimisticPatchAtom); + return AsyncResult.map(stored, (preferences) => ({ ...preferences, ...optimisticPatch })); + }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); + + const updatePreferencesAtom = runtime + .fn( + (patch: Partial, get) => { + get.set(optimisticPatchAtom, { ...get(optimisticPatchAtom), ...patch }); + return MobilePreferencesStore.pipe(Effect.flatMap((store) => store.savePatch(patch))); + }, + // 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(mobilePreferencesStoreLayer); +export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime); + +export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; +export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; From 7ff0669fb79da4d362f7e28799c55aa11c21f409 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 07:36:04 +0200 Subject: [PATCH 09/28] Harden mobile persistence state Use idiomatic Effect services and errors, make optimistic preference rollback concurrency-safe, avoid stale teardown writes, and self-heal bounded mobile cache files. Co-authored-by: codex --- apps/mobile/src/connection/cache-file-name.ts | 17 +++ apps/mobile/src/connection/storage.test.ts | 11 ++ apps/mobile/src/connection/storage.ts | 133 +++++++++++------- apps/mobile/src/state/preferences.test.ts | 74 ++++++++++ apps/mobile/src/state/preferences.ts | 99 +++++++++---- .../client-runtime/src/state/server.test.ts | 39 +++++ packages/client-runtime/src/state/server.ts | 24 +++- 7 files changed, 315 insertions(+), 82 deletions(-) create mode 100644 apps/mobile/src/connection/cache-file-name.ts diff --git a/apps/mobile/src/connection/cache-file-name.ts b/apps/mobile/src/connection/cache-file-name.ts new file mode 100644 index 00000000000..6e843c997b5 --- /dev/null +++ b/apps/mobile/src/connection/cache-file-name.ts @@ -0,0 +1,17 @@ +function fnv1a32(input: string, seed: number): number { + let hash = seed; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +export function vcsRefsCacheFileName(cwd: string): string { + // Workspace paths can exceed the platform filename limit once URI-encoded. + // Two independently seeded hashes keep the filename bounded; the stored cwd + // is still validated after decoding, so a collision becomes a cache miss. + const first = fnv1a32(cwd, 0x811c9dc5).toString(16).padStart(8, "0"); + const second = fnv1a32(cwd, 0x9e3779b9).toString(16).padStart(8, "0"); + return `${first}${second}.json`; +} diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index 031c152e659..53d89c26450 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import { vcsRefsCacheFileName } from "./cache-file-name"; import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, @@ -82,3 +83,13 @@ describe("mobile connection catalog storage", () => { }), ); }); + +describe("mobile environment cache filenames", () => { + it("uses stable, bounded filenames for workspace paths", () => { + const longCwd = `/workspace/${"deeply-nested/".repeat(100)}project`; + + expect(vcsRefsCacheFileName(longCwd)).toBe(vcsRefsCacheFileName(longCwd)); + expect(vcsRefsCacheFileName(longCwd)).not.toBe(vcsRefsCacheFileName(`${longCwd}-other`)); + expect(vcsRefsCacheFileName(longCwd).length).toBeLessThan(32); + }); +}); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index c1a62a6a9f0..264429656c6 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -29,6 +29,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SecureStore from "expo-secure-store"; +import { vcsRefsCacheFileName } from "./cache-file-name"; import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; @@ -127,10 +128,6 @@ function environmentCacheFileName(environmentId: EnvironmentId): string { return `${encodeURIComponent(environmentId)}.json`; } -function vcsRefsFileName(cwd: string): string { - return `${encodeURIComponent(cwd)}.json`; -} - const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")( function* ( environmentId: EnvironmentId, @@ -159,7 +156,10 @@ const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFil threadId: ThreadId, operation: "load-thread" | "save-thread" | "remove-thread", ) { - const { File } = yield* Effect.promise(() => import("expo-file-system")); + const { File } = yield* Effect.tryPromise({ + try: () => import("expo-file-system"), + catch: (cause) => shellPersistenceError(operation, cause), + }); return new File( yield* threadSnapshotDirectory(environmentId, operation), threadSnapshotFileName(threadId), @@ -206,7 +206,9 @@ const serverConfigFile = Effect.fn("mobile.connectionStorage.serverConfigFile")( try: async () => { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, SERVER_CONFIG_CACHE_DIRECTORY); - directory.create({ idempotent: true, intermediates: true }); + if (operation !== "clear-environment") { + directory.create({ idempotent: true, intermediates: true }); + } return new File(directory, environmentCacheFileName(environmentId)); }, catch: (cause) => shellPersistenceError(operation, cause), @@ -239,8 +241,45 @@ const vcsRefsFile = Effect.fn("mobile.connectionStorage.vcsRefsFile")(function* cwd: string, operation: "load-vcs-refs" | "save-vcs-refs", ) { - const { File } = yield* Effect.promise(() => import("expo-file-system")); - return new File(yield* vcsRefsDirectory(environmentId, operation), vcsRefsFileName(cwd)); + const { File } = yield* Effect.tryPromise({ + try: () => import("expo-file-system"), + catch: (cause) => shellPersistenceError(operation, cause), + }); + return new File(yield* vcsRefsDirectory(environmentId, operation), vcsRefsCacheFileName(cwd)); +}); + +const decodeCacheOrDiscard = Effect.fn("mobile.connectionStorage.decodeCacheOrDiscard")(function* < + A, +>(input: { + readonly decode: Effect.Effect; + readonly file: { readonly delete: () => void }; + readonly environmentId: EnvironmentId; + readonly operation: "load-server-config" | "load-vcs-refs"; + readonly description: string; +}) { + return yield* input.decode.pipe( + Effect.map(Option.some), + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning(`Discarding corrupt cached ${input.description}.`, { + environmentId: input.environmentId, + error: String(error), + }); + yield* Effect.try({ + try: () => input.file.delete(), + catch: (cause) => shellPersistenceError(input.operation, cause), + }).pipe( + Effect.catch((deleteError) => + Effect.logWarning(`Could not delete corrupt cached ${input.description}.`, { + environmentId: input.environmentId, + error: String(deleteError), + }), + ), + ); + return Option.none(); + }), + ), + ); }); const shellSnapshotFileInDirectory = Effect.fn( @@ -254,7 +293,9 @@ const shellSnapshotFileInDirectory = Effect.fn( try: async () => { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, directoryName); - directory.create({ idempotent: true, intermediates: true }); + if (operation !== "clear-environment") { + directory.create({ idempotent: true, intermediates: true }); + } return new File(directory, shellSnapshotFileName(environmentId)); }, catch: (cause) => shellPersistenceError(operation, cause), @@ -423,8 +464,8 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("save-shell", cause), }); }), - loadServerConfig: (environmentId) => - Effect.gen(function* () { + loadServerConfig: Effect.fn("mobile.connectionStorage.loadServerConfig")( + function* (environmentId) { const file = yield* serverConfigFile(environmentId, "load-server-config"); if (!file.exists) { return Option.none(); @@ -433,35 +474,20 @@ export const connectionStorageLayer = Layer.effectContext( try: () => file.text(), catch: (cause) => shellPersistenceError("load-server-config", cause), }); - const stored = yield* decodeStoredServerConfig(raw).pipe( - Effect.map(Option.some), - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning("Discarding corrupt cached server configuration.", { - environmentId, - error: String(error), - }); - yield* Effect.try({ - try: () => file.delete(), - catch: (cause) => shellPersistenceError("load-server-config", cause), - }).pipe( - Effect.catch((deleteError) => - Effect.logWarning("Could not delete corrupt cached server configuration.", { - environmentId, - error: String(deleteError), - }), - ), - ); - return Option.none(); - }), - ), - ); + const stored = yield* decodeCacheOrDiscard({ + decode: decodeStoredServerConfig(raw), + file, + environmentId, + operation: "load-server-config", + description: "server configuration", + }); return Option.flatMap(stored, (value) => value.environmentId === environmentId ? Option.some(value.config) : Option.none(), ); - }), - saveServerConfig: (environmentId, config) => - Effect.gen(function* () { + }, + ), + saveServerConfig: Effect.fn("mobile.connectionStorage.saveServerConfig")( + function* (environmentId, config) { const file = yield* serverConfigFile(environmentId, "save-server-config"); const encoded = yield* encodeStoredServerConfig({ schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, @@ -477,7 +503,8 @@ export const connectionStorageLayer = Layer.effectContext( }, catch: (cause) => shellPersistenceError("save-server-config", cause), }); - }), + }, + ), loadThread: (environmentId, threadId) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); @@ -514,8 +541,8 @@ export const connectionStorageLayer = Layer.effectContext( catch: (cause) => shellPersistenceError("save-thread", cause), }); }), - loadVcsRefs: (environmentId, cwd) => - Effect.gen(function* () { + loadVcsRefs: Effect.fn("mobile.connectionStorage.loadVcsRefs")( + function* (environmentId, cwd) { const file = yield* vcsRefsFile(environmentId, cwd, "load-vcs-refs"); if (!file.exists) { return Option.none(); @@ -524,15 +551,22 @@ export const connectionStorageLayer = Layer.effectContext( try: () => file.text(), catch: (cause) => shellPersistenceError("load-vcs-refs", cause), }); - const stored = yield* decodeStoredVcsRefs(raw).pipe( - Effect.mapError((cause) => shellPersistenceError("load-vcs-refs", cause)), + const stored = yield* decodeCacheOrDiscard({ + decode: decodeStoredVcsRefs(raw), + file, + environmentId, + operation: "load-vcs-refs", + description: "Git refs", + }); + return Option.flatMap(stored, (value) => + value.environmentId === environmentId && value.cwd === cwd + ? Option.some(value.refs) + : Option.none(), ); - return stored.environmentId === environmentId && stored.cwd === cwd - ? Option.some(stored.refs) - : Option.none(); - }), - saveVcsRefs: (environmentId, cwd, refs) => - Effect.gen(function* () { + }, + ), + saveVcsRefs: Effect.fn("mobile.connectionStorage.saveVcsRefs")( + function* (environmentId, cwd, refs) { const file = yield* vcsRefsFile(environmentId, cwd, "save-vcs-refs"); const encoded = yield* encodeStoredVcsRefs({ schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, @@ -549,7 +583,8 @@ export const connectionStorageLayer = Layer.effectContext( }, catch: (cause) => shellPersistenceError("save-vcs-refs", cause), }); - }), + }, + ), removeThread: (environmentId, threadId) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 16d4d4954e3..24b5e2a9997 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -1,6 +1,7 @@ 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"; @@ -21,6 +22,7 @@ import type { Preferences } from "../lib/storage"; import { createMobilePreferencesState, MobilePreferencesLoadError, + MobilePreferencesSaveError, MobilePreferencesStore, } from "./preferences"; @@ -124,4 +126,76 @@ describe("mobile preferences state", () => { 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(); + }), + ); }); diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index 8c663a722cd..85182131ba5 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,18 +1,20 @@ import * as Context from "effect/Context"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { loadPreferences, savePreferencesPatch, type Preferences } from "../lib/storage"; -export class MobilePreferencesLoadError extends Data.TaggedError("MobilePreferencesLoadError")<{ - readonly cause: unknown; -}> {} +export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( + "MobilePreferencesLoadError", + { cause: Schema.Defect() }, +) {} -export class MobilePreferencesSaveError extends Data.TaggedError("MobilePreferencesSaveError")<{ - readonly cause: unknown; -}> {} +export class MobilePreferencesSaveError extends Schema.TaggedErrorClass()( + "MobilePreferencesSaveError", + { cause: Schema.Defect() }, +) {} export class MobilePreferencesStore extends Context.Service< MobilePreferencesStore, @@ -22,22 +24,28 @@ export class MobilePreferencesStore extends Context.Service< patch: Partial, ) => Effect.Effect; } ->()("@t3tools/mobile/state/preferences/MobilePreferencesStore") {} - -const mobilePreferencesStoreLayer = Layer.succeed( - MobilePreferencesStore, - MobilePreferencesStore.of({ - load: Effect.tryPromise({ - try: loadPreferences, - catch: (cause) => new MobilePreferencesLoadError({ cause }), - }), - savePatch: (patch) => - Effect.tryPromise({ - try: () => savePreferencesPatch(patch), - catch: (cause) => new MobilePreferencesSaveError({ cause }), +>()("@t3tools/mobile/state/preferences/MobilePreferencesStore") { + static readonly layer = Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: Effect.tryPromise({ + try: loadPreferences, + catch: (cause) => new MobilePreferencesLoadError({ cause }), }), - }), -); + savePatch: Effect.fn("MobilePreferencesStore.savePatch")((patch) => + Effect.tryPromise({ + try: () => savePreferencesPatch(patch), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }), + ), + }), + ); +} + +interface OptimisticPreferences { + readonly values: Partial; + readonly versions: Partial>; +} /** * Owns the device preference blob for the lifetime of the app registry. @@ -49,27 +57,60 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime store.load), - Effect.catch(() => Effect.succeed({})), + 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>({}).pipe( + const optimisticPatchAtom = Atom.make({ values: {}, versions: {} }).pipe( Atom.keepAlive, Atom.withLabel("mobile:preferences:optimistic-patch"), ); + let nextPatchVersion = 0; const preferencesAtom = Atom.make((get) => { const stored = get(storedPreferencesAtom); - const optimisticPatch = get(optimisticPatchAtom); - return AsyncResult.map(stored, (preferences) => ({ ...preferences, ...optimisticPatch })); + const optimistic = get(optimisticPatchAtom); + return AsyncResult.map(stored, (preferences) => ({ ...preferences, ...optimistic.values })); }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); const updatePreferencesAtom = runtime .fn( (patch: Partial, get) => { - get.set(optimisticPatchAtom, { ...get(optimisticPatchAtom), ...patch }); - return MobilePreferencesStore.pipe(Effect.flatMap((store) => store.savePatch(patch))); + 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.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 @@ -81,7 +122,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime { 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 200e2d587af..a126a9b4454 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -8,6 +8,7 @@ import { 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"; @@ -111,25 +112,39 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf })), ); const persistence = yield* Queue.sliding(1); + const pendingPersistence = yield* Ref.make>(Option.none()); const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* ( config: ServerConfig, ) { - yield* cache.saveServerConfig(environmentId, config).pipe( + 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(persist), + Stream.runForEach(persistPending), Effect.forkScoped, ); @@ -140,6 +155,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf 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); }), @@ -148,11 +164,11 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf ); yield* Effect.addFinalizer(() => - SubscriptionRef.get(state).pipe( + Ref.get(pendingPersistence).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.void, - onSome: (projection) => persist(projection.config), + onSome: (config) => persist(config).pipe(Effect.asVoid), }), ), ), From 074171a0676e5e50eb6f05abed91f81da36f9a0d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 07:53:50 +0200 Subject: [PATCH 10/28] feat(mobile): manage client caches with sqlite Co-authored-by: codex --- apps/mobile/app.config.ts | 1 + apps/mobile/package.json | 1 + apps/mobile/src/Stack.tsx | 8 + apps/mobile/src/connection/cache-file-name.ts | 17 - .../environment-cache-store.test.ts | 97 +++ .../src/connection/environment-cache-store.ts | 236 ++++++ apps/mobile/src/connection/platform.ts | 9 +- apps/mobile/src/connection/storage.test.ts | 11 - apps/mobile/src/connection/storage.ts | 497 +---------- .../SettingsClientStorageRouteScreen.tsx | 246 ++++++ .../features/settings/SettingsRouteScreen.tsx | 1 + .../AppearancePreferencesProvider.tsx | 4 +- .../components/settings-sheet-targets.ts | 6 +- apps/mobile/src/lib/storage.ts | 55 +- .../mobile/src/persistence/mobile-database.ts | 285 +++++++ apps/mobile/src/state/client-cache-state.ts | 90 ++ apps/mobile/src/state/preferences.ts | 2 +- pnpm-lock.yaml | 802 +++++++++++++++++- 18 files changed, 1834 insertions(+), 534 deletions(-) delete mode 100644 apps/mobile/src/connection/cache-file-name.ts create mode 100644 apps/mobile/src/connection/environment-cache-store.test.ts create mode 100644 apps/mobile/src/connection/environment-cache-store.ts create mode 100644 apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx create mode 100644 apps/mobile/src/persistence/mobile-database.ts create mode 100644 apps/mobile/src/state/client-cache-state.ts 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/package.json b/apps/mobile/package.json index 7455f65b348..215802e4c43 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -86,6 +86,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/cache-file-name.ts b/apps/mobile/src/connection/cache-file-name.ts deleted file mode 100644 index 6e843c997b5..00000000000 --- a/apps/mobile/src/connection/cache-file-name.ts +++ /dev/null @@ -1,17 +0,0 @@ -function fnv1a32(input: string, seed: number): number { - let hash = seed; - for (let index = 0; index < input.length; index += 1) { - hash ^= input.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} - -export function vcsRefsCacheFileName(cwd: string): string { - // Workspace paths can exceed the platform filename limit once URI-encoded. - // Two independently seeded hashes keep the filename bounded; the stored cwd - // is still validated after decoding, so a collision becomes a cache miss. - const first = fnv1a32(cwd, 0x811c9dc5).toString(16).padStart(8, "0"); - const second = fnv1a32(cwd, 0x9e3779b9).toString(16).padStart(8, "0"); - return `${first}${second}.json`; -} 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..2cd3c7053bb --- /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 { makeEnvironmentCacheStore } 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 = makeEnvironmentCacheStore(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 = makeEnvironmentCacheStore(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 = makeEnvironmentCacheStore(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..8baa78527c8 --- /dev/null +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -0,0 +1,236 @@ +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 Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { + type ClientCacheKind, + MobileDatabase, + MobileDatabaseError, +} 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: MobileDatabaseError) => persistenceError(operation, error); +} + +function loadDecodedCache(input: { + readonly database: MobileDatabase["Service"]; + readonly environmentId: EnvironmentId; + readonly kind: 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 function makeEnvironmentCacheStore( + database: MobileDatabase["Service"], +): EnvironmentCacheStore["Service"] { + 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"))), + ), + }); +} diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index fd3c1530392..cf661caf8ea 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -29,6 +29,7 @@ import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +import { mobileDatabaseContextLayer } from "../persistence/mobile-database"; import { connectionStorageLayer } from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { @@ -168,6 +169,10 @@ const platformConnectionSourceLayer = Layer.succeed( }), ); +const providedConnectionStorageLayer = connectionStorageLayer.pipe( + Layer.provide(mobileDatabaseContextLayer), +); + const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ @@ -190,7 +195,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( ); type ConnectionPlatformLayerSource = - | typeof connectionStorageLayer + | typeof providedConnectionStorageLayer | typeof connectivityLayer | typeof wakeupsLayer | typeof capabilitiesLayer @@ -202,7 +207,7 @@ export const connectionPlatformLayer: Layer.Layer< Layer.Error, Layer.Services > = Layer.mergeAll( - connectionStorageLayer, + providedConnectionStorageLayer, connectivityLayer, wakeupsLayer, capabilitiesLayer, diff --git a/apps/mobile/src/connection/storage.test.ts b/apps/mobile/src/connection/storage.test.ts index 53d89c26450..031c152e659 100644 --- a/apps/mobile/src/connection/storage.test.ts +++ b/apps/mobile/src/connection/storage.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import { vcsRefsCacheFileName } from "./cache-file-name"; import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, @@ -83,13 +82,3 @@ describe("mobile connection catalog storage", () => { }), ); }); - -describe("mobile environment cache filenames", () => { - it("uses stable, bounded filenames for workspace paths", () => { - const longCwd = `/workspace/${"deeply-nested/".repeat(100)}project`; - - expect(vcsRefsCacheFileName(longCwd)).toBe(vcsRefsCacheFileName(longCwd)); - expect(vcsRefsCacheFileName(longCwd)).not.toBe(vcsRefsCacheFileName(`${longCwd}-other`)); - expect(vcsRefsCacheFileName(longCwd).length).toBeLessThan(32); - }); -}); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 264429656c6..2b432efb1ad 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -14,84 +14,15 @@ import { CredentialStore, ProfileStore, } from "@t3tools/client-runtime/connection"; -import { - EnvironmentId, - OrchestrationShellSnapshot, - OrchestrationThreadDetailSnapshot, - ServerConfig, - ThreadId, - VcsListRefsResult, -} 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 { vcsRefsCacheFileName } from "./cache-file-name"; +import { MobileDatabase } from "../persistence/mobile-database"; 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 SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; -const SERVER_CONFIG_CACHE_DIRECTORY = "connection-server-configs"; -const VCS_REFS_CACHE_SCHEMA_VERSION = 1; -const VCS_REFS_CACHE_DIRECTORY = "connection-vcs-refs"; - -const StoredShellSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - snapshot: OrchestrationShellSnapshot, -}); -const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot); - -const StoredThreadSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - threadId: ThreadId, - snapshot: OrchestrationThreadDetailSnapshot, -}); -const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); - -const StoredServerConfig = Schema.Struct({ - schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - config: ServerConfig, -}); -const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig); - -const StoredVcsRefs = Schema.Struct({ - schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION), - environmentId: EnvironmentId, - cwd: Schema.String, - refs: VcsListRefsResult, -}); -const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); - -const LegacyStoredShellSnapshot = Schema.Struct({ - schemaVersion: Schema.Literal(1), - environmentId: EnvironmentId, - snapshotReceivedAt: Schema.String, - snapshot: OrchestrationShellSnapshot, -}); -const LegacyStoredShellSnapshotJson = Schema.fromJsonString(LegacyStoredShellSnapshot); - -const decodeStoredShellSnapshot = Schema.decodeEffect(StoredShellSnapshotJson); -const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); -const decodeStoredThreadSnapshot = Schema.decodeEffect(StoredThreadSnapshotJson); -const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); -const decodeStoredServerConfig = Schema.decodeEffect(StoredServerConfigJson); -const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); -const decodeStoredVcsRefs = Schema.decodeEffect(StoredVcsRefsJson); -const encodeStoredVcsRefs = Schema.encodeEffect(StoredVcsRefsJson); -const decodeLegacyStoredShellSnapshot = Schema.decodeEffect(LegacyStoredShellSnapshotJson); +import { makeEnvironmentCacheStore } from "./environment-cache-store"; function catalogError(operation: string, cause: unknown) { return new ConnectionTransientError({ @@ -100,72 +31,6 @@ function catalogError(operation: string, cause: unknown) { }); } -function shellPersistenceError( - operation: - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "load-server-config" - | "save-server-config" - | "load-vcs-refs" - | "save-vcs-refs" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} - -function threadSnapshotFileName(threadId: ThreadId): string { - return `${encodeURIComponent(threadId)}.json`; -} - -function environmentCacheFileName(environmentId: EnvironmentId): string { - return `${encodeURIComponent(environmentId)}.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.tryPromise({ - try: () => import("expo-file-system"), - catch: (cause) => shellPersistenceError(operation, cause), - }); - return new File( - yield* threadSnapshotDirectory(environmentId, operation), - threadSnapshotFileName(threadId), - ); -}); - function targetPersistenceError( operation: "list-targets" | "register-connection" | "remove-connection", error: ConnectionTransientError, @@ -177,143 +42,29 @@ function targetPersistenceError( } const secureCatalogStorage: SecureCatalogStorage = { - getItem: (key) => + getItem: Effect.fn("MobileConnectionCatalogStorage.getItem")((key) => Effect.tryPromise({ try: () => SecureStore.getItemAsync(key), catch: (cause) => catalogError("load", cause), }), - setItem: (key, value) => + ), + setItem: Effect.fn("MobileConnectionCatalogStorage.setItem")((key, value) => Effect.tryPromise({ try: () => SecureStore.setItemAsync(key, value), catch: (cause) => catalogError("save", cause), }), - deleteItem: (key) => + ), + deleteItem: Effect.fn("MobileConnectionCatalogStorage.deleteItem")((key) => Effect.tryPromise({ try: () => SecureStore.deleteItemAsync(key), catch: (cause) => catalogError("delete", cause), }), + ), }; -function shellSnapshotFileName(environmentId: EnvironmentId): string { - return environmentCacheFileName(environmentId); -} - -const serverConfigFile = Effect.fn("mobile.connectionStorage.serverConfigFile")(function* ( - environmentId: EnvironmentId, - operation: "load-server-config" | "save-server-config" | "clear-environment", -) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, File, Paths } = await import("expo-file-system"); - const directory = new Directory(Paths.document, SERVER_CONFIG_CACHE_DIRECTORY); - if (operation !== "clear-environment") { - directory.create({ idempotent: true, intermediates: true }); - } - return new File(directory, environmentCacheFileName(environmentId)); - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); -}); - -const vcsRefsDirectory = Effect.fn("mobile.connectionStorage.vcsRefsDirectory")(function* ( - environmentId: EnvironmentId, - operation: "load-vcs-refs" | "save-vcs-refs" | "clear-environment", -) { - return yield* Effect.tryPromise({ - try: async () => { - const { Directory, Paths } = await import("expo-file-system"); - const directory = new Directory( - Paths.document, - VCS_REFS_CACHE_DIRECTORY, - encodeURIComponent(environmentId), - ); - if (operation !== "clear-environment") { - directory.create({ idempotent: true, intermediates: true }); - } - return directory; - }, - catch: (cause) => shellPersistenceError(operation, cause), - }); -}); - -const vcsRefsFile = Effect.fn("mobile.connectionStorage.vcsRefsFile")(function* ( - environmentId: EnvironmentId, - cwd: string, - operation: "load-vcs-refs" | "save-vcs-refs", -) { - const { File } = yield* Effect.tryPromise({ - try: () => import("expo-file-system"), - catch: (cause) => shellPersistenceError(operation, cause), - }); - return new File(yield* vcsRefsDirectory(environmentId, operation), vcsRefsCacheFileName(cwd)); -}); - -const decodeCacheOrDiscard = Effect.fn("mobile.connectionStorage.decodeCacheOrDiscard")(function* < - A, ->(input: { - readonly decode: Effect.Effect; - readonly file: { readonly delete: () => void }; - readonly environmentId: EnvironmentId; - readonly operation: "load-server-config" | "load-vcs-refs"; - readonly description: string; -}) { - return yield* input.decode.pipe( - Effect.map(Option.some), - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning(`Discarding corrupt cached ${input.description}.`, { - environmentId: input.environmentId, - error: String(error), - }); - yield* Effect.try({ - try: () => input.file.delete(), - catch: (cause) => shellPersistenceError(input.operation, cause), - }).pipe( - Effect.catch((deleteError) => - Effect.logWarning(`Could not delete corrupt cached ${input.description}.`, { - environmentId: input.environmentId, - error: String(deleteError), - }), - ), - ); - return Option.none(); - }), - ), - ); -}); - -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); - if (operation !== "clear-environment") { - 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 database = yield* MobileDatabase; const catalog = yield* makeCatalogStore(secureCatalogStorage); const targetStore = ConnectionTargetStore.of({ @@ -411,235 +162,7 @@ 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 stored = yield* decodeStoredShellSnapshot(raw).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 legacyStored = yield* decodeLegacyStoredShellSnapshot(legacyRaw).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* encodeStoredShellSnapshot(stored).pipe( - Effect.mapError((cause) => shellPersistenceError("save-shell", cause)), - ); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(encoded); - }, - catch: (cause) => shellPersistenceError("save-shell", cause), - }); - }), - loadServerConfig: Effect.fn("mobile.connectionStorage.loadServerConfig")( - function* (environmentId) { - const file = yield* serverConfigFile(environmentId, "load-server-config"); - if (!file.exists) { - return Option.none(); - } - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-server-config", cause), - }); - const stored = yield* decodeCacheOrDiscard({ - decode: decodeStoredServerConfig(raw), - file, - environmentId, - operation: "load-server-config", - description: "server configuration", - }); - return Option.flatMap(stored, (value) => - value.environmentId === environmentId ? Option.some(value.config) : Option.none(), - ); - }, - ), - saveServerConfig: Effect.fn("mobile.connectionStorage.saveServerConfig")( - function* (environmentId, config) { - const file = yield* serverConfigFile(environmentId, "save-server-config"); - const encoded = yield* encodeStoredServerConfig({ - schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION, - environmentId, - config, - }).pipe(Effect.mapError((cause) => shellPersistenceError("save-server-config", cause))); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(encoded); - }, - catch: (cause) => shellPersistenceError("save-server-config", 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 stored = yield* decodeStoredThreadSnapshot(raw).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* encodeStoredThreadSnapshot({ - 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(encoded); - }, - catch: (cause) => shellPersistenceError("save-thread", cause), - }); - }), - loadVcsRefs: Effect.fn("mobile.connectionStorage.loadVcsRefs")( - function* (environmentId, cwd) { - const file = yield* vcsRefsFile(environmentId, cwd, "load-vcs-refs"); - if (!file.exists) { - return Option.none(); - } - const raw = yield* Effect.tryPromise({ - try: () => file.text(), - catch: (cause) => shellPersistenceError("load-vcs-refs", cause), - }); - const stored = yield* decodeCacheOrDiscard({ - decode: decodeStoredVcsRefs(raw), - file, - environmentId, - operation: "load-vcs-refs", - description: "Git refs", - }); - return Option.flatMap(stored, (value) => - value.environmentId === environmentId && value.cwd === cwd - ? Option.some(value.refs) - : Option.none(), - ); - }, - ), - saveVcsRefs: Effect.fn("mobile.connectionStorage.saveVcsRefs")( - function* (environmentId, cwd, refs) { - const file = yield* vcsRefsFile(environmentId, cwd, "save-vcs-refs"); - const encoded = yield* encodeStoredVcsRefs({ - schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION, - environmentId, - cwd, - refs, - }).pipe(Effect.mapError((cause) => shellPersistenceError("save-vcs-refs", cause))); - yield* Effect.try({ - try: () => { - if (!file.exists) { - file.create({ intermediates: true, overwrite: true }); - } - file.write(encoded); - }, - catch: (cause) => shellPersistenceError("save-vcs-refs", 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 configFile = yield* serverConfigFile(environmentId, "clear-environment"); - if (configFile.exists) { - yield* Effect.try({ - try: () => configFile.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), - }); - } - const refsDirectory = yield* vcsRefsDirectory(environmentId, "clear-environment"); - if (refsDirectory.exists) { - yield* Effect.try({ - try: () => refsDirectory.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), - }); - } - }), - }); + const cacheStore = makeEnvironmentCacheStore(database); return Context.make(ConnectionTargetStore, targetStore).pipe( Context.add(ConnectionRegistrationStore, registrationStore), diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx new file mode 100644 index 00000000000..2e378efe51e --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -0,0 +1,246 @@ +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 destructiveColor = useThemeColor("--color-danger"); + 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 ( + + + + + + + + Stored offline data + + {summary + ? `${formatBytes(summary.payloadBytes)} across ${formatRecordCount(summary.recordCount)}` + : "Calculating storage…"} + + + {!summary ? : null} + + + + Cache data makes threads, models, and branches available while offline. Active + environments rebuild their cache as they are used. + + + + + {environmentSummaries.length > 0 ? ( + environmentSummaries.map((environment, index) => ( + confirmClearEnvironment(environment)} + /> + )) + ) : ( + + + No cached data + + Offline cache records will appear here after environments are used. + + + )} + + + + + + + Clear All 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 destructiveColor = useThemeColor("--color-danger"); + const kinds = formatKinds(props.environment); + + return ( + + + + + {props.environmentLabel} + + + {formatBytes(props.environment.payloadBytes)} · {kinds} + + + + + Clear + + + + ); +} + +function formatKinds(summary: EnvironmentClientCacheSummary): string { + const labels: Array = []; + const threads = summary.kinds.thread ?? 0; + const branches = summary.kinds["vcs-refs"] ?? 0; + if (threads > 0) labels.push(`${threads} thread${threads === 1 ? "" : "s"}`); + if ((summary.kinds.shell ?? 0) > 0) labels.push("projects"); + if ((summary.kinds["server-config"] ?? 0) > 0) labels.push("models"); + if (branches > 0) labels.push(`${branches} branch set${branches === 1 ? "" : "s"}`); + return labels.length > 0 ? labels.join(" · ") : formatRecordCount(summary.recordCount); +} + +function formatRecordCount(count: number): string { + return `${count} record${count === 1 ? "" : "s"}`; +} + +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..9cca50f5ee2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -488,6 +488,7 @@ function AppSettingsSection() { return ( + ()( "MobileSecureStorageError", { - operation: Schema.Literals(["read", "write", "generate-device-id"]), + operation: Schema.Literals(["read", "write", "delete", "generate-device-id"]), key: MobileStorageKey, cause: Schema.Defect(), }, @@ -91,8 +94,15 @@ async function writeStorageItem(key: MobileStorageKeyValue, value: string): Prom } } -async function readJsonStorageItem(key: MobileStorageKeyValue): Promise { - const raw = (await readStorageItem(key)) ?? ""; +async function deleteStorageItem(key: MobileStorageKeyValue): Promise { + try { + await SecureStore.deleteItemAsync(key); + } catch (cause) { + throw new MobileSecureStorageError({ operation: "delete", key, cause }); + } +} + +function parseJsonStorageItem(key: MobileStorageKeyValue, raw: string): T | null { if (!raw.trim()) { return null; } @@ -108,6 +118,11 @@ async function readJsonStorageItem(key: MobileStorageKeyValue): Promise(key: MobileStorageKeyValue): Promise { + const raw = (await readStorageItem(key)) ?? ""; + return parseJsonStorageItem(key, raw); +} + async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) { let encoded: string; try { @@ -159,7 +174,29 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis } export async function loadPreferences(): Promise { - const parsed = await readJsonStorageItem(PREFERENCES_KEY); + const storedJson = await mobileDatabaseRuntime.runPromise( + MobileDatabase.pipe(Effect.flatMap((database) => database.loadPreferencesJson)), + ); + let parsed = Option.isSome(storedJson) + ? parseJsonStorageItem(PREFERENCES_KEY, storedJson.value) + : null; + + if (Option.isNone(storedJson)) { + const legacyJson = await readStorageItem(PREFERENCES_KEY); + parsed = + legacyJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, legacyJson); + if (parsed !== null) { + await mobileDatabaseRuntime.runPromise( + MobileDatabase.pipe( + Effect.flatMap((database) => database.savePreferencesJson(JSON.stringify(parsed))), + ), + ); + await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { + console.warn("[mobile-storage] could not remove migrated preferences", cause); + }); + } + } + if (!parsed || typeof parsed !== "object") { return {}; } @@ -220,7 +257,15 @@ export async function updatePreferences( ...current, ...update(current), }; - await writeJsonStorageItem(PREFERENCES_KEY, next); + let encoded: string; + try { + encoded = JSON.stringify(next); + } catch (cause) { + throw new MobileStorageEncodeError({ key: PREFERENCES_KEY, cause }); + } + await mobileDatabaseRuntime.runPromise( + MobileDatabase.pipe(Effect.flatMap((database) => database.savePreferencesJson(encoded))), + ); return next; }); preferencesWriteQueue = task.catch(() => undefined); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts new file mode 100644 index 00000000000..26ec81a059e --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -0,0 +1,285 @@ +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 ManagedRuntime from "effect/ManagedRuntime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +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; +} + +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 }); +} + +async function removeLegacyFileCaches(): Promise { + try { + const { Directory, Paths } = await import("expo-file-system"); + for (const directoryName of LEGACY_CACHE_DIRECTORIES) { + try { + const directory = new Directory(Paths.document, directoryName); + if (directory.exists) directory.delete(); + } catch (cause) { + console.warn(`[mobile-database] could not remove legacy cache ${directoryName}`, cause); + } + } + } catch (cause) { + console.warn("[mobile-database] could not load legacy cache cleanup", cause); + } +} + +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, MobileDatabaseError>; + readonly savePreferencesJson: (payload: string) => Effect.Effect; + } +>()("@t3tools/mobile/persistence/MobileDatabase") { + static readonly layer = Layer.effect( + MobileDatabase, + 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 + ); + + PRAGMA user_version = ${DATABASE_SCHEMA_VERSION}; + `); + }); + if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) { + // These records are disposable caches. Starting cold avoids carrying the old + // filename-based store forward while still reclaiming its disk usage once. + await removeLegacyFileCaches(); + } + }, + 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<{ readonly payload: string }>( + "SELECT payload FROM client_preferences WHERE singleton = 1", + ), + catch: databaseError("load-preferences"), + }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), + savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload) => + 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, + Date.now(), + ), + catch: databaseError("save-preferences"), + }).pipe(Effect.asVoid), + ), + }); + }), + ); +} + +export const mobileDatabaseRuntime = ManagedRuntime.make(MobileDatabase.layer); + +export const mobileDatabaseContextLayer: Layer.Layer = + Layer.effectContext(mobileDatabaseRuntime.contextEffect); 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..beb50d822fc --- /dev/null +++ b/apps/mobile/src/state/client-cache-state.ts @@ -0,0 +1,90 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import { + type ClientCacheKind, + MobileDatabase, + mobileDatabaseContextLayer, +} from "../persistence/mobile-database"; + +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(mobileDatabaseContextLayer); + +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.ts b/apps/mobile/src/state/preferences.ts index 85182131ba5..0930627a3b3 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -49,7 +49,7 @@ interface OptimisticPreferences { /** * Owns the device preference blob for the lifetime of the app registry. - * Optimistic patches are kept separately so writes made while SecureStore is + * 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) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 558d5bcf9b4..d3f18ff73e7 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) @@ -5448,6 +5451,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'} @@ -6659,6 +6665,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==} @@ -12068,6 +12081,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 @@ -12144,12 +12234,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 @@ -12218,6 +12323,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 @@ -12275,6 +12390,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) @@ -12356,6 +12485,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': {} @@ -12366,6 +12511,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) @@ -13272,18 +13434,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 @@ -13306,6 +13495,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 @@ -13313,6 +13525,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 @@ -13326,12 +13545,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) @@ -13343,6 +13583,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) @@ -13350,6 +13602,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) @@ -13360,6 +13620,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) @@ -13370,6 +13641,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) @@ -13379,6 +13661,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 @@ -13397,6 +13689,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) @@ -13404,6 +13714,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) @@ -13412,6 +13730,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 @@ -13429,12 +13755,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) @@ -13443,12 +13793,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: @@ -13457,12 +13824,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 @@ -13480,6 +13862,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 @@ -13624,6 +14012,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 @@ -14795,7 +15193,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 @@ -14838,7 +15236,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: @@ -15078,6 +15476,8 @@ snapshots: auto-bind@5.0.1: {} + await-lock@2.2.2: {} + aws-ssl-profiles@1.1.2: {} aws4fetch@1.0.20: {} @@ -15866,13 +16266,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 @@ -16147,6 +16548,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) @@ -16191,6 +16604,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) @@ -16231,6 +16653,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) @@ -16238,12 +16666,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) @@ -16264,6 +16707,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)) @@ -16274,6 +16723,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) @@ -16299,10 +16759,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) @@ -16379,6 +16855,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) @@ -16395,6 +16922,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): @@ -16406,6 +16948,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) @@ -16497,6 +17049,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): @@ -18876,6 +19471,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 @@ -18919,6 +19519,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 @@ -18928,6 +19538,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 @@ -18938,6 +19558,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 @@ -18966,11 +19592,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 @@ -18978,6 +19619,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 @@ -19005,6 +19654,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 @@ -19025,6 +19682,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 @@ -19070,6 +19748,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 @@ -19085,6 +19809,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 @@ -19096,6 +19829,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 @@ -19104,6 +19849,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: {} @@ -20148,10 +20902,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 @@ -20160,6 +20927,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 @@ -20196,6 +20972,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 + verror@1.10.1: dependencies: assert-plus: 1.0.0 From 88eb8432188dbdb0013900b2d4ef1d3ffe5c1ced Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:09:52 +0200 Subject: [PATCH 11/28] fix(client): address persistence review findings Preserve legacy offline caches during SQLite migration, harden preference fallback and optimistic rollback, and keep live server config ahead of cached state.\n\nCo-authored-by: codex --- apps/mobile/src/features/home/HomeScreen.tsx | 7 +- .../SettingsClientStorageRouteScreen.tsx | 26 +++- .../AppearancePreferencesProvider.tsx | 10 +- apps/mobile/src/lib/storage.test.ts | 19 ++- apps/mobile/src/lib/storage.ts | 13 +- .../src/persistence/mobile-database.test.ts | 49 ++++++++ .../mobile/src/persistence/mobile-database.ts | 118 ++++++++++++++++-- apps/mobile/src/state/preferences.test.ts | 50 ++++++++ apps/mobile/src/state/preferences.ts | 29 ++++- .../client-runtime/src/state/server.test.ts | 34 +++++ packages/client-runtime/src/state/server.ts | 19 ++- 11 files changed, 345 insertions(+), 29 deletions(-) create mode 100644 apps/mobile/src/persistence/mobile-database.test.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d8ab9b5e6f5..aa4d94ff267 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -178,11 +178,14 @@ export function HomeScreen(props: HomeScreenProps) { } return next; }, [groupDisplayStates, preferencesResult]); + const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); + effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; const updateGroupDisplay = useCallback( (key: string, action: HomeGroupDisplayAction) => { - const next = new Map(effectiveGroupDisplayStates); + 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[] = []; @@ -194,7 +197,7 @@ export function HomeScreen(props: HomeScreenProps) { savePreferences({ collapsedProjectGroups }); } }, - [effectiveGroupDisplayStates, savePreferences], + [savePreferences], ); const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 2e378efe51e..edaa860a4fc 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -73,11 +73,12 @@ export function SettingsClientStorageRouteScreen() { - {environmentSummaries.length > 0 ? ( + {AsyncResult.isFailure(summaryResult) ? ( + + + Storage unavailable + + Restart the app and try again. + + + ) : !summary ? ( + + + + Inspecting cached data… + + + ) : environmentSummaries.length > 0 ? ( environmentSummaries.map((environment, index) => ( ) => { - const next = resolveAppearancePreferences({ ...preferences, ...patch }); - savePreferences({ - baseFontSize: next.baseFontSize, - terminalFontSize: next.terminalFontSize, - codeFontSize: next.codeFontSize, - codeWordBreak: next.codeWordBreak, - }); + savePreferences(patch); }, - [preferences, savePreferences], + [savePreferences], ); const setBaseFontSize = useCallback( diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 084f9430d08..623e3f41217 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -18,6 +18,10 @@ vi.mock("expo-secure-store", () => ({ setItemAsync: mocks.setItemAsync, })); +vi.mock("expo-sqlite", () => ({ + openDatabaseAsync: vi.fn(() => Promise.reject(new Error("database unavailable"))), +})); + vi.mock("react-native", () => ({ Platform: { OS: "ios", @@ -30,7 +34,7 @@ vi.mock("./runtime", () => ({ }, })); -import { loadSavedConnections, saveConnection } from "./storage"; +import { loadPreferences, loadSavedConnections, saveConnection } from "./storage"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -100,4 +104,17 @@ describe("mobile connection storage", () => { warn.mockRestore(); }); + + it("loads legacy preferences when SQLite is unavailable", async () => { + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); + expect(warn).toHaveBeenCalledWith( + "[mobile-storage] database unavailable, using legacy preferences", + expect.anything(), + ); + + warn.mockRestore(); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index ac38c86110b..760e0c00141 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -174,9 +174,14 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis } export async function loadPreferences(): Promise { - const storedJson = await mobileDatabaseRuntime.runPromise( - MobileDatabase.pipe(Effect.flatMap((database) => database.loadPreferencesJson)), - ); + let databaseAvailable = true; + const storedJson = await mobileDatabaseRuntime + .runPromise(MobileDatabase.pipe(Effect.flatMap((database) => database.loadPreferencesJson))) + .catch((cause) => { + databaseAvailable = false; + console.warn("[mobile-storage] database unavailable, using legacy preferences", cause); + return Option.none(); + }); let parsed = Option.isSome(storedJson) ? parseJsonStorageItem(PREFERENCES_KEY, storedJson.value) : null; @@ -185,7 +190,7 @@ export async function loadPreferences(): Promise { const legacyJson = await readStorageItem(PREFERENCES_KEY); parsed = legacyJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, legacyJson); - if (parsed !== null) { + if (parsed !== null && databaseAvailable) { await mobileDatabaseRuntime.runPromise( MobileDatabase.pipe( Effect.flatMap((database) => database.savePreferencesJson(JSON.stringify(parsed))), 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..bba5083fc55 --- /dev/null +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { decodeLegacyCacheRecord } from "./mobile-database"; + +describe("mobile database legacy cache migration", () => { + 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 index 26ec81a059e..15f1ea94816 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer"; import * as ManagedRuntime from "effect/ManagedRuntime"; 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; @@ -64,19 +65,117 @@ function databaseError(operation: typeof MobileDatabaseOperation.Type) { return (cause: unknown) => new MobileDatabaseError({ operation, cause }); } -async function removeLegacyFileCaches(): Promise { +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 { - const { Directory, Paths } = await import("expo-file-system"); + 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) directory.delete(); + 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) { - console.warn(`[mobile-database] could not remove legacy cache ${directoryName}`, 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 cleanup", cause); + console.warn("[mobile-database] could not load legacy cache migration", cause); + return false; } } @@ -152,14 +251,13 @@ export class MobileDatabase extends Context.Service< payload TEXT NOT NULL, updated_at INTEGER NOT NULL ); - - PRAGMA user_version = ${DATABASE_SCHEMA_VERSION}; `); }); if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) { - // These records are disposable caches. Starting cold avoids carrying the old - // filename-based store forward while still reclaiming its disk usage once. - await removeLegacyFileCaches(); + const migrated = await migrateLegacyFileCaches(database); + if (migrated) { + await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`); + } } }, catch: databaseError("migrate"), diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 24b5e2a9997..741b9f0e0a9 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -198,4 +198,54 @@ describe("mobile preferences state", () => { 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 index 0930627a3b3..918e04d8f54 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -70,12 +70,21 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime({}).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, ...optimistic.values })); + return AsyncResult.map(stored, (preferences) => ({ + ...preferences, + ...confirmed, + ...optimistic.values, + })); }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences")); const updatePreferencesAtom = runtime @@ -93,6 +102,24 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime 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); diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 42c25fc5d45..7ced26f0ff1 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -25,6 +25,7 @@ import { applyServerConfigProjection, makeEnvironmentServerConfigState, projectServerWelcome, + resolveServerConfigValue, } from "./server.ts"; const CONFIG = { @@ -37,6 +38,12 @@ 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", @@ -92,6 +99,33 @@ describe("server state projection", () => { 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(); diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index a126a9b4454..7306a0a1071 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -30,6 +30,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; export interface ServerConfigProjection { readonly config: ServerConfig; readonly latestEvent: ServerConfigStreamEvent; + readonly source: "cache" | "live"; } export function applyServerConfigProjection( @@ -41,6 +42,7 @@ export function applyServerConfigProjection( return Option.some({ config: event.config, latestEvent: event, + source: "live", }); case "keybindingsUpdated": return Option.map(current, (projection) => ({ @@ -50,6 +52,7 @@ export function applyServerConfigProjection( issues: event.payload.issues, }, latestEvent: event, + source: "live", })); case "providerStatuses": return Option.map(current, (projection) => ({ @@ -58,6 +61,7 @@ export function applyServerConfigProjection( providers: event.payload.providers, }, latestEvent: event, + source: "live", })); case "settingsUpdated": return Option.map(current, (projection) => ({ @@ -66,6 +70,7 @@ export function applyServerConfigProjection( settings: event.payload.settings, }, latestEvent: event, + source: "live", })); } } @@ -109,6 +114,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf Option.map(cachedConfig, (config) => ({ config, latestEvent: cachedConfigSnapshotEvent(config), + source: "cache" as const, })), ); const persistence = yield* Queue.sliding(1); @@ -215,6 +221,14 @@ 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, options: { @@ -251,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) => From 5168c71729c0df1aad81db50c4d0f48f8d08ce57 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:15:12 +0200 Subject: [PATCH 12/28] fix(client): keep VCS polling after transient failures Co-authored-by: codex --- packages/client-runtime/src/state/vcs.test.ts | 25 ++++++++++++++----- packages/client-runtime/src/state/vcs.ts | 24 +++++++++++++++++- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index b8f20f61b98..60a128a5a75 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -116,12 +116,18 @@ describe("cached VCS refs", () => { ), ); - it.effect("propagates a live failure instead of reusing 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]: () => Effect.fail(expectedError), + [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, @@ -133,7 +139,7 @@ describe("cached VCS refs", () => { retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); - const error = yield* Stream.unwrap( + const result = Stream.unwrap( makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.provideService( @@ -141,10 +147,17 @@ describe("cached VCS refs", () => { cacheWithRefs(Option.some(CACHED_REFS)), ), ), - ).pipe(Stream.runHead, Effect.flip); + ).pipe(Stream.runHead); + const fiber = yield* Effect.forkChild(result); - expect(error).toBe(expectedError); - }), + 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())), ), ); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 2f395236aa0..4ef94c2619f 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -108,7 +108,29 @@ export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChange generation === null ? Stream.empty : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe( - Stream.mapEffect(refresh, { concurrency: 1 }), + 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, + }), + ), ), ), ); From 4e203b168b4d6b4a5ad92176d011dd5921cf5762 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:20:39 +0200 Subject: [PATCH 13/28] fix(mobile): prevent EAS install heap exhaustion Pin EAS builds to the repository Node version and give workspace dependency installation a 4 GB heap across all mobile build profiles.\n\nCo-authored-by: codex --- apps/mobile/eas.json | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 82c707eac12..e6123fc1d74 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -7,8 +7,10 @@ "build": { "development": { "corepack": true, + "node": "24.13.1", "env": { - "APP_VARIANT": "development" + "APP_VARIANT": "development", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "development", "developmentClient": true, @@ -16,8 +18,10 @@ }, "preview": { "corepack": true, + "node": "24.13.1", "env": { - "APP_VARIANT": "preview" + "APP_VARIANT": "preview", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -25,9 +29,11 @@ }, "preview:dev": { "corepack": true, + "node": "24.13.1", "env": { "APP_VARIANT": "preview", - "MOBILE_VERSION_POLICY": "fingerprint" + "MOBILE_VERSION_POLICY": "fingerprint", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "preview", "environment": "preview", @@ -39,8 +45,10 @@ }, "production": { "corepack": true, + "node": "24.13.1", "env": { - "APP_VARIANT": "production" + "APP_VARIANT": "production", + "NODE_OPTIONS": "--max-old-space-size=4096" }, "channel": "production", "environment": "production", From efe64bf516a3e49b544d563551991044680925a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:25:33 +0200 Subject: [PATCH 14/28] fix(mobile): use EAS default Node toolchain Keep the larger heap for workspace installs while avoiding the Node 24 Corepack shim collision with EAS's pnpm installer.\n\nCo-authored-by: codex --- apps/mobile/eas.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index e6123fc1d74..a1d315b7e14 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -7,7 +7,6 @@ "build": { "development": { "corepack": true, - "node": "24.13.1", "env": { "APP_VARIANT": "development", "NODE_OPTIONS": "--max-old-space-size=4096" @@ -18,7 +17,6 @@ }, "preview": { "corepack": true, - "node": "24.13.1", "env": { "APP_VARIANT": "preview", "NODE_OPTIONS": "--max-old-space-size=4096" @@ -29,7 +27,6 @@ }, "preview:dev": { "corepack": true, - "node": "24.13.1", "env": { "APP_VARIANT": "preview", "MOBILE_VERSION_POLICY": "fingerprint", @@ -45,7 +42,6 @@ }, "production": { "corepack": true, - "node": "24.13.1", "env": { "APP_VARIANT": "production", "NODE_OPTIONS": "--max-old-space-size=4096" From 9001555dbc7ca89ef848a261418a302d3fc83bb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:39:54 +0200 Subject: [PATCH 15/28] chore(mobile): add preview dev-client command Run Metro with the EAS preview environment while disabling local dotenv loading and selecting the preview app scheme.\n\nCo-authored-by: codex --- apps/mobile/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 215802e4c43..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", From e7cb7f8f9a45ece6a6bb6466272c5fe446ba3859 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:45:42 +0200 Subject: [PATCH 16/28] fix(mobile): refine client storage screen Use the themed danger foreground for cache actions, remove redundant cache metadata, and present storage totals as compact single-line rows.\n\nCo-authored-by: codex --- .../SettingsClientStorageRouteScreen.tsx | 65 +++++++------------ 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index edaa860a4fc..6f4f26c90f7 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -18,7 +18,7 @@ import { SettingsSection } from "./components/SettingsSection"; export function SettingsClientStorageRouteScreen() { const insets = useSafeAreaInsets(); const iconColor = useThemeColor("--color-icon"); - const destructiveColor = useThemeColor("--color-danger"); + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); const summaryResult = useAtomValue(clientCacheSummaryAtom); const clearResult = useAtomValue(clearClientCacheAtom); const clearCache = useAtomSet(clearClientCacheAtom); @@ -93,15 +93,14 @@ export function SettingsClientStorageRouteScreen() { type="monochrome" weight="regular" /> - - Stored offline data - - {summary - ? `${formatBytes(summary.payloadBytes)} across ${formatRecordCount(summary.recordCount)}` - : "Calculating storage…"} + Stored offline data + {summary ? ( + + {formatBytes(summary.payloadBytes)} - - {!summary ? : null} + ) : ( + + )} @@ -116,7 +115,7 @@ export function SettingsClientStorageRouteScreen() { @@ -174,12 +173,12 @@ export function SettingsClientStorageRouteScreen() { - Clear All Caches - {isClearing ? : null} + Clear All Caches + {isClearing ? : null} @@ -187,7 +186,7 @@ export function SettingsClientStorageRouteScreen() { appearance preferences. {AsyncResult.isFailure(summaryResult) || AsyncResult.isFailure(clearResult) ? ( - + Client storage is temporarily unavailable. Try again after restarting the app. ) : null} @@ -205,9 +204,7 @@ function CacheEnvironmentRow(props: { readonly onClear: () => void; }) { const iconColor = useThemeColor("--color-icon"); - const destructiveColor = useThemeColor("--color-danger"); - const kinds = formatKinds(props.environment); - + const dangerForegroundColor = useThemeColor("--color-danger-foreground"); return ( - - - {props.environmentLabel} - - - {formatBytes(props.environment.payloadBytes)} · {kinds} - - + + {props.environmentLabel} + + + {formatBytes(props.environment.payloadBytes)} + - + Clear @@ -246,21 +244,6 @@ function CacheEnvironmentRow(props: { ); } -function formatKinds(summary: EnvironmentClientCacheSummary): string { - const labels: Array = []; - const threads = summary.kinds.thread ?? 0; - const branches = summary.kinds["vcs-refs"] ?? 0; - if (threads > 0) labels.push(`${threads} thread${threads === 1 ? "" : "s"}`); - if ((summary.kinds.shell ?? 0) > 0) labels.push("projects"); - if ((summary.kinds["server-config"] ?? 0) > 0) labels.push("models"); - if (branches > 0) labels.push(`${branches} branch set${branches === 1 ? "" : "s"}`); - return labels.length > 0 ? labels.join(" · ") : formatRecordCount(summary.recordCount); -} - -function formatRecordCount(count: number): string { - return `${count} record${count === 1 ? "" : "s"}`; -} - 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`; From e481f1b1b8661d593c965fa2e7114670b2ff22f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:46:32 +0200 Subject: [PATCH 17/28] fix(mobile): combine cache size with clear action Keep environment cache rows compact by presenting each payload size as part of its scoped clear action.\n\nCo-authored-by: codex --- .../settings/SettingsClientStorageRouteScreen.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index 6f4f26c90f7..f0bee1ee8f0 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -223,9 +223,6 @@ function CacheEnvironmentRow(props: { {props.environmentLabel} - - {formatBytes(props.environment.payloadBytes)} - - Clear + Clear {formatBytes(props.environment.payloadBytes)} From 854bed837b821ba8dc045be7464f0f9fcc8c3852 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:48:40 +0200 Subject: [PATCH 18/28] fix(mobile): consolidate client cache total Remove the redundant storage summary section and show the aggregate payload size directly in the global clear action.\n\nCo-authored-by: codex --- .../SettingsClientStorageRouteScreen.tsx | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx index f0bee1ee8f0..4b5cd9f8414 100644 --- a/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx @@ -83,32 +83,6 @@ export function SettingsClientStorageRouteScreen() { paddingTop: 16, }} > - - - - - Stored offline data - {summary ? ( - - {formatBytes(summary.payloadBytes)} - - ) : ( - - )} - - - - Cache data makes threads, models, and branches available while offline. Active - environments rebuild their cache as they are used. - - - {AsyncResult.isFailure(summaryResult) ? ( @@ -177,7 +151,9 @@ export function SettingsClientStorageRouteScreen() { type="monochrome" weight="regular" /> - Clear All Caches + + {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"} + {isClearing ? : null} From ec5bdc028cec68ad802c80de8497d0ee413cac7c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 08:59:34 +0200 Subject: [PATCH 19/28] fix(mobile): preserve preferences when sqlite fails Co-authored-by: codex --- apps/mobile/src/lib/storage.test.ts | 85 ++++++++++++++++++- apps/mobile/src/lib/storage.ts | 59 ++++++++----- .../src/provider/Layers/CursorAdapter.test.ts | 14 ++- 3 files changed, 131 insertions(+), 27 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 623e3f41217..99928c048c2 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -3,23 +3,71 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const mocks = vi.hoisted(() => { const values = new Map(); + let preferencesJson: string | null = null; + let loadPreferencesFails = false; + let savePreferencesFails = false; return { - clear: () => values.clear(), + clear: () => { + values.clear(); + preferencesJson = null; + loadPreferencesFails = false; + savePreferencesFails = false; + }, + getStoredValue: (key: string) => values.get(key) ?? null, + getPreferencesJson: () => preferencesJson, + setPreferencesJson: (value: string) => { + preferencesJson = value; + }, + 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 }); + }), + runAsync: vi.fn((_sql: string, payload?: unknown) => { + if (savePreferencesFails) { + return Promise.reject(new Error("database unavailable")); + } + if (typeof payload === "string") { + preferencesJson = payload; + } + 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.reject(new Error("database unavailable"))), + openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), })); vi.mock("react-native", () => ({ @@ -34,7 +82,12 @@ vi.mock("./runtime", () => ({ }, })); -import { loadPreferences, loadSavedConnections, saveConnection } from "./storage"; +import { + loadPreferences, + loadSavedConnections, + saveConnection, + savePreferencesPatch, +} from "./storage"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -106,6 +159,7 @@ describe("mobile connection storage", () => { }); it("loads legacy preferences when SQLite is unavailable", async () => { + mocks.setDatabaseFailures(true, true); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); @@ -117,4 +171,29 @@ describe("mobile connection storage", () => { warn.mockRestore(); }); + + it("falls back to secure storage when SQLite cannot save preferences", async () => { + mocks.setDatabaseFailures(true, true); + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); + expect(JSON.parse(mocks.getStoredValue("t3code.preferences") ?? "")).toEqual({ + baseFontSize: 19, + }); + expect(warn).toHaveBeenCalledWith( + "[mobile-storage] database unavailable, saving preferences to secure storage", + expect.anything(), + ); + + warn.mockRestore(); + }); + + it("reconciles fallback preferences after SQLite recovers", async () => { + mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 })); + await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); + + await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); + expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); + expect(mocks.getStoredValue("t3code.preferences")).toBeNull(); + }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index 760e0c00141..ca3d5aab3a1 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -173,6 +173,25 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } +async function savePreferencesJson(encoded: string): Promise { + try { + await mobileDatabaseRuntime.runPromise( + MobileDatabase.pipe(Effect.flatMap((database) => database.savePreferencesJson(encoded))), + ); + } catch (cause) { + console.warn( + "[mobile-storage] database unavailable, saving preferences to secure storage", + cause, + ); + await writeStorageItem(PREFERENCES_KEY, encoded); + return; + } + + await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { + console.warn("[mobile-storage] could not remove migrated preferences", cause); + }); +} + export async function loadPreferences(): Promise { let databaseAvailable = true; const storedJson = await mobileDatabaseRuntime @@ -182,24 +201,26 @@ export async function loadPreferences(): Promise { console.warn("[mobile-storage] database unavailable, using legacy preferences", cause); return Option.none(); }); - let parsed = Option.isSome(storedJson) - ? parseJsonStorageItem(PREFERENCES_KEY, storedJson.value) - : null; - - if (Option.isNone(storedJson)) { - const legacyJson = await readStorageItem(PREFERENCES_KEY); - parsed = - legacyJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, legacyJson); - if (parsed !== null && databaseAvailable) { - await mobileDatabaseRuntime.runPromise( - MobileDatabase.pipe( - Effect.flatMap((database) => database.savePreferencesJson(JSON.stringify(parsed))), - ), - ); - await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { - console.warn("[mobile-storage] could not remove migrated preferences", cause); - }); + const legacyJson = await readStorageItem(PREFERENCES_KEY).catch((cause) => { + if (Option.isNone(storedJson)) { + throw cause; } + console.warn("[mobile-storage] could not inspect legacy preferences", cause); + return null; + }); + const legacyPreferences = + legacyJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, legacyJson); + let parsed = + legacyPreferences ?? + (Option.isSome(storedJson) + ? parseJsonStorageItem(PREFERENCES_KEY, storedJson.value) + : null); + + // SecureStore is both the legacy location and the durable fallback when + // SQLite is temporarily unavailable. Prefer it and reconcile it back into + // SQLite so a recovered database cannot revive an older preference value. + if (legacyPreferences !== null && databaseAvailable && legacyJson !== null) { + await savePreferencesJson(legacyJson); } if (!parsed || typeof parsed !== "object") { @@ -268,9 +289,7 @@ export async function updatePreferences( } catch (cause) { throw new MobileStorageEncodeError({ key: PREFERENCES_KEY, cause }); } - await mobileDatabaseRuntime.runPromise( - MobileDatabase.pipe(Effect.flatMap((database) => database.savePreferencesJson(encoded))), - ); + await savePreferencesJson(encoded); return next; }); preferencesWriteQueue = task.catch(() => undefined); 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); }), From 57f89098157f3987d8df521605972273c100886b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 09:09:53 +0200 Subject: [PATCH 20/28] fix(mobile): order preference persistence fallbacks Co-authored-by: codex --- apps/mobile/src/lib/storage.test.ts | 52 +++++++-- apps/mobile/src/lib/storage.ts | 105 ++++++++++++++---- .../mobile/src/persistence/mobile-database.ts | 27 +++-- 3 files changed, 146 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 99928c048c2..29025e0ebb7 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -4,19 +4,22 @@ 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(); preferencesJson = null; + preferencesUpdatedAt = 0; loadPreferencesFails = false; savePreferencesFails = false; }, getStoredValue: (key: string) => values.get(key) ?? null, getPreferencesJson: () => preferencesJson, - setPreferencesJson: (value: string) => { + setPreferencesJson: (value: string, updatedAt: number) => { preferencesJson = value; + preferencesUpdatedAt = updatedAt; }, setDatabaseFailures: (load: boolean, save: boolean) => { loadPreferencesFails = load; @@ -45,15 +48,22 @@ const mocks = vi.hoisted(() => { if (loadPreferencesFails) { return Promise.reject(new Error("database unavailable")); } - return Promise.resolve(preferencesJson === null ? null : { payload: preferencesJson }); + return Promise.resolve( + preferencesJson === null + ? null + : { payload: preferencesJson, updatedAt: preferencesUpdatedAt }, + ); }), - runAsync: vi.fn((_sql: string, payload?: unknown) => { + 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(); }), }, @@ -177,9 +187,12 @@ describe("mobile connection storage", () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); - expect(JSON.parse(mocks.getStoredValue("t3code.preferences") ?? "")).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)); expect(warn).toHaveBeenCalledWith( "[mobile-storage] database unavailable, saving preferences to secure storage", expect.anything(), @@ -189,11 +202,32 @@ describe("mobile connection storage", () => { }); it("reconciles fallback preferences after SQLite recovers", async () => { - mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 })); - await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); + 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")).toBeNull(); + 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(); }); }); diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts index ca3d5aab3a1..7027006dd23 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -11,15 +11,21 @@ import { type SavedRemoteConnection, toStableSavedRemoteConnection, } from "./connection"; -import { MobileDatabase, mobileDatabaseRuntime } from "../persistence/mobile-database"; +import { + MobileDatabase, + mobileDatabaseRuntime, + type StoredPreferencesJson, +} from "../persistence/mobile-database"; const CONNECTIONS_KEY = "t3code.connections"; const PREFERENCES_KEY = "t3code.preferences"; +const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; 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, + PREFERENCES_FALLBACK_KEY, AGENT_AWARENESS_DEVICE_ID_KEY, AGENT_AWARENESS_REGISTRATION_KEY, ]); @@ -173,22 +179,56 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next }); } -async function savePreferencesJson(encoded: string): Promise { +interface PreferencesFallback { + readonly payload: string; + readonly updatedAt: number; +} + +let lastPreferencesUpdatedAt = 0; + +function nextPreferencesUpdatedAt(): number { + lastPreferencesUpdatedAt = Math.max(Date.now(), lastPreferencesUpdatedAt + 1); + return lastPreferencesUpdatedAt; +} + +function parsePreferencesFallback(raw: string | null): PreferencesFallback | null { + if (raw === null) return null; + const parsed = parseJsonStorageItem(PREFERENCES_FALLBACK_KEY, raw); + if ( + typeof parsed !== "object" || + parsed === null || + !("payload" in parsed) || + typeof parsed.payload !== "string" || + !("updatedAt" in parsed) || + typeof parsed.updatedAt !== "number" + ) { + return null; + } + return { payload: parsed.payload, updatedAt: parsed.updatedAt }; +} + +async function savePreferencesJson( + encoded: string, + updatedAt = nextPreferencesUpdatedAt(), +): Promise { + lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, updatedAt); try { await mobileDatabaseRuntime.runPromise( - MobileDatabase.pipe(Effect.flatMap((database) => database.savePreferencesJson(encoded))), + MobileDatabase.pipe( + Effect.flatMap((database) => database.savePreferencesJson(encoded, updatedAt)), + ), ); } catch (cause) { console.warn( "[mobile-storage] database unavailable, saving preferences to secure storage", cause, ); - await writeStorageItem(PREFERENCES_KEY, encoded); + await writeJsonStorageItem(PREFERENCES_FALLBACK_KEY, { payload: encoded, updatedAt }); return; } - await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { - console.warn("[mobile-storage] could not remove migrated preferences", cause); + await deleteStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { + console.warn("[mobile-storage] could not remove preferences fallback", cause); }); } @@ -199,30 +239,51 @@ export async function loadPreferences(): Promise { .catch((cause) => { databaseAvailable = false; console.warn("[mobile-storage] database unavailable, using legacy preferences", cause); - return Option.none(); + return Option.none(); }); - const legacyJson = await readStorageItem(PREFERENCES_KEY).catch((cause) => { + const fallbackJson = await readStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { if (Option.isNone(storedJson)) { throw cause; } - console.warn("[mobile-storage] could not inspect legacy preferences", cause); + console.warn("[mobile-storage] could not inspect preferences fallback", cause); return null; }); - const legacyPreferences = - legacyJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, legacyJson); - let parsed = - legacyPreferences ?? - (Option.isSome(storedJson) - ? parseJsonStorageItem(PREFERENCES_KEY, storedJson.value) - : null); - - // SecureStore is both the legacy location and the durable fallback when - // SQLite is temporarily unavailable. Prefer it and reconcile it back into - // SQLite so a recovered database cannot revive an older preference value. - if (legacyPreferences !== null && databaseAvailable && legacyJson !== null) { - await savePreferencesJson(legacyJson); + const fallback = parsePreferencesFallback(fallbackJson); + const fallbackIsNewer = + fallback !== null && + (Option.isNone(storedJson) || fallback.updatedAt > storedJson.value.updatedAt); + + let selectedJson: string | null = null; + if (fallbackIsNewer) { + selectedJson = fallback.payload; + lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, fallback.updatedAt); + if (databaseAvailable) { + await savePreferencesJson(fallback.payload, fallback.updatedAt); + } + } else if (Option.isSome(storedJson)) { + selectedJson = storedJson.value.payload; + lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, storedJson.value.updatedAt); + if (fallback !== null) { + await deleteStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { + console.warn("[mobile-storage] could not remove stale preferences fallback", cause); + }); + } } + if (selectedJson === null) { + const legacyJson = await readStorageItem(PREFERENCES_KEY); + selectedJson = legacyJson; + if (legacyJson !== null && databaseAvailable) { + await savePreferencesJson(legacyJson); + await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { + console.warn("[mobile-storage] could not remove migrated preferences", cause); + }); + } + } + + const parsed = + selectedJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, selectedJson); + if (!parsed || typeof parsed !== "object") { return {}; } diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 15f1ea94816..c79fe4fd9c7 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -27,6 +27,11 @@ export interface ClientCacheSummaryRow { readonly payloadBytes: number; } +export interface StoredPreferencesJson { + readonly payload: string; + readonly updatedAt: number; +} + const ClientCacheSummaryRows = Schema.Array( Schema.Struct({ environmentId: Schema.String, @@ -207,8 +212,14 @@ export class MobileDatabase extends Context.Service< ReadonlyArray, MobileDatabaseError >; - readonly loadPreferencesJson: Effect.Effect, MobileDatabaseError>; - readonly savePreferencesJson: (payload: string) => Effect.Effect; + readonly loadPreferencesJson: Effect.Effect< + Option.Option, + MobileDatabaseError + >; + readonly savePreferencesJson: ( + payload: string, + updatedAt: number, + ) => Effect.Effect; } >()("@t3tools/mobile/persistence/MobileDatabase") { static readonly layer = Layer.effect( @@ -352,12 +363,14 @@ export class MobileDatabase extends Context.Service< ), loadPreferencesJson: Effect.tryPromise({ try: () => - database.getFirstAsync<{ readonly payload: string }>( - "SELECT payload FROM client_preferences WHERE singleton = 1", + database.getFirstAsync( + `SELECT payload, updated_at AS updatedAt + FROM client_preferences + WHERE singleton = 1`, ), catch: databaseError("load-preferences"), - }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), - savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload) => + }).pipe(Effect.map(Option.fromNullishOr)), + savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) => Effect.tryPromise({ try: () => database.runAsync( @@ -367,7 +380,7 @@ export class MobileDatabase extends Context.Service< payload = excluded.payload, updated_at = excluded.updated_at`, payload, - Date.now(), + updatedAt, ), catch: databaseError("save-preferences"), }).pipe(Effect.asVoid), From 5e69a0369dfbc011d3aea9e1e7040545487efe46 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 09:21:20 +0200 Subject: [PATCH 21/28] fix(mobile): validate preference fallback candidates Co-authored-by: codex --- apps/mobile/src/lib/storage.test.ts | 23 ++++++++++++++++ apps/mobile/src/lib/storage.ts | 42 +++++++++++++++++++---------- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 29025e0ebb7..4e4d268bb78 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -230,4 +230,27 @@ describe("mobile connection storage", () => { 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 index 7027006dd23..0f7b9fce65b 100644 --- a/apps/mobile/src/lib/storage.ts +++ b/apps/mobile/src/lib/storage.ts @@ -182,6 +182,7 @@ export async function clearSavedConnection(environmentId: EnvironmentId): Promis interface PreferencesFallback { readonly payload: string; readonly updatedAt: number; + readonly preferences: Preferences; } let lastPreferencesUpdatedAt = 0; @@ -191,6 +192,14 @@ function nextPreferencesUpdatedAt(): number { return lastPreferencesUpdatedAt; } +function parsePreferencesPayload(raw: string | null): Preferences | null { + if (raw === null) return null; + const parsed = parseJsonStorageItem(PREFERENCES_KEY, raw); + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) + ? (parsed as Preferences) + : null; +} + function parsePreferencesFallback(raw: string | null): PreferencesFallback | null { if (raw === null) return null; const parsed = parseJsonStorageItem(PREFERENCES_FALLBACK_KEY, raw); @@ -204,7 +213,10 @@ function parsePreferencesFallback(raw: string | null): PreferencesFallback | nul ) { return null; } - return { payload: parsed.payload, updatedAt: parsed.updatedAt }; + const preferences = parsePreferencesPayload(parsed.payload); + return preferences === null + ? null + : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences }; } async function savePreferencesJson( @@ -249,31 +261,36 @@ export async function loadPreferences(): Promise { return null; }); const fallback = parsePreferencesFallback(fallbackJson); + const storedPreferences = Option.isSome(storedJson) + ? parsePreferencesPayload(storedJson.value.payload) + : null; const fallbackIsNewer = fallback !== null && - (Option.isNone(storedJson) || fallback.updatedAt > storedJson.value.updatedAt); + (storedPreferences === null || + (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt)); - let selectedJson: string | null = null; + let parsed: Preferences | null = null; if (fallbackIsNewer) { - selectedJson = fallback.payload; + parsed = fallback.preferences; lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, fallback.updatedAt); if (databaseAvailable) { await savePreferencesJson(fallback.payload, fallback.updatedAt); } - } else if (Option.isSome(storedJson)) { - selectedJson = storedJson.value.payload; + } else if (storedPreferences !== null && Option.isSome(storedJson)) { + parsed = storedPreferences; lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, storedJson.value.updatedAt); - if (fallback !== null) { + if (fallbackJson !== null) { await deleteStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { console.warn("[mobile-storage] could not remove stale preferences fallback", cause); }); } } - if (selectedJson === null) { + if (parsed === null) { const legacyJson = await readStorageItem(PREFERENCES_KEY); - selectedJson = legacyJson; - if (legacyJson !== null && databaseAvailable) { + const legacyPreferences = parsePreferencesPayload(legacyJson); + parsed = legacyPreferences; + if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) { await savePreferencesJson(legacyJson); await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { console.warn("[mobile-storage] could not remove migrated preferences", cause); @@ -281,10 +298,7 @@ export async function loadPreferences(): Promise { } } - const parsed = - selectedJson === null ? null : parseJsonStorageItem(PREFERENCES_KEY, selectedJson); - - if (!parsed || typeof parsed !== "object") { + if (parsed === null) { return {}; } From eb28d7da73b1cd2399f7a9904a5d413916ca38df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 10:02:48 +0200 Subject: [PATCH 22/28] refactor(mobile): model persistence as Effect services Co-authored-by: codex --- .../effect-service-conventions.md | 9 + apps/mobile/src/connection/catalog-store.ts | 30 +- .../environment-cache-store.test.ts | 8 +- .../src/connection/environment-cache-store.ts | 22 +- apps/mobile/src/connection/platform.ts | 8 +- apps/mobile/src/connection/storage.test.ts | 37 +- apps/mobile/src/connection/storage.ts | 41 +- .../liveActivityPreferences.test.ts | 4 +- .../liveActivityPreferences.ts | 2 +- .../agent-awareness/registrationPayload.ts | 2 +- .../remoteRegistration.test.ts | 4 +- .../agent-awareness/remoteRegistration.ts | 2 +- .../features/cloud/connectOnboardingOptOut.ts | 4 +- .../features/cloud/linkEnvironment.test.ts | 4 +- .../src/features/cloud/linkEnvironment.ts | 2 +- .../features/settings/SettingsRouteScreen.tsx | 2 +- apps/mobile/src/lib/storage.test.ts | 23 +- apps/mobile/src/lib/storage.ts | 443 ------------------ apps/mobile/src/persistence/imperative.ts | 50 ++ .../src/persistence/mobile-database.test.ts | 26 +- .../mobile/src/persistence/mobile-database.ts | 263 ++++++----- .../src/persistence/mobile-preferences.ts | 305 ++++++++++++ .../src/persistence/mobile-secure-storage.ts | 52 ++ apps/mobile/src/persistence/mobile-storage.ts | 227 +++++++++ apps/mobile/src/persistence/runtime.ts | 22 + apps/mobile/src/state/client-cache-state.ts | 9 +- apps/mobile/src/state/preferences.test.ts | 23 +- apps/mobile/src/state/preferences.ts | 46 +- 28 files changed, 932 insertions(+), 738 deletions(-) delete mode 100644 apps/mobile/src/lib/storage.ts create mode 100644 apps/mobile/src/persistence/imperative.ts create mode 100644 apps/mobile/src/persistence/mobile-preferences.ts create mode 100644 apps/mobile/src/persistence/mobile-secure-storage.ts create mode 100644 apps/mobile/src/persistence/mobile-storage.ts create mode 100644 apps/mobile/src/persistence/runtime.ts 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/src/connection/catalog-store.ts b/apps/mobile/src/connection/catalog-store.ts index 7f99d4058b3..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"; @@ -47,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 @@ -74,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; }); @@ -85,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()), ), ), @@ -110,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 index 2cd3c7053bb..0caf2b41592 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -4,7 +4,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import { makeEnvironmentCacheStore } from "./environment-cache-store"; +import { make } from "./environment-cache-store"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); const REFS: VcsListRefsResult = { @@ -60,7 +60,7 @@ describe("mobile SQLite environment cache store", () => { it.effect("round-trips schema-validated VCS refs", () => Effect.gen(function* () { const memory = makeDatabase(); - const store = makeEnvironmentCacheStore(memory.database); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS); @@ -71,7 +71,7 @@ describe("mobile SQLite environment cache store", () => { it.effect("deletes a corrupt cache record and treats it as a miss", () => Effect.gen(function* () { const memory = makeDatabase(); - const store = makeEnvironmentCacheStore(memory.database); + const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database)); const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo"); memory.values.set(id, "{not-json"); @@ -83,7 +83,7 @@ describe("mobile SQLite environment cache store", () => { it.effect("clears one environment without touching another", () => Effect.gen(function* () { const memory = makeDatabase(); - const store = makeEnvironmentCacheStore(memory.database); + 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); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index 8baa78527c8..04729ad21df 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -10,14 +10,11 @@ import { 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 { - type ClientCacheKind, - MobileDatabase, - MobileDatabaseError, -} from "../persistence/mobile-database"; +import * as MobileDatabase from "../persistence/mobile-database"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; @@ -72,13 +69,13 @@ function persistenceError(operation: CacheOperation, cause: unknown) { } function mapDatabaseError(operation: CacheOperation) { - return (error: MobileDatabaseError) => persistenceError(operation, error); + return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); } function loadDecodedCache(input: { - readonly database: MobileDatabase["Service"]; + readonly database: MobileDatabase.MobileDatabase["Service"]; readonly environmentId: EnvironmentId; - readonly kind: ClientCacheKind; + readonly kind: MobileDatabase.ClientCacheKind; readonly cacheKey: string; readonly operation: CacheOperation; readonly decode: (raw: string) => Effect.Effect; @@ -113,9 +110,8 @@ function loadDecodedCache(input: { ); } -export function makeEnvironmentCacheStore( - database: MobileDatabase["Service"], -): EnvironmentCacheStore["Service"] { +export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { + const database = yield* MobileDatabase.MobileDatabase; return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ @@ -233,4 +229,6 @@ export function makeEnvironmentCacheStore( .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 cf661caf8ea..d9ca1c9e245 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -25,11 +25,11 @@ import * as Network from "expo-network"; import { AppState } from "react-native"; import { authClientMetadata } from "../lib/authClientMetadata"; -import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; +import { loadOrCreateAgentAwarenessDeviceId } from "../persistence/imperative"; import { appAtomRegistry } from "../state/atom-registry"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; -import { mobileDatabaseContextLayer } from "../persistence/mobile-database"; +import * as PersistenceRuntime from "../persistence/runtime"; import { connectionStorageLayer } from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { @@ -170,7 +170,7 @@ const platformConnectionSourceLayer = Layer.succeed( ); const providedConnectionStorageLayer = connectionStorageLayer.pipe( - Layer.provide(mobileDatabaseContextLayer), + Layer.provide(PersistenceRuntime.contextLayer), ); const environmentOwnedDataCleanupLayer = Layer.succeed( @@ -196,6 +196,7 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( type ConnectionPlatformLayerSource = | typeof providedConnectionStorageLayer + | typeof PersistenceRuntime.contextLayer | typeof connectivityLayer | typeof wakeupsLayer | typeof capabilitiesLayer @@ -208,6 +209,7 @@ export const connectionPlatformLayer: Layer.Layer< Layer.Services > = Layer.mergeAll( providedConnectionStorageLayer, + PersistenceRuntime.contextLayer, connectivityLayer, wakeupsLayer, capabilitiesLayer, 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 2b432efb1ad..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, @@ -18,18 +17,7 @@ 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 SecureStore from "expo-secure-store"; - -import { MobileDatabase } from "../persistence/mobile-database"; -import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; -import { makeEnvironmentCacheStore } from "./environment-cache-store"; - -function catalogError(operation: string, cause: unknown) { - return new ConnectionTransientError({ - reason: "remote-unavailable", - detail: `Could not ${operation} the local connection catalog: ${String(cause)}`, - }); -} +import * as CatalogStore from "./catalog-store"; function targetPersistenceError( operation: "list-targets" | "register-connection" | "remove-connection", @@ -41,31 +29,9 @@ function targetPersistenceError( }); } -const secureCatalogStorage: SecureCatalogStorage = { - getItem: Effect.fn("MobileConnectionCatalogStorage.getItem")((key) => - Effect.tryPromise({ - try: () => SecureStore.getItemAsync(key), - catch: (cause) => catalogError("load", cause), - }), - ), - setItem: Effect.fn("MobileConnectionCatalogStorage.setItem")((key, value) => - Effect.tryPromise({ - try: () => SecureStore.setItemAsync(key, value), - catch: (cause) => catalogError("save", cause), - }), - ), - deleteItem: Effect.fn("MobileConnectionCatalogStorage.deleteItem")((key) => - Effect.tryPromise({ - try: () => SecureStore.deleteItemAsync(key), - catch: (cause) => catalogError("delete", cause), - }), - ), -}; - export const connectionStorageLayer = Layer.effectContext( Effect.gen(function* () { - const database = yield* MobileDatabase; - const catalog = yield* makeCatalogStore(secureCatalogStorage); + const catalog = yield* CatalogStore.make(); const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( @@ -162,14 +128,11 @@ export const connectionStorageLayer = Layer.effectContext( ), })), }); - const cacheStore = makeEnvironmentCacheStore(database); - 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..44a2cfaae24 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,12 +7,12 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../lib/storage"; +import { savePreferencesPatch } from "../../persistence/imperative"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; -vi.mock("../../lib/storage", () => ({ +vi.mock("../../persistence/imperative", () => ({ savePreferencesPatch: vi.fn(() => Promise.resolve()), })); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 932376e8bce..e668a6c2220 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -4,7 +4,7 @@ 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 { savePreferencesPatch } from "../../persistence/imperative"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index cd2e36a403c..98515d8351f 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/imperative"; // 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..c5f24cfdb72 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -21,7 +21,7 @@ import { loadAgentAwarenessRegistrationRecord, loadOrCreateAgentAwarenessDeviceId, saveAgentAwarenessRegistrationRecord, -} from "../../lib/storage"; +} from "../../persistence/imperative"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; import { AgentAwarenessOperationError, @@ -142,7 +142,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 })), diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index e9de93c1dcb..74464270354 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -27,7 +27,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"; 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..78360d4adb7 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -36,7 +36,7 @@ vi.mock("react-native", () => ({ }, })); -vi.mock("../../lib/storage", () => ({ +vi.mock("../../persistence/imperative", () => ({ loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), loadPreferences: vi.fn(() => Promise.resolve({})), })); @@ -705,7 +705,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")); + const storage = yield* Effect.promise(() => import("../../persistence/imperative")); vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ liveActivitiesEnabled: false, }); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a77ca628978..a6a8d06b4ae 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -30,7 +30,7 @@ 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 { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../persistence/imperative"; import { resolveCloudPublicConfig } from "./publicConfig"; const RELAY_STATUS_AND_CONNECT_SCOPES = [ diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 9cca50f5ee2..22e597b125a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -31,7 +31,7 @@ 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 { loadPreferences } from "../../persistence/imperative"; import { useThemeColor } from "../../lib/useThemeColor"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; diff --git a/apps/mobile/src/lib/storage.test.ts b/apps/mobile/src/lib/storage.test.ts index 4e4d268bb78..b655c6feee4 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -86,18 +86,12 @@ vi.mock("react-native", () => ({ }, })); -vi.mock("./runtime", () => ({ - runtime: { - runPromise: vi.fn(), - }, -})); - import { loadPreferences, loadSavedConnections, saveConnection, savePreferencesPatch, -} from "./storage"; +} from "../persistence/imperative"; import { toStableSavedRemoteConnection } from "./connection"; const managedConnection = { @@ -171,21 +165,12 @@ describe("mobile connection storage", () => { it("loads legacy preferences when SQLite is unavailable", async () => { mocks.setDatabaseFailures(true, true); await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); - expect(warn).toHaveBeenCalledWith( - "[mobile-storage] database unavailable, using legacy preferences", - expect.anything(), - ); - - warn.mockRestore(); }); it("falls back to secure storage when SQLite cannot save preferences", async () => { mocks.setDatabaseFailures(true, true); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { readonly payload: string; @@ -193,12 +178,6 @@ describe("mobile connection storage", () => { }; expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 }); expect(fallback.updatedAt).toEqual(expect.any(Number)); - expect(warn).toHaveBeenCalledWith( - "[mobile-storage] database unavailable, saving preferences to secure storage", - expect.anything(), - ); - - warn.mockRestore(); }); it("reconciles fallback preferences after SQLite recovers", async () => { diff --git a/apps/mobile/src/lib/storage.ts b/apps/mobile/src/lib/storage.ts deleted file mode 100644 index 0f7b9fce65b..00000000000 --- a/apps/mobile/src/lib/storage.ts +++ /dev/null @@ -1,443 +0,0 @@ -import * as Arr from "effect/Array"; -import * as Effect from "effect/Effect"; -import { pipe } from "effect/Function"; -import * as Option from "effect/Option"; -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"; -import { - MobileDatabase, - mobileDatabaseRuntime, - type StoredPreferencesJson, -} from "../persistence/mobile-database"; - -const CONNECTIONS_KEY = "t3code.connections"; -const PREFERENCES_KEY = "t3code.preferences"; -const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; -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, - PREFERENCES_FALLBACK_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", "delete", "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; - /** Home-screen project groups the user collapsed, by group key. */ - readonly collapsedProjectGroups?: readonly string[]; -} - -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 deleteStorageItem(key: MobileStorageKeyValue): Promise { - try { - await SecureStore.deleteItemAsync(key); - } catch (cause) { - throw new MobileSecureStorageError({ operation: "delete", key, cause }); - } -} - -function parseJsonStorageItem(key: MobileStorageKeyValue, raw: string): T | null { - 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 readJsonStorageItem(key: MobileStorageKeyValue): Promise { - const raw = (await readStorageItem(key)) ?? ""; - return parseJsonStorageItem(key, raw); -} - -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 }); -} - -interface PreferencesFallback { - readonly payload: string; - readonly updatedAt: number; - readonly preferences: Preferences; -} - -let lastPreferencesUpdatedAt = 0; - -function nextPreferencesUpdatedAt(): number { - lastPreferencesUpdatedAt = Math.max(Date.now(), lastPreferencesUpdatedAt + 1); - return lastPreferencesUpdatedAt; -} - -function parsePreferencesPayload(raw: string | null): Preferences | null { - if (raw === null) return null; - const parsed = parseJsonStorageItem(PREFERENCES_KEY, raw); - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) - ? (parsed as Preferences) - : null; -} - -function parsePreferencesFallback(raw: string | null): PreferencesFallback | null { - if (raw === null) return null; - const parsed = parseJsonStorageItem(PREFERENCES_FALLBACK_KEY, raw); - if ( - typeof parsed !== "object" || - parsed === null || - !("payload" in parsed) || - typeof parsed.payload !== "string" || - !("updatedAt" in parsed) || - typeof parsed.updatedAt !== "number" - ) { - return null; - } - const preferences = parsePreferencesPayload(parsed.payload); - return preferences === null - ? null - : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences }; -} - -async function savePreferencesJson( - encoded: string, - updatedAt = nextPreferencesUpdatedAt(), -): Promise { - lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, updatedAt); - try { - await mobileDatabaseRuntime.runPromise( - MobileDatabase.pipe( - Effect.flatMap((database) => database.savePreferencesJson(encoded, updatedAt)), - ), - ); - } catch (cause) { - console.warn( - "[mobile-storage] database unavailable, saving preferences to secure storage", - cause, - ); - await writeJsonStorageItem(PREFERENCES_FALLBACK_KEY, { payload: encoded, updatedAt }); - return; - } - - await deleteStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { - console.warn("[mobile-storage] could not remove preferences fallback", cause); - }); -} - -export async function loadPreferences(): Promise { - let databaseAvailable = true; - const storedJson = await mobileDatabaseRuntime - .runPromise(MobileDatabase.pipe(Effect.flatMap((database) => database.loadPreferencesJson))) - .catch((cause) => { - databaseAvailable = false; - console.warn("[mobile-storage] database unavailable, using legacy preferences", cause); - return Option.none(); - }); - const fallbackJson = await readStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { - if (Option.isNone(storedJson)) { - throw cause; - } - console.warn("[mobile-storage] could not inspect preferences fallback", cause); - return null; - }); - const fallback = parsePreferencesFallback(fallbackJson); - const storedPreferences = Option.isSome(storedJson) - ? parsePreferencesPayload(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; - lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, fallback.updatedAt); - if (databaseAvailable) { - await savePreferencesJson(fallback.payload, fallback.updatedAt); - } - } else if (storedPreferences !== null && Option.isSome(storedJson)) { - parsed = storedPreferences; - lastPreferencesUpdatedAt = Math.max(lastPreferencesUpdatedAt, storedJson.value.updatedAt); - if (fallbackJson !== null) { - await deleteStorageItem(PREFERENCES_FALLBACK_KEY).catch((cause) => { - console.warn("[mobile-storage] could not remove stale preferences fallback", cause); - }); - } - } - - if (parsed === null) { - const legacyJson = await readStorageItem(PREFERENCES_KEY); - const legacyPreferences = parsePreferencesPayload(legacyJson); - parsed = legacyPreferences; - if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) { - await savePreferencesJson(legacyJson); - await deleteStorageItem(PREFERENCES_KEY).catch((cause) => { - console.warn("[mobile-storage] could not remove migrated preferences", cause); - }); - } - } - - if (parsed === null) { - return {}; - } - - 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; -} - -// 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), - }; - let encoded: string; - try { - encoded = JSON.stringify(next); - } catch (cause) { - throw new MobileStorageEncodeError({ key: PREFERENCES_KEY, cause }); - } - await savePreferencesJson(encoded); - 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..ad23eabe153 --- /dev/null +++ b/apps/mobile/src/persistence/imperative.ts @@ -0,0 +1,50 @@ +import * as Effect from "effect/Effect"; + +import * as MobilePreferences from "./mobile-preferences"; +import { runtime } from "./runtime"; +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 clearSavedConnection = ( + environmentId: Parameters[0], +) => runStorage((storage) => storage.clearSavedConnection(environmentId)); + +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/mobile-database.test.ts b/apps/mobile/src/persistence/mobile-database.test.ts index bba5083fc55..56c3e31657e 100644 --- a/apps/mobile/src/persistence/mobile-database.test.ts +++ b/apps/mobile/src/persistence/mobile-database.test.ts @@ -1,8 +1,30 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; -import { decodeLegacyCacheRecord } from "./mobile-database"; +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, diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index c79fe4fd9c7..33eb50f640c 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -2,7 +2,6 @@ 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 ManagedRuntime from "effect/ManagedRuntime"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type { SQLiteDatabase } from "expo-sqlite"; @@ -221,29 +220,28 @@ export class MobileDatabase extends Context.Service< updatedAt: number, ) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobileDatabase") { - static readonly layer = Layer.effect( - MobileDatabase, - 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), - ); +>()("@t3tools/mobile/persistence/MobileDatabase") {} - 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(` +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, @@ -263,81 +261,81 @@ export class MobileDatabase extends Context.Service< 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"), }); + 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 + 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 + 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"), + environmentId, + kind, + cacheKey, + schemaVersion, + payload, + Date.now(), + ), + catch: databaseError("save-cache"), }).pipe(Effect.asVoid), - inspectCaches: Effect.tryPromise({ - try: () => - database.getAllAsync(` + ), + 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, @@ -347,50 +345,65 @@ export class MobileDatabase extends Context.Service< 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 + 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) + ), + 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), - ), - }); - }), - ); + 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 mobileDatabaseRuntime = ManagedRuntime.make(MobileDatabase.layer); +export const make = Effect.result(makeAvailable).pipe( + Effect.map((result) => + result._tag === "Success" ? result.success : makeUnavailable(result.failure), + ), +); -export const mobileDatabaseContextLayer: Layer.Layer = - Layer.effectContext(mobileDatabaseRuntime.contextEffect); +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/persistence/runtime.ts b/apps/mobile/src/persistence/runtime.ts new file mode 100644 index 00000000000..cf9cf69b6ec --- /dev/null +++ b/apps/mobile/src/persistence/runtime.ts @@ -0,0 +1,22 @@ +import * as Layer from "effect/Layer"; +import * as ManagedRuntime from "effect/ManagedRuntime"; + +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); + +export const runtime = ManagedRuntime.make(layer); + +/** Provides the app-owned persistence services to framework integration layers. */ +export const contextLayer = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index beb50d822fc..c6cdc860f78 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -2,11 +2,8 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; -import { - type ClientCacheKind, - MobileDatabase, - mobileDatabaseContextLayer, -} from "../persistence/mobile-database"; +import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import * as PersistenceRuntime from "../persistence/runtime"; export interface EnvironmentClientCacheSummary { readonly environmentId: EnvironmentId; @@ -61,7 +58,7 @@ function aggregateCacheSummary( }; } -const clientCacheRuntime = Atom.runtime(mobileDatabaseContextLayer); +const clientCacheRuntime = Atom.runtime(PersistenceRuntime.contextLayer); export const clientCacheSummaryAtom = clientCacheRuntime .atom( diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index 741b9f0e0a9..e4d0e419a11 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -18,7 +18,7 @@ vi.mock("../lib/runtime", () => ({ runtime: { runPromise: vi.fn() }, })); -import type { Preferences } from "../lib/storage"; +import type { Preferences } from "../persistence/mobile-preferences"; import { createMobilePreferencesState, MobilePreferencesLoadError, @@ -34,9 +34,26 @@ function deferred() { return { promise, resolve } as const; } -function makePreferencesState(service: MobilePreferencesStore["Service"]) { +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, MobilePreferencesStore.of(service))), + Atom.runtime(Layer.succeed(MobilePreferencesStore, completeService)), ); } diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index 918e04d8f54..d9fc4f5687b 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,46 +1,14 @@ -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 { AsyncResult, Atom } from "effect/unstable/reactivity"; -import { loadPreferences, savePreferencesPatch, type Preferences } from "../lib/storage"; +import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; +import * as PersistenceRuntime from "../persistence/runtime"; -export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( - "MobilePreferencesLoadError", - { cause: Schema.Defect() }, -) {} - -export class MobilePreferencesSaveError extends Schema.TaggedErrorClass()( - "MobilePreferencesSaveError", - { cause: Schema.Defect() }, -) {} - -export class MobilePreferencesStore extends Context.Service< +export { + MobilePreferencesLoadError, + MobilePreferencesSaveError, MobilePreferencesStore, - { - readonly load: Effect.Effect; - readonly savePatch: ( - patch: Partial, - ) => Effect.Effect; - } ->()("@t3tools/mobile/state/preferences/MobilePreferencesStore") { - static readonly layer = Layer.succeed( - MobilePreferencesStore, - MobilePreferencesStore.of({ - load: Effect.tryPromise({ - try: loadPreferences, - catch: (cause) => new MobilePreferencesLoadError({ cause }), - }), - savePatch: Effect.fn("MobilePreferencesStore.savePatch")((patch) => - Effect.tryPromise({ - try: () => savePreferencesPatch(patch), - catch: (cause) => new MobilePreferencesSaveError({ cause }), - }), - ), - }), - ); -} +} from "../persistence/mobile-preferences"; interface OptimisticPreferences { readonly values: Partial; @@ -149,7 +117,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime Date: Thu, 9 Jul 2026 10:16:36 +0200 Subject: [PATCH 23/28] refactor(mobile): keep persistence in app runtime Co-authored-by: codex --- apps/mobile/src/connection/platform.ts | 170 +++++++++--------- .../liveActivityPreferences.test.ts | 36 +++- .../liveActivityPreferences.ts | 24 ++- .../agent-awareness/registrationPayload.ts | 2 +- .../features/cloud/linkEnvironment.test.ts | 43 ++++- .../src/features/cloud/linkEnvironment.ts | 42 +++-- .../features/settings/SettingsRouteScreen.tsx | 24 ++- apps/mobile/src/lib/runtime.ts | 3 + apps/mobile/src/lib/storage.test.ts | 8 + apps/mobile/src/persistence/imperative.ts | 5 +- .../src/persistence/{runtime.ts => layer.ts} | 6 - apps/mobile/src/state/client-cache-state.ts | 4 +- apps/mobile/src/state/preferences.test.ts | 10 +- apps/mobile/src/state/preferences.ts | 4 +- 14 files changed, 240 insertions(+), 141 deletions(-) rename apps/mobile/src/persistence/{runtime.ts => layer.ts} (70%) diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index d9ca1c9e245..cc51b56ee70 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -25,11 +25,11 @@ import * as Network from "expo-network"; import { AppState } from "react-native"; import { authClientMetadata } from "../lib/authClientMetadata"; -import { loadOrCreateAgentAwarenessDeviceId } from "../persistence/imperative"; +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"; -import * as PersistenceRuntime from "../persistence/runtime"; import { connectionStorageLayer } from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { @@ -84,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( @@ -170,7 +175,10 @@ const platformConnectionSourceLayer = Layer.succeed( ); const providedConnectionStorageLayer = connectionStorageLayer.pipe( - Layer.provide(PersistenceRuntime.contextLayer), + Layer.provide(Runtime.runtimeContextLayer), +); +const providedCapabilitiesLayer = capabilitiesLayer.pipe( + Layer.provide(Runtime.runtimeContextLayer), ); const environmentOwnedDataCleanupLayer = Layer.succeed( @@ -196,10 +204,10 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( type ConnectionPlatformLayerSource = | typeof providedConnectionStorageLayer - | typeof PersistenceRuntime.contextLayer + | typeof Runtime.runtimeContextLayer | typeof connectivityLayer | typeof wakeupsLayer - | typeof capabilitiesLayer + | typeof providedCapabilitiesLayer | typeof platformConnectionSourceLayer | typeof environmentOwnedDataCleanupLayer; @@ -209,10 +217,10 @@ export const connectionPlatformLayer: Layer.Layer< Layer.Services > = Layer.mergeAll( providedConnectionStorageLayer, - PersistenceRuntime.contextLayer, + Runtime.runtimeContextLayer, connectivityLayer, wakeupsLayer, - capabilitiesLayer, + providedCapabilitiesLayer, platformConnectionSourceLayer, environmentOwnedDataCleanupLayer, ); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 44a2cfaae24..97fdefc519f 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,15 +7,24 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../persistence/imperative"; +import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; +import { MobileStorage } from "../../persistence/mobile-storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; -vi.mock("../../persistence/imperative", () => ({ - 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" }, +})); + +const savePreferencesPatch = vi.fn((patch: Record) => Effect.succeed(patch)); + vi.mock("../cloud/linkEnvironment", () => ({ linkEnvironmentToCloud: vi.fn(() => Effect.void), })); @@ -40,6 +49,27 @@ const testLayer = Layer.mergeAll( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), ), + Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: Effect.succeed({}), + savePatch: savePreferencesPatch, + 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, + }), + ), ); describe("liveActivityPreferences", () => { diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index e668a6c2220..2031814a6d5 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -4,7 +4,8 @@ import { HttpClient } from "effect/unstable/http"; import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { savePreferencesPatch } from "../../persistence/imperative"; +import * as MobilePreferences from "../../persistence/mobile-preferences"; +import * as MobileStorage from "../../persistence/mobile-storage"; import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; @@ -24,12 +25,23 @@ export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; readonly clerkToken: string | null; readonly connections: ReadonlyArray; -}): Effect.Effect { +}): Effect.Effect< + void, + unknown, + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | MobilePreferences.MobilePreferencesStore + | MobileStorage.MobileStorage +> { return Effect.gen(function* () { - yield* Effect.tryPromise({ - try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), - catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), - }); + const preferences = yield* MobilePreferences.MobilePreferencesStore; + yield* preferences + .savePatch({ liveActivitiesEnabled: input.enabled }) + .pipe( + Effect.mapError( + (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), + ), + ); yield* refreshAgentAwarenessRegistration(); diff --git a/apps/mobile/src/features/agent-awareness/registrationPayload.ts b/apps/mobile/src/features/agent-awareness/registrationPayload.ts index 98515d8351f..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 "../../persistence/imperative"; +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/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 78360d4adb7..0279df7e4db 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -7,6 +7,8 @@ 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, @@ -36,11 +38,14 @@ vi.mock("react-native", () => ({ }, })); -vi.mock("../../persistence/imperative", () => ({ - 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 +74,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 +106,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())); @@ -705,10 +735,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("../../persistence/imperative")); - 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) { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index a6a8d06b4ae..b8a96ebecee 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 "../../persistence/imperative"; +import * as MobilePreferences from "../../persistence/mobile-preferences"; +import * as MobileStorage from "../../persistence/mobile-storage"; import { resolveCloudPublicConfig } from "./publicConfig"; const RELAY_STATUS_AND_CONNECT_SCOPES = [ @@ -262,7 +263,10 @@ export function linkEnvironmentToCloud(input: { }): Effect.Effect< void, CloudEnvironmentLinkError, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient + | HttpClient.HttpClient + | ManagedRelay.ManagedRelayClient + | MobilePreferences.MobilePreferencesStore + | MobileStorage.MobileStorage > { return Effect.gen(function* () { if (!input.connection.bearerToken) { @@ -273,14 +277,14 @@ 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 storage = yield* MobileStorage.MobileStorage; + const preferencesStore = yield* MobilePreferences.MobilePreferencesStore; + const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), + ); + const preferences = yield* preferencesStore.load.pipe( + Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")), + ); const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; const challenge = yield* relayClient .createEnvironmentLinkChallenge({ @@ -460,10 +464,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 +561,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 +579,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/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 22e597b125a..20d29d5ed65 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 { 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 "../../persistence/imperative"; import { useThemeColor } from "../../lib/useThemeColor"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -122,6 +124,7 @@ function LocalSettingsRouteScreen() { } function ConfiguredSettingsRouteScreen() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); @@ -167,16 +170,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(() => 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 b655c6feee4..a97252c7b72 100644 --- a/apps/mobile/src/lib/storage.test.ts +++ b/apps/mobile/src/lib/storage.test.ts @@ -80,6 +80,14 @@ 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", diff --git a/apps/mobile/src/persistence/imperative.ts b/apps/mobile/src/persistence/imperative.ts index ad23eabe153..f2c8b0b5352 100644 --- a/apps/mobile/src/persistence/imperative.ts +++ b/apps/mobile/src/persistence/imperative.ts @@ -1,7 +1,7 @@ import * as Effect from "effect/Effect"; +import { runtime } from "../lib/runtime"; import * as MobilePreferences from "./mobile-preferences"; -import { runtime } from "./runtime"; import * as MobileStorage from "./mobile-storage"; export type { Preferences } from "./mobile-preferences"; @@ -26,9 +26,6 @@ export const loadSavedConnections = () => runStorage((storage) => storage.loadSa export const saveConnection = ( connection: Parameters[0], ) => runStorage((storage) => storage.saveConnection(connection)); -export const clearSavedConnection = ( - environmentId: Parameters[0], -) => runStorage((storage) => storage.clearSavedConnection(environmentId)); export const loadPreferences = () => runPreferences((store) => store.load); export const savePreferencesPatch = (patch: Partial) => diff --git a/apps/mobile/src/persistence/runtime.ts b/apps/mobile/src/persistence/layer.ts similarity index 70% rename from apps/mobile/src/persistence/runtime.ts rename to apps/mobile/src/persistence/layer.ts index cf9cf69b6ec..5a9d1dacff3 100644 --- a/apps/mobile/src/persistence/runtime.ts +++ b/apps/mobile/src/persistence/layer.ts @@ -1,5 +1,4 @@ import * as Layer from "effect/Layer"; -import * as ManagedRuntime from "effect/ManagedRuntime"; import * as EnvironmentCacheStore from "../connection/environment-cache-store"; import * as MobileDatabase from "./mobile-database"; @@ -15,8 +14,3 @@ const dependentLayer = Layer.mergeAll( ).pipe(Layer.provide(baseLayer)); export const layer = Layer.merge(baseLayer, dependentLayer); - -export const runtime = ManagedRuntime.make(layer); - -/** Provides the app-owned persistence services to framework integration layers. */ -export const contextLayer = Layer.effectContext(runtime.contextEffect); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index c6cdc860f78..3912857b575 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; -import * as PersistenceRuntime from "../persistence/runtime"; +import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { readonly environmentId: EnvironmentId; @@ -58,7 +58,7 @@ function aggregateCacheSummary( }; } -const clientCacheRuntime = Atom.runtime(PersistenceRuntime.contextLayer); +const clientCacheRuntime = Atom.runtime(Runtime.runtimeContextLayer); export const clientCacheSummaryAtom = clientCacheRuntime .atom( diff --git a/apps/mobile/src/state/preferences.test.ts b/apps/mobile/src/state/preferences.test.ts index e4d0e419a11..c53594eb230 100644 --- a/apps/mobile/src/state/preferences.test.ts +++ b/apps/mobile/src/state/preferences.test.ts @@ -14,9 +14,13 @@ vi.mock("react-native", () => ({ Platform: { OS: "ios" }, })); -vi.mock("../lib/runtime", () => ({ - runtime: { runPromise: vi.fn() }, -})); +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 { diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d9fc4f5687b..d173cf55be5 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -2,7 +2,7 @@ import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; -import * as PersistenceRuntime from "../persistence/runtime"; +import * as Runtime from "../lib/runtime"; export { MobilePreferencesLoadError, @@ -117,7 +117,7 @@ export function createMobilePreferencesState(runtime: Atom.AtomRuntime Date: Thu, 9 Jul 2026 10:27:44 +0200 Subject: [PATCH 24/28] fix(mobile): sync live activity preference state Co-authored-by: codex --- .../src/features/settings/SettingsRouteScreen.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 20d29d5ed65..65803b1f3b2 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -1,5 +1,5 @@ import { useAuth, useUser } from "@clerk/expo"; -import { useAtomValue } from "@effect/atom-react"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; import * as Updates from "expo-updates"; @@ -34,7 +34,7 @@ import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -125,6 +125,7 @@ function LocalSettingsRouteScreen() { function ConfiguredSettingsRouteScreen() { const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); @@ -281,6 +282,7 @@ function ConfiguredSettingsRouteScreen() { return; } + savePreferences({ liveActivitiesEnabled: true }); const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ @@ -320,7 +322,7 @@ 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, promptSignIn, savePreferences]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { @@ -345,6 +347,7 @@ function ConfiguredSettingsRouteScreen() { (enabled: boolean) => { if (!enabled) { setLiveActivityStatus("disabled"); + savePreferences({ liveActivitiesEnabled: false }); void (async () => { let token: string | null = null; if (isSignedIn) { @@ -387,7 +390,7 @@ function ConfiguredSettingsRouteScreen() { void linkEnvironments(); }, - [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], + [connections, getToken, isSignedIn, linkEnvironments, promptSignIn, savePreferences], ); const openAccount = useCallback(() => { From 8f7e4f20095d1676daea172c7af482b860624155 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 10:46:00 +0200 Subject: [PATCH 25/28] fix(mobile): commit live activity preferences after sync Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 47 +++++++++---------- .../liveActivityPreferences.ts | 43 +++++------------ .../remoteRegistration.test.ts | 10 ++++ .../agent-awareness/remoteRegistration.ts | 35 +++++++++++++- .../features/cloud/linkEnvironment.test.ts | 41 ++++++++++++++++ .../src/features/cloud/linkEnvironment.ts | 41 +++++++++++----- .../features/settings/SettingsRouteScreen.tsx | 5 +- 7 files changed, 149 insertions(+), 73 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 97fdefc519f..0467afebada 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,11 +7,10 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; -import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; -import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; vi.mock("expo-secure-store", () => ({ deleteItemAsync: vi.fn(), @@ -23,14 +22,12 @@ vi.mock("react-native", () => ({ Platform: { OS: "ios" }, })); -const savePreferencesPatch = vi.fn((patch: Record) => Effect.succeed(patch)); - 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 = { @@ -49,14 +46,6 @@ const testLayer = Layer.mergeAll( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), ), - Layer.succeed( - MobilePreferencesStore, - MobilePreferencesStore.of({ - load: Effect.succeed({}), - savePatch: savePreferencesPatch, - update: () => Effect.succeed({}), - }), - ), Layer.succeed( MobileStorage, MobileStorage.of({ @@ -85,11 +74,13 @@ describe("liveActivityPreferences", () => { 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)), ); @@ -102,11 +93,13 @@ describe("liveActivityPreferences", () => { 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)), ); @@ -119,9 +112,10 @@ describe("liveActivityPreferences", () => { 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)), ); @@ -138,10 +132,11 @@ describe("liveActivityPreferences", () => { 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)); }); diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 2031814a6d5..ddc7bd76e6a 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,25 +1,11 @@ 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 * as MobilePreferences from "../../persistence/mobile-preferences"; import * as MobileStorage from "../../persistence/mobile-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}).`; - } -} +import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; +import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; export function setLiveActivityUpdatesEnabled(input: { readonly enabled: boolean; @@ -28,22 +14,12 @@ export function setLiveActivityUpdatesEnabled(input: { }): Effect.Effect< void, unknown, - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | MobilePreferences.MobilePreferencesStore - | MobileStorage.MobileStorage + HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | MobileStorage.MobileStorage > { return Effect.gen(function* () { - const preferences = yield* MobilePreferences.MobilePreferencesStore; - yield* preferences - .savePatch({ liveActivitiesEnabled: input.enabled }) - .pipe( - Effect.mapError( - (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), - ), - ); - - yield* refreshAgentAwarenessRegistration(); + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: input.enabled, + }); const clerkToken = input.clerkToken; if (!clerkToken) { @@ -52,7 +28,12 @@ export function setLiveActivityUpdatesEnabled(input: { yield* Effect.forEach( input.connections.filter((connection) => connection.bearerToken !== null), - (connection) => linkEnvironmentToCloud({ clerkToken, connection }), + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: input.enabled, + }), { concurrency: "unbounded" }, ); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index c5f24cfdb72..01284450725 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -27,6 +27,7 @@ import { AgentAwarenessOperationError, __resetAgentAwarenessRemoteRegistrationForTest, getAgentAwarenessRegistrationStatus, + mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, normalizeAgentAwarenessRelayBaseUrl, @@ -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 74464270354..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, @@ -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/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 0279df7e4db..246eb3f34fa 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -13,6 +13,7 @@ import { MobileStorage } from "../../persistence/mobile-storage"; import { cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, + linkEnvironmentToCloudWithPreference, connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, @@ -176,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", () => { @@ -788,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 b8a96ebecee..5423686e048 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -257,17 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { return Effect.void; } -export function linkEnvironmentToCloud(input: { +interface LinkEnvironmentToCloudInput { readonly connection: SavedRemoteConnection; readonly clerkToken: string; -}): Effect.Effect< - void, - CloudEnvironmentLinkError, +} + +type LinkEnvironmentToCloudRequirements = | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient - | MobilePreferences.MobilePreferencesStore - | MobileStorage.MobileStorage -> { + | MobileStorage.MobileStorage; + +export function linkEnvironmentToCloudWithPreference( + input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, +): Effect.Effect { return Effect.gen(function* () { if (!input.connection.bearerToken) { return yield* new CloudEnvironmentLinkError({ @@ -278,14 +280,10 @@ export function linkEnvironmentToCloud(input: { const relayUrl = yield* requireRelayUrl(); const relayClient = yield* ManagedRelay.ManagedRelayClient; const storage = yield* MobileStorage.MobileStorage; - const preferencesStore = yield* MobilePreferences.MobilePreferencesStore; const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), ); - const preferences = yield* preferencesStore.load.pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")), - ); - const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; + const liveActivitiesEnabled = input.liveActivitiesEnabled; const challenge = yield* relayClient .createEnvironmentLinkChallenge({ clerkToken: input.clerkToken, @@ -354,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< diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 65803b1f3b2..31255b532a6 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -282,7 +282,6 @@ function ConfiguredSettingsRouteScreen() { return; } - savePreferences({ liveActivitiesEnabled: true }); const updateResult = await settleAsyncResult(() => runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ @@ -304,6 +303,7 @@ function ConfiguredSettingsRouteScreen() { return; } + savePreferences({ liveActivitiesEnabled: true }); refreshManagedRelayEnvironments(); setLiveActivityStatus("enabled"); // The environment link can succeed while this device's own registration @@ -347,7 +347,6 @@ function ConfiguredSettingsRouteScreen() { (enabled: boolean) => { if (!enabled) { setLiveActivityStatus("disabled"); - savePreferences({ liveActivitiesEnabled: false }); void (async () => { let token: string | null = null; if (isSignedIn) { @@ -373,11 +372,13 @@ function ConfiguredSettingsRouteScreen() { ), ); if (updateResult._tag === "Failure") { + setLiveActivityStatus("enabled"); reportAtomCommandResult(updateResult, { label: "live activity disable", }); return; } + savePreferences({ liveActivitiesEnabled: false }); refreshManagedRelayEnvironments(); })(); return; From f3c1cc8974afa46df54f6a99e9078c13c2112b17 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 10:56:10 +0200 Subject: [PATCH 26/28] fix(mobile): roll back partial preference sync Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 48 ++++++++++- .../liveActivityPreferences.ts | 86 ++++++++++++------- 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 0467afebada..54ea6e9c729 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,8 +7,12 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; +import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; -import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; +import { + CloudEnvironmentLinkError, + linkEnvironmentToCloudWithPreference, +} from "../cloud/linkEnvironment"; import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; @@ -42,6 +46,14 @@ const connection: SavedRemoteConnection = { const testLayer = Layer.mergeAll( Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), + Layer.succeed( + MobilePreferencesStore, + MobilePreferencesStore.of({ + load: Effect.succeed({ liveActivitiesEnabled: true }), + savePatch: () => Effect.die("unexpected preference write"), + update: () => Effect.die("unexpected preference update"), + }), + ), Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), @@ -140,4 +152,38 @@ describe("liveActivityPreferences", () => { }); }).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, + 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 ddc7bd76e6a..8de92650273 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,40 +1,64 @@ import * as Effect from "effect/Effect"; -import { HttpClient } from "effect/unstable/http"; -import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { SavedRemoteConnection } from "../../lib/connection"; -import * as MobileStorage from "../../persistence/mobile-storage"; +import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; -export function setLiveActivityUpdatesEnabled(input: { - readonly enabled: boolean; - readonly clerkToken: string | null; - readonly connections: ReadonlyArray; -}): Effect.Effect< - void, - unknown, - HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | MobileStorage.MobileStorage -> { - return Effect.gen(function* () { - yield* updateAgentAwarenessRegistrationPreferences({ - liveActivitiesEnabled: input.enabled, +export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( + function* (input: { + readonly enabled: boolean; + readonly clerkToken: string | null; + readonly connections: ReadonlyArray; + }) { + const preferences = yield* MobilePreferencesStore; + const previousEnabled = (yield* preferences.load).liveActivitiesEnabled !== false; + 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" }, + ); + }); + + const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { + yield* updateAgentAwarenessRegistrationPreferences({ + liveActivitiesEnabled: previousEnabled, + }).pipe(Effect.catchCause(() => Effect.void)); + + const clerkToken = input.clerkToken; + if (!clerkToken) return; + + yield* Effect.forEach( + linkedConnections, + (connection) => + linkEnvironmentToCloudWithPreference({ + clerkToken, + connection, + liveActivitiesEnabled: previousEnabled, + }).pipe(Effect.catchCause(() => Effect.void)), + { concurrency: "unbounded" }, + ); }); - const clerkToken = input.clerkToken; - if (!clerkToken) { - return; - } - - yield* Effect.forEach( - input.connections.filter((connection) => connection.bearerToken !== null), - (connection) => - linkEnvironmentToCloudWithPreference({ - clerkToken, - connection, - liveActivitiesEnabled: input.enabled, - }), - { concurrency: "unbounded" }, + yield* updateRelayPreference(input.enabled).pipe( + Effect.onError(() => restoreRelayPreference()), ); - }); -} + }, +); From 43884b5c6124b9c620b117914702b853394e2aad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 11:03:47 +0200 Subject: [PATCH 27/28] fix(mobile): make preference rollback observable Co-authored-by: codex --- .../liveActivityPreferences.test.ts | 14 ++++------- .../liveActivityPreferences.ts | 23 +++++++++++++------ .../features/settings/SettingsRouteScreen.tsx | 2 ++ 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts index 54ea6e9c729..9796a8ec242 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts @@ -7,7 +7,6 @@ import * as Layer from "effect/Layer"; import { HttpClient } from "effect/unstable/http"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { CloudEnvironmentLinkError, @@ -46,14 +45,6 @@ const connection: SavedRemoteConnection = { const testLayer = Layer.mergeAll( Layer.succeed(ManagedRelay.ManagedRelayClient, null as never), - Layer.succeed( - MobilePreferencesStore, - MobilePreferencesStore.of({ - load: Effect.succeed({ liveActivitiesEnabled: true }), - savePatch: () => Effect.die("unexpected preference write"), - update: () => Effect.die("unexpected preference update"), - }), - ), Layer.succeed( HttpClient.HttpClient, HttpClient.make(() => Effect.die("unexpected HTTP request")), @@ -82,6 +73,7 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: "clerk-token", connections: [connection], }); @@ -101,6 +93,7 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection], }); @@ -120,6 +113,7 @@ describe("liveActivityPreferences", () => { Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: null, connections: [connection], }); @@ -140,6 +134,7 @@ describe("liveActivityPreferences", () => { return Effect.gen(function* () { yield* setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: "clerk-token", connections: [connection, managedConnection], }); @@ -162,6 +157,7 @@ describe("liveActivityPreferences", () => { const exit = yield* Effect.exit( setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: "clerk-token", connections: [connection], }), diff --git a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts index 8de92650273..1b30769e7bc 100644 --- a/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts +++ b/apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts @@ -1,18 +1,16 @@ import * as Effect from "effect/Effect"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; 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 preferences = yield* MobilePreferencesStore; - const previousEnabled = (yield* preferences.load).liveActivitiesEnabled !== false; const linkedConnections = input.connections.filter( (connection) => connection.bearerToken !== null, ); @@ -39,8 +37,12 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () { yield* updateAgentAwarenessRegistrationPreferences({ - liveActivitiesEnabled: previousEnabled, - }).pipe(Effect.catchCause(() => Effect.void)); + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Could not restore Live Activity device preference.", cause), + ), + ); const clerkToken = input.clerkToken; if (!clerkToken) return; @@ -51,8 +53,15 @@ export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEn linkEnvironmentToCloudWithPreference({ clerkToken, connection, - liveActivitiesEnabled: previousEnabled, - }).pipe(Effect.catchCause(() => Effect.void)), + liveActivitiesEnabled: input.previousEnabled, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning( + `Could not restore Live Activity preference for environment ${connection.environmentId}.`, + cause, + ), + ), + ), { concurrency: "unbounded" }, ); }); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 31255b532a6..f57663a3e1a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -286,6 +286,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, + previousEnabled: false, clerkToken: tokenResult.value, connections, }), @@ -366,6 +367,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, + previousEnabled: true, clerkToken: token, connections, }), From 9dd6245cc54e898f501681d67557929e2e23ac16 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 9 Jul 2026 11:11:22 +0200 Subject: [PATCH 28/28] fix(mobile): preserve actual live activity state Co-authored-by: codex --- .../features/settings/SettingsRouteScreen.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index f57663a3e1a..6f9d5bc85d0 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -135,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; @@ -286,7 +289,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: true, - previousEnabled: false, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: tokenResult.value, connections, }), @@ -323,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, savePreferences]); + }, [ + connections, + environmentCount, + getToken, + isSignedIn, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ]); const handleDeviceNotificationsChange = useCallback( (enabled: boolean) => { @@ -367,7 +378,7 @@ function ConfiguredSettingsRouteScreen() { runtime.runPromiseExit( setLiveActivityUpdatesEnabled({ enabled: false, - previousEnabled: true, + previousEnabled: liveActivitiesPreferenceEnabled, clerkToken: token, connections, }), @@ -393,7 +404,15 @@ function ConfiguredSettingsRouteScreen() { void linkEnvironments(); }, - [connections, getToken, isSignedIn, linkEnvironments, promptSignIn, savePreferences], + [ + connections, + getToken, + isSignedIn, + linkEnvironments, + liveActivitiesPreferenceEnabled, + promptSignIn, + savePreferences, + ], ); const openAccount = useCallback(() => {