core: effectify CLI account commands with tagged PollResult union

- Rewrite all account CLI commands (login, logout, switch, orgs) as
  Effect.fn with AccountService accessed directly via yield*
- Convert PollResult from plain object union to Schema.TaggedClass
  variants with Schema.Union and Match.valueTags for exhaustive matching
- Add recursive poll function for device auth flow (stack-safe via
  Effect trampolining)
- Add shared Effect runtime at src/effect/runtime.ts
- Add Effect wrappers for @clack/prompts at src/cli/effect/prompt.ts
- Add @effect/language-service TS plugin for editor support
- Refactor repo tests to use testEffect helper with Layer-based setup
- Parallelize org fetching in orgs command

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kit Langton
2026-03-06 12:34:03 -05:00
parent 48158ce97d
commit f807875a99
11 changed files with 365 additions and 275 deletions

View File

@@ -350,6 +350,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.78.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -915,6 +916,8 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/language-service": ["@effect/language-service@0.78.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-dEYMhvhKFhikXpXjOlcByRKOU34xFeZbZcOuVTeWTQmjmZ/ZF4K3z0Ku6vL4cWKC8RRIB5di+ix18Y4f9ey5Dg=="],
"@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="],
"@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="],

View File

@@ -27,6 +27,7 @@
},
"devDependencies": {
"@babel/core": "7.28.4",
"@effect/language-service": "0.78.0",
"@octokit/webhooks-types": "7.6.1",
"@opencode-ai/script": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",

View File

@@ -1,4 +1,4 @@
import { Effect, ManagedRuntime, Option } from "effect"
import { Effect, Option } from "effect"
import {
Account as AccountSchema,
@@ -13,7 +13,7 @@ import {
export { AccessToken, AccountID, OrgID } from "./service"
const runtime = ManagedRuntime.make(AccountService.defaultLayer)
import { runtime } from "@/effect/runtime"
type AccountServiceShape = ReturnType<typeof AccountService.of>

View File

@@ -42,10 +42,21 @@ export class Login extends Schema.Class<Login>("Login")({
interval: Schema.Number,
}) {}
export type PollResult =
| { type: "success"; email: string }
| { type: "pending" }
| { type: "slow" }
| { type: "expired" }
| { type: "denied" }
| { type: "error"; msg: string }
export class PollSuccess extends Schema.TaggedClass<PollSuccess>()("PollSuccess", {
email: Schema.String,
}) {}
export class PollPending extends Schema.TaggedClass<PollPending>()("PollPending", {}) {}
export class PollSlow extends Schema.TaggedClass<PollSlow>()("PollSlow", {}) {}
export class PollExpired extends Schema.TaggedClass<PollExpired>()("PollExpired", {}) {}
export class PollDenied extends Schema.TaggedClass<PollDenied>()("PollDenied", {}) {}
export class PollError extends Schema.TaggedClass<PollError>()("PollError", {
cause: Schema.Defect,
}) {}
export const PollResult = Schema.Union([PollSuccess, PollPending, PollSlow, PollExpired, PollDenied, PollError])
export type PollResult = Schema.Schema.Type<typeof PollResult>

View File

@@ -9,9 +9,37 @@ import {
import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo"
import { AccessToken, Account, AccountID, AccountServiceError, Login, OrgID, type PollResult } from "./schema"
import {
AccessToken,
Account,
AccountID,
AccountServiceError,
Login,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
} from "./schema"
export { AccessToken, Account, AccountID, AccountServiceError, Login, OrgID, type PollResult } from "./schema"
export {
AccessToken,
Account,
AccountID,
AccountServiceError,
Login,
OrgID,
PollDenied,
PollError,
PollExpired,
PollPending,
type PollResult,
PollSlow,
PollSuccess,
} from "./schema"
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
@@ -267,11 +295,11 @@ export class AccountService extends ServiceMap.Service<
)
if (!parsed.access_token) {
if (parsed.error === "authorization_pending") return { type: "pending" } as const
if (parsed.error === "slow_down") return { type: "slow" } as const
if (parsed.error === "expired_token") return { type: "expired" } as const
if (parsed.error === "access_denied") return { type: "denied" } as const
return { type: "error", msg: parsed.error ?? JSON.stringify(parsed) } as const
if (parsed.error === "authorization_pending") return new PollPending()
if (parsed.error === "slow_down") return new PollSlow()
if (parsed.error === "expired_token") return new PollExpired()
if (parsed.error === "access_denied") return new PollDenied()
return new PollError({ cause: parsed.error })
}
const access = parsed.access_token
@@ -310,7 +338,7 @@ export class AccountService extends ServiceMap.Service<
const userEmail = user.email
if (!userId || !userEmail) {
return { type: "error", msg: "No id or email in response" } as const
return new PollError({ cause: "No id or email in response" })
}
const firstOrgID = remoteOrgs.length > 0 ? Option.fromNullishOr(remoteOrgs[0].id) : Option.none()
@@ -329,7 +357,7 @@ export class AccountService extends ServiceMap.Service<
orgID: firstOrgID,
})
return { type: "success", email: userEmail } as const
return new PollSuccess({ email: userEmail })
})
return AccountService.of({

View File

@@ -1,7 +1,111 @@
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { Effect, Match, Option } from "effect"
import { UI } from "../ui"
import { Account, OrgID } from "@/account"
import { OrgID } from "@/account"
import { runtime } from "@/effect/runtime"
import { AccountService, type PollResult } from "@/account/service"
import { type AccountServiceError } from "@/account/schema"
import * as Prompt from "../effect/prompt"
import open from "open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const loginEffect = Effect.fn("login")(function* (url?: string) {
const service = yield* AccountService
yield* Prompt.intro("Log in")
const login = yield* service.login(url)
yield* Prompt.log.info("Go to: " + login.url)
yield* Prompt.log.info("Enter code: " + login.user)
yield* openBrowser(login.url)
const s = Prompt.spinner()
yield* s.start("Waiting for authorization...")
const poll = (wait: number): Effect.Effect<PollResult, AccountServiceError> =>
Effect.gen(function* () {
yield* Effect.sleep(wait)
const result = yield* service.poll(login)
if (result._tag === "PollPending") return yield* poll(wait)
if (result._tag === "PollSlow") return yield* poll(wait + 5000)
return result
})
const result = yield* poll(login.interval * 1000)
yield* Match.valueTags(result, {
PollSuccess: (r) =>
Effect.gen(function* () {
yield* s.stop("Logged in as " + r.email)
yield* Prompt.outro("Done")
}),
PollExpired: () => s.stop("Device code expired", 1),
PollDenied: () => s.stop("Authorization denied", 1),
PollError: (r) => s.stop("Error: " + String(r.cause), 1),
PollPending: () => s.stop("Unexpected state", 1),
PollSlow: () => s.stop("Unexpected state", 1),
})
})
const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const service = yield* AccountService
if (email) {
const accounts = yield* service.list()
const match = accounts.find((a) => a.email === email)
if (!match) return yield* println("Account not found: " + email)
yield* service.remove(match.id)
yield* println("Logged out from " + email)
return
}
const active = yield* service.active()
if (Option.isNone(active)) return yield* println("Not logged in")
yield* service.remove(active.value.id)
yield* println("Logged out from " + active.value.email)
})
const switchEffect = Effect.fn("switch")(function* () {
const service = yield* AccountService
const active = yield* service.active()
if (Option.isNone(active)) return yield* println("Not logged in")
const orgs = yield* service.orgs(active.value.id)
if (orgs.length === 0) return yield* println("No orgs found")
yield* Prompt.intro("Switch org")
const opts = orgs.map((o) => ({
value: o.id,
label: o.id === active.value.org_id ? o.name + UI.Style.TEXT_DIM + " (active)" : o.name,
}))
const selected = yield* Prompt.select({ message: "Select org", options: opts })
yield* service.use(active.value.id, Option.some(OrgID.make(selected)))
yield* Prompt.outro("Switched to " + orgs.find((o) => o.id === selected)?.name)
})
const orgsEffect = Effect.fn("orgs")(function* () {
const service = yield* AccountService
const accounts = yield* service.list()
if (accounts.length === 0) return yield* println("No accounts found")
const allOrgs = yield* Effect.all(
accounts.map((account) =>
service.orgs(account.id).pipe(Effect.map((orgs) => orgs.map((org) => ({ org, account })))),
),
{ concurrency: "unbounded" },
)
for (const { org, account } of allOrgs.flat()) {
yield* println([org.name, account.email, org.id].join("\t"))
}
})
export const LoginCommand = cmd({
command: "login [url]",
@@ -13,59 +117,7 @@ export const LoginCommand = cmd({
}),
async handler(args) {
UI.empty()
prompts.intro("Log in")
const url = args.url as string | undefined
const login = await Account.login(url)
prompts.log.info("Go to: " + login.url)
prompts.log.info("Enter code: " + login.user)
try {
const open =
process.platform === "darwin"
? ["open", login.url]
: process.platform === "win32"
? ["cmd", "/c", "start", login.url]
: ["xdg-open", login.url]
Bun.spawn(open, { stdout: "ignore", stderr: "ignore" })
} catch {}
const spinner = prompts.spinner()
spinner.start("Waiting for authorization...")
let wait = login.interval * 1000
while (true) {
await Bun.sleep(wait)
const result = await Account.poll(login)
if (result.type === "success") {
spinner.stop("Logged in as " + result.email)
prompts.outro("Done")
return
}
if (result.type === "pending") continue
if (result.type === "slow") {
wait += 5000
continue
}
if (result.type === "expired") {
spinner.stop("Device code expired", 1)
return
}
if (result.type === "denied") {
spinner.stop("Authorization denied", 1)
return
}
spinner.stop("Error: " + result.msg, 1)
return
}
await runtime.runPromise(loginEffect(args.url))
},
})
@@ -78,27 +130,8 @@ export const LogoutCommand = cmd({
type: "string",
}),
async handler(args) {
const email = args.email as string | undefined
if (email) {
const accounts = Account.list()
const match = accounts.find((a) => a.email === email)
if (!match) {
UI.println("Account not found: " + email)
return
}
Account.remove(match.id)
UI.println("Logged out from " + email)
return
}
const active = Account.active()
if (!active) {
UI.println("Not logged in")
return
}
Account.remove(active.id)
UI.println("Logged out from " + active.email)
UI.empty()
await runtime.runPromise(logoutEffect(args.email))
},
})
@@ -107,35 +140,7 @@ export const SwitchCommand = cmd({
describe: "switch active org",
async handler() {
UI.empty()
const active = Account.active()
if (!active) {
UI.println("Not logged in")
return
}
const orgs = await Account.orgs(active.id)
if (orgs.length === 0) {
UI.println("No orgs found")
return
}
prompts.intro("Switch org")
const opts = orgs.map((o) => ({
value: o.id,
label: o.id === active.org_id ? o.name + UI.Style.TEXT_DIM + " (active)" : o.name,
}))
const selected = await prompts.select({
message: "Select org",
options: opts,
})
if (prompts.isCancel(selected)) return
Account.use(active.id, OrgID.make(selected))
prompts.outro("Switched to " + orgs.find((o) => o.id === selected)?.name)
await runtime.runPromise(switchEffect())
},
})
@@ -144,18 +149,7 @@ export const OrgsCommand = cmd({
aliases: ["org"],
describe: "list all orgs",
async handler() {
const accounts = Account.list()
if (accounts.length === 0) {
UI.println("No accounts found")
return
}
for (const account of accounts) {
const orgs = await Account.orgs(account.id)
for (const org of orgs) {
UI.println([org.name, account.email, org.id].join("\t"))
}
}
UI.empty()
await runtime.runPromise(orgsEffect())
},
})

View File

@@ -0,0 +1,24 @@
import * as prompts from "@clack/prompts"
import { Effect, Schema } from "effect"
export class PromptCancelled extends Schema.TaggedErrorClass<PromptCancelled>()("PromptCancelled", {}) {}
export const intro = (msg: string) => Effect.sync(() => prompts.intro(msg))
export const outro = (msg: string) => Effect.sync(() => prompts.outro(msg))
export const log = {
info: (msg: string) => Effect.sync(() => prompts.log.info(msg)),
}
export const select = <Value>(opts: Parameters<typeof prompts.select<Value>>[0]) =>
Effect.tryPromise(() => prompts.select(opts)).pipe(
Effect.flatMap((result) => (prompts.isCancel(result) ? Effect.fail(new PromptCancelled()) : Effect.succeed(result))),
)
export const spinner = () => {
const s = prompts.spinner()
return {
start: (msg: string) => Effect.sync(() => s.start(msg)),
stop: (msg: string, code?: number) => Effect.sync(() => s.stop(msg, code)),
}
}

View File

@@ -0,0 +1,4 @@
import { ManagedRuntime } from "effect"
import { AccountService } from "@/account/service"
export const runtime = ManagedRuntime.make(AccountService.defaultLayer)

View File

@@ -1,36 +1,36 @@
import { test, expect, afterEach, afterAll } from "bun:test"
import { Effect, ManagedRuntime, Option } from "effect"
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
import { AccountID, OrgID } from "../../src/account/schema"
import { resetDatabase } from "../fixture/db"
import { testEffect } from "../fixture/effect"
afterEach(async () => {
await resetDatabase()
})
const reset = Layer.effectDiscard(Effect.promise(() => resetDatabase()))
afterAll(async () => {
await runtime.dispose()
})
const it = testEffect(Layer.merge(AccountRepo.layer, reset))
const runtime = ManagedRuntime.make(AccountRepo.layer)
const run = <A>(effect: Effect.Effect<A, unknown, AccountRepo>) => runtime.runPromise(effect)
const repo = AccountRepo
it.effect(
"list returns empty when no accounts exist",
Effect.gen(function* () {
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts).toEqual([])
}),
)
test("list returns empty when no accounts exist", async () => {
const accounts = await run(repo.use((r) => r.list()))
expect(accounts).toEqual([])
})
it.effect(
"active returns none when no accounts exist",
Effect.gen(function* () {
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isNone(active)).toBe(true)
}),
)
test("active returns none when no accounts exist", async () => {
const active = await run(repo.use((r) => r.active()))
expect(Option.isNone(active)).toBe(true)
})
test("persistAccount inserts and getRow retrieves", async () => {
const id = AccountID.make("user-1")
await run(
repo.use((r) =>
it.effect(
"persistAccount inserts and getRow retrieves",
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -40,23 +40,24 @@ test("persistAccount inserts and getRow retrieves", async () => {
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
),
)
)
const row = await run(repo.use((r) => r.getRow(id)))
expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row)
expect(value.id).toBe("user-1")
expect(value.email).toBe("test@example.com")
expect(value.org_id).toBe("org-1")
})
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.isSome(row)).toBe(true)
const value = Option.getOrThrow(row)
expect(value.id).toBe("user-1")
expect(value.email).toBe("test@example.com")
expect(value.org_id).toBe("org-1")
}),
)
test("persistAccount sets active account (clears previous org_ids)", async () => {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
it.effect(
"persistAccount sets active account (clears previous org_ids)",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "first@example.com",
@@ -66,11 +67,9 @@ test("persistAccount sets active account (clears previous org_ids)", async () =>
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
),
)
)
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "second@example.com",
@@ -80,25 +79,24 @@ test("persistAccount sets active account (clears previous org_ids)", async () =>
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-2")),
}),
),
)
)
// First account should have org_id cleared
const row1 = await run(repo.use((r) => r.getRow(id1)))
expect(Option.getOrThrow(row1).org_id).toBeNull()
const row1 = yield* AccountRepo.use((r) => r.getRow(id1))
expect(Option.getOrThrow(row1).org_id).toBeNull()
// Second account should be active
const active = await run(repo.use((r) => r.active()))
expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
})
const active = yield* AccountRepo.use((r) => r.active())
expect(Option.isSome(active)).toBe(true)
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
}),
)
test("list returns all accounts", async () => {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
it.effect(
"list returns all accounts",
Effect.gen(function* () {
const id1 = AccountID.make("user-1")
const id2 = AccountID.make("user-2")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id1,
email: "a@example.com",
@@ -108,11 +106,9 @@ test("list returns all accounts", async () => {
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
),
)
)
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id: id2,
email: "b@example.com",
@@ -122,19 +118,20 @@ test("list returns all accounts", async () => {
expiry: Date.now() + 3600_000,
orgID: Option.some(OrgID.make("org-1")),
}),
),
)
)
const accounts = await run(repo.use((r) => r.list()))
expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
})
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts.length).toBe(2)
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
}),
)
test("remove deletes an account", async () => {
const id = AccountID.make("user-1")
it.effect(
"remove deletes an account",
Effect.gen(function* () {
const id = AccountID.make("user-1")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -144,20 +141,21 @@ test("remove deletes an account", async () => {
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
),
)
)
await run(repo.use((r) => r.remove(id)))
yield* AccountRepo.use((r) => r.remove(id))
const row = await run(repo.use((r) => r.getRow(id)))
expect(Option.isNone(row)).toBe(true)
})
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.isNone(row)).toBe(true)
}),
)
test("use sets org_id on account", async () => {
const id = AccountID.make("user-1")
it.effect(
"use sets org_id on account",
Effect.gen(function* () {
const id = AccountID.make("user-1")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -167,25 +165,24 @@ test("use sets org_id on account", async () => {
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
),
)
)
// Set org
await run(repo.use((r) => r.use(id, Option.some(OrgID.make("org-99")))))
const row = await run(repo.use((r) => r.getRow(id)))
expect(Option.getOrThrow(row).org_id).toBe("org-99")
yield* AccountRepo.use((r) => r.use(id, Option.some(OrgID.make("org-99"))))
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.getOrThrow(row).org_id).toBe("org-99")
// Clear org
await run(repo.use((r) => r.use(id, Option.none())))
const row2 = await run(repo.use((r) => r.getRow(id)))
expect(Option.getOrThrow(row2).org_id).toBeNull()
})
yield* AccountRepo.use((r) => r.use(id, Option.none()))
const row2 = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.getOrThrow(row2).org_id).toBeNull()
}),
)
test("persistToken updates token fields", async () => {
const id = AccountID.make("user-1")
it.effect(
"persistToken updates token fields",
Effect.gen(function* () {
const id = AccountID.make("user-1")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -195,24 +192,32 @@ test("persistToken updates token fields", async () => {
expiry: 1000,
orgID: Option.none(),
}),
),
)
)
const newExpiry = Date.now() + 7200_000
await run(repo.use((r) => r.persistToken({ accountID: id, accessToken: "new_token", refreshToken: "new_refresh", expiry: Option.some(newExpiry) })))
const expiry = Date.now() + 7200_000
yield* AccountRepo.use((r) =>
r.persistToken({
accountID: id,
accessToken: "new_token",
refreshToken: "new_refresh",
expiry: Option.some(expiry),
}),
)
const row = await run(repo.use((r) => r.getRow(id)))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("new_token")
expect(value.refresh_token).toBe("new_refresh")
expect(value.token_expiry).toBe(newExpiry)
})
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("new_token")
expect(value.refresh_token).toBe("new_refresh")
expect(value.token_expiry).toBe(expiry)
}),
)
test("persistToken with no expiry sets token_expiry to null", async () => {
const id = AccountID.make("user-1")
it.effect(
"persistToken with no expiry sets token_expiry to null",
Effect.gen(function* () {
const id = AccountID.make("user-1")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -222,20 +227,28 @@ test("persistToken with no expiry sets token_expiry to null", async () => {
expiry: 1000,
orgID: Option.none(),
}),
),
)
)
await run(repo.use((r) => r.persistToken({ accountID: id, accessToken: "new_token", refreshToken: "new_refresh", expiry: Option.none() })))
yield* AccountRepo.use((r) =>
r.persistToken({
accountID: id,
accessToken: "new_token",
refreshToken: "new_refresh",
expiry: Option.none(),
}),
)
const row = await run(repo.use((r) => r.getRow(id)))
expect(Option.getOrThrow(row).token_expiry).toBeNull()
})
const row = yield* AccountRepo.use((r) => r.getRow(id))
expect(Option.getOrThrow(row).token_expiry).toBeNull()
}),
)
test("persistAccount upserts on conflict", async () => {
const id = AccountID.make("user-1")
it.effect(
"persistAccount upserts on conflict",
Effect.gen(function* () {
const id = AccountID.make("user-1")
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -245,12 +258,9 @@ test("persistAccount upserts on conflict", async () => {
expiry: 1000,
orgID: Option.some(OrgID.make("org-1")),
}),
),
)
)
// Upsert same id with new tokens
await run(
repo.use((r) =>
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
@@ -260,19 +270,22 @@ test("persistAccount upserts on conflict", async () => {
expiry: 2000,
orgID: Option.some(OrgID.make("org-2")),
}),
),
)
)
const accounts = await run(repo.use((r) => r.list()))
expect(accounts.length).toBe(1)
const accounts = yield* AccountRepo.use((r) => r.list())
expect(accounts.length).toBe(1)
const row = await run(repo.use((r) => r.getRow(id)))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("at_v2")
expect(value.org_id).toBe("org-2")
})
const row = yield* AccountRepo.use((r) => r.getRow(id))
const value = Option.getOrThrow(row)
expect(value.access_token).toBe("at_v2")
expect(value.org_id).toBe("org-2")
}),
)
test("getRow returns none for nonexistent account", async () => {
const row = await run(repo.use((r) => r.getRow(AccountID.make("nope"))))
expect(Option.isNone(row)).toBe(true)
})
it.effect(
"getRow returns none for nonexistent account",
Effect.gen(function* () {
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
expect(Option.isNone(row)).toBe(true)
}),
)

View File

@@ -0,0 +1,7 @@
import { test } from "bun:test"
import { Effect, Layer } from "effect"
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => ({
effect: <A, E2>(name: string, value: Effect.Effect<A, E2, R>) =>
test(name, () => Effect.runPromise(value.pipe(Effect.provide(layer)))),
})

View File

@@ -11,6 +11,11 @@
"paths": {
"@/*": ["./src/*"],
"@tui/*": ["./src/cli/cmd/tui/*"]
}
},
"plugins": [{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}]
}
}