Compare commits

...
Author SHA1 Message Date
Shoubhit Dash 0b6f0dd0af fix(core): optimize session list pagination 2026-08-25 16:36:26 +05:30
8 changed files with 155 additions and 17 deletions
+29 -3
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "8ec8230a-2d6e-48c8-9dc8-c587c98a0008",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -2029,12 +2029,38 @@
{
"value": "parent_id",
"isExpression": false
},
{
"value": "time_updated",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_v2_parent_idx",
"name": "session_v2_parent_time_updated_id_idx",
"entityType": "indexes",
"table": "session_v2"
},
{
"columns": [
{
"value": "time_updated",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_v2_time_updated_id_idx",
"entityType": "indexes",
"table": "session_v2"
},
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260825110242_session_time_updated_index.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,17 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260825110242_session_time_updated_index",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`DROP INDEX IF EXISTS \`session_v2_parent_idx\`;`)
yield* tx.run(
`CREATE INDEX \`session_v2_parent_time_updated_id_idx\` ON \`session_v2\` (\`parent_id\`,\`time_updated\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`session_v2_time_updated_id_idx\` ON \`session_v2\` (\`time_updated\`,\`id\`);`)
})
},
}
export default migration
+4 -1
View File
@@ -270,7 +270,10 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_parent_time_updated_id_idx\` ON \`session_v2\` (\`parent_id\`,\`time_updated\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`session_v2_time_updated_id_idx\` ON \`session_v2\` (\`time_updated\`,\`id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
)
+8 -12
View File
@@ -3,7 +3,7 @@ export * from "./session/schema.js"
import { Cause, Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { and, asc, desc, eq, gt, isNull, lt, sql, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
import { Workspace } from "./workspace.js"
import { Model } from "./model.js"
@@ -488,24 +488,20 @@ const layer = Layer.effect(
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if (input.search)
conditions.push(
sql`${SessionTable.title} LIKE ${`%${input.search.replace(/[\\%_]/g, "\\$&")}%`} ESCAPE ${"\\"}`,
)
if (input.parentID !== undefined)
conditions.push(
input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID),
)
if (input.anchor) {
if (input.anchor)
conditions.push(
order === "asc"
? or(
gt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), gt(SessionTable.id, input.anchor.id)),
)!
: or(
lt(sortColumn, input.anchor.time),
and(eq(sortColumn, input.anchor.time), lt(SessionTable.id, input.anchor.id)),
)!,
? sql`(${sortColumn}, ${SessionTable.id}) > (${input.anchor.time}, ${input.anchor.id})`
: sql`(${sortColumn}, ${SessionTable.id}) < (${input.anchor.time}, ${input.anchor.id})`,
)
}
const query = db
.select()
.from(SessionTable)
+2 -1
View File
@@ -69,7 +69,8 @@ export const SessionTable = sqliteTable(
(table) => [
index("session_v2_project_idx").on(table.project_id),
index("session_v2_workspace_idx").on(table.workspace_id),
index("session_v2_parent_idx").on(table.parent_id),
index("session_v2_parent_time_updated_id_idx").on(table.parent_id, table.time_updated, table.id),
index("session_v2_time_updated_id_idx").on(table.time_updated, table.id),
index("session_v2_time_suspended_idx")
.on(table.time_suspended)
.where(sql`${table.time_suspended} is not null`),
@@ -18,6 +18,7 @@ import workspaceMigration from "@opencode-ai/core/database/migration/20260808023
import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts"
import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox"
import sessionViewedStateMigration from "@opencode-ai/core/database/migration/20260819222447_session_viewed_state"
import sessionTimeUpdatedIndexMigration from "@opencode-ai/core/database/migration/20260825110242_session_time_updated_index"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -100,6 +101,35 @@ describe("DatabaseMigration", () => {
)
})
test("replaces the parent index with ordered session listing indexes", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session_v2 (id text PRIMARY KEY, parent_id text, time_updated integer NOT NULL)`)
yield* db.run(sql`CREATE INDEX session_v2_parent_idx ON session_v2 (parent_id)`)
yield* DatabaseMigration.applyOnly(db, [sessionTimeUpdatedIndexMigration])
expect(
yield* db.all(sql`
SELECT name FROM sqlite_master
WHERE type = 'index' AND name LIKE 'session_v2_%'
ORDER BY name
`),
).toEqual([{ name: "session_v2_parent_time_updated_id_idx" }, { name: "session_v2_time_updated_id_idx" }])
const plan = yield* db.all<{ detail: string }>(sql`
EXPLAIN QUERY PLAN
SELECT id FROM session_v2
WHERE parent_id IS NULL AND (time_updated, id) < (${10}, ${"ses_anchor"})
ORDER BY time_updated DESC, id DESC
LIMIT 50
`)
expect(plan.some((item) => item.detail.includes("session_v2_parent_time_updated_id_idx"))).toBe(true)
expect(plan.some((item) => item.detail.includes("TEMP B-TREE"))).toBe(false)
}),
)
})
test("rejects a non-empty database without a session table", async () => {
await expect(
run(
+63
View File
@@ -291,6 +291,69 @@ describe("Session.create", () => {
}),
)
it.effect("paginates sessions with matching update times in both directions", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const { db } = yield* Database.Service
const older = yield* session.create({ id: Session.ID.make("ses_a"), location, title: "older" })
const tied = yield* session.create({ id: Session.ID.make("ses_b"), location, title: "tied" })
const newest = yield* session.create({ id: Session.ID.make("ses_c"), location, title: "newest" })
yield* Effect.forEach(
[
{ id: older.id, time: 10 },
{ id: tied.id, time: 10 },
{ id: newest.id, time: 20 },
],
(item) => db.update(SessionTable).set({ time_updated: item.time }).where(eq(SessionTable.id, item.id)).run(),
)
expect((yield* session.list({ limit: 1, order: "desc" })).data.map((item) => item.id)).toEqual([newest.id])
expect(
(yield* session.list({
limit: 1,
order: "desc",
anchor: { id: newest.id, time: 20, direction: "next" },
})).data.map((item) => item.id),
).toEqual([tied.id])
expect(
(yield* session.list({
limit: 1,
order: "desc",
anchor: { id: tied.id, time: 10, direction: "next" },
})).data.map((item) => item.id),
).toEqual([older.id])
expect(
(yield* session.list({
limit: 1,
order: "desc",
anchor: { id: older.id, time: 10, direction: "previous" },
})).data.map((item) => item.id),
).toEqual([tied.id])
expect(
(yield* session.list({
limit: 2,
order: "asc",
anchor: { id: older.id, time: 10, direction: "next" },
})).data.map((item) => item.id),
).toEqual([tied.id, newest.id])
}),
)
it.effect("treats SQL wildcard characters in session searches literally", () =>
Effect.gen(function* () {
const session = yield* Session.Service
yield* Effect.forEach(
["100% complete", "100 percent", "under_score", "underscore", "back\\slash", "backslash"],
(title) => session.create({ location, title }),
)
expect((yield* session.list({ search: "%" })).data.map((item) => item.title)).toEqual(["100% complete"])
expect((yield* session.list({ search: "_" })).data.map((item) => item.title)).toEqual(["under_score"])
expect((yield* session.list({ search: "\\" })).data.map((item) => item.title)).toEqual(["back\\slash"])
}),
)
it.effect("filters direct child sessions by parent ID", () =>
Effect.gen(function* () {
const session = yield* Session.Service