Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton
c3c8aa78b9 fix: use z.infer<typeof Info> instead of z.infer<typeof WorkspaceInfo> 2026-03-10 21:35:03 -04:00
Kit Langton
deecd7ea89 fix: address PR feedback
- Remove unnecessary WorkspaceInfoType alias import
- Use input.projectID instead of config.projectID in workspace create
- Compare against ProjectID.global instead of String coercion in test
2026-03-10 21:33:22 -04:00
Kit Langton
ca116111f4 fix(import): remove redundant project_id override in toRow call 2026-03-10 21:29:34 -04:00
Kit Langton
e9f973ebc2 feat(project): brand ProjectID through Drizzle and Zod schemas
Introduce Effect branded ProjectID type into Drizzle table columns
and Zod schemas so the brand flows through z.infer automatically.
Adds ProjectID.zod helper to bridge Effect brands into Zod,
eliminating manual Omit & intersect type overrides.
2026-03-10 21:27:02 -04:00
15 changed files with 77 additions and 44 deletions

View File

@@ -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)

View File

@@ -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>

View File

@@ -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" }),
})

View File

@@ -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,
})

View File

@@ -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(),
})

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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>()),
})),
)

View File

@@ -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

View File

@@ -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(),
})

View File

@@ -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(),

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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 () => {