| 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,6 +43,15 @@ Review changed TypeScript and directly affected call sites for the conventions b |
| 43 | - Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. | 43 | - Keep implementation-specific names when an abstract port module contains one of several possible implementations, for example `makeCloudflaredRelayClient` and `layerCloudflared` in `RelayClient.ts`. |
| 44 | - `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. | 44 | - `infra/relay/src/db.ts` is an intentional exception: an inline `Layer.succeed(RelayDb, db)` is acceptable without generic `make`/`layer` exports. |
| 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 | ## Errors and predicates | 55 | ## Errors and predicates |
| 47 | 56 | ||
| 48 | - Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. | 57 | - Define service failures with `Schema.TaggedErrorClass` and structured attributes. Derive `message` from those attributes rather than storing an unstructured message as the only data. |
apps/mobile/app.config.ts+1| ... | @@ -123,6 +123,7 @@ const config: ExpoConfig = { | ... | @@ -123,6 +123,7 @@ const config: ExpoConfig = { |
| 123 | plugins: [ | 123 | plugins: [ |
| 124 | "expo-font", | 124 | "expo-font", |
| 125 | "expo-secure-store", | 125 | "expo-secure-store", |
| 126 | "expo-sqlite", | ||
| 126 | ["@clerk/expo", { theme: "./clerk-theme.json" }], | 127 | ["@clerk/expo", { theme: "./clerk-theme.json" }], |
| 127 | "expo-web-browser", | 128 | "expo-web-browser", |
| 128 | [ | 129 | [ |
apps/mobile/eas.json+8-4| ... | @@ -8,7 +8,8 @@ | ... | @@ -8,7 +8,8 @@ |
| 8 | "development": { | 8 | "development": { |
| 9 | "corepack": true, | 9 | "corepack": true, |
| 10 | "env": { | 10 | "env": { |
| 11 | "APP_VARIANT": "development" | 11 | "APP_VARIANT": "development", |
| 12 | "NODE_OPTIONS": "--max-old-space-size=4096" | ||
| 12 | }, | 13 | }, |
| 13 | "channel": "development", | 14 | "channel": "development", |
| 14 | "developmentClient": true, | 15 | "developmentClient": true, |
| ... | @@ -17,7 +18,8 @@ | ... | @@ -17,7 +18,8 @@ |
| 17 | "preview": { | 18 | "preview": { |
| 18 | "corepack": true, | 19 | "corepack": true, |
| 19 | "env": { | 20 | "env": { |
| 20 | "APP_VARIANT": "preview" | 21 | "APP_VARIANT": "preview", |
| 22 | "NODE_OPTIONS": "--max-old-space-size=4096" | ||
| 21 | }, | 23 | }, |
| 22 | "channel": "preview", | 24 | "channel": "preview", |
| 23 | "environment": "preview", | 25 | "environment": "preview", |
| ... | @@ -27,7 +29,8 @@ | ... | @@ -27,7 +29,8 @@ |
| 27 | "corepack": true, | 29 | "corepack": true, |
| 28 | "env": { | 30 | "env": { |
| 29 | "APP_VARIANT": "preview", | 31 | "APP_VARIANT": "preview", |
| 30 | "MOBILE_VERSION_POLICY": "fingerprint" | 32 | "MOBILE_VERSION_POLICY": "fingerprint", |
| 33 | "NODE_OPTIONS": "--max-old-space-size=4096" | ||
| 31 | }, | 34 | }, |
| 32 | "channel": "preview", | 35 | "channel": "preview", |
| 33 | "environment": "preview", | 36 | "environment": "preview", |
| ... | @@ -40,7 +43,8 @@ | ... | @@ -40,7 +43,8 @@ |
| 40 | "production": { | 43 | "production": { |
| 41 | "corepack": true, | 44 | "corepack": true, |
| 42 | "env": { | 45 | "env": { |
| 43 | "APP_VARIANT": "production" | 46 | "APP_VARIANT": "production", |
| 47 | "NODE_OPTIONS": "--max-old-space-size=4096" | ||
| 44 | }, | 48 | }, |
| 45 | "channel": "production", | 49 | "channel": "production", |
| 46 | "environment": "production", | 50 | "environment": "production", |
apps/mobile/package.json+2| ... | @@ -6,6 +6,7 @@ | ... | @@ -6,6 +6,7 @@ |
| 6 | "scripts": { | 6 | "scripts": { |
| 7 | "dev": "expo start --clear", | 7 | "dev": "expo start --clear", |
| 8 | "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", | 8 | "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear", |
| 9 | "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear'", | ||
| 9 | "start": "expo start", | 10 | "start": "expo start", |
| 10 | "start:dev": "APP_VARIANT=development expo start", | 11 | "start:dev": "APP_VARIANT=development expo start", |
| 11 | "start:preview": "APP_VARIANT=preview expo start", | 12 | "start:preview": "APP_VARIANT=preview expo start", |
| ... | @@ -86,6 +87,7 @@ | ... | @@ -86,6 +87,7 @@ |
| 86 | "expo-paste-input": "^0.1.15", | 87 | "expo-paste-input": "^0.1.15", |
| 87 | "expo-secure-store": "~56.0.4", | 88 | "expo-secure-store": "~56.0.4", |
| 88 | "expo-splash-screen": "~56.0.10", | 89 | "expo-splash-screen": "~56.0.10", |
| 90 | "expo-sqlite": "~56.0.5", | ||
| 89 | "expo-symbols": "~56.0.6", | 91 | "expo-symbols": "~56.0.6", |
| 90 | "expo-updates": "~56.0.19", | 92 | "expo-updates": "~56.0.19", |
| 91 | "expo-web-browser": "~56.0.5", | 93 | "expo-web-browser": "~56.0.5", |
apps/mobile/src/Stack.tsx+8| ... | @@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr | ... | @@ -41,6 +41,7 @@ import { NewTaskDraftRouteScreen } from "./features/threads/NewTaskDraftRouteScr |
| 41 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; | 41 | import { NewTaskFlowProvider } from "./features/threads/new-task-flow-provider"; |
| 42 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; | 42 | import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen"; |
| 43 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; | 43 | import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen"; |
| 44 | import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen"; | ||
| 44 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; | 45 | import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen"; |
| 45 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; | 46 | import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; |
| 46 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; | 47 | import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; |
| ... | @@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({ | ... | @@ -147,6 +148,13 @@ const SettingsSheetStack = createNativeStackNavigator({ |
| 147 | title: "Appearance", | 148 | title: "Appearance", |
| 148 | }, | 149 | }, |
| 149 | }), | 150 | }), |
| 151 | SettingsClientStorage: createNativeStackScreen({ | ||
| 152 | screen: SettingsClientStorageRouteScreen, | ||
| 153 | linking: "client-storage", | ||
| 154 | options: { | ||
| 155 | title: "Client Storage", | ||
| 156 | }, | ||
| 157 | }), | ||
| 150 | SettingsAuth: createNativeStackScreen({ | 158 | SettingsAuth: createNativeStackScreen({ |
| 151 | screen: SettingsAuthRouteScreen, | 159 | screen: SettingsAuthRouteScreen, |
| 152 | linking: "auth", | 160 | linking: "auth", |
apps/mobile/src/connection/catalog-store.ts+25-26| ... | @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref"; | ... | @@ -10,6 +10,7 @@ import * as Ref from "effect/Ref"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | import * as Semaphore from "effect/Semaphore"; | 11 | import * as Semaphore from "effect/Semaphore"; |
| 12 | 12 | ||
| 13 | import * as MobileSecureStorage from "../persistence/mobile-secure-storage"; | ||
| 13 | import { migrateLegacyConnectionCatalog } from "./migration"; | 14 | import { migrateLegacyConnectionCatalog } from "./migration"; |
| 14 | 15 | ||
| 15 | export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; | 16 | export const CONNECTION_CATALOG_KEY = "t3code.connection-catalog.v1"; |
| ... | @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) { | ... | @@ -22,23 +23,22 @@ function catalogError(operation: string, cause: unknown) { |
| 22 | }); | 23 | }); |
| 23 | } | 24 | } |
| 24 | 25 | ||
| 26 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); | ||
| 27 | const decodeConnectionCatalogDocument = Schema.decodeEffect(ConnectionCatalogDocumentJson); | ||
| 28 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); | ||
| 29 | |||
| 25 | const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { | 30 | const decodeCatalog = Effect.fn("mobile.connectionStorage.decodeCatalog")(function* (raw: string) { |
| 26 | const parsed = yield* Effect.try({ | 31 | return yield* decodeConnectionCatalogDocument(raw).pipe( |
| 27 | try: () => JSON.parse(raw) as unknown, | 32 | Effect.mapError((cause) => catalogError("decode", cause)), |
| 28 | catch: (cause) => catalogError("decode", cause), | 33 | ); |
| 29 | }); | ||
| 30 | return yield* Effect.fromResult( | ||
| 31 | Schema.decodeUnknownResult(ConnectionCatalogDocument)(parsed), | ||
| 32 | ).pipe(Effect.mapError((cause) => catalogError("decode", cause))); | ||
| 33 | }); | 34 | }); |
| 34 | 35 | ||
| 35 | const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( | 36 | const encodeCatalog = Effect.fn("mobile.connectionStorage.encodeCatalog")(function* ( |
| 36 | catalog: ConnectionCatalogDocumentType, | 37 | catalog: ConnectionCatalogDocumentType, |
| 37 | ) { | 38 | ) { |
| 38 | const encoded = yield* Effect.fromResult( | 39 | return yield* encodeConnectionCatalogDocument(catalog).pipe( |
| 39 | Schema.encodeUnknownResult(ConnectionCatalogDocument)(catalog), | 40 | Effect.mapError((cause) => catalogError("encode", cause)), |
| 40 | ).pipe(Effect.mapError((cause) => catalogError("encode", cause))); | 41 | ); |
| 41 | return JSON.stringify(encoded); | ||
| 42 | }); | 42 | }); |
| 43 | 43 | ||
| 44 | interface CatalogStore { | 44 | interface CatalogStore { |
| ... | @@ -48,20 +48,19 @@ interface CatalogStore { | ... | @@ -48,20 +48,19 @@ interface CatalogStore { |
| 48 | ) => Effect.Effect<void, ConnectionTransientError>; | 48 | ) => Effect.Effect<void, ConnectionTransientError>; |
| 49 | } | 49 | } |
| 50 | 50 | ||
| 51 | export interface SecureCatalogStorage { | 51 | export const make = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* () { |
| 52 | readonly getItem: (key: string) => Effect.Effect<string | null, ConnectionTransientError>; | 52 | const storage = yield* MobileSecureStorage.MobileSecureStorage; |
| 53 | readonly setItem: (key: string, value: string) => Effect.Effect<void, ConnectionTransientError>; | 53 | const getItem = (key: string) => |
| 54 | readonly deleteItem: (key: string) => Effect.Effect<void, ConnectionTransientError>; | 54 | storage.getItem(key).pipe(Effect.mapError((cause) => catalogError("load", cause))); |
| 55 | } | 55 | const setItem = (key: string, value: string) => |
| 56 | 56 | storage.setItem(key, value).pipe(Effect.mapError((cause) => catalogError("save", cause))); | |
| 57 | export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogStore")(function* ( | 57 | const deleteItem = (key: string) => |
| 58 | storage: SecureCatalogStorage, | 58 | storage.removeItem(key).pipe(Effect.mapError((cause) => catalogError("delete", cause))); |
| 59 | ) { | ||
| 60 | const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none()); | 59 | const state = yield* Ref.make<Option.Option<ConnectionCatalogDocumentType>>(Option.none()); |
| 61 | const lock = yield* Semaphore.make(1); | 60 | const lock = yield* Semaphore.make(1); |
| 62 | 61 | ||
| 63 | const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { | 62 | const loadLegacyCatalog = Effect.fn("mobile.connectionStorage.loadLegacyCatalog")(function* () { |
| 64 | const legacyRaw = yield* storage.getItem(LEGACY_CONNECTIONS_KEY); | 63 | const legacyRaw = yield* getItem(LEGACY_CONNECTIONS_KEY); |
| 65 | const catalog = | 64 | const catalog = |
| 66 | legacyRaw === null || legacyRaw.trim() === "" | 65 | legacyRaw === null || legacyRaw.trim() === "" |
| 67 | ? EMPTY_CONNECTION_CATALOG_DOCUMENT | 66 | ? EMPTY_CONNECTION_CATALOG_DOCUMENT |
| ... | @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS | ... | @@ -75,8 +74,8 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS |
| 75 | ); | 74 | ); |
| 76 | if (legacyRaw !== null && legacyRaw.trim() !== "") { | 75 | if (legacyRaw !== null && legacyRaw.trim() !== "") { |
| 77 | const encoded = yield* encodeCatalog(catalog); | 76 | const encoded = yield* encodeCatalog(catalog); |
| 78 | yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); | 77 | yield* setItem(CONNECTION_CATALOG_KEY, encoded); |
| 79 | yield* storage.deleteItem(LEGACY_CONNECTIONS_KEY); | 78 | yield* deleteItem(LEGACY_CONNECTIONS_KEY); |
| 80 | } | 79 | } |
| 81 | return catalog; | 80 | return catalog; |
| 82 | }); | 81 | }); |
| ... | @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS | ... | @@ -86,13 +85,13 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS |
| 86 | if (Option.isSome(cached)) { | 85 | if (Option.isSome(cached)) { |
| 87 | return cached.value; | 86 | return cached.value; |
| 88 | } | 87 | } |
| 89 | const raw = yield* storage.getItem(CONNECTION_CATALOG_KEY); | 88 | const raw = yield* getItem(CONNECTION_CATALOG_KEY); |
| 90 | let catalog: ConnectionCatalogDocumentType; | 89 | let catalog: ConnectionCatalogDocumentType; |
| 91 | if (raw !== null && raw.trim() !== "") { | 90 | if (raw !== null && raw.trim() !== "") { |
| 92 | catalog = yield* decodeCatalog(raw).pipe( | 91 | catalog = yield* decodeCatalog(raw).pipe( |
| 93 | Effect.catch((error) => | 92 | Effect.catch((error) => |
| 94 | Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( | 93 | Effect.logWarning("Discarding corrupt mobile connection catalog", error).pipe( |
| 95 | Effect.andThen(storage.deleteItem(CONNECTION_CATALOG_KEY)), | 94 | Effect.andThen(deleteItem(CONNECTION_CATALOG_KEY)), |
| 96 | Effect.andThen(loadLegacyCatalog()), | 95 | Effect.andThen(loadLegacyCatalog()), |
| 97 | ), | 96 | ), |
| 98 | ), | 97 | ), |
| ... | @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS | ... | @@ -111,7 +110,7 @@ export const makeCatalogStore = Effect.fn("mobile.connectionStorage.makeCatalogS |
| 111 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 112 | const next = transform(yield* loadUnlocked()); | 111 | const next = transform(yield* loadUnlocked()); |
| 113 | const encoded = yield* encodeCatalog(next); | 112 | const encoded = yield* encodeCatalog(next); |
| 114 | yield* storage.setItem(CONNECTION_CATALOG_KEY, encoded); | 113 | yield* setItem(CONNECTION_CATALOG_KEY, encoded); |
| 115 | yield* Ref.set(state, Option.some(next)); | 114 | yield* Ref.set(state, Option.some(next)); |
| 116 | }), | 115 | }), |
| 117 | ); | 116 | ); |
apps/mobile/src/connection/environment-cache-store.test.ts created+97| ... | @@ -0,0 +1,97 @@ | ||
| 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,7 +25,8 @@ import * as Network from "expo-network"; |
| 25 | import { AppState } from "react-native"; | 25 | import { AppState } from "react-native"; |
| 26 | 26 | ||
| 27 | import { authClientMetadata } from "../lib/authClientMetadata"; | 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 | import { appAtomRegistry } from "../state/atom-registry"; | 30 | import { appAtomRegistry } from "../state/atom-registry"; |
| 30 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; | 31 | import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; |
| 31 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; | 32 | import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; |
| ... | @@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({ | ... | @@ -83,82 +84,87 @@ const wakeupsLayer = Wakeups.layer({ |
| 83 | ), | 84 | ), |
| 84 | }); | 85 | }); |
| 85 | 86 | ||
| 86 | const capabilitiesLayer = Layer.succeedContext( | 87 | const capabilitiesLayer = Layer.effectContext( |
| 87 | Context.make( | 88 | Effect.gen(function* () { |
| 88 | CloudSession, | 89 | const storage = yield* MobileStorage.MobileStorage; |
| 89 | CloudSession.of({ | 90 | return Context.make( |
| 90 | clerkToken: Effect.gen(function* () { | 91 | CloudSession, |
| 91 | const session = appAtomRegistry.get(managedRelaySessionAtom); | 92 | CloudSession.of({ |
| 92 | if (session === null) { | 93 | clerkToken: Effect.gen(function* () { |
| 93 | return yield* new ConnectionBlockedError({ | 94 | const session = appAtomRegistry.get(managedRelaySessionAtom); |
| 94 | reason: "authentication", | 95 | if (session === null) { |
| 95 | detail: "Sign in to T3 Connect to connect this environment.", | 96 | return yield* new ConnectionBlockedError({ |
| 96 | }); | 97 | reason: "authentication", |
| 97 | } | 98 | detail: "Sign in to T3 Connect to connect this environment.", |
| 98 | const token = yield* session.readClerkToken().pipe( | 99 | }); |
| 99 | Effect.mapError( | 100 | } |
| 100 | (error) => | 101 | const token = yield* session.readClerkToken().pipe( |
| 101 | new ConnectionTransientError({ | 102 | Effect.mapError( |
| 102 | reason: "network", | 103 | (error) => |
| 103 | detail: error.message, | 104 | new ConnectionTransientError({ |
| 104 | }), | 105 | reason: "network", |
| 105 | ), | 106 | detail: error.message, |
| 106 | ); | 107 | }), |
| 107 | if (token === null) { | 108 | ), |
| 108 | return yield* new ConnectionBlockedError({ | 109 | ); |
| 109 | reason: "authentication", | 110 | if (token === null) { |
| 110 | detail: "The T3 Connect session is unavailable.", | 111 | return yield* new ConnectionBlockedError({ |
| 111 | }); | 112 | reason: "authentication", |
| 112 | } | 113 | detail: "The T3 Connect session is unavailable.", |
| 113 | return token; | 114 | }); |
| 114 | }), | 115 | } |
| 115 | }), | 116 | return token; |
| 116 | ).pipe( | 117 | }), |
| 117 | Context.add( | ||
| 118 | PrimaryEnvironmentAuth, | ||
| 119 | PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), | ||
| 120 | ), | ||
| 121 | Context.add( | ||
| 122 | RelayDeviceIdentity, | ||
| 123 | RelayDeviceIdentity.of({ | ||
| 124 | deviceId: Effect.tryPromise({ | ||
| 125 | try: () => loadOrCreateAgentAwarenessDeviceId(), | ||
| 126 | catch: (cause) => | ||
| 127 | new ConnectionTransientError({ | ||
| 128 | reason: "remote-unavailable", | ||
| 129 | detail: `Could not load the mobile device identity: ${String(cause)}`, | ||
| 130 | }), | ||
| 131 | }).pipe(Effect.map(Option.some)), | ||
| 132 | }), | ||
| 133 | ), | ||
| 134 | Context.add( | ||
| 135 | ClientPresentation, | ||
| 136 | ClientPresentation.of({ | ||
| 137 | metadata: authClientMetadata(), | ||
| 138 | scopes: AuthStandardClientScopes, | ||
| 139 | }), | 118 | }), |
| 140 | ), | 119 | ).pipe( |
| 141 | Context.add( | 120 | Context.add( |
| 142 | SshEnvironmentGateway, | 121 | PrimaryEnvironmentAuth, |
| 143 | SshEnvironmentGateway.of({ | 122 | PrimaryEnvironmentAuth.of({ bearerToken: Effect.succeed(Option.none()) }), |
| 144 | provision: () => | 123 | ), |
| 145 | Effect.fail( | 124 | Context.add( |
| 146 | new ConnectionBlockedError({ | 125 | RelayDeviceIdentity, |
| 147 | reason: "unsupported", | 126 | RelayDeviceIdentity.of({ |
| 148 | detail: "SSH environments are only available in the desktop app.", | 127 | deviceId: storage.loadOrCreateAgentAwarenessDeviceId.pipe( |
| 149 | }), | 128 | Effect.mapError( |
| 150 | ), | 129 | (cause) => |
| 151 | prepare: () => | 130 | new ConnectionTransientError({ |
| 152 | Effect.fail( | 131 | reason: "remote-unavailable", |
| 153 | new ConnectionBlockedError({ | 132 | detail: `Could not load the mobile device identity: ${String(cause)}`, |
| 154 | reason: "unsupported", | 133 | }), |
| 155 | detail: "SSH environments are only available in the desktop app.", | 134 | ), |
| 156 | }), | 135 | Effect.map(Option.some), |
| 157 | ), | 136 | ), |
| 158 | disconnect: () => Effect.void, | 137 | }), |
| 159 | }), | 138 | ), |
| 160 | ), | 139 | Context.add( |
| 161 | ), | 140 | ClientPresentation, |
| 141 | ClientPresentation.of({ | ||
| 142 | metadata: authClientMetadata(), | ||
| 143 | scopes: AuthStandardClientScopes, | ||
| 144 | }), | ||
| 145 | ), | ||
| 146 | Context.add( | ||
| 147 | SshEnvironmentGateway, | ||
| 148 | SshEnvironmentGateway.of({ | ||
| 149 | provision: () => | ||
| 150 | Effect.fail( | ||
| 151 | new ConnectionBlockedError({ | ||
| 152 | reason: "unsupported", | ||
| 153 | detail: "SSH environments are only available in the desktop app.", | ||
| 154 | }), | ||
| 155 | ), | ||
| 156 | prepare: () => | ||
| 157 | Effect.fail( | ||
| 158 | new ConnectionBlockedError({ | ||
| 159 | reason: "unsupported", | ||
| 160 | detail: "SSH environments are only available in the desktop app.", | ||
| 161 | }), | ||
| 162 | ), | ||
| 163 | disconnect: () => Effect.void, | ||
| 164 | }), | ||
| 165 | ), | ||
| 166 | ); | ||
| 167 | }), | ||
| 162 | ); | 168 | ); |
| 163 | 169 | ||
| 164 | const platformConnectionSourceLayer = Layer.succeed( | 170 | const platformConnectionSourceLayer = Layer.succeed( |
| ... | @@ -168,6 +174,13 @@ 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 | const environmentOwnedDataCleanupLayer = Layer.succeed( | 184 | const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 172 | EnvironmentOwnedDataCleanup, | 185 | EnvironmentOwnedDataCleanup, |
| 173 | EnvironmentOwnedDataCleanup.of({ | 186 | EnvironmentOwnedDataCleanup.of({ |
| ... | @@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( | ... | @@ -190,10 +203,11 @@ const environmentOwnedDataCleanupLayer = Layer.succeed( |
| 190 | ); | 203 | ); |
| 191 | 204 | ||
| 192 | type ConnectionPlatformLayerSource = | 205 | type ConnectionPlatformLayerSource = |
| 193 | | typeof connectionStorageLayer | 206 | | typeof providedConnectionStorageLayer |
| 207 | | typeof Runtime.runtimeContextLayer | ||
| 194 | | typeof connectivityLayer | 208 | | typeof connectivityLayer |
| 195 | | typeof wakeupsLayer | 209 | | typeof wakeupsLayer |
| 196 | | typeof capabilitiesLayer | 210 | | typeof providedCapabilitiesLayer |
| 197 | | typeof platformConnectionSourceLayer | 211 | | typeof platformConnectionSourceLayer |
| 198 | | typeof environmentOwnedDataCleanupLayer; | 212 | | typeof environmentOwnedDataCleanupLayer; |
| 199 | 213 | ||
| ... | @@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer< | ... | @@ -202,10 +216,11 @@ export const connectionPlatformLayer: Layer.Layer< |
| 202 | Layer.Error<ConnectionPlatformLayerSource>, | 216 | Layer.Error<ConnectionPlatformLayerSource>, |
| 203 | Layer.Services<ConnectionPlatformLayerSource> | 217 | Layer.Services<ConnectionPlatformLayerSource> |
| 204 | > = Layer.mergeAll( | 218 | > = Layer.mergeAll( |
| 205 | connectionStorageLayer, | 219 | providedConnectionStorageLayer, |
| 220 | Runtime.runtimeContextLayer, | ||
| 206 | connectivityLayer, | 221 | connectivityLayer, |
| 207 | wakeupsLayer, | 222 | wakeupsLayer, |
| 208 | capabilitiesLayer, | 223 | providedCapabilitiesLayer, |
| 209 | platformConnectionSourceLayer, | 224 | platformConnectionSourceLayer, |
| 210 | environmentOwnedDataCleanupLayer, | 225 | environmentOwnedDataCleanupLayer, |
| 211 | ); | 226 | ); |
apps/mobile/src/connection/storage.test.ts+25-12| ... | @@ -1,28 +1,35 @@ | ... | @@ -1,28 +1,35 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import { vi } from "vite-plus/test"; | ||
| 3 | 4 | ||
| 4 | import { | 5 | vi.mock("react-native", () => ({ |
| 5 | CONNECTION_CATALOG_KEY, | 6 | Platform: { OS: "ios" }, |
| 6 | LEGACY_CONNECTIONS_KEY, | 7 | })); |
| 7 | makeCatalogStore, | 8 | |
| 8 | type SecureCatalogStorage, | 9 | vi.mock("expo-secure-store", () => ({ |
| 9 | } from "./catalog-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 | function makeStorage(initial: Readonly<Record<string, string>>) { | 18 | function makeStorage(initial: Readonly<Record<string, string>>) { |
| 12 | const values = new Map(Object.entries(initial)); | 19 | const values = new Map(Object.entries(initial)); |
| 13 | const deleted: Array<string> = []; | 20 | const deleted: Array<string> = []; |
| 14 | const storage: SecureCatalogStorage = { | 21 | const storage = MobileSecureStorage.of({ |
| 15 | getItem: (key) => Effect.sync(() => values.get(key) ?? null), | 22 | getItem: (key) => Effect.sync(() => values.get(key) ?? null), |
| 16 | setItem: (key, value) => | 23 | setItem: (key, value) => |
| 17 | Effect.sync(() => { | 24 | Effect.sync(() => { |
| 18 | values.set(key, value); | 25 | values.set(key, value); |
| 19 | }), | 26 | }), |
| 20 | deleteItem: (key) => | 27 | removeItem: (key) => |
| 21 | Effect.sync(() => { | 28 | Effect.sync(() => { |
| 22 | deleted.push(key); | 29 | deleted.push(key); |
| 23 | values.delete(key); | 30 | values.delete(key); |
| 24 | }), | 31 | }), |
| 25 | }; | 32 | }); |
| 26 | return { deleted, storage, values }; | 33 | return { deleted, storage, values }; |
| 27 | } | 34 | } |
| 28 | 35 | ||
| ... | @@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => { | ... | @@ -32,7 +39,9 @@ describe("mobile connection catalog storage", () => { |
| 32 | const memory = makeStorage({ | 39 | const memory = makeStorage({ |
| 33 | [CONNECTION_CATALOG_KEY]: "{not-json", | 40 | [CONNECTION_CATALOG_KEY]: "{not-json", |
| 34 | }); | 41 | }); |
| 35 | const catalog = yield* makeCatalogStore(memory.storage); | 42 | const catalog = yield* make().pipe( |
| 43 | Effect.provideService(MobileSecureStorage, memory.storage), | ||
| 44 | ); | ||
| 36 | 45 | ||
| 37 | expect((yield* catalog.read).targets).toEqual([]); | 46 | expect((yield* catalog.read).targets).toEqual([]); |
| 38 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); | 47 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY]); |
| ... | @@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => { | ... | @@ -44,7 +53,9 @@ describe("mobile connection catalog storage", () => { |
| 44 | const memory = makeStorage({ | 53 | const memory = makeStorage({ |
| 45 | [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), | 54 | [LEGACY_CONNECTIONS_KEY]: JSON.stringify({ connections: [{ invalid: true }] }), |
| 46 | }); | 55 | }); |
| 47 | const catalog = yield* makeCatalogStore(memory.storage); | 56 | const catalog = yield* make().pipe( |
| 57 | Effect.provideService(MobileSecureStorage, memory.storage), | ||
| 58 | ); | ||
| 48 | 59 | ||
| 49 | expect((yield* catalog.read).targets).toEqual([]); | 60 | expect((yield* catalog.read).targets).toEqual([]); |
| 50 | expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); | 61 | expect(memory.deleted).toEqual([LEGACY_CONNECTIONS_KEY]); |
| ... | @@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => { | ... | @@ -71,7 +82,9 @@ describe("mobile connection catalog storage", () => { |
| 71 | ], | 82 | ], |
| 72 | }), | 83 | }), |
| 73 | }); | 84 | }); |
| 74 | const catalog = yield* makeCatalogStore(memory.storage); | 85 | const catalog = yield* make().pipe( |
| 86 | Effect.provideService(MobileSecureStorage, memory.storage), | ||
| 87 | ); | ||
| 75 | 88 | ||
| 76 | expect((yield* catalog.read).targets).toHaveLength(1); | 89 | expect((yield* catalog.read).targets).toHaveLength(1); |
| 77 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); | 90 | expect(memory.deleted).toEqual([CONNECTION_CATALOG_KEY, LEGACY_CONNECTIONS_KEY]); |
apps/mobile/src/connection/storage.ts+2-299| ... | @@ -2,7 +2,6 @@ import { | ... | @@ -2,7 +2,6 @@ import { |
| 2 | ConnectionPersistenceError, | 2 | ConnectionPersistenceError, |
| 3 | ConnectionRegistrationStore, | 3 | ConnectionRegistrationStore, |
| 4 | ConnectionTargetStore, | 4 | ConnectionTargetStore, |
| 5 | EnvironmentCacheStore, | ||
| 6 | registerConnectionInCatalog, | 5 | registerConnectionInCatalog, |
| 7 | removeConnectionFromCatalog, | 6 | removeConnectionFromCatalog, |
| 8 | removeCatalogValue, | 7 | removeCatalogValue, |
| ... | @@ -14,111 +13,11 @@ import { | ... | @@ -14,111 +13,11 @@ import { |
| 14 | CredentialStore, | 13 | CredentialStore, |
| 15 | ProfileStore, | 14 | ProfileStore, |
| 16 | } from "@t3tools/client-runtime/connection"; | 15 | } from "@t3tools/client-runtime/connection"; |
| 17 | import { | ||
| 18 | EnvironmentId, | ||
| 19 | OrchestrationShellSnapshot, | ||
| 20 | OrchestrationThreadDetailSnapshot, | ||
| 21 | ThreadId, | ||
| 22 | } from "@t3tools/contracts"; | ||
| 23 | import * as Context from "effect/Context"; | 16 | import * as Context from "effect/Context"; |
| 24 | import * as Effect from "effect/Effect"; | 17 | import * as Effect from "effect/Effect"; |
| 25 | import * as Layer from "effect/Layer"; | 18 | import * as Layer from "effect/Layer"; |
| 26 | import * as Option from "effect/Option"; | 19 | import * as Option from "effect/Option"; |
| 27 | import * as Schema from "effect/Schema"; | 20 | import * as CatalogStore from "./catalog-store"; |
| 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 | }); | ||
| 122 | 21 | ||
| 123 | function targetPersistenceError( | 22 | function targetPersistenceError( |
| 124 | operation: "list-targets" | "register-connection" | "remove-connection", | 23 | operation: "list-targets" | "register-connection" | "remove-connection", |
| ... | @@ -130,59 +29,9 @@ function targetPersistenceError( | ... | @@ -130,59 +29,9 @@ function targetPersistenceError( |
| 130 | }); | 29 | }); |
| 131 | } | 30 | } |
| 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 | export const connectionStorageLayer = Layer.effectContext( | 32 | export const connectionStorageLayer = Layer.effectContext( |
| 184 | Effect.gen(function* () { | 33 | Effect.gen(function* () { |
| 185 | const catalog = yield* makeCatalogStore(secureCatalogStorage); | 34 | const catalog = yield* CatalogStore.make(); |
| 186 | 35 | ||
| 187 | const targetStore = ConnectionTargetStore.of({ | 36 | const targetStore = ConnectionTargetStore.of({ |
| 188 | list: catalog.read.pipe( | 37 | list: catalog.read.pipe( |
| ... | @@ -279,157 +128,11 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -279,157 +128,11 @@ export const connectionStorageLayer = Layer.effectContext( |
| 279 | ), | 128 | ), |
| 280 | })), | 129 | })), |
| 281 | }); | 130 | }); |
| 282 | const cacheStore = EnvironmentCacheStore.of({ | ||
| 283 | loadShell: (environmentId) => | ||
| 284 | Effect.gen(function* () { | ||
| 285 | const file = yield* shellSnapshotFile(environmentId, "load-shell"); | ||
| 286 | if (file.exists) { | ||
| 287 | const raw = yield* Effect.tryPromise({ | ||
| 288 | try: () => file.text(), | ||
| 289 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 290 | }); | ||
| 291 | const parsed = yield* Effect.try({ | ||
| 292 | try: () => JSON.parse(raw) as unknown, | ||
| 293 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 294 | }); | ||
| 295 | const stored = yield* Effect.fromResult( | ||
| 296 | Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), | ||
| 297 | ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); | ||
| 298 | return stored.environmentId === environmentId | ||
| 299 | ? Option.some(stored.snapshot) | ||
| 300 | : Option.none(); | ||
| 301 | } | ||
| 302 | |||
| 303 | const legacyFile = yield* legacyShellSnapshotFile(environmentId, "load-shell"); | ||
| 304 | if (!legacyFile.exists) { | ||
| 305 | return Option.none(); | ||
| 306 | } | ||
| 307 | const legacyRaw = yield* Effect.tryPromise({ | ||
| 308 | try: () => legacyFile.text(), | ||
| 309 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 310 | }); | ||
| 311 | const legacyParsed = yield* Effect.try({ | ||
| 312 | try: () => JSON.parse(legacyRaw) as unknown, | ||
| 313 | catch: (cause) => shellPersistenceError("load-shell", cause), | ||
| 314 | }); | ||
| 315 | const legacyStored = yield* Effect.fromResult( | ||
| 316 | Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), | ||
| 317 | ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); | ||
| 318 | return legacyStored.environmentId === environmentId | ||
| 319 | ? Option.some(legacyStored.snapshot) | ||
| 320 | : Option.none(); | ||
| 321 | }), | ||
| 322 | saveShell: (environmentId, snapshot) => | ||
| 323 | Effect.gen(function* () { | ||
| 324 | const file = yield* shellSnapshotFile(environmentId, "save-shell"); | ||
| 325 | const stored = { | ||
| 326 | schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 327 | environmentId, | ||
| 328 | snapshot, | ||
| 329 | } as const; | ||
| 330 | const encoded = yield* Effect.fromResult( | ||
| 331 | Schema.encodeUnknownResult(StoredShellSnapshot)(stored), | ||
| 332 | ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); | ||
| 333 | yield* Effect.try({ | ||
| 334 | try: () => { | ||
| 335 | if (!file.exists) { | ||
| 336 | file.create({ intermediates: true, overwrite: true }); | ||
| 337 | } | ||
| 338 | file.write(JSON.stringify(encoded)); | ||
| 339 | }, | ||
| 340 | catch: (cause) => shellPersistenceError("save-shell", cause), | ||
| 341 | }); | ||
| 342 | }), | ||
| 343 | loadThread: (environmentId, threadId) => | ||
| 344 | Effect.gen(function* () { | ||
| 345 | const file = yield* threadSnapshotFile(environmentId, threadId, "load-thread"); | ||
| 346 | if (!file.exists) { | ||
| 347 | return Option.none(); | ||
| 348 | } | ||
| 349 | const raw = yield* Effect.tryPromise({ | ||
| 350 | try: () => file.text(), | ||
| 351 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 352 | }); | ||
| 353 | const parsed = yield* Effect.try({ | ||
| 354 | try: () => JSON.parse(raw) as unknown, | ||
| 355 | catch: (cause) => shellPersistenceError("load-thread", cause), | ||
| 356 | }); | ||
| 357 | const stored = yield* Effect.fromResult( | ||
| 358 | Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), | ||
| 359 | ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); | ||
| 360 | return stored.environmentId === environmentId && stored.threadId === threadId | ||
| 361 | ? Option.some(stored.snapshot) | ||
| 362 | : Option.none(); | ||
| 363 | }), | ||
| 364 | saveThread: (environmentId, snapshot) => | ||
| 365 | Effect.gen(function* () { | ||
| 366 | const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread"); | ||
| 367 | const encoded = yield* Effect.fromResult( | ||
| 368 | Schema.encodeUnknownResult(StoredThreadSnapshot)({ | ||
| 369 | schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION, | ||
| 370 | environmentId, | ||
| 371 | threadId: snapshot.thread.id, | ||
| 372 | snapshot, | ||
| 373 | }), | ||
| 374 | ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); | ||
| 375 | yield* Effect.try({ | ||
| 376 | try: () => { | ||
| 377 | if (!file.exists) { | ||
| 378 | file.create({ intermediates: true, overwrite: true }); | ||
| 379 | } | ||
| 380 | file.write(JSON.stringify(encoded)); | ||
| 381 | }, | ||
| 382 | catch: (cause) => shellPersistenceError("save-thread", cause), | ||
| 383 | }); | ||
| 384 | }), | ||
| 385 | removeThread: (environmentId, threadId) => | ||
| 386 | Effect.gen(function* () { | ||
| 387 | const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); | ||
| 388 | if (file.exists) { | ||
| 389 | file.delete(); | ||
| 390 | } | ||
| 391 | }).pipe( | ||
| 392 | Effect.mapError((cause) => | ||
| 393 | cause._tag === "ConnectionPersistenceError" | ||
| 394 | ? cause | ||
| 395 | : shellPersistenceError("remove-thread", cause), | ||
| 396 | ), | ||
| 397 | ), | ||
| 398 | clear: (environmentId) => | ||
| 399 | Effect.gen(function* () { | ||
| 400 | const file = yield* shellSnapshotFile(environmentId, "clear-environment"); | ||
| 401 | if (file.exists) { | ||
| 402 | yield* Effect.try({ | ||
| 403 | try: () => file.delete(), | ||
| 404 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 405 | }); | ||
| 406 | } | ||
| 407 | const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); | ||
| 408 | if (legacyFile.exists) { | ||
| 409 | yield* Effect.try({ | ||
| 410 | try: () => legacyFile.delete(), | ||
| 411 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 412 | }); | ||
| 413 | } | ||
| 414 | const threadDirectory = yield* threadSnapshotDirectory( | ||
| 415 | environmentId, | ||
| 416 | "clear-environment", | ||
| 417 | ); | ||
| 418 | if (threadDirectory.exists) { | ||
| 419 | yield* Effect.try({ | ||
| 420 | try: () => threadDirectory.delete(), | ||
| 421 | catch: (cause) => shellPersistenceError("clear-environment", cause), | ||
| 422 | }); | ||
| 423 | } | ||
| 424 | }), | ||
| 425 | }); | ||
| 426 | |||
| 427 | return Context.make(ConnectionTargetStore, targetStore).pipe( | 131 | return Context.make(ConnectionTargetStore, targetStore).pipe( |
| 428 | Context.add(ConnectionRegistrationStore, registrationStore), | 132 | Context.add(ConnectionRegistrationStore, registrationStore), |
| 429 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), | 133 | Context.add(ProfileStore.ConnectionProfileStore, profileStore), |
| 430 | Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), | 134 | Context.add(CredentialStore.ConnectionCredentialStore, credentialStore), |
| 431 | Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), | 135 | Context.add(TokenStore.RemoteDpopAccessTokenStore, remoteTokenStore), |
| 432 | Context.add(EnvironmentCacheStore, cacheStore), | ||
| 433 | ); | 136 | ); |
| 434 | }), | 137 | }), |
| 435 | ); | 138 | ); |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.test.ts+85-18| ... | @@ -7,21 +7,30 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,21 +7,30 @@ import * as Layer from "effect/Layer"; |
| 7 | import { HttpClient } from "effect/unstable/http"; | 7 | import { HttpClient } from "effect/unstable/http"; |
| 8 | 8 | ||
| 9 | import type { SavedRemoteConnection } from "../../lib/connection"; | 9 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 10 | import { savePreferencesPatch } from "../../lib/storage"; | 10 | import { MobileStorage } from "../../persistence/mobile-storage"; |
| 11 | import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; | 11 | import { |
| 12 | CloudEnvironmentLinkError, | ||
| 13 | linkEnvironmentToCloudWithPreference, | ||
| 14 | } from "../cloud/linkEnvironment"; | ||
| 12 | import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; | 15 | import { setLiveActivityUpdatesEnabled } from "./liveActivityPreferences"; |
| 13 | import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; | 16 | import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; |
| 14 | 17 | ||
| 15 | vi.mock("../../lib/storage", () => ({ | 18 | vi.mock("expo-secure-store", () => ({ |
| 16 | savePreferencesPatch: vi.fn(() => Promise.resolve()), | 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 | vi.mock("../cloud/linkEnvironment", () => ({ | 28 | vi.mock("../cloud/linkEnvironment", () => ({ |
| 20 | linkEnvironmentToCloud: vi.fn(() => Effect.void), | 29 | linkEnvironmentToCloudWithPreference: vi.fn(() => Effect.void), |
| 21 | })); | 30 | })); |
| 22 | 31 | ||
| 23 | vi.mock("./remoteRegistration", () => ({ | 32 | vi.mock("./remoteRegistration", () => ({ |
| 24 | refreshAgentAwarenessRegistration: vi.fn(() => Effect.void), | 33 | updateAgentAwarenessRegistrationPreferences: vi.fn(() => Effect.void), |
| 25 | })); | 34 | })); |
| 26 | 35 | ||
| 27 | const connection: SavedRemoteConnection = { | 36 | const connection: SavedRemoteConnection = { |
| ... | @@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll( | ... | @@ -40,6 +49,19 @@ const testLayer = Layer.mergeAll( |
| 40 | HttpClient.HttpClient, | 49 | HttpClient.HttpClient, |
| 41 | HttpClient.make(() => Effect.die("unexpected HTTP request")), | 50 | HttpClient.make(() => Effect.die("unexpected HTTP request")), |
| 42 | ), | 51 | ), |
| 52 | Layer.succeed( | ||
| 53 | MobileStorage, | ||
| 54 | MobileStorage.of({ | ||
| 55 | loadSavedConnections: Effect.succeed([]), | ||
| 56 | saveConnection: () => Effect.void, | ||
| 57 | clearSavedConnection: () => Effect.void, | ||
| 58 | loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), | ||
| 59 | loadAgentAwarenessDeviceId: Effect.succeed("device-1"), | ||
| 60 | loadAgentAwarenessRegistrationRecord: Effect.succeed(null), | ||
| 61 | saveAgentAwarenessRegistrationRecord: () => Effect.void, | ||
| 62 | clearAgentAwarenessRegistrationRecord: Effect.void, | ||
| 63 | }), | ||
| 64 | ), | ||
| 43 | ); | 65 | ); |
| 44 | 66 | ||
| 45 | describe("liveActivityPreferences", () => { | 67 | describe("liveActivityPreferences", () => { |
| ... | @@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => { | ... | @@ -51,15 +73,18 @@ describe("liveActivityPreferences", () => { |
| 51 | Effect.gen(function* () { | 73 | Effect.gen(function* () { |
| 52 | yield* setLiveActivityUpdatesEnabled({ | 74 | yield* setLiveActivityUpdatesEnabled({ |
| 53 | enabled: false, | 75 | enabled: false, |
| 76 | previousEnabled: true, | ||
| 54 | clerkToken: "clerk-token", | 77 | clerkToken: "clerk-token", |
| 55 | connections: [connection], | 78 | connections: [connection], |
| 56 | }); | 79 | }); |
| 57 | 80 | ||
| 58 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | 81 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ |
| 59 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | 82 | liveActivitiesEnabled: false, |
| 60 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | 83 | }); |
| 84 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ | ||
| 61 | clerkToken: "clerk-token", | 85 | clerkToken: "clerk-token", |
| 62 | connection, | 86 | connection, |
| 87 | liveActivitiesEnabled: false, | ||
| 63 | }); | 88 | }); |
| 64 | }).pipe(Effect.provide(testLayer)), | 89 | }).pipe(Effect.provide(testLayer)), |
| 65 | ); | 90 | ); |
| ... | @@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => { | ... | @@ -68,15 +93,18 @@ describe("liveActivityPreferences", () => { |
| 68 | Effect.gen(function* () { | 93 | Effect.gen(function* () { |
| 69 | yield* setLiveActivityUpdatesEnabled({ | 94 | yield* setLiveActivityUpdatesEnabled({ |
| 70 | enabled: true, | 95 | enabled: true, |
| 96 | previousEnabled: false, | ||
| 71 | clerkToken: "clerk-token", | 97 | clerkToken: "clerk-token", |
| 72 | connections: [connection], | 98 | connections: [connection], |
| 73 | }); | 99 | }); |
| 74 | 100 | ||
| 75 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: true }); | 101 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ |
| 76 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | 102 | liveActivitiesEnabled: true, |
| 77 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | 103 | }); |
| 104 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ | ||
| 78 | clerkToken: "clerk-token", | 105 | clerkToken: "clerk-token", |
| 79 | connection, | 106 | connection, |
| 107 | liveActivitiesEnabled: true, | ||
| 80 | }); | 108 | }); |
| 81 | }).pipe(Effect.provide(testLayer)), | 109 | }).pipe(Effect.provide(testLayer)), |
| 82 | ); | 110 | ); |
| ... | @@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => { | ... | @@ -85,13 +113,15 @@ describe("liveActivityPreferences", () => { |
| 85 | Effect.gen(function* () { | 113 | Effect.gen(function* () { |
| 86 | yield* setLiveActivityUpdatesEnabled({ | 114 | yield* setLiveActivityUpdatesEnabled({ |
| 87 | enabled: false, | 115 | enabled: false, |
| 116 | previousEnabled: true, | ||
| 88 | clerkToken: null, | 117 | clerkToken: null, |
| 89 | connections: [connection], | 118 | connections: [connection], |
| 90 | }); | 119 | }); |
| 91 | 120 | ||
| 92 | expect(savePreferencesPatch).toHaveBeenCalledWith({ liveActivitiesEnabled: false }); | 121 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenCalledWith({ |
| 93 | expect(refreshAgentAwarenessRegistration).toHaveBeenCalledTimes(1); | 122 | liveActivitiesEnabled: false, |
| 94 | expect(linkEnvironmentToCloud).not.toHaveBeenCalled(); | 123 | }); |
| 124 | expect(linkEnvironmentToCloudWithPreference).not.toHaveBeenCalled(); | ||
| 95 | }).pipe(Effect.provide(testLayer)), | 125 | }).pipe(Effect.provide(testLayer)), |
| 96 | ); | 126 | ); |
| 97 | 127 | ||
| ... | @@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => { | ... | @@ -104,14 +134,51 @@ describe("liveActivityPreferences", () => { |
| 104 | return Effect.gen(function* () { | 134 | return Effect.gen(function* () { |
| 105 | yield* setLiveActivityUpdatesEnabled({ | 135 | yield* setLiveActivityUpdatesEnabled({ |
| 106 | enabled: true, | 136 | enabled: true, |
| 137 | previousEnabled: false, | ||
| 107 | clerkToken: "clerk-token", | 138 | clerkToken: "clerk-token", |
| 108 | connections: [connection, managedConnection], | 139 | connections: [connection, managedConnection], |
| 109 | }); | 140 | }); |
| 110 | 141 | ||
| 111 | expect(linkEnvironmentToCloud).toHaveBeenCalledTimes(1); | 142 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledTimes(1); |
| 112 | expect(linkEnvironmentToCloud).toHaveBeenCalledWith({ | 143 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenCalledWith({ |
| 144 | clerkToken: "clerk-token", | ||
| 145 | connection, | ||
| 146 | liveActivitiesEnabled: true, | ||
| 147 | }); | ||
| 148 | }).pipe(Effect.provide(testLayer)); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it.effect("restores relay preferences when an environment update fails", () => { | ||
| 152 | vi.mocked(linkEnvironmentToCloudWithPreference).mockImplementationOnce(() => | ||
| 153 | Effect.fail(new CloudEnvironmentLinkError({ message: "environment update failed" })), | ||
| 154 | ); | ||
| 155 | |||
| 156 | return Effect.gen(function* () { | ||
| 157 | const exit = yield* Effect.exit( | ||
| 158 | setLiveActivityUpdatesEnabled({ | ||
| 159 | enabled: false, | ||
| 160 | previousEnabled: true, | ||
| 161 | clerkToken: "clerk-token", | ||
| 162 | connections: [connection], | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | |||
| 166 | expect(exit._tag).toBe("Failure"); | ||
| 167 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(1, { | ||
| 168 | liveActivitiesEnabled: false, | ||
| 169 | }); | ||
| 170 | expect(updateAgentAwarenessRegistrationPreferences).toHaveBeenNthCalledWith(2, { | ||
| 171 | liveActivitiesEnabled: true, | ||
| 172 | }); | ||
| 173 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(1, { | ||
| 174 | clerkToken: "clerk-token", | ||
| 175 | connection, | ||
| 176 | liveActivitiesEnabled: false, | ||
| 177 | }); | ||
| 178 | expect(linkEnvironmentToCloudWithPreference).toHaveBeenNthCalledWith(2, { | ||
| 113 | clerkToken: "clerk-token", | 179 | clerkToken: "clerk-token", |
| 114 | connection, | 180 | connection, |
| 181 | liveActivitiesEnabled: true, | ||
| 115 | }); | 182 | }); |
| 116 | }).pipe(Effect.provide(testLayer)); | 183 | }).pipe(Effect.provide(testLayer)); |
| 117 | }); | 184 | }); |
apps/mobile/src/features/agent-awareness/liveActivityPreferences.ts+65-39| ... | @@ -1,47 +1,73 @@ | ... | @@ -1,47 +1,73 @@ |
| 1 | import * as Effect from "effect/Effect"; | 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 | import type { SavedRemoteConnection } from "../../lib/connection"; | 3 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 7 | import { savePreferencesPatch } from "../../lib/storage"; | 4 | import { linkEnvironmentToCloudWithPreference } from "../cloud/linkEnvironment"; |
| 8 | import { linkEnvironmentToCloud } from "../cloud/linkEnvironment"; | 5 | import { updateAgentAwarenessRegistrationPreferences } from "./remoteRegistration"; |
| 9 | import { refreshAgentAwarenessRegistration } from "./remoteRegistration"; | 6 | |
| 10 | 7 | export const setLiveActivityUpdatesEnabled = Effect.fn("setLiveActivityUpdatesEnabled")( | |
| 11 | export class LiveActivityPreferenceSaveError extends Schema.TaggedErrorClass<LiveActivityPreferenceSaveError>()( | 8 | function* (input: { |
| 12 | "LiveActivityPreferenceSaveError", | 9 | readonly enabled: boolean; |
| 13 | { | 10 | readonly previousEnabled: boolean; |
| 14 | enabled: Schema.Boolean, | 11 | readonly clerkToken: string | null; |
| 15 | cause: Schema.Defect(), | 12 | readonly connections: ReadonlyArray<SavedRemoteConnection>; |
| 16 | }, | 13 | }) { |
| 17 | ) { | 14 | const linkedConnections = input.connections.filter( |
| 18 | override get message(): string { | 15 | (connection) => connection.bearerToken !== null, |
| 19 | return `Failed to save the Live Activity updates setting (enabled: ${this.enabled}).`; | 16 | ); |
| 20 | } | 17 | |
| 21 | } | 18 | const updateRelayPreference = Effect.fn("updateRelayPreference")(function* (enabled: boolean) { |
| 22 | 19 | yield* updateAgentAwarenessRegistrationPreferences({ | |
| 23 | export function setLiveActivityUpdatesEnabled(input: { | 20 | liveActivitiesEnabled: enabled, |
| 24 | readonly enabled: boolean; | 21 | }); |
| 25 | readonly clerkToken: string | null; | 22 | |
| 26 | readonly connections: ReadonlyArray<SavedRemoteConnection>; | 23 | const clerkToken = input.clerkToken; |
| 27 | }): Effect.Effect<void, unknown, HttpClient.HttpClient | ManagedRelay.ManagedRelayClient> { | 24 | if (!clerkToken) return; |
| 28 | return Effect.gen(function* () { | 25 | |
| 29 | yield* Effect.tryPromise({ | 26 | yield* Effect.forEach( |
| 30 | try: () => savePreferencesPatch({ liveActivitiesEnabled: input.enabled }), | 27 | linkedConnections, |
| 31 | catch: (cause) => new LiveActivityPreferenceSaveError({ enabled: input.enabled, cause }), | 28 | (connection) => |
| 29 | linkEnvironmentToCloudWithPreference({ | ||
| 30 | clerkToken, | ||
| 31 | connection, | ||
| 32 | liveActivitiesEnabled: enabled, | ||
| 33 | }), | ||
| 34 | { concurrency: "unbounded" }, | ||
| 35 | ); | ||
| 32 | }); | 36 | }); |
| 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; | 50 | yield* Effect.forEach( |
| 37 | if (!clerkToken) { | 51 | linkedConnections, |
| 38 | return; | 52 | (connection) => |
| 39 | } | 53 | linkEnvironmentToCloudWithPreference({ |
| 54 | clerkToken, | ||
| 55 | connection, | ||
| 56 | liveActivitiesEnabled: input.previousEnabled, | ||
| 57 | }).pipe( | ||
| 58 | Effect.catchCause((cause) => | ||
| 59 | Effect.logWarning( | ||
| 60 | `Could not restore Live Activity preference for environment ${connection.environmentId}.`, | ||
| 61 | cause, | ||
| 62 | ), | ||
| 63 | ), | ||
| 64 | ), | ||
| 65 | { concurrency: "unbounded" }, | ||
| 66 | ); | ||
| 67 | }); | ||
| 40 | 68 | ||
| 41 | yield* Effect.forEach( | 69 | yield* updateRelayPreference(input.enabled).pipe( |
| 42 | input.connections.filter((connection) => connection.bearerToken !== null), | 70 | Effect.onError(() => restoreRelayPreference()), |
| 43 | (connection) => linkEnvironmentToCloud({ clerkToken, connection }), | ||
| 44 | { concurrency: "unbounded" }, | ||
| 45 | ); | 71 | ); |
| 46 | }); | 72 | }, |
| 47 | } | 73 | ); |
apps/mobile/src/features/agent-awareness/registrationPayload.ts+1-1| ... | @@ -1,6 +1,6 @@ | ... | @@ -1,6 +1,6 @@ |
| 1 | import type { RelayDeviceRegistrationRequest } from "@t3tools/contracts/relay"; | 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 | // Development builds are Xcode-signed and receive sandbox APNs tokens; | 5 | // Development builds are Xcode-signed and receive sandbox APNs tokens; |
| 6 | // preview and production builds are distribution-signed and use production | 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,12 +21,13 @@ import { |
| 21 | loadAgentAwarenessRegistrationRecord, | 21 | loadAgentAwarenessRegistrationRecord, |
| 22 | loadOrCreateAgentAwarenessDeviceId, | 22 | loadOrCreateAgentAwarenessDeviceId, |
| 23 | saveAgentAwarenessRegistrationRecord, | 23 | saveAgentAwarenessRegistrationRecord, |
| 24 | } from "../../lib/storage"; | 24 | } from "../../persistence/imperative"; |
| 25 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; | 25 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| 26 | import { | 26 | import { |
| 27 | AgentAwarenessOperationError, | 27 | AgentAwarenessOperationError, |
| 28 | __resetAgentAwarenessRemoteRegistrationForTest, | 28 | __resetAgentAwarenessRemoteRegistrationForTest, |
| 29 | getAgentAwarenessRegistrationStatus, | 29 | getAgentAwarenessRegistrationStatus, |
| 30 | mergeAgentAwarenessRegistrationPreferences, | ||
| 30 | refreshActiveLiveActivityRemoteRegistration, | 31 | refreshActiveLiveActivityRemoteRegistration, |
| 31 | refreshAgentAwarenessRegistration, | 32 | refreshAgentAwarenessRegistration, |
| 32 | normalizeAgentAwarenessRelayBaseUrl, | 33 | normalizeAgentAwarenessRelayBaseUrl, |
| ... | @@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({ | ... | @@ -142,7 +143,7 @@ vi.mock("../../lib/runtime", () => ({ |
| 142 | }, | 143 | }, |
| 143 | })); | 144 | })); |
| 144 | 145 | ||
| 145 | vi.mock("../../lib/storage", () => ({ | 146 | vi.mock("../../persistence/imperative", () => ({ |
| 146 | loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | 147 | loadAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), |
| 147 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | 148 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), |
| 148 | loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), | 149 | loadPreferences: vi.fn(() => Promise.resolve({ liveActivitiesEnabled: false })), |
| ... | @@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { | ... | @@ -324,6 +325,15 @@ describe("makeRelayDeviceRegistrationRequest", () => { |
| 324 | expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); | 325 | expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); |
| 325 | }); | 326 | }); |
| 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 | it.effect("registers at most one listener while a Live Activity push token is pending", () => { | 337 | it.effect("registers at most one listener while a Live Activity push token is pending", () => { |
| 328 | registerAgentAwarenessConnection(savedConnection()); | 338 | registerAgentAwarenessConnection(savedConnection()); |
| 329 | const addPushTokenListener = vi.fn(); | 339 | const addPushTokenListener = vi.fn(); |
apps/mobile/src/features/agent-awareness/remoteRegistration.ts+34-3| ... | @@ -20,6 +20,7 @@ import { | ... | @@ -20,6 +20,7 @@ import { |
| 20 | 20 | ||
| 21 | import type { SavedRemoteConnection } from "../../lib/connection"; | 21 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 22 | import { runtime } from "../../lib/runtime"; | 22 | import { runtime } from "../../lib/runtime"; |
| 23 | import type { Preferences } from "../../persistence/mobile-preferences"; | ||
| 23 | import { | 24 | import { |
| 24 | clearAgentAwarenessRegistrationRecord, | 25 | clearAgentAwarenessRegistrationRecord, |
| 25 | loadAgentAwarenessDeviceId, | 26 | loadAgentAwarenessDeviceId, |
| ... | @@ -27,7 +28,7 @@ import { | ... | @@ -27,7 +28,7 @@ import { |
| 27 | loadOrCreateAgentAwarenessDeviceId, | 28 | loadOrCreateAgentAwarenessDeviceId, |
| 28 | loadPreferences, | 29 | loadPreferences, |
| 29 | saveAgentAwarenessRegistrationRecord, | 30 | saveAgentAwarenessRegistrationRecord, |
| 30 | } from "../../lib/storage"; | 31 | } from "../../persistence/imperative"; |
| 31 | import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; | 32 | import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; |
| 32 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; | 33 | import { resolveCloudPublicConfig } from "../cloud/publicConfig"; |
| 33 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; | 34 | import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; |
| ... | @@ -124,6 +125,17 @@ interface DeviceRegistrationInput { | ... | @@ -124,6 +125,17 @@ interface DeviceRegistrationInput { |
| 124 | readonly observedPushToken?: string; | 125 | readonly observedPushToken?: string; |
| 125 | } | 126 | } |
| 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 | export function normalizeAgentAwarenessRelayBaseUrl( | 139 | export function normalizeAgentAwarenessRelayBaseUrl( |
| 128 | value: string | null | undefined, | 140 | value: string | null | undefined, |
| 129 | ): string | null { | 141 | ): string | null { |
| ... | @@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri | ... | @@ -655,7 +667,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri |
| 655 | } | 667 | } |
| 656 | 668 | ||
| 657 | function registerDevice( | 669 | function registerDevice( |
| 658 | input: DeviceRegistrationInput = {}, | 670 | input: RegisterDeviceInput = {}, |
| 659 | expectedGeneration = deviceRegistrationGeneration, | 671 | expectedGeneration = deviceRegistrationGeneration, |
| 660 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { | 672 | ): Effect.Effect<void, unknown, ManagedRelay.ManagedRelayClient> { |
| 661 | return Effect.gen(function* () { | 673 | return Effect.gen(function* () { |
| ... | @@ -665,7 +677,7 @@ function registerDevice( | ... | @@ -665,7 +677,7 @@ function registerDevice( |
| 665 | } | 677 | } |
| 666 | 678 | ||
| 667 | logRegistrationDebug("device registration loading local state", { expectedGeneration }); | 679 | logRegistrationDebug("device registration loading local state", { expectedGeneration }); |
| 668 | const [deviceId, preferences] = yield* Effect.all([ | 680 | const [deviceId, storedPreferences] = yield* Effect.all([ |
| 669 | Effect.tryPromise({ | 681 | Effect.tryPromise({ |
| 670 | try: () => loadOrCreateAgentAwarenessDeviceId(), | 682 | try: () => loadOrCreateAgentAwarenessDeviceId(), |
| 671 | catch: (cause) => | 683 | catch: (cause) => |
| ... | @@ -683,6 +695,10 @@ function registerDevice( | ... | @@ -683,6 +695,10 @@ function registerDevice( |
| 683 | }), | 695 | }), |
| 684 | }), | 696 | }), |
| 685 | ]); | 697 | ]); |
| 698 | const preferences = mergeAgentAwarenessRegistrationPreferences( | ||
| 699 | storedPreferences, | ||
| 700 | input.preferencesOverride, | ||
| 701 | ); | ||
| 686 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); | 702 | const pushTokenRegistration = yield* nativePushTokenRegistration(input?.observedPushToken); |
| 687 | logRegistrationDebug("device registration local state ready", { | 703 | logRegistrationDebug("device registration local state ready", { |
| 688 | expectedGeneration, | 704 | expectedGeneration, |
| ... | @@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< | ... | @@ -820,6 +836,21 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< |
| 820 | ); | 836 | ); |
| 821 | } | 837 | } |
| 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 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { | 854 | export function __resetAgentAwarenessRemoteRegistrationForTest(): void { |
| 824 | environmentConnections.clear(); | 855 | environmentConnections.clear(); |
| 825 | pushTokenSubscription?.remove(); | 856 | pushTokenSubscription?.remove(); |
apps/mobile/src/features/cloud/connectOnboardingOptOut.ts+2-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import { loadPreferences, updatePreferences } from "../../lib/storage"; | 1 | import { loadPreferences, updatePreferences } from "../../persistence/imperative"; |
| 2 | 2 | ||
| 3 | // Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports | 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 | // module graph; that breaks CloudAuthProvider.test.ts suite loading. | 5 | // module graph; that breaks CloudAuthProvider.test.ts suite loading. |
| 6 | 6 | ||
| 7 | /** Whether the account chose "Don't show this again". */ | 7 | /** Whether the account chose "Don't show this again". */ |
apps/mobile/src/features/cloud/linkEnvironment.test.ts+76-8| ... | @@ -7,10 +7,13 @@ import { RelayMobileClientId } from "@t3tools/contracts/relay"; | ... | @@ -7,10 +7,13 @@ import { RelayMobileClientId } from "@t3tools/contracts/relay"; |
| 7 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; | 7 | import { ManagedRelay } from "@t3tools/client-runtime/relay"; |
| 8 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; | 8 | import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; |
| 9 | import { HttpClient } from "effect/unstable/http"; | 9 | import { HttpClient } from "effect/unstable/http"; |
| 10 | import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; | ||
| 11 | import { MobileStorage } from "../../persistence/mobile-storage"; | ||
| 10 | 12 | ||
| 11 | import { | 13 | import { |
| 12 | cloudEnvironmentsPendingStatus, | 14 | cloudEnvironmentsPendingStatus, |
| 13 | linkEnvironmentToCloud, | 15 | linkEnvironmentToCloud, |
| 16 | linkEnvironmentToCloudWithPreference, | ||
| 14 | connectCloudEnvironment, | 17 | connectCloudEnvironment, |
| 15 | listCloudEnvironments, | 18 | listCloudEnvironments, |
| 16 | listCloudEnvironmentsWithStatus, | 19 | listCloudEnvironmentsWithStatus, |
| ... | @@ -36,11 +39,14 @@ vi.mock("react-native", () => ({ | ... | @@ -36,11 +39,14 @@ vi.mock("react-native", () => ({ |
| 36 | }, | 39 | }, |
| 37 | })); | 40 | })); |
| 38 | 41 | ||
| 39 | vi.mock("../../lib/storage", () => ({ | 42 | vi.mock("expo-secure-store", () => ({ |
| 40 | loadOrCreateAgentAwarenessDeviceId: vi.fn(() => Promise.resolve("device-1")), | 43 | deleteItemAsync: vi.fn(), |
| 41 | loadPreferences: vi.fn(() => Promise.resolve({})), | 44 | getItemAsync: vi.fn(), |
| 45 | setItemAsync: vi.fn(), | ||
| 42 | })); | 46 | })); |
| 43 | 47 | ||
| 48 | const loadPreferences = vi.fn(() => Effect.succeed({})); | ||
| 49 | |||
| 44 | const savedConnection = { | 50 | const savedConnection = { |
| 45 | environmentId: EnvironmentId.make("env-1"), | 51 | environmentId: EnvironmentId.make("env-1"), |
| 46 | environmentLabel: "Desktop", | 52 | environmentLabel: "Desktop", |
| ... | @@ -69,6 +75,27 @@ function cloudClientLayer() { | ... | @@ -69,6 +75,27 @@ function cloudClientLayer() { |
| 69 | const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); | 75 | const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); |
| 70 | return Layer.mergeAll( | 76 | return Layer.mergeAll( |
| 71 | httpClientLayer, | 77 | httpClientLayer, |
| 78 | Layer.succeed( | ||
| 79 | MobilePreferencesStore, | ||
| 80 | MobilePreferencesStore.of({ | ||
| 81 | load: loadPreferences(), | ||
| 82 | savePatch: (patch) => Effect.succeed(patch), | ||
| 83 | update: () => Effect.succeed({}), | ||
| 84 | }), | ||
| 85 | ), | ||
| 86 | Layer.succeed( | ||
| 87 | MobileStorage, | ||
| 88 | MobileStorage.of({ | ||
| 89 | loadSavedConnections: Effect.succeed([]), | ||
| 90 | saveConnection: () => Effect.void, | ||
| 91 | clearSavedConnection: () => Effect.void, | ||
| 92 | loadOrCreateAgentAwarenessDeviceId: Effect.succeed("device-1"), | ||
| 93 | loadAgentAwarenessDeviceId: Effect.succeed("device-1"), | ||
| 94 | loadAgentAwarenessRegistrationRecord: Effect.succeed(null), | ||
| 95 | saveAgentAwarenessRegistrationRecord: () => Effect.void, | ||
| 96 | clearAgentAwarenessRegistrationRecord: Effect.void, | ||
| 97 | }), | ||
| 98 | ), | ||
| 72 | ManagedRelay.layer({ | 99 | ManagedRelay.layer({ |
| 73 | relayUrl: "https://relay.example.test", | 100 | relayUrl: "https://relay.example.test", |
| 74 | clientId: RelayMobileClientId, | 101 | clientId: RelayMobileClientId, |
| ... | @@ -80,7 +107,11 @@ const withCloudServices = <A, E>( | ... | @@ -80,7 +107,11 @@ const withCloudServices = <A, E>( |
| 80 | effect: Effect.Effect< | 107 | effect: Effect.Effect< |
| 81 | A, | 108 | A, |
| 82 | E, | 109 | E, |
| 83 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner | 110 | | HttpClient.HttpClient |
| 111 | | ManagedRelay.ManagedRelayClient | ||
| 112 | | ManagedRelay.ManagedRelayDpopSigner | ||
| 113 | | MobilePreferencesStore | ||
| 114 | | MobileStorage | ||
| 84 | >, | 115 | >, |
| 85 | ) => effect.pipe(Effect.provide(cloudClientLayer())); | 116 | ) => effect.pipe(Effect.provide(cloudClientLayer())); |
| 86 | 117 | ||
| ... | @@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => { | ... | @@ -146,6 +177,7 @@ describe("mobile cloud link environment client", () => { |
| 146 | beforeEach(() => { | 177 | beforeEach(() => { |
| 147 | vi.restoreAllMocks(); | 178 | vi.restoreAllMocks(); |
| 148 | createProofMock.mockClear(); | 179 | createProofMock.mockClear(); |
| 180 | loadPreferences.mockClear(); | ||
| 149 | }); | 181 | }); |
| 150 | 182 | ||
| 151 | it("normalizes configured relay base URLs before building DPoP-bound requests", () => { | 183 | it("normalizes configured relay base URLs before building DPoP-bound requests", () => { |
| ... | @@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => { | ... | @@ -705,10 +737,7 @@ describe("mobile cloud link environment client", () => { |
| 705 | 737 | ||
| 706 | it.effect("preserves disabled Live Activity preferences when linking an environment", () => | 738 | it.effect("preserves disabled Live Activity preferences when linking an environment", () => |
| 707 | Effect.gen(function* () { | 739 | Effect.gen(function* () { |
| 708 | const storage = yield* Effect.promise(() => import("../../lib/storage")); | 740 | loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false })); |
| 709 | vi.mocked(storage.loadPreferences).mockResolvedValueOnce({ | ||
| 710 | liveActivitiesEnabled: false, | ||
| 711 | }); | ||
| 712 | const bodies: Array<unknown> = []; | 741 | const bodies: Array<unknown> = []; |
| 713 | const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { | 742 | const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { |
| 714 | if (init?.body) { | 743 | if (init?.body) { |
| ... | @@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => { | ... | @@ -761,6 +790,45 @@ describe("mobile cloud link environment client", () => { |
| 761 | }), | 790 | }), |
| 762 | ); | 791 | ); |
| 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 | it.effect( | 832 | it.effect( |
| 765 | "does not persist cloud connect bootstrap credentials in saved connection records", | 833 | "does not persist cloud connect bootstrap credentials in saved connection records", |
| 766 | () => | 834 | () => |
apps/mobile/src/features/cloud/linkEnvironment.ts+49-22| ... | @@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; | ... | @@ -30,7 +30,8 @@ import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; |
| 30 | 30 | ||
| 31 | import { authClientMetadata } from "../../lib/authClientMetadata"; | 31 | import { authClientMetadata } from "../../lib/authClientMetadata"; |
| 32 | import type { SavedRemoteConnection } from "../../lib/connection"; | 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 | import { resolveCloudPublicConfig } from "./publicConfig"; | 35 | import { resolveCloudPublicConfig } from "./publicConfig"; |
| 35 | 36 | ||
| 36 | const RELAY_STATUS_AND_CONNECT_SCOPES = [ | 37 | const RELAY_STATUS_AND_CONNECT_SCOPES = [ |
| ... | @@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { | ... | @@ -256,14 +257,19 @@ function ensureConnectEndpointMatchesEnvironment(input: { |
| 256 | return Effect.void; | 257 | return Effect.void; |
| 257 | } | 258 | } |
| 258 | 259 | ||
| 259 | export function linkEnvironmentToCloud(input: { | 260 | interface LinkEnvironmentToCloudInput { |
| 260 | readonly connection: SavedRemoteConnection; | 261 | readonly connection: SavedRemoteConnection; |
| 261 | readonly clerkToken: string; | 262 | readonly clerkToken: string; |
| 262 | }): Effect.Effect< | 263 | } |
| 263 | void, | 264 | |
| 264 | CloudEnvironmentLinkError, | 265 | type LinkEnvironmentToCloudRequirements = |
| 265 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | 266 | | HttpClient.HttpClient |
| 266 | > { | 267 | | ManagedRelay.ManagedRelayClient |
| 268 | | MobileStorage.MobileStorage; | ||
| 269 | |||
| 270 | export function linkEnvironmentToCloudWithPreference( | ||
| 271 | input: LinkEnvironmentToCloudInput & { readonly liveActivitiesEnabled: boolean }, | ||
| 272 | ): Effect.Effect<void, CloudEnvironmentLinkError, LinkEnvironmentToCloudRequirements> { | ||
| 267 | return Effect.gen(function* () { | 273 | return Effect.gen(function* () { |
| 268 | if (!input.connection.bearerToken) { | 274 | if (!input.connection.bearerToken) { |
| 269 | return yield* new CloudEnvironmentLinkError({ | 275 | return yield* new CloudEnvironmentLinkError({ |
| ... | @@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: { | ... | @@ -273,15 +279,11 @@ export function linkEnvironmentToCloud(input: { |
| 273 | const localBearerToken = input.connection.bearerToken; | 279 | const localBearerToken = input.connection.bearerToken; |
| 274 | const relayUrl = yield* requireRelayUrl(); | 280 | const relayUrl = yield* requireRelayUrl(); |
| 275 | const relayClient = yield* ManagedRelay.ManagedRelayClient; | 281 | const relayClient = yield* ManagedRelay.ManagedRelayClient; |
| 276 | const deviceId = yield* Effect.tryPromise({ | 282 | const storage = yield* MobileStorage.MobileStorage; |
| 277 | try: () => loadOrCreateAgentAwarenessDeviceId(), | 283 | const deviceId = yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( |
| 278 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | 284 | Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), |
| 279 | }); | 285 | ); |
| 280 | const preferences = yield* Effect.tryPromise({ | 286 | const liveActivitiesEnabled = input.liveActivitiesEnabled; |
| 281 | try: () => loadPreferences(), | ||
| 282 | catch: cloudEnvironmentLinkError("Could not load mobile notification preferences."), | ||
| 283 | }); | ||
| 284 | const liveActivitiesEnabled = preferences.liveActivitiesEnabled !== false; | ||
| 285 | const challenge = yield* relayClient | 287 | const challenge = yield* relayClient |
| 286 | .createEnvironmentLinkChallenge({ | 288 | .createEnvironmentLinkChallenge({ |
| 287 | clerkToken: input.clerkToken, | 289 | clerkToken: input.clerkToken, |
| ... | @@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: { | ... | @@ -350,6 +352,25 @@ export function linkEnvironmentToCloud(input: { |
| 350 | }); | 352 | }); |
| 351 | } | 353 | } |
| 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 | export function listCloudEnvironments(input: { | 374 | export function listCloudEnvironments(input: { |
| 354 | readonly clerkToken: string; | 375 | readonly clerkToken: string; |
| 355 | }): Effect.Effect< | 376 | }): Effect.Effect< |
| ... | @@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: { | ... | @@ -460,10 +481,10 @@ export function listCloudEnvironmentsWithStatus(input: { |
| 460 | 481 | ||
| 461 | const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( | 482 | const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( |
| 462 | function* () { | 483 | function* () { |
| 463 | return yield* Effect.tryPromise({ | 484 | const storage = yield* MobileStorage.MobileStorage; |
| 464 | try: () => loadOrCreateAgentAwarenessDeviceId(), | 485 | return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( |
| 465 | catch: cloudEnvironmentLinkError("Could not load the mobile device id."), | 486 | Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), |
| 466 | }); | 487 | ); |
| 467 | }, | 488 | }, |
| 468 | ); | 489 | ); |
| 469 | 490 | ||
| ... | @@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: { | ... | @@ -557,7 +578,10 @@ export function connectCloudEnvironment(input: { |
| 557 | }): Effect.Effect< | 578 | }): Effect.Effect< |
| 558 | SavedRemoteConnection, | 579 | SavedRemoteConnection, |
| 559 | CloudEnvironmentLinkError, | 580 | CloudEnvironmentLinkError, |
| 560 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner | 581 | | HttpClient.HttpClient |
| 582 | | ManagedRelay.ManagedRelayClient | ||
| 583 | | ManagedRelay.ManagedRelayDpopSigner | ||
| 584 | | MobileStorage.MobileStorage | ||
| 561 | > { | 585 | > { |
| 562 | return connectRelayManagedEnvironment({ | 586 | return connectRelayManagedEnvironment({ |
| 563 | clerkToken: input.clerkToken, | 587 | clerkToken: input.clerkToken, |
| ... | @@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: { | ... | @@ -572,7 +596,10 @@ export function refreshCloudEnvironmentConnection(input: { |
| 572 | }): Effect.Effect< | 596 | }): Effect.Effect< |
| 573 | SavedRemoteConnection, | 597 | SavedRemoteConnection, |
| 574 | CloudEnvironmentLinkError, | 598 | CloudEnvironmentLinkError, |
| 575 | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner | 599 | | HttpClient.HttpClient |
| 600 | | ManagedRelay.ManagedRelayClient | ||
| 601 | | ManagedRelay.ManagedRelayDpopSigner | ||
| 602 | | MobileStorage.MobileStorage | ||
| 576 | > { | 603 | > { |
| 577 | return connectRelayManagedEnvironment({ | 604 | return connectRelayManagedEnvironment({ |
| 578 | clerkToken: input.clerkToken, | 605 | clerkToken: input.clerkToken, |
apps/mobile/src/features/home/HomeScreen.tsx+41-11| ... | @@ -12,6 +12,8 @@ import type { | ... | @@ -12,6 +12,8 @@ import type { |
| 12 | SidebarProjectGroupingMode, | 12 | SidebarProjectGroupingMode, |
| 13 | SidebarThreadSortOrder, | 13 | SidebarThreadSortOrder, |
| 14 | } from "@t3tools/contracts"; | 14 | } from "@t3tools/contracts"; |
| 15 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 16 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 15 | import { useCallback, useMemo, useRef, useState } from "react"; | 17 | import { useCallback, useMemo, useRef, useState } from "react"; |
| 16 | import { ActivityIndicator, Platform, View } from "react-native"; | 18 | import { ActivityIndicator, Platform, View } from "react-native"; |
| 17 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; | 19 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| ... | @@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; | ... | @@ -22,6 +24,7 @@ import { EmptyState } from "../../components/EmptyState"; |
| 22 | import type { WorkspaceState } from "../../state/workspaceModel"; | 24 | import type { WorkspaceState } from "../../state/workspaceModel"; |
| 23 | import type { SavedRemoteConnection } from "../../lib/connection"; | 25 | import type { SavedRemoteConnection } from "../../lib/connection"; |
| 24 | import { scopedProjectKey } from "../../lib/scopedEntities"; | 26 | import { scopedProjectKey } from "../../lib/scopedEntities"; |
| 27 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 25 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 28 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 26 | import { | 29 | import { |
| 27 | PendingTaskListRow, | 30 | PendingTaskListRow, |
| ... | @@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -154,21 +157,48 @@ export function HomeScreen(props: HomeScreenProps) { |
| 154 | const [groupDisplayStates, setGroupDisplayStates] = useState< | 157 | const [groupDisplayStates, setGroupDisplayStates] = useState< |
| 155 | ReadonlyMap<string, HomeGroupDisplayState> | 158 | ReadonlyMap<string, HomeGroupDisplayState> |
| 156 | >(() => new Map()); | 159 | >(() => new Map()); |
| 160 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | ||
| 161 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 157 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); | 162 | const openSwipeableRef = useRef<SwipeableMethods | null>(null); |
| 158 | const listRef = useRef<LegendListRef | null>(null); | 163 | const listRef = useRef<LegendListRef | null>(null); |
| 159 | const insets = useSafeAreaInsets(); | 164 | const insets = useSafeAreaInsets(); |
| 160 | const accentColor = useThemeColor("--color-icon-muted"); | 165 | const accentColor = useThemeColor("--color-icon-muted"); |
| 161 | 166 | ||
| 162 | const updateGroupDisplay = useCallback((key: string, action: HomeGroupDisplayAction) => { | 167 | const effectiveGroupDisplayStates = useMemo(() => { |
| 163 | setGroupDisplayStates((previous) => { | 168 | const next = new Map(groupDisplayStates); |
| 164 | const next = new Map(previous); | 169 | if (!AsyncResult.isSuccess(preferencesResult)) { |
| 165 | next.set( | ||
| 166 | key, | ||
| 167 | nextGroupDisplayState(previous.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action), | ||
| 168 | ); | ||
| 169 | return next; | 170 | return next; |
| 170 | }); | 171 | } |
| 171 | }, []); | 172 | for (const key of preferencesResult.value.collapsedProjectGroups ?? []) { |
| 173 | const existing = next.get(key); | ||
| 174 | next.set(key, { | ||
| 175 | ...(existing ?? DEFAULT_GROUP_DISPLAY_STATE), | ||
| 176 | collapsed: true, | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | return next; | ||
| 180 | }, [groupDisplayStates, preferencesResult]); | ||
| 181 | const effectiveGroupDisplayStatesRef = useRef(effectiveGroupDisplayStates); | ||
| 182 | effectiveGroupDisplayStatesRef.current = effectiveGroupDisplayStates; | ||
| 183 | |||
| 184 | const updateGroupDisplay = useCallback( | ||
| 185 | (key: string, action: HomeGroupDisplayAction) => { | ||
| 186 | const next = new Map(effectiveGroupDisplayStatesRef.current); | ||
| 187 | next.set(key, nextGroupDisplayState(next.get(key) ?? DEFAULT_GROUP_DISPLAY_STATE, action)); | ||
| 188 | effectiveGroupDisplayStatesRef.current = next; | ||
| 189 | setGroupDisplayStates(next); | ||
| 190 | if (action === "toggle-collapsed") { | ||
| 191 | const collapsedProjectGroups: string[] = []; | ||
| 192 | for (const [groupKey, state] of next) { | ||
| 193 | if (state.collapsed) { | ||
| 194 | collapsedProjectGroups.push(groupKey); | ||
| 195 | } | ||
| 196 | } | ||
| 197 | savePreferences({ collapsedProjectGroups }); | ||
| 198 | } | ||
| 199 | }, | ||
| 200 | [savePreferences], | ||
| 201 | ); | ||
| 172 | 202 | ||
| 173 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { | 203 | const handleSwipeableWillOpen = useCallback((methods: SwipeableMethods) => { |
| 174 | if (openSwipeableRef.current !== methods) { | 204 | if (openSwipeableRef.current !== methods) { |
| ... | @@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) { | ... | @@ -219,10 +249,10 @@ export function HomeScreen(props: HomeScreenProps) { |
| 219 | () => | 249 | () => |
| 220 | buildHomeListLayout({ | 250 | buildHomeListLayout({ |
| 221 | groups: projectGroups, | 251 | groups: projectGroups, |
| 222 | displayStates: groupDisplayStates, | 252 | displayStates: effectiveGroupDisplayStates, |
| 223 | showAllThreads: hasSearchQuery, | 253 | showAllThreads: hasSearchQuery, |
| 224 | }), | 254 | }), |
| 225 | [projectGroups, groupDisplayStates, hasSearchQuery], | 255 | [projectGroups, effectiveGroupDisplayStates, hasSearchQuery], |
| 226 | ); | 256 | ); |
| 227 | 257 | ||
| 228 | const projectCwdByKey = useMemo(() => { | 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,4 +1,5 @@ |
| 1 | import { useAuth, useUser } from "@clerk/expo"; | 1 | import { useAuth, useUser } from "@clerk/expo"; |
| 2 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; | ||
| 2 | import Constants from "expo-constants"; | 3 | import Constants from "expo-constants"; |
| 3 | import * as Notifications from "expo-notifications"; | 4 | import * as Notifications from "expo-notifications"; |
| 4 | import * as Updates from "expo-updates"; | 5 | import * as Updates from "expo-updates"; |
| ... | @@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native"; | ... | @@ -6,6 +7,7 @@ import { useNavigation } from "@react-navigation/native"; |
| 6 | import { NativeStackScreenOptions } from "../../native/StackHeader"; | 7 | import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 7 | import { SymbolView } from "expo-symbols"; | 8 | import { SymbolView } from "expo-symbols"; |
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | ||
| 9 | import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; | 11 | import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; |
| 10 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; | 12 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; |
| 11 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| ... | @@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu | ... | @@ -31,8 +33,8 @@ import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/pu |
| 31 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; | 33 | import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; |
| 32 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 34 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 33 | import { runtime } from "../../lib/runtime"; | 35 | import { runtime } from "../../lib/runtime"; |
| 34 | import { loadPreferences } from "../../lib/storage"; | ||
| 35 | import { useThemeColor } from "../../lib/useThemeColor"; | 36 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 37 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | ||
| 36 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 38 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 37 | import { SettingsRow } from "./components/SettingsRow"; | 39 | import { SettingsRow } from "./components/SettingsRow"; |
| 38 | import { SettingsSection } from "./components/SettingsSection"; | 40 | import { SettingsSection } from "./components/SettingsSection"; |
| ... | @@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() { | ... | @@ -122,6 +124,8 @@ function LocalSettingsRouteScreen() { |
| 122 | } | 124 | } |
| 123 | 125 | ||
| 124 | function ConfiguredSettingsRouteScreen() { | 126 | function ConfiguredSettingsRouteScreen() { |
| 127 | const preferencesResult = useAtomValue(mobilePreferencesAtom); | ||
| 128 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); | ||
| 125 | const insets = useSafeAreaInsets(); | 129 | const insets = useSafeAreaInsets(); |
| 126 | const navigation = useNavigation(); | 130 | const navigation = useNavigation(); |
| 127 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); | 131 | const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); |
| ... | @@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -131,6 +135,9 @@ function ConfiguredSettingsRouteScreen() { |
| 131 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); | 135 | const [notificationStatus, setNotificationStatus] = useState<NotificationStatus>("checking"); |
| 132 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); | 136 | const [liveActivityStatus, setLiveActivityStatus] = useState<LiveActivityStatus>("checking"); |
| 133 | const deviceRegistered = useDeviceRegistered(); | 137 | const deviceRegistered = useDeviceRegistered(); |
| 138 | const liveActivitiesPreferenceEnabled = AsyncResult.isSuccess(preferencesResult) | ||
| 139 | ? preferencesResult.value.liveActivitiesEnabled !== false | ||
| 140 | : true; | ||
| 134 | 141 | ||
| 135 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); | 142 | const connections = useMemo(() => Object.values(savedConnectionsById), [savedConnectionsById]); |
| 136 | const environmentCount = connections.length; | 143 | const environmentCount = connections.length; |
| ... | @@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -167,16 +174,19 @@ function ConfiguredSettingsRouteScreen() { |
| 167 | setLiveActivityStatus("signed-out"); | 174 | setLiveActivityStatus("signed-out"); |
| 168 | return; | 175 | return; |
| 169 | } | 176 | } |
| 170 | void (async () => { | 177 | if (!AsyncResult.isSuccess(preferencesResult)) { |
| 171 | const result = await settlePromise(() => loadPreferences()); | 178 | if (AsyncResult.isFailure(preferencesResult)) { |
| 172 | if (result._tag === "Failure") { | 179 | reportAtomCommandResult(preferencesResult, { label: "live activity preference load" }); |
| 173 | reportAtomCommandResult(result, { label: "live activity preference load" }); | ||
| 174 | setLiveActivityStatus("enabled"); | 180 | setLiveActivityStatus("enabled"); |
| 175 | return; | 181 | } else { |
| 182 | setLiveActivityStatus("checking"); | ||
| 176 | } | 183 | } |
| 177 | setLiveActivityStatus(result.value.liveActivitiesEnabled === false ? "disabled" : "enabled"); | 184 | return; |
| 178 | })(); | 185 | } |
| 179 | }, [isLoaded, isSignedIn]); | 186 | setLiveActivityStatus( |
| 187 | preferencesResult.value.liveActivitiesEnabled === false ? "disabled" : "enabled", | ||
| 188 | ); | ||
| 189 | }, [isLoaded, isSignedIn, preferencesResult]); | ||
| 180 | 190 | ||
| 181 | const requestNotifications = useCallback(async () => { | 191 | const requestNotifications = useCallback(async () => { |
| 182 | const result = await settleAsyncResult(() => | 192 | const result = await settleAsyncResult(() => |
| ... | @@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -279,6 +289,7 @@ function ConfiguredSettingsRouteScreen() { |
| 279 | runtime.runPromiseExit( | 289 | runtime.runPromiseExit( |
| 280 | setLiveActivityUpdatesEnabled({ | 290 | setLiveActivityUpdatesEnabled({ |
| 281 | enabled: true, | 291 | enabled: true, |
| 292 | previousEnabled: liveActivitiesPreferenceEnabled, | ||
| 282 | clerkToken: tokenResult.value, | 293 | clerkToken: tokenResult.value, |
| 283 | connections, | 294 | connections, |
| 284 | }), | 295 | }), |
| ... | @@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -296,6 +307,7 @@ function ConfiguredSettingsRouteScreen() { |
| 296 | return; | 307 | return; |
| 297 | } | 308 | } |
| 298 | 309 | ||
| 310 | savePreferences({ liveActivitiesEnabled: true }); | ||
| 299 | refreshManagedRelayEnvironments(); | 311 | refreshManagedRelayEnvironments(); |
| 300 | setLiveActivityStatus("enabled"); | 312 | setLiveActivityStatus("enabled"); |
| 301 | // The environment link can succeed while this device's own registration | 313 | // The environment link can succeed while this device's own registration |
| ... | @@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -314,7 +326,15 @@ function ConfiguredSettingsRouteScreen() { |
| 314 | "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", | 326 | "This device could not be registered with T3 Connect, so Live Activities won't appear yet. They'll start once registration succeeds.", |
| 315 | ); | 327 | ); |
| 316 | } | 328 | } |
| 317 | }, [connections, environmentCount, getToken, isSignedIn, promptSignIn]); | 329 | }, [ |
| 330 | connections, | ||
| 331 | environmentCount, | ||
| 332 | getToken, | ||
| 333 | isSignedIn, | ||
| 334 | liveActivitiesPreferenceEnabled, | ||
| 335 | promptSignIn, | ||
| 336 | savePreferences, | ||
| 337 | ]); | ||
| 318 | 338 | ||
| 319 | const handleDeviceNotificationsChange = useCallback( | 339 | const handleDeviceNotificationsChange = useCallback( |
| 320 | (enabled: boolean) => { | 340 | (enabled: boolean) => { |
| ... | @@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -358,17 +378,20 @@ function ConfiguredSettingsRouteScreen() { |
| 358 | runtime.runPromiseExit( | 378 | runtime.runPromiseExit( |
| 359 | setLiveActivityUpdatesEnabled({ | 379 | setLiveActivityUpdatesEnabled({ |
| 360 | enabled: false, | 380 | enabled: false, |
| 381 | previousEnabled: liveActivitiesPreferenceEnabled, | ||
| 361 | clerkToken: token, | 382 | clerkToken: token, |
| 362 | connections, | 383 | connections, |
| 363 | }), | 384 | }), |
| 364 | ), | 385 | ), |
| 365 | ); | 386 | ); |
| 366 | if (updateResult._tag === "Failure") { | 387 | if (updateResult._tag === "Failure") { |
| 388 | setLiveActivityStatus("enabled"); | ||
| 367 | reportAtomCommandResult(updateResult, { | 389 | reportAtomCommandResult(updateResult, { |
| 368 | label: "live activity disable", | 390 | label: "live activity disable", |
| 369 | }); | 391 | }); |
| 370 | return; | 392 | return; |
| 371 | } | 393 | } |
| 394 | savePreferences({ liveActivitiesEnabled: false }); | ||
| 372 | refreshManagedRelayEnvironments(); | 395 | refreshManagedRelayEnvironments(); |
| 373 | })(); | 396 | })(); |
| 374 | return; | 397 | return; |
| ... | @@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() { | ... | @@ -381,7 +404,15 @@ function ConfiguredSettingsRouteScreen() { |
| 381 | 404 | ||
| 382 | void linkEnvironments(); | 405 | void linkEnvironments(); |
| 383 | }, | 406 | }, |
| 384 | [connections, getToken, isSignedIn, linkEnvironments, promptSignIn], | 407 | [ |
| 408 | connections, | ||
| 409 | getToken, | ||
| 410 | isSignedIn, | ||
| 411 | linkEnvironments, | ||
| 412 | liveActivitiesPreferenceEnabled, | ||
| 413 | promptSignIn, | ||
| 414 | savePreferences, | ||
| 415 | ], | ||
| 385 | ); | 416 | ); |
| 386 | 417 | ||
| 387 | const openAccount = useCallback(() => { | 418 | const openAccount = useCallback(() => { |
| ... | @@ -488,6 +519,7 @@ function AppSettingsSection() { | ... | @@ -488,6 +519,7 @@ function AppSettingsSection() { |
| 488 | 519 | ||
| 489 | return ( | 520 | return ( |
| 490 | <SettingsSection title="App"> | 521 | <SettingsSection title="App"> |
| 522 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | ||
| 491 | <View className="flex-row items-center gap-4 p-4"> | 523 | <View className="flex-row items-center gap-4 p-4"> |
| 492 | <SymbolView | 524 | <SymbolView |
| 493 | name="info.circle" | 525 | name="info.circle" |
apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx+24-56| ... | @@ -1,12 +1,7 @@ | ... | @@ -1,12 +1,7 @@ |
| 1 | import { | 1 | import { createContext, use, useCallback, useEffect, useMemo, type ReactNode } from "react"; |
| 2 | createContext, | 2 | |
| 3 | useCallback, | 3 | import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | useContext, | 4 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 5 | useEffect, | ||
| 6 | useMemo, | ||
| 7 | useState, | ||
| 8 | type ReactNode, | ||
| 9 | } from "react"; | ||
| 10 | 5 | ||
| 11 | import { Uniwind } from "uniwind"; | 6 | import { Uniwind } from "uniwind"; |
| 12 | 7 | ||
| ... | @@ -17,7 +12,7 @@ import { | ... | @@ -17,7 +12,7 @@ import { |
| 17 | type AppearancePreferences, | 12 | type AppearancePreferences, |
| 18 | type ResolvedAppearance, | 13 | type ResolvedAppearance, |
| 19 | } from "../../../lib/appearancePreferences"; | 14 | } from "../../../lib/appearancePreferences"; |
| 20 | import { loadPreferences, savePreferencesPatch } from "../../../lib/storage"; | 15 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; |
| 21 | import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; | 16 | import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; |
| 22 | 17 | ||
| 23 | interface AppearancePreferencesContextValue { | 18 | interface AppearancePreferencesContextValue { |
| ... | @@ -52,55 +47,28 @@ function applyTextScaleVariables(baseFontSize: number) { | ... | @@ -52,55 +47,28 @@ function applyTextScaleVariables(baseFontSize: number) { |
| 52 | } | 47 | } |
| 53 | 48 | ||
| 54 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { | 49 | export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { |
| 55 | const [preferences, setPreferences] = useState<AppearancePreferences>(() => | 50 | const preferencesResult = useAtomValue(mobilePreferencesAtom); |
| 56 | resolveAppearancePreferences(null), | 51 | const savePreferences = useAtomSet(updateMobilePreferencesAtom); |
| 52 | const preferences = useMemo( | ||
| 53 | () => | ||
| 54 | resolveAppearancePreferences( | ||
| 55 | AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value : null, | ||
| 56 | ), | ||
| 57 | [preferencesResult], | ||
| 57 | ); | 58 | ); |
| 58 | const [isReady, setIsReady] = useState(false); | 59 | const isReady = AsyncResult.isSuccess(preferencesResult) && !preferencesResult.waiting; |
| 59 | |||
| 60 | useEffect(() => { | ||
| 61 | let cancelled = false; | ||
| 62 | |||
| 63 | void loadPreferences() | ||
| 64 | .then((stored) => { | ||
| 65 | if (cancelled) { | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | |||
| 69 | const resolved = resolveAppearancePreferences(stored); | ||
| 70 | setPreferences(resolved); | ||
| 71 | cacheTerminalFontSize(resolveAppearance(resolved).terminalFontSize); | ||
| 72 | setIsReady(true); | ||
| 73 | }) | ||
| 74 | .catch(() => { | ||
| 75 | if (cancelled) { | ||
| 76 | return; | ||
| 77 | } | ||
| 78 | |||
| 79 | setIsReady(true); | ||
| 80 | }); | ||
| 81 | |||
| 82 | return () => { | ||
| 83 | cancelled = true; | ||
| 84 | }; | ||
| 85 | }, []); | ||
| 86 | 60 | ||
| 87 | useEffect(() => { | 61 | useEffect(() => { |
| 88 | applyTextScaleVariables(preferences.baseFontSize); | 62 | applyTextScaleVariables(preferences.baseFontSize); |
| 89 | }, [preferences.baseFontSize]); | 63 | cacheTerminalFontSize(resolveAppearance(preferences).terminalFontSize); |
| 90 | 64 | }, [preferences]); | |
| 91 | const updatePreferences = useCallback((patch: Partial<AppearancePreferences>) => { | 65 | |
| 92 | setPreferences((current) => { | 66 | const updatePreferences = useCallback( |
| 93 | const next = resolveAppearancePreferences({ ...current, ...patch }); | 67 | (patch: Partial<AppearancePreferences>) => { |
| 94 | cacheTerminalFontSize(resolveAppearance(next).terminalFontSize); | 68 | savePreferences(patch); |
| 95 | void savePreferencesPatch({ | 69 | }, |
| 96 | baseFontSize: next.baseFontSize, | 70 | [savePreferences], |
| 97 | terminalFontSize: next.terminalFontSize, | 71 | ); |
| 98 | codeFontSize: next.codeFontSize, | ||
| 99 | codeWordBreak: next.codeWordBreak, | ||
| 100 | }).catch(() => undefined); | ||
| 101 | return next; | ||
| 102 | }); | ||
| 103 | }, []); | ||
| 104 | 72 | ||
| 105 | const setBaseFontSize = useCallback( | 73 | const setBaseFontSize = useCallback( |
| 106 | (value: number) => { | 74 | (value: number) => { |
| ... | @@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN | ... | @@ -150,7 +118,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN |
| 150 | } | 118 | } |
| 151 | 119 | ||
| 152 | export function useAppearancePreferences(): AppearancePreferencesContextValue { | 120 | export function useAppearancePreferences(): AppearancePreferencesContextValue { |
| 153 | const context = useContext(AppearancePreferencesContext); | 121 | const context = use(AppearancePreferencesContext); |
| 154 | if (!context) { | 122 | if (!context) { |
| 155 | throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); | 123 | throw new Error("useAppearancePreferences must be used within AppearancePreferencesProvider"); |
| 156 | } | 124 | } |
apps/mobile/src/features/settings/components/settings-sheet-targets.ts+5-1| ... | @@ -1 +1,5 @@ | ... | @@ -1 +1,5 @@ |
| 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,6 +8,7 @@ import { cryptoLayer } from "../features/cloud/dpop"; |
| 8 | import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; | 8 | import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer"; |
| 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; | 9 | import { resolveCloudPublicConfig } from "../features/cloud/publicConfig"; |
| 10 | import { tracingLayer } from "../features/observability/tracing"; | 10 | import { tracingLayer } from "../features/observability/tracing"; |
| 11 | import * as Persistence from "../persistence/layer"; | ||
| 11 | 12 | ||
| 12 | function configuredRelayUrl(): string { | 13 | function configuredRelayUrl(): string { |
| 13 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; | 14 | return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid"; |
| ... | @@ -20,6 +21,7 @@ type RuntimeLayerSource = | ... | @@ -20,6 +21,7 @@ type RuntimeLayerSource = |
| 20 | | typeof Socket.layerWebSocketConstructorGlobal | 21 | | typeof Socket.layerWebSocketConstructorGlobal |
| 21 | | typeof cryptoLayer | 22 | | typeof cryptoLayer |
| 22 | | typeof httpClientLayer | 23 | | typeof httpClientLayer |
| 24 | | typeof Persistence.layer | ||
| 23 | | typeof tracingLayer; | 25 | | typeof tracingLayer; |
| 24 | 26 | ||
| 25 | const runtimeLayer = Layer.merge( | 27 | const runtimeLayer = Layer.merge( |
| ... | @@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge( | ... | @@ -29,6 +31,7 @@ const runtimeLayer = Layer.merge( |
| 29 | Layer.provideMerge(cryptoLayer), | 31 | Layer.provideMerge(cryptoLayer), |
| 30 | Layer.provideMerge(httpClientLayer), | 32 | Layer.provideMerge(httpClientLayer), |
| 31 | Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), | 33 | Layer.provideMerge(tracingLayer.pipe(Layer.provide(httpClientLayer))), |
| 34 | Layer.provideMerge(Persistence.layer), | ||
| 32 | ); | 35 | ); |
| 33 | 36 | ||
| 34 | export const runtime: ManagedRuntime.ManagedRuntime< | 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,34 +3,103 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | const mocks = vi.hoisted(() => { | 4 | const mocks = vi.hoisted(() => { |
| 5 | const values = new Map<string, string>(); | 5 | const values = new Map<string, string>(); |
| 6 | let preferencesJson: string | null = null; | ||
| 7 | let preferencesUpdatedAt = 0; | ||
| 8 | let loadPreferencesFails = false; | ||
| 9 | let savePreferencesFails = false; | ||
| 6 | return { | 10 | return { |
| 7 | clear: () => values.clear(), | 11 | clear: () => { |
| 12 | values.clear(); | ||
| 13 | preferencesJson = null; | ||
| 14 | preferencesUpdatedAt = 0; | ||
| 15 | loadPreferencesFails = false; | ||
| 16 | savePreferencesFails = false; | ||
| 17 | }, | ||
| 18 | getStoredValue: (key: string) => values.get(key) ?? null, | ||
| 19 | getPreferencesJson: () => preferencesJson, | ||
| 20 | setPreferencesJson: (value: string, updatedAt: number) => { | ||
| 21 | preferencesJson = value; | ||
| 22 | preferencesUpdatedAt = updatedAt; | ||
| 23 | }, | ||
| 24 | setDatabaseFailures: (load: boolean, save: boolean) => { | ||
| 25 | loadPreferencesFails = load; | ||
| 26 | savePreferencesFails = save; | ||
| 27 | }, | ||
| 8 | getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), | 28 | getItemAsync: vi.fn((key: string) => Promise.resolve(values.get(key) ?? null)), |
| 9 | setItemAsync: vi.fn((key: string, value: string) => { | 29 | setItemAsync: vi.fn((key: string, value: string) => { |
| 10 | values.set(key, value); | 30 | values.set(key, value); |
| 11 | return Promise.resolve(); | 31 | return Promise.resolve(); |
| 12 | }), | 32 | }), |
| 33 | deleteItemAsync: vi.fn((key: string) => { | ||
| 34 | values.delete(key); | ||
| 35 | return Promise.resolve(); | ||
| 36 | }), | ||
| 37 | database: { | ||
| 38 | closeAsync: vi.fn(() => Promise.resolve()), | ||
| 39 | execAsync: vi.fn(() => Promise.resolve()), | ||
| 40 | withExclusiveTransactionAsync: vi.fn( | ||
| 41 | (run: (transaction: { execAsync: () => Promise<void> }) => Promise<void>) => | ||
| 42 | run({ execAsync: () => Promise.resolve() }), | ||
| 43 | ), | ||
| 44 | getFirstAsync: vi.fn((sql: string) => { | ||
| 45 | if (sql.includes("PRAGMA user_version")) { | ||
| 46 | return Promise.resolve({ user_version: 1 }); | ||
| 47 | } | ||
| 48 | if (loadPreferencesFails) { | ||
| 49 | return Promise.reject(new Error("database unavailable")); | ||
| 50 | } | ||
| 51 | return Promise.resolve( | ||
| 52 | preferencesJson === null | ||
| 53 | ? null | ||
| 54 | : { payload: preferencesJson, updatedAt: preferencesUpdatedAt }, | ||
| 55 | ); | ||
| 56 | }), | ||
| 57 | runAsync: vi.fn((_sql: string, payload?: unknown, updatedAt?: unknown) => { | ||
| 58 | if (savePreferencesFails) { | ||
| 59 | return Promise.reject(new Error("database unavailable")); | ||
| 60 | } | ||
| 61 | if (typeof payload === "string") { | ||
| 62 | preferencesJson = payload; | ||
| 63 | } | ||
| 64 | if (typeof updatedAt === "number") { | ||
| 65 | preferencesUpdatedAt = updatedAt; | ||
| 66 | } | ||
| 67 | return Promise.resolve(); | ||
| 68 | }), | ||
| 69 | }, | ||
| 13 | }; | 70 | }; |
| 14 | }); | 71 | }); |
| 15 | 72 | ||
| 16 | vi.mock("expo-secure-store", () => ({ | 73 | vi.mock("expo-secure-store", () => ({ |
| 74 | deleteItemAsync: mocks.deleteItemAsync, | ||
| 17 | getItemAsync: mocks.getItemAsync, | 75 | getItemAsync: mocks.getItemAsync, |
| 18 | setItemAsync: mocks.setItemAsync, | 76 | setItemAsync: mocks.setItemAsync, |
| 19 | })); | 77 | })); |
| 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 | vi.mock("react-native", () => ({ | 91 | vi.mock("react-native", () => ({ |
| 22 | Platform: { | 92 | Platform: { |
| 23 | OS: "ios", | 93 | OS: "ios", |
| 24 | }, | 94 | }, |
| 25 | })); | 95 | })); |
| 26 | 96 | ||
| 27 | vi.mock("./runtime", () => ({ | 97 | import { |
| 28 | runtime: { | 98 | loadPreferences, |
| 29 | runPromise: vi.fn(), | 99 | loadSavedConnections, |
| 30 | }, | 100 | saveConnection, |
| 31 | })); | 101 | savePreferencesPatch, |
| 32 | 102 | } from "../persistence/imperative"; | |
| 33 | import { loadSavedConnections, saveConnection } from "./storage"; | ||
| 34 | import { toStableSavedRemoteConnection } from "./connection"; | 103 | import { toStableSavedRemoteConnection } from "./connection"; |
| 35 | 104 | ||
| 36 | const managedConnection = { | 105 | const managedConnection = { |
| ... | @@ -100,4 +169,75 @@ describe("mobile connection storage", () => { | ... | @@ -100,4 +169,75 @@ describe("mobile connection storage", () => { |
| 100 | 169 | ||
| 101 | warn.mockRestore(); | 170 | warn.mockRestore(); |
| 102 | }); | 171 | }); |
| 172 | |||
| 173 | it("loads legacy preferences when SQLite is unavailable", async () => { | ||
| 174 | mocks.setDatabaseFailures(true, true); | ||
| 175 | await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 17 })); | ||
| 176 | |||
| 177 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 17 }); | ||
| 178 | }); | ||
| 179 | |||
| 180 | it("falls back to secure storage when SQLite cannot save preferences", async () => { | ||
| 181 | mocks.setDatabaseFailures(true, true); | ||
| 182 | await expect(savePreferencesPatch({ baseFontSize: 19 })).resolves.toEqual({ baseFontSize: 19 }); | ||
| 183 | const fallback = JSON.parse(mocks.getStoredValue("t3code.preferences.fallback") ?? "") as { | ||
| 184 | readonly payload: string; | ||
| 185 | readonly updatedAt: number; | ||
| 186 | }; | ||
| 187 | expect(JSON.parse(fallback.payload)).toEqual({ baseFontSize: 19 }); | ||
| 188 | expect(fallback.updatedAt).toEqual(expect.any(Number)); | ||
| 189 | }); | ||
| 190 | |||
| 191 | it("reconciles fallback preferences after SQLite recovers", async () => { | ||
| 192 | mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 15 }), 10); | ||
| 193 | await mocks.setItemAsync( | ||
| 194 | "t3code.preferences.fallback", | ||
| 195 | JSON.stringify({ | ||
| 196 | payload: JSON.stringify({ baseFontSize: 19 }), | ||
| 197 | updatedAt: 20, | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 19 }); | ||
| 202 | expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 19 }); | ||
| 203 | expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); | ||
| 204 | }); | ||
| 205 | |||
| 206 | it("ignores a stale fallback when its previous deletion failed", async () => { | ||
| 207 | mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); | ||
| 208 | await mocks.setItemAsync( | ||
| 209 | "t3code.preferences.fallback", | ||
| 210 | JSON.stringify({ | ||
| 211 | payload: JSON.stringify({ baseFontSize: 19 }), | ||
| 212 | updatedAt: 20, | ||
| 213 | }), | ||
| 214 | ); | ||
| 215 | |||
| 216 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); | ||
| 217 | expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); | ||
| 218 | expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); | ||
| 219 | }); | ||
| 220 | |||
| 221 | it("ignores an invalid fallback even when it has a newer timestamp", async () => { | ||
| 222 | mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); | ||
| 223 | await mocks.setItemAsync( | ||
| 224 | "t3code.preferences.fallback", | ||
| 225 | JSON.stringify({ payload: "{", updatedAt: 40 }), | ||
| 226 | ); | ||
| 227 | const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); | ||
| 228 | |||
| 229 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); | ||
| 230 | expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); | ||
| 231 | expect(mocks.getStoredValue("t3code.preferences.fallback")).toBeNull(); | ||
| 232 | |||
| 233 | warn.mockRestore(); | ||
| 234 | }); | ||
| 235 | |||
| 236 | it("keeps SQLite authoritative when stale legacy preferences remain", async () => { | ||
| 237 | mocks.setPreferencesJson(JSON.stringify({ baseFontSize: 21 }), 30); | ||
| 238 | await mocks.setItemAsync("t3code.preferences", JSON.stringify({ baseFontSize: 19 })); | ||
| 239 | |||
| 240 | await expect(loadPreferences()).resolves.toEqual({ baseFontSize: 21 }); | ||
| 241 | expect(JSON.parse(mocks.getPreferencesJson() ?? "")).toEqual({ baseFontSize: 21 }); | ||
| 242 | }); | ||
| 103 | }); | 243 | }); |
apps/mobile/src/lib/storage.ts deleted-291| ... | @@ -1,291 +0,0 @@ | ||
| 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,10 +1030,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1030 | entry.result.outcome !== null && | 1030 | entry.result.outcome !== null && |
| 1031 | "outcome" in entry.result.outcome && | 1031 | "outcome" in entry.result.outcome && |
| 1032 | entry.result.outcome.outcome === "cancelled"; | 1032 | entry.result.outcome.outcome === "cancelled"; |
| 1033 | yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel"); | 1033 | const cancelRequests = yield* waitForJsonLogMatch( |
| 1034 | const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse); | 1034 | requestLogPath, |
| 1035 | assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); | 1035 | (entry) => entry.method === "session/cancel", |
| 1036 | assert.isTrue(requests.some(isCancelledApprovalResponse)); | 1036 | ); |
| 1037 | const approvalResponses = yield* waitForJsonLogMatch( | ||
| 1038 | requestLogPath, | ||
| 1039 | isCancelledApprovalResponse, | ||
| 1040 | ); | ||
| 1041 | assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); | ||
| 1042 | assert.isTrue(approvalResponses.some(isCancelledApprovalResponse)); | ||
| 1037 | 1043 | ||
| 1038 | yield* adapter.stopSession(threadId); | 1044 | yield* adapter.stopSession(threadId); |
| 1039 | }), | 1045 | }), |
apps/web/src/connection/storage.ts+118-1| ... | @@ -21,7 +21,9 @@ import { | ... | @@ -21,7 +21,9 @@ import { |
| 21 | EnvironmentId, | 21 | EnvironmentId, |
| 22 | OrchestrationShellSnapshot, | 22 | OrchestrationShellSnapshot, |
| 23 | OrchestrationThreadDetailSnapshot, | 23 | OrchestrationThreadDetailSnapshot, |
| 24 | ServerConfig, | ||
| 24 | ThreadId, | 25 | ThreadId, |
| 26 | VcsListRefsResult, | ||
| 25 | } from "@t3tools/contracts"; | 27 | } from "@t3tools/contracts"; |
| 26 | import * as Context from "effect/Context"; | 28 | import * as Context from "effect/Context"; |
| 27 | import * as Effect from "effect/Effect"; | 29 | import * as Effect from "effect/Effect"; |
| ... | @@ -32,10 +34,12 @@ import * as Schema from "effect/Schema"; | ... | @@ -32,10 +34,12 @@ import * as Schema from "effect/Schema"; |
| 32 | import * as Semaphore from "effect/Semaphore"; | 34 | import * as Semaphore from "effect/Semaphore"; |
| 33 | 35 | ||
| 34 | const DATABASE_NAME = "t3code:connection-runtime"; | 36 | const DATABASE_NAME = "t3code:connection-runtime"; |
| 35 | const DATABASE_VERSION = 2; | 37 | const DATABASE_VERSION = 4; |
| 36 | const CATALOG_STORE_NAME = "catalog"; | 38 | const CATALOG_STORE_NAME = "catalog"; |
| 37 | const SHELL_STORE_NAME = "shell"; | 39 | const SHELL_STORE_NAME = "shell"; |
| 38 | const THREAD_STORE_NAME = "thread"; | 40 | const THREAD_STORE_NAME = "thread"; |
| 41 | const SERVER_CONFIG_STORE_NAME = "server-config"; | ||
| 42 | const VCS_REFS_STORE_NAME = "vcs-refs"; | ||
| 39 | const CATALOG_KEY = "document"; | 43 | const CATALOG_KEY = "document"; |
| 40 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; | 44 | const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; |
| 41 | 45 | ||
| ... | @@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({ | ... | @@ -55,6 +59,19 @@ const StoredThreadSnapshot = Schema.Struct({ |
| 55 | snapshot: OrchestrationThreadDetailSnapshot, | 59 | snapshot: OrchestrationThreadDetailSnapshot, |
| 56 | }); | 60 | }); |
| 57 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot); | 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 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); | 75 | const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument); |
| 59 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); | 76 | const decodeConnectionCatalogDocument = Schema.decodeUnknownEffect(ConnectionCatalogDocumentJson); |
| 60 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); | 77 | const encodeConnectionCatalogDocument = Schema.encodeEffect(ConnectionCatalogDocumentJson); |
| ... | @@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot | ... | @@ -62,6 +79,10 @@ const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredShellSnapshot |
| 62 | const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); | 79 | const encodeStoredShellSnapshot = Schema.encodeEffect(StoredShellSnapshotJson); |
| 63 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); | 80 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredThreadSnapshotJson); |
| 64 | const encodeStoredThreadSnapshot = Schema.encodeEffect(StoredThreadSnapshotJson); | 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 | function catalogError(operation: string, cause: unknown) { | 87 | function catalogError(operation: string, cause: unknown) { |
| 67 | return new ConnectionTransientError({ | 88 | return new ConnectionTransientError({ |
| ... | @@ -80,6 +101,10 @@ function persistenceError( | ... | @@ -80,6 +101,10 @@ function persistenceError( |
| 80 | | "load-thread" | 101 | | "load-thread" |
| 81 | | "save-thread" | 102 | | "save-thread" |
| 82 | | "remove-thread" | 103 | | "remove-thread" |
| 104 | | "load-server-config" | ||
| 105 | | "save-server-config" | ||
| 106 | | "load-vcs-refs" | ||
| 107 | | "save-vcs-refs" | ||
| 83 | | "clear-environment", | 108 | | "clear-environment", |
| 84 | cause: unknown, | 109 | cause: unknown, |
| 85 | ) { | 110 | ) { |
| ... | @@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( | ... | @@ -108,6 +133,12 @@ const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* ( |
| 108 | if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { | 133 | if (!request.result.objectStoreNames.contains(THREAD_STORE_NAME)) { |
| 109 | request.result.createObjectStore(THREAD_STORE_NAME); | 134 | request.result.createObjectStore(THREAD_STORE_NAME); |
| 110 | } | 135 | } |
| 136 | if (!request.result.objectStoreNames.contains(SERVER_CONFIG_STORE_NAME)) { | ||
| 137 | request.result.createObjectStore(SERVER_CONFIG_STORE_NAME); | ||
| 138 | } | ||
| 139 | if (!request.result.objectStoreNames.contains(VCS_REFS_STORE_NAME)) { | ||
| 140 | request.result.createObjectStore(VCS_REFS_STORE_NAME); | ||
| 141 | } | ||
| 111 | }); | 142 | }); |
| 112 | request.addEventListener("error", () => { | 143 | request.addEventListener("error", () => { |
| 113 | resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); | 144 | resume(Effect.fail(catalogError("open", request.error ?? "Unknown IndexedDB error"))); |
| ... | @@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { | ... | @@ -197,6 +228,10 @@ function threadCacheKey(environmentId: EnvironmentId, threadId: ThreadId) { |
| 197 | return `${environmentId}:${threadId}`; | 228 | return `${environmentId}:${threadId}`; |
| 198 | } | 229 | } |
| 199 | 230 | ||
| 231 | function vcsRefsCacheKey(environmentId: EnvironmentId, cwd: string) { | ||
| 232 | return `${environmentId}:${cwd}`; | ||
| 233 | } | ||
| 234 | |||
| 200 | const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { | 235 | const decodeCatalog = Effect.fn("web.connectionStorage.decodeCatalog")(function* (raw: string) { |
| 201 | return yield* decodeConnectionCatalogDocument(raw).pipe( | 236 | return yield* decodeConnectionCatalogDocument(raw).pipe( |
| 202 | Effect.mapError((cause) => catalogError("decode", cause)), | 237 | Effect.mapError((cause) => catalogError("decode", cause)), |
| ... | @@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -462,6 +497,40 @@ export const connectionStorageLayer = Layer.effectContext( |
| 462 | : persistenceError("save-shell", cause), | 497 | : persistenceError("save-shell", cause), |
| 463 | ), | 498 | ), |
| 464 | ), | 499 | ), |
| 500 | loadServerConfig: (environmentId) => | ||
| 501 | readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe( | ||
| 502 | Effect.flatMap((raw) => { | ||
| 503 | if (typeof raw !== "string") { | ||
| 504 | return Effect.succeed(Option.none()); | ||
| 505 | } | ||
| 506 | return decodeStoredServerConfig(raw).pipe( | ||
| 507 | Effect.mapError((cause) => persistenceError("load-server-config", cause)), | ||
| 508 | Effect.map((stored) => | ||
| 509 | stored.environmentId === environmentId ? Option.some(stored.config) : Option.none(), | ||
| 510 | ), | ||
| 511 | ); | ||
| 512 | }), | ||
| 513 | Effect.mapError((cause) => | ||
| 514 | cause._tag === "ConnectionPersistenceError" | ||
| 515 | ? cause | ||
| 516 | : persistenceError("load-server-config", cause), | ||
| 517 | ), | ||
| 518 | ), | ||
| 519 | saveServerConfig: (environmentId, config) => | ||
| 520 | Effect.gen(function* () { | ||
| 521 | const encoded = yield* encodeStoredServerConfig({ | ||
| 522 | schemaVersion: 1, | ||
| 523 | environmentId, | ||
| 524 | config, | ||
| 525 | }).pipe(Effect.mapError((cause) => persistenceError("save-server-config", cause))); | ||
| 526 | yield* writeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId, encoded); | ||
| 527 | }).pipe( | ||
| 528 | Effect.mapError((cause) => | ||
| 529 | cause._tag === "ConnectionPersistenceError" | ||
| 530 | ? cause | ||
| 531 | : persistenceError("save-server-config", cause), | ||
| 532 | ), | ||
| 533 | ), | ||
| 465 | loadThread: (environmentId, threadId) => | 534 | loadThread: (environmentId, threadId) => |
| 466 | readDatabaseValue( | 535 | readDatabaseValue( |
| 467 | database, | 536 | database, |
| ... | @@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -508,6 +577,48 @@ export const connectionStorageLayer = Layer.effectContext( |
| 508 | : persistenceError("save-thread", cause), | 577 | : persistenceError("save-thread", cause), |
| 509 | ), | 578 | ), |
| 510 | ), | 579 | ), |
| 580 | loadVcsRefs: (environmentId, cwd) => | ||
| 581 | readDatabaseValue(database, VCS_REFS_STORE_NAME, vcsRefsCacheKey(environmentId, cwd)).pipe( | ||
| 582 | Effect.flatMap((raw) => { | ||
| 583 | if (typeof raw !== "string") { | ||
| 584 | return Effect.succeed(Option.none()); | ||
| 585 | } | ||
| 586 | return decodeStoredVcsRefs(raw).pipe( | ||
| 587 | Effect.mapError((cause) => persistenceError("load-vcs-refs", cause)), | ||
| 588 | Effect.map((stored) => | ||
| 589 | stored.environmentId === environmentId && stored.cwd === cwd | ||
| 590 | ? Option.some(stored.refs) | ||
| 591 | : Option.none(), | ||
| 592 | ), | ||
| 593 | ); | ||
| 594 | }), | ||
| 595 | Effect.mapError((cause) => | ||
| 596 | cause._tag === "ConnectionPersistenceError" | ||
| 597 | ? cause | ||
| 598 | : persistenceError("load-vcs-refs", cause), | ||
| 599 | ), | ||
| 600 | ), | ||
| 601 | saveVcsRefs: (environmentId, cwd, refs) => | ||
| 602 | Effect.gen(function* () { | ||
| 603 | const encoded = yield* encodeStoredVcsRefs({ | ||
| 604 | schemaVersion: 1, | ||
| 605 | environmentId, | ||
| 606 | cwd, | ||
| 607 | refs, | ||
| 608 | }).pipe(Effect.mapError((cause) => persistenceError("save-vcs-refs", cause))); | ||
| 609 | yield* writeDatabaseValue( | ||
| 610 | database, | ||
| 611 | VCS_REFS_STORE_NAME, | ||
| 612 | vcsRefsCacheKey(environmentId, cwd), | ||
| 613 | encoded, | ||
| 614 | ); | ||
| 615 | }).pipe( | ||
| 616 | Effect.mapError((cause) => | ||
| 617 | cause._tag === "ConnectionPersistenceError" | ||
| 618 | ? cause | ||
| 619 | : persistenceError("save-vcs-refs", cause), | ||
| 620 | ), | ||
| 621 | ), | ||
| 511 | removeThread: (environmentId, threadId) => | 622 | removeThread: (environmentId, threadId) => |
| 512 | removeDatabaseValue( | 623 | removeDatabaseValue( |
| 513 | database, | 624 | database, |
| ... | @@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext( | ... | @@ -523,6 +634,12 @@ export const connectionStorageLayer = Layer.effectContext( |
| 523 | THREAD_STORE_NAME, | 634 | THREAD_STORE_NAME, |
| 524 | IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), | 635 | IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), |
| 525 | ), | 636 | ), |
| 637 | removeDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId), | ||
| 638 | removeDatabaseValuesInRange( | ||
| 639 | database, | ||
| 640 | VCS_REFS_STORE_NAME, | ||
| 641 | IDBKeyRange.bound(`${environmentId}:`, `${environmentId}:\uffff`), | ||
| 642 | ), | ||
| 526 | ], | 643 | ], |
| 527 | { concurrency: "unbounded", discard: true }, | 644 | { concurrency: "unbounded", discard: true }, |
| 528 | ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), | 645 | ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), |
packages/client-runtime/src/connection/registry.test.ts+4| ... | @@ -247,6 +247,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( | ... | @@ -247,6 +247,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( |
| 247 | loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), | 247 | loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), |
| 248 | saveThread: (_environmentId, _thread) => Effect.void, | 248 | saveThread: (_environmentId, _thread) => Effect.void, |
| 249 | removeThread: (_environmentId, _threadId) => Effect.void, | 249 | removeThread: (_environmentId, _threadId) => Effect.void, |
| 250 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 251 | saveServerConfig: () => Effect.void, | ||
| 252 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 253 | saveVcsRefs: () => Effect.void, | ||
| 250 | clear: (environmentId) => | 254 | clear: (environmentId) => |
| 251 | Ref.update(shellCache, (current) => { | 255 | Ref.update(shellCache, (current) => { |
| 252 | const next = new Map(current); | 256 | const next = new Map(current); |
packages/client-runtime/src/platform/persistence.ts+30| ... | @@ -2,7 +2,9 @@ import { | ... | @@ -2,7 +2,9 @@ import { |
| 2 | type EnvironmentId, | 2 | type EnvironmentId, |
| 3 | type OrchestrationShellSnapshot, | 3 | type OrchestrationShellSnapshot, |
| 4 | type OrchestrationThreadDetailSnapshot, | 4 | type OrchestrationThreadDetailSnapshot, |
| 5 | type ServerConfig, | ||
| 5 | type ThreadId, | 6 | type ThreadId, |
| 7 | type VcsListRefsResult, | ||
| 6 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 7 | import * as Context from "effect/Context"; | 9 | import * as Context from "effect/Context"; |
| 8 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| ... | @@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass<Connecti | ... | @@ -24,6 +26,10 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass<Connecti |
| 24 | "load-thread", | 26 | "load-thread", |
| 25 | "save-thread", | 27 | "save-thread", |
| 26 | "remove-thread", | 28 | "remove-thread", |
| 29 | "load-server-config", | ||
| 30 | "save-server-config", | ||
| 31 | "load-vcs-refs", | ||
| 32 | "save-vcs-refs", | ||
| 27 | "clear-environment", | 33 | "clear-environment", |
| 28 | ]), | 34 | ]), |
| 29 | message: Schema.String, | 35 | message: Schema.String, |
| ... | @@ -72,6 +78,30 @@ export class EnvironmentCacheStore extends Context.Service< | ... | @@ -72,6 +78,30 @@ export class EnvironmentCacheStore extends Context.Service< |
| 72 | environmentId: EnvironmentId, | 78 | environmentId: EnvironmentId, |
| 73 | threadId: ThreadId, | 79 | threadId: ThreadId, |
| 74 | ) => Effect.Effect<void, ConnectionPersistenceError>; | 80 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
| 81 | /** | ||
| 82 | * The last complete server configuration. This deliberately includes provider | ||
| 83 | * metadata so offline task creation can still offer the models a user last saw. | ||
| 84 | */ | ||
| 85 | readonly loadServerConfig: ( | ||
| 86 | environmentId: EnvironmentId, | ||
| 87 | ) => Effect.Effect<Option.Option<ServerConfig>, ConnectionPersistenceError>; | ||
| 88 | readonly saveServerConfig: ( | ||
| 89 | environmentId: EnvironmentId, | ||
| 90 | config: ServerConfig, | ||
| 91 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 92 | /** | ||
| 93 | * The unfiltered branch list for a workspace. Query-specific lists are not | ||
| 94 | * cached because they are incomplete and unsafe to present as a full picker. | ||
| 95 | */ | ||
| 96 | readonly loadVcsRefs: ( | ||
| 97 | environmentId: EnvironmentId, | ||
| 98 | cwd: string, | ||
| 99 | ) => Effect.Effect<Option.Option<VcsListRefsResult>, ConnectionPersistenceError>; | ||
| 100 | readonly saveVcsRefs: ( | ||
| 101 | environmentId: EnvironmentId, | ||
| 102 | cwd: string, | ||
| 103 | refs: VcsListRefsResult, | ||
| 104 | ) => Effect.Effect<void, ConnectionPersistenceError>; | ||
| 75 | readonly clear: ( | 105 | readonly clear: ( |
| 76 | environmentId: EnvironmentId, | 106 | environmentId: EnvironmentId, |
| 77 | ) => Effect.Effect<void, ConnectionPersistenceError>; | 107 | ) => Effect.Effect<void, ConnectionPersistenceError>; |
packages/client-runtime/src/state/server.test.ts+175-2| ... | @@ -1,8 +1,32 @@ | ... | @@ -1,8 +1,32 @@ |
| 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 | import { describe, expect, it } from "@effect/vitest"; | 8 | import { describe, expect, it } from "@effect/vitest"; |
| 9 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Option from "effect/Option"; | 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 | const CONFIG = { | 31 | const CONFIG = { |
| 8 | availableEditors: [], | 32 | availableEditors: [], |
| ... | @@ -14,6 +38,29 @@ const CONFIG = { | ... | @@ -14,6 +38,29 @@ const CONFIG = { |
| 14 | settings: {}, | 38 | settings: {}, |
| 15 | } as unknown as ServerConfig; | 39 | } as unknown as ServerConfig; |
| 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 | describe("server state projection", () => { | 64 | describe("server state projection", () => { |
| 18 | it("applies every config category to the projected snapshot", () => { | 65 | it("applies every config category to the projected snapshot", () => { |
| 19 | const snapshot = applyServerConfigProjection(Option.none(), { | 66 | const snapshot = applyServerConfigProjection(Option.none(), { |
| ... | @@ -51,4 +98,130 @@ describe("server state projection", () => { | ... | @@ -51,4 +98,130 @@ describe("server state projection", () => { |
| 51 | expect(Option.getOrThrow(afterReady)).toBe(welcome); | 98 | expect(Option.getOrThrow(afterReady)).toBe(welcome); |
| 52 | expect(emitted).toEqual([]); | 99 | expect(emitted).toEqual([]); |
| 53 | }); | 100 | }); |
| 101 | |||
| 102 | it("prefers an active session config over cache until a live event arrives", () => { | ||
| 103 | const cached = { ...CONFIG, settings: { source: "cache" } } as unknown as ServerConfig; | ||
| 104 | const initial = { ...CONFIG, settings: { source: "session" } } as unknown as ServerConfig; | ||
| 105 | const live = { ...CONFIG, settings: { source: "live" } } as unknown as ServerConfig; | ||
| 106 | |||
| 107 | expect( | ||
| 108 | resolveServerConfigValue( | ||
| 109 | { | ||
| 110 | config: cached, | ||
| 111 | latestEvent: snapshotEvent(cached), | ||
| 112 | source: "cache", | ||
| 113 | }, | ||
| 114 | initial, | ||
| 115 | ), | ||
| 116 | ).toBe(initial); | ||
| 117 | expect( | ||
| 118 | resolveServerConfigValue( | ||
| 119 | { | ||
| 120 | config: live, | ||
| 121 | latestEvent: snapshotEvent(live), | ||
| 122 | source: "live", | ||
| 123 | }, | ||
| 124 | initial, | ||
| 125 | ), | ||
| 126 | ).toBe(live); | ||
| 127 | }); | ||
| 128 | |||
| 129 | it.effect("starts from cached configuration and persists the live projection", () => | ||
| 130 | Effect.gen(function* () { | ||
| 131 | const events = yield* Queue.unbounded<ServerConfigStreamEvent>(); | ||
| 132 | const client = { | ||
| 133 | [WS_METHODS.subscribeServerConfig]: () => Stream.fromQueue(events), | ||
| 134 | } as unknown as WsRpcProtocolClient; | ||
| 135 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 136 | target: TARGET, | ||
| 137 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 138 | session: yield* SubscriptionRef.make(Option.some(session(client))), | ||
| 139 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 140 | connect: Effect.void, | ||
| 141 | disconnect: Effect.void, | ||
| 142 | retryNow: Effect.void, | ||
| 143 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 144 | const savedConfigs = yield* Queue.unbounded<ServerConfig>(); | ||
| 145 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 146 | loadShell: () => Effect.succeed(Option.none()), | ||
| 147 | saveShell: () => Effect.void, | ||
| 148 | loadThread: () => Effect.succeed(Option.none()), | ||
| 149 | saveThread: () => Effect.void, | ||
| 150 | removeThread: () => Effect.void, | ||
| 151 | loadServerConfig: () => Effect.succeed(Option.some(CONFIG)), | ||
| 152 | saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), | ||
| 153 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 154 | saveVcsRefs: () => Effect.void, | ||
| 155 | clear: () => Effect.void, | ||
| 156 | }); | ||
| 157 | |||
| 158 | yield* Effect.scoped( | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const state = yield* makeEnvironmentServerConfigState().pipe( | ||
| 161 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 162 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 163 | ); | ||
| 164 | expect(Option.getOrThrow(yield* SubscriptionRef.get(state)).config).toBe(CONFIG); | ||
| 165 | |||
| 166 | const providers: ServerConfig["providers"] = []; | ||
| 167 | yield* Queue.offer(events, { | ||
| 168 | version: 1, | ||
| 169 | type: "providerStatuses", | ||
| 170 | payload: { providers }, | ||
| 171 | }); | ||
| 172 | const projected = yield* SubscriptionRef.changes(state).pipe( | ||
| 173 | Stream.filter((value) => | ||
| 174 | Option.match(value, { | ||
| 175 | onNone: () => false, | ||
| 176 | onSome: (projection) => projection.latestEvent.type === "providerStatuses", | ||
| 177 | }), | ||
| 178 | ), | ||
| 179 | Stream.runHead, | ||
| 180 | ); | ||
| 181 | expect(Option.getOrThrow(Option.getOrThrow(projected)).config.providers).toBe(providers); | ||
| 182 | }), | ||
| 183 | ); | ||
| 184 | |||
| 185 | expect((yield* Queue.take(savedConfigs)).providers).toEqual([]); | ||
| 186 | }), | ||
| 187 | ); | ||
| 188 | |||
| 189 | it.effect("does not rewrite cached configuration when no live update arrives", () => | ||
| 190 | Effect.gen(function* () { | ||
| 191 | const client = { | ||
| 192 | [WS_METHODS.subscribeServerConfig]: () => Stream.empty, | ||
| 193 | } as unknown as WsRpcProtocolClient; | ||
| 194 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 195 | target: TARGET, | ||
| 196 | state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), | ||
| 197 | session: yield* SubscriptionRef.make(Option.some(session(client))), | ||
| 198 | prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()), | ||
| 199 | connect: Effect.void, | ||
| 200 | disconnect: Effect.void, | ||
| 201 | retryNow: Effect.void, | ||
| 202 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 203 | const savedConfigs = yield* Queue.unbounded<ServerConfig>(); | ||
| 204 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 205 | loadShell: () => Effect.succeed(Option.none()), | ||
| 206 | saveShell: () => Effect.void, | ||
| 207 | loadThread: () => Effect.succeed(Option.none()), | ||
| 208 | saveThread: () => Effect.void, | ||
| 209 | removeThread: () => Effect.void, | ||
| 210 | loadServerConfig: () => Effect.succeed(Option.some(CONFIG)), | ||
| 211 | saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), | ||
| 212 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 213 | saveVcsRefs: () => Effect.void, | ||
| 214 | clear: () => Effect.void, | ||
| 215 | }); | ||
| 216 | |||
| 217 | yield* Effect.scoped( | ||
| 218 | makeEnvironmentServerConfigState().pipe( | ||
| 219 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 220 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | |||
| 224 | expect(yield* Queue.poll(savedConfigs)).toEqual(Option.none()); | ||
| 225 | }), | ||
| 226 | ); | ||
| 54 | }); | 227 | }); |
packages/client-runtime/src/state/server.ts+161-8| ... | @@ -5,8 +5,13 @@ import { | ... | @@ -5,8 +5,13 @@ import { |
| 5 | type ServerLifecycleWelcomePayload, | 5 | type ServerLifecycleWelcomePayload, |
| 6 | WS_METHODS, | 6 | WS_METHODS, |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | 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 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 14 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 10 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | 15 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 11 | 16 | ||
| 12 | import { | 17 | import { |
| ... | @@ -16,10 +21,16 @@ import { | ... | @@ -16,10 +21,16 @@ import { |
| 16 | createEnvironmentRpcSubscriptionAtomFamily, | 21 | createEnvironmentRpcSubscriptionAtomFamily, |
| 17 | } from "./runtime.ts"; | 22 | } from "./runtime.ts"; |
| 18 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 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 | export interface ServerConfigProjection { | 30 | export interface ServerConfigProjection { |
| 21 | readonly config: ServerConfig; | 31 | readonly config: ServerConfig; |
| 22 | readonly latestEvent: ServerConfigStreamEvent; | 32 | readonly latestEvent: ServerConfigStreamEvent; |
| 33 | readonly source: "cache" | "live"; | ||
| 23 | } | 34 | } |
| 24 | 35 | ||
| 25 | export function applyServerConfigProjection( | 36 | export function applyServerConfigProjection( |
| ... | @@ -31,6 +42,7 @@ export function applyServerConfigProjection( | ... | @@ -31,6 +42,7 @@ export function applyServerConfigProjection( |
| 31 | return Option.some({ | 42 | return Option.some({ |
| 32 | config: event.config, | 43 | config: event.config, |
| 33 | latestEvent: event, | 44 | latestEvent: event, |
| 45 | source: "live", | ||
| 34 | }); | 46 | }); |
| 35 | case "keybindingsUpdated": | 47 | case "keybindingsUpdated": |
| 36 | return Option.map(current, (projection) => ({ | 48 | return Option.map(current, (projection) => ({ |
| ... | @@ -40,6 +52,7 @@ export function applyServerConfigProjection( | ... | @@ -40,6 +52,7 @@ export function applyServerConfigProjection( |
| 40 | issues: event.payload.issues, | 52 | issues: event.payload.issues, |
| 41 | }, | 53 | }, |
| 42 | latestEvent: event, | 54 | latestEvent: event, |
| 55 | source: "live", | ||
| 43 | })); | 56 | })); |
| 44 | case "providerStatuses": | 57 | case "providerStatuses": |
| 45 | return Option.map(current, (projection) => ({ | 58 | return Option.map(current, (projection) => ({ |
| ... | @@ -48,6 +61,7 @@ export function applyServerConfigProjection( | ... | @@ -48,6 +61,7 @@ export function applyServerConfigProjection( |
| 48 | providers: event.payload.providers, | 61 | providers: event.payload.providers, |
| 49 | }, | 62 | }, |
| 50 | latestEvent: event, | 63 | latestEvent: event, |
| 64 | source: "live", | ||
| 51 | })); | 65 | })); |
| 52 | case "settingsUpdated": | 66 | case "settingsUpdated": |
| 53 | return Option.map(current, (projection) => ({ | 67 | return Option.map(current, (projection) => ({ |
| ... | @@ -56,6 +70,7 @@ export function applyServerConfigProjection( | ... | @@ -56,6 +70,7 @@ export function applyServerConfigProjection( |
| 56 | settings: event.payload.settings, | 70 | settings: event.payload.settings, |
| 57 | }, | 71 | }, |
| 58 | latestEvent: event, | 72 | latestEvent: event, |
| 73 | source: "live", | ||
| 59 | })); | 74 | })); |
| 60 | } | 75 | } |
| 61 | } | 76 | } |
| ... | @@ -68,6 +83,127 @@ export function projectServerConfig( | ... | @@ -68,6 +83,127 @@ export function projectServerConfig( |
| 68 | return [next, Option.toArray(next)]; | 83 | return [next, Option.toArray(next)]; |
| 69 | } | 84 | } |
| 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 | export function projectServerWelcome( | 207 | export function projectServerWelcome( |
| 72 | current: Option.Option<ServerLifecycleWelcomePayload>, | 208 | current: Option.Option<ServerLifecycleWelcomePayload>, |
| 73 | event: { | 209 | event: { |
| ... | @@ -85,8 +221,16 @@ export function projectServerWelcome( | ... | @@ -85,8 +221,16 @@ export function projectServerWelcome( |
| 85 | return [Option.some(welcome), [welcome]]; | 221 | return [Option.some(welcome), [welcome]]; |
| 86 | } | 222 | } |
| 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 | export function createServerEnvironmentAtoms<R, E>( | 232 | export function createServerEnvironmentAtoms<R, E>( |
| 89 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | 233 | runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>, |
| 90 | options: { | 234 | options: { |
| 91 | readonly initialConfigValueAtom: ( | 235 | readonly initialConfigValueAtom: ( |
| 92 | environmentId: EnvironmentId, | 236 | environmentId: EnvironmentId, |
| ... | @@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -98,12 +242,18 @@ export function createServerEnvironmentAtoms<R, E>( |
| 98 | mode: "serial" as const, | 242 | mode: "serial" as const, |
| 99 | key: ({ environmentId }: { readonly environmentId: string }) => environmentId, | 243 | key: ({ environmentId }: { readonly environmentId: string }) => environmentId, |
| 100 | }; | 244 | }; |
| 101 | const configProjection = createEnvironmentRpcSubscriptionAtomFamily(runtime, { | 245 | const configProjectionFamily = Atom.family((environmentId: EnvironmentId) => |
| 102 | label: "environment-data:server:config-projection", | 246 | runtime |
| 103 | tag: WS_METHODS.subscribeServerConfig, | 247 | .atom(serverConfigStateChanges(environmentId)) |
| 104 | transform: (stream) => | 248 | .pipe( |
| 105 | stream.pipe(Stream.mapAccum(Option.none<ServerConfigProjection>, projectServerConfig)), | 249 | Atom.setIdleTTL(5 * 60_000), |
| 106 | }); | 250 | Atom.withLabel(`environment-data:server:config-projection:${environmentId}`), |
| 251 | ), | ||
| 252 | ); | ||
| 253 | const configProjection = (target: { | ||
| 254 | readonly environmentId: EnvironmentId; | ||
| 255 | readonly input: EnvironmentRpcInput<typeof WS_METHODS.subscribeServerConfig>; | ||
| 256 | }) => configProjectionFamily(target.environmentId); | ||
| 107 | const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe( | 257 | const emptyConfigAtom = Atom.make<ServerConfig | null>(null).pipe( |
| 108 | Atom.withLabel("environment-data:server:config:empty"), | 258 | Atom.withLabel("environment-data:server:config:empty"), |
| 109 | ); | 259 | ); |
| ... | @@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -115,7 +265,10 @@ export function createServerEnvironmentAtoms<R, E>( |
| 115 | const projection = Option.getOrNull( | 265 | const projection = Option.getOrNull( |
| 116 | AsyncResult.value(get(configProjection({ environmentId, input: {} }))), | 266 | AsyncResult.value(get(configProjection({ environmentId, input: {} }))), |
| 117 | ); | 267 | ); |
| 118 | return projection?.config ?? get(options.initialConfigValueAtom(environmentId)); | 268 | return resolveServerConfigValue( |
| 269 | projection, | ||
| 270 | get(options.initialConfigValueAtom(environmentId)), | ||
| 271 | ); | ||
| 119 | }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); | 272 | }).pipe(Atom.withLabel(`environment-data:server:config:${environmentId}`)); |
| 120 | }); | 273 | }); |
| 121 | const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => | 274 | const settingsValueAtom = Atom.family((environmentId: EnvironmentId) => |
packages/client-runtime/src/state/shell-sync.test.ts+8| ... | @@ -81,6 +81,10 @@ describe("environment shell synchronization", () => { | ... | @@ -81,6 +81,10 @@ describe("environment shell synchronization", () => { |
| 81 | loadThread: () => Effect.succeed(Option.none()), | 81 | loadThread: () => Effect.succeed(Option.none()), |
| 82 | saveThread: () => Effect.void, | 82 | saveThread: () => Effect.void, |
| 83 | removeThread: () => Effect.void, | 83 | removeThread: () => Effect.void, |
| 84 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 85 | saveServerConfig: () => Effect.void, | ||
| 86 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 87 | saveVcsRefs: () => Effect.void, | ||
| 84 | clear: () => Effect.void, | 88 | clear: () => Effect.void, |
| 85 | }); | 89 | }); |
| 86 | // Cold cache with no HTTP snapshot available → falls back to the | 90 | // Cold cache with no HTTP snapshot available → falls back to the |
| ... | @@ -171,6 +175,10 @@ describe("environment shell synchronization", () => { | ... | @@ -171,6 +175,10 @@ describe("environment shell synchronization", () => { |
| 171 | loadThread: () => Effect.succeed(Option.none()), | 175 | loadThread: () => Effect.succeed(Option.none()), |
| 172 | saveThread: () => Effect.void, | 176 | saveThread: () => Effect.void, |
| 173 | removeThread: () => Effect.void, | 177 | removeThread: () => Effect.void, |
| 178 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 179 | saveServerConfig: () => Effect.void, | ||
| 180 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 181 | saveVcsRefs: () => Effect.void, | ||
| 174 | clear: () => Effect.void, | 182 | clear: () => Effect.void, |
| 175 | }); | 183 | }); |
| 176 | const snapshotLoader = ShellSnapshotLoader.of({ | 184 | const snapshotLoader = ShellSnapshotLoader.of({ |
packages/client-runtime/src/state/threads-sync.test.ts+4| ... | @@ -170,6 +170,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o | ... | @@ -170,6 +170,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o |
| 170 | Ref.update(savedThreads, (current) => [...current, thread]), | 170 | Ref.update(savedThreads, (current) => [...current, thread]), |
| 171 | removeThread: (_environmentId, threadId) => | 171 | removeThread: (_environmentId, threadId) => |
| 172 | Ref.update(removedThreads, (current) => [...current, threadId]), | 172 | Ref.update(removedThreads, (current) => [...current, threadId]), |
| 173 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 174 | saveServerConfig: () => Effect.void, | ||
| 175 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 176 | saveVcsRefs: () => Effect.void, | ||
| 173 | clear: () => Effect.void, | 177 | clear: () => Effect.void, |
| 174 | }); | 178 | }); |
| 175 | const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( | 179 | const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe( |
packages/client-runtime/src/state/vcs.test.ts created+232| ... | @@ -0,0 +1,232 @@ | ||
| 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,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 | import { applyGitStatusStreamEvent } from "@t3tools/shared/git"; | 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 | import * as Stream from "effect/Stream"; | 12 | import * as Stream from "effect/Stream"; |
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 4 | import { Atom } from "effect/unstable/reactivity"; | 14 | import { Atom } from "effect/unstable/reactivity"; |
| 5 | 15 | ||
| 6 | import { | 16 | import { createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily } from "./runtime.ts"; |
| 7 | createEnvironmentRpcCommand, | ||
| 8 | createEnvironmentRpcQueryAtomFamily, | ||
| 9 | createEnvironmentSubscriptionAtomFamily, | ||
| 10 | } from "./runtime.ts"; | ||
| 11 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 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 | import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; | 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 | export function createVcsEnvironmentAtoms<R, E>( | 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 { | 148 | const listRefsByEnvironment = Atom.family((environmentId: EnvironmentId) => |
| 19 | listRefs: createEnvironmentRpcQueryAtomFamily(runtime, { | 149 | Atom.family((inputKey: string) => { |
| 20 | label: "environment-data:vcs:list-refs", | 150 | const input = JSON.parse(inputKey) as VcsListRefsInput; |
| 21 | tag: WS_METHODS.vcsListRefs, | 151 | return runtime |
| 22 | staleTimeMs: 5_000, | 152 | .atom(cachedVcsRefsChanges(environmentId, input)) |
| 153 | .pipe( | ||
| 154 | Atom.setIdleTTL(5 * 60_000), | ||
| 155 | Atom.withLabel(`environment-data:vcs:list-refs:${environmentId}:${inputKey}`), | ||
| 156 | ); | ||
| 23 | }), | 157 | }), |
| 158 | ); | ||
| 159 | const listRefs = (target: { | ||
| 160 | readonly environmentId: EnvironmentId; | ||
| 161 | readonly input: VcsListRefsInput; | ||
| 162 | }) => listRefsByEnvironment(target.environmentId)(JSON.stringify(target.input)); | ||
| 163 | |||
| 164 | return { | ||
| 165 | listRefs, | ||
| 24 | status: createEnvironmentSubscriptionAtomFamily(runtime, { | 166 | status: createEnvironmentSubscriptionAtomFamily(runtime, { |
| 25 | label: "environment-data:vcs:status", | 167 | label: "environment-data:vcs:status", |
| 26 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => | 168 | subscribe: (input: EnvironmentRpcInput<typeof WS_METHODS.subscribeVcsStatus>) => |
pnpm-lock.yaml+794-8| ... | @@ -327,6 +327,9 @@ importers: | ... | @@ -327,6 +327,9 @@ importers: |
| 327 | expo-splash-screen: | 327 | expo-splash-screen: |
| 328 | specifier: ~56.0.10 | 328 | specifier: ~56.0.10 |
| 329 | version: 56.0.10(expo@56.0.12)(typescript@6.0.3) | 329 | version: 56.0.10(expo@56.0.12)(typescript@6.0.3) |
| 330 | expo-sqlite: | ||
| 331 | specifier: ~56.0.5 | ||
| 332 | version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | ||
| 330 | expo-symbols: | 333 | expo-symbols: |
| 331 | specifier: ~56.0.6 | 334 | specifier: ~56.0.6 |
| 332 | version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 335 | version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| ... | @@ -662,10 +665,10 @@ importers: | ... | @@ -662,10 +665,10 @@ importers: |
| 662 | version: link:../../packages/shared | 665 | version: link:../../packages/shared |
| 663 | alchemy: | 666 | alchemy: |
| 664 | specifier: https://pkg.ing/alchemy/078ff00 | 667 | specifier: https://pkg.ing/alchemy/078ff00 |
| 665 | version: https://pkg.ing/alchemy/078ff00(58fb6eb83dd48b3750245d08d24c00ab) | 668 | version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) |
| 666 | drizzle-orm: | 669 | drizzle-orm: |
| 667 | specifier: 1.0.0-rc.3 | 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 | effect: | 672 | effect: |
| 670 | specifier: 4.0.0-beta.78 | 673 | specifier: 4.0.0-beta.78 |
| 671 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) | 674 | version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
| ... | @@ -5445,6 +5448,9 @@ packages: | ... | @@ -5445,6 +5448,9 @@ packages: |
| 5445 | resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} | 5448 | resolution: {integrity: sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==} |
| 5446 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} | 5449 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} |
| 5447 | 5450 | ||
| 5451 | await-lock@2.2.2: | ||
| 5452 | resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} | ||
| 5453 | |||
| 5448 | aws-ssl-profiles@1.1.2: | 5454 | aws-ssl-profiles@1.1.2: |
| 5449 | resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} | 5455 | resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} |
| 5450 | engines: {node: '>= 6.0.0'} | 5456 | engines: {node: '>= 6.0.0'} |
| ... | @@ -6661,6 +6667,13 @@ packages: | ... | @@ -6661,6 +6667,13 @@ packages: |
| 6661 | peerDependencies: | 6667 | peerDependencies: |
| 6662 | expo: '*' | 6668 | expo: '*' |
| 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 | expo-structured-headers@56.0.0: | 6677 | expo-structured-headers@56.0.0: |
| 6665 | resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} | 6678 | resolution: {integrity: sha512-Yv4x+SQxNnMQm4nu8NFfzx197YaDhdYH2N0u7tGErwWTmH9Tm1SAhqo7bLbBWLC9kf7W+kdzTshLU9rTiCXWGw==} |
| 6666 | 6679 | ||
| ... | @@ -12056,6 +12069,83 @@ snapshots: | ... | @@ -12056,6 +12069,83 @@ snapshots: |
| 12056 | - typescript | 12069 | - typescript |
| 12057 | - utf-8-validate | 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 | '@expo/code-signing-certificates@0.0.6': | 12149 | '@expo/code-signing-certificates@0.0.6': |
| 12060 | dependencies: | 12150 | dependencies: |
| 12061 | node-forge: 1.4.0 | 12151 | node-forge: 1.4.0 |
| ... | @@ -12132,12 +12222,27 @@ snapshots: | ... | @@ -12132,12 +12222,27 @@ snapshots: |
| 12132 | react: 19.2.3 | 12222 | react: 19.2.3 |
| 12133 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12223 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 12233 | '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 12136 | dependencies: | 12234 | dependencies: |
| 12137 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 12235 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 12138 | react: 19.2.3 | 12236 | react: 19.2.3 |
| 12139 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12237 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | '@expo/env@2.3.0': | 12246 | '@expo/env@2.3.0': |
| 12142 | dependencies: | 12247 | dependencies: |
| 12143 | chalk: 4.1.2 | 12248 | chalk: 4.1.2 |
| ... | @@ -12206,6 +12311,16 @@ snapshots: | ... | @@ -12206,6 +12311,16 @@ snapshots: |
| 12206 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 12311 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 12207 | stacktrace-parser: 0.1.11 | 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 | '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)': | 12324 | '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)': |
| 12210 | dependencies: | 12325 | dependencies: |
| 12211 | '@babel/code-frame': 7.29.7 | 12326 | '@babel/code-frame': 7.29.7 |
| ... | @@ -12263,6 +12378,20 @@ snapshots: | ... | @@ -12263,6 +12378,20 @@ snapshots: |
| 12263 | optionalDependencies: | 12378 | optionalDependencies: |
| 12264 | react-dom: 19.2.3(react@19.2.3) | 12379 | react-dom: 19.2.3(react@19.2.3) |
| 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 | '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': | 12395 | '@expo/metro@56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)': |
| 12267 | dependencies: | 12396 | dependencies: |
| 12268 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 12397 | metro: 0.84.4(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| ... | @@ -12344,6 +12473,22 @@ snapshots: | ... | @@ -12344,6 +12473,22 @@ snapshots: |
| 12344 | transitivePeerDependencies: | 12473 | transitivePeerDependencies: |
| 12345 | - supports-color | 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 | '@expo/schema-utils@56.0.1': {} | 12492 | '@expo/schema-utils@56.0.1': {} |
| 12348 | 12493 | ||
| 12349 | '@expo/sdk-runtime-versions@1.0.0': {} | 12494 | '@expo/sdk-runtime-versions@1.0.0': {} |
| ... | @@ -12354,6 +12499,23 @@ snapshots: | ... | @@ -12354,6 +12499,23 @@ snapshots: |
| 12354 | 12499 | ||
| 12355 | '@expo/sudo-prompt@9.3.2': {} | 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 | '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)': | 12519 | '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)': |
| 12358 | dependencies: | 12520 | dependencies: |
| 12359 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 12521 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -13286,18 +13448,45 @@ snapshots: | ... | @@ -13286,18 +13448,45 @@ snapshots: |
| 13286 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13448 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13287 | optional: true | 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 | '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': | 13464 | '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.16)(react@19.2.3)': |
| 13290 | dependencies: | 13465 | dependencies: |
| 13291 | react: 19.2.3 | 13466 | react: 19.2.3 |
| 13292 | optionalDependencies: | 13467 | optionalDependencies: |
| 13293 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)': | 13477 | '@radix-ui/react-context@1.1.2(@types/react@19.2.16)(react@19.2.3)': |
| 13296 | dependencies: | 13478 | dependencies: |
| 13297 | react: 19.2.3 | 13479 | react: 19.2.3 |
| 13298 | optionalDependencies: | 13480 | optionalDependencies: |
| 13299 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13490 | '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13302 | dependencies: | 13491 | dependencies: |
| 13303 | '@radix-ui/primitive': 1.1.3 | 13492 | '@radix-ui/primitive': 1.1.3 |
| ... | @@ -13320,6 +13509,29 @@ snapshots: | ... | @@ -13320,6 +13509,29 @@ snapshots: |
| 13320 | '@types/react': 19.2.16 | 13509 | '@types/react': 19.2.16 |
| 13321 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13510 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 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 | '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)': | 13535 | '@radix-ui/react-direction@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13324 | dependencies: | 13536 | dependencies: |
| 13325 | react: 19.2.3 | 13537 | react: 19.2.3 |
| ... | @@ -13327,6 +13539,13 @@ snapshots: | ... | @@ -13327,6 +13539,13 @@ snapshots: |
| 13327 | '@types/react': 19.2.16 | 13539 | '@types/react': 19.2.16 |
| 13328 | optional: true | 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 | '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13549 | '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13331 | dependencies: | 13550 | dependencies: |
| 13332 | '@radix-ui/primitive': 1.1.3 | 13551 | '@radix-ui/primitive': 1.1.3 |
| ... | @@ -13340,12 +13559,33 @@ snapshots: | ... | @@ -13340,12 +13559,33 @@ snapshots: |
| 13340 | '@types/react': 19.2.16 | 13559 | '@types/react': 19.2.16 |
| 13341 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13560 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 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 | '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)': | 13576 | '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.16)(react@19.2.3)': |
| 13344 | dependencies: | 13577 | dependencies: |
| 13345 | react: 19.2.3 | 13578 | react: 19.2.3 |
| 13346 | optionalDependencies: | 13579 | optionalDependencies: |
| 13347 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13589 | '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13350 | dependencies: | 13590 | dependencies: |
| 13351 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) | 13591 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13357,6 +13597,18 @@ snapshots: | ... | @@ -13357,6 +13597,18 @@ snapshots: |
| 13357 | '@types/react': 19.2.16 | 13597 | '@types/react': 19.2.16 |
| 13358 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13598 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 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 | '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)': | 13612 | '@radix-ui/react-id@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13361 | dependencies: | 13613 | dependencies: |
| 13362 | '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) | 13614 | '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13364,6 +13616,14 @@ snapshots: | ... | @@ -13364,6 +13616,14 @@ snapshots: |
| 13364 | optionalDependencies: | 13616 | optionalDependencies: |
| 13365 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13627 | '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13368 | dependencies: | 13628 | dependencies: |
| 13369 | '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) | 13629 | '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) |
| ... | @@ -13374,6 +13634,17 @@ snapshots: | ... | @@ -13374,6 +13634,17 @@ snapshots: |
| 13374 | '@types/react': 19.2.16 | 13634 | '@types/react': 19.2.16 |
| 13375 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13635 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 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 | '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13648 | '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13378 | dependencies: | 13649 | dependencies: |
| 13379 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) | 13650 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13384,6 +13655,17 @@ snapshots: | ... | @@ -13384,6 +13655,17 @@ snapshots: |
| 13384 | '@types/react': 19.2.16 | 13655 | '@types/react': 19.2.16 |
| 13385 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13656 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 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 | '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13669 | '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13388 | dependencies: | 13670 | dependencies: |
| 13389 | '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3) | 13671 | '@radix-ui/react-slot': 1.2.3(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13393,6 +13675,16 @@ snapshots: | ... | @@ -13393,6 +13675,16 @@ snapshots: |
| 13393 | '@types/react': 19.2.16 | 13675 | '@types/react': 19.2.16 |
| 13394 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13676 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 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 | '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13688 | '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13397 | dependencies: | 13689 | dependencies: |
| 13398 | '@radix-ui/primitive': 1.1.3 | 13690 | '@radix-ui/primitive': 1.1.3 |
| ... | @@ -13411,6 +13703,24 @@ snapshots: | ... | @@ -13411,6 +13703,24 @@ snapshots: |
| 13411 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13703 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13412 | optional: true | 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 | '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': | 13724 | '@radix-ui/react-slot@1.2.3(@types/react@19.2.16)(react@19.2.3)': |
| 13415 | dependencies: | 13725 | dependencies: |
| 13416 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) | 13726 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13418,6 +13728,14 @@ snapshots: | ... | @@ -13418,6 +13728,14 @@ snapshots: |
| 13418 | optionalDependencies: | 13728 | optionalDependencies: |
| 13419 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)': | 13739 | '@radix-ui/react-slot@1.2.4(@types/react@19.2.16)(react@19.2.3)': |
| 13422 | dependencies: | 13740 | dependencies: |
| 13423 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) | 13741 | '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13426,6 +13744,14 @@ snapshots: | ... | @@ -13426,6 +13744,14 @@ snapshots: |
| 13426 | '@types/react': 19.2.16 | 13744 | '@types/react': 19.2.16 |
| 13427 | optional: true | 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 | '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': | 13755 | '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': |
| 13430 | dependencies: | 13756 | dependencies: |
| 13431 | '@radix-ui/primitive': 1.1.3 | 13757 | '@radix-ui/primitive': 1.1.3 |
| ... | @@ -13443,12 +13769,36 @@ snapshots: | ... | @@ -13443,12 +13769,36 @@ snapshots: |
| 13443 | '@types/react-dom': 19.2.3(@types/react@19.2.16) | 13769 | '@types/react-dom': 19.2.3(@types/react@19.2.16) |
| 13444 | optional: true | 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 | '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': | 13789 | '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13447 | dependencies: | 13790 | dependencies: |
| 13448 | react: 19.2.3 | 13791 | react: 19.2.3 |
| 13449 | optionalDependencies: | 13792 | optionalDependencies: |
| 13450 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)': | 13802 | '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.16)(react@19.2.3)': |
| 13453 | dependencies: | 13803 | dependencies: |
| 13454 | '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3) | 13804 | '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.3) |
| ... | @@ -13457,12 +13807,29 @@ snapshots: | ... | @@ -13457,12 +13807,29 @@ snapshots: |
| 13457 | optionalDependencies: | 13807 | optionalDependencies: |
| 13458 | '@types/react': 19.2.16 | 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 | dependencies: | 13811 | dependencies: |
| 13462 | '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.16)(react@19.2.3) | 13812 | '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.16)(react@19.2.6) |
| 13463 | react: 19.2.3 | 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 | optionalDependencies: | 13815 | optionalDependencies: |
| 13465 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)': | 13834 | '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13468 | dependencies: | 13835 | dependencies: |
| ... | @@ -13471,12 +13838,27 @@ snapshots: | ... | @@ -13471,12 +13838,27 @@ snapshots: |
| 13471 | optionalDependencies: | 13838 | optionalDependencies: |
| 13472 | '@types/react': 19.2.16 | 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 | '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)': | 13849 | '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.16)(react@19.2.3)': |
| 13475 | dependencies: | 13850 | dependencies: |
| 13476 | react: 19.2.3 | 13851 | react: 19.2.3 |
| 13477 | optionalDependencies: | 13852 | optionalDependencies: |
| 13478 | '@types/react': 19.2.16 | 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 | '@react-grab/cli@0.1.44': | 13862 | '@react-grab/cli@0.1.44': |
| 13481 | dependencies: | 13863 | dependencies: |
| 13482 | agent-install: 0.0.5 | 13864 | agent-install: 0.0.5 |
| ... | @@ -13494,6 +13876,12 @@ snapshots: | ... | @@ -13494,6 +13876,12 @@ snapshots: |
| 13494 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 13876 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 13495 | optional: true | 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 | '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': | 13885 | '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': |
| 13498 | dependencies: | 13886 | dependencies: |
| 13499 | react: 19.2.3 | 13887 | react: 19.2.3 |
| ... | @@ -13638,6 +14026,16 @@ snapshots: | ... | @@ -13638,6 +14026,16 @@ snapshots: |
| 13638 | optionalDependencies: | 14026 | optionalDependencies: |
| 13639 | '@types/react': 19.2.16 | 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 | '@react-navigation/core@7.21.2(react@19.2.3)': | 14039 | '@react-navigation/core@7.21.2(react@19.2.3)': |
| 13642 | dependencies: | 14040 | dependencies: |
| 13643 | '@react-navigation/routers': 7.6.0 | 14041 | '@react-navigation/routers': 7.6.0 |
| ... | @@ -14797,7 +15195,7 @@ snapshots: | ... | @@ -14797,7 +15195,7 @@ snapshots: |
| 14797 | json-schema-traverse: 1.0.0 | 15195 | json-schema-traverse: 1.0.0 |
| 14798 | require-from-string: 2.0.2 | 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 | dependencies: | 15199 | dependencies: |
| 14802 | '@alchemy.run/node-utils': 0.0.4 | 15200 | '@alchemy.run/node-utils': 0.0.4 |
| 14803 | '@aws-sdk/credential-providers': 3.1062.0 | 15201 | '@aws-sdk/credential-providers': 3.1062.0 |
| ... | @@ -14840,7 +15238,7 @@ snapshots: | ... | @@ -14840,7 +15238,7 @@ snapshots: |
| 14840 | '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) | 15238 | '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) |
| 14841 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) | 15239 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) |
| 14842 | drizzle-kit: 1.0.0-rc.3 | 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 | vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' | 15242 | vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' |
| 14845 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 15243 | ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 14846 | transitivePeerDependencies: | 15244 | transitivePeerDependencies: |
| ... | @@ -15083,6 +15481,8 @@ snapshots: | ... | @@ -15083,6 +15481,8 @@ snapshots: |
| 15083 | 15481 | ||
| 15084 | auto-bind@5.0.1: {} | 15482 | auto-bind@5.0.1: {} |
| 15085 | 15483 | ||
| 15484 | await-lock@2.2.2: {} | ||
| 15485 | |||
| 15086 | aws-ssl-profiles@1.1.2: {} | 15486 | aws-ssl-profiles@1.1.2: {} |
| 15087 | 15487 | ||
| 15088 | aws4@1.13.2: {} | 15488 | aws4@1.13.2: {} |
| ... | @@ -15856,13 +16256,14 @@ snapshots: | ... | @@ -15856,13 +16256,14 @@ snapshots: |
| 15856 | get-tsconfig: 4.14.0 | 16256 | get-tsconfig: 4.14.0 |
| 15857 | jiti: 2.7.0 | 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 | optionalDependencies: | 16260 | optionalDependencies: |
| 15861 | '@cloudflare/workers-types': 4.20260604.1 | 16261 | '@cloudflare/workers-types': 4.20260604.1 |
| 15862 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) | 16262 | '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) |
| 15863 | '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) | 16263 | '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) |
| 15864 | bun-types: 1.3.14 | 16264 | bun-types: 1.3.14 |
| 15865 | effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) | 16265 | effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) |
| 16266 | expo-sqlite: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) | ||
| 15866 | mysql2: 3.22.4(@types/node@24.12.4) | 16267 | mysql2: 3.22.4(@types/node@24.12.4) |
| 15867 | pg: 8.21.0 | 16268 | pg: 8.21.0 |
| 15868 | zod: 4.4.3 | 16269 | zod: 4.4.3 |
| ... | @@ -16142,6 +16543,18 @@ snapshots: | ... | @@ -16142,6 +16543,18 @@ snapshots: |
| 16142 | - supports-color | 16543 | - supports-color |
| 16143 | - typescript | 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 | expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16558 | expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16146 | dependencies: | 16559 | dependencies: |
| 16147 | expo-application: 56.0.3(expo@56.0.12) | 16560 | expo-application: 56.0.3(expo@56.0.12) |
| ... | @@ -16186,6 +16599,15 @@ snapshots: | ... | @@ -16186,6 +16599,15 @@ snapshots: |
| 16186 | transitivePeerDependencies: | 16599 | transitivePeerDependencies: |
| 16187 | - supports-color | 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 | expo-crypto@56.0.4(expo@56.0.12): | 16611 | expo-crypto@56.0.4(expo@56.0.12): |
| 16190 | dependencies: | 16612 | dependencies: |
| 16191 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16613 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16226,6 +16648,12 @@ snapshots: | ... | @@ -16226,6 +16648,12 @@ snapshots: |
| 16226 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16648 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 16227 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16649 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16657 | expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16230 | dependencies: | 16658 | dependencies: |
| 16231 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16659 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16233,12 +16661,27 @@ snapshots: | ... | @@ -16233,12 +16661,27 @@ snapshots: |
| 16233 | react: 19.2.3 | 16661 | react: 19.2.3 |
| 16234 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16662 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16672 | expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16237 | dependencies: | 16673 | dependencies: |
| 16238 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16674 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 16239 | react: 19.2.3 | 16675 | react: 19.2.3 |
| 16240 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16676 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | expo-haptics@56.0.3(expo@56.0.12): | 16685 | expo-haptics@56.0.3(expo@56.0.12): |
| 16243 | dependencies: | 16686 | dependencies: |
| 16244 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16687 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16259,6 +16702,12 @@ snapshots: | ... | @@ -16259,6 +16702,12 @@ snapshots: |
| 16259 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16702 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| 16260 | react: 19.2.3 | 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 | expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16711 | expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 16263 | dependencies: | 16712 | dependencies: |
| 16264 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) | 16713 | expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) |
| ... | @@ -16269,6 +16718,17 @@ snapshots: | ... | @@ -16269,6 +16718,17 @@ snapshots: |
| 16269 | - expo | 16718 | - expo |
| 16270 | - supports-color | 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 | expo-manifests@56.0.4(expo@56.0.12): | 16732 | expo-manifests@56.0.4(expo@56.0.12): |
| 16273 | dependencies: | 16733 | dependencies: |
| 16274 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16734 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16294,10 +16754,26 @@ snapshots: | ... | @@ -16294,10 +16754,26 @@ snapshots: |
| 16294 | optionalDependencies: | 16754 | optionalDependencies: |
| 16295 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 16755 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 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 | expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): | 16768 | expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): |
| 16298 | dependencies: | 16769 | dependencies: |
| 16299 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16770 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | expo-network@56.0.5(expo@56.0.12)(react@19.2.3): | 16777 | expo-network@56.0.5(expo@56.0.12)(react@19.2.3): |
| 16302 | dependencies: | 16778 | dependencies: |
| 16303 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16779 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16374,6 +16850,57 @@ snapshots: | ... | @@ -16374,6 +16850,57 @@ snapshots: |
| 16374 | - supports-color | 16850 | - supports-color |
| 16375 | optional: true | 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 | expo-secure-store@56.0.4(expo@56.0.12): | 16904 | expo-secure-store@56.0.4(expo@56.0.12): |
| 16378 | dependencies: | 16905 | dependencies: |
| 16379 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16906 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16390,6 +16917,21 @@ snapshots: | ... | @@ -16390,6 +16917,21 @@ snapshots: |
| 16390 | - supports-color | 16917 | - supports-color |
| 16391 | - typescript | 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 | expo-structured-headers@56.0.0: {} | 16935 | expo-structured-headers@56.0.0: {} |
| 16394 | 16936 | ||
| 16395 | expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 16937 | expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| ... | @@ -16401,6 +16943,16 @@ snapshots: | ... | @@ -16401,6 +16943,16 @@ snapshots: |
| 16401 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 16943 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 16402 | sf-symbols-typescript: 2.2.0 | 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 | expo-updates-interface@56.0.2(expo@56.0.12): | 16956 | expo-updates-interface@56.0.2(expo@56.0.12): |
| 16405 | dependencies: | 16957 | dependencies: |
| 16406 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) | 16958 | expo: 56.0.12(8895228379997a2a064f9644cda56ed0) |
| ... | @@ -16492,6 +17044,49 @@ snapshots: | ... | @@ -16492,6 +17044,49 @@ snapshots: |
| 16492 | - typescript | 17044 | - typescript |
| 16493 | - utf-8-validate | 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 | exponential-backoff@3.1.3: {} | 17090 | exponential-backoff@3.1.3: {} |
| 16496 | 17091 | ||
| 16497 | express-rate-limit@8.5.2(express@5.2.1): | 17092 | express-rate-limit@8.5.2(express@5.2.1): |
| ... | @@ -18878,6 +19473,11 @@ snapshots: | ... | @@ -18878,6 +19473,11 @@ snapshots: |
| 18878 | dependencies: | 19473 | dependencies: |
| 18879 | react: 19.2.3 | 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 | react-grab@0.1.44(react@19.2.6): | 19481 | react-grab@0.1.44(react@19.2.6): |
| 18882 | dependencies: | 19482 | dependencies: |
| 18883 | '@react-grab/cli': 0.1.44 | 19483 | '@react-grab/cli': 0.1.44 |
| ... | @@ -18921,6 +19521,16 @@ snapshots: | ... | @@ -18921,6 +19521,16 @@ snapshots: |
| 18921 | use-latest-callback: 0.2.6(react@19.2.3) | 19521 | use-latest-callback: 0.2.6(react@19.2.3) |
| 18922 | optional: true | 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 | react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19534 | react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18925 | dependencies: | 19535 | dependencies: |
| 18926 | '@egjs/hammerjs': 2.0.17 | 19536 | '@egjs/hammerjs': 2.0.17 |
| ... | @@ -18930,6 +19540,16 @@ snapshots: | ... | @@ -18930,6 +19540,16 @@ snapshots: |
| 18930 | react: 19.2.3 | 19540 | react: 19.2.3 |
| 18931 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19541 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19553 | react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18934 | dependencies: | 19554 | dependencies: |
| 18935 | react: 19.2.3 | 19555 | react: 19.2.3 |
| ... | @@ -18940,6 +19560,12 @@ snapshots: | ... | @@ -18940,6 +19560,12 @@ snapshots: |
| 18940 | react: 19.2.3 | 19560 | react: 19.2.3 |
| 18941 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19561 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19569 | react-native-keyboard-controller@1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18944 | dependencies: | 19570 | dependencies: |
| 18945 | react: 19.2.3 | 19571 | react: 19.2.3 |
| ... | @@ -18968,11 +19594,26 @@ snapshots: | ... | @@ -18968,11 +19594,26 @@ snapshots: |
| 18968 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) | 19594 | react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) |
| 18969 | semver: 7.8.5 | 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 | react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19606 | react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18972 | dependencies: | 19607 | dependencies: |
| 18973 | react: 19.2.3 | 19608 | react: 19.2.3 |
| 18974 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19609 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19617 | react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18977 | dependencies: | 19618 | dependencies: |
| 18978 | react: 19.2.3 | 19619 | react: 19.2.3 |
| ... | @@ -18980,6 +19621,14 @@ snapshots: | ... | @@ -18980,6 +19621,14 @@ snapshots: |
| 18980 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19621 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 18981 | warn-once: 0.1.1 | 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 | react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19632 | react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 18984 | dependencies: | 19633 | dependencies: |
| 18985 | '@shikijs/types': 4.3.0 | 19634 | '@shikijs/types': 4.3.0 |
| ... | @@ -19007,6 +19656,14 @@ snapshots: | ... | @@ -19007,6 +19656,14 @@ snapshots: |
| 19007 | react: 19.2.3 | 19656 | react: 19.2.3 |
| 19008 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) | 19657 | react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) |
| 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 | react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): | 19667 | react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): |
| 19011 | dependencies: | 19668 | dependencies: |
| 19012 | '@babel/core': 7.29.7 | 19669 | '@babel/core': 7.29.7 |
| ... | @@ -19027,6 +19684,27 @@ snapshots: | ... | @@ -19027,6 +19684,27 @@ snapshots: |
| 19027 | transitivePeerDependencies: | 19684 | transitivePeerDependencies: |
| 19028 | - supports-color | 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 | react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): | 19708 | react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): |
| 19031 | dependencies: | 19709 | dependencies: |
| 19032 | '@react-native/assets-registry': 0.85.3 | 19710 | '@react-native/assets-registry': 0.85.3 |
| ... | @@ -19072,6 +19750,52 @@ snapshots: | ... | @@ -19072,6 +19750,52 @@ snapshots: |
| 19072 | - supports-color | 19750 | - supports-color |
| 19073 | - utf-8-validate | 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 | react-reconciler@0.33.0(react@19.2.6): | 19799 | react-reconciler@0.33.0(react@19.2.6): |
| 19076 | dependencies: | 19800 | dependencies: |
| 19077 | react: 19.2.6 | 19801 | react: 19.2.6 |
| ... | @@ -19087,6 +19811,15 @@ snapshots: | ... | @@ -19087,6 +19811,15 @@ snapshots: |
| 19087 | optionalDependencies: | 19811 | optionalDependencies: |
| 19088 | '@types/react': 19.2.16 | 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 | react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3): | 19823 | react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.3): |
| 19091 | dependencies: | 19824 | dependencies: |
| 19092 | react: 19.2.3 | 19825 | react: 19.2.3 |
| ... | @@ -19098,6 +19831,18 @@ snapshots: | ... | @@ -19098,6 +19831,18 @@ snapshots: |
| 19098 | optionalDependencies: | 19831 | optionalDependencies: |
| 19099 | '@types/react': 19.2.16 | 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 | react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3): | 19846 | react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.3): |
| 19102 | dependencies: | 19847 | dependencies: |
| 19103 | get-nonce: 1.0.1 | 19848 | get-nonce: 1.0.1 |
| ... | @@ -19106,6 +19851,15 @@ snapshots: | ... | @@ -19106,6 +19851,15 @@ snapshots: |
| 19106 | optionalDependencies: | 19851 | optionalDependencies: |
| 19107 | '@types/react': 19.2.16 | 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 | react@19.2.3: {} | 19863 | react@19.2.3: {} |
| 19110 | 19864 | ||
| 19111 | react@19.2.6: {} | 19865 | react@19.2.6: {} |
| ... | @@ -20149,10 +20903,23 @@ snapshots: | ... | @@ -20149,10 +20903,23 @@ snapshots: |
| 20149 | optionalDependencies: | 20903 | optionalDependencies: |
| 20150 | '@types/react': 19.2.16 | 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 | use-latest-callback@0.2.6(react@19.2.3): | 20914 | use-latest-callback@0.2.6(react@19.2.3): |
| 20153 | dependencies: | 20915 | dependencies: |
| 20154 | react: 19.2.3 | 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 | use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3): | 20923 | use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.3): |
| 20157 | dependencies: | 20924 | dependencies: |
| 20158 | detect-node-es: 1.1.0 | 20925 | detect-node-es: 1.1.0 |
| ... | @@ -20161,6 +20928,15 @@ snapshots: | ... | @@ -20161,6 +20928,15 @@ snapshots: |
| 20161 | optionalDependencies: | 20928 | optionalDependencies: |
| 20162 | '@types/react': 19.2.16 | 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 | use-sync-external-store@1.6.0(react@19.2.3): | 20940 | use-sync-external-store@1.6.0(react@19.2.3): |
| 20165 | dependencies: | 20941 | dependencies: |
| 20166 | react: 19.2.3 | 20942 | react: 19.2.3 |
| ... | @@ -20197,6 +20973,16 @@ snapshots: | ... | @@ -20197,6 +20973,16 @@ snapshots: |
| 20197 | - '@types/react' | 20973 | - '@types/react' |
| 20198 | - '@types/react-dom' | 20974 | - '@types/react-dom' |
| 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 | vfile-location@5.0.3: | 20986 | vfile-location@5.0.3: |
| 20201 | dependencies: | 20987 | dependencies: |
| 20202 | '@types/unist': 3.0.3 | 20988 | '@types/unist': 3.0.3 |