Skip to content

Commit dbc00aa

Browse files
authored
feat(id): brand ProjectID through Drizzle and Zod schemas (anomalyco#16948)
1 parent c37f7b9 commit dbc00aa

15 files changed

Lines changed: 77 additions & 44 deletions

File tree

packages/opencode/src/cli/cmd/import.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export const ImportCommand = cmd({
8686
await bootstrap(process.cwd(), async () => {
8787
let exportData:
8888
| {
89-
info: Session.Info
89+
info: SDKSession
9090
messages: Array<{
9191
info: Message
9292
parts: Part[]
@@ -152,7 +152,7 @@ export const ImportCommand = cmd({
152152
return
153153
}
154154

155-
const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id }
155+
const row = Session.toRow({ ...exportData.info, projectID: Instance.project.id })
156156
Database.use((db) =>
157157
db
158158
.insert(SessionTable)

packages/opencode/src/control-plane/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import z from "zod"
22
import { Identifier } from "@/id/id"
3+
import { ProjectID } from "@/project/schema"
34

45
export const WorkspaceInfo = z.object({
56
id: Identifier.schema("workspace"),
@@ -8,7 +9,7 @@ export const WorkspaceInfo = z.object({
89
name: z.string().nullable(),
910
directory: z.string().nullable(),
1011
extra: z.unknown().nullable(),
11-
projectID: z.string(),
12+
projectID: ProjectID.zod,
1213
})
1314
export type WorkspaceInfo = z.infer<typeof WorkspaceInfo>
1415

packages/opencode/src/control-plane/workspace.sql.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { sqliteTable, text } from "drizzle-orm/sqlite-core"
22
import { ProjectTable } from "../project/project.sql"
3+
import type { ProjectID } from "../project/schema"
34

45
export const WorkspaceTable = sqliteTable("workspace", {
56
id: text().primaryKey(),
@@ -9,6 +10,7 @@ export const WorkspaceTable = sqliteTable("workspace", {
910
directory: text(),
1011
extra: text({ mode: "json" }),
1112
project_id: text()
13+
.$type<ProjectID>()
1214
.notNull()
1315
.references(() => ProjectTable.id, { onDelete: "cascade" }),
1416
})

packages/opencode/src/control-plane/workspace.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Project } from "@/project/project"
66
import { BusEvent } from "@/bus/bus-event"
77
import { GlobalBus } from "@/bus/global"
88
import { Log } from "@/util/log"
9+
import { ProjectID } from "@/project/schema"
910
import { WorkspaceTable } from "./workspace.sql"
1011
import { getAdaptor } from "./adaptors"
1112
import { WorkspaceInfo } from "./types"
@@ -48,7 +49,7 @@ export namespace Workspace {
4849
id: Identifier.schema("workspace").optional(),
4950
type: Info.shape.type,
5051
branch: Info.shape.branch,
51-
projectID: Info.shape.projectID,
52+
projectID: ProjectID.zod,
5253
extra: Info.shape.extra,
5354
})
5455

packages/opencode/src/permission/next.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Database, eq } from "@/storage/db"
77
import { PermissionTable } from "@/session/session.sql"
88
import { fn } from "@/util/fn"
99
import { Log } from "@/util/log"
10+
import { ProjectID } from "@/project/schema"
1011
import { Wildcard } from "@/util/wildcard"
1112
import os from "os"
1213
import z from "zod"
@@ -90,7 +91,7 @@ export namespace PermissionNext {
9091
export type Reply = z.infer<typeof Reply>
9192

9293
export const Approval = z.object({
93-
projectID: z.string(),
94+
projectID: ProjectID.zod,
9495
patterns: z.string().array(),
9596
})
9697

packages/opencode/src/project/project.sql.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
22
import { Timestamps } from "../storage/schema.sql"
3+
import type { ProjectID } from "./schema"
34

45
export const ProjectTable = sqliteTable("project", {
5-
id: text().primaryKey(),
6+
id: text().$type<ProjectID>().primaryKey(),
67
worktree: text().notNull(),
78
vcs: text(),
89
name: text(),

packages/opencode/src/project/project.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { existsSync } from "fs"
1515
import { git } from "../util/git"
1616
import { Glob } from "../util/glob"
1717
import { which } from "../util/which"
18+
import { ProjectID } from "./schema"
1819

1920
export namespace Project {
2021
const log = Log.create({ service: "project" })
@@ -33,7 +34,7 @@ export namespace Project {
3334

3435
export const Info = z
3536
.object({
36-
id: z.string(),
37+
id: ProjectID.zod,
3738
worktree: z.string(),
3839
vcs: z.literal("git").optional(),
3940
name: z.string().optional(),
@@ -73,7 +74,7 @@ export namespace Project {
7374
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
7475
: undefined
7576
return {
76-
id: row.id,
77+
id: ProjectID.make(row.id),
7778
worktree: row.worktree,
7879
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
7980
name: row.name ?? undefined,
@@ -91,6 +92,7 @@ export namespace Project {
9192
function readCachedId(dir: string) {
9293
return Filesystem.readText(path.join(dir, "opencode"))
9394
.then((x) => x.trim())
95+
.then(ProjectID.make)
9496
.catch(() => undefined)
9597
}
9698

@@ -111,7 +113,7 @@ export namespace Project {
111113

112114
if (!gitBinary) {
113115
return {
114-
id: id ?? "global",
116+
id: id ?? ProjectID.global,
115117
worktree: sandbox,
116118
sandbox,
117119
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
@@ -130,7 +132,7 @@ export namespace Project {
130132

131133
if (!worktree) {
132134
return {
133-
id: id ?? "global",
135+
id: id ?? ProjectID.global,
134136
worktree: sandbox,
135137
sandbox,
136138
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
@@ -160,22 +162,22 @@ export namespace Project {
160162

161163
if (!roots) {
162164
return {
163-
id: "global",
165+
id: ProjectID.global,
164166
worktree: sandbox,
165167
sandbox,
166168
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
167169
}
168170
}
169171

170-
id = roots[0]
172+
id = roots[0] ? ProjectID.make(roots[0]) : undefined
171173
if (id) {
172174
await Filesystem.write(path.join(dotgit, "opencode"), id).catch(() => undefined)
173175
}
174176
}
175177

176178
if (!id) {
177179
return {
178-
id: "global",
180+
id: ProjectID.global,
179181
worktree: sandbox,
180182
sandbox,
181183
vcs: "git",
@@ -208,7 +210,7 @@ export namespace Project {
208210
}
209211

210212
return {
211-
id: "global",
213+
id: ProjectID.global,
212214
worktree: "/",
213215
sandbox: "/",
214216
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
@@ -228,7 +230,7 @@ export namespace Project {
228230
updated: Date.now(),
229231
},
230232
}
231-
if (data.id !== "global") {
233+
if (data.id !== ProjectID.global) {
232234
await migrateFromGlobal(data.id, data.worktree)
233235
}
234236
return fresh
@@ -308,12 +310,12 @@ export namespace Project {
308310
return
309311
}
310312

311-
async function migrateFromGlobal(id: string, worktree: string) {
312-
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
313+
async function migrateFromGlobal(id: ProjectID, worktree: string) {
314+
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, ProjectID.global)).get())
313315
if (!row) return
314316

315317
const sessions = Database.use((db) =>
316-
db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
318+
db.select().from(SessionTable).where(eq(SessionTable.project_id, ProjectID.global)).all(),
317319
)
318320
if (sessions.length === 0) return
319321

@@ -323,14 +325,14 @@ export namespace Project {
323325
// Skip sessions that belong to a different directory
324326
if (row.directory && row.directory !== worktree) return
325327

326-
log.info("migrating session", { sessionID: row.id, from: "global", to: id })
328+
log.info("migrating session", { sessionID: row.id, from: ProjectID.global, to: id })
327329
Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
328330
}).catch((error) => {
329331
log.error("failed to migrate sessions from global to project", { error, projectId: id })
330332
})
331333
}
332334

333-
export function setInitialized(id: string) {
335+
export function setInitialized(id: ProjectID) {
334336
Database.use((db) =>
335337
db
336338
.update(ProjectTable)
@@ -352,7 +354,7 @@ export namespace Project {
352354
)
353355
}
354356

355-
export function get(id: string): Info | undefined {
357+
export function get(id: ProjectID): Info | undefined {
356358
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
357359
if (!row) return undefined
358360
return fromRow(row)
@@ -375,12 +377,13 @@ export namespace Project {
375377

376378
export const update = fn(
377379
z.object({
378-
projectID: z.string(),
380+
projectID: ProjectID.zod,
379381
name: z.string().optional(),
380382
icon: Info.shape.icon.optional(),
381383
commands: Info.shape.commands.optional(),
382384
}),
383385
async (input) => {
386+
const id = ProjectID.make(input.projectID)
384387
const result = Database.use((db) =>
385388
db
386389
.update(ProjectTable)
@@ -391,7 +394,7 @@ export namespace Project {
391394
commands: input.commands,
392395
time_updated: Date.now(),
393396
})
394-
.where(eq(ProjectTable.id, input.projectID))
397+
.where(eq(ProjectTable.id, id))
395398
.returning()
396399
.get(),
397400
)
@@ -407,7 +410,7 @@ export namespace Project {
407410
},
408411
)
409412

410-
export async function sandboxes(id: string) {
413+
export async function sandboxes(id: ProjectID) {
411414
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
412415
if (!row) return []
413416
const data = fromRow(row)
@@ -419,7 +422,7 @@ export namespace Project {
419422
return valid
420423
}
421424

422-
export async function addSandbox(id: string, directory: string) {
425+
export async function addSandbox(id: ProjectID, directory: string) {
423426
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
424427
if (!row) throw new Error(`Project not found: ${id}`)
425428
const sandboxes = [...row.sandboxes]
@@ -443,7 +446,7 @@ export namespace Project {
443446
return data
444447
}
445448

446-
export async function removeSandbox(id: string, directory: string) {
449+
export async function removeSandbox(id: ProjectID, directory: string) {
447450
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
448451
if (!row) throw new Error(`Project not found: ${id}`)
449452
const sandboxes = row.sandboxes.filter((s) => s !== directory)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Schema } from "effect"
2+
import z from "zod"
3+
4+
import { withStatics } from "@/util/schema"
5+
6+
const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectId"))
7+
8+
export type ProjectID = typeof projectIdSchema.Type
9+
10+
export const ProjectID = projectIdSchema.pipe(
11+
withStatics((schema: typeof projectIdSchema) => ({
12+
global: schema.makeUnsafe("global"),
13+
make: (id: string) => schema.makeUnsafe(id),
14+
zod: z.string().pipe(z.custom<ProjectID>()),
15+
})),
16+
)

packages/opencode/src/server/routes/project.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { resolver } from "hono-openapi"
44
import { Instance } from "../../project/instance"
55
import { Project } from "../../project/project"
66
import z from "zod"
7+
import { ProjectID } from "../../project/schema"
78
import { errors } from "../error"
89
import { lazy } from "../../util/lazy"
910
import { InstanceBootstrap } from "../../project/bootstrap"
@@ -105,7 +106,7 @@ export const ProjectRoutes = lazy(() =>
105106
...errors(400, 404),
106107
},
107108
}),
108-
validator("param", z.object({ projectID: z.string() })),
109+
validator("param", z.object({ projectID: ProjectID.zod })),
109110
validator("json", Project.update.schema.omit({ projectID: true })),
110111
async (c) => {
111112
const projectID = c.req.valid("param").projectID

packages/opencode/src/session/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { fn } from "@/util/fn"
2323
import { Command } from "../command"
2424
import { Snapshot } from "@/snapshot"
2525
import { WorkspaceContext } from "../control-plane/workspace-context"
26+
import { ProjectID } from "../project/schema"
2627

2728
import type { Provider } from "@/provider/provider"
2829
import { PermissionNext } from "@/permission/next"
@@ -120,7 +121,7 @@ export namespace Session {
120121
.object({
121122
id: Identifier.schema("session"),
122123
slug: z.string(),
123-
projectID: z.string(),
124+
projectID: ProjectID.zod,
124125
workspaceID: z.string().optional(),
125126
directory: z.string(),
126127
parentID: Identifier.schema("session").optional(),
@@ -162,7 +163,7 @@ export namespace Session {
162163

163164
export const ProjectInfo = z
164165
.object({
165-
id: z.string(),
166+
id: ProjectID.zod,
166167
name: z.string().optional(),
167168
worktree: z.string(),
168169
})

0 commit comments

Comments
 (0)