refactor: normalize AccountRepo to canonical Effect service pattern (#22991)

This commit is contained in:
Kit Langton
2026-04-17 01:43:57 +00:00
committed by GitHub
parent 5b9fa32255
commit 9c87a144e8
5 changed files with 184 additions and 184 deletions
+2 -2
View File
@@ -181,10 +181,10 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Account") {}
export const layer: Layer.Layer<Service, never, AccountRepo | HttpClient.HttpClient> = Layer.effect( export const layer: Layer.Layer<Service, never, AccountRepo.Service | HttpClient.HttpClient> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const repo = yield* AccountRepo const repo = yield* AccountRepo.Service
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const httpRead = withTransientReadRetry(http) const httpRead = withTransientReadRetry(http)
const httpOk = HttpClient.filterStatusOk(http) const httpOk = HttpClient.filterStatusOk(http)
+8 -8
View File
@@ -13,8 +13,7 @@ type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
const ACCOUNT_STATE_ID = 1 const ACCOUNT_STATE_ID = 1
export namespace AccountRepo { export interface Interface {
export interface Service {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
readonly list: () => Effect.Effect<Info[], AccountRepoError> readonly list: () => Effect.Effect<Info[], AccountRepoError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError> readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
@@ -36,11 +35,11 @@ export namespace AccountRepo {
orgID: Option.Option<OrgID> orgID: Option.Option<OrgID>
}) => Effect.Effect<void, AccountRepoError> }) => Effect.Effect<void, AccountRepoError>
} }
}
export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Service>()("@opencode/AccountRepo") { export class Service extends Context.Service<Service, Interface>()("@opencode/AccountRepo") {}
static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
AccountRepo, export const layer: Layer.Layer<Service> = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const decode = Schema.decodeUnknownSync(Info) const decode = Schema.decodeUnknownSync(Info)
@@ -152,7 +151,7 @@ export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Servic
}).pipe(Effect.asVoid), }).pipe(Effect.asVoid),
) )
return AccountRepo.of({ return Service.of({
active, active,
list, list,
remove, remove,
@@ -163,4 +162,5 @@ export class AccountRepo extends Context.Service<AccountRepo, AccountRepo.Servic
}) })
}), }),
) )
}
export * as AccountRepo from "./repo"
+39 -39
View File
@@ -18,14 +18,14 @@ const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
it.live("list returns empty when no accounts exist", () => it.live("list returns empty when no accounts exist", () =>
Effect.gen(function* () { Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list()) const accounts = yield* AccountRepo.Service.use((r) => r.list())
expect(accounts).toEqual([]) expect(accounts).toEqual([])
}), }),
) )
it.live("active returns none when no accounts exist", () => it.live("active returns none when no accounts exist", () =>
Effect.gen(function* () { Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.isNone(active)).toBe(true) expect(Option.isNone(active)).toBe(true)
}), }),
) )
@@ -33,7 +33,7 @@ it.live("active returns none when no accounts exist", () =>
it.live("persistAccount inserts and getRow retrieves", () => it.live("persistAccount inserts and getRow retrieves", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -45,13 +45,13 @@ it.live("persistAccount inserts and getRow retrieves", () =>
}), }),
) )
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
expect(Option.isSome(row)).toBe(true) expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row) const value = Option.getOrThrow(row)
expect(value.id).toBe(AccountID.make("user-1")) expect(value.id).toBe(AccountID.make("user-1"))
expect(value.email).toBe("test@example.com") expect(value.email).toBe("test@example.com")
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1")) expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
}), }),
) )
@@ -60,7 +60,7 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -72,9 +72,9 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
}), }),
) )
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
const list = yield* AccountRepo.use((r) => r.list()) const list = yield* AccountRepo.Service.use((r) => r.list())
expect(Option.getOrThrow(row).url).toBe("https://control.example.com") expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
expect(Option.getOrThrow(active).url).toBe("https://control.example.com") expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
@@ -87,7 +87,7 @@ it.live("persistAccount sets the active account and org", () =>
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: id1, id: id1,
email: "first@example.com", email: "first@example.com",
@@ -99,7 +99,7 @@ it.live("persistAccount sets the active account and org", () =>
}), }),
) )
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: id2, id: id2,
email: "second@example.com", email: "second@example.com",
@@ -112,7 +112,7 @@ it.live("persistAccount sets the active account and org", () =>
) )
// Last persisted account is active with its org // Last persisted account is active with its org
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.isSome(active)).toBe(true) expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2")) expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2")) expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
@@ -124,7 +124,7 @@ it.live("list returns all accounts", () =>
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: id1, id: id1,
email: "a@example.com", email: "a@example.com",
@@ -136,7 +136,7 @@ it.live("list returns all accounts", () =>
}), }),
) )
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: id2, id: id2,
email: "b@example.com", email: "b@example.com",
@@ -148,7 +148,7 @@ it.live("list returns all accounts", () =>
}), }),
) )
const accounts = yield* AccountRepo.use((r) => r.list()) const accounts = yield* AccountRepo.Service.use((r) => r.list())
expect(accounts.length).toBe(2) expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"]) expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
}), }),
@@ -158,7 +158,7 @@ it.live("remove deletes an account", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -170,9 +170,9 @@ it.live("remove deletes an account", () =>
}), }),
) )
yield* AccountRepo.use((r) => r.remove(id)) yield* AccountRepo.Service.use((r) => r.remove(id))
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
expect(Option.isNone(row)).toBe(true) expect(Option.isNone(row)).toBe(true)
}), }),
) )
@@ -182,7 +182,7 @@ it.live("use stores the selected org and marks the account active", () =>
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2") const id2 = AccountID.make("user-2")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: id1, id: id1,
email: "first@example.com", email: "first@example.com",
@@ -194,7 +194,7 @@ it.live("use stores the selected org and marks the account active", () =>
}), }),
) )
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: id2, id: id2,
email: "second@example.com", email: "second@example.com",
@@ -206,13 +206,13 @@ it.live("use stores the selected org and marks the account active", () =>
}), }),
) )
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99")))) yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
const active1 = yield* AccountRepo.use((r) => r.active()) const active1 = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active1).id).toBe(id1) expect(Option.getOrThrow(active1).id).toBe(id1)
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99")) expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
yield* AccountRepo.use((r) => r.use(id1, Option.none())) yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
const active2 = yield* AccountRepo.use((r) => r.active()) const active2 = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active2).active_org_id).toBeNull() expect(Option.getOrThrow(active2).active_org_id).toBeNull()
}), }),
) )
@@ -221,7 +221,7 @@ it.live("persistToken updates token fields", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -234,7 +234,7 @@ it.live("persistToken updates token fields", () =>
) )
const expiry = Date.now() + 7200_000 const expiry = Date.now() + 7200_000
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistToken({ r.persistToken({
accountID: id, accountID: id,
accessToken: AccessToken.make("new_token"), accessToken: AccessToken.make("new_token"),
@@ -243,7 +243,7 @@ it.live("persistToken updates token fields", () =>
}), }),
) )
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row) const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("new_token")) expect(value.access_token).toBe(AccessToken.make("new_token"))
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh")) expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
@@ -255,7 +255,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -267,7 +267,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
}), }),
) )
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistToken({ r.persistToken({
accountID: id, accountID: id,
accessToken: AccessToken.make("new_token"), accessToken: AccessToken.make("new_token"),
@@ -276,7 +276,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
}), }),
) )
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
expect(Option.getOrThrow(row).token_expiry).toBeNull() expect(Option.getOrThrow(row).token_expiry).toBeNull()
}), }),
) )
@@ -285,7 +285,7 @@ it.live("persistAccount upserts on conflict", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -297,7 +297,7 @@ it.live("persistAccount upserts on conflict", () =>
}), }),
) )
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -309,14 +309,14 @@ it.live("persistAccount upserts on conflict", () =>
}), }),
) )
const accounts = yield* AccountRepo.use((r) => r.list()) const accounts = yield* AccountRepo.Service.use((r) => r.list())
expect(accounts.length).toBe(1) expect(accounts.length).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row) const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_v2")) expect(value.access_token).toBe(AccessToken.make("at_v2"))
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2")) expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
}), }),
) )
@@ -325,7 +325,7 @@ it.live("remove clears active state when deleting the active account", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "test@example.com", email: "test@example.com",
@@ -337,16 +337,16 @@ it.live("remove clears active state when deleting the active account", () =>
}), }),
) )
yield* AccountRepo.use((r) => r.remove(id)) yield* AccountRepo.Service.use((r) => r.remove(id))
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.isNone(active)).toBe(true) expect(Option.isNone(active)).toBe(true)
}), }),
) )
it.live("getRow returns none for nonexistent account", () => it.live("getRow returns none for nonexistent account", () =>
Effect.gen(function* () { Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope"))) const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true) expect(Option.isNone(row)).toBe(true)
}), }),
) )
+10 -10
View File
@@ -122,7 +122,7 @@ it.live("login maps transport failures to account transport errors", () =>
it.live("orgsByAccount groups orgs per account", () => it.live("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: AccountID.make("user-1"), id: AccountID.make("user-1"),
email: "one@example.com", email: "one@example.com",
@@ -134,7 +134,7 @@ it.live("orgsByAccount groups orgs per account", () =>
}), }),
) )
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id: AccountID.make("user-2"), id: AccountID.make("user-2"),
email: "two@example.com", email: "two@example.com",
@@ -177,7 +177,7 @@ it.live("token refresh persists the new token", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "user@example.com", email: "user@example.com",
@@ -206,7 +206,7 @@ it.live("token refresh persists the new token", () =>
expect(Option.getOrThrow(token)).toBeDefined() expect(Option.getOrThrow(token)).toBeDefined()
expect(String(Option.getOrThrow(token))).toBe("at_new") expect(String(Option.getOrThrow(token))).toBe("at_new")
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row) const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new")) expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
@@ -218,7 +218,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "user@example.com", email: "user@example.com",
@@ -251,7 +251,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
expect(String(Option.getOrThrow(token))).toBe("at_new") expect(String(Option.getOrThrow(token))).toBe("at_new")
expect(refreshCalls).toBe(1) expect(refreshCalls).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row) const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new")) expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
@@ -262,7 +262,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "user@example.com", email: "user@example.com",
@@ -315,7 +315,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
expect(String(Option.getOrThrow(token))).toBe("at_new") expect(String(Option.getOrThrow(token))).toBe("at_new")
expect(refreshCalls).toBe(1) expect(refreshCalls).toBe(1)
const row = yield* AccountRepo.use((r) => r.getRow(id)) const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
const value = Option.getOrThrow(row) const value = Option.getOrThrow(row)
expect(value.access_token).toBe(AccessToken.make("at_new")) expect(value.access_token).toBe(AccessToken.make("at_new"))
expect(value.refresh_token).toBe(RefreshToken.make("rt_new")) expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
@@ -326,7 +326,7 @@ it.live("config sends the selected org header", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id = AccountID.make("user-1") const id = AccountID.make("user-1")
yield* AccountRepo.use((r) => yield* AccountRepo.Service.use((r) =>
r.persistAccount({ r.persistAccount({
id, id,
email: "user@example.com", email: "user@example.com",
@@ -388,7 +388,7 @@ it.live("poll stores the account and first org on success", () =>
expect(res.email).toBe("user@example.com") expect(res.email).toBe("user@example.com")
} }
const active = yield* AccountRepo.use((r) => r.active()) const active = yield* AccountRepo.Service.use((r) => r.active())
expect(Option.getOrThrow(active)).toEqual( expect(Option.getOrThrow(active)).toEqual(
expect.objectContaining({ expect.objectContaining({
id: "user-1", id: "user-1",
@@ -72,7 +72,7 @@ const share = (id: SessionID) =>
Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get()) Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get())
const seed = (url: string, org?: string) => const seed = (url: string, org?: string) =>
AccountRepo.use((repo) => AccountRepo.Service.use((repo) =>
repo.persistAccount({ repo.persistAccount({
id: AccountID.make("account-1"), id: AccountID.make("account-1"),
email: "user@example.com", email: "user@example.com",