import { LayerNode } from "@opencode-ai/core/effect/layer-node" 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 "@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"] const ACCOUNT_STATE_ID = 1 export interface Interface { readonly active: () => Effect.Effect, AccountRepoError> readonly list: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> readonly persistToken: (input: { accountID: AccountID accessToken: AccessToken refreshToken: RefreshToken expiry: Option.Option }) => Effect.Effect readonly persistAccount: (input: { id: AccountID email: string url: string accessToken: AccessToken refreshToken: RefreshToken expiry: number orgID: Option.Option }) => Effect.Effect } export class Service extends Context.Service()("@opencode/AccountRepo") {} export const use = serviceUse(Service) export const layer = Layer.effect( Service, Effect.gen(function* () { const { db } = yield* Database.Service const decode = Schema.decodeUnknownSync(Info) const query = (effect: Effect.Effect) => effect.pipe(Effect.mapError((cause) => new AccountRepoError({ message: "Database operation failed", cause }))) const current = Effect.fnUntraced(function* () { const state = yield* db.select().from(AccountStateTable).where(eq(AccountStateTable.id, ACCOUNT_STATE_ID)).get() if (!state?.active_account_id) return const account = yield* db.select().from(AccountTable).where(eq(AccountTable.id, state.active_account_id)).get() if (!account) return return { ...account, active_org_id: state.active_org_id ?? null } }) const state = (accountID: AccountID, orgID: Option.Option) => { const id = Option.getOrNull(orgID) return db .insert(AccountStateTable) .values({ id: ACCOUNT_STATE_ID, active_account_id: accountID, active_org_id: id }) .onConflictDoUpdate({ target: AccountStateTable.id, set: { active_account_id: accountID, active_org_id: id }, }) .run() } const active = Effect.fn("AccountRepo.active")(() => query(current()).pipe(Effect.map((row) => (row ? Option.some(decode(row)) : Option.none()))), ) const list = Effect.fn("AccountRepo.list")(() => query( db .select() .from(AccountTable) .all() .pipe(Effect.map((rows) => rows.map((row: AccountRow) => decode({ ...row, active_org_id: null })))), ), ) const remove = Effect.fn("AccountRepo.remove")((accountID: AccountID) => 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) => query(state(accountID, orgID)).pipe(Effect.asVoid), ) const getRow = Effect.fn("AccountRepo.getRow")((accountID: AccountID) => 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 .update(AccountTable) .set({ access_token: input.accessToken, refresh_token: input.refreshToken, token_expiry: Option.getOrNull(input.expiry), }) .where(eq(AccountTable.id, input.accountID)) .run(), ).pipe(Effect.asVoid), ) const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => query( db.transaction((tx) => Effect.gen(function* () { const url = normalizeServerUrl(input.url) 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({ active, list, remove, use, getRow, persistToken, persistAccount, }) }), ) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) export const node = LayerNode.make(layer, [Database.node]) export * as AccountRepo from "./repo"