| author | |
| committer | |
| log | 2250e3ee7a2c2bf3abca1aeccce90de30625736b |
| tree | 388fcf30916c23534c5c7662966b4823f7054926 |
| parent | 3201e00ad4f59cc287517e61a19b069b497c16d2 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | 44 | - `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. |
| 45 | 45 | |
| 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 | 55 | ## Errors and predicates |
| 47 | 56 | |
| 48 | 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 | 123 | plugins: [ |
| 124 | 124 | "expo-font", |
| 125 | 125 | "expo-secure-store", |
| 126 | "expo-sqlite", | |
| 126 | 127 | ["@clerk/expo", { theme: "./clerk-theme.json" }], |
| 127 | 128 | "expo-web-browser", |
| 128 | 129 | [ |
apps/mobile/eas.json+8-4| ... | ... | @@ -8,7 +8,8 @@ |
| 8 | 8 | "development": { |
| 9 | 9 | "corepack": true, |
| 10 | 10 | "env": { |
| 11 | "APP_VARIANT": "development" | |
| 11 | "APP_VARIANT": "development", | |
| 12 | "NODE_OPTIONS": "--max-old-space-size=4096" | |
| 12 | 13 | }, |
| 13 | 14 | "channel": "development", |
| 14 | 15 | "developmentClient": true, |
| ... | ... | @@ -17,7 +18,8 @@ |
| 17 | 18 | "preview": { |
| 18 | 19 | "corepack": true, |
| 19 | 20 | "env": { |
| 20 | "APP_VARIANT": "preview" | |
| 21 | "APP_VARIANT": "preview", | |
| 22 | "NODE_OPTIONS": "--max-old-space-size=4096" | |
| 21 | 23 | }, |
| 22 | 24 | "channel": "preview", |
| 23 | 25 | "environment": "preview", |
| ... | ... | @@ -27,7 +29,8 @@ |
| 27 | 29 | "corepack": true, |
| 28 | 30 | "env": { |
| 29 | 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 | 35 | "channel": "preview", |
| 33 | 36 | "environment": "preview", |
| ... | ... | @@ -40,7 +43,8 @@ |
| 40 | 43 | "production": { |
| 41 | 44 | "corepack": true, |
| 42 | 45 | "env": { |
| 43 | "APP_VARIANT": "production" | |
| 46 | "APP_VARIANT": "production", | |
| 47 | "NODE_OPTIONS": "--max-old-space-size=4096" | |
| 44 | 48 | }, |
| 45 | 49 | "channel": "production", |
| 46 | 50 | "environment": "production", |
apps/mobile/package.json+2| ... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 | "scripts": { |
| 7 | 7 | "dev": "expo start --clear", |
| 8 | 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 | 10 | "start": "expo start", |
| 10 | 11 | "start:dev": "APP_VARIANT=development expo start", |
| 11 | 12 | "start:preview": "APP_VARIANT=preview expo start", |
| ... | ... | @@ -86,6 +87,7 @@ |
| 86 | 87 | "expo-paste-input": "^0.1.15", |
| 87 | 88 | "expo-secure-store": "~56.0.4", |
| 88 | 89 | "expo-splash-screen": "~56.0.10", |
| 90 | "expo-sqlite": "~56.0.5", | |
| 89 | 91 | "expo-symbols": "~56.0.6", |
| 90 | 92 | "expo-updates": "~56.0.19", |
| 91 | 93 | "expo-web-browser": "~56.0.5", |
apps/mobile/src/Stack.tsx+8| ... | ... | @@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr |
| 41 | 41 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 42 | 42 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 43 | 43 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| 44 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; | |
| 44 | 45 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 45 | 46 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 46 | 47 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| ... | ... | @@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 147 | 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 | 158 | SettingsAuth: createNativeStackScreen({ |
| 151 | 159 | screen: SettingsAuthRouteScreen, |
| 152 | 160 | linking: "auth", |
apps/mobile/src/connection/catalog-store.ts+25-26| ... | ... | @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref"; |
| 10 | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | import * as Semaphore from "effect/Semaphore"; |
| 12 | 12 | |
| 13 | import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; | |
| 13 | 14 | import { migrateLegacyConnectionCatalog } from "./migration"; |
| 14 | 15 | |
| 15 | 16 | export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; |
| ... | ... | @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) { |
| 22 | 23 | }); |
| 23 | 24 | } |
| 24 | 25 | |
| 26 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); | |
| 27 | const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); | |
| 28 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); | |
| 29 | ||
| 25 | 30 | const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { |
| 26 | const parsed = yield* Effect.try({ | |
| 27 | try: () => JSON.parse(raw) as unknown, | |
| 28 | catch: (cause) => catalogError("decode", cause), | |
| 29 | }); | |
| 30 | return yield* Effect.fromResult( | |
| 31 | Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), | |
| 32 | ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); | |
| 31 | return yield* decodeConnectionCatalogDocument(raw).pipe( | |
| 32 | Effect.mapError((cause) => catalogError("decode", cause)), | |
| 33 | ); | |
| 33 | 34 | }); |
| 34 | 35 | |
| 35 | 36 | const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( |
| 36 | 37 | catalog: ConnectionCatalogDocumentType, |
| 37 | 38 | ) { |
| 38 | const encoded = yield* Effect.fromResult( | |
| 39 | Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), | |
| 40 | ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); | |
| 41 | return JSON.stringify(encoded); | |
| 39 | return yield* encodeConnectionCatalogDocument(catalog).pipe( | |
| 40 | Effect.mapError((cause) => catalogError("encode", cause)), | |
| 41 | ); | |
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | 44 | interface CatalogStore { |
| ... | ... | @@ -48,20 +48,19 @@ interface CatalogStore { |
| 48 | 48 | ) => Effect.Effect<void, ConnectionTransientError>; |
| 49 | 49 | } |
| 50 | 50 | |
| 51 | export interface SecureCatalogStorage { | |
| 52 | readonly getItem: (key: string) => Effect.Effect<string | null, ConnectionTransientError>; | |
| 53 | readonly setItem: (key: string, value: string) => Effect.Effect<void, ConnectionTransientError>; | |
| 54 | readonly deleteItem: (key: string) => Effect.Effect<void, ConnectionTransientError>; | |
| 55 | } | |
| 56 | ||
| 57 | export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( | |
| 58 | storage: SecureCatalogStorage, | |
| 59 | ) { | |
| 51 | export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { | |
| 52 | const storage = yield* MobileSecureStorage.MobileSecureStorage; | |
| 53 | const getItem = (key: string) => | |
| 54 | storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); | |
| 55 | const setItem = (key: string, value: string) => | |
| 56 | storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); | |
| 57 | const deleteItem = (key: string) => | |
| 58 | storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); | |
| 60 | 59 | const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none()); |
| 61 | 60 | const lock = yield* Semaphore.make(1); |
| 62 | 61 | |
| 63 | 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 | 64 | const catalog = |
| 66 | 65 | legacyRaw === null || legacyRaw.trim() === "" |
| 67 | 66 | ? EMPTY_CONNECTION_CATALOG_DOCUMENT |
| ... | ... | @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS |
| 75 | 74 | ); |
| 76 | 75 | if (legacyRaw !== null && legacyRaw.trim() !== "") { |
| 77 | 76 | const encoded = yield* encodeCatalog(catalog); |
| 78 | yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); | |
| 79 | yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); | |
| 77 | yield* setItem(CONNECTION_CATALOG_KEY, encoded); | |
| 78 | yield* deleteItem(LEGACY_CONNECTIONS_KEY); | |
| 80 | 79 | } |
| 81 | 80 | return catalog; |
| 82 | 81 | }); |
| ... | ... | @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS |
| 86 | 85 | if (Option.isSome(cached)) { |
| 87 | 86 | return cached.value; |
| 88 | 87 | } |
| 89 | const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); | |
| 88 | const raw = yield* getItem(CONNECTION_CATALOG_KEY); | |
| 90 | 89 | let catalog: ConnectionCatalogDocumentType; |
| 91 | 90 | if (raw !== null && raw.trim() !== "") { |
| 92 | 91 | catalog = yield* decodeCatalog(raw).pipe( |
| 93 | 92 | Effect.catch((error) => |
| 94 | 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 | 95 | Effect.andThen(loadLegacyCatalog()), |
| 97 | 96 | ), |
| 98 | 97 | ), |
| ... | ... | @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS |
| 111 | 110 | Effect.gen(function* () { |
| 112 | 111 | const next = transform(yield* loadUnlocked()); |
| 113 | 112 | const encoded = yield* encodeCatalog(next); |
| 114 | yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); | |
| 113 | yield* setItem(CONNECTION_CATALOG_KEY, encoded); | |
| 115 | 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 @@ |
| 1 | import { EnvironmentId, type VcsListRefsResult } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | ||
| 6 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; | |
| 7 | import { make } from "./environment-cache-store"; | |
| 8 | ||
| 9 | const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); | |
| 10 | const 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 | ||
| 25 | function cacheId(environmentId: EnvironmentId, kind: ClientCacheKind, cacheKey: string) { | |
| 26 | return `${environmentId}:${kind}:${cacheKey}`; | |
| 27 | } | |
| 28 | ||
| 29 | function 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 | ||
| 59 | describe("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 @@ |
| 1 | import { | |
| 2 | ConnectionPersistenceError, | |
| 3 | EnvironmentCacheStore, | |
| 4 | } from "@t3tools/client-runtime/platform"; | |
| 5 | import { | |
| 6 | type EnvironmentId, | |
| 7 | OrchestrationShellSnapshot, | |
| 8 | OrchestrationThreadDetailSnapshot, | |
| 9 | ServerConfig, | |
| 10 | VcsListRefsResult, | |
| 11 | } from "@t3tools/contracts"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Layer from "effect/Layer"; | |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | ||
| 17 | import * as MobileDatabase from "../persistence/mobile-database"; | |
| 18 | ||
| 19 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | |
| 20 | const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; | |
| 21 | const SERVER_CONFIG_CACHE_SCHEMA_VERSION = 1; | |
| 22 | const VCS_REFS_CACHE_SCHEMA_VERSION = 1; | |
| 23 | ||
| 24 | const StoredShellSnapshot = Schema.Struct({ | |
| 25 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | |
| 26 | environmentId: Schema.String, | |
| 27 | snapshot: OrchestrationShellSnapshot, | |
| 28 | }); | |
| 29 | const StoredThreadSnapshot = Schema.Struct({ | |
| 30 | schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), | |
| 31 | environmentId: Schema.String, | |
| 32 | threadId: Schema.String, | |
| 33 | snapshot: OrchestrationThreadDetailSnapshot, | |
| 34 | }); | |
| 35 | const StoredServerConfig = Schema.Struct({ | |
| 36 | schemaVersion: Schema.Literal(SERVER_CONFIG_CACHE_SCHEMA_VERSION), | |
| 37 | environmentId: Schema.String, | |
| 38 | config: ServerConfig, | |
| 39 | }); | |
| 40 | const 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 | ||
| 47 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect( | |
| 48 | Schema.fromJsonString(StoredShellSnapshot), | |
| 49 | ); | |
| 50 | const encodeStoredShellSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredShellSnapshot)); | |
| 51 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect( | |
| 52 | Schema.fromJsonString(StoredThreadSnapshot), | |
| 53 | ); | |
| 54 | const encodeStoredThreadSnapshot = Schema.encodeEffect(Schema.fromJsonString(StoredThreadSnapshot)); | |
| 55 | const decodeStoredServerConfig = Schema.decodeUnknownEffect( | |
| 56 | Schema.fromJsonString(StoredServerConfig), | |
| 57 | ); | |
| 58 | const encodeStoredServerConfig = Schema.encodeEffect(Schema.fromJsonString(StoredServerConfig)); | |
| 59 | const decodeStoredVcsRefs = Schema.decodeUnknownEffect(Schema.fromJsonString(StoredVcsRefs)); | |
| 60 | const encodeStoredVcsRefs = Schema.encodeEffect(Schema.fromJsonString(StoredVcsRefs)); | |
| 61 | ||
| 62 | type CacheOperation = ConnectionPersistenceError["operation"]; | |
| 63 | ||
| 64 | function persistenceError(operation: CacheOperation, cause: unknown) { | |
| 65 | return new ConnectionPersistenceError({ | |
| 66 | operation, | |
| 67 | message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, | |
| 68 | }); | |
| 69 | } | |
| 70 | ||
| 71 | function mapDatabaseError(operation: CacheOperation) { | |
| 72 | return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); | |
| 73 | } | |
| 74 | ||
| 75 | function 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 | ||
| 113 | export 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 | ||
| 234 | export 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 | 25 | import { AppState } from "react-native"; |
| 26 | 26 | |
| 27 | 27 | import { authClientMetadata } from "../lib/authClientMetadata"; |
| 28 | import { loadOrCreateAgentAwarenessDeviceId } from "../lib/storage"; | |
| 28 | import * as Runtime from "../lib/runtime"; | |
| 29 | import * as MobileStorage from "../persistence/mobile-storage"; | |
| 29 | 30 | import { appAtomRegistry } from "../state/atom-registry"; |
| 30 | 31 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; |
| 31 | 32 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; |
| ... | ... | @@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({ |
| 83 | 84 | ), |
| 84 | 85 | }); |
| 85 | 86 | |
| 86 | const capabilitiesLayer = Layer.succeedContext( | |
| 87 | Context.make( | |
| 88 | CloudSession, | |
| 89 | CloudSession.of({ | |
| 90 | clerkToken: Effect.gen(function* () { | |
| 91 | const session = appAtomRegistry.get(managedRelaySessionAtom); | |
| 92 | if (session === null) { | |
| 93 | return yield* new ConnectionBlockedError({ | |
| 94 | reason: "authentication", | |
| 95 | detail: "Sign in to T3 Connect to connect this environment.", | |
| 96 | }); | |
| 97 | } | |
| 98 | const token = yield* session.readClerkToken().pipe( | |
| 99 | Effect.mapError( | |
| 100 | (error) => | |
| 101 | new ConnectionTransientError({ | |
| 102 | reason: "network", | |
| 103 | detail: error.message, | |
| 104 | }), | |
| 105 | ), | |
| 106 | ); | |
| 107 | if (token === null) { | |
| 108 | return yield* new ConnectionBlockedError({ | |
| 109 | reason: "authentication", | |
| 110 | detail: "The T3 Connect session is unavailable.", | |
| 111 | }); | |
| 112 | } | |
| 113 | return token; | |
| 114 | }), | |
| 115 | }), | |
| 116 | ).pipe( | |
| 117 | Context.add( | |
| 118 | PrimaryEnvironmentAuth, | |
| 119 | PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), | |
| 120 | ), | |
| 121 | Context.add( | |
| 122 | RelayDeviceIdentity, | |
| 123 | RelayDeviceIdentity.of({ | |
| 124 | deviceId: Effect.tryPromise({ | |
| 125 | try: () => loadOrCreateAgentAwarenessDeviceId(), | |
| 126 | catch: (cause) => | |
| 127 | new ConnectionTransientError({ | |
| 128 | reason: "remote-unavailable", | |
| 129 | detail: `Could not load the mobile device identity: ${String(cause)}`, | |
| 130 | }), | |
| 131 | }).pipe(Effect.map(Option.some)), | |
| 132 | }), | |
| 133 | ), | |
| 134 | Context.add( | |
| 135 | ClientPresentation, | |
| 136 | ClientPresentation.of({ | |
| 137 | metadata: authClientMetadata(), | |
| 138 | scopes: AuthStandardClientScopes, | |
| 87 | const capabilitiesLayer = Layer.effectContext( | |
| 88 | Effect.gen(function* () { | |
| 89 | const storage = yield* MobileStorage.MobileStorage; | |
| 90 | return Context.make( | |
| 91 | CloudSession, | |
| 92 | CloudSession.of({ | |
| 93 | clerkToken: Effect.gen(function* () { | |
| 94 | const session = appAtomRegistry.get(managedRelaySessionAtom); | |
| 95 | if (session === null) { | |
| 96 | return yield* new ConnectionBlockedError({ | |
| 97 | reason: "authentication", | |
| 98 | detail: "Sign in to T3 Connect to connect this environment.", | |
| 99 | }); | |
| 100 | } | |
| 101 | const token = yield* session.readClerkToken().pipe( | |
| 102 | Effect.mapError( | |
| 103 | (error) => | |
| 104 | new ConnectionTransientError({ | |
| 105 | reason: "network", | |
| 106 | detail: error.message, | |
| 107 | }), | |
| 108 | ), | |
| 109 | ); | |
| 110 | if (token === null) { | |
| 111 | return yield* new ConnectionBlockedError({ | |
| 112 | reason: "authentication", | |
| 113 | detail: "The T3 Connect session is unavailable.", | |
| 114 | }); | |
| 115 | } | |
| 116 | return token; | |
| 117 | }), | |
| 139 | 118 | }), |
| 140 | ), | |
| 141 | Context.add( | |
| 142 | SshEnvironmentGateway, | |
| 143 | SshEnvironmentGateway.of({ | |
| 144 | provision: () => | |
| 145 | Effect.fail( | |
| 146 | new ConnectionBlockedError({ | |
| 147 | reason: "unsupported", | |
| 148 | detail: "SSH environments are only available in the desktop app.", | |
| 149 | }), | |
| 150 | ), | |
| 151 | prepare: () => | |
| 152 | Effect.fail( | |
| 153 | new ConnectionBlockedError({ | |
| 154 | reason: "unsupported", | |
| 155 | detail: "SSH environments are only available in the desktop app.", | |
| 156 | }), | |
| 119 | ).pipe( | |
| 120 | Context.add( | |
| 121 | PrimaryEnvironmentAuth, | |
| 122 | PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), | |
| 123 | ), | |
| 124 | Context.add( | |
| 125 | RelayDeviceIdentity, | |
| 126 | RelayDeviceIdentity.of({ | |
| 127 | deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe( | |
| 128 | Effect.mapError( | |
| 129 | (cause) => | |
| 130 | new ConnectionTransientError({ | |
| 131 | reason: "remote-unavailable", | |
| 132 | detail: `Could not load the mobile device identity: ${String(cause)}`, | |
| 133 | }), | |
| 134 | ), | |
| 135 | Effect.map(Option.some), | |
| 157 | 136 | ), |
| 158 | disconnect: () => Effect.void, | |
| 159 | }), | |
| 160 | ), | |
| 161 | ), | |
| 137 | }), | |
| 138 | ), | |
| 139 | Context.add( | |
| 140 | ClientPresentation, | |
| 141 | ClientPresentation.of({ | |
| 142 | metadata: authClientMetadata(), | |
| 143 | scopes: AuthStandardClientScopes, | |
| 144 | }), | |
| 145 | ), | |
| 146 | Context.add( | |
| 147 | SshEnvironmentGateway, | |
| 148 | SshEnvironmentGateway.of({ | |
| 149 | provision: () => | |
| 150 | Effect.fail( | |
| 151 | new ConnectionBlockedError({ | |
| 152 | reason: "unsupported", | |
| 153 | detail: "SSH environments are only available in the desktop app.", | |
| 154 | }), | |
| 155 | ), | |
| 156 | prepare: () => | |
| 157 | Effect.fail( | |
| 158 | new ConnectionBlockedError({ | |
| 159 | reason: "unsupported", | |
| 160 | detail: "SSH environments are only available in the desktop app.", | |
| 161 | }), | |
| 162 | ), | |
| 163 | disconnect: () => Effect.void, | |
| 164 | }), | |
| 165 | ), | |
| 166 | ); | |
| 167 | }), | |
| 162 | 168 | ); |
| 163 | 169 | |
| 164 | 170 | const platformConnectionSourceLayer = Layer.succeed( |
| ... | ... | @@ -168,6 +174,13 @@ const platformConnectionSourceLayer = Layer.succeed( |
| 168 | 174 | }), |
| 169 | 175 | ); |
| 170 | 176 | |
| 177 | const providedConnectionStorageLayer = connectionStorageLayer.pipe( | |
| 178 | Layer.provide(Runtime.runtimeContextLayer), | |
| 179 | ); | |
| 180 | const providedCapabilitiesLayer = capabilitiesLayer.pipe( | |
| 181 | Layer.provide(Runtime.runtimeContextLayer), | |
| 182 | ); | |
| 183 | ||
| 171 | 184 | const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 172 | 185 | EnvironmentOwnedDataCleanup, |
| 173 | 186 | EnvironmentOwnedDataCleanup.of({ |
| ... | ... | @@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 190 | 203 | ); |
| 191 | 204 | |
| 192 | 205 | type ConnectionPlatformLayerSource = |
| 193 | | typeof connectionStorageLayer | |
| 206 | | typeof providedConnectionStorageLayer | |
| 207 | | typeof Runtime.runtimeContextLayer | |
| 194 | 208 | | typeof connectivityLayer |
| 195 | 209 | | typeof wakeupsLayer |
| 196 | | typeof capabilitiesLayer | |
| 210 | | typeof providedCapabilitiesLayer | |
| 197 | 211 | | typeof platformConnectionSourceLayer |
| 198 | 212 | | typeof environmentOwnedDataCleanupLayer; |
| 199 | 213 | |
| ... | ... | @@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer< |
| 202 | 216 | Layer.Error<ConnectionPlatformLayerSource>, |
| 203 | 217 | Layer.Services<ConnectionPlatformLayerSource> |
| 204 | 218 | > = Layer.mergeAll( |
| 205 | connectionStorageLayer, | |
| 219 | providedConnectionStorageLayer, | |
| 220 | Runtime.runtimeContextLayer, | |
| 206 | 221 | connectivityLayer, |
| 207 | 222 | wakeupsLayer, |
| 208 | capabilitiesLayer, | |
| 223 | providedCapabilitiesLayer, | |
| 209 | 224 | platformConnectionSourceLayer, |
| 210 | 225 | environmentOwnedDataCleanupLayer, |
| 211 | 226 | ); |
apps/mobile/src/connection/storage.test.ts+25-12| ... | ... | @@ -1,28 +1,35 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | import * as Effect from "effect/Effect"; |
| 3 | import { vi } from "vite-plus/test"; | |
| 3 | 4 | |
| 4 | import { | |
| 5 | CONNECTION_CATALOG_KEY, | |
| 6 | LEGACY_CONNECTIONS_KEY, | |
| 7 | makeCatalogStore, | |
| 8 | type SecureCatalogStorage, | |
| 9 | } from "./catalog-store"; | |
| 5 | vi.mock("react-native", () => ({ | |
| 6 | Platform: { OS: "ios" }, | |
| 7 | })); | |
| 8 | ||
| 9 | vi.mock("expo-secure-store", () => ({ | |
| 10 | deleteItemAsync: vi.fn(), | |
| 11 | getItemAsync: vi.fn(), | |
| 12 | setItemAsync: vi.fn(), | |
| 13 | })); | |
| 14 | ||
| 15 | import { CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY, make } from "./catalog-store"; | |
| 16 | import { MobileSecureStorage } from "../persistence/mobile-secure-storage"; | |
| 10 | 17 | |
| 11 | 18 | function makeStorage(initial: Readonly<Record<string, string>>) { |
| 12 | 19 | const values = new Map(Object.entries(initial)); |
| 13 | 20 | const deleted: Array<string> = []; |
| 14 | const storage: SecureCatalogStorage = { | |
| 21 | const storage = MobileSecureStorage.of({ | |
| 15 | 22 | getItem: (key) => Effect.sync(() => values.get(key) ?? null), |
| 16 | 23 | setItem: (key, value) => |
| 17 | 24 | Effect.sync(() => { |
| 18 | 25 | values.set(key, value); |
| 19 | 26 | }), |
| 20 | deleteItem: (key) => | |
| 27 | removeItem: (key) => | |
| 21 | 28 | Effect.sync(() => { |
| 22 | 29 | deleted.push(key); |
| 23 | 30 | values.delete(key); |
| 24 | 31 | }), |
| 25 | }; | |
| 32 | }); | |
| 26 | 33 | return { deleted, storage, values }; |
| 27 | 34 | } |
| 28 | 35 | |
| ... | ... | @@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => { |
| 32 | 39 | const memory = makeStorage({ |
| 33 | 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 | ); | |
| 36 | 45 | |
| 37 | 46 | expect((yield* catalog.read).targets).toEqual([]); |
| 38 | 47 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); |
| ... | ... | @@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => { |
| 44 | 53 | const memory = makeStorage({ |
| 45 | 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 | ); | |
| 48 | 59 | |
| 49 | 60 | expect((yield* catalog.read).targets).toEqual([]); |
| 50 | 61 | expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); |
| ... | ... | @@ -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 | ); | |
| 75 | 88 | |
| 76 | 89 | expect((yield* catalog.read).targets).toHaveLength(1); |
| 77 | 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 | 2 | ConnectionPersistenceError, |
| 3 | 3 | ConnectionRegistrationStore, |
| 4 | 4 | ConnectionTargetStore, |
| 5 | EnvironmentCacheStore, | |
| 6 | 5 | registerConnectionInCatalog, |
| 7 | 6 | removeConnectionFromCatalog, |
| 8 | 7 | removeCatalogValue, |
| ... | ... | @@ -14,111 +13,11 @@ import { |
| 14 | 13 | CredentialStore, |
| 15 | 14 | ProfileStore, |
| 16 | 15 | } from "@t3tools/client-runtime/connection"; |
| 17 | import { | |
| 18 | EnvironmentId, | |
| 19 | OrchestrationShellSnapshot, | |
| 20 | OrchestrationThreadDetailSnapshot, | |
| 21 | ThreadId, | |
| 22 | } from "@t3tools/contracts"; | |
| 23 | 16 | import * as Context from "effect/Context"; |
| 24 | 17 | import * as Effect from "effect/Effect"; |
| 25 | 18 | import * as Layer from "effect/Layer"; |
| 26 | 19 | import * as Option from "effect/Option"; |
| 27 | import * as Schema from "effect/Schema"; | |
| 28 | import * as SecureStore from "expo-secure-store"; | |
| 29 | ||
| 30 | import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store"; | |
| 31 | ||
| 32 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | |
| 33 | const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots"; | |
| 34 | const 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. | |
| 38 | const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2; | |
| 39 | const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots"; | |
| 40 | ||
| 41 | const StoredShellSnapshot = Schema.Struct({ | |
| 42 | schemaVersion: Schema.Literal(SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION), | |
| 43 | environmentId: EnvironmentId, | |
| 44 | snapshot: OrchestrationShellSnapshot, | |
| 45 | }); | |
| 46 | ||
| 47 | const StoredThreadSnapshot = Schema.Struct({ | |
| 48 | schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION), | |
| 49 | environmentId: EnvironmentId, | |
| 50 | threadId: ThreadId, | |
| 51 | snapshot: OrchestrationThreadDetailSnapshot, | |
| 52 | }); | |
| 53 | ||
| 54 | const LegacyStoredShellSnapshot = Schema.Struct({ | |
| 55 | schemaVersion: Schema.Literal(1), | |
| 56 | environmentId: EnvironmentId, | |
| 57 | snapshotReceivedAt: Schema.String, | |
| 58 | snapshot: OrchestrationShellSnapshot, | |
| 59 | }); | |
| 60 | ||
| 61 | function 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 | ||
| 68 | function 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 | ||
| 84 | function threadSnapshotFileName(threadId: ThreadId): string { | |
| 85 | return `${encodeURIComponent(threadId)}.json`; | |
| 86 | } | |
| 87 | ||
| 88 | const 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 | ||
| 111 | const 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 | }); | |
| 20 | import * as CatalogStore from "./catalog-store"; | |
| 122 | 21 | |
| 123 | 22 | function targetPersistenceError( |
| 124 | 23 | operation: "list-targets" | "register-connection" | "remove-connection", |
| ... | ... | @@ -130,59 +29,9 @@ function targetPersistenceError( |
| 130 | 29 | }); |
| 131 | 30 | } |
| 132 | 31 | |
| 133 | const 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 | ||
| 151 | function shellSnapshotFileName(environmentId: EnvironmentId): string { | |
| 152 | return `${encodeURIComponent(environmentId)}.json`; | |
| 153 | } | |
| 154 | ||
| 155 | const 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 | ||
| 173 | const shellSnapshotFile = ( | |
| 174 | environmentId: EnvironmentId, | |
| 175 | operation: "load-shell" | "save-shell" | "clear-environment", | |
| 176 | ) => shellSnapshotFileInDirectory(environmentId, operation, SHELL_SNAPSHOT_CACHE_DIRECTORY); | |
| 177 | ||
| 178 | const legacyShellSnapshotFile = ( | |
| 179 | environmentId: EnvironmentId, | |
| 180 | operation: "load-shell" | "clear-environment", | |
| 181 | ) => shellSnapshotFileInDirectory(environmentId, operation, LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY); | |
| 182 | ||
| 183 | 32 | export const connectionStorageLayer = Layer.effectContext( |
| 184 | 33 | Effect.gen(function* () { |
| 185 | const catalog = yield* makeCatalogStore(secureCatalogStorage); | |
| 34 | const catalog = yield* CatalogStore.make(); | |
| 186 | 35 | |
| 187 | 36 | const targetStore = ConnectionTargetStore.of({ |
| 188 | 37 | list: catalog.read.pipe( |
| ... | ... | @@ -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 | 131 | return Context.make(ConnectionTargetStore, targetStore).pipe( |
| 428 | 132 | Context.add(ConnectionRegistrationStore, registrationStore), |
| 429 | 133 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), |
| 430 | 134 | Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), |
| 431 | 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 | 7 | import { HttpClient } from "effect/unstable/http"; |
| 8 | 8 | |
| 9 | 9 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 10 | import { savePreferencesPatch } from "../../lib/storage"; | |
| 11 | import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; | |
| 10 | import { MobileStorage } from "../../persistence/mobile-storage"; | |
| 11 | import { | |
| 12 | CloudEnvironmentLinkError, | |
| 13 | linkEnvironmentToCloudWithPreference, | |
| 14 | } from "../cloud/linkEnvironment"; | |
| 12 | 15 | import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; |
| 13 | import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; | |
| 16 | import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; | |
| 14 | 17 | |
| 15 | vi.mock("../../lib/storage", () => ({ | |
| 16 | savePreferencesPatch: vi.fn(() => Promise.resolve()), | |
| 18 | vi.mock("expo-secure-store", () => ({ | |
| 19 | deleteItemAsync: vi.fn(), | |
| 20 | getItemAsync: vi.fn(), | |
| 21 | setItemAsync: vi.fn(), | |
| 22 | })); | |
| 23 | ||
| 24 | vi.mock("react-native", () => ({ | |
| 25 | Platform: { OS: "ios" }, | |
| 17 | 26 | })); |
| 18 | 27 | |
| 19 | 28 | vi.mock("../cloud/linkEnvironment", () => ({ |
| 20 | linkEnvironmentToCloud: vi.fn(() => Effect.void), | |
| 29 | linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), | |
| 21 | 30 | })); |
| 22 | 31 | |
| 23 | 32 | vi.mock("./remoteRegistration", () => ({ |
| 24 | refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), | |
| 33 | updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), | |
| 25 | 34 | })); |
| 26 | 35 | |
| 27 | 36 | const connection: SavedRemoteConnection = { |
| ... | ... | @@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll( |
| 40 | 49 | HttpClient.HttpClient, |
| 41 | 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 | ); |
| 44 | 66 | |
| 45 | 67 | describe("liveActivityPreferences", () => { |
| ... | ... | @@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => { |
| 51 | 73 | Effect.gen(function* () { |
| 52 | 74 | yield* setLiveActivityUpdatesEnabled({ |
| 53 | 75 | enabled: false, |
| 76 | previousEnabled: true, | |
| 54 | 77 | clerkToken: "clerk-token", |
| 55 | 78 | connections: [connection], |
| 56 | 79 | }); |
| 57 | 80 | |
| 58 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | |
| 59 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | |
| 60 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | |
| 81 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ | |
| 82 | liveActivitiesEnabled: false, | |
| 83 | }); | |
| 84 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ | |
| 61 | 85 | clerkToken: "clerk-token", |
| 62 | 86 | connection, |
| 87 | liveActivitiesEnabled: false, | |
| 63 | 88 | }); |
| 64 | 89 | }).pipe(Effect.provide(testLayer)), |
| 65 | 90 | ); |
| ... | ... | @@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => { |
| 68 | 93 | Effect.gen(function* () { |
| 69 | 94 | yield* setLiveActivityUpdatesEnabled({ |
| 70 | 95 | enabled: true, |
| 96 | previousEnabled: false, | |
| 71 | 97 | clerkToken: "clerk-token", |
| 72 | 98 | connections: [connection], |
| 73 | 99 | }); |
| 74 | 100 | |
| 75 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); | |
| 76 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | |
| 77 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | |
| 101 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ | |
| 102 | liveActivitiesEnabled: true, | |
| 103 | }); | |
| 104 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ | |
| 78 | 105 | clerkToken: "clerk-token", |
| 79 | 106 | connection, |
| 107 | liveActivitiesEnabled: true, | |
| 80 | 108 | }); |
| 81 | 109 | }).pipe(Effect.provide(testLayer)), |
| 82 | 110 | ); |
| ... | ... | @@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => { |
| 85 | 113 | Effect.gen(function* () { |
| 86 | 114 | yield* setLiveActivityUpdatesEnabled({ |
| 87 | 115 | enabled: false, |
| 116 | previousEnabled: true, | |
| 88 | 117 | clerkToken: null, |
| 89 | 118 | connections: [connection], |
| 90 | 119 | }); |
| 91 | 120 | |
| 92 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | |
| 93 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | |
| 94 | expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); | |
| 121 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ | |
| 122 | liveActivitiesEnabled: false, | |
| 123 | }); | |
| 124 | expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); | |
| 95 | 125 | }).pipe(Effect.provide(testLayer)), |
| 96 | 126 | ); |
| 97 | 127 | |
| ... | ... | @@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => { |
| 104 | 134 | return Effect.gen(function* () { |
| 105 | 135 | yield* setLiveActivityUpdatesEnabled({ |
| 106 | 136 | enabled: true, |
| 137 | previousEnabled: false, | |
| 107 | 138 | clerkToken: "clerk-token", |
| 108 | 139 | connections: [connection, managedConnection], |
| 109 | 140 | }); |
| 110 | 141 | |
| 111 | expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); | |
| 112 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | |
| 142 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); | |
| 143 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ | |
| 144 | clerkToken: "clerk-token", | |
| 145 | connection, | |
| 146 | liveActivitiesEnabled: true, | |
| 147 | }); | |
| 148 | }).pipe(Effect.provide(testLayer)); | |
| 149 | }); | |
| 150 | ||
| 151 | it.effect("restores relay preferences when an environment update fails", () => { | |
| 152 | vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => | |
| 153 | Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), | |
| 154 | ); | |
| 155 | ||
| 156 | return Effect.gen(function* () { | |
| 157 | const exit = yield* Effect.exit( | |
| 158 | setLiveActivityUpdatesEnabled({ | |
| 159 | enabled: false, | |
| 160 | previousEnabled: true, | |
| 161 | clerkToken: "clerk-token", | |
| 162 | connections: [connection], | |
| 163 | }), | |
| 164 | ); | |
| 165 | ||
| 166 | expect(exit._tag).toBe("Failure"); | |
| 167 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { | |
| 168 | liveActivitiesEnabled: false, | |
| 169 | }); | |
| 170 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { | |
| 171 | liveActivitiesEnabled: true, | |
| 172 | }); | |
| 173 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { | |
| 174 | clerkToken: "clerk-token", | |
| 175 | connection, | |
| 176 | liveActivitiesEnabled: false, | |
| 177 | }); | |
| 178 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { | |
| 113 | 179 | clerkToken: "clerk-token", |
| 114 | 180 | connection, |
| 181 | liveActivitiesEnabled: true, | |
| 115 | 182 | }); |
| 116 | 183 | }).pipe(Effect.provide(testLayer)); |
| 117 | 184 | }); |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+65-39| ... | ... | @@ -1,47 +1,73 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | import { HttpClient } from "effect/unstable/http"; | |
| 4 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; | |
| 5 | 2 | |
| 6 | 3 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 7 | import { savePreferencesPatch } from "../../lib/storage"; | |
| 8 | import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; | |
| 9 | import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; | |
| 10 | ||
| 11 | export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass<LiveActivityPreferenceSaveError>()( | |
| 12 | "LiveActivityPreferenceSaveError", | |
| 13 | { | |
| 14 | enabled: Schema.Boolean, | |
| 15 | cause: Schema.Defect(), | |
| 16 | }, | |
| 17 | ) { | |
| 18 | override get message(): string { | |
| 19 | return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | export function setLiveActivityUpdatesEnabled(input: { | |
| 24 | readonly enabled: boolean; | |
| 25 | readonly clerkToken: string | null; | |
| 26 | readonly connections: ReadonlyArray<SavedRemoteConnection>; | |
| 27 | }): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient> { | |
| 28 | return Effect.gen(function* () { | |
| 29 | yield* Effect.tryPromise({ | |
| 30 | try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), | |
| 31 | catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), | |
| 4 | import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; | |
| 5 | import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; | |
| 6 | ||
| 7 | export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( | |
| 8 | function* (input: { | |
| 9 | readonly enabled: boolean; | |
| 10 | readonly previousEnabled: boolean; | |
| 11 | readonly clerkToken: string | null; | |
| 12 | readonly connections: ReadonlyArray<SavedRemoteConnection>; | |
| 13 | }) { | |
| 14 | const linkedConnections = input.connections.filter( | |
| 15 | (connection) => connection.bearerToken !== null, | |
| 16 | ); | |
| 17 | ||
| 18 | const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { | |
| 19 | yield* updateAgentAwarenessRegistrationPreferences({ | |
| 20 | liveActivitiesEnabled: enabled, | |
| 21 | }); | |
| 22 | ||
| 23 | const clerkToken = input.clerkToken; | |
| 24 | if (!clerkToken) return; | |
| 25 | ||
| 26 | yield* Effect.forEach( | |
| 27 | linkedConnections, | |
| 28 | (connection) => | |
| 29 | linkEnvironmentToCloudWithPreference({ | |
| 30 | clerkToken, | |
| 31 | connection, | |
| 32 | liveActivitiesEnabled: enabled, | |
| 33 | }), | |
| 34 | { concurrency: "unbounded" }, | |
| 35 | ); | |
| 32 | 36 | }); |
| 33 | 37 | |
| 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; | |
| 35 | 49 | |
| 36 | const clerkToken = input.clerkToken; | |
| 37 | if (!clerkToken) { | |
| 38 | return; | |
| 39 | } | |
| 50 | yield* Effect.forEach( | |
| 51 | linkedConnections, | |
| 52 | (connection) => | |
| 53 | linkEnvironmentToCloudWithPreference({ | |
| 54 | clerkToken, | |
| 55 | connection, | |
| 56 | liveActivitiesEnabled: input.previousEnabled, | |
| 57 | }).pipe( | |
| 58 | Effect.catchCause((cause) => | |
| 59 | Effect.logWarning( | |
| 60 | `Could not restore Live Activity preference for environment ${connection.environmentId}.`, | |
| 61 | cause, | |
| 62 | ), | |
| 63 | ), | |
| 64 | ), | |
| 65 | { concurrency: "unbounded" }, | |
| 66 | ); | |
| 67 | }); | |
| 40 | 68 | |
| 41 | yield* Effect.forEach( | |
| 42 | input.connections.filter((connection) => connection.bearerToken !== null), | |
| 43 | (connection) => linkEnvironmentToCloud({ clerkToken, connection }), | |
| 44 | { concurrency: "unbounded" }, | |
| 69 | yield* updateRelayPreference(input.enabled).pipe( | |
| 70 | Effect.onError(() => restoreRelayPreference()), | |
| 45 | 71 | ); |
| 46 | }); | |
| 47 | } | |
| 72 | }, | |
| 73 | ); |
apps/mobile/src/features/agent-awareness/registrationPayload.ts+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; |
| 2 | 2 | |
| 3 | import type { Preferences } from "../../lib/storage"; | |
| 3 | import type { Preferences } from "../../persistence/mobile-preferences"; | |
| 4 | 4 | |
| 5 | 5 | // Development builds are Xcode-signed and receive sandbox APNs tokens; |
| 6 | 6 | // 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 | 21 | loadAgentAwarenessRegistrationRecord, |
| 22 | 22 | loadOrCreateAgentAwarenessDeviceId, |
| 23 | 23 | saveAgentAwarenessRegistrationRecord, |
| 24 | } from "../../lib/storage"; | |
| 24 | } from "../../persistence/imperative"; | |
| 25 | 25 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 26 | 26 | import { |
| 27 | 27 | AgentAwarenessOperationError, |
| 28 | 28 | __resetAgentAwarenessRemoteRegistrationForTest, |
| 29 | 29 | getAgentAwarenessRegistrationStatus, |
| 30 | mergeAgentAwarenessRegistrationPreferences, | |
| 30 | 31 | refreshActiveLiveActivityRemoteRegistration, |
| 31 | 32 | refreshAgentAwarenessRegistration, |
| 32 | 33 | normalizeAgentAwarenessRelayBaseUrl, |
| ... | ... | @@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({ |
| 142 | 143 | }, |
| 143 | 144 | })); |
| 144 | 145 | |
| 145 | vi.mock("../../lib/storage", () => ({ | |
| 146 | vi.mock("../../persistence/imperative", () => ({ | |
| 146 | 147 | loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), |
| 147 | 148 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), |
| 148 | 149 | loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), |
| ... | ... | @@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 324 | 325 | expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); |
| 325 | 326 | }); |
| 326 | 327 | |
| 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 | 337 | it.effect("registers at most one listener while a Live Activity push token is pending", () => { |
| 328 | 338 | registerAgentAwarenessConnection(savedConnection()); |
| 329 | 339 | const addPushTokenListener = vi.fn(); |
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+34-3| ... | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | |
| 21 | 21 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 22 | 22 | import { runtime } from "../../lib/runtime"; |
| 23 | import type { Preferences } from "../../persistence/mobile-preferences"; | |
| 23 | 24 | import { |
| 24 | 25 | clearAgentAwarenessRegistrationRecord, |
| 25 | 26 | loadAgentAwarenessDeviceId, |
| ... | ... | @@ -27,7 +28,7 @@ import { |
| 27 | 28 | loadOrCreateAgentAwarenessDeviceId, |
| 28 | 29 | loadPreferences, |
| 29 | 30 | saveAgentAwarenessRegistrationRecord, |
| 30 | } from "../../lib/storage"; | |
| 31 | } from "../../persistence/imperative"; | |
| 31 | 32 | import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; |
| 32 | 33 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; |
| 33 | 34 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| ... | ... | @@ -124,6 +125,17 @@ interface DeviceRegistrationInput { |
| 124 | 125 | readonly observedPushToken?: string; |
| 125 | 126 | } |
| 126 | 127 | |
| 128 | interface RegisterDeviceInput extends DeviceRegistrationInput { | |
| 129 | readonly preferencesOverride?: Partial<Preferences>; | |
| 130 | } | |
| 131 | ||
| 132 | export function mergeAgentAwarenessRegistrationPreferences( | |
| 133 | stored: Preferences, | |
| 134 | override: Partial<Preferences> | undefined, | |
| 135 | ): Preferences { | |
| 136 | return { ...stored, ...override }; | |
| 137 | } | |
| 138 | ||
| 127 | 139 | export function normalizeAgentAwarenessRelayBaseUrl( |
| 128 | 140 | value: string | null | undefined, |
| 129 | 141 | ): string | null { |
| ... | ... | @@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri |
| 655 | 667 | } |
| 656 | 668 | |
| 657 | 669 | function registerDevice( |
| 658 | input: DeviceRegistrationInput = {}, | |
| 670 | input: RegisterDeviceInput = {}, | |
| 659 | 671 | expectedGeneration = deviceRegistrationGeneration, |
| 660 | 672 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { |
| 661 | 673 | return Effect.gen(function* () { |
| ... | ... | @@ -665,7 +677,7 @@ function registerDevice( |
| 665 | 677 | } |
| 666 | 678 | |
| 667 | 679 | logRegistrationDebug("device registration loading local state", { expectedGeneration }); |
| 668 | const [deviceId, preferences] = yield* Effect.all([ | |
| 680 | const [deviceId, storedPreferences] = yield* Effect.all([ | |
| 669 | 681 | Effect.tryPromise({ |
| 670 | 682 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| 671 | 683 | catch: (cause) => |
| ... | ... | @@ -683,6 +695,10 @@ function registerDevice( |
| 683 | 695 | }), |
| 684 | 696 | }), |
| 685 | 697 | ]); |
| 698 | const preferences = mergeAgentAwarenessRegistrationPreferences( | |
| 699 | storedPreferences, | |
| 700 | input.preferencesOverride, | |
| 701 | ); | |
| 686 | 702 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); |
| 687 | 703 | logRegistrationDebug("device registration local state ready", { |
| 688 | 704 | expectedGeneration, |
| ... | ... | @@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< |
| 820 | 836 | ); |
| 821 | 837 | } |
| 822 | 838 | |
| 839 | export 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 | ||
| 823 | 854 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { |
| 824 | 855 | environmentConnections.clear(); |
| 825 | 856 | pushTokenSubscription?.remove(); |
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | import { loadPreferences, updatePreferences } from "../../lib/storage"; | |
| 1 | import { loadPreferences, updatePreferences } from "../../persistence/imperative"; | |
| 2 | 2 | |
| 3 | 3 | // Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports |
| 4 | // the request signal) never pulls lib/storage — expo-secure-store — into its | |
| 4 | // the request signal) never pulls the persistence adapter into its | |
| 5 | 5 | // module graph; that breaks CloudAuthProvider.test.ts suite loading. |
| 6 | 6 | |
| 7 | 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 | 7 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; |
| 8 | 8 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 9 | 9 | import { HttpClient } from "effect/unstable/http"; |
| 10 | import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; | |
| 11 | import { MobileStorage } from "../../persistence/mobile-storage"; | |
| 10 | 12 | |
| 11 | 13 | import { |
| 12 | 14 | cloudEnvironmentsPendingStatus, |
| 13 | 15 | linkEnvironmentToCloud, |
| 16 | linkEnvironmentToCloudWithPreference, | |
| 14 | 17 | connectCloudEnvironment, |
| 15 | 18 | listCloudEnvironments, |
| 16 | 19 | listCloudEnvironmentsWithStatus, |
| ... | ... | @@ -36,11 +39,14 @@ vi.mock("react-native", () => ({ |
| 36 | 39 | }, |
| 37 | 40 | })); |
| 38 | 41 | |
| 39 | vi.mock("../../lib/storage", () => ({ | |
| 40 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | |
| 41 | loadPreferences: vi.fn(() => Promise.resolve({})), | |
| 42 | vi.mock("expo-secure-store", () => ({ | |
| 43 | deleteItemAsync: vi.fn(), | |
| 44 | getItemAsync: vi.fn(), | |
| 45 | setItemAsync: vi.fn(), | |
| 42 | 46 | })); |
| 43 | 47 | |
| 48 | const loadPreferences = vi.fn(() => Effect.succeed({})); | |
| 49 | ||
| 44 | 50 | const savedConnection = { |
| 45 | 51 | environmentId: EnvironmentId.make("env-1"), |
| 46 | 52 | environmentLabel: "Desktop", |
| ... | ... | @@ -69,6 +75,27 @@ function cloudClientLayer() { |
| 69 | 75 | const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); |
| 70 | 76 | return Layer.mergeAll( |
| 71 | 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 | 99 | ManagedRelay.layer({ |
| 73 | 100 | relayUrl: "https://relay.example.test", |
| 74 | 101 | clientId: RelayMobileClientId, |
| ... | ... | @@ -80,7 +107,11 @@ const withCloudServices = <A, E>( |
| 80 | 107 | effect: Effect.Effect< |
| 81 | 108 | A, |
| 82 | 109 | E, |
| 83 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner | |
| 110 | | HttpClient.HttpClient | |
| 111 | | ManagedRelay.ManagedRelayClient | |
| 112 | | ManagedRelay.ManagedRelayDpopSigner | |
| 113 | | MobilePreferencesStore | |
| 114 | | MobileStorage | |
| 84 | 115 | >, |
| 85 | 116 | ) => effect.pipe(Effect.provide(cloudClientLayer())); |
| 86 | 117 | |
| ... | ... | @@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => { |
| 146 | 177 | beforeEach(() => { |
| 147 | 178 | vi.restoreAllMocks(); |
| 148 | 179 | createProofMock.mockClear(); |
| 180 | loadPreferences.mockClear(); | |
| 149 | 181 | }); |
| 150 | 182 | |
| 151 | 183 | it("normalizes configured relay base URLs before building DPoP-bound requests", () => { |
| ... | ... | @@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => { |
| 705 | 737 | |
| 706 | 738 | it.effect("preserves disabled Live Activity preferences when linking an environment", () => |
| 707 | 739 | Effect.gen(function* () { |
| 708 | const storage = yield* Effect.promise(() => import("../../lib/storage")); | |
| 709 | vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ | |
| 710 | liveActivitiesEnabled: false, | |
| 711 | }); | |
| 740 | loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false })); | |
| 712 | 741 | const bodies: Array<unknown> = []; |
| 713 | 742 | const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { |
| 714 | 743 | if (init?.body) { |
| ... | ... | @@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => { |
| 761 | 790 | }), |
| 762 | 791 | ); |
| 763 | 792 | |
| 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 | 832 | it.effect( |
| 765 | 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 | 30 | |
| 31 | 31 | import { authClientMetadata } from "../../lib/authClientMetadata"; |
| 32 | 32 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 33 | import { loadOrCreateAgentAwarenessDeviceId, loadPreferences } from "../../lib/storage"; | |
| 33 | import * as MobilePreferences from "../../persistence/mobile-preferences"; | |
| 34 | import * as MobileStorage from "../../persistence/mobile-storage"; | |
| 34 | 35 | import { resolveCloudPublicConfig } from "./publicConfig"; |
| 35 | 36 | |
| 36 | 37 | const RELAY_STATUS_AND_CONNECT_SCOPES = [ |
| ... | ... | @@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { |
| 256 | 257 | return Effect.void; |
| 257 | 258 | } |
| 258 | 259 | |
| 259 | export function linkEnvironmentToCloud(input: { | |
| 260 | interface LinkEnvironmentToCloudInput { | |
| 260 | 261 | readonly connection: SavedRemoteConnection; |
| 261 | 262 | readonly clerkToken: string; |
| 262 | }): Effect.Effect< | |
| 263 | void, | |
| 264 | CloudEnvironmentLinkError, | |
| 265 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | |
| 266 | > { | |
| 263 | } | |
| 264 | ||
| 265 | type LinkEnvironmentToCloudRequirements = | |
| 266 | | HttpClient.HttpClient | |
| 267 | | ManagedRelay.ManagedRelayClient | |
| 268 | | MobileStorage.MobileStorage; | |
| 269 | ||
| 270 | export function linkEnvironmentToCloudWithPreference( | |
| 271 | input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, | |
| 272 | ): Effect.Effect<void, CloudEnvironmentLinkError, LinkEnvironmentToCloudRequirements> { | |
| 267 | 273 | return Effect.gen(function* () { |
| 268 | 274 | if (!input.connection.bearerToken) { |
| 269 | 275 | return yield* new CloudEnvironmentLinkError({ |
| ... | ... | @@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: { |
| 273 | 279 | const localBearerToken = input.connection.bearerToken; |
| 274 | 280 | const relayUrl = yield* requireRelayUrl(); |
| 275 | 281 | const relayClient = yield* ManagedRelay.ManagedRelayClient; |
| 276 | const deviceId = yield* Effect.tryPromise({ | |
| 277 | try: () => loadOrCreateAgentAwarenessDeviceId(), | |
| 278 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | |
| 279 | }); | |
| 280 | const preferences = yield* Effect.tryPromise({ | |
| 281 | try: () => loadPreferences(), | |
| 282 | catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), | |
| 283 | }); | |
| 284 | const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; | |
| 282 | const storage = yield* MobileStorage.MobileStorage; | |
| 283 | const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( | |
| 284 | Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), | |
| 285 | ); | |
| 286 | const liveActivitiesEnabled = input.liveActivitiesEnabled; | |
| 285 | 287 | const challenge = yield* relayClient |
| 286 | 288 | .createEnvironmentLinkChallenge({ |
| 287 | 289 | clerkToken: input.clerkToken, |
| ... | ... | @@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: { |
| 350 | 352 | }); |
| 351 | 353 | } |
| 352 | 354 | |
| 355 | export 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 | ||
| 353 | 374 | export function listCloudEnvironments(input: { |
| 354 | 375 | readonly clerkToken: string; |
| 355 | 376 | }): Effect.Effect< |
| ... | ... | @@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: { |
| 460 | 481 | |
| 461 | 482 | const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( |
| 462 | 483 | function* () { |
| 463 | return yield* Effect.tryPromise({ | |
| 464 | try: () => loadOrCreateAgentAwarenessDeviceId(), | |
| 465 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | |
| 466 | }); | |
| 484 | const storage = yield* MobileStorage.MobileStorage; | |
| 485 | return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( | |
| 486 | Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), | |
| 487 | ); | |
| 467 | 488 | }, |
| 468 | 489 | ); |
| 469 | 490 | |
| ... | ... | @@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: { |
| 557 | 578 | }): Effect.Effect< |
| 558 | 579 | SavedRemoteConnection, |
| 559 | 580 | CloudEnvironmentLinkError, |
| 560 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner | |
| 581 | | HttpClient.HttpClient | |
| 582 | | ManagedRelay.ManagedRelayClient | |
| 583 | | ManagedRelay.ManagedRelayDpopSigner | |
| 584 | | MobileStorage.MobileStorage | |
| 561 | 585 | > { |
| 562 | 586 | return connectRelayManagedEnvironment({ |
| 563 | 587 | clerkToken: input.clerkToken, |
| ... | ... | @@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: { |
| 572 | 596 | }): Effect.Effect< |
| 573 | 597 | SavedRemoteConnection, |
| 574 | 598 | CloudEnvironmentLinkError, |
| 575 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner | |
| 599 | | HttpClient.HttpClient | |
| 600 | | ManagedRelay.ManagedRelayClient | |
| 601 | | ManagedRelay.ManagedRelayDpopSigner | |
| 602 | | MobileStorage.MobileStorage | |
| 576 | 603 | > { |
| 577 | 604 | return connectRelayManagedEnvironment({ |
| 578 | 605 | clerkToken: input.clerkToken, |
apps/mobile/src/features/home/HomeScreen.tsx+41-11| ... | ... | @@ -12,6 +12,8 @@ import type { |
| 12 | 12 | SidebarProjectGroupingMode, |
| 13 | 13 | SidebarThreadSortOrder, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 15 | 17 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 16 | 18 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 17 | 19 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| ... | ... | @@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; |
| 22 | 24 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 23 | 25 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 24 | 26 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 27 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 25 | 28 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 26 | 29 | import { |
| 27 | 30 | PendingTaskListRow, |
| ... | ... | @@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) { |
| 154 | 157 | const [groupDisplayStates, setGroupDisplayStates] = useState< |
| 155 | 158 | ReadonlyMap<string, HomeGroupDisplayState> |
| 156 | 159 | >(() => new Map()); |
| 160 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | |
| 161 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 157 | 162 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 158 | 163 | const listRef = useRef<LegendListRef | null>(null); |
| 159 | 164 | const insets = useSafeAreaInsets(); |
| 160 | 165 | const accentColor = useThemeColor("--color-icon-muted"); |
| 161 | 166 | |
| 162 | const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { | |
| 163 | setGroupDisplayStates((previous) => { | |
| 164 | const next = new Map(previous); | |
| 165 | next.set( | |
| 166 | key, | |
| 167 | nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | |
| 168 | ); | |
| 167 | const effectiveGroupDisplayStates = useMemo(() => { | |
| 168 | const next = new Map(groupDisplayStates); | |
| 169 | if (!AsyncResult.isSuccess(preferencesResult)) { | |
| 169 | 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 | ); | |
| 172 | 202 | |
| 173 | 203 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| 174 | 204 | if (openSwipeableRef.current !== methods) { |
| ... | ... | @@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 219 | 249 | () => |
| 220 | 250 | buildHomeListLayout({ |
| 221 | 251 | groups: projectGroups, |
| 222 | displayStates: groupDisplayStates, | |
| 252 | displayStates: effectiveGroupDisplayStates, | |
| 223 | 253 | showAllThreads: hasSearchQuery, |
| 224 | 254 | }), |
| 225 | [projectGroups, groupDisplayStates, hasSearchQuery], | |
| 255 | [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], | |
| 226 | 256 | ); |
| 227 | 257 | |
| 228 | 258 | const projectCwdByKey = useMemo(() => { |
apps/mobile/src/features/settings/SettingsClientStorageRouteScreen.tsx created+225| ... | ... | @@ -0,0 +1,225 @@ |
| 1 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 2 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 3 | import { SymbolView } from "expo-symbols"; | |
| 4 | import { useMemo } from "react"; | |
| 5 | import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native"; | |
| 6 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 7 | ||
| 8 | import { AppText as Text } from "../../components/AppText"; | |
| 9 | import { useThemeColor } from "../../lib/useThemeColor"; | |
| 10 | import { | |
| 11 | clearClientCacheAtom, | |
| 12 | clientCacheSummaryAtom, | |
| 13 | type EnvironmentClientCacheSummary, | |
| 14 | } from "../../state/client-cache-state"; | |
| 15 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | |
| 16 | import { SettingsSection } from "./components/SettingsSection"; | |
| 17 | ||
| 18 | export 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 | ||
| 175 | function 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 | ||
| 221 | function 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 | 1 | import { useAuth, useUser } from "@clerk/expo"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 2 | 3 | import Constants from "expo-constants"; |
| 3 | 4 | import * as Notifications from "expo-notifications"; |
| 4 | 5 | import * as Updates from "expo-updates"; |
| ... | ... | @@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native"; |
| 6 | 7 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | 8 | import { SymbolView } from "expo-symbols"; |
| 8 | 9 | import * as Effect from "effect/Effect"; |
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 9 | 11 | import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; |
| 10 | 12 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; |
| 11 | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | ... | @@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu |
| 31 | 33 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 32 | 34 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 33 | 35 | import { runtime } from "../../lib/runtime"; |
| 34 | import { loadPreferences } from "../../lib/storage"; | |
| 35 | 36 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 37 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | |
| 36 | 38 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 37 | 39 | import { SettingsRow } from "./components/SettingsRow"; |
| 38 | 40 | import { SettingsSection } from "./components/SettingsSection"; |
| ... | ... | @@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() { |
| 122 | 124 | } |
| 123 | 125 | |
| 124 | 126 | function ConfiguredSettingsRouteScreen() { |
| 127 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | |
| 128 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 125 | 129 | const insets = useSafeAreaInsets(); |
| 126 | 130 | const navigation = useNavigation(); |
| 127 | 131 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); |
| ... | ... | @@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() { |
| 131 | 135 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); |
| 132 | 136 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); |
| 133 | 137 | const deviceRegistered = useDeviceRegistered(); |
| 138 | const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) | |
| 139 | ? preferencesResult.value.liveActivitiesEnabled !== false | |
| 140 | : true; | |
| 134 | 141 | |
| 135 | 142 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); |
| 136 | 143 | const environmentCount = connections.length; |
| ... | ... | @@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() { |
| 167 | 174 | setLiveActivityStatus("signed-out"); |
| 168 | 175 | return; |
| 169 | 176 | } |
| 170 | void (async () => { | |
| 171 | const result = await settlePromise(() => loadPreferences()); | |
| 172 | if (result._tag === "Failure") { | |
| 173 | reportAtomCommandResult(result, { label: "live activity preference load" }); | |
| 177 | if (!AsyncResult.isSuccess(preferencesResult)) { | |
| 178 | if (AsyncResult.isFailure(preferencesResult)) { | |
| 179 | reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); | |
| 174 | 180 | setLiveActivityStatus("enabled"); |
| 175 | return; | |
| 181 | } else { | |
| 182 | setLiveActivityStatus("checking"); | |
| 176 | 183 | } |
| 177 | setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); | |
| 178 | })(); | |
| 179 | }, [isLoaded, isSignedIn]); | |
| 184 | return; | |
| 185 | } | |
| 186 | setLiveActivityStatus( | |
| 187 | preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", | |
| 188 | ); | |
| 189 | }, [isLoaded, isSignedIn, preferencesResult]); | |
| 180 | 190 | |
| 181 | 191 | const requestNotifications = useCallback(async () => { |
| 182 | 192 | const result = await settleAsyncResult(() => |
| ... | ... | @@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() { |
| 279 | 289 | runtime.runPromiseExit( |
| 280 | 290 | setLiveActivityUpdatesEnabled({ |
| 281 | 291 | enabled: true, |
| 292 | previousEnabled: liveActivitiesPreferenceEnabled, | |
| 282 | 293 | clerkToken: tokenResult.value, |
| 283 | 294 | connections, |
| 284 | 295 | }), |
| ... | ... | @@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() { |
| 296 | 307 | return; |
| 297 | 308 | } |
| 298 | 309 | |
| 310 | savePreferences({ liveActivitiesEnabled: true }); | |
| 299 | 311 | refreshManagedRelayEnvironments(); |
| 300 | 312 | setLiveActivityStatus("enabled"); |
| 301 | 313 | // The environment link can succeed while this device's own registration |
| ... | ... | @@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() { |
| 314 | 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 | ]); | |
| 318 | 338 | |
| 319 | 339 | const handleDeviceNotificationsChange = useCallback( |
| 320 | 340 | (enabled: boolean) => { |
| ... | ... | @@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() { |
| 358 | 378 | runtime.runPromiseExit( |
| 359 | 379 | setLiveActivityUpdatesEnabled({ |
| 360 | 380 | enabled: false, |
| 381 | previousEnabled: liveActivitiesPreferenceEnabled, | |
| 361 | 382 | clerkToken: token, |
| 362 | 383 | connections, |
| 363 | 384 | }), |
| 364 | 385 | ), |
| 365 | 386 | ); |
| 366 | 387 | if (updateResult._tag === "Failure") { |
| 388 | setLiveActivityStatus("enabled"); | |
| 367 | 389 | reportAtomCommandResult(updateResult, { |
| 368 | 390 | label: "live activity disable", |
| 369 | 391 | }); |
| 370 | 392 | return; |
| 371 | 393 | } |
| 394 | savePreferences({ liveActivitiesEnabled: false }); | |
| 372 | 395 | refreshManagedRelayEnvironments(); |
| 373 | 396 | })(); |
| 374 | 397 | return; |
| ... | ... | @@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() { |
| 381 | 404 | |
| 382 | 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 | ); |
| 386 | 417 | |
| 387 | 418 | const openAccount = useCallback(() => { |
| ... | ... | @@ -488,6 +519,7 @@ function AppSettingsSection() { |
| 488 | 519 | |
| 489 | 520 | return ( |
| 490 | 521 | <SettingsSection title="App"> |
| 522 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | |
| 491 | 523 | <View className="flex-row items-center gap-4 p-4"> |
| 492 | 524 | <SymbolView |
| 493 | 525 | name="info.circle" |
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+24-56| ... | ... | @@ -1,12 +1,7 @@ |
| 1 | import { | |
| 2 | createContext, | |
| 3 | useCallback, | |
| 4 | useContext, | |
| 5 | useEffect, | |
| 6 | useMemo, | |
| 7 | useState, | |
| 8 | type ReactNode, | |
| 9 | } from "react"; | |
| 1 | import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react"; | |
| 2 | ||
| 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | import { AsyncResult } from "effect/unstable/reactivity"; | |
| 10 | 5 | |
| 11 | 6 | import { Uniwind } from "uniwind"; |
| 12 | 7 | |
| ... | ... | @@ -17,7 +12,7 @@ import { |
| 17 | 12 | type AppearancePreferences, |
| 18 | 13 | type ResolvedAppearance, |
| 19 | 14 | } from "../../../lib/appearancePreferences"; |
| 20 | import { loadPreferences, savePreferencesPatch } from "../../../lib/storage"; | |
| 15 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; | |
| 21 | 16 | import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; |
| 22 | 17 | |
| 23 | 18 | interface AppearancePreferencesContextValue { |
| ... | ... | @@ -52,55 +47,28 @@ function applyTextScaleVariables(baseFontSize: number) { |
| 52 | 47 | } |
| 53 | 48 | |
| 54 | 49 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { |
| 55 | const [preferences, setPreferences] = useState<AppearancePreferences>(() => | |
| 56 | resolveAppearancePreferences(null), | |
| 50 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | |
| 51 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | |
| 52 | const preferences = useMemo( | |
| 53 | () => | |
| 54 | resolveAppearancePreferences( | |
| 55 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, | |
| 56 | ), | |
| 57 | [preferencesResult], | |
| 57 | 58 | ); |
| 58 | const [isReady, setIsReady] = useState(false); | |
| 59 | ||
| 60 | useEffect(() => { | |
| 61 | let cancelled = false; | |
| 62 | ||
| 63 | void loadPreferences() | |
| 64 | .then((stored) => { | |
| 65 | if (cancelled) { | |
| 66 | return; | |
| 67 | } | |
| 68 | ||
| 69 | const resolved = resolveAppearancePreferences(stored); | |
| 70 | setPreferences(resolved); | |
| 71 | cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); | |
| 72 | setIsReady(true); | |
| 73 | }) | |
| 74 | .catch(() => { | |
| 75 | if (cancelled) { | |
| 76 | return; | |
| 77 | } | |
| 78 | ||
| 79 | setIsReady(true); | |
| 80 | }); | |
| 81 | ||
| 82 | return () => { | |
| 83 | cancelled = true; | |
| 84 | }; | |
| 85 | }, []); | |
| 59 | const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; | |
| 86 | 60 | |
| 87 | 61 | useEffect(() => { |
| 88 | 62 | applyTextScaleVariables(preferences.baseFontSize); |
| 89 | }, [preferences.baseFontSize]); | |
| 90 | ||
| 91 | const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => { | |
| 92 | setPreferences((current) => { | |
| 93 | const next = resolveAppearancePreferences({ ...current, ...patch }); | |
| 94 | cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); | |
| 95 | void savePreferencesPatch({ | |
| 96 | baseFontSize: next.baseFontSize, | |
| 97 | terminalFontSize: next.terminalFontSize, | |
| 98 | codeFontSize: next.codeFontSize, | |
| 99 | codeWordBreak: next.codeWordBreak, | |
| 100 | }).catch(() => undefined); | |
| 101 | return next; | |
| 102 | }); | |
| 103 | }, []); | |
| 63 | cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); | |
| 64 | }, [preferences]); | |
| 65 | ||
| 66 | const updatePreferences = useCallback( | |
| 67 | (patch: Partial<AppearancePreferences>) => { | |
| 68 | savePreferences(patch); | |
| 69 | }, | |
| 70 | [savePreferences], | |
| 71 | ); | |
| 104 | 72 | |
| 105 | 73 | const setBaseFontSize = useCallback( |
| 106 | 74 | (value: number) => { |
| ... | ... | @@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 150 | 118 | } |
| 151 | 119 | |
| 152 | 120 | export function useAppearancePreferences(): AppearancePreferencesContextValue { |
| 153 | const context = useContext(AppearancePreferencesContext); | |
| 121 | const context = use(AppearancePreferencesContext); | |
| 154 | 122 | if (!context) { |
| 155 | 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 | export type SettingsSheetTarget = "SettingsEnvironments" | "SettingsArchive" | "SettingsAppearance"; | |
| 1 | export 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 | 8 | import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; |
| 9 | 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 10 | 10 | import { tracingLayer } from "../features/observability/tracing"; |
| 11 | import * as Persistence from "../persistence/layer"; | |
| 11 | 12 | |
| 12 | 13 | function configuredRelayUrl(): string { |
| 13 | 14 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; |
| ... | ... | @@ -20,6 +21,7 @@ type RuntimeLayerSource = |
| 20 | 21 | | typeof Socket.layerWebSocketConstructorGlobal |
| 21 | 22 | | typeof cryptoLayer |
| 22 | 23 | | typeof httpClientLayer |
| 24 | | typeof Persistence.layer | |
| 23 | 25 | | typeof tracingLayer; |
| 24 | 26 | |
| 25 | 27 | const runtimeLayer = Layer.merge( |
| ... | ... | @@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge( |
| 29 | 31 | Layer.provideMerge(cryptoLayer), |
| 30 | 32 | Layer.provideMerge(httpClientLayer), |
| 31 | 33 | Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), |
| 34 | Layer.provideMerge(Persistence.layer), | |
| 32 | 35 | ); |
| 33 | 36 | |
| 34 | 37 | export 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 | 3 | |
| 4 | 4 | const mocks = vi.hoisted(() => { |
| 5 | 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 | 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 | 28 | getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), |
| 9 | 29 | setItemAsync: vi.fn((key: string, value: string) => { |
| 10 | 30 | values.set(key, value); |
| 11 | 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 | }); |
| 15 | 72 | |
| 16 | 73 | vi.mock("expo-secure-store", () => ({ |
| 74 | deleteItemAsync: mocks.deleteItemAsync, | |
| 17 | 75 | getItemAsync: mocks.getItemAsync, |
| 18 | 76 | setItemAsync: mocks.setItemAsync, |
| 19 | 77 | })); |
| 20 | 78 | |
| 79 | vi.mock("expo-sqlite", () => ({ | |
| 80 | openDatabaseAsync: vi.fn(() => Promise.resolve(mocks.database)), | |
| 81 | })); | |
| 82 | ||
| 83 | vi.mock("expo-crypto", () => ({ | |
| 84 | getRandomBytes: vi.fn(() => new Uint8Array(16)), | |
| 85 | })); | |
| 86 | ||
| 87 | vi.mock("expo-constants", () => ({ | |
| 88 | default: { expoConfig: { extra: {} } }, | |
| 89 | })); | |
| 90 | ||
| 21 | 91 | vi.mock("react-native", () => ({ |
| 22 | 92 | Platform: { |
| 23 | 93 | OS: "ios", |
| 24 | 94 | }, |
| 25 | 95 | })); |
| 26 | 96 | |
| 27 | vi.mock("./runtime", () => ({ | |
| 28 | runtime: { | |
| 29 | runPromise: vi.fn(), | |
| 30 | }, | |
| 31 | })); | |
| 32 | ||
| 33 | import { loadSavedConnections, saveConnection } from "./storage"; | |
| 97 | import { | |
| 98 | loadPreferences, | |
| 99 | loadSavedConnections, | |
| 100 | saveConnection, | |
| 101 | savePreferencesPatch, | |
| 102 | } from "../persistence/imperative"; | |
| 34 | 103 | import { toStableSavedRemoteConnection } from "./connection"; |
| 35 | 104 | |
| 36 | 105 | const managedConnection = { |
| ... | ... | @@ -100,4 +169,75 @@ describe("mobile connection storage", () => { |
| 100 | 169 | |
| 101 | 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 @@ |
| 1 | import * as Arr from "effect/Array"; | |
| 2 | import { pipe } from "effect/Function"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import * as SecureStore from "expo-secure-store"; | |
| 5 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 6 | ||
| 7 | import { | |
| 8 | isRelayManagedConnection, | |
| 9 | type SavedRemoteConnection, | |
| 10 | toStableSavedRemoteConnection, | |
| 11 | } from "./connection"; | |
| 12 | ||
| 13 | const CONNECTIONS_KEY = "t3code.connections"; | |
| 14 | const PREFERENCES_KEY = "t3code.preferences"; | |
| 15 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; | |
| 16 | const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; | |
| 17 | const MobileStorageKey = Schema.Literals([ | |
| 18 | CONNECTIONS_KEY, | |
| 19 | PREFERENCES_KEY, | |
| 20 | AGENT_AWARENESS_DEVICE_ID_KEY, | |
| 21 | AGENT_AWARENESS_REGISTRATION_KEY, | |
| 22 | ]); | |
| 23 | type MobileStorageKeyValue = typeof MobileStorageKey.Type; | |
| 24 | ||
| 25 | export 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 | ||
| 38 | export 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 | ||
| 50 | export 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 | ||
| 62 | export 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 | ||
| 76 | async 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 | ||
| 84 | async 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 | ||
| 92 | async 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 | ||
| 109 | async 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 | ||
| 119 | export 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 | ||
| 135 | export 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 | ||
| 150 | export 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 | ||
| 159 | export 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. | |
| 204 | let preferencesWriteQueue: Promise<unknown> = Promise.resolve(); | |
| 205 | ||
| 206 | export 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 | ||
| 222 | export async function savePreferencesPatch(patch: Partial<Preferences>): Promise<Preferences> { | |
| 223 | return updatePreferences(() => patch); | |
| 224 | } | |
| 225 | ||
| 226 | export 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 | ||
| 245 | export async function loadAgentAwarenessDeviceId(): Promise<string | null> { | |
| 246 | const existing = await readStorageItem(AGENT_AWARENESS_DEVICE_ID_KEY); | |
| 247 | return existing?.trim() ? existing : null; | |
| 248 | } | |
| 249 | ||
| 250 | export 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. | |
| 262 | export 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 | ||
| 283 | export async function saveAgentAwarenessRegistrationRecord( | |
| 284 | record: AgentAwarenessRegistrationRecord, | |
| 285 | ): Promise<void> { | |
| 286 | await writeJsonStorageItem(AGENT_AWARENESS_REGISTRATION_KEY, record); | |
| 287 | } | |
| 288 | ||
| 289 | export 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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | ||
| 3 | import { runtime } from "../lib/runtime"; | |
| 4 | import * as MobilePreferences from "./mobile-preferences"; | |
| 5 | import * as MobileStorage from "./mobile-storage"; | |
| 6 | ||
| 7 | export type { Preferences } from "./mobile-preferences"; | |
| 8 | export type { AgentAwarenessRegistrationRecord } from "./mobile-storage"; | |
| 9 | export { MobilePreferencesLoadError, MobilePreferencesSaveError } from "./mobile-preferences"; | |
| 10 | export { | |
| 11 | MobileDeviceIdGenerationError, | |
| 12 | MobileStorageDecodeError, | |
| 13 | MobileStorageEncodeError, | |
| 14 | } from "./mobile-storage"; | |
| 15 | export { MobileSecureStorageError } from "./mobile-secure-storage"; | |
| 16 | ||
| 17 | const runStorage = <A, E>( | |
| 18 | use: (storage: MobileStorage.MobileStorage["Service"]) => Effect.Effect<A, E>, | |
| 19 | ) => runtime.runPromise(MobileStorage.MobileStorage.pipe(Effect.flatMap(use))); | |
| 20 | ||
| 21 | const runPreferences = <A, E>( | |
| 22 | use: (store: MobilePreferences.MobilePreferencesStore["Service"]) => Effect.Effect<A, E>, | |
| 23 | ) => runtime.runPromise(MobilePreferences.MobilePreferencesStore.pipe(Effect.flatMap(use))); | |
| 24 | ||
| 25 | export const loadSavedConnections = () => runStorage((storage) => storage.loadSavedConnections); | |
| 26 | export const saveConnection = ( | |
| 27 | connection: Parameters<MobileStorage.MobileStorage["Service"]["saveConnection"]>[0], | |
| 28 | ) => runStorage((storage) => storage.saveConnection(connection)); | |
| 29 | ||
| 30 | export const loadPreferences = () => runPreferences((store) => store.load); | |
| 31 | export const savePreferencesPatch = (patch: Partial<MobilePreferences.Preferences>) => | |
| 32 | runPreferences((store) => store.savePatch(patch)); | |
| 33 | export const updatePreferences = ( | |
| 34 | transform: (current: MobilePreferences.Preferences) => Partial<MobilePreferences.Preferences>, | |
| 35 | ) => runPreferences((store) => store.update(transform)); | |
| 36 | ||
| 37 | export const loadOrCreateAgentAwarenessDeviceId = () => | |
| 38 | runStorage((storage) => storage.loadOrCreateAgentAwarenessDeviceId); | |
| 39 | export const loadAgentAwarenessDeviceId = () => | |
| 40 | runStorage((storage) => storage.loadAgentAwarenessDeviceId); | |
| 41 | export const loadAgentAwarenessRegistrationRecord = () => | |
| 42 | runStorage((storage) => storage.loadAgentAwarenessRegistrationRecord); | |
| 43 | export const saveAgentAwarenessRegistrationRecord = ( | |
| 44 | record: MobileStorage.AgentAwarenessRegistrationRecord, | |
| 45 | ) => runStorage((storage) => storage.saveAgentAwarenessRegistrationRecord(record)); | |
| 46 | export const clearAgentAwarenessRegistrationRecord = () => | |
| 47 | runStorage((storage) => storage.clearAgentAwarenessRegistrationRecord); |
apps/mobile/src/persistence/layer.ts created+16| ... | ... | @@ -0,0 +1,16 @@ |
| 1 | import * as Layer from "effect/Layer"; | |
| 2 | ||
| 3 | import * as EnvironmentCacheStore from "../connection/environment-cache-store"; | |
| 4 | import * as MobileDatabase from "./mobile-database"; | |
| 5 | import * as MobilePreferences from "./mobile-preferences"; | |
| 6 | import * as MobileSecureStorage from "./mobile-secure-storage"; | |
| 7 | import * as MobileStorage from "./mobile-storage"; | |
| 8 | ||
| 9 | const baseLayer = Layer.merge(MobileDatabase.layer, MobileSecureStorage.layer); | |
| 10 | const dependentLayer = Layer.mergeAll( | |
| 11 | MobilePreferences.layer, | |
| 12 | MobileStorage.layer, | |
| 13 | EnvironmentCacheStore.layer, | |
| 14 | ).pipe(Layer.provide(baseLayer)); | |
| 15 | ||
| 16 | export const layer = Layer.merge(baseLayer, dependentLayer); |
apps/mobile/src/persistence/mobile-database.test.ts created+71| ... | ... | @@ -0,0 +1,71 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const openDatabaseAsync = vi.hoisted(() => vi.fn()); | |
| 6 | ||
| 7 | vi.mock("expo-sqlite", () => ({ openDatabaseAsync })); | |
| 8 | ||
| 9 | import { decodeLegacyCacheRecord, make } from "./mobile-database"; | |
| 10 | ||
| 11 | describe("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 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import type { SQLiteDatabase } from "expo-sqlite"; | |
| 8 | ||
| 9 | const DATABASE_NAME = "t3code-client.db"; | |
| 10 | const DATABASE_SCHEMA_VERSION = 1; | |
| 11 | const 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 | ||
| 19 | export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); | |
| 20 | export type ClientCacheKind = typeof ClientCacheKind.Type; | |
| 21 | ||
| 22 | export interface ClientCacheSummaryRow { | |
| 23 | readonly environmentId: EnvironmentId; | |
| 24 | readonly kind: ClientCacheKind; | |
| 25 | readonly recordCount: number; | |
| 26 | readonly payloadBytes: number; | |
| 27 | } | |
| 28 | ||
| 29 | export interface StoredPreferencesJson { | |
| 30 | readonly payload: string; | |
| 31 | readonly updatedAt: number; | |
| 32 | } | |
| 33 | ||
| 34 | const ClientCacheSummaryRows = Schema.Array( | |
| 35 | Schema.Struct({ | |
| 36 | environmentId: Schema.String, | |
| 37 | kind: ClientCacheKind, | |
| 38 | recordCount: Schema.Number, | |
| 39 | payloadBytes: Schema.Number, | |
| 40 | }), | |
| 41 | ); | |
| 42 | ||
| 43 | const 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 | ||
| 56 | export 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 | ||
| 68 | function databaseError(operation: typeof MobileDatabaseOperation.Type) { | |
| 69 | return (cause: unknown) => new MobileDatabaseError({ operation, cause }); | |
| 70 | } | |
| 71 | ||
| 72 | interface LegacyCacheRecord { | |
| 73 | readonly environmentId: string; | |
| 74 | readonly kind: ClientCacheKind; | |
| 75 | readonly cacheKey: string; | |
| 76 | readonly schemaVersion: number; | |
| 77 | readonly payload: string; | |
| 78 | } | |
| 79 | ||
| 80 | function objectRecord(value: unknown): Record<string, unknown> | null { | |
| 81 | return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null; | |
| 82 | } | |
| 83 | ||
| 84 | export 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 | ||
| 143 | async 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 | ||
| 186 | export 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 | ||
| 225 | const 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 | ||
| 389 | function 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 | ||
| 403 | export const make = Effect.result(makeAvailable).pipe( | |
| 404 | Effect.map((result) => | |
| 405 | result._tag === "Success" ? result.success : makeUnavailable(result.failure), | |
| 406 | ), | |
| 407 | ); | |
| 408 | ||
| 409 | export const layer = Layer.effect(MobileDatabase, make); |
apps/mobile/src/persistence/mobile-preferences.ts created+305| ... | ... | @@ -0,0 +1,305 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | import * as Semaphore from "effect/Semaphore"; | |
| 8 | ||
| 9 | import * as MobileDatabase from "./mobile-database"; | |
| 10 | import * as MobileSecureStorage from "./mobile-secure-storage"; | |
| 11 | import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-storage"; | |
| 12 | ||
| 13 | const PREFERENCES_KEY = "t3code.preferences"; | |
| 14 | const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; | |
| 15 | ||
| 16 | export 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 | ||
| 27 | export 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 | ||
| 36 | export 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 | ||
| 45 | interface PreferencesFallback { | |
| 46 | readonly payload: string; | |
| 47 | readonly updatedAt: number; | |
| 48 | readonly preferences: Preferences; | |
| 49 | } | |
| 50 | ||
| 51 | export 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 | ||
| 64 | function 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 | ||
| 103 | export 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 | ||
| 305 | export const layer = Layer.effect(MobilePreferencesStore, make()); |
apps/mobile/src/persistence/mobile-secure-storage.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import * as SecureStore from "expo-secure-store"; | |
| 6 | ||
| 7 | const MobileSecureStorageOperation = Schema.Literals(["read", "write", "delete"]); | |
| 8 | ||
| 9 | export 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 | ||
| 22 | export 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 | ||
| 31 | export 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 | ||
| 52 | export const layer = Layer.succeed(MobileSecureStorage, make); |
apps/mobile/src/persistence/mobile-storage.ts created+227| ... | ... | @@ -0,0 +1,227 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Arr from "effect/Array"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import { pipe } from "effect/Function"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Schema from "effect/Schema"; | |
| 8 | ||
| 9 | import { | |
| 10 | isRelayManagedConnection, | |
| 11 | type SavedRemoteConnection, | |
| 12 | toStableSavedRemoteConnection, | |
| 13 | } from "../lib/connection"; | |
| 14 | import * as MobileSecureStorage from "./mobile-secure-storage"; | |
| 15 | ||
| 16 | const CONNECTIONS_KEY = "t3code.connections"; | |
| 17 | const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; | |
| 18 | const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; | |
| 19 | ||
| 20 | export 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 | ||
| 32 | export 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 | ||
| 44 | export 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 | ||
| 53 | export interface AgentAwarenessRegistrationRecord { | |
| 54 | readonly identity: string; | |
| 55 | readonly signature: string; | |
| 56 | readonly pushToStartToken?: string; | |
| 57 | } | |
| 58 | ||
| 59 | export 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 | ||
| 103 | export 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 | ||
| 227 | export const layer = Layer.effect(MobileStorage, make()); |
apps/mobile/src/state/client-cache-state.ts created+87| ... | ... | @@ -0,0 +1,87 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import { Atom } from "effect/unstable/reactivity"; | |
| 4 | ||
| 5 | import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; | |
| 6 | import * as Runtime from "../lib/runtime"; | |
| 7 | ||
| 8 | export interface EnvironmentClientCacheSummary { | |
| 9 | readonly environmentId: EnvironmentId; | |
| 10 | readonly recordCount: number; | |
| 11 | readonly payloadBytes: number; | |
| 12 | readonly kinds: Readonly<Partial<Record<ClientCacheKind, number>>>; | |
| 13 | } | |
| 14 | ||
| 15 | export interface ClientCacheSummary { | |
| 16 | readonly recordCount: number; | |
| 17 | readonly payloadBytes: number; | |
| 18 | readonly environments: ReadonlyArray<EnvironmentClientCacheSummary>; | |
| 19 | } | |
| 20 | ||
| 21 | export type ClientCacheClearScope = | |
| 22 | | { readonly type: "all" } | |
| 23 | | { readonly type: "environment"; readonly environmentId: EnvironmentId }; | |
| 24 | ||
| 25 | function 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 | ||
| 61 | const clientCacheRuntime = Atom.runtime(Runtime.runtimeContextLayer); | |
| 62 | ||
| 63 | export 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 | ||
| 72 | export 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 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | |
| 6 | import { vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | vi.mock("expo-secure-store", () => ({ | |
| 9 | getItemAsync: vi.fn(), | |
| 10 | setItemAsync: vi.fn(), | |
| 11 | })); | |
| 12 | ||
| 13 | vi.mock("react-native", () => ({ | |
| 14 | Platform: { OS: "ios" }, | |
| 15 | })); | |
| 16 | ||
| 17 | vi.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 | ||
| 25 | import type { Preferences } from "../persistence/mobile-preferences"; | |
| 26 | import { | |
| 27 | createMobilePreferencesState, | |
| 28 | MobilePreferencesLoadError, | |
| 29 | MobilePreferencesSaveError, | |
| 30 | MobilePreferencesStore, | |
| 31 | } from "./preferences"; | |
| 32 | ||
| 33 | function 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 | ||
| 41 | function 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 | ||
| 64 | describe("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 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 3 | ||
| 4 | import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; | |
| 5 | import * as Runtime from "../lib/runtime"; | |
| 6 | ||
| 7 | export { | |
| 8 | MobilePreferencesLoadError, | |
| 9 | MobilePreferencesSaveError, | |
| 10 | MobilePreferencesStore, | |
| 11 | } from "../persistence/mobile-preferences"; | |
| 12 | ||
| 13 | interface 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 | */ | |
| 23 | export 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 | ||
| 120 | const mobilePreferencesRuntime = Atom.runtime(Runtime.runtimeContextLayer); | |
| 121 | export const mobilePreferencesState = createMobilePreferencesState(mobilePreferencesRuntime); | |
| 122 | ||
| 123 | export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; | |
| 124 | export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; |
apps/server/src/provider/Layers/CursorAdapter.test.ts+10-4| ... | ... | @@ -1030,10 +1030,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1030 | 1030 | entry.result.outcome !== null && |
| 1031 | 1031 | "outcome" in entry.result.outcome && |
| 1032 | 1032 | entry.result.outcome.outcome === "cancelled"; |
| 1033 | yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel"); | |
| 1034 | const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse); | |
| 1035 | assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); | |
| 1036 | assert.isTrue(requests.some(isCancelledApprovalResponse)); | |
| 1033 | const cancelRequests = yield* waitForJsonLogMatch( | |
| 1034 | requestLogPath, | |
| 1035 | (entry) => entry.method === "session/cancel", | |
| 1036 | ); | |
| 1037 | const approvalResponses = yield* waitForJsonLogMatch( | |
| 1038 | requestLogPath, | |
| 1039 | isCancelledApprovalResponse, | |
| 1040 | ); | |
| 1041 | assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); | |
| 1042 | assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); | |
| 1037 | 1043 | |
| 1038 | 1044 | yield* adapter.stopSession(threadId); |
| 1039 | 1045 | }), |
apps/web/src/connection/storage.ts+118-1| ... | ... | @@ -21,7 +21,9 @@ import { |
| 21 | 21 | EnvironmentId, |
| 22 | 22 | OrchestrationShellSnapshot, |
| 23 | 23 | OrchestrationThreadDetailSnapshot, |
| 24 | ServerConfig, | |
| 24 | 25 | ThreadId, |
| 26 | VcsListRefsResult, | |
| 25 | 27 | } from "@t3tools/contracts"; |
| 26 | 28 | import * as Context from "effect/Context"; |
| 27 | 29 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -32,10 +34,12 @@ import * as Schema from "effect/Schema"; |
| 32 | 34 | import * as Semaphore from "effect/Semaphore"; |
| 33 | 35 | |
| 34 | 36 | const DATABASE_NAME = "t3code:connection-runtime"; |
| 35 | const DATABASE_VERSION = 2; | |
| 37 | const DATABASE_VERSION = 4; | |
| 36 | 38 | const CATALOG_STORE_NAME = "catalog"; |
| 37 | 39 | const SHELL_STORE_NAME = "shell"; |
| 38 | 40 | const THREAD_STORE_NAME = "thread"; |
| 41 | const SERVER_CONFIG_STORE_NAME = "server-config"; | |
| 42 | const VCS_REFS_STORE_NAME = "vcs-refs"; | |
| 39 | 43 | const CATALOG_KEY = "document"; |
| 40 | 44 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 41 | 45 | |
| ... | ... | @@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({ |
| 55 | 59 | snapshot: OrchestrationThreadDetailSnapshot, |
| 56 | 60 | }); |
| 57 | 61 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); |
| 62 | const StoredServerConfig = Schema.Struct({ | |
| 63 | schemaVersion: Schema.Literal(1), | |
| 64 | environmentId: EnvironmentId, | |
| 65 | config: ServerConfig, | |
| 66 | }); | |
| 67 | const StoredServerConfigJson = Schema.fromJsonString(StoredServerConfig); | |
| 68 | const StoredVcsRefs = Schema.Struct({ | |
| 69 | schemaVersion: Schema.Literal(1), | |
| 70 | environmentId: EnvironmentId, | |
| 71 | cwd: Schema.String, | |
| 72 | refs: VcsListRefsResult, | |
| 73 | }); | |
| 74 | const StoredVcsRefsJson = Schema.fromJsonString(StoredVcsRefs); | |
| 58 | 75 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); |
| 59 | 76 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); |
| 60 | 77 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); |
| ... | ... | @@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot |
| 62 | 79 | const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); |
| 63 | 80 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); |
| 64 | 81 | const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); |
| 82 | const decodeStoredServerConfig = Schema.decodeUnknownEffect(StoredServerConfigJson); | |
| 83 | const encodeStoredServerConfig = Schema.encodeEffect(StoredServerConfigJson); | |
| 84 | const decodeStoredVcsRefs = Schema.decodeUnknownEffect(StoredVcsRefsJson); | |
| 85 | const encodeStoredVcsRefs = Schema.encodeEffect(StoredVcsRefsJson); | |
| 65 | 86 | |
| 66 | 87 | function catalogError(operation: string, cause: unknown) { |
| 67 | 88 | return new ConnectionTransientError({ |
| ... | ... | @@ -80,6 +101,10 @@ function persistenceError( |
| 80 | 101 | | "load-thread" |
| 81 | 102 | | "save-thread" |
| 82 | 103 | | "remove-thread" |
| 104 | | "load-server-config" | |
| 105 | | "save-server-config" | |
| 106 | | "load-vcs-refs" | |
| 107 | | "save-vcs-refs" | |
| 83 | 108 | | "clear-environment", |
| 84 | 109 | cause: unknown, |
| 85 | 110 | ) { |
| ... | ... | @@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( |
| 108 | 133 | if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { |
| 109 | 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 | 143 | request.addEventListener("error", () => { |
| 113 | 144 | resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); |
| ... | ... | @@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { |
| 197 | 228 | return `${environmentId}:${threadId}`; |
| 198 | 229 | } |
| 199 | 230 | |
| 231 | function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { | |
| 232 | return `${environmentId}:${cwd}`; | |
| 233 | } | |
| 234 | ||
| 200 | 235 | const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { |
| 201 | 236 | return yield* decodeConnectionCatalogDocument(raw).pipe( |
| 202 | 237 | Effect.mapError((cause) => catalogError("decode", cause)), |
| ... | ... | @@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext( |
| 462 | 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 | 534 | loadThread: (environmentId, threadId) => |
| 466 | 535 | readDatabaseValue( |
| 467 | 536 | database, |
| ... | ... | @@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext( |
| 508 | 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 | 622 | removeThread: (environmentId, threadId) => |
| 512 | 623 | removeDatabaseValue( |
| 513 | 624 | database, |
| ... | ... | @@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext( |
| 523 | 634 | THREAD_STORE_NAME, |
| 524 | 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 | 644 | { concurrency: "unbounded", discard: true }, |
| 528 | 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 | 247 | loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), |
| 248 | 248 | saveThread: (_environmentId, _thread) => Effect.void, |
| 249 | 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 | 254 | clear: (environmentId) => |
| 251 | 255 | Ref.update(shellCache, (current) => { |
| 252 | 256 | const next = new Map(current); |
packages/client-runtime/src/platform/persistence.ts+30| ... | ... | @@ -2,7 +2,9 @@ import { |
| 2 | 2 | type EnvironmentId, |
| 3 | 3 | type OrchestrationShellSnapshot, |
| 4 | 4 | type OrchestrationThreadDetailSnapshot, |
| 5 | type ServerConfig, | |
| 5 | 6 | type ThreadId, |
| 7 | type VcsListRefsResult, | |
| 6 | 8 | } from "@t3tools/contracts"; |
| 7 | 9 | import * as Context from "effect/Context"; |
| 8 | 10 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass<Connecti |
| 24 | 26 | "load-thread", |
| 25 | 27 | "save-thread", |
| 26 | 28 | "remove-thread", |
| 29 | "load-server-config", | |
| 30 | "save-server-config", | |
| 31 | "load-vcs-refs", | |
| 32 | "save-vcs-refs", | |
| 27 | 33 | "clear-environment", |
| 28 | 34 | ]), |
| 29 | 35 | message: Schema.String, |
| ... | ... | @@ -72,6 +78,30 @@ export class EnvironmentCacheStore extends Context.Service< |
| 72 | 78 | environmentId: EnvironmentId, |
| 73 | 79 | threadId: ThreadId, |
| 74 | 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 | 105 | readonly clear: ( |
| 76 | 106 | environmentId: EnvironmentId, |
| 77 | 107 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
packages/client-runtime/src/state/server.test.ts+175-2| ... | ... | @@ -1,8 +1,32 @@ |
| 1 | import { type ServerConfig, type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | EnvironmentId, | |
| 3 | type ServerConfig, | |
| 4 | type ServerConfigStreamEvent, | |
| 5 | type ServerLifecycleWelcomePayload, | |
| 6 | WS_METHODS, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import * as Effect from "effect/Effect"; | |
| 3 | 10 | import * as Option from "effect/Option"; |
| 11 | import * as Queue from "effect/Queue"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 4 | 14 | |
| 5 | import { applyServerConfigProjection, projectServerWelcome } from "./server.ts"; | |
| 15 | import { | |
| 16 | AVAILABLE_CONNECTION_STATE, | |
| 17 | PrimaryConnectionTarget, | |
| 18 | type PreparedConnection, | |
| 19 | } from "../connection/model.ts"; | |
| 20 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 21 | import * as Persistence from "../platform/persistence.ts"; | |
| 22 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | |
| 23 | import type { RpcSession } from "../rpc/session.ts"; | |
| 24 | import { | |
| 25 | applyServerConfigProjection, | |
| 26 | makeEnvironmentServerConfigState, | |
| 27 | projectServerWelcome, | |
| 28 | resolveServerConfigValue, | |
| 29 | } from "./server.ts"; | |
| 6 | 30 | |
| 7 | 31 | const CONFIG = { |
| 8 | 32 | availableEditors: [], |
| ... | ... | @@ -14,6 +38,29 @@ const CONFIG = { |
| 14 | 38 | settings: {}, |
| 15 | 39 | } as unknown as ServerConfig; |
| 16 | 40 | |
| 41 | const snapshotEvent = (config: ServerConfig): ServerConfigStreamEvent => ({ | |
| 42 | version: 1, | |
| 43 | type: "snapshot", | |
| 44 | config, | |
| 45 | }); | |
| 46 | ||
| 47 | const 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 | ||
| 54 | function 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 | ||
| 17 | 64 | describe("server state projection", () => { |
| 18 | 65 | it("applies every config category to the projected snapshot", () => { |
| 19 | 66 | const snapshot = applyServerConfigProjection(Option.none(), { |
| ... | ... | @@ -51,4 +98,130 @@ describe("server state projection", () => { |
| 51 | 98 | expect(Option.getOrThrow(afterReady)).toBe(welcome); |
| 52 | 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 | 5 | type ServerLifecycleWelcomePayload, |
| 6 | 6 | WS_METHODS, |
| 7 | 7 | } from "@t3tools/contracts"; |
| 8 | import * as Effect from "effect/Effect"; | |
| 8 | 9 | import * as Option from "effect/Option"; |
| 10 | import * as Queue from "effect/Queue"; | |
| 11 | import * as Ref from "effect/Ref"; | |
| 12 | import * as Result from "effect/Result"; | |
| 9 | 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 10 | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 11 | 16 | |
| 12 | 17 | import { |
| ... | ... | @@ -16,10 +21,16 @@ import { |
| 16 | 21 | createEnvironmentRpcSubscriptionAtomFamily, |
| 17 | 22 | } from "./runtime.ts"; |
| 18 | 23 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 24 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | |
| 25 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | |
| 26 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | |
| 27 | import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | |
| 28 | import { followStreamInEnvironment } from "./runtime.ts"; | |
| 19 | 29 | |
| 20 | 30 | export interface ServerConfigProjection { |
| 21 | 31 | readonly config: ServerConfig; |
| 22 | 32 | readonly latestEvent: ServerConfigStreamEvent; |
| 33 | readonly source: "cache" | "live"; | |
| 23 | 34 | } |
| 24 | 35 | |
| 25 | 36 | export function applyServerConfigProjection( |
| ... | ... | @@ -31,6 +42,7 @@ export function applyServerConfigProjection( |
| 31 | 42 | return Option.some({ |
| 32 | 43 | config: event.config, |
| 33 | 44 | latestEvent: event, |
| 45 | source: "live", | |
| 34 | 46 | }); |
| 35 | 47 | case "keybindingsUpdated": |
| 36 | 48 | return Option.map(current, (projection) => ({ |
| ... | ... | @@ -40,6 +52,7 @@ export function applyServerConfigProjection( |
| 40 | 52 | issues: event.payload.issues, |
| 41 | 53 | }, |
| 42 | 54 | latestEvent: event, |
| 55 | source: "live", | |
| 43 | 56 | })); |
| 44 | 57 | case "providerStatuses": |
| 45 | 58 | return Option.map(current, (projection) => ({ |
| ... | ... | @@ -48,6 +61,7 @@ export function applyServerConfigProjection( |
| 48 | 61 | providers: event.payload.providers, |
| 49 | 62 | }, |
| 50 | 63 | latestEvent: event, |
| 64 | source: "live", | |
| 51 | 65 | })); |
| 52 | 66 | case "settingsUpdated": |
| 53 | 67 | return Option.map(current, (projection) => ({ |
| ... | ... | @@ -56,6 +70,7 @@ export function applyServerConfigProjection( |
| 56 | 70 | settings: event.payload.settings, |
| 57 | 71 | }, |
| 58 | 72 | latestEvent: event, |
| 73 | source: "live", | |
| 59 | 74 | })); |
| 60 | 75 | } |
| 61 | 76 | } |
| ... | ... | @@ -68,6 +83,127 @@ export function projectServerConfig( |
| 68 | 83 | return [next, Option.toArray(next)]; |
| 69 | 84 | } |
| 70 | 85 | |
| 86 | const 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 | */ | |
| 97 | export 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 | ||
| 187 | export 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 | ||
| 71 | 207 | export function projectServerWelcome( |
| 72 | 208 | current: Option.Option<ServerLifecycleWelcomePayload>, |
| 73 | 209 | event: { |
| ... | ... | @@ -85,8 +221,16 @@ export function projectServerWelcome( |
| 85 | 221 | return [Option.some(welcome), [welcome]]; |
| 86 | 222 | } |
| 87 | 223 | |
| 224 | export 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 | ||
| 88 | 232 | export function createServerEnvironmentAtoms<R, E>( |
| 89 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 233 | runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, | |
| 90 | 234 | options: { |
| 91 | 235 | readonly initialConfigValueAtom: ( |
| 92 | 236 | environmentId: EnvironmentId, |
| ... | ... | @@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms<R, E>( |
| 98 | 242 | mode: "serial" as const, |
| 99 | 243 | key: ({ environmentId }: { readonly environmentId: string }) => environmentId, |
| 100 | 244 | }; |
| 101 | const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 102 | label: "environment-data:server:config-projection", | |
| 103 | tag: WS_METHODS.subscribeServerConfig, | |
| 104 | transform: (stream) => | |
| 105 | stream.pipe(Stream.mapAccum(Option.none<ServerConfigProjection>, projectServerConfig)), | |
| 106 | }); | |
| 245 | const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => | |
| 246 | runtime | |
| 247 | .atom(serverConfigStateChanges(environmentId)) | |
| 248 | .pipe( | |
| 249 | Atom.setIdleTTL(5 * 60_000), | |
| 250 | Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), | |
| 251 | ), | |
| 252 | ); | |
| 253 | const configProjection = (target: { | |
| 254 | readonly environmentId: EnvironmentId; | |
| 255 | readonly input: EnvironmentRpcInput<typeof WS_METHODS.subscribeServerConfig>; | |
| 256 | }) => configProjectionFamily(target.environmentId); | |
| 107 | 257 | const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe( |
| 108 | 258 | Atom.withLabel("environment-data:server:config:empty"), |
| 109 | 259 | ); |
| ... | ... | @@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms<R, E>( |
| 115 | 265 | const projection = Option.getOrNull( |
| 116 | 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 | 272 | }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); |
| 120 | 273 | }); |
| 121 | 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 | 81 | loadThread: () => Effect.succeed(Option.none()), |
| 82 | 82 | saveThread: () => Effect.void, |
| 83 | 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 | 88 | clear: () => Effect.void, |
| 85 | 89 | }); |
| 86 | 90 | // Cold cache with no HTTP snapshot available → falls back to the |
| ... | ... | @@ -171,6 +175,10 @@ describe("environment shell synchronization", () => { |
| 171 | 175 | loadThread: () => Effect.succeed(Option.none()), |
| 172 | 176 | saveThread: () => Effect.void, |
| 173 | 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 | 182 | clear: () => Effect.void, |
| 175 | 183 | }); |
| 176 | 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 | 170 | Ref.update(savedThreads, (current) => [...current, thread]), |
| 171 | 171 | removeThread: (_environmentId, threadId) => |
| 172 | 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 | 177 | clear: () => Effect.void, |
| 174 | 178 | }); |
| 175 | 179 | const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( |
packages/client-runtime/src/state/vcs.test.ts created+232| ... | ... | @@ -0,0 +1,232 @@ |
| 1 | import { EnvironmentId, WS_METHODS, type VcsListRefsResult } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Ref from "effect/Ref"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 9 | import * as TestClock from "effect/testing/TestClock"; | |
| 10 | ||
| 11 | import { | |
| 12 | AVAILABLE_CONNECTION_STATE, | |
| 13 | PrimaryConnectionTarget, | |
| 14 | type PreparedConnection, | |
| 15 | type SupervisorConnectionState, | |
| 16 | } from "../connection/model.ts"; | |
| 17 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | |
| 18 | import * as Persistence from "../platform/persistence.ts"; | |
| 19 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | |
| 20 | import type { RpcSession } from "../rpc/session.ts"; | |
| 21 | import { makeCachedVcsRefsChanges } from "./vcs.ts"; | |
| 22 | ||
| 23 | const 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 | ||
| 30 | const 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 | ||
| 39 | const 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 | ||
| 54 | const 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 | ||
| 66 | function 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 | ||
| 76 | function 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 | ||
| 91 | describe("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 | import { type VcsStatusResult, WS_METHODS } from "@t3tools/contracts"; | |
| 1 | import { | |
| 2 | type EnvironmentId, | |
| 3 | type VcsListRefsInput, | |
| 4 | type VcsListRefsResult, | |
| 5 | type VcsStatusResult, | |
| 6 | WS_METHODS, | |
| 7 | } from "@t3tools/contracts"; | |
| 2 | 8 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Result from "effect/Result"; | |
| 3 | 12 | import * as Stream from "effect/Stream"; |
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 4 | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | 15 | |
| 6 | import { | |
| 7 | createEnvironmentRpcCommand, | |
| 8 | createEnvironmentRpcQueryAtomFamily, | |
| 9 | createEnvironmentSubscriptionAtomFamily, | |
| 10 | } from "./runtime.ts"; | |
| 16 | import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; | |
| 11 | 17 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 12 | import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | |
| 18 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | |
| 19 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | |
| 20 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | |
| 21 | import { request, subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; | |
| 22 | import { followStreamInEnvironment } from "./runtime.ts"; | |
| 13 | 23 | import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; |
| 14 | 24 | |
| 25 | const OFFLINE_BRANCH_LIST_LIMIT = 100; | |
| 26 | const VCS_REFS_REVALIDATE_INTERVAL = "5 seconds"; | |
| 27 | ||
| 28 | function 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 | */ | |
| 44 | export 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 | ||
| 141 | export function cachedVcsRefsChanges(environmentId: EnvironmentId, input: VcsListRefsInput) { | |
| 142 | return followStreamInEnvironment(environmentId, Stream.unwrap(makeCachedVcsRefsChanges(input))); | |
| 143 | } | |
| 144 | ||
| 15 | 145 | export function createVcsEnvironmentAtoms<R, E>( |
| 16 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 146 | runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, | |
| 17 | 147 | ) { |
| 18 | return { | |
| 19 | listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { | |
| 20 | label: "environment-data:vcs:list-refs", | |
| 21 | tag: WS_METHODS.vcsListRefs, | |
| 22 | staleTimeMs: 5_000, | |
| 148 | const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) => | |
| 149 | Atom.family((inputKey: string) => { | |
| 150 | const input = JSON.parse(inputKey) as VcsListRefsInput; | |
| 151 | return runtime | |
| 152 | .atom(cachedVcsRefsChanges(environmentId, input)) | |
| 153 | .pipe( | |
| 154 | Atom.setIdleTTL(5 * 60_000), | |
| 155 | Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), | |
| 156 | ); | |
| 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 | 166 | status: createEnvironmentSubscriptionAtomFamily(runtime, { |
| 25 | 167 | label: "environment-data:vcs:status", |
| 26 | 168 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => |
pnpm-lock.yaml+794-8| ... | ... | @@ -327,6 +327,9 @@ importers: |
| 327 | 327 | expo-splash-screen: |
| 328 | 328 | specifier: ~56.0.10 |
| 329 | 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 | 333 | expo-symbols: |
| 331 | 334 | specifier: ~56.0.6 |
| 332 | 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 | 665 | version: link:../../packages/shared |
| 663 | 666 | alchemy: |
| 664 | 667 | specifier: https://pkg.ing/alchemy/078ff00 |
| 665 | version: https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab) | |
| 668 | version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) | |
| 666 | 669 | drizzle-orm: |
| 667 | 670 | 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 | 672 | effect: |
| 670 | 673 | specifier: 4.0.0-beta.78 |
| 671 | 674 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
| ... | ... | @@ -5445,6 +5448,9 @@ packages: |
| 5445 | 5448 | resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} |
| 5446 | 5449 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} |
| 5447 | 5450 | |
| 5451 | await-lock@2.2.2: | |
| 5452 | resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} | |
| 5453 | ||
| 5448 | 5454 | aws-ssl-profiles@1.1.2: |
| 5449 | 5455 | resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} |
| 5450 | 5456 | engines: {node: '>= 6.0.0'} |
| ... | ... | @@ -6661,6 +6667,13 @@ packages: |
| 6661 | 6667 | peerDependencies: |
| 6662 | 6668 | expo: '*' |
| 6663 | 6669 | |
| 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 | 6677 | expo-structured-headers@56.0.0: |
| 6665 | 6678 | resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} |
| 6666 | 6679 | |
| ... | ... | @@ -12056,6 +12069,83 @@ snapshots: |
| 12056 | 12069 | - typescript |
| 12057 | 12070 | - utf-8-validate |
| 12058 | 12071 | |
| 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 | 12149 | '@expo/code-signing-certificates@0.0.6': |
| 12060 | 12150 | dependencies: |
| 12061 | 12151 | node-forge: 1.4.0 |
| ... | ... | @@ -12132,12 +12222,27 @@ snapshots: |
| 12132 | 12222 | react: 19.2.3 |
| 12133 | 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) |
| 12134 | 12224 | |
| 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 | 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 | 12234 | dependencies: |
| 12137 | 12235 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 12138 | 12236 | react: 19.2.3 |
| 12139 | 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) |
| 12140 | 12238 | |
| 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 | 12246 | '@expo/env@2.3.0': |
| 12142 | 12247 | dependencies: |
| 12143 | 12248 | chalk: 4.1.2 |
| ... | ... | @@ -12206,6 +12311,16 @@ snapshots: |
| 12206 | 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 | 12312 | stacktrace-parser: 0.1.11 |
| 12208 | 12313 | |
| 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 | 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 | 12325 | dependencies: |
| 12211 | 12326 | '@babel/code-frame': 7.29.7 |
| ... | ... | @@ -12263,6 +12378,20 @@ snapshots: |
| 12263 | 12378 | optionalDependencies: |
| 12264 | 12379 | react-dom: 19.2.3(react@19.2.3) |
| 12265 | 12380 | |
| 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 | 12395 | '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 12267 | 12396 | dependencies: |
| 12268 | 12397 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| ... | ... | @@ -12344,6 +12473,22 @@ snapshots: |
| 12344 | 12473 | transitivePeerDependencies: |
| 12345 | 12474 | - supports-color |
| 12346 | 12475 | |
| 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 | 12492 | '@expo/schema-utils@56.0.1': {} |
| 12348 | 12493 | |
| 12349 | 12494 | '@expo/sdk-runtime-versions@1.0.0': {} |
| ... | ... | @@ -12354,6 +12499,23 @@ snapshots: |
| 12354 | 12499 | |
| 12355 | 12500 | '@expo/sudo-prompt@9.3.2': {} |
| 12356 | 12501 | |
| 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 | 12519 | '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)': |
| 12358 | 12520 | dependencies: |
| 12359 | 12521 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -13286,18 +13448,45 @@ snapshots: |
| 13286 | 13448 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13287 | 13449 | optional: true |
| 13288 | 13450 | |
| 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 | 13464 | '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': |
| 13290 | 13465 | dependencies: |
| 13291 | 13466 | react: 19.2.3 |
| 13292 | 13467 | optionalDependencies: |
| 13293 | 13468 | '@types/react': 19.2.16 |
| 13294 | 13469 | |
| 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 | 13477 | '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)': |
| 13296 | 13478 | dependencies: |
| 13297 | 13479 | react: 19.2.3 |
| 13298 | 13480 | optionalDependencies: |
| 13299 | 13481 | '@types/react': 19.2.16 |
| 13300 | 13482 | |
| 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 | 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 | 13491 | dependencies: |
| 13303 | 13492 | '@radix-ui/primitive': 1.1.3 |
| ... | ... | @@ -13320,6 +13509,29 @@ snapshots: |
| 13320 | 13509 | '@types/react': 19.2.16 |
| 13321 | 13510 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13322 | 13511 | |
| 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 | 13535 | '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13324 | 13536 | dependencies: |
| 13325 | 13537 | react: 19.2.3 |
| ... | ... | @@ -13327,6 +13539,13 @@ snapshots: |
| 13327 | 13539 | '@types/react': 19.2.16 |
| 13328 | 13540 | optional: true |
| 13329 | 13541 | |
| 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 | 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 | 13550 | dependencies: |
| 13332 | 13551 | '@radix-ui/primitive': 1.1.3 |
| ... | ... | @@ -13340,12 +13559,33 @@ snapshots: |
| 13340 | 13559 | '@types/react': 19.2.16 |
| 13341 | 13560 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13342 | 13561 | |
| 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 | 13576 | '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)': |
| 13344 | 13577 | dependencies: |
| 13345 | 13578 | react: 19.2.3 |
| 13346 | 13579 | optionalDependencies: |
| 13347 | 13580 | '@types/react': 19.2.16 |
| 13348 | 13581 | |
| 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 | 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 | 13590 | dependencies: |
| 13351 | 13591 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | ... | @@ -13357,6 +13597,18 @@ snapshots: |
| 13357 | 13597 | '@types/react': 19.2.16 |
| 13358 | 13598 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13359 | 13599 | |
| 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 | 13612 | '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13361 | 13613 | dependencies: |
| 13362 | 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 | 13616 | optionalDependencies: |
| 13365 | 13617 | '@types/react': 19.2.16 |
| 13366 | 13618 | |
| 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 | 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 | 13628 | dependencies: |
| 13369 | 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 | 13634 | '@types/react': 19.2.16 |
| 13375 | 13635 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13376 | 13636 | |
| 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 | 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 | 13649 | dependencies: |
| 13379 | 13650 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | ... | @@ -13384,6 +13655,17 @@ snapshots: |
| 13384 | 13655 | '@types/react': 19.2.16 |
| 13385 | 13656 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13386 | 13657 | |
| 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 | 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 | 13670 | dependencies: |
| 13389 | 13671 | '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3) |
| ... | ... | @@ -13393,6 +13675,16 @@ snapshots: |
| 13393 | 13675 | '@types/react': 19.2.16 |
| 13394 | 13676 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13395 | 13677 | |
| 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 | 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 | 13689 | dependencies: |
| 13398 | 13690 | '@radix-ui/primitive': 1.1.3 |
| ... | ... | @@ -13411,6 +13703,24 @@ snapshots: |
| 13411 | 13703 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13412 | 13704 | optional: true |
| 13413 | 13705 | |
| 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 | 13724 | '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': |
| 13415 | 13725 | dependencies: |
| 13416 | 13726 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | ... | @@ -13418,6 +13728,14 @@ snapshots: |
| 13418 | 13728 | optionalDependencies: |
| 13419 | 13729 | '@types/react': 19.2.16 |
| 13420 | 13730 | |
| 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 | 13739 | '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)': |
| 13422 | 13740 | dependencies: |
| 13423 | 13741 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | ... | @@ -13426,6 +13744,14 @@ snapshots: |
| 13426 | 13744 | '@types/react': 19.2.16 |
| 13427 | 13745 | optional: true |
| 13428 | 13746 | |
| 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 | 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 | 13756 | dependencies: |
| 13431 | 13757 | '@radix-ui/primitive': 1.1.3 |
| ... | ... | @@ -13443,12 +13769,36 @@ snapshots: |
| 13443 | 13769 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13444 | 13770 | optional: true |
| 13445 | 13771 | |
| 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 | 13789 | '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13447 | 13790 | dependencies: |
| 13448 | 13791 | react: 19.2.3 |
| 13449 | 13792 | optionalDependencies: |
| 13450 | 13793 | '@types/react': 19.2.16 |
| 13451 | 13794 | |
| 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 | 13802 | '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)': |
| 13453 | 13803 | dependencies: |
| 13454 | 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 | 13807 | optionalDependencies: |
| 13458 | 13808 | '@types/react': 19.2.16 |
| 13459 | 13809 | |
| 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 | 13811 | dependencies: |
| 13462 | '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) | |
| 13463 | react: 19.2.3 | |
| 13812 | '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.6) | |
| 13813 | '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.6) | |
| 13814 | react: 19.2.6 | |
| 13464 | 13815 | optionalDependencies: |
| 13465 | 13816 | '@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 | |
| 13466 | 13833 | |
| 13467 | 13834 | '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13468 | 13835 | dependencies: |
| ... | ... | @@ -13471,12 +13838,27 @@ snapshots: |
| 13471 | 13838 | optionalDependencies: |
| 13472 | 13839 | '@types/react': 19.2.16 |
| 13473 | 13840 | |
| 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 | 13849 | '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13475 | 13850 | dependencies: |
| 13476 | 13851 | react: 19.2.3 |
| 13477 | 13852 | optionalDependencies: |
| 13478 | 13853 | '@types/react': 19.2.16 |
| 13479 | 13854 | |
| 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 | 13862 | '@react-grab/cli@0.1.44': |
| 13481 | 13863 | dependencies: |
| 13482 | 13864 | agent-install: 0.0.5 |
| ... | ... | @@ -13494,6 +13876,12 @@ snapshots: |
| 13494 | 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 | 13877 | optional: true |
| 13496 | 13878 | |
| 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 | 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 | 13886 | dependencies: |
| 13499 | 13887 | react: 19.2.3 |
| ... | ... | @@ -13638,6 +14026,16 @@ snapshots: |
| 13638 | 14026 | optionalDependencies: |
| 13639 | 14027 | '@types/react': 19.2.16 |
| 13640 | 14028 | |
| 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 | 14039 | '@react-navigation/core@7.21.2(react@19.2.3)': |
| 13642 | 14040 | dependencies: |
| 13643 | 14041 | '@react-navigation/routers': 7.6.0 |
| ... | ... | @@ -14797,7 +15195,7 @@ snapshots: |
| 14797 | 15195 | json-schema-traverse: 1.0.0 |
| 14798 | 15196 | require-from-string: 2.0.2 |
| 14799 | 15197 | |
| 14800 | alchemy@https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab): | |
| 15198 | alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): | |
| 14801 | 15199 | dependencies: |
| 14802 | 15200 | '@alchemy.run/node-utils': 0.0.4 |
| 14803 | 15201 | '@aws-sdk/credential-providers': 3.1062.0 |
| ... | ... | @@ -14840,7 +15238,7 @@ snapshots: |
| 14840 | 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 | 15239 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) |
| 14842 | 15240 | 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 | 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 | 15243 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14846 | 15244 | transitivePeerDependencies: |
| ... | ... | @@ -15083,6 +15481,8 @@ snapshots: |
| 15083 | 15481 | |
| 15084 | 15482 | auto-bind@5.0.1: {} |
| 15085 | 15483 | |
| 15484 | await-lock@2.2.2: {} | |
| 15485 | ||
| 15086 | 15486 | aws-ssl-profiles@1.1.2: {} |
| 15087 | 15487 | |
| 15088 | 15488 | aws4@1.13.2: {} |
| ... | ... | @@ -15856,13 +16256,14 @@ snapshots: |
| 15856 | 16256 | get-tsconfig: 4.14.0 |
| 15857 | 16257 | jiti: 2.7.0 |
| 15858 | 16258 | |
| 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 | 16260 | optionalDependencies: |
| 15861 | 16261 | '@cloudflare/workers-types': 4.20260604.1 |
| 15862 | 16262 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) |
| 15863 | 16263 | '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 15864 | 16264 | bun-types: 1.3.14 |
| 15865 | 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 | 16267 | mysql2: 3.22.4(@types/node@24.12.4) |
| 15867 | 16268 | pg: 8.21.0 |
| 15868 | 16269 | zod: 4.4.3 |
| ... | ... | @@ -16142,6 +16543,18 @@ snapshots: |
| 16142 | 16543 | - supports-color |
| 16143 | 16544 | - typescript |
| 16144 | 16545 | |
| 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 | 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 | 16559 | dependencies: |
| 16147 | 16560 | expo-application: 56.0.3(expo@56.0.12) |
| ... | ... | @@ -16186,6 +16599,15 @@ snapshots: |
| 16186 | 16599 | transitivePeerDependencies: |
| 16187 | 16600 | - supports-color |
| 16188 | 16601 | |
| 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 | 16611 | expo-crypto@56.0.4(expo@56.0.12): |
| 16190 | 16612 | dependencies: |
| 16191 | 16613 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16226,6 +16648,12 @@ snapshots: |
| 16226 | 16648 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 16227 | 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) |
| 16228 | 16650 | |
| 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 | 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 | 16658 | dependencies: |
| 16231 | 16659 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16233,12 +16661,27 @@ snapshots: |
| 16233 | 16661 | react: 19.2.3 |
| 16234 | 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) |
| 16235 | 16663 | |
| 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 | 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 | 16673 | dependencies: |
| 16238 | 16674 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 16239 | 16675 | react: 19.2.3 |
| 16240 | 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) |
| 16241 | 16677 | |
| 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 | 16685 | expo-haptics@56.0.3(expo@56.0.12): |
| 16243 | 16686 | dependencies: |
| 16244 | 16687 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16259,6 +16702,12 @@ snapshots: |
| 16259 | 16702 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 16260 | 16703 | react: 19.2.3 |
| 16261 | 16704 | |
| 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 | 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 | 16712 | dependencies: |
| 16264 | 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 | 16718 | - expo |
| 16270 | 16719 | - supports-color |
| 16271 | 16720 | |
| 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 | 16732 | expo-manifests@56.0.4(expo@56.0.12): |
| 16273 | 16733 | dependencies: |
| 16274 | 16734 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16294,10 +16754,26 @@ snapshots: |
| 16294 | 16754 | optionalDependencies: |
| 16295 | 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) |
| 16296 | 16756 | |
| 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 | 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 | 16769 | dependencies: |
| 16299 | 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) |
| 16300 | 16771 | |
| 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 | 16777 | expo-network@56.0.5(expo@56.0.12)(react@19.2.3): |
| 16302 | 16778 | dependencies: |
| 16303 | 16779 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16374,6 +16850,57 @@ snapshots: |
| 16374 | 16850 | - supports-color |
| 16375 | 16851 | optional: true |
| 16376 | 16852 | |
| 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 | 16904 | expo-secure-store@56.0.4(expo@56.0.12): |
| 16378 | 16905 | dependencies: |
| 16379 | 16906 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16390,6 +16917,21 @@ snapshots: |
| 16390 | 16917 | - supports-color |
| 16391 | 16918 | - typescript |
| 16392 | 16919 | |
| 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 | 16935 | expo-structured-headers@56.0.0: {} |
| 16394 | 16936 | |
| 16395 | 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 | 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 | 16944 | sf-symbols-typescript: 2.2.0 |
| 16403 | 16945 | |
| 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 | 16956 | expo-updates-interface@56.0.2(expo@56.0.12): |
| 16405 | 16957 | dependencies: |
| 16406 | 16958 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | ... | @@ -16492,6 +17044,49 @@ snapshots: |
| 16492 | 17044 | - typescript |
| 16493 | 17045 | - utf-8-validate |
| 16494 | 17046 | |
| 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 | 17090 | exponential-backoff@3.1.3: {} |
| 16496 | 17091 | |
| 16497 | 17092 | express-rate-limit@8.5.2(express@5.2.1): |
| ... | ... | @@ -18878,6 +19473,11 @@ snapshots: |
| 18878 | 19473 | dependencies: |
| 18879 | 19474 | react: 19.2.3 |
| 18880 | 19475 | |
| 19476 | react-freeze@1.0.4(react@19.2.6): | |
| 19477 | dependencies: | |
| 19478 | react: 19.2.6 | |
| 19479 | optional: true | |
| 19480 | ||
| 18881 | 19481 | react-grab@0.1.44(react@19.2.6): |
| 18882 | 19482 | dependencies: |
| 18883 | 19483 | '@react-grab/cli': 0.1.44 |
| ... | ... | @@ -18921,6 +19521,16 @@ snapshots: |
| 18921 | 19521 | use-latest-callback: 0.2.6(react@19.2.3) |
| 18922 | 19522 | optional: true |
| 18923 | 19523 | |
| 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 | 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 | 19535 | dependencies: |
| 18926 | 19536 | '@egjs/hammerjs': 2.0.17 |
| ... | ... | @@ -18930,6 +19540,16 @@ snapshots: |
| 18930 | 19540 | react: 19.2.3 |
| 18931 | 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) |
| 18932 | 19542 | |
| 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 | 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 | 19554 | dependencies: |
| 18935 | 19555 | react: 19.2.3 |
| ... | ... | @@ -18940,6 +19560,12 @@ snapshots: |
| 18940 | 19560 | react: 19.2.3 |
| 18941 | 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) |
| 18942 | 19562 | |
| 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 | 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 | 19570 | dependencies: |
| 18945 | 19571 | react: 19.2.3 |
| ... | ... | @@ -18968,11 +19594,26 @@ snapshots: |
| 18968 | 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 | 19595 | semver: 7.8.5 |
| 18970 | 19596 | |
| 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 | 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 | 19607 | dependencies: |
| 18973 | 19608 | react: 19.2.3 |
| 18974 | 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) |
| 18975 | 19610 | |
| 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 | 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 | 19618 | dependencies: |
| 18978 | 19619 | react: 19.2.3 |
| ... | ... | @@ -18980,6 +19621,14 @@ snapshots: |
| 18980 | 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 | 19622 | warn-once: 0.1.1 |
| 18982 | 19623 | |
| 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 | 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 | 19633 | dependencies: |
| 18985 | 19634 | '@shikijs/types': 4.3.0 |
| ... | ... | @@ -19007,6 +19656,14 @@ snapshots: |
| 19007 | 19656 | react: 19.2.3 |
| 19008 | 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) |
| 19009 | 19658 | |
| 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 | 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 | 19668 | dependencies: |
| 19012 | 19669 | '@babel/core': 7.29.7 |
| ... | ... | @@ -19027,6 +19684,27 @@ snapshots: |
| 19027 | 19684 | transitivePeerDependencies: |
| 19028 | 19685 | - supports-color |
| 19029 | 19686 | |
| 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 | 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 | 19709 | dependencies: |
| 19032 | 19710 | '@react-native/assets-registry': 0.85.3 |
| ... | ... | @@ -19072,6 +19750,52 @@ snapshots: |
| 19072 | 19750 | - supports-color |
| 19073 | 19751 | - utf-8-validate |
| 19074 | 19752 | |
| 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 | 19799 | react-reconciler@0.33.0(react@19.2.6): |
| 19076 | 19800 | dependencies: |
| 19077 | 19801 | react: 19.2.6 |
| ... | ... | @@ -19087,6 +19811,15 @@ snapshots: |
| 19087 | 19811 | optionalDependencies: |
| 19088 | 19812 | '@types/react': 19.2.16 |
| 19089 | 19813 | |
| 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 | 19823 | react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3): |
| 19091 | 19824 | dependencies: |
| 19092 | 19825 | react: 19.2.3 |
| ... | ... | @@ -19098,6 +19831,18 @@ snapshots: |
| 19098 | 19831 | optionalDependencies: |
| 19099 | 19832 | '@types/react': 19.2.16 |
| 19100 | 19833 | |
| 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 | 19846 | react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3): |
| 19102 | 19847 | dependencies: |
| 19103 | 19848 | get-nonce: 1.0.1 |
| ... | ... | @@ -19106,6 +19851,15 @@ snapshots: |
| 19106 | 19851 | optionalDependencies: |
| 19107 | 19852 | '@types/react': 19.2.16 |
| 19108 | 19853 | |
| 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 | 19863 | react@19.2.3: {} |
| 19110 | 19864 | |
| 19111 | 19865 | react@19.2.6: {} |
| ... | ... | @@ -20149,10 +20903,23 @@ snapshots: |
| 20149 | 20903 | optionalDependencies: |
| 20150 | 20904 | '@types/react': 19.2.16 |
| 20151 | 20905 | |
| 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 | 20914 | use-latest-callback@0.2.6(react@19.2.3): |
| 20153 | 20915 | dependencies: |
| 20154 | 20916 | react: 19.2.3 |
| 20155 | 20917 | |
| 20918 | use-latest-callback@0.2.6(react@19.2.6): | |
| 20919 | dependencies: | |
| 20920 | react: 19.2.6 | |
| 20921 | optional: true | |
| 20922 | ||
| 20156 | 20923 | use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3): |
| 20157 | 20924 | dependencies: |
| 20158 | 20925 | detect-node-es: 1.1.0 |
| ... | ... | @@ -20161,6 +20928,15 @@ snapshots: |
| 20161 | 20928 | optionalDependencies: |
| 20162 | 20929 | '@types/react': 19.2.16 |
| 20163 | 20930 | |
| 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 | 20940 | use-sync-external-store@1.6.0(react@19.2.3): |
| 20165 | 20941 | dependencies: |
| 20166 | 20942 | react: 19.2.3 |
| ... | ... | @@ -20197,6 +20973,16 @@ snapshots: |
| 20197 | 20973 | - '@types/react' |
| 20198 | 20974 | - '@types/react-dom' |
| 20199 | 20975 | |
| 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 | 20986 | vfile-location@5.0.3: |
| 20201 | 20987 | dependencies: |
| 20202 | 20988 | '@types/unist': 3.0.3 |