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..efe489b0fde35 --- /dev/null +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.test.ts @@ -0,0 +1,74 @@ +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"; + +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("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.mockImplementation(() => + new Promise((resolve) => { + releaseDrafts = resolve; + }).then(() => { + draftsCleared = true; + }), + ); + + const cleanup = yield* Persistence.EnvironmentOwnedDataCleanup; + yield* cleanup.clear(ENVIRONMENT_ID); + + 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( + 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 new file mode 100644 index 0000000000000..28c6cda29a4d3 --- /dev/null +++ b/apps/mobile/src/connection/environment-owned-data-cleanup.ts @@ -0,0 +1,48 @@ +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"; +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({ + 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({ + try: () => clear(environmentId), + catch: (cause) => new EnvironmentOwnedDataClearError({ cause }), + }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear mobile environment-owned data.", { + environmentId, + operation, + ...safeErrorLogAttributes(error.cause), + }), + ), + ); + return Effect.all( + [ + settle("outbox", clearThreadOutboxEnvironment), + settle("drafts", clearComposerDraftsEnvironment), + Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), + ], + { concurrency: "unbounded", discard: true }, + ); + }), + }), +); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 407016ab484d9..de461f35ab104 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, @@ -25,10 +21,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" { @@ -255,22 +249,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: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")((environmentId) => - Effect.all( - [ - Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), - Effect.promise(() => clearComposerDraftsEnvironment(environmentId)), - Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), - ], - { concurrency: "unbounded", discard: true }, - ), - ), - }), -); - type ConnectionPlatformLayerSource = | typeof layerProvidedConnectionStorage | typeof Runtime.layer @@ -278,7 +256,7 @@ type ConnectionPlatformLayerSource = | typeof layerWakeups | typeof layerProvidedCapabilities | typeof layerPlatformConnectionSource - | typeof layerEnvironmentOwnedDataCleanup; + | typeof EnvironmentOwnedDataCleanup.layer; export const layer: Layer.Layer< Layer.Success, @@ -291,5 +269,5 @@ export const layer: Layer.Layer< layerWakeups, layerProvidedCapabilities, layerPlatformConnectionSource, - layerEnvironmentOwnedDataCleanup, + EnvironmentOwnedDataCleanup.layer, );