refactor(opencode): remove storage db from domain services
This commit is contained in:
@@ -454,6 +454,6 @@ export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export * as Account from "./account"
|
||||
|
||||
@@ -2,16 +2,13 @@ import { eq } from "drizzle-orm"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { Effect, Layer, Option, Schema, Context } from "effect"
|
||||
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AccountStateTable, AccountTable } from "@opencode-ai/core/account/sql"
|
||||
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
|
||||
import { normalizeServerUrl } from "./url"
|
||||
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
export interface Interface {
|
||||
@@ -41,32 +38,33 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Ac
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<Service> = Layer.effect(
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const query = <A>(f: DbTransactionCallback<A>) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
const query = <A, E>(effect: Effect.Effect<A, E>) => effect.pipe(Effect.orDie)
|
||||
|
||||
const tx = <A>(f: DbTransactionCallback<A>) =>
|
||||
Effect.try({
|
||||
try: () => Database.transaction(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const current = (db: DbClient) => {
|
||||
const state = db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get()
|
||||
const current = Effect.fnUntraced(function* () {
|
||||
const state = yield* db
|
||||
.select()
|
||||
.from(AccountStateTable)
|
||||
.where(eq(AccountStateTable.id, ACCOUNT_STATE_ID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!state?.active_account_id) return
|
||||
const account = db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get()
|
||||
const account = yield* db
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.where(eq(AccountTable.id, state.active_account_id))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!account) return
|
||||
return { ...account, active_org_id: state.active_org_id ?? null }
|
||||
}
|
||||
})
|
||||
|
||||
const state = (db: DbClient, accountID: AccountID, orgID: Option.Option<OrgID>) => {
|
||||
const state = (accountID: AccountID, orgID: Option.Option<OrgID>) => {
|
||||
const id = Option.getOrNull(orgID)
|
||||
return db
|
||||
.insert(AccountStateTable)
|
||||
@@ -79,41 +77,46 @@ export const layer: Layer.Layer<Service> = Layer.effect(
|
||||
}
|
||||
|
||||
const active = Effect.fn("AccountRepo.active")(() =>
|
||||
query((db) => current(db)).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||
query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))),
|
||||
)
|
||||
|
||||
const list = Effect.fn("AccountRepo.list")(() =>
|
||||
query((db) =>
|
||||
query(
|
||||
db
|
||||
.select()
|
||||
.from(AccountTable)
|
||||
.all()
|
||||
.map((row: AccountRow) => decode({ ...row, active_org_id: null })),
|
||||
.pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))),
|
||||
),
|
||||
)
|
||||
|
||||
const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) =>
|
||||
tx((db) => {
|
||||
db.update(AccountStateTable)
|
||||
.set({ active_account_id: null, active_org_id: null })
|
||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||
.run()
|
||||
db.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||
}).pipe(Effect.asVoid),
|
||||
query(
|
||||
db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.update(AccountStateTable)
|
||||
.set({ active_account_id: null, active_org_id: null })
|
||||
.where(eq(AccountStateTable.active_account_id, accountID))
|
||||
.run()
|
||||
yield* tx.delete(AccountTable).where(eq(AccountTable.id, accountID)).run()
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
const use = Effect.fn("AccountRepo.use")((accountID: AccountID, orgID: Option.Option<OrgID>) =>
|
||||
query((db) => state(db, accountID, orgID)).pipe(Effect.asVoid),
|
||||
query(state(accountID, orgID)).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) =>
|
||||
query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
query(db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe(
|
||||
Effect.map(Option.fromNullishOr),
|
||||
),
|
||||
)
|
||||
|
||||
const persistToken = Effect.fn("AccountRepo.persistToken")((input) =>
|
||||
query((db) =>
|
||||
query(
|
||||
db
|
||||
.update(AccountTable)
|
||||
.set({
|
||||
@@ -127,31 +130,36 @@ export const layer: Layer.Layer<Service> = Layer.effect(
|
||||
)
|
||||
|
||||
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
|
||||
tx((db) => {
|
||||
const url = normalizeServerUrl(input.url)
|
||||
query(
|
||||
db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
const url = normalizeServerUrl(input.url)
|
||||
|
||||
db.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
void state(db, input.id, input.orgID)
|
||||
}).pipe(Effect.asVoid),
|
||||
yield* tx
|
||||
.insert(AccountTable)
|
||||
.values({
|
||||
id: input.id,
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AccountTable.id,
|
||||
set: {
|
||||
email: input.email,
|
||||
url,
|
||||
access_token: input.accessToken,
|
||||
refresh_token: input.refreshToken,
|
||||
token_expiry: input.expiry,
|
||||
},
|
||||
})
|
||||
.run()
|
||||
yield* state(input.id, input.orgID)
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.asVoid),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
@@ -166,4 +174,6 @@ export const layer: Layer.Layer<Service> = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
|
||||
export * as AccountRepo from "./repo"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { EOL } from "os"
|
||||
import { Project } from "@/project/project"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
|
||||
import { cmd } from "../cmd"
|
||||
|
||||
const runtime = makeRuntime(Project.Service, Project.defaultLayer)
|
||||
|
||||
export const ScrapCommand = cmd({
|
||||
command: "scrap",
|
||||
describe: "list all known projects",
|
||||
builder: (yargs) => yargs,
|
||||
async handler() {
|
||||
const timer = Log.Default.time("scrap")
|
||||
const list = await Project.list()
|
||||
const list = await runtime.runPromise((project) => project.list())
|
||||
process.stdout.write(JSON.stringify(list, null, 2) + EOL)
|
||||
timer.stop()
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { asc } from "drizzle-orm"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { inArray } from "drizzle-orm"
|
||||
@@ -74,9 +74,6 @@ function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
|
||||
}
|
||||
}
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
const log = Log.create({ service: "workspace-sync" })
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
@@ -181,6 +178,7 @@ export const layer = Layer.effect(
|
||||
const vcs = yield* Vcs.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const { db } = yield* Database.Service
|
||||
const connections = new Map<WorkspaceV2.ID, ConnectionStatus>()
|
||||
const syncFibers = yield* FiberMap.make<WorkspaceV2.ID, void, SyncLoopError>()
|
||||
|
||||
@@ -333,19 +331,22 @@ export const layer = Layer.effect(
|
||||
url: URL | string,
|
||||
headers: HeadersInit | undefined,
|
||||
) {
|
||||
const sessionIDs = yield* db((db) =>
|
||||
db
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.workspace_id, space.id))
|
||||
.all()
|
||||
.map((row) => row.id),
|
||||
)
|
||||
const sessionIDs = (yield* db
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.workspace_id, space.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)).map((row) => row.id)
|
||||
const state = sessionIDs.length
|
||||
? Object.fromEntries(
|
||||
(yield* db((db) =>
|
||||
db.select().from(EventSequenceTable).where(inArray(EventSequenceTable.aggregate_id, sessionIDs)).all(),
|
||||
)).map((row) => [row.aggregate_id, row.seq]),
|
||||
(
|
||||
yield* db
|
||||
.select()
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, sessionIDs))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
).map((row) => [row.aggregate_id, row.seq]),
|
||||
)
|
||||
: {}
|
||||
|
||||
@@ -551,20 +552,20 @@ export const layer = Layer.effect(
|
||||
timeUsed: Date.now(),
|
||||
}
|
||||
|
||||
yield* db((db) => {
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: info.id,
|
||||
type: info.type,
|
||||
branch: info.branch,
|
||||
name: info.name,
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: info.id,
|
||||
type: info.type,
|
||||
branch: info.branch,
|
||||
name: info.name,
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const env = {
|
||||
OPENCODE_AUTH_CONTENT: JSON.stringify(yield* auth.all()),
|
||||
@@ -603,13 +604,12 @@ export const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const current = yield* db((db) =>
|
||||
db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get(),
|
||||
)
|
||||
const current = yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
if (current?.workspaceID) {
|
||||
const previous = yield* get(current.workspaceID)
|
||||
@@ -710,20 +710,19 @@ export const layer = Layer.effect(
|
||||
return
|
||||
}
|
||||
|
||||
const rows = yield* db((db) =>
|
||||
db
|
||||
.select({
|
||||
id: EventTable.id,
|
||||
aggregateID: EventTable.aggregate_id,
|
||||
seq: EventTable.seq,
|
||||
type: EventTable.type,
|
||||
data: EventTable.data,
|
||||
})
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, input.sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
const rows = yield* db
|
||||
.select({
|
||||
id: EventTable.id,
|
||||
aggregateID: EventTable.aggregate_id,
|
||||
seq: EventTable.seq,
|
||||
type: EventTable.type,
|
||||
data: EventTable.data,
|
||||
})
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, input.sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (rows.length === 0)
|
||||
return yield* new SessionEventsNotFoundError({
|
||||
message: `No events found for session: ${input.sessionID}`,
|
||||
@@ -829,15 +828,14 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const list = Effect.fn("Workspace.list")(function* (project: Project.Info) {
|
||||
return yield* db((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.project_id, project.id))
|
||||
.all()
|
||||
.map(fromRow)
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
)
|
||||
return (yield* db
|
||||
.select()
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.project_id, project.id))
|
||||
.all()
|
||||
.pipe(Effect.orDie))
|
||||
.map(fromRow)
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
const syncList = Effect.fn("Workspace.syncList")(function* (project: Project.Info) {
|
||||
@@ -874,20 +872,20 @@ export const layer = Layer.effect(
|
||||
timeUsed: Date.now(),
|
||||
}
|
||||
|
||||
yield* db((db) => {
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: info.id,
|
||||
type: info.type,
|
||||
branch: info.branch,
|
||||
name: info.name,
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run()
|
||||
})
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: info.id,
|
||||
type: info.type,
|
||||
branch: info.branch,
|
||||
name: info.name,
|
||||
directory: info.directory,
|
||||
extra: info.extra,
|
||||
project_id: info.projectID,
|
||||
time_used: info.timeUsed,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* startSync(info)
|
||||
}),
|
||||
@@ -896,19 +894,18 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const get = Effect.fn("Workspace.get")(function* (id: WorkspaceV2.ID) {
|
||||
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
return fromRow(row)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("Workspace.remove")(function* (id: WorkspaceV2.ID) {
|
||||
const sessions = yield* db((db) =>
|
||||
db
|
||||
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.workspace_id, id))
|
||||
.all(),
|
||||
)
|
||||
const sessions = yield* db
|
||||
.select({ id: SessionTable.id, parentID: SessionTable.parent_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.workspace_id, id))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const sessionIDs = new Set(sessions.map((sessionInfo) => sessionInfo.id))
|
||||
yield* Effect.forEach(
|
||||
sessions.filter((sessionInfo) => !sessionInfo.parentID || !sessionIDs.has(sessionInfo.parentID)),
|
||||
@@ -917,7 +914,7 @@ export const layer = Layer.effect(
|
||||
{ discard: true },
|
||||
)
|
||||
|
||||
const row = yield* db((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
|
||||
const row = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
|
||||
yield* stopSync(id)
|
||||
@@ -933,7 +930,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
yield* db((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
|
||||
yield* db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie)
|
||||
return info
|
||||
})
|
||||
|
||||
@@ -952,19 +949,10 @@ export const layer = Layer.effect(
|
||||
signal?: AbortSignal,
|
||||
timeout = TIMEOUT,
|
||||
) {
|
||||
if (synced(state)) return
|
||||
if (yield* synced(db, state)) return
|
||||
|
||||
yield* Effect.catch(
|
||||
waitEvent({
|
||||
timeout,
|
||||
signal,
|
||||
fn(event) {
|
||||
if (event.workspace !== workspaceID && event.payload.type !== "sync") {
|
||||
return false
|
||||
}
|
||||
return synced(state)
|
||||
},
|
||||
}),
|
||||
waitUntilSynced({ db, workspaceID, state, signal, timeout }),
|
||||
(): Effect.Effect<never, WaitForSyncError> =>
|
||||
signal?.aborted
|
||||
? Effect.fail(
|
||||
@@ -983,13 +971,12 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const startWorkspaceSyncing = Effect.fn("Workspace.startWorkspaceSyncing")(function* (projectID: ProjectV2.ID) {
|
||||
const rows = yield* db((db) =>
|
||||
db
|
||||
.selectDistinct({ workspace: WorkspaceTable })
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.project_id, projectID))
|
||||
.all(),
|
||||
)
|
||||
const rows = yield* db
|
||||
.selectDistinct({ workspace: WorkspaceTable })
|
||||
.from(WorkspaceTable)
|
||||
.where(eq(WorkspaceTable.project_id, projectID))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
for (const { workspace } of rows) {
|
||||
yield* startSync(fromRow(workspace)).pipe(
|
||||
@@ -1030,6 +1017,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
@@ -1044,26 +1032,46 @@ type HistoryEvent = {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
function synced(state: Record<string, number>) {
|
||||
function waitUntilSynced(input: {
|
||||
db: Database.Interface["db"]
|
||||
workspaceID: WorkspaceV2.ID
|
||||
state: Record<string, number>
|
||||
signal?: AbortSignal
|
||||
timeout: number
|
||||
}): Effect.Effect<void, unknown> {
|
||||
return Effect.suspend(() =>
|
||||
waitEvent({
|
||||
timeout: input.timeout,
|
||||
signal: input.signal,
|
||||
fn(event) {
|
||||
return event.workspace === input.workspaceID || event.payload.type === "sync"
|
||||
},
|
||||
}).pipe(
|
||||
Effect.andThen(synced(input.db, input.state)),
|
||||
Effect.flatMap((done): Effect.Effect<void, unknown> => (done ? Effect.void : waitUntilSynced(input))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function synced(db: Database.Interface["db"], state: Record<string, number>): Effect.Effect<boolean> {
|
||||
const ids = Object.keys(state)
|
||||
if (ids.length === 0) return true
|
||||
if (ids.length === 0) return Effect.succeed(true)
|
||||
|
||||
const done = Object.fromEntries(
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select({
|
||||
id: EventSequenceTable.aggregate_id,
|
||||
seq: EventSequenceTable.seq,
|
||||
})
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, ids))
|
||||
.all(),
|
||||
).map((row) => [row.id, row.seq]),
|
||||
) as Record<string, number>
|
||||
|
||||
return ids.every((id) => {
|
||||
return (done[id] ?? -1) >= state[id]
|
||||
})
|
||||
return db
|
||||
.select({
|
||||
id: EventSequenceTable.aggregate_id,
|
||||
seq: EventSequenceTable.seq,
|
||||
})
|
||||
.from(EventSequenceTable)
|
||||
.where(inArray(EventSequenceTable.aggregate_id, ids))
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => {
|
||||
const done = Object.fromEntries(rows.map((row) => [row.id, row.seq])) as Record<string, number>
|
||||
return ids.every((id) => (done[id] ?? -1) >= state[id])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function route(url: string | URL, path: string) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { and, eq, sql } from "drizzle-orm"
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
@@ -145,6 +145,7 @@ export const layer = Layer.effect(
|
||||
const projectV2 = yield* ProjectV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const { db } = yield* Database.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
@@ -162,9 +163,6 @@ export const layer = Layer.effect(
|
||||
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
|
||||
)
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
const emitUpdated = (data: Info) =>
|
||||
Effect.sync(() =>
|
||||
GlobalBus.emit("event", {
|
||||
@@ -186,13 +184,15 @@ export const layer = Layer.effect(
|
||||
if (oldID === ProjectV2.ID.global) return
|
||||
if (oldID === newID) return
|
||||
|
||||
yield* Effect.sync(() =>
|
||||
Database.transaction(
|
||||
(d) => {
|
||||
const oldProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
|
||||
const newProject = d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
|
||||
yield* db
|
||||
.transaction(
|
||||
(d) =>
|
||||
Effect.gen(function* () {
|
||||
const oldProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, oldID)).get()
|
||||
const newProject = yield* d.select().from(ProjectTable).where(eq(ProjectTable.id, newID)).get()
|
||||
if (oldProject && !newProject) {
|
||||
d.insert(ProjectTable)
|
||||
yield* d
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
...oldProject,
|
||||
id: newID,
|
||||
@@ -201,10 +201,11 @@ export const layer = Layer.effect(
|
||||
.run()
|
||||
}
|
||||
|
||||
const oldPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
|
||||
const newPermission = d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
|
||||
const oldPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, oldID)).get()
|
||||
const newPermission = yield* d.select().from(PermissionTable).where(eq(PermissionTable.project_id, newID)).get()
|
||||
if (oldPermission && newPermission) {
|
||||
d.update(PermissionTable)
|
||||
yield* d
|
||||
.update(PermissionTable)
|
||||
.set({
|
||||
data: mergePermissionRules(oldPermission.data, newPermission.data),
|
||||
time_created: Math.min(oldPermission.time_created, newPermission.time_created),
|
||||
@@ -212,23 +213,24 @@ export const layer = Layer.effect(
|
||||
})
|
||||
.where(eq(PermissionTable.project_id, newID))
|
||||
.run()
|
||||
d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
yield* d.delete(PermissionTable).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
}
|
||||
if (oldPermission && !newPermission) {
|
||||
d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
yield* d.update(PermissionTable).set({ project_id: newID }).where(eq(PermissionTable.project_id, oldID)).run()
|
||||
}
|
||||
|
||||
d.update(SessionTable)
|
||||
yield* d
|
||||
.update(SessionTable)
|
||||
.set({ project_id: newID, time_updated: sql`${SessionTable.time_updated}` })
|
||||
.where(eq(SessionTable.project_id, oldID))
|
||||
.run()
|
||||
d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
|
||||
yield* d.update(WorkspaceTable).set({ project_id: newID }).where(eq(WorkspaceTable.project_id, oldID)).run()
|
||||
|
||||
if (oldProject) d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
|
||||
},
|
||||
if (oldProject) yield* d.delete(ProjectTable).where(eq(ProjectTable.id, oldID)).run()
|
||||
}),
|
||||
{ behavior: "immediate" },
|
||||
),
|
||||
)
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
|
||||
@@ -240,7 +242,7 @@ export const layer = Layer.effect(
|
||||
// Phase 2: upsert
|
||||
const projectID = ProjectV2.ID.make(data.id)
|
||||
yield* migrateProjectId(data.previous ? ProjectV2.ID.make(data.previous) : undefined, projectID)
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
|
||||
const existing = row
|
||||
? fromRow(row)
|
||||
: {
|
||||
@@ -275,8 +277,7 @@ export const layer = Layer.effect(
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
|
||||
|
||||
yield* db((d) =>
|
||||
d
|
||||
yield* db
|
||||
.insert(ProjectTable)
|
||||
.values({
|
||||
id: result.id,
|
||||
@@ -307,17 +308,16 @@ export const layer = Layer.effect(
|
||||
commands: result.commands,
|
||||
},
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
if (projectID !== ProjectV2.ID.global) {
|
||||
yield* db((d) =>
|
||||
d
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ project_id: projectID })
|
||||
.where(and(eq(SessionTable.project_id, ProjectV2.ID.global), eq(SessionTable.directory, data.directory)))
|
||||
.run(),
|
||||
)
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
yield* emitUpdated(result)
|
||||
@@ -352,17 +352,16 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const list = Effect.fn("Project.list")(function* () {
|
||||
return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
|
||||
return (yield* db.select().from(ProjectTable).all().pipe(Effect.orDie)).map(fromRow)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Project.get")(function* (id: ProjectV2.ID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
|
||||
return row ? fromRow(row) : undefined
|
||||
})
|
||||
|
||||
const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
const result = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({
|
||||
name: input.name,
|
||||
@@ -374,8 +373,8 @@ export const layer = Layer.effect(
|
||||
})
|
||||
.where(eq(ProjectTable.id, input.projectID))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!result) return yield* new NotFoundError({ projectID: input.projectID })
|
||||
const data = fromRow(result)
|
||||
yield* emitUpdated(data)
|
||||
@@ -394,9 +393,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectV2.ID) {
|
||||
yield* db((d) =>
|
||||
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
yield* db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run().pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const initState = yield* InstanceState.make(
|
||||
@@ -415,7 +412,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectV2.ID) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) return []
|
||||
const data = fromRow(row)
|
||||
return yield* Effect.forEach(
|
||||
@@ -430,34 +427,32 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = [...row.sandboxes]
|
||||
if (!sboxes.includes(directory)) sboxes.push(directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
const result = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
|
||||
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) {
|
||||
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
|
||||
if (!row) throw new Error(`Project not found: ${id}`)
|
||||
const sboxes = row.sandboxes.filter((s) => s !== directory)
|
||||
const result = yield* db((d) =>
|
||||
d
|
||||
const result = yield* db
|
||||
.update(ProjectTable)
|
||||
.set({ sandboxes: sboxes, time_updated: Date.now() })
|
||||
.where(eq(ProjectTable.id, id))
|
||||
.returning()
|
||||
.get(),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!result) throw new Error(`Project not found: ${id}`)
|
||||
yield* emitUpdated(fromRow(result))
|
||||
})
|
||||
@@ -484,31 +479,10 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export function list() {
|
||||
return Database.use((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(ProjectTable)
|
||||
.all()
|
||||
.map((row) => fromRow(row)),
|
||||
)
|
||||
}
|
||||
|
||||
export function get(id: ProjectV2.ID): Info | undefined {
|
||||
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
|
||||
if (!row) return undefined
|
||||
return fromRow(row)
|
||||
}
|
||||
|
||||
export function setInitialized(id: ProjectV2.ID) {
|
||||
Database.use((db) =>
|
||||
db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
|
||||
)
|
||||
}
|
||||
|
||||
export * as Project from "./project"
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageV2 } from "@/session/message-v2"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Config } from "@/config/config"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -79,9 +79,6 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Sh
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
|
||||
Effect.sync(() => Database.use(fn))
|
||||
|
||||
function api(resource: string): Api {
|
||||
return {
|
||||
create: `/api/${resource}`,
|
||||
@@ -115,12 +112,13 @@ export const layer = Layer.effect(
|
||||
const account = yield* Account.Service
|
||||
const bus = yield* Bus.Service
|
||||
const cfg = yield* Config.Service
|
||||
const { db } = yield* Database.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const httpOk = HttpClient.filterStatusOk(http)
|
||||
const provider = yield* Provider.Service
|
||||
const session = yield* Session.Service
|
||||
|
||||
function sync(sessionID: SessionID, data: Data[]): Effect.Effect<void> {
|
||||
function sync(sessionID: SessionID, data: Data[]) {
|
||||
return Effect.gen(function* () {
|
||||
if (disabled) return
|
||||
const share = yield* getCached(sessionID)
|
||||
@@ -233,9 +231,12 @@ export const layer = Layer.effect(
|
||||
})
|
||||
|
||||
const get = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const row = yield* db((db) =>
|
||||
db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
|
||||
)
|
||||
const row = yield* db
|
||||
.select()
|
||||
.from(SessionShareTable)
|
||||
.where(eq(SessionShareTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) return
|
||||
return { id: row.id, secret: row.secret, url: row.url } satisfies Share
|
||||
})
|
||||
@@ -321,16 +322,15 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((r) => httpOk.execute(r)),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ShareSchema)),
|
||||
)
|
||||
yield* db((db) =>
|
||||
db
|
||||
.insert(SessionShareTable)
|
||||
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
|
||||
.onConflictDoUpdate({
|
||||
target: SessionShareTable.session_id,
|
||||
set: { id: result.id, secret: result.secret, url: result.url },
|
||||
})
|
||||
.run(),
|
||||
)
|
||||
yield* db
|
||||
.insert(SessionShareTable)
|
||||
.values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
|
||||
.onConflictDoUpdate({
|
||||
target: SessionShareTable.session_id,
|
||||
set: { id: result.id, secret: result.secret, url: result.url },
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.shared.set(sessionID, result)
|
||||
yield* full(sessionID).pipe(
|
||||
@@ -362,7 +362,7 @@ export const layer = Layer.effect(
|
||||
Effect.flatMap((r) => httpOk.execute(r)),
|
||||
)
|
||||
|
||||
yield* db((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
|
||||
yield* db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run().pipe(Effect.orDie)
|
||||
s.shared.delete(sessionID)
|
||||
s.queue.delete(sessionID)
|
||||
})
|
||||
@@ -375,6 +375,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
|
||||
@@ -14,7 +14,7 @@ const truncate = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
|
||||
|
||||
it.live("list returns empty when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -26,7 +26,7 @@ const truncate = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
const it = testEffect(Layer.merge(AccountRepo.defaultLayer, truncate))
|
||||
|
||||
const insideEagerRefreshWindow = Duration.toMillis(Duration.minutes(1))
|
||||
const outsideEagerRefreshWindow = Duration.toMillis(Duration.minutes(10))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database as CoreDatabase } from "@opencode-ai/core/database/database"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
@@ -52,6 +53,7 @@ const workspaceLayer = (experimentalWorkspaces: boolean) =>
|
||||
Layer.provide(SessionPrompt.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(CoreDatabase.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces })),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
@@ -20,6 +21,7 @@ export const workspaceLayerWithRuntimeFlags = (overrides: Partial<RuntimeFlags.I
|
||||
Layer.provide(SessionPrompt.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(overrides)),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
@@ -50,6 +51,7 @@ const workspaceLayer = Workspace.layer.pipe(
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(Vcs.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: true })),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Bus } from "@/bus"
|
||||
import { Project } from "@/project/project"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -6,7 +6,7 @@ import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { PermissionTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
@@ -28,16 +28,9 @@ void Log.init({ print: false })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, Database.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const it = testEffect(layer)
|
||||
|
||||
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
})
|
||||
}
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
@@ -85,6 +78,7 @@ function projectLayerWithFailure(failArg: string) {
|
||||
Layer.provide(Bus.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
}
|
||||
@@ -96,6 +90,7 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
||||
Layer.provide(AppProcess.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
}
|
||||
@@ -107,10 +102,11 @@ const iconDiscoveryIt = testEffect(
|
||||
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Project.Info> {
|
||||
function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Project.Info, never, Project.Service> {
|
||||
return Effect.gen(function* () {
|
||||
const project = Project.get(id)
|
||||
if (project?.icon?.url) return project
|
||||
const project = yield* Project.Service
|
||||
const info = yield* project.get(id)
|
||||
if (info?.icon?.url) return info
|
||||
if (attempts <= 0) throw new Error(`Project icon was not discovered: ${id}`)
|
||||
yield* Effect.sleep("10 millis")
|
||||
return yield* waitForProjectIcon(id, attempts - 1)
|
||||
@@ -120,15 +116,16 @@ function waitForProjectIcon(id: ProjectV2.ID, attempts = 50): Effect.Effect<Proj
|
||||
describe("Project.fromDirectory", () => {
|
||||
it.live("should handle git repository with no commits", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).toBe(ProjectV2.ID.global)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(result.project).toBeDefined()
|
||||
expect(result.project.id).toBe(ProjectV2.ID.global)
|
||||
expect(result.project.vcs).toBe("git")
|
||||
expect(result.project.worktree).toBe(tmp)
|
||||
|
||||
const opencodeFile = path.join(tmp, ".git", "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
|
||||
@@ -137,119 +134,114 @@ describe("Project.fromDirectory", () => {
|
||||
|
||||
it.live("should handle git repository with commits", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).not.toBe(ProjectV2.ID.global)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(result.project).toBeDefined()
|
||||
expect(result.project.id).not.toBe(ProjectV2.ID.global)
|
||||
expect(result.project.vcs).toBe("git")
|
||||
expect(result.project.worktree).toBe(tmp)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.id).toBe(ProjectV2.ID.global)
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
expect(result.project.id).toBe(ProjectV2.ID.global)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("derives stable project ID from root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(b.id).toBe(a.id)
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
const next = yield* project.fromDirectory(tmp)
|
||||
expect(next.project.id).toBe(result.project.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("prefers normalized origin remote over root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
expect(result.project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("normalizes equivalent origin URL forms to the same project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const ssh = yield* tmpdirScoped({ git: true })
|
||||
const https = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
|
||||
const result = yield* project.fromDirectory(ssh)
|
||||
const next = yield* project.fromDirectory(https)
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
expect(result.project.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(next.project.id).toBe(result.project.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project: rootProject } = yield* projects.fromDirectory(tmp)
|
||||
const rootResult = yield* projects.fromDirectory(tmp)
|
||||
const rootProject = rootResult.project
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceV2.ID.ascending()
|
||||
|
||||
yield* Effect.sync(() => {
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
db.insert(WorkspaceTable)
|
||||
.values({
|
||||
id: workspaceID,
|
||||
type: "local",
|
||||
name: "test",
|
||||
project_id: rootProject.id,
|
||||
})
|
||||
.run()
|
||||
yield* db
|
||||
.insert(SessionTable)
|
||||
.values({
|
||||
id: sessionID,
|
||||
project_id: rootProject.id,
|
||||
slug: sessionID,
|
||||
directory: tmp,
|
||||
title: "test",
|
||||
version: "0.0.0-test",
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(PermissionTable)
|
||||
.values({
|
||||
project_id: rootProject.id,
|
||||
data: [{ permission: "edit", pattern: "*", action: "allow" }],
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(WorkspaceTable)
|
||||
.values({ id: workspaceID, type: "local", name: "test", project_id: rootProject.id })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
const result = yield* projects.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get()),
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get())?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(
|
||||
Database.use((db) => db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get()),
|
||||
).toBeDefined()
|
||||
expect(
|
||||
Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get())
|
||||
?.project_id,
|
||||
).toBe(remoteID)
|
||||
expect(result.project.id).toBe(remoteID)
|
||||
expect(yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, rootProject.id)).get().pipe(Effect.orDie)).toBeUndefined()
|
||||
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID)
|
||||
expect(yield* db.select().from(PermissionTable).where(eq(PermissionTable.project_id, remoteID)).get().pipe(Effect.orDie)).toBeDefined()
|
||||
expect((yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get().pipe(Effect.orDie))?.project_id).toBe(remoteID)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -257,34 +249,37 @@ describe("Project.fromDirectory", () => {
|
||||
describe("Project.fromDirectory git failure paths", () => {
|
||||
it.live("keeps vcs when rev-list exits non-zero (no commits)", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
|
||||
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectV2.ID.global)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
expect(result.project.vcs).toBe("git")
|
||||
expect(result.project.id).toBe(ProjectV2.ID.global)
|
||||
expect(result.project.worktree).toBe(tmp)
|
||||
}),
|
||||
)
|
||||
|
||||
failureIt("--show-toplevel").live("handles show-toplevel failure gracefully", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
expect(result.project.worktree).toBe(tmp)
|
||||
expect(result.sandbox).toBe(tmp)
|
||||
}),
|
||||
)
|
||||
|
||||
failureIt("--git-common-dir").live("handles git-common-dir failure gracefully", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
expect(result.project.worktree).toBe(tmp)
|
||||
expect(result.sandbox).toBe(tmp)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -292,18 +287,20 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
describe("Project.fromDirectory with worktrees", () => {
|
||||
it.live("should set worktree to root when called from root", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
expect(result.project.worktree).toBe(tmp)
|
||||
expect(result.sandbox).toBe(tmp)
|
||||
expect(result.project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("tracks a linked worktree as the opened project directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-worktree")
|
||||
@@ -317,20 +314,21 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const result = yield* project.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
expect(project.sandboxes).not.toContain(worktreePath)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
expect(result.project.worktree).toBe(worktreePath)
|
||||
expect(result.sandbox).toBe(worktreePath)
|
||||
expect(result.project.sandboxes).not.toContain(worktreePath)
|
||||
expect(result.project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("worktree should share project ID with main repo", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -343,9 +341,9 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const next = yield* project.fromDirectory(worktreePath)
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
expect(next.project.id).toBe(result.project.id)
|
||||
|
||||
const cache = path.join(tmp, ".git", "opencode")
|
||||
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
|
||||
@@ -355,6 +353,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
|
||||
it.live("separate clones of the same repo should share project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
// Create a bare remote, push, then clone into a second directory
|
||||
@@ -366,15 +365,16 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
|
||||
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
const next = yield* project.fromDirectory(clone)
|
||||
|
||||
expect(b.id).toBe(a.id)
|
||||
expect(next.project.id).toBe(result.project.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should accumulate multiple worktrees in sandboxes", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const worktree1 = path.join(tmp, "..", path.basename(tmp) + "-wt1")
|
||||
@@ -398,12 +398,12 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
|
||||
|
||||
yield* run((svc) => svc.fromDirectory(worktree1))
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
|
||||
yield* project.fromDirectory(worktree1)
|
||||
const result = yield* project.fromDirectory(worktree2)
|
||||
|
||||
expect(project.worktree).toBe(worktree1)
|
||||
expect(project.sandboxes).toContain(worktree2)
|
||||
expect(project.sandboxes).not.toContain(tmp)
|
||||
expect(result.project.worktree).toBe(worktree1)
|
||||
expect(result.project.sandboxes).toContain(worktree2)
|
||||
expect(result.project.sandboxes).not.toContain(tmp)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -411,12 +411,13 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
describe("Project.discover", () => {
|
||||
iconDiscoveryIt.live("discovers favicon from fromDirectory when enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const updated = yield* waitForProjectIcon(project.id)
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
const updated = yield* waitForProjectIcon(result.project.id)
|
||||
|
||||
expect(updated.icon?.url).toStartWith("data:")
|
||||
expect(updated.icon?.url).toContain("base64")
|
||||
@@ -425,15 +426,16 @@ describe("Project.discover", () => {
|
||||
|
||||
it.live("should discover favicon.png in root", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
|
||||
yield* run((svc) => svc.discover(project))
|
||||
yield* project.discover(result.project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
const updated = yield* project.get(result.project.id)
|
||||
expect(updated).toBeDefined()
|
||||
expect(updated!.icon).toBeDefined()
|
||||
expect(updated!.icon?.url).toStartWith("data:")
|
||||
@@ -444,14 +446,15 @@ describe("Project.discover", () => {
|
||||
|
||||
it.live("should not discover non-image files", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
|
||||
|
||||
yield* run((svc) => svc.discover(project))
|
||||
yield* project.discover(result.project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
const updated = yield* project.get(result.project.id)
|
||||
expect(updated).toBeDefined()
|
||||
expect(updated!.icon).toBeUndefined()
|
||||
}),
|
||||
@@ -459,25 +462,24 @@ describe("Project.discover", () => {
|
||||
|
||||
it.live("should not discover favicon when override is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,override" },
|
||||
}),
|
||||
)
|
||||
yield* project.update({
|
||||
projectID: result.project.id,
|
||||
icon: { override: "data:image/png;base64,override" },
|
||||
})
|
||||
|
||||
const updatedProject = yield* run((svc) => svc.get(project.id))
|
||||
const updatedProject = yield* project.get(result.project.id)
|
||||
if (!updatedProject) throw new Error("Project not found")
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
|
||||
yield* run((svc) => svc.discover(updatedProject))
|
||||
yield* project.discover(updatedProject)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
const updated = yield* project.get(result.project.id)
|
||||
expect(updated).toBeDefined()
|
||||
expect(updated!.icon?.override).toBe("data:image/png;base64,override")
|
||||
expect(updated!.icon?.url).toBeUndefined()
|
||||
@@ -488,107 +490,100 @@ describe("Project.discover", () => {
|
||||
describe("Project.update", () => {
|
||||
it.live("should update name", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
}),
|
||||
)
|
||||
const updated = yield* project.update({
|
||||
projectID: result.project.id,
|
||||
name: "New Project Name",
|
||||
})
|
||||
|
||||
expect(updated.name).toBe("New Project Name")
|
||||
|
||||
const fromDb = Project.get(project.id)
|
||||
const fromDb = yield* project.get(result.project.id)
|
||||
expect(fromDb?.name).toBe("New Project Name")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should update icon url", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* project.update({
|
||||
projectID: result.project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
})
|
||||
|
||||
expect(updated.icon?.url).toBe("https://example.com/icon.png")
|
||||
|
||||
const fromDb = Project.get(project.id)
|
||||
const fromDb = yield* project.get(result.project.id)
|
||||
expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should update icon color", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* project.update({
|
||||
projectID: result.project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
})
|
||||
|
||||
expect(updated.icon?.color).toBe("#ff0000")
|
||||
|
||||
const fromDb = Project.get(project.id)
|
||||
const fromDb = yield* project.get(result.project.id)
|
||||
expect(fromDb?.icon?.color).toBe("#ff0000")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should update icon override", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,abc123" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* project.update({
|
||||
projectID: result.project.id,
|
||||
icon: { override: "data:image/png;base64,abc123" },
|
||||
})
|
||||
|
||||
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
|
||||
|
||||
const fromDb = Project.get(project.id)
|
||||
const fromDb = yield* project.get(result.project.id)
|
||||
expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should update commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* project.update({
|
||||
projectID: result.project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
})
|
||||
|
||||
expect(updated.commands?.start).toBe("npm run dev")
|
||||
|
||||
const fromDb = Project.get(project.id)
|
||||
const fromDb = yield* project.get(result.project.id)
|
||||
expect(fromDb?.commands?.start).toBe("npm run dev")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("should fail when project not found", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: ProjectV2.ID.make("nonexistent-project-id"),
|
||||
name: "Should Fail",
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
const project = yield* Project.Service
|
||||
const exit = yield* project
|
||||
.update({ projectID: ProjectV2.ID.make("nonexistent-project-id"), name: "Should Fail" })
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
@@ -599,8 +594,9 @@ describe("Project.update", () => {
|
||||
|
||||
it.live("should emit GlobalBus event on update", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
let eventPayload: any = null
|
||||
const on = (data: any) => {
|
||||
@@ -609,7 +605,7 @@ describe("Project.update", () => {
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
|
||||
yield* project.update({ projectID: result.project.id, name: "Updated Name" })
|
||||
|
||||
expect(eventPayload).not.toBeNull()
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
@@ -619,17 +615,16 @@ describe("Project.update", () => {
|
||||
|
||||
it.live("should update multiple fields at once", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* project.update({
|
||||
projectID: result.project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
})
|
||||
|
||||
expect(updated.name).toBe("Multi Update")
|
||||
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
|
||||
@@ -643,43 +638,49 @@ describe("Project.update", () => {
|
||||
describe("Project.list and Project.get", () => {
|
||||
it.live("list returns all projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const all = Project.list()
|
||||
const all = yield* project.list()
|
||||
expect(all.length).toBeGreaterThan(0)
|
||||
expect(all.find((p) => p.id === project.id)).toBeDefined()
|
||||
expect(all.find((p) => p.id === result.project.id)).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("get returns project by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
const found = Project.get(project.id)
|
||||
const found = yield* project.get(result.project.id)
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.id).toBe(project.id)
|
||||
expect(found!.id).toBe(result.project.id)
|
||||
}),
|
||||
)
|
||||
|
||||
test("get returns undefined for unknown id", () => {
|
||||
const found = Project.get(ProjectV2.ID.make("nonexistent"))
|
||||
expect(found).toBeUndefined()
|
||||
})
|
||||
it.live("get returns undefined for unknown id", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const found = yield* project.get(ProjectV2.ID.make("nonexistent"))
|
||||
expect(found).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Project.setInitialized", () => {
|
||||
it.live("sets time_initialized on project", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
|
||||
expect(project.time.initialized).toBeUndefined()
|
||||
expect(result.project.time.initialized).toBeUndefined()
|
||||
|
||||
Project.setInitialized(project.id)
|
||||
yield* project.setInitialized(result.project.id)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
const updated = yield* project.get(result.project.id)
|
||||
expect(updated?.time.initialized).toBeDefined()
|
||||
}),
|
||||
)
|
||||
@@ -688,26 +689,28 @@ describe("Project.setInitialized", () => {
|
||||
describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
it.live("addSandbox adds directory and removeSandbox removes it", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
const sandboxDir = path.join(tmp, "sandbox-test")
|
||||
|
||||
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
yield* project.addSandbox(result.project.id, sandboxDir)
|
||||
|
||||
let found = Project.get(project.id)
|
||||
let found = yield* project.get(result.project.id)
|
||||
expect(found?.sandboxes).toContain(sandboxDir)
|
||||
|
||||
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
|
||||
yield* project.removeSandbox(result.project.id, sandboxDir)
|
||||
|
||||
found = Project.get(project.id)
|
||||
found = yield* project.get(result.project.id)
|
||||
expect(found?.sandboxes).not.toContain(sandboxDir)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("addSandbox emits GlobalBus event", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const result = yield* project.fromDirectory(tmp)
|
||||
const sandboxDir = path.join(tmp, "sandbox-event")
|
||||
|
||||
const events: any[] = []
|
||||
@@ -715,7 +718,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
yield* project.addSandbox(result.project.id, sandboxDir)
|
||||
|
||||
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
|
||||
}),
|
||||
@@ -725,6 +728,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
describe("Project.fromDirectory with bare repos", () => {
|
||||
it.live("worktree from bare repo should cache in bare repo, not parent", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const parentDir = path.dirname(tmp)
|
||||
@@ -737,10 +741,10 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const result = yield* project.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.id).not.toBe(ProjectV2.ID.global)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(result.project.id).not.toBe(ProjectV2.ID.global)
|
||||
expect(result.project.worktree).toBe(worktreePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
const wrongCache = path.join(parentDir, ".git", "opencode")
|
||||
@@ -752,6 +756,7 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
|
||||
it.live("different bare repos under same parent should not share project ID", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp1 = yield* tmpdirScoped({ git: true })
|
||||
const tmp2 = yield* tmpdirScoped({ git: true })
|
||||
|
||||
@@ -771,10 +776,10 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
|
||||
|
||||
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
|
||||
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
|
||||
const result = yield* project.fromDirectory(worktreeA)
|
||||
const next = yield* project.fromDirectory(worktreeB)
|
||||
|
||||
expect(projA.id).not.toBe(projB.id)
|
||||
expect(result.project.id).not.toBe(next.project.id)
|
||||
|
||||
const cacheA = path.join(bareA, "opencode")
|
||||
const cacheB = path.join(bareB, "opencode")
|
||||
@@ -788,6 +793,7 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
|
||||
it.live("bare repo without .git suffix is still detected via core.bare", () =>
|
||||
Effect.gen(function* () {
|
||||
const project = yield* Project.Service
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const parentDir = path.dirname(tmp)
|
||||
@@ -800,10 +806,10 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const result = yield* project.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.id).not.toBe(ProjectV2.ID.global)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(result.project.id).not.toBe(ProjectV2.ID.global)
|
||||
expect(result.project.worktree).toBe(worktreePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Session } from "@/session/session"
|
||||
import type { SessionID } from "../../src/session/schema"
|
||||
import { ShareNext } from "@/share/share-next"
|
||||
import { SessionShareTable } from "@opencode-ai/core/share/sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -22,7 +22,8 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const env = Layer.mergeAll(
|
||||
Session.defaultLayer,
|
||||
AccountRepo.layer,
|
||||
AccountRepo.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
NodeFileSystem.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
)
|
||||
@@ -43,8 +44,9 @@ function live(client: HttpClient.HttpClient) {
|
||||
const http = Layer.succeed(HttpClient.HttpClient, client)
|
||||
return ShareNext.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))),
|
||||
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(http),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
@@ -57,12 +59,13 @@ function wired(client: HttpClient.HttpClient) {
|
||||
Bus.layer,
|
||||
ShareNext.layer,
|
||||
Session.defaultLayer,
|
||||
AccountRepo.layer,
|
||||
AccountRepo.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
NodeFileSystem.layer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
).pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(http))),
|
||||
Layer.provide(Account.layer.pipe(Layer.provide(AccountRepo.defaultLayer), Layer.provide(http))),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(http),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
@@ -70,7 +73,10 @@ function wired(client: HttpClient.HttpClient) {
|
||||
}
|
||||
|
||||
const share = (id: SessionID) =>
|
||||
Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get())
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return yield* db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get().pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const seed = (url: string, org?: string) =>
|
||||
AccountRepo.Service.use((repo) =>
|
||||
@@ -169,7 +175,7 @@ describe("ShareNext", () => {
|
||||
expect(result.url).toBe("https://legacy-share.example.com/share/abc")
|
||||
expect(result.secret).toBe("sec_123")
|
||||
|
||||
const row = share(session.id)
|
||||
const row = yield* share(session.id)
|
||||
expect(row?.id).toBe("shr_abc")
|
||||
expect(row?.url).toBe("https://legacy-share.example.com/share/abc")
|
||||
expect(row?.secret).toBe("sec_123")
|
||||
@@ -207,7 +213,7 @@ describe("ShareNext", () => {
|
||||
yield* ShareNext.use.remove(session.id)
|
||||
}).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(share(session.id)).toBeUndefined()
|
||||
expect(yield* share(session.id)).toBeUndefined()
|
||||
expect(seen.map((req) => [req.method, req.url])).toEqual([
|
||||
["POST", "https://legacy-share.example.com/api/share"],
|
||||
["DELETE", "https://legacy-share.example.com/api/share/shr_abc"],
|
||||
@@ -228,7 +234,7 @@ describe("ShareNext", () => {
|
||||
)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(share(session.id)).toBeUndefined()
|
||||
expect(yield* share(session.id)).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -252,19 +258,17 @@ describe("ShareNext", () => {
|
||||
const info = yield* session.create({ title: "first" })
|
||||
yield* share.init()
|
||||
yield* Effect.sleep(50)
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionShareTable)
|
||||
.values({
|
||||
session_id: info.id,
|
||||
id: "shr_abc",
|
||||
url: "https://legacy-share.example.com/share/abc",
|
||||
secret: "sec_123",
|
||||
})
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionShareTable)
|
||||
.values({
|
||||
session_id: info.id,
|
||||
id: "shr_abc",
|
||||
url: "https://legacy-share.example.com/share/abc",
|
||||
secret: "sec_123",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* bus.publish(Session.Event.Diff, {
|
||||
sessionID: info.id,
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
# Remove `packages/opencode/src/storage/db.ts`
|
||||
|
||||
## Goal
|
||||
|
||||
Remove all production usages of the legacy `packages/opencode/src/storage/db.ts` module.
|
||||
|
||||
This means eliminating imports from `@/storage/db` or `./storage/db`, including:
|
||||
|
||||
- `Database.use(...)`
|
||||
- `Database.transaction(...)`
|
||||
- `Database.effect(...)`
|
||||
- `Database.Client()`
|
||||
- `Database.getPath()`
|
||||
- `Database.TxOrDb` / `Database.Transaction`
|
||||
- drizzle helpers re-exported from `@/storage/db`, such as `eq`
|
||||
|
||||
This does not mean removing SQLite or Drizzle everywhere in one step. The smaller target is deleting the opencode legacy wrapper by moving call sites onto deeper modules or onto the core/effect database adapter directly.
|
||||
|
||||
## Current Inventory
|
||||
|
||||
Production imports from `packages/opencode/src/storage/db.ts` are concentrated in 22 source files:
|
||||
|
||||
- `packages/opencode/src/account/repo.ts`
|
||||
- `packages/opencode/src/cli/cmd/db.ts`
|
||||
- `packages/opencode/src/cli/cmd/import.ts`
|
||||
- `packages/opencode/src/cli/cmd/stats.ts`
|
||||
- `packages/opencode/src/control-plane/workspace.ts`
|
||||
- `packages/opencode/src/data-migration.ts`
|
||||
- `packages/opencode/src/index.ts`
|
||||
- `packages/opencode/src/node.ts`
|
||||
- `packages/opencode/src/permission/index.ts`
|
||||
- `packages/opencode/src/project/project.ts`
|
||||
- `packages/opencode/src/server/projectors.ts`
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts`
|
||||
- `packages/opencode/src/server/shared/fence.ts`
|
||||
- `packages/opencode/src/session/message-v2.ts`
|
||||
- `packages/opencode/src/session/projectors.ts`
|
||||
- `packages/opencode/src/session/prompt.ts`
|
||||
- `packages/opencode/src/session/session.ts`
|
||||
- `packages/opencode/src/session/todo.ts`
|
||||
- `packages/opencode/src/share/share-next.ts`
|
||||
- `packages/opencode/src/storage/db.ts`
|
||||
- `packages/opencode/src/sync/index.ts`
|
||||
- `packages/opencode/src/worktree/index.ts`
|
||||
|
||||
There are 65 direct API/type references in those files. The references fall into the groups below.
|
||||
|
||||
## Group 1: Database Runtime And Startup
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/storage/db.ts`
|
||||
- `packages/opencode/src/index.ts`
|
||||
- `packages/opencode/src/node.ts`
|
||||
- `packages/opencode/src/cli/cmd/db.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `storage/db.ts` opens the singleton database, applies pragmas, exposes callback-style access, holds ambient transaction context, and queues post-commit effects.
|
||||
- `index.ts` checks `Database.getPath()` to decide whether JSON migration is needed, then runs `JsonMigration.run(drizzle({ client: Database.Client().$client }), ...)`.
|
||||
- `node.ts` publicly re-exports `Database` from the legacy module.
|
||||
- `cli/cmd/db.ts` uses `Database.getPath()` to print the path, open a readonly Bun SQLite handle, run `sqlite3`, and vacuum.
|
||||
|
||||
Why this group comes first:
|
||||
|
||||
- These call sites define the seam currently used by every other group.
|
||||
- Deleting `storage/db.ts` requires an explicit replacement for database path, client acquisition, migration startup, and close/finalization.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Move database path and client startup behind the core/effect database module rather than the opencode wrapper.
|
||||
- Replace `Database.Client()` with an Effect-provided database service or a narrow startup-only adapter.
|
||||
- Replace the public `node.ts` re-export with either no export or a stable non-legacy database capability.
|
||||
- Keep `cli/cmd/db.ts` as an admin/raw SQLite tool, but make it ask the replacement database path provider instead of importing `@/storage/db`.
|
||||
|
||||
## Group 2: Sync Event Transaction Boundary
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/sync/index.ts`
|
||||
- `packages/opencode/src/session/projectors.ts`
|
||||
- `packages/opencode/src/server/projectors.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `SyncEvent.run` uses `Database.transaction(..., { behavior: "immediate" })` to allocate event sequence numbers safely.
|
||||
- `SyncEvent.process` wraps projector execution, event sequence writes, event log writes, and post-commit publishing in `Database.transaction(...)`.
|
||||
- `Database.effect(...)` queues publish side effects until after the transaction commits.
|
||||
- Projector functions accept `Database.TxOrDb` so they can write through either a root client or the active transaction.
|
||||
|
||||
Why this group is critical:
|
||||
|
||||
- It depends on the most non-obvious legacy behavior: nested `Database.use` inside a transaction must see the active transaction, and `Database.effect` must not publish until commit.
|
||||
- It is the central seam for session, message, permission, workspace, and server projection writes.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Replace `Database.TxOrDb` with an explicit projector transaction type from the replacement database adapter.
|
||||
- Move transaction context and after-commit behavior into an Effect-native sync event implementation.
|
||||
- Preserve immediate transaction behavior for sequence allocation.
|
||||
- Convert projector registration to accept the new transaction interface before converting every projector body.
|
||||
|
||||
Suggested first step:
|
||||
|
||||
- Create a narrow internal module for sync projection execution, then migrate `SyncEvent.project(...)` and projector type signatures to that module. Keep the implementation backed by the new database adapter until all projector users are moved.
|
||||
|
||||
## Group 3: Domain Repositories Already Behind Services
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/account/repo.ts`
|
||||
- `packages/opencode/src/project/project.ts`
|
||||
- `packages/opencode/src/control-plane/workspace.ts`
|
||||
- `packages/opencode/src/share/share-next.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- These modules already expose Effect services or Effect functions, but internally wrap `Database.use` with local `db(...)` helpers or `Effect.try`.
|
||||
- `account/repo.ts` uses both `Database.use` and `Database.transaction` through a repository interface.
|
||||
- `project/project.ts` has the largest mixed usage: Effect service methods use a local `db(...)` helper, while legacy top-level functions still call `Database.use` directly.
|
||||
- `control-plane/workspace.ts` and `share/share-next.ts` have local Effect wrappers around `Database.use`.
|
||||
|
||||
Why this group is tractable:
|
||||
|
||||
- The public interfaces are already deeper than the database calls.
|
||||
- Most callers should not need to know whether these modules use Drizzle, files, or core services internally.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Inject the replacement database service into each Effect layer and yield Effect Drizzle queries directly.
|
||||
- Replace local callback wrappers with direct Effect queries.
|
||||
- Move remaining synchronous top-level helpers either behind the existing service interface or onto core modules.
|
||||
|
||||
Suggested order:
|
||||
|
||||
- Start with `account/repo.ts`; it has a clear repository interface and few call sites.
|
||||
- Then migrate `share/share-next.ts` and `control-plane/workspace.ts` local wrappers.
|
||||
- Leave `project/project.ts` for last in this group because it mixes project resolution, VCS, global bus emission, migration, and legacy top-level helpers.
|
||||
|
||||
## Group 4: Session And Message Read Models
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/session/session.ts`
|
||||
- `packages/opencode/src/session/message-v2.ts`
|
||||
- `packages/opencode/src/session/prompt.ts`
|
||||
- `packages/opencode/src/session/todo.ts`
|
||||
- `packages/opencode/src/session/projectors.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `session/session.ts` uses `Database.use` for session reads, list queries, children, part lookup, and global list helpers.
|
||||
- `session/message-v2.ts` uses `Database.use` to page messages, hydrate parts, fetch one message, and fetch parts.
|
||||
- `session/prompt.ts` imports `eq` from `@/storage/db` and reads current prompt-related session/message rows directly.
|
||||
- `session/todo.ts` uses `Database.transaction` for todo replacement and `Database.use` for list reads.
|
||||
- `session/projectors.ts` uses `TxOrDb` for session/message usage projection helpers.
|
||||
|
||||
Why this group should be split:
|
||||
|
||||
- Reads can move independently from projector writes.
|
||||
- Message hydration is used by model prompt construction and session APIs, so changing it without a stable read module would spread query details across callers.
|
||||
- Projector writes are tied to Group 2's transaction type.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Create or use a session/message read module with Effect-native methods for `get`, `list`, `page`, `parts`, and prompt assembly reads.
|
||||
- Move todo persistence either into a session todo repository or into the sync event projection path.
|
||||
- Convert `session/projectors.ts` only after Group 2 defines the replacement projector transaction type.
|
||||
|
||||
Suggested order:
|
||||
|
||||
- Migrate `session/message-v2.ts` reads first because the module already centralizes message pagination and hydration.
|
||||
- Migrate `session/session.ts` read helpers next.
|
||||
- Migrate `session/prompt.ts` after message/session reads exist, and import drizzle operators from `drizzle-orm` if any direct SQL remains temporarily.
|
||||
- Migrate `session/todo.ts` writes with the sync transaction work or move them behind a repository.
|
||||
|
||||
## Group 5: Legacy CLI And One-Off Admin Reads
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/cli/cmd/import.ts`
|
||||
- `packages/opencode/src/cli/cmd/stats.ts`
|
||||
- `packages/opencode/src/server/shared/fence.ts`
|
||||
- `packages/opencode/src/server/routes/instance/httpapi/handlers/sync.ts`
|
||||
- `packages/opencode/src/worktree/index.ts`
|
||||
- `packages/opencode/src/permission/index.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- `cli/cmd/import.ts` writes imported sessions/messages/parts directly with `Database.use`.
|
||||
- `cli/cmd/stats.ts` reads all sessions directly.
|
||||
- `server/shared/fence.ts` queries sessions for fence context.
|
||||
- `handlers/sync.ts` reads event rows for HTTP sync endpoints.
|
||||
- `worktree/index.ts` looks up a project row for worktree behavior.
|
||||
- `permission/index.ts` reads permission rows directly.
|
||||
|
||||
Why this group is mostly cleanup:
|
||||
|
||||
- Most usages are small and can either call an existing domain service or be given a narrow query function.
|
||||
- They are not defining shared transaction semantics.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Replace direct database reads with existing services where possible.
|
||||
- For admin/import commands, prefer dedicated import/stat modules rather than direct database access from command handlers.
|
||||
- For HTTP sync reads, move the event log query behind the sync event module.
|
||||
- For permission and worktree reads, call the permission/project services if available; otherwise add narrow repository methods.
|
||||
|
||||
## Group 6: Data Migrations
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/opencode/src/data-migration.ts`
|
||||
|
||||
Current usage:
|
||||
|
||||
- Checks `DataMigrationTable` with `Database.use`.
|
||||
- Runs resumable data migrations with `Database.use` and `Database.transaction`.
|
||||
- Writes completion rows with `Database.use`.
|
||||
|
||||
Why this group is separate:
|
||||
|
||||
- Data migrations are database-native by definition and may reasonably stay close to SQL.
|
||||
- They should not depend on the legacy opencode wrapper, but they do need a stable transaction API and migration completion store.
|
||||
|
||||
Target shape:
|
||||
|
||||
- Run data migrations through the same Effect database service used by startup/migrations.
|
||||
- Keep SQL-heavy migration logic local to `data-migration.ts`, but remove callback-style legacy access.
|
||||
- Ensure migrations still run in a scoped/background fiber and remain resumable.
|
||||
|
||||
## Recommended Migration Sequence
|
||||
|
||||
1. Replace the legacy runtime seam from Group 1 with an Effect-native database module that exposes path, client/query access, transaction, and after-commit behavior.
|
||||
2. Port Group 2 sync event transaction semantics to the new module before touching projector bodies.
|
||||
3. Migrate Group 3 repositories that already hide database access behind service interfaces.
|
||||
4. Migrate Group 4 session/message reads, then projector write helpers once the new projector transaction type exists.
|
||||
5. Migrate Group 6 data migrations onto the new database service.
|
||||
6. Clean up Group 5 one-off reads and CLI/admin commands.
|
||||
7. Remove drizzle helper re-exports from `@/storage/db` imports by importing operators directly from `drizzle-orm` during each file migration.
|
||||
8. Delete `packages/opencode/src/storage/db.ts` once `rg "@/storage/db|./storage/db|Database\." packages/opencode/src` no longer finds legacy usages.
|
||||
|
||||
## Invariants To Preserve
|
||||
|
||||
- Nested reads inside a transaction must use the active transaction, not the root client.
|
||||
- `SyncEvent.run` sequence allocation must keep immediate transaction behavior.
|
||||
- Post-commit publish effects must not run before the transaction commits.
|
||||
- Data migrations must remain resumable and record completion only after successful migration work.
|
||||
- Existing schema ownership remains in `packages/core/src/**/*.sql.ts`; do not move table definitions back into `packages/opencode`.
|
||||
|
||||
## Verification Commands
|
||||
|
||||
- `rg "@/storage/db|./storage/db|Database\.(use|transaction|effect|Client|getPath)|\bTxOrDb\b|\bTransaction\b" packages/opencode/src`
|
||||
- `bun typecheck` from `packages/opencode`
|
||||
- Relevant package tests from `packages/opencode`, not the repo root
|
||||
Reference in New Issue
Block a user