diff --git a/apps/server/CLAUDE.md b/apps/server/CLAUDE.md index 3192c9919..e65157644 100644 --- a/apps/server/CLAUDE.md +++ b/apps/server/CLAUDE.md @@ -49,7 +49,7 @@ Local observability: `docker compose -f apps/server/docker-compose.otel.yml up - - **Flux read/write separation**: `FluxService` reads (Redis cache-aside), `BillingService` writes (single Postgres tx that mutates `user_flux` and writes the matching `flux_transaction` ledger row). Never put write-balance logic in `flux.ts`. - **No async billing pipeline**: debits and credits update balance + ledger in one transaction. The `(user_id, request_id)` partial unique index gives DB-level idempotency for retries; LLM `request log` rows are written best-effort right after the response is delivered. -- **In-process LLM/TTS router**: `/api/v1/openai` is dispatched by `services/llm-router` reading `LLM_ROUTER_CONFIG` (per-model upstream chain + envelope-encrypted keys). `chat/completions` walks LLM upstreams with key fallback; `audio/speech` delegates to a TTS adapter (`azure` / `dashscope-cosyvoice` / `volcengine`); `audio/voices` returns the adapter's compiled-in catalog. Server handles auth/billing/logging, not model execution. +- **In-process LLM/TTS router**: `/api/v1/openai` is dispatched by `services/domain/llm-router` reading `LLM_ROUTER_CONFIG` (per-model upstream chain + envelope-encrypted keys). `chat/completions` walks LLM upstreams with key fallback; `audio/speech` delegates to a TTS adapter (`azure` / `dashscope-cosyvoice` / `volcengine`); `audio/voices` returns the adapter's compiled-in catalog. Server handles auth/billing/logging, not model execution. - **Redis is cache + pub/sub, not truth**: balance cache, app_settings read cache, WebSocket cross-instance pub/sub. Truth is always Postgres. - **Auth**: Better Auth + OIDC. `sessionMiddleware` fills context but doesn't block; `authGuard` returns 401. - **Multi-instance safe**: all writes go through Postgres transactions; cross-instance messaging uses Redis Pub/Sub. No async work, no in-process singletons — admin flux grants happen synchronously inside the POST that triggered them. diff --git a/apps/server/docs/ai-context/account-deletion.md b/apps/server/docs/ai-context/account-deletion.md index 5de506de8..aa43737b9 100644 --- a/apps/server/docs/ai-context/account-deletion.md +++ b/apps/server/docs/ai-context/account-deletion.md @@ -62,7 +62,7 @@ auth ──depends on──► userDeletionService ──depends on──► [st auth 和业务 service **互不依赖**,双方都只依赖 `userDeletionService` 这层抽象。这是 DIP 的标准形态。 ```ts -// apps/server/src/services/user-deletion/types.ts +// apps/server/src/services/domain/user-deletion/types.ts export interface UserDeletionHandler { name: string /** Lower runs first. 10=external side-effects, 20=financial+cache, 30=pure DB */ @@ -101,11 +101,11 @@ export interface UserDeletionService { **所有读业务表的查询都必须加 `isNull(deletedAt)` 过滤**,否则被删用户的数据还能被列出来 / 关联出来。重点扫描: -- `apps/server/src/services/flux.ts` — getBalance / readBalance -- `apps/server/src/services/characters.ts` — listCharacters -- `apps/server/src/services/providers.ts` — listProviderConfigs -- `apps/server/src/services/chats.ts` — listChats / listMessages -- `apps/server/src/services/billing/billing-service.ts` — invoice / sub 查询 +- `apps/server/src/services/domain/flux.ts` — getBalance / readBalance +- `apps/server/src/services/domain/characters.ts` — listCharacters +- `apps/server/src/services/domain/providers.ts` — listProviderConfigs +- `apps/server/src/services/domain/chats.ts` — listChats / listMessages +- `apps/server/src/services/domain/billing/billing-service.ts` — invoice / sub 查询 写完后用 `pnpm typecheck` + grep `from(flux|character|chats|providers|stripe)` 兜底。 @@ -158,8 +158,8 @@ better-auth `internalAdapter.deleteAccounts` 删本地 `account` 表(user 跟 - 业务表 schema: `apps/server/src/schemas/{flux,flux-transaction,stripe,characters,user-character,providers,chats}.ts` - Auth schema (不改): `apps/server/src/schemas/accounts.ts` - Auth 配置: `apps/server/src/libs/auth.ts` (extend with `user.deleteUser`) -- Email service: `apps/server/src/services/email.ts` (extend interface + Resend impl) -- Deletion scheduler: `apps/server/src/services/user-deletion/` (registry only, no domain logic) +- Email service: `apps/server/src/services/adapters/email.ts` (extend interface + Resend impl) +- Deletion scheduler: `apps/server/src/services/domain/user-deletion/` (registry only, no domain logic) - 各 service 自己的 `deleteAllForUser`: `apps/server/src/services/{characters,chats,flux,providers,stripe}.ts` - UI - settings page: `packages/stage-pages/src/pages/settings/account/account-settings-page.vue` (line ~430 TODO) - UI - confirmation page (新): `apps/ui-server-auth/src/pages/delete-account.vue` diff --git a/apps/server/docs/ai-context/admin-flux-grants.md b/apps/server/docs/ai-context/admin-flux-grants.md index 5200bd9f4..80a5df2c9 100644 --- a/apps/server/docs/ai-context/admin-flux-grants.md +++ b/apps/server/docs/ai-context/admin-flux-grants.md @@ -87,8 +87,8 @@ SELECT user_id, amount, balance_after, created_at ## 6. 实现位置 - 路由:[`apps/server/src/routes/admin/flux-grants/index.ts`](apps/server/src/routes/admin/flux-grants/index.ts) -- Service:[`apps/server/src/services/admin-flux-grants/index.ts`](apps/server/src/services/admin-flux-grants/index.ts) -- 单测:[`apps/server/src/services/admin-flux-grants/tests/admin-flux-grants.test.ts`](apps/server/src/services/admin-flux-grants/tests/admin-flux-grants.test.ts) +- Service:[`apps/server/src/services/domain/admin/flux-grants/index.ts`](apps/server/src/services/domain/admin/flux-grants/index.ts) +- 单测:[`apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts`](apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts) - adminGuard:[`apps/server/src/middlewares/admin-guard.ts`](apps/server/src/middlewares/admin-guard.ts) - 数据库:**没有**专门的表;唯一持久化是 `flux_transaction` ledger - 已废弃:`flux_grant_batch` / `flux_grant_batch_recipient`(drizzle migration `0011_superb_lady_deathstrike.sql` 删表) diff --git a/apps/server/docs/ai-context/architecture-overview.md b/apps/server/docs/ai-context/architecture-overview.md index 758469975..f2293d573 100644 --- a/apps/server/docs/ai-context/architecture-overview.md +++ b/apps/server/docs/ai-context/architecture-overview.md @@ -140,10 +140,10 @@ CLI 入口在 `src/bin/run.ts`,只有一种角色: ### LLM/TTS 路由在进程内,而不是本地 provider 编排 -`/api/v1/openai` 由 `services/llm-router` 读取 `LLM_ROUTER_CONFIG` 后按 upstream 链路 + key rotator 直接调 provider(OpenRouter、Azure Speech、阿里云 DashScope、火山引擎 等),不再依赖外部 knoway sidecar。因此: +`/api/v1/openai` 由 `services/domain/llm-router` 读取 `LLM_ROUTER_CONFIG` 后按 upstream 链路 + key rotator 直接调 provider(OpenRouter、Azure Speech、阿里云 DashScope、火山引擎 等),不再依赖外部 knoway sidecar。因此: - 服务端关心的是鉴权、限流、计费、日志、观测、上游路由与 key 健康 -- 具体模型协议翻译由 `services/llm-router` 与 `services/tts-adapters` 的 adapter 完成 +- 具体模型协议翻译由 `services/domain/llm-router` 与 `services/adapters/tts` 的 adapter 完成 ### Redis 有多种职责,但都不是余额真相源 @@ -160,4 +160,4 @@ Redis 在这里同时承担: ## 当前值得注意的实现信号 - `/api/v1/openai` 当前开放:`POST /chat/completions`、`POST /chat/completion`、`POST /audio/speech`、`GET /audio/voices`。`handleTranscription` 路由尚未挂载。 -- `flux_grant_batch` schema / service / route 已被简化版 `admin-flux-grants` 取代,但旧的 `src/schemas/flux-grant-batch.ts`、`src/services/admin-flux-grant-batch/`、`src/routes/admin/flux-grant-batches/` 仍以 dead code 形态残留在仓库里,没有在 `app.ts` 装配。改这块前直接删旧文件,不要继续往里面加东西。 +- `flux_grant_batch` schema 已被简化版 `admin-flux-grants` 取代。代码层(service / route / worker / tests)已删。但 `src/schemas/flux-grant-batch.ts` 仍在并跟随 `schemas/index.ts` 导出,对应生产 DB 表 `flux_grant_batch` / `flux_grant_batch_recipient` 也仍在。下次清理要删 schema 文件 + 生成 drop table migration,属破坏性 DDL,需单独 PR 处理。 diff --git a/apps/server/docs/ai-context/billing-architecture.md b/apps/server/docs/ai-context/billing-architecture.md index 4355ad287..b7bce0c4a 100644 --- a/apps/server/docs/ai-context/billing-architecture.md +++ b/apps/server/docs/ai-context/billing-architecture.md @@ -53,7 +53,7 @@ TTS 字符、STT 秒等单价 < 1 Flux 的服务通过 `FluxMeter` 累计零头 ## 关键服务 -### BillingService (`services/billing/billing-service.ts`) +### BillingService (`services/domain/billing/billing-service.ts`) 所有余额写操作的唯一入口: @@ -62,7 +62,7 @@ TTS 字符、STT 秒等单价 < 1 Flux 的服务通过 `FluxMeter` 累计零头 - **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,按 session 幂等 - **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,按 invoice 幂等 -### FluxService (`services/flux.ts`) +### FluxService (`services/domain/flux.ts`) 只负责读操作: diff --git a/apps/server/docs/ai-context/config-and-naming-conventions.md b/apps/server/docs/ai-context/config-and-naming-conventions.md index 86677cb9f..64ba82ebe 100644 --- a/apps/server/docs/ai-context/config-and-naming-conventions.md +++ b/apps/server/docs/ai-context/config-and-naming-conventions.md @@ -18,7 +18,7 @@ ### 单一真相源 -`src/services/config-kv.ts` 中的 `ConfigEntrySchemas` 是以下三件事的单一真相源: +`src/services/adapters/config-kv.ts` 中的 `ConfigEntrySchemas` 是以下三件事的单一真相源: - 配置值的运行时校验 - 配置值的默认值 diff --git a/apps/server/docs/ai-context/email-auth-resend.md b/apps/server/docs/ai-context/email-auth-resend.md index 708b46a08..885b71178 100644 --- a/apps/server/docs/ai-context/email-auth-resend.md +++ b/apps/server/docs/ai-context/email-auth-resend.md @@ -27,7 +27,7 @@ Last updated: 2026-04-27 In: -- `apps/server/src/services/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。 +- `apps/server/src/services/adapters/email.ts`:统一 `EmailService` 接口(`sendVerification` / `sendPasswordReset` / `sendMagicLink` / `sendChangeEmail`),每个方法对应一个 HTML + plaintext 模板。 - `apps/server/src/libs/auth.ts`:装上 4 个 callback;启用 `requireEmailVerification: true`;加载 `magicLink` plugin。 - `apps/server/src/libs/env.ts`:新增 `RESEND_API_KEY`(必填)、`RESEND_FROM_EMAIL`(必填)、`RESEND_FROM_NAME`(可选)、`AUTH_EMAIL_VERIFY_REDIRECT_URL` / `AUTH_PASSWORD_RESET_REDIRECT_URL`(可选,默认根据 `API_SERVER_URL` 推算 ui-server-auth origin)。 - `apps/server/src/app.ts`:把 `EmailService` 通过 `injeca` 装配,注入到 `auth` provider。 diff --git a/apps/server/docs/ai-context/flux-meter.md b/apps/server/docs/ai-context/flux-meter.md index c59882650..f901a23f1 100644 --- a/apps/server/docs/ai-context/flux-meter.md +++ b/apps/server/docs/ai-context/flux-meter.md @@ -62,7 +62,7 @@ INCRBY/DECRBY 的组合在 Redis 单线程模型下天然原子;多服务实 ## API -`packages/server/src/services/billing/flux-meter.ts` +`packages/server/src/services/domain/billing/flux-meter.ts` - `createFluxMeter(redis, billingService, { name, resolveRuntime })` → meter 实例 - `resolveRuntime: () => Promise<{ unitsPerFlux, debtTtlSeconds }>` **每次调用都执行**,不做进程内缓存。多实例部署下任一实例改配置,其它实例下一次请求立即生效。 @@ -93,7 +93,7 @@ INCRBY/DECRBY 的组合在 Redis 单线程模型下天然原子;多服务实 ### 接入步骤 -1. 在 `services/config-kv.ts` 加费率/TTL 配置项 +1. 在 `services/adapters/config-kv.ts` 加费率/TTL 配置项 2. 在 `app.ts` 用 `injeca.provide` 注册新 meter,注入对应路由 / 服务 3. 在路由中:先 `assertCanAfford`,调上游成功后 `accumulate` 4. 加单测覆盖:累计跨阈值、empty input、余额不足 diff --git a/apps/server/docs/ai-context/llm-router-codex-followups.md b/apps/server/docs/ai-context/llm-router-codex-followups.md index 01d683ede..14451288f 100644 --- a/apps/server/docs/ai-context/llm-router-codex-followups.md +++ b/apps/server/docs/ai-context/llm-router-codex-followups.md @@ -13,11 +13,11 @@ remaining findings are deferred — each evaluated through the AGENTS.md "client disconnects but upstream keeps generating + burning paid quota" leak. 2. **Failed upstream response bodies not drained** — fixed in - `apps/server/src/services/llm-router/router.ts`. Every non-2xx fallback + `apps/server/src/services/domain/llm-router/router.ts`. Every non-2xx fallback path now calls `response.body?.cancel()` before continuing. Prevents socket-pool exhaustion under fallback storms. 3. **SSML voice attribute injection** — fixed in - `apps/server/src/services/tts-adapters/azure.ts`. Voice id is + `apps/server/src/services/adapters/tts/azure.ts`. Voice id is regex-validated (`^[a-z0-9-]+$/i`) before SSML interpolation; invalid values throw `BAD_REQUEST`. Prevents attribute-context breakout under the server's Azure credential. diff --git a/apps/server/docs/ai-context/metrics-ownership.md b/apps/server/docs/ai-context/metrics-ownership.md index 776701aee..3e9758ebe 100644 --- a/apps/server/docs/ai-context/metrics-ownership.md +++ b/apps/server/docs/ai-context/metrics-ownership.md @@ -112,7 +112,7 @@ 已接入: - 前端 `posthog-js` 通过 `packages/stage-ui/src/stores/analytics/posthog.ts` 初始化,三个 app(web / desktop / pocket)按 `isStageTamagotchi()` 等选 project key -- 后端 `posthog-node` 通过 `apps/server/src/services/posthog.ts` + injeca provider `services:posthog` +- 后端 `posthog-node` 通过 `apps/server/src/services/adapters/posthog.ts` + injeca provider `services:posthog` - 前端↔后端 identity merge:`useSharedAnalyticsStore.initialize()` watch `authStore.isAuthenticated` 自动调 `posthog.identify(user.id)` / `reset()` 已埋点: @@ -152,7 +152,7 @@ `apps/server`: ```ts -// services/posthog.ts(新增) +// services/adapters/posthog.ts(新增) import { PostHog } from 'posthog-node' export function createPostHog(env: ServerEnv) { diff --git a/apps/server/docs/ai-context/observability-metrics.md b/apps/server/docs/ai-context/observability-metrics.md index 9da8f8729..db34679fa 100644 --- a/apps/server/docs/ai-context/observability-metrics.md +++ b/apps/server/docs/ai-context/observability-metrics.md @@ -52,13 +52,13 @@ OTel SDK 在导出到 Prometheus 时做两件事: | Metric | 类型 | 落点 | Labels | |---|---|---|---| -| `chat.messages` | Counter | [services/chats.ts](../../src/services/chats.ts) `pushMessages` | — | -| `character.created` | Counter | [services/characters.ts](../../src/services/characters.ts) | — | +| `chat.messages` | Counter | [services/domain/chats.ts](../../src/services/domain/chats.ts) `pushMessages` | — | +| `character.created` | Counter | [services/domain/characters.ts](../../src/services/domain/characters.ts) | — | | `character.deleted` | Counter | 同上 | — | | `character.engagement` | Counter | 同上(like/bookmark) | `action`(`like` / `unlike` / `bookmark` / `unbookmark`) | | `ws.connections.active` | ObservableGauge | [routes/chat-ws/index.ts](../../src/routes/chat-ws/index.ts) `addCallback` walks `userConnections` Map | — | | `ws.messages.sent` | Counter | 同上 | — | -| `ws.messages.received` | Counter | [services/chats.ts](../../src/services/chats.ts) | — | +| `ws.messages.received` | Counter | [services/domain/chats.ts](../../src/services/domain/chats.ts) | — | ## Revenue & Billing @@ -80,10 +80,10 @@ OTel SDK 在导出到 Prometheus 时做两件事: | Metric | 类型 | 落点 | Labels | |---|---|---|---| | `airi.billing.flux.consumed` | Counter | [routes/openai/v1/index.ts](../../src/routes/openai/v1/index.ts) `recordMetrics`(chat / tts) | `gen_ai.request.model`、`gen_ai.operation.name`/`airi.gen_ai.operation.kind`、`http.response.status_code` | -| `airi.billing.flux.credited` | Counter | [services/billing/billing-service.ts](../../src/services/billing/billing-service.ts) 三条入账路径 | `source`(`stripe.checkout`/`stripe.invoice`/`promo`/`admin_grant`/...)、`type`(`credit`/`promo`) | +| `airi.billing.flux.credited` | Counter | [services/domain/billing/billing-service.ts](../../src/services/domain/billing/billing-service.ts) 三条入账路径 | `source`(`stripe.checkout`/`stripe.invoice`/`promo`/`admin_grant`/...)、`type`(`credit`/`promo`) | | `airi.billing.flux.unbilled` | Counter | [routes/openai/v1/index.ts](../../src/routes/openai/v1/index.ts) streaming 路径里 `consumeFluxForLLM` 失败的 catch | `gen_ai.request.model`、`reason`(`debit_failed`)、`stage`(`streaming`) | -| `flux.insufficient_balance` | Counter | [services/billing/billing-service.ts](../../src/services/billing/billing-service.ts) `debitFlux` | — | -| `airi.billing.tts.chars` | Counter | [services/billing/flux-meter.ts](../../src/services/billing/flux-meter.ts) `accumulate` | `meter`(`tts`)、`model` | +| `flux.insufficient_balance` | Counter | [services/domain/billing/billing-service.ts](../../src/services/domain/billing/billing-service.ts) `debitFlux` | — | +| `airi.billing.tts.chars` | Counter | [services/domain/billing/flux-meter.ts](../../src/services/domain/billing/flux-meter.ts) `accumulate` | `meter`(`tts`)、`model` | | `airi.billing.tts.preflight_rejections` | Counter | `flux-meter.ts` `assertCanAfford` | `meter`、`reason`(`insufficient_balance`) | > **`airi.billing.flux.unbilled` 是 P0 告警金线**:流式响应已经发给用户(HTTP 200,token 已经流出),但 post-stream debit 抛错——response 路径不会因此 5xx,DB latency 也只在 catch 那一瞬间显著。HTTP / DB 告警**覆盖不到**这条静默 revenue leak。推荐 alert:`increase(airi_billing_flux_unbilled_total[5m]) > 0` 持续 > 0 立刻 page。 @@ -101,7 +101,7 @@ OTel SDK 在导出到 Prometheus 时做两件事: ## Email(Resend) -来源 [services/email.ts](../../src/services/email.ts) 的 `send()` 内部 try/catch。 +来源 [services/adapters/email.ts](../../src/services/adapters/email.ts) 的 `send()` 内部 try/catch。 | Metric | 类型 | Labels | |---|---|---| diff --git a/apps/server/docs/ai-context/redis-boundaries-and-pubsub.md b/apps/server/docs/ai-context/redis-boundaries-and-pubsub.md index 06bb784e7..5bac0e529 100644 --- a/apps/server/docs/ai-context/redis-boundaries-and-pubsub.md +++ b/apps/server/docs/ai-context/redis-boundaries-and-pubsub.md @@ -162,9 +162,9 @@ const data = JSON.parse(message) as BroadcastMessage - key helper 集中点 - `src/utils/redis-keys.ts` - 余额读 cache-aside - - `src/services/flux.ts` + - `src/services/domain/flux.ts` - Sub-Flux 计量债务账本 - - `src/services/billing/flux-meter.ts` + - `src/services/domain/billing/flux-meter.ts` - TTS voices 上游响应缓存 - `src/routes/openai/v1/index.ts::handleListVoices` - Pub/Sub 聊天广播 diff --git a/apps/server/docs/ai-context/transport-and-routes.md b/apps/server/docs/ai-context/transport-and-routes.md index d073b1d6b..ae031f05b 100644 --- a/apps/server/docs/ai-context/transport-and-routes.md +++ b/apps/server/docs/ai-context/transport-and-routes.md @@ -66,7 +66,7 @@ 实现位置: - route: `src/routes/characters/index.ts` -- service: `src/services/characters.ts` +- service: `src/services/domain/characters.ts` 主要能力: @@ -91,7 +91,7 @@ 实现位置: - route: `src/routes/providers/index.ts` -- service: `src/services/providers.ts` +- service: `src/services/domain/providers.ts` 主要能力: @@ -110,7 +110,7 @@ 实现位置: - route: `src/routes/chats/index.ts` -- service: `src/services/chats.ts` +- service: `src/services/domain/chats.ts` 主要能力: @@ -240,7 +240,7 @@ 实现位置: - route: `src/routes/admin/flux-grants/index.ts` -- service: `src/services/admin-flux-grants/index.ts` +- service: `src/services/domain/admin/flux-grants/index.ts` - guard: `src/middlewares/admin-guard.ts` 主要能力: diff --git a/apps/server/docs/ai-context/verifications/account-deletion.md b/apps/server/docs/ai-context/verifications/account-deletion.md index 3c55394eb..5d032e8ab 100644 --- a/apps/server/docs/ai-context/verifications/account-deletion.md +++ b/apps/server/docs/ai-context/verifications/account-deletion.md @@ -55,7 +55,7 @@ vue-router never registered the route. Re-build → fixes. | Server typecheck | `pnpm -F @proj-airi/server typecheck` exits clean | 2026-04-28 | | Monorepo typecheck | `pnpm typecheck` exits clean across all packages | 2026-04-28 | | Lint | `pnpm lint` reports 0 errors in deletion-service / handler / UI files | 2026-04-28 | -| Deletion service unit tests | `pnpm exec vitest run apps/server/src/services/user-deletion` → `2 files / 14 tests pass` (registry priority order, abort-on-error, serial execution, idempotency, per-service `deleteAllForUser` correctness) | 2026-04-28 | +| Deletion service unit tests | `pnpm exec vitest run apps/server/src/services/domain/user-deletion` → `2 files / 14 tests pass` (registry priority order, abort-on-error, serial execution, idempotency, per-service `deleteAllForUser` correctness) | 2026-04-28 | | Architecture refactor | Domain knowledge moved out of `*-deletion-handler.ts` files into each business service's own `deleteAllForUser` method. Registry retained as a thin scheduler. `auth → userDeletionService → 5 business services` (auth and services no longer depend on each other). | 2026-04-28 | | Server full test suite | `pnpm -F @proj-airi/server exec vitest run` → 244/245 pass; the single failure (`origin.test.ts`) is pre-existing on `main` and unrelated | 2026-04-28 | | UI typecheck | `pnpm -F @proj-airi/stage-pages typecheck` and `pnpm -F @proj-airi/ui-server-auth typecheck` both clean | 2026-04-28 | diff --git a/apps/server/scripts/e2e-llm-router.ts b/apps/server/scripts/e2e-llm-router.ts index 2c7f0dbe4..6d2f8d74f 100644 --- a/apps/server/scripts/e2e-llm-router.ts +++ b/apps/server/scripts/e2e-llm-router.ts @@ -21,8 +21,8 @@ import { env, exit } from 'node:process' import Redis from 'ioredis' import { parseEnv } from '../src/libs/env' -import { createConfigKVService } from '../src/services/config-kv' -import { createLlmRouterService } from '../src/services/llm-router' +import { createConfigKVService } from '../src/services/adapters/config-kv' +import { createLlmRouterService } from '../src/services/domain/llm-router' import { createEnvelopeCrypto } from '../src/utils/envelope-crypto' async function main() { diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index b741e10e0..19d4f1c4f 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,20 +5,20 @@ import type { AuthInstance } from './libs/auth' import type { Database } from './libs/db' import type { Env } from './libs/env' import type { OtelInstance } from './otel' -import type { AdminFluxGrantsService } from './services/admin-flux-grants' -import type { AdminRouterConfigService } from './services/admin-router-config' -import type { BillingService } from './services/billing/billing-service' -import type { FluxMeter } from './services/billing/flux-meter' -import type { CharacterService } from './services/characters' -import type { ChatService } from './services/chats' -import type { ConfigKVService } from './services/config-kv' -import type { FluxService } from './services/flux' -import type { FluxTransactionService } from './services/flux-transaction' -import type { LlmRouterService } from './services/llm-router' -import type { ProviderService } from './services/providers' -import type { RequestLogService } from './services/request-log' -import type { StripeService } from './services/stripe' -import type { UserDeletionService } from './services/user-deletion' +import type { ConfigKVService } from './services/adapters/config-kv' +import type { AdminFluxGrantsService } from './services/domain/admin/flux-grants' +import type { AdminRouterConfigService } from './services/domain/admin/router-config' +import type { BillingService } from './services/domain/billing/billing-service' +import type { FluxMeter } from './services/domain/billing/flux-meter' +import type { CharacterService } from './services/domain/characters' +import type { ChatService } from './services/domain/chats' +import type { FluxService } from './services/domain/flux' +import type { FluxTransactionService } from './services/domain/flux-transaction' +import type { LlmRouterService } from './services/domain/llm-router' +import type { ProviderService } from './services/domain/providers' +import type { RequestLogService } from './services/domain/request-log' +import type { StripeService } from './services/domain/stripe' +import type { UserDeletionService } from './services/domain/user-deletion' import type { HonoEnv } from './types/hono' import type { EnvelopeCrypto } from './utils/envelope-crypto' @@ -58,22 +58,22 @@ import { createFluxRoutes } from './routes/flux' import { createV1Routes } from './routes/openai/v1' import { createProviderRoutes } from './routes/providers' import { createStripeRoutes } from './routes/stripe' -import { createAdminFluxGrantsService } from './services/admin-flux-grants' -import { createAdminRouterConfigService } from './services/admin-router-config' -import { createBillingService } from './services/billing/billing-service' -import { createFluxMeter } from './services/billing/flux-meter' -import { createCharacterService } from './services/characters' -import { createChatService } from './services/chats' -import { createConfigKVService } from './services/config-kv' -import { createEmailService } from './services/email' -import { createFluxService } from './services/flux' -import { createFluxTransactionService } from './services/flux-transaction' -import { createConfigSyncSubscriber, createLlmRouterService } from './services/llm-router' -import { createPostHogClient } from './services/posthog' -import { createProviderService } from './services/providers' -import { createRequestLogService } from './services/request-log' -import { createStripeService } from './services/stripe' -import { createUserDeletionService } from './services/user-deletion' +import { createConfigKVService } from './services/adapters/config-kv' +import { createEmailService } from './services/adapters/email' +import { createPostHogClient } from './services/adapters/posthog' +import { createAdminFluxGrantsService } from './services/domain/admin/flux-grants' +import { createAdminRouterConfigService } from './services/domain/admin/router-config' +import { createBillingService } from './services/domain/billing/billing-service' +import { createFluxMeter } from './services/domain/billing/flux-meter' +import { createCharacterService } from './services/domain/characters' +import { createChatService } from './services/domain/chats' +import { createFluxService } from './services/domain/flux' +import { createFluxTransactionService } from './services/domain/flux-transaction' +import { createConfigSyncSubscriber, createLlmRouterService } from './services/domain/llm-router' +import { createProviderService } from './services/domain/providers' +import { createRequestLogService } from './services/domain/request-log' +import { createStripeService } from './services/domain/stripe' +import { createUserDeletionService } from './services/domain/user-deletion' import { createEnvelopeCrypto } from './utils/envelope-crypto' import { ApiError, createInternalError } from './utils/error' import { nanoid } from './utils/id' diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 1d5245590..8df6390d0 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -1,8 +1,8 @@ import type { PostHog } from 'posthog-node' import type { AuthMetrics } from '../otel' -import type { EmailService } from '../services/email' -import type { UserDeletionService } from '../services/user-deletion' +import type { EmailService } from '../services/adapters/email' +import type { UserDeletionService } from '../services/domain/user-deletion' import type { Database } from './db' import type { Env } from './env' diff --git a/apps/server/src/middlewares/config-guard.ts b/apps/server/src/middlewares/config-guard.ts index 10928181a..3740164f5 100644 --- a/apps/server/src/middlewares/config-guard.ts +++ b/apps/server/src/middlewares/config-guard.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from 'hono' -import type { ConfigKVService } from '../services/config-kv' +import type { ConfigKVService } from '../services/adapters/config-kv' import type { HonoEnv } from '../types/hono' import { createServiceUnavailableError } from '../utils/error' diff --git a/apps/server/src/routes/admin/config/router/index.ts b/apps/server/src/routes/admin/config/router/index.ts index 64e56f24e..c608a8e7a 100644 --- a/apps/server/src/routes/admin/config/router/index.ts +++ b/apps/server/src/routes/admin/config/router/index.ts @@ -1,5 +1,5 @@ import type { Env } from '../../../../libs/env' -import type { AdminRouterConfigService, SliceInput } from '../../../../services/admin-router-config' +import type { AdminRouterConfigService, SliceInput } from '../../../../services/domain/admin/router-config' import type { HonoEnv } from '../../../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/admin/flux-grant-batches/index.ts b/apps/server/src/routes/admin/flux-grant-batches/index.ts deleted file mode 100644 index 4ba1bc85a..000000000 --- a/apps/server/src/routes/admin/flux-grant-batches/index.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { Env } from '../../../libs/env' -import type { FluxGrantBatchService } from '../../../services/admin-flux-grant-batch/flux-grant-batch-service' -import type { HonoEnv } from '../../../types/hono' - -import { Hono } from 'hono' -import { array, email, integer, maxLength, maxValue, minLength, minValue, nonEmpty, number, object, optional, parse, pipe, safeParse, string, transform } from 'valibot' - -import { adminGuard } from '../../../middlewares/admin-guard' -import { authGuard } from '../../../middlewares/auth' -import { createBadRequestError, createNotFoundError } from '../../../utils/error' - -/** - * Per-batch upper bound on amount per user. Caps a single typo from - * issuing absurd amounts. Operator can override later via configKV. - */ -const MAX_GRANT_AMOUNT_PER_USER = 10_000 - -/** - * Hard cap on emails per single batch request. Beyond this we'd rather - * the operator chunk into multiple batches. - */ -const MAX_EMAILS_PER_BATCH = 10_000 - -const CreateBatchBodySchema = object({ - name: pipe(string(), nonEmpty('name is required'), maxLength(100)), - amount: pipe( - number(), - integer('amount must be an integer'), - minValue(1, 'amount must be at least 1'), - maxValue(MAX_GRANT_AMOUNT_PER_USER, `amount must be at most ${MAX_GRANT_AMOUNT_PER_USER}`), - ), - description: optional(pipe(string(), maxLength(500))), - emails: pipe( - array(pipe(string(), email('emails must be valid email addresses'))), - minLength(1, 'emails must not be empty'), - maxLength(MAX_EMAILS_PER_BATCH, `emails must be at most ${MAX_EMAILS_PER_BATCH} entries`), - ), -}) - -const ListQuerySchema = object({ - limit: optional( - pipe(string(), transform(Number), integer(), minValue(1), maxValue(100)), - '20', - ), - cursor: optional(string()), - status: optional(string()), -}) - -/** - * Routes for `/api/admin/flux-grant-batches/*`. - * - * Use when: - * - Mounting under `/api/admin/flux-grant-batches` in `app.ts` - * - * Expects: - * - `sessionMiddleware` already attached (so `c.get('user')` is populated) - * - `env.ADMIN_EMAILS` configured for the deployment - * - * Returns: - * - A Hono sub-router. The caller mounts it; `app.ts` attaches CORS/body - * limit/error handlers globally. - */ -export function createAdminFluxGrantBatchRoutes( - fluxGrantBatchService: FluxGrantBatchService, - env: Env, - /** - * Throttle hint surfaced in dry-run preview to give an estimated duration. - * Comes from the same configKV/env value the worker uses. - * @default 50 - */ - throttlePerSec: number = 50, -) { - return new Hono() - .use('*', authGuard) - .use('*', adminGuard(env)) - .post('/', async (c) => { - const user = c.get('user')! - const dryRun = c.req.query('dryRun') === 'true' - - const raw = await c.req.json().catch(() => null) - if (raw == null) - throw createBadRequestError('Request body must be JSON', 'INVALID_BODY') - - const parsed = safeParse(CreateBatchBodySchema, raw) - if (!parsed.success) { - throw createBadRequestError( - 'Invalid request body', - 'INVALID_BODY', - parsed.issues.map(i => ({ path: i.path?.map(p => p.key).join('.'), message: i.message })), - ) - } - - const body = parsed.output - - if (dryRun) { - const summary = await fluxGrantBatchService.preview({ - name: body.name, - amount: body.amount, - description: body.description, - emails: body.emails, - throttlePerSec, - }) - return c.json({ preview: summary }) - } - - const { batch, summary } = await fluxGrantBatchService.create({ - name: body.name, - amount: body.amount, - description: body.description, - emails: body.emails, - createdByUserId: user.id, - throttlePerSec, - }) - - return c.json({ - batch: { - id: batch.id, - name: batch.name, - status: batch.status, - createdAt: batch.createdAt.toISOString(), - createdByUserId: batch.createdByUserId, - }, - summary: { - totalEmails: summary.totalEmails, - pending: summary.willGrant, - skipped: summary.willSkip.notFound + summary.willSkip.userDeleted + summary.willSkip.duplicateInInput, - totalFluxToIssue: summary.totalFluxToIssue, - }, - }, 202) - }) - .get('/', async (c) => { - const query = parse(ListQuerySchema, { - limit: c.req.query('limit'), - cursor: c.req.query('cursor'), - status: c.req.query('status'), - }) - - const { batches, nextCursor } = await fluxGrantBatchService.list({ - limit: query.limit, - cursor: query.cursor, - status: query.status, - }) - - return c.json({ - batches: batches.map(b => ({ - id: b.id, - name: b.name, - type: b.type, - amount: b.amount, - status: b.status, - createdByUserId: b.createdByUserId, - createdAt: b.createdAt.toISOString(), - startedAt: b.startedAt?.toISOString() ?? null, - completedAt: b.completedAt?.toISOString() ?? null, - })), - nextCursor, - }) - }) - .get('/:id', async (c) => { - const id = c.req.param('id') - const result = await fluxGrantBatchService.get(id) - if (!result) - throw createNotFoundError('Flux grant batch not found', { id }) - - return c.json({ - batch: { - id: result.batch.id, - name: result.batch.name, - type: result.batch.type, - amount: result.batch.amount, - description: result.batch.description, - status: result.batch.status, - createdByUserId: result.batch.createdByUserId, - createdAt: result.batch.createdAt.toISOString(), - startedAt: result.batch.startedAt?.toISOString() ?? null, - completedAt: result.batch.completedAt?.toISOString() ?? null, - }, - progress: result.progress, - recentFailures: result.recentFailures.map(f => ({ - id: f.id, - inputEmail: f.inputEmail, - userId: f.userId, - errorReason: f.errorReason, - attemptCount: f.attemptCount, - lastAttemptedAt: f.lastAttemptedAt?.toISOString() ?? null, - })), - }) - }) - .post('/:id/retry', async (c) => { - const id = c.req.param('id') - const existing = await fluxGrantBatchService.get(id) - if (!existing) - throw createNotFoundError('Flux grant batch not found', { id }) - - const result = await fluxGrantBatchService.retryFailed(id) - return c.json(result) - }) -} diff --git a/apps/server/src/routes/admin/flux-grants/index.ts b/apps/server/src/routes/admin/flux-grants/index.ts index 6f768acd5..b18ed3ad6 100644 --- a/apps/server/src/routes/admin/flux-grants/index.ts +++ b/apps/server/src/routes/admin/flux-grants/index.ts @@ -1,5 +1,5 @@ import type { Env } from '../../../libs/env' -import type { AdminFluxGrantsService } from '../../../services/admin-flux-grants' +import type { AdminFluxGrantsService } from '../../../services/domain/admin/flux-grants' import type { HonoEnv } from '../../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/audio-speech-ws/index.ts b/apps/server/src/routes/audio-speech-ws/index.ts index 747cbd347..fe8c56992 100644 --- a/apps/server/src/routes/audio-speech-ws/index.ts +++ b/apps/server/src/routes/audio-speech-ws/index.ts @@ -1,9 +1,9 @@ import type { WSContext, WSEvents } from 'hono/ws' -import type { FluxMeter } from '../../services/billing/flux-meter' -import type { ConfigKVService } from '../../services/config-kv' -import type { FluxService } from '../../services/flux' -import type { RequestLogService } from '../../services/request-log' +import type { ConfigKVService } from '../../services/adapters/config-kv' +import type { FluxMeter } from '../../services/domain/billing/flux-meter' +import type { FluxService } from '../../services/domain/flux' +import type { RequestLogService } from '../../services/domain/request-log' import type { EnvelopeCrypto } from '../../utils/envelope-crypto' import { Buffer } from 'node:buffer' diff --git a/apps/server/src/routes/auth/index.ts b/apps/server/src/routes/auth/index.ts index b70ecba0f..b3b89d589 100644 --- a/apps/server/src/routes/auth/index.ts +++ b/apps/server/src/routes/auth/index.ts @@ -2,7 +2,7 @@ import type { AuthInstance } from '../../libs/auth' import type { Database } from '../../libs/db' import type { Env } from '../../libs/env' import type { RateLimitMetrics } from '../../otel' -import type { ConfigKVService } from '../../services/config-kv' +import type { ConfigKVService } from '../../services/adapters/config-kv' import type { HonoEnv } from '../../types/hono' import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from '@better-auth/oauth-provider' diff --git a/apps/server/src/routes/characters/index.ts b/apps/server/src/routes/characters/index.ts index aee0ef691..6c98cb16e 100644 --- a/apps/server/src/routes/characters/index.ts +++ b/apps/server/src/routes/characters/index.ts @@ -1,4 +1,4 @@ -import type { CharacterService } from '../../services/characters' +import type { CharacterService } from '../../services/domain/characters' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/characters/route.test.ts b/apps/server/src/routes/characters/route.test.ts index 6a6da29b9..b4e9c873c 100644 --- a/apps/server/src/routes/characters/route.test.ts +++ b/apps/server/src/routes/characters/route.test.ts @@ -6,7 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { createCharacterRoutes } from '.' import { mockDB } from '../../libs/mock-db' -import { createCharacterService } from '../../services/characters' +import { createCharacterService } from '../../services/domain/characters' import { ApiError } from '../../utils/error' import * as schema from '../../schemas' diff --git a/apps/server/src/routes/chat-ws/index.ts b/apps/server/src/routes/chat-ws/index.ts index 754745087..932fcaa5d 100644 --- a/apps/server/src/routes/chat-ws/index.ts +++ b/apps/server/src/routes/chat-ws/index.ts @@ -2,7 +2,7 @@ import type Redis from 'ioredis' import type { HonoWsInvocableEventContext } from '../../libs/eventa-hono-adapter' import type { EngagementMetrics } from '../../otel' -import type { ChatService } from '../../services/chats' +import type { ChatService } from '../../services/domain/chats' import { useLogger } from '@guiiai/logg' import { defineInvokeHandler } from '@moeru/eventa' diff --git a/apps/server/src/routes/chats/index.ts b/apps/server/src/routes/chats/index.ts index ce179f989..b5b4f17f9 100644 --- a/apps/server/src/routes/chats/index.ts +++ b/apps/server/src/routes/chats/index.ts @@ -1,4 +1,4 @@ -import type { ChatService } from '../../services/chats' +import type { ChatService } from '../../services/domain/chats' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/flux/index.ts b/apps/server/src/routes/flux/index.ts index 8f2739811..1cf7795cf 100644 --- a/apps/server/src/routes/flux/index.ts +++ b/apps/server/src/routes/flux/index.ts @@ -1,5 +1,5 @@ -import type { FluxService } from '../../services/flux' -import type { FluxTransactionService } from '../../services/flux-transaction' +import type { FluxService } from '../../services/domain/flux' +import type { FluxTransactionService } from '../../services/domain/flux-transaction' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/flux/route.test.ts b/apps/server/src/routes/flux/route.test.ts index 473b1d467..e1228e446 100644 --- a/apps/server/src/routes/flux/route.test.ts +++ b/apps/server/src/routes/flux/route.test.ts @@ -1,5 +1,5 @@ -import type { FluxService } from '../../services/flux' -import type { FluxTransactionService } from '../../services/flux-transaction' +import type { FluxService } from '../../services/domain/flux' +import type { FluxTransactionService } from '../../services/domain/flux-transaction' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/openai/v1/index.ts b/apps/server/src/routes/openai/v1/index.ts index bebdc6b80..0081a5f78 100644 --- a/apps/server/src/routes/openai/v1/index.ts +++ b/apps/server/src/routes/openai/v1/index.ts @@ -2,13 +2,13 @@ import type { Context } from 'hono' import type { PostHog } from 'posthog-node' import type { GenAiMetrics, RateLimitMetrics, RevenueMetrics } from '../../../otel' -import type { UsageInfo } from '../../../services/billing/billing' -import type { BillingService } from '../../../services/billing/billing-service' -import type { FluxMeter } from '../../../services/billing/flux-meter' -import type { ConfigKVService } from '../../../services/config-kv' -import type { FluxService } from '../../../services/flux' -import type { LlmRouterService } from '../../../services/llm-router' -import type { RequestLogService } from '../../../services/request-log' +import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { UsageInfo } from '../../../services/domain/billing/billing' +import type { BillingService } from '../../../services/domain/billing/billing-service' +import type { FluxMeter } from '../../../services/domain/billing/flux-meter' +import type { FluxService } from '../../../services/domain/flux' +import type { LlmRouterService } from '../../../services/domain/llm-router' +import type { RequestLogService } from '../../../services/domain/request-log' import type { HonoEnv } from '../../../types/hono' import { useLogger } from '@guiiai/logg' @@ -18,8 +18,8 @@ import { Hono } from 'hono' import { authGuard } from '../../../middlewares/auth' import { configGuard } from '../../../middlewares/config-guard' import { rateLimiter } from '../../../middlewares/rate-limit' -import { calculateFluxFromUsage, extractUsageFromBody } from '../../../services/billing/billing' -import { captureSafe } from '../../../services/posthog' +import { captureSafe } from '../../../services/adapters/posthog' +import { calculateFluxFromUsage, extractUsageFromBody } from '../../../services/domain/billing/billing' import { createPaymentRequiredError } from '../../../utils/error' import { nanoid } from '../../../utils/id' import { diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 7576c3072..8eb57e414 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -1,8 +1,8 @@ -import type { BillingService } from '../../../services/billing/billing-service' -import type { ConfigKVService } from '../../../services/config-kv' -import type { FluxService } from '../../../services/flux' -import type { LlmRouterService } from '../../../services/llm-router' -import type { RequestLogService } from '../../../services/request-log' +import type { ConfigKVService } from '../../../services/adapters/config-kv' +import type { BillingService } from '../../../services/domain/billing/billing-service' +import type { FluxService } from '../../../services/domain/flux' +import type { LlmRouterService } from '../../../services/domain/llm-router' +import type { RequestLogService } from '../../../services/domain/request-log' import type { HonoEnv } from '../../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/providers/index.ts b/apps/server/src/routes/providers/index.ts index d6d0b2554..b4dd01d8c 100644 --- a/apps/server/src/routes/providers/index.ts +++ b/apps/server/src/routes/providers/index.ts @@ -1,4 +1,4 @@ -import type { ProviderService } from '../../services/providers' +import type { ProviderService } from '../../services/domain/providers' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/routes/providers/route.test.ts b/apps/server/src/routes/providers/route.test.ts index 480e6a8f0..3d72b8dfa 100644 --- a/apps/server/src/routes/providers/route.test.ts +++ b/apps/server/src/routes/providers/route.test.ts @@ -6,7 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { createProviderRoutes } from '.' import { mockDB } from '../../libs/mock-db' -import { createProviderService } from '../../services/providers' +import { createProviderService } from '../../services/domain/providers' import { ApiError } from '../../utils/error' import * as schema from '../../schemas' diff --git a/apps/server/src/routes/stripe/index.ts b/apps/server/src/routes/stripe/index.ts index fe7d26974..c98270415 100644 --- a/apps/server/src/routes/stripe/index.ts +++ b/apps/server/src/routes/stripe/index.ts @@ -3,10 +3,10 @@ import type { PostHog } from 'posthog-node' import type { Env } from '../../libs/env' import type { RateLimitMetrics, RevenueMetrics } from '../../otel' -import type { BillingService } from '../../services/billing/billing-service' -import type { ConfigKVService } from '../../services/config-kv' -import type { FluxService } from '../../services/flux' -import type { StripeService } from '../../services/stripe' +import type { ConfigKVService } from '../../services/adapters/config-kv' +import type { BillingService } from '../../services/domain/billing/billing-service' +import type { FluxService } from '../../services/domain/flux' +import type { StripeService } from '../../services/domain/stripe' import type { HonoEnv } from '../../types/hono' import Stripe from 'stripe' @@ -17,7 +17,7 @@ import { safeParse } from 'valibot' import { authGuard } from '../../middlewares/auth' import { rateLimiter } from '../../middlewares/rate-limit' -import { captureSafe } from '../../services/posthog' +import { captureSafe } from '../../services/adapters/posthog' import { createBadRequestError, createServiceUnavailableError } from '../../utils/error' import { errorMessageFromUnknown } from '../../utils/error-message' import { resolveTrustedRequestOrigin } from '../../utils/origin' diff --git a/apps/server/src/routes/stripe/route.test.ts b/apps/server/src/routes/stripe/route.test.ts index bb344c467..42fa287ab 100644 --- a/apps/server/src/routes/stripe/route.test.ts +++ b/apps/server/src/routes/stripe/route.test.ts @@ -1,8 +1,8 @@ import type { StripeCheckoutSession, StripeInvoice } from '../../schemas/stripe' -import type { BillingService } from '../../services/billing/billing-service' -import type { ConfigKVService } from '../../services/config-kv' -import type { FluxService } from '../../services/flux' -import type { StripeService } from '../../services/stripe' +import type { ConfigKVService } from '../../services/adapters/config-kv' +import type { BillingService } from '../../services/domain/billing/billing-service' +import type { FluxService } from '../../services/domain/flux' +import type { StripeService } from '../../services/domain/stripe' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' diff --git a/apps/server/src/services/tests/config-kv.test.ts b/apps/server/src/services/adapters/config-kv.test.ts similarity index 98% rename from apps/server/src/services/tests/config-kv.test.ts rename to apps/server/src/services/adapters/config-kv.test.ts index ace74eb08..b73c31fbd 100644 --- a/apps/server/src/services/tests/config-kv.test.ts +++ b/apps/server/src/services/adapters/config-kv.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { configRedisKey } from '../../utils/redis-keys' -import { createConfigKVService } from '../config-kv' +import { createConfigKVService } from './config-kv' function createMockRedis() { const store = new Map() diff --git a/apps/server/src/services/config-kv.ts b/apps/server/src/services/adapters/config-kv.ts similarity index 98% rename from apps/server/src/services/config-kv.ts rename to apps/server/src/services/adapters/config-kv.ts index b71db3857..758dab4be 100644 --- a/apps/server/src/services/config-kv.ts +++ b/apps/server/src/services/adapters/config-kv.ts @@ -3,8 +3,8 @@ import type { InferOutput } from 'valibot' import { any, array, boolean, check, nonEmpty, number, object, optional, parse, picklist, pipe, record, regex, string } from 'valibot' -import { createServiceUnavailableError } from '../utils/error' -import { configRedisKey } from '../utils/redis-keys' +import { createServiceUnavailableError } from '../../utils/error' +import { configRedisKey } from '../../utils/redis-keys' /** * LLM/TTS router config tree. Single composite entry under configKV holds the diff --git a/apps/server/src/services/email.ts b/apps/server/src/services/adapters/email.ts similarity index 99% rename from apps/server/src/services/email.ts rename to apps/server/src/services/adapters/email.ts index 2a8e02f22..0c36df10b 100644 --- a/apps/server/src/services/email.ts +++ b/apps/server/src/services/adapters/email.ts @@ -1,12 +1,12 @@ import type { Logger } from '@guiiai/logg' -import type { EmailMetrics } from '../otel' +import type { EmailMetrics } from '../../otel' import { useLogger } from '@guiiai/logg' import { errorMessageFrom } from '@moeru/std' import { Resend } from 'resend' -import { ApiError } from '../utils/error' +import { ApiError } from '../../utils/error' /** * Outbound email payload accepted by {@link EmailService.send}. diff --git a/apps/server/src/services/posthog.ts b/apps/server/src/services/adapters/posthog.ts similarity index 98% rename from apps/server/src/services/posthog.ts rename to apps/server/src/services/adapters/posthog.ts index ee1be26bf..c2ef529f1 100644 --- a/apps/server/src/services/posthog.ts +++ b/apps/server/src/services/adapters/posthog.ts @@ -1,4 +1,4 @@ -import type { Env } from '../libs/env' +import type { Env } from '../../libs/env' import { useLogger } from '@guiiai/logg' import { PostHog } from 'posthog-node' diff --git a/apps/server/src/services/tts-adapters/azure.ts b/apps/server/src/services/adapters/tts/azure.ts similarity index 99% rename from apps/server/src/services/tts-adapters/azure.ts rename to apps/server/src/services/adapters/tts/azure.ts index 402a8a9a3..c2075c751 100644 --- a/apps/server/src/services/tts-adapters/azure.ts +++ b/apps/server/src/services/adapters/tts/azure.ts @@ -6,7 +6,7 @@ import { errorMessageFrom } from '@moeru/std' import azureVoices from './voices/azure.json' with { type: 'json' } -import { createBadRequestError, createInternalError } from '../../utils/error' +import { createBadRequestError, createInternalError } from '../../../utils/error' // NOTICE: // Voice IDs Azure accepts are stable strings like `en-US-AvaMultilingualNeural`. diff --git a/apps/server/src/services/tts-adapters/dashscope-cosyvoice.test.ts b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts similarity index 100% rename from apps/server/src/services/tts-adapters/dashscope-cosyvoice.test.ts rename to apps/server/src/services/adapters/tts/dashscope-cosyvoice.test.ts diff --git a/apps/server/src/services/tts-adapters/dashscope-cosyvoice.ts b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts similarity index 99% rename from apps/server/src/services/tts-adapters/dashscope-cosyvoice.ts rename to apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts index cf7b47d64..5044392da 100644 --- a/apps/server/src/services/tts-adapters/dashscope-cosyvoice.ts +++ b/apps/server/src/services/adapters/tts/dashscope-cosyvoice.ts @@ -8,7 +8,7 @@ import { errorMessageFrom } from '@moeru/std' import cosyvoiceVoices from './voices/dashscope-cosyvoice.json' with { type: 'json' } -import { createInternalError } from '../../utils/error' +import { createInternalError } from '../../../utils/error' /** * Default DashScope cosyvoice voice id. v2 voice ids carry an explicit `_v2` diff --git a/apps/server/src/services/tts-adapters/index.test.ts b/apps/server/src/services/adapters/tts/index.test.ts similarity index 97% rename from apps/server/src/services/tts-adapters/index.test.ts rename to apps/server/src/services/adapters/tts/index.test.ts index ffccea3da..ecbf354ee 100644 --- a/apps/server/src/services/tts-adapters/index.test.ts +++ b/apps/server/src/services/adapters/tts/index.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { ApiError } from '../../utils/error' +import { ApiError } from '../../../utils/error' import { getAdapter } from './index' describe('getAdapter', () => { diff --git a/apps/server/src/services/tts-adapters/index.ts b/apps/server/src/services/adapters/tts/index.ts similarity index 95% rename from apps/server/src/services/tts-adapters/index.ts rename to apps/server/src/services/adapters/tts/index.ts index c3412db57..b464b16a2 100644 --- a/apps/server/src/services/tts-adapters/index.ts +++ b/apps/server/src/services/adapters/tts/index.ts @@ -1,6 +1,6 @@ import type { TtsAdapter, TtsAdapterId } from './types' -import { createBadRequestError } from '../../utils/error' +import { createBadRequestError } from '../../../utils/error' import { azureAdapter } from './azure' import { dashscopeCosyvoiceAdapter } from './dashscope-cosyvoice' import { volcengineAdapter } from './volcengine' diff --git a/apps/server/src/services/tts-adapters/types.ts b/apps/server/src/services/adapters/tts/types.ts similarity index 100% rename from apps/server/src/services/tts-adapters/types.ts rename to apps/server/src/services/adapters/tts/types.ts diff --git a/apps/server/src/services/tts-adapters/voices/azure.json b/apps/server/src/services/adapters/tts/voices/azure.json similarity index 100% rename from apps/server/src/services/tts-adapters/voices/azure.json rename to apps/server/src/services/adapters/tts/voices/azure.json diff --git a/apps/server/src/services/tts-adapters/voices/dashscope-cosyvoice.json b/apps/server/src/services/adapters/tts/voices/dashscope-cosyvoice.json similarity index 100% rename from apps/server/src/services/tts-adapters/voices/dashscope-cosyvoice.json rename to apps/server/src/services/adapters/tts/voices/dashscope-cosyvoice.json diff --git a/apps/server/src/services/tts-adapters/voices/volcengine.json b/apps/server/src/services/adapters/tts/voices/volcengine.json similarity index 100% rename from apps/server/src/services/tts-adapters/voices/volcengine.json rename to apps/server/src/services/adapters/tts/voices/volcengine.json diff --git a/apps/server/src/services/tts-adapters/volcengine.ts b/apps/server/src/services/adapters/tts/volcengine.ts similarity index 98% rename from apps/server/src/services/tts-adapters/volcengine.ts rename to apps/server/src/services/adapters/tts/volcengine.ts index 7b0c1b865..d955a0eba 100644 --- a/apps/server/src/services/tts-adapters/volcengine.ts +++ b/apps/server/src/services/adapters/tts/volcengine.ts @@ -8,8 +8,8 @@ import { errorMessageFrom } from '@moeru/std' import volcengineVoices from './voices/volcengine.json' with { type: 'json' } -import { createInternalError } from '../../utils/error' -import { nanoid } from '../../utils/id' +import { createInternalError } from '../../../utils/error' +import { nanoid } from '../../../utils/id' /** * Default Volcengine TTS voice id. `BV001_streaming` is Volcengine's standard diff --git a/apps/server/src/services/admin-flux-grant-batch/flux-grant-batch-service.ts b/apps/server/src/services/admin-flux-grant-batch/flux-grant-batch-service.ts deleted file mode 100644 index 90b6e3fbd..000000000 --- a/apps/server/src/services/admin-flux-grant-batch/flux-grant-batch-service.ts +++ /dev/null @@ -1,460 +0,0 @@ -import type { Database } from '../../libs/db' - -import { Buffer } from 'node:buffer' - -import { useLogger } from '@guiiai/logg' -import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm' - -import * as accountsSchema from '../../schemas/accounts' -import * as fluxSchema from '../../schemas/flux' -import * as batchSchema from '../../schemas/flux-grant-batch' - -const logger = useLogger('admin-flux-grant-batch-service') - -/** - * Per-email outcome of resolving an input list against the user table. - * Used by both dry-run preview and persisted recipient rows. - */ -export interface ResolvedEmail { - inputEmail: string - userId: string | null - status: 'pending' | 'skipped' - errorReason: 'not_found' | 'user_deleted' | 'duplicate_in_input' | null -} - -export interface ResolveSummary { - totalEmails: number - willGrant: number - willSkip: { - notFound: number - userDeleted: number - duplicateInInput: number - } - totalFluxToIssue: number - estimatedDurationSec: number - samples: { - willGrant: string[] - notFound: string[] - userDeleted: string[] - } -} - -/** - * Resolve a list of input emails against the user table. - * - * Use when: - * - Previewing a dry-run before batch creation - * - Persisting `flux_grant_batch_recipient` rows with a deterministic resolution snapshot - * - * Expects: - * - `emails` is non-empty; case is preserved in output but compared via LOWER - * - * Returns: - * - One entry per input email (duplicates included with `duplicate_in_input`), - * plus a summary used directly in dry-run responses - */ -async function resolveEmails( - db: Database, - emails: string[], - amountPerUser: number, - throttlePerSec: number, -): Promise<{ resolved: ResolvedEmail[], summary: ResolveSummary }> { - // Lowercase index of inputs → first-seen index. Subsequent occurrences are duplicates. - const seenLower = new Map() - const resolved: ResolvedEmail[] = emails.map((email, idx) => { - const lower = email.toLowerCase() - if (seenLower.has(lower)) { - return { inputEmail: email, userId: null, status: 'skipped', errorReason: 'duplicate_in_input' } - } - seenLower.set(lower, idx) - return { inputEmail: email, userId: null, status: 'pending', errorReason: null } - }) - - const lowerEmails = Array.from(seenLower.keys()) - - // Bulk-fetch matching users (case-insensitive). For 10k inputs this is one - // query — Postgres handles `LOWER(email) IN (...)` fine on a unique index - // because `email` is already unique; collation just means we filter post-fetch. - const users = await db - .select({ id: accountsSchema.user.id, email: accountsSchema.user.email }) - .from(accountsSchema.user) - .where(inArray(sql`LOWER(${accountsSchema.user.email})`, lowerEmails)) - - const userByLowerEmail = new Map(users.map(u => [u.email.toLowerCase(), u.id])) - - // Bulk-fetch user_flux rows for matched userIds — only need to know which are soft-deleted. - const matchedUserIds = users.map(u => u.id) - const fluxRows = matchedUserIds.length > 0 - ? await db - .select({ userId: fluxSchema.userFlux.userId, deletedAt: fluxSchema.userFlux.deletedAt }) - .from(fluxSchema.userFlux) - .where(inArray(fluxSchema.userFlux.userId, matchedUserIds)) - : [] - const deletedUserIds = new Set( - fluxRows.filter(r => r.deletedAt != null).map(r => r.userId), - ) - - // Annotate the resolved list now that we have user lookups + deletion status. - for (const entry of resolved) { - if (entry.errorReason === 'duplicate_in_input') - continue - - const userId = userByLowerEmail.get(entry.inputEmail.toLowerCase()) - if (!userId) { - entry.status = 'skipped' - entry.errorReason = 'not_found' - continue - } - - if (deletedUserIds.has(userId)) { - entry.userId = userId - entry.status = 'skipped' - entry.errorReason = 'user_deleted' - continue - } - - entry.userId = userId - entry.status = 'pending' - } - - // Counts and samples for preview output. - const willGrant = resolved.filter(r => r.status === 'pending').length - const notFound = resolved.filter(r => r.errorReason === 'not_found').length - const userDeleted = resolved.filter(r => r.errorReason === 'user_deleted').length - const duplicateInInput = resolved.filter(r => r.errorReason === 'duplicate_in_input').length - - const summary: ResolveSummary = { - totalEmails: emails.length, - willGrant, - willSkip: { notFound, userDeleted, duplicateInInput }, - totalFluxToIssue: willGrant * amountPerUser, - estimatedDurationSec: Math.ceil(willGrant / Math.max(1, throttlePerSec)), - samples: { - willGrant: resolved.filter(r => r.status === 'pending').slice(0, 5).map(r => r.inputEmail), - notFound: resolved.filter(r => r.errorReason === 'not_found').slice(0, 5).map(r => r.inputEmail), - userDeleted: resolved.filter(r => r.errorReason === 'user_deleted').slice(0, 5).map(r => r.inputEmail), - }, - } - - return { resolved, summary } -} - -export interface CreateBatchInput { - name: string - amount: number - description?: string - emails: string[] - createdByUserId: string - throttlePerSec: number -} - -export function createFluxGrantBatchService(db: Database) { - return { - /** - * Preview a batch without writing anything. Used by `?dryRun=true`. - */ - async preview(input: Omit) { - const { summary } = await resolveEmails(db, input.emails, input.amount, input.throttlePerSec) - return summary - }, - - /** - * Create a persistent batch + per-email recipient rows. Worker picks up - * `pending` rows asynchronously. - * - * Resolution happens here (not in the worker) so dry-run preview numbers - * match real execution exactly. - */ - async create(input: CreateBatchInput) { - const { resolved, summary } = await resolveEmails( - db, - input.emails, - input.amount, - input.throttlePerSec, - ) - - const batchRow = await db.transaction(async (tx) => { - const [created] = await tx.insert(batchSchema.fluxGrantBatch).values({ - name: input.name, - type: 'promo', - amount: input.amount, - description: input.description, - status: 'created', - createdByUserId: input.createdByUserId, - }).returning() - - if (!created) - throw new Error('Failed to insert flux_grant_batch row') - - const recipientRows = resolved.map(r => ({ - batchId: created.id, - inputEmail: r.inputEmail, - userId: r.userId, - status: r.status, - errorReason: r.errorReason, - })) - - // Chunk inserts to keep a single statement under Postgres' parameter limit - // (≈ 65k params; 5 cols/row = ≈ 13k rows per chunk; we use 1k for headroom). - const CHUNK = 1000 - for (let i = 0; i < recipientRows.length; i += CHUNK) - await tx.insert(batchSchema.fluxGrantBatchRecipient).values(recipientRows.slice(i, i + CHUNK)) - - return created - }) - - logger.withFields({ - batchId: batchRow.id, - name: input.name, - userCount: input.emails.length, - willGrant: summary.willGrant, - }).log('Flux grant batch created') - - return { batch: batchRow, summary } - }, - - /** - * Get batch + progress counts + recent failure samples. - * Returns null if the batch does not exist. - */ - async get(batchId: string) { - const [row] = await db - .select() - .from(batchSchema.fluxGrantBatch) - .where(eq(batchSchema.fluxGrantBatch.id, batchId)) - .limit(1) - - if (!row) - return null - - const recipientStatusRows = await db - .select({ status: batchSchema.fluxGrantBatchRecipient.status, count: sql`count(*)::int` }) - .from(batchSchema.fluxGrantBatchRecipient) - .where(eq(batchSchema.fluxGrantBatchRecipient.batchId, batchId)) - .groupBy(batchSchema.fluxGrantBatchRecipient.status) - - const progress = { total: 0, pending: 0, granted: 0, skipped: 0, failed: 0 } - for (const r of recipientStatusRows) { - progress.total += r.count - if (r.status === 'pending') - progress.pending = r.count - else if (r.status === 'granted') - progress.granted = r.count - else if (r.status === 'skipped') - progress.skipped = r.count - else if (r.status === 'failed') - progress.failed = r.count - } - - const recentFailures = await db - .select({ - id: batchSchema.fluxGrantBatchRecipient.id, - inputEmail: batchSchema.fluxGrantBatchRecipient.inputEmail, - userId: batchSchema.fluxGrantBatchRecipient.userId, - errorReason: batchSchema.fluxGrantBatchRecipient.errorReason, - attemptCount: batchSchema.fluxGrantBatchRecipient.attemptCount, - lastAttemptedAt: batchSchema.fluxGrantBatchRecipient.lastAttemptedAt, - }) - .from(batchSchema.fluxGrantBatchRecipient) - .where(and( - eq(batchSchema.fluxGrantBatchRecipient.batchId, batchId), - eq(batchSchema.fluxGrantBatchRecipient.status, 'failed'), - )) - .orderBy(desc(batchSchema.fluxGrantBatchRecipient.lastAttemptedAt)) - .limit(20) - - return { batch: row, progress, recentFailures } - }, - - /** - * Paginated list. Cursor is the last seen createdAt+id pair, base64-encoded. - */ - async list(opts: { limit: number, cursor?: string, status?: string }) { - const limit = Math.min(100, Math.max(1, opts.limit)) - let cursorTime: Date | null = null - let cursorId: string | null = null - - if (opts.cursor) { - try { - const decoded = JSON.parse(Buffer.from(opts.cursor, 'base64').toString('utf-8')) - cursorTime = new Date(decoded.t) - cursorId = decoded.i - } - catch { - // bad cursor → treat as no cursor - } - } - - const whereParts = [] - if (opts.status) - whereParts.push(eq(batchSchema.fluxGrantBatch.status, opts.status)) - if (cursorTime && cursorId) { - whereParts.push(sql`(${batchSchema.fluxGrantBatch.createdAt}, ${batchSchema.fluxGrantBatch.id}) < (${cursorTime}, ${cursorId})`) - } - - const rows = await db - .select() - .from(batchSchema.fluxGrantBatch) - .where(whereParts.length > 0 ? and(...whereParts) : undefined) - .orderBy(desc(batchSchema.fluxGrantBatch.createdAt), desc(batchSchema.fluxGrantBatch.id)) - .limit(limit + 1) - - const hasMore = rows.length > limit - const items = hasMore ? rows.slice(0, limit) : rows - - let nextCursor: string | null = null - if (hasMore) { - const last = items[items.length - 1]! - nextCursor = Buffer.from(JSON.stringify({ t: last.createdAt.toISOString(), i: last.id })).toString('base64') - } - - return { batches: items, nextCursor } - }, - - /** - * Reset all `failed` recipients in a batch back to `pending` so the worker - * picks them up on the next poll. Idempotent: returns 0 when nothing is failed. - * - * Returns: - * - { retriedCount } — number of rows transitioned from failed → pending - */ - async retryFailed(batchId: string) { - const updated = await db - .update(batchSchema.fluxGrantBatchRecipient) - .set({ - status: 'pending', - attemptCount: 0, - lastAttemptedAt: null, - errorReason: null, - }) - .where(and( - eq(batchSchema.fluxGrantBatchRecipient.batchId, batchId), - eq(batchSchema.fluxGrantBatchRecipient.status, 'failed'), - )) - .returning({ id: batchSchema.fluxGrantBatchRecipient.id }) - - // If a batch was completed and we re-opened pending rows, flip its - // status back to running so the worker picks it up. - if (updated.length > 0) { - await db - .update(batchSchema.fluxGrantBatch) - .set({ status: 'running', completedAt: null }) - .where(and( - eq(batchSchema.fluxGrantBatch.id, batchId), - inArray(batchSchema.fluxGrantBatch.status, ['completed', 'failed_partial']), - )) - } - - logger.withFields({ batchId, retriedCount: updated.length }).log('Retry failed recipients') - return { retriedCount: updated.length } - }, - } -} - -export type FluxGrantBatchService = ReturnType - -/** - * Exported for test reuse. - */ -export { resolveEmails } - -/** - * Worker-side query: count remaining pending recipients for a batch. Used to - * decide when to flip batch status to completed/failed_partial. - * - * Returns: - * - { pending, failed, total } counts - */ -export async function getBatchTerminalCheck(db: Database, batchId: string) { - const rows = await db - .select({ status: batchSchema.fluxGrantBatchRecipient.status, count: sql`count(*)::int` }) - .from(batchSchema.fluxGrantBatchRecipient) - .where(eq(batchSchema.fluxGrantBatchRecipient.batchId, batchId)) - .groupBy(batchSchema.fluxGrantBatchRecipient.status) - - let pending = 0 - let failed = 0 - let total = 0 - for (const r of rows) { - total += r.count - if (r.status === 'pending') - pending = r.count - else if (r.status === 'failed') - failed = r.count - } - return { pending, failed, total } -} - -/** - * Worker-side query: find batches that are still in `created` or `running` - * and have at least one pending recipient whose backoff has elapsed. Workers - * iterate this set so they don't waste polls on empty/finished batches. - * - * NOTICE: - * Why this isn't `selectDistinct(...).innerJoin(recipient)`: - * Postgres rejects `SELECT DISTINCT ... ORDER BY col` when `col` isn't in - * the select list (sqlstate 42P10). We previously had `orderBy(createdAt)` - * with only `id` + `status` selected → runtime crash on the first worker - * tick (caught during local dev 2026-05-08). - * - * Switched to a plain `SELECT … WHERE EXISTS (recipient row meeting - * criteria)`, which is also conceptually right: "show me batches that - * have at least one ready-to-process recipient", not "join then dedupe". - */ -export async function findActiveBatches(db: Database, now: Date) { - const rows = await db - .select({ - id: batchSchema.fluxGrantBatch.id, - status: batchSchema.fluxGrantBatch.status, - }) - .from(batchSchema.fluxGrantBatch) - .where(and( - inArray(batchSchema.fluxGrantBatch.status, ['created', 'running']), - sql`EXISTS ( - SELECT 1 FROM ${batchSchema.fluxGrantBatchRecipient} - WHERE ${batchSchema.fluxGrantBatchRecipient.batchId} = ${batchSchema.fluxGrantBatch.id} - AND ${batchSchema.fluxGrantBatchRecipient.status} = 'pending' - AND (${batchSchema.fluxGrantBatchRecipient.lastAttemptedAt} IS NULL - OR ${batchSchema.fluxGrantBatchRecipient.lastAttemptedAt} < ${now}) - )`, - )) - .orderBy(batchSchema.fluxGrantBatch.createdAt) - - return rows -} - -/** - * Worker-side helper: mark a batch started (status='running', startedAt set) - * if it's still 'created'. Idempotent: no-op when already running. - */ -export async function markBatchStartedIfNeeded(db: Database, batchId: string, now: Date) { - await db - .update(batchSchema.fluxGrantBatch) - .set({ status: 'running', startedAt: now }) - .where(and( - eq(batchSchema.fluxGrantBatch.id, batchId), - eq(batchSchema.fluxGrantBatch.status, 'created'), - isNull(batchSchema.fluxGrantBatch.startedAt), - )) -} - -/** - * Worker-side helper: when no pending rows remain, flip the batch to a - * terminal status. `failed_partial` if any failed/skipped, else `completed`. - * Idempotent: skips batches already in terminal status. - */ -export async function finalizeBatchIfDone(db: Database, batchId: string, now: Date) { - const counts = await getBatchTerminalCheck(db, batchId) - if (counts.pending > 0) - return - - const terminal = counts.failed > 0 ? 'failed_partial' : 'completed' - - await db - .update(batchSchema.fluxGrantBatch) - .set({ status: terminal, completedAt: now }) - .where(and( - eq(batchSchema.fluxGrantBatch.id, batchId), - inArray(batchSchema.fluxGrantBatch.status, ['created', 'running']), - )) -} diff --git a/apps/server/src/services/admin-flux-grant-batch/flux-grant-batch-worker.ts b/apps/server/src/services/admin-flux-grant-batch/flux-grant-batch-worker.ts deleted file mode 100644 index a250ab192..000000000 --- a/apps/server/src/services/admin-flux-grant-batch/flux-grant-batch-worker.ts +++ /dev/null @@ -1,360 +0,0 @@ -import type { Database } from '../../libs/db' -import type { BillingService } from '../billing/billing-service' - -import { useLogger } from '@guiiai/logg' -import { errorMessageFrom } from '@moeru/std' -import { and, asc, eq, isNull, or, sql } from 'drizzle-orm' - -import { - finalizeBatchIfDone, - findActiveBatches, - markBatchStartedIfNeeded, -} from './flux-grant-batch-service' - -import * as fluxSchema from '../../schemas/flux' -import * as batchSchema from '../../schemas/flux-grant-batch' - -const logger = useLogger('admin-flux-grant-batch-worker').useGlobalConfig() - -export interface FluxGrantBatchWorkerOptions { - /** - * How many pending recipients the worker tries to process per polling tick. - * @default 50 - */ - batchSize?: number - /** - * How many grants to issue per second across this worker instance. - * Sleep between grant calls is `1000 / throttlePerSec`. - * @default 50 - */ - throttlePerSec?: number - /** - * Maximum number of attempts before a recipient is marked `failed` permanently. - * Operator can re-arm via `POST /api/admin/flux-grant-batches/:id/retry`. - * @default 3 - */ - maxAttempts?: number - /** - * Sleep duration when no work is available, in ms. - * @default 1000 - */ - idleSleepMs?: number -} - -const DEFAULTS = { - batchSize: 50, - throttlePerSec: 50, - maxAttempts: 3, - idleSleepMs: 1000, -} as const - -/** - * Compute the next-attempt cooldown for a recipient whose previous attempt failed. - * - * Schedule: - * - attempt 0 → 0s (never attempted) - * - attempt 1 → 30s - * - attempt 2 → 5min - * - attempt 3+ → terminal (handled by caller, not this fn) - */ -export function backoffMs(attempt: number): number { - if (attempt <= 0) - return 0 - if (attempt === 1) - return 30_000 - return 5 * 60_000 -} - -/** - * Sleep that wakes up early when the abort signal fires. - */ -function sleep(ms: number, signal: AbortSignal): Promise { - if (ms <= 0) - return Promise.resolve() - return new Promise((resolve) => { - let timer: ReturnType | null = null - const onAbort = () => { - if (timer != null) - clearTimeout(timer) - resolve() - } - timer = setTimeout(() => { - signal.removeEventListener('abort', onAbort) - resolve() - }, ms) - signal.addEventListener('abort', onAbort, { once: true }) - }) -} - -/** - * Claim up to `batchSize` pending recipients whose backoff has elapsed. - * - * Use when: - * - Worker tick wants a fresh batch of recipients to process - * - * Expects: - * - Caller is OK with rows being locked for the duration of `processBatch`; - * we don't pre-commit a "claimed" status because the row stays locked under - * `FOR UPDATE SKIP LOCKED` until the outer transaction completes. - * - * Returns: - * - Up to `batchSize` rows joined with parent batch meta. Empty array means - * no work for this batch right now. - */ -async function selectClaimablePending( - db: Database, - batchId: string, - batchSize: number, - now: Date, -) { - // Backoff cutoffs are encoded as a SQL expression: a row is claimable if - // last_attempted_at IS NULL OR (now - last_attempted_at) >= backoff(attempt_count). - // Step function: ≤0 attempts → 0s, =1 → 30s, ≥2 → 5min. - return db - .select({ - recipientId: batchSchema.fluxGrantBatchRecipient.id, - batchId: batchSchema.fluxGrantBatchRecipient.batchId, - userId: batchSchema.fluxGrantBatchRecipient.userId, - inputEmail: batchSchema.fluxGrantBatchRecipient.inputEmail, - attemptCount: batchSchema.fluxGrantBatchRecipient.attemptCount, - batchName: batchSchema.fluxGrantBatch.name, - batchAmount: batchSchema.fluxGrantBatch.amount, - batchDescription: batchSchema.fluxGrantBatch.description, - }) - .from(batchSchema.fluxGrantBatchRecipient) - .innerJoin( - batchSchema.fluxGrantBatch, - eq(batchSchema.fluxGrantBatch.id, batchSchema.fluxGrantBatchRecipient.batchId), - ) - .where(and( - eq(batchSchema.fluxGrantBatchRecipient.batchId, batchId), - eq(batchSchema.fluxGrantBatchRecipient.status, 'pending'), - or( - isNull(batchSchema.fluxGrantBatchRecipient.lastAttemptedAt), - sql`${batchSchema.fluxGrantBatchRecipient.lastAttemptedAt} + - (CASE - WHEN ${batchSchema.fluxGrantBatchRecipient.attemptCount} <= 0 THEN INTERVAL '0 seconds' - WHEN ${batchSchema.fluxGrantBatchRecipient.attemptCount} = 1 THEN INTERVAL '30 seconds' - ELSE INTERVAL '5 minutes' - END) <= ${now}`, - ), - )) - .orderBy(asc(batchSchema.fluxGrantBatchRecipient.createdAt)) - .limit(batchSize) - .for('update', { skipLocked: true }) -} - -/** - * Process one recipient: re-check user soft-delete, call creditFlux, mark outcome. - * - * Use when: - * - Inside the transaction holding the `FOR UPDATE SKIP LOCKED` row - * - * Expects: - * - `userId` is non-null (NULL recipients are pre-skipped at creation time and - * never enter the worker queue) - * - * Returns: - * - 'granted' | 'skipped' | 'failed_transient' | 'failed_permanent' - */ -async function processSingleRecipient( - deps: { db: Database, billingService: BillingService }, - recipient: { - recipientId: string - batchId: string - userId: string - inputEmail: string - attemptCount: number - batchName: string - batchAmount: number - batchDescription: string | null - }, - maxAttempts: number, - now: Date, -): Promise<'granted' | 'skipped' | 'failed_transient' | 'failed_permanent'> { - // Re-check soft-delete. The user might have signed off between batch - // creation and worker pick-up. - const [fluxRow] = await deps.db - .select({ deletedAt: fluxSchema.userFlux.deletedAt }) - .from(fluxSchema.userFlux) - .where(eq(fluxSchema.userFlux.userId, recipient.userId)) - .limit(1) - - if (fluxRow && fluxRow.deletedAt != null) { - await deps.db - .update(batchSchema.fluxGrantBatchRecipient) - .set({ - status: 'skipped', - errorReason: 'user_deleted_after_resolution', - lastAttemptedAt: now, - }) - .where(eq(batchSchema.fluxGrantBatchRecipient.id, recipient.recipientId)) - return 'skipped' - } - - try { - const result = await deps.billingService.creditFlux({ - userId: recipient.userId, - amount: recipient.batchAmount, - type: 'promo', - requestId: `flux-grant-batch-${recipient.batchId}-${recipient.recipientId}`, - description: recipient.batchDescription ?? `Flux grant batch: ${recipient.batchName}`, - source: 'admin_promo', - auditMetadata: { - batchId: recipient.batchId, - batchName: recipient.batchName, - recipientId: recipient.recipientId, - }, - }) - - await deps.db - .update(batchSchema.fluxGrantBatchRecipient) - .set({ - status: 'granted', - attemptCount: recipient.attemptCount + 1, - lastAttemptedAt: now, - fluxTransactionId: result.fluxTransactionId, - errorReason: null, - }) - .where(eq(batchSchema.fluxGrantBatchRecipient.id, recipient.recipientId)) - - return 'granted' - } - catch (err) { - const nextAttempt = recipient.attemptCount + 1 - const errorMessage = errorMessageFrom(err) ?? 'Unknown error' - const isPermanent = nextAttempt >= maxAttempts - - await deps.db - .update(batchSchema.fluxGrantBatchRecipient) - .set({ - status: isPermanent ? 'failed' : 'pending', - attemptCount: nextAttempt, - lastAttemptedAt: now, - errorReason: errorMessage.slice(0, 500), - }) - .where(eq(batchSchema.fluxGrantBatchRecipient.id, recipient.recipientId)) - - logger.withError(err).withFields({ - recipientId: recipient.recipientId, - batchId: recipient.batchId, - userId: recipient.userId, - attempt: nextAttempt, - isPermanent, - }).warn('Recipient grant attempt failed') - - return isPermanent ? 'failed_permanent' : 'failed_transient' - } -} - -/** - * Run the flux grant batch worker polling loop until `signal` aborts. - * - * Use when: - * - Started alongside the billing-consumer role in `bin/run-billing-consumer.ts` - * - * Expects: - * - `billingService` writes to the same DB / Redis / billing stream as the - * API process (multi-instance Railway deployment) - * - * Call stack: - * - * runBillingConsumer (../bin/run-billing-consumer) - * -> {@link runFluxGrantBatchWorker} - * -> {@link findActiveBatches} - * -> {@link selectClaimablePending} (FOR UPDATE SKIP LOCKED) - * -> {@link processSingleRecipient} - * -> billingService.creditFlux (writes ledger + cache + event) - * -> {@link finalizeBatchIfDone} - */ -export async function runFluxGrantBatchWorker( - deps: { db: Database, billingService: BillingService }, - signal: AbortSignal, - options: FluxGrantBatchWorkerOptions = {}, -): Promise { - const batchSize = options.batchSize ?? DEFAULTS.batchSize - const throttlePerSec = options.throttlePerSec ?? DEFAULTS.throttlePerSec - const maxAttempts = options.maxAttempts ?? DEFAULTS.maxAttempts - const idleSleepMs = options.idleSleepMs ?? DEFAULTS.idleSleepMs - const perGrantSleepMs = Math.max(0, Math.floor(1000 / throttlePerSec)) - - logger.withFields({ batchSize, throttlePerSec, maxAttempts, idleSleepMs }).log('Flux grant batch worker started') - - while (!signal.aborted) { - try { - const now = new Date() - const active = await findActiveBatches(deps.db, now) - - if (active.length === 0) { - await sleep(idleSleepMs, signal) - continue - } - - let totalProcessed = 0 - - for (const batch of active) { - if (signal.aborted) - break - - await markBatchStartedIfNeeded(deps.db, batch.id, now) - - // Process the batch in transactional chunks. Each chunk holds - // FOR UPDATE SKIP LOCKED on its rows for the duration of the tx. - await deps.db.transaction(async (tx) => { - const claimed = await selectClaimablePending(tx as unknown as Database, batch.id, batchSize, now) - if (claimed.length === 0) - return - - for (const recipient of claimed) { - if (signal.aborted) - break - if (recipient.userId == null) { - // Defensive: NULL userId rows should never be 'pending' (they're - // resolved as 'skipped' at creation), but if one slips in mark it - // skipped here too. - await (tx as unknown as Database) - .update(batchSchema.fluxGrantBatchRecipient) - .set({ status: 'skipped', errorReason: 'not_found', lastAttemptedAt: now }) - .where(eq(batchSchema.fluxGrantBatchRecipient.id, recipient.recipientId)) - totalProcessed++ - continue - } - - await processSingleRecipient( - { db: tx as unknown as Database, billingService: deps.billingService }, - { - recipientId: recipient.recipientId, - batchId: recipient.batchId, - userId: recipient.userId, - inputEmail: recipient.inputEmail, - attemptCount: recipient.attemptCount, - batchName: recipient.batchName, - batchAmount: recipient.batchAmount, - batchDescription: recipient.batchDescription, - }, - maxAttempts, - now, - ) - - totalProcessed++ - if (perGrantSleepMs > 0) - await sleep(perGrantSleepMs, signal) - } - }) - - // After a chunk run, see whether this batch is now done. - await finalizeBatchIfDone(deps.db, batch.id, new Date()) - } - - // No work this tick → idle sleep so we don't hammer the DB. - if (totalProcessed === 0) - await sleep(idleSleepMs, signal) - } - catch (err) { - logger.withError(err).error('Flux grant batch worker tick failed; sleeping before retry') - await sleep(idleSleepMs, signal) - } - } - - logger.log('Flux grant batch worker stopped') -} diff --git a/apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-service.test.ts b/apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-service.test.ts deleted file mode 100644 index 100efb68d..000000000 --- a/apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-service.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import type { Database } from '../../../libs/db' - -import { eq } from 'drizzle-orm' -import { beforeAll, beforeEach, describe, expect, it } from 'vitest' - -import { mockDB } from '../../../libs/mock-db' -import { createFluxGrantBatchService, resolveEmails } from '../flux-grant-batch-service' - -import * as schema from '../../../schemas' - -describe('resolveEmails', () => { - let db: Database - - beforeAll(async () => { - db = await mockDB(schema) - - // Three users: one normal, one with deleted user_flux, one we never insert - // user_flux for at all (so it shows up as "user exists, no flux row" → still - // pending since user.id matches; default flux init happens at credit time). - await db.insert(schema.user).values([ - { id: 'uid_normal', name: 'Normal', email: 'Normal@Example.com' }, - { id: 'uid_deleted', name: 'Deleted', email: 'deleted@example.com' }, - { id: 'uid_no_flux', name: 'NoFlux', email: 'noflux@example.com' }, - ]) - - await db.insert(schema.userFlux).values([ - { userId: 'uid_normal', flux: 100 }, - { userId: 'uid_deleted', flux: 0, deletedAt: new Date() }, - ]) - }) - - beforeEach(async () => { - // No state to reset between tests — resolveEmails is read-only. - }) - - it('matches users case-insensitively against the user table', async () => { - const { resolved, summary } = await resolveEmails(db, ['NORMAL@example.com'], 200, 50) - expect(resolved).toHaveLength(1) - expect(resolved[0]).toMatchObject({ - inputEmail: 'NORMAL@example.com', - userId: 'uid_normal', - status: 'pending', - errorReason: null, - }) - expect(summary.willGrant).toBe(1) - expect(summary.totalFluxToIssue).toBe(200) - }) - - it('marks unknown emails as not_found', async () => { - const { resolved, summary } = await resolveEmails(db, ['ghost@example.com'], 200, 50) - expect(resolved[0]).toMatchObject({ - inputEmail: 'ghost@example.com', - userId: null, - status: 'skipped', - errorReason: 'not_found', - }) - expect(summary.willGrant).toBe(0) - expect(summary.willSkip.notFound).toBe(1) - }) - - it('marks soft-deleted users as user_deleted (userId still attached for audit)', async () => { - const { resolved, summary } = await resolveEmails(db, ['deleted@example.com'], 200, 50) - expect(resolved[0]).toMatchObject({ - inputEmail: 'deleted@example.com', - userId: 'uid_deleted', - status: 'skipped', - errorReason: 'user_deleted', - }) - expect(summary.willSkip.userDeleted).toBe(1) - }) - - it('keeps the first occurrence and tags subsequent duplicates', async () => { - const { resolved, summary } = await resolveEmails( - db, - ['normal@example.com', 'NORMAL@EXAMPLE.COM', 'normal@example.com'], - 200, - 50, - ) - expect(resolved).toHaveLength(3) - expect(resolved[0].errorReason).toBeNull() - expect(resolved[0].status).toBe('pending') - expect(resolved[1].errorReason).toBe('duplicate_in_input') - expect(resolved[2].errorReason).toBe('duplicate_in_input') - expect(summary.willGrant).toBe(1) - expect(summary.willSkip.duplicateInInput).toBe(2) - }) - - it('caps preview samples at 5 entries per category', async () => { - const ghosts = Array.from({ length: 12 }, (_, i) => `ghost${i}@example.com`) - const { summary } = await resolveEmails(db, ghosts, 200, 50) - expect(summary.samples.notFound).toHaveLength(5) - expect(summary.willSkip.notFound).toBe(12) - }) - - it('estimatedDurationSec rounds up based on throttle', async () => { - const ten = Array.from({ length: 10 }, (_, i) => `ghost${i + 100}@example.com`) - // 10 unknown emails → 0 willGrant → 0s estimate - const { summary: zero } = await resolveEmails(db, ten, 200, 50) - expect(zero.estimatedDurationSec).toBe(0) - - // 1 grantable + throttle 50 → ceil(1/50) = 1s - const { summary: one } = await resolveEmails(db, ['normal@example.com'], 200, 50) - expect(one.estimatedDurationSec).toBe(1) - }) -}) - -describe('createFluxGrantBatchService.create', () => { - let db: Database - - beforeAll(async () => { - db = await mockDB(schema) - - await db.insert(schema.user).values([ - { id: 'uid_grant_a', name: 'A', email: 'a@example.com' }, - { id: 'uid_grant_b', name: 'B', email: 'b@example.com' }, - ]) - await db.insert(schema.userFlux).values([ - { userId: 'uid_grant_a', flux: 0 }, - { userId: 'uid_grant_b', flux: 0 }, - ]) - }) - - beforeEach(async () => { - await db.delete(schema.fluxGrantBatchRecipient) - await db.delete(schema.fluxGrantBatch) - }) - - it('persists batch + per-email recipient rows with resolution status', async () => { - const service = createFluxGrantBatchService(db) - const { batch, summary } = await service.create({ - name: 'Test Promo', - amount: 200, - emails: ['a@example.com', 'unknown@example.com'], - createdByUserId: 'uid_admin', - throttlePerSec: 50, - }) - - expect(batch.status).toBe('created') - expect(batch.createdByUserId).toBe('uid_admin') - expect(summary.willGrant).toBe(1) - expect(summary.willSkip.notFound).toBe(1) - - const recipients = await db.select().from(schema.fluxGrantBatchRecipient).where( - eq(schema.fluxGrantBatchRecipient.batchId, batch.id), - ) - expect(recipients).toHaveLength(2) - - const aRecipient = recipients.find(r => r.inputEmail === 'a@example.com')! - expect(aRecipient.status).toBe('pending') - expect(aRecipient.userId).toBe('uid_grant_a') - - const unknownRecipient = recipients.find(r => r.inputEmail === 'unknown@example.com')! - expect(unknownRecipient.status).toBe('skipped') - expect(unknownRecipient.errorReason).toBe('not_found') - expect(unknownRecipient.userId).toBeNull() - }) - - it('returns a stable preview summary on dry-run without persisting anything', async () => { - const service = createFluxGrantBatchService(db) - const before = await db.select().from(schema.fluxGrantBatch) - const summary = await service.preview({ - name: 'Preview Test', - amount: 50, - emails: ['a@example.com', 'b@example.com', 'unknown@example.com'], - throttlePerSec: 50, - }) - expect(summary.willGrant).toBe(2) - expect(summary.willSkip.notFound).toBe(1) - expect(summary.totalFluxToIssue).toBe(100) - const after = await db.select().from(schema.fluxGrantBatch) - expect(after).toHaveLength(before.length) - }) -}) - -describe('createFluxGrantBatchService.retryFailed', () => { - let db: Database - - beforeAll(async () => { - db = await mockDB(schema) - }) - - beforeEach(async () => { - await db.delete(schema.fluxGrantBatchRecipient) - await db.delete(schema.fluxGrantBatch) - }) - - it('moves failed recipients back to pending and reopens completed batches', async () => { - const service = createFluxGrantBatchService(db) - - const [batch] = await db.insert(schema.fluxGrantBatch).values({ - name: 'Retry Test', - type: 'promo', - amount: 100, - status: 'completed', - createdByUserId: 'uid_admin', - completedAt: new Date(), - }).returning() - - await db.insert(schema.fluxGrantBatchRecipient).values([ - { - batchId: batch!.id, - inputEmail: 'failed1@example.com', - userId: 'uid_failed1', - status: 'failed', - attemptCount: 3, - errorReason: 'DB timeout', - lastAttemptedAt: new Date(), - }, - { - batchId: batch!.id, - inputEmail: 'granted@example.com', - userId: 'uid_granted', - status: 'granted', - attemptCount: 1, - }, - ]) - - const result = await service.retryFailed(batch!.id) - expect(result.retriedCount).toBe(1) - - const [reopened] = await db.select().from(schema.fluxGrantBatch).where(eq(schema.fluxGrantBatch.id, batch!.id)) - expect(reopened?.status).toBe('running') - expect(reopened?.completedAt).toBeNull() - - const recipients = await db.select().from(schema.fluxGrantBatchRecipient).where( - eq(schema.fluxGrantBatchRecipient.batchId, batch!.id), - ) - const failedRecipient = recipients.find(r => r.inputEmail === 'failed1@example.com')! - expect(failedRecipient.status).toBe('pending') - expect(failedRecipient.attemptCount).toBe(0) - expect(failedRecipient.errorReason).toBeNull() - expect(failedRecipient.lastAttemptedAt).toBeNull() - }) - - it('is a no-op when no failed recipients exist (idempotent)', async () => { - const service = createFluxGrantBatchService(db) - const [batch] = await db.insert(schema.fluxGrantBatch).values({ - name: 'Idempotent Retry', - type: 'promo', - amount: 100, - status: 'completed', - createdByUserId: 'uid_admin', - }).returning() - - const result = await service.retryFailed(batch!.id) - expect(result.retriedCount).toBe(0) - - const [unchanged] = await db.select().from(schema.fluxGrantBatch).where(eq(schema.fluxGrantBatch.id, batch!.id)) - expect(unchanged?.status).toBe('completed') - }) -}) diff --git a/apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-worker.test.ts b/apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-worker.test.ts deleted file mode 100644 index c5c35d5a7..000000000 --- a/apps/server/src/services/admin-flux-grant-batch/tests/flux-grant-batch-worker.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { backoffMs } from '../flux-grant-batch-worker' - -describe('backoffMs', () => { - it('returns 0 for unattempted recipients', () => { - expect(backoffMs(0)).toBe(0) - expect(backoffMs(-1)).toBe(0) - }) - - it('returns 30s after the first attempt', () => { - expect(backoffMs(1)).toBe(30_000) - }) - - it('returns 5min after the second and subsequent attempts', () => { - expect(backoffMs(2)).toBe(300_000) - expect(backoffMs(5)).toBe(300_000) - }) -}) diff --git a/apps/server/src/services/admin-flux-grants/index.ts b/apps/server/src/services/domain/admin/flux-grants/index.ts similarity index 97% rename from apps/server/src/services/admin-flux-grants/index.ts rename to apps/server/src/services/domain/admin/flux-grants/index.ts index 4f9e693ae..4b4730bea 100644 --- a/apps/server/src/services/admin-flux-grants/index.ts +++ b/apps/server/src/services/domain/admin/flux-grants/index.ts @@ -1,12 +1,12 @@ -import type { Database } from '../../libs/db' -import type { BillingService } from '../billing/billing-service' +import type { Database } from '../../../../libs/db' +import type { BillingService } from '../../billing/billing-service' import { useLogger } from '@guiiai/logg' import { errorMessageFrom } from '@moeru/std' import { inArray } from 'drizzle-orm' -import * as accountsSchema from '../../schemas/accounts' -import * as fluxSchema from '../../schemas/flux' +import * as accountsSchema from '../../../../schemas/accounts' +import * as fluxSchema from '../../../../schemas/flux' const logger = useLogger('admin-flux-grants').useGlobalConfig() diff --git a/apps/server/src/services/admin-flux-grants/tests/admin-flux-grants.test.ts b/apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts similarity index 98% rename from apps/server/src/services/admin-flux-grants/tests/admin-flux-grants.test.ts rename to apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts index 7c7dc39d2..d68e40028 100644 --- a/apps/server/src/services/admin-flux-grants/tests/admin-flux-grants.test.ts +++ b/apps/server/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts @@ -1,13 +1,13 @@ -import type { Database } from '../../../libs/db' -import type { BillingService } from '../../billing/billing-service' +import type { Database } from '../../../../../libs/db' +import type { BillingService } from '../../../billing/billing-service' import { eq } from 'drizzle-orm' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { createAdminFluxGrantsService, resolveEmails } from '..' -import { mockDB } from '../../../libs/mock-db' +import { mockDB } from '../../../../../libs/mock-db' -import * as schema from '../../../schemas' +import * as schema from '../../../../../schemas' describe('resolveEmails', () => { let db: Database diff --git a/apps/server/src/services/admin-router-config/index.ts b/apps/server/src/services/domain/admin/router-config/index.ts similarity index 98% rename from apps/server/src/services/admin-router-config/index.ts rename to apps/server/src/services/domain/admin/router-config/index.ts index a54d8a291..7d8efc96d 100644 --- a/apps/server/src/services/admin-router-config/index.ts +++ b/apps/server/src/services/domain/admin/router-config/index.ts @@ -1,12 +1,12 @@ import type Redis from 'ioredis' import type { InferOutput } from 'valibot' -import type { EnvelopeCrypto } from '../../utils/envelope-crypto' -import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, ttsUpstreamSchema } from '../config-kv' +import type { EnvelopeCrypto } from '../../../../utils/envelope-crypto' +import type { ConfigKVService, llmModelSchema, llmRouterConfigSchema, ttsModelSchema, ttsUpstreamSchema } from '../../../adapters/config-kv' import { useLogger } from '@guiiai/logg' -import { createBadRequestError } from '../../utils/error' +import { createBadRequestError } from '../../../../utils/error' /** * AAD label used when encrypting/decrypting the streaming TTS upstream key. diff --git a/apps/server/src/services/admin-router-config/tests/admin-router-config.test.ts b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts similarity index 99% rename from apps/server/src/services/admin-router-config/tests/admin-router-config.test.ts rename to apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts index 1baa849ae..58810fb06 100644 --- a/apps/server/src/services/admin-router-config/tests/admin-router-config.test.ts +++ b/apps/server/src/services/domain/admin/router-config/tests/admin-router-config.test.ts @@ -1,6 +1,6 @@ import type Redis from 'ioredis' -import type { ConfigKVService } from '../../config-kv' +import type { ConfigKVService } from '../../../../adapters/config-kv' import { randomBytes } from 'node:crypto' @@ -15,7 +15,7 @@ import { createAdminRouterConfigService, redactCiphertext, } from '..' -import { createEnvelopeCrypto } from '../../../utils/envelope-crypto' +import { createEnvelopeCrypto } from '../../../../../utils/envelope-crypto' function freshEnvelope() { return createEnvelopeCrypto({ masterKey: randomBytes(32) }) diff --git a/apps/server/src/services/billing/billing-service.ts b/apps/server/src/services/domain/billing/billing-service.ts similarity index 97% rename from apps/server/src/services/billing/billing-service.ts rename to apps/server/src/services/domain/billing/billing-service.ts index 1b0f804f2..737c2315f 100644 --- a/apps/server/src/services/billing/billing-service.ts +++ b/apps/server/src/services/domain/billing/billing-service.ts @@ -1,18 +1,18 @@ import type Redis from 'ioredis' -import type { Database } from '../../libs/db' -import type { RevenueMetrics } from '../../otel' -import type { ConfigKVService } from '../config-kv' +import type { Database } from '../../../libs/db' +import type { RevenueMetrics } from '../../../otel' +import type { ConfigKVService } from '../../adapters/config-kv' import { useLogger } from '@guiiai/logg' import { and, eq } from 'drizzle-orm' -import { createPaymentRequiredError } from '../../utils/error' -import { userFluxRedisKey } from '../../utils/redis-keys' +import { createPaymentRequiredError } from '../../../utils/error' +import { userFluxRedisKey } from '../../../utils/redis-keys' -import * as fluxSchema from '../../schemas/flux' -import * as fluxTxSchema from '../../schemas/flux-transaction' -import * as stripeSchema from '../../schemas/stripe' +import * as fluxSchema from '../../../schemas/flux' +import * as fluxTxSchema from '../../../schemas/flux-transaction' +import * as stripeSchema from '../../../schemas/stripe' const logger = useLogger('billing-service') diff --git a/apps/server/src/services/billing/billing.ts b/apps/server/src/services/domain/billing/billing.ts similarity index 100% rename from apps/server/src/services/billing/billing.ts rename to apps/server/src/services/domain/billing/billing.ts diff --git a/apps/server/src/services/billing/flux-meter.ts b/apps/server/src/services/domain/billing/flux-meter.ts similarity index 97% rename from apps/server/src/services/billing/flux-meter.ts rename to apps/server/src/services/domain/billing/flux-meter.ts index 585c0c337..b60d6ab4c 100644 --- a/apps/server/src/services/billing/flux-meter.ts +++ b/apps/server/src/services/domain/billing/flux-meter.ts @@ -1,13 +1,13 @@ import type Redis from 'ioredis' -import type { RevenueMetrics } from '../../otel' +import type { RevenueMetrics } from '../../../otel' import type { BillingService } from './billing-service' import { useLogger } from '@guiiai/logg' -import { createPaymentRequiredError } from '../../utils/error' -import { GEN_AI_ATTR_REQUEST_MODEL } from '../../utils/observability' -import { userFluxMeterDebtRedisKey } from '../../utils/redis-keys' +import { createPaymentRequiredError } from '../../../utils/error' +import { GEN_AI_ATTR_REQUEST_MODEL } from '../../../utils/observability' +import { userFluxMeterDebtRedisKey } from '../../../utils/redis-keys' const logger = useLogger('flux-meter') diff --git a/apps/server/src/services/billing/tests/billing-service.test.ts b/apps/server/src/services/domain/billing/tests/billing-service.test.ts similarity index 97% rename from apps/server/src/services/billing/tests/billing-service.test.ts rename to apps/server/src/services/domain/billing/tests/billing-service.test.ts index 5603de353..e607c173d 100644 --- a/apps/server/src/services/billing/tests/billing-service.test.ts +++ b/apps/server/src/services/domain/billing/tests/billing-service.test.ts @@ -1,16 +1,16 @@ import type Redis from 'ioredis' -import type { Database } from '../../../libs/db' -import type { createConfigKVService } from '../../config-kv' +import type { Database } from '../../../../libs/db' +import type { createConfigKVService } from '../../../adapters/config-kv' import { and, eq } from 'drizzle-orm' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { mockDB } from '../../../libs/mock-db' -import { userFluxRedisKey } from '../../../utils/redis-keys' +import { mockDB } from '../../../../libs/mock-db' +import { userFluxRedisKey } from '../../../../utils/redis-keys' import { createBillingService } from '../billing-service' -import * as schema from '../../../schemas' +import * as schema from '../../../../schemas' function createMockConfigKV(overrides: Record = {}): ReturnType { const defaults: Record = { INITIAL_USER_FLUX: 100, FLUX_PER_REQUEST: 1, ...overrides } diff --git a/apps/server/src/services/billing/tests/billing.test.ts b/apps/server/src/services/domain/billing/tests/billing.test.ts similarity index 100% rename from apps/server/src/services/billing/tests/billing.test.ts rename to apps/server/src/services/domain/billing/tests/billing.test.ts diff --git a/apps/server/src/services/billing/tests/flux-meter.test.ts b/apps/server/src/services/domain/billing/tests/flux-meter.test.ts similarity index 100% rename from apps/server/src/services/billing/tests/flux-meter.test.ts rename to apps/server/src/services/domain/billing/tests/flux-meter.test.ts diff --git a/apps/server/src/services/tests/characters.test.ts b/apps/server/src/services/domain/characters.test.ts similarity index 98% rename from apps/server/src/services/tests/characters.test.ts rename to apps/server/src/services/domain/characters.test.ts index 67c618646..c068cefd9 100644 --- a/apps/server/src/services/tests/characters.test.ts +++ b/apps/server/src/services/domain/characters.test.ts @@ -3,7 +3,7 @@ import type { Database } from '../../libs/db' import { beforeAll, describe, expect, it } from 'vitest' import { mockDB } from '../../libs/mock-db' -import { createCharacterService } from '../characters' +import { createCharacterService } from './characters' import * as schema from '../../schemas' diff --git a/apps/server/src/services/characters.ts b/apps/server/src/services/domain/characters.ts similarity index 97% rename from apps/server/src/services/characters.ts rename to apps/server/src/services/domain/characters.ts index 3a68d1ba9..7895c533a 100644 --- a/apps/server/src/services/characters.ts +++ b/apps/server/src/services/domain/characters.ts @@ -1,11 +1,11 @@ -import type { Database } from '../libs/db' -import type { EngagementMetrics } from '../otel' +import type { Database } from '../../libs/db' +import type { EngagementMetrics } from '../../otel' import { useLogger } from '@guiiai/logg' import { and, eq, isNull, or, sql } from 'drizzle-orm' -import * as schema from '../schemas/characters' -import * as userCharacterSchema from '../schemas/user-character' +import * as schema from '../../schemas/characters' +import * as userCharacterSchema from '../../schemas/user-character' const logger = useLogger('characters') diff --git a/apps/server/src/services/tests/chats.test.ts b/apps/server/src/services/domain/chats.test.ts similarity index 95% rename from apps/server/src/services/tests/chats.test.ts rename to apps/server/src/services/domain/chats.test.ts index ffa3babb5..bb770043c 100644 --- a/apps/server/src/services/tests/chats.test.ts +++ b/apps/server/src/services/domain/chats.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { clampLimit, resolveSenderId } from '../chats' +import { clampLimit, resolveSenderId } from './chats' describe('resolveSenderId', () => { it('returns userId for user role', () => { diff --git a/apps/server/src/services/chats.ts b/apps/server/src/services/domain/chats.ts similarity index 98% rename from apps/server/src/services/chats.ts rename to apps/server/src/services/domain/chats.ts index bee371ac4..3b7c1db73 100644 --- a/apps/server/src/services/chats.ts +++ b/apps/server/src/services/domain/chats.ts @@ -1,15 +1,15 @@ import type { MessageRole, WireMessage } from '@proj-airi/server-sdk-shared' -import type { Database } from '../libs/db' -import type { EngagementMetrics } from '../otel' +import type { Database } from '../../libs/db' +import type { EngagementMetrics } from '../../otel' import { useLogger } from '@guiiai/logg' import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm' -import { createForbiddenError, createNotFoundError } from '../utils/error' -import { nanoid } from '../utils/id' +import { createForbiddenError, createNotFoundError } from '../../utils/error' +import { nanoid } from '../../utils/id' -import * as schema from '../schemas/chats' +import * as schema from '../../schemas/chats' const logger = useLogger('chats') diff --git a/apps/server/src/services/tests/flux-transaction.test.ts b/apps/server/src/services/domain/flux-transaction.test.ts similarity index 97% rename from apps/server/src/services/tests/flux-transaction.test.ts rename to apps/server/src/services/domain/flux-transaction.test.ts index 6f941d504..2e886ea9b 100644 --- a/apps/server/src/services/tests/flux-transaction.test.ts +++ b/apps/server/src/services/domain/flux-transaction.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from 'vitest' import { mockDB } from '../../libs/mock-db' -import { createFluxTransactionService } from '../flux-transaction' +import { createFluxTransactionService } from './flux-transaction' import * as schema from '../../schemas' diff --git a/apps/server/src/services/flux-transaction.ts b/apps/server/src/services/domain/flux-transaction.ts similarity index 95% rename from apps/server/src/services/flux-transaction.ts rename to apps/server/src/services/domain/flux-transaction.ts index 4b0cf4e54..d7aaf9cde 100644 --- a/apps/server/src/services/flux-transaction.ts +++ b/apps/server/src/services/domain/flux-transaction.ts @@ -1,9 +1,9 @@ -import type { Database } from '../libs/db' +import type { Database } from '../../libs/db' import { useLogger } from '@guiiai/logg' import { and, desc, eq, inArray } from 'drizzle-orm' -import * as schema from '../schemas/flux-transaction' +import * as schema from '../../schemas/flux-transaction' const logger = useLogger('flux-transaction') diff --git a/apps/server/src/services/tests/flux.test.ts b/apps/server/src/services/domain/flux.test.ts similarity index 96% rename from apps/server/src/services/tests/flux.test.ts rename to apps/server/src/services/domain/flux.test.ts index 173dfcf2e..a8b1ccff2 100644 --- a/apps/server/src/services/tests/flux.test.ts +++ b/apps/server/src/services/domain/flux.test.ts @@ -1,14 +1,14 @@ import type Redis from 'ioredis' import type { Database } from '../../libs/db' -import type { createConfigKVService } from '../config-kv' +import type { createConfigKVService } from '../adapters/config-kv' import { eq } from 'drizzle-orm' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDB } from '../../libs/mock-db' import { userFluxRedisKey } from '../../utils/redis-keys' -import { createFluxService } from '../flux' +import { createFluxService } from './flux' import * as schema from '../../schemas' diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/domain/flux.ts similarity index 93% rename from apps/server/src/services/flux.ts rename to apps/server/src/services/domain/flux.ts index f535a16c0..e47c234a8 100644 --- a/apps/server/src/services/flux.ts +++ b/apps/server/src/services/domain/flux.ts @@ -1,15 +1,15 @@ import type Redis from 'ioredis' -import type { Database } from '../libs/db' -import type { ConfigKVService } from './config-kv' +import type { Database } from '../../libs/db' +import type { ConfigKVService } from '../adapters/config-kv' import { useLogger } from '@guiiai/logg' import { and, eq, isNull } from 'drizzle-orm' -import { userFluxRedisKey } from '../utils/redis-keys' +import { userFluxRedisKey } from '../../utils/redis-keys' -import * as schema from '../schemas/flux' -import * as fluxTxSchema from '../schemas/flux-transaction' +import * as schema from '../../schemas/flux' +import * as fluxTxSchema from '../../schemas/flux-transaction' const logger = useLogger('flux-service') diff --git a/apps/server/src/services/llm-router/config-loader.test.ts b/apps/server/src/services/domain/llm-router/config-loader.test.ts similarity index 97% rename from apps/server/src/services/llm-router/config-loader.test.ts rename to apps/server/src/services/domain/llm-router/config-loader.test.ts index 83f9dac52..0cc1e9184 100644 --- a/apps/server/src/services/llm-router/config-loader.test.ts +++ b/apps/server/src/services/domain/llm-router/config-loader.test.ts @@ -1,9 +1,9 @@ -import type { ConfigKVService } from '../config-kv' +import type { ConfigKVService } from '../../adapters/config-kv' import type { RouterConfig } from './types' import { describe, expect, it, vi } from 'vitest' -import { ApiError } from '../../utils/error' +import { ApiError } from '../../../utils/error' import { createConfigLoader } from './config-loader' function makeConfig(): RouterConfig { diff --git a/apps/server/src/services/llm-router/config-loader.ts b/apps/server/src/services/domain/llm-router/config-loader.ts similarity index 97% rename from apps/server/src/services/llm-router/config-loader.ts rename to apps/server/src/services/domain/llm-router/config-loader.ts index 22e59f776..a4f8316fa 100644 --- a/apps/server/src/services/llm-router/config-loader.ts +++ b/apps/server/src/services/domain/llm-router/config-loader.ts @@ -1,7 +1,7 @@ -import type { ConfigKVService } from '../config-kv' +import type { ConfigKVService } from '../../adapters/config-kv' import type { LlmModel, ModelKind, RouterConfig, TtsModel } from './types' -import { createBadRequestError, createServiceUnavailableError } from '../../utils/error' +import { createBadRequestError, createServiceUnavailableError } from '../../../utils/error' /** * Default TTL for the in-memory config cache. Plan KTD-4 fallback path: diff --git a/apps/server/src/services/llm-router/config-sync-subscriber.ts b/apps/server/src/services/domain/llm-router/config-sync-subscriber.ts similarity index 98% rename from apps/server/src/services/llm-router/config-sync-subscriber.ts rename to apps/server/src/services/domain/llm-router/config-sync-subscriber.ts index 67862c18a..749effa41 100644 --- a/apps/server/src/services/llm-router/config-sync-subscriber.ts +++ b/apps/server/src/services/domain/llm-router/config-sync-subscriber.ts @@ -1,7 +1,7 @@ import type { useLogger } from '@guiiai/logg' import type Redis from 'ioredis' -import type { GatewayMetrics } from '../../otel' +import type { GatewayMetrics } from '../../../otel' import type { LlmRouterService } from './router' /** diff --git a/apps/server/src/services/llm-router/error-mapping.test.ts b/apps/server/src/services/domain/llm-router/error-mapping.test.ts similarity index 98% rename from apps/server/src/services/llm-router/error-mapping.test.ts rename to apps/server/src/services/domain/llm-router/error-mapping.test.ts index ee6c0ee14..b4127aaff 100644 --- a/apps/server/src/services/llm-router/error-mapping.test.ts +++ b/apps/server/src/services/domain/llm-router/error-mapping.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { ApiError } from '../../utils/error' +import { ApiError } from '../../../utils/error' import { mapUpstreamError } from './error-mapping' const exampleContext = { triedKeys: 2, triedUpstreams: 1, lastStatusCode: 401 as const } diff --git a/apps/server/src/services/llm-router/error-mapping.ts b/apps/server/src/services/domain/llm-router/error-mapping.ts similarity index 97% rename from apps/server/src/services/llm-router/error-mapping.ts rename to apps/server/src/services/domain/llm-router/error-mapping.ts index 7c67df848..80e575421 100644 --- a/apps/server/src/services/llm-router/error-mapping.ts +++ b/apps/server/src/services/domain/llm-router/error-mapping.ts @@ -1,6 +1,6 @@ -import type { ApiError } from '../../utils/error' +import type { ApiError } from '../../../utils/error' -import { createBadGatewayError, createGatewayTimeoutError, createInternalError, createServiceUnavailableError } from '../../utils/error' +import { createBadGatewayError, createGatewayTimeoutError, createInternalError, createServiceUnavailableError } from '../../../utils/error' /** * Sanitized context for `mapUpstreamError`. diff --git a/apps/server/src/services/domain/llm-router/index.ts b/apps/server/src/services/domain/llm-router/index.ts new file mode 100644 index 000000000..50c92f585 --- /dev/null +++ b/apps/server/src/services/domain/llm-router/index.ts @@ -0,0 +1,6 @@ +export { createConfigSyncSubscriber } from './config-sync-subscriber' + +export { createLlmRouterService } from './router' +export type { LlmRouterService } from './router' + +export type { LlmModel, TtsModel, TtsUpstream } from './types' diff --git a/apps/server/src/services/llm-router/key-rotator.test.ts b/apps/server/src/services/domain/llm-router/key-rotator.test.ts similarity index 97% rename from apps/server/src/services/llm-router/key-rotator.test.ts rename to apps/server/src/services/domain/llm-router/key-rotator.test.ts index d1c1dd4f6..f3e2da29a 100644 --- a/apps/server/src/services/llm-router/key-rotator.test.ts +++ b/apps/server/src/services/domain/llm-router/key-rotator.test.ts @@ -2,14 +2,14 @@ import type { Buffer } from 'node:buffer' import type { Counter } from '@opentelemetry/api' -import type { GatewayMetrics } from '../../otel' +import type { GatewayMetrics } from '../../../otel' import { randomBytes } from 'node:crypto' import { describe, expect, it, vi } from 'vitest' -import { createEnvelopeCrypto } from '../../utils/envelope-crypto' -import { ApiError } from '../../utils/error' +import { createEnvelopeCrypto } from '../../../utils/envelope-crypto' +import { ApiError } from '../../../utils/error' import { createKeyRotator } from './key-rotator' function freshMasterKey(): Buffer { diff --git a/apps/server/src/services/llm-router/key-rotator.ts b/apps/server/src/services/domain/llm-router/key-rotator.ts similarity index 94% rename from apps/server/src/services/llm-router/key-rotator.ts rename to apps/server/src/services/domain/llm-router/key-rotator.ts index db2bf1292..449c0da5f 100644 --- a/apps/server/src/services/llm-router/key-rotator.ts +++ b/apps/server/src/services/domain/llm-router/key-rotator.ts @@ -1,11 +1,11 @@ import type { Buffer } from 'node:buffer' -import type { GatewayMetrics } from '../../otel' -import type { EnvelopeCrypto } from '../../utils/envelope-crypto' +import type { GatewayMetrics } from '../../../otel' +import type { EnvelopeCrypto } from '../../../utils/envelope-crypto' import { errorMessageFrom } from '@moeru/std' -import { createServiceUnavailableError } from '../../utils/error' +import { createServiceUnavailableError } from '../../../utils/error' /** * Minimal shape of one upstream as needed by the rotator. We do not depend diff --git a/apps/server/src/services/llm-router/router.test.ts b/apps/server/src/services/domain/llm-router/router.test.ts similarity index 99% rename from apps/server/src/services/llm-router/router.test.ts rename to apps/server/src/services/domain/llm-router/router.test.ts index 090a06fce..7bf4a038e 100644 --- a/apps/server/src/services/llm-router/router.test.ts +++ b/apps/server/src/services/domain/llm-router/router.test.ts @@ -2,16 +2,16 @@ import type { Buffer } from 'node:buffer' import type { Counter } from '@opentelemetry/api' -import type { GatewayMetrics } from '../../otel' -import type { ConfigKVService } from '../config-kv' +import type { GatewayMetrics } from '../../../otel' +import type { ConfigKVService } from '../../adapters/config-kv' import type { RouterConfig } from './types' import { randomBytes } from 'node:crypto' import { afterEach, describe, expect, it, vi } from 'vitest' -import { createEnvelopeCrypto } from '../../utils/envelope-crypto' -import { ApiError } from '../../utils/error' +import { createEnvelopeCrypto } from '../../../utils/envelope-crypto' +import { ApiError } from '../../../utils/error' import { createLlmRouterService } from './router' function freshMasterKey(): Buffer { diff --git a/apps/server/src/services/llm-router/router.ts b/apps/server/src/services/domain/llm-router/router.ts similarity index 98% rename from apps/server/src/services/llm-router/router.ts rename to apps/server/src/services/domain/llm-router/router.ts index e33ef38ca..e7affc00a 100644 --- a/apps/server/src/services/llm-router/router.ts +++ b/apps/server/src/services/domain/llm-router/router.ts @@ -1,9 +1,9 @@ import type { Buffer } from 'node:buffer' -import type { GatewayMetrics } from '../../otel' -import type { EnvelopeCrypto } from '../../utils/envelope-crypto' -import type { ConfigKVService } from '../config-kv' -import type { TtsAdapterId, TtsInput } from '../tts-adapters/types' +import type { GatewayMetrics } from '../../../otel' +import type { EnvelopeCrypto } from '../../../utils/envelope-crypto' +import type { ConfigKVService } from '../../adapters/config-kv' +import type { TtsAdapterId, TtsInput } from '../../adapters/tts/types' import type { LlmRouteRequest, LlmUpstream, TtsUpstream } from './types' import { Buffer as NodeBuffer } from 'node:buffer' @@ -11,15 +11,15 @@ import { Buffer as NodeBuffer } from 'node:buffer' import { useLogger } from '@guiiai/logg' import { trace } from '@opentelemetry/api' -import { ApiError } from '../../utils/error' -import { errorMessageFromUnknown } from '../../utils/error-message' +import { ApiError } from '../../../utils/error' +import { errorMessageFromUnknown } from '../../../utils/error-message' import { AIRI_ATTR_GEN_AI_GATEWAY_FALLBACK_DEPTH, AIRI_ATTR_GEN_AI_GATEWAY_KEY_ID, AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_INDEX, AIRI_ATTR_GEN_AI_GATEWAY_UPSTREAM_URL, -} from '../../utils/observability' -import { getAdapter } from '../tts-adapters' +} from '../../../utils/observability' +import { getAdapter } from '../../adapters/tts' import { createConfigLoader } from './config-loader' import { mapUpstreamError } from './error-mapping' import { createKeyRotator } from './key-rotator' diff --git a/apps/server/src/services/llm-router/types.ts b/apps/server/src/services/domain/llm-router/types.ts similarity index 99% rename from apps/server/src/services/llm-router/types.ts rename to apps/server/src/services/domain/llm-router/types.ts index 4aec47627..9a477130f 100644 --- a/apps/server/src/services/llm-router/types.ts +++ b/apps/server/src/services/domain/llm-router/types.ts @@ -15,7 +15,7 @@ import type { llmUpstreamSchema, ttsModelSchema, ttsUpstreamSchema, -} from '../config-kv' +} from '../../adapters/config-kv' /** * Composite router config (the value at `LLM_ROUTER_CONFIG` in configKV). diff --git a/apps/server/src/services/tests/providers.test.ts b/apps/server/src/services/domain/providers.test.ts similarity index 98% rename from apps/server/src/services/tests/providers.test.ts rename to apps/server/src/services/domain/providers.test.ts index 56a3382dd..be5fa0e99 100644 --- a/apps/server/src/services/tests/providers.test.ts +++ b/apps/server/src/services/domain/providers.test.ts @@ -3,7 +3,7 @@ import type { Database } from '../../libs/db' import { beforeAll, describe, expect, it } from 'vitest' import { mockDB } from '../../libs/mock-db' -import { createProviderService } from '../providers' +import { createProviderService } from './providers' import * as schema from '../../schemas' diff --git a/apps/server/src/services/providers.ts b/apps/server/src/services/domain/providers.ts similarity index 98% rename from apps/server/src/services/providers.ts rename to apps/server/src/services/domain/providers.ts index 0ee1d6a56..f24a729a9 100644 --- a/apps/server/src/services/providers.ts +++ b/apps/server/src/services/domain/providers.ts @@ -1,9 +1,9 @@ -import type { Database } from '../libs/db' +import type { Database } from '../../libs/db' import { useLogger } from '@guiiai/logg' import { and, eq, isNull, sql } from 'drizzle-orm' -import * as schema from '../schemas/providers' +import * as schema from '../../schemas/providers' const logger = useLogger('providers') diff --git a/apps/server/src/services/request-log.ts b/apps/server/src/services/domain/request-log.ts similarity index 81% rename from apps/server/src/services/request-log.ts rename to apps/server/src/services/domain/request-log.ts index 36fccc6e4..668f2efb4 100644 --- a/apps/server/src/services/request-log.ts +++ b/apps/server/src/services/domain/request-log.ts @@ -1,6 +1,6 @@ -import type { Database } from '../libs/db' +import type { Database } from '../../libs/db' -import * as schema from '../schemas/llm-request-log' +import * as schema from '../../schemas/llm-request-log' export interface RequestLogEntry { userId: string diff --git a/apps/server/src/services/tests/stripe.test.ts b/apps/server/src/services/domain/stripe.test.ts similarity index 99% rename from apps/server/src/services/tests/stripe.test.ts rename to apps/server/src/services/domain/stripe.test.ts index d1dd9b931..9ec6cea9a 100644 --- a/apps/server/src/services/tests/stripe.test.ts +++ b/apps/server/src/services/domain/stripe.test.ts @@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm' import { beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDB } from '../../libs/mock-db' -import { createStripeService } from '../stripe' +import { createStripeService } from './stripe' import * as schema from '../../schemas' diff --git a/apps/server/src/services/stripe.ts b/apps/server/src/services/domain/stripe.ts similarity index 98% rename from apps/server/src/services/stripe.ts rename to apps/server/src/services/domain/stripe.ts index e3a35bf3e..fc66c2943 100644 --- a/apps/server/src/services/stripe.ts +++ b/apps/server/src/services/domain/stripe.ts @@ -1,12 +1,12 @@ import type Stripe from 'stripe' -import type { Database } from '../libs/db' -import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe' +import type { Database } from '../../libs/db' +import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../../schemas/stripe' import { useLogger } from '@guiiai/logg' import { and, eq, isNull, notInArray } from 'drizzle-orm' -import * as schema from '../schemas/stripe' +import * as schema from '../../schemas/stripe' const logger = useLogger('stripe-service') diff --git a/apps/server/src/services/user-deletion/index.test.ts b/apps/server/src/services/domain/user-deletion/index.test.ts similarity index 100% rename from apps/server/src/services/user-deletion/index.test.ts rename to apps/server/src/services/domain/user-deletion/index.test.ts diff --git a/apps/server/src/services/user-deletion/index.ts b/apps/server/src/services/domain/user-deletion/index.ts similarity index 100% rename from apps/server/src/services/user-deletion/index.ts rename to apps/server/src/services/domain/user-deletion/index.ts diff --git a/apps/server/src/services/user-deletion/service-deletion.test.ts b/apps/server/src/services/domain/user-deletion/service-deletion.test.ts similarity index 98% rename from apps/server/src/services/user-deletion/service-deletion.test.ts rename to apps/server/src/services/domain/user-deletion/service-deletion.test.ts index e88866d17..488bd3efa 100644 --- a/apps/server/src/services/user-deletion/service-deletion.test.ts +++ b/apps/server/src/services/domain/user-deletion/service-deletion.test.ts @@ -1,15 +1,15 @@ -import type { Database } from '../../libs/db' +import type { Database } from '../../../libs/db' import { eq } from 'drizzle-orm' import { beforeAll, describe, expect, it, vi } from 'vitest' -import { mockDB } from '../../libs/mock-db' +import { mockDB } from '../../../libs/mock-db' import { createCharacterService } from '../characters' import { createChatService } from '../chats' import { createFluxService } from '../flux' import { createProviderService } from '../providers' -import * as schema from '../../schemas' +import * as schema from '../../../schemas' function fakeRedis() { const map = new Map() diff --git a/apps/server/src/services/user-deletion/types.ts b/apps/server/src/services/domain/user-deletion/types.ts similarity index 100% rename from apps/server/src/services/user-deletion/types.ts rename to apps/server/src/services/domain/user-deletion/types.ts diff --git a/apps/server/src/services/llm-router/index.ts b/apps/server/src/services/llm-router/index.ts deleted file mode 100644 index 6a47ac3a2..000000000 --- a/apps/server/src/services/llm-router/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { createConfigLoader } from './config-loader' -export type { ConfigLoader, ConfigLoaderOptions, ModelConfigSlice } from './config-loader' - -export { createConfigSyncSubscriber } from './config-sync-subscriber' -export type { ConfigSyncSubscriber, ConfigSyncSubscriberOptions } from './config-sync-subscriber' - -export { mapUpstreamError } from './error-mapping' -export type { RouterErrorCause, UpstreamAttempt, UpstreamErrorContext } from './error-mapping' - -export { createKeyRotator } from './key-rotator' -export type { RotatableUpstream, RotatedKey } from './key-rotator' - -export { createLlmRouterService } from './router' -export type { CreateLlmRouterServiceOptions, LlmRouterService } from './router' - -export type { - FallbackTriggers, - KeyEntry, - LlmModel, - LlmRouteContext, - LlmRouteRequest, - LlmUpstream, - ModelKind, - RouterConfig, - RouterDefaults, - TtsModel, - TtsUpstream, -} from './types' diff --git a/apps/server/src/utils/redis-compressed.ts b/apps/server/src/utils/redis-compressed.ts deleted file mode 100644 index c39b5d87d..000000000 --- a/apps/server/src/utils/redis-compressed.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Buffer } from 'node:buffer' - -import type Redis from 'ioredis' - -import { promisify } from 'node:util' -import { gunzip, gzip } from 'node:zlib' - -const gzipAsync = promisify(gzip) -const gunzipAsync = promisify(gunzip) - -// First two bytes of a gzip stream are 0x1F 0x8B. Used to distinguish gzipped -// payloads from legacy plain-text entries written before compression was -// introduced for a given key. -function isGzipped(buf: Buffer): boolean { - return buf.length >= 2 && buf[0] === 0x1F && buf[1] === 0x8B -} - -/** - * Read a Redis value, transparently gunzipping it when it was written by - * `setCompressed`. Non-gzipped payloads (e.g. pre-compression legacy entries, - * or values set via raw `redis.set`) are returned as plain utf-8 strings so - * callers can migrate existing keys without a forced invalidation. - * - * Use when: value size is large enough (~1KB+) that gzip wins on Redis/network - * bandwidth, and you accept the ~10ms CPU trade per request. Caller still - * handles parse/validate since we only care about bytes in transit. - * - * Returns null on cache miss. - */ -export async function getCompressed(redis: Redis, key: string): Promise { - const cached = await redis.getBuffer(key) - if (cached == null) - return null - - return isGzipped(cached) - ? (await gunzipAsync(cached)).toString('utf8') - : cached.toString('utf8') -} - -/** - * Gzip-compress the value and store under key. Always writes gzipped bytes so - * subsequent `getCompressed` reads never hit the legacy passthrough branch. - * - * Expects: caller has already serialized the value (JSON.stringify, etc.) — - * this helper is deliberately type-free about what the string represents. - */ -export async function setCompressed( - redis: Redis, - key: string, - value: string, - ttlSeconds?: number, -): Promise { - const compressed = await gzipAsync(value) - if (ttlSeconds != null) { - await redis.set(key, compressed, 'EX', ttlSeconds) - } - else { - await redis.set(key, compressed) - } -} diff --git a/apps/server/src/utils/tests/redis-compressed.test.ts b/apps/server/src/utils/tests/redis-compressed.test.ts deleted file mode 100644 index 12ff87f0e..000000000 --- a/apps/server/src/utils/tests/redis-compressed.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { Buffer } from 'node:buffer' -import { promisify } from 'node:util' -import { gzip } from 'node:zlib' - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { getCompressed, setCompressed } from '../redis-compressed' - -const gzipAsync = promisify(gzip) - -function createMockRedis() { - const store = new Map() - return { - getBuffer: vi.fn(async (key: string) => store.get(key) ?? null), - set: vi.fn(async (key: string, value: string | Buffer, _ex?: string, _ttl?: number) => { - store.set(key, Buffer.isBuffer(value) ? value : Buffer.from(value, 'utf8')) - return 'OK' - }), - _store: store, - } -} - -describe('redis-compressed', () => { - let redis: ReturnType - - beforeEach(() => { - redis = createMockRedis() - }) - - describe('setCompressed', () => { - it('writes gzipped bytes under the key', async () => { - await setCompressed(redis as any, 'k', 'hello world'.repeat(100)) - - const stored = redis._store.get('k') - expect(stored).toBeDefined() - expect(stored![0]).toBe(0x1F) - expect(stored![1]).toBe(0x8B) - }) - - it('sets TTL via EX when ttlSeconds is provided', async () => { - await setCompressed(redis as any, 'k', 'payload', 600) - - expect(redis.set).toHaveBeenCalledWith('k', expect.any(Buffer), 'EX', 600) - }) - - it('omits EX when ttlSeconds is undefined', async () => { - await setCompressed(redis as any, 'k', 'payload') - - const [, , ex] = redis.set.mock.calls[0] - expect(ex).toBeUndefined() - }) - - it('round-trips: value written by setCompressed reads back identical via getCompressed', async () => { - const original = JSON.stringify({ voices: Array.from({ length: 50 }, (_, i) => ({ id: `v-${i}` })) }) - await setCompressed(redis as any, 'k', original) - - const read = await getCompressed(redis as any, 'k') - expect(read).toBe(original) - }) - }) - - describe('getCompressed', () => { - it('returns null on cache miss', async () => { - const result = await getCompressed(redis as any, 'missing') - expect(result).toBeNull() - }) - - it('gunzips entries written with gzip magic bytes', async () => { - const text = 'payload'.repeat(50) - const compressed = await gzipAsync(text) - redis._store.set('k', compressed) - - const result = await getCompressed(redis as any, 'k') - expect(result).toBe(text) - }) - - it('returns legacy plain-text entries as utf-8 without attempting gunzip', async () => { - redis._store.set('k', Buffer.from('{"legacy":"value"}', 'utf8')) - - const result = await getCompressed(redis as any, 'k') - expect(result).toBe('{"legacy":"value"}') - }) - }) -}) diff --git a/apps/server/tests/verifications/_harness.ts b/apps/server/tests/verifications/_harness.ts index 7bb2b266a..8e756cb6f 100644 --- a/apps/server/tests/verifications/_harness.ts +++ b/apps/server/tests/verifications/_harness.ts @@ -6,10 +6,10 @@ import { vi } from 'vitest' import { buildApp } from '../../src/app' import { mockDB } from '../../src/libs/mock-db' -import { createAdminFluxGrantsService } from '../../src/services/admin-flux-grants' -import { createBillingService } from '../../src/services/billing/billing-service' -import { createFluxService } from '../../src/services/flux' -import { createUserDeletionService } from '../../src/services/user-deletion' +import { createAdminFluxGrantsService } from '../../src/services/domain/admin/flux-grants' +import { createBillingService } from '../../src/services/domain/billing/billing-service' +import { createFluxService } from '../../src/services/domain/flux' +import { createUserDeletionService } from '../../src/services/domain/user-deletion' import { userFluxRedisKey } from '../../src/utils/redis-keys' import * as schema from '../../src/schemas'