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...@@ -43,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b
43- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`.43- Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`.
44- `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports.44- `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
46## Errors and predicates55## Errors and predicates
4756
48- Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data.57- 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 = {...@@ -123,6 +123,7 @@ const config: ExpoConfig = {
123 plugins: [123 plugins: [
124 "expo-font",124 "expo-font",
125 "expo-secure-store",125 "expo-secure-store",
126 "expo-sqlite",
126 ["@clerk/expo", { theme: "./clerk-theme.json" }],127 ["@clerk/expo", { theme: "./clerk-theme.json" }],
127 "expo-web-browser",128 "expo-web-browser",
128 [129 [
apps/mobile/eas.json+8-4
...@@ -8,7 +8,8 @@...@@ -8,7 +8,8 @@
8 "development": {8 "development": {
9 "corepack": true,9 "corepack": true,
10 "env": {10 "env": {
11 "APP_VARIANT": "development"11 "APP_VARIANT": "development",
12 "NODE_OPTIONS": "--max-old-space-size=4096"
12 },13 },
13 "channel": "development",14 "channel": "development",
14 "developmentClient": true,15 "developmentClient": true,
...@@ -17,7 +18,8 @@...@@ -17,7 +18,8 @@
17 "preview": {18 "preview": {
18 "corepack": true,19 "corepack": true,
19 "env": {20 "env": {
20 "APP_VARIANT": "preview"21 "APP_VARIANT": "preview",
22 "NODE_OPTIONS": "--max-old-space-size=4096"
21 },23 },
22 "channel": "preview",24 "channel": "preview",
23 "environment": "preview",25 "environment": "preview",
...@@ -27,7 +29,8 @@...@@ -27,7 +29,8 @@
27 "corepack": true,29 "corepack": true,
28 "env": {30 "env": {
29 "APP_VARIANT": "preview",31 "APP_VARIANT": "preview",
30 "MOBILE_VERSION_POLICY": "fingerprint"32 "MOBILE_VERSION_POLICY": "fingerprint",
33 "NODE_OPTIONS": "--max-old-space-size=4096"
31 },34 },
32 "channel": "preview",35 "channel": "preview",
33 "environment": "preview",36 "environment": "preview",
...@@ -40,7 +43,8 @@...@@ -40,7 +43,8 @@
40 "production": {43 "production": {
41 "corepack": true,44 "corepack": true,
42 "env": {45 "env": {
43 "APP_VARIANT": "production"46 "APP_VARIANT": "production",
47 "NODE_OPTIONS": "--max-old-space-size=4096"
44 },48 },
45 "channel": "production",49 "channel": "production",
46 "environment": "production",50 "environment": "production",
apps/mobile/package.json+2
...@@ -6,6 +6,7 @@...@@ -6,6 +6,7 @@
6 "scripts": {6 "scripts": {
7 "dev": "expo start --clear",7 "dev": "expo start --clear",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear",8 "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'",
9 "start": "expo start",10 "start": "expo start",
10 "start:dev": "APP_VARIANT=development expo start",11 "start:dev": "APP_VARIANT=development expo start",
11 "start:preview": "APP_VARIANT=preview expo start",12 "start:preview": "APP_VARIANT=preview expo start",
...@@ -86,6 +87,7 @@...@@ -86,6 +87,7 @@
86 "expo-paste-input": "^0.1.15",87 "expo-paste-input": "^0.1.15",
87 "expo-secure-store": "~56.0.4",88 "expo-secure-store": "~56.0.4",
88 "expo-splash-screen": "~56.0.10",89 "expo-splash-screen": "~56.0.10",
90 "expo-sqlite": "~56.0.5",
89 "expo-symbols": "~56.0.6",91 "expo-symbols": "~56.0.6",
90 "expo-updates": "~56.0.19",92 "expo-updates": "~56.0.19",
91 "expo-web-browser": "~56.0.5",93 "expo-web-browser": "~56.0.5",
apps/mobile/src/Stack.tsx+8
...@@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr...@@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr
41import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";41import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider";
42import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";42import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
43import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";43import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
44import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
44import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";45import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
45import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";46import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
46import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";47import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen";
...@@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({...@@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({
147 title: "Appearance",148 title: "Appearance",
148 },149 },
149 }),150 }),
151 SettingsClientStorage: createNativeStackScreen({
152 screen: SettingsClientStorageRouteScreen,
153 linking: "client-storage",
154 options: {
155 title: "Client Storage",
156 },
157 }),
150 SettingsAuth: createNativeStackScreen({158 SettingsAuth: createNativeStackScreen({
151 screen: SettingsAuthRouteScreen,159 screen: SettingsAuthRouteScreen,
152 linking: "auth",160 linking: "auth",
apps/mobile/src/connection/catalog-store.ts+25-26
...@@ -10,6 +10,7 @@ import * as Ref from "effect/Ref";...@@ -10,6 +10,7 @@ import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";10import * as Schema from "effect/Schema";
11import * as Semaphore from "effect/Semaphore";11import * as Semaphore from "effect/Semaphore";
1212
13import * as MobileSecureStorage from "../persistence/mobile-secure-storage";
13import { migrateLegacyConnectionCatalog } from "./migration";14import { migrateLegacyConnectionCatalog } from "./migration";
1415
15export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";16export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1";
...@@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) {...@@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) {
22 });23 });
23}24}
2425
26const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
27const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson);
28const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
29
25const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) {30const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) {
26 const parsed = yield* Effect.try({31 return yield* decodeConnectionCatalogDocument(raw).pipe(
27 try: () => JSON.parse(raw) as unknown,32 Effect.mapError((cause) => catalogError("decode", cause)),
28 catch: (cause) => catalogError("decode", cause),33 );
29 });
30 return yield* Effect.fromResult(
31 Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed),
32 ).pipe(Effect.mapError((cause) => catalogError("decode", cause)));
33});34});
3435
35const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* (36const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* (
36 catalog: ConnectionCatalogDocumentType,37 catalog: ConnectionCatalogDocumentType,
37) {38) {
38 const encoded = yield* Effect.fromResult(39 return yield* encodeConnectionCatalogDocument(catalog).pipe(
39 Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog),40 Effect.mapError((cause) => catalogError("encode", cause)),
40 ).pipe(Effect.mapError((cause) => catalogError("encode", cause)));41 );
41 return JSON.stringify(encoded);
42});42});
4343
44interface CatalogStore {44interface CatalogStore {
...@@ -48,20 +48,19 @@ interface CatalogStore {...@@ -48,20 +48,19 @@ interface CatalogStore {
48 ) => Effect.Effect<void, ConnectionTransientError>;48 ) => Effect.Effect<void, ConnectionTransientError>;
49}49}
5050
51export interface SecureCatalogStorage {51export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () {
52 readonly getItem: (key: string) => Effect.Effect<string | null, ConnectionTransientError>;52 const storage = yield* MobileSecureStorage.MobileSecureStorage;
53 readonly setItem: (key: string, value: string) => Effect.Effect<void, ConnectionTransientError>;53 const getItem = (key: string) =>
54 readonly deleteItem: (key: string) => Effect.Effect<void, ConnectionTransientError>;54 storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause)));
55}55 const setItem = (key: string, value: string) =>
5656 storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause)));
57export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* (57 const deleteItem = (key: string) =>
58 storage: SecureCatalogStorage,58 storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause)));
59) {
60 const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none());59 const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none());
61 const lock = yield* Semaphore.make(1);60 const lock = yield* Semaphore.make(1);
6261
63 const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () {62 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);
65 const catalog =64 const catalog =
66 legacyRaw === null || legacyRaw.trim() === ""65 legacyRaw === null || legacyRaw.trim() === ""
67 ? EMPTY_CONNECTION_CATALOG_DOCUMENT66 ? EMPTY_CONNECTION_CATALOG_DOCUMENT
...@@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS...@@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
75 );74 );
76 if (legacyRaw !== null && legacyRaw.trim() !== "") {75 if (legacyRaw !== null && legacyRaw.trim() !== "") {
77 const encoded = yield* encodeCatalog(catalog);76 const encoded = yield* encodeCatalog(catalog);
78 yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded);77 yield* setItem(CONNECTION_CATALOG_KEY, encoded);
79 yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY);78 yield* deleteItem(LEGACY_CONNECTIONS_KEY);
80 }79 }
81 return catalog;80 return catalog;
82 });81 });
...@@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS...@@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
86 if (Option.isSome(cached)) {85 if (Option.isSome(cached)) {
87 return cached.value;86 return cached.value;
88 }87 }
89 const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY);88 const raw = yield* getItem(CONNECTION_CATALOG_KEY);
90 let catalog: ConnectionCatalogDocumentType;89 let catalog: ConnectionCatalogDocumentType;
91 if (raw !== null && raw.trim() !== "") {90 if (raw !== null && raw.trim() !== "") {
92 catalog = yield* decodeCatalog(raw).pipe(91 catalog = yield* decodeCatalog(raw).pipe(
93 Effect.catch((error) =>92 Effect.catch((error) =>
94 Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe(93 Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe(
95 Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)),94 Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)),
96 Effect.andThen(loadLegacyCatalog()),95 Effect.andThen(loadLegacyCatalog()),
97 ),96 ),
98 ),97 ),
...@@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS...@@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS
111 Effect.gen(function* () {110 Effect.gen(function* () {
112 const next = transform(yield* loadUnlocked());111 const next = transform(yield* loadUnlocked());
113 const encoded = yield* encodeCatalog(next);112 const encoded = yield* encodeCatalog(next);
114 yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded);113 yield* setItem(CONNECTION_CATALOG_KEY, encoded);
115 yield* Ref.set(state, Option.some(next));114 yield* Ref.set(state, Option.some(next));
116 }),115 }),
117 );116 );
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";...@@ -25,7 +25,8 @@ import * as Network from "expo-network";
25import { AppState } from "react-native";25import { AppState } from "react-native";
2626
27import { authClientMetadata } from "../lib/authClientMetadata";27import { authClientMetadata } from "../lib/authClientMetadata";
28import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage";28import * as Runtime from "../lib/runtime";
29import * as MobileStorage from "../persistence/mobile-storage";
29import { appAtomRegistry } from "../state/atom-registry";30import { appAtomRegistry } from "../state/atom-registry";
30import { clearThreadOutboxEnvironment } from "../state/thread-outbox";31import { clearThreadOutboxEnvironment } from "../state/thread-outbox";
31import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";32import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
...@@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({...@@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({
83 ),84 ),
84});85});
8586
86const capabilitiesLayer = Layer.succeedContext(87const capabilitiesLayer = Layer.effectContext(
87 Context.make(88 Effect.gen(function* () {
88 CloudSession,89 const storage = yield* MobileStorage.MobileStorage;
89 CloudSession.of({90 return Context.make(
90 clerkToken: Effect.gen(function* () {91 CloudSession,
91 const session = appAtomRegistry.get(managedRelaySessionAtom);92 CloudSession.of({
92 if (session === null) {93 clerkToken: Effect.gen(function* () {
93 return yield* new ConnectionBlockedError({94 const session = appAtomRegistry.get(managedRelaySessionAtom);
94 reason: "authentication",95 if (session === null) {
95 detail: "Sign in to T3 Connect to connect this environment.",96 return yield* new ConnectionBlockedError({
96 });97 reason: "authentication",
97 }98 detail: "Sign in to T3 Connect to connect this environment.",
98 const token = yield* session.readClerkToken().pipe(99 });
99 Effect.mapError(100 }
100 (error) =>101 const token = yield* session.readClerkToken().pipe(
101 new ConnectionTransientError({102 Effect.mapError(
102 reason: "network",103 (error) =>
103 detail: error.message,104 new ConnectionTransientError({
104 }),105 reason: "network",
105 ),106 detail: error.message,
106 );107 }),
107 if (token === null) {108 ),
108 return yield* new ConnectionBlockedError({109 );
109 reason: "authentication",110 if (token === null) {
110 detail: "The T3 Connect session is unavailable.",111 return yield* new ConnectionBlockedError({
111 });112 reason: "authentication",
112 }113 detail: "The T3 Connect session is unavailable.",
113 return token;114 });
114 }),115 }
115 }),116 return token;
116 ).pipe(117 }),
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,
139 }),118 }),
140 ),119 ).pipe(
141 Context.add(120 Context.add(
142 SshEnvironmentGateway,121 PrimaryEnvironmentAuth,
143 SshEnvironmentGateway.of({122 PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }),
144 provision: () =>123 ),
145 Effect.fail(124 Context.add(
146 new ConnectionBlockedError({125 RelayDeviceIdentity,
147 reason: "unsupported",126 RelayDeviceIdentity.of({
148 detail: "SSH environments are only available in the desktop app.",127 deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe(
149 }),128 Effect.mapError(
150 ),129 (cause) =>
151 prepare: () =>130 new ConnectionTransientError({
152 Effect.fail(131 reason: "remote-unavailable",
153 new ConnectionBlockedError({132 detail: `Could not load the mobile device identity: ${String(cause)}`,
154 reason: "unsupported",133 }),
155 detail: "SSH environments are only available in the desktop app.",134 ),
156 }),135 Effect.map(Option.some),
157 ),136 ),
158 disconnect: () => Effect.void,137 }),
159 }),138 ),
160 ),139 Context.add(
161 ),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 }),
162);168);
163169
164const platformConnectionSourceLayer = Layer.succeed(170const platformConnectionSourceLayer = Layer.succeed(
...@@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed(...@@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed(
168 }),174 }),
169);175);
170176
177const providedConnectionStorageLayer = connectionStorageLayer.pipe(
178 Layer.provide(Runtime.runtimeContextLayer),
179);
180const providedCapabilitiesLayer = capabilitiesLayer.pipe(
181 Layer.provide(Runtime.runtimeContextLayer),
182);
183
171const environmentOwnedDataCleanupLayer = Layer.succeed(184const environmentOwnedDataCleanupLayer = Layer.succeed(
172 EnvironmentOwnedDataCleanup,185 EnvironmentOwnedDataCleanup,
173 EnvironmentOwnedDataCleanup.of({186 EnvironmentOwnedDataCleanup.of({
...@@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(...@@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed(
190);203);
191204
192type ConnectionPlatformLayerSource =205type ConnectionPlatformLayerSource =
193 | typeof connectionStorageLayer206 | typeof providedConnectionStorageLayer
207 | typeof Runtime.runtimeContextLayer
194 | typeof connectivityLayer208 | typeof connectivityLayer
195 | typeof wakeupsLayer209 | typeof wakeupsLayer
196 | typeof capabilitiesLayer210 | typeof providedCapabilitiesLayer
197 | typeof platformConnectionSourceLayer211 | typeof platformConnectionSourceLayer
198 | typeof environmentOwnedDataCleanupLayer;212 | typeof environmentOwnedDataCleanupLayer;
199213
...@@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer<...@@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer<
202 Layer.Error<ConnectionPlatformLayerSource>,216 Layer.Error<ConnectionPlatformLayerSource>,
203 Layer.Services<ConnectionPlatformLayerSource>217 Layer.Services<ConnectionPlatformLayerSource>
204> = Layer.mergeAll(218> = Layer.mergeAll(
205 connectionStorageLayer,219 providedConnectionStorageLayer,
220 Runtime.runtimeContextLayer,
206 connectivityLayer,221 connectivityLayer,
207 wakeupsLayer,222 wakeupsLayer,
208 capabilitiesLayer,223 providedCapabilitiesLayer,
209 platformConnectionSourceLayer,224 platformConnectionSourceLayer,
210 environmentOwnedDataCleanupLayer,225 environmentOwnedDataCleanupLayer,
211);226);
apps/mobile/src/connection/storage.test.ts+25-12
...@@ -1,28 +1,35 @@...@@ -1,28 +1,35 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import { vi } from "vite-plus/test";
34
4import {5vi.mock("react-native", () => ({
5 CONNECTION_CATALOG_KEY,6 Platform: { OS: "ios" },
6 LEGACY_CONNECTIONS_KEY,7}));
7 makeCatalogStore,8
8 type SecureCatalogStorage,9vi.mock("expo-secure-store", () => ({
9} from "./catalog-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
11function makeStorage(initial: Readonly<Record<string, string>>) {18function makeStorage(initial: Readonly<Record<string, string>>) {
12 const values = new Map(Object.entries(initial));19 const values = new Map(Object.entries(initial));
13 const deleted: Array<string> = [];20 const deleted: Array<string> = [];
14 const storage: SecureCatalogStorage = {21 const storage = MobileSecureStorage.of({
15 getItem: (key) => Effect.sync(() => values.get(key) ?? null),22 getItem: (key) => Effect.sync(() => values.get(key) ?? null),
16 setItem: (key, value) =>23 setItem: (key, value) =>
17 Effect.sync(() => {24 Effect.sync(() => {
18 values.set(key, value);25 values.set(key, value);
19 }),26 }),
20 deleteItem: (key) =>27 removeItem: (key) =>
21 Effect.sync(() => {28 Effect.sync(() => {
22 deleted.push(key);29 deleted.push(key);
23 values.delete(key);30 values.delete(key);
24 }),31 }),
25 };32 });
26 return { deleted, storage, values };33 return { deleted, storage, values };
27}34}
2835
...@@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => {...@@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => {
32 const memory = makeStorage({39 const memory = makeStorage({
33 [CONNECTION_CATALOG_KEY]: "{not-json",40 [CONNECTION_CATALOG_KEY]: "{not-json",
34 });41 });
35 const catalog = yield* makeCatalogStore(memory.storage);42 const catalog = yield* make().pipe(
43 Effect.provideService(MobileSecureStorage, memory.storage),
44 );
3645
37 expect((yield* catalog.read).targets).toEqual([]);46 expect((yield* catalog.read).targets).toEqual([]);
38 expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]);47 expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]);
...@@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => {...@@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => {
44 const memory = makeStorage({53 const memory = makeStorage({
45 [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }),54 [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }),
46 });55 });
47 const catalog = yield* makeCatalogStore(memory.storage);56 const catalog = yield* make().pipe(
57 Effect.provideService(MobileSecureStorage, memory.storage),
58 );
4859
49 expect((yield* catalog.read).targets).toEqual([]);60 expect((yield* catalog.read).targets).toEqual([]);
50 expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]);61 expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]);
...@@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => {...@@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => {
71 ],82 ],
72 }),83 }),
73 });84 });
74 const catalog = yield* makeCatalogStore(memory.storage);85 const catalog = yield* make().pipe(
86 Effect.provideService(MobileSecureStorage, memory.storage),
87 );
7588
76 expect((yield* catalog.read).targets).toHaveLength(1);89 expect((yield* catalog.read).targets).toHaveLength(1);
77 expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]);90 expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]);
apps/mobile/src/connection/storage.ts+2-299
...@@ -2,7 +2,6 @@ import {...@@ -2,7 +2,6 @@ import {
2 ConnectionPersistenceError,2 ConnectionPersistenceError,
3 ConnectionRegistrationStore,3 ConnectionRegistrationStore,
4 ConnectionTargetStore,4 ConnectionTargetStore,
5 EnvironmentCacheStore,
6 registerConnectionInCatalog,5 registerConnectionInCatalog,
7 removeConnectionFromCatalog,6 removeConnectionFromCatalog,
8 removeCatalogValue,7 removeCatalogValue,
...@@ -14,111 +13,11 @@ import {...@@ -14,111 +13,11 @@ import {
14 CredentialStore,13 CredentialStore,
15 ProfileStore,14 ProfileStore,
16} from "@t3tools/client-runtime/connection";15} from "@t3tools/client-runtime/connection";
17import {
18 EnvironmentId,
19 OrchestrationShellSnapshot,
20 OrchestrationThreadDetailSnapshot,
21 ThreadId,
22} from "@t3tools/contracts";
23import * as Context from "effect/Context";16import * as Context from "effect/Context";
24import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
25import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";19import * as Option from "effect/Option";
27import * as Schema from "effect/Schema";20import * as CatalogStore from "./catalog-store";
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});
12221
123function targetPersistenceError(22function targetPersistenceError(
124 operation: "list-targets" | "register-connection" | "remove-connection",23 operation: "list-targets" | "register-connection" | "remove-connection",
...@@ -130,59 +29,9 @@ function targetPersistenceError(...@@ -130,59 +29,9 @@ function targetPersistenceError(
130 });29 });
131}30}
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
183export const connectionStorageLayer = Layer.effectContext(32export const connectionStorageLayer = Layer.effectContext(
184 Effect.gen(function* () {33 Effect.gen(function* () {
185 const catalog = yield* makeCatalogStore(secureCatalogStorage);34 const catalog = yield* CatalogStore.make();
18635
187 const targetStore = ConnectionTargetStore.of({36 const targetStore = ConnectionTargetStore.of({
188 list: catalog.read.pipe(37 list: catalog.read.pipe(
...@@ -279,157 +128,11 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -279,157 +128,11 @@ export const connectionStorageLayer = Layer.effectContext(
279 ),128 ),
280 })),129 })),
281 });130 });
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
427 return Context.make(ConnectionTargetStore, targetStore).pipe(131 return Context.make(ConnectionTargetStore, targetStore).pipe(
428 Context.add(ConnectionRegistrationStore, registrationStore),132 Context.add(ConnectionRegistrationStore, registrationStore),
429 Context.add(ProfileStore.ConnectionProfileStore, profileStore),133 Context.add(ProfileStore.ConnectionProfileStore, profileStore),
430 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),134 Context.add(CredentialStore.ConnectionCredentialStore, credentialStore),
431 Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),135 Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore),
432 Context.add(EnvironmentCacheStore, cacheStore),
433 );136 );
434 }),137 }),
435);138);
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+85-18
...@@ -7,21 +7,30 @@ import * as Layer from "effect/Layer";...@@ -7,21 +7,30 @@ import * as Layer from "effect/Layer";
7import { HttpClient } from "effect/unstable/http";7import { HttpClient } from "effect/unstable/http";
88
9import type { SavedRemoteConnection } from "../../lib/connection";9import type { SavedRemoteConnection } from "../../lib/connection";
10import { savePreferencesPatch } from "../../lib/storage";10import { MobileStorage } from "../../persistence/mobile-storage";
11import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";11import {
12 CloudEnvironmentLinkError,
13 linkEnvironmentToCloudWithPreference,
14} from "../cloud/linkEnvironment";
12import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";15import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences";
13import { refreshAgentAwarenessRegistration } from "./remoteRegistration";16import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";
1417
15vi.mock("../../lib/storage", () => ({18vi.mock("expo-secure-store", () => ({
16 savePreferencesPatch: vi.fn(() => Promise.resolve()),19 deleteItemAsync: vi.fn(),
20 getItemAsync: vi.fn(),
21 setItemAsync: vi.fn(),
22}));
23
24vi.mock("react-native", () => ({
25 Platform: { OS: "ios" },
17}));26}));
1827
19vi.mock("../cloud/linkEnvironment", () => ({28vi.mock("../cloud/linkEnvironment", () => ({
20 linkEnvironmentToCloud: vi.fn(() => Effect.void),29 linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void),
21}));30}));
2231
23vi.mock("./remoteRegistration", () => ({32vi.mock("./remoteRegistration", () => ({
24 refreshAgentAwarenessRegistration: vi.fn(() => Effect.void),33 updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void),
25}));34}));
2635
27const connection: SavedRemoteConnection = {36const connection: SavedRemoteConnection = {
...@@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll(...@@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll(
40 HttpClient.HttpClient,49 HttpClient.HttpClient,
41 HttpClient.make(() => Effect.die("unexpected HTTP request")),50 HttpClient.make(() => Effect.die("unexpected HTTP request")),
42 ),51 ),
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 ),
43);65);
4466
45describe("liveActivityPreferences", () => {67describe("liveActivityPreferences", () => {
...@@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => {...@@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => {
51 Effect.gen(function* () {73 Effect.gen(function* () {
52 yield* setLiveActivityUpdatesEnabled({74 yield* setLiveActivityUpdatesEnabled({
53 enabled: false,75 enabled: false,
76 previousEnabled: true,
54 clerkToken: "clerk-token",77 clerkToken: "clerk-token",
55 connections: [connection],78 connections: [connection],
56 });79 });
5780
58 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });81 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
59 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);82 liveActivitiesEnabled: false,
60 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({83 });
84 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({
61 clerkToken: "clerk-token",85 clerkToken: "clerk-token",
62 connection,86 connection,
87 liveActivitiesEnabled: false,
63 });88 });
64 }).pipe(Effect.provide(testLayer)),89 }).pipe(Effect.provide(testLayer)),
65 );90 );
...@@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => {...@@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => {
68 Effect.gen(function* () {93 Effect.gen(function* () {
69 yield* setLiveActivityUpdatesEnabled({94 yield* setLiveActivityUpdatesEnabled({
70 enabled: true,95 enabled: true,
96 previousEnabled: false,
71 clerkToken: "clerk-token",97 clerkToken: "clerk-token",
72 connections: [connection],98 connections: [connection],
73 });99 });
74100
75 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true });101 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
76 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);102 liveActivitiesEnabled: true,
77 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({103 });
104 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({
78 clerkToken: "clerk-token",105 clerkToken: "clerk-token",
79 connection,106 connection,
107 liveActivitiesEnabled: true,
80 });108 });
81 }).pipe(Effect.provide(testLayer)),109 }).pipe(Effect.provide(testLayer)),
82 );110 );
...@@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => {...@@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => {
85 Effect.gen(function* () {113 Effect.gen(function* () {
86 yield* setLiveActivityUpdatesEnabled({114 yield* setLiveActivityUpdatesEnabled({
87 enabled: false,115 enabled: false,
116 previousEnabled: true,
88 clerkToken: null,117 clerkToken: null,
89 connections: [connection],118 connections: [connection],
90 });119 });
91120
92 expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false });121 expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({
93 expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1);122 liveActivitiesEnabled: false,
94 expect(linkEnvironmentToCloud).not.toHaveBeenCalled();123 });
124 expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled();
95 }).pipe(Effect.provide(testLayer)),125 }).pipe(Effect.provide(testLayer)),
96 );126 );
97127
...@@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => {...@@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => {
104 return Effect.gen(function* () {134 return Effect.gen(function* () {
105 yield* setLiveActivityUpdatesEnabled({135 yield* setLiveActivityUpdatesEnabled({
106 enabled: true,136 enabled: true,
137 previousEnabled: false,
107 clerkToken: "clerk-token",138 clerkToken: "clerk-token",
108 connections: [connection, managedConnection],139 connections: [connection, managedConnection],
109 });140 });
110141
111 expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1);142 expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1);
112 expect(linkEnvironmentToCloud).toHaveBeenCalledWith({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, {
113 clerkToken: "clerk-token",179 clerkToken: "clerk-token",
114 connection,180 connection,
181 liveActivitiesEnabled: true,
115 });182 });
116 }).pipe(Effect.provide(testLayer));183 }).pipe(Effect.provide(testLayer));
117 });184 });
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+65-39
...@@ -1,47 +1,73 @@...@@ -1,47 +1,73 @@
1import * as Effect from "effect/Effect";1import * 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
6import type { SavedRemoteConnection } from "../../lib/connection";3import type { SavedRemoteConnection } from "../../lib/connection";
7import { savePreferencesPatch } from "../../lib/storage";4import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment";
8import { linkEnvironmentToCloud } from "../cloud/linkEnvironment";5import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration";
9import { refreshAgentAwarenessRegistration } from "./remoteRegistration";6
107export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")(
11export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass<LiveActivityPreferenceSaveError>()(8 function* (input: {
12 "LiveActivityPreferenceSaveError",9 readonly enabled: boolean;
13 {10 readonly previousEnabled: boolean;
14 enabled: Schema.Boolean,11 readonly clerkToken: string | null;
15 cause: Schema.Defect(),12 readonly connections: ReadonlyArray<SavedRemoteConnection>;
16 },13 }) {
17) {14 const linkedConnections = input.connections.filter(
18 override get message(): string {15 (connection) => connection.bearerToken !== null,
19 return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`;16 );
20 }17
21}18 const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) {
2219 yield* updateAgentAwarenessRegistrationPreferences({
23export function setLiveActivityUpdatesEnabled(input: {20 liveActivitiesEnabled: enabled,
24 readonly enabled: boolean;21 });
25 readonly clerkToken: string | null;22
26 readonly connections: ReadonlyArray<SavedRemoteConnection>;23 const clerkToken = input.clerkToken;
27}): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient> {24 if (!clerkToken) return;
28 return Effect.gen(function* () {25
29 yield* Effect.tryPromise({26 yield* Effect.forEach(
30 try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }),27 linkedConnections,
31 catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }),28 (connection) =>
29 linkEnvironmentToCloudWithPreference({
30 clerkToken,
31 connection,
32 liveActivitiesEnabled: enabled,
33 }),
34 { concurrency: "unbounded" },
35 );
32 });36 });
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;50 yield* Effect.forEach(
37 if (!clerkToken) {51 linkedConnections,
38 return;52 (connection) =>
39 }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(69 yield* updateRelayPreference(input.enabled).pipe(
42 input.connections.filter((connection) => connection.bearerToken !== null),70 Effect.onError(() => restoreRelayPreference()),
43 (connection) => linkEnvironmentToCloud({ clerkToken, connection }),
44 { concurrency: "unbounded" },
45 );71 );
46 });72 },
47}73);
apps/mobile/src/features/agent-awareness/registrationPayload.ts+1-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";1import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay";
22
3import type { Preferences } from "../../lib/storage";3import type { Preferences } from "../../persistence/mobile-preferences";
44
5// Development builds are Xcode-signed and receive sandbox APNs tokens;5// Development builds are Xcode-signed and receive sandbox APNs tokens;
6// preview and production builds are distribution-signed and use production6// 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 {...@@ -21,12 +21,13 @@ import {
21 loadAgentAwarenessRegistrationRecord,21 loadAgentAwarenessRegistrationRecord,
22 loadOrCreateAgentAwarenessDeviceId,22 loadOrCreateAgentAwarenessDeviceId,
23 saveAgentAwarenessRegistrationRecord,23 saveAgentAwarenessRegistrationRecord,
24} from "../../lib/storage";24} from "../../persistence/imperative";
25import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";25import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
26import {26import {
27 AgentAwarenessOperationError,27 AgentAwarenessOperationError,
28 __resetAgentAwarenessRemoteRegistrationForTest,28 __resetAgentAwarenessRemoteRegistrationForTest,
29 getAgentAwarenessRegistrationStatus,29 getAgentAwarenessRegistrationStatus,
30 mergeAgentAwarenessRegistrationPreferences,
30 refreshActiveLiveActivityRemoteRegistration,31 refreshActiveLiveActivityRemoteRegistration,
31 refreshAgentAwarenessRegistration,32 refreshAgentAwarenessRegistration,
32 normalizeAgentAwarenessRelayBaseUrl,33 normalizeAgentAwarenessRelayBaseUrl,
...@@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({...@@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({
142 },143 },
143}));144}));
144145
145vi.mock("../../lib/storage", () => ({146vi.mock("../../persistence/imperative", () => ({
146 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),147 loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
147 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),148 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),
148 loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })),149 loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })),
...@@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => {...@@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => {
324 expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull();325 expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull();
325 });326 });
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
327 it.effect("registers at most one listener while a Live Activity push token is pending", () => {337 it.effect("registers at most one listener while a Live Activity push token is pending", () => {
328 registerAgentAwarenessConnection(savedConnection());338 registerAgentAwarenessConnection(savedConnection());
329 const addPushTokenListener = vi.fn();339 const addPushTokenListener = vi.fn();
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+34-3
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
2020
21import type { SavedRemoteConnection } from "../../lib/connection";21import type { SavedRemoteConnection } from "../../lib/connection";
22import { runtime } from "../../lib/runtime";22import { runtime } from "../../lib/runtime";
23import type { Preferences } from "../../persistence/mobile-preferences";
23import {24import {
24 clearAgentAwarenessRegistrationRecord,25 clearAgentAwarenessRegistrationRecord,
25 loadAgentAwarenessDeviceId,26 loadAgentAwarenessDeviceId,
...@@ -27,7 +28,7 @@ import {...@@ -27,7 +28,7 @@ import {
27 loadOrCreateAgentAwarenessDeviceId,28 loadOrCreateAgentAwarenessDeviceId,
28 loadPreferences,29 loadPreferences,
29 saveAgentAwarenessRegistrationRecord,30 saveAgentAwarenessRegistrationRecord,
30} from "../../lib/storage";31} from "../../persistence/imperative";
31import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";32import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity";
32import { resolveCloudPublicConfig } from "../cloud/publicConfig";33import { resolveCloudPublicConfig } from "../cloud/publicConfig";
33import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";34import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload";
...@@ -124,6 +125,17 @@ interface DeviceRegistrationInput {...@@ -124,6 +125,17 @@ interface DeviceRegistrationInput {
124 readonly observedPushToken?: string;125 readonly observedPushToken?: string;
125}126}
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
127export function normalizeAgentAwarenessRelayBaseUrl(139export function normalizeAgentAwarenessRelayBaseUrl(
128 value: string | null | undefined,140 value: string | null | undefined,
129): string | null {141): string | null {
...@@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri...@@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri
655}667}
656668
657function registerDevice(669function registerDevice(
658 input: DeviceRegistrationInput = {},670 input: RegisterDeviceInput = {},
659 expectedGeneration = deviceRegistrationGeneration,671 expectedGeneration = deviceRegistrationGeneration,
660): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {672): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> {
661 return Effect.gen(function* () {673 return Effect.gen(function* () {
...@@ -665,7 +677,7 @@ function registerDevice(...@@ -665,7 +677,7 @@ function registerDevice(
665 }677 }
666678
667 logRegistrationDebug("device registration loading local state", { expectedGeneration });679 logRegistrationDebug("device registration loading local state", { expectedGeneration });
668 const [deviceId, preferences] = yield* Effect.all([680 const [deviceId, storedPreferences] = yield* Effect.all([
669 Effect.tryPromise({681 Effect.tryPromise({
670 try: () => loadOrCreateAgentAwarenessDeviceId(),682 try: () => loadOrCreateAgentAwarenessDeviceId(),
671 catch: (cause) =>683 catch: (cause) =>
...@@ -683,6 +695,10 @@ function registerDevice(...@@ -683,6 +695,10 @@ function registerDevice(
683 }),695 }),
684 }),696 }),
685 ]);697 ]);
698 const preferences = mergeAgentAwarenessRegistrationPreferences(
699 storedPreferences,
700 input.preferencesOverride,
701 );
686 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);702 const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken);
687 logRegistrationDebug("device registration local state ready", {703 logRegistrationDebug("device registration local state ready", {
688 expectedGeneration,704 expectedGeneration,
...@@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect<...@@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect<
820 );836 );
821}837}
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
823export function __resetAgentAwarenessRemoteRegistrationForTest(): void {854export function __resetAgentAwarenessRemoteRegistrationForTest(): void {
824 environmentConnections.clear();855 environmentConnections.clear();
825 pushTokenSubscription?.remove();856 pushTokenSubscription?.remove();
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts+2-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { loadPreferences, updatePreferences } from "../../lib/storage";1import { loadPreferences, updatePreferences } from "../../persistence/imperative";
22
3// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports3// Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports
4// the request signal) never pulls lib/storage — expo-secure-store — into its4// the request signal) never pulls the persistence adapter into its
5// module graph; that breaks CloudAuthProvider.test.ts suite loading.5// module graph; that breaks CloudAuthProvider.test.ts suite loading.
66
7/** Whether the account chose "Don't show this again". */7/** 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";...@@ -7,10 +7,13 @@ import { RelayMobileClientId } from "@t3tools/contracts/relay";
7import { ManagedRelay } from "@t3tools/client-runtime/relay";7import { ManagedRelay } from "@t3tools/client-runtime/relay";
8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";8import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc";
9import { HttpClient } from "effect/unstable/http";9import { HttpClient } from "effect/unstable/http";
10import { MobilePreferencesStore } from "../../persistence/mobile-preferences";
11import { MobileStorage } from "../../persistence/mobile-storage";
1012
11import {13import {
12 cloudEnvironmentsPendingStatus,14 cloudEnvironmentsPendingStatus,
13 linkEnvironmentToCloud,15 linkEnvironmentToCloud,
16 linkEnvironmentToCloudWithPreference,
14 connectCloudEnvironment,17 connectCloudEnvironment,
15 listCloudEnvironments,18 listCloudEnvironments,
16 listCloudEnvironmentsWithStatus,19 listCloudEnvironmentsWithStatus,
...@@ -36,11 +39,14 @@ vi.mock("react-native", () => ({...@@ -36,11 +39,14 @@ vi.mock("react-native", () => ({
36 },39 },
37}));40}));
3841
39vi.mock("../../lib/storage", () => ({42vi.mock("expo-secure-store", () => ({
40 loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")),43 deleteItemAsync: vi.fn(),
41 loadPreferences: vi.fn(() => Promise.resolve({})),44 getItemAsync: vi.fn(),
45 setItemAsync: vi.fn(),
42}));46}));
4347
48const loadPreferences = vi.fn(() => Effect.succeed({}));
49
44const savedConnection = {50const savedConnection = {
45 environmentId: EnvironmentId.make("env-1"),51 environmentId: EnvironmentId.make("env-1"),
46 environmentLabel: "Desktop",52 environmentLabel: "Desktop",
...@@ -69,6 +75,27 @@ function cloudClientLayer() {...@@ -69,6 +75,27 @@ function cloudClientLayer() {
69 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));75 const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init));
70 return Layer.mergeAll(76 return Layer.mergeAll(
71 httpClientLayer,77 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 ),
72 ManagedRelay.layer({99 ManagedRelay.layer({
73 relayUrl: "https://relay.example.test",100 relayUrl: "https://relay.example.test",
74 clientId: RelayMobileClientId,101 clientId: RelayMobileClientId,
...@@ -80,7 +107,11 @@ const withCloudServices = <A, E>(...@@ -80,7 +107,11 @@ const withCloudServices = <A, E>(
80 effect: Effect.Effect<107 effect: Effect.Effect<
81 A,108 A,
82 E,109 E,
83 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner110 | HttpClient.HttpClient
111 | ManagedRelay.ManagedRelayClient
112 | ManagedRelay.ManagedRelayDpopSigner
113 | MobilePreferencesStore
114 | MobileStorage
84 >,115 >,
85) => effect.pipe(Effect.provide(cloudClientLayer()));116) => effect.pipe(Effect.provide(cloudClientLayer()));
86117
...@@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => {...@@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => {
146 beforeEach(() => {177 beforeEach(() => {
147 vi.restoreAllMocks();178 vi.restoreAllMocks();
148 createProofMock.mockClear();179 createProofMock.mockClear();
180 loadPreferences.mockClear();
149 });181 });
150182
151 it("normalizes configured relay base URLs before building DPoP-bound requests", () => {183 it("normalizes configured relay base URLs before building DPoP-bound requests", () => {
...@@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => {...@@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => {
705737
706 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>738 it.effect("preserves disabled Live Activity preferences when linking an environment", () =>
707 Effect.gen(function* () {739 Effect.gen(function* () {
708 const storage = yield* Effect.promise(() => import("../../lib/storage"));740 loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false }));
709 vi.mocked(storage.loadPreferences).mockResolvedValueOnce({
710 liveActivitiesEnabled: false,
711 });
712 const bodies: Array<unknown> = [];741 const bodies: Array<unknown> = [];
713 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {742 const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => {
714 if (init?.body) {743 if (init?.body) {
...@@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => {...@@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => {
761 }),790 }),
762 );791 );
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
764 it.effect(832 it.effect(
765 "does not persist cloud connect bootstrap credentials in saved connection records",833 "does not persist cloud connect bootstrap credentials in saved connection records",
766 () =>834 () =>
apps/mobile/src/features/cloud/linkEnvironment.ts+49-22
...@@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";...@@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc";
3030
31import { authClientMetadata } from "../../lib/authClientMetadata";31import { authClientMetadata } from "../../lib/authClientMetadata";
32import type { SavedRemoteConnection } from "../../lib/connection";32import 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";
34import { resolveCloudPublicConfig } from "./publicConfig";35import { resolveCloudPublicConfig } from "./publicConfig";
3536
36const RELAY_STATUS_AND_CONNECT_SCOPES = [37const RELAY_STATUS_AND_CONNECT_SCOPES = [
...@@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: {...@@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: {
256 return Effect.void;257 return Effect.void;
257}258}
258259
259export function linkEnvironmentToCloud(input: {260interface LinkEnvironmentToCloudInput {
260 readonly connection: SavedRemoteConnection;261 readonly connection: SavedRemoteConnection;
261 readonly clerkToken: string;262 readonly clerkToken: string;
262}): Effect.Effect<263}
263 void,264
264 CloudEnvironmentLinkError,265type LinkEnvironmentToCloudRequirements =
265 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient266 | HttpClient.HttpClient
266> {267 | ManagedRelay.ManagedRelayClient
268 | MobileStorage.MobileStorage;
269
270export function linkEnvironmentToCloudWithPreference(
271 input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean },
272): Effect.Effect<void, CloudEnvironmentLinkError, LinkEnvironmentToCloudRequirements> {
267 return Effect.gen(function* () {273 return Effect.gen(function* () {
268 if (!input.connection.bearerToken) {274 if (!input.connection.bearerToken) {
269 return yield* new CloudEnvironmentLinkError({275 return yield* new CloudEnvironmentLinkError({
...@@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: {...@@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: {
273 const localBearerToken = input.connection.bearerToken;279 const localBearerToken = input.connection.bearerToken;
274 const relayUrl = yield* requireRelayUrl();280 const relayUrl = yield* requireRelayUrl();
275 const relayClient = yield* ManagedRelay.ManagedRelayClient;281 const relayClient = yield* ManagedRelay.ManagedRelayClient;
276 const deviceId = yield* Effect.tryPromise({282 const storage = yield* MobileStorage.MobileStorage;
277 try: () => loadOrCreateAgentAwarenessDeviceId(),283 const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
278 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),284 Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")),
279 });285 );
280 const preferences = yield* Effect.tryPromise({286 const liveActivitiesEnabled = input.liveActivitiesEnabled;
281 try: () => loadPreferences(),
282 catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."),
283 });
284 const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false;
285 const challenge = yield* relayClient287 const challenge = yield* relayClient
286 .createEnvironmentLinkChallenge({288 .createEnvironmentLinkChallenge({
287 clerkToken: input.clerkToken,289 clerkToken: input.clerkToken,
...@@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: {...@@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: {
350 });352 });
351}353}
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
353export function listCloudEnvironments(input: {374export function listCloudEnvironments(input: {
354 readonly clerkToken: string;375 readonly clerkToken: string;
355}): Effect.Effect<376}): Effect.Effect<
...@@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: {...@@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: {
460481
461const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(482const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")(
462 function* () {483 function* () {
463 return yield* Effect.tryPromise({484 const storage = yield* MobileStorage.MobileStorage;
464 try: () => loadOrCreateAgentAwarenessDeviceId(),485 return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe(
465 catch: cloudEnvironmentLinkError("Could not load the mobile device id."),486 Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")),
466 });487 );
467 },488 },
468);489);
469490
...@@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: {...@@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: {
557}): Effect.Effect<578}): Effect.Effect<
558 SavedRemoteConnection,579 SavedRemoteConnection,
559 CloudEnvironmentLinkError,580 CloudEnvironmentLinkError,
560 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner581 | HttpClient.HttpClient
582 | ManagedRelay.ManagedRelayClient
583 | ManagedRelay.ManagedRelayDpopSigner
584 | MobileStorage.MobileStorage
561> {585> {
562 return connectRelayManagedEnvironment({586 return connectRelayManagedEnvironment({
563 clerkToken: input.clerkToken,587 clerkToken: input.clerkToken,
...@@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: {...@@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: {
572}): Effect.Effect<596}): Effect.Effect<
573 SavedRemoteConnection,597 SavedRemoteConnection,
574 CloudEnvironmentLinkError,598 CloudEnvironmentLinkError,
575 HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner599 | HttpClient.HttpClient
600 | ManagedRelay.ManagedRelayClient
601 | ManagedRelay.ManagedRelayDpopSigner
602 | MobileStorage.MobileStorage
576> {603> {
577 return connectRelayManagedEnvironment({604 return connectRelayManagedEnvironment({
578 clerkToken: input.clerkToken,605 clerkToken: input.clerkToken,
apps/mobile/src/features/home/HomeScreen.tsx+41-11
...@@ -12,6 +12,8 @@ import type {...@@ -12,6 +12,8 @@ import type {
12 SidebarProjectGroupingMode,12 SidebarProjectGroupingMode,
13 SidebarThreadSortOrder,13 SidebarThreadSortOrder,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { useAtomSet, useAtomValue } from "@effect/atom-react";
16import { AsyncResult } from "effect/unstable/reactivity";
15import { useCallback, useMemo, useRef, useState } from "react";17import { useCallback, useMemo, useRef, useState } from "react";
16import { ActivityIndicator, Platform, View } from "react-native";18import { ActivityIndicator, Platform, View } from "react-native";
17import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";19import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
...@@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState";...@@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState";
22import type { WorkspaceState } from "../../state/workspaceModel";24import type { WorkspaceState } from "../../state/workspaceModel";
23import type { SavedRemoteConnection } from "../../lib/connection";25import type { SavedRemoteConnection } from "../../lib/connection";
24import { scopedProjectKey } from "../../lib/scopedEntities";26import { scopedProjectKey } from "../../lib/scopedEntities";
27import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
25import type { PendingNewTask } from "../../state/use-pending-new-tasks";28import type { PendingNewTask } from "../../state/use-pending-new-tasks";
26import {29import {
27 PendingTaskListRow,30 PendingTaskListRow,
...@@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) {
154 const [groupDisplayStates, setGroupDisplayStates] = useState<157 const [groupDisplayStates, setGroupDisplayStates] = useState<
155 ReadonlyMap<string, HomeGroupDisplayState>158 ReadonlyMap<string, HomeGroupDisplayState>
156 >(() => new Map());159 >(() => new Map());
160 const preferencesResult = useAtomValue(mobilePreferencesAtom);
161 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
157 const openSwipeableRef = useRef<SwipeableMethods | null>(null);162 const openSwipeableRef = useRef<SwipeableMethods | null>(null);
158 const listRef = useRef<LegendListRef | null>(null);163 const listRef = useRef<LegendListRef | null>(null);
159 const insets = useSafeAreaInsets();164 const insets = useSafeAreaInsets();
160 const accentColor = useThemeColor("--color-icon-muted");165 const accentColor = useThemeColor("--color-icon-muted");
161166
162 const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => {167 const effectiveGroupDisplayStates = useMemo(() => {
163 setGroupDisplayStates((previous) => {168 const next = new Map(groupDisplayStates);
164 const next = new Map(previous);169 if (!AsyncResult.isSuccess(preferencesResult)) {
165 next.set(
166 key,
167 nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action),
168 );
169 return next;170 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
173 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {203 const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => {
174 if (openSwipeableRef.current !== methods) {204 if (openSwipeableRef.current !== methods) {
...@@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) {
219 () =>249 () =>
220 buildHomeListLayout({250 buildHomeListLayout({
221 groups: projectGroups,251 groups: projectGroups,
222 displayStates: groupDisplayStates,252 displayStates: effectiveGroupDisplayStates,
223 showAllThreads: hasSearchQuery,253 showAllThreads: hasSearchQuery,
224 }),254 }),
225 [projectGroups, groupDisplayStates, hasSearchQuery],255 [projectGroups, effectiveGroupDisplayStates, hasSearchQuery],
226 );256 );
227257
228 const projectCwdByKey = useMemo(() => {258 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 @@...@@ -1,4 +1,5 @@
1import { useAuth, useUser } from "@clerk/expo";1import { useAuth, useUser } from "@clerk/expo";
2import { useAtomSet, useAtomValue } from "@effect/atom-react";
2import Constants from "expo-constants";3import Constants from "expo-constants";
3import * as Notifications from "expo-notifications";4import * as Notifications from "expo-notifications";
4import * as Updates from "expo-updates";5import * as Updates from "expo-updates";
...@@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native";...@@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native";
6import { NativeStackScreenOptions } from "../../native/StackHeader";7import { NativeStackScreenOptions } from "../../native/StackHeader";
7import { SymbolView } from "expo-symbols";8import { SymbolView } from "expo-symbols";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import { AsyncResult } from "effect/unstable/reactivity";
9import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";11import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
10import { Alert, Linking, Platform, ScrollView, View } from "react-native";12import { Alert, Linking, Platform, ScrollView, View } from "react-native";
11import { useSafeAreaInsets } from "react-native-safe-area-context";13import { useSafeAreaInsets } from "react-native-safe-area-context";
...@@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu...@@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu
31import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";33import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items";
32import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";34import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
33import { runtime } from "../../lib/runtime";35import { runtime } from "../../lib/runtime";
34import { loadPreferences } from "../../lib/storage";
35import { useThemeColor } from "../../lib/useThemeColor";36import { useThemeColor } from "../../lib/useThemeColor";
37import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
36import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";38import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
37import { SettingsRow } from "./components/SettingsRow";39import { SettingsRow } from "./components/SettingsRow";
38import { SettingsSection } from "./components/SettingsSection";40import { SettingsSection } from "./components/SettingsSection";
...@@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() {...@@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() {
122}124}
123125
124function ConfiguredSettingsRouteScreen() {126function ConfiguredSettingsRouteScreen() {
127 const preferencesResult = useAtomValue(mobilePreferencesAtom);
128 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
125 const insets = useSafeAreaInsets();129 const insets = useSafeAreaInsets();
126 const navigation = useNavigation();130 const navigation = useNavigation();
127 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();131 const { expand: expandClerkSheet } = useClerkSettingsSheetDetent();
...@@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() {...@@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() {
131 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");135 const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking");
132 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");136 const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking");
133 const deviceRegistered = useDeviceRegistered();137 const deviceRegistered = useDeviceRegistered();
138 const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult)
139 ? preferencesResult.value.liveActivitiesEnabled !== false
140 : true;
134141
135 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);142 const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]);
136 const environmentCount = connections.length;143 const environmentCount = connections.length;
...@@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() {...@@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() {
167 setLiveActivityStatus("signed-out");174 setLiveActivityStatus("signed-out");
168 return;175 return;
169 }176 }
170 void (async () => {177 if (!AsyncResult.isSuccess(preferencesResult)) {
171 const result = await settlePromise(() => loadPreferences());178 if (AsyncResult.isFailure(preferencesResult)) {
172 if (result._tag === "Failure") {179 reportAtomCommandResult(preferencesResult, { label: "live activity preference load" });
173 reportAtomCommandResult(result, { label: "live activity preference load" });
174 setLiveActivityStatus("enabled");180 setLiveActivityStatus("enabled");
175 return;181 } else {
182 setLiveActivityStatus("checking");
176 }183 }
177 setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled");184 return;
178 })();185 }
179 }, [isLoaded, isSignedIn]);186 setLiveActivityStatus(
187 preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled",
188 );
189 }, [isLoaded, isSignedIn, preferencesResult]);
180190
181 const requestNotifications = useCallback(async () => {191 const requestNotifications = useCallback(async () => {
182 const result = await settleAsyncResult(() =>192 const result = await settleAsyncResult(() =>
...@@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() {
279 runtime.runPromiseExit(289 runtime.runPromiseExit(
280 setLiveActivityUpdatesEnabled({290 setLiveActivityUpdatesEnabled({
281 enabled: true,291 enabled: true,
292 previousEnabled: liveActivitiesPreferenceEnabled,
282 clerkToken: tokenResult.value,293 clerkToken: tokenResult.value,
283 connections,294 connections,
284 }),295 }),
...@@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() {...@@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() {
296 return;307 return;
297 }308 }
298309
310 savePreferences({ liveActivitiesEnabled: true });
299 refreshManagedRelayEnvironments();311 refreshManagedRelayEnvironments();
300 setLiveActivityStatus("enabled");312 setLiveActivityStatus("enabled");
301 // The environment link can succeed while this device's own registration313 // The environment link can succeed while this device's own registration
...@@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() {...@@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() {
314 "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.",326 "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.",
315 );327 );
316 }328 }
317 }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]);329 }, [
330 connections,
331 environmentCount,
332 getToken,
333 isSignedIn,
334 liveActivitiesPreferenceEnabled,
335 promptSignIn,
336 savePreferences,
337 ]);
318338
319 const handleDeviceNotificationsChange = useCallback(339 const handleDeviceNotificationsChange = useCallback(
320 (enabled: boolean) => {340 (enabled: boolean) => {
...@@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() {...@@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() {
358 runtime.runPromiseExit(378 runtime.runPromiseExit(
359 setLiveActivityUpdatesEnabled({379 setLiveActivityUpdatesEnabled({
360 enabled: false,380 enabled: false,
381 previousEnabled: liveActivitiesPreferenceEnabled,
361 clerkToken: token,382 clerkToken: token,
362 connections,383 connections,
363 }),384 }),
364 ),385 ),
365 );386 );
366 if (updateResult._tag === "Failure") {387 if (updateResult._tag === "Failure") {
388 setLiveActivityStatus("enabled");
367 reportAtomCommandResult(updateResult, {389 reportAtomCommandResult(updateResult, {
368 label: "live activity disable",390 label: "live activity disable",
369 });391 });
370 return;392 return;
371 }393 }
394 savePreferences({ liveActivitiesEnabled: false });
372 refreshManagedRelayEnvironments();395 refreshManagedRelayEnvironments();
373 })();396 })();
374 return;397 return;
...@@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() {...@@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() {
381404
382 void linkEnvironments();405 void linkEnvironments();
383 },406 },
384 [connections, getToken, isSignedIn, linkEnvironments, promptSignIn],407 [
408 connections,
409 getToken,
410 isSignedIn,
411 linkEnvironments,
412 liveActivitiesPreferenceEnabled,
413 promptSignIn,
414 savePreferences,
415 ],
385 );416 );
386417
387 const openAccount = useCallback(() => {418 const openAccount = useCallback(() => {
...@@ -488,6 +519,7 @@ function AppSettingsSection() {...@@ -488,6 +519,7 @@ function AppSettingsSection() {
488519
489 return (520 return (
490 <SettingsSection title="App">521 <SettingsSection title="App">
522 <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" />
491 <View className="flex-row items-center gap-4 p-4">523 <View className="flex-row items-center gap-4 p-4">
492 <SymbolView524 <SymbolView
493 name="info.circle"525 name="info.circle"
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+24-56
...@@ -1,12 +1,7 @@...@@ -1,12 +1,7 @@
1import {1import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react";
2 createContext,2
3 useCallback,3import { useAtomSet, useAtomValue } from "@effect/atom-react";
4 useContext,4import { AsyncResult } from "effect/unstable/reactivity";
5 useEffect,
6 useMemo,
7 useState,
8 type ReactNode,
9} from "react";
105
11import { Uniwind } from "uniwind";6import { Uniwind } from "uniwind";
127
...@@ -17,7 +12,7 @@ import {...@@ -17,7 +12,7 @@ import {
17 type AppearancePreferences,12 type AppearancePreferences,
18 type ResolvedAppearance,13 type ResolvedAppearance,
19} from "../../../lib/appearancePreferences";14} from "../../../lib/appearancePreferences";
20import { loadPreferences, savePreferencesPatch } from "../../../lib/storage";15import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences";
21import { cacheTerminalFontSize } from "../../terminal/terminalUiState";16import { cacheTerminalFontSize } from "../../terminal/terminalUiState";
2217
23interface AppearancePreferencesContextValue {18interface AppearancePreferencesContextValue {
...@@ -52,55 +47,28 @@ function applyTextScaleVariables(baseFontSize: number) {...@@ -52,55 +47,28 @@ function applyTextScaleVariables(baseFontSize: number) {
52}47}
5348
54export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {49export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) {
55 const [preferences, setPreferences] = useState<AppearancePreferences>(() =>50 const preferencesResult = useAtomValue(mobilePreferencesAtom);
56 resolveAppearancePreferences(null),51 const savePreferences = useAtomSet(updateMobilePreferencesAtom);
52 const preferences = useMemo(
53 () =>
54 resolveAppearancePreferences(
55 AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null,
56 ),
57 [preferencesResult],
57 );58 );
58 const [isReady, setIsReady] = useState(false);59 const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting;
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 }, []);
8660
87 useEffect(() => {61 useEffect(() => {
88 applyTextScaleVariables(preferences.baseFontSize);62 applyTextScaleVariables(preferences.baseFontSize);
89 }, [preferences.baseFontSize]);63 cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize);
9064 }, [preferences]);
91 const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => {65
92 setPreferences((current) => {66 const updatePreferences = useCallback(
93 const next = resolveAppearancePreferences({ ...current, ...patch });67 (patch: Partial<AppearancePreferences>) => {
94 cacheTerminalFontSize(resolveAppearance(next).terminalFontSize);68 savePreferences(patch);
95 void savePreferencesPatch({69 },
96 baseFontSize: next.baseFontSize,70 [savePreferences],
97 terminalFontSize: next.terminalFontSize,71 );
98 codeFontSize: next.codeFontSize,
99 codeWordBreak: next.codeWordBreak,
100 }).catch(() => undefined);
101 return next;
102 });
103 }, []);
10472
105 const setBaseFontSize = useCallback(73 const setBaseFontSize = useCallback(
106 (value: number) => {74 (value: number) => {
...@@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN...@@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN
150}118}
151119
152export function useAppearancePreferences(): AppearancePreferencesContextValue {120export function useAppearancePreferences(): AppearancePreferencesContextValue {
153 const context = useContext(AppearancePreferencesContext);121 const context = use(AppearancePreferencesContext);
154 if (!context) {122 if (!context) {
155 throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");123 throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider");
156 }124 }
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+5-1
...@@ -1 +1,5 @@...@@ -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";...@@ -8,6 +8,7 @@ import { cryptoLayer } from "../features/cloud/dpop";
8import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer";8import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer";
9import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";9import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
10import { tracingLayer } from "../features/observability/tracing";10import { tracingLayer } from "../features/observability/tracing";
11import * as Persistence from "../persistence/layer";
1112
12function configuredRelayUrl(): string {13function configuredRelayUrl(): string {
13 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";14 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";
...@@ -20,6 +21,7 @@ type RuntimeLayerSource =...@@ -20,6 +21,7 @@ type RuntimeLayerSource =
20 | typeof Socket.layerWebSocketConstructorGlobal21 | typeof Socket.layerWebSocketConstructorGlobal
21 | typeof cryptoLayer22 | typeof cryptoLayer
22 | typeof httpClientLayer23 | typeof httpClientLayer
24 | typeof Persistence.layer
23 | typeof tracingLayer;25 | typeof tracingLayer;
2426
25const runtimeLayer = Layer.merge(27const runtimeLayer = Layer.merge(
...@@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge(...@@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge(
29 Layer.provideMerge(cryptoLayer),31 Layer.provideMerge(cryptoLayer),
30 Layer.provideMerge(httpClientLayer),32 Layer.provideMerge(httpClientLayer),
31 Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),33 Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))),
34 Layer.provideMerge(Persistence.layer),
32);35);
3336
34export const runtime: ManagedRuntime.ManagedRuntime<37export 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";...@@ -3,34 +3,103 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
33
4const mocks = vi.hoisted(() => {4const mocks = vi.hoisted(() => {
5 const values = new Map<string, string>();5 const values = new Map<string, string>();
6 let preferencesJson: string | null = null;
7 let preferencesUpdatedAt = 0;
8 let loadPreferencesFails = false;
9 let savePreferencesFails = false;
6 return {10 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 },
8 getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)),28 getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)),
9 setItemAsync: vi.fn((key: string, value: string) => {29 setItemAsync: vi.fn((key: string, value: string) => {
10 values.set(key, value);30 values.set(key, value);
11 return Promise.resolve();31 return Promise.resolve();
12 }),32 }),
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 },
13 };70 };
14});71});
1572
16vi.mock("expo-secure-store", () => ({73vi.mock("expo-secure-store", () => ({
74 deleteItemAsync: mocks.deleteItemAsync,
17 getItemAsync: mocks.getItemAsync,75 getItemAsync: mocks.getItemAsync,
18 setItemAsync: mocks.setItemAsync,76 setItemAsync: mocks.setItemAsync,
19}));77}));
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
21vi.mock("react-native", () => ({91vi.mock("react-native", () => ({
22 Platform: {92 Platform: {
23 OS: "ios",93 OS: "ios",
24 },94 },
25}));95}));
2696
27vi.mock("./runtime", () => ({97import {
28 runtime: {98 loadPreferences,
29 runPromise: vi.fn(),99 loadSavedConnections,
30 },100 saveConnection,
31}));101 savePreferencesPatch,
32102} from "../persistence/imperative";
33import { loadSavedConnections, saveConnection } from "./storage";
34import { toStableSavedRemoteConnection } from "./connection";103import { toStableSavedRemoteConnection } from "./connection";
35104
36const managedConnection = {105const managedConnection = {
...@@ -100,4 +169,75 @@ describe("mobile connection storage", () => {...@@ -100,4 +169,75 @@ describe("mobile connection storage", () => {
100169
101 warn.mockRestore();170 warn.mockRestore();
102 });171 });
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 });
103});243});
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) => {...@@ -1030,10 +1030,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
1030 entry.result.outcome !== null &&1030 entry.result.outcome !== null &&
1031 "outcome" in entry.result.outcome &&1031 "outcome" in entry.result.outcome &&
1032 entry.result.outcome.outcome === "cancelled";1032 entry.result.outcome.outcome === "cancelled";
1033 yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel");1033 const cancelRequests = yield* waitForJsonLogMatch(
1034 const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse);1034 requestLogPath,
1035 assert.isTrue(requests.some((entry) => entry.method === "session/cancel"));1035 (entry) => entry.method === "session/cancel",
1036 assert.isTrue(requests.some(isCancelledApprovalResponse));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
1038 yield* adapter.stopSession(threadId);1044 yield* adapter.stopSession(threadId);
1039 }),1045 }),
apps/web/src/connection/storage.ts+118-1
...@@ -21,7 +21,9 @@ import {...@@ -21,7 +21,9 @@ import {
21 EnvironmentId,21 EnvironmentId,
22 OrchestrationShellSnapshot,22 OrchestrationShellSnapshot,
23 OrchestrationThreadDetailSnapshot,23 OrchestrationThreadDetailSnapshot,
24 ServerConfig,
24 ThreadId,25 ThreadId,
26 VcsListRefsResult,
25} from "@t3tools/contracts";27} from "@t3tools/contracts";
26import * as Context from "effect/Context";28import * as Context from "effect/Context";
27import * as Effect from "effect/Effect";29import * as Effect from "effect/Effect";
...@@ -32,10 +34,12 @@ import * as Schema from "effect/Schema";...@@ -32,10 +34,12 @@ import * as Schema from "effect/Schema";
32import * as Semaphore from "effect/Semaphore";34import * as Semaphore from "effect/Semaphore";
3335
34const DATABASE_NAME = "t3code:connection-runtime";36const DATABASE_NAME = "t3code:connection-runtime";
35const DATABASE_VERSION = 2;37const DATABASE_VERSION = 4;
36const CATALOG_STORE_NAME = "catalog";38const CATALOG_STORE_NAME = "catalog";
37const SHELL_STORE_NAME = "shell";39const SHELL_STORE_NAME = "shell";
38const THREAD_STORE_NAME = "thread";40const THREAD_STORE_NAME = "thread";
41const SERVER_CONFIG_STORE_NAME = "server-config";
42const VCS_REFS_STORE_NAME = "vcs-refs";
39const CATALOG_KEY = "document";43const CATALOG_KEY = "document";
40const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;44const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
4145
...@@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({...@@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({
55 snapshot: OrchestrationThreadDetailSnapshot,59 snapshot: OrchestrationThreadDetailSnapshot,
56});60});
57const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);61const 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);
58const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);75const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
59const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);76const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson);
60const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);77const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson);
...@@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot...@@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot
62const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);79const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson);
63const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);80const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson);
64const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson);81const 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
66function catalogError(operation: string, cause: unknown) {87function catalogError(operation: string, cause: unknown) {
67 return new ConnectionTransientError({88 return new ConnectionTransientError({
...@@ -80,6 +101,10 @@ function persistenceError(...@@ -80,6 +101,10 @@ function persistenceError(
80 | "load-thread"101 | "load-thread"
81 | "save-thread"102 | "save-thread"
82 | "remove-thread"103 | "remove-thread"
104 | "load-server-config"
105 | "save-server-config"
106 | "load-vcs-refs"
107 | "save-vcs-refs"
83 | "clear-environment",108 | "clear-environment",
84 cause: unknown,109 cause: unknown,
85) {110) {
...@@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* (...@@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* (
108 if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {133 if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) {
109 request.result.createObjectStore(THREAD_STORE_NAME);134 request.result.createObjectStore(THREAD_STORE_NAME);
110 }135 }
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 }
111 });142 });
112 request.addEventListener("error", () => {143 request.addEventListener("error", () => {
113 resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));144 resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error")));
...@@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {...@@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) {
197 return `${environmentId}:${threadId}`;228 return `${environmentId}:${threadId}`;
198}229}
199230
231function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) {
232 return `${environmentId}:${cwd}`;
233}
234
200const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) {235const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) {
201 return yield* decodeConnectionCatalogDocument(raw).pipe(236 return yield* decodeConnectionCatalogDocument(raw).pipe(
202 Effect.mapError((cause) => catalogError("decode", cause)),237 Effect.mapError((cause) => catalogError("decode", cause)),
...@@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext(
462 : persistenceError("save-shell", cause),497 : persistenceError("save-shell", cause),
463 ),498 ),
464 ),499 ),
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 ),
465 loadThread: (environmentId, threadId) =>534 loadThread: (environmentId, threadId) =>
466 readDatabaseValue(535 readDatabaseValue(
467 database,536 database,
...@@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext(
508 : persistenceError("save-thread", cause),577 : persistenceError("save-thread", cause),
509 ),578 ),
510 ),579 ),
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 ),
511 removeThread: (environmentId, threadId) =>622 removeThread: (environmentId, threadId) =>
512 removeDatabaseValue(623 removeDatabaseValue(
513 database,624 database,
...@@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext(
523 THREAD_STORE_NAME,634 THREAD_STORE_NAME,
524 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),635 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
525 ),636 ),
637 removeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId),
638 removeDatabaseValuesInRange(
639 database,
640 VCS_REFS_STORE_NAME,
641 IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`),
642 ),
526 ],643 ],
527 { concurrency: "unbounded", discard: true },644 { concurrency: "unbounded", discard: true },
528 ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))),645 ).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* (...@@ -247,6 +247,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* (
247 loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()),247 loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()),
248 saveThread: (_environmentId, _thread) => Effect.void,248 saveThread: (_environmentId, _thread) => Effect.void,
249 removeThread: (_environmentId, _threadId) => Effect.void,249 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,
250 clear: (environmentId) =>254 clear: (environmentId) =>
251 Ref.update(shellCache, (current) => {255 Ref.update(shellCache, (current) => {
252 const next = new Map(current);256 const next = new Map(current);
packages/client-runtime/src/platform/persistence.ts+30
...@@ -2,7 +2,9 @@ import {...@@ -2,7 +2,9 @@ import {
2 type EnvironmentId,2 type EnvironmentId,
3 type OrchestrationShellSnapshot,3 type OrchestrationShellSnapshot,
4 type OrchestrationThreadDetailSnapshot,4 type OrchestrationThreadDetailSnapshot,
5 type ServerConfig,
5 type ThreadId,6 type ThreadId,
7 type VcsListRefsResult,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
7import * as Context from "effect/Context";9import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
...@@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass<Connecti...@@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass<Connecti
24 "load-thread",26 "load-thread",
25 "save-thread",27 "save-thread",
26 "remove-thread",28 "remove-thread",
29 "load-server-config",
30 "save-server-config",
31 "load-vcs-refs",
32 "save-vcs-refs",
27 "clear-environment",33 "clear-environment",
28 ]),34 ]),
29 message: Schema.String,35 message: Schema.String,
...@@ -72,6 +78,30 @@ export class EnvironmentCacheStore extends Context.Service<...@@ -72,6 +78,30 @@ export class EnvironmentCacheStore extends Context.Service<
72 environmentId: EnvironmentId,78 environmentId: EnvironmentId,
73 threadId: ThreadId,79 threadId: ThreadId,
74 ) => Effect.Effect<void, ConnectionPersistenceError>;80 ) => 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>;
75 readonly clear: (105 readonly clear: (
76 environmentId: EnvironmentId,106 environmentId: EnvironmentId,
77 ) => Effect.Effect<void, ConnectionPersistenceError>;107 ) => Effect.Effect<void, ConnectionPersistenceError>;
packages/client-runtime/src/state/server.test.ts+175-2
...@@ -1,8 +1,32 @@...@@ -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";
2import { describe, expect, it } from "@effect/vitest";8import { describe, expect, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";10import * 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
7const CONFIG = {31const CONFIG = {
8 availableEditors: [],32 availableEditors: [],
...@@ -14,6 +38,29 @@ const CONFIG = {...@@ -14,6 +38,29 @@ const CONFIG = {
14 settings: {},38 settings: {},
15} as unknown as ServerConfig;39} 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
17describe("server state projection", () => {64describe("server state projection", () => {
18 it("applies every config category to the projected snapshot", () => {65 it("applies every config category to the projected snapshot", () => {
19 const snapshot = applyServerConfigProjection(Option.none(), {66 const snapshot = applyServerConfigProjection(Option.none(), {
...@@ -51,4 +98,130 @@ describe("server state projection", () => {...@@ -51,4 +98,130 @@ describe("server state projection", () => {
51 expect(Option.getOrThrow(afterReady)).toBe(welcome);98 expect(Option.getOrThrow(afterReady)).toBe(welcome);
52 expect(emitted).toEqual([]);99 expect(emitted).toEqual([]);
53 });100 });
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 );
54});227});
packages/client-runtime/src/state/server.ts+161-8
...@@ -5,8 +5,13 @@ import {...@@ -5,8 +5,13 @@ import {
5 type ServerLifecycleWelcomePayload,5 type ServerLifecycleWelcomePayload,
6 WS_METHODS,6 WS_METHODS,
7} from "@t3tools/contracts";7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";9import * as Option from "effect/Option";
10import * as Queue from "effect/Queue";
11import * as Ref from "effect/Ref";
12import * as Result from "effect/Result";
9import * as Stream from "effect/Stream";13import * as Stream from "effect/Stream";
14import * as SubscriptionRef from "effect/SubscriptionRef";
10import { AsyncResult, Atom } from "effect/unstable/reactivity";15import { AsyncResult, Atom } from "effect/unstable/reactivity";
1116
12import {17import {
...@@ -16,10 +21,16 @@ import {...@@ -16,10 +21,16 @@ import {
16 createEnvironmentRpcSubscriptionAtomFamily,21 createEnvironmentRpcSubscriptionAtomFamily,
17} from "./runtime.ts";22} from "./runtime.ts";
18import type { EnvironmentRegistry } from "../connection/registry.ts";23import 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
20export interface ServerConfigProjection {30export interface ServerConfigProjection {
21 readonly config: ServerConfig;31 readonly config: ServerConfig;
22 readonly latestEvent: ServerConfigStreamEvent;32 readonly latestEvent: ServerConfigStreamEvent;
33 readonly source: "cache" | "live";
23}34}
2435
25export function applyServerConfigProjection(36export function applyServerConfigProjection(
...@@ -31,6 +42,7 @@ export function applyServerConfigProjection(...@@ -31,6 +42,7 @@ export function applyServerConfigProjection(
31 return Option.some({42 return Option.some({
32 config: event.config,43 config: event.config,
33 latestEvent: event,44 latestEvent: event,
45 source: "live",
34 });46 });
35 case "keybindingsUpdated":47 case "keybindingsUpdated":
36 return Option.map(current, (projection) => ({48 return Option.map(current, (projection) => ({
...@@ -40,6 +52,7 @@ export function applyServerConfigProjection(...@@ -40,6 +52,7 @@ export function applyServerConfigProjection(
40 issues: event.payload.issues,52 issues: event.payload.issues,
41 },53 },
42 latestEvent: event,54 latestEvent: event,
55 source: "live",
43 }));56 }));
44 case "providerStatuses":57 case "providerStatuses":
45 return Option.map(current, (projection) => ({58 return Option.map(current, (projection) => ({
...@@ -48,6 +61,7 @@ export function applyServerConfigProjection(...@@ -48,6 +61,7 @@ export function applyServerConfigProjection(
48 providers: event.payload.providers,61 providers: event.payload.providers,
49 },62 },
50 latestEvent: event,63 latestEvent: event,
64 source: "live",
51 }));65 }));
52 case "settingsUpdated":66 case "settingsUpdated":
53 return Option.map(current, (projection) => ({67 return Option.map(current, (projection) => ({
...@@ -56,6 +70,7 @@ export function applyServerConfigProjection(...@@ -56,6 +70,7 @@ export function applyServerConfigProjection(
56 settings: event.payload.settings,70 settings: event.payload.settings,
57 },71 },
58 latestEvent: event,72 latestEvent: event,
73 source: "live",
59 }));74 }));
60 }75 }
61}76}
...@@ -68,6 +83,127 @@ export function projectServerConfig(...@@ -68,6 +83,127 @@ export function projectServerConfig(
68 return [next, Option.toArray(next)];83 return [next, Option.toArray(next)];
69}84}
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
71export function projectServerWelcome(207export function projectServerWelcome(
72 current: Option.Option<ServerLifecycleWelcomePayload>,208 current: Option.Option<ServerLifecycleWelcomePayload>,
73 event: {209 event: {
...@@ -85,8 +221,16 @@ export function projectServerWelcome(...@@ -85,8 +221,16 @@ export function projectServerWelcome(
85 return [Option.some(welcome), [welcome]];221 return [Option.some(welcome), [welcome]];
86}222}
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
88export function createServerEnvironmentAtoms<R, E>(232export function createServerEnvironmentAtoms<R, E>(
89 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,233 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
90 options: {234 options: {
91 readonly initialConfigValueAtom: (235 readonly initialConfigValueAtom: (
92 environmentId: EnvironmentId,236 environmentId: EnvironmentId,
...@@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms<R, E>(
98 mode: "serial" as const,242 mode: "serial" as const,
99 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,243 key: ({ environmentId }: { readonly environmentId: string }) => environmentId,
100 };244 };
101 const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, {245 const configProjectionFamily = Atom.family((environmentId: EnvironmentId) =>
102 label: "environment-data:server:config-projection",246 runtime
103 tag: WS_METHODS.subscribeServerConfig,247 .atom(serverConfigStateChanges(environmentId))
104 transform: (stream) =>248 .pipe(
105 stream.pipe(Stream.mapAccum(Option.none<ServerConfigProjection>, projectServerConfig)),249 Atom.setIdleTTL(5 * 60_000),
106 });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);
107 const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe(257 const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe(
108 Atom.withLabel("environment-data:server:config:empty"),258 Atom.withLabel("environment-data:server:config:empty"),
109 );259 );
...@@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms<R, E>(...@@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms<R, E>(
115 const projection = Option.getOrNull(265 const projection = Option.getOrNull(
116 AsyncResult.value(get(configProjection({ environmentId, input: {} }))),266 AsyncResult.value(get(configProjection({ environmentId, input: {} }))),
117 );267 );
118 return projection?.config ?? get(options.initialConfigValueAtom(environmentId));268 return resolveServerConfigValue(
269 projection,
270 get(options.initialConfigValueAtom(environmentId)),
271 );
119 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));272 }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`));
120 });273 });
121 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>274 const settingsValueAtom = Atom.family((environmentId: EnvironmentId) =>
packages/client-runtime/src/state/shell-sync.test.ts+8
...@@ -81,6 +81,10 @@ describe("environment shell synchronization", () => {...@@ -81,6 +81,10 @@ describe("environment shell synchronization", () => {
81 loadThread: () => Effect.succeed(Option.none()),81 loadThread: () => Effect.succeed(Option.none()),
82 saveThread: () => Effect.void,82 saveThread: () => Effect.void,
83 removeThread: () => Effect.void,83 removeThread: () => Effect.void,
84 loadServerConfig: () => Effect.succeed(Option.none()),
85 saveServerConfig: () => Effect.void,
86 loadVcsRefs: () => Effect.succeed(Option.none()),
87 saveVcsRefs: () => Effect.void,
84 clear: () => Effect.void,88 clear: () => Effect.void,
85 });89 });
86 // Cold cache with no HTTP snapshot available → falls back to the90 // Cold cache with no HTTP snapshot available → falls back to the
...@@ -171,6 +175,10 @@ describe("environment shell synchronization", () => {...@@ -171,6 +175,10 @@ describe("environment shell synchronization", () => {
171 loadThread: () => Effect.succeed(Option.none()),175 loadThread: () => Effect.succeed(Option.none()),
172 saveThread: () => Effect.void,176 saveThread: () => Effect.void,
173 removeThread: () => Effect.void,177 removeThread: () => Effect.void,
178 loadServerConfig: () => Effect.succeed(Option.none()),
179 saveServerConfig: () => Effect.void,
180 loadVcsRefs: () => Effect.succeed(Option.none()),
181 saveVcsRefs: () => Effect.void,
174 clear: () => Effect.void,182 clear: () => Effect.void,
175 });183 });
176 const snapshotLoader = ShellSnapshotLoader.of({184 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...@@ -170,6 +170,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
170 Ref.update(savedThreads, (current) => [...current, thread]),170 Ref.update(savedThreads, (current) => [...current, thread]),
171 removeThread: (_environmentId, threadId) =>171 removeThread: (_environmentId, threadId) =>
172 Ref.update(removedThreads, (current) => [...current, threadId]),172 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,
173 clear: () => Effect.void,177 clear: () => Effect.void,
174 });178 });
175 const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(179 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 @@...@@ -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";
2import { applyGitStatusStreamEvent } from "@t3tools/shared/git";8import { applyGitStatusStreamEvent } from "@t3tools/shared/git";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11import * as Result from "effect/Result";
3import * as Stream from "effect/Stream";12import * as Stream from "effect/Stream";
13import * as SubscriptionRef from "effect/SubscriptionRef";
4import { Atom } from "effect/unstable/reactivity";14import { Atom } from "effect/unstable/reactivity";
515
6import {16import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts";
7 createEnvironmentRpcCommand,
8 createEnvironmentRpcQueryAtomFamily,
9 createEnvironmentSubscriptionAtomFamily,
10} from "./runtime.ts";
11import type { EnvironmentRegistry } from "../connection/registry.ts";17import 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";
13import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts";23import { 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
15export function createVcsEnvironmentAtoms<R, E>(145export function createVcsEnvironmentAtoms<R, E>(
16 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,146 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
17) {147) {
18 return {148 const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) =>
19 listRefs: createEnvironmentRpcQueryAtomFamily(runtime, {149 Atom.family((inputKey: string) => {
20 label: "environment-data:vcs:list-refs",150 const input = JSON.parse(inputKey) as VcsListRefsInput;
21 tag: WS_METHODS.vcsListRefs,151 return runtime
22 staleTimeMs: 5_000,152 .atom(cachedVcsRefsChanges(environmentId, input))
153 .pipe(
154 Atom.setIdleTTL(5 * 60_000),
155 Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`),
156 );
23 }),157 }),
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,
24 status: createEnvironmentSubscriptionAtomFamily(runtime, {166 status: createEnvironmentSubscriptionAtomFamily(runtime, {
25 label: "environment-data:vcs:status",167 label: "environment-data:vcs:status",
26 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) =>168 subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) =>
pnpm-lock.yaml+794-8
...@@ -327,6 +327,9 @@ importers:...@@ -327,6 +327,9 @@ importers:
327 expo-splash-screen:327 expo-splash-screen:
328 specifier: ~56.0.10328 specifier: ~56.0.10
329 version: 56.0.10(expo@56.0.12)(typescript@6.0.3)329 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)
330 expo-symbols:333 expo-symbols:
331 specifier: ~56.0.6334 specifier: ~56.0.6
332 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)335 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:...@@ -662,10 +665,10 @@ importers:
662 version: link:../../packages/shared665 version: link:../../packages/shared
663 alchemy:666 alchemy:
664 specifier: https://pkg.ing/alchemy/078ff00667 specifier: https://pkg.ing/alchemy/078ff00
665 version: https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab)668 version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39)
666 drizzle-orm:669 drizzle-orm:
667 specifier: 1.0.0-rc.3670 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)
669 effect:672 effect:
670 specifier: 4.0.0-beta.78673 specifier: 4.0.0-beta.78
671 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)674 version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)
...@@ -5445,6 +5448,9 @@ packages:...@@ -5445,6 +5448,9 @@ packages:
5445 resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}5448 resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==}
5446 engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}5449 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
5448 aws-ssl-profiles@1.1.2:5454 aws-ssl-profiles@1.1.2:
5449 resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}5455 resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
5450 engines: {node: '>= 6.0.0'}5456 engines: {node: '>= 6.0.0'}
...@@ -6661,6 +6667,13 @@ packages:...@@ -6661,6 +6667,13 @@ packages:
6661 peerDependencies:6667 peerDependencies:
6662 expo: '*'6668 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
6664 expo-structured-headers@56.0.0:6677 expo-structured-headers@56.0.0:
6665 resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==}6678 resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==}
66666679
...@@ -12056,6 +12069,83 @@ snapshots:...@@ -12056,6 +12069,83 @@ snapshots:
12056 - typescript12069 - typescript
12057 - utf-8-validate12070 - 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
12059 '@expo/code-signing-certificates@0.0.6':12149 '@expo/code-signing-certificates@0.0.6':
12060 dependencies:12150 dependencies:
12061 node-forge: 1.4.012151 node-forge: 1.4.0
...@@ -12132,12 +12222,27 @@ snapshots:...@@ -12132,12 +12222,27 @@ snapshots:
12132 react: 19.2.312222 react: 19.2.3
12133 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12223 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
12135 '@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)':12233 '@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)':
12136 dependencies:12234 dependencies:
12137 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)12235 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
12138 react: 19.2.312236 react: 19.2.3
12139 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12237 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
12141 '@expo/env@2.3.0':12246 '@expo/env@2.3.0':
12142 dependencies:12247 dependencies:
12143 chalk: 4.1.212248 chalk: 4.1.2
...@@ -12206,6 +12311,16 @@ snapshots:...@@ -12206,6 +12311,16 @@ snapshots:
12206 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)12311 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
12207 stacktrace-parser: 0.1.1112312 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
12209 '@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)':12324 '@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)':
12210 dependencies:12325 dependencies:
12211 '@babel/code-frame': 7.29.712326 '@babel/code-frame': 7.29.7
...@@ -12263,6 +12378,20 @@ snapshots:...@@ -12263,6 +12378,20 @@ snapshots:
12263 optionalDependencies:12378 optionalDependencies:
12264 react-dom: 19.2.3(react@19.2.3)12379 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
12266 '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)':12395 '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)':
12267 dependencies:12396 dependencies:
12268 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)12397 metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6)
...@@ -12344,6 +12473,22 @@ snapshots:...@@ -12344,6 +12473,22 @@ snapshots:
12344 transitivePeerDependencies:12473 transitivePeerDependencies:
12345 - supports-color12474 - 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
12347 '@expo/schema-utils@56.0.1': {}12492 '@expo/schema-utils@56.0.1': {}
1234812493
12349 '@expo/sdk-runtime-versions@1.0.0': {}12494 '@expo/sdk-runtime-versions@1.0.0': {}
...@@ -12354,6 +12499,23 @@ snapshots:...@@ -12354,6 +12499,23 @@ snapshots:
1235412499
12355 '@expo/sudo-prompt@9.3.2': {}12500 '@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
12357 '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)':12519 '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)':
12358 dependencies:12520 dependencies:
12359 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)12521 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -13286,18 +13448,45 @@ snapshots:...@@ -13286,18 +13448,45 @@ snapshots:
13286 '@types/react-dom': 19.2.3(@types/react@19.2.16)13448 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13287 optional: true13449 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
13289 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)':13464 '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)':
13290 dependencies:13465 dependencies:
13291 react: 19.2.313466 react: 19.2.3
13292 optionalDependencies:13467 optionalDependencies:
13293 '@types/react': 19.2.1613468 '@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
13295 '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)':13477 '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)':
13296 dependencies:13478 dependencies:
13297 react: 19.2.313479 react: 19.2.3
13298 optionalDependencies:13480 optionalDependencies:
13299 '@types/react': 19.2.1613481 '@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
13301 '@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)':13490 '@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)':
13302 dependencies:13491 dependencies:
13303 '@radix-ui/primitive': 1.1.313492 '@radix-ui/primitive': 1.1.3
...@@ -13320,6 +13509,29 @@ snapshots:...@@ -13320,6 +13509,29 @@ snapshots:
13320 '@types/react': 19.2.1613509 '@types/react': 19.2.16
13321 '@types/react-dom': 19.2.3(@types/react@19.2.16)13510 '@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
13323 '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)':13535 '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)':
13324 dependencies:13536 dependencies:
13325 react: 19.2.313537 react: 19.2.3
...@@ -13327,6 +13539,13 @@ snapshots:...@@ -13327,6 +13539,13 @@ snapshots:
13327 '@types/react': 19.2.1613539 '@types/react': 19.2.16
13328 optional: true13540 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
13330 '@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)':13549 '@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)':
13331 dependencies:13550 dependencies:
13332 '@radix-ui/primitive': 1.1.313551 '@radix-ui/primitive': 1.1.3
...@@ -13340,12 +13559,33 @@ snapshots:...@@ -13340,12 +13559,33 @@ snapshots:
13340 '@types/react': 19.2.1613559 '@types/react': 19.2.16
13341 '@types/react-dom': 19.2.3(@types/react@19.2.16)13560 '@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
13343 '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)':13576 '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)':
13344 dependencies:13577 dependencies:
13345 react: 19.2.313578 react: 19.2.3
13346 optionalDependencies:13579 optionalDependencies:
13347 '@types/react': 19.2.1613580 '@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
13349 '@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)':13589 '@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)':
13350 dependencies:13590 dependencies:
13351 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)13591 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
...@@ -13357,6 +13597,18 @@ snapshots:...@@ -13357,6 +13597,18 @@ snapshots:
13357 '@types/react': 19.2.1613597 '@types/react': 19.2.16
13358 '@types/react-dom': 19.2.3(@types/react@19.2.16)13598 '@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
13360 '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)':13612 '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)':
13361 dependencies:13613 dependencies:
13362 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3)13614 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3)
...@@ -13364,6 +13616,14 @@ snapshots:...@@ -13364,6 +13616,14 @@ snapshots:
13364 optionalDependencies:13616 optionalDependencies:
13365 '@types/react': 19.2.1613617 '@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
13367 '@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)':13627 '@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)':
13368 dependencies:13628 dependencies:
13369 '@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)13629 '@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:...@@ -13374,6 +13634,17 @@ snapshots:
13374 '@types/react': 19.2.1613634 '@types/react': 19.2.16
13375 '@types/react-dom': 19.2.3(@types/react@19.2.16)13635 '@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
13377 '@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)':13648 '@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)':
13378 dependencies:13649 dependencies:
13379 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)13650 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
...@@ -13384,6 +13655,17 @@ snapshots:...@@ -13384,6 +13655,17 @@ snapshots:
13384 '@types/react': 19.2.1613655 '@types/react': 19.2.16
13385 '@types/react-dom': 19.2.3(@types/react@19.2.16)13656 '@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
13387 '@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)':13669 '@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)':
13388 dependencies:13670 dependencies:
13389 '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3)13671 '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3)
...@@ -13393,6 +13675,16 @@ snapshots:...@@ -13393,6 +13675,16 @@ snapshots:
13393 '@types/react': 19.2.1613675 '@types/react': 19.2.16
13394 '@types/react-dom': 19.2.3(@types/react@19.2.16)13676 '@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
13396 '@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)':13688 '@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)':
13397 dependencies:13689 dependencies:
13398 '@radix-ui/primitive': 1.1.313690 '@radix-ui/primitive': 1.1.3
...@@ -13411,6 +13703,24 @@ snapshots:...@@ -13411,6 +13703,24 @@ snapshots:
13411 '@types/react-dom': 19.2.3(@types/react@19.2.16)13703 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13412 optional: true13704 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
13414 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)':13724 '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)':
13415 dependencies:13725 dependencies:
13416 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)13726 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
...@@ -13418,6 +13728,14 @@ snapshots:...@@ -13418,6 +13728,14 @@ snapshots:
13418 optionalDependencies:13728 optionalDependencies:
13419 '@types/react': 19.2.1613729 '@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
13421 '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)':13739 '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)':
13422 dependencies:13740 dependencies:
13423 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)13741 '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3)
...@@ -13426,6 +13744,14 @@ snapshots:...@@ -13426,6 +13744,14 @@ snapshots:
13426 '@types/react': 19.2.1613744 '@types/react': 19.2.16
13427 optional: true13745 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
13429 '@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)':13755 '@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)':
13430 dependencies:13756 dependencies:
13431 '@radix-ui/primitive': 1.1.313757 '@radix-ui/primitive': 1.1.3
...@@ -13443,12 +13769,36 @@ snapshots:...@@ -13443,12 +13769,36 @@ snapshots:
13443 '@types/react-dom': 19.2.3(@types/react@19.2.16)13769 '@types/react-dom': 19.2.3(@types/react@19.2.16)
13444 optional: true13770 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
13446 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)':13789 '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)':
13447 dependencies:13790 dependencies:
13448 react: 19.2.313791 react: 19.2.3
13449 optionalDependencies:13792 optionalDependencies:
13450 '@types/react': 19.2.1613793 '@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
13452 '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)':13802 '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)':
13453 dependencies:13803 dependencies:
13454 '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3)13804 '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3)
...@@ -13457,12 +13807,29 @@ snapshots:...@@ -13457,12 +13807,29 @@ snapshots:
13457 optionalDependencies:13807 optionalDependencies:
13458 '@types/react': 19.2.1613808 '@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)':
13461 dependencies:13811 dependencies:
13462 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3)13812 '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.6)
13463 react: 19.2.313813 '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6)
13814 react: 19.2.6
13464 optionalDependencies:13815 optionalDependencies:
13465 '@types/react': 19.2.1613816 '@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
13467 '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)':13834 '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)':
13468 dependencies:13835 dependencies:
...@@ -13471,12 +13838,27 @@ snapshots:...@@ -13471,12 +13838,27 @@ snapshots:
13471 optionalDependencies:13838 optionalDependencies:
13472 '@types/react': 19.2.1613839 '@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
13474 '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)':13849 '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)':
13475 dependencies:13850 dependencies:
13476 react: 19.2.313851 react: 19.2.3
13477 optionalDependencies:13852 optionalDependencies:
13478 '@types/react': 19.2.1613853 '@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
13480 '@react-grab/cli@0.1.44':13862 '@react-grab/cli@0.1.44':
13481 dependencies:13863 dependencies:
13482 agent-install: 0.0.513864 agent-install: 0.0.5
...@@ -13494,6 +13876,12 @@ snapshots:...@@ -13494,6 +13876,12 @@ snapshots:
13494 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)13876 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
13495 optional: true13877 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
13497 '@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)':13885 '@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)':
13498 dependencies:13886 dependencies:
13499 react: 19.2.313887 react: 19.2.3
...@@ -13638,6 +14026,16 @@ snapshots:...@@ -13638,6 +14026,16 @@ snapshots:
13638 optionalDependencies:14026 optionalDependencies:
13639 '@types/react': 19.2.1614027 '@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
13641 '@react-navigation/core@7.21.2(react@19.2.3)':14039 '@react-navigation/core@7.21.2(react@19.2.3)':
13642 dependencies:14040 dependencies:
13643 '@react-navigation/routers': 7.6.014041 '@react-navigation/routers': 7.6.0
...@@ -14797,7 +15195,7 @@ snapshots:...@@ -14797,7 +15195,7 @@ snapshots:
14797 json-schema-traverse: 1.0.015195 json-schema-traverse: 1.0.0
14798 require-from-string: 2.0.215196 require-from-string: 2.0.2
1479915197
14800 alchemy@https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab):15198 alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39):
14801 dependencies:15199 dependencies:
14802 '@alchemy.run/node-utils': 0.0.415200 '@alchemy.run/node-utils': 0.0.4
14803 '@aws-sdk/credential-providers': 3.1062.015201 '@aws-sdk/credential-providers': 3.1062.0
...@@ -14840,7 +15238,7 @@ snapshots:...@@ -14840,7 +15238,7 @@ snapshots:
14840 '@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)15238 '@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)
14841 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))15239 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
14842 drizzle-kit: 1.0.0-rc.315240 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)
14844 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)'15242 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)'
14845 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)15243 ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
14846 transitivePeerDependencies:15244 transitivePeerDependencies:
...@@ -15083,6 +15481,8 @@ snapshots:...@@ -15083,6 +15481,8 @@ snapshots:
1508315481
15084 auto-bind@5.0.1: {}15482 auto-bind@5.0.1: {}
1508515483
15484 await-lock@2.2.2: {}
15485
15086 aws-ssl-profiles@1.1.2: {}15486 aws-ssl-profiles@1.1.2: {}
1508715487
15088 aws4@1.13.2: {}15488 aws4@1.13.2: {}
...@@ -15856,13 +16256,14 @@ snapshots:...@@ -15856,13 +16256,14 @@ snapshots:
15856 get-tsconfig: 4.14.016256 get-tsconfig: 4.14.0
15857 jiti: 2.7.016257 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):
15860 optionalDependencies:16260 optionalDependencies:
15861 '@cloudflare/workers-types': 4.20260604.116261 '@cloudflare/workers-types': 4.20260604.1
15862 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))16262 '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))
15863 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)16263 '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6)
15864 bun-types: 1.3.1416264 bun-types: 1.3.14
15865 effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)16265 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)
15866 mysql2: 3.22.4(@types/node@24.12.4)16267 mysql2: 3.22.4(@types/node@24.12.4)
15867 pg: 8.21.016268 pg: 8.21.0
15868 zod: 4.4.316269 zod: 4.4.3
...@@ -16142,6 +16543,18 @@ snapshots:...@@ -16142,6 +16543,18 @@ snapshots:
16142 - supports-color16543 - supports-color
16143 - typescript16544 - 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
16145 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):16558 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):
16146 dependencies:16559 dependencies:
16147 expo-application: 56.0.3(expo@56.0.12)16560 expo-application: 56.0.3(expo@56.0.12)
...@@ -16186,6 +16599,15 @@ snapshots:...@@ -16186,6 +16599,15 @@ snapshots:
16186 transitivePeerDependencies:16599 transitivePeerDependencies:
16187 - supports-color16600 - 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
16189 expo-crypto@56.0.4(expo@56.0.12):16611 expo-crypto@56.0.4(expo@56.0.12):
16190 dependencies:16612 dependencies:
16191 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16613 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16226,6 +16648,12 @@ snapshots:...@@ -16226,6 +16648,12 @@ snapshots:
16226 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16648 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16227 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16649 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
16229 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):16657 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):
16230 dependencies:16658 dependencies:
16231 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16659 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16233,12 +16661,27 @@ snapshots:...@@ -16233,12 +16661,27 @@ snapshots:
16233 react: 19.2.316661 react: 19.2.3
16234 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16662 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
16236 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):16672 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):
16237 dependencies:16673 dependencies:
16238 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16674 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16239 react: 19.2.316675 react: 19.2.3
16240 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16676 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
16242 expo-haptics@56.0.3(expo@56.0.12):16685 expo-haptics@56.0.3(expo@56.0.12):
16243 dependencies:16686 dependencies:
16244 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16687 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16259,6 +16702,12 @@ snapshots:...@@ -16259,6 +16702,12 @@ snapshots:
16259 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16702 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
16260 react: 19.2.316703 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
16262 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):16711 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):
16263 dependencies:16712 dependencies:
16264 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))16713 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:...@@ -16269,6 +16718,17 @@ snapshots:
16269 - expo16718 - expo
16270 - supports-color16719 - 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
16272 expo-manifests@56.0.4(expo@56.0.12):16732 expo-manifests@56.0.4(expo@56.0.12):
16273 dependencies:16733 dependencies:
16274 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16734 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16294,10 +16754,26 @@ snapshots:...@@ -16294,10 +16754,26 @@ snapshots:
16294 optionalDependencies:16754 optionalDependencies:
16295 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)16755 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
16297 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)):16768 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)):
16298 dependencies:16769 dependencies:
16299 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16770 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
16301 expo-network@56.0.5(expo@56.0.12)(react@19.2.3):16777 expo-network@56.0.5(expo@56.0.12)(react@19.2.3):
16302 dependencies:16778 dependencies:
16303 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16779 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16374,6 +16850,57 @@ snapshots:...@@ -16374,6 +16850,57 @@ snapshots:
16374 - supports-color16850 - supports-color
16375 optional: true16851 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
16377 expo-secure-store@56.0.4(expo@56.0.12):16904 expo-secure-store@56.0.4(expo@56.0.12):
16378 dependencies:16905 dependencies:
16379 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16906 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16390,6 +16917,21 @@ snapshots:...@@ -16390,6 +16917,21 @@ snapshots:
16390 - supports-color16917 - supports-color
16391 - typescript16918 - 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
16393 expo-structured-headers@56.0.0: {}16935 expo-structured-headers@56.0.0: {}
1639416936
16395 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):16937 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:...@@ -16401,6 +16943,16 @@ snapshots:
16401 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)16943 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
16402 sf-symbols-typescript: 2.2.016944 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
16404 expo-updates-interface@56.0.2(expo@56.0.12):16956 expo-updates-interface@56.0.2(expo@56.0.12):
16405 dependencies:16957 dependencies:
16406 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)16958 expo: 56.0.12(8895228379997a2a064f9644cda56ed0)
...@@ -16492,6 +17044,49 @@ snapshots:...@@ -16492,6 +17044,49 @@ snapshots:
16492 - typescript17044 - typescript
16493 - utf-8-validate17045 - 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
16495 exponential-backoff@3.1.3: {}17090 exponential-backoff@3.1.3: {}
1649617091
16497 express-rate-limit@8.5.2(express@5.2.1):17092 express-rate-limit@8.5.2(express@5.2.1):
...@@ -18878,6 +19473,11 @@ snapshots:...@@ -18878,6 +19473,11 @@ snapshots:
18878 dependencies:19473 dependencies:
18879 react: 19.2.319474 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
18881 react-grab@0.1.44(react@19.2.6):19481 react-grab@0.1.44(react@19.2.6):
18882 dependencies:19482 dependencies:
18883 '@react-grab/cli': 0.1.4419483 '@react-grab/cli': 0.1.44
...@@ -18921,6 +19521,16 @@ snapshots:...@@ -18921,6 +19521,16 @@ snapshots:
18921 use-latest-callback: 0.2.6(react@19.2.3)19521 use-latest-callback: 0.2.6(react@19.2.3)
18922 optional: true19522 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
18924 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):19534 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):
18925 dependencies:19535 dependencies:
18926 '@egjs/hammerjs': 2.0.1719536 '@egjs/hammerjs': 2.0.17
...@@ -18930,6 +19540,16 @@ snapshots:...@@ -18930,6 +19540,16 @@ snapshots:
18930 react: 19.2.319540 react: 19.2.3
18931 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19541 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
18933 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):19553 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):
18934 dependencies:19554 dependencies:
18935 react: 19.2.319555 react: 19.2.3
...@@ -18940,6 +19560,12 @@ snapshots:...@@ -18940,6 +19560,12 @@ snapshots:
18940 react: 19.2.319560 react: 19.2.3
18941 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19561 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
18943 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):19569 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):
18944 dependencies:19570 dependencies:
18945 react: 19.2.319571 react: 19.2.3
...@@ -18968,11 +19594,26 @@ snapshots:...@@ -18968,11 +19594,26 @@ snapshots:
18968 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)19594 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)
18969 semver: 7.8.519595 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
18971 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):19606 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):
18972 dependencies:19607 dependencies:
18973 react: 19.2.319608 react: 19.2.3
18974 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19609 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
18976 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):19617 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):
18977 dependencies:19618 dependencies:
18978 react: 19.2.319619 react: 19.2.3
...@@ -18980,6 +19621,14 @@ snapshots:...@@ -18980,6 +19621,14 @@ snapshots:
18980 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19621 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)
18981 warn-once: 0.1.119622 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
18983 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):19632 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):
18984 dependencies:19633 dependencies:
18985 '@shikijs/types': 4.3.019634 '@shikijs/types': 4.3.0
...@@ -19007,6 +19656,14 @@ snapshots:...@@ -19007,6 +19656,14 @@ snapshots:
19007 react: 19.2.319656 react: 19.2.3
19008 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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)19657 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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
19010 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):19667 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):
19011 dependencies:19668 dependencies:
19012 '@babel/core': 7.29.719669 '@babel/core': 7.29.7
...@@ -19027,6 +19684,27 @@ snapshots:...@@ -19027,6 +19684,27 @@ snapshots:
19027 transitivePeerDependencies:19684 transitivePeerDependencies:
19028 - supports-color19685 - 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
19030 react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):19708 react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.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):
19031 dependencies:19709 dependencies:
19032 '@react-native/assets-registry': 0.85.319710 '@react-native/assets-registry': 0.85.3
...@@ -19072,6 +19750,52 @@ snapshots:...@@ -19072,6 +19750,52 @@ snapshots:
19072 - supports-color19750 - supports-color
19073 - utf-8-validate19751 - 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
19075 react-reconciler@0.33.0(react@19.2.6):19799 react-reconciler@0.33.0(react@19.2.6):
19076 dependencies:19800 dependencies:
19077 react: 19.2.619801 react: 19.2.6
...@@ -19087,6 +19811,15 @@ snapshots:...@@ -19087,6 +19811,15 @@ snapshots:
19087 optionalDependencies:19811 optionalDependencies:
19088 '@types/react': 19.2.1619812 '@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
19090 react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3):19823 react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3):
19091 dependencies:19824 dependencies:
19092 react: 19.2.319825 react: 19.2.3
...@@ -19098,6 +19831,18 @@ snapshots:...@@ -19098,6 +19831,18 @@ snapshots:
19098 optionalDependencies:19831 optionalDependencies:
19099 '@types/react': 19.2.1619832 '@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
19101 react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3):19846 react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3):
19102 dependencies:19847 dependencies:
19103 get-nonce: 1.0.119848 get-nonce: 1.0.1
...@@ -19106,6 +19851,15 @@ snapshots:...@@ -19106,6 +19851,15 @@ snapshots:
19106 optionalDependencies:19851 optionalDependencies:
19107 '@types/react': 19.2.1619852 '@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
19109 react@19.2.3: {}19863 react@19.2.3: {}
1911019864
19111 react@19.2.6: {}19865 react@19.2.6: {}
...@@ -20149,10 +20903,23 @@ snapshots:...@@ -20149,10 +20903,23 @@ snapshots:
20149 optionalDependencies:20903 optionalDependencies:
20150 '@types/react': 19.2.1620904 '@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
20152 use-latest-callback@0.2.6(react@19.2.3):20914 use-latest-callback@0.2.6(react@19.2.3):
20153 dependencies:20915 dependencies:
20154 react: 19.2.320916 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
20156 use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3):20923 use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3):
20157 dependencies:20924 dependencies:
20158 detect-node-es: 1.1.020925 detect-node-es: 1.1.0
...@@ -20161,6 +20928,15 @@ snapshots:...@@ -20161,6 +20928,15 @@ snapshots:
20161 optionalDependencies:20928 optionalDependencies:
20162 '@types/react': 19.2.1620929 '@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
20164 use-sync-external-store@1.6.0(react@19.2.3):20940 use-sync-external-store@1.6.0(react@19.2.3):
20165 dependencies:20941 dependencies:
20166 react: 19.2.320942 react: 19.2.3
...@@ -20197,6 +20973,16 @@ snapshots:...@@ -20197,6 +20973,16 @@ snapshots:
20197 - '@types/react'20973 - '@types/react'
20198 - '@types/react-dom'20974 - '@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
20200 vfile-location@5.0.3:20986 vfile-location@5.0.3:
20201 dependencies:20987 dependencies:
20202 '@types/unist': 3.0.320988 '@types/unist': 3.0.3