refactor(schema): move session pagination cursors to schema
This commit is contained in:
parent
2cfe8883ea
commit
903acdf0d4
7 changed files with 225 additions and 166 deletions
48
packages/schema/src/session-messages-cursor.ts
Normal file
48
packages/schema/src/session-messages-cursor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
125
packages/schema/src/sessions-query.ts
Normal file
125
packages/schema/src/sessions-query.ts
Normal 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
|
||||
33
packages/schema/test/sessions-query.test.ts
Normal file
33
packages/schema/test/sessions-query.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue