diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index eb5964379a8..cfea5fdbb39 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -86,7 +86,7 @@ export const ImportCommand = cmd({ await bootstrap(process.cwd(), async () => { let exportData: | { - info: Session.Info + info: SDKSession messages: Array<{ info: Message parts: Part[] @@ -152,7 +152,7 @@ export const ImportCommand = cmd({ return } - const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id } + const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id }) Database.use((db) => db .insert(SessionTable) diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 3d27757fd1b..53a6c8a0732 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,6 @@ import z from "zod" import { Identifier } from "@/id/id" +import { ProjectID } from "@/project/schema" export const WorkspaceInfo = z.object({ id: Identifier.schema("workspace"), @@ -8,7 +9,7 @@ export const WorkspaceInfo = z.object({ name: z.string().nullable(), directory: z.string().nullable(), extra: z.unknown().nullable(), - projectID: z.string(), + projectID: ProjectID.zod, }) export type WorkspaceInfo = z.infer diff --git a/packages/opencode/src/control-plane/workspace.sql.ts b/packages/opencode/src/control-plane/workspace.sql.ts index 7639620691e..eb7d21d1e89 100644 --- a/packages/opencode/src/control-plane/workspace.sql.ts +++ b/packages/opencode/src/control-plane/workspace.sql.ts @@ -1,5 +1,6 @@ import { sqliteTable, text } from "drizzle-orm/sqlite-core" import { ProjectTable } from "../project/project.sql" +import type { ProjectID } from "../project/schema" export const WorkspaceTable = sqliteTable("workspace", { id: text().primaryKey(), @@ -9,6 +10,7 @@ export const WorkspaceTable = sqliteTable("workspace", { directory: text(), extra: text({ mode: "json" }), project_id: text() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index 8c76fbdab99..f3af985cac6 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -6,6 +6,7 @@ import { Project } from "@/project/project" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" import { Log } from "@/util/log" +import { ProjectID } from "@/project/schema" import { WorkspaceTable } from "./workspace.sql" import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" @@ -48,7 +49,7 @@ export namespace Workspace { id: Identifier.schema("workspace").optional(), type: Info.shape.type, branch: Info.shape.branch, - projectID: Info.shape.projectID, + projectID: ProjectID.zod, extra: Info.shape.extra, }) diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 1e1df62a3ce..4e47e43c8f6 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -7,6 +7,7 @@ import { Database, eq } from "@/storage/db" import { PermissionTable } from "@/session/session.sql" import { fn } from "@/util/fn" import { Log } from "@/util/log" +import { ProjectID } from "@/project/schema" import { Wildcard } from "@/util/wildcard" import os from "os" import z from "zod" @@ -90,7 +91,7 @@ export namespace PermissionNext { export type Reply = z.infer export const Approval = z.object({ - projectID: z.string(), + projectID: ProjectID.zod, patterns: z.string().array(), }) diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index 7f0f8ca5323..efbc400b5ee 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -1,8 +1,9 @@ import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core" import { Timestamps } from "../storage/schema.sql" +import type { ProjectID } from "./schema" export const ProjectTable = sqliteTable("project", { - id: text().primaryKey(), + id: text().$type().primaryKey(), worktree: text().notNull(), vcs: text(), name: text(), diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 68cece0a52a..196dc8da619 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -15,6 +15,7 @@ import { existsSync } from "fs" import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" +import { ProjectID } from "./schema" export namespace Project { const log = Log.create({ service: "project" }) @@ -33,7 +34,7 @@ export namespace Project { export const Info = z .object({ - id: z.string(), + id: ProjectID.zod, worktree: z.string(), vcs: z.literal("git").optional(), name: z.string().optional(), @@ -73,7 +74,7 @@ export namespace Project { ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined return { - id: row.id, + id: ProjectID.make(row.id), worktree: row.worktree, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, @@ -91,6 +92,7 @@ export namespace Project { function readCachedId(dir: string) { return Filesystem.readText(path.join(dir, "opencode")) .then((x) => x.trim()) + .then(ProjectID.make) .catch(() => undefined) } @@ -111,7 +113,7 @@ export namespace Project { if (!gitBinary) { return { - id: id ?? "global", + id: id ?? ProjectID.global, worktree: sandbox, sandbox, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), @@ -130,7 +132,7 @@ export namespace Project { if (!worktree) { return { - id: id ?? "global", + id: id ?? ProjectID.global, worktree: sandbox, sandbox, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), @@ -160,14 +162,14 @@ export namespace Project { if (!roots) { return { - id: "global", + id: ProjectID.global, worktree: sandbox, sandbox, vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } } - id = roots[0] + id = roots[0] ? ProjectID.make(roots[0]) : undefined if (id) { await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined) } @@ -175,7 +177,7 @@ export namespace Project { if (!id) { return { - id: "global", + id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git", @@ -208,7 +210,7 @@ export namespace Project { } return { - id: "global", + id: ProjectID.global, worktree: "/", sandbox: "/", vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), @@ -228,7 +230,7 @@ export namespace Project { updated: Date.now(), }, } - if (data.id !== "global") { + if (data.id !== ProjectID.global) { await migrateFromGlobal(data.id, data.worktree) } return fresh @@ -308,12 +310,12 @@ export namespace Project { return } - async function migrateFromGlobal(id: string, worktree: string) { - const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get()) + async function migrateFromGlobal(id: ProjectID, worktree: string) { + const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, ProjectID.global)).get()) if (!row) return const sessions = Database.use((db) => - db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(), + db.select().from(SessionTable).where(eq(SessionTable.project_id, ProjectID.global)).all(), ) if (sessions.length === 0) return @@ -323,14 +325,14 @@ export namespace Project { // Skip sessions that belong to a different directory if (row.directory && row.directory !== worktree) return - log.info("migrating session", { sessionID: row.id, from: "global", to: id }) + log.info("migrating session", { sessionID: row.id, from: ProjectID.global, to: id }) Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run()) }).catch((error) => { log.error("failed to migrate sessions from global to project", { error, projectId: id }) }) } - export function setInitialized(id: string) { + export function setInitialized(id: ProjectID) { Database.use((db) => db .update(ProjectTable) @@ -352,7 +354,7 @@ export namespace Project { ) } - export function get(id: string): Info | undefined { + export function get(id: ProjectID): Info | undefined { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return undefined return fromRow(row) @@ -375,12 +377,13 @@ export namespace Project { export const update = fn( z.object({ - projectID: z.string(), + projectID: ProjectID.zod, name: z.string().optional(), icon: Info.shape.icon.optional(), commands: Info.shape.commands.optional(), }), async (input) => { + const id = ProjectID.make(input.projectID) const result = Database.use((db) => db .update(ProjectTable) @@ -391,7 +394,7 @@ export namespace Project { commands: input.commands, time_updated: Date.now(), }) - .where(eq(ProjectTable.id, input.projectID)) + .where(eq(ProjectTable.id, id)) .returning() .get(), ) @@ -407,7 +410,7 @@ export namespace Project { }, ) - export async function sandboxes(id: string) { + export async function sandboxes(id: ProjectID) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) return [] const data = fromRow(row) @@ -419,7 +422,7 @@ export namespace Project { return valid } - export async function addSandbox(id: string, directory: string) { + export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) const sandboxes = [...row.sandboxes] @@ -443,7 +446,7 @@ export namespace Project { return data } - export async function removeSandbox(id: string, directory: string) { + export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) const sandboxes = row.sandboxes.filter((s) => s !== directory) diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts new file mode 100644 index 00000000000..adaa9a8314a --- /dev/null +++ b/packages/opencode/src/project/schema.ts @@ -0,0 +1,16 @@ +import { Schema } from "effect" +import z from "zod" + +import { withStatics } from "@/util/schema" + +const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId")) + +export type ProjectID = typeof projectIdSchema.Type + +export const ProjectID = projectIdSchema.pipe( + withStatics((schema: typeof projectIdSchema) => ({ + global: schema.makeUnsafe("global"), + make: (id: string) => schema.makeUnsafe(id), + zod: z.string().pipe(z.custom()), + })), +) diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 85314df9371..994d58b0ca1 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -4,6 +4,7 @@ import { resolver } from "hono-openapi" import { Instance } from "../../project/instance" import { Project } from "../../project/project" import z from "zod" +import { ProjectID } from "../../project/schema" import { errors } from "../error" import { lazy } from "../../util/lazy" import { InstanceBootstrap } from "../../project/bootstrap" @@ -105,7 +106,7 @@ export const ProjectRoutes = lazy(() => ...errors(400, 404), }, }), - validator("param", z.object({ projectID: z.string() })), + validator("param", z.object({ projectID: ProjectID.zod })), validator("json", Project.update.schema.omit({ projectID: true })), async (c) => { const projectID = c.req.valid("param").projectID diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 5cc4d7da8d3..ff499fe2e77 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -23,6 +23,7 @@ import { fn } from "@/util/fn" import { Command } from "../command" import { Snapshot } from "@/snapshot" import { WorkspaceContext } from "../control-plane/workspace-context" +import { ProjectID } from "../project/schema" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" @@ -120,7 +121,7 @@ export namespace Session { .object({ id: Identifier.schema("session"), slug: z.string(), - projectID: z.string(), + projectID: ProjectID.zod, workspaceID: z.string().optional(), directory: z.string(), parentID: Identifier.schema("session").optional(), @@ -162,7 +163,7 @@ export namespace Session { export const ProjectInfo = z .object({ - id: z.string(), + id: ProjectID.zod, name: z.string().optional(), worktree: z.string(), }) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index b3228f400fa..0b62dbbd8c2 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -3,6 +3,7 @@ import { ProjectTable } from "../project/project.sql" import type { MessageV2 } from "./message-v2" import type { Snapshot } from "../snapshot" import type { PermissionNext } from "../permission/next" +import type { ProjectID } from "../project/schema" import { Timestamps } from "../storage/schema.sql" type PartData = Omit @@ -13,6 +14,7 @@ export const SessionTable = sqliteTable( { id: text().primaryKey(), project_id: text() + .$type() .notNull() .references(() => ProjectTable.id, { onDelete: "cascade" }), workspace_id: text(), diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index aa5613010cc..6ed0e482024 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -8,6 +8,7 @@ import { InstanceBootstrap } from "../project/bootstrap" import { Project } from "../project/project" import { Database, eq } from "../storage/db" import { ProjectTable } from "../project/project.sql" +import type { ProjectID } from "../project/schema" import { fn } from "../util/fn" import { Log } from "../util/log" import { Process } from "../util/process" @@ -310,7 +311,7 @@ export namespace Worktree { return false } - async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) { + async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()) const project = row ? Project.fromRow(row) : undefined const startup = project?.commands?.start?.trim() ?? "" @@ -322,7 +323,7 @@ export namespace Worktree { return true } - function queueStartScripts(directory: string, input: { projectID: string; extra?: string }) { + function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) { setTimeout(() => { const start = async () => { await runStartScripts(directory, input) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 80394fbff50..90727cf8a08 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,6 +8,7 @@ import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" import { Global } from "../../src/global" +import { ProjectID } from "../../src/project/schema" import { Filesystem } from "../../src/util/filesystem" // Get managed config directory from environment (set in preload.ts) @@ -44,7 +45,7 @@ async function check(map: (dir: string) => string) { const cfg = await Config.get() expect(cfg.snapshot).toBe(true) expect(Instance.directory).toBe(Filesystem.resolve(tmp.path)) - expect(Instance.project.id).not.toBe("global") + expect(Instance.project.id).not.toBe(ProjectID.global) }, }) } finally { diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index fef9e4190e2..cc6b8dde5b8 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -6,6 +6,7 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" +import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) @@ -74,7 +75,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project).toBeDefined() - expect(project.id).toBe("global") + expect(project.id).toBe(ProjectID.global) expect(project.vcs).toBe("git") expect(project.worktree).toBe(tmp.path) @@ -90,7 +91,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project).toBeDefined() - expect(project.id).not.toBe("global") + expect(project.id).not.toBe(ProjectID.global) expect(project.vcs).toBe("git") expect(project.worktree).toBe(tmp.path) @@ -107,7 +108,7 @@ describe("Project.fromDirectory", () => { await withMode("rev-list-fail", async () => { const { project } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.id).toBe("global") + expect(project.id).toBe(ProjectID.global) expect(project.worktree).toBe(tmp.path) }) }) @@ -301,7 +302,7 @@ describe("Project.update", () => { await expect( Project.update({ - projectID: "nonexistent-project-id", + projectID: ProjectID.make("nonexistent-project-id"), name: "Should Fail", }), ).rejects.toThrow("Project not found: nonexistent-project-id") diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts index 40dd6114538..76dfb8b4502 100644 --- a/packages/opencode/test/storage/json-migration.test.ts +++ b/packages/opencode/test/storage/json-migration.test.ts @@ -8,6 +8,7 @@ import { readFileSync, readdirSync } from "fs" import { JsonMigration } from "../../src/storage/json-migration" import { Global } from "../../src/global" import { ProjectTable } from "../../src/project/project.sql" +import { ProjectID } from "../../src/project/schema" import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql" import { SessionShareTable } from "../../src/share/share.sql" @@ -123,7 +124,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_test123abc") + expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) expect(projects[0].worktree).toBe("/test/path") expect(projects[0].name).toBe("Test Project") expect(projects[0].sandboxes).toEqual(["/test/sandbox"]) @@ -148,7 +149,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_filename") // Uses filename, not JSON id + expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id }) test("migrates project with commands", async () => { @@ -169,7 +170,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_with_commands") + expect(projects[0].id).toBe(ProjectID.make("proj_with_commands")) expect(projects[0].commands).toEqual({ start: "npm run dev" }) }) @@ -190,7 +191,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_no_commands") + expect(projects[0].id).toBe(ProjectID.make("proj_no_commands")) expect(projects[0].commands).toBeNull() }) @@ -220,7 +221,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_test456def") - expect(sessions[0].project_id).toBe("proj_test123abc") + expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc")) expect(sessions[0].slug).toBe("test-session") expect(sessions[0].title).toBe("Test Session Title") expect(sessions[0].summary_additions).toBe(10) @@ -426,7 +427,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_migrated") - expect(sessions[0].project_id).toBe(gitBasedProjectID) // Uses directory, not stale JSON + expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON }) test("uses filename for session id when JSON has different value", async () => { @@ -458,7 +459,7 @@ describe("JSON to SQLite migration", () => { const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id - expect(sessions[0].project_id).toBe("proj_test123abc") + expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc")) }) test("is idempotent (running twice doesn't duplicate)", async () => { @@ -643,7 +644,7 @@ describe("JSON to SQLite migration", () => { const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) - expect(projects[0].id).toBe("proj_test123abc") + expect(projects[0].id).toBe(ProjectID.make("proj_test123abc")) }) test("skips invalid todo entries while preserving source positions", async () => {