فهرست منبع

feat(id): brand ProjectID through Drizzle and Zod schemas (#16948)

Kit Langton 5 ماه پیش
والد
کامیت
dbc00aa8e0

+ 2 - 2
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)

+ 2 - 1
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<typeof WorkspaceInfo>
 

+ 2 - 0
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<ProjectID>()
     .notNull()
     .references(() => ProjectTable.id, { onDelete: "cascade" }),
 })

+ 2 - 1
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,
   })
 

+ 2 - 1
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<typeof Reply>
 
   export const Approval = z.object({
-    projectID: z.string(),
+    projectID: ProjectID.zod,
     patterns: z.string().array(),
   })
 

+ 2 - 1
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<ProjectID>().primaryKey(),
   worktree: text().notNull(),
   vcs: text(),
   name: text(),

+ 23 - 20
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)

+ 16 - 0
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<ProjectID>()),
+  })),
+)

+ 2 - 1
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

+ 3 - 2
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(),
     })

+ 2 - 0
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<MessageV2.Part, "id" | "sessionID" | "messageID">
@@ -13,6 +14,7 @@ export const SessionTable = sqliteTable(
   {
     id: text().primaryKey(),
     project_id: text()
+      .$type<ProjectID>()
       .notNull()
       .references(() => ProjectTable.id, { onDelete: "cascade" }),
     workspace_id: text(),

+ 3 - 2
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)

+ 2 - 1
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 {

+ 5 - 4
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")

+ 9 - 8
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 () => {