sketch: typed SessionNotFound (design validation)
Demonstrates the typed-errors-at-the-service-layer pattern on a single
method (`Session.Service.get`):
- Define `Session.SessionNotFound` as `Schema.ErrorClass` with
`httpApiStatus: 404` and a body shape that matches the existing Hono
`NamedError` envelope (`name: "NotFoundError"`, `data: { message }`).
- Update `Session.Service.Interface.get` and the implementation to fail
in the E channel via `yield* new SessionNotFound(...)`.
- HttpApi endpoint declares the typed error directly. The handler drops
its `mapNotFound` wrapper for `get` — Effect HttpApi auto-routes the
404 status from the schema annotation and serializes the body fields.
- Hono adapter (`runRequest`) bridges the typed error back to the
legacy `NotFoundError` defect so the existing `ErrorMiddleware` keeps
rendering 404 + body until that adapter is retired.
End-to-end validation: the unskipped parity test
(`Error JSON shape parity > HttpApi 404 body matches NamedError shape`)
now passes against the HttpApi adapter.
This is a sketch, not for merge — most cascading consumers are
absorbed via `Effect.orDie` (legacy never-E surface preserved). Real
rollout migrates each method/handler explicitly.
This commit is contained in:
parent
bd32252a7e
commit
a2bc535367
9 changed files with 134 additions and 53 deletions
|
|
@ -123,7 +123,10 @@ export const SessionApi = HttpApi.make("session")
|
||||||
HttpApiEndpoint.get("get", SessionPaths.get, {
|
HttpApiEndpoint.get("get", SessionPaths.get, {
|
||||||
params: { sessionID: SessionID },
|
params: { sessionID: SessionID },
|
||||||
success: described(Session.Info, "Get session"),
|
success: described(Session.Info, "Get session"),
|
||||||
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
|
// Sketch: typed `Session.SessionNotFound` declared directly. Effect
|
||||||
|
// HttpApi auto-routes the 404 status (`httpApiStatus` annotation)
|
||||||
|
// and serializes the schema fields (`name`, `data.message`).
|
||||||
|
error: [HttpApiError.BadRequest, Session.SessionNotFound],
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "session.get",
|
identifier: "session.get",
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,22 @@ import {
|
||||||
UpdatePayload,
|
UpdatePayload,
|
||||||
} from "../groups/session"
|
} from "../groups/session"
|
||||||
|
|
||||||
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
const mapNotFound = <A, E, R>(
|
||||||
|
self: Effect.Effect<A, E, R>,
|
||||||
|
): Effect.Effect<A, Exclude<E, Session.SessionNotFound> | HttpApiError.NotFound, R> =>
|
||||||
self.pipe(
|
self.pipe(
|
||||||
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
|
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
|
||||||
|
// Sketch: bridge typed `SessionNotFound` into the legacy wrapper so
|
||||||
|
// handlers that internally call `session.get` (whose endpoint
|
||||||
|
// declarations have not been migrated yet) keep returning 404.
|
||||||
|
Effect.catchIf(
|
||||||
|
(e: unknown) => e instanceof Session.SessionNotFound,
|
||||||
|
() => Effect.fail(new HttpApiError.NotFound({})),
|
||||||
|
),
|
||||||
Effect.catchDefect((error) =>
|
Effect.catchDefect((error) =>
|
||||||
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
|
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
|
||||||
),
|
),
|
||||||
)
|
) as Effect.Effect<A, Exclude<E, Session.SessionNotFound> | HttpApiError.NotFound, R>
|
||||||
|
|
||||||
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
|
export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", (handlers) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|
@ -78,8 +87,11 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||||
return Object.fromEntries(yield* statusSvc.list())
|
return Object.fromEntries(yield* statusSvc.list())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Sketch: typed `SessionNotFound` flows through the E channel — HttpApi
|
||||||
|
// maps the status (404 via `httpApiStatus`) and renders the body via the
|
||||||
|
// schema annotations. No `mapNotFound` wrapper required.
|
||||||
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
return yield* mapNotFound(session.get(ctx.params.sessionID))
|
return yield* session.get(ctx.params.sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
|
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
|
|
@ -178,20 +190,24 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||||
params: { sessionID: SessionID }
|
params: { sessionID: SessionID }
|
||||||
payload: typeof UpdatePayload.Type
|
payload: typeof UpdatePayload.Type
|
||||||
}) {
|
}) {
|
||||||
const current = yield* session.get(ctx.params.sessionID)
|
return yield* mapNotFound(
|
||||||
if (ctx.payload.title !== undefined) {
|
Effect.gen(function* () {
|
||||||
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
|
const current = yield* session.get(ctx.params.sessionID)
|
||||||
}
|
if (ctx.payload.title !== undefined) {
|
||||||
if (ctx.payload.permission !== undefined) {
|
yield* session.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
|
||||||
yield* session.setPermission({
|
}
|
||||||
sessionID: ctx.params.sessionID,
|
if (ctx.payload.permission !== undefined) {
|
||||||
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
|
yield* session.setPermission({
|
||||||
})
|
sessionID: ctx.params.sessionID,
|
||||||
}
|
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
|
||||||
if (ctx.payload.time?.archived !== undefined) {
|
})
|
||||||
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
|
}
|
||||||
}
|
if (ctx.payload.time?.archived !== undefined) {
|
||||||
return yield* session.get(ctx.params.sessionID)
|
yield* session.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
|
||||||
|
}
|
||||||
|
return yield* session.get(ctx.params.sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
|
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
|
||||||
|
|
@ -221,35 +237,48 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||||
})
|
})
|
||||||
|
|
||||||
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
|
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
return yield* mapNotFound(
|
||||||
return yield* session.get(ctx.params.sessionID)
|
Effect.gen(function* () {
|
||||||
|
yield* shareSvc.share(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||||
|
return yield* session.get(ctx.params.sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
|
const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
|
||||||
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
return yield* mapNotFound(
|
||||||
return yield* session.get(ctx.params.sessionID)
|
Effect.gen(function* () {
|
||||||
|
yield* shareSvc.unshare(ctx.params.sessionID).pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||||
|
return yield* session.get(ctx.params.sessionID)
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
|
const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
|
||||||
params: { sessionID: SessionID }
|
params: { sessionID: SessionID }
|
||||||
payload: typeof SummarizePayload.Type
|
payload: typeof SummarizePayload.Type
|
||||||
}) {
|
}) {
|
||||||
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
|
return yield* mapNotFound(
|
||||||
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
|
Effect.gen(function* () {
|
||||||
const defaultAgent = yield* agentSvc.defaultAgent()
|
yield* revertSvc.cleanup(yield* session.get(ctx.params.sessionID))
|
||||||
const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
|
const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
|
||||||
|
const defaultAgent = yield* agentSvc.defaultAgent()
|
||||||
|
const currentAgent =
|
||||||
|
messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
|
||||||
|
|
||||||
yield* compactSvc.create({
|
yield* compactSvc.create({
|
||||||
sessionID: ctx.params.sessionID,
|
sessionID: ctx.params.sessionID,
|
||||||
agent: currentAgent,
|
agent: currentAgent,
|
||||||
model: {
|
model: {
|
||||||
providerID: ctx.payload.providerID,
|
providerID: ctx.payload.providerID,
|
||||||
modelID: ctx.payload.modelID,
|
modelID: ctx.payload.modelID,
|
||||||
},
|
},
|
||||||
auto: ctx.payload.auto ?? false,
|
auto: ctx.payload.auto ?? false,
|
||||||
})
|
})
|
||||||
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
|
yield* promptSvc.loop({ sessionID: ctx.params.sessionID })
|
||||||
return true
|
return true
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
|
const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,12 @@ function routeHttpApiWorkspace<E>(
|
||||||
const request = yield* HttpServerRequest.HttpServerRequest
|
const request = yield* HttpServerRequest.HttpServerRequest
|
||||||
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
|
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
|
||||||
const session = sessionID
|
const session = sessionID
|
||||||
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(Effect.catchDefect(() => Effect.void))
|
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
|
||||||
|
// Sketch: also swallow the typed `SessionNotFound` so this routing
|
||||||
|
// probe stays best-effort (matches the previous defect-only catch).
|
||||||
|
Effect.catch(() => Effect.succeed(undefined)),
|
||||||
|
Effect.catchDefect(() => Effect.succeed(undefined)),
|
||||||
|
)
|
||||||
: undefined
|
: undefined
|
||||||
const plan = yield* planRequest(request, session?.workspaceID)
|
const plan = yield* planRequest(request, session?.workspaceID)
|
||||||
return yield* routeWorkspace(client, effect, plan)
|
return yield* routeWorkspace(client, effect, plan)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import type { Context } from "hono"
|
import type { Context } from "hono"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
import { NotFoundError } from "@/storage/storage"
|
||||||
|
import { Session } from "@/session/session"
|
||||||
|
|
||||||
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
|
type AppEnv = Parameters<typeof AppRuntime.runPromise>[0] extends Effect.Effect<any, any, infer R> ? R : never
|
||||||
|
|
||||||
|
|
@ -40,8 +42,25 @@ export function requestAttributes(c: RequestLike): Record<string, string> {
|
||||||
return attributes
|
return attributes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bridge typed service errors to the legacy Hono `NamedError` ErrorMiddleware.
|
||||||
|
// The HttpApi adapter consumes typed errors directly via schema annotations,
|
||||||
|
// but Hono's ErrorMiddleware switches on `instanceof NamedError`. Until the
|
||||||
|
// legacy adapter is retired, catch the new typed errors here and rethrow the
|
||||||
|
// equivalent NamedError so the existing 404 wiring keeps working.
|
||||||
|
const adaptTypedErrors = <A, E, R>(
|
||||||
|
self: Effect.Effect<A, E, R>,
|
||||||
|
): Effect.Effect<A, Exclude<E, Session.SessionNotFound>, R> =>
|
||||||
|
self.pipe(
|
||||||
|
Effect.catchIf(
|
||||||
|
(e: unknown) => e instanceof Session.SessionNotFound,
|
||||||
|
(e) => Effect.die(new NotFoundError({ message: (e as Session.SessionNotFound).data.message })),
|
||||||
|
),
|
||||||
|
) as unknown as Effect.Effect<A, Exclude<E, Session.SessionNotFound>, R>
|
||||||
|
|
||||||
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
|
export function runRequest<A, E>(name: string, c: Context, effect: Effect.Effect<A, E, AppEnv>) {
|
||||||
return AppRuntime.runPromise(effect.pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })))
|
return AppRuntime.runPromise(
|
||||||
|
adaptTypedErrors(effect).pipe(Effect.withSpan(name, { attributes: requestAttributes(c) })),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function jsonRequest<C extends Context, A, E>(
|
export async function jsonRequest<C extends Context, A, E>(
|
||||||
|
|
|
||||||
|
|
@ -745,7 +745,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
|
const markReady = ready ? ready.open.pipe(Effect.asVoid) : Effect.void
|
||||||
const { msg, part, cwd } = yield* Effect.gen(function* () {
|
const { msg, part, cwd } = yield* Effect.gen(function* () {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* Effect.orDie(sessions.get(input.sessionID))
|
||||||
if (session.revert) {
|
if (session.revert) {
|
||||||
yield* revert.cleanup(session)
|
yield* revert.cleanup(session)
|
||||||
}
|
}
|
||||||
|
|
@ -1369,7 +1369,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
|
|
||||||
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
const prompt: (input: PromptInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.prompt")(
|
||||||
function* (input: PromptInput) {
|
function* (input: PromptInput) {
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* Effect.orDie(sessions.get(input.sessionID))
|
||||||
yield* revert.cleanup(session)
|
yield* revert.cleanup(session)
|
||||||
const message = yield* createUserMessage(input)
|
const message = yield* createUserMessage(input)
|
||||||
yield* sessions.touch(input.sessionID)
|
yield* sessions.touch(input.sessionID)
|
||||||
|
|
@ -1402,7 +1402,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||||
const slog = elog.with({ sessionID })
|
const slog = elog.with({ sessionID })
|
||||||
let structured: unknown | undefined
|
let structured: unknown | undefined
|
||||||
let step = 0
|
let step = 0
|
||||||
const session = yield* sessions.get(sessionID)
|
// Sketch: keep public never-E surface; defect on missing session.
|
||||||
|
const session = yield* Effect.orDie(sessions.get(sessionID))
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
yield* status.set(sessionID, { type: "busy" })
|
yield* status.set(sessionID, { type: "busy" })
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export const layer = Layer.effect(
|
||||||
yield* state.assertNotBusy(input.sessionID)
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||||
let lastUser: MessageV2.User | undefined
|
let lastUser: MessageV2.User | undefined
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* Effect.orDie(sessions.get(input.sessionID))
|
||||||
|
|
||||||
let rev: Session.Info["revert"]
|
let rev: Session.Info["revert"]
|
||||||
const patches: Snapshot.Patch[] = []
|
const patches: Snapshot.Patch[] = []
|
||||||
|
|
@ -89,17 +89,17 @@ export const layer = Layer.effect(
|
||||||
files: diffs.length,
|
files: diffs.length,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return yield* sessions.get(input.sessionID)
|
return yield* Effect.orDie(sessions.get(input.sessionID))
|
||||||
})
|
})
|
||||||
|
|
||||||
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
||||||
log.info("unreverting", input)
|
log.info("unreverting", input)
|
||||||
yield* state.assertNotBusy(input.sessionID)
|
yield* state.assertNotBusy(input.sessionID)
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* Effect.orDie(sessions.get(input.sessionID))
|
||||||
if (!session.revert) return session
|
if (!session.revert) return session
|
||||||
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
||||||
yield* sessions.clearRevert(input.sessionID)
|
yield* sessions.clearRevert(input.sessionID)
|
||||||
return yield* sessions.get(input.sessionID)
|
return yield* Effect.orDie(sessions.get(input.sessionID))
|
||||||
})
|
})
|
||||||
|
|
||||||
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
||||||
|
|
|
||||||
|
|
@ -434,7 +434,7 @@ export interface Interface {
|
||||||
}) => Effect.Effect<Info>
|
}) => Effect.Effect<Info>
|
||||||
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
|
readonly fork: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Info>
|
||||||
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
|
readonly touch: (sessionID: SessionID) => Effect.Effect<void>
|
||||||
readonly get: (id: SessionID) => Effect.Effect<Info>
|
readonly get: (id: SessionID) => Effect.Effect<Info, SessionNotFound>
|
||||||
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
|
readonly setTitle: (input: { sessionID: SessionID; title: string }) => Effect.Effect<void>
|
||||||
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
|
readonly setArchived: (input: { sessionID: SessionID; time?: number }) => Effect.Effect<void>
|
||||||
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
|
readonly setPermission: (input: { sessionID: SessionID; permission: Permission.Ruleset }) => Effect.Effect<void>
|
||||||
|
|
@ -472,6 +472,22 @@ export interface Interface {
|
||||||
) => Effect.Effect<Option.Option<MessageV2.WithParts>>
|
) => Effect.Effect<Option.Option<MessageV2.WithParts>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed not-found error for `Session.Service.get`.
|
||||||
|
*
|
||||||
|
* The `httpApiStatus: 404` annotation lets the Effect HttpApi auto-route the
|
||||||
|
* status. The schema fields (`name` literal, `data.message`) match Hono's
|
||||||
|
* `NamedError` JSON envelope so SDK consumers see the same body shape on both
|
||||||
|
* adapters.
|
||||||
|
*/
|
||||||
|
export class SessionNotFound extends Schema.ErrorClass<SessionNotFound>("opencode/Session/NotFound")(
|
||||||
|
{
|
||||||
|
name: Schema.tag("NotFoundError"),
|
||||||
|
data: Schema.Struct({ message: Schema.String }),
|
||||||
|
},
|
||||||
|
{ httpApiStatus: 404 },
|
||||||
|
) {}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Session") {}
|
||||||
|
|
||||||
export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
|
export type Patch = Types.DeepMutable<SyncEvent.Event<typeof Event.Updated>["data"]["info"]>
|
||||||
|
|
@ -534,7 +550,7 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||||
|
|
||||||
const get = Effect.fn("Session.get")(function* (id: SessionID) {
|
const get = Effect.fn("Session.get")(function* (id: SessionID) {
|
||||||
const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
const row = yield* db((d) => d.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||||
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
|
if (!row) return yield* new SessionNotFound({ data: { message: `Session not found: ${id}` } })
|
||||||
return fromRow(row)
|
return fromRow(row)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -556,7 +572,9 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||||
|
|
||||||
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
|
const remove: Interface["remove"] = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||||
try {
|
try {
|
||||||
const session = yield* get(sessionID)
|
// Sketch: keep `remove`'s legacy never-E surface; convert
|
||||||
|
// `SessionNotFound` to a defect (caught below by the try/catch).
|
||||||
|
const session = yield* Effect.orDie(get(sessionID))
|
||||||
const kids = yield* children(sessionID)
|
const kids = yield* children(sessionID)
|
||||||
for (const child of kids) {
|
for (const child of kids) {
|
||||||
yield* remove(child.id)
|
yield* remove(child.id)
|
||||||
|
|
@ -641,7 +659,9 @@ export const layer: Layer.Layer<Service, never, Bus.Service | Storage.Service |
|
||||||
|
|
||||||
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||||
const ctx = yield* InstanceState.context
|
const ctx = yield* InstanceState.context
|
||||||
const original = yield* get(input.sessionID)
|
// Sketch: keep `fork`'s legacy never-E surface; convert SessionNotFound
|
||||||
|
// to a defect. When `fork` itself is migrated, propagate it.
|
||||||
|
const original = yield* Effect.orDie(get(input.sessionID))
|
||||||
const title = getForkedTitle(original.title)
|
const title = getForkedTitle(original.title)
|
||||||
const session = yield* createNext({
|
const session = yield* createNext({
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
|
|
|
||||||
|
|
@ -958,7 +958,7 @@ describe("workspace-old sync state", () => {
|
||||||
|
|
||||||
yield* eventuallyEffect(
|
yield* eventuallyEffect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from history")
|
expect((yield* Effect.orDie(sessionSvc.get(session.id))).title).toBe("from history")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
|
expect(historyBodies).toEqual([{ [session.id]: historyNextSeq - 1 }])
|
||||||
|
|
@ -1106,7 +1106,7 @@ describe("workspace-old sync state", () => {
|
||||||
|
|
||||||
yield* eventuallyEffect(
|
yield* eventuallyEffect(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect((yield* sessionSvc.get(session.id)).title).toBe("from sse")
|
expect((yield* Effect.orDie(sessionSvc.get(session.id))).title).toBe("from sse")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,11 @@ describe("404 mapping for missing session", () => {
|
||||||
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
|
// FIXME: unskip when error JSON shape policy is decided + applied (separate PR).
|
||||||
// ──────────────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────────────
|
||||||
describe("Error JSON shape parity", () => {
|
describe("Error JSON shape parity", () => {
|
||||||
test.todo("HttpApi 404 body matches NamedError shape", async () => {
|
// Sketch: validates the typed-error pattern end-to-end on `session.get`. The
|
||||||
|
// service now fails with a typed `SessionNotFound` error annotated
|
||||||
|
// `httpApiStatus: 404`. The endpoint declares the schema, and HttpApi auto-
|
||||||
|
// routes the status + body — no `mapNotFound` wrapper required.
|
||||||
|
test("HttpApi 404 body matches NamedError shape (sketch: session.get)", async () => {
|
||||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||||
|
|
||||||
const response = await app(true).request("/session/ses_does_not_exist", {
|
const response = await app(true).request("/session/ses_does_not_exist", {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue