diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 276ea3c5c08..cca2df3e722 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -62,22 +62,6 @@ function catalogError(operation: string, cause: unknown) { }); } -function shellPersistenceError( - operation: - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} - function threadSnapshotFileName(threadId: ThreadId): string { return `${encodeURIComponent(threadId)}.json`; } @@ -100,7 +84,7 @@ const threadSnapshotDirectory = Effect.fn("mobile.connectionStorage.threadSnapsh } return directory; }, - catch: (cause) => shellPersistenceError(operation, cause), + catch: (cause) => new ConnectionPersistenceError({ operation, environmentId, cause }), }); }, ); @@ -117,16 +101,6 @@ const threadSnapshotFile = Effect.fn("mobile.connectionStorage.threadSnapshotFil ); }); -function targetPersistenceError( - operation: "list-targets" | "register-connection" | "remove-connection", - error: ConnectionTransientError, -) { - return new ConnectionPersistenceError({ - operation, - message: error.message, - }); -} - const secureCatalogStorage: SecureCatalogStorage = { getItem: (key) => Effect.tryPromise({ @@ -163,7 +137,7 @@ const shellSnapshotFileInDirectory = Effect.fn( directory.create({ idempotent: true, intermediates: true }); return new File(directory, shellSnapshotFileName(environmentId)); }, - catch: (cause) => shellPersistenceError(operation, cause), + catch: (cause) => new ConnectionPersistenceError({ operation, environmentId, cause }), }); }); @@ -184,18 +158,38 @@ export const connectionStorageLayer = Layer.effectContext( const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( Effect.map((document) => document.targets), - Effect.mapError((error) => targetPersistenceError("list-targets", error)), + Effect.mapError( + (cause) => new ConnectionPersistenceError({ operation: "list-targets", cause }), + ), ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => catalog .update((document) => registerConnectionInCatalog(document, registration)) - .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + .pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "register-connection", + environmentId: registration.target.environmentId, + cause, + }), + ), + ), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) - .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), + .pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "remove-connection", + environmentId: target.environmentId, + cause, + }), + ), + ), }); const profileStore = ProfileStore.make({ get: (connectionId) => @@ -283,15 +277,34 @@ export const connectionStorageLayer = Layer.effectContext( if (file.exists) { const raw = yield* Effect.tryPromise({ try: () => file.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), }); const parsed = yield* Effect.try({ try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), }); const stored = yield* Effect.fromResult( Schema.decodeUnknownResult(StoredShellSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), + ), + ); return stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(); @@ -303,15 +316,34 @@ export const connectionStorageLayer = Layer.effectContext( } const legacyRaw = yield* Effect.tryPromise({ try: () => legacyFile.text(), - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), }); const legacyParsed = yield* Effect.try({ try: () => JSON.parse(legacyRaw) as unknown, - catch: (cause) => shellPersistenceError("load-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), }); const legacyStored = yield* Effect.fromResult( Schema.decodeUnknownResult(LegacyStoredShellSnapshot)(legacyParsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-shell", cause))); + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), + ), + ); return legacyStored.environmentId === environmentId ? Option.some(legacyStored.snapshot) : Option.none(); @@ -326,7 +358,16 @@ export const connectionStorageLayer = Layer.effectContext( } as const; const encoded = yield* Effect.fromResult( Schema.encodeUnknownResult(StoredShellSnapshot)(stored), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-shell", cause))); + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "save-shell", + environmentId, + cause, + }), + ), + ); yield* Effect.try({ try: () => { if (!file.exists) { @@ -334,7 +375,12 @@ export const connectionStorageLayer = Layer.effectContext( } file.write(JSON.stringify(encoded)); }, - catch: (cause) => shellPersistenceError("save-shell", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "save-shell", + environmentId, + cause, + }), }); }), loadThread: (environmentId, threadId) => @@ -345,15 +391,37 @@ export const connectionStorageLayer = Layer.effectContext( } const raw = yield* Effect.tryPromise({ try: () => file.text(), - catch: (cause) => shellPersistenceError("load-thread", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + environmentId, + threadId, + cause, + }), }); const parsed = yield* Effect.try({ try: () => JSON.parse(raw) as unknown, - catch: (cause) => shellPersistenceError("load-thread", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + environmentId, + threadId, + cause, + }), }); const stored = yield* Effect.fromResult( Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed), - ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause))); + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + environmentId, + threadId, + cause, + }), + ), + ); return stored.environmentId === environmentId && stored.threadId === threadId ? Option.some(stored.thread) : Option.none(); @@ -368,7 +436,17 @@ export const connectionStorageLayer = Layer.effectContext( threadId: thread.id, thread, }), - ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause))); + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "save-thread", + environmentId, + threadId: thread.id, + cause, + }), + ), + ); yield* Effect.try({ try: () => { if (!file.exists) { @@ -376,36 +454,55 @@ export const connectionStorageLayer = Layer.effectContext( } file.write(JSON.stringify(encoded)); }, - catch: (cause) => shellPersistenceError("save-thread", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "save-thread", + environmentId, + threadId: thread.id, + cause, + }), }); }), removeThread: (environmentId, threadId) => Effect.gen(function* () { const file = yield* threadSnapshotFile(environmentId, threadId, "remove-thread"); if (file.exists) { - file.delete(); + yield* Effect.try({ + try: () => file.delete(), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "remove-thread", + environmentId, + threadId, + cause, + }), + }); } - }).pipe( - Effect.mapError((cause) => - cause._tag === "ConnectionPersistenceError" - ? cause - : shellPersistenceError("remove-thread", cause), - ), - ), + }), clear: (environmentId) => Effect.gen(function* () { const file = yield* shellSnapshotFile(environmentId, "clear-environment"); if (file.exists) { yield* Effect.try({ try: () => file.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + environmentId, + cause, + }), }); } const legacyFile = yield* legacyShellSnapshotFile(environmentId, "clear-environment"); if (legacyFile.exists) { yield* Effect.try({ try: () => legacyFile.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + environmentId, + cause, + }), }); } const threadDirectory = yield* threadSnapshotDirectory( @@ -415,7 +512,12 @@ export const connectionStorageLayer = Layer.effectContext( if (threadDirectory.exists) { yield* Effect.try({ try: () => threadDirectory.delete(), - catch: (cause) => shellPersistenceError("clear-environment", cause), + catch: (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + environmentId, + cause, + }), }); } }), diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index d118a428ed7..3b4e60fb00a 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -67,25 +67,6 @@ function catalogError(operation: string, cause: unknown) { }); } -function persistenceError( - operation: - | "list-targets" - | "register-connection" - | "remove-connection" - | "load-shell" - | "save-shell" - | "load-thread" - | "save-thread" - | "remove-thread" - | "clear-environment", - cause: unknown, -) { - return new ConnectionPersistenceError({ - operation, - message: `Could not ${operation.replaceAll("-", " ")}: ${String(cause)}`, - }); -} - const openDatabase = Effect.fn("web.connectionStorage.openDatabase")(function* () { return yield* Effect.callback((resume) => { if (typeof indexedDB === "undefined") { @@ -330,18 +311,38 @@ export const connectionStorageLayer = Layer.effectContext( const targetStore = ConnectionTargetStore.of({ list: catalog.read.pipe( Effect.map((document) => document.targets), - Effect.mapError((cause) => persistenceError("list-targets", cause)), + Effect.mapError( + (cause) => new ConnectionPersistenceError({ operation: "list-targets", cause }), + ), ), }); const registrationStore = ConnectionRegistrationStore.of({ register: (registration) => catalog .update((document) => registerConnectionInCatalog(document, registration)) - .pipe(Effect.mapError((cause) => persistenceError("register-connection", cause))), + .pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "register-connection", + environmentId: registration.target.environmentId, + cause, + }), + ), + ), remove: (target) => catalog .update((document) => removeConnectionFromCatalog(document, target)) - .pipe(Effect.mapError((cause) => persistenceError("remove-connection", cause))), + .pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "remove-connection", + environmentId: target.environmentId, + cause, + }), + ), + ), }); const profileStore = ProfileStore.make({ get: (connectionId) => @@ -430,7 +431,14 @@ export const connectionStorageLayer = Layer.effectContext( return Effect.succeed(Option.none()); } return decodeStoredShellSnapshot(raw).pipe( - Effect.mapError((cause) => persistenceError("load-shell", cause)), + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), + ), Effect.map((stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) @@ -441,7 +449,11 @@ export const connectionStorageLayer = Layer.effectContext( Effect.mapError((cause) => cause._tag === "ConnectionPersistenceError" ? cause - : persistenceError("load-shell", cause), + : new ConnectionPersistenceError({ + operation: "load-shell", + environmentId, + cause, + }), ), ), saveShell: (environmentId, snapshot) => @@ -450,13 +462,26 @@ export const connectionStorageLayer = Layer.effectContext( schemaVersion: SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION, environmentId, snapshot, - }).pipe(Effect.mapError((cause) => persistenceError("save-shell", cause))); + }).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "save-shell", + environmentId, + cause, + }), + ), + ); yield* writeDatabaseValue(database, SHELL_STORE_NAME, environmentId, encoded); }).pipe( Effect.mapError((cause) => cause._tag === "ConnectionPersistenceError" ? cause - : persistenceError("save-shell", cause), + : new ConnectionPersistenceError({ + operation: "save-shell", + environmentId, + cause, + }), ), ), loadThread: (environmentId, threadId) => @@ -470,7 +495,15 @@ export const connectionStorageLayer = Layer.effectContext( return Effect.succeed(Option.none()); } return decodeStoredThreadSnapshot(raw).pipe( - Effect.mapError((cause) => persistenceError("load-thread", cause)), + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "load-thread", + environmentId, + threadId, + cause, + }), + ), Effect.map((stored) => stored.environmentId === environmentId && stored.threadId === threadId ? Option.some(stored.thread) @@ -481,7 +514,12 @@ export const connectionStorageLayer = Layer.effectContext( Effect.mapError((cause) => cause._tag === "ConnectionPersistenceError" ? cause - : persistenceError("load-thread", cause), + : new ConnectionPersistenceError({ + operation: "load-thread", + environmentId, + threadId, + cause, + }), ), ), saveThread: (environmentId, thread) => @@ -491,7 +529,17 @@ export const connectionStorageLayer = Layer.effectContext( environmentId, threadId: thread.id, thread, - }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause))); + }).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "save-thread", + environmentId, + threadId: thread.id, + cause, + }), + ), + ); yield* writeDatabaseValue( database, THREAD_STORE_NAME, @@ -502,7 +550,12 @@ export const connectionStorageLayer = Layer.effectContext( Effect.mapError((cause) => cause._tag === "ConnectionPersistenceError" ? cause - : persistenceError("save-thread", cause), + : new ConnectionPersistenceError({ + operation: "save-thread", + environmentId, + threadId: thread.id, + cause, + }), ), ), removeThread: (environmentId, threadId) => @@ -510,7 +563,17 @@ export const connectionStorageLayer = Layer.effectContext( database, THREAD_STORE_NAME, threadCacheKey(environmentId, threadId), - ).pipe(Effect.mapError((cause) => persistenceError("remove-thread", cause))), + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "remove-thread", + environmentId, + threadId, + cause, + }), + ), + ), clear: (environmentId) => Effect.all( [ @@ -522,7 +585,16 @@ export const connectionStorageLayer = Layer.effectContext( ), ], { concurrency: "unbounded", discard: true }, - ).pipe(Effect.mapError((cause) => persistenceError("clear-environment", cause))), + ).pipe( + Effect.mapError( + (cause) => + new ConnectionPersistenceError({ + operation: "clear-environment", + environmentId, + cause, + }), + ), + ), }); return Context.make(ConnectionTargetStore, targetStore).pipe( diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 885ba4cb781..5dc94c15a50 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -694,7 +694,7 @@ describe("EnvironmentRegistry", () => { Effect.fail( new Persistence.ConnectionPersistenceError({ operation: "remove-connection", - message: "Storage is unavailable.", + cause: new Error("Storage is unavailable."), }), ), }); diff --git a/packages/client-runtime/src/platform/persistence.test.ts b/packages/client-runtime/src/platform/persistence.test.ts new file mode 100644 index 00000000000..dce1d1f0e27 --- /dev/null +++ b/packages/client-runtime/src/platform/persistence.test.ts @@ -0,0 +1,24 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { ConnectionPersistenceError } from "./persistence.ts"; + +describe("ConnectionPersistenceError", () => { + it("retains resource context and the exact underlying failure", () => { + const cause = new Error("disk unavailable"); + const error = new ConnectionPersistenceError({ + operation: "load-thread", + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + cause, + }); + + expect(error).toMatchObject({ + operation: "load-thread", + environmentId: "environment-1", + threadId: "thread-1", + cause, + }); + expect(error.message).toBe("Could not load thread."); + }); +}); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 71664bf4601..51e944116c9 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -1,8 +1,8 @@ import { - type EnvironmentId, + EnvironmentId, type OrchestrationThread, type OrchestrationShellSnapshot, - type ThreadId, + ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -26,9 +26,15 @@ export class ConnectionPersistenceError extends Schema.TaggedErrorClass