refactor(schema): move session pagination cursors to schema

This commit is contained in:
Kit Langton 2026-07-31 15:23:21 -04:00
commit 903acdf0d4
7 changed files with 225 additions and 166 deletions

View file

@ -0,0 +1,48 @@
export * as SessionMessagesCursor from "./session-messages-cursor.js"
import { Effect, Encoding, Result, Schema } from "effect"
import { SessionMessage } from "./session-message.js"
/**
* Shared session message pagination cursor. Lives in schema so the protocol
* message handler and the core plugin host paginate identically. The encoded
* cursor carries the page order, so a cursor cannot be combined with an
* explicit order.
*/
export const DefaultLimit = 50
export const Order = Schema.Literals(["asc", "desc"])
export type Order = typeof Order.Type
const Payload = Schema.Struct({
id: SessionMessage.ID,
order: Order,
direction: Schema.Literals(["previous", "next"]),
})
export type Payload = typeof Payload.Type
const PayloadJson = Schema.fromJsonString(Payload)
const encodePayload = Schema.encodeSync(PayloadJson)
const decodePayload = Schema.decodeUnknownEffect(PayloadJson)
const invalidCursor = "Invalid cursor" as const
export const make = (input: Payload) => Encoding.encodeBase64Url(encodePayload(input))
export const parse = (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodePayload(result.success).pipe(Effect.mapError(() => invalidCursor))
})
/** previous/next cursors for one returned page of messages. */
export const page = (data: ReadonlyArray<SessionMessage.Info>, order: Order) => {
const first = data[0]
const last = data.at(-1)
return {
previous: first ? make({ id: first.id, order, direction: "previous" }) : undefined,
next: last ? make({ id: last.id, order, direction: "next" }) : undefined,
}
}

View file

@ -0,0 +1,125 @@
export * as SessionsQuery from "./sessions-query.js"
import { DateTime, Effect, Encoding, Result, Schema, SchemaGetter, Struct } from "effect"
import { Project } from "./project.js"
import { AbsolutePath, PositiveInt, RelativePath, statics } from "./schema.js"
import { Session } from "./session.js"
import { Workspace } from "./workspace.js"
/**
* Shared session list query and cursor codec. Lives in schema so both the
* protocol session group and the core plugin host paginate identically.
*/
export const DefaultLimit = 50
const ParentIDFilter = Schema.Union([
Session.ID,
Schema.Null.pipe(
Schema.encodeTo(Schema.Literal("null"), {
decode: SchemaGetter.transform(() => null),
encode: SchemaGetter.transform(() => "null" as const),
}),
),
]).annotate({
description: "Filter by parent session. Use null to return only root sessions.",
})
export const Fields = {
workspace: Workspace.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.",
}),
search: Schema.optional(Schema.String),
parentID: ParentIDFilter.pipe(Schema.optional),
}
const DirectoryQuery = Schema.Struct({
...Fields,
directory: AbsolutePath,
})
const ProjectQuery = Schema.Struct({
...Fields,
project: Project.ID,
subpath: RelativePath.pipe(Schema.optional),
})
const AllQuery = Schema.Struct(Fields)
const withAnchor = <Fields extends Schema.Struct.Fields>(schema: Schema.Struct<Fields>) =>
schema.mapFields((fields) => ({
...Struct.omit(fields, ["limit"]),
anchor: Session.ListAnchor,
}))
const CursorInput = Schema.Union([withAnchor(DirectoryQuery), withAnchor(ProjectQuery), withAnchor(AllQuery)])
const CursorJson = Schema.fromJsonString(CursorInput)
const encodeCursor = Schema.encodeSync(CursorJson)
const decodeCursor = Schema.decodeUnknownEffect(CursorJson)
const invalidCursor = "Invalid cursor" as const
type PageQuery = Omit<typeof Query.Type, "limit" | "cursor">
export const Cursor = Schema.String.pipe(
Schema.brand("SessionsCursor"),
statics((schema) => {
const make = schema.make.bind(schema)
const makeCursor = (input: typeof CursorInput.Type) => make(Encoding.encodeBase64Url(encodeCursor(input)))
return {
make: makeCursor,
parse: (input: string) =>
Effect.suspend(() => {
const result = Encoding.decodeBase64UrlString(input)
return Result.isFailure(result)
? Effect.fail(invalidCursor)
: decodeCursor(result.success).pipe(Effect.mapError(() => invalidCursor))
}),
/**
* previous/next cursors for one returned page, re-anchoring the
* originating query at the page's first and last items.
*/
page: (query: PageQuery, data: ReadonlyArray<Session.Info>) => {
const first = data[0]
const last = data.at(-1)
return {
previous: first
? makeCursor({
...query,
anchor: {
id: first.id,
time: DateTime.toEpochMillis(first.time.updated),
direction: "previous",
},
})
: undefined,
next: last
? makeCursor({
...query,
anchor: {
id: last.id,
time: DateTime.toEpochMillis(last.time.updated),
direction: "next",
},
})
: undefined,
}
},
}
}),
)
export type Cursor = typeof Cursor.Type
export const Query = Schema.Struct({
...Fields,
directory: AbsolutePath.pipe(Schema.optional),
project: Project.ID.pipe(Schema.optional),
subpath: RelativePath.pipe(Schema.optional),
cursor: Cursor.annotate({
description: "Opaque pagination cursor returned as cursor.previous or cursor.next in the previous response.",
}).pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export type Query = typeof Query.Type

View file

@ -0,0 +1,33 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { SessionsQuery } from "../src/sessions-query.js"
import { SessionMessagesCursor } from "../src/session-messages-cursor.js"
import { SessionMessage } from "../src/session-message.js"
import { Session } from "../src/session.js"
describe("SessionsQuery.Cursor", () => {
test("round trips without Node globals", async () => {
const input = {
workspace: undefined,
search: "protocol",
order: "desc" as const,
anchor: { id: Session.ID.make("ses_test"), time: 1, direction: "next" as const },
}
const cursor = SessionsQuery.Cursor.make(input)
expect(await Effect.runPromise(SessionsQuery.Cursor.parse(cursor))).toEqual(input)
})
})
describe("SessionMessagesCursor", () => {
test("round trips", async () => {
const input = {
id: SessionMessage.ID.make("msg_test"),
order: "desc" as const,
direction: "next" as const,
}
const cursor = SessionMessagesCursor.make(input)
expect(await Effect.runPromise(SessionMessagesCursor.parse(cursor))).toEqual(input)
})
})