authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-09 02:23:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-09 11:23:51+02:00
log2250e3ee7a2c2bf3abca1aeccce90de30625736b
tree388fcf30916c23534c5c7662966b4823f7054926
parent3201e00ad4f59cc287517e61a19b069b497c16d2
signature Signed by PGP key B5690EEEBB952194

feat(client): persist offline environment data and mobile preferences (#3795)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: codex <codex@users.noreply.github.com>

48 files changed, 4619 insertions(+), 929 deletions(-)

.macroscope/check-run-agents/effect-service-conventions.md+9
......@@ -43,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b
4343- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`.
4444- `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports.
4545
46## Dependency acquisition and runtime boundaries
47
48- 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.
49- 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.
50- `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.
51- 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.
52- 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.
53- 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.
54
4655## Errors and predicates
4756
4857- Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data.
apps/mobile/app.config.ts+1
......@@ -123,6 +123,7 @@ const config: ExpoConfig = {
123123 plugins: [
124124 "expo-font",
125125 "expo-secure-store",
126 "expo-sqlite",
126127 ["@clerk/expo", { theme: "./clerk-theme.json" }],
127128 "expo-web-browser",
128129 [
apps/mobile/eas.json+8-4
......@@ -8,7 +8,8 @@
88 "development": {
99 "corepack": true,
1010 "env": {
11 "APP_VARIANT": "development"
11 "APP_VARIANT": "development",
12 "NODE_OPTIONS": "--max-old-space-size=4096"
1213 },
1314 "channel": "development",
1415 "developmentClient": true,
......@@ -17,7 +18,8 @@
1718 "preview": {
1819 "corepack": true,
1920 "env": {
20 "APP_VARIANT": "preview"
21 "APP_VARIANT": "preview",
22 "NODE_OPTIONS": "--max-old-space-size=4096"
2123 },
2224 "channel": "preview",
2325 "environment": "preview",
......@@ -27,7 +29,8 @@
2729 "corepack": true,
2830 "env": {
2931 "APP_VARIANT": "preview",
30 "MOBILE_VERSION_POLICY": "fingerprint"
32 "MOBILE_VERSION_POLICY": "fingerprint",
33 "NODE_OPTIONS": "--max-old-space-size=4096"
3134 },
3235 "channel": "preview",
3336 "environment": "preview",
......@@ -40,7 +43,8 @@
4043 "production": {
4144 "corepack": true,
4245 "env": {
43 "APP_VARIANT": "production"
46 "APP_VARIANT": "production",
47 "NODE_OPTIONS": "--max-old-space-size=4096"
4448 },
4549 "channel": "production",
4650 "environment": "production",
apps/mobile/package.json+2
......@@ -6,6 +6,7 @@
66 "scripts": {
77 "dev": "expo start --clear",
88 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",
9 "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear'",
910 "start": "expo start",
1011 "start:dev": "APP_VARIANT=development expo start",
1112 "start:preview": "APP_VARIANT=preview expo start",
......@@ -86,6 +87,7 @@
8687 "expo-paste-input": "^0.1.15",
8788 "expo-secure-store": "~56.0.4",
8889 "expo-splash-screen": "~56.0.10",
90 "expo-sqlite": "~56.0.5",
8991 "expo-symbols": "~56.0.6",
9092 "expo-updates": "~56.0.19",
9193 "expo-web-browser": "~56.0.5",
apps/mobile/src/Stack.tsx+8
......@@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr
4141import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
4242import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
4343import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
44import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
4445import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
4546import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
4647import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
......@@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({
147148 title: "Appearance",
148149 },
149150 }),
151 SettingsClientStorage: createNativeStackScreen({
152 screen: SettingsClientStorageRouteScreen,
153 linking: "client-storage",
154 options: {
155 title: "Client Storage",
156 },
157 }),
150158 SettingsAuth: createNativeStackScreen({
151159 screen: SettingsAuthRouteScreen,
152160 linking: "auth",
apps/mobile/src/connection/catalog-store.ts+25-26
......@@ -10,6 +10,7 @@ import * as Ref from "effect/Ref";
1010import * as Schema from "effect/Schema";
1111import * as Semaphore from "effect/Semaphore";
1212
13import * as MobileSecureStorage from "../persistence/mobile-secure-storage";
1314import { migrateLegacyConnectionCatalog } from "./migration";
1415
1516export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";
......@@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) {
2223 });
2324}
2425
26const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
27const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson);
28const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
29
2530const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) {
26 const parsed = yield* Effect.try({
27 try: () => JSON.parse(raw) as unknown,
28 catch: (cause) => catalogError("decode", cause),
29 });
30 return yield* Effect.fromResult(
31 Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed),
32 ).pipe(Effect.mapError((cause) => catalogError("decode", cause)));
31 return yield* decodeConnectionCatalogDocument(raw).pipe(
32 Effect.mapError((cause) => catalogError("decode", cause)),
33 );
3334});
3435
3536const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* (
3637 catalog: ConnectionCatalogDocumentType,
3738) {
38 const encoded = yield* Effect.fromResult(
39 Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog),
40 ).pipe(Effect.mapError((cause) => catalogError("encode", cause)));
41 return JSON.stringify(encoded);
39 return yield* encodeConnectionCatalogDocument(catalog).pipe(
40 Effect.mapError((cause) => catalogError("encode", cause)),
41 );
4242});
4343
4444interface CatalogStore {
......@@ -48,20 +48,19 @@ interface CatalogStore {
4848 ) => Effect.Effect<void, ConnectionTransientError>;
4949}
5050
51export interface SecureCatalogStorage {
52 readonly getItem: (key: string) => Effect.Effect<string | null, ConnectionTransientError>;
53 readonly setItem: (key: string, value: string) => Effect.Effect<void, ConnectionTransientError>;
54 readonly deleteItem: (key: string) => Effect.Effect<void, ConnectionTransientError>;
55}
56
57export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* (
58 storage: SecureCatalogStorage,
59) {
51export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () {
52 const storage = yield* MobileSecureStorage.MobileSecureStorage;
53 const getItem = (key: string) =>
54 storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause)));
55 const setItem = (key: string, value: string) =>
56 storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause)));
57 const deleteItem = (key: string) =>
58 storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause)));
6059 const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none());
6160 const lock = yield* Semaphore.make(1);
6261
6362 const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () {
64 const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY);
63 const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY);
6564 const catalog =
6665 legacyRaw === null || legacyRaw.trim() === ""
6766 ? EMPTY_CONNECTION_CATALOG_DOCUMENT
......@@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
7574 );
7675 if (legacyRaw !== null && legacyRaw.trim() !== "") {
7776 const encoded = yield* encodeCatalog(catalog);
78 yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded);
79 yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY);
77 yield* setItem(CONNECTION_CATALOG_KEY, encoded);
78 yield* deleteItem(LEGACY_CONNECTIONS_KEY);
8079 }
8180 return catalog;
8281 });
......@@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
8685 if (Option.isSome(cached)) {
8786 return cached.value;
8887 }
89 const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY);
88 const raw = yield* getItem(CONNECTION_CATALOG_KEY);
9089 let catalog: ConnectionCatalogDocumentType;
9190 if (raw !== null && raw.trim() !== "") {
9291 catalog = yield* decodeCatalog(raw).pipe(
9392 Effect.catch((error) =>
9493 Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe(
95 Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)),
94 Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)),
9695 Effect.andThen(loadLegacyCatalog()),
9796 ),
9897 ),
......@@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
111110 Effect.gen(function* () {
112111 const next = transform(yield* loadUnlocked());
113112 const encoded = yield* encodeCatalog(next);
114 yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded);
113 yield* setItem(CONNECTION_CATALOG_KEY, encoded);
115114 yield* Ref.set(state, Option.some(next));
116115 }),
117116 );
apps/mobile/src/connection/environment-cache-store.test.ts created+97
......@@ -0,0 +1,97 @@
1import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5
6import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
7import { make } from "./environment-cache-store";
8
9const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
10const REFS: VcsListRefsResult = {
11 refs: [
12 {
13 name: "main",
14 current: true,
15 isDefault: true,
16 worktreePath: "/repo",
17 },
18 ],
19 isRepo: true,
20 hasPrimaryRemote: true,
21 nextCursor: null,
22 totalCount: 1,
23};
24
25function cacheId(environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string) {
26 return `${environmentId}:${kind}:${cacheKey}`;
27}
28
29function makeDatabase() {
30 const values = new Map<string, string>();
31 const removed: Array<string> = [];
32 const database = MobileDatabase.of({
33 loadCache: (environmentId, kind, cacheKey) =>
34 Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))),
35 saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) =>
36 Effect.sync(() => {
37 values.set(cacheId(environmentId, kind, cacheKey), payload);
38 }),
39 removeCache: (environmentId, kind, cacheKey) =>
40 Effect.sync(() => {
41 const id = cacheId(environmentId, kind, cacheKey);
42 removed.push(id);
43 values.delete(id);
44 }),
45 clearEnvironmentCache: (environmentId) =>
46 Effect.sync(() => {
47 for (const key of values.keys()) {
48 if (key.startsWith(`${environmentId}:`)) values.delete(key);
49 }
50 }),
51 clearAllCaches: Effect.sync(() => values.clear()),
52 inspectCaches: Effect.succeed([]),
53 loadPreferencesJson: Effect.succeed(Option.none()),
54 savePreferencesJson: () => Effect.void,
55 });
56 return { database, removed, values };
57}
58
59describe("mobile SQLite environment cache store", () => {
60 it.effect("round-trips schema-validated VCS refs", () =>
61 Effect.gen(function* () {
62 const memory = makeDatabase();
63 const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
64
65 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
66
67 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.some(REFS));
68 }),
69 );
70
71 it.effect("deletes a corrupt cache record and treats it as a miss", () =>
72 Effect.gen(function* () {
73 const memory = makeDatabase();
74 const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
75 const id = cacheId(ENVIRONMENT_ID, "vcs-refs", "/repo");
76 memory.values.set(id, "{not-json");
77
78 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
79 expect(memory.removed).toEqual([id]);
80 }),
81 );
82
83 it.effect("clears one environment without touching another", () =>
84 Effect.gen(function* () {
85 const memory = makeDatabase();
86 const store = yield* make().pipe(Effect.provideService(MobileDatabase, memory.database));
87 const otherEnvironmentId = EnvironmentId.make("environment-2");
88 yield* store.saveVcsRefs(ENVIRONMENT_ID, "/repo", REFS);
89 yield* store.saveVcsRefs(otherEnvironmentId, "/repo", REFS);
90
91 yield* store.clear(ENVIRONMENT_ID);
92
93 expect(yield* store.loadVcsRefs(ENVIRONMENT_ID, "/repo")).toEqual(Option.none());
94 expect(yield* store.loadVcsRefs(otherEnvironmentId, "/repo")).toEqual(Option.some(REFS));
95 }),
96 );
97});
apps/mobile/src/connection/environment-cache-store.ts created+234
......@@ -0,0 +1,234 @@
1import {
2 ConnectionPersistenceError,
3 EnvironmentCacheStore,
4} from "@t3tools/client-runtime/platform";
5import {
6 type EnvironmentId,
7 OrchestrationShellSnapshot,
8 OrchestrationThreadDetailSnapshot,
9 ServerConfig,
10 VcsListRefsResult,
11} from "@t3tools/contracts";
12import * as Effect from "effect/Effect";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Schema from "effect/Schema";
16
17import * as MobileDatabase from "../persistence/mobile-database";
18
19const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
20const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
21const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1;
22const VCS_REFS_CACHE_SCHEMA_VERSION = 1;
23
24const StoredShellSnapshot = Schema.Struct({
25 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
26 environmentId: Schema.String,
27 snapshot: OrchestrationShellSnapshot,
28});
29const StoredThreadSnapshot = Schema.Struct({
30 schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
31 environmentId: Schema.String,
32 threadId: Schema.String,
33 snapshot: OrchestrationThreadDetailSnapshot,
34});
35const StoredServerConfig = Schema.Struct({
36 schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION),
37 environmentId: Schema.String,
38 config: ServerConfig,
39});
40const StoredVcsRefs = Schema.Struct({
41 schemaVersion: Schema.Literal(VCS_REFS_CACHE_SCHEMA_VERSION),
42 environmentId: Schema.String,
43 cwd: Schema.String,
44 refs: VcsListRefsResult,
45});
46
47const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(
48 Schema.fromJsonString(StoredShellSnapshot),
49);
50const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot));
51const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(
52 Schema.fromJsonString(StoredThreadSnapshot),
53);
54const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot));
55const decodeStoredServerConfig = Schema.decodeUnknownEffect(
56 Schema.fromJsonString(StoredServerConfig),
57);
58const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig));
59const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs));
60const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs));
61
62type CacheOperation = ConnectionPersistenceError["operation"];
63
64function persistenceError(operation: CacheOperation, cause: unknown) {
65 return new ConnectionPersistenceError({
66 operation,
67 message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,
68 });
69}
70
71function mapDatabaseError(operation: CacheOperation) {
72 return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error);
73}
74
75function loadDecodedCache<A, B>(input: {
76 readonly database: MobileDatabase.MobileDatabase["Service"];
77 readonly environmentId: EnvironmentId;
78 readonly kind: MobileDatabase.ClientCacheKind;
79 readonly cacheKey: string;
80 readonly operation: CacheOperation;
81 readonly decode: (raw: string) => Effect.Effect<A, unknown>;
82 readonly select: (value: A) => Option.Option<B>;
83}): Effect.Effect<Option.Option<B>, ConnectionPersistenceError> {
84 return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe(
85 Effect.mapError(mapDatabaseError(input.operation)),
86 Effect.flatMap(
87 Option.match({
88 onNone: () => Effect.succeed(Option.none<B>()),
89 onSome: (raw) =>
90 input.decode(raw).pipe(
91 Effect.map(input.select),
92 Effect.catch((cause) =>
93 Effect.logWarning("Discarding corrupt mobile client cache record.", {
94 environmentId: input.environmentId,
95 kind: input.kind,
96 cacheKey: input.cacheKey,
97 cause: String(cause),
98 }).pipe(
99 Effect.andThen(
100 input.database
101 .removeCache(input.environmentId, input.kind, input.cacheKey)
102 .pipe(Effect.catch(() => Effect.void)),
103 ),
104 Effect.as(Option.none<B>()),
105 ),
106 ),
107 ),
108 }),
109 ),
110 );
111}
112
113export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () {
114 const database = yield* MobileDatabase.MobileDatabase;
115 return EnvironmentCacheStore.of({
116 loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) =>
117 loadDecodedCache({
118 database,
119 environmentId,
120 kind: "shell",
121 cacheKey: "snapshot",
122 operation: "load-shell",
123 decode: decodeStoredShellSnapshot,
124 select: (stored) =>
125 stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(),
126 }),
127 ),
128 saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) {
129 const payload = yield* encodeStoredShellSnapshot({
130 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
131 environmentId,
132 snapshot,
133 }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause)));
134 yield* database
135 .saveCache(environmentId, "shell", "snapshot", SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
136 .pipe(Effect.mapError(mapDatabaseError("save-shell")));
137 }),
138 loadThread: Effect.fn("MobileEnvironmentCache.loadThread")((environmentId, threadId) =>
139 loadDecodedCache({
140 database,
141 environmentId,
142 kind: "thread",
143 cacheKey: threadId,
144 operation: "load-thread",
145 decode: decodeStoredThreadSnapshot,
146 select: (stored) =>
147 stored.environmentId === environmentId && stored.threadId === threadId
148 ? Option.some(stored.snapshot)
149 : Option.none(),
150 }),
151 ),
152 saveThread: Effect.fn("MobileEnvironmentCache.saveThread")(function* (environmentId, snapshot) {
153 const threadId = snapshot.thread.id;
154 const payload = yield* encodeStoredThreadSnapshot({
155 schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
156 environmentId,
157 threadId,
158 snapshot,
159 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
160 yield* database
161 .saveCache(environmentId, "thread", threadId, THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, payload)
162 .pipe(Effect.mapError(mapDatabaseError("save-thread")));
163 }),
164 removeThread: Effect.fn("MobileEnvironmentCache.removeThread")((environmentId, threadId) =>
165 database
166 .removeCache(environmentId, "thread", threadId)
167 .pipe(Effect.mapError(mapDatabaseError("remove-thread"))),
168 ),
169 loadServerConfig: Effect.fn("MobileEnvironmentCache.loadServerConfig")((environmentId) =>
170 loadDecodedCache({
171 database,
172 environmentId,
173 kind: "server-config",
174 cacheKey: "config",
175 operation: "load-server-config",
176 decode: decodeStoredServerConfig,
177 select: (stored) =>
178 stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(),
179 }),
180 ),
181 saveServerConfig: Effect.fn("MobileEnvironmentCache.saveServerConfig")(
182 function* (environmentId, config) {
183 const payload = yield* encodeStoredServerConfig({
184 schemaVersion: SERVER_CONFIG_CACHE_SCHEMA_VERSION,
185 environmentId,
186 config,
187 }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause)));
188 yield* database
189 .saveCache(
190 environmentId,
191 "server-config",
192 "config",
193 SERVER_CONFIG_CACHE_SCHEMA_VERSION,
194 payload,
195 )
196 .pipe(Effect.mapError(mapDatabaseError("save-server-config")));
197 },
198 ),
199 loadVcsRefs: Effect.fn("MobileEnvironmentCache.loadVcsRefs")((environmentId, cwd) =>
200 loadDecodedCache({
201 database,
202 environmentId,
203 kind: "vcs-refs",
204 cacheKey: cwd,
205 operation: "load-vcs-refs",
206 decode: decodeStoredVcsRefs,
207 select: (stored) =>
208 stored.environmentId === environmentId && stored.cwd === cwd
209 ? Option.some(stored.refs)
210 : Option.none(),
211 }),
212 ),
213 saveVcsRefs: Effect.fn("MobileEnvironmentCache.saveVcsRefs")(
214 function* (environmentId, cwd, refs) {
215 const payload = yield* encodeStoredVcsRefs({
216 schemaVersion: VCS_REFS_CACHE_SCHEMA_VERSION,
217 environmentId,
218 cwd,
219 refs,
220 }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause)));
221 yield* database
222 .saveCache(environmentId, "vcs-refs", cwd, VCS_REFS_CACHE_SCHEMA_VERSION, payload)
223 .pipe(Effect.mapError(mapDatabaseError("save-vcs-refs")));
224 },
225 ),
226 clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) =>
227 database
228 .clearEnvironmentCache(environmentId)
229 .pipe(Effect.mapError(mapDatabaseError("clear-environment"))),
230 ),
231 });
232});
233
234export const layer = Layer.effect(EnvironmentCacheStore, make());
apps/mobile/src/connection/platform.ts+94-79
......@@ -25,7 +25,8 @@ import * as Network from "expo-network";
2525import { AppState } from "react-native";
2626
2727import { authClientMetadata } from "../lib/authClientMetadata";
28import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage";
28import * as Runtime from "../lib/runtime";
29import * as MobileStorage from "../persistence/mobile-storage";
2930import { appAtomRegistry } from "../state/atom-registry";
3031import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
3132import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
......@@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({
8384 ),
8485});
8586
86const capabilitiesLayer = Layer.succeedContext(
87 Context.make(
88 CloudSession,
89 CloudSession.of({
90 clerkToken: Effect.gen(function* () {
91 const session = appAtomRegistry.get(managedRelaySessionAtom);
92 if (session === null) {
93 return yield* new ConnectionBlockedError({
94 reason: "authentication",
95 detail: "Sign in to T3 Connect to connect this environment.",
96 });
97 }
98 const token = yield* session.readClerkToken().pipe(
99 Effect.mapError(
100 (error) =>
101 new ConnectionTransientError({
102 reason: "network",
103 detail: error.message,
104 }),
105 ),
106 );
107 if (token === null) {
108 return yield* new ConnectionBlockedError({
109 reason: "authentication",
110 detail: "The T3 Connect session is unavailable.",
111 });
112 }
113 return token;
114 }),
115 }),
116 ).pipe(
117 Context.add(
118 PrimaryEnvironmentAuth,
119 PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }),
120 ),
121 Context.add(
122 RelayDeviceIdentity,
123 RelayDeviceIdentity.of({
124 deviceId: Effect.tryPromise({
125 try: () => loadOrCreateAgentAwarenessDeviceId(),
126 catch: (cause) =>
127 new ConnectionTransientError({
128 reason: "remote-unavailable",
129 detail: `Could not load the mobile device identity: ${String(cause)}`,
130 }),
131 }).pipe(Effect.map(Option.some)),
132 }),
133 ),
134 Context.add(
135 ClientPresentation,
136 ClientPresentation.of({
137 metadata: authClientMetadata(),
138 scopes: AuthStandardClientScopes,
87const capabilitiesLayer = Layer.effectContext(
88 Effect.gen(function* () {
89 const storage = yield* MobileStorage.MobileStorage;
90 return Context.make(
91 CloudSession,
92 CloudSession.of({
93 clerkToken: Effect.gen(function* () {
94 const session = appAtomRegistry.get(managedRelaySessionAtom);
95 if (session === null) {
96 return yield* new ConnectionBlockedError({
97 reason: "authentication",
98 detail: "Sign in to T3 Connect to connect this environment.",
99 });
100 }
101 const token = yield* session.readClerkToken().pipe(
102 Effect.mapError(
103 (error) =>
104 new ConnectionTransientError({
105 reason: "network",
106 detail: error.message,
107 }),
108 ),
109 );
110 if (token === null) {
111 return yield* new ConnectionBlockedError({
112 reason: "authentication",
113 detail: "The T3 Connect session is unavailable.",
114 });
115 }
116 return token;
117 }),
139118 }),
140 ),
141 Context.add(
142 SshEnvironmentGateway,
143 SshEnvironmentGateway.of({
144 provision: () =>
145 Effect.fail(
146 new ConnectionBlockedError({
147 reason: "unsupported",
148 detail: "SSH environments are only available in the desktop app.",
149 }),
150 ),
151 prepare: () =>
152 Effect.fail(
153 new ConnectionBlockedError({
154 reason: "unsupported",
155 detail: "SSH environments are only available in the desktop app.",
156 }),
119 ).pipe(
120 Context.add(
121 PrimaryEnvironmentAuth,
122 PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }),
123 ),
124 Context.add(
125 RelayDeviceIdentity,
126 RelayDeviceIdentity.of({
127 deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe(
128 Effect.mapError(
129 (cause) =>
130 new ConnectionTransientError({
131 reason: "remote-unavailable",
132 detail: `Could not load the mobile device identity: ${String(cause)}`,
133 }),
134 ),
135 Effect.map(Option.some),
157136 ),
158 disconnect: () => Effect.void,
159 }),
160 ),
161 ),
137 }),
138 ),
139 Context.add(
140 ClientPresentation,
141 ClientPresentation.of({
142 metadata: authClientMetadata(),
143 scopes: AuthStandardClientScopes,
144 }),
145 ),
146 Context.add(
147 SshEnvironmentGateway,
148 SshEnvironmentGateway.of({
149 provision: () =>
150 Effect.fail(
151 new ConnectionBlockedError({
152 reason: "unsupported",
153 detail: "SSH environments are only available in the desktop app.",
154 }),
155 ),
156 prepare: () =>
157 Effect.fail(
158 new ConnectionBlockedError({
159 reason: "unsupported",
160 detail: "SSH environments are only available in the desktop app.",
161 }),
162 ),
163 disconnect: () => Effect.void,
164 }),
165 ),
166 );
167 }),
162168);
163169
164170const platformConnectionSourceLayer = Layer.succeed(
......@@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed(
168174 }),
169175);
170176
177const providedConnectionStorageLayer = connectionStorageLayer.pipe(
178 Layer.provide(Runtime.runtimeContextLayer),
179);
180const providedCapabilitiesLayer = capabilitiesLayer.pipe(
181 Layer.provide(Runtime.runtimeContextLayer),
182);
183
171184const environmentOwnedDataCleanupLayer = Layer.succeed(
172185 EnvironmentOwnedDataCleanup,
173186 EnvironmentOwnedDataCleanup.of({
......@@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(
190203);
191204
192205type ConnectionPlatformLayerSource =
193 | typeof connectionStorageLayer
206 | typeof providedConnectionStorageLayer
207 | typeof Runtime.runtimeContextLayer
194208 | typeof connectivityLayer
195209 | typeof wakeupsLayer
196 | typeof capabilitiesLayer
210 | typeof providedCapabilitiesLayer
197211 | typeof platformConnectionSourceLayer
198212 | typeof environmentOwnedDataCleanupLayer;
199213
......@@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer<
202216 Layer.Error<ConnectionPlatformLayerSource>,
203217 Layer.Services<ConnectionPlatformLayerSource>
204218> = Layer.mergeAll(
205 connectionStorageLayer,
219 providedConnectionStorageLayer,
220 Runtime.runtimeContextLayer,
206221 connectivityLayer,
207222 wakeupsLayer,
208 capabilitiesLayer,
223 providedCapabilitiesLayer,
209224 platformConnectionSourceLayer,
210225 environmentOwnedDataCleanupLayer,
211226);
apps/mobile/src/connection/storage.test.ts+25-12
......@@ -1,28 +1,35 @@
11import { describe, expect, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import { vi } from "vite-plus/test";
34
4import {
5 CONNECTION_CATALOG_KEY,
6 LEGACY_CONNECTIONS_KEY,
7 makeCatalogStore,
8 type SecureCatalogStorage,
9} from "./catalog-store";
5vi.mock("react-native", () => ({
6 Platform: { OS: "ios" },
7}));
8
9vi.mock("expo-secure-store", () => ({
10 deleteItemAsync: vi.fn(),
11 getItemAsync: vi.fn(),
12 setItemAsync: vi.fn(),
13}));
14
15import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store";
16import { MobileSecureStorage } from "../persistence/mobile-secure-storage";
1017
1118function makeStorage(initial: Readonly<Record<string, string>>) {
1219 const values = new Map(Object.entries(initial));
1320 const deleted: Array<string> = [];
14 const storage: SecureCatalogStorage = {
21 const storage = MobileSecureStorage.of({
1522 getItem: (key) => Effect.sync(() => values.get(key) ?? null),
1623 setItem: (key, value) =>
1724 Effect.sync(() => {
1825 values.set(key, value);
1926 }),
20 deleteItem: (key) =>
27 removeItem: (key) =>
2128 Effect.sync(() => {
2229 deleted.push(key);
2330 values.delete(key);
2431 }),
25 };
32 });
2633 return { deleted, storage, values };
2734}
2835
......@@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => {
3239 const memory = makeStorage({
3340 [CONNECTION_CATALOG_KEY]: "{not-json",
3441 });
35 const catalog = yield* makeCatalogStore(memory.storage);
42 const catalog = yield* make().pipe(
43 Effect.provideService(MobileSecureStorage, memory.storage),
44 );
3645
3746 expect((yield* catalog.read).targets).toEqual([]);
3847 expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]);
......@@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => {
4453 const memory = makeStorage({
4554 [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }),
4655 });
47 const catalog = yield* makeCatalogStore(memory.storage);
56 const catalog = yield* make().pipe(
57 Effect.provideService(MobileSecureStorage, memory.storage),
58 );
4859
4960 expect((yield* catalog.read).targets).toEqual([]);
5061 expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]);
......@@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => {
7182 ],
7283 }),
7384 });
74 const catalog = yield* makeCatalogStore(memory.storage);
85 const catalog = yield* make().pipe(
86 Effect.provideService(MobileSecureStorage, memory.storage),
87 );
7588
7689 expect((yield* catalog.read).targets).toHaveLength(1);
7790 expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]);
apps/mobile/src/connection/storage.ts+2-299
......@@ -2,7 +2,6 @@ import {
22 ConnectionPersistenceError,
33 ConnectionRegistrationStore,
44 ConnectionTargetStore,
5 EnvironmentCacheStore,
65 registerConnectionInCatalog,
76 removeConnectionFromCatalog,
87 removeCatalogValue,
......@@ -14,111 +13,11 @@ import {
1413 CredentialStore,
1514 ProfileStore,
1615} from "@t3tools/client-runtime/connection";
17import {
18 EnvironmentId,
19 OrchestrationShellSnapshot,
20 OrchestrationThreadDetailSnapshot,
21 ThreadId,
22} from "@t3tools/contracts";
2316import * as Context from "effect/Context";
2417import * as Effect from "effect/Effect";
2518import * as Layer from "effect/Layer";
2619import * as Option from "effect/Option";
27import * as Schema from "effect/Schema";
28import * as SecureStore from "expo-secure-store";
29
30import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
31
32const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
33const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
34const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
35// v2 stores the snapshot sequence alongside the thread so a warm cache can
36// resume via `afterSequence` instead of re-downloading the full thread body.
37// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.
38const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
39const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
40
41const StoredShellSnapshot = Schema.Struct({
42 schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION),
43 environmentId: EnvironmentId,
44 snapshot: OrchestrationShellSnapshot,
45});
46
47const StoredThreadSnapshot = Schema.Struct({
48 schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
49 environmentId: EnvironmentId,
50 threadId: ThreadId,
51 snapshot: OrchestrationThreadDetailSnapshot,
52});
53
54const LegacyStoredShellSnapshot = Schema.Struct({
55 schemaVersion: Schema.Literal(1),
56 environmentId: EnvironmentId,
57 snapshotReceivedAt: Schema.String,
58 snapshot: OrchestrationShellSnapshot,
59});
60
61function catalogError(operation: string, cause: unknown) {
62 return new ConnectionTransientError({
63 reason: "remote-unavailable",
64 detail: `Could not ${operation} the local connection catalog: ${String(cause)}`,
65 });
66}
67
68function shellPersistenceError(
69 operation:
70 | "load-shell"
71 | "save-shell"
72 | "load-thread"
73 | "save-thread"
74 | "remove-thread"
75 | "clear-environment",
76 cause: unknown,
77) {
78 return new ConnectionPersistenceError({
79 operation,
80 message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`,
81 });
82}
83
84function threadSnapshotFileName(threadId: ThreadId): string {
85 return `${encodeURIComponent(threadId)}.json`;
86}
87
88const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapshotDirectory")(
89 function* (
90 environmentId: EnvironmentId,
91 operation: "load-thread" | "save-thread" | "remove-thread" | "clear-environment",
92 ) {
93 return yield* Effect.tryPromise({
94 try: async () => {
95 const { Directory, Paths } = await import("expo-file-system");
96 const directory = new Directory(
97 Paths.document,
98 THREAD_SNAPSHOT_CACHE_DIRECTORY,
99 encodeURIComponent(environmentId),
100 );
101 if (operation !== "clear-environment") {
102 directory.create({ idempotent: true, intermediates: true });
103 }
104 return directory;
105 },
106 catch: (cause) => shellPersistenceError(operation, cause),
107 });
108 },
109);
110
111const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFile")(function* (
112 environmentId: EnvironmentId,
113 threadId: ThreadId,
114 operation: "load-thread" | "save-thread" | "remove-thread",
115) {
116 const { File } = yield* Effect.promise(() => import("expo-file-system"));
117 return new File(
118 yield* threadSnapshotDirectory(environmentId, operation),
119 threadSnapshotFileName(threadId),
120 );
121});
20import * as CatalogStore from "./catalog-store";
12221
12322function targetPersistenceError(
12423 operation: "list-targets" | "register-connection" | "remove-connection",
......@@ -130,59 +29,9 @@ function targetPersistenceError(
13029 });
13130}
13231
133const secureCatalogStorage: SecureCatalogStorage = {
134 getItem: (key) =>
135 Effect.tryPromise({
136 try: () => SecureStore.getItemAsync(key),
137 catch: (cause) => catalogError("load", cause),
138 }),
139 setItem: (key, value) =>
140 Effect.tryPromise({
141 try: () => SecureStore.setItemAsync(key, value),
142 catch: (cause) => catalogError("save", cause),
143 }),
144 deleteItem: (key) =>
145 Effect.tryPromise({
146 try: () => SecureStore.deleteItemAsync(key),
147 catch: (cause) => catalogError("delete", cause),
148 }),
149};
150
151function shellSnapshotFileName(environmentId: EnvironmentId): string {
152 return `${encodeURIComponent(environmentId)}.json`;
153}
154
155const shellSnapshotFileInDirectory = Effect.fn(
156 "mobile.connectionStorage.shellSnapshotFileInDirectory",
157)(function* (
158 environmentId: EnvironmentId,
159 operation: "load-shell" | "save-shell" | "clear-environment",
160 directoryName: string,
161) {
162 return yield* Effect.tryPromise({
163 try: async () => {
164 const { Directory, File, Paths } = await import("expo-file-system");
165 const directory = new Directory(Paths.document, directoryName);
166 directory.create({ idempotent: true, intermediates: true });
167 return new File(directory, shellSnapshotFileName(environmentId));
168 },
169 catch: (cause) => shellPersistenceError(operation, cause),
170 });
171});
172
173const shellSnapshotFile = (
174 environmentId: EnvironmentId,
175 operation: "load-shell" | "save-shell" | "clear-environment",
176) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY);
177
178const legacyShellSnapshotFile = (
179 environmentId: EnvironmentId,
180 operation: "load-shell" | "clear-environment",
181) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY);
182
18332export const connectionStorageLayer = Layer.effectContext(
18433 Effect.gen(function* () {
185 const catalog = yield* makeCatalogStore(secureCatalogStorage);
34 const catalog = yield* CatalogStore.make();
18635
18736 const targetStore = ConnectionTargetStore.of({
18837 list: catalog.read.pipe(
......@@ -279,157 +128,11 @@ export const connectionStorageLayer = Layer.effectContext(
279128 ),
280129 })),
281130 });
282 const cacheStore = EnvironmentCacheStore.of({
283 loadShell: (environmentId) =>
284 Effect.gen(function* () {
285 const file = yield* shellSnapshotFile(environmentId, "load-shell");
286 if (file.exists) {
287 const raw = yield* Effect.tryPromise({
288 try: () => file.text(),
289 catch: (cause) => shellPersistenceError("load-shell", cause),
290 });
291 const parsed = yield* Effect.try({
292 try: () => JSON.parse(raw) as unknown,
293 catch: (cause) => shellPersistenceError("load-shell", cause),
294 });
295 const stored = yield* Effect.fromResult(
296 Schema.decodeUnknownResult(StoredShellSnapshot)(parsed),
297 ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause)));
298 return stored.environmentId === environmentId
299 ? Option.some(stored.snapshot)
300 : Option.none();
301 }
302
303 const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell");
304 if (!legacyFile.exists) {
305 return Option.none();
306 }
307 const legacyRaw = yield* Effect.tryPromise({
308 try: () => legacyFile.text(),
309 catch: (cause) => shellPersistenceError("load-shell", cause),
310 });
311 const legacyParsed = yield* Effect.try({
312 try: () => JSON.parse(legacyRaw) as unknown,
313 catch: (cause) => shellPersistenceError("load-shell", cause),
314 });
315 const legacyStored = yield* Effect.fromResult(
316 Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed),
317 ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause)));
318 return legacyStored.environmentId === environmentId
319 ? Option.some(legacyStored.snapshot)
320 : Option.none();
321 }),
322 saveShell: (environmentId, snapshot) =>
323 Effect.gen(function* () {
324 const file = yield* shellSnapshotFile(environmentId, "save-shell");
325 const stored = {
326 schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION,
327 environmentId,
328 snapshot,
329 } as const;
330 const encoded = yield* Effect.fromResult(
331 Schema.encodeUnknownResult(StoredShellSnapshot)(stored),
332 ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause)));
333 yield* Effect.try({
334 try: () => {
335 if (!file.exists) {
336 file.create({ intermediates: true, overwrite: true });
337 }
338 file.write(JSON.stringify(encoded));
339 },
340 catch: (cause) => shellPersistenceError("save-shell", cause),
341 });
342 }),
343 loadThread: (environmentId, threadId) =>
344 Effect.gen(function* () {
345 const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread");
346 if (!file.exists) {
347 return Option.none();
348 }
349 const raw = yield* Effect.tryPromise({
350 try: () => file.text(),
351 catch: (cause) => shellPersistenceError("load-thread", cause),
352 });
353 const parsed = yield* Effect.try({
354 try: () => JSON.parse(raw) as unknown,
355 catch: (cause) => shellPersistenceError("load-thread", cause),
356 });
357 const stored = yield* Effect.fromResult(
358 Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),
359 ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));
360 return stored.environmentId === environmentId && stored.threadId === threadId
361 ? Option.some(stored.snapshot)
362 : Option.none();
363 }),
364 saveThread: (environmentId, snapshot) =>
365 Effect.gen(function* () {
366 const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread");
367 const encoded = yield* Effect.fromResult(
368 Schema.encodeUnknownResult(StoredThreadSnapshot)({
369 schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
370 environmentId,
371 threadId: snapshot.thread.id,
372 snapshot,
373 }),
374 ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));
375 yield* Effect.try({
376 try: () => {
377 if (!file.exists) {
378 file.create({ intermediates: true, overwrite: true });
379 }
380 file.write(JSON.stringify(encoded));
381 },
382 catch: (cause) => shellPersistenceError("save-thread", cause),
383 });
384 }),
385 removeThread: (environmentId, threadId) =>
386 Effect.gen(function* () {
387 const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread");
388 if (file.exists) {
389 file.delete();
390 }
391 }).pipe(
392 Effect.mapError((cause) =>
393 cause._tag === "ConnectionPersistenceError"
394 ? cause
395 : shellPersistenceError("remove-thread", cause),
396 ),
397 ),
398 clear: (environmentId) =>
399 Effect.gen(function* () {
400 const file = yield* shellSnapshotFile(environmentId, "clear-environment");
401 if (file.exists) {
402 yield* Effect.try({
403 try: () => file.delete(),
404 catch: (cause) => shellPersistenceError("clear-environment", cause),
405 });
406 }
407 const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment");
408 if (legacyFile.exists) {
409 yield* Effect.try({
410 try: () => legacyFile.delete(),
411 catch: (cause) => shellPersistenceError("clear-environment", cause),
412 });
413 }
414 const threadDirectory = yield* threadSnapshotDirectory(
415 environmentId,
416 "clear-environment",
417 );
418 if (threadDirectory.exists) {
419 yield* Effect.try({
420 try: () => threadDirectory.delete(),
421 catch: (cause) => shellPersistenceError("clear-environment", cause),
422 });
423 }
424 }),
425 });
426
427131 return Context.make(ConnectionTargetStore, targetStore).pipe(
428132 Context.add(ConnectionRegistrationStore, registrationStore),
429133 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
430134 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
431135 Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),
432 Context.add(EnvironmentCacheStore, cacheStore),
433136 );
434137 }),
435138);
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+85-18
......@@ -7,21 +7,30 @@ import * as Layer from "effect/Layer";
77import { HttpClient } from "effect/unstable/http";
88
99import type { SavedRemoteConnection } from "../../lib/connection";
10import { savePreferencesPatch } from "../../lib/storage";
11import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
10import { MobileStorage } from "../../persistence/mobile-storage";
11import {
12 CloudEnvironmentLinkError,
13 linkEnvironmentToCloudWithPreference,
14} from "../cloud/linkEnvironment";
1215import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";
13import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
16import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";
1417
15vi.mock("../../lib/storage", () => ({
16 savePreferencesPatch: vi.fn(() => Promise.resolve()),
18vi.mock("expo-secure-store", () => ({
19 deleteItemAsync: vi.fn(),
20 getItemAsync: vi.fn(),
21 setItemAsync: vi.fn(),
22}));
23
24vi.mock("react-native", () => ({
25 Platform: { OS: "ios" },
1726}));
1827
1928vi.mock("../cloud/linkEnvironment", () => ({
20 linkEnvironmentToCloud: vi.fn(() => Effect.void),
29 linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void),
2130}));
2231
2332vi.mock("./remoteRegistration", () => ({
24 refreshAgentAwarenessRegistration: vi.fn(() => Effect.void),
33 updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void),
2534}));
2635
2736const connection: SavedRemoteConnection = {
......@@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll(
4049 HttpClient.HttpClient,
4150 HttpClient.make(() => Effect.die("unexpected HTTP request")),
4251 ),
52 Layer.succeed(
53 MobileStorage,
54 MobileStorage.of({
55 loadSavedConnections: Effect.succeed([]),
56 saveConnection: () => Effect.void,
57 clearSavedConnection: () => Effect.void,
58 loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"),
59 loadAgentAwarenessDeviceId: Effect.succeed("device-1"),
60 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
61 saveAgentAwarenessRegistrationRecord: () => Effect.void,
62 clearAgentAwarenessRegistrationRecord: Effect.void,
63 }),
64 ),
4365);
4466
4567describe("liveActivityPreferences", () => {
......@@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => {
5173 Effect.gen(function* () {
5274 yield* setLiveActivityUpdatesEnabled({
5375 enabled: false,
76 previousEnabled: true,
5477 clerkToken: "clerk-token",
5578 connections: [connection],
5679 });
5780
58 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
59 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
60 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
81 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
82 liveActivitiesEnabled: false,
83 });
84 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({
6185 clerkToken: "clerk-token",
6286 connection,
87 liveActivitiesEnabled: false,
6388 });
6489 }).pipe(Effect.provide(testLayer)),
6590 );
......@@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => {
6893 Effect.gen(function* () {
6994 yield* setLiveActivityUpdatesEnabled({
7095 enabled: true,
96 previousEnabled: false,
7197 clerkToken: "clerk-token",
7298 connections: [connection],
7399 });
74100
75 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true });
76 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
77 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
101 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
102 liveActivitiesEnabled: true,
103 });
104 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({
78105 clerkToken: "clerk-token",
79106 connection,
107 liveActivitiesEnabled: true,
80108 });
81109 }).pipe(Effect.provide(testLayer)),
82110 );
......@@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => {
85113 Effect.gen(function* () {
86114 yield* setLiveActivityUpdatesEnabled({
87115 enabled: false,
116 previousEnabled: true,
88117 clerkToken: null,
89118 connections: [connection],
90119 });
91120
92 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });
93 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);
94 expect(linkEnvironmentToCloud).not.toHaveBeenCalled();
121 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
122 liveActivitiesEnabled: false,
123 });
124 expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
95125 }).pipe(Effect.provide(testLayer)),
96126 );
97127
......@@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => {
104134 return Effect.gen(function* () {
105135 yield* setLiveActivityUpdatesEnabled({
106136 enabled: true,
137 previousEnabled: false,
107138 clerkToken: "clerk-token",
108139 connections: [connection, managedConnection],
109140 });
110141
111 expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1);
112 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({
142 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
143 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({
144 clerkToken: "clerk-token",
145 connection,
146 liveActivitiesEnabled: true,
147 });
148 }).pipe(Effect.provide(testLayer));
149 });
150
151 it.effect("restores relay preferences when an environment update fails", () => {
152 vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() =>
153 Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })),
154 );
155
156 return Effect.gen(function* () {
157 const exit = yield* Effect.exit(
158 setLiveActivityUpdatesEnabled({
159 enabled: false,
160 previousEnabled: true,
161 clerkToken: "clerk-token",
162 connections: [connection],
163 }),
164 );
165
166 expect(exit._tag).toBe("Failure");
167 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, {
168 liveActivitiesEnabled: false,
169 });
170 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, {
171 liveActivitiesEnabled: true,
172 });
173 expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, {
174 clerkToken: "clerk-token",
175 connection,
176 liveActivitiesEnabled: false,
177 });
178 expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, {
113179 clerkToken: "clerk-token",
114180 connection,
181 liveActivitiesEnabled: true,
115182 });
116183 }).pipe(Effect.provide(testLayer));
117184 });
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+65-39
......@@ -1,47 +1,73 @@
11import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import { HttpClient } from "effect/unstable/http";
4import { ManagedRelay } from "@t3tools/client-runtime/relay";
52
63import type { SavedRemoteConnection } from "../../lib/connection";
7import { savePreferencesPatch } from "../../lib/storage";
8import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";
9import { refreshAgentAwarenessRegistration } from "./remoteRegistration";
10
11export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass<LiveActivityPreferenceSaveError>()(
12 "LiveActivityPreferenceSaveError",
13 {
14 enabled: Schema.Boolean,
15 cause: Schema.Defect(),
16 },
17) {
18 override get message(): string {
19 return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`;
20 }
21}
22
23export function setLiveActivityUpdatesEnabled(input: {
24 readonly enabled: boolean;
25 readonly clerkToken: string | null;
26 readonly connections: ReadonlyArray<SavedRemoteConnection>;
27}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient> {
28 return Effect.gen(function* () {
29 yield* Effect.tryPromise({
30 try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }),
31 catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }),
4import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
5import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";
6
7export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")(
8 function* (input: {
9 readonly enabled: boolean;
10 readonly previousEnabled: boolean;
11 readonly clerkToken: string | null;
12 readonly connections: ReadonlyArray<SavedRemoteConnection>;
13 }) {
14 const linkedConnections = input.connections.filter(
15 (connection) => connection.bearerToken !== null,
16 );
17
18 const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) {
19 yield* updateAgentAwarenessRegistrationPreferences({
20 liveActivitiesEnabled: enabled,
21 });
22
23 const clerkToken = input.clerkToken;
24 if (!clerkToken) return;
25
26 yield* Effect.forEach(
27 linkedConnections,
28 (connection) =>
29 linkEnvironmentToCloudWithPreference({
30 clerkToken,
31 connection,
32 liveActivitiesEnabled: enabled,
33 }),
34 { concurrency: "unbounded" },
35 );
3236 });
3337
34 yield* refreshAgentAwarenessRegistration();
38 const restoreRelayPreference = Effect.fn("restoreRelayPreference")(function* () {
39 yield* updateAgentAwarenessRegistrationPreferences({
40 liveActivitiesEnabled: input.previousEnabled,
41 }).pipe(
42 Effect.catchCause((cause) =>
43 Effect.logWarning("Could not restore Live Activity device preference.", cause),
44 ),
45 );
46
47 const clerkToken = input.clerkToken;
48 if (!clerkToken) return;
3549
36 const clerkToken = input.clerkToken;
37 if (!clerkToken) {
38 return;
39 }
50 yield* Effect.forEach(
51 linkedConnections,
52 (connection) =>
53 linkEnvironmentToCloudWithPreference({
54 clerkToken,
55 connection,
56 liveActivitiesEnabled: input.previousEnabled,
57 }).pipe(
58 Effect.catchCause((cause) =>
59 Effect.logWarning(
60 `Could not restore Live Activity preference for environment ${connection.environmentId}.`,
61 cause,
62 ),
63 ),
64 ),
65 { concurrency: "unbounded" },
66 );
67 });
4068
41 yield* Effect.forEach(
42 input.connections.filter((connection) => connection.bearerToken !== null),
43 (connection) => linkEnvironmentToCloud({ clerkToken, connection }),
44 { concurrency: "unbounded" },
69 yield* updateRelayPreference(input.enabled).pipe(
70 Effect.onError(() => restoreRelayPreference()),
4571 );
46 });
47}
72 },
73);
apps/mobile/src/features/agent-awareness/registrationPayload.ts+1-1
......@@ -1,6 +1,6 @@
11import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
22
3import type { Preferences } from "../../lib/storage";
3import type { Preferences } from "../../persistence/mobile-preferences";
44
55// Development builds are Xcode-signed and receive sandbox APNs tokens;
66// preview and production builds are distribution-signed and use production
apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts+12-2
......@@ -21,12 +21,13 @@ import {
2121 loadAgentAwarenessRegistrationRecord,
2222 loadOrCreateAgentAwarenessDeviceId,
2323 saveAgentAwarenessRegistrationRecord,
24} from "../../lib/storage";
24} from "../../persistence/imperative";
2525import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
2626import {
2727 AgentAwarenessOperationError,
2828 __resetAgentAwarenessRemoteRegistrationForTest,
2929 getAgentAwarenessRegistrationStatus,
30 mergeAgentAwarenessRegistrationPreferences,
3031 refreshActiveLiveActivityRemoteRegistration,
3132 refreshAgentAwarenessRegistration,
3233 normalizeAgentAwarenessRelayBaseUrl,
......@@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({
142143 },
143144}));
144145
145vi.mock("../../lib/storage", () => ({
146vi.mock("../../persistence/imperative", () => ({
146147 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
147148 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
148149 loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })),
......@@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => {
324325 expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull();
325326 });
326327
328 it("overrides persisted preferences for an in-flight registration", () => {
329 expect(
330 mergeAgentAwarenessRegistrationPreferences(
331 { liveActivitiesEnabled: false, baseFontSize: 18 },
332 { liveActivitiesEnabled: true },
333 ),
334 ).toEqual({ liveActivitiesEnabled: true, baseFontSize: 18 });
335 });
336
327337 it.effect("registers at most one listener while a Live Activity push token is pending", () => {
328338 registerAgentAwarenessConnection(savedConnection());
329339 const addPushTokenListener = vi.fn();
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+34-3
......@@ -20,6 +20,7 @@ import {
2020
2121import type { SavedRemoteConnection } from "../../lib/connection";
2222import { runtime } from "../../lib/runtime";
23import type { Preferences } from "../../persistence/mobile-preferences";
2324import {
2425 clearAgentAwarenessRegistrationRecord,
2526 loadAgentAwarenessDeviceId,
......@@ -27,7 +28,7 @@ import {
2728 loadOrCreateAgentAwarenessDeviceId,
2829 loadPreferences,
2930 saveAgentAwarenessRegistrationRecord,
30} from "../../lib/storage";
31} from "../../persistence/imperative";
3132import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
3233import { resolveCloudPublicConfig } from "../cloud/publicConfig";
3334import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
......@@ -124,6 +125,17 @@ interface DeviceRegistrationInput {
124125 readonly observedPushToken?: string;
125126}
126127
128interface RegisterDeviceInput extends DeviceRegistrationInput {
129 readonly preferencesOverride?: Partial<Preferences>;
130}
131
132export function mergeAgentAwarenessRegistrationPreferences(
133 stored: Preferences,
134 override: Partial<Preferences> | undefined,
135): Preferences {
136 return { ...stored, ...override };
137}
138
127139export function normalizeAgentAwarenessRelayBaseUrl(
128140 value: string | null | undefined,
129141): string | null {
......@@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri
655667}
656668
657669function registerDevice(
658 input: DeviceRegistrationInput = {},
670 input: RegisterDeviceInput = {},
659671 expectedGeneration = deviceRegistrationGeneration,
660672): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
661673 return Effect.gen(function* () {
......@@ -665,7 +677,7 @@ function registerDevice(
665677 }
666678
667679 logRegistrationDebug("device registration loading local state", { expectedGeneration });
668 const [deviceId, preferences] = yield* Effect.all([
680 const [deviceId, storedPreferences] = yield* Effect.all([
669681 Effect.tryPromise({
670682 try: () => loadOrCreateAgentAwarenessDeviceId(),
671683 catch: (cause) =>
......@@ -683,6 +695,10 @@ function registerDevice(
683695 }),
684696 }),
685697 ]);
698 const preferences = mergeAgentAwarenessRegistrationPreferences(
699 storedPreferences,
700 input.preferencesOverride,
701 );
686702 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
687703 logRegistrationDebug("device registration local state ready", {
688704 expectedGeneration,
......@@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect<
820836 );
821837}
822838
839export function updateAgentAwarenessRegistrationPreferences(
840 preferencesOverride: Partial<Preferences>,
841): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
842 return registerDevice({ preferencesOverride }).pipe(
843 Effect.tapError((error) =>
844 Effect.sync(() => {
845 if (registrationStatus !== "registered") {
846 setRegistrationStatus("failed");
847 }
848 logRegistrationError("device preference registration refresh failed", error);
849 }),
850 ),
851 );
852}
853
823854export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
824855 environmentConnections.clear();
825856 pushTokenSubscription?.remove();
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts+2-2
......@@ -1,7 +1,7 @@
1import { loadPreferences, updatePreferences } from "../../lib/storage";
1import { loadPreferences, updatePreferences } from "../../persistence/imperative";
22
33// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports
4// the request signal) never pulls lib/storage — expo-secure-store — into its
4// the request signal) never pulls the persistence adapter into its
55// module graph; that breaks CloudAuthProvider.test.ts suite loading.
66
77/** Whether the account chose "Don't show this again". */
apps/mobile/src/features/cloud/linkEnvironment.test.ts+76-8
......@@ -7,10 +7,13 @@ import { RelayMobileClientId } from "@t3tools/contracts/relay";
77import { ManagedRelay } from "@t3tools/client-runtime/relay";
88import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
99import { HttpClient } from "effect/unstable/http";
10import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
11import { MobileStorage } from "../../persistence/mobile-storage";
1012
1113import {
1214 cloudEnvironmentsPendingStatus,
1315 linkEnvironmentToCloud,
16 linkEnvironmentToCloudWithPreference,
1417 connectCloudEnvironment,
1518 listCloudEnvironments,
1619 listCloudEnvironmentsWithStatus,
......@@ -36,11 +39,14 @@ vi.mock("react-native", () => ({
3639 },
3740}));
3841
39vi.mock("../../lib/storage", () => ({
40 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
41 loadPreferences: vi.fn(() => Promise.resolve({})),
42vi.mock("expo-secure-store", () => ({
43 deleteItemAsync: vi.fn(),
44 getItemAsync: vi.fn(),
45 setItemAsync: vi.fn(),
4246}));
4347
48const loadPreferences = vi.fn(() => Effect.succeed({}));
49
4450const savedConnection = {
4551 environmentId: EnvironmentId.make("env-1"),
4652 environmentLabel: "Desktop",
......@@ -69,6 +75,27 @@ function cloudClientLayer() {
6975 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
7076 return Layer.mergeAll(
7177 httpClientLayer,
78 Layer.succeed(
79 MobilePreferencesStore,
80 MobilePreferencesStore.of({
81 load: loadPreferences(),
82 savePatch: (patch) => Effect.succeed(patch),
83 update: () => Effect.succeed({}),
84 }),
85 ),
86 Layer.succeed(
87 MobileStorage,
88 MobileStorage.of({
89 loadSavedConnections: Effect.succeed([]),
90 saveConnection: () => Effect.void,
91 clearSavedConnection: () => Effect.void,
92 loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"),
93 loadAgentAwarenessDeviceId: Effect.succeed("device-1"),
94 loadAgentAwarenessRegistrationRecord: Effect.succeed(null),
95 saveAgentAwarenessRegistrationRecord: () => Effect.void,
96 clearAgentAwarenessRegistrationRecord: Effect.void,
97 }),
98 ),
7299 ManagedRelay.layer({
73100 relayUrl: "https://relay.example.test",
74101 clientId: RelayMobileClientId,
......@@ -80,7 +107,11 @@ const withCloudServices = <A, E>(
80107 effect: Effect.Effect<
81108 A,
82109 E,
83 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
110 | HttpClient.HttpClient
111 | ManagedRelay.ManagedRelayClient
112 | ManagedRelay.ManagedRelayDpopSigner
113 | MobilePreferencesStore
114 | MobileStorage
84115 >,
85116) => effect.pipe(Effect.provide(cloudClientLayer()));
86117
......@@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => {
146177 beforeEach(() => {
147178 vi.restoreAllMocks();
148179 createProofMock.mockClear();
180 loadPreferences.mockClear();
149181 });
150182
151183 it("normalizes configured relay base URLs before building DPoP-bound requests", () => {
......@@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => {
705737
706738 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
707739 Effect.gen(function* () {
708 const storage = yield* Effect.promise(() => import("../../lib/storage"));
709 vi.mocked(storage.loadPreferences).mockResolvedValueOnce({
710 liveActivitiesEnabled: false,
711 });
740 loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false }));
712741 const bodies: Array<unknown> = [];
713742 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
714743 if (init?.body) {
......@@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => {
761790 }),
762791 );
763792
793 it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () =>
794 Effect.gen(function* () {
795 loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read"));
796 const bodies: Array<Record<string, unknown>> = [];
797 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
798 if (init?.body) {
799 // @effect-diagnostics-next-line preferSchemaOverJson:off
800 bodies.push(JSON.parse(requestBodyText(init.body)) as Record<string, unknown>);
801 }
802 if (String(url).endsWith("/v1/client/environment-link-challenges")) {
803 return Promise.resolve(Response.json(validLinkChallengeResponse()));
804 }
805 if (String(url).endsWith("/api/connect/link-proof")) {
806 return Promise.resolve(Response.json(validLinkProof()));
807 }
808 if (String(url).endsWith("/v1/client/environment-links")) {
809 return Promise.resolve(Response.json(validLinkResponse()));
810 }
811 return Promise.resolve(
812 Response.json({ ok: true, endpointRuntimeStatus: { status: "configured" } }),
813 );
814 });
815 vi.stubGlobal("fetch", fetchMock);
816
817 yield* withCloudServices(
818 linkEnvironmentToCloudWithPreference({
819 clerkToken: "clerk-token",
820 connection: savedConnection,
821 liveActivitiesEnabled: true,
822 }),
823 );
824
825 expect(bodies.filter((body) => "liveActivitiesEnabled" in body)).toEqual([
826 expect.objectContaining({ liveActivitiesEnabled: true }),
827 expect.objectContaining({ liveActivitiesEnabled: true }),
828 ]);
829 }),
830 );
831
764832 it.effect(
765833 "does not persist cloud connect bootstrap credentials in saved connection records",
766834 () =>
apps/mobile/src/features/cloud/linkEnvironment.ts+49-22
......@@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3030
3131import { authClientMetadata } from "../../lib/authClientMetadata";
3232import type { SavedRemoteConnection } from "../../lib/connection";
33import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage";
33import * as MobilePreferences from "../../persistence/mobile-preferences";
34import * as MobileStorage from "../../persistence/mobile-storage";
3435import { resolveCloudPublicConfig } from "./publicConfig";
3536
3637const RELAY_STATUS_AND_CONNECT_SCOPES = [
......@@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: {
256257 return Effect.void;
257258}
258259
259export function linkEnvironmentToCloud(input: {
260interface LinkEnvironmentToCloudInput {
260261 readonly connection: SavedRemoteConnection;
261262 readonly clerkToken: string;
262}): Effect.Effect<
263 void,
264 CloudEnvironmentLinkError,
265 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient
266> {
263}
264
265type LinkEnvironmentToCloudRequirements =
266 | HttpClient.HttpClient
267 | ManagedRelay.ManagedRelayClient
268 | MobileStorage.MobileStorage;
269
270export function linkEnvironmentToCloudWithPreference(
271 input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean },
272): Effect.Effect<void, CloudEnvironmentLinkError, LinkEnvironmentToCloudRequirements> {
267273 return Effect.gen(function* () {
268274 if (!input.connection.bearerToken) {
269275 return yield* new CloudEnvironmentLinkError({
......@@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: {
273279 const localBearerToken = input.connection.bearerToken;
274280 const relayUrl = yield* requireRelayUrl();
275281 const relayClient = yield* ManagedRelay.ManagedRelayClient;
276 const deviceId = yield* Effect.tryPromise({
277 try: () => loadOrCreateAgentAwarenessDeviceId(),
278 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
279 });
280 const preferences = yield* Effect.tryPromise({
281 try: () => loadPreferences(),
282 catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."),
283 });
284 const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false;
282 const storage = yield* MobileStorage.MobileStorage;
283 const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
284 Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")),
285 );
286 const liveActivitiesEnabled = input.liveActivitiesEnabled;
285287 const challenge = yield* relayClient
286288 .createEnvironmentLinkChallenge({
287289 clerkToken: input.clerkToken,
......@@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: {
350352 });
351353}
352354
355export function linkEnvironmentToCloud(
356 input: LinkEnvironmentToCloudInput,
357): Effect.Effect<
358 void,
359 CloudEnvironmentLinkError,
360 LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore
361> {
362 return MobilePreferences.MobilePreferencesStore.pipe(
363 Effect.flatMap((preferencesStore) => preferencesStore.load),
364 Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")),
365 Effect.flatMap((preferences) =>
366 linkEnvironmentToCloudWithPreference({
367 ...input,
368 liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false,
369 }),
370 ),
371 );
372}
373
353374export function listCloudEnvironments(input: {
354375 readonly clerkToken: string;
355376}): Effect.Effect<
......@@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: {
460481
461482const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(
462483 function* () {
463 return yield* Effect.tryPromise({
464 try: () => loadOrCreateAgentAwarenessDeviceId(),
465 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),
466 });
484 const storage = yield* MobileStorage.MobileStorage;
485 return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
486 Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")),
487 );
467488 },
468489);
469490
......@@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: {
557578}): Effect.Effect<
558579 SavedRemoteConnection,
559580 CloudEnvironmentLinkError,
560 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
581 | HttpClient.HttpClient
582 | ManagedRelay.ManagedRelayClient
583 | ManagedRelay.ManagedRelayDpopSigner
584 | MobileStorage.MobileStorage
561585> {
562586 return connectRelayManagedEnvironment({
563587 clerkToken: input.clerkToken,
......@@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: {
572596}): Effect.Effect<
573597 SavedRemoteConnection,
574598 CloudEnvironmentLinkError,
575 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner
599 | HttpClient.HttpClient
600 | ManagedRelay.ManagedRelayClient
601 | ManagedRelay.ManagedRelayDpopSigner
602 | MobileStorage.MobileStorage
576603> {
577604 return connectRelayManagedEnvironment({
578605 clerkToken: input.clerkToken,
apps/mobile/src/features/home/HomeScreen.tsx+41-11
......@@ -12,6 +12,8 @@ import type {
1212 SidebarProjectGroupingMode,
1313 SidebarThreadSortOrder,
1414} from "@t3tools/contracts";
15import { useAtomSet, useAtomValue } from "@effect/atom-react";
16import { AsyncResult } from "effect/unstable/reactivity";
1517import { useCallback, useMemo, useRef, useState } from "react";
1618import { ActivityIndicator, Platform, View } from "react-native";
1719import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
......@@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState";
2224import type { WorkspaceState } from "../../state/workspaceModel";
2325import type { SavedRemoteConnection } from "../../lib/connection";
2426import { scopedProjectKey } from "../../lib/scopedEntities";
27import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
2528import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2629import {
2730 PendingTaskListRow,
......@@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) {
154157 const [groupDisplayStates, setGroupDisplayStates] = useState<
155158 ReadonlyMap<string, HomeGroupDisplayState>
156159 >(() => new Map());
160 const preferencesResult = useAtomValue(mobilePreferencesAtom);
161 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
157162 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
158163 const listRef = useRef<LegendListRef | null>(null);
159164 const insets = useSafeAreaInsets();
160165 const accentColor = useThemeColor("--color-icon-muted");
161166
162 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {
163 setGroupDisplayStates((previous) => {
164 const next = new Map(previous);
165 next.set(
166 key,
167 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
168 );
167 const effectiveGroupDisplayStates = useMemo(() => {
168 const next = new Map(groupDisplayStates);
169 if (!AsyncResult.isSuccess(preferencesResult)) {
169170 return next;
170 });
171 }, []);
171 }
172 for (const key of preferencesResult.value.collapsedProjectGroups ?? []) {
173 const existing = next.get(key);
174 next.set(key, {
175 ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE),
176 collapsed: true,
177 });
178 }
179 return next;
180 }, [groupDisplayStates, preferencesResult]);
181 const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates);
182 effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates;
183
184 const updateGroupDisplay = useCallback(
185 (key: string, action: HomeGroupDisplayAction) => {
186 const next = new Map(effectiveGroupDisplayStatesRef.current);
187 next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action));
188 effectiveGroupDisplayStatesRef.current = next;
189 setGroupDisplayStates(next);
190 if (action === "toggle-collapsed") {
191 const collapsedProjectGroups: string[] = [];
192 for (const [groupKey, state] of next) {
193 if (state.collapsed) {
194 collapsedProjectGroups.push(groupKey);
195 }
196 }
197 savePreferences({ collapsedProjectGroups });
198 }
199 },
200 [savePreferences],
201 );
172202
173203 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
174204 if (openSwipeableRef.current !== methods) {
......@@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) {
219249 () =>
220250 buildHomeListLayout({
221251 groups: projectGroups,
222 displayStates: groupDisplayStates,
252 displayStates: effectiveGroupDisplayStates,
223253 showAllThreads: hasSearchQuery,
224254 }),
225 [projectGroups, groupDisplayStates, hasSearchQuery],
255 [projectGroups, effectiveGroupDisplayStates, hasSearchQuery],
226256 );
227257
228258 const projectCwdByKey = useMemo(() => {
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx created+225
......@@ -0,0 +1,225 @@
1import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import { AsyncResult } from "effect/unstable/reactivity";
3import { SymbolView } from "expo-symbols";
4import { useMemo } from "react";
5import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
6import { useSafeAreaInsets } from "react-native-safe-area-context";
7
8import { AppText as Text } from "../../components/AppText";
9import { useThemeColor } from "../../lib/useThemeColor";
10import {
11 clearClientCacheAtom,
12 clientCacheSummaryAtom,
13 type EnvironmentClientCacheSummary,
14} from "../../state/client-cache-state";
15import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
16import { SettingsSection } from "./components/SettingsSection";
17
18export function SettingsClientStorageRouteScreen() {
19 const insets = useSafeAreaInsets();
20 const iconColor = useThemeColor("--color-icon");
21 const dangerForegroundColor = useThemeColor("--color-danger-foreground");
22 const summaryResult = useAtomValue(clientCacheSummaryAtom);
23 const clearResult = useAtomValue(clearClientCacheAtom);
24 const clearCache = useAtomSet(clearClientCacheAtom);
25 const { savedConnectionsById } = useSavedRemoteConnections();
26 const isClearing = clearResult.waiting;
27 const summary = AsyncResult.isSuccess(summaryResult) ? summaryResult.value : null;
28 const environmentSummaries = useMemo(
29 () =>
30 [...(summary?.environments ?? [])].sort((left, right) => {
31 const leftLabel = savedConnectionsById[left.environmentId]?.environmentLabel ?? "";
32 const rightLabel = savedConnectionsById[right.environmentId]?.environmentLabel ?? "";
33 return leftLabel.localeCompare(rightLabel);
34 }),
35 [savedConnectionsById, summary?.environments],
36 );
37
38 const confirmClearEnvironment = (environment: EnvironmentClientCacheSummary) => {
39 const label =
40 savedConnectionsById[environment.environmentId]?.environmentLabel ??
41 environment.environmentId;
42 Alert.alert(
43 `Clear cache for ${label}?`,
44 "This removes offline threads, server metadata, and cached branches for this environment. The saved connection and credentials stay intact.",
45 [
46 { text: "Cancel", style: "cancel" },
47 {
48 text: "Clear Cache",
49 style: "destructive",
50 onPress: () =>
51 clearCache({ type: "environment", environmentId: environment.environmentId }),
52 },
53 ],
54 );
55 };
56
57 const confirmClearAll = () => {
58 Alert.alert(
59 "Clear all client caches?",
60 "This removes offline data for every environment. Connections, credentials, account data, and app preferences stay intact.",
61 [
62 { text: "Cancel", style: "cancel" },
63 {
64 text: "Clear All Caches",
65 style: "destructive",
66 onPress: () => clearCache({ type: "all" }),
67 },
68 ],
69 );
70 };
71
72 return (
73 <View collapsable={false} className="flex-1 bg-sheet">
74 <ScrollView
75 contentInsetAdjustmentBehavior="automatic"
76 contentInset={{ bottom: Math.max(insets.bottom, 18) }}
77 showsVerticalScrollIndicator={false}
78 style={{ flex: 1 }}
79 contentContainerStyle={{
80 gap: 24,
81 paddingBottom: 18,
82 paddingHorizontal: 20,
83 paddingTop: 16,
84 }}
85 >
86 <SettingsSection title="Environment caches">
87 {AsyncResult.isFailure(summaryResult) ? (
88 <View className="items-center gap-2 px-6 py-8">
89 <SymbolView
90 name="exclamationmark.triangle"
91 size={28}
92 tintColor={dangerForegroundColor}
93 type="monochrome"
94 weight="regular"
95 />
96 <Text className="text-center text-base text-foreground">Storage unavailable</Text>
97 <Text className="text-center text-sm text-foreground-muted">
98 Restart the app and try again.
99 </Text>
100 </View>
101 ) : !summary ? (
102 <View className="items-center gap-3 px-6 py-8">
103 <ActivityIndicator />
104 <Text className="text-center text-sm text-foreground-muted">
105 Inspecting cached data…
106 </Text>
107 </View>
108 ) : environmentSummaries.length > 0 ? (
109 environmentSummaries.map((environment, index) => (
110 <CacheEnvironmentRow
111 key={environment.environmentId}
112 environment={environment}
113 environmentLabel={
114 savedConnectionsById[environment.environmentId]?.environmentLabel ??
115 environment.environmentId
116 }
117 disabled={isClearing}
118 first={index === 0}
119 onClear={() => confirmClearEnvironment(environment)}
120 />
121 ))
122 ) : (
123 <View className="items-center gap-2 px-6 py-8">
124 <SymbolView
125 name="checkmark.circle"
126 size={28}
127 tintColor={iconColor}
128 type="monochrome"
129 weight="regular"
130 />
131 <Text className="text-center text-base text-foreground">No cached data</Text>
132 <Text className="text-center text-sm text-foreground-muted">
133 Offline cache records will appear here after environments are used.
134 </Text>
135 </View>
136 )}
137 </SettingsSection>
138
139 <View className="gap-3">
140 <SettingsSection title="Actions">
141 <Pressable
142 accessibilityRole="button"
143 disabled={isClearing || !summary || summary.recordCount === 0}
144 onPress={confirmClearAll}
145 className="flex-row items-center gap-4 p-4 disabled:opacity-40"
146 >
147 <SymbolView
148 name="trash"
149 size={22}
150 tintColor={dangerForegroundColor}
151 type="monochrome"
152 weight="regular"
153 />
154 <Text className="flex-1 text-lg tabular-nums text-danger-foreground">
155 {summary ? `Clear ${formatBytes(summary.payloadBytes)}` : "Clear caches"}
156 </Text>
157 {isClearing ? <ActivityIndicator color={dangerForegroundColor} /> : null}
158 </Pressable>
159 </SettingsSection>
160 <Text className="px-2 text-sm leading-normal text-foreground-muted">
161 Clearing caches never removes environment connections, credentials, account data, or
162 appearance preferences.
163 </Text>
164 {AsyncResult.isFailure(summaryResult) || AsyncResult.isFailure(clearResult) ? (
165 <Text selectable className="px-2 text-sm text-danger-foreground">
166 Client storage is temporarily unavailable. Try again after restarting the app.
167 </Text>
168 ) : null}
169 </View>
170 </ScrollView>
171 </View>
172 );
173}
174
175function CacheEnvironmentRow(props: {
176 readonly environment: EnvironmentClientCacheSummary;
177 readonly environmentLabel: string;
178 readonly disabled: boolean;
179 readonly first: boolean;
180 readonly onClear: () => void;
181}) {
182 const iconColor = useThemeColor("--color-icon");
183 const dangerForegroundColor = useThemeColor("--color-danger-foreground");
184 return (
185 <View
186 className={
187 props.first
188 ? "flex-row items-center gap-3 p-4"
189 : "border-t border-border flex-row items-center gap-3 p-4"
190 }
191 >
192 <SymbolView
193 name="desktopcomputer"
194 size={22}
195 tintColor={iconColor}
196 type="monochrome"
197 weight="regular"
198 />
199 <Text className="min-w-0 flex-1 text-base text-foreground" numberOfLines={1}>
200 {props.environmentLabel}
201 </Text>
202 <Pressable
203 accessibilityLabel={`Clear cache for ${props.environmentLabel}`}
204 accessibilityRole="button"
205 disabled={props.disabled}
206 onPress={props.onClear}
207 className="rounded-full px-3 py-2 disabled:opacity-40"
208 >
209 <Text
210 className="font-t3-medium tabular-nums text-danger-foreground"
211 numberOfLines={1}
212 style={{ color: dangerForegroundColor }}
213 >
214 Clear {formatBytes(props.environment.payloadBytes)}
215 </Text>
216 </Pressable>
217 </View>
218 );
219}
220
221function formatBytes(bytes: number): string {
222 if (bytes < 1024) return `${bytes} B`;
223 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`;
224 return `${(bytes / (1024 * 1024)).toFixed(bytes < 10 * 1024 * 1024 ? 1 : 0)} MB`;
225}
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+43-11
......@@ -1,4 +1,5 @@
11import { useAuth, useUser } from "@clerk/expo";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";
23import Constants from "expo-constants";
34import * as Notifications from "expo-notifications";
45import * as Updates from "expo-updates";
......@@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native";
67import { NativeStackScreenOptions } from "../../native/StackHeader";
78import { SymbolView } from "expo-symbols";
89import * as Effect from "effect/Effect";
10import { AsyncResult } from "effect/unstable/reactivity";
911import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
1012import { Alert, Linking, Platform, ScrollView, View } from "react-native";
1113import { useSafeAreaInsets } from "react-native-safe-area-context";
......@@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu
3133import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
3234import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
3335import { runtime } from "../../lib/runtime";
34import { loadPreferences } from "../../lib/storage";
3536import { useThemeColor } from "../../lib/useThemeColor";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3638import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
3739import { SettingsRow } from "./components/SettingsRow";
3840import { SettingsSection } from "./components/SettingsSection";
......@@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() {
122124}
123125
124126function ConfiguredSettingsRouteScreen() {
127 const preferencesResult = useAtomValue(mobilePreferencesAtom);
128 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
125129 const insets = useSafeAreaInsets();
126130 const navigation = useNavigation();
127131 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
......@@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() {
131135 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
132136 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
133137 const deviceRegistered = useDeviceRegistered();
138 const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult)
139 ? preferencesResult.value.liveActivitiesEnabled !== false
140 : true;
134141
135142 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
136143 const environmentCount = connections.length;
......@@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() {
167174 setLiveActivityStatus("signed-out");
168175 return;
169176 }
170 void (async () => {
171 const result = await settlePromise(() => loadPreferences());
172 if (result._tag === "Failure") {
173 reportAtomCommandResult(result, { label: "live activity preference load" });
177 if (!AsyncResult.isSuccess(preferencesResult)) {
178 if (AsyncResult.isFailure(preferencesResult)) {
179 reportAtomCommandResult(preferencesResult, { label: "live activity preference load" });
174180 setLiveActivityStatus("enabled");
175 return;
181 } else {
182 setLiveActivityStatus("checking");
176183 }
177 setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled");
178 })();
179 }, [isLoaded, isSignedIn]);
184 return;
185 }
186 setLiveActivityStatus(
187 preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled",
188 );
189 }, [isLoaded, isSignedIn, preferencesResult]);
180190
181191 const requestNotifications = useCallback(async () => {
182192 const result = await settleAsyncResult(() =>
......@@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() {
279289 runtime.runPromiseExit(
280290 setLiveActivityUpdatesEnabled({
281291 enabled: true,
292 previousEnabled: liveActivitiesPreferenceEnabled,
282293 clerkToken: tokenResult.value,
283294 connections,
284295 }),
......@@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() {
296307 return;
297308 }
298309
310 savePreferences({ liveActivitiesEnabled: true });
299311 refreshManagedRelayEnvironments();
300312 setLiveActivityStatus("enabled");
301313 // The environment link can succeed while this device's own registration
......@@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() {
314326 "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.",
315327 );
316328 }
317 }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);
329 }, [
330 connections,
331 environmentCount,
332 getToken,
333 isSignedIn,
334 liveActivitiesPreferenceEnabled,
335 promptSignIn,
336 savePreferences,
337 ]);
318338
319339 const handleDeviceNotificationsChange = useCallback(
320340 (enabled: boolean) => {
......@@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() {
358378 runtime.runPromiseExit(
359379 setLiveActivityUpdatesEnabled({
360380 enabled: false,
381 previousEnabled: liveActivitiesPreferenceEnabled,
361382 clerkToken: token,
362383 connections,
363384 }),
364385 ),
365386 );
366387 if (updateResult._tag === "Failure") {
388 setLiveActivityStatus("enabled");
367389 reportAtomCommandResult(updateResult, {
368390 label: "live activity disable",
369391 });
370392 return;
371393 }
394 savePreferences({ liveActivitiesEnabled: false });
372395 refreshManagedRelayEnvironments();
373396 })();
374397 return;
......@@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() {
381404
382405 void linkEnvironments();
383406 },
384 [connections, getToken, isSignedIn, linkEnvironments, promptSignIn],
407 [
408 connections,
409 getToken,
410 isSignedIn,
411 linkEnvironments,
412 liveActivitiesPreferenceEnabled,
413 promptSignIn,
414 savePreferences,
415 ],
385416 );
386417
387418 const openAccount = useCallback(() => {
......@@ -488,6 +519,7 @@ function AppSettingsSection() {
488519
489520 return (
490521 <SettingsSection title="App">
522 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
491523 <View className="flex-row items-center gap-4 p-4">
492524 <SymbolView
493525 name="info.circle"
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+24-56
......@@ -1,12 +1,7 @@
1import {
2 createContext,
3 useCallback,
4 useContext,
5 useEffect,
6 useMemo,
7 useState,
8 type ReactNode,
9} from "react";
1import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react";
2
3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4import { AsyncResult } from "effect/unstable/reactivity";
105
116import { Uniwind } from "uniwind";
127
......@@ -17,7 +12,7 @@ import {
1712 type AppearancePreferences,
1813 type ResolvedAppearance,
1914} from "../../../lib/appearancePreferences";
20import { loadPreferences, savePreferencesPatch } from "../../../lib/storage";
15import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
2116import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
2217
2318interface AppearancePreferencesContextValue {
......@@ -52,55 +47,28 @@ function applyTextScaleVariables(baseFontSize: number) {
5247}
5348
5449export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
55 const [preferences, setPreferences] = useState<AppearancePreferences>(() =>
56 resolveAppearancePreferences(null),
50 const preferencesResult = useAtomValue(mobilePreferencesAtom);
51 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
52 const preferences = useMemo(
53 () =>
54 resolveAppearancePreferences(
55 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null,
56 ),
57 [preferencesResult],
5758 );
58 const [isReady, setIsReady] = useState(false);
59
60 useEffect(() => {
61 let cancelled = false;
62
63 void loadPreferences()
64 .then((stored) => {
65 if (cancelled) {
66 return;
67 }
68
69 const resolved = resolveAppearancePreferences(stored);
70 setPreferences(resolved);
71 cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize);
72 setIsReady(true);
73 })
74 .catch(() => {
75 if (cancelled) {
76 return;
77 }
78
79 setIsReady(true);
80 });
81
82 return () => {
83 cancelled = true;
84 };
85 }, []);
59 const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
8660
8761 useEffect(() => {
8862 applyTextScaleVariables(preferences.baseFontSize);
89 }, [preferences.baseFontSize]);
90
91 const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => {
92 setPreferences((current) => {
93 const next = resolveAppearancePreferences({ ...current, ...patch });
94 cacheTerminalFontSize(resolveAppearance(next).terminalFontSize);
95 void savePreferencesPatch({
96 baseFontSize: next.baseFontSize,
97 terminalFontSize: next.terminalFontSize,
98 codeFontSize: next.codeFontSize,
99 codeWordBreak: next.codeWordBreak,
100 }).catch(() => undefined);
101 return next;
102 });
103 }, []);
63 cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
64 }, [preferences]);
65
66 const updatePreferences = useCallback(
67 (patch: Partial<AppearancePreferences>) => {
68 savePreferences(patch);
69 },
70 [savePreferences],
71 );
10472
10573 const setBaseFontSize = useCallback(
10674 (value: number) => {
......@@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
150118}
151119
152120export function useAppearancePreferences(): AppearancePreferencesContextValue {
153 const context = useContext(AppearancePreferencesContext);
121 const context = use(AppearancePreferencesContext);
154122 if (!context) {
155123 throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
156124 }
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+5-1
......@@ -1 +1,5 @@
1export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance";
1export type SettingsSheetTarget =
2 | "SettingsEnvironments"
3 | "SettingsArchive"
4 | "SettingsAppearance"
5 | "SettingsClientStorage";
apps/mobile/src/lib/runtime.ts+3
......@@ -8,6 +8,7 @@ import { cryptoLayer } from "../features/cloud/dpop";
88import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer";
99import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
1010import { tracingLayer } from "../features/observability/tracing";
11import * as Persistence from "../persistence/layer";
1112
1213function configuredRelayUrl(): string {
1314 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";
......@@ -20,6 +21,7 @@ type RuntimeLayerSource =
2021 | typeof Socket.layerWebSocketConstructorGlobal
2122 | typeof cryptoLayer
2223 | typeof httpClientLayer
24 | typeof Persistence.layer
2325 | typeof tracingLayer;
2426
2527const runtimeLayer = Layer.merge(
......@@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge(
2931 Layer.provideMerge(cryptoLayer),
3032 Layer.provideMerge(httpClientLayer),
3133 Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),
34 Layer.provideMerge(Persistence.layer),
3235);
3336
3437export const runtime: ManagedRuntime.ManagedRuntime<
apps/mobile/src/lib/storage.test.ts+148-8
......@@ -3,34 +3,103 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
44const mocks = vi.hoisted(() => {
55 const values = new Map<string, string>();
6 let preferencesJson: string | null = null;
7 let preferencesUpdatedAt = 0;
8 let loadPreferencesFails = false;
9 let savePreferencesFails = false;
610 return {
7 clear: () => values.clear(),
11 clear: () => {
12 values.clear();
13 preferencesJson = null;
14 preferencesUpdatedAt = 0;
15 loadPreferencesFails = false;
16 savePreferencesFails = false;
17 },
18 getStoredValue: (key: string) => values.get(key) ?? null,
19 getPreferencesJson: () => preferencesJson,
20 setPreferencesJson: (value: string, updatedAt: number) => {
21 preferencesJson = value;
22 preferencesUpdatedAt = updatedAt;
23 },
24 setDatabaseFailures: (load: boolean, save: boolean) => {
25 loadPreferencesFails = load;
26 savePreferencesFails = save;
27 },
828 getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
929 setItemAsync: vi.fn((key: string, value: string) => {
1030 values.set(key, value);
1131 return Promise.resolve();
1232 }),
33 deleteItemAsync: vi.fn((key: string) => {
34 values.delete(key);
35 return Promise.resolve();
36 }),
37 database: {
38 closeAsync: vi.fn(() => Promise.resolve()),
39 execAsync: vi.fn(() => Promise.resolve()),
40 withExclusiveTransactionAsync: vi.fn(
41 (run: (transaction: { execAsync: () => Promise<void> }) => Promise<void>) =>
42 run({ execAsync: () => Promise.resolve() }),
43 ),
44 getFirstAsync: vi.fn((sql: string) => {
45 if (sql.includes("PRAGMA user_version")) {
46 return Promise.resolve({ user_version: 1 });
47 }
48 if (loadPreferencesFails) {
49 return Promise.reject(new Error("database unavailable"));
50 }
51 return Promise.resolve(
52 preferencesJson === null
53 ? null
54 : { payload: preferencesJson, updatedAt: preferencesUpdatedAt },
55 );
56 }),
57 runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => {
58 if (savePreferencesFails) {
59 return Promise.reject(new Error("database unavailable"));
60 }
61 if (typeof payload === "string") {
62 preferencesJson = payload;
63 }
64 if (typeof updatedAt === "number") {
65 preferencesUpdatedAt = updatedAt;
66 }
67 return Promise.resolve();
68 }),
69 },
1370 };
1471});
1572
1673vi.mock("expo-secure-store", () => ({
74 deleteItemAsync: mocks.deleteItemAsync,
1775 getItemAsync: mocks.getItemAsync,
1876 setItemAsync: mocks.setItemAsync,
1977}));
2078
79vi.mock("expo-sqlite", () => ({
80 openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)),
81}));
82
83vi.mock("expo-crypto", () => ({
84 getRandomBytes: vi.fn(() => new Uint8Array(16)),
85}));
86
87vi.mock("expo-constants", () => ({
88 default: { expoConfig: { extra: {} } },
89}));
90
2191vi.mock("react-native", () => ({
2292 Platform: {
2393 OS: "ios",
2494 },
2595}));
2696
27vi.mock("./runtime", () => ({
28 runtime: {
29 runPromise: vi.fn(),
30 },
31}));
32
33import { loadSavedConnections, saveConnection } from "./storage";
97import {
98 loadPreferences,
99 loadSavedConnections,
100 saveConnection,
101 savePreferencesPatch,
102} from "../persistence/imperative";
34103import { toStableSavedRemoteConnection } from "./connection";
35104
36105const managedConnection = {
......@@ -100,4 +169,75 @@ describe("mobile connection storage", () => {
100169
101170 warn.mockRestore();
102171 });
172
173 it("loads legacy preferences when SQLite is unavailable", async () => {
174 mocks.setDatabaseFailures(true, true);
175 await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 }));
176
177 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 });
178 });
179
180 it("falls back to secure storage when SQLite cannot save preferences", async () => {
181 mocks.setDatabaseFailures(true, true);
182 await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 });
183 const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as {
184 readonly payload: string;
185 readonly updatedAt: number;
186 };
187 expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 });
188 expect(fallback.updatedAt).toEqual(expect.any(Number));
189 });
190
191 it("reconciles fallback preferences after SQLite recovers", async () => {
192 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10);
193 await mocks.setItemAsync(
194 "t3code.preferences.fallback",
195 JSON.stringify({
196 payload: JSON.stringify({ baseFontSize: 19 }),
197 updatedAt: 20,
198 }),
199 );
200
201 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 });
202 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 });
203 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
204 });
205
206 it("ignores a stale fallback when its previous deletion failed", async () => {
207 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
208 await mocks.setItemAsync(
209 "t3code.preferences.fallback",
210 JSON.stringify({
211 payload: JSON.stringify({ baseFontSize: 19 }),
212 updatedAt: 20,
213 }),
214 );
215
216 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
217 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
218 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
219 });
220
221 it("ignores an invalid fallback even when it has a newer timestamp", async () => {
222 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
223 await mocks.setItemAsync(
224 "t3code.preferences.fallback",
225 JSON.stringify({ payload: "{", updatedAt: 40 }),
226 );
227 const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
228
229 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
230 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
231 expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull();
232
233 warn.mockRestore();
234 });
235
236 it("keeps SQLite authoritative when stale legacy preferences remain", async () => {
237 mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30);
238 await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 }));
239
240 await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 });
241 expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 });
242 });
103243});
apps/mobile/src/lib/storage.ts deleted-291
......@@ -1,291 +0,0 @@
1import * as Arr from "effect/Array";
2import { pipe } from "effect/Function";
3import * as Schema from "effect/Schema";
4import * as SecureStore from "expo-secure-store";
5import { EnvironmentId } from "@t3tools/contracts";
6
7import {
8 isRelayManagedConnection,
9 type SavedRemoteConnection,
10 toStableSavedRemoteConnection,
11} from "./connection";
12
13const CONNECTIONS_KEY = "t3code.connections";
14const PREFERENCES_KEY = "t3code.preferences";
15const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
16const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
17const MobileStorageKey = Schema.Literals([
18 CONNECTIONS_KEY,
19 PREFERENCES_KEY,
20 AGENT_AWARENESS_DEVICE_ID_KEY,
21 AGENT_AWARENESS_REGISTRATION_KEY,
22]);
23type MobileStorageKeyValue = typeof MobileStorageKey.Type;
24
25export class MobileSecureStorageError extends Schema.TaggedErrorClass<MobileSecureStorageError>()(
26 "MobileSecureStorageError",
27 {
28 operation: Schema.Literals(["read", "write", "generate-device-id"]),
29 key: MobileStorageKey,
30 cause: Schema.Defect(),
31 },
32) {
33 override get message(): string {
34 return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`;
35 }
36}
37
38export class MobileStorageDecodeError extends Schema.TaggedErrorClass<MobileStorageDecodeError>()(
39 "MobileStorageDecodeError",
40 {
41 key: MobileStorageKey,
42 cause: Schema.Defect(),
43 },
44) {
45 override get message(): string {
46 return `Failed to decode mobile storage value for key ${this.key}.`;
47 }
48}
49
50export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStorageEncodeError>()(
51 "MobileStorageEncodeError",
52 {
53 key: MobileStorageKey,
54 cause: Schema.Defect(),
55 },
56) {
57 override get message(): string {
58 return `Failed to encode mobile storage value for key ${this.key}.`;
59 }
60}
61
62export interface Preferences {
63 readonly liveActivitiesEnabled?: boolean;
64 readonly baseFontSize?: number;
65 /** Terminal font size override; null/absent means derived from baseFontSize. */
66 readonly terminalFontSize?: number | null;
67 /** Legacy key predating baseFontSize; read once for migration. */
68 readonly markdownFontSize?: number;
69 /** Code/diff font size override; null/absent means derived from baseFontSize. */
70 readonly codeFontSize?: number | null;
71 readonly codeWordBreak?: boolean;
72 /** Cloud account ids that opted out of the T3 Connect onboarding sheet. */
73 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
74}
75
76async function readStorageItem(key: MobileStorageKeyValue): Promise<string | null> {
77 try {
78 return await SecureStore.getItemAsync(key);
79 } catch (cause) {
80 throw new MobileSecureStorageError({ operation: "read", key, cause });
81 }
82}
83
84async function writeStorageItem(key: MobileStorageKeyValue, value: string): Promise<void> {
85 try {
86 await SecureStore.setItemAsync(key, value);
87 } catch (cause) {
88 throw new MobileSecureStorageError({ operation: "write", key, cause });
89 }
90}
91
92async function readJsonStorageItem<T>(key: MobileStorageKeyValue): Promise<T | null> {
93 const raw = (await readStorageItem(key)) ?? "";
94 if (!raw.trim()) {
95 return null;
96 }
97
98 try {
99 return JSON.parse(raw) as T;
100 } catch (cause) {
101 console.warn(
102 "[mobile-storage] ignored invalid JSON",
103 new MobileStorageDecodeError({ key, cause }),
104 );
105 return null;
106 }
107}
108
109async function writeJsonStorageItem(key: MobileStorageKeyValue, value: unknown) {
110 let encoded: string;
111 try {
112 encoded = JSON.stringify(value);
113 } catch (cause) {
114 throw new MobileStorageEncodeError({ key, cause });
115 }
116 await writeStorageItem(key, encoded);
117}
118
119export async function loadSavedConnections(): Promise<ReadonlyArray<SavedRemoteConnection>> {
120 const parsed = await readJsonStorageItem<{
121 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
122 }>(CONNECTIONS_KEY);
123 if (!parsed) {
124 return [];
125 }
126
127 return pipe(
128 parsed.connections ?? [],
129 Arr.filter(
130 (c) => !!c.environmentId && (!!c.bearerToken?.trim() || isRelayManagedConnection(c)),
131 ),
132 );
133}
134
135export async function saveConnection(connection: SavedRemoteConnection): Promise<void> {
136 const current = await loadSavedConnections();
137 const stableConnection = toStableSavedRemoteConnection(connection);
138 const next = current.some((entry) => entry.environmentId === connection.environmentId)
139 ? pipe(
140 current,
141 Arr.map((entry) =>
142 entry.environmentId === connection.environmentId ? stableConnection : entry,
143 ),
144 )
145 : pipe(current, Arr.append(stableConnection));
146
147 await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next });
148}
149
150export async function clearSavedConnection(environmentId: EnvironmentId): Promise<void> {
151 const current = await loadSavedConnections();
152 const next = pipe(
153 current,
154 Arr.filter((entry) => entry.environmentId !== environmentId),
155 );
156 await writeJsonStorageItem(CONNECTIONS_KEY, { connections: next });
157}
158
159export async function loadPreferences(): Promise<Preferences> {
160 const parsed = await readJsonStorageItem<Preferences>(PREFERENCES_KEY);
161 if (!parsed || typeof parsed !== "object") {
162 return {};
163 }
164
165 const preferences: {
166 liveActivitiesEnabled?: boolean;
167 baseFontSize?: number;
168 terminalFontSize?: number | null;
169 markdownFontSize?: number;
170 codeFontSize?: number | null;
171 codeWordBreak?: boolean;
172 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
173 } = {};
174
175 if (typeof parsed.liveActivitiesEnabled === "boolean") {
176 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
177 }
178 if (typeof parsed.baseFontSize === "number") {
179 preferences.baseFontSize = parsed.baseFontSize;
180 }
181 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
182 preferences.terminalFontSize = parsed.terminalFontSize;
183 }
184 if (typeof parsed.markdownFontSize === "number") {
185 preferences.markdownFontSize = parsed.markdownFontSize;
186 }
187 if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) {
188 preferences.codeFontSize = parsed.codeFontSize;
189 }
190 if (typeof parsed.codeWordBreak === "boolean") {
191 preferences.codeWordBreak = parsed.codeWordBreak;
192 }
193 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
194 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
195 (account): account is string => typeof account === "string",
196 );
197 }
198
199 return preferences;
200}
201
202// Preference writes are read-modify-write over one JSON blob; concurrent
203// writers would drop each other's fields, so all writes are serialized here.
204let preferencesWriteQueue: Promise<unknown> = Promise.resolve();
205
206export async function updatePreferences(
207 update: (current: Preferences) => Partial<Preferences>,
208): Promise<Preferences> {
209 const task = preferencesWriteQueue.then(async () => {
210 const current = await loadPreferences();
211 const next: Preferences = {
212 ...current,
213 ...update(current),
214 };
215 await writeJsonStorageItem(PREFERENCES_KEY, next);
216 return next;
217 });
218 preferencesWriteQueue = task.catch(() => undefined);
219 return task;
220}
221
222export async function savePreferencesPatch(patch: Partial<Preferences>): Promise<Preferences> {
223 return updatePreferences(() => patch);
224}
225
226export async function loadOrCreateAgentAwarenessDeviceId(): Promise<string> {
227 const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY);
228 if (existing?.trim()) {
229 return existing;
230 }
231
232 const deviceId = await import("./uuid")
233 .then(({ uuidv4 }) => uuidv4())
234 .catch((cause) => {
235 throw new MobileSecureStorageError({
236 operation: "generate-device-id",
237 key: AGENT_AWARENESS_DEVICE_ID_KEY,
238 cause,
239 });
240 });
241 await writeStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
242 return deviceId;
243}
244
245export async function loadAgentAwarenessDeviceId(): Promise<string | null> {
246 const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY);
247 return existing?.trim() ? existing : null;
248}
249
250export interface AgentAwarenessRegistrationRecord {
251 readonly identity: string;
252 readonly signature: string;
253 // Last push-to-start token the relay accepted. Registrations triggered
254 // without a token event merge it back in so token absence never reads as a
255 // change (which would defeat the register-once skip every launch).
256 readonly pushToStartToken?: string;
257}
258
259// Remembers the account identity and payload signature the relay last accepted
260// so the app does not re-register on every launch while nothing has changed.
261// Cleared only on sign-out.
262export async function loadAgentAwarenessRegistrationRecord(): Promise<AgentAwarenessRegistrationRecord | null> {
263 const parsed = await readJsonStorageItem<AgentAwarenessRegistrationRecord>(
264 AGENT_AWARENESS_REGISTRATION_KEY,
265 );
266 if (
267 !parsed ||
268 typeof parsed !== "object" ||
269 typeof parsed.identity !== "string" ||
270 typeof parsed.signature !== "string"
271 ) {
272 return null;
273 }
274 return {
275 identity: parsed.identity,
276 signature: parsed.signature,
277 ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken
278 ? { pushToStartToken: parsed.pushToStartToken }
279 : {}),
280 };
281}
282
283export async function saveAgentAwarenessRegistrationRecord(
284 record: AgentAwarenessRegistrationRecord,
285): Promise<void> {
286 await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record);
287}
288
289export async function clearAgentAwarenessRegistrationRecord(): Promise<void> {
290 await writeStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, "");
291}
apps/mobile/src/persistence/imperative.ts created+47
......@@ -0,0 +1,47 @@
1import * as Effect from "effect/Effect";
2
3import { runtime } from "../lib/runtime";
4import * as MobilePreferences from "./mobile-preferences";
5import * as MobileStorage from "./mobile-storage";
6
7export type { Preferences } from "./mobile-preferences";
8export type { AgentAwarenessRegistrationRecord } from "./mobile-storage";
9export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences";
10export {
11 MobileDeviceIdGenerationError,
12 MobileStorageDecodeError,
13 MobileStorageEncodeError,
14} from "./mobile-storage";
15export { MobileSecureStorageError } from "./mobile-secure-storage";
16
17const runStorage = <A, E>(
18 use: (storage: MobileStorage.MobileStorage["Service"]) => Effect.Effect<A, E>,
19) => runtime.runPromise(MobileStorage.MobileStorage.pipe(Effect.flatMap(use)));
20
21const runPreferences = <A, E>(
22 use: (store: MobilePreferences.MobilePreferencesStore["Service"]) => Effect.Effect<A, E>,
23) => runtime.runPromise(MobilePreferences.MobilePreferencesStore.pipe(Effect.flatMap(use)));
24
25export const loadSavedConnections = () => runStorage((storage) => storage.loadSavedConnections);
26export const saveConnection = (
27 connection: Parameters<MobileStorage.MobileStorage["Service"]["saveConnection"]>[0],
28) => runStorage((storage) => storage.saveConnection(connection));
29
30export const loadPreferences = () => runPreferences((store) => store.load);
31export const savePreferencesPatch = (patch: Partial<MobilePreferences.Preferences>) =>
32 runPreferences((store) => store.savePatch(patch));
33export const updatePreferences = (
34 transform: (current: MobilePreferences.Preferences) => Partial<MobilePreferences.Preferences>,
35) => runPreferences((store) => store.update(transform));
36
37export const loadOrCreateAgentAwarenessDeviceId = () =>
38 runStorage((storage) => storage.loadOrCreateAgentAwarenessDeviceId);
39export const loadAgentAwarenessDeviceId = () =>
40 runStorage((storage) => storage.loadAgentAwarenessDeviceId);
41export const loadAgentAwarenessRegistrationRecord = () =>
42 runStorage((storage) => storage.loadAgentAwarenessRegistrationRecord);
43export const saveAgentAwarenessRegistrationRecord = (
44 record: MobileStorage.AgentAwarenessRegistrationRecord,
45) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record));
46export const clearAgentAwarenessRegistrationRecord = () =>
47 runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord);
apps/mobile/src/persistence/layer.ts created+16
......@@ -0,0 +1,16 @@
1import * as Layer from "effect/Layer";
2
3import * as EnvironmentCacheStore from "../connection/environment-cache-store";
4import * as MobileDatabase from "./mobile-database";
5import * as MobilePreferences from "./mobile-preferences";
6import * as MobileSecureStorage from "./mobile-secure-storage";
7import * as MobileStorage from "./mobile-storage";
8
9const baseLayer = Layer.merge(MobileDatabase.layer, MobileSecureStorage.layer);
10const dependentLayer = Layer.mergeAll(
11 MobilePreferences.layer,
12 MobileStorage.layer,
13 EnvironmentCacheStore.layer,
14).pipe(Layer.provide(baseLayer));
15
16export const layer = Layer.merge(baseLayer, dependentLayer);
apps/mobile/src/persistence/mobile-database.test.ts created+71
......@@ -0,0 +1,71 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { vi } from "vite-plus/test";
4
5const openDatabaseAsync = vi.hoisted(() => vi.fn());
6
7vi.mock("expo-sqlite", () => ({ openDatabaseAsync }));
8
9import { decodeLegacyCacheRecord, make } from "./mobile-database";
10
11describe("mobile database legacy cache migration", () => {
12 it.effect("keeps acquisition failures typed on database operations", () =>
13 Effect.scoped(
14 Effect.gen(function* () {
15 openDatabaseAsync.mockRejectedValueOnce(new Error("SQLite unavailable"));
16
17 const database = yield* make;
18 const result = yield* Effect.result(database.loadPreferencesJson);
19
20 expect(result).toMatchObject({
21 _tag: "Failure",
22 failure: { _tag: "MobileDatabaseError", operation: "open" },
23 });
24 }),
25 ),
26 );
27
28 it("maps legacy thread records to their SQLite identity", () => {
29 const payload = JSON.stringify({
30 schemaVersion: 2,
31 environmentId: "environment-1",
32 threadId: "thread-1",
33 snapshot: {},
34 });
35
36 expect(decodeLegacyCacheRecord("connection-thread-snapshots", payload)).toEqual({
37 environmentId: "environment-1",
38 kind: "thread",
39 cacheKey: "thread-1",
40 schemaVersion: 2,
41 payload,
42 });
43 });
44
45 it("preserves the old shell payload for schema decoding after migration", () => {
46 const payload = JSON.stringify({
47 schemaVersion: 1,
48 environmentId: "environment-1",
49 snapshotReceivedAt: "2026-07-01T00:00:00.000Z",
50 snapshot: {},
51 });
52
53 expect(decodeLegacyCacheRecord("shell-snapshots", payload)).toEqual({
54 environmentId: "environment-1",
55 kind: "shell",
56 cacheKey: "snapshot",
57 schemaVersion: 1,
58 payload,
59 });
60 });
61
62 it("skips malformed legacy records", () => {
63 expect(decodeLegacyCacheRecord("connection-vcs-refs", "{not-json")).toBeNull();
64 expect(
65 decodeLegacyCacheRecord(
66 "connection-vcs-refs",
67 JSON.stringify({ schemaVersion: 1, environmentId: "environment-1" }),
68 ),
69 ).toBeNull();
70 });
71});
apps/mobile/src/persistence/mobile-database.ts created+409
......@@ -0,0 +1,409 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Schema from "effect/Schema";
7import type { SQLiteDatabase } from "expo-sqlite";
8
9const DATABASE_NAME = "t3code-client.db";
10const DATABASE_SCHEMA_VERSION = 1;
11const LEGACY_CACHE_DIRECTORIES = [
12 "connection-shell-snapshots",
13 "shell-snapshots",
14 "connection-thread-snapshots",
15 "connection-server-configs",
16 "connection-vcs-refs",
17] as const;
18
19export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]);
20export type ClientCacheKind = typeof ClientCacheKind.Type;
21
22export interface ClientCacheSummaryRow {
23 readonly environmentId: EnvironmentId;
24 readonly kind: ClientCacheKind;
25 readonly recordCount: number;
26 readonly payloadBytes: number;
27}
28
29export interface StoredPreferencesJson {
30 readonly payload: string;
31 readonly updatedAt: number;
32}
33
34const ClientCacheSummaryRows = Schema.Array(
35 Schema.Struct({
36 environmentId: Schema.String,
37 kind: ClientCacheKind,
38 recordCount: Schema.Number,
39 payloadBytes: Schema.Number,
40 }),
41);
42
43const MobileDatabaseOperation = Schema.Literals([
44 "open",
45 "migrate",
46 "load-cache",
47 "save-cache",
48 "remove-cache",
49 "clear-environment-cache",
50 "clear-all-caches",
51 "inspect-caches",
52 "load-preferences",
53 "save-preferences",
54]);
55
56export class MobileDatabaseError extends Schema.TaggedErrorClass<MobileDatabaseError>()(
57 "MobileDatabaseError",
58 {
59 operation: MobileDatabaseOperation,
60 cause: Schema.Defect(),
61 },
62) {
63 override get message(): string {
64 return `Mobile database operation failed: ${this.operation}.`;
65 }
66}
67
68function databaseError(operation: typeof MobileDatabaseOperation.Type) {
69 return (cause: unknown) => new MobileDatabaseError({ operation, cause });
70}
71
72interface LegacyCacheRecord {
73 readonly environmentId: string;
74 readonly kind: ClientCacheKind;
75 readonly cacheKey: string;
76 readonly schemaVersion: number;
77 readonly payload: string;
78}
79
80function objectRecord(value: unknown): Record<string, unknown> | null {
81 return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
82}
83
84export function decodeLegacyCacheRecord(
85 directoryName: (typeof LEGACY_CACHE_DIRECTORIES)[number],
86 payload: string,
87): LegacyCacheRecord | null {
88 let parsed: Record<string, unknown> | null;
89 try {
90 parsed = objectRecord(JSON.parse(payload));
91 } catch {
92 return null;
93 }
94 if (
95 parsed === null ||
96 typeof parsed.environmentId !== "string" ||
97 typeof parsed.schemaVersion !== "number"
98 ) {
99 return null;
100 }
101
102 switch (directoryName) {
103 case "connection-shell-snapshots":
104 case "shell-snapshots":
105 return {
106 environmentId: parsed.environmentId,
107 kind: "shell",
108 cacheKey: "snapshot",
109 schemaVersion: parsed.schemaVersion,
110 payload,
111 };
112 case "connection-thread-snapshots":
113 return typeof parsed.threadId === "string"
114 ? {
115 environmentId: parsed.environmentId,
116 kind: "thread",
117 cacheKey: parsed.threadId,
118 schemaVersion: parsed.schemaVersion,
119 payload,
120 }
121 : null;
122 case "connection-server-configs":
123 return {
124 environmentId: parsed.environmentId,
125 kind: "server-config",
126 cacheKey: "config",
127 schemaVersion: parsed.schemaVersion,
128 payload,
129 };
130 case "connection-vcs-refs":
131 return typeof parsed.cwd === "string"
132 ? {
133 environmentId: parsed.environmentId,
134 kind: "vcs-refs",
135 cacheKey: parsed.cwd,
136 schemaVersion: parsed.schemaVersion,
137 payload,
138 }
139 : null;
140 }
141}
142
143async function migrateLegacyFileCaches(database: SQLiteDatabase): Promise<boolean> {
144 try {
145 const { Directory, File, Paths } = await import("expo-file-system");
146 let complete = true;
147 const listFiles = (
148 directory: InstanceType<typeof Directory>,
149 ): Array<InstanceType<typeof File>> =>
150 directory.list().flatMap((entry) => (entry instanceof File ? [entry] : listFiles(entry)));
151
152 for (const directoryName of LEGACY_CACHE_DIRECTORIES) {
153 try {
154 const directory = new Directory(Paths.document, directoryName);
155 if (!directory.exists) continue;
156 for (const file of listFiles(directory)) {
157 const payload = await file.text();
158 const record = decodeLegacyCacheRecord(directoryName, payload);
159 if (record === null) continue;
160 await database.runAsync(
161 `INSERT INTO client_cache
162 (environment_id, kind, cache_key, schema_version, payload, updated_at)
163 VALUES (?, ?, ?, ?, ?, ?)
164 ON CONFLICT (environment_id, kind, cache_key) DO NOTHING`,
165 record.environmentId,
166 record.kind,
167 record.cacheKey,
168 record.schemaVersion,
169 record.payload,
170 Date.now(),
171 );
172 }
173 directory.delete();
174 } catch (cause) {
175 complete = false;
176 console.warn(`[mobile-database] could not migrate legacy cache ${directoryName}`, cause);
177 }
178 }
179 return complete;
180 } catch (cause) {
181 console.warn("[mobile-database] could not load legacy cache migration", cause);
182 return false;
183 }
184}
185
186export class MobileDatabase extends Context.Service<
187 MobileDatabase,
188 {
189 readonly loadCache: (
190 environmentId: EnvironmentId,
191 kind: ClientCacheKind,
192 cacheKey: string,
193 ) => Effect.Effect<Option.Option<string>, MobileDatabaseError>;
194 readonly saveCache: (
195 environmentId: EnvironmentId,
196 kind: ClientCacheKind,
197 cacheKey: string,
198 schemaVersion: number,
199 payload: string,
200 ) => Effect.Effect<void, MobileDatabaseError>;
201 readonly removeCache: (
202 environmentId: EnvironmentId,
203 kind: ClientCacheKind,
204 cacheKey: string,
205 ) => Effect.Effect<void, MobileDatabaseError>;
206 readonly clearEnvironmentCache: (
207 environmentId: EnvironmentId,
208 ) => Effect.Effect<void, MobileDatabaseError>;
209 readonly clearAllCaches: Effect.Effect<void, MobileDatabaseError>;
210 readonly inspectCaches: Effect.Effect<
211 ReadonlyArray<ClientCacheSummaryRow>,
212 MobileDatabaseError
213 >;
214 readonly loadPreferencesJson: Effect.Effect<
215 Option.Option<StoredPreferencesJson>,
216 MobileDatabaseError
217 >;
218 readonly savePreferencesJson: (
219 payload: string,
220 updatedAt: number,
221 ) => Effect.Effect<void, MobileDatabaseError>;
222 }
223>()("@t3tools/mobile/persistence/MobileDatabase") {}
224
225const makeAvailable = Effect.gen(function* () {
226 const database = yield* Effect.acquireRelease(
227 Effect.tryPromise({
228 try: async () => {
229 const SQLite = await import("expo-sqlite");
230 return SQLite.openDatabaseAsync(DATABASE_NAME);
231 },
232 catch: databaseError("open"),
233 }),
234 (openDatabase) => Effect.promise(() => openDatabase.closeAsync()).pipe(Effect.ignore),
235 );
236
237 yield* Effect.tryPromise({
238 try: async () => {
239 await database.execAsync("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;");
240 const schema = await database.getFirstAsync<{ readonly user_version: number }>(
241 "PRAGMA user_version",
242 );
243 await database.withExclusiveTransactionAsync(async (transaction) => {
244 await transaction.execAsync(`
245 CREATE TABLE IF NOT EXISTS client_cache (
246 environment_id TEXT NOT NULL,
247 kind TEXT NOT NULL,
248 cache_key TEXT NOT NULL,
249 schema_version INTEGER NOT NULL,
250 payload TEXT NOT NULL,
251 updated_at INTEGER NOT NULL,
252 PRIMARY KEY (environment_id, kind, cache_key)
253 ) WITHOUT ROWID;
254
255 CREATE INDEX IF NOT EXISTS client_cache_environment_updated
256 ON client_cache (environment_id, updated_at DESC);
257
258 CREATE TABLE IF NOT EXISTS client_preferences (
259 singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1),
260 payload TEXT NOT NULL,
261 updated_at INTEGER NOT NULL
262 );
263 `);
264 });
265 if ((schema?.user_version ?? 0) < DATABASE_SCHEMA_VERSION) {
266 const migrated = await migrateLegacyFileCaches(database);
267 if (migrated) {
268 await database.execAsync(`PRAGMA user_version = ${DATABASE_SCHEMA_VERSION};`);
269 }
270 }
271 },
272 catch: databaseError("migrate"),
273 });
274
275 return MobileDatabase.of({
276 loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) =>
277 Effect.tryPromise({
278 try: () =>
279 database.getFirstAsync<{ readonly payload: string }>(
280 `SELECT payload
281 FROM client_cache
282 WHERE environment_id = ? AND kind = ? AND cache_key = ?`,
283 environmentId,
284 kind,
285 cacheKey,
286 ),
287 catch: databaseError("load-cache"),
288 }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))),
289 ),
290 saveCache: Effect.fn("MobileDatabase.saveCache")(
291 (environmentId, kind, cacheKey, schemaVersion, payload) =>
292 Effect.tryPromise({
293 try: () =>
294 database.runAsync(
295 `INSERT INTO client_cache
296 (environment_id, kind, cache_key, schema_version, payload, updated_at)
297 VALUES (?, ?, ?, ?, ?, ?)
298 ON CONFLICT (environment_id, kind, cache_key) DO UPDATE SET
299 schema_version = excluded.schema_version,
300 payload = excluded.payload,
301 updated_at = excluded.updated_at`,
302 environmentId,
303 kind,
304 cacheKey,
305 schemaVersion,
306 payload,
307 Date.now(),
308 ),
309 catch: databaseError("save-cache"),
310 }).pipe(Effect.asVoid),
311 ),
312 removeCache: Effect.fn("MobileDatabase.removeCache")((environmentId, kind, cacheKey) =>
313 Effect.tryPromise({
314 try: () =>
315 database.runAsync(
316 `DELETE FROM client_cache
317 WHERE environment_id = ? AND kind = ? AND cache_key = ?`,
318 environmentId,
319 kind,
320 cacheKey,
321 ),
322 catch: databaseError("remove-cache"),
323 }).pipe(Effect.asVoid),
324 ),
325 clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")((environmentId) =>
326 Effect.tryPromise({
327 try: () =>
328 database.runAsync("DELETE FROM client_cache WHERE environment_id = ?", environmentId),
329 catch: databaseError("clear-environment-cache"),
330 }).pipe(Effect.asVoid),
331 ),
332 clearAllCaches: Effect.tryPromise({
333 try: () => database.runAsync("DELETE FROM client_cache"),
334 catch: databaseError("clear-all-caches"),
335 }).pipe(Effect.asVoid),
336 inspectCaches: Effect.tryPromise({
337 try: () =>
338 database.getAllAsync<unknown>(`
339 SELECT
340 environment_id AS environmentId,
341 kind,
342 COUNT(*) AS recordCount,
343 COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS payloadBytes
344 FROM client_cache
345 GROUP BY environment_id, kind
346 ORDER BY environment_id, kind
347 `),
348 catch: databaseError("inspect-caches"),
349 }).pipe(
350 Effect.flatMap(Schema.decodeUnknownEffect(ClientCacheSummaryRows)),
351 Effect.mapError(databaseError("inspect-caches")),
352 Effect.map(
353 (rows): ReadonlyArray<ClientCacheSummaryRow> =>
354 rows.map((row) => ({
355 environmentId: row.environmentId as EnvironmentId,
356 kind: row.kind,
357 recordCount: row.recordCount,
358 payloadBytes: row.payloadBytes,
359 })),
360 ),
361 ),
362 loadPreferencesJson: Effect.tryPromise({
363 try: () =>
364 database.getFirstAsync<StoredPreferencesJson>(
365 `SELECT payload, updated_at AS updatedAt
366 FROM client_preferences
367 WHERE singleton = 1`,
368 ),
369 catch: databaseError("load-preferences"),
370 }).pipe(Effect.map(Option.fromNullishOr)),
371 savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")((payload, updatedAt) =>
372 Effect.tryPromise({
373 try: () =>
374 database.runAsync(
375 `INSERT INTO client_preferences (singleton, payload, updated_at)
376 VALUES (1, ?, ?)
377 ON CONFLICT (singleton) DO UPDATE SET
378 payload = excluded.payload,
379 updated_at = excluded.updated_at`,
380 payload,
381 updatedAt,
382 ),
383 catch: databaseError("save-preferences"),
384 }).pipe(Effect.asVoid),
385 ),
386 });
387});
388
389function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] {
390 const fail = Effect.fail(error);
391 return MobileDatabase.of({
392 loadCache: () => fail,
393 saveCache: () => fail,
394 removeCache: () => fail,
395 clearEnvironmentCache: () => fail,
396 clearAllCaches: fail,
397 inspectCaches: fail,
398 loadPreferencesJson: fail,
399 savePreferencesJson: () => fail,
400 });
401}
402
403export const make = Effect.result(makeAvailable).pipe(
404 Effect.map((result) =>
405 result._tag === "Success" ? result.success : makeUnavailable(result.failure),
406 ),
407);
408
409export const layer = Layer.effect(MobileDatabase, make);
apps/mobile/src/persistence/mobile-preferences.ts created+305
......@@ -0,0 +1,305 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import * as Semaphore from "effect/Semaphore";
8
9import * as MobileDatabase from "./mobile-database";
10import * as MobileSecureStorage from "./mobile-secure-storage";
11import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage";
12
13const PREFERENCES_KEY = "t3code.preferences";
14const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback";
15
16export interface Preferences {
17 readonly liveActivitiesEnabled?: boolean;
18 readonly baseFontSize?: number;
19 readonly terminalFontSize?: number | null;
20 readonly markdownFontSize?: number;
21 readonly codeFontSize?: number | null;
22 readonly codeWordBreak?: boolean;
23 readonly connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
24 readonly collapsedProjectGroups?: readonly string[];
25}
26
27export class MobilePreferencesLoadError extends Schema.TaggedErrorClass<MobilePreferencesLoadError>()(
28 "MobilePreferencesLoadError",
29 { cause: Schema.Defect() },
30) {
31 override get message(): string {
32 return "Failed to load mobile preferences.";
33 }
34}
35
36export class MobilePreferencesSaveError extends Schema.TaggedErrorClass<MobilePreferencesSaveError>()(
37 "MobilePreferencesSaveError",
38 { cause: Schema.Defect() },
39) {
40 override get message(): string {
41 return "Failed to save mobile preferences.";
42 }
43}
44
45interface PreferencesFallback {
46 readonly payload: string;
47 readonly updatedAt: number;
48 readonly preferences: Preferences;
49}
50
51export class MobilePreferencesStore extends Context.Service<
52 MobilePreferencesStore,
53 {
54 readonly load: Effect.Effect<Preferences, MobilePreferencesLoadError>;
55 readonly savePatch: (
56 patch: Partial<Preferences>,
57 ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;
58 readonly update: (
59 transform: (current: Preferences) => Partial<Preferences>,
60 ) => Effect.Effect<Preferences, MobilePreferencesSaveError>;
61 }
62>()("@t3tools/mobile/persistence/MobilePreferencesStore") {}
63
64function sanitizePreferences(parsed: Preferences): Preferences {
65 const preferences: {
66 liveActivitiesEnabled?: boolean;
67 baseFontSize?: number;
68 terminalFontSize?: number | null;
69 markdownFontSize?: number;
70 codeFontSize?: number | null;
71 codeWordBreak?: boolean;
72 connectOnboardingOptOutAccounts?: ReadonlyArray<string>;
73 collapsedProjectGroups?: readonly string[];
74 } = {};
75
76 if (typeof parsed.liveActivitiesEnabled === "boolean") {
77 preferences.liveActivitiesEnabled = parsed.liveActivitiesEnabled;
78 }
79 if (typeof parsed.baseFontSize === "number") preferences.baseFontSize = parsed.baseFontSize;
80 if (typeof parsed.terminalFontSize === "number" || parsed.terminalFontSize === null) {
81 preferences.terminalFontSize = parsed.terminalFontSize;
82 }
83 if (typeof parsed.markdownFontSize === "number") {
84 preferences.markdownFontSize = parsed.markdownFontSize;
85 }
86 if (typeof parsed.codeFontSize === "number" || parsed.codeFontSize === null) {
87 preferences.codeFontSize = parsed.codeFontSize;
88 }
89 if (typeof parsed.codeWordBreak === "boolean") preferences.codeWordBreak = parsed.codeWordBreak;
90 if (Array.isArray(parsed.connectOnboardingOptOutAccounts)) {
91 preferences.connectOnboardingOptOutAccounts = parsed.connectOnboardingOptOutAccounts.filter(
92 (account): account is string => typeof account === "string",
93 );
94 }
95 if (Array.isArray(parsed.collapsedProjectGroups)) {
96 preferences.collapsedProjectGroups = parsed.collapsedProjectGroups.filter(
97 (key): key is string => typeof key === "string",
98 );
99 }
100 return preferences;
101}
102
103export const make = Effect.fn("MobilePreferencesStore.make")(function* () {
104 const database = yield* MobileDatabase.MobileDatabase;
105 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
106 const lock = yield* Semaphore.make(1);
107 const lastUpdatedAt = yield* Ref.make(0);
108
109 const parsePayload = (raw: string | null): Preferences | null => {
110 if (raw === null || !raw.trim()) return null;
111 let parsed: unknown;
112 try {
113 parsed = JSON.parse(raw);
114 } catch (cause) {
115 console.warn(
116 "[mobile-storage] ignored invalid JSON",
117 new MobileStorageDecodeError({ key: PREFERENCES_KEY, cause }),
118 );
119 return null;
120 }
121 return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
122 ? (parsed as Preferences)
123 : null;
124 };
125
126 const parseFallback = (raw: string | null): PreferencesFallback | null => {
127 if (raw === null || !raw.trim()) return null;
128 let parsed: unknown;
129 try {
130 parsed = JSON.parse(raw);
131 } catch (cause) {
132 console.warn(
133 "[mobile-storage] ignored invalid JSON",
134 new MobileStorageDecodeError({ key: PREFERENCES_FALLBACK_KEY, cause }),
135 );
136 return null;
137 }
138 if (
139 typeof parsed !== "object" ||
140 parsed === null ||
141 !("payload" in parsed) ||
142 typeof parsed.payload !== "string" ||
143 !("updatedAt" in parsed) ||
144 typeof parsed.updatedAt !== "number"
145 ) {
146 return null;
147 }
148 const preferences = parsePayload(parsed.payload);
149 return preferences === null
150 ? null
151 : { payload: parsed.payload, updatedAt: parsed.updatedAt, preferences };
152 };
153
154 const encode = Effect.fn("MobilePreferencesStore.encode")(function* (
155 key: string,
156 value: unknown,
157 ) {
158 return yield* Effect.try({
159 try: () => JSON.stringify(value),
160 catch: (cause) => new MobileStorageEncodeError({ key, cause }),
161 });
162 });
163
164 const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => {
165 const next = Math.max(Date.now(), last + 1);
166 return [next, next] as const;
167 });
168
169 const saveJson = Effect.fn("MobilePreferencesStore.saveJson")(function* (
170 payload: string,
171 updatedAt?: number,
172 ) {
173 const timestamp = updatedAt ?? (yield* nextUpdatedAt);
174 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, timestamp));
175 const databaseResult = yield* Effect.result(database.savePreferencesJson(payload, timestamp));
176 if (databaseResult._tag === "Failure") {
177 yield* Effect.logWarning("Database unavailable; saving preferences to secure storage.").pipe(
178 Effect.annotateLogs({ cause: databaseResult.failure }),
179 );
180 const fallback = yield* encode(PREFERENCES_FALLBACK_KEY, { payload, updatedAt: timestamp });
181 yield* secureStorage.setItem(PREFERENCES_FALLBACK_KEY, fallback);
182 return;
183 }
184 yield* secureStorage
185 .removeItem(PREFERENCES_FALLBACK_KEY)
186 .pipe(
187 Effect.catch((error) =>
188 Effect.logWarning("Could not remove the mobile preferences fallback.").pipe(
189 Effect.annotateLogs({ error }),
190 ),
191 ),
192 );
193 });
194
195 const loadUnlocked = Effect.gen(function* () {
196 const databaseResult = yield* Effect.result(database.loadPreferencesJson);
197 const databaseAvailable = databaseResult._tag === "Success";
198 const storedJson = databaseAvailable
199 ? databaseResult.success
200 : Option.none<MobileDatabase.StoredPreferencesJson>();
201 if (databaseResult._tag === "Failure") {
202 yield* Effect.logWarning("Database unavailable; loading fallback preferences.").pipe(
203 Effect.annotateLogs({ cause: databaseResult.failure }),
204 );
205 }
206
207 const fallbackResult = yield* Effect.result(secureStorage.getItem(PREFERENCES_FALLBACK_KEY));
208 let fallbackJson: string | null = null;
209 if (fallbackResult._tag === "Success") {
210 fallbackJson = fallbackResult.success;
211 } else if (Option.isNone(storedJson)) {
212 return yield* fallbackResult.failure;
213 } else {
214 yield* Effect.logWarning("Could not inspect the mobile preferences fallback.").pipe(
215 Effect.annotateLogs({ error: fallbackResult.failure }),
216 );
217 }
218
219 const fallback = parseFallback(fallbackJson);
220 const storedPreferences = Option.isSome(storedJson)
221 ? parsePayload(storedJson.value.payload)
222 : null;
223 const fallbackIsNewer =
224 fallback !== null &&
225 (storedPreferences === null ||
226 (Option.isSome(storedJson) && fallback.updatedAt > storedJson.value.updatedAt));
227
228 let parsed: Preferences | null = null;
229 if (fallbackIsNewer) {
230 parsed = fallback.preferences;
231 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, fallback.updatedAt));
232 if (databaseAvailable) yield* saveJson(fallback.payload, fallback.updatedAt);
233 } else if (storedPreferences !== null && Option.isSome(storedJson)) {
234 parsed = storedPreferences;
235 yield* Ref.update(lastUpdatedAt, (last) => Math.max(last, storedJson.value.updatedAt));
236 if (fallbackJson !== null) {
237 yield* secureStorage
238 .removeItem(PREFERENCES_FALLBACK_KEY)
239 .pipe(
240 Effect.catch((error) =>
241 Effect.logWarning("Could not remove a stale mobile preferences fallback.").pipe(
242 Effect.annotateLogs({ error }),
243 ),
244 ),
245 );
246 }
247 }
248
249 if (parsed === null) {
250 const legacyJson = yield* secureStorage.getItem(PREFERENCES_KEY);
251 const legacyPreferences = parsePayload(legacyJson);
252 parsed = legacyPreferences;
253 if (legacyJson !== null && legacyPreferences !== null && databaseAvailable) {
254 yield* saveJson(legacyJson);
255 yield* secureStorage
256 .removeItem(PREFERENCES_KEY)
257 .pipe(
258 Effect.catch((error) =>
259 Effect.logWarning("Could not remove migrated mobile preferences.").pipe(
260 Effect.annotateLogs({ error }),
261 ),
262 ),
263 );
264 }
265 }
266
267 return parsed === null ? {} : sanitizePreferences(parsed);
268 });
269
270 const load = lock
271 .withPermits(1)(loadUnlocked)
272 .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause })));
273
274 const update = Effect.fn("MobilePreferencesStore.update")((transform) =>
275 lock
276 .withPermits(1)(
277 Effect.gen(function* () {
278 const current = yield* loadUnlocked;
279 const patch = yield* Effect.try({
280 try: () => transform(current),
281 catch: (cause) => new MobilePreferencesSaveError({ cause }),
282 });
283 const next: Preferences = { ...current, ...patch };
284 const payload = yield* encode(PREFERENCES_KEY, next);
285 yield* saveJson(payload);
286 return next;
287 }),
288 )
289 .pipe(
290 Effect.mapError((cause) =>
291 cause instanceof MobilePreferencesSaveError
292 ? cause
293 : new MobilePreferencesSaveError({ cause }),
294 ),
295 ),
296 );
297
298 return MobilePreferencesStore.of({
299 load,
300 update,
301 savePatch: (patch) => update(() => patch),
302 });
303});
304
305export const layer = Layer.effect(MobilePreferencesStore, make());
apps/mobile/src/persistence/mobile-secure-storage.ts created+52
......@@ -0,0 +1,52 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as SecureStore from "expo-secure-store";
6
7const MobileSecureStorageOperation = Schema.Literals(["read", "write", "delete"]);
8
9export class MobileSecureStorageError extends Schema.TaggedErrorClass<MobileSecureStorageError>()(
10 "MobileSecureStorageError",
11 {
12 operation: MobileSecureStorageOperation,
13 key: Schema.String,
14 cause: Schema.Defect(),
15 },
16) {
17 override get message(): string {
18 return `Mobile secure storage operation ${this.operation} failed for key ${this.key}.`;
19 }
20}
21
22export class MobileSecureStorage extends Context.Service<
23 MobileSecureStorage,
24 {
25 readonly getItem: (key: string) => Effect.Effect<string | null, MobileSecureStorageError>;
26 readonly setItem: (key: string, value: string) => Effect.Effect<void, MobileSecureStorageError>;
27 readonly removeItem: (key: string) => Effect.Effect<void, MobileSecureStorageError>;
28 }
29>()("@t3tools/mobile/persistence/MobileSecureStorage") {}
30
31export const make = MobileSecureStorage.of({
32 getItem: Effect.fn("MobileSecureStorage.getItem")((key) =>
33 Effect.tryPromise({
34 try: () => SecureStore.getItemAsync(key),
35 catch: (cause) => new MobileSecureStorageError({ operation: "read", key, cause }),
36 }),
37 ),
38 setItem: Effect.fn("MobileSecureStorage.setItem")((key, value) =>
39 Effect.tryPromise({
40 try: () => SecureStore.setItemAsync(key, value),
41 catch: (cause) => new MobileSecureStorageError({ operation: "write", key, cause }),
42 }),
43 ),
44 removeItem: Effect.fn("MobileSecureStorage.removeItem")((key) =>
45 Effect.tryPromise({
46 try: () => SecureStore.deleteItemAsync(key),
47 catch: (cause) => new MobileSecureStorageError({ operation: "delete", key, cause }),
48 }),
49 ),
50});
51
52export const layer = Layer.succeed(MobileSecureStorage, make);
apps/mobile/src/persistence/mobile-storage.ts created+227
......@@ -0,0 +1,227 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Arr from "effect/Array";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import { pipe } from "effect/Function";
6import * as Layer from "effect/Layer";
7import * as Schema from "effect/Schema";
8
9import {
10 isRelayManagedConnection,
11 type SavedRemoteConnection,
12 toStableSavedRemoteConnection,
13} from "../lib/connection";
14import * as MobileSecureStorage from "./mobile-secure-storage";
15
16const CONNECTIONS_KEY = "t3code.connections";
17const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id";
18const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration";
19
20export class MobileStorageDecodeError extends Schema.TaggedErrorClass<MobileStorageDecodeError>()(
21 "MobileStorageDecodeError",
22 {
23 key: Schema.String,
24 cause: Schema.Defect(),
25 },
26) {
27 override get message(): string {
28 return `Failed to decode mobile storage value for key ${this.key}.`;
29 }
30}
31
32export class MobileStorageEncodeError extends Schema.TaggedErrorClass<MobileStorageEncodeError>()(
33 "MobileStorageEncodeError",
34 {
35 key: Schema.String,
36 cause: Schema.Defect(),
37 },
38) {
39 override get message(): string {
40 return `Failed to encode mobile storage value for key ${this.key}.`;
41 }
42}
43
44export class MobileDeviceIdGenerationError extends Schema.TaggedErrorClass<MobileDeviceIdGenerationError>()(
45 "MobileDeviceIdGenerationError",
46 { cause: Schema.Defect() },
47) {
48 override get message(): string {
49 return "Failed to generate the mobile agent-awareness device id.";
50 }
51}
52
53export interface AgentAwarenessRegistrationRecord {
54 readonly identity: string;
55 readonly signature: string;
56 readonly pushToStartToken?: string;
57}
58
59export class MobileStorage extends Context.Service<
60 MobileStorage,
61 {
62 readonly loadSavedConnections: Effect.Effect<
63 ReadonlyArray<SavedRemoteConnection>,
64 MobileSecureStorage.MobileSecureStorageError
65 >;
66 readonly saveConnection: (
67 connection: SavedRemoteConnection,
68 ) => Effect.Effect<
69 void,
70 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
71 >;
72 readonly clearSavedConnection: (
73 environmentId: EnvironmentId,
74 ) => Effect.Effect<
75 void,
76 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
77 >;
78 readonly loadOrCreateAgentAwarenessDeviceId: Effect.Effect<
79 string,
80 MobileSecureStorage.MobileSecureStorageError | MobileDeviceIdGenerationError
81 >;
82 readonly loadAgentAwarenessDeviceId: Effect.Effect<
83 string | null,
84 MobileSecureStorage.MobileSecureStorageError
85 >;
86 readonly loadAgentAwarenessRegistrationRecord: Effect.Effect<
87 AgentAwarenessRegistrationRecord | null,
88 MobileSecureStorage.MobileSecureStorageError
89 >;
90 readonly saveAgentAwarenessRegistrationRecord: (
91 record: AgentAwarenessRegistrationRecord,
92 ) => Effect.Effect<
93 void,
94 MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError
95 >;
96 readonly clearAgentAwarenessRegistrationRecord: Effect.Effect<
97 void,
98 MobileSecureStorage.MobileSecureStorageError
99 >;
100 }
101>()("@t3tools/mobile/persistence/MobileStorage") {}
102
103export const make = Effect.fn("MobileStorage.make")(function* () {
104 const secureStorage = yield* MobileSecureStorage.MobileSecureStorage;
105
106 const parseJson = <A>(key: string, raw: string): A | null => {
107 if (!raw.trim()) return null;
108 try {
109 return JSON.parse(raw) as A;
110 } catch (cause) {
111 console.warn(
112 "[mobile-storage] ignored invalid JSON",
113 new MobileStorageDecodeError({ key, cause }),
114 );
115 return null;
116 }
117 };
118
119 const readJson = Effect.fn("MobileStorage.readJson")(function* <A>(key: string) {
120 const raw = (yield* secureStorage.getItem(key)) ?? "";
121 return parseJson<A>(key, raw);
122 });
123
124 const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) {
125 const encoded = yield* Effect.try({
126 try: () => JSON.stringify(value),
127 catch: (cause) => new MobileStorageEncodeError({ key, cause }),
128 });
129 yield* secureStorage.setItem(key, encoded);
130 });
131
132 const loadSavedConnections = readJson<{
133 readonly connections?: ReadonlyArray<SavedRemoteConnection>;
134 }>(CONNECTIONS_KEY).pipe(
135 Effect.map((parsed) =>
136 pipe(
137 parsed?.connections ?? [],
138 Arr.filter(
139 (connection) =>
140 !!connection.environmentId &&
141 (!!connection.bearerToken?.trim() || isRelayManagedConnection(connection)),
142 ),
143 ),
144 ),
145 );
146
147 const saveConnection = Effect.fn("MobileStorage.saveConnection")(function* (
148 connection: SavedRemoteConnection,
149 ) {
150 const current = yield* loadSavedConnections;
151 const stableConnection = toStableSavedRemoteConnection(connection);
152 const next = current.some((entry) => entry.environmentId === connection.environmentId)
153 ? pipe(
154 current,
155 Arr.map((entry) =>
156 entry.environmentId === connection.environmentId ? stableConnection : entry,
157 ),
158 )
159 : pipe(current, Arr.append(stableConnection));
160 yield* writeJson(CONNECTIONS_KEY, { connections: next });
161 });
162
163 const clearSavedConnection = Effect.fn("MobileStorage.clearSavedConnection")(function* (
164 environmentId: EnvironmentId,
165 ) {
166 const current = yield* loadSavedConnections;
167 const next = pipe(
168 current,
169 Arr.filter((entry) => entry.environmentId !== environmentId),
170 );
171 yield* writeJson(CONNECTIONS_KEY, { connections: next });
172 });
173
174 const loadOrCreateAgentAwarenessDeviceId = Effect.gen(function* () {
175 const existing = yield* secureStorage.getItem(AGENT_AWARENESS_DEVICE_ID_KEY);
176 if (existing?.trim()) return existing;
177 const deviceId = yield* Effect.tryPromise({
178 try: () => import("../lib/uuid").then(({ uuidv4 }) => uuidv4()),
179 catch: (cause) => new MobileDeviceIdGenerationError({ cause }),
180 });
181 yield* secureStorage.setItem(AGENT_AWARENESS_DEVICE_ID_KEY, deviceId);
182 return deviceId;
183 });
184
185 const loadAgentAwarenessDeviceId = secureStorage
186 .getItem(AGENT_AWARENESS_DEVICE_ID_KEY)
187 .pipe(Effect.map((existing) => (existing?.trim() ? existing : null)));
188
189 const loadAgentAwarenessRegistrationRecord = readJson<AgentAwarenessRegistrationRecord>(
190 AGENT_AWARENESS_REGISTRATION_KEY,
191 ).pipe(
192 Effect.map((parsed) => {
193 if (
194 !parsed ||
195 typeof parsed !== "object" ||
196 typeof parsed.identity !== "string" ||
197 typeof parsed.signature !== "string"
198 ) {
199 return null;
200 }
201 return {
202 identity: parsed.identity,
203 signature: parsed.signature,
204 ...(typeof parsed.pushToStartToken === "string" && parsed.pushToStartToken
205 ? { pushToStartToken: parsed.pushToStartToken }
206 : {}),
207 };
208 }),
209 );
210
211 return MobileStorage.of({
212 loadSavedConnections,
213 saveConnection,
214 clearSavedConnection,
215 loadOrCreateAgentAwarenessDeviceId,
216 loadAgentAwarenessDeviceId,
217 loadAgentAwarenessRegistrationRecord,
218 saveAgentAwarenessRegistrationRecord: (record) =>
219 writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record),
220 clearAgentAwarenessRegistrationRecord: secureStorage.setItem(
221 AGENT_AWARENESS_REGISTRATION_KEY,
222 "",
223 ),
224 });
225});
226
227export const layer = Layer.effect(MobileStorage, make());
apps/mobile/src/state/client-cache-state.ts created+87
......@@ -0,0 +1,87 @@
1import type { EnvironmentId } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import { Atom } from "effect/unstable/reactivity";
4
5import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database";
6import * as Runtime from "../lib/runtime";
7
8export interface EnvironmentClientCacheSummary {
9 readonly environmentId: EnvironmentId;
10 readonly recordCount: number;
11 readonly payloadBytes: number;
12 readonly kinds: Readonly<Partial<Record<ClientCacheKind, number>>>;
13}
14
15export interface ClientCacheSummary {
16 readonly recordCount: number;
17 readonly payloadBytes: number;
18 readonly environments: ReadonlyArray<EnvironmentClientCacheSummary>;
19}
20
21export type ClientCacheClearScope =
22 | { readonly type: "all" }
23 | { readonly type: "environment"; readonly environmentId: EnvironmentId };
24
25function aggregateCacheSummary(
26 rows: ReadonlyArray<{
27 readonly environmentId: EnvironmentId;
28 readonly kind: ClientCacheKind;
29 readonly recordCount: number;
30 readonly payloadBytes: number;
31 }>,
32): ClientCacheSummary {
33 const environments = new Map<EnvironmentId, EnvironmentClientCacheSummary>();
34 let recordCount = 0;
35 let payloadBytes = 0;
36
37 for (const row of rows) {
38 recordCount += row.recordCount;
39 payloadBytes += row.payloadBytes;
40 const current = environments.get(row.environmentId) ?? {
41 environmentId: row.environmentId,
42 recordCount: 0,
43 payloadBytes: 0,
44 kinds: {},
45 };
46 environments.set(row.environmentId, {
47 environmentId: row.environmentId,
48 recordCount: current.recordCount + row.recordCount,
49 payloadBytes: current.payloadBytes + row.payloadBytes,
50 kinds: { ...current.kinds, [row.kind]: row.recordCount },
51 });
52 }
53
54 return {
55 recordCount,
56 payloadBytes,
57 environments: [...environments.values()],
58 };
59}
60
61const clientCacheRuntime = Atom.runtime(Runtime.runtimeContextLayer);
62
63export const clientCacheSummaryAtom = clientCacheRuntime
64 .atom(
65 MobileDatabase.pipe(
66 Effect.flatMap((database) => database.inspectCaches),
67 Effect.map(aggregateCacheSummary),
68 ),
69 )
70 .pipe(Atom.withLabel("mobile:client-cache:summary"));
71
72export const clearClientCacheAtom = clientCacheRuntime
73 .fn((scope: ClientCacheClearScope, get) =>
74 MobileDatabase.pipe(
75 Effect.flatMap((database) =>
76 scope.type === "all"
77 ? database.clearAllCaches
78 : database.clearEnvironmentCache(scope.environmentId),
79 ),
80 Effect.tap(() =>
81 Effect.sync(() => {
82 get.refresh(clientCacheSummaryAtom);
83 }),
84 ),
85 ),
86 )
87 .pipe(Atom.withLabel("mobile:client-cache:clear"));
apps/mobile/src/state/preferences.test.ts created+272
......@@ -0,0 +1,272 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
6import { vi } from "vite-plus/test";
7
8vi.mock("expo-secure-store", () => ({
9 getItemAsync: vi.fn(),
10 setItemAsync: vi.fn(),
11}));
12
13vi.mock("react-native", () => ({
14 Platform: { OS: "ios" },
15}));
16
17vi.mock("../lib/runtime", async () => {
18 const Layer = await import("effect/Layer");
19 return {
20 runtime: { runPromise: vi.fn() },
21 runtimeContextLayer: Layer.empty,
22 };
23});
24
25import type { Preferences } from "../persistence/mobile-preferences";
26import {
27 createMobilePreferencesState,
28 MobilePreferencesLoadError,
29 MobilePreferencesSaveError,
30 MobilePreferencesStore,
31} from "./preferences";
32
33function deferred<A>() {
34 let resolve!: (value: A) => void;
35 const promise = new Promise<A>((resume) => {
36 resolve = resume;
37 });
38 return { promise, resolve } as const;
39}
40
41function makePreferencesState(
42 service: Omit<MobilePreferencesStore["Service"], "update"> &
43 Partial<Pick<MobilePreferencesStore["Service"], "update">>,
44) {
45 const completeService = MobilePreferencesStore.of({
46 ...service,
47 update:
48 service.update ??
49 ((transform) =>
50 service.load.pipe(
51 Effect.flatMap((current) => service.savePatch(transform(current))),
52 Effect.mapError((cause) =>
53 cause._tag === "MobilePreferencesSaveError"
54 ? cause
55 : new MobilePreferencesSaveError({ cause }),
56 ),
57 )),
58 });
59 return createMobilePreferencesState(
60 Atom.runtime(Layer.succeed(MobilePreferencesStore, completeService)),
61 );
62}
63
64describe("mobile preferences state", () => {
65 it.effect("shares one preference load across consumers", () =>
66 Effect.gen(function* () {
67 const load = vi.fn(() => Promise.resolve<Preferences>({ baseFontSize: 17 }));
68 const state = makePreferencesState({
69 load: Effect.promise(load),
70 savePatch: (patch) => Effect.succeed(patch),
71 });
72 const registry = AtomRegistry.make();
73 const unmountFirst = registry.mount(state.preferencesAtom);
74 const unmountSecond = registry.mount(state.preferencesAtom);
75
76 expect(
77 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
78 suspendOnWaiting: true,
79 }),
80 ).toEqual({ baseFontSize: 17 });
81 expect(load).toHaveBeenCalledTimes(1);
82
83 unmountSecond();
84 unmountFirst();
85 registry.dispose();
86 }),
87 );
88
89 it.effect("preserves an optimistic patch when the initial load finishes later", () =>
90 Effect.gen(function* () {
91 const pendingLoad = deferred<Preferences>();
92 const savePatch = vi.fn((patch: Partial<Preferences>) => Effect.succeed(patch));
93 const state = makePreferencesState({
94 load: Effect.promise(() => pendingLoad.promise),
95 savePatch,
96 });
97 const registry = AtomRegistry.make();
98 const unmountPreferences = registry.mount(state.preferencesAtom);
99 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
100
101 registry.set(state.updatePreferencesAtom, {
102 collapsedProjectGroups: ["project:new"],
103 });
104 pendingLoad.resolve({
105 baseFontSize: 18,
106 collapsedProjectGroups: ["project:old"],
107 });
108
109 const preferences = yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
110 suspendOnWaiting: true,
111 });
112 expect(preferences).toEqual({
113 baseFontSize: 18,
114 collapsedProjectGroups: ["project:new"],
115 });
116 expect(savePatch).toHaveBeenCalledWith({
117 collapsedProjectGroups: ["project:new"],
118 });
119 expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);
120
121 unmountUpdate();
122 unmountPreferences();
123 registry.dispose();
124 }),
125 );
126
127 it.effect("falls back to empty preferences when secure storage cannot be read", () =>
128 Effect.gen(function* () {
129 const state = makePreferencesState({
130 load: Effect.fail(
131 new MobilePreferencesLoadError({
132 cause: new Error("secure storage unavailable"),
133 }),
134 ),
135 savePatch: (patch) => Effect.succeed(patch),
136 });
137 const registry = AtomRegistry.make();
138 const unmount = registry.mount(state.preferencesAtom);
139
140 expect(
141 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
142 suspendOnWaiting: true,
143 }),
144 ).toEqual({});
145
146 unmount();
147 registry.dispose();
148 }),
149 );
150
151 it.effect("does not roll back a newer optimistic write with the same value", () =>
152 Effect.gen(function* () {
153 let saveCount = 0;
154 const state = makePreferencesState({
155 load: Effect.succeed({ baseFontSize: 16, codeFontSize: 13 }),
156 savePatch: (patch) => {
157 saveCount += 1;
158 return saveCount === 1
159 ? Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") }))
160 : Effect.succeed(patch);
161 },
162 });
163 const registry = AtomRegistry.make();
164 const unmountPreferences = registry.mount(state.preferencesAtom);
165 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
166
167 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
168 suspendOnWaiting: true,
169 });
170 registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });
171 registry.set(state.updatePreferencesAtom, { baseFontSize: 18, codeFontSize: 15 });
172
173 yield* Effect.promise(() =>
174 vi.waitFor(() => {
175 expect(AsyncResult.isFailure(registry.get(state.updatePreferencesAtom))).toBe(false);
176 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
177 {
178 baseFontSize: 18,
179 codeFontSize: 15,
180 },
181 );
182 }),
183 );
184
185 unmountUpdate();
186 unmountPreferences();
187 registry.dispose();
188 }),
189 );
190
191 it.effect("rolls back an optimistic field when its save fails", () =>
192 Effect.gen(function* () {
193 const state = makePreferencesState({
194 load: Effect.succeed({ baseFontSize: 16 }),
195 savePatch: () =>
196 Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") })),
197 });
198 const registry = AtomRegistry.make();
199 const unmountPreferences = registry.mount(state.preferencesAtom);
200 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
201
202 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
203 suspendOnWaiting: true,
204 });
205 registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });
206
207 yield* Effect.promise(() =>
208 vi.waitFor(() => {
209 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
210 {
211 baseFontSize: 16,
212 },
213 );
214 }),
215 );
216
217 unmountUpdate();
218 unmountPreferences();
219 registry.dispose();
220 }),
221 );
222
223 it.effect("rolls back to the last confirmed value after a later save fails", () =>
224 Effect.gen(function* () {
225 let saveCount = 0;
226 const state = makePreferencesState({
227 load: Effect.succeed({ baseFontSize: 16 }),
228 savePatch: () => {
229 saveCount += 1;
230 return saveCount === 1
231 ? Effect.succeed({ baseFontSize: 14 })
232 : Effect.fail(new MobilePreferencesSaveError({ cause: new Error("write failed") }));
233 },
234 });
235 const registry = AtomRegistry.make();
236 const unmountPreferences = registry.mount(state.preferencesAtom);
237 const unmountUpdate = registry.mount(state.updatePreferencesAtom);
238
239 yield* AtomRegistry.getResult(registry, state.preferencesAtom, {
240 suspendOnWaiting: true,
241 });
242 registry.set(state.updatePreferencesAtom, { baseFontSize: 14 });
243 yield* Effect.promise(() =>
244 vi.waitFor(() => {
245 expect(saveCount).toBe(1);
246 expect(registry.get(state.updatePreferencesAtom).waiting).toBe(false);
247 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
248 {
249 baseFontSize: 14,
250 },
251 );
252 }),
253 );
254
255 registry.set(state.updatePreferencesAtom, { baseFontSize: 18 });
256 yield* Effect.promise(() =>
257 vi.waitFor(() => {
258 expect(saveCount).toBe(2);
259 expect(Option.getOrThrow(AsyncResult.value(registry.get(state.preferencesAtom)))).toEqual(
260 {
261 baseFontSize: 14,
262 },
263 );
264 }),
265 );
266
267 unmountUpdate();
268 unmountPreferences();
269 registry.dispose();
270 }),
271 );
272});
apps/mobile/src/state/preferences.ts created+124
......@@ -0,0 +1,124 @@
1import * as Effect from "effect/Effect";
2import { AsyncResult, Atom } from "effect/unstable/reactivity";
3
4import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences";
5import * as Runtime from "../lib/runtime";
6
7export {
8 MobilePreferencesLoadError,
9 MobilePreferencesSaveError,
10 MobilePreferencesStore,
11} from "../persistence/mobile-preferences";
12
13interface OptimisticPreferences {
14 readonly values: Partial<Preferences>;
15 readonly versions: Partial<Record<keyof Preferences, number>>;
16}
17
18/**
19 * Owns the device preference blob for the lifetime of the app registry.
20 * Optimistic patches are kept separately so writes made while persistence is
21 * still loading cannot be replaced by the eventual read result.
22 */
23export function createMobilePreferencesState(runtime: Atom.AtomRuntime<MobilePreferencesStore>) {
24 const storedPreferencesAtom = runtime
25 .atom(
26 MobilePreferencesStore.pipe(
27 Effect.flatMap((store) => store.load),
28 Effect.catch((error) =>
29 Effect.logWarning("Could not load mobile preferences.", error).pipe(
30 Effect.as<Preferences>({}),
31 ),
32 ),
33 ),
34 )
35 .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:stored"));
36
37 const optimisticPatchAtom = Atom.make<OptimisticPreferences>({ values: {}, versions: {} }).pipe(
38 Atom.keepAlive,
39 Atom.withLabel("mobile:preferences:optimistic-patch"),
40 );
41 const confirmedPreferencesAtom = Atom.make<Preferences>({}).pipe(
42 Atom.keepAlive,
43 Atom.withLabel("mobile:preferences:confirmed"),
44 );
45 let nextPatchVersion = 0;
46
47 const preferencesAtom = Atom.make((get) => {
48 const stored = get(storedPreferencesAtom);
49 const confirmed = get(confirmedPreferencesAtom);
50 const optimistic = get(optimisticPatchAtom);
51 return AsyncResult.map(stored, (preferences) => ({
52 ...preferences,
53 ...confirmed,
54 ...optimistic.values,
55 }));
56 }).pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences"));
57
58 const updatePreferencesAtom = runtime
59 .fn(
60 (patch: Partial<Preferences>, get) => {
61 const version = ++nextPatchVersion;
62 const current = get(optimisticPatchAtom);
63 const versions = { ...current.versions };
64 for (const key of Object.keys(patch) as Array<keyof Preferences>) {
65 versions[key] = version;
66 }
67 get.set(optimisticPatchAtom, {
68 values: { ...current.values, ...patch },
69 versions,
70 });
71 return MobilePreferencesStore.pipe(
72 Effect.flatMap((store) => store.savePatch(patch)),
73 Effect.tap((saved) =>
74 Effect.sync(() => {
75 get.set(confirmedPreferencesAtom, saved);
76 const optimistic = get(optimisticPatchAtom);
77 const values = { ...optimistic.values } as Record<string, unknown>;
78 const currentVersions = { ...optimistic.versions } as Record<string, unknown>;
79 for (const key of Object.keys(patch) as Array<keyof Preferences>) {
80 if (optimistic.versions[key] === version) {
81 delete values[key];
82 delete currentVersions[key];
83 }
84 }
85 get.set(optimisticPatchAtom, {
86 values: values as Partial<Preferences>,
87 versions: currentVersions as Partial<Record<keyof Preferences, number>>,
88 });
89 }),
90 ),
91 Effect.tapError(() =>
92 Effect.sync(() => {
93 const optimistic = get(optimisticPatchAtom);
94 const values = { ...optimistic.values } as Record<string, unknown>;
95 const currentVersions = { ...optimistic.versions } as Record<string, unknown>;
96 for (const key of Object.keys(patch) as Array<keyof Preferences>) {
97 if (optimistic.versions[key] === version) {
98 delete values[key];
99 delete currentVersions[key];
100 }
101 }
102 get.set(optimisticPatchAtom, {
103 values: values as Partial<Preferences>,
104 versions: currentVersions as Partial<Record<keyof Preferences, number>>,
105 });
106 }),
107 ),
108 );
109 },
110 // The storage layer serializes preference read-modify-write operations.
111 // Keep every invocation alive so one preference update cannot interrupt
112 // another update to a different field in the shared blob.
113 { concurrent: true },
114 )
115 .pipe(Atom.keepAlive, Atom.withLabel("mobile:preferences:update"));
116
117 return { preferencesAtom, updatePreferencesAtom } as const;
118}
119
120const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer);
121export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime);
122
123export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom;
124export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom;
apps/server/src/provider/Layers/CursorAdapter.test.ts+10-4
......@@ -1030,10 +1030,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
10301030 entry.result.outcome !== null &&
10311031 "outcome" in entry.result.outcome &&
10321032 entry.result.outcome.outcome === "cancelled";
1033 yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel");
1034 const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse);
1035 assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));
1036 assert.isTrue(requests.some(isCancelledApprovalResponse));
1033 const cancelRequests = yield* waitForJsonLogMatch(
1034 requestLogPath,
1035 (entry) => entry.method === "session/cancel",
1036 );
1037 const approvalResponses = yield* waitForJsonLogMatch(
1038 requestLogPath,
1039 isCancelledApprovalResponse,
1040 );
1041 assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel"));
1042 assert.isTrue(approvalResponses.some(isCancelledApprovalResponse));
10371043
10381044 yield* adapter.stopSession(threadId);
10391045 }),
apps/web/src/connection/storage.ts+118-1
......@@ -21,7 +21,9 @@ import {
2121 EnvironmentId,
2222 OrchestrationShellSnapshot,
2323 OrchestrationThreadDetailSnapshot,
24 ServerConfig,
2425 ThreadId,
26 VcsListRefsResult,
2527} from "@t3tools/contracts";
2628import * as Context from "effect/Context";
2729import * as Effect from "effect/Effect";
......@@ -32,10 +34,12 @@ import * as Schema from "effect/Schema";
3234import * as Semaphore from "effect/Semaphore";
3335
3436const DATABASE_NAME = "t3code:connection-runtime";
35const DATABASE_VERSION = 2;
37const DATABASE_VERSION = 4;
3638const CATALOG_STORE_NAME = "catalog";
3739const SHELL_STORE_NAME = "shell";
3840const THREAD_STORE_NAME = "thread";
41const SERVER_CONFIG_STORE_NAME = "server-config";
42const VCS_REFS_STORE_NAME = "vcs-refs";
3943const CATALOG_KEY = "document";
4044const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
4145
......@@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({
5559 snapshot: OrchestrationThreadDetailSnapshot,
5660});
5761const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
62const StoredServerConfig = Schema.Struct({
63 schemaVersion: Schema.Literal(1),
64 environmentId: EnvironmentId,
65 config: ServerConfig,
66});
67const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig);
68const StoredVcsRefs = Schema.Struct({
69 schemaVersion: Schema.Literal(1),
70 environmentId: EnvironmentId,
71 cwd: Schema.String,
72 refs: VcsListRefsResult,
73});
74const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs);
5875const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
5976const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
6077const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
......@@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot
6279const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
6380const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
6481const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);
82const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson);
83const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson);
84const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson);
85const encodeStoredVcsRefs = Schema.encodeEffect(StoredVcsRefsJson);
6586
6687function catalogError(operation: string, cause: unknown) {
6788 return new ConnectionTransientError({
......@@ -80,6 +101,10 @@ function persistenceError(
80101 | "load-thread"
81102 | "save-thread"
82103 | "remove-thread"
104 | "load-server-config"
105 | "save-server-config"
106 | "load-vcs-refs"
107 | "save-vcs-refs"
83108 | "clear-environment",
84109 cause: unknown,
85110) {
......@@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* (
108133 if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {
109134 request.result.createObjectStore(THREAD_STORE_NAME);
110135 }
136 if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) {
137 request.result.createObjectStore(SERVER_CONFIG_STORE_NAME);
138 }
139 if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) {
140 request.result.createObjectStore(VCS_REFS_STORE_NAME);
141 }
111142 });
112143 request.addEventListener("error", () => {
113144 resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));
......@@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {
197228 return `${environmentId}:${threadId}`;
198229}
199230
231function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) {
232 return `${environmentId}:${cwd}`;
233}
234
200235const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) {
201236 return yield* decodeConnectionCatalogDocument(raw).pipe(
202237 Effect.mapError((cause) => catalogError("decode", cause)),
......@@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext(
462497 : persistenceError("save-shell", cause),
463498 ),
464499 ),
500 loadServerConfig: (environmentId) =>
501 readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe(
502 Effect.flatMap((raw) => {
503 if (typeof raw !== "string") {
504 return Effect.succeed(Option.none());
505 }
506 return decodeStoredServerConfig(raw).pipe(
507 Effect.mapError((cause) => persistenceError("load-server-config", cause)),
508 Effect.map((stored) =>
509 stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(),
510 ),
511 );
512 }),
513 Effect.mapError((cause) =>
514 cause._tag === "ConnectionPersistenceError"
515 ? cause
516 : persistenceError("load-server-config", cause),
517 ),
518 ),
519 saveServerConfig: (environmentId, config) =>
520 Effect.gen(function* () {
521 const encoded = yield* encodeStoredServerConfig({
522 schemaVersion: 1,
523 environmentId,
524 config,
525 }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause)));
526 yield* writeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId, encoded);
527 }).pipe(
528 Effect.mapError((cause) =>
529 cause._tag === "ConnectionPersistenceError"
530 ? cause
531 : persistenceError("save-server-config", cause),
532 ),
533 ),
465534 loadThread: (environmentId, threadId) =>
466535 readDatabaseValue(
467536 database,
......@@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext(
508577 : persistenceError("save-thread", cause),
509578 ),
510579 ),
580 loadVcsRefs: (environmentId, cwd) =>
581 readDatabaseValue(database, VCS_REFS_STORE_NAME, vcsRefsCacheKey(environmentId, cwd)).pipe(
582 Effect.flatMap((raw) => {
583 if (typeof raw !== "string") {
584 return Effect.succeed(Option.none());
585 }
586 return decodeStoredVcsRefs(raw).pipe(
587 Effect.mapError((cause) => persistenceError("load-vcs-refs", cause)),
588 Effect.map((stored) =>
589 stored.environmentId === environmentId && stored.cwd === cwd
590 ? Option.some(stored.refs)
591 : Option.none(),
592 ),
593 );
594 }),
595 Effect.mapError((cause) =>
596 cause._tag === "ConnectionPersistenceError"
597 ? cause
598 : persistenceError("load-vcs-refs", cause),
599 ),
600 ),
601 saveVcsRefs: (environmentId, cwd, refs) =>
602 Effect.gen(function* () {
603 const encoded = yield* encodeStoredVcsRefs({
604 schemaVersion: 1,
605 environmentId,
606 cwd,
607 refs,
608 }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause)));
609 yield* writeDatabaseValue(
610 database,
611 VCS_REFS_STORE_NAME,
612 vcsRefsCacheKey(environmentId, cwd),
613 encoded,
614 );
615 }).pipe(
616 Effect.mapError((cause) =>
617 cause._tag === "ConnectionPersistenceError"
618 ? cause
619 : persistenceError("save-vcs-refs", cause),
620 ),
621 ),
511622 removeThread: (environmentId, threadId) =>
512623 removeDatabaseValue(
513624 database,
......@@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext(
523634 THREAD_STORE_NAME,
524635 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
525636 ),
637 removeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId),
638 removeDatabaseValuesInRange(
639 database,
640 VCS_REFS_STORE_NAME,
641 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
642 ),
526643 ],
527644 { concurrency: "unbounded", discard: true },
528645 ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))),
packages/client-runtime/src/connection/registry.test.ts+4
......@@ -247,6 +247,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
247247 loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()),
248248 saveThread: (_environmentId, _thread) => Effect.void,
249249 removeThread: (_environmentId, _threadId) => Effect.void,
250 loadServerConfig: () => Effect.succeed(Option.none()),
251 saveServerConfig: () => Effect.void,
252 loadVcsRefs: () => Effect.succeed(Option.none()),
253 saveVcsRefs: () => Effect.void,
250254 clear: (environmentId) =>
251255 Ref.update(shellCache, (current) => {
252256 const next = new Map(current);
packages/client-runtime/src/platform/persistence.ts+30
......@@ -2,7 +2,9 @@ import {
22 type EnvironmentId,
33 type OrchestrationShellSnapshot,
44 type OrchestrationThreadDetailSnapshot,
5 type ServerConfig,
56 type ThreadId,
7 type VcsListRefsResult,
68} from "@t3tools/contracts";
79import * as Context from "effect/Context";
810import * as Effect from "effect/Effect";
......@@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass<Connecti
2426 "load-thread",
2527 "save-thread",
2628 "remove-thread",
29 "load-server-config",
30 "save-server-config",
31 "load-vcs-refs",
32 "save-vcs-refs",
2733 "clear-environment",
2834 ]),
2935 message: Schema.String,
......@@ -72,6 +78,30 @@ export class EnvironmentCacheStore extends Context.Service<
7278 environmentId: EnvironmentId,
7379 threadId: ThreadId,
7480 ) => Effect.Effect<void, ConnectionPersistenceError>;
81 /**
82 * The last complete server configuration. This deliberately includes provider
83 * metadata so offline task creation can still offer the models a user last saw.
84 */
85 readonly loadServerConfig: (
86 environmentId: EnvironmentId,
87 ) => Effect.Effect<Option.Option<ServerConfig>, ConnectionPersistenceError>;
88 readonly saveServerConfig: (
89 environmentId: EnvironmentId,
90 config: ServerConfig,
91 ) => Effect.Effect<void, ConnectionPersistenceError>;
92 /**
93 * The unfiltered branch list for a workspace. Query-specific lists are not
94 * cached because they are incomplete and unsafe to present as a full picker.
95 */
96 readonly loadVcsRefs: (
97 environmentId: EnvironmentId,
98 cwd: string,
99 ) => Effect.Effect<Option.Option<VcsListRefsResult>, ConnectionPersistenceError>;
100 readonly saveVcsRefs: (
101 environmentId: EnvironmentId,
102 cwd: string,
103 refs: VcsListRefsResult,
104 ) => Effect.Effect<void, ConnectionPersistenceError>;
75105 readonly clear: (
76106 environmentId: EnvironmentId,
77107 ) => Effect.Effect<void, ConnectionPersistenceError>;
packages/client-runtime/src/state/server.test.ts+175-2
......@@ -1,8 +1,32 @@
1import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts";
1import {
2 EnvironmentId,
3 type ServerConfig,
4 type ServerConfigStreamEvent,
5 type ServerLifecycleWelcomePayload,
6 WS_METHODS,
7} from "@t3tools/contracts";
28import { describe, expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
310import * as Option from "effect/Option";
11import * as Queue from "effect/Queue";
12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
414
5import { applyServerConfigProjection, projectServerWelcome } from "./server.ts";
15import {
16 AVAILABLE_CONNECTION_STATE,
17 PrimaryConnectionTarget,
18 type PreparedConnection,
19} from "../connection/model.ts";
20import * as EnvironmentSupervisor from "../connection/supervisor.ts";
21import * as Persistence from "../platform/persistence.ts";
22import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
23import type { RpcSession } from "../rpc/session.ts";
24import {
25 applyServerConfigProjection,
26 makeEnvironmentServerConfigState,
27 projectServerWelcome,
28 resolveServerConfigValue,
29} from "./server.ts";
630
731const CONFIG = {
832 availableEditors: [],
......@@ -14,6 +38,29 @@ const CONFIG = {
1438 settings: {},
1539} as unknown as ServerConfig;
1640
41const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({
42 version: 1,
43 type: "snapshot",
44 config,
45});
46
47const TARGET = new PrimaryConnectionTarget({
48 environmentId: EnvironmentId.make("environment-1"),
49 label: "Test environment",
50 httpBaseUrl: "https://environment.example.test",
51 wsBaseUrl: "wss://environment.example.test",
52});
53
54function session(client: WsRpcProtocolClient): RpcSession {
55 return {
56 client,
57 initialConfig: Effect.succeed(CONFIG),
58 ready: Effect.void,
59 probe: Effect.void,
60 closed: Effect.never,
61 };
62}
63
1764describe("server state projection", () => {
1865 it("applies every config category to the projected snapshot", () => {
1966 const snapshot = applyServerConfigProjection(Option.none(), {
......@@ -51,4 +98,130 @@ describe("server state projection", () => {
5198 expect(Option.getOrThrow(afterReady)).toBe(welcome);
5299 expect(emitted).toEqual([]);
53100 });
101
102 it("prefers an active session config over cache until a live event arrives", () => {
103 const cached = { ...CONFIG, settings: { source: "cache" } } as unknown as ServerConfig;
104 const initial = { ...CONFIG, settings: { source: "session" } } as unknown as ServerConfig;
105 const live = { ...CONFIG, settings: { source: "live" } } as unknown as ServerConfig;
106
107 expect(
108 resolveServerConfigValue(
109 {
110 config: cached,
111 latestEvent: snapshotEvent(cached),
112 source: "cache",
113 },
114 initial,
115 ),
116 ).toBe(initial);
117 expect(
118 resolveServerConfigValue(
119 {
120 config: live,
121 latestEvent: snapshotEvent(live),
122 source: "live",
123 },
124 initial,
125 ),
126 ).toBe(live);
127 });
128
129 it.effect("starts from cached configuration and persists the live projection", () =>
130 Effect.gen(function* () {
131 const events = yield* Queue.unbounded<ServerConfigStreamEvent>();
132 const client = {
133 [WS_METHODS.subscribeServerConfig]: () => Stream.fromQueue(events),
134 } as unknown as WsRpcProtocolClient;
135 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
136 target: TARGET,
137 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
138 session: yield* SubscriptionRef.make(Option.some(session(client))),
139 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
140 connect: Effect.void,
141 disconnect: Effect.void,
142 retryNow: Effect.void,
143 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
144 const savedConfigs = yield* Queue.unbounded<ServerConfig>();
145 const cache = Persistence.EnvironmentCacheStore.of({
146 loadShell: () => Effect.succeed(Option.none()),
147 saveShell: () => Effect.void,
148 loadThread: () => Effect.succeed(Option.none()),
149 saveThread: () => Effect.void,
150 removeThread: () => Effect.void,
151 loadServerConfig: () => Effect.succeed(Option.some(CONFIG)),
152 saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config),
153 loadVcsRefs: () => Effect.succeed(Option.none()),
154 saveVcsRefs: () => Effect.void,
155 clear: () => Effect.void,
156 });
157
158 yield* Effect.scoped(
159 Effect.gen(function* () {
160 const state = yield* makeEnvironmentServerConfigState().pipe(
161 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
162 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
163 );
164 expect(Option.getOrThrow(yield* SubscriptionRef.get(state)).config).toBe(CONFIG);
165
166 const providers: ServerConfig["providers"] = [];
167 yield* Queue.offer(events, {
168 version: 1,
169 type: "providerStatuses",
170 payload: { providers },
171 });
172 const projected = yield* SubscriptionRef.changes(state).pipe(
173 Stream.filter((value) =>
174 Option.match(value, {
175 onNone: () => false,
176 onSome: (projection) => projection.latestEvent.type === "providerStatuses",
177 }),
178 ),
179 Stream.runHead,
180 );
181 expect(Option.getOrThrow(Option.getOrThrow(projected)).config.providers).toBe(providers);
182 }),
183 );
184
185 expect((yield* Queue.take(savedConfigs)).providers).toEqual([]);
186 }),
187 );
188
189 it.effect("does not rewrite cached configuration when no live update arrives", () =>
190 Effect.gen(function* () {
191 const client = {
192 [WS_METHODS.subscribeServerConfig]: () => Stream.empty,
193 } as unknown as WsRpcProtocolClient;
194 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
195 target: TARGET,
196 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
197 session: yield* SubscriptionRef.make(Option.some(session(client))),
198 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
199 connect: Effect.void,
200 disconnect: Effect.void,
201 retryNow: Effect.void,
202 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
203 const savedConfigs = yield* Queue.unbounded<ServerConfig>();
204 const cache = Persistence.EnvironmentCacheStore.of({
205 loadShell: () => Effect.succeed(Option.none()),
206 saveShell: () => Effect.void,
207 loadThread: () => Effect.succeed(Option.none()),
208 saveThread: () => Effect.void,
209 removeThread: () => Effect.void,
210 loadServerConfig: () => Effect.succeed(Option.some(CONFIG)),
211 saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config),
212 loadVcsRefs: () => Effect.succeed(Option.none()),
213 saveVcsRefs: () => Effect.void,
214 clear: () => Effect.void,
215 });
216
217 yield* Effect.scoped(
218 makeEnvironmentServerConfigState().pipe(
219 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
220 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
221 ),
222 );
223
224 expect(yield* Queue.poll(savedConfigs)).toEqual(Option.none());
225 }),
226 );
54227});
packages/client-runtime/src/state/server.ts+161-8
......@@ -5,8 +5,13 @@ import {
55 type ServerLifecycleWelcomePayload,
66 WS_METHODS,
77} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
89import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";
11import * as Ref from "effect/Ref";
12import * as Result from "effect/Result";
913import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
1015import { AsyncResult, Atom } from "effect/unstable/reactivity";
1116
1217import {
......@@ -16,10 +21,16 @@ import {
1621 createEnvironmentRpcSubscriptionAtomFamily,
1722} from "./runtime.ts";
1823import type { EnvironmentRegistry } from "../connection/registry.ts";
24import { EnvironmentSupervisor } from "../connection/supervisor.ts";
25import { safeErrorLogAttributes } from "../errors/safeLog.ts";
26import { EnvironmentCacheStore } from "../platform/persistence.ts";
27import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";
28import { followStreamInEnvironment } from "./runtime.ts";
1929
2030export interface ServerConfigProjection {
2131 readonly config: ServerConfig;
2232 readonly latestEvent: ServerConfigStreamEvent;
33 readonly source: "cache" | "live";
2334}
2435
2536export function applyServerConfigProjection(
......@@ -31,6 +42,7 @@ export function applyServerConfigProjection(
3142 return Option.some({
3243 config: event.config,
3344 latestEvent: event,
45 source: "live",
3446 });
3547 case "keybindingsUpdated":
3648 return Option.map(current, (projection) => ({
......@@ -40,6 +52,7 @@ export function applyServerConfigProjection(
4052 issues: event.payload.issues,
4153 },
4254 latestEvent: event,
55 source: "live",
4356 }));
4457 case "providerStatuses":
4558 return Option.map(current, (projection) => ({
......@@ -48,6 +61,7 @@ export function applyServerConfigProjection(
4861 providers: event.payload.providers,
4962 },
5063 latestEvent: event,
64 source: "live",
5165 }));
5266 case "settingsUpdated":
5367 return Option.map(current, (projection) => ({
......@@ -56,6 +70,7 @@ export function applyServerConfigProjection(
5670 settings: event.payload.settings,
5771 },
5872 latestEvent: event,
73 source: "live",
5974 }));
6075 }
6176}
......@@ -68,6 +83,127 @@ export function projectServerConfig(
6883 return [next, Option.toArray(next)];
6984}
7085
86const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({
87 version: 1,
88 type: "snapshot",
89 config,
90});
91
92/**
93 * Keeps a complete server configuration available during reconnects. Server
94 * config carries the provider/model catalogue used by task creation, so it is
95 * useful—and safe—to retain after a transport session ends.
96 */
97export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")(
98 function* () {
99 const supervisor = yield* EnvironmentSupervisor;
100 const cache = yield* EnvironmentCacheStore;
101 const environmentId = supervisor.target.environmentId;
102 const cachedConfig = yield* cache.loadServerConfig(environmentId).pipe(
103 Effect.catch((error) =>
104 Effect.logWarning("Could not load cached server configuration.").pipe(
105 Effect.annotateLogs({
106 environmentId,
107 ...safeErrorLogAttributes(error),
108 }),
109 Effect.as(Option.none<ServerConfig>()),
110 ),
111 ),
112 );
113 const state = yield* SubscriptionRef.make<Option.Option<ServerConfigProjection>>(
114 Option.map(cachedConfig, (config) => ({
115 config,
116 latestEvent: cachedConfigSnapshotEvent(config),
117 source: "cache" as const,
118 })),
119 );
120 const persistence = yield* Queue.sliding<ServerConfig>(1);
121 const pendingPersistence = yield* Ref.make<Option.Option<ServerConfig>>(Option.none());
122
123 const persist = Effect.fn("EnvironmentServerConfigState.persist")(function* (
124 config: ServerConfig,
125 ) {
126 return yield* cache.saveServerConfig(environmentId, config).pipe(
127 Effect.as(true),
128 Effect.catch((error) =>
129 Effect.logWarning("Could not persist cached server configuration.").pipe(
130 Effect.annotateLogs({
131 environmentId,
132 ...safeErrorLogAttributes(error),
133 }),
134 Effect.as(false),
135 ),
136 ),
137 );
138 });
139
140 const persistPending = Effect.fn("EnvironmentServerConfigState.persistPending")(function* (
141 config: ServerConfig,
142 ) {
143 if (!(yield* persist(config))) {
144 return;
145 }
146 yield* Ref.update(pendingPersistence, (pending) =>
147 Option.isSome(pending) && pending.value === config ? Option.none() : pending,
148 );
149 });
150
151 yield* Stream.fromQueue(persistence).pipe(
152 Stream.debounce("500 millis"),
153 Stream.runForEach(persistPending),
154 Effect.forkScoped,
155 );
156
157 yield* subscribe(WS_METHODS.subscribeServerConfig, {}).pipe(
158 Stream.runForEach((event) =>
159 Effect.gen(function* () {
160 const next = applyServerConfigProjection(yield* SubscriptionRef.get(state), event);
161 if (Option.isNone(next)) {
162 return;
163 }
164 yield* Ref.set(pendingPersistence, Option.some(next.value.config));
165 yield* SubscriptionRef.set(state, next);
166 yield* Queue.offer(persistence, next.value.config);
167 }),
168 ),
169 Effect.forkScoped,
170 );
171
172 yield* Effect.addFinalizer(() =>
173 Ref.get(pendingPersistence).pipe(
174 Effect.flatMap(
175 Option.match({
176 onNone: () => Effect.void,
177 onSome: (config) => persist(config).pipe(Effect.asVoid),
178 }),
179 ),
180 ),
181 );
182
183 return state;
184 },
185);
186
187export function serverConfigStateChanges(environmentId: EnvironmentId) {
188 return followStreamInEnvironment(
189 environmentId,
190 Stream.unwrap(
191 makeEnvironmentServerConfigState().pipe(
192 Effect.map((state) =>
193 SubscriptionRef.changes(state).pipe(
194 Stream.filterMap((projection) =>
195 Option.match(projection, {
196 onNone: () => Result.failVoid,
197 onSome: (value) => Result.succeed(value),
198 }),
199 ),
200 ),
201 ),
202 ),
203 ),
204 );
205}
206
71207export function projectServerWelcome(
72208 current: Option.Option<ServerLifecycleWelcomePayload>,
73209 event: {
......@@ -85,8 +221,16 @@ export function projectServerWelcome(
85221 return [Option.some(welcome), [welcome]];
86222}
87223
224export function resolveServerConfigValue(
225 projection: ServerConfigProjection | null,
226 initialConfig: ServerConfig | null,
227): ServerConfig | null {
228 if (projection?.source === "live") return projection.config;
229 return initialConfig ?? projection?.config ?? null;
230}
231
88232export function createServerEnvironmentAtoms<R, E>(
89 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
233 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
90234 options: {
91235 readonly initialConfigValueAtom: (
92236 environmentId: EnvironmentId,
......@@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms<R, E>(
98242 mode: "serial" as const,
99243 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
100244 };
101 const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, {
102 label: "environment-data:server:config-projection",
103 tag: WS_METHODS.subscribeServerConfig,
104 transform: (stream) =>
105 stream.pipe(Stream.mapAccum(Option.none<ServerConfigProjection>, projectServerConfig)),
106 });
245 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>
246 runtime
247 .atom(serverConfigStateChanges(environmentId))
248 .pipe(
249 Atom.setIdleTTL(5 * 60_000),
250 Atom.withLabel(`environment-data:server:config-projection:${environmentId}`),
251 ),
252 );
253 const configProjection = (target: {
254 readonly environmentId: EnvironmentId;
255 readonly input: EnvironmentRpcInput<typeof WS_METHODS.subscribeServerConfig>;
256 }) => configProjectionFamily(target.environmentId);
107257 const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe(
108258 Atom.withLabel("environment-data:server:config:empty"),
109259 );
......@@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms<R, E>(
115265 const projection = Option.getOrNull(
116266 AsyncResult.value(get(configProjection({ environmentId, input: {} }))),
117267 );
118 return projection?.config ?? get(options.initialConfigValueAtom(environmentId));
268 return resolveServerConfigValue(
269 projection,
270 get(options.initialConfigValueAtom(environmentId)),
271 );
119272 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
120273 });
121274 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>
packages/client-runtime/src/state/shell-sync.test.ts+8
......@@ -81,6 +81,10 @@ describe("environment shell synchronization", () => {
8181 loadThread: () => Effect.succeed(Option.none()),
8282 saveThread: () => Effect.void,
8383 removeThread: () => Effect.void,
84 loadServerConfig: () => Effect.succeed(Option.none()),
85 saveServerConfig: () => Effect.void,
86 loadVcsRefs: () => Effect.succeed(Option.none()),
87 saveVcsRefs: () => Effect.void,
8488 clear: () => Effect.void,
8589 });
8690 // Cold cache with no HTTP snapshot available → falls back to the
......@@ -171,6 +175,10 @@ describe("environment shell synchronization", () => {
171175 loadThread: () => Effect.succeed(Option.none()),
172176 saveThread: () => Effect.void,
173177 removeThread: () => Effect.void,
178 loadServerConfig: () => Effect.succeed(Option.none()),
179 saveServerConfig: () => Effect.void,
180 loadVcsRefs: () => Effect.succeed(Option.none()),
181 saveVcsRefs: () => Effect.void,
174182 clear: () => Effect.void,
175183 });
176184 const snapshotLoader = ShellSnapshotLoader.of({
packages/client-runtime/src/state/threads-sync.test.ts+4
......@@ -170,6 +170,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
170170 Ref.update(savedThreads, (current) => [...current, thread]),
171171 removeThread: (_environmentId, threadId) =>
172172 Ref.update(removedThreads, (current) => [...current, threadId]),
173 loadServerConfig: () => Effect.succeed(Option.none()),
174 saveServerConfig: () => Effect.void,
175 loadVcsRefs: () => Effect.succeed(Option.none()),
176 saveVcsRefs: () => Effect.void,
173177 clear: () => Effect.void,
174178 });
175179 const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
packages/client-runtime/src/state/vcs.test.ts created+232
......@@ -0,0 +1,232 @@
1import { EnvironmentId, WS_METHODS, type VcsListRefsResult } from "@t3tools/contracts";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as Option from "effect/Option";
6import * as Ref from "effect/Ref";
7import * as Stream from "effect/Stream";
8import * as SubscriptionRef from "effect/SubscriptionRef";
9import * as TestClock from "effect/testing/TestClock";
10
11import {
12 AVAILABLE_CONNECTION_STATE,
13 PrimaryConnectionTarget,
14 type PreparedConnection,
15 type SupervisorConnectionState,
16} from "../connection/model.ts";
17import * as EnvironmentSupervisor from "../connection/supervisor.ts";
18import * as Persistence from "../platform/persistence.ts";
19import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
20import type { RpcSession } from "../rpc/session.ts";
21import { makeCachedVcsRefsChanges } from "./vcs.ts";
22
23const TARGET = new PrimaryConnectionTarget({
24 environmentId: EnvironmentId.make("environment-1"),
25 label: "Test environment",
26 httpBaseUrl: "https://environment.example.test",
27 wsBaseUrl: "wss://environment.example.test",
28});
29
30const CONNECTED_CONNECTION_STATE: SupervisorConnectionState = {
31 ...AVAILABLE_CONNECTION_STATE,
32 desired: true,
33 network: "online",
34 phase: "connected",
35 attempt: 1,
36 generation: 1,
37};
38
39const CACHED_REFS: VcsListRefsResult = {
40 refs: [
41 {
42 name: "main",
43 current: true,
44 isDefault: true,
45 worktreePath: "/repo",
46 },
47 ],
48 isRepo: true,
49 hasPrimaryRemote: true,
50 nextCursor: null,
51 totalCount: 1,
52};
53
54const LIVE_REFS: VcsListRefsResult = {
55 ...CACHED_REFS,
56 refs: [
57 {
58 name: "release",
59 current: true,
60 isDefault: true,
61 worktreePath: "/repo",
62 },
63 ],
64};
65
66function session(client: WsRpcProtocolClient): RpcSession {
67 return {
68 client,
69 initialConfig: Effect.never,
70 ready: Effect.void,
71 probe: Effect.void,
72 closed: Effect.never,
73 };
74}
75
76function cacheWithRefs(refs: Option.Option<VcsListRefsResult>) {
77 return Persistence.EnvironmentCacheStore.of({
78 loadShell: () => Effect.succeed(Option.none()),
79 saveShell: () => Effect.void,
80 loadThread: () => Effect.succeed(Option.none()),
81 saveThread: () => Effect.void,
82 removeThread: () => Effect.void,
83 loadServerConfig: () => Effect.succeed(Option.none()),
84 saveServerConfig: () => Effect.void,
85 loadVcsRefs: () => Effect.succeed(refs),
86 saveVcsRefs: () => Effect.void,
87 clear: () => Effect.void,
88 });
89}
90
91describe("cached VCS refs", () => {
92 it.effect("loads an unfiltered branch list without a connection", () =>
93 Effect.scoped(
94 Effect.gen(function* () {
95 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
96 target: TARGET,
97 state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE),
98 session: yield* SubscriptionRef.make(Option.none<RpcSession>()),
99 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
100 connect: Effect.void,
101 disconnect: Effect.void,
102 retryNow: Effect.void,
103 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
104 const refs = yield* Stream.unwrap(
105 makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe(
106 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
107 Effect.provideService(
108 Persistence.EnvironmentCacheStore,
109 cacheWithRefs(Option.some(CACHED_REFS)),
110 ),
111 ),
112 ).pipe(Stream.runHead);
113
114 expect(Option.getOrThrow(refs)).toEqual(CACHED_REFS);
115 }),
116 ),
117 );
118
119 it.effect("continues polling after a transient live failure", () =>
120 Effect.scoped(
121 Effect.gen(function* () {
122 const expectedError = new Error("Could not list Git refs.");
123 const calls = yield* Ref.make(0);
124 const client = {
125 [WS_METHODS.vcsListRefs]: () =>
126 Ref.updateAndGet(calls, (count) => count + 1).pipe(
127 Effect.flatMap((count) =>
128 count === 1 ? Effect.fail(expectedError) : Effect.succeed(LIVE_REFS),
129 ),
130 ),
131 } as unknown as WsRpcProtocolClient;
132 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
133 target: TARGET,
134 state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE),
135 session: yield* SubscriptionRef.make(Option.some(session(client))),
136 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
137 connect: Effect.void,
138 disconnect: Effect.void,
139 retryNow: Effect.void,
140 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
141
142 const result = Stream.unwrap(
143 makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe(
144 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
145 Effect.provideService(
146 Persistence.EnvironmentCacheStore,
147 cacheWithRefs(Option.some(CACHED_REFS)),
148 ),
149 ),
150 ).pipe(Stream.runHead);
151 const fiber = yield* Effect.forkChild(result);
152
153 for (let attempt = 0; attempt < 100 && (yield* Ref.get(calls)) < 1; attempt += 1) {
154 yield* Effect.yieldNow;
155 }
156 expect(yield* Ref.get(calls)).toBe(1);
157
158 yield* TestClock.adjust("5 seconds");
159 expect(Option.getOrThrow(yield* Fiber.join(fiber))).toEqual(LIVE_REFS);
160 }).pipe(Effect.provide(TestClock.layer())),
161 ),
162 );
163
164 it.effect("revalidates connected refs every five seconds", () =>
165 Effect.scoped(
166 Effect.gen(function* () {
167 const calls = yield* Ref.make(0);
168 const client = {
169 [WS_METHODS.vcsListRefs]: () =>
170 Ref.updateAndGet(calls, (count) => count + 1).pipe(
171 Effect.map((count) => (count === 1 ? CACHED_REFS : LIVE_REFS)),
172 ),
173 } as unknown as WsRpcProtocolClient;
174 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
175 target: TARGET,
176 state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE),
177 session: yield* SubscriptionRef.make(Option.some(session(client))),
178 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
179 connect: Effect.void,
180 disconnect: Effect.void,
181 retryNow: Effect.void,
182 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
183 const results = Stream.unwrap(
184 makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe(
185 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
186 Effect.provideService(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())),
187 ),
188 ).pipe(Stream.take(2), Stream.runCollect);
189 const fiber = yield* Effect.forkChild(results);
190
191 for (let attempt = 0; attempt < 100 && (yield* Ref.get(calls)) < 1; attempt += 1) {
192 yield* Effect.yieldNow;
193 }
194 expect(yield* Ref.get(calls)).toBe(1);
195
196 yield* TestClock.adjust("5 seconds");
197 expect(Array.from(yield* Fiber.join(fiber))).toEqual([CACHED_REFS, LIVE_REFS]);
198 }).pipe(Effect.provide(TestClock.layer())),
199 ),
200 );
201
202 it.effect("does not emit persisted refs before a live refresh", () =>
203 Effect.scoped(
204 Effect.gen(function* () {
205 const client = {
206 [WS_METHODS.vcsListRefs]: () => Effect.succeed(LIVE_REFS),
207 } as unknown as WsRpcProtocolClient;
208 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
209 target: TARGET,
210 state: yield* SubscriptionRef.make(CONNECTED_CONNECTION_STATE),
211 session: yield* SubscriptionRef.make(Option.some(session(client))),
212 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
213 connect: Effect.void,
214 disconnect: Effect.void,
215 retryNow: Effect.void,
216 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
217
218 const refs = yield* Stream.unwrap(
219 makeCachedVcsRefsChanges({ cwd: "/repo", limit: 100 }).pipe(
220 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
221 Effect.provideService(
222 Persistence.EnvironmentCacheStore,
223 cacheWithRefs(Option.some(CACHED_REFS)),
224 ),
225 ),
226 ).pipe(Stream.runHead);
227
228 expect(Option.getOrThrow(refs)).toEqual(LIVE_REFS);
229 }),
230 ),
231 );
232});
packages/client-runtime/src/state/vcs.ts+155-13
......@@ -1,26 +1,168 @@
1import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts";
1import {
2 type EnvironmentId,
3 type VcsListRefsInput,
4 type VcsListRefsResult,
5 type VcsStatusResult,
6 WS_METHODS,
7} from "@t3tools/contracts";
28import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Result from "effect/Result";
312import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
414import { Atom } from "effect/unstable/reactivity";
515
6import {
7 createEnvironmentRpcCommand,
8 createEnvironmentRpcQueryAtomFamily,
9 createEnvironmentSubscriptionAtomFamily,
10} from "./runtime.ts";
16import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts";
1117import type { EnvironmentRegistry } from "../connection/registry.ts";
12import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";
18import { EnvironmentSupervisor } from "../connection/supervisor.ts";
19import { safeErrorLogAttributes } from "../errors/safeLog.ts";
20import { EnvironmentCacheStore } from "../platform/persistence.ts";
21import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts";
22import { followStreamInEnvironment } from "./runtime.ts";
1323import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts";
1424
25const OFFLINE_BRANCH_LIST_LIMIT = 100;
26const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds";
27
28function canUseVcsRefsCache(input: VcsListRefsInput): boolean {
29 return (
30 input.query === undefined &&
31 input.cursor === undefined &&
32 input.includeMatchingRemoteRefs === undefined &&
33 input.refKind === undefined &&
34 input.limit === OFFLINE_BRANCH_LIST_LIMIT
35 );
36}
37
38/**
39 * Retains the last unfiltered branch-list response for the new-task picker.
40 * Filtered or paginated lists intentionally stay live-only: treating a
41 * partial result as a complete offline list would make branch selection
42 * misleading.
43 */
44export const makeCachedVcsRefsChanges = Effect.fn("CachedVcsRefsState.makeChanges")(function* (
45 input: VcsListRefsInput,
46) {
47 const supervisor = yield* EnvironmentSupervisor;
48 const cache = yield* EnvironmentCacheStore;
49 const environmentId = supervisor.target.environmentId;
50 const useCache = canUseVcsRefsCache(input);
51 const cached = useCache
52 ? yield* cache.loadVcsRefs(environmentId, input.cwd).pipe(
53 Effect.catch((error) =>
54 Effect.logWarning("Could not load cached Git refs.").pipe(
55 Effect.annotateLogs({
56 environmentId,
57 cwd: input.cwd,
58 ...safeErrorLogAttributes(error),
59 }),
60 Effect.as(Option.none<VcsListRefsResult>()),
61 ),
62 ),
63 )
64 : Option.none<VcsListRefsResult>();
65 const refresh = Effect.fn("CachedVcsRefsState.refresh")(function* () {
66 const refs = yield* request(WS_METHODS.vcsListRefs, input).pipe(
67 Effect.provideService(EnvironmentSupervisor, supervisor),
68 );
69 if (useCache) {
70 yield* cache.saveVcsRefs(environmentId, input.cwd, refs).pipe(
71 Effect.catch((error) =>
72 Effect.logWarning("Could not persist cached Git refs.").pipe(
73 Effect.annotateLogs({
74 environmentId,
75 cwd: input.cwd,
76 ...safeErrorLogAttributes(error),
77 }),
78 ),
79 ),
80 );
81 }
82 return refs;
83 });
84
85 const cachedRefs = Stream.fromEffect(
86 SubscriptionRef.get(supervisor.state).pipe(
87 Effect.flatMap((connection) =>
88 connection.phase === "connected"
89 ? Effect.succeed(Option.none<VcsListRefsResult>())
90 : Effect.succeed(cached),
91 ),
92 ),
93 ).pipe(
94 Stream.filterMap((refs) =>
95 Option.match(refs, {
96 onNone: () => Result.failVoid,
97 onSome: Result.succeed,
98 }),
99 ),
100 );
101 const refreshedRefs = Stream.concat(
102 Stream.fromEffect(SubscriptionRef.get(supervisor.state)),
103 SubscriptionRef.changes(supervisor.state),
104 ).pipe(
105 Stream.map((connection) => (connection.phase === "connected" ? connection.generation : null)),
106 Stream.changes,
107 Stream.switchMap((generation) =>
108 generation === null
109 ? Stream.empty
110 : Stream.tick(VCS_REFS_REVALIDATE_INTERVAL).pipe(
111 Stream.mapEffect(
112 () =>
113 refresh().pipe(
114 Effect.map(Option.some),
115 Effect.catch((error) =>
116 Effect.logWarning("Could not refresh Git refs.").pipe(
117 Effect.annotateLogs({
118 environmentId,
119 cwd: input.cwd,
120 ...safeErrorLogAttributes(error),
121 }),
122 Effect.as(Option.none<VcsListRefsResult>()),
123 ),
124 ),
125 ),
126 { concurrency: 1 },
127 ),
128 Stream.filterMap((refs) =>
129 Option.match(refs, {
130 onNone: () => Result.failVoid,
131 onSome: Result.succeed,
132 }),
133 ),
134 ),
135 ),
136 );
137
138 return Stream.concat(cachedRefs, refreshedRefs);
139});
140
141export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) {
142 return followStreamInEnvironment(environmentId, Stream.unwrap(makeCachedVcsRefsChanges(input)));
143}
144
15145export function createVcsEnvironmentAtoms<R, E>(
16 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
146 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
17147) {
18 return {
19 listRefs: createEnvironmentRpcQueryAtomFamily(runtime, {
20 label: "environment-data:vcs:list-refs",
21 tag: WS_METHODS.vcsListRefs,
22 staleTimeMs: 5_000,
148 const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) =>
149 Atom.family((inputKey: string) => {
150 const input = JSON.parse(inputKey) as VcsListRefsInput;
151 return runtime
152 .atom(cachedVcsRefsChanges(environmentId, input))
153 .pipe(
154 Atom.setIdleTTL(5 * 60_000),
155 Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`),
156 );
23157 }),
158 );
159 const listRefs = (target: {
160 readonly environmentId: EnvironmentId;
161 readonly input: VcsListRefsInput;
162 }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input));
163
164 return {
165 listRefs,
24166 status: createEnvironmentSubscriptionAtomFamily(runtime, {
25167 label: "environment-data:vcs:status",
26168 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) =>
pnpm-lock.yaml+794-8
......@@ -327,6 +327,9 @@ importers:
327327 expo-splash-screen:
328328 specifier: ~56.0.10
329329 version: 56.0.10(expo@56.0.12)(typescript@6.0.3)
330 expo-sqlite:
331 specifier: ~56.0.5
332 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)
330333 expo-symbols:
331334 specifier: ~56.0.6
332335 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:
662665 version: link:../../packages/shared
663666 alchemy:
664667 specifier: https://pkg.ing/alchemy/078ff00
665 version: https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab)
668 version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39)
666669 drizzle-orm:
667670 specifier: 1.0.0-rc.3
668 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)
671 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)
669672 effect:
670673 specifier: 4.0.0-beta.78
671674 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
......@@ -5445,6 +5448,9 @@ packages:
54455448 resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
54465449 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
54475450
5451 await-lock@2.2.2:
5452 resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
5453
54485454 aws-ssl-profiles@1.1.2:
54495455 resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
54505456 engines: {node: '>= 6.0.0'}
......@@ -6661,6 +6667,13 @@ packages:
66616667 peerDependencies:
66626668 expo: '*'
66636669
6670 expo-sqlite@56.0.5:
6671 resolution: {integrity: sha512-wHYRVLS5nUFEtli45wHaO+RjlRY8sQXyOSgENVk6I4zq7+FgySqjOk3YOYW6IKIMwhj5XzjMJO+pY8xKUy73Kw==}
6672 peerDependencies:
6673 expo: '*'
6674 react: '*'
6675 react-native: '*'
6676
66646677 expo-structured-headers@56.0.0:
66656678 resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==}
66666679
......@@ -12056,6 +12069,83 @@ snapshots:
1205612069 - typescript
1205712070 - utf-8-validate
1205812071
12072 '@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)':
12073 dependencies:
12074 '@expo/code-signing-certificates': 0.0.6
12075 '@expo/config': 56.0.9(typescript@6.0.3)
12076 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
12077 '@expo/devcert': 1.2.1
12078 '@expo/env': 2.3.0
12079 '@expo/image-utils': 0.10.1(typescript@6.0.3)
12080 '@expo/inline-modules': 0.0.12(typescript@6.0.3)
12081 '@expo/json-file': 10.2.0
12082 '@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)
12083 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12084 '@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)
12085 '@expo/metro-file-map': 56.0.3
12086 '@expo/osascript': 2.6.0
12087 '@expo/package-manager': 1.12.1
12088 '@expo/plist': 0.7.0
12089 '@expo/prebuild-config': 56.0.16(typescript@6.0.3)
12090 '@expo/require-utils': 56.1.3(typescript@6.0.3)
12091 '@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)
12092 '@expo/schema-utils': 56.0.1
12093 '@expo/spawn-async': 1.8.0
12094 '@expo/ws-tunnel': 2.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))
12095 '@expo/xcpretty': 4.4.4
12096 '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12097 accepts: 1.3.8
12098 arg: 5.0.2
12099 bplist-creator: 0.1.0
12100 bplist-parser: 0.3.2
12101 chalk: 4.1.2
12102 ci-info: 3.9.0
12103 compression: 1.8.1
12104 connect: 3.7.0
12105 debug: 4.4.3
12106 dnssd-advertise: 1.1.4
12107 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
12108 expo-server: 56.0.5
12109 fetch-nodeshim: 0.4.10
12110 getenv: 2.0.0
12111 glob: 13.0.6
12112 lan-network: 0.2.1
12113 multitars: 1.0.0
12114 node-forge: 1.4.0
12115 npm-package-arg: 11.0.3
12116 ora: 3.4.0
12117 picomatch: 4.0.4
12118 pretty-format: 29.7.0
12119 progress: 2.0.3
12120 prompts: 2.4.2
12121 resolve-from: 5.0.0
12122 semver: 7.8.5
12123 send: 0.19.2
12124 slugify: 1.6.9
12125 stacktrace-parser: 0.1.11
12126 structured-headers: 0.4.1
12127 terminal-link: 2.1.1
12128 toqr: 0.1.1
12129 wrap-ansi: 7.0.0
12130 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
12131 zod: 3.25.76
12132 optionalDependencies:
12133 expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f)
12134 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12135 transitivePeerDependencies:
12136 - '@expo/dom-webview'
12137 - '@expo/metro-runtime'
12138 - bufferutil
12139 - expo-constants
12140 - expo-font
12141 - react
12142 - react-dom
12143 - react-server-dom-webpack
12144 - supports-color
12145 - typescript
12146 - utf-8-validate
12147 optional: true
12148
1205912149 '@expo/code-signing-certificates@0.0.6':
1206012150 dependencies:
1206112151 node-forge: 1.4.0
......@@ -12132,12 +12222,27 @@ snapshots:
1213212222 react: 19.2.3
1213312223 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1213412224
12225 '@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)':
12226 dependencies:
12227 chalk: 4.1.2
12228 optionalDependencies:
12229 react: 19.2.6
12230 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12231 optional: true
12232
1213512233 '@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)':
1213612234 dependencies:
1213712235 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
1213812236 react: 19.2.3
1213912237 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1214012238
12239 '@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)':
12240 dependencies:
12241 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
12242 react: 19.2.6
12243 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12244 optional: true
12245
1214112246 '@expo/env@2.3.0':
1214212247 dependencies:
1214312248 chalk: 4.1.2
......@@ -12206,6 +12311,16 @@ snapshots:
1220612311 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1220712312 stacktrace-parser: 0.1.11
1220812313
12314 '@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)':
12315 dependencies:
12316 '@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)
12317 anser: 1.4.10
12318 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
12319 react: 19.2.6
12320 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12321 stacktrace-parser: 0.1.11
12322 optional: true
12323
1220912324 '@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)':
1221012325 dependencies:
1221112326 '@babel/code-frame': 7.29.7
......@@ -12263,6 +12378,20 @@ snapshots:
1226312378 optionalDependencies:
1226412379 react-dom: 19.2.3(react@19.2.3)
1226512380
12381 '@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)':
12382 dependencies:
12383 '@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)
12384 anser: 1.4.10
12385 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
12386 pretty-format: 29.7.0
12387 react: 19.2.6
12388 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12389 stacktrace-parser: 0.1.11
12390 whatwg-fetch: 3.6.20
12391 optionalDependencies:
12392 react-dom: 19.2.6(react@19.2.6)
12393 optional: true
12394
1226612395 '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
1226712396 dependencies:
1226812397 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
......@@ -12344,6 +12473,22 @@ snapshots:
1234412473 transitivePeerDependencies:
1234512474 - supports-color
1234612475
12476 '@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)':
12477 dependencies:
12478 debug: 4.4.3
12479 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
12480 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))
12481 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)
12482 expo-server: 56.0.5
12483 react: 19.2.6
12484 optionalDependencies:
12485 '@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)
12486 expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f)
12487 react-dom: 19.2.6(react@19.2.6)
12488 transitivePeerDependencies:
12489 - supports-color
12490 optional: true
12491
1234712492 '@expo/schema-utils@56.0.1': {}
1234812493
1234912494 '@expo/sdk-runtime-versions@1.0.0': {}
......@@ -12354,6 +12499,23 @@ snapshots:
1235412499
1235512500 '@expo/sudo-prompt@9.3.2': {}
1235612501
12502 '@expo/ui@56.0.18(32843e0c0883df8bccfa0b8323659df5)':
12503 dependencies:
12504 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
12505 react: 19.2.6
12506 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12507 sf-symbols-typescript: 2.2.0
12508 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)
12509 optionalDependencies:
12510 '@babel/core': 7.29.7
12511 react-dom: 19.2.6(react@19.2.6)
12512 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)
12513 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)
12514 transitivePeerDependencies:
12515 - '@types/react'
12516 - '@types/react-dom'
12517 optional: true
12518
1235712519 '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)':
1235812520 dependencies:
1235912521 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -13286,18 +13448,45 @@ snapshots:
1328613448 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1328713449 optional: true
1328813450
13451 '@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)':
13452 dependencies:
13453 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13454 '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13455 '@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)
13456 '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6)
13457 react: 19.2.6
13458 react-dom: 19.2.6(react@19.2.6)
13459 optionalDependencies:
13460 '@types/react': 19.2.16
13461 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13462 optional: true
13463
1328913464 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)':
1329013465 dependencies:
1329113466 react: 19.2.3
1329213467 optionalDependencies:
1329313468 '@types/react': 19.2.16
1329413469
13470 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.6)':
13471 dependencies:
13472 react: 19.2.6
13473 optionalDependencies:
13474 '@types/react': 19.2.16
13475 optional: true
13476
1329513477 '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)':
1329613478 dependencies:
1329713479 react: 19.2.3
1329813480 optionalDependencies:
1329913481 '@types/react': 19.2.16
1330013482
13483 '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.6)':
13484 dependencies:
13485 react: 19.2.6
13486 optionalDependencies:
13487 '@types/react': 19.2.16
13488 optional: true
13489
1330113490 '@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)':
1330213491 dependencies:
1330313492 '@radix-ui/primitive': 1.1.3
......@@ -13320,6 +13509,29 @@ snapshots:
1332013509 '@types/react': 19.2.16
1332113510 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1332213511
13512 '@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)':
13513 dependencies:
13514 '@radix-ui/primitive': 1.1.3
13515 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13516 '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13517 '@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)
13518 '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.16)(react@19.2.6)
13519 '@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)
13520 '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13521 '@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)
13522 '@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)
13523 '@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)
13524 '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6)
13525 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6)
13526 aria-hidden: 1.2.6
13527 react: 19.2.6
13528 react-dom: 19.2.6(react@19.2.6)
13529 react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.6)
13530 optionalDependencies:
13531 '@types/react': 19.2.16
13532 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13533 optional: true
13534
1332313535 '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)':
1332413536 dependencies:
1332513537 react: 19.2.3
......@@ -13327,6 +13539,13 @@ snapshots:
1332713539 '@types/react': 19.2.16
1332813540 optional: true
1332913541
13542 '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.6)':
13543 dependencies:
13544 react: 19.2.6
13545 optionalDependencies:
13546 '@types/react': 19.2.16
13547 optional: true
13548
1333013549 '@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)':
1333113550 dependencies:
1333213551 '@radix-ui/primitive': 1.1.3
......@@ -13340,12 +13559,33 @@ snapshots:
1334013559 '@types/react': 19.2.16
1334113560 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1334213561
13562 '@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)':
13563 dependencies:
13564 '@radix-ui/primitive': 1.1.3
13565 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13566 '@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)
13567 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13568 '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13569 react: 19.2.6
13570 react-dom: 19.2.6(react@19.2.6)
13571 optionalDependencies:
13572 '@types/react': 19.2.16
13573 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13574 optional: true
13575
1334313576 '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)':
1334413577 dependencies:
1334513578 react: 19.2.3
1334613579 optionalDependencies:
1334713580 '@types/react': 19.2.16
1334813581
13582 '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.6)':
13583 dependencies:
13584 react: 19.2.6
13585 optionalDependencies:
13586 '@types/react': 19.2.16
13587 optional: true
13588
1334913589 '@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)':
1335013590 dependencies:
1335113591 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
......@@ -13357,6 +13597,18 @@ snapshots:
1335713597 '@types/react': 19.2.16
1335813598 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1335913599
13600 '@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)':
13601 dependencies:
13602 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13603 '@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)
13604 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13605 react: 19.2.6
13606 react-dom: 19.2.6(react@19.2.6)
13607 optionalDependencies:
13608 '@types/react': 19.2.16
13609 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13610 optional: true
13611
1336013612 '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)':
1336113613 dependencies:
1336213614 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3)
......@@ -13364,6 +13616,14 @@ snapshots:
1336413616 optionalDependencies:
1336513617 '@types/react': 19.2.16
1336613618
13619 '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.6)':
13620 dependencies:
13621 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13622 react: 19.2.6
13623 optionalDependencies:
13624 '@types/react': 19.2.16
13625 optional: true
13626
1336713627 '@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)':
1336813628 dependencies:
1336913629 '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
......@@ -13374,6 +13634,17 @@ snapshots:
1337413634 '@types/react': 19.2.16
1337513635 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1337613636
13637 '@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)':
13638 dependencies:
13639 '@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)
13640 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13641 react: 19.2.6
13642 react-dom: 19.2.6(react@19.2.6)
13643 optionalDependencies:
13644 '@types/react': 19.2.16
13645 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13646 optional: true
13647
1337713648 '@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)':
1337813649 dependencies:
1337913650 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
......@@ -13384,6 +13655,17 @@ snapshots:
1338413655 '@types/react': 19.2.16
1338513656 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1338613657
13658 '@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)':
13659 dependencies:
13660 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13661 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13662 react: 19.2.6
13663 react-dom: 19.2.6(react@19.2.6)
13664 optionalDependencies:
13665 '@types/react': 19.2.16
13666 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13667 optional: true
13668
1338713669 '@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)':
1338813670 dependencies:
1338913671 '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3)
......@@ -13393,6 +13675,16 @@ snapshots:
1339313675 '@types/react': 19.2.16
1339413676 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1339513677
13678 '@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)':
13679 dependencies:
13680 '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.6)
13681 react: 19.2.6
13682 react-dom: 19.2.6(react@19.2.6)
13683 optionalDependencies:
13684 '@types/react': 19.2.16
13685 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13686 optional: true
13687
1339613688 '@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)':
1339713689 dependencies:
1339813690 '@radix-ui/primitive': 1.1.3
......@@ -13411,6 +13703,24 @@ snapshots:
1341113703 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1341213704 optional: true
1341313705
13706 '@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)':
13707 dependencies:
13708 '@radix-ui/primitive': 1.1.3
13709 '@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)
13710 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13711 '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13712 '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13713 '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13714 '@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)
13715 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13716 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6)
13717 react: 19.2.6
13718 react-dom: 19.2.6(react@19.2.6)
13719 optionalDependencies:
13720 '@types/react': 19.2.16
13721 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13722 optional: true
13723
1341413724 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)':
1341513725 dependencies:
1341613726 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
......@@ -13418,6 +13728,14 @@ snapshots:
1341813728 optionalDependencies:
1341913729 '@types/react': 19.2.16
1342013730
13731 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.6)':
13732 dependencies:
13733 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13734 react: 19.2.6
13735 optionalDependencies:
13736 '@types/react': 19.2.16
13737 optional: true
13738
1342113739 '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)':
1342213740 dependencies:
1342313741 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
......@@ -13426,6 +13744,14 @@ snapshots:
1342613744 '@types/react': 19.2.16
1342713745 optional: true
1342813746
13747 '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.6)':
13748 dependencies:
13749 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13750 react: 19.2.6
13751 optionalDependencies:
13752 '@types/react': 19.2.16
13753 optional: true
13754
1342913755 '@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)':
1343013756 dependencies:
1343113757 '@radix-ui/primitive': 1.1.3
......@@ -13443,12 +13769,36 @@ snapshots:
1344313769 '@types/react-dom': 19.2.3(@types/react@19.2.16)
1344413770 optional: true
1344513771
13772 '@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)':
13773 dependencies:
13774 '@radix-ui/primitive': 1.1.3
13775 '@radix-ui/react-context': 1.1.2(@types/react@19.2.16)(react@19.2.6)
13776 '@radix-ui/react-direction': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13777 '@radix-ui/react-id': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13778 '@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)
13779 '@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)
13780 '@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)
13781 '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.16)(react@19.2.6)
13782 react: 19.2.6
13783 react-dom: 19.2.6(react@19.2.6)
13784 optionalDependencies:
13785 '@types/react': 19.2.16
13786 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13787 optional: true
13788
1344613789 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)':
1344713790 dependencies:
1344813791 react: 19.2.3
1344913792 optionalDependencies:
1345013793 '@types/react': 19.2.16
1345113794
13795 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.6)':
13796 dependencies:
13797 react: 19.2.6
13798 optionalDependencies:
13799 '@types/react': 19.2.16
13800 optional: true
13801
1345213802 '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)':
1345313803 dependencies:
1345413804 '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3)
......@@ -13457,12 +13807,29 @@ snapshots:
1345713807 optionalDependencies:
1345813808 '@types/react': 19.2.16
1345913809
13460 '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.3)':
13810 '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.6)':
1346113811 dependencies:
13462 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3)
13463 react: 19.2.3
13812 '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.6)
13813 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13814 react: 19.2.6
1346413815 optionalDependencies:
1346513816 '@types/react': 19.2.16
13817 optional: true
13818
13819 '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.3)':
13820 dependencies:
13821 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3)
13822 react: 19.2.3
13823 optionalDependencies:
13824 '@types/react': 19.2.16
13825
13826 '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.16)(react@19.2.6)':
13827 dependencies:
13828 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13829 react: 19.2.6
13830 optionalDependencies:
13831 '@types/react': 19.2.16
13832 optional: true
1346613833
1346713834 '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)':
1346813835 dependencies:
......@@ -13471,12 +13838,27 @@ snapshots:
1347113838 optionalDependencies:
1347213839 '@types/react': 19.2.16
1347313840
13841 '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.6)':
13842 dependencies:
13843 '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13844 react: 19.2.6
13845 optionalDependencies:
13846 '@types/react': 19.2.16
13847 optional: true
13848
1347413849 '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)':
1347513850 dependencies:
1347613851 react: 19.2.3
1347713852 optionalDependencies:
1347813853 '@types/react': 19.2.16
1347913854
13855 '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.6)':
13856 dependencies:
13857 react: 19.2.6
13858 optionalDependencies:
13859 '@types/react': 19.2.16
13860 optional: true
13861
1348013862 '@react-grab/cli@0.1.44':
1348113863 dependencies:
1348213864 agent-install: 0.0.5
......@@ -13494,6 +13876,12 @@ snapshots:
1349413876 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1349513877 optional: true
1349613878
13879 '@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)':
13880 dependencies:
13881 react: 19.2.6
13882 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
13883 optional: true
13884
1349713885 '@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)':
1349813886 dependencies:
1349913887 react: 19.2.3
......@@ -13638,6 +14026,16 @@ snapshots:
1363814026 optionalDependencies:
1363914027 '@types/react': 19.2.16
1364014028
14029 '@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)':
14030 dependencies:
14031 invariant: 2.2.4
14032 nullthrows: 1.1.1
14033 react: 19.2.6
14034 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
14035 optionalDependencies:
14036 '@types/react': 19.2.16
14037 optional: true
14038
1364114039 '@react-navigation/core@7.21.2(react@19.2.3)':
1364214040 dependencies:
1364314041 '@react-navigation/routers': 7.6.0
......@@ -14797,7 +15195,7 @@ snapshots:
1479715195 json-schema-traverse: 1.0.0
1479815196 require-from-string: 2.0.2
1479915197
14800 alchemy@https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab):
15198 alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39):
1480115199 dependencies:
1480215200 '@alchemy.run/node-utils': 0.0.4
1480315201 '@aws-sdk/credential-providers': 3.1062.0
......@@ -14840,7 +15238,7 @@ snapshots:
1484015238 '@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)
1484115239 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
1484215240 drizzle-kit: 1.0.0-rc.3
14843 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)
15241 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)
1484415242 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)'
1484515243 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1484615244 transitivePeerDependencies:
......@@ -15083,6 +15481,8 @@ snapshots:
1508315481
1508415482 auto-bind@5.0.1: {}
1508515483
15484 await-lock@2.2.2: {}
15485
1508615486 aws-ssl-profiles@1.1.2: {}
1508715487
1508815488 aws4@1.13.2: {}
......@@ -15856,13 +16256,14 @@ snapshots:
1585616256 get-tsconfig: 4.14.0
1585716257 jiti: 2.7.0
1585816258
15859 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):
16259 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):
1586016260 optionalDependencies:
1586116261 '@cloudflare/workers-types': 4.20260604.1
1586216262 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
1586316263 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
1586416264 bun-types: 1.3.14
1586516265 effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
16266 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)
1586616267 mysql2: 3.22.4(@types/node@24.12.4)
1586716268 pg: 8.21.0
1586816269 zod: 4.4.3
......@@ -16142,6 +16543,18 @@ snapshots:
1614216543 - supports-color
1614316544 - typescript
1614416545
16546 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):
16547 dependencies:
16548 '@expo/image-utils': 0.10.1(typescript@6.0.3)
16549 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16550 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))
16551 react: 19.2.6
16552 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16553 transitivePeerDependencies:
16554 - supports-color
16555 - typescript
16556 optional: true
16557
1614516558 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):
1614616559 dependencies:
1614716560 expo-application: 56.0.3(expo@56.0.12)
......@@ -16186,6 +16599,15 @@ snapshots:
1618616599 transitivePeerDependencies:
1618716600 - supports-color
1618816601
16602 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)):
16603 dependencies:
16604 '@expo/env': 2.3.0
16605 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16606 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16607 transitivePeerDependencies:
16608 - supports-color
16609 optional: true
16610
1618916611 expo-crypto@56.0.4(expo@56.0.12):
1619016612 dependencies:
1619116613 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16226,6 +16648,12 @@ snapshots:
1622616648 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
1622716649 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1622816650
16651 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)):
16652 dependencies:
16653 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16654 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16655 optional: true
16656
1622916657 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):
1623016658 dependencies:
1623116659 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16233,12 +16661,27 @@ snapshots:
1623316661 react: 19.2.3
1623416662 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1623516663
16664 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):
16665 dependencies:
16666 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16667 fontfaceobserver: 2.3.0
16668 react: 19.2.6
16669 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16670 optional: true
16671
1623616672 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):
1623716673 dependencies:
1623816674 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
1623916675 react: 19.2.3
1624016676 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1624116677
16678 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):
16679 dependencies:
16680 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16681 react: 19.2.6
16682 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16683 optional: true
16684
1624216685 expo-haptics@56.0.3(expo@56.0.12):
1624316686 dependencies:
1624416687 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16259,6 +16702,12 @@ snapshots:
1625916702 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
1626016703 react: 19.2.3
1626116704
16705 expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.6):
16706 dependencies:
16707 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16708 react: 19.2.6
16709 optional: true
16710
1626216711 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):
1626316712 dependencies:
1626416713 expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))
......@@ -16269,6 +16718,17 @@ snapshots:
1626916718 - expo
1627016719 - supports-color
1627116720
16721 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):
16722 dependencies:
16723 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))
16724 invariant: 2.2.4
16725 react: 19.2.6
16726 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16727 transitivePeerDependencies:
16728 - expo
16729 - supports-color
16730 optional: true
16731
1627216732 expo-manifests@56.0.4(expo@56.0.12):
1627316733 dependencies:
1627416734 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16294,10 +16754,26 @@ snapshots:
1629416754 optionalDependencies:
1629516755 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)
1629616756
16757 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):
16758 dependencies:
16759 '@expo/expo-modules-macros-plugin': 0.2.2
16760 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))
16761 invariant: 2.2.4
16762 react: 19.2.6
16763 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16764 optionalDependencies:
16765 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)
16766 optional: true
16767
1629716768 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)):
1629816769 dependencies:
1629916770 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1630016771
16772 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)):
16773 dependencies:
16774 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16775 optional: true
16776
1630116777 expo-network@56.0.5(expo@56.0.12)(react@19.2.3):
1630216778 dependencies:
1630316779 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16374,6 +16850,57 @@ snapshots:
1637416850 - supports-color
1637516851 optional: true
1637616852
16853 expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f):
16854 dependencies:
16855 '@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)
16856 '@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)
16857 '@expo/schema-utils': 56.0.1
16858 '@expo/ui': 56.0.18(32843e0c0883df8bccfa0b8323659df5)
16859 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.6)
16860 '@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)
16861 '@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)
16862 '@testing-library/jest-dom': 6.9.1
16863 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
16864 client-only: 0.0.1
16865 color: 4.2.3
16866 debug: 4.4.3
16867 escape-string-regexp: 4.0.0
16868 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16869 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))
16870 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)
16871 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)
16872 expo-server: 56.0.5
16873 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)
16874 fast-deep-equal: 3.1.3
16875 invariant: 2.2.4
16876 nanoid: 3.3.12
16877 query-string: 7.1.3
16878 react: 19.2.6
16879 react-fast-compare: 3.2.2
16880 react-is: 19.2.7
16881 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16882 react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee)
16883 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)
16884 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)
16885 server-only: 0.0.1
16886 sf-symbols-typescript: 2.2.0
16887 shallowequal: 1.1.0
16888 standard-navigation: 0.0.5
16889 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)
16890 optionalDependencies:
16891 react-dom: 19.2.6(react@19.2.6)
16892 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)
16893 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)
16894 transitivePeerDependencies:
16895 - '@babel/core'
16896 - '@testing-library/dom'
16897 - '@types/react'
16898 - '@types/react-dom'
16899 - expo-font
16900 - react-native-worklets
16901 - supports-color
16902 optional: true
16903
1637716904 expo-secure-store@56.0.4(expo@56.0.12):
1637816905 dependencies:
1637916906 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16390,6 +16917,21 @@ snapshots:
1639016917 - supports-color
1639116918 - typescript
1639216919
16920 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):
16921 dependencies:
16922 await-lock: 2.2.2
16923 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16924 react: 19.2.3
16925 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16926
16927 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):
16928 dependencies:
16929 await-lock: 2.2.2
16930 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16931 react: 19.2.6
16932 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16933 optional: true
16934
1639316935 expo-structured-headers@56.0.0: {}
1639416936
1639516937 expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3):
......@@ -16401,6 +16943,16 @@ snapshots:
1640116943 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1640216944 sf-symbols-typescript: 2.2.0
1640316945
16946 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):
16947 dependencies:
16948 '@expo-google-fonts/material-symbols': 0.4.38
16949 expo: 56.0.12(deb8cbf3e0f411b34ba85a995c9982ba)
16950 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)
16951 react: 19.2.6
16952 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16953 sf-symbols-typescript: 2.2.0
16954 optional: true
16955
1640416956 expo-updates-interface@56.0.2(expo@56.0.12):
1640516957 dependencies:
1640616958 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
......@@ -16492,6 +17044,49 @@ snapshots:
1649217044 - typescript
1649317045 - utf-8-validate
1649417046
17047 expo@56.0.12(deb8cbf3e0f411b34ba85a995c9982ba):
17048 dependencies:
17049 '@babel/runtime': 7.29.7
17050 '@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)
17051 '@expo/config': 56.0.9(typescript@6.0.3)
17052 '@expo/config-plugins': 56.0.9(typescript@6.0.3)
17053 '@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)
17054 '@expo/fingerprint': 0.19.4
17055 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3)
17056 '@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)
17057 '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
17058 '@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)
17059 '@ungap/structured-clone': 1.3.1
17060 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)
17061 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)
17062 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))
17063 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))
17064 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)
17065 expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.6)
17066 expo-modules-autolinking: 56.0.16(typescript@6.0.3)
17067 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)
17068 pretty-format: 29.7.0
17069 react: 19.2.6
17070 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
17071 react-refresh: 0.14.2
17072 whatwg-url-minimum: 0.1.2
17073 optionalDependencies:
17074 '@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)
17075 '@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)
17076 react-dom: 19.2.6(react@19.2.6)
17077 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)
17078 transitivePeerDependencies:
17079 - '@babel/core'
17080 - bufferutil
17081 - expo-router
17082 - expo-widgets
17083 - react-native-worklets
17084 - react-server-dom-webpack
17085 - supports-color
17086 - typescript
17087 - utf-8-validate
17088 optional: true
17089
1649517090 exponential-backoff@3.1.3: {}
1649617091
1649717092 express-rate-limit@8.5.2(express@5.2.1):
......@@ -18878,6 +19473,11 @@ snapshots:
1887819473 dependencies:
1887919474 react: 19.2.3
1888019475
19476 react-freeze@1.0.4(react@19.2.6):
19477 dependencies:
19478 react: 19.2.6
19479 optional: true
19480
1888119481 react-grab@0.1.44(react@19.2.6):
1888219482 dependencies:
1888319483 '@react-grab/cli': 0.1.44
......@@ -18921,6 +19521,16 @@ snapshots:
1892119521 use-latest-callback: 0.2.6(react@19.2.3)
1892219522 optional: true
1892319523
19524 react-native-drawer-layout@4.2.4(de9b2f2dc96a3557fdc0df187a8417ee):
19525 dependencies:
19526 color: 4.2.3
19527 react: 19.2.6
19528 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19529 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)
19530 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)
19531 use-latest-callback: 0.2.6(react@19.2.6)
19532 optional: true
19533
1892419534 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):
1892519535 dependencies:
1892619536 '@egjs/hammerjs': 2.0.17
......@@ -18930,6 +19540,16 @@ snapshots:
1893019540 react: 19.2.3
1893119541 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1893219542
19543 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):
19544 dependencies:
19545 '@egjs/hammerjs': 2.0.17
19546 '@types/react-test-renderer': 19.1.0
19547 hoist-non-react-statics: 3.3.2
19548 invariant: 2.2.4
19549 react: 19.2.6
19550 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19551 optional: true
19552
1893319553 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):
1893419554 dependencies:
1893519555 react: 19.2.3
......@@ -18940,6 +19560,12 @@ snapshots:
1894019560 react: 19.2.3
1894119561 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1894219562
19563 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):
19564 dependencies:
19565 react: 19.2.6
19566 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19567 optional: true
19568
1894319569 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):
1894419570 dependencies:
1894519571 react: 19.2.3
......@@ -18968,11 +19594,26 @@ snapshots:
1896819594 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)
1896919595 semver: 7.8.5
1897019596
19597 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):
19598 dependencies:
19599 react: 19.2.6
19600 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19601 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)
19602 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)
19603 semver: 7.8.5
19604 optional: true
19605
1897119606 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):
1897219607 dependencies:
1897319608 react: 19.2.3
1897419609 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1897519610
19611 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):
19612 dependencies:
19613 react: 19.2.6
19614 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19615 optional: true
19616
1897619617 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):
1897719618 dependencies:
1897819619 react: 19.2.3
......@@ -18980,6 +19621,14 @@ snapshots:
1898019621 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1898119622 warn-once: 0.1.1
1898219623
19624 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):
19625 dependencies:
19626 react: 19.2.6
19627 react-freeze: 1.0.4(react@19.2.6)
19628 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19629 warn-once: 0.1.1
19630 optional: true
19631
1898319632 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):
1898419633 dependencies:
1898519634 '@shikijs/types': 4.3.0
......@@ -19007,6 +19656,14 @@ snapshots:
1900719656 react: 19.2.3
1900819657 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
1900919658
19659 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):
19660 dependencies:
19661 escape-string-regexp: 4.0.0
19662 invariant: 2.2.4
19663 react: 19.2.6
19664 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19665 optional: true
19666
1901019667 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):
1901119668 dependencies:
1901219669 '@babel/core': 7.29.7
......@@ -19027,6 +19684,27 @@ snapshots:
1902719684 transitivePeerDependencies:
1902819685 - supports-color
1902919686
19687 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):
19688 dependencies:
19689 '@babel/core': 7.29.7
19690 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
19691 '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7)
19692 '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7)
19693 '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7)
19694 '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7)
19695 '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7)
19696 '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7)
19697 '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7)
19698 '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
19699 '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)
19700 convert-source-map: 2.0.0
19701 react: 19.2.6
19702 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
19703 semver: 7.8.5
19704 transitivePeerDependencies:
19705 - supports-color
19706 optional: true
19707
1903019708 react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):
1903119709 dependencies:
1903219710 '@react-native/assets-registry': 0.85.3
......@@ -19072,6 +19750,52 @@ snapshots:
1907219750 - supports-color
1907319751 - utf-8-validate
1907419752
19753 react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):
19754 dependencies:
19755 '@react-native/assets-registry': 0.85.3
19756 '@react-native/codegen': 0.85.3(@babel/core@7.29.7)
19757 '@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)
19758 '@react-native/gradle-plugin': 0.85.3
19759 '@react-native/js-polyfills': 0.85.3
19760 '@react-native/normalize-colors': 0.85.3
19761 '@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)
19762 abort-controller: 3.0.0
19763 anser: 1.4.10
19764 ansi-regex: 5.0.1
19765 babel-plugin-syntax-hermes-parser: 0.33.3
19766 base64-js: 1.5.1
19767 commander: 12.1.0
19768 flow-enums-runtime: 0.0.6
19769 hermes-compiler: 250829098.0.10
19770 invariant: 2.2.4
19771 memoize-one: 5.2.1
19772 metro-runtime: 0.84.4
19773 metro-source-map: 0.84.4
19774 nullthrows: 1.1.1
19775 pretty-format: 29.7.0
19776 promise: 8.3.0
19777 react: 19.2.6
19778 react-devtools-core: 6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)
19779 react-refresh: 0.14.2
19780 regenerator-runtime: 0.13.11
19781 scheduler: 0.27.0
19782 semver: 7.8.5
19783 stacktrace-parser: 0.1.11
19784 tinyglobby: 0.2.17
19785 whatwg-fetch: 3.6.20
19786 ws: 7.5.11(bufferutil@4.1.0)(utf-8-validate@6.0.6)
19787 yargs: 17.7.2
19788 optionalDependencies:
19789 '@types/react': 19.2.16
19790 transitivePeerDependencies:
19791 - '@babel/core'
19792 - '@react-native-community/cli'
19793 - '@react-native/metro-config'
19794 - bufferutil
19795 - supports-color
19796 - utf-8-validate
19797 optional: true
19798
1907519799 react-reconciler@0.33.0(react@19.2.6):
1907619800 dependencies:
1907719801 react: 19.2.6
......@@ -19087,6 +19811,15 @@ snapshots:
1908719811 optionalDependencies:
1908819812 '@types/react': 19.2.16
1908919813
19814 react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.6):
19815 dependencies:
19816 react: 19.2.6
19817 react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.6)
19818 tslib: 2.8.1
19819 optionalDependencies:
19820 '@types/react': 19.2.16
19821 optional: true
19822
1909019823 react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3):
1909119824 dependencies:
1909219825 react: 19.2.3
......@@ -19098,6 +19831,18 @@ snapshots:
1909819831 optionalDependencies:
1909919832 '@types/react': 19.2.16
1910019833
19834 react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.6):
19835 dependencies:
19836 react: 19.2.6
19837 react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.6)
19838 react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.6)
19839 tslib: 2.8.1
19840 use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.6)
19841 use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.6)
19842 optionalDependencies:
19843 '@types/react': 19.2.16
19844 optional: true
19845
1910119846 react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3):
1910219847 dependencies:
1910319848 get-nonce: 1.0.1
......@@ -19106,6 +19851,15 @@ snapshots:
1910619851 optionalDependencies:
1910719852 '@types/react': 19.2.16
1910819853
19854 react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.6):
19855 dependencies:
19856 get-nonce: 1.0.1
19857 react: 19.2.6
19858 tslib: 2.8.1
19859 optionalDependencies:
19860 '@types/react': 19.2.16
19861 optional: true
19862
1910919863 react@19.2.3: {}
1911019864
1911119865 react@19.2.6: {}
......@@ -20149,10 +20903,23 @@ snapshots:
2014920903 optionalDependencies:
2015020904 '@types/react': 19.2.16
2015120905
20906 use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.6):
20907 dependencies:
20908 react: 19.2.6
20909 tslib: 2.8.1
20910 optionalDependencies:
20911 '@types/react': 19.2.16
20912 optional: true
20913
2015220914 use-latest-callback@0.2.6(react@19.2.3):
2015320915 dependencies:
2015420916 react: 19.2.3
2015520917
20918 use-latest-callback@0.2.6(react@19.2.6):
20919 dependencies:
20920 react: 19.2.6
20921 optional: true
20922
2015620923 use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3):
2015720924 dependencies:
2015820925 detect-node-es: 1.1.0
......@@ -20161,6 +20928,15 @@ snapshots:
2016120928 optionalDependencies:
2016220929 '@types/react': 19.2.16
2016320930
20931 use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.6):
20932 dependencies:
20933 detect-node-es: 1.1.0
20934 react: 19.2.6
20935 tslib: 2.8.1
20936 optionalDependencies:
20937 '@types/react': 19.2.16
20938 optional: true
20939
2016420940 use-sync-external-store@1.6.0(react@19.2.3):
2016520941 dependencies:
2016620942 react: 19.2.3
......@@ -20197,6 +20973,16 @@ snapshots:
2019720973 - '@types/react'
2019820974 - '@types/react-dom'
2019920975
20976 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):
20977 dependencies:
20978 '@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)
20979 react: 19.2.6
20980 react-dom: 19.2.6(react@19.2.6)
20981 transitivePeerDependencies:
20982 - '@types/react'
20983 - '@types/react-dom'
20984 optional: true
20985
2020020986 vfile-location@5.0.3:
2020120987 dependencies:
2020220988 '@types/unist': 3.0.3