mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-02 06:16:48 +02:00
fix(opencode): enforce storage path invariants (#29666)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
packages/core/migration/**/snapshot.json linguist-generated
|
||||
packages/core/src/database/migration.gen.ts linguist-generated
|
||||
@@ -0,0 +1,7 @@
|
||||
UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';
|
||||
--> statement-breakpoint
|
||||
UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');
|
||||
--> statement-breakpoint
|
||||
UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';
|
||||
--> statement-breakpoint
|
||||
UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');
|
||||
+1634
File diff suppressed because it is too large
Load Diff
+1
@@ -23,5 +23,6 @@ export const migrations = (
|
||||
import("./migration/20260510033149_session_usage"),
|
||||
import("./migration/20260511000411_data_migration_state"),
|
||||
import("./migration/20260511173437_session-metadata"),
|
||||
import("./migration/20260601010001_normalize_storage_paths"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260601010001_normalize_storage_paths",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';`)
|
||||
yield* tx.run(`UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');`)
|
||||
yield* tx.run(`UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';`)
|
||||
yield* tx.run(`UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -0,0 +1,91 @@
|
||||
import nodePath from "path"
|
||||
import { customType } from "drizzle-orm/sqlite-core"
|
||||
import { AbsolutePath } from "../schema"
|
||||
|
||||
function storagePath(input: string) {
|
||||
if (process.platform !== "win32") return input
|
||||
return input.replaceAll("\\", "/")
|
||||
}
|
||||
|
||||
function isWindowsStoragePath(input: string) {
|
||||
return /^[A-Za-z]:\//.test(input) || input.startsWith("//")
|
||||
}
|
||||
|
||||
function absolute(input: string) {
|
||||
const result = storagePath(input)
|
||||
if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) {
|
||||
throw new Error(`Path is not absolute: ${input}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function toPlatform(input: string) {
|
||||
if (process.platform !== "win32" || !isWindowsStoragePath(input)) return input
|
||||
return input.replaceAll("/", "\\")
|
||||
}
|
||||
|
||||
export const absoluteColumn = customType<{
|
||||
data: AbsolutePath
|
||||
driverData: string
|
||||
driverOutput: string
|
||||
}>({
|
||||
dataType() {
|
||||
return "text"
|
||||
},
|
||||
toDriver(input) {
|
||||
return absolute(input)
|
||||
},
|
||||
fromDriver(input) {
|
||||
return AbsolutePath.make(toPlatform(absolute(input)))
|
||||
},
|
||||
})
|
||||
|
||||
// Legacy sessions may persist an empty directory. Keep that existing value
|
||||
// readable while normalizing and validating every real directory.
|
||||
export const directoryColumn = customType<{
|
||||
data: string
|
||||
driverData: string
|
||||
driverOutput: string
|
||||
}>({
|
||||
dataType() {
|
||||
return "text"
|
||||
},
|
||||
toDriver(input) {
|
||||
return input ? absolute(input) : input
|
||||
},
|
||||
fromDriver(input) {
|
||||
return input ? toPlatform(absolute(input)) : input
|
||||
},
|
||||
})
|
||||
|
||||
export const pathColumn = customType<{
|
||||
data: string
|
||||
driverData: string
|
||||
driverOutput: string
|
||||
}>({
|
||||
dataType() {
|
||||
return "text"
|
||||
},
|
||||
toDriver(input) {
|
||||
return storagePath(input)
|
||||
},
|
||||
fromDriver(input) {
|
||||
return storagePath(input)
|
||||
},
|
||||
})
|
||||
|
||||
export const absoluteArrayColumn = customType<{
|
||||
data: AbsolutePath[]
|
||||
driverData: string
|
||||
driverOutput: string
|
||||
}>({
|
||||
dataType() {
|
||||
return "text"
|
||||
},
|
||||
toDriver(input) {
|
||||
return JSON.stringify(input.map(absolute))
|
||||
},
|
||||
fromDriver(input) {
|
||||
return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
|
||||
},
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
|
||||
import * as DatabasePath from "../database/path"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
import { ProjectV2 } from "../project"
|
||||
|
||||
export const ProjectTable = sqliteTable("project", {
|
||||
id: text().$type<ProjectV2.ID>().primaryKey(),
|
||||
worktree: text().notNull(),
|
||||
worktree: DatabasePath.absoluteColumn().notNull(),
|
||||
vcs: text(),
|
||||
name: text(),
|
||||
icon_url: text(),
|
||||
@@ -12,6 +13,6 @@ export const ProjectTable = sqliteTable("project", {
|
||||
icon_color: text(),
|
||||
...Timestamps,
|
||||
time_initialized: integer(),
|
||||
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
|
||||
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
|
||||
commands: text({ mode: "json" }).$type<{ start?: string }>(),
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
|
||||
import * as DatabasePath from "../database/path"
|
||||
import { ProjectTable } from "../project/sql"
|
||||
import type { SessionMessage } from "./message"
|
||||
import type { Snapshot } from "../snapshot"
|
||||
@@ -24,8 +25,8 @@ export const SessionTable = sqliteTable(
|
||||
workspace_id: text().$type<WorkspaceV2.ID>(),
|
||||
parent_id: text().$type<SessionSchema.ID>(),
|
||||
slug: text().notNull(),
|
||||
directory: text().notNull(),
|
||||
path: text(),
|
||||
directory: DatabasePath.directoryColumn().notNull(),
|
||||
path: DatabasePath.pathColumn(),
|
||||
title: text().notNull(),
|
||||
version: text().notNull(),
|
||||
share_url: text(),
|
||||
|
||||
@@ -4,9 +4,15 @@ import { fileURLToPath } from "url"
|
||||
import { SqliteClient } from "@effect/sql-sqlite-bun"
|
||||
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
import { Effect } from "effect"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { eq, inArray, sql } from "drizzle-orm"
|
||||
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
|
||||
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
|
||||
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
|
||||
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
|
||||
|
||||
@@ -37,7 +43,7 @@ describe("DatabaseMigration", () => {
|
||||
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
|
||||
name: "session",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 21 })
|
||||
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -71,6 +77,151 @@ describe("DatabaseMigration", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
|
||||
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
|
||||
// Windows-shaped rows (drive + backslash) must be normalized.
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
|
||||
"C:\\Repo\\Thing\\sandbox",
|
||||
])})`,
|
||||
)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
|
||||
)
|
||||
// UNC worktrees and their sandboxes must normalize too (not just drive paths).
|
||||
yield* db.run(
|
||||
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
|
||||
"\\\\server\\share\\sandbox",
|
||||
])})`,
|
||||
)
|
||||
// The "/" worktree sentinel and POSIX paths (including a pathological
|
||||
// backslash in a POSIX filename) must survive byte-for-byte.
|
||||
yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
|
||||
yield* db.run(
|
||||
sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
|
||||
)
|
||||
|
||||
yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
|
||||
|
||||
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
|
||||
worktree: "C:/Repo/Thing",
|
||||
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
|
||||
directory: "C:/Repo/Thing/packages/api",
|
||||
path: "packages/api",
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
|
||||
worktree: "//server/share",
|
||||
sandboxes: JSON.stringify(["//server/share/sandbox"]),
|
||||
})
|
||||
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
|
||||
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
|
||||
directory: "/home/me/we\\ird",
|
||||
path: "src\\weird",
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("maps native Windows paths through database columns", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDb
|
||||
yield* DatabaseMigration.apply(db)
|
||||
const projectID = ProjectV2.ID.make("codec_project")
|
||||
const worktree = AbsolutePath.make("C:\\Repo\\Thing")
|
||||
const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
|
||||
const directory = "C:\\Repo\\Thing\\packages\\api"
|
||||
const sessionID = SessionSchema.ID.make("ses_codec")
|
||||
|
||||
expect(() =>
|
||||
Effect.runSync(
|
||||
db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: ProjectV2.ID.make("invalid_path"),
|
||||
worktree: AbsolutePath.make("not-absolute"),
|
||||
sandboxes: [],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run(),
|
||||
),
|
||||
).toThrow()
|
||||
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: projectID,
|
||||
worktree,
|
||||
sandboxes: [sandbox],
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: projectID,
|
||||
slug: "codec",
|
||||
directory,
|
||||
path: "packages\\api",
|
||||
title: "Codec",
|
||||
version: "test",
|
||||
time_created: 1,
|
||||
time_updated: 1,
|
||||
})
|
||||
.run()
|
||||
|
||||
expect(yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`)).toEqual({
|
||||
worktree: "C:/Repo/Thing",
|
||||
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
|
||||
})
|
||||
expect(yield* db.get<{ directory: string; path: string }>(sql`SELECT directory, path FROM session WHERE id = ${sessionID}`)).toEqual({
|
||||
directory: "C:/Repo/Thing/packages/api",
|
||||
path: "packages/api",
|
||||
})
|
||||
|
||||
const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
|
||||
const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
|
||||
expect(project?.worktree).toBe(worktree)
|
||||
expect(project?.sandboxes).toEqual([sandbox])
|
||||
expect(session?.directory).toBe(directory)
|
||||
expect(session?.path).toBe("packages/api")
|
||||
|
||||
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
|
||||
sessionID,
|
||||
)
|
||||
|
||||
const moved = AbsolutePath.make("D:\\Moved\\Thing")
|
||||
const updated = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({ worktree: moved, sandboxes: [moved] })
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.returning()
|
||||
.get()
|
||||
expect(updated?.worktree).toBe(moved)
|
||||
expect(updated?.sandboxes).toEqual([moved])
|
||||
expect(
|
||||
yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`),
|
||||
).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
|
||||
expect((yield* db.select().from(ProjectTable).where(inArray(ProjectTable.worktree, [moved])).get())?.id).toBe(
|
||||
projectID,
|
||||
)
|
||||
|
||||
yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
|
||||
expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())).toThrow()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("imports existing drizzle migration state", async () => {
|
||||
await run(
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -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