* Tighten HTTP error schemas, keep rich causes internally Establish a clear rule: Schema.TaggedError is HTTP wire-facing — every field must be a safe public field. Data.TaggedError is internal-only; it can carry rich cause: unknown for logging/debugging and can't be accidentally wired to an HttpApi endpoint because it isn't a Schema. - OpenApiParseError (HTTP-exposed): drop error: Schema.Defect field - GraphqlIntrospectionError (HTTP-exposed): drop error: Schema.Defect field; log underlying causes via Effect.logError at construction sites in introspect.ts - OpenApiInvocationError, GraphqlInvocationError, GoogleDiscoveryParseError, GoogleDiscoveryInvocationError, KeychainError, ToolInvocationError: convert Schema.TaggedError -> Data.TaggedError, rename error -> cause - Cloud auth (UserStoreError, WorkOSError): keep empty schema + 500 annotation, log full cause via Effect.tapErrorCause at service wrappers * Extract withServiceLogging helper + errors.test.ts contract Collapse the repetitive tapErrorCause + mapError + withSpan ceremony at each service wrapper into one helper in auth/errors.ts. Add a real HttpApi integration test that pins down the contract: - Wire response contains only declared tagged-error fields - Server-side logs capture the full Cause chain (drizzle query, params, nested pg Error.cause) The test uses a real HttpApi + HttpApiBuilder.toWebHandler + fetch round-trip with a capturing Logger to verify both sides without mocking. Validates Option C from the earlier discussion: service-level logging is the only pattern that preserves the full cause because mapError discards it before any edge middleware could see it. * Remove errors.test.ts * Simplify graphql introspection error handling Drop ad-hoc Effect.logError sprinkles on branch-level failures (bad status, returned errors, missing __schema) — the tagged error's message already carries the useful info and there's no Cause chain to tap. Keep tapErrorCause + mapError inline for the two cases where an upstream error exists (HTTP request failure, JSON parse failure).
Showing the changes to apps/cloud/src (3 of 14 files). Show all
Unified · Split
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Context, Effect, Layer } from "effect"; |
| 2 | 2 | import { makeUserStore } from "../services/user-store"; |
| 3 | 3 | import { DbService } from "../services/db"; |
| 4 | −import { UserStoreError } from "./errors"; | |
| 4 | +import { UserStoreError, withServiceLogging } from "./errors"; | |
| 5 | 5 | |
| 6 | 6 | // AuthContext is defined in ./middleware.ts to keep middleware-related types together. |
| 7 | 7 | export { AuthContext } from "./middleware"; |
| @@ -12,24 +12,14 @@ export { AuthContext } from "./middleware"; · 4 unmodified lines | ||
| 12 | 12 | |
| 13 | 13 | type RawStore = ReturnType<typeof makeUserStore>; |
| 14 | 14 | |
| 15 | −const makeService = (store: RawStore) => { | |
| 16 | − const use = <A>(fn: (s: RawStore) => Promise<A>) => | |
| 17 | − Effect.tryPromise({ | |
| 18 | − try: () => fn(store), | |
| 19 | − catch: (cause) => cause, | |
| 20 | − }).pipe( | |
| 21 | − Effect.tapError((cause) => | |
| 22 | − Effect.sync(() => { | |
| 23 | − // eslint-disable-next-line no-console | |
| 24 | − console.error("[user_store] query failed:", cause); | |
| 25 | − }), | |
| 26 | − ), | |
| 27 | − Effect.mapError(() => new UserStoreError()), | |
| 28 | − Effect.withSpan("user_store"), | |
| 29 | − ); | |
| 30 | − | |
| 31 | − return { use }; | |
| 32 | −}; | |
| 15 | +const makeService = (store: RawStore) => ({ | |
| 16 | + use: <A>(fn: (s: RawStore) => Promise<A>) => | |
| 17 | + withServiceLogging( | |
| 18 | + "user_store", | |
| 19 | + () => new UserStoreError(), | |
| 20 | + Effect.tryPromise({ try: () => fn(store), catch: (e) => e }), | |
| 21 | + ), | |
| 22 | +}); | |
| 33 | 23 | |
| 34 | 24 | type UserStoreServiceType = ReturnType<typeof makeService>; |
| 35 | 25 | |
| @@ -1,10 +1,6 @@ | ||
| 1 | 1 | import { HttpApiSchema } from "@effect/platform"; |
| 2 | −import { Schema } from "effect"; | |
| 2 | +import { Effect, Schema } from "effect"; | |
| 3 | 3 | |
| 4 | −// Tagged errors returned to HTTP clients. The response payload must NEVER | |
| 5 | −// include internal details (SQL, stack traces, WorkOS error bodies) — those | |
| 6 | −// can leak schema + secrets. Keep the schema empty and log the `cause` | |
| 7 | −// server-side via Effect's logger when the error is constructed. | |
| 8 | 4 | export class UserStoreError extends Schema.TaggedError<UserStoreError>()( |
| 9 | 5 | "UserStoreError", |
| 10 | 6 | {}, |
| @@ -16,3 +12,26 @@ export class WorkOSError extends Schema.TaggedError<WorkOSError>()( · 5 unmodified lines | ||
| 16 | 12 | {}, |
| 17 | 13 | HttpApiSchema.annotations({ status: 500 }), |
| 18 | 14 | ) {} |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Service-boundary error wrapper. Logs the full Cause chain (drizzle | |
| 18 | + * query/params, pg error codes, nested Error.cause, etc.) via Effect's | |
| 19 | + * structured logger, then maps to a tagged error so the HTTP wire | |
| 20 | + * response contains only safe fields. | |
| 21 | + * | |
| 22 | + * Use this whenever a Promise-based API gets lifted into an Effect and | |
| 23 | + * its failure needs both debuggable server-side logging and a safe | |
| 24 | + * public shape. | |
| 25 | + */ | |
| 26 | +export const withServiceLogging = <A, E, R>( | |
| 27 | + name: string, | |
| 28 | + publicError: () => E, | |
| 29 | + effect: Effect.Effect<A, unknown, R>, | |
| 30 | +): Effect.Effect<A, E, R> => | |
| 31 | + effect.pipe( | |
| 32 | + Effect.tapErrorCause((cause) => | |
| 33 | + Effect.logError(`${name} failed`, cause), | |
| 34 | + ), | |
| 35 | + Effect.mapError(publicError), | |
| 36 | + Effect.withSpan(name), | |
| 37 | + ); | |
| @@ -4,7 +4,7 @@ · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | import { Context, Effect, Layer } from "effect"; |
| 6 | 6 | import { WorkOS } from "@workos-inc/node/worker"; |
| 7 | −import { WorkOSError } from "./errors"; | |
| 7 | +import { WorkOSError, withServiceLogging } from "./errors"; | |
| 8 | 8 | import { server } from "../env"; |
| 9 | 9 | |
| 10 | 10 | const COOKIE_NAME = "wos-session"; |
| @@ -26,18 +26,10 @@ const make = Effect.gen(function* () { · 15 unmodified lines | ||
| 26 | 26 | const workos = new WorkOS({ apiKey, clientId }); |
| 27 | 27 | |
| 28 | 28 | const use = <A>(fn: (wos: WorkOS) => Promise<A>) => |
| 29 | − Effect.tryPromise({ | |
| 30 | − try: () => fn(workos), | |
| 31 | − catch: (cause) => cause, | |
| 32 | − }).pipe( | |
| 33 | − Effect.tapError((cause) => | |
| 34 | − Effect.sync(() => { | |
| 35 | − // eslint-disable-next-line no-console | |
| 36 | − console.error("[workos] call failed:", cause); | |
| 37 | − }), | |
| 38 | − ), | |
| 39 | − Effect.mapError(() => new WorkOSError()), | |
| 40 | − Effect.withSpan("workos"), | |
| 29 | + withServiceLogging( | |
| 30 | + "workos", | |
| 31 | + () => new WorkOSError(), | |
| 32 | + Effect.tryPromise({ try: () => fn(workos), catch: (e) => e }), | |
| 41 | 33 | ); |
| 42 | 34 | |
| 43 | 35 | const authenticateSealedSession = (sessionData: string) => |