Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 58 additions & 3 deletions packages/core/src/session/store.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
export * as SessionStore from "./store.js"

import { and, asc, desc, eq, gt, isNotNull, isNull, like, lt, notInArray, or, sql, type SQL } from "drizzle-orm"
import {
and,
asc,
desc,
eq,
gt,
inArray,
isNotNull,
isNull,
like,
lt,
notInArray,
or,
sql,
type SQL,
} from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode/schema/project"
import { Workspace } from "@opencode/schema/workspace"
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode/schema/schema"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { FSUtil } from "@opencode/util/fs-util"
import { SessionHistory } from "./history.js"
import { MessageDecodeError } from "./error.js"
import { SessionMessage } from "./message.js"
Expand Down Expand Up @@ -90,6 +106,7 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const fs = yield* FSUtil.Service

return Service.of({
get: Effect.fnUntraced(function* (sessionID) {
Expand All @@ -102,7 +119,6 @@ const layer = Layer.effect(
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const sortColumn = SessionTable.time_updated
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 ("project" in input && input.subpath !== undefined) conditions.push(eq(SessionTable.path, input.subpath))
Expand All @@ -124,6 +140,45 @@ const layer = Layer.effect(
)!,
)
}
if ("directory" in input) {
// Older sessions and native moves can retain a different spelling of
// the same directory. Case folding only finds candidates: filesystem
// resolution must prove identity, including case-sensitive volumes.
// normalizePath uses native realpath on Windows; Bun's callback-based
// realPath can otherwise preserve the caller's case spelling.
// Resolve before LIMIT so excluded directories cannot shorten a page.
const candidates = yield* db
.selectDistinct({ directory: SessionTable.directory })
.from(SessionTable)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.all()
.pipe(Effect.orDie)
const aliases = candidates.filter(
(row) => row.directory !== input.directory && row.directory.toUpperCase() === input.directory.toUpperCase(),
)
const canonical =
aliases.length > 0
? yield* fs.realPath(input.directory).pipe(
Effect.map(FSUtil.normalizePath),
Effect.orElseSucceed(() => undefined),
)
: undefined
const directories =
canonical === undefined
? []
: yield* Effect.forEach(
aliases,
(row) =>
fs.realPath(row.directory).pipe(
Effect.map(FSUtil.normalizePath),
Effect.map((resolved) => (resolved === canonical ? [row.directory] : [])),
Effect.orElseSucceed(() => []),
),
{ concurrency: 8 },
)
// Exact spelling remains readable even for missing/offline histories.
conditions.push(inArray(SessionTable.directory, [input.directory, ...directories.flat()]))
}
const query = db
.select()
.from(SessionTable)
Expand Down Expand Up @@ -253,4 +308,4 @@ const layer = Layer.effect(
}),
)

export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, FSUtil.node] })
113 changes: 107 additions & 6 deletions packages/core/test/session-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { DateTime, Effect } from "effect"
import path from "node:path"
import { Bus } from "@opencode/core/bus"
import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
Expand All @@ -12,16 +13,21 @@ import { AbsolutePath } from "@opencode/schema/schema"
import { Session } from "@opencode/schema/session"
import { SessionEvent } from "@opencode/schema/session-event"
import { SessionMessage } from "@opencode/schema/session-message"
import { Workspace } from "@opencode/schema/workspace"
import { FSUtil } from "@opencode/util/fs-util"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { testEffect } from "./lib/effect"

const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]), [
Bus.node.replace(Bus.configured({ persist: true })),
]),
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, FSUtil.node]),
[Bus.node.replace(Bus.configured({ persist: true }))],
),
)

const seedSessions = (rows: { id: string; updated: number }[]) =>
const seedSessions = (
rows: { id: string; updated: number; directory?: string; parentID?: Session.ID; workspaceID?: Workspace.ID }[],
) =>
Effect.gen(function* () {
const database = yield* Database.Service
const bus = yield* Bus.Service
Expand All @@ -33,7 +39,11 @@ const seedSessions = (rows: { id: string; updated: number }[]) =>
yield* bus.publish(SessionEvent.Created, {
sessionID,
projectID: Project.ID.global,
location: { directory },
location: {
directory: row.directory ? AbsolutePath.make(row.directory) : directory,
workspaceID: row.workspaceID,
},
parentID: row.parentID,
slug: "store-test",
version: "test",
})
Expand All @@ -51,6 +61,97 @@ const seedSessions = (rows: { id: string; updated: number }[]) =>
})

describe("SessionStore", () => {
for (const [upperName, lowerName] of [
["Checkout", "checkout"],
["ÉQUIPE", "équipe"],
["Σ", "ς"],
]) {
it.effect(
`matches ${upperName}/${lowerName} historical directory spellings only when the filesystem confirms identity`,
() =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const store = yield* SessionStore.Service
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-directory-case-" })
const upper = AbsolutePath.make(path.join(tmp, upperName!))
const lower = AbsolutePath.make(path.join(tmp, lowerName!))
yield* fs.makeDirectory(upper, { recursive: true })
yield* fs.makeDirectory(lower, { recursive: true })
// These are one directory on a normal Windows/macOS filesystem and two
// on a case-sensitive filesystem. Exercise the actual host's behavior.
const same = (yield* fs.readDirectory(tmp)).length === 1
yield* seedSessions([
{ id: "ses_upper", updated: 3, directory: upper },
{ id: "ses_lower", updated: 2, directory: lower },
{ id: "ses_other", updated: 4, directory: path.join(tmp, "Checkout-other") },
])
expect((yield* store.list({ directory: upper })).map((session) => String(session.id))).toEqual(
same ? ["ses_upper", "ses_lower"] : ["ses_upper"],
)
expect((yield* store.list({ directory: lower })).map((session) => String(session.id))).toEqual(
same ? ["ses_upper", "ses_lower"] : ["ses_lower"],
)
expect((yield* store.get(Session.ID.make("ses_lower")))?.location.directory).toBe(lower)
}),
)
}

it.effect("applies directory identity before page limits and retains search, parent and workspace selectors", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const store = yield* SessionStore.Service
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-directory-pages-" })
const upper = AbsolutePath.make(path.join(tmp, "Checkout"))
const lower = AbsolutePath.make(path.join(tmp, "checkout"))
yield* fs.makeDirectory(upper, { recursive: true })
yield* fs.makeDirectory(lower, { recursive: true })
const same = (yield* fs.readDirectory(tmp)).length === 1
const workspaceID = Workspace.ID.make("wrk_test")
yield* seedSessions([
{ id: "ses_match_a", updated: 5, directory: upper, workspaceID },
{ id: "ses_match_b", updated: 4, directory: lower, workspaceID },
{ id: "ses_match_c", updated: 3, directory: upper, workspaceID },
{ id: "ses_match_child", updated: 8, directory: lower, workspaceID, parentID: Session.ID.make("ses_match_a") },
{ id: "ses_match_foreign", updated: 7, directory: lower, workspaceID: Workspace.ID.make("wrk_other") },
{ id: "ses_unrelated", updated: 6, directory: lower, workspaceID },
])
const input = { directory: upper, workspaceID, parentID: null, search: "match", limit: 2 } as const
const first = yield* store.list(input)
expect(first.map((session) => String(session.id))).toEqual(
same ? ["ses_match_a", "ses_match_b"] : ["ses_match_a", "ses_match_c"],
)
const last = first[first.length - 1]!
const next = yield* store.list({
...input,
anchor: { id: last.id, time: DateTime.toEpochMillis(last.time.updated), direction: "next" },
})
expect(next.map((session) => String(session.id))).toEqual(same ? ["ses_match_c"] : [])
const previous = yield* store.list({
...input,
anchor: { id: Session.ID.make("ses_match_c"), time: 3, direction: "previous" },
})
expect(previous.map((session) => String(session.id))).toEqual(
same ? ["ses_match_a", "ses_match_b"] : ["ses_match_a"],
)
}),
)

it.effect("preserves exact missing-directory history without conflating unresolvable case variants", () =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const store = yield* SessionStore.Service
const tmp = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-directory-missing-" })
const upper = AbsolutePath.make(path.join(tmp, "Missing"))
const lower = AbsolutePath.make(path.join(tmp, "missing"))
yield* seedSessions([
{ id: "ses_upper", updated: 2, directory: upper },
{ id: "ses_lower", updated: 1, directory: lower },
])
expect((yield* store.list({ directory: upper })).map((session) => String(session.id))).toEqual(["ses_upper"])
expect((yield* store.list({ directory: lower })).map((session) => String(session.id))).toEqual(["ses_lower"])
}),
)

it.effect("lists by updated time and ID with exclusive two-item pages in either direction", () =>
Effect.gen(function* () {
yield* seedSessions([
Expand Down
Loading