core: simplify account repo/service after review

- persistToken: positional params → named object to prevent swaps
- persistAccount: wrap in Database.transaction for atomicity
- Internal response schemas: Schema.Class → Schema.Struct (lighter)
- fromRow: pass row directly to decoder (strips unknown keys)
- Test: add afterAll runtime disposal, use branded ID in assertion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kit Langton
2026-03-06 10:39:34 -05:00
parent adc9536a16
commit 48158ce97d
3 changed files with 70 additions and 69 deletions

View File

@@ -17,13 +17,7 @@ const db = <A>(run: (db: DbClient) => A) =>
catch: (cause) => new AccountServiceError({ operation: "db", message: "Database operation failed", cause }),
})
const fromRow = (row: AccountRow) =>
decodeAccount({
id: row.id,
email: row.email,
url: row.url,
org_id: row.org_id,
})
const fromRow = (row: AccountRow) => decodeAccount(row)
export class AccountRepo extends ServiceMap.Service<
AccountRepo,
@@ -33,12 +27,12 @@ export class AccountRepo extends ServiceMap.Service<
readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountServiceError>
readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountServiceError>
readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountServiceError>
readonly persistToken: (
accountID: AccountID,
accessToken: string,
refreshToken: string,
expiry: Option.Option<number>,
) => Effect.Effect<void, AccountServiceError>
readonly persistToken: (input: {
accountID: AccountID
accessToken: string
refreshToken: string
expiry: Option.Option<number>
}) => Effect.Effect<void, AccountServiceError>
readonly persistAccount: (input: {
id: AccountID
email: string
@@ -81,45 +75,48 @@ export class AccountRepo extends ServiceMap.Service<
),
),
persistToken: Effect.fn("AccountRepo.persistToken")(
(accountID: AccountID, accessToken: string, refreshToken: string, expiry: Option.Option<number>) =>
db((db) =>
db
.update(AccountTable)
.set({
access_token: accessToken,
refresh_token: refreshToken,
token_expiry: Option.getOrNull(expiry),
})
.where(eq(AccountTable.id, accountID))
.run(),
).pipe(Effect.asVoid),
persistToken: Effect.fn("AccountRepo.persistToken")((input) =>
db((db) =>
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),
),
persistAccount: Effect.fn("AccountRepo.persistAccount")((input) => {
const orgID = Option.getOrNull(input.orgID)
return db((db) => {
db.update(AccountTable).set({ org_id: null }).where(isNotNull(AccountTable.org_id)).run()
db.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
org_id: orgID,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
org_id: orgID,
},
})
.run()
return Effect.try({
try: () =>
Database.transaction((tx) => {
tx.update(AccountTable).set({ org_id: null }).where(isNotNull(AccountTable.org_id)).run()
tx.insert(AccountTable)
.values({
id: input.id,
email: input.email,
url: input.url,
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
org_id: orgID,
})
.onConflictDoUpdate({
target: AccountTable.id,
set: {
access_token: input.accessToken,
refresh_token: input.refreshToken,
token_expiry: input.expiry,
org_id: orgID,
},
})
.run()
}),
catch: (cause) => new AccountServiceError({ operation: "db", message: "Database operation failed", cause }),
}).pipe(Effect.asVoid)
}),
}),

View File

@@ -13,43 +13,43 @@ import { AccessToken, Account, AccountID, AccountServiceError, Login, OrgID, typ
export { AccessToken, Account, AccountID, AccountServiceError, Login, OrgID, type PollResult } from "./schema"
class RemoteOrg extends Schema.Class<RemoteOrg>("RemoteOrg")({
const RemoteOrg = Schema.Struct({
id: Schema.optional(OrgID),
name: Schema.optional(Schema.String),
}) {}
})
const RemoteOrgs = Schema.Array(RemoteOrg)
class RemoteConfig extends Schema.Class<RemoteConfig>("RemoteConfig")({
const RemoteConfig = Schema.Struct({
config: Schema.Record(Schema.String, Schema.Json),
}) {}
})
class TokenRefresh extends Schema.Class<TokenRefresh>("TokenRefresh")({
const TokenRefresh = Schema.Struct({
access_token: Schema.String,
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
}) {}
})
class DeviceCode extends Schema.Class<DeviceCode>("DeviceCode")({
const DeviceCode = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
verification_uri_complete: Schema.String,
expires_in: Schema.Number,
interval: Schema.Number,
}) {}
})
class DeviceToken extends Schema.Class<DeviceToken>("DeviceToken")({
const DeviceToken = Schema.Struct({
access_token: Schema.optional(Schema.String),
refresh_token: Schema.optional(Schema.String),
expires_in: Schema.optional(Schema.Number),
error: Schema.optional(Schema.String),
error_description: Schema.optional(Schema.String),
}) {}
})
class User extends Schema.Class<User>("User")({
const User = Schema.Struct({
id: Schema.optional(AccountID),
email: Schema.optional(Schema.String),
}) {}
})
const ClientId = Schema.Struct({ client_id: Schema.String })
@@ -145,12 +145,12 @@ export class AccountService extends ServiceMap.Service<
const expiry = Option.fromNullishOr(parsed.expires_in).pipe(Option.map((e) => now + e * 1000))
yield* repo.persistToken(
AccountID.make(found.id),
parsed.access_token,
parsed.refresh_token ?? found.refresh_token,
yield* repo.persistToken({
accountID: AccountID.make(found.id),
accessToken: parsed.access_token,
refreshToken: parsed.refresh_token ?? found.refresh_token,
expiry,
)
})
return Option.some(AccessToken.make(parsed.access_token))
})

View File

@@ -1,4 +1,4 @@
import { test, expect, afterEach } from "bun:test"
import { test, expect, afterEach, afterAll } from "bun:test"
import { Effect, ManagedRuntime, Option } from "effect"
import { AccountRepo } from "../../src/account/repo"
@@ -9,6 +9,10 @@ afterEach(async () => {
await resetDatabase()
})
afterAll(async () => {
await runtime.dispose()
})
const runtime = ManagedRuntime.make(AccountRepo.layer)
const run = <A>(effect: Effect.Effect<A, unknown, AccountRepo>) => runtime.runPromise(effect)
const repo = AccountRepo
@@ -86,7 +90,7 @@ test("persistAccount sets active account (clears previous org_ids)", async () =>
// 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("user-2")
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
})
test("list returns all accounts", async () => {
@@ -195,7 +199,7 @@ test("persistToken updates token fields", async () => {
)
const newExpiry = Date.now() + 7200_000
await run(repo.use((r) => r.persistToken(id, "new_token", "new_refresh", Option.some(newExpiry))))
await run(repo.use((r) => r.persistToken({ accountID: id, accessToken: "new_token", refreshToken: "new_refresh", expiry: Option.some(newExpiry) })))
const row = await run(repo.use((r) => r.getRow(id)))
const value = Option.getOrThrow(row)
@@ -221,7 +225,7 @@ test("persistToken with no expiry sets token_expiry to null", async () => {
),
)
await run(repo.use((r) => r.persistToken(id, "new_token", "new_refresh", Option.none())))
await run(repo.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()