From 0d2f8763b3b9966e6c124bd6a7f88a8a9a08d8c8 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 19:12:03 +0000 Subject: [PATCH 1/4] fix(mobile): removing an environment no longer fails when a queued message can't be read The mobile environment-owned data cleanup wrapped the outbox and draft clears in Effect.promise, so a rejection became a defect that skipped the existing log-and-continue branch and failed the whole removal. Use Effect.tryPromise so the warning branch handles it, and move the layer into its own module so it can be tested without loading the platform layer. Co-Authored-By: Claude Opus 5.5 --- .../environment-owned-data-cleanup.test.ts | 37 +++++++++++++++++++ .../environment-owned-data-cleanup.ts | 30 +++++++++++++++ apps/mobile/src/connection/platform.ts | 36 ++---------------- 3 files changed, 71 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/connection/environment-owned-data-cleanup.test.ts create mode 100644 apps/mobile/src/connection/environment-owned-data-cleanup.ts diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts new file mode 100644 index 0000000000000..2316ef6b44674 --- /dev/null +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts @@ -0,0 +1,37 @@ +import { Persistence } from "@t3tools/client-runtime/platform"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +const harness = vi.hoisted(() => ({ + clearOutbox: vi.fn<(environmentId: string) => Promise>(), + clearDrafts: vi.fn<(environmentId: string) => Promise>(), + clearComposerErrors: vi.fn(), +})); + +vi.mock("../state/thread-outbox-removal", () => ({ + clearThreadOutboxEnvironment: harness.clearOutbox, +})); +vi.mock("../state/use-composer-drafts", () => ({ + clearComposerDraftsEnvironment: harness.clearDrafts, +})); +vi.mock("../state/thread-composer-error", () => ({ + clearThreadComposerErrorsForEnvironment: harness.clearComposerErrors, +})); + +import * as EnvironmentOwnedDataCleanup from "./environment-owned-data-cleanup"; + +const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); + +describe("EnvironmentOwnedDataCleanup", () => { + it.effect("succeeds when a clear rejects", () => + Effect.gen(function* () { + harness.clearOutbox.mockRejectedValue(new Error("Some queued messages could not be read.")); + harness.clearDrafts.mockResolvedValue(); + + const cleanup = yield* Persistence.EnvironmentOwnedDataCleanup; + yield* cleanup.clear(ENVIRONMENT_ID); + }).pipe(Effect.provide(EnvironmentOwnedDataCleanup.layer)), + ); +}); diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.ts new file mode 100644 index 0000000000000..4a2d35bd588f4 --- /dev/null +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.ts @@ -0,0 +1,30 @@ +import { Persistence } from "@t3tools/client-runtime/platform"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; +import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; +import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; + +export const layer = Layer.succeed( + Persistence.EnvironmentOwnedDataCleanup, + Persistence.EnvironmentOwnedDataCleanup.of({ + clear: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")(function (environmentId) { + return Effect.all( + [ + Effect.tryPromise(() => clearThreadOutboxEnvironment(environmentId)), + Effect.tryPromise(() => clearComposerDraftsEnvironment(environmentId)), + 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/platform.ts b/apps/mobile/src/connection/platform.ts index b99b41cbffb7b..302918e5d14e8 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -1,8 +1,4 @@ -import { - ClientCapabilities, - PlatformConnectionSource, - Persistence, -} from "@t3tools/client-runtime/platform"; +import { ClientCapabilities, PlatformConnectionSource } from "@t3tools/client-runtime/platform"; import { ConnectionBlockedError, ConnectionTransientError, @@ -24,10 +20,8 @@ import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; -import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; -import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; -import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; +import * as EnvironmentOwnedDataCleanup from "./environment-owned-data-cleanup"; import * as ConnectionStorage from "./storage"; function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { @@ -249,28 +243,6 @@ const layerPlatformConnectionSource = Layer.succeed( const layerProvidedConnectionStorage = ConnectionStorage.layer.pipe(Layer.provide(Runtime.layer)); const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.layer)); -const layerEnvironmentOwnedDataCleanup = Layer.succeed( - Persistence.EnvironmentOwnedDataCleanup, - Persistence.EnvironmentOwnedDataCleanup.of({ - clear: (environmentId) => - Effect.all( - [ - Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), - Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), - Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), - ], - { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not clear mobile environment-owned data.", { - environmentId, - cause, - }), - ), - ), - }), -); - type ConnectionPlatformLayerSource = | typeof layerProvidedConnectionStorage | typeof Runtime.layer @@ -278,7 +250,7 @@ type ConnectionPlatformLayerSource = | typeof layerWakeups | typeof layerProvidedCapabilities | typeof layerPlatformConnectionSource - | typeof layerEnvironmentOwnedDataCleanup; + | typeof EnvironmentOwnedDataCleanup.layer; export const layer: Layer.Layer< Layer.Success, @@ -291,5 +263,5 @@ export const layer: Layer.Layer< layerWakeups, layerProvidedCapabilities, layerPlatformConnectionSource, - layerEnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.layer, ); From 6789c92a2b3a458152dc3bace71f58294afc5c83 Mon Sep 17 00:00:00 2001 From: only21mil Date: Sat, 31 Oct 2026 20:53:43 +0000 Subject: [PATCH 2/4] fix(mobile): environment cleanup waits for every clear and logs a bounded warning A rejected outbox clear made Effect.all fail fast and interrupt the drafts clear, whose promise kept running after the removal released its lease. Each clear now catches its own failure, so the cleanup waits for all of them. The warning logs the operation and safeErrorLogAttributes instead of the raw cause. Co-Authored-By: Claude Opus 5.5 --- .../environment-owned-data-cleanup.test.ts | 44 ++++++++++++++++--- .../environment-owned-data-cleanup.ts | 27 ++++++++---- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts index 2316ef6b44674..2b440b960f076 100644 --- a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts @@ -1,7 +1,8 @@ import { Persistence } from "@t3tools/client-runtime/platform"; import { EnvironmentId } from "@t3tools/contracts"; -import { describe, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Logger from "effect/Logger"; import { vi } from "vite-plus/test"; const harness = vi.hoisted(() => ({ @@ -25,13 +26,44 @@ import * as EnvironmentOwnedDataCleanup from "./environment-owned-data-cleanup"; const ENVIRONMENT_ID = EnvironmentId.make("environment-1"); describe("EnvironmentOwnedDataCleanup", () => { - it.effect("succeeds when a clear rejects", () => - Effect.gen(function* () { + it.effect("waits for the other clears and logs a bounded warning when one rejects", () => { + const warnings: Array = []; + let releaseDrafts = () => {}; + let draftsCleared = false; + // The drafts clear finishes only after the outbox failure is logged. + const logger = Logger.make(({ message }) => { + warnings.push(message); + releaseDrafts(); + }); + return Effect.gen(function* () { harness.clearOutbox.mockRejectedValue(new Error("Some queued messages could not be read.")); - harness.clearDrafts.mockResolvedValue(); + harness.clearDrafts.mockReturnValue( + new Promise((resolve) => { + releaseDrafts = resolve; + }).then(() => { + draftsCleared = true; + }), + ); const cleanup = yield* Persistence.EnvironmentOwnedDataCleanup; yield* cleanup.clear(ENVIRONMENT_ID); - }).pipe(Effect.provide(EnvironmentOwnedDataCleanup.layer)), - ); + + expect(draftsCleared).toBe(true); + expect(warnings).toEqual([ + [ + "Could not clear mobile environment-owned data.", + { + environmentId: ENVIRONMENT_ID, + operation: "outbox", + errorType: "error", + errorName: "Error", + stack: expect.any(String), + }, + ], + ]); + }).pipe( + Effect.provide(EnvironmentOwnedDataCleanup.layer), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ); + }); }); diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.ts index 4a2d35bd588f4..126e66f8f1e16 100644 --- a/apps/mobile/src/connection/environment-owned-data-cleanup.ts +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.ts @@ -1,4 +1,6 @@ +import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { Persistence } from "@t3tools/client-runtime/platform"; +import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -10,20 +12,27 @@ export const layer = Layer.succeed( Persistence.EnvironmentOwnedDataCleanup, Persistence.EnvironmentOwnedDataCleanup.of({ clear: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")(function (environmentId) { + // Each clear settles on its own: a failure must not interrupt a clear that is still running. + const settle = ( + operation: "outbox" | "drafts", + clear: (environmentId: EnvironmentId) => Promise, + ) => + Effect.tryPromise(() => clear(environmentId)).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + operation, + ...safeErrorLogAttributes(error.cause), + }), + ), + ); return Effect.all( [ - Effect.tryPromise(() => clearThreadOutboxEnvironment(environmentId)), - Effect.tryPromise(() => clearComposerDraftsEnvironment(environmentId)), + settle("outbox", clearThreadOutboxEnvironment), + settle("drafts", clearComposerDraftsEnvironment), Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), ], { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not clear mobile environment-owned data.", { - environmentId, - cause, - }), - ), ); }), }), From 05e49ed68e65b781af4edb4a1137eae7f5cba631 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 5 Nov 2026 04:26:19 +0000 Subject: [PATCH 3/4] fix(mobile): environment cleanup maps clear failures to a typed error --- .../connection/environment-owned-data-cleanup.test.ts | 9 +++++++-- .../src/connection/environment-owned-data-cleanup.ts | 11 ++++++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts index 2b440b960f076..a4403eeb32712 100644 --- a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts @@ -2,6 +2,7 @@ import { Persistence } from "@t3tools/client-runtime/platform"; import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import { vi } from "vite-plus/test"; @@ -62,8 +63,12 @@ describe("EnvironmentOwnedDataCleanup", () => { ], ]); }).pipe( - Effect.provide(EnvironmentOwnedDataCleanup.layer), - Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + Effect.provide( + Layer.merge( + EnvironmentOwnedDataCleanup.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), ); }); }); diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.ts index 126e66f8f1e16..28c6cda29a4d3 100644 --- a/apps/mobile/src/connection/environment-owned-data-cleanup.ts +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.ts @@ -3,11 +3,17 @@ import { Persistence } from "@t3tools/client-runtime/platform"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import { clearThreadComposerErrorsForEnvironment } from "../state/thread-composer-error"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox-removal"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; +class EnvironmentOwnedDataClearError extends Schema.TaggedError()( + "EnvironmentOwnedDataClearError", + { cause: Schema.Defect() }, +) {} + export const layer = Layer.succeed( Persistence.EnvironmentOwnedDataCleanup, Persistence.EnvironmentOwnedDataCleanup.of({ @@ -17,7 +23,10 @@ export const layer = Layer.succeed( operation: "outbox" | "drafts", clear: (environmentId: EnvironmentId) => Promise, ) => - Effect.tryPromise(() => clear(environmentId)).pipe( + Effect.tryPromise({ + try: () => clear(environmentId), + catch: (cause) => new EnvironmentOwnedDataClearError({ cause }), + }).pipe( Effect.catch((error) => Effect.logWarning("Could not clear mobile environment-owned data.", { environmentId, From 8b29305d4d13ce5d24657486df3f81b7bda32486 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 09:46:06 +0000 Subject: [PATCH 4/4] test(mobile): drafts-clear mock builds its promise only when called Co-Authored-By: Claude Opus 5.5 --- .../src/connection/environment-owned-data-cleanup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts index a4403eeb32712..efe489b0fde35 100644 --- a/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts @@ -38,7 +38,7 @@ describe("EnvironmentOwnedDataCleanup", () => { }); return Effect.gen(function* () { harness.clearOutbox.mockRejectedValue(new Error("Some queued messages could not be read.")); - harness.clearDrafts.mockReturnValue( + harness.clearDrafts.mockImplementation(() => new Promise((resolve) => { releaseDrafts = resolve; }).then(() => {