fix(opencode): enforce storage path invariants (#29666)
This commit is contained in:
@@ -297,7 +297,7 @@ export const layer = Layer.effect(
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: result.id,
|
||||
worktree: result.worktree,
|
||||
worktree: AbsolutePath.make(result.worktree),
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
@@ -306,13 +306,13 @@ export const layer = Layer.effect(
|
||||
time_created: result.time.created,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)),
|
||||
commands: result.commands,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: ProjectTable.id,
|
||||
set: {
|
||||
worktree: result.worktree,
|
||||
worktree: AbsolutePath.make(result.worktree),
|
||||
vcs: result.vcs ?? null,
|
||||
name: result.name,
|
||||
icon_url: result.icon?.url,
|
||||
@@ -320,7 +320,7 @@ export const layer = Layer.effect(
|
||||
icon_color: result.icon?.color,
|
||||
time_updated: result.time.updated,
|
||||
time_initialized: result.time.initialized,
|
||||
sandboxes: result.sandboxes,
|
||||
sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)),
|
||||
commands: result.commands,
|
||||
},
|
||||
})
|
||||
@@ -451,8 +451,9 @@ export const layer = Layer.effect(
|
||||
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sandbox = AbsolutePath.make(directory)
|
||||
const sboxes = [...row.sandboxes]
|
||||
if (!sboxes.includes(directory)) sboxes.push(directory)
|
||||
if (!sboxes.includes(sandbox)) sboxes.push(sandbox)
|
||||
const result = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
@@ -467,7 +468,8 @@ export const layer = Layer.effect(
|
||||
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
const sandbox = AbsolutePath.make(directory)
|
||||
const sboxes = row.sandboxes.filter((s) => s !== sandbox)
|
||||
const result = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
|
||||
@@ -19,6 +19,7 @@ import { gte } from "drizzle-orm"
|
||||
import { isNull } from "drizzle-orm"
|
||||
import { desc } from "drizzle-orm"
|
||||
import { like } from "drizzle-orm"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { inArray } from "drizzle-orm"
|
||||
import { lt } from "drizzle-orm"
|
||||
import { or } from "drizzle-orm"
|
||||
@@ -1047,7 +1048,10 @@ function listByProject(
|
||||
}
|
||||
if (input.path !== undefined) {
|
||||
if (input.path) {
|
||||
const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
|
||||
const conds = [
|
||||
eq(SessionTable.path, input.path),
|
||||
like(SessionTable.path, sql.param(`${input.path}/%`, SessionTable.path)),
|
||||
]
|
||||
|
||||
conditions.push(
|
||||
input.directory
|
||||
|
||||
@@ -13,6 +13,7 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
@@ -304,7 +305,7 @@ function insertProject(id: ProjectV2.ID, worktree: string) {
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id,
|
||||
worktree,
|
||||
worktree: AbsolutePath.make(worktree),
|
||||
vcs: null,
|
||||
name: null,
|
||||
time_created: Date.now(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -48,7 +49,7 @@ function ensureGlobal() {
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: ProjectV2.ID.global,
|
||||
worktree: "/",
|
||||
worktree: AbsolutePath.make("/"),
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
sandboxes: [],
|
||||
|
||||
@@ -99,6 +99,33 @@ describe("session.list", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"matches a session regardless of directory separator on Windows",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "win32") return
|
||||
const test = yield* TestInstance
|
||||
const dir = path.join(test.directory, "packages", "opencode")
|
||||
yield* Effect.promise(() => mkdir(dir, { recursive: true }))
|
||||
|
||||
const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir))
|
||||
|
||||
// A forward-slash query (e.g. from the SDK/HTTP layer) must still find it —
|
||||
// this is the regression: backslash-stored vs forward-slash-queried.
|
||||
const forwardIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ directory: dir.replaceAll("\\", "/") }),
|
||||
)).map((session) => session.id)
|
||||
expect(forwardIDs).toContain(created.id)
|
||||
|
||||
// The native form must keep matching too.
|
||||
const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map(
|
||||
(session) => session.id,
|
||||
)
|
||||
expect(nativeIDs).toContain(created.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"filters by path and ignores directory when path is provided",
|
||||
() =>
|
||||
@@ -132,6 +159,14 @@ describe("session.list", () => {
|
||||
expect(pathIDs).toContain(current.id)
|
||||
expect(pathIDs).toContain(deeper.id)
|
||||
expect(pathIDs).not.toContain(sibling.id)
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const windowsPathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({ path: "packages\\opencode\\src" }),
|
||||
)).map((session) => session.id)
|
||||
expect(windowsPathIDs).toContain(current.id)
|
||||
expect(windowsPathIDs).toContain(deeper.id)
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import { JsonMigration } from "@/storage/json-migration"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionShareTable } from "@opencode-ai/core/share/sql"
|
||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||
@@ -128,9 +129,39 @@ describe("JSON to SQLite migration", () => {
|
||||
const projects = db.select().from(ProjectTable).all()
|
||||
expect(projects.length).toBe(1)
|
||||
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
|
||||
expect(projects[0].worktree).toBe("/test/path")
|
||||
expect(projects[0].worktree).toBe(AbsolutePath.make("/test/path"))
|
||||
expect(projects[0].name).toBe("Test Project")
|
||||
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
|
||||
expect(projects[0].sandboxes).toEqual([AbsolutePath.make("/test/sandbox")])
|
||||
})
|
||||
|
||||
test("stores imported Windows project and session paths in storage form", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await writeProject(storageDir, {
|
||||
id: "proj_test123abc",
|
||||
worktree: "C:\\Repo\\Thing",
|
||||
vcs: "git",
|
||||
sandboxes: ["C:\\Repo\\Thing\\sandbox"],
|
||||
})
|
||||
await writeSession(storageDir, "proj_test123abc", {
|
||||
id: "ses_test456def",
|
||||
slug: "storage-path",
|
||||
directory: "C:\\Repo\\Thing\\packages\\api",
|
||||
path: "packages\\api",
|
||||
title: "Storage Path",
|
||||
version: "test",
|
||||
})
|
||||
|
||||
await JsonMigration.run(db)
|
||||
|
||||
expect(sqlite.query("SELECT worktree, sandboxes FROM project WHERE id = ?").get("proj_test123abc")).toEqual({
|
||||
worktree: "C:/Repo/Thing",
|
||||
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
|
||||
})
|
||||
expect(sqlite.query("SELECT directory, path FROM session WHERE id = ?").get("ses_test456def")).toEqual({
|
||||
directory: "C:/Repo/Thing/packages/api",
|
||||
path: "packages/api",
|
||||
})
|
||||
})
|
||||
|
||||
test("uses filename for project id when JSON has different value", async () => {
|
||||
|
||||
Reference in New Issue
Block a user