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

This commit is contained in:
Kit Langton
2026-03-11 16:44:26 -04:00
committed by GitHub
parent c37f7b9d99
commit dbc00aa8e0
15 changed files with 77 additions and 44 deletions

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