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..685d7ebbb2a99 --- /dev/null +++ b/tsconfig.effect.json @@ -0,0 +1,131 @@ +{ + "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" } } + } + ] + } + ] + } +}