Browse Source

refactor(core): simplify session pagination

Dax Raad 3 tháng trước cách đây
mục cha
commit
8d03f6c5be

+ 26 - 26
packages/core/src/session.ts

@@ -15,7 +15,7 @@ import { Database } from "./database/database"
 import { SessionProjector } from "./session/projector"
 import { SessionMessageTable, SessionTable } from "./session/sql"
 import { SessionSchema } from "./session/schema"
-import { AbsolutePath, RelativePath } from "./schema"
+import { AbsolutePath, PositiveInt, RelativePath } from "./schema"
 import { AgentV2 } from "./agent"
 
 // get project -> project.locations
@@ -27,35 +27,35 @@ import { AgentV2 } from "./agent"
 //   - by subpath
 // - by workspace (home is special)
 
-export const ListCursor = Schema.Struct({
+export const ListAnchor = Schema.Struct({
   id: SessionSchema.ID,
   time: Schema.Finite,
   direction: Schema.Literals(["previous", "next"]),
 })
-export type ListCursor = typeof ListCursor.Type
+export type ListAnchor = typeof ListAnchor.Type
 
 const ListInputBase = {
   workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
   search: Schema.String.pipe(Schema.optional),
-  limit: Schema.Int.pipe(Schema.optional),
+  limit: PositiveInt.pipe(Schema.optional),
   order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
-  cursor: ListCursor.pipe(Schema.optional),
+  anchor: ListAnchor.pipe(Schema.optional),
 }
 
-export const ListInput = Schema.Union([
-  Schema.Struct({
-    ...ListInputBase,
-  }),
-  Schema.Struct({
-    ...ListInputBase,
-    directory: AbsolutePath,
-  }),
-  Schema.Struct({
-    ...ListInputBase,
-    project: ProjectV2.ID,
-    subpath: RelativePath.pipe(Schema.optional),
-  }),
-])
+const ListDirectoryInput = Schema.Struct({
+  ...ListInputBase,
+  directory: AbsolutePath,
+})
+
+const ListProjectInput = Schema.Struct({
+  ...ListInputBase,
+  project: ProjectV2.ID,
+  subpath: RelativePath.pipe(Schema.optional),
+})
+
+const ListAllInput = Schema.Struct(ListInputBase)
+
+export const ListInput = Schema.Union([ListDirectoryInput, ListProjectInput, ListAllInput])
 export type ListInput = typeof ListInput.Type
 
 type CreateInput = {
@@ -205,25 +205,25 @@ export const layer = Layer.effect(
         return fromRow(row)
       }),
       list: Effect.fn("V2Session.list")(function* (input = {}) {
-        const direction = input.cursor?.direction ?? "next"
+        const direction = input.anchor?.direction ?? "next"
         const requestedOrder = input.order ?? "desc"
         const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
-        const sortColumn = SessionTable.time_updated
+        const sortColumn = SessionTable.time_created
         const conditions: SQL[] = []
         if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
         if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
         if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
         if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
-        if (input.cursor) {
+        if (input.anchor) {
           conditions.push(
             order === "asc"
               ? or(
-                  gt(sortColumn, input.cursor.time),
-                  and(eq(sortColumn, input.cursor.time), gt(SessionTable.id, input.cursor.id)),
+                  gt(sortColumn, input.anchor.time),
+                  and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
                 )!
               : or(
-                  lt(sortColumn, input.cursor.time),
-                  and(eq(sortColumn, input.cursor.time), lt(SessionTable.id, input.cursor.id)),
+                  lt(sortColumn, input.anchor.time),
+                  and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
                 )!,
           )
         }

+ 66 - 19
packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts

@@ -2,7 +2,10 @@ import { SessionID } from "@/session/schema"
 import { SessionMessage } from "@opencode-ai/core/session/message"
 import { Prompt } from "@opencode-ai/core/session/prompt"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { Schema } from "effect"
+import { ProjectV2 } from "@opencode-ai/core/project"
+import { AbsolutePath, PositiveInt, RelativePath, withStatics } from "@opencode-ai/core/schema"
+import { WorkspaceV2 } from "@opencode-ai/core/workspace"
+import { Schema, Struct } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import {
   InvalidCursorError,
@@ -12,29 +15,73 @@ import {
   UnknownError,
 } from "../../errors"
 import { V2Authorization } from "../../middleware/authorization"
-import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
-import { QueryBoolean } from "../query"
+import { WorkspaceRoutingQuery } from "../../middleware/workspace-routing"
 
-export const SessionsQuery = Schema.Struct({
-  ...WorkspaceRoutingQueryFields,
-  limit: Schema.optional(
-    Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(200)),
-  ).annotate({
+const SessionsQueryFields = {
+  workspace: WorkspaceV2.ID.pipe(Schema.optional),
+  limit: Schema.NumberFromString.pipe(Schema.decodeTo(PositiveInt), Schema.optional).annotate({
     description: "Maximum number of sessions to return. Defaults to the newest 50 sessions.",
   }),
   order: Schema.optional(Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")])).annotate({
     description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
   }),
-  path: Schema.optional(Schema.String),
-  roots: Schema.optional(QueryBoolean),
-  start: Schema.optional(Schema.NumberFromString),
   search: Schema.optional(Schema.String),
-  cursor: Schema.optional(
-    Schema.String.annotate({
-      description:
-        "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.",
-    }),
-  ),
+}
+
+const SessionsDirectoryQuery = Schema.Struct({
+  ...SessionsQueryFields,
+  directory: AbsolutePath,
+})
+
+const SessionsProjectQuery = Schema.Struct({
+  ...SessionsQueryFields,
+  project: ProjectV2.ID,
+  subpath: RelativePath.pipe(Schema.optional),
+})
+
+const SessionsAllQuery = Schema.Struct(SessionsQueryFields)
+
+const withCursor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
+  schema.mapFields((fields) => ({
+    ...Struct.omit(fields, ["limit"]),
+    anchor: SessionV2.ListAnchor,
+  }))
+
+const SessionsCursorInput = Schema.Union([
+  withCursor(SessionsDirectoryQuery),
+  withCursor(SessionsProjectQuery),
+  withCursor(SessionsAllQuery),
+])
+const SessionsCursorJson = Schema.fromJsonString(SessionsCursorInput)
+const encodeSessionsCursor = Schema.encodeSync(SessionsCursorJson)
+const decodeSessionsCursor = Schema.decodeUnknownEffect(SessionsCursorJson)
+
+export const SessionsCursor = Schema.String.pipe(
+  Schema.brand("V2SessionsCursor"),
+  withStatics((schema) => {
+    const make = schema.make
+    return {
+      make: (input: typeof SessionsCursorInput.Type) =>
+        make(Buffer.from(encodeSessionsCursor(input)).toString("base64url")),
+      parse: (input: string) => decodeSessionsCursor(Buffer.from(input, "base64url").toString("utf8")),
+    }
+  }),
+)
+export type SessionsCursor = typeof SessionsCursor.Type
+
+const SessionsCursorQuery = Schema.Struct({
+  cursor: SessionsCursor.annotate({
+    description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
+  }),
+  limit: SessionsQueryFields.limit,
+})
+
+export const SessionsQuery = Schema.Struct({
+  ...SessionsQueryFields,
+  directory: AbsolutePath.pipe(Schema.optional),
+  project: ProjectV2.ID.pipe(Schema.optional),
+  subpath: RelativePath.pipe(Schema.optional),
+  cursor: SessionsCursorQuery.fields.cursor.pipe(Schema.optional),
 }).annotate({ identifier: "V2SessionsQuery" })
 
 export const SessionGroup = HttpApiGroup.make("v2.session")
@@ -44,8 +91,8 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
       success: Schema.Struct({
         items: Schema.Array(SessionV2.Info),
         cursor: Schema.Struct({
-          previous: Schema.String.pipe(Schema.optional),
-          next: Schema.String.pipe(Schema.optional),
+          previous: SessionsCursor.pipe(Schema.optional),
+          next: SessionsCursor.pipe(Schema.optional),
         }),
       }).annotate({ identifier: "V2SessionsResponse" }),
       error: [InvalidCursorError, InvalidRequestError],

+ 33 - 119
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts

@@ -1,95 +1,12 @@
-import { WorkspaceV2 } from "@opencode-ai/core/workspace"
 import { SessionV2 } from "@opencode-ai/core/session"
-import { AbsolutePath } from "@opencode-ai/core/schema"
-import { DateTime, Effect, Option, Schema } from "effect"
+import { DateTime, Effect } from "effect"
 import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../../api"
-import {
-  InvalidCursorError,
-  InvalidRequestError,
-  ServiceUnavailableError,
-  SessionNotFoundError,
-  UnknownError,
-} from "../../errors"
+import { SessionsCursor } from "../../groups/v2/session"
+import { InvalidCursorError, ServiceUnavailableError, SessionNotFoundError, UnknownError } from "../../errors"
 
 const DefaultSessionsLimit = 50
 
-const SessionCursor = Schema.Struct({
-  id: SessionV2.Info.fields.id,
-  time: Schema.Finite,
-  order: Schema.Union([Schema.Literal("asc"), Schema.Literal("desc")]),
-  direction: Schema.Union([Schema.Literal("previous"), Schema.Literal("next")]),
-  directory: Schema.String.pipe(Schema.optional),
-  path: Schema.String.pipe(Schema.optional),
-  workspaceID: WorkspaceV2.ID.pipe(Schema.optional),
-  roots: Schema.Boolean.pipe(Schema.optional),
-  start: Schema.Finite.pipe(Schema.optional),
-  search: Schema.String.pipe(Schema.optional),
-})
-type SessionCursor = typeof SessionCursor.Type
-
-const decodeCursor = Schema.decodeUnknownSync(SessionCursor)
-
-function hasCursorFilter(query: {
-  readonly order?: unknown
-  readonly path?: unknown
-  readonly roots?: unknown
-  readonly start?: unknown
-  readonly search?: unknown
-}) {
-  return (
-    query.order !== undefined ||
-    query.path !== undefined ||
-    query.roots !== undefined ||
-    query.start !== undefined ||
-    query.search !== undefined
-  )
-}
-
-function hasCursorRoutingMismatch(
-  query: { readonly directory?: string; readonly workspace?: string },
-  decoded: SessionCursor | undefined,
-) {
-  if (!decoded) return false
-  if (query.directory !== undefined && query.directory !== decoded.directory) return true
-  return query.workspace !== undefined && query.workspace !== decoded.workspaceID
-}
-
-const sessionCursor = {
-  encode(
-    session: SessionV2.Info,
-    order: "asc" | "desc",
-    direction: "previous" | "next",
-    filters: Pick<SessionCursor, "directory" | "path" | "workspaceID" | "roots" | "start" | "search">,
-  ) {
-    return Buffer.from(
-      JSON.stringify({
-        ...filters,
-        id: session.id,
-        time: DateTime.toEpochMillis(session.time.updated),
-        order,
-        direction,
-      }),
-    ).toString("base64url")
-  },
-  decode(input: string) {
-    return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8")))
-  },
-}
-
-function decodeWorkspaceID(input: string | undefined) {
-  if (input === undefined) return Effect.succeed(undefined)
-  const workspaceID = Schema.decodeUnknownOption(WorkspaceV2.ID)(input)
-  if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value)
-  return Effect.fail(
-    new InvalidRequestError({
-      message: "Invalid workspace query parameter",
-      kind: "Query",
-      field: "workspace",
-    }),
-  )
-}
-
 export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
   Effect.gen(function* () {
     const session = yield* SessionV2.Service
@@ -98,45 +15,42 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
       .handle(
         "sessions",
         Effect.fn(function* (ctx) {
-          if (ctx.query.cursor && hasCursorFilter(ctx.query))
-            return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order or filters" })
-          const decoded = yield* Effect.try({
-            try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
-            catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
-          })
-          if (hasCursorRoutingMismatch(ctx.query, decoded))
-            return yield* new InvalidCursorError({ message: "Cursor does not match requested directory or workspace" })
-          const order = decoded?.order ?? ctx.query.order ?? "desc"
-          const filters = decoded ?? {
-            directory: ctx.query.directory,
-            path: ctx.query.path,
-            workspaceID: yield* decodeWorkspaceID(ctx.query.workspace),
-            roots: ctx.query.roots,
-            start: ctx.query.start,
-            search: ctx.query.search,
-          }
-          const input = {
+          const query =
+            ctx.query.cursor !== undefined
+              ? yield* SessionsCursor.parse(ctx.query.cursor).pipe(
+                  Effect.mapError(() => new InvalidCursorError({ message: "Invalid cursor" })),
+                )
+              : ctx.query
+          const sessions = yield* session.list({
+            ...query,
+            workspaceID: query.workspace,
             limit: ctx.query.limit ?? DefaultSessionsLimit,
-            order,
-            workspaceID: filters.workspaceID,
-            search: filters.search,
-            cursor: decoded ? { id: decoded.id, time: decoded.time, direction: decoded.direction } : undefined,
-          }
-          const sessions = yield* session.list(
-            filters.directory
-              ? {
-                  ...input,
-                  directory: AbsolutePath.make(filters.directory),
-                }
-              : input,
-          )
+          })
           const first = sessions[0]
           const last = sessions.at(-1)
           return {
             items: sessions,
             cursor: {
-              previous: first ? sessionCursor.encode(first, order, "previous", filters) : undefined,
-              next: last ? sessionCursor.encode(last, order, "next", filters) : undefined,
+              previous: first
+                ? SessionsCursor.make({
+                    ...query,
+                    anchor: {
+                      id: first.id,
+                      time: DateTime.toEpochMillis(first.time.created),
+                      direction: "previous",
+                    },
+                  })
+                : undefined,
+              next: last
+                ? SessionsCursor.make({
+                    ...query,
+                    anchor: {
+                      id: last.id,
+                      time: DateTime.toEpochMillis(last.time.created),
+                      direction: "next",
+                    },
+                  })
+                : undefined,
             },
           }
         }),

+ 1 - 6
packages/opencode/test/server/httpapi-query-schema-drift.test.ts

@@ -25,7 +25,6 @@ import {
 } from "../../src/server/routes/instance/httpapi/groups/session"
 import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
 import { MessagesQuery as V2MessagesQuery } from "../../src/server/routes/instance/httpapi/groups/v2/message"
-import { SessionsQuery as V2SessionsQuery } from "../../src/server/routes/instance/httpapi/groups/v2/session"
 import { QueryBoolean, QueryBooleanOpenApi } from "../../src/server/routes/instance/httpapi/groups/query"
 import { resetDatabase } from "../fixture/db"
 import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -55,7 +54,6 @@ const openApiDriftRoutes = [
   { method: "get", path: ExperimentalPaths.session, query: ExperimentalSessionListQuery },
   { method: "get", path: ExperimentalPaths.tool, query: ToolListQuery },
   { method: "get", path: InstancePaths.vcsDiff, query: VcsDiffQuery },
-  { method: "get", path: "/api/session", query: V2SessionsQuery },
   { method: "get", path: "/api/session/:sessionID/message", query: V2MessagesQuery },
 ] satisfies Array<{ method: Method; path: string; query: QuerySchema }>
 
@@ -72,8 +70,6 @@ const numericSdkQueryParams = [
     name: "limit",
     schema: { type: "integer", minimum: 0, maximum: Number.MAX_SAFE_INTEGER },
   },
-  { method: "get", path: "/api/session", name: "limit", schema: { type: "number" } },
-  { method: "get", path: "/api/session", name: "start", schema: { type: "number" } },
   { method: "get", path: "/api/session/:sessionID/message", name: "limit", schema: { type: "number" } },
 ] satisfies Array<{ method: Method; path: string; name: string; schema: OpenApiSchema }>
 
@@ -81,7 +77,6 @@ const booleanSdkQueryParams = [
   { method: "get", path: ExperimentalPaths.session, name: "roots" },
   { method: "get", path: ExperimentalPaths.session, name: "archived" },
   { method: "get", path: SessionPaths.list, name: "roots" },
-  { method: "get", path: "/api/session", name: "roots" },
 ] satisfies Array<{ method: Method; path: string; name: string }>
 
 const queryParamPatterns = [
@@ -239,7 +234,7 @@ describe("httpapi query schema drift", () => {
             ],
           },
           path: "/fixture",
-          query: { fields: {} },
+          query: Schema.Struct({}),
         }),
       ).toThrow("advertises query params not accepted by runtime schema")
     }),

+ 18 - 18
packages/opencode/test/server/httpapi-session.test.ts

@@ -444,17 +444,27 @@ describe("session HttpApi", () => {
         yield* insertLegacyAssistantMessage(session.id, 1)
         yield* insertLegacyAssistantMessage(session.id, 2)
 
-        const sessionPage = yield* request(`/api/session?limit=1`, { headers })
+        const sessionPage = yield* request(
+          `/api/session?${new URLSearchParams({
+            limit: "1",
+            order: "asc",
+            directory: test.directory,
+            search: "v2",
+          })}`,
+          { headers },
+        )
         const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
         expect(sessionCursor).toBeTruthy()
-
-        const cursorWithFilter = yield* request(`/api/session?cursor=${sessionCursor}&search=v2`, { headers })
-        expect(cursorWithFilter.status).toBe(400)
-        expect(yield* responseJson(cursorWithFilter)).toMatchObject({
-          _tag: "InvalidCursorError",
-          message: "Cursor cannot be combined with order or filters",
+        expect(JSON.parse(Buffer.from(sessionCursor!, "base64url").toString("utf8"))).toMatchObject({
+          order: "asc",
+          directory: test.directory,
+          search: "v2",
+          anchor: { id: session.id, direction: "next" },
         })
 
+        const sessionNextPage = yield* request(`/api/session?cursor=${sessionCursor}`, { headers })
+        expect(sessionNextPage.status).toBe(200)
+
         const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
         expect(invalidSessionCursor.status).toBe(400)
         expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
@@ -462,21 +472,11 @@ describe("session HttpApi", () => {
           message: "Invalid cursor",
         })
 
-        const mismatchedRouting = yield* request(`/api/session?cursor=${sessionCursor}&directory=/elsewhere`, {
-          headers,
-        })
-        expect(mismatchedRouting.status).toBe(400)
-        expect(yield* responseJson(mismatchedRouting)).toMatchObject({
-          _tag: "InvalidCursorError",
-          message: "Cursor does not match requested directory or workspace",
-        })
-
         const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
         expect(invalidWorkspace.status).toBe(400)
         expect(yield* responseJson(invalidWorkspace)).toMatchObject({
           _tag: "InvalidRequestError",
-          message: "Invalid workspace query parameter",
-          field: "workspace",
+          kind: "Query",
         })
 
         const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })

+ 6 - 8
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -4342,14 +4342,13 @@ export class Session3 extends HeyApiClient {
    */
   public list<ThrowOnError extends boolean = false>(
     parameters?: {
-      directory?: string
       workspace?: string
       limit?: number
       order?: "asc" | "desc"
-      path?: string
-      roots?: boolean | "true" | "false"
-      start?: number
       search?: string
+      directory?: string
+      project?: string
+      subpath?: string
       cursor?: string
     },
     options?: Options<never, ThrowOnError>,
@@ -4359,14 +4358,13 @@ export class Session3 extends HeyApiClient {
       [
         {
           args: [
-            { in: "query", key: "directory" },
             { in: "query", key: "workspace" },
             { in: "query", key: "limit" },
             { in: "query", key: "order" },
-            { in: "query", key: "path" },
-            { in: "query", key: "roots" },
-            { in: "query", key: "start" },
             { in: "query", key: "search" },
+            { in: "query", key: "directory" },
+            { in: "query", key: "project" },
+            { in: "query", key: "subpath" },
             { in: "query", key: "cursor" },
           ],
         },

+ 4 - 5
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -7969,16 +7969,15 @@ export type V2SessionListData = {
   body?: never
   path?: never
   query?: {
-    directory?: string
     workspace?: string
     limit?: number
     order?: "asc" | "desc"
-    path?: string
-    roots?: boolean | "true" | "false"
-    start?: number
     search?: string
+    directory?: string
+    project?: string
+    subpath?: string
     /**
-     * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response. Do not combine with order or filters.
+     * Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.
      */
     cursor?: string
   }