From 3e5ce00c0a312f6df5025c694f0af5182931fe3b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:27:09 -0800 Subject: [PATCH 1/2] build: run Effect rules on mobile and lock in clean suggestion rules apps/mobile extended Expo's tsconfig and never loaded the Effect language service. Its config now extends tsconfig.effect.json, which holds the rules for every workspace, and its 94 findings are fixed. Suggestion rules with no remaining hits become errors so new violations fail the typecheck. Fixes the two errors whose `name` payload shadowed the error's own name. Co-Authored-By: Claude Opus 5.5 --- .../preview/BrowserImport/BrowserImport.ts | 7 +- .../src/components/PdfPreview.android.tsx | 5 +- .../environment-cache-store.test.ts | 2 +- .../src/connection/environment-cache-store.ts | 6 +- apps/mobile/src/connection/migration.ts | 17 ++- apps/mobile/src/connection/platform.ts | 30 ++--- apps/mobile/src/connection/storage.ts | 39 +++--- .../remoteRegistration.test.ts | 13 +- .../agent-awareness/remoteRegistration.ts | 44 +++--- apps/mobile/src/features/cloud/dpop.ts | 4 +- .../SettingsDiagnosticsRouteScreen.tsx | 7 +- .../src/features/files/useFileTreeEntries.ts | 6 +- .../pull-requests/usePullRequestList.test.tsx | 12 +- .../pull-requests/usePullRequestList.ts | 22 +-- .../settings/environmentUpdateCheck.ts | 21 ++- .../sharing/incoming-share-storage.ts | 4 +- apps/mobile/src/lib/attachmentDocument.ts | 17 ++- apps/mobile/src/lib/composerImagePreview.ts | 7 +- .../src/lib/hot-swappable-atom-runtime.ts | 1 + apps/mobile/src/lib/localAttachmentPreview.ts | 21 ++- .../mobile/src/persistence/mobile-database.ts | 29 ++-- .../src/persistence/mobile-preferences.ts | 55 ++++---- .../src/persistence/mobile-secure-storage.ts | 2 +- apps/mobile/src/persistence/mobile-storage.ts | 19 +-- apps/mobile/src/state/assets.ts | 48 +++++-- .../src/state/composer-attachment-uploads.ts | 5 +- apps/mobile/src/state/use-composer-drafts.ts | 4 +- apps/mobile/tsconfig.json | 28 +++- apps/server/src/assets/AttachmentUpload.ts | 2 +- .../server/src/auth/ServerSecretStore.test.ts | 19 +-- apps/server/src/cloud/bootService.test.ts | 4 +- apps/server/src/provider/AntigravityAuth.ts | 4 +- .../SourceControlRepositoryService.ts | 2 +- .../AntigravityTextGeneration.test.ts | 2 +- infra/relay/src/clientConfig.ts | 6 +- .../src/connection/registry.test.ts | 5 +- packages/effect-acp/src/agent.test.ts | 7 +- packages/effect-acp/src/client.test.ts | 7 +- .../src/server/providerAuthFlow.ts | 2 +- .../src/server/adapter.test.ts | 6 +- packages/provider-cursor/src/server/auth.ts | 2 +- tsconfig.base.json | 91 +------------ tsconfig.effect.json | 126 ++++++++++++++++++ 43 files changed, 468 insertions(+), 292 deletions(-) create mode 100644 tsconfig.effect.json diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 0bf95cd641c5b..0a762d92416aa 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -96,12 +96,12 @@ export class BrowserCookieWriteError extends Schema.TaggedError new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), + catch: (cause) => + new BrowserCookieWriteError({ url: cookie.url, cookieName: cookie.name, cause }), }).pipe( Effect.as(true), Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), diff --git a/apps/mobile/src/components/PdfPreview.android.tsx b/apps/mobile/src/components/PdfPreview.android.tsx index 32e79d576e11e..6be0f427e61e9 100644 --- a/apps/mobile/src/components/PdfPreview.android.tsx +++ b/apps/mobile/src/components/PdfPreview.android.tsx @@ -31,7 +31,10 @@ const preparedPdfAtom = Atom.family((uri: string) => }, catch: (cause) => (cause instanceof Error ? cause.message : "The PDF could not be loaded."), }).pipe( - Effect.flatMap((file) => (file === null ? Effect.interrupt : Effect.succeed(file))), + Effect.filterOrElse( + (file) => file !== null, + () => Effect.interrupt, + ), Effect.interruptible, ), (file) => Effect.sync(file.dispose), diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index d8031de9e7a39..2e80259bb8c76 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -252,7 +252,7 @@ function makeDatabase() { }), clearAllCaches: Effect.sync(() => values.clear()), inspectCaches: Effect.succeed([]), - loadPreferencesJson: Effect.succeed(Option.none()), + loadPreferencesJson: Effect.succeedNone, savePreferencesJson: () => Effect.void, }); return { database, removed, schemaVersions, values }; diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index b55af4718d15f..6fada3fbeda3d 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -54,13 +54,13 @@ function mapDatabaseError(operation: CacheOperation) { return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); } -function loadDecodedCache(input: { +function loadDecodedCache(input: { readonly database: MobileDatabase.MobileDatabase["Service"]; readonly environmentId: EnvironmentId; readonly kind: MobileDatabase.ClientCacheKind; readonly cacheKey: string; readonly operation: CacheOperation; - readonly decode: (raw: string) => Effect.Effect; + readonly decode: (raw: string) => Effect.Effect; readonly select: (value: A) => Option.Option; }): Effect.Effect, Persistence.ConnectionPersistenceError> { return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe( @@ -81,7 +81,7 @@ function loadDecodedCache(input: { Effect.andThen( input.database .removeCache(input.environmentId, input.kind, input.cacheKey) - .pipe(Effect.catch(() => Effect.void)), + .pipe(Effect.ignore), ), Effect.as(Option.none()), ), diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts index 3df01fe7ca000..0ae950aa22a99 100644 --- a/apps/mobile/src/connection/migration.ts +++ b/apps/mobile/src/connection/migration.ts @@ -87,16 +87,19 @@ function migrateConnection( ); } +const decodeLegacyCatalogJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + export const migrateLegacyConnectionCatalog = Effect.fn( "mobile.connectionMigration.migrateCatalog", )(function* (raw: string) { - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - new LegacyConnectionMigrationError({ - message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, - }), - }); + const parsed = yield* decodeLegacyCatalogJson(raw).pipe( + Effect.mapError( + (cause) => + new LegacyConnectionMigrationError({ + message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, + }), + ), + ); const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( Effect.mapError( (cause) => diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index b99b41cbffb7b..407016ab484d9 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -10,6 +10,7 @@ import { Wakeups, } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -120,8 +121,9 @@ const layerWakeups = Wakeups.layer({ [ Stream.callback<"application-active-probe" | "application-active-long">((queue) => Effect.acquireRelease( - Effect.sync(() => { - let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null; + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + let backgroundedAtMs = AppState.currentState === "background" ? nowMs : null; return AppState.addEventListener("change", (state) => { if (state === "background") { backgroundedAtMs = Date.now(); @@ -187,7 +189,7 @@ const layerCapabilities = Layer.effectContext( Context.add( ClientCapabilities.PrimaryEnvironmentAuth, ClientCapabilities.PrimaryEnvironmentAuth.of({ - bearerToken: Effect.succeed(Option.none()), + bearerToken: Effect.succeedNone, }), ), // The relay device id already persists in secure storage; analytics only sends its hash. @@ -205,7 +207,7 @@ const layerCapabilities = Layer.effectContext( detail: `Could not load the mobile device identity: ${String(cause)}`, }), ), - Effect.map(Option.some), + Effect.asSome, ), }), ), @@ -218,21 +220,25 @@ const layerCapabilities = Layer.effectContext( Context.add( ClientCapabilities.SshEnvironmentGateway, ClientCapabilities.SshEnvironmentGateway.of({ - provision: () => + provision: Effect.fn("ClientCapabilities.SshEnvironmentGateway.provision")(() => Effect.fail( new ConnectionBlockedError({ reason: "unsupported", detail: "SSH environments are only available in the desktop app.", }), ), - prepare: () => + ), + prepare: Effect.fn("ClientCapabilities.SshEnvironmentGateway.prepare")(() => Effect.fail( new ConnectionBlockedError({ reason: "unsupported", detail: "SSH environments are only available in the desktop app.", }), ), - disconnect: () => Effect.void, + ), + disconnect: Effect.fn("ClientCapabilities.SshEnvironmentGateway.disconnect")( + () => Effect.void, + ), }), ), ); @@ -252,7 +258,7 @@ const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.l const layerEnvironmentOwnedDataCleanup = Layer.succeed( Persistence.EnvironmentOwnedDataCleanup, Persistence.EnvironmentOwnedDataCleanup.of({ - clear: (environmentId) => + clear: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")((environmentId) => Effect.all( [ Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), @@ -260,14 +266,8 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), ], { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not clear mobile environment-owned data.", { - environmentId, - cause, - }), - ), ), + ), }), ); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 83805e11942b1..91a6ac71919da 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -59,24 +59,33 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ - register: (registration, routes) => - catalog - .update((document) => registerConnectionInCatalog(document, registration, routes)) - .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), - setRoutes: (environmentId, routes) => - catalog - .update((document) => setRoutesInCatalog(document, environmentId, routes)) - .pipe(Effect.mapError((error) => targetPersistenceError("set-connection-routes", error))), - remove: (environmentId) => + register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( + (registration, routes) => + catalog + .update((document) => registerConnectionInCatalog(document, registration, routes)) + .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + ), + setRoutes: Effect.fn("Persistence.ConnectionRegistrationStore.setRoutes")( + (environmentId, routes) => + catalog + .update((document) => setRoutesInCatalog(document, environmentId, routes)) + .pipe( + Effect.mapError((error) => targetPersistenceError("set-connection-routes", error)), + ), + ), + remove: Effect.fn("Persistence.ConnectionRegistrationStore.remove")((environmentId) => catalog .update((document) => removeConnectionFromCatalog(document, environmentId)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), - setEnabled: (environmentId, enabled) => - catalog - .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) - .pipe( - Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)), - ), + ), + setEnabled: Effect.fn("Persistence.ConnectionRegistrationStore.setEnabled")( + (environmentId, enabled) => + catalog + .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) + .pipe( + Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)), + ), + ), }); const profileStore = ProfileStore.make({ get: (connectionId) => diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff2..e9f5a3b1e5394 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -31,6 +31,7 @@ import { releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, updateAgentAwarenessRegistrationPreferences, + type AgentAwarenessRegistrationError, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; @@ -216,7 +217,11 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO } idlePasses = 0; const exit = yield* Effect.exit( - pending.operation as Effect.Effect, + pending.operation as Effect.Effect< + unknown, + AgentAwarenessRegistrationError, + ManagedRelay.ManagedRelayClient + >, ); yield* Effect.sync(() => { pending.resolve(exit); @@ -394,7 +399,11 @@ describe("makeRelayDeviceRegistrationRequest", () => { // activity lock, or, without one, at its device-id lookup. const rotatedRegistration = yield* Effect.forkChild( Effect.exit( - rotated.operation as Effect.Effect, + rotated.operation as Effect.Effect< + unknown, + AgentAwarenessRegistrationError, + ManagedRelay.ManagedRelayClient + >, ), { startImmediately: true }, ); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a04..6151c8935cc2b 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -5,6 +5,7 @@ import { } from "./androidNotifications"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -75,6 +76,10 @@ export class AgentAwarenessOperationError extends Schema.TaggedError(); // Each accepted registration replays the relay aggregate, repairing drifted or orphaned activities. @@ -321,7 +326,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWithRelay")(function* ( body: RelayDeviceRegistrationRequest, expectedGeneration: number, -): Effect.fn.Return { +): Effect.fn.Return { if (expectedGeneration !== deviceRegistrationGeneration) { return; } @@ -340,10 +345,9 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith } const identity = relayTokenProviderIdentity ?? ""; - const persisted = yield* Effect.tryPromise({ - try: () => loadAgentAwarenessRegistrationRecord(), - catch: (cause) => cause, - }).pipe(Effect.orElseSucceed(() => null)); + const persisted = yield* Effect.tryPromise(() => loadAgentAwarenessRegistrationRecord()).pipe( + Effect.orElseSucceed(() => null), + ); if (expectedGeneration !== deviceRegistrationGeneration) { // Signed out while the record loaded — do not resurrect the cleared // record or report the previous account's registration as current. @@ -354,10 +358,11 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith // Android registration also silently replays the current card. Collapse // foreground bursts, but repair missed pushes on cold start or a return // after time away, just like re-registering an iOS activity token. + const now = yield* Clock.currentTimeMillis; const needsAndroidReplay = body.platform === "android" && (androidDeviceReplayedAt === null || - Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); + now - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); if ( persisted && persisted.identity === identity && @@ -379,7 +384,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith // stale success must not overwrite either. return; } - if (body.platform === "android") androidDeviceReplayedAt = Date.now(); + if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis; setRegistrationStatus("registered"); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ @@ -395,7 +400,7 @@ const unregisterDeviceWithRelay = Effect.fn("remoteRegistration.unregisterDevice function* (input: { readonly deviceId: string; readonly tokenProvider: () => Promise; - }): Effect.fn.Return { + }): Effect.fn.Return { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, @@ -513,7 +518,7 @@ const readAgentActivitySnapshot: () => Effect.Effect< const registerLiveActivityWithRelay = Effect.fn("remoteRegistration.registerLiveActivityWithRelay")( function* ( body: RelayLiveActivityRegistrationRequest, - ): Effect.fn.Return { + ): Effect.fn.Return { if (!readRelayConfig()) return false; const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { @@ -540,8 +545,8 @@ function logRegistrationError(context: string, error: unknown): void { }); } -function runRegistrationInBackground( - operation: Effect.Effect, +function runRegistrationInBackground( + operation: Effect.Effect, context: string, ): void { void (async () => { @@ -623,7 +628,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ( input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, -): Effect.fn.Return { +): Effect.fn.Return { if (!canRegisterPushNotifications()) { return; } @@ -687,7 +692,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* function registerDeviceForCurrentUser(): Effect.Effect< void, - unknown, + AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient > { return registerDevice(undefined); @@ -773,7 +778,7 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< export function updateAgentAwarenessRegistrationPreferences( preferencesOverride: Partial, -): Effect.Effect { +): Effect.Effect { return registerDevice({ preferencesOverride }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -837,7 +842,7 @@ export const registerLiveActivityPushToken = Effect.fn( "remoteRegistration.registerLiveActivityPushToken", )(function* (input: { readonly activity: LiveActivity; -}): Effect.fn.Return { +}): Effect.fn.Return { if (!canRegisterRemoteLiveActivities()) { return false; } @@ -885,9 +890,12 @@ const registerLiveActivityPushTokenValue = Effect.fn( "remoteRegistration.registerLiveActivityPushTokenValue", )(function* (input: { readonly activityPushToken: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); - if (acceptedAt !== undefined && Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS) { + if ( + acceptedAt !== undefined && + (yield* Clock.currentTimeMillis) - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS + ) { return true; } const deviceId = yield* Effect.tryPromise({ @@ -903,7 +911,7 @@ const registerLiveActivityPushTokenValue = Effect.fn( activityPushToken: input.activityPushToken, }); if (registered) { - registeredActivityPushTokens.set(input.activityPushToken, Date.now()); + registeredActivityPushTokens.set(input.activityPushToken, yield* Clock.currentTimeMillis); } return registered; }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index c26050dc5d4e4..b3eaf006f3d34 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -60,7 +60,9 @@ export const layer = Layer.mergeAll( Digest.Digest, Digest.Digest.of({ digest, - digestHex: (algorithm, data) => Effect.map(digest(algorithm, data), Hex.encode), + digestHex: Effect.fn("Digest.Digest.digestHex")((algorithm, data) => + Effect.map(digest(algorithm, data), Hex.encode), + ), }), ), ); diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index 01532c826f847..b3efc031af645 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -42,10 +42,9 @@ const crashLogAtom = Atom.make( records: parseStartupCrashRecords(entries), })), Effect.catch((error) => - Effect.sync((): CrashLogState => { - console.warn("[diagnostics] could not read the expo-updates log", error); - return { status: "unavailable" }; - }), + Effect.logWarning("[diagnostics] could not read the expo-updates log", error).pipe( + Effect.as({ status: "unavailable" }), + ), ), ), ).pipe(Atom.withLabel("mobile:diagnostics-crash-log")); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 62c2e33746a9f..53354bc640db7 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -2,6 +2,7 @@ import { useAtomMount, useAtomSubscribe } from "@effect/atom-react"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import { ProjectListEntriesError, type EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { useLayoutEffect, useRef, useState } from "react"; import { Atom } from "effect/reactivity"; @@ -11,6 +12,8 @@ import { projectEnvironment } from "../../state/projects"; import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +const isProjectListEntriesError = Schema.is(ProjectListEntriesError); + function createDirectories() { return { target: {}, @@ -273,8 +276,7 @@ export function useFileTreeEntries(input: { }); }; const missingRoot = - root.failure instanceof ProjectListEntriesError && - root.failure.failure === "workspace_root_not_found"; + isProjectListEntriesError(root.failure) && root.failure.failure === "workspace_root_not_found"; const rootData = missingRoot ? null : root.data; const rootListing = root.successResult ?? directories.rootListing; if ( diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx index 8b78390bab20d..77c97d559553d 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx @@ -23,6 +23,11 @@ vi.mock("../../connection/catalog", async () => { vi.mock("../../state/pull-requests", async () => { const { AsyncResult, Atom } = await import("effect/reactivity"); const Effect = await import("effect/Effect"); + const Schema = await import("effect/Schema"); + class PullRequestListTestError extends Schema.TaggedError()( + "PullRequestListTestError", + { message: Schema.String }, + ) {} const page = (nextCursors: PullRequestListResult["nextCursors"]): PullRequestListResult => ({ entries: [], nextCursors, @@ -37,8 +42,11 @@ vi.mock("../../state/pull-requests", async () => { return Atom.make( Effect.suspend(() => { if (target.environmentId === "offline") - return Effect.fail(new Error("Environment is not connected.")); - if (target.input.cursors && mocks.failPage) return Effect.fail(new Error("Page failed.")); + return Effect.fail( + new PullRequestListTestError({ message: "Environment is not connected." }), + ); + if (target.input.cursors && mocks.failPage) + return Effect.fail(new PullRequestListTestError({ message: "Page failed." })); return Effect.succeed(response); }), ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 8b94f5d1993c7..d4fe1ab3461bd 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -131,16 +131,18 @@ async function refreshHeads( // One load of each kind per list scope. The atoms are not kept alive, so leaving a scope (a // filter change, unmount) interrupts its load and aborts the requests. -const pageLoadFamily = Atom.family((scopeKey: string) => - Atom.fn((retry: boolean, get) => - Effect.promise((signal) => loadNextPages(get.registry, JSON.parse(scopeKey), retry, signal)), - ), -); -const refreshLoadFamily = Atom.family((scopeKey: string) => - Atom.fn((_: void, get) => - Effect.promise((signal) => refreshHeads(get.registry, JSON.parse(scopeKey), signal)), - ), -); +const pageLoadFamily = Atom.family((scopeKey: string) => { + const targets: readonly ListTarget[] = JSON.parse(scopeKey); + return Atom.fn((retry: boolean, get) => + Effect.promise((signal) => loadNextPages(get.registry, targets, retry, signal)), + ); +}); +const refreshLoadFamily = Atom.family((scopeKey: string) => { + const targets: readonly ListTarget[] = JSON.parse(scopeKey); + return Atom.fn((_: void, get) => + Effect.promise((signal) => refreshHeads(get.registry, targets, signal)), + ); +}); // Changes when any of the scope's environments brings up a new session. const sessionsFamily = Atom.family((scopeKey: string) => diff --git a/apps/mobile/src/features/settings/environmentUpdateCheck.ts b/apps/mobile/src/features/settings/environmentUpdateCheck.ts index 1bbe0c63cae5d..fc6b1ffd74e41 100644 --- a/apps/mobile/src/features/settings/environmentUpdateCheck.ts +++ b/apps/mobile/src/features/settings/environmentUpdateCheck.ts @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import { findEnvironmentUpdate } from "./environment-maintenance"; @@ -10,8 +11,19 @@ export interface EnvironmentReleaseCheck { readonly targetVersion: string | null; } +export class EnvironmentUpdateCheckError extends Schema.TaggedError()( + "EnvironmentUpdateCheckError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + const asError = (cause: unknown) => - cause instanceof Error ? cause : new Error("Could not check for updates."); + new EnvironmentUpdateCheckError({ + message: cause instanceof Error ? cause.message : "Could not check for updates.", + cause, + }); /** * Looks up the newest T3 Code release for one environment. The atom is not @@ -29,7 +41,12 @@ export const environmentReleaseCheckAtom = Atom.family((environmentId: Environme }).pipe( Effect.timeoutOrElse({ duration: "20 seconds", - orElse: () => Effect.fail(new Error("Checking for updates timed out. Try again.")), + orElse: () => + Effect.fail( + new EnvironmentUpdateCheckError({ + message: "Checking for updates timed out. Try again.", + }), + ), }), ); return { fromVersion, targetVersion } satisfies EnvironmentReleaseCheck; diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts index 06d64e4bc349b..87a997069ab4b 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -18,6 +18,8 @@ export class IncomingShareStorageError extends Schema.TaggedError right.createdAt.localeCompare(left.createdAt)); } catch (cause) { - if (cause instanceof IncomingShareStorageError) { + if (isIncomingShareStorageError(cause)) { throw cause; } throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts index 76fbd335ce04f..0f6ffc98b3abe 100644 --- a/apps/mobile/src/lib/attachmentDocument.ts +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -10,6 +10,7 @@ import { fetch } from "expo/fetch"; import { File } from "expo-file-system"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { useEffect, useRef, useState } from "react"; import type { FileBackedComposerAttachment } from "./composerImages"; @@ -57,19 +58,31 @@ async function readAttachmentText(key: AttachmentTextKey, signal: AbortSignal) { return readFilePreviewResponse(response, signal); } +class AttachmentTextReadError extends Schema.TaggedError()( + "AttachmentTextReadError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + // Plain-object keys compare structurally (Effect `Equal`/`Hash`). Unmounting the // last reader interrupts the read, which aborts the fetch. const attachmentTextAtom = Atom.family((key: AttachmentTextKey) => Atom.make( Effect.tryPromise({ try: (signal) => readAttachmentText(key, signal), - catch: (cause) => (cause instanceof Error ? cause : new Error("Could not read this file.")), + catch: (cause) => + new AttachmentTextReadError({ + message: cause instanceof Error ? cause.message : "Could not read this file.", + cause, + }), }), ).pipe(Atom.withLabel("attachment-text")), ); const noAttachmentText = Atom.make( - AsyncResult.initial<{ text: string; truncated: boolean }, Error>(), + AsyncResult.initial<{ text: string; truncated: boolean }, AttachmentTextReadError>(), ).pipe(Atom.withLabel("attachment-text:none")); /** Captured bytes never resolve against the workspace. */ diff --git a/apps/mobile/src/lib/composerImagePreview.ts b/apps/mobile/src/lib/composerImagePreview.ts index 6e96acb7599cf..bcd649b3d106d 100644 --- a/apps/mobile/src/lib/composerImagePreview.ts +++ b/apps/mobile/src/lib/composerImagePreview.ts @@ -50,10 +50,9 @@ export const materializedPreviewAtom = Atom.family(({ attachment }: Materialized Atom.make( Effect.tryPromise(() => materializeDataUrlPreview(attachment.id, attachment.previewUri)).pipe( Effect.catch((error) => - Effect.sync(() => { - console.warn("[composer-attachments] could not cache an image preview", error); - return null; - }), + Effect.logWarning("[composer-attachments] could not cache an image preview", error).pipe( + Effect.as(null), + ), ), ), ).pipe(Atom.withLabel(`composer-image-preview:${attachment.id}`)), diff --git a/apps/mobile/src/lib/hot-swappable-atom-runtime.ts b/apps/mobile/src/lib/hot-swappable-atom-runtime.ts index e3462be9123df..e0ec8bbeda69c 100644 --- a/apps/mobile/src/lib/hot-swappable-atom-runtime.ts +++ b/apps/mobile/src/lib/hot-swappable-atom-runtime.ts @@ -48,6 +48,7 @@ export function hotSwappableAtomRuntime(options: { entry = existing; options.registry.set( entry.layerAtom, + // @effect-diagnostics-next-line anyUnknownInErrorContext:off - the HMR registry erases each runtime's R and E options.layer as Layer.Layer< unknown, unknown, diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 1957001371bc2..8c764297c3075 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Hash from "effect/Hash"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { Atom, AsyncResult } from "effect/reactivity"; import type { FileBackedComposerAttachment } from "./composerImages"; @@ -84,6 +85,14 @@ class LocalAttachmentPreviewKey implements Equal.Equal { } } +class LocalAttachmentPreviewLoadError extends Schema.TaggedError()( + "LocalAttachmentPreviewLoadError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + const localAttachmentPreviewFamily = Atom.family((key: LocalAttachmentPreviewKey) => Atom.make( // The lease lives as long as the atom: unmounting the last reader (or a @@ -92,8 +101,16 @@ const localAttachmentPreviewFamily = Atom.family((key: LocalAttachmentPreviewKey Effect.tryPromise({ try: (signal) => loadLocalAttachmentPreview(key.attachment, signal), catch: (cause) => - cause instanceof Error ? cause : new Error("The local file is unavailable."), - }).pipe(Effect.flatMap((file) => (file === null ? Effect.interrupt : Effect.succeed(file)))), + new LocalAttachmentPreviewLoadError({ + message: cause instanceof Error ? cause.message : "The local file is unavailable.", + cause, + }), + }).pipe( + Effect.filterOrElse( + (file) => file !== null, + () => Effect.interrupt, + ), + ), (file) => Effect.sync(file.dispose), ), ).pipe(Atom.withLabel(`local-attachment-preview:${key.attachment.id}`)), diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 0a6a100912def..cbe8d3b74ca79 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -115,7 +116,7 @@ export class MobileDatabase extends Context.Service< updatedAt: number, ) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobileDatabase") {} +>()("@t3tools/mobile/persistence/mobile-database/MobileDatabase") {} const makeAvailable = Effect.gen(function* () { const database = yield* Effect.acquireRelease( @@ -165,8 +166,9 @@ const makeAvailable = Effect.gen(function* () { }); return MobileDatabase.of({ - loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) => - Effect.tryPromise({ + loadCache: Effect.fn("MobileDatabase.loadCache")(function* (environmentId, kind, cacheKey) { + const now = yield* Clock.currentTimeMillis; + const row = yield* Effect.tryPromise({ try: () => database.getFirstAsync<{ readonly payload: string }>( `SELECT payload @@ -176,11 +178,12 @@ const makeAvailable = Effect.gen(function* () { environmentId, kind, cacheKey, - Date.now() - 30 * 24 * 60 * 60_000, + now - 30 * 24 * 60 * 60_000, ), catch: databaseError("load-cache"), - }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), - ), + }); + return Option.fromNullishOr(row?.payload); + }), listCache: Effect.fn("MobileDatabase.listCache")((kind) => Effect.tryPromise({ try: () => @@ -331,16 +334,16 @@ const makeAvailable = Effect.gen(function* () { function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] { const fail = Effect.fail(error); return MobileDatabase.of({ - loadCache: () => fail, - listCache: () => fail, - saveCache: () => fail, - removeCache: () => fail, - clearCacheKind: () => fail, - clearEnvironmentCache: () => fail, + loadCache: Effect.fn("MobileDatabase.loadCache")(() => fail), + listCache: Effect.fn("MobileDatabase.listCache")(() => fail), + saveCache: Effect.fn("MobileDatabase.saveCache")(() => fail), + removeCache: Effect.fn("MobileDatabase.removeCache")(() => fail), + clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")(() => fail), + clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")(() => fail), clearAllCaches: fail, inspectCaches: fail, loadPreferencesJson: fail, - savePreferencesJson: () => fail, + savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")(() => fail), }); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 43ac75e185411..3760ae10c3026 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -25,6 +25,7 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; +const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); export const PullRequestListControls = Schema.Struct({ state: PullRequestListState, @@ -148,6 +149,8 @@ export class MobilePreferencesSaveError extends Schema.TaggedError Partial, ) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobilePreferencesStore") {} +>()("@t3tools/mobile/persistence/mobile-preferences/MobilePreferencesStore") {} function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { @@ -417,10 +420,9 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { key: string, value: unknown, ) { - return yield* Effect.try({ - try: () => JSON.stringify(value), - catch: (cause) => new MobileStorageEncodeError({ key, cause }), - }); + return yield* encodeJson(value).pipe( + Effect.mapError((cause) => new MobileStorageEncodeError({ key, cause })), + ); }); const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => { @@ -533,34 +535,33 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { .withPermits(1)(loadUnlocked) .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause }))); - const update = Effect.fn("MobilePreferencesStore.update")((transform) => - lock - .withPermits(1)( - Effect.gen(function* () { - const current = yield* loadUnlocked; - const patch = yield* Effect.try({ - try: () => transform(current), - catch: (cause) => new MobilePreferencesSaveError({ cause }), - }); - const next: Preferences = { ...current, ...patch }; - const payload = yield* encode(PREFERENCES_KEY, next); - yield* saveJson(payload); - return next; - }), - ) - .pipe( - Effect.mapError((cause) => - cause instanceof MobilePreferencesSaveError - ? cause - : new MobilePreferencesSaveError({ cause }), + const update = Effect.fn("MobilePreferencesStore.update")( + (transform: (current: Preferences) => Partial) => + lock + .withPermits(1)( + Effect.gen(function* () { + const current = yield* loadUnlocked; + const patch = yield* Effect.try({ + try: () => transform(current), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }); + const next: Preferences = { ...current, ...patch }; + const payload = yield* encode(PREFERENCES_KEY, next); + yield* saveJson(payload); + return next; + }), + ) + .pipe( + Effect.mapError((cause) => + isMobilePreferencesSaveError(cause) ? cause : new MobilePreferencesSaveError({ cause }), + ), ), - ), ); return MobilePreferencesStore.of({ load, update, - savePatch: (patch) => update(() => patch), + savePatch: Effect.fn("MobilePreferencesStore.savePatch")((patch) => update(() => patch)), }); }); diff --git a/apps/mobile/src/persistence/mobile-secure-storage.ts b/apps/mobile/src/persistence/mobile-secure-storage.ts index 7269e7969e5d3..ea889f39740b9 100644 --- a/apps/mobile/src/persistence/mobile-secure-storage.ts +++ b/apps/mobile/src/persistence/mobile-secure-storage.ts @@ -26,7 +26,7 @@ export class MobileSecureStorage extends Context.Service< readonly setItem: (key: string, value: string) => Effect.Effect; readonly removeItem: (key: string) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobileSecureStorage") {} +>()("@t3tools/mobile/persistence/mobile-secure-storage/MobileSecureStorage") {} const make = MobileSecureStorage.of({ getItem: Effect.fn("MobileSecureStorage.getItem")((key) => diff --git a/apps/mobile/src/persistence/mobile-storage.ts b/apps/mobile/src/persistence/mobile-storage.ts index f15f1c820d02e..23fe82c52e480 100644 --- a/apps/mobile/src/persistence/mobile-storage.ts +++ b/apps/mobile/src/persistence/mobile-storage.ts @@ -11,6 +11,7 @@ import * as MobileSecureStorage from "./mobile-secure-storage"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts"; +const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); export class MobileStorageDecodeError extends Schema.TaggedError()( "MobileStorageDecodeError", @@ -93,7 +94,7 @@ export class MobileStorage extends Context.Service< MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError >; } ->()("@t3tools/mobile/persistence/MobileStorage") {} +>()("@t3tools/mobile/persistence/mobile-storage/MobileStorage") {} export const make = Effect.fn("MobileStorage.make")(function* () { const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; @@ -118,10 +119,9 @@ export const make = Effect.fn("MobileStorage.make")(function* () { }); const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) { - const encoded = yield* Effect.try({ - try: () => JSON.stringify(value), - catch: (cause) => new MobileStorageEncodeError({ key, cause }), - }); + const encoded = yield* encodeJson(value).pipe( + Effect.mapError((cause) => new MobileStorageEncodeError({ key, cause })), + ); yield* secureStorage.setItem(key, encoded); }); @@ -184,14 +184,17 @@ export const make = Effect.fn("MobileStorage.make")(function* () { loadOrCreateAgentAwarenessDeviceId, loadAgentAwarenessDeviceId, loadAgentAwarenessRegistrationRecord, - saveAgentAwarenessRegistrationRecord: (record) => - writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record), + saveAgentAwarenessRegistrationRecord: Effect.fn( + "MobileStorage.saveAgentAwarenessRegistrationRecord", + )((record) => writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record)), clearAgentAwarenessRegistrationRecord: secureStorage.setItem( AGENT_AWARENESS_REGISTRATION_KEY, "", ), loadRecentThreadShortcuts, - saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }), + saveRecentThreadShortcuts: Effect.fn("MobileStorage.saveRecentThreadShortcuts")((threads) => + writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }), + ), }); }); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index d29833dfd4226..62ae955e99259 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -10,7 +10,9 @@ import { } from "@t3tools/client-runtime/state/assets"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom, AsyncResult } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; @@ -143,27 +145,47 @@ interface FreshAssetUrlKey { readonly revision: number; } +class AssetUrlUnavailableError extends Schema.TaggedError()( + "AssetUrlUnavailableError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + // Plain-object keys compare structurally (Effect `Equal`/`Hash`). const freshAssetUrlFamily = Atom.family((key: FreshAssetUrlKey) => Atom.make( Effect.tryPromise({ - try: async (signal): Promise => { - const url = - key.environmentId === null - ? null - : await refreshAssetUrl(key.environmentId, key.resource, signal); - if (!url) throw new Error("Reconnect to this environment and try again."); - return { url, authorizedAt: Date.now() }; - }, + try: async (signal) => + key.environmentId === null + ? null + : await refreshAssetUrl(key.environmentId, key.resource, signal), catch: (cause) => - cause instanceof Error ? cause : new Error("The attachment is unavailable."), - }), + new AssetUrlUnavailableError({ + message: cause instanceof Error ? cause.message : "The attachment is unavailable.", + cause, + }), + }).pipe( + Effect.flatMap((url) => + url + ? Effect.map(Clock.currentTimeMillis, (authorizedAt): AuthorizedAssetUrl => ({ + url, + authorizedAt, + })) + : Effect.fail( + new AssetUrlUnavailableError({ + message: "Reconnect to this environment and try again.", + }), + ), + ), + ), ).pipe(Atom.withLabel("fresh-asset-url")), ); -const noFreshAssetUrl = Atom.make(AsyncResult.initial()).pipe( - Atom.withLabel("fresh-asset-url:none"), -); +const noFreshAssetUrl = Atom.make( + AsyncResult.initial(), +).pipe(Atom.withLabel("fresh-asset-url:none")); const noSessionGeneration = Atom.make(0); /** diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 2618786dae157..0749116fa6859 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -1,6 +1,7 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom, AsyncResult } from "effect/reactivity"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; @@ -31,6 +32,8 @@ import { import { environmentSession, readEnvironmentScope, sessionHasScope } from "./session"; import { workspaceConnections } from "./workspace"; +const isComposerDraftPersistenceError = Schema.is(ComposerDraftPersistenceError); + export { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, @@ -164,7 +167,7 @@ export function createComposerAttachmentUploadWorker( if (!retained) return "abandon"; // Draft-save failure only costs re-upload after restart; the outbox saves references independently, so preserve accepted uploads. await flushComposerDrafts().catch((error: unknown) => { - if (!(error instanceof ComposerDraftPersistenceError)) throw error; + if (!isComposerDraftPersistenceError(error)) throw error; console.warn("[composer-attachments] could not save the uploaded reference", error); }); return "persisted"; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 4e7d96de3b192..564becccf7dd8 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -358,6 +358,8 @@ export class ComposerDraftPersistenceError extends Schema.TaggedError true), + Effect.as(true), Effect.catch((error) => { if (error.reason._tag === "AlreadyExists") return Effect.succeed(false); return Effect.tryPromise({ diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 5bc04734676ce..08a33e2fde3c5 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,7 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -93,17 +92,13 @@ const layerConcurrentReadMissFileSystem = Layer.effect( yield* Deferred.succeed(readBarrier, void 0); } yield* Deferred.await(readBarrier); - return yield* Effect.failCause( - Cause.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: String(path), - description: "Secret file does not exist yet.", - }), - ), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: String(path), + description: "Secret file does not exist yet.", + }); }); }), ) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4979f8d4f3418..6809e7645506e 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -624,9 +624,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const { service, commands, control, makeService } = yield* makeHarness(); yield* service.install(); const failure = yield* service - .withStopped( - Effect.suspend(() => Effect.fail(control.active ? "running" : "repair failed")), - ) + .withStopped(Effect.failSync(() => (control.active ? "running" : "repair failed"))) .pipe(Effect.flip); expect(failure).toBe("repair failed"); expect(control.active).toBe(true); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index d1ebb7d53954f..90105dd4b7c12 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -212,7 +212,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ); // Propagate interruption after the exit wait so concurrent stop waiters stay attached. return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), + Effect.flatten, Effect.ensuring(Fiber.interrupt(fiber)), Effect.ensuring( Effect.sync(() => { @@ -544,7 +544,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }), ); const worker = yield* task.pipe(Effect.forkIn(instanceScope)); - return yield* Fiber.await(worker).pipe(Effect.flatMap((result) => result)); + return yield* Fiber.await(worker).pipe(Effect.flatten); }), subscribe: (ownerSessionId) => SubscriptionRef.changes(snapshot).pipe( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 64242b72d4d58..07c7b28d82ff2 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -488,7 +488,7 @@ export const make = Effect.gen(function* () { args: ["rev-parse", "--verify", "HEAD"], }) .pipe( - Effect.map(() => true), + Effect.as(true), Effect.orElseSucceed(() => false), ); if (!hasCommits) { diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 62205d2835985..5cb6a1f8d4d8b 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -115,7 +115,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* }), ); return yield* Fiber.await(child).pipe( - Effect.flatMap((exit) => exit), + Effect.flatten, Effect.ensuring(Fiber.interrupt(child)), ); }); diff --git a/infra/relay/src/clientConfig.ts b/infra/relay/src/clientConfig.ts index ed312f44d5a39..a0749a205fae8 100644 --- a/infra/relay/src/clientConfig.ts +++ b/infra/relay/src/clientConfig.ts @@ -45,10 +45,10 @@ const relayClientConfigEnv = (config: RelayClientConfig & { readonly url: string export class EnvValueNotSingleLineError extends Schema.TaggedError()( "EnvValueNotSingleLineError", - { name: Schema.String }, + { key: Schema.String }, ) { override get message(): string { - return `${this.name} contains a line break and cannot be written as one .env assignment.`; + return `${this.key} contains a line break and cannot be written as one .env assignment.`; } } @@ -106,7 +106,7 @@ export const PublishClientConfig = Alchemy.Action( const entries = relayClientConfigEnv({ ...input, url }); // Reject line breaks in provider values to prevent injected assignments. for (const [name, value] of Object.entries(entries)) { - if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ name }); + if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ key: name }); } const existing = (yield* fs.exists(target)) ? yield* fs.readFileString(target) : ""; yield* fs.writeFileString(target, reconcileEnvFile(existing, entries)); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index b95ba3156f70e..f6c305ab6bb90 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -525,10 +525,7 @@ function registrySpanNames(effect: Effect.Effect) { return new Tracer.NativeSpan(options); }, }); - return effect.pipe( - Effect.withTracer(tracer), - Effect.map(() => names), - ); + return effect.pipe(Effect.withTracer(tracer), Effect.as(names)); } function awaitConnectionState( diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 47211d54b2b84..68058c582d47c 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -256,12 +256,7 @@ it.effect("effect-acp agent keeps RPC ids within int32 and apart from extension Schema.fromJsonString(RequestPermissionRequest), ); const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); - const firstIsPermission = yield* decodedPermission(firstOutbound).pipe( - Effect.match({ - onFailure: () => false, - onSuccess: () => true, - }), - ); + const firstIsPermission = yield* decodedPermission(firstOutbound).pipe(Effect.isSuccess); const permissionRequest = firstIsPermission ? yield* decodedPermission(firstOutbound) diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 8a1cc36390bbd..0736cb369c994 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -718,12 +718,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { const decodedInitialize = Schema.decodeEffect(Schema.fromJsonString(InitializeRequest)); const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); - const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe( - Effect.match({ - onFailure: () => false, - onSuccess: () => true, - }), - ); + const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe(Effect.isSuccess); const initializeRequest = firstIsInitialize ? yield* decodedInitialize(firstOutbound) diff --git a/packages/provider-core/src/server/providerAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts index c1f8227eb25ff..863748b753c26 100644 --- a/packages/provider-core/src/server/providerAuthFlow.ts +++ b/packages/provider-core/src/server/providerAuthFlow.ts @@ -221,7 +221,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { Effect.forkIn(child), ); return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), + Effect.flatten, Effect.onExit((result) => Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, ), diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 69fa9cd63a800..cc8afd31311bb 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -116,7 +116,7 @@ describe("CursorAdapterV2", () => { : {}), }, }, - }).pipe(Effect.orDie); + }); } const readUpdates: ReadonlyArray = [ { @@ -144,7 +144,7 @@ describe("CursorAdapterV2", () => { })), ]; for (const update of readUpdates) { - yield* input.onDelta!(update).pipe(Effect.orDie); + yield* input.onDelta!(update); } return { agentId: "native-cursor-lifecycle", @@ -158,7 +158,7 @@ describe("CursorAdapterV2", () => { }), cancel: Effect.void, }; - }), + }).pipe(Effect.orDie), }), }), Effect.provide(TestProviderHost.layer({ cwd: workspace })), diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index d34b3721f6ae7..c13db819ec280 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -142,7 +142,7 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu Effect.forkIn(child), ); return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), + Effect.flatten, Effect.onExit((result) => Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, ), diff --git a/tsconfig.base.json b/tsconfig.base.json index 0f8690cae11ec..2720340734941 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,4 +1,6 @@ { + // The Effect language service config lives apart so apps/mobile can extend it with Expo's base. + "extends": "./tsconfig.effect.json", "compilerOptions": { "target": "ESNext", "module": "NodeNext", @@ -17,93 +19,6 @@ "skipLibCheck": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, - "resolveJsonModule": true, - "plugins": [ - { - "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node", "effect"], - "effectFn": ["span", "inferred-span", "suggested-span"], - "diagnosticSeverity": { - "abortControllerInEffect": "error", - "runEffectInsideEffect": "error", - "flatMapConditionalToFilterOrFail": "error", - "catchConditionalRefailToCatchIf": "error", - "tryCatchInEffectGen": "error", - "schemaStructWithTag": "error", - "preferTypedSchemaDecoder": "error", - "catchUnfailableEffect": "error", - "raceFirstWithSleepToTimeout": "error", - "redundantMapError": "error", - "optionMatchToFromOption": "error", - "catchToIgnore": "error", - "unnecessaryEffectGen": "error", - "allOfMapToForEach": "error", - "catchToOrElseSucceed": "error", - "unnecessaryFailYieldableError": "error", - "preferSucceedSomeOrNone": "error", - "effectSucceedWithVoid": "error", - "provideLayerSucceedToProvideService": "error", - "flatMapIgnoredParamToAndThen": "error", - "mapSomeToAsSome": "error", - "unnecessaryPipeChain": "error", - "returnEffectInGen": "off", - "effectFnOpportunity": "error", - "anyUnknownInErrorContext": "error", - "unsafeEffectTypeAssertion": "error", - "instanceOfSchema": "error", - "deterministicKeys": "error", - "strictEffectProvide": "off", - // We use Effect's unstable modules (http, sql) on purpose. These rules - // also make effect-tsgo 0.47+ report false TS2790 errors on `delete`. - "unstableApiUsage": "off", - "experimentalApiUsage": "off", - "missingEffectServiceDependency": "error", - "leakingRequirements": "error", - "globalErrorInEffectCatch": "error", - "globalErrorInEffectFailure": "error", - "unknownInEffectCatch": "error", - "strictBooleanExpressions": "off", - "lazyEffect": "off", - "globalJsonInEffect": "error", - "schemaSyncInEffect": "error", - "cryptoRandomUUID": "error", - "cryptoRandomUUIDInEffect": "error", - "nodeBuiltinImport": "error", - "globalDate": "error", - "globalDateInEffect": "error", - "globalConsole": "error", - "globalConsoleInEffect": "error", - "globalRandom": "error", - "globalRandomInEffect": "error", - "globalTimers": "error", - "globalTimersInEffect": "error", - "globalFetch": "error", - "globalFetchInEffect": "error", - "schemaNumber": "off" - }, - "overrides": [ - { - "include": ["**/*.test.ts", "**/*.test.tsx"], - "options": { - "diagnosticSeverity": { - "globalJsonInEffect": "off", - "effectFnOpportunity": "off" - } - } - }, - { - "include": [ - "**/*.spec.ts", - "**/*.spec.tsx", - "**/*.testkit.ts", - "**/*.testkit.tsx", - "**/{test,tests,testkit,testing,fixtures,integration}/**", - "packages/provider-testing/**" - ], - "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } - } - ] - } - ] + "resolveJsonModule": true } } diff --git a/tsconfig.effect.json b/tsconfig.effect.json new file mode 100644 index 0000000000000..c6ac87376e618 --- /dev/null +++ b/tsconfig.effect.json @@ -0,0 +1,126 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "effectFn": ["span", "inferred-span", "suggested-span"], + "diagnosticSeverity": { + "abortControllerInEffect": "error", + "runEffectInsideEffect": "error", + "flatMapConditionalToFilterOrFail": "error", + "catchConditionalRefailToCatchIf": "error", + "tryCatchInEffectGen": "error", + "schemaStructWithTag": "error", + "preferTypedSchemaDecoder": "error", + "catchUnfailableEffect": "error", + "raceFirstWithSleepToTimeout": "error", + "redundantMapError": "error", + "optionMatchToFromOption": "error", + "catchToIgnore": "error", + "unnecessaryEffectGen": "error", + "allOfMapToForEach": "error", + "catchToOrElseSucceed": "error", + "unnecessaryFailYieldableError": "error", + "preferSucceedSomeOrNone": "error", + "effectSucceedWithVoid": "error", + "provideLayerSucceedToProvideService": "error", + "flatMapIgnoredParamToAndThen": "error", + "mapSomeToAsSome": "error", + "unnecessaryPipeChain": "error", + "returnEffectInGen": "off", + "effectFnOpportunity": "error", + "anyUnknownInErrorContext": "error", + "unsafeEffectTypeAssertion": "error", + "instanceOfSchema": "error", + "deterministicKeys": "error", + "strictEffectProvide": "off", + // We use Effect's unstable modules (http, sql) on purpose. These rules + // also make effect-tsgo 0.47+ report false TS2790 errors on `delete`. + "unstableApiUsage": "off", + "experimentalApiUsage": "off", + "missingEffectServiceDependency": "error", + "leakingRequirements": "error", + "globalErrorInEffectCatch": "error", + "globalErrorInEffectFailure": "error", + "unknownInEffectCatch": "error", + "strictBooleanExpressions": "off", + "lazyEffect": "off", + "globalJsonInEffect": "error", + "schemaSyncInEffect": "error", + "cryptoRandomUUID": "error", + "cryptoRandomUUIDInEffect": "error", + "nodeBuiltinImport": "error", + "globalDate": "error", + "globalDateInEffect": "error", + "globalConsole": "error", + "globalConsoleInEffect": "error", + "globalRandom": "error", + "globalRandomInEffect": "error", + "globalTimers": "error", + "globalTimersInEffect": "error", + "globalFetch": "error", + "globalFetchInEffect": "error", + "acquireReleaseDisposable": "error", + "catchAllTagDispatchToCatchTag": "error", + "catchAllToMapError": "error", + "catchChainToFirstSuccessOf": "error", + "catchDieToOrDie": "error", + "catchIfTagToCatchTag": "error", + "catchRefailToTapError": "error", + "catchTagToCatchReason": "error", + "effectMapFlatten": "error", + "effectMapVoid": "error", + "effectSleepZeroToYieldNow": "error", + "effectSucceedWithUndefined": "error", + "errorPayloadShadowsName": "error", + "failCauseToConstructor": "error", + "flatMapIdentityToFlatten": "error", + "flatMapToMap": "error", + "flatMapToTap": "error", + "manualMetricTapToTrack": "error", + "mapConstToAs": "error", + "mapSomeCatchNoSuchElement": "error", + "matchBooleanToIsSuccess": "error", + "matchEffectToMapBoth": "error", + "matchEffectToMatch": "error", + "matchToResultOrOption": "error", + "multipleCatchTag": "error", + "preferUnsafeConstructor": "error", + "redundantOrDie": "error", + "redundantSchemaTagIdentifier": "error", + "resultMatchToFromResult": "error", + "runOfExitToRunExit": "error", + "suspendFailToFailSync": "error", + "syncToSucceed": "error", + "timeoutCatchTagToTimeoutOrElse": "error", + "unnecessaryPipe": "error", + "unnecessaryTypeofType": "error", + "schemaNumber": "off" + }, + "overrides": [ + { + "include": ["**/*.test.ts", "**/*.test.tsx"], + "options": { + "diagnosticSeverity": { + "globalJsonInEffect": "off", + "effectFnOpportunity": "off" + } + } + }, + { + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.testkit.ts", + "**/*.testkit.tsx", + "**/{test,tests,testkit,testing,fixtures,integration}/**", + "packages/provider-testing/**" + ], + "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } + } + ] + } + ] + } +} From 8216ae31abacee8c13dac1003add7ea3f2c34637 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:48:48 -0800 Subject: [PATCH 2/2] build: make the test-directory Effect override actually match Override globs take no braces and a trailing /** matches no files, so the directory patterns never applied. Co-Authored-By: Claude Opus 5.5 --- tsconfig.effect.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tsconfig.effect.json b/tsconfig.effect.json index c6ac87376e618..685d7ebbb2a99 100644 --- a/tsconfig.effect.json +++ b/tsconfig.effect.json @@ -114,8 +114,13 @@ "**/*.spec.tsx", "**/*.testkit.ts", "**/*.testkit.tsx", - "**/{test,tests,testkit,testing,fixtures,integration}/**", - "packages/provider-testing/**" + "**/test/**/*", + "**/tests/**/*", + "**/testkit/**/*", + "**/testing/**/*", + "**/fixtures/**/*", + "**/integration/**/*", + "packages/provider-testing/**/*" ], "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } }