diff --git a/.github/workflows/deploy-cloudflare-workers-dev-server.yml b/.github/workflows/deploy-cloudflare-workers-dev-server.yml index 839321486..27059f7a0 100644 --- a/.github/workflows/deploy-cloudflare-workers-dev-server.yml +++ b/.github/workflows/deploy-cloudflare-workers-dev-server.yml @@ -5,6 +5,8 @@ on: branches: - 'server-dev' + workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true diff --git a/.gitignore b/.gitignore index 83308f1ca..188d39de8 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,4 @@ plugins-development apps/stage-pocket/ios/buildServer.json .ghfs/ +.docs/superpowers diff --git a/apps/server/docs/ai-context/billing-architecture.md b/apps/server/docs/ai-context/billing-architecture.md new file mode 100644 index 000000000..b9e79095e --- /dev/null +++ b/apps/server/docs/ai-context/billing-architecture.md @@ -0,0 +1,111 @@ +# Distributed Billing Plan + +## 架构概述 + +`apps/server` 的计费链采用 **Postgres 作为唯一账本真相源**,Redis 仅作缓存。所有余额变化在 DB 事务内原子完成,同步写入 `flux_ledger`(流水)和 `outbox_events`(事件),通过 Redis Streams 分发给下游 consumer。 + +### 数据模型 + +- **`user_flux`** — 用户余额快照(单行/用户) +- **`flux_ledger`** — append-only 账务流水(type: credit/debit/initial, amount, balanceBefore, balanceAfter, requestId) + - 含 partial unique index `(userId, requestId) WHERE requestId IS NOT NULL`,DB 层幂等防重 +- **`outbox_events`** — 事件暂存,claim-lease 模式分发 +- **`flux_audit_log`** — 用户可见的历史记录 + +### 同步链路(已实现) + +每次余额变化的 DB 事务内: + +1. `SELECT user_flux FOR UPDATE` 锁行 +2. 更新 `user_flux.flux` +3. 写 `flux_ledger` +4. 写 `flux_audit_log` +5. 写 `outbox_events` +6. 事务提交后 best-effort 更新 Redis 缓存 + +### 异步链路(已实现) + +- **outbox-dispatcher** — 轮询 `outbox_events`,发布到 Redis Stream `billing-events` +- **cache-sync-consumer** — 消费 Stream 事件,同步 Redis 缓存(处理 `flux.debited` 和 `flux.credited`) + +### 事件模型 + +Stream: `billing-events` + +| Event Type | 触发场景 | +|---|---| +| `flux.debited` | LLM 请求扣费 | +| `flux.credited` | Stripe 充值、管理员授予 | +| `stripe.checkout.completed` | 一次性支付完成 | +| `llm.request.completed` | LLM 请求结束 | + +### 进程角色 + +通过 `src/bin/run.ts` 分角色启动: + +- `api` — HTTP 服务 +- `outbox-dispatcher` — outbox → Redis Stream +- `cache-sync-consumer` — Redis 缓存同步(处理 `flux.debited` + `flux.credited`) + +## 关键服务 + +### BillingService (`services/billing-service.ts`) + +所有余额写操作的唯一入口: + +- **`debitFlux()`** — 扣费(LLM 请求),事务内:锁行 → 检余额(402) → 更新余额 → ledger → audit → outbox(`flux.debited`) +- **`creditFlux()`** — 通用充值 +- **`creditFluxFromStripeCheckout()`** — Stripe 一次性支付充值,幂等(`fluxCredited` 标志) +- **`creditFluxFromInvoice()`** — Stripe 订阅发票充值,幂等 + +### FluxService (`services/flux.ts`) + +只负责读操作: + +- **`getFlux()`** — Redis cache-aside 读(miss → DB → 填充 Redis),新用户自动初始化 + 写 ledger(type=initial) +- **`updateStripeCustomerId()`** + +### Redis 职责边界 + +Redis **不是**余额真相源,仅用于: + +- `getFlux()` 读缓存(加速,丢失无影响) +- 配置 KV +- WebSocket 广播 +- Redis Streams 事件总线 + +## 实现状态 + +| Phase | 状态 | 关键点 | +|-------|------|--------| +| 1. DB-first 账本 | ✅ 已完成 | `flux_ledger` 表,`SELECT FOR UPDATE` 原子扣减,Redis 降为缓存 | +| 2. Outbox 事件 | ✅ 已完成 | 所有余额变化产生 outbox 事件,debit + credit 均覆盖 | +| 3. Redis Streams | ✅ 已完成 | MQ、dispatcher、worker 全部就位 | +| 4. Stripe 幂等 | ✅ 已完成 | checkout + invoice 事务内幂等检查 | +| 5. LLM 计费优化 | ⚠️ 部分 | 已有 `requestId` 和 DB 事务扣费,待加 tiktoken fallback | +| 6. 部署拆分 | ✅ 已完成 | `bin/run.ts` 三角色启动(api / outbox-dispatcher / cache-sync-consumer) | +| 7. 幂等防重 | ✅ 已完成 | `flux_ledger` partial unique index on `(userId, requestId)` | +| 8. Cache-sync 适配 | ✅ 已完成 | 同时处理 `flux.debited` 和 `flux.credited` 事件 | + +### 已删除 + +- `flux-write-back.ts` — 定时回写补偿机制,不再需要 +- `FluxService.consumeFlux()` / `addFlux()` — 写操作已移至 BillingService +- `llm_request_log.settled` — 无消费者,已移除 +- `billing-consumer` 进程角色 — 空壳(仅 log),已移除;需要账务分析时重新添加 + +## 剩余 TODO + +### Phase 5 完善:LLM 计费精度 + +当前 LLM 扣费在 gateway 未返回 token 用量时使用固定 fallback rate,不精确: + +- [ ] **tiktoken fallback** — gateway 未返回 usage 时,用 tiktoken 从 request messages + response body 自行计算 token 数 +- [x] **消除静默失败** — non-streaming: debit 失败直接抛错阻断响应;streaming: 已发送无法撤回,改为 error 级别日志+记录 requestId 便于追查 + +## 明确不做 + +- 不引入 Kafka / RabbitMQ +- 不拆成多个独立 repo +- 不做预扣模式(无法准确估算 LLM 响应 token 数) +- 中期如角色扩容策略差异大,再考虑拆为 `server-api` / `server-workers` / `server-webhooks` diff --git a/apps/server/drizzle/0002_mean_tigra.sql b/apps/server/drizzle/0002_mean_tigra.sql index 776cd672d..98a30af90 100644 --- a/apps/server/drizzle/0002_mean_tigra.sql +++ b/apps/server/drizzle/0002_mean_tigra.sql @@ -60,7 +60,7 @@ CREATE TABLE "stripe_subscription" ( "status" text NOT NULL, "current_period_start" timestamp, "current_period_end" timestamp, - "cancel_at_period_end" text, + "cancel_at_period_end" boolean, "canceled_at" timestamp, "ended_at" timestamp, "metadata" text, diff --git a/apps/server/drizzle/0005_skinny_leech.sql b/apps/server/drizzle/0005_skinny_leech.sql new file mode 100644 index 000000000..35c819172 --- /dev/null +++ b/apps/server/drizzle/0005_skinny_leech.sql @@ -0,0 +1,45 @@ +CREATE TABLE "flux_ledger" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "type" text NOT NULL, + "amount" integer NOT NULL, + "balance_before" integer NOT NULL, + "balance_after" integer NOT NULL, + "request_id" text, + "description" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "outbox_events" ( + "id" text PRIMARY KEY NOT NULL, + "event_id" text NOT NULL, + "event_type" text NOT NULL, + "aggregate_id" text NOT NULL, + "user_id" text NOT NULL, + "request_id" text, + "schema_version" integer NOT NULL, + "payload" text NOT NULL, + "occurred_at" timestamp NOT NULL, + "available_at" timestamp DEFAULT now() NOT NULL, + "claimed_by" text, + "claim_expires_at" timestamp, + "published_at" timestamp, + "stream_message_id" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "llm_request_log" DROP CONSTRAINT "llm_request_log_user_id_user_id_fk"; +--> statement-breakpoint +ALTER TABLE "messages" ALTER COLUMN "sender_id" DROP NOT NULL;--> statement-breakpoint +ALTER TABLE "messages" ADD COLUMN "seq" integer;--> statement-breakpoint +ALTER TABLE "stripe_checkout_session" ADD COLUMN "flux_credited" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "stripe_invoice" ADD COLUMN "flux_credited" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "flux_ledger" ADD CONSTRAINT "flux_ledger_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "flux_ledger_user_id_idx" ON "flux_ledger" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "flux_ledger_created_at_idx" ON "flux_ledger" USING btree ("created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "flux_ledger_user_request_uniq" ON "flux_ledger" USING btree ("user_id","request_id") WHERE request_id IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "outbox_events_event_id_idx" ON "outbox_events" USING btree ("event_id");--> statement-breakpoint +CREATE INDEX "outbox_events_publish_scan_idx" ON "outbox_events" USING btree ("published_at","available_at","claim_expires_at","created_at");--> statement-breakpoint +CREATE INDEX "outbox_events_claimed_by_idx" ON "outbox_events" USING btree ("claimed_by");--> statement-breakpoint +ALTER TABLE "llm_request_log" DROP COLUMN "settled"; \ No newline at end of file diff --git a/apps/server/drizzle/meta/0005_snapshot.json b/apps/server/drizzle/meta/0005_snapshot.json new file mode 100644 index 000000000..3bd48d8bc --- /dev/null +++ b/apps/server/drizzle/meta/0005_snapshot.json @@ -0,0 +1,2510 @@ +{ + "id": "a5a18f1d-badd-401b-8f55-a4745aade80c", + "prevId": "45bc0dad-65f5-4695-9115-3d7d376b6440", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "characters_creator_id_user_id_fk": { + "name": "characters_creator_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "characters_owner_id_user_id_fk": { + "name": "characters_owner_id_user_id_fk", + "tableFrom": "characters", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_audit_log": { + "name": "flux_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "flux_audit_log_user_id_user_id_fk": { + "name": "flux_audit_log_user_id_user_id_fk", + "tableFrom": "flux_audit_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_ledger": { + "name": "flux_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_ledger_user_id_idx": { + "name": "flux_ledger_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_ledger_created_at_idx": { + "name": "flux_ledger_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_ledger_user_request_uniq": { + "name": "flux_ledger_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "flux_ledger_user_id_user_id_fk": { + "name": "flux_ledger_user_id_user_id_fk", + "tableFrom": "flux_ledger", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_flux_user_id_user_id_fk": { + "name": "user_flux_user_id_user_id_fk", + "tableFrom": "user_flux", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_events": { + "name": "outbox_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stream_message_id": { + "name": "stream_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_events_event_id_idx": { + "name": "outbox_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_events_publish_scan_idx": { + "name": "outbox_events_publish_scan_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_events_claimed_by_idx": { + "name": "outbox_events_claimed_by_idx", + "columns": [ + { + "expression": "claimed_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_provider_configs_owner_id_user_id_fk": { + "name": "user_provider_configs_owner_id_user_id_fk", + "tableFrom": "user_provider_configs", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_checkout_session_user_id_user_id_fk": { + "name": "stripe_checkout_session_user_id_user_id_fk", + "tableFrom": "stripe_checkout_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_customer_user_id_user_id_fk": { + "name": "stripe_customer_user_id_user_id_fk", + "tableFrom": "stripe_customer", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_invoice_user_id_user_id_fk": { + "name": "stripe_invoice_user_id_user_id_fk", + "tableFrom": "stripe_invoice", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "stripe_subscription_user_id_user_id_fk": { + "name": "stripe_subscription_user_id_user_id_fk", + "tableFrom": "stripe_subscription", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_user_id_user_id_fk": { + "name": "user_character_bookmarks_user_id_user_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_user_id_user_id_fk": { + "name": "user_character_likes_user_id_user_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 38fb9d328..b6409295b 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1773421458174, "tag": "0004_bouncy_devos", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1774527361016, + "tag": "0005_skinny_leech", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/server/otel/grafana/dashboards/airi-engagement-cloud.json b/apps/server/otel/grafana/dashboards/airi-engagement-cloud.json new file mode 100644 index 000000000..9680c08e6 --- /dev/null +++ b/apps/server/otel/grafana/dashboards/airi-engagement-cloud.json @@ -0,0 +1,279 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Chat", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 200 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 1 }, + "id": 1, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Chat Syncs (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(chat_sync_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "syncs" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 1 }, + "id": 2, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Messages (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(chat_messages_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "messages" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "reqps" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 }, + "id": 3, + "title": "Chat Sync Rate by Type", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (chat_type) (rate(chat_sync_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "{{chat_type}}" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, + "id": 4, + "title": "Message Volume Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(chat_messages_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "messages/s" + } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 17 }, + "id": 101, + "title": "Characters", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 50 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 18 }, + "id": 5, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Characters Created (24h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(character_created_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "created" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 18 }, + "id": 6, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Characters Deleted (24h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(character_deleted_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "deleted" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "stacking": { "mode": "normal" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 }, + "id": 7, + "title": "Engagement Actions", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (action) (rate(character_engagement_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "{{action}}" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 }, + "id": 8, + "title": "Character Creation Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(character_created_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "created/s" + } + ] + } + ], + "schemaVersion": 39, + "tags": ["airi", "engagement", "grafana-cloud"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "production", "value": "production" }, + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "definition": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "hide": 0, + "includeAll": true, + "multi": false, + "name": "env", + "query": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "refresh": 2, + "type": "query" + }, + { + "current": { "selected": false, "text": "server", "value": "server" }, + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "definition": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "service", + "query": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "refresh": 2, + "type": "query" + } + ] + }, + "time": { "from": "now-24h", "to": "now" }, + "title": "AIRI Engagement (Cloud)", + "uid": "airi-engagement-cloud" +} diff --git a/apps/server/otel/grafana/dashboards/airi-revenue-cloud.json b/apps/server/otel/grafana/dashboards/airi-revenue-cloud.json new file mode 100644 index 000000000..d25b31143 --- /dev/null +++ b/apps/server/otel/grafana/dashboards/airi-revenue-cloud.json @@ -0,0 +1,319 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Stripe", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 50 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 6, "x": 0, "y": 1 }, + "id": 1, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Checkouts Created (24h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(stripe_checkout_created_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "created" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 30 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 6, "x": 6, "y": 1 }, + "id": 2, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Checkouts Completed (24h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(stripe_checkout_completed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "completed" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 1 }, + { "color": "red", "value": 5 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 1 }, + "id": 3, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Payment Failures (24h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(stripe_payment_failed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "failures" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "red", "value": null }, + { "color": "yellow", "value": 0.5 }, + { "color": "green", "value": 0.8 } + ] + }, + "unit": "percentunit" + } + }, + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 1 }, + "id": 4, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Conversion Rate", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(stripe_checkout_completed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h])) / sum(increase(stripe_checkout_created_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "conversion" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 9 }, + "id": 5, + "title": "Checkout Funnel Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(stripe_checkout_created_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "created/s" + }, + { + "expr": "sum(rate(stripe_checkout_completed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "completed/s" + }, + { + "expr": "sum(rate(stripe_payment_failed_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "failed/s" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "stacking": { "mode": "normal" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 9 }, + "id": 6, + "title": "Subscription Events", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (event_type) (rate(stripe_subscription_event_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "{{event_type}}" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 17 }, + "id": 7, + "title": "Stripe Events by Type", + "type": "timeseries", + "targets": [ + { + "expr": "sum by (event_type) (rate(stripe_events_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "{{event_type}}" + } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 25 }, + "id": 101, + "title": "Flux", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 26 }, + "id": 8, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Insufficient Balance (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(flux_insufficient_balance_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "insufficient" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 16, "x": 8, "y": 26 }, + "id": 9, + "title": "Insufficient Balance Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(flux_insufficient_balance_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "insufficient/s" + } + ] + } + ], + "schemaVersion": 39, + "tags": ["airi", "revenue", "stripe", "grafana-cloud"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "production", "value": "production" }, + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "definition": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "hide": 0, + "includeAll": true, + "multi": false, + "name": "env", + "query": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "refresh": 2, + "type": "query" + }, + { + "current": { "selected": false, "text": "server", "value": "server" }, + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "definition": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "service", + "query": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "refresh": 2, + "type": "query" + } + ] + }, + "time": { "from": "now-7d", "to": "now" }, + "title": "AIRI Revenue (Cloud)", + "uid": "airi-revenue-cloud" +} diff --git a/apps/server/otel/grafana/dashboards/airi-user-auth-cloud.json b/apps/server/otel/grafana/dashboards/airi-user-auth-cloud.json new file mode 100644 index 000000000..11a34af48 --- /dev/null +++ b/apps/server/otel/grafana/dashboards/airi-user-auth-cloud.json @@ -0,0 +1,421 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 100, + "title": "Registration", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 10 }, + { "color": "red", "value": 50 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 1 }, + "id": 1, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, + "orientation": "auto", + "textMode": "auto", + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto" + }, + "title": "New Users (24h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(user_registered_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[24h]))", + "legendFormat": "new users" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 1 }, + "id": 2, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "New Users (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(user_registered_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "new users" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.1 }, + { "color": "red", "value": 1 } + ] + }, + "unit": "reqps" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 1 }, + "id": 3, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Registration Rate", + "type": "stat", + "targets": [ + { + "expr": "sum(rate(user_registered_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "reg/s" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 9 }, + "id": 4, + "title": "Registrations Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(increase(user_registered_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "new users" + } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 17 }, + "id": 101, + "title": "Sessions", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 200 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 18 }, + "id": 5, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Active Sessions", + "type": "stat", + "targets": [ + { + "expr": "sum(user_active_sessions{service_name=~\"$service\", deployment_environment=~\"$env\"})", + "legendFormat": "active" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 50 }, + { "color": "red", "value": 200 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 18 }, + "id": 6, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Logins (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(user_login_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "logins" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 26 }, + "id": 7, + "title": "Login Rate Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(user_login_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "logins/s" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 26 }, + "id": 8, + "title": "Active Sessions Over Time", + "type": "timeseries", + "targets": [ + { + "expr": "sum(user_active_sessions{service_name=~\"$service\", deployment_environment=~\"$env\"})", + "legendFormat": "active sessions" + } + ] + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 34 }, + "id": 102, + "title": "Auth Health", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 100 }, + { "color": "red", "value": 500 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 0, "y": 35 }, + "id": 9, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Auth Attempts (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(auth_attempts_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "attempts" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 5 }, + { "color": "red", "value": 20 } + ] + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 8, "y": 35 }, + "id": 10, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Auth Failures (1h)", + "type": "stat", + "targets": [ + { + "expr": "sum(increase(auth_failures_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[1h]))", + "legendFormat": "failures" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "thresholds": { + "mode": "absolute", + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 0.05 }, + { "color": "red", "value": 0.2 } + ] + }, + "unit": "percentunit" + } + }, + "gridPos": { "h": 8, "w": 8, "x": 16, "y": 35 }, + "id": 11, + "options": { + "reduceOptions": { "calcs": ["lastNotNull"] }, + "colorMode": "value", + "graphMode": "area" + }, + "title": "Auth Failure Rate", + "type": "stat", + "targets": [ + { + "expr": "sum(rate(auth_failures_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval])) / sum(rate(auth_attempts_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "failure rate" + } + ] + }, + { + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "fillOpacity": 20, + "stacking": { "mode": "none" } + }, + "unit": "short" + } + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 43 }, + "id": 12, + "title": "Auth Trends", + "type": "timeseries", + "targets": [ + { + "expr": "sum(rate(auth_attempts_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "attempts/s" + }, + { + "expr": "sum(rate(auth_failures_total{service_name=~\"$service\", deployment_environment=~\"$env\"}[$__rate_interval]))", + "legendFormat": "failures/s" + } + ] + } + ], + "schemaVersion": 39, + "tags": ["airi", "users", "auth", "grafana-cloud"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "production", "value": "production" }, + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "definition": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "hide": 0, + "includeAll": true, + "multi": false, + "name": "env", + "query": "label_values(http_server_request_duration_milliseconds_count, deployment_environment)", + "refresh": 2, + "type": "query" + }, + { + "current": { "selected": false, "text": "server", "value": "server" }, + "datasource": { "type": "prometheus", "uid": "grafanacloud-projairi-prom" }, + "definition": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "hide": 0, + "includeAll": true, + "multi": true, + "name": "service", + "query": "label_values(http_server_request_duration_milliseconds_count{deployment_environment=~\"$env\"}, service_name)", + "refresh": 2, + "type": "query" + } + ] + }, + "time": { "from": "now-24h", "to": "now" }, + "title": "AIRI User & Auth (Cloud)", + "uid": "airi-user-auth-cloud" +} diff --git a/apps/server/package.json b/apps/server/package.json index d5ec687af..c5204e37d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -6,49 +6,54 @@ "scripts": { "apply:env": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE", "auth:generate": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/accounts.ts -y", - "dev": "pnpm run apply:env -- tsx --watch src/app.ts", - "start": "tsx src/app.ts", + "dev": "pnpm run apply:env -- tsx --watch src/bin/run.ts api", + "start": "pnpm run apply:env -- tsx src/bin/run.ts api", + "server": "pnpm run apply:env -- tsx src/bin/run.ts", "typecheck": "tsc --noEmit", "db:generate": "drizzle-kit generate", "db:push": "pnpm run apply:env -- drizzle-kit push" }, "dependencies": { - "@better-auth/drizzle-adapter": "catalog:", + "@better-auth/drizzle-adapter": "^1.5.6", "@dotenvx/dotenvx": "^1.57.2", "@electric-sql/pglite": "catalog:", "@guiiai/logg": "catalog:", "@hono/node-server": "^1.19.11", - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-logs-otlp-proto": "^0.213.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0", - "@opentelemetry/instrumentation-http": "^0.213.0", - "@opentelemetry/instrumentation-ioredis": "^0.61.0", - "@opentelemetry/instrumentation-pg": "^0.65.0", - "@opentelemetry/instrumentation-runtime-node": "^0.26.0", - "@opentelemetry/resources": "^2.6.0", - "@opentelemetry/sdk-logs": "^0.213.0", - "@opentelemetry/sdk-metrics": "^2.6.0", - "@opentelemetry/sdk-node": "^0.213.0", - "@opentelemetry/sdk-trace-node": "^2.6.0", + "@hono/node-ws": "catalog:", + "@moeru/eventa": "catalog:", + "@moeru/std": "catalog:", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-logs-otlp-proto": "^0.214.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.214.0", + "@opentelemetry/instrumentation-http": "^0.214.0", + "@opentelemetry/instrumentation-ioredis": "^0.62.0", + "@opentelemetry/instrumentation-pg": "^0.66.0", + "@opentelemetry/instrumentation-runtime-node": "^0.27.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-logs": "^0.214.0", + "@opentelemetry/sdk-metrics": "^2.6.1", + "@opentelemetry/sdk-node": "^0.214.0", + "@opentelemetry/sdk-trace-node": "^2.6.1", "@opentelemetry/semantic-conventions": "^1.40.0", - "@proj-airi/drizzle-orm-browser-migrator": "catalog:", + "@proj-airi/drizzle-orm-browser-migrator": "^0.1.6", "@proj-airi/server-schema": "workspace:*", + "@proj-airi/server-sdk": "workspace:", "better-auth": "^1.5.6", - "drizzle-orm": "catalog:", + "drizzle-orm": "^0.45.1", "drizzle-valibot": "catalog:", "hono": "catalog:", + "hono-rate-limiter": "catalog:", "injeca": "catalog:", - "ioredis": "^5.10.0", + "ioredis": "^5.10.1", "pg": "^8.20.0", - "postgres": "^3.4.8", - "stripe": "^20.4.0", + "stripe": "^21.0.0", "tsx": "^4.21.0", "valibot": "catalog:" }, "devDependencies": { "@better-auth/cli": "^1.4.21", "@types/pg": "^8.20.0", - "drizzle-kit": "catalog:" + "drizzle-kit": "^0.31.10" } } diff --git a/apps/server/src/api/chats.schema.ts b/apps/server/src/api/chats.schema.ts index 0a3961aad..ebd94aeee 100644 --- a/apps/server/src/api/chats.schema.ts +++ b/apps/server/src/api/chats.schema.ts @@ -1,4 +1,4 @@ -import { array, literal, number, object, optional, string, union } from 'valibot' +import { array, literal, maxLength, minLength, object, optional, pipe, string, union } from 'valibot' const ChatTypeSchema = union([ literal('private'), @@ -13,33 +13,23 @@ const ChatMemberTypeSchema = union([ literal('bot'), ]) -const ChatMessageRoleSchema = union([ - literal('system'), - literal('user'), - literal('assistant'), - literal('tool'), - literal('error'), -]) - -export const ChatSyncMessageSchema = object({ - id: string(), - role: ChatMessageRoleSchema, - content: string(), - createdAt: optional(number()), -}) - -export const ChatSyncSchema = object({ - chat: object({ - id: string(), - type: optional(ChatTypeSchema), - title: optional(string()), - createdAt: optional(number()), - updatedAt: optional(number()), - }), +export const CreateChatSchema = object({ + id: optional(pipe(string(), minLength(1), maxLength(30))), + type: optional(ChatTypeSchema), + title: optional(string()), members: optional(array(object({ type: ChatMemberTypeSchema, userId: optional(string()), characterId: optional(string()), }))), - messages: array(ChatSyncMessageSchema), +}) + +export const UpdateChatSchema = object({ + title: optional(string()), +}) + +export const AddMemberSchema = object({ + type: ChatMemberTypeSchema, + userId: optional(string()), + characterId: optional(string()), }) diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 79044f18d..6cbd28f60 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,10 +1,14 @@ +import type Redis from 'ioredis' + import type { Env } from './libs/env' +import type { OtelInstance } from './libs/otel' import type { HonoEnv } from './types/hono' import process from 'node:process' import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg' import { serve } from '@hono/node-server' +import { createNodeWebSocket } from '@hono/node-ws' import { Hono } from 'hono' import { bodyLimit } from 'hono/body-limit' import { cors } from 'hono/cors' @@ -18,21 +22,25 @@ import { initOtel } from './libs/otel' import { createRedis } from './libs/redis' import { sessionMiddleware } from './middlewares/auth' import { otelMiddleware } from './middlewares/otel' +import { rateLimiter } from './middlewares/rate-limit' import { createCharacterRoutes } from './routes/characters' +import { createChatWsHandlers } from './routes/chat-ws' import { createChatRoutes } from './routes/chats' import { createFluxRoutes } from './routes/flux' import { createProviderRoutes } from './routes/providers' import { createStripeRoutes } from './routes/stripe' import { createV1CompletionsRoutes } from './routes/v1completions' +import { createBillingService } from './services/billing-service' import { createCharacterService } from './services/characters' import { createChatService } from './services/chats' import { createConfigKVService } from './services/config-kv' import { createFluxService } from './services/flux' import { createFluxAuditService } from './services/flux-audit' +import { createOutboxService } from './services/outbox-service' import { createProviderService } from './services/providers' import { createRequestLogService } from './services/request-log' import { createStripeService } from './services/stripe' -import { ApiError, createInternalError } from './utils/error' +import { ApiError, createInternalError, createUnauthorizedError } from './utils/error' import { getTrustedOrigin } from './utils/origin' type AuthService = ReturnType @@ -41,11 +49,10 @@ type ChatService = ReturnType type ProviderService = ReturnType type FluxService = ReturnType type ConfigKVService = ReturnType -type FluxAuditService = ReturnType type RequestLogService = ReturnType type StripeDBService = ReturnType - -type OtelMetrics = ReturnType +type FluxAuditService = ReturnType +type BillingService = ReturnType interface AppDeps { auth: AuthService @@ -56,9 +63,11 @@ interface AppDeps { fluxAuditService: FluxAuditService requestLogService: RequestLogService stripeService: StripeDBService + billingService: BillingService configKV: ConfigKVService + redis: Redis env: Env - otel: OtelMetrics | null + otel: OtelInstance | null } function buildApp({ @@ -70,7 +79,9 @@ function buildApp({ fluxAuditService, requestLogService, stripeService, + billingService, configKV, + redis, env, otel, }: AppDeps) { @@ -87,10 +98,28 @@ function buildApp({ .use(honoLogger()) if (otel) { - app.use('*', otelMiddleware(otel)) + app.use('*', otelMiddleware(otel.http)) } - return app + // WebSocket setup — must be registered BEFORE bodyLimit middleware + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) + const chatWsSetup = createChatWsHandlers(chatService, redis, otel?.engagement ?? null) + + app.get('/ws/chat', upgradeWebSocket(async (c) => { + const token = c.req.query('token') + if (!token) { + throw createUnauthorizedError('Missing token') + } + const session = await auth.api.getSession({ + headers: new Headers({ Authorization: `Bearer ${token}` }), + }) + if (!session?.user) { + throw createUnauthorizedError('Invalid token') + } + return chatWsSetup(session.user.id) + })) + + const builtApp = app .use('*', sessionMiddleware(auth)) .use('*', bodyLimit({ maxSize: 1024 * 1024 })) .onError((err, c) => { @@ -120,7 +149,13 @@ function buildApp({ /** * Auth routes are handled by the auth instance directly, * Powered by better-auth. + * Rate limited by IP: 20 requests per minute. */ + .use('/api/auth/*', rateLimiter({ + max: 20, + windowSec: 60, + keyGenerator: c => c.req.header('x-forwarded-for') ?? c.req.header('x-real-ip') ?? 'unknown', + })) .on(['POST', 'GET'], '/api/auth/*', c => auth.handler(c.req.raw)) /** @@ -141,7 +176,7 @@ function buildApp({ /** * V1 routes for official provider. */ - .route('/api/v1', createV1CompletionsRoutes(fluxService, configKV, requestLogService, otel)) + .route('/api/v1', createV1CompletionsRoutes(fluxService, billingService, configKV, requestLogService, otel?.llm ?? null)) /** * Flux routes. @@ -151,12 +186,14 @@ function buildApp({ /** * Stripe routes. */ - .route('/api/stripe', createStripeRoutes(fluxService, stripeService, configKV, env)) + .route('/api/stripe', createStripeRoutes(fluxService, stripeService, billingService, configKV, env, otel?.revenue)) + + return { app: builtApp, injectWebSocket } } -export type AppType = ReturnType +export type AppType = ReturnType['app'] -async function createApp() { +export async function createApp() { initLogger(LoggerLevel.Debug, LoggerFormat.Pretty) injeca.setLogger(createLoggLogger(useLogger('injeca').useGlobalConfig())) const logger = useLogger('app').useGlobalConfig() @@ -202,14 +239,19 @@ async function createApp() { build: ({ dependsOn }) => createConfigKVService(dependsOn.redis), }) + const outboxService = injeca.provide('services:outbox', { + dependsOn: { db }, + build: ({ dependsOn }) => createOutboxService(dependsOn.db), + }) + const auth = injeca.provide('services:auth', { - dependsOn: { db, env: parsedEnv }, - build: ({ dependsOn }) => createAuth(dependsOn.db, dependsOn.env), + dependsOn: { db, env: parsedEnv, otel }, + build: ({ dependsOn }) => createAuth(dependsOn.db, dependsOn.env, dependsOn.otel?.auth), }) const characterService = injeca.provide('services:characters', { - dependsOn: { db }, - build: ({ dependsOn }) => createCharacterService(dependsOn.db), + dependsOn: { db, otel }, + build: ({ dependsOn }) => createCharacterService(dependsOn.db, dependsOn.otel?.engagement), }) const providerService = injeca.provide('services:providers', { @@ -218,8 +260,8 @@ async function createApp() { }) const chatService = injeca.provide('services:chats', { - dependsOn: { db }, - build: ({ dependsOn }) => createChatService(dependsOn.db), + dependsOn: { db, otel }, + build: ({ dependsOn }) => createChatService(dependsOn.db, dependsOn.otel?.engagement), }) const stripeService = injeca.provide('services:stripe', { @@ -233,8 +275,8 @@ async function createApp() { }) const fluxService = injeca.provide('services:flux', { - dependsOn: { db, redis, configKV, fluxAuditService }, - build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV, dependsOn.fluxAuditService), + dependsOn: { db, redis, configKV }, + build: ({ dependsOn }) => createFluxService(dependsOn.db, dependsOn.redis, dependsOn.configKV), }) const requestLogService = injeca.provide('services:requestLog', { @@ -242,8 +284,14 @@ async function createApp() { build: ({ dependsOn }) => createRequestLogService(dependsOn.db), }) + const billingService = injeca.provide('services:billing', { + dependsOn: { db, redis, outboxService, configKV, otel }, + build: ({ dependsOn }) => createBillingService(dependsOn.db, dependsOn.redis, dependsOn.outboxService, dependsOn.configKV, dependsOn.otel?.revenue), + }) + await injeca.start() const resolved = await injeca.resolve({ + db, auth, characterService, chatService, @@ -252,11 +300,13 @@ async function createApp() { fluxAuditService, requestLogService, stripeService, + billingService, configKV, + redis, env: parsedEnv, otel, }) - const app = buildApp({ + const { app, injectWebSocket } = buildApp({ auth: resolved.auth, characterService: resolved.characterService, chatService: resolved.chatService, @@ -265,7 +315,9 @@ async function createApp() { fluxAuditService: resolved.fluxAuditService, requestLogService: resolved.requestLogService, stripeService: resolved.stripeService, + billingService: resolved.billingService, configKV: resolved.configKV, + redis: resolved.redis, env: resolved.env, otel: resolved.otel, }) @@ -273,18 +325,27 @@ async function createApp() { logger.withFields({ hostname: resolved.env.HOST, port: resolved.env.PORT }).log('Server started') return { - ...app, + app, + injectWebSocket, port: Number(resolved.env.PORT), hostname: resolved.env.HOST, - } satisfies Parameters[0] + } } -// eslint-disable-next-line antfu/no-top-level-await -serve(await createApp()) - -function handleError(error: unknown, type: string) { +function handleProcessError(error: unknown, type: string) { useLogger().withError(error).error(type) } -process.on('uncaughtException', error => handleError(error, 'Uncaught exception')) -process.on('unhandledRejection', error => handleError(error, 'Unhandled rejection')) +export async function runApiServer(): Promise { + const { app: honoApp, injectWebSocket, port, hostname } = await createApp() + const server = serve({ fetch: honoApp.fetch, port, hostname }) + injectWebSocket(server) + + process.on('uncaughtException', error => handleProcessError(error, 'Uncaught exception')) + process.on('unhandledRejection', error => handleProcessError(error, 'Unhandled rejection')) + + await new Promise((resolve, reject) => { + server.once('close', () => resolve()) + server.once('error', error => reject(error)) + }) +} diff --git a/apps/server/src/bin/__test__/run-billing-events-consumer.test.ts b/apps/server/src/bin/__test__/run-billing-events-consumer.test.ts new file mode 100644 index 000000000..60b86d310 --- /dev/null +++ b/apps/server/src/bin/__test__/run-billing-events-consumer.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { handleCacheSyncMessage } from '../run-billing-events-consumer' + +describe('handleCacheSyncMessage', () => { + it('updates the flux cache when a balance event includes balanceAfter', async () => { + const redis = { + set: vi.fn(async () => 'OK'), + } + + await handleCacheSyncMessage({ + streamMessageId: '1740000000000-0', + event: { + eventId: 'evt-1', + eventType: 'flux.credited', + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 10, + balanceAfter: 110, + source: 'stripe.checkout.completed', + }, + }, + }, redis as any) + + expect(redis.set).toHaveBeenCalledWith('flux:user-1', '110') + }) + + it('ignores non-balance events', async () => { + const redis = { + set: vi.fn(async () => 'OK'), + } + + await handleCacheSyncMessage({ + streamMessageId: '1740000000000-1', + event: { + eventId: 'evt-2', + eventType: 'stripe.checkout.completed', + aggregateId: 'sess-1', + userId: 'user-1', + requestId: 'req-2', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + stripeEventId: 'stripe-evt-1', + stripeSessionId: 'sess-1', + amount: 500, + currency: 'usd', + }, + }, + }, redis as any) + + expect(redis.set).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/bin/__test__/run.test.ts b/apps/server/src/bin/__test__/run.test.ts new file mode 100644 index 000000000..4920b5b82 --- /dev/null +++ b/apps/server/src/bin/__test__/run.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' + +import { getServerCliHelpText, parseServerRole } from '../run' + +describe('server cli', () => { + it('parses supported roles', () => { + expect(parseServerRole(['api'])).toBe('api') + expect(parseServerRole(['cache-sync-consumer'])).toBe('cache-sync-consumer') + expect(parseServerRole(['outbox-dispatcher'])).toBe('outbox-dispatcher') + }) + + it('returns null for unsupported or missing roles', () => { + expect(parseServerRole([])).toBeNull() + expect(parseServerRole(['unknown'])).toBeNull() + }) + + it('returns help text that lists all supported roles', () => { + expect(getServerCliHelpText()).toContain('Usage: server ') + expect(getServerCliHelpText()).toContain('api') + expect(getServerCliHelpText()).toContain('cache-sync-consumer') + expect(getServerCliHelpText()).toContain('outbox-dispatcher') + }) +}) diff --git a/apps/server/src/bin/run-billing-events-consumer.ts b/apps/server/src/bin/run-billing-events-consumer.ts new file mode 100644 index 000000000..4efc45ccf --- /dev/null +++ b/apps/server/src/bin/run-billing-events-consumer.ts @@ -0,0 +1,97 @@ +import type { BillingStreamMessage } from '../services/billing-mq' + +import { pid } from 'node:process' + +import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg' + +import { parseEnv } from '../libs/env' +import { createRedis } from '../libs/redis' +import { createBillingMqService } from '../services/billing-mq' +import { createBillingMqWorker } from '../services/billing-mq-worker' +import { fluxRedisKey } from '../services/flux' + +function parsePositiveInteger(rawValue: string, envKey: string): number { + const parsed = Number(rawValue) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${envKey} must be a positive integer`) + } + + return parsed +} + +export interface RunBillingEventsConsumerOptions { + group: string + loggerName: string + handleMessage?: (message: BillingStreamMessage, redis: ReturnType) => Promise +} + +export async function runBillingEventsConsumer(options: RunBillingEventsConsumerOptions): Promise { + initLogger(LoggerLevel.Debug, LoggerFormat.Pretty) + + const env = parseEnv(globalThis.process.env) + const logger = useLogger(options.loggerName).useGlobalConfig() + const redis = createRedis(env.REDIS_URL) + + await redis.connect() + + const abortController = new AbortController() + const consumer = env.BILLING_EVENTS_CONSUMER_NAME ?? `${options.group}-${pid}` + + const shutdown = async (signalName: string) => { + if (abortController.signal.aborted) { + return + } + + logger.withFields({ signalName }).log('Stopping billing MQ consumer') + abortController.abort() + } + + globalThis.process.once('SIGINT', () => { + void shutdown('SIGINT') + }) + globalThis.process.once('SIGTERM', () => { + void shutdown('SIGTERM') + }) + + try { + const mq = createBillingMqService(redis, { + stream: env.BILLING_EVENTS_STREAM, + }) + + const worker = createBillingMqWorker(mq) + const handleMessage = options.handleMessage ?? (async (message: BillingStreamMessage) => { + logger.withFields({ + group: options.group, + consumer, + eventId: message.event.eventId, + eventType: message.event.eventType, + aggregateId: message.event.aggregateId, + userId: message.event.userId, + streamMessageId: message.streamMessageId, + }).log('Consumed billing MQ event') + }) + + await worker.run({ + group: options.group, + consumer, + signal: abortController.signal, + batchSize: parsePositiveInteger(env.BILLING_EVENTS_BATCH_SIZE, 'BILLING_EVENTS_BATCH_SIZE'), + blockMs: parsePositiveInteger(env.BILLING_EVENTS_BLOCK_MS, 'BILLING_EVENTS_BLOCK_MS'), + minIdleTimeMs: parsePositiveInteger(env.BILLING_EVENTS_MIN_IDLE_MS, 'BILLING_EVENTS_MIN_IDLE_MS'), + onMessage: message => handleMessage(message, redis), + }) + } + finally { + await redis.quit() + } +} + +export async function handleCacheSyncMessage( + message: BillingStreamMessage, + redis: ReturnType, +): Promise { + if ((message.event.eventType === 'flux.credited' || message.event.eventType === 'flux.debited') + && message.event.payload.balanceAfter != null) { + await redis.set(fluxRedisKey(message.event.userId), String(message.event.payload.balanceAfter)) + } +} diff --git a/apps/server/src/bin/run-outbox-dispatcher.ts b/apps/server/src/bin/run-outbox-dispatcher.ts new file mode 100644 index 000000000..b04571663 --- /dev/null +++ b/apps/server/src/bin/run-outbox-dispatcher.ts @@ -0,0 +1,67 @@ +import { pid } from 'node:process' + +import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg' + +import { createDrizzle, migrateDatabase } from '../libs/db' +import { parseEnv } from '../libs/env' +import { createRedis } from '../libs/redis' +import { createBillingMqService } from '../services/billing-mq' +import { createOutboxDispatcher } from '../services/outbox-dispatcher' +import { createOutboxService } from '../services/outbox-service' + +function parsePositiveInteger(rawValue: string, envKey: string): number { + const parsed = Number(rawValue) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${envKey} must be a positive integer`) + } + + return parsed +} + +export async function runOutboxDispatcher(): Promise { + initLogger(LoggerLevel.Debug, LoggerFormat.Pretty) + + const env = parseEnv(globalThis.process.env) + const logger = useLogger('outbox-dispatcher').useGlobalConfig() + const { db, pool } = createDrizzle(env.DATABASE_URL) + const redis = createRedis(env.REDIS_URL) + + await db.execute('SELECT 1') + await migrateDatabase(db) + await redis.connect() + + const abortController = new AbortController() + const claimedBy = env.OUTBOX_DISPATCHER_NAME ?? `outbox-dispatcher-${pid}` + + const shutdown = (signalName: string) => { + if (abortController.signal.aborted) { + return + } + + logger.withFields({ signalName }).log('Stopping outbox dispatcher') + abortController.abort() + } + + globalThis.process.once('SIGINT', () => shutdown('SIGINT')) + globalThis.process.once('SIGTERM', () => shutdown('SIGTERM')) + + try { + const outboxService = createOutboxService(db) + const billingMqService = createBillingMqService(redis, { + stream: env.BILLING_EVENTS_STREAM, + }) + const dispatcher = createOutboxDispatcher(outboxService, billingMqService) + + await dispatcher.run({ + claimedBy, + signal: abortController.signal, + batchSize: parsePositiveInteger(env.OUTBOX_DISPATCHER_BATCH_SIZE, 'OUTBOX_DISPATCHER_BATCH_SIZE'), + claimTtlMs: parsePositiveInteger(env.OUTBOX_DISPATCHER_CLAIM_TTL_MS, 'OUTBOX_DISPATCHER_CLAIM_TTL_MS'), + pollIntervalMs: parsePositiveInteger(env.OUTBOX_DISPATCHER_POLL_MS, 'OUTBOX_DISPATCHER_POLL_MS'), + }) + } + finally { + await redis.quit() + await pool.end() + } +} diff --git a/apps/server/src/bin/run.ts b/apps/server/src/bin/run.ts new file mode 100644 index 000000000..6a041053b --- /dev/null +++ b/apps/server/src/bin/run.ts @@ -0,0 +1,79 @@ +#!/usr/bin/env node + +import process from 'node:process' + +import { pathToFileURL } from 'node:url' + +import { errorMessageFrom } from '@moeru/std' + +import { runApiServer } from '../app' +import { handleCacheSyncMessage, runBillingEventsConsumer } from './run-billing-events-consumer' +import { runOutboxDispatcher } from './run-outbox-dispatcher' + +const serverRoles = ['api', 'cache-sync-consumer', 'outbox-dispatcher'] as const + +type ServerRole = typeof serverRoles[number] + +export function getServerCliHelpText(): string { + return [ + 'Usage: server ', + '', + 'Roles:', + ' api Start the HTTP/WebSocket API process', + ' cache-sync-consumer Start the cache-sync Redis Streams consumer', + ' outbox-dispatcher Publish DB outbox events to Redis Streams', + ].join('\n') +} + +export function parseServerRole(args: string[]): ServerRole | null { + const role = args[0] + if (!role) { + return null + } + + return serverRoles.includes(role as ServerRole) ? role as ServerRole : null +} + +async function runServerRole(role: ServerRole): Promise { + switch (role) { + case 'api': + await runApiServer() + return + case 'cache-sync-consumer': + await runBillingEventsConsumer({ + group: 'cache-sync', + loggerName: 'cache-sync-consumer', + handleMessage: handleCacheSyncMessage, + }) + return + case 'outbox-dispatcher': + await runOutboxDispatcher() + } +} + +async function main(): Promise { + const role = parseServerRole(process.argv.slice(2)) + if (!role) { + process.stdout.write(`${getServerCliHelpText()}\n`) + process.exitCode = 1 + return + } + + await runServerRole(role) +} + +function isExecutedAsMainModule(): boolean { + const entryFile = process.argv[1] + if (!entryFile) { + return false + } + + return import.meta.url === pathToFileURL(entryFile).href +} + +if (isExecutedAsMainModule()) { + void main().catch((error: unknown) => { + process.stderr.write(`${errorMessageFrom(error) ?? 'Unknown error'}\n`) + process.exit(1) + }) +} diff --git a/apps/server/src/libs/__test__/eventa-hono-adapter.test.ts b/apps/server/src/libs/__test__/eventa-hono-adapter.test.ts new file mode 100644 index 000000000..f3ddb9204 --- /dev/null +++ b/apps/server/src/libs/__test__/eventa-hono-adapter.test.ts @@ -0,0 +1,102 @@ +import type { WSContext } from 'hono/ws' + +import { defineEventa, defineInvokeEventa, defineInvokeHandler } from '@moeru/eventa' +import { describe, expect, it, vi } from 'vitest' + +import { createPeerHooks } from '../eventa-hono-adapter' + +function createMockWSContext(): WSContext & { sentMessages: string[] } { + const sentMessages: string[] = [] + return { + send: vi.fn((data: string) => sentMessages.push(data)), + close: vi.fn(), + readyState: 1, + raw: {}, + url: null, + protocol: null, + sentMessages, + } as any +} + +describe('eventa Hono adapter', () => { + it('creates peer context on open and cleans up on close', () => { + let contextReceived = false + const { hooks } = createPeerHooks({ + onContext: () => { contextReceived = true }, + }) + const ws = createMockWSContext() + hooks.onOpen!({} as any, ws) + expect(contextReceived).toBe(true) + hooks.onClose!({} as any, ws) + }) + + it('routes inbound messages to eventa context and invokes handler', async () => { + const echo = defineInvokeEventa<{ out: string }, { in: string }>('test:echo') + + const { hooks } = createPeerHooks({ + onContext: (ctx) => { + defineInvokeHandler(ctx, echo, (req) => { + return { out: req.in.toUpperCase() } + }) + }, + }) + + const ws = createMockWSContext() + hooks.onOpen!({} as any, ws) + + // The invoke wire format: type must match sendEvent id, payload.body + // carries the invoke envelope with invokeId + content. + const invokeId = 'invoke-1' + const payload = JSON.stringify({ + id: 'msg-1', + type: echo.sendEvent.id, + payload: { + body: { + invokeId, + content: { in: 'hello' }, + }, + }, + timestamp: Date.now(), + }) + + const messageEvent = { data: payload } as any + hooks.onMessage!({} as any, ws, messageEvent) + + await new Promise(r => setTimeout(r, 100)) + + // Filter out lifecycle event messages (wsConnectedEvent) + const responses = ws.sentMessages + .map(m => JSON.parse(m)) + .filter((m: any) => !m.type.startsWith('eventa:adapters:')) + + expect(responses.length).toBeGreaterThan(0) + + // The invoke response payload contains the full event object spread into it. + // The actual response content is nested under body.content. + const response = responses[0] + expect(response.payload.body.invokeId).toBe(invokeId) + expect(response.payload.body.content).toEqual({ out: 'HELLO' }) + }) + + it('emits simple events and forwards them over the wire', () => { + const ping = defineEventa<{ msg: string }>('test:ping') + + let capturedCtx: any + const { hooks } = createPeerHooks({ + onContext: (ctx) => { capturedCtx = ctx }, + }) + + const ws = createMockWSContext() + hooks.onOpen!({} as any, ws) + + // Clear the connected lifecycle message + ws.sentMessages.length = 0 + + // Emit an outbound event from the context + capturedCtx.emit(ping, { msg: 'pong' }) + + expect(ws.sentMessages.length).toBe(1) + const sent = JSON.parse(ws.sentMessages[0]) + expect(sent.type).toBe('test:ping') + }) +}) diff --git a/apps/server/src/libs/auth.ts b/apps/server/src/libs/auth.ts index 2371a8e49..989d71ee5 100644 --- a/apps/server/src/libs/auth.ts +++ b/apps/server/src/libs/auth.ts @@ -1,5 +1,6 @@ import type { Database } from './db' import type { Env } from './env' +import type { AuthMetrics } from './otel' import { betterAuth } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' @@ -7,10 +8,24 @@ import { bearer } from 'better-auth/plugins' import * as authSchema from '../schemas/accounts' +function extractOrigins(env: Env): string[] { + const origins = new Set() + for (const url of [env.CLIENT_URL, env.API_SERVER_URL]) { + try { + const { origin } = new URL(url) + origins.add(origin) + } + catch { + // skip malformed URLs + } + } + return [...origins] +} + // NOTICE: return type uses `any` to avoid TS2742 — betterAuth's inferred type // references internal pnpm paths (@better-auth/core) that aren't directly accessible -export function createAuth(db: Database, env: Env): any { +export function createAuth(db: Database, env: Env, metrics?: AuthMetrics | null): any { return betterAuth({ database: drizzleAdapter(db, { provider: 'pg', @@ -28,7 +43,7 @@ export function createAuth(db: Database, env: Env): any { }, baseURL: env.API_SERVER_URL, - trustedOrigins: ['*'], + trustedOrigins: extractOrigins(env), // To skip state-mismatch errors // https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378 @@ -49,5 +64,28 @@ export function createAuth(db: Database, env: Env): any { clientSecret: env.AUTH_GITHUB_CLIENT_SECRET, }, }, + + databaseHooks: { + user: { + create: { + after: async () => { + metrics?.userRegistered.add(1) + }, + }, + }, + session: { + create: { + after: async () => { + metrics?.userLogin.add(1) + metrics?.activeSessions.add(1) + }, + }, + delete: { + after: async () => { + metrics?.activeSessions.add(-1) + }, + }, + }, + }, }) } diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index de7a95f64..63252397d 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -24,6 +24,16 @@ const EnvSchema = object({ STRIPE_SECRET_KEY: optional(string()), STRIPE_WEBHOOK_SECRET: optional(string()), + BILLING_EVENTS_STREAM: optional(string(), 'billing-events'), + BILLING_EVENTS_CONSUMER_NAME: optional(string()), + BILLING_EVENTS_BATCH_SIZE: optional(string(), '10'), + BILLING_EVENTS_BLOCK_MS: optional(string(), '5000'), + BILLING_EVENTS_MIN_IDLE_MS: optional(string(), '30000'), + + OUTBOX_DISPATCHER_NAME: optional(string()), + OUTBOX_DISPATCHER_BATCH_SIZE: optional(string(), '10'), + OUTBOX_DISPATCHER_CLAIM_TTL_MS: optional(string(), '30000'), + OUTBOX_DISPATCHER_POLL_MS: optional(string(), '1000'), // OpenTelemetry OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'), OTEL_SERVICE_NAME: optional(string(), 'server'), diff --git a/apps/server/src/libs/eventa-hono-adapter.ts b/apps/server/src/libs/eventa-hono-adapter.ts new file mode 100644 index 000000000..82badb976 --- /dev/null +++ b/apps/server/src/libs/eventa-hono-adapter.ts @@ -0,0 +1,196 @@ +import type { EventContext } from '@moeru/eventa' +import type { WSContext, WSEvents } from 'hono/ws' + +import { and, createContext, defineEventa, defineInboundEventa, defineOutboundEventa, EventaFlowDirection, matchBy } from '@moeru/eventa' + +// Re-implement the internal websocket payload helpers since they are not exported +// from @moeru/eventa's public API. These match the wire format used by the H3 and +// native adapters so that clients using any eventa adapter remain interoperable. + +interface WebsocketPayload { + id: string + type: string + payload: Record + timestamp: number +} + +function generateWebsocketPayload(type: string, payload: Record): WebsocketPayload { + return { + id: crypto.randomUUID(), + type, + payload, + timestamp: Date.now(), + } +} + +function parseWebsocketPayload(data: string): WebsocketPayload { + return JSON.parse(data) +} + +// --------------------------------------------------------------------------- +// Lifecycle events +// --------------------------------------------------------------------------- + +export const wsConnectedEvent = defineEventa('eventa:adapters:hono-ws:connected') +export const wsDisconnectedEvent = defineEventa('eventa:adapters:hono-ws:disconnected') +export const wsErrorEvent = defineEventa('eventa:adapters:hono-ws:error') + +// --------------------------------------------------------------------------- +// Per-peer adapter +// --------------------------------------------------------------------------- + +export interface CreatePeerHooksOptions { + /** Called when a new peer connects and its EventContext is ready. */ + onContext?: (ctx: EventContext) => void +} + +export interface PeerHooksResult { + hooks: WSEvents +} + +/** + * Create Hono WSEvents hooks that manage one eventa EventContext per peer. + * + * This is the Hono equivalent of the H3 adapter's `createPeerContext` / + * `createPeerHooks`. Each time `onOpen` fires a fresh EventContext is created, + * outbound events are serialised to `ws.send()`, and incoming messages are + * routed into the context as inbound events. + */ +export function createPeerHooks(options: CreatePeerHooksOptions = {}): PeerHooksResult { + let ctx: EventContext | undefined + let cleanup: (() => void) | undefined + + const hooks: WSEvents = { + onOpen(_event, ws) { + ctx = createContext() + + // Intercept outbound events and forward them over the WebSocket. + // This mirrors the H3 adapter's pattern exactly. + const offOutbound = ctx.on( + and( + matchBy((e: any) => e._flowDirection === EventaFlowDirection.Outbound || !e._flowDirection), + matchBy('*'), + ), + (event: any) => { + const data = JSON.stringify( + generateWebsocketPayload(event.id, { + ...defineOutboundEventa(event.type), + ...event, + }), + ) + ws.send(data) + }, + ) + + cleanup = offOutbound + + // Emit lifecycle event + ctx.emit(wsConnectedEvent, {}, { raw: {} }) + + // Notify caller + options.onContext?.(ctx) + }, + + onMessage(_event, ws, message) { + if (!ctx) + return + + try { + const raw = typeof message.data === 'string' ? message.data : String(message.data) + const { type, payload } = parseWebsocketPayload(raw) + ctx.emit(defineInboundEventa(type), (payload as any).body, { raw: { message } }) + } + catch (error) { + console.error('Failed to parse WebSocket message:', error) + ctx.emit(wsErrorEvent, { error }, { raw: { message } }) + } + }, + + onClose(_event, _ws) { + if (!ctx) + return + + ctx.emit(wsDisconnectedEvent, {}, { raw: {} }) + cleanup?.() + ctx = undefined + cleanup = undefined + }, + + onError(event, _ws) { + if (!ctx) + return + + ctx.emit(wsErrorEvent, { error: event }, { raw: { error: event } }) + }, + } + + return { hooks } +} + +// --------------------------------------------------------------------------- +// Global (broadcast) adapter +// --------------------------------------------------------------------------- + +export interface GlobalHooksResult { + hooks: WSEvents + context: EventContext +} + +/** + * Create a single shared EventContext that broadcasts outbound events to every + * connected peer — the Hono equivalent of the H3 adapter's + * `createGlobalContext`. + */ +export function createGlobalHooks(): GlobalHooksResult { + const ctx = createContext() + const peers = new Set() + + // Broadcast outbound events to all connected peers. + ctx.on( + and( + matchBy((e: any) => e._flowDirection === EventaFlowDirection.Outbound || !e._flowDirection), + matchBy('*'), + ), + (event: any) => { + const data = JSON.stringify( + generateWebsocketPayload(event.id, { + ...defineOutboundEventa(event.type), + ...event, + }), + ) + for (const peer of peers) { + peer.send(data) + } + }, + ) + + const hooks: WSEvents = { + onOpen(_event, ws) { + peers.add(ws) + ctx.emit(wsConnectedEvent, {}, { raw: {} }) + }, + + onMessage(_event, _ws, message) { + try { + const raw = typeof message.data === 'string' ? message.data : String(message.data) + const { type, payload } = parseWebsocketPayload(raw) + ctx.emit(defineInboundEventa(type), (payload as any).body, { raw: { message } }) + } + catch (error) { + console.error('Failed to parse WebSocket message:', error) + ctx.emit(wsErrorEvent, { error }, { raw: { message } }) + } + }, + + onClose(_event, ws) { + peers.delete(ws) + ctx.emit(wsDisconnectedEvent, {}, { raw: {} }) + }, + + onError(event, _ws) { + ctx.emit(wsErrorEvent, { error: event }, { raw: { error: event } }) + }, + } + + return { hooks, context: ctx } +} diff --git a/apps/server/src/libs/otel.ts b/apps/server/src/libs/otel.ts index 7cccb496d..11bd30009 100644 --- a/apps/server/src/libs/otel.ts +++ b/apps/server/src/libs/otel.ts @@ -1,5 +1,7 @@ import type { IncomingMessage } from 'node:http' +import type { Counter, Histogram, UpDownCounter } from '@opentelemetry/api' + import type { Env } from './env' import { env as processEnv } from 'node:process' @@ -22,7 +24,64 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic const logger = useLogger('otel') -export function initOtel(env: Env) { +export interface HttpMetrics { + requestDuration: Histogram + activeRequests: UpDownCounter +} + +export interface AuthMetrics { + attempts: Counter + failures: Counter + userRegistered: Counter + userLogin: Counter + activeSessions: UpDownCounter +} + +export interface EngagementMetrics { + chatSync: Counter + chatMessages: Counter + characterCreated: Counter + characterDeleted: Counter + characterEngagement: Counter + wsConnectionsActive: UpDownCounter + wsMessagesSent: Counter + wsMessagesReceived: Counter +} + +export interface RevenueMetrics { + stripeCheckoutCreated: Counter + stripeCheckoutCompleted: Counter + stripePaymentFailed: Counter + stripeSubscriptionEvent: Counter + stripeEvents: Counter + fluxInsufficientBalance: Counter +} + +export interface LlmMetrics { + requestDuration: Histogram + requestCount: Counter + tokensPrompt: Counter + tokensCompletion: Counter + fluxConsumed: Counter +} + +export interface DbMetrics { + queryDuration: Histogram + redisCommandDuration: Histogram +} + +export interface OtelInstance { + sdk: NodeSDK + http: HttpMetrics + auth: AuthMetrics + engagement: EngagementMetrics + revenue: RevenueMetrics + llm: LlmMetrics + db: DbMetrics + shutdown: () => Promise +} + +export function initOtel(env: Env): OtelInstance | undefined { const otlpEndpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT const serviceName = env.OTEL_SERVICE_NAME @@ -109,59 +168,117 @@ export function initOtel(env: Env) { const meter = metrics.getMeter(serviceName) - // Custom application metrics - const httpRequestDuration = meter.createHistogram('http.server.request.duration', { - description: 'HTTP server request duration in milliseconds', - unit: 'ms', - }) + // HTTP metrics + const http: HttpMetrics = { + requestDuration: meter.createHistogram('http.server.request.duration', { + description: 'HTTP server request duration in milliseconds', + unit: 'ms', + }), + activeRequests: meter.createUpDownCounter('http.server.active_requests', { + description: 'Number of active HTTP requests', + }), + } - const httpActiveRequests = meter.createUpDownCounter('http.server.active_requests', { - description: 'Number of active HTTP requests', - }) + // Auth & User metrics + const auth: AuthMetrics = { + attempts: meter.createCounter('auth.attempts', { + description: 'Number of authentication attempts', + }), + failures: meter.createCounter('auth.failures', { + description: 'Number of failed authentication attempts', + }), + userRegistered: meter.createCounter('user.registered', { + description: 'Number of new user registrations', + }), + userLogin: meter.createCounter('user.login', { + description: 'Number of user logins', + }), + activeSessions: meter.createUpDownCounter('user.active_sessions', { + description: 'Number of active user sessions', + }), + } - const dbQueryDuration = meter.createHistogram('db.client.operation.duration', { - description: 'Database operation duration in milliseconds', - unit: 'ms', - }) + // Engagement metrics + const engagement: EngagementMetrics = { + chatSync: meter.createCounter('chat.sync', { + description: 'Number of chat sync operations', + }), + chatMessages: meter.createCounter('chat.messages', { + description: 'Number of chat messages synced', + }), + characterCreated: meter.createCounter('character.created', { + description: 'Number of characters created', + }), + characterDeleted: meter.createCounter('character.deleted', { + description: 'Number of characters deleted', + }), + characterEngagement: meter.createCounter('character.engagement', { + description: 'Number of character engagement actions (like/bookmark)', + }), + wsConnectionsActive: meter.createUpDownCounter('ws.connections.active', { + description: 'Active WebSocket connections', + }), + wsMessagesSent: meter.createCounter('ws.messages.sent', { + description: 'Messages sent via WebSocket', + }), + wsMessagesReceived: meter.createCounter('ws.messages.received', { + description: 'Messages received via WebSocket', + }), + } - const redisCommandDuration = meter.createHistogram('redis.client.command.duration', { - description: 'Redis command duration in milliseconds', - unit: 'ms', - }) - - const authAttempts = meter.createCounter('auth.attempts', { - description: 'Number of authentication attempts', - }) - - const authFailures = meter.createCounter('auth.failures', { - description: 'Number of failed authentication attempts', - }) - - const stripeEvents = meter.createCounter('stripe.events', { - description: 'Number of Stripe webhook events processed', - }) + // Revenue metrics + const revenue: RevenueMetrics = { + stripeCheckoutCreated: meter.createCounter('stripe.checkout.created', { + description: 'Number of Stripe checkout sessions created', + }), + stripeCheckoutCompleted: meter.createCounter('stripe.checkout.completed', { + description: 'Number of Stripe checkout sessions completed', + }), + stripePaymentFailed: meter.createCounter('stripe.payment.failed', { + description: 'Number of failed Stripe payments', + }), + stripeSubscriptionEvent: meter.createCounter('stripe.subscription.event', { + description: 'Number of Stripe subscription lifecycle events', + }), + stripeEvents: meter.createCounter('stripe.events', { + description: 'Number of Stripe webhook events processed', + }), + fluxInsufficientBalance: meter.createCounter('flux.insufficient_balance', { + description: 'Number of insufficient flux balance errors', + }), + } // LLM / Gateway metrics - const llmRequestDuration = meter.createHistogram('llm.request.duration', { - description: 'LLM gateway request duration in milliseconds', - unit: 'ms', - }) + const llm: LlmMetrics = { + requestDuration: meter.createHistogram('llm.request.duration', { + description: 'LLM gateway request duration in milliseconds', + unit: 'ms', + }), + requestCount: meter.createCounter('llm.request.count', { + description: 'Number of LLM gateway requests', + }), + tokensPrompt: meter.createCounter('llm.tokens.prompt', { + description: 'Total prompt tokens consumed', + }), + tokensCompletion: meter.createCounter('llm.tokens.completion', { + description: 'Total completion tokens consumed', + }), + fluxConsumed: meter.createCounter('flux.consumed', { + description: 'Total flux consumed', + }), + } - const llmRequestCount = meter.createCounter('llm.request.count', { - description: 'Number of LLM gateway requests', - }) - - const llmTokensPrompt = meter.createCounter('llm.tokens.prompt', { - description: 'Total prompt tokens consumed', - }) - - const llmTokensCompletion = meter.createCounter('llm.tokens.completion', { - description: 'Total completion tokens consumed', - }) - - const fluxConsumed = meter.createCounter('flux.consumed', { - description: 'Total flux consumed', - }) + // Database metrics + const db: DbMetrics = { + queryDuration: meter.createHistogram('db.client.operation.duration', { + description: 'Database operation duration in milliseconds', + unit: 'ms', + }), + redisCommandDuration: meter.createHistogram('redis.client.command.duration', { + description: 'Redis command duration in milliseconds', + unit: 'ms', + }), + } // Graceful shutdown const shutdown = async () => { @@ -176,20 +293,12 @@ export function initOtel(env: Env) { return { sdk, - meter, - httpRequestDuration, - httpActiveRequests, - dbQueryDuration, - redisCommandDuration, - authAttempts, - authFailures, - stripeEvents, - llmRequestDuration, - llmRequestCount, - llmTokensPrompt, - llmTokensCompletion, - fluxConsumed, - + http, + auth, + engagement, + revenue, + llm, + db, shutdown, } } diff --git a/apps/server/src/middlewares/otel.ts b/apps/server/src/middlewares/otel.ts index f12bfc1c6..c1685bf6e 100644 --- a/apps/server/src/middlewares/otel.ts +++ b/apps/server/src/middlewares/otel.ts @@ -1,7 +1,9 @@ import type { MiddlewareHandler } from 'hono' +import type { HttpMetrics } from '../libs/otel' import type { HonoEnv } from '../types/hono' +import { errorMessageFrom } from '@moeru/std' import { context, SpanStatusCode, trace } from '@opentelemetry/api' const tracer = trace.getTracer('airi-server-hono') @@ -10,16 +12,13 @@ const tracer = trace.getTracer('airi-server-hono') * Hono middleware that creates spans for each request and records * custom HTTP metrics (duration, active requests, status codes). */ -export function otelMiddleware(otelMetrics: { - httpRequestDuration: { record: (value: number, attributes?: Record) => void } - httpActiveRequests: { add: (value: number, attributes?: Record) => void } -}): MiddlewareHandler { +export function otelMiddleware(http: HttpMetrics): MiddlewareHandler { return async (c, next) => { const startTime = performance.now() const method = c.req.method const path = c.req.path - otelMetrics.httpActiveRequests.add(1, { 'http.request.method': method, 'http.route': path }) + http.activeRequests.add(1, { 'http.request.method': method, 'http.route': path }) const span = tracer.startSpan(`${method} ${path}`, { attributes: { @@ -39,19 +38,19 @@ export function otelMiddleware(otelMetrics: { span.setStatus({ code: SpanStatusCode.ERROR, message: `HTTP ${status}` }) } - otelMetrics.httpRequestDuration.record(performance.now() - startTime, { + http.requestDuration.record(performance.now() - startTime, { 'http.request.method': method, 'http.route': path, 'http.response.status_code': status, }) } catch (err) { - span.setStatus({ code: SpanStatusCode.ERROR, message: err instanceof Error ? err.message : 'Unknown error' }) - span.recordException(err instanceof Error ? err : new Error(String(err))) + span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessageFrom(err) ?? 'Unknown error' }) + span.recordException(err instanceof Error ? err : new Error(errorMessageFrom(err) ?? 'Unknown error')) throw err } finally { - otelMetrics.httpActiveRequests.add(-1, { 'http.request.method': method, 'http.route': path }) + http.activeRequests.add(-1, { 'http.request.method': method, 'http.route': path }) span.end() } } diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts new file mode 100644 index 000000000..bab7914d8 --- /dev/null +++ b/apps/server/src/middlewares/rate-limit.ts @@ -0,0 +1,28 @@ +import type { Context } from 'hono' + +import type { HonoEnv } from '../types/hono' + +import { rateLimiter as createRateLimiter } from 'hono-rate-limiter' + +interface RateLimitOptions { + /** Max requests allowed within the window */ + max: number + /** Window size in seconds */ + windowSec: number + /** Key generator: extracts a unique identifier from the request */ + keyGenerator?: (c: Context) => string +} + +/** + * Rate limiter middleware powered by hono-rate-limiter. + * Uses in-memory store by default (single-instance). + */ +export function rateLimiter(opts: RateLimitOptions) { + return createRateLimiter({ + windowMs: opts.windowSec * 1000, + limit: opts.max, + standardHeaders: 'draft-6', + keyGenerator: opts.keyGenerator + ?? (c => c.get('user')?.id ?? c.req.header('x-forwarded-for') ?? 'anonymous'), + }) +} diff --git a/apps/server/src/routes/__test__/characters.test.ts b/apps/server/src/routes/__test__/characters.test.ts index 57e338619..fddd99d1c 100644 --- a/apps/server/src/routes/__test__/characters.test.ts +++ b/apps/server/src/routes/__test__/characters.test.ts @@ -1,3 +1,4 @@ +import type { Database } from '../../libs/db' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' @@ -11,7 +12,7 @@ import { createCharacterRoutes } from '../characters' import * as schema from '../../schemas' describe('characterRoutes', () => { - let db: any + let db: Database let characterService: any let app: Hono let testUser: any diff --git a/apps/server/src/routes/__test__/providers.test.ts b/apps/server/src/routes/__test__/providers.test.ts index 5fce9f091..b6c5016d4 100644 --- a/apps/server/src/routes/__test__/providers.test.ts +++ b/apps/server/src/routes/__test__/providers.test.ts @@ -1,3 +1,4 @@ +import type { Database } from '../../libs/db' import type { HonoEnv } from '../../types/hono' import { Hono } from 'hono' @@ -11,7 +12,7 @@ import { createProviderRoutes } from '../providers' import * as schema from '../../schemas' describe('providerRoutes', () => { - let db: any + let db: Database let providerService: any let app: Hono let testUser: any diff --git a/apps/server/src/routes/__test__/stripe.test.ts b/apps/server/src/routes/__test__/stripe.test.ts new file mode 100644 index 000000000..46c92affb --- /dev/null +++ b/apps/server/src/routes/__test__/stripe.test.ts @@ -0,0 +1,436 @@ +import type { BillingService } from '../../services/billing-service' +import type { ConfigKVService } from '../../services/config-kv' +import type { FluxService } from '../../services/flux' +import type { StripeService } from '../../services/stripe' +import type { HonoEnv } from '../../types/hono' + +import { Hono } from 'hono' +import { describe, expect, it, vi } from 'vitest' + +import { ApiError } from '../../utils/error' +import { createStripeRoutes } from '../stripe' + +// --- Mock helpers --- + +function createMockFluxService(): FluxService { + return { + getFlux: vi.fn(async () => ({ userId: 'user-1', flux: 100 })), + updateStripeCustomerId: vi.fn(), + } as any +} + +function createMockStripeService(overrides: Partial = {}): StripeService { + return { + upsertCustomer: vi.fn(async data => ({ id: 'id-1', createdAt: new Date(), updatedAt: new Date(), ...data })), + getCustomerByUserId: vi.fn(async () => undefined), + getCustomerByStripeId: vi.fn(async () => undefined), + upsertCheckoutSession: vi.fn(async data => ({ id: 'id-1', fluxCredited: false, createdAt: new Date(), updatedAt: new Date(), ...data })), + getCheckoutSessionsByUserId: vi.fn(async () => []), + upsertSubscription: vi.fn(async data => ({ id: 'id-1', createdAt: new Date(), updatedAt: new Date(), ...data })), + getActiveSubscription: vi.fn(async () => undefined), + upsertInvoice: vi.fn(async data => ({ id: 'id-1', fluxCredited: false, createdAt: new Date(), updatedAt: new Date(), ...data })), + getInvoicesByUserId: vi.fn(async () => []), + ...overrides, + } as any +} + +function createMockBillingService(): BillingService { + return { + debitFlux: vi.fn(), + creditFlux: vi.fn(), + creditFluxFromStripeCheckout: vi.fn(async () => ({ applied: true, balanceAfter: 500 })), + creditFluxFromInvoice: vi.fn(async () => ({ applied: true, balanceAfter: 500 })), + } as any +} + +function createMockConfigKV(overrides: Record = {}): ConfigKVService { + const defaults: Record = { + FLUX_PER_CENT: 1, + FLUX_PACKAGES: [{ amount: 500, label: '$5' }], + ...overrides, + } + return { + getOrThrow: vi.fn(async (key: string) => { + if (defaults[key] === undefined) + throw new Error(`Config key "${key}" is not set`) + return defaults[key] + }), + getOptional: vi.fn(async (key: string) => defaults[key] ?? null), + get: vi.fn(async (key: string) => defaults[key]), + set: vi.fn(), + } as any +} + +const testEnv = { + STRIPE_SECRET_KEY: 'sk_test_fake', + STRIPE_WEBHOOK_SECRET: 'whsec_test_fake', + CLIENT_URL: 'http://localhost:3000', +} as any + +const testUser = { id: 'user-1', name: 'Test User', email: 'test@example.com' } + +function createTestApp( + fluxService: FluxService, + stripeService: StripeService, + billingService: BillingService, + configKV: ConfigKVService, +) { + const routes = createStripeRoutes(fluxService, stripeService, billingService, configKV, testEnv) + const app = new Hono() + + app.onError((err, c) => { + if (err instanceof ApiError) { + return c.json({ + error: err.errorCode, + message: err.message, + details: err.details, + }, err.statusCode) + } + return c.json({ error: 'Internal Server Error', message: err.message }, 500) + }) + + // Inject user from env (simulates sessionMiddleware) + app.use('*', async (c, next) => { + const user = (c.env as any)?.user + if (user) { + c.set('user', user) + } + await next() + }) + + app.route('/api/stripe', routes) + return app +} + +// --- Tests --- + +describe('stripeRoutes', () => { + describe('gET /api/stripe/packages', () => { + it('returns configured packages', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/packages') + expect(res.status).toBe(200) + + const data = await res.json() + expect(data).toEqual([{ amount: 500, label: '$5' }]) + }) + + it('returns empty array when no packages configured', async () => { + const configKV = createMockConfigKV() + configKV.getOptional = vi.fn(async () => null) + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + configKV, + ) + + const res = await app.request('/api/stripe/packages') + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + }) + + describe('pOST /api/stripe/checkout', () => { + it('returns 401 when unauthenticated', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: 500 }), + }) + expect(res.status).toBe(401) + }) + + it('returns 400 for invalid amount (zero)', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: 0 }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(400) + }) + + it('returns 400 for invalid amount (negative)', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: -100 }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(400) + }) + + it('returns 400 for amount exceeding max ($10,000)', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: 1_000_001 }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(400) + }) + + it('returns 400 for non-integer amount', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: 9.99 }), + }), + { user: testUser } as any, + ) + expect(res.status).toBe(400) + }) + + it('returns 503 when Stripe is not configured', async () => { + const routes = createStripeRoutes( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + { ...testEnv, STRIPE_SECRET_KEY: '' } as any, + ) + const app = new Hono() + app.onError((err, c) => { + if (err instanceof ApiError) + return c.json({ error: err.errorCode }, err.statusCode) + return c.json({ error: 'Internal Server Error' }, 500) + }) + app.use('*', async (c, next) => { + c.set('user', testUser as any) + await next() + }) + app.route('/api/stripe', routes) + + const res = await app.request('/api/stripe/checkout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amount: 500 }), + }) + expect(res.status).toBe(503) + }) + }) + + describe('gET /api/stripe/orders', () => { + it('returns 401 when unauthenticated', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/orders') + expect(res.status).toBe(401) + }) + + it('returns checkout sessions for the authenticated user', async () => { + const mockSessions = [ + { id: '1', stripeSessionId: 'cs_1', status: 'complete' }, + { id: '2', stripeSessionId: 'cs_2', status: 'open' }, + ] + const stripeService = createMockStripeService({ + getCheckoutSessionsByUserId: vi.fn(async () => mockSessions), + }) + const app = createTestApp( + createMockFluxService(), + stripeService, + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/orders'), + { user: testUser } as any, + ) + expect(res.status).toBe(200) + + const data = await res.json() + expect(data).toHaveLength(2) + expect(stripeService.getCheckoutSessionsByUserId).toHaveBeenCalledWith('user-1') + }) + }) + + describe('gET /api/stripe/invoices', () => { + it('returns 401 when unauthenticated', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/invoices') + expect(res.status).toBe(401) + }) + + it('returns invoices for the authenticated user', async () => { + const mockInvoices = [{ id: '1', stripeInvoiceId: 'inv_1', status: 'paid' }] + const stripeService = createMockStripeService({ + getInvoicesByUserId: vi.fn(async () => mockInvoices), + }) + const app = createTestApp( + createMockFluxService(), + stripeService, + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/invoices'), + { user: testUser } as any, + ) + expect(res.status).toBe(200) + + const data = await res.json() + expect(data).toHaveLength(1) + expect(stripeService.getInvoicesByUserId).toHaveBeenCalledWith('user-1') + }) + }) + + describe('pOST /api/stripe/portal', () => { + it('returns 401 when unauthenticated', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/portal', { method: 'POST' }) + expect(res.status).toBe(401) + }) + + it('returns 400 when user has no billing account', async () => { + const stripeService = createMockStripeService({ + getCustomerByUserId: vi.fn(async () => undefined), + }) + const app = createTestApp( + createMockFluxService(), + stripeService, + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.fetch( + new Request('http://localhost/api/stripe/portal', { method: 'POST' }), + { user: testUser } as any, + ) + expect(res.status).toBe(400) + + const data = await res.json() as any + expect(data.error).toBe('NO_CUSTOMER') + }) + }) + + describe('pOST /api/stripe/webhook', () => { + it('returns 400 when signature is missing', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/webhook', { + method: 'POST', + body: '{}', + }) + expect(res.status).toBe(400) + + const data = await res.json() as any + expect(data.error).toBe('MISSING_SIGNATURE') + }) + + it('returns 400 when signature is invalid', async () => { + const app = createTestApp( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + ) + + const res = await app.request('/api/stripe/webhook', { + method: 'POST', + headers: { 'stripe-signature': 'invalid_sig' }, + body: '{}', + }) + expect(res.status).toBe(400) + + const data = await res.json() as any + expect(data.error).toBe('WEBHOOK_ERROR') + }) + + it('returns 503 when Stripe is not configured', async () => { + const routes = createStripeRoutes( + createMockFluxService(), + createMockStripeService(), + createMockBillingService(), + createMockConfigKV(), + { ...testEnv, STRIPE_SECRET_KEY: '', STRIPE_WEBHOOK_SECRET: '' } as any, + ) + const app = new Hono() + app.onError((err, c) => { + if (err instanceof ApiError) + return c.json({ error: err.errorCode }, err.statusCode) + return c.json({ error: 'Internal Server Error' }, 500) + }) + app.route('/api/stripe', routes) + + const res = await app.request('/api/stripe/webhook', { + method: 'POST', + headers: { 'stripe-signature': 'test_sig' }, + body: '{}', + }) + expect(res.status).toBe(503) + }) + }) +}) diff --git a/apps/server/src/routes/__test__/v1completions.test.ts b/apps/server/src/routes/__test__/v1completions.test.ts index 68df007f5..70bb5d23d 100644 --- a/apps/server/src/routes/__test__/v1completions.test.ts +++ b/apps/server/src/routes/__test__/v1completions.test.ts @@ -1,3 +1,4 @@ +import type { BillingService } from '../../services/billing-service' import type { ConfigKVService } from '../../services/config-kv' import type { FluxService } from '../../services/flux' import type { RequestLogService } from '../../services/request-log' @@ -14,12 +15,23 @@ import { createV1CompletionsRoutes } from '../v1completions' function createMockFluxService(flux = 100): FluxService { return { getFlux: vi.fn(async () => ({ userId: 'user-1', flux })), - consumeFlux: vi.fn(async (_userId: string, amount: number) => ({ userId: 'user-1', flux: flux - amount })), - addFlux: vi.fn(async (_userId: string, amount: number) => ({ userId: 'user-1', flux: flux + amount })), updateStripeCustomerId: vi.fn(), } as any } +function createMockBillingService(flux = 100): BillingService { + let balance = flux + return { + debitFlux: vi.fn(async (input: { userId: string, amount: number }) => { + balance -= input.amount + return { userId: input.userId, flux: balance } + }), + creditFlux: vi.fn(), + creditFluxFromStripeCheckout: vi.fn(), + creditFluxFromInvoice: vi.fn(), + } as any +} + function createMockConfigKV(overrides: Record = {}): ConfigKVService { const defaults: Record = { FLUX_PER_REQUEST: 1, @@ -51,8 +63,9 @@ function createTestApp( fluxService: FluxService, configKV: ConfigKVService, requestLogService: RequestLogService, + billingService?: BillingService, ) { - const routes = createV1CompletionsRoutes(fluxService, configKV, requestLogService, null) + const routes = createV1CompletionsRoutes(fluxService, billingService ?? createMockBillingService(), configKV, requestLogService, null) const app = new Hono() app.onError((err, c) => { @@ -132,9 +145,10 @@ describe('v1CompletionsRoutes', () => { })) const fluxService = createMockFluxService(100) + const billingService = createMockBillingService(100) const configKV = createMockConfigKV({ GATEWAY_BASE_URL: 'http://mock-gateway/' }) const requestLogService = createMockRequestLogService() - const app = createTestApp(fluxService, configKV, requestLogService) + const app = createTestApp(fluxService, configKV, requestLogService, billingService) const res = await app.fetch( new Request('http://localhost/api/v1/chat/completions', { @@ -146,14 +160,13 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(200) - const data = await res.json() as any + const data = await res.json() expect(data.id).toBe('chatcmpl-1') - // Verify flux was consumed - expect(fluxService.consumeFlux).toHaveBeenCalledWith('user-1', 1, { - description: 'openai/gpt-5-mini', - metadata: undefined, - }) + // Verify flux was debited via billingService + expect(billingService.debitFlux).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', amount: 1 }), + ) // Verify upstream was called with correct URL and resolved model expect(globalThis.fetch).toHaveBeenCalledWith( @@ -225,8 +238,8 @@ describe('v1CompletionsRoutes', () => { headers: { 'Content-Type': 'application/json' }, })) - const fluxService = createMockFluxService(100) - const app = createTestApp(fluxService, createMockConfigKV(), createMockRequestLogService()) + const billingService = createMockBillingService(100) + const app = createTestApp(createMockFluxService(100), createMockConfigKV(), createMockRequestLogService(), billingService) const res = await app.fetch( new Request('http://localhost/api/v1/chat/completions', { @@ -238,9 +251,8 @@ describe('v1CompletionsRoutes', () => { ) expect(res.status).toBe(500) - // Post-billing: no charge on failed requests, no refund needed - expect(fluxService.consumeFlux).not.toHaveBeenCalled() - expect(fluxService.addFlux).not.toHaveBeenCalled() + // Post-billing: no charge on failed requests + expect(billingService.debitFlux).not.toHaveBeenCalled() }) it('should return 503 when config keys are missing', async () => { diff --git a/apps/server/src/routes/characters.ts b/apps/server/src/routes/characters.ts index 0c78fc1f7..25d02becb 100644 --- a/apps/server/src/routes/characters.ts +++ b/apps/server/src/routes/characters.ts @@ -41,7 +41,6 @@ export function createCharacterRoutes(characterService: CharacterService) { throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues) } - // @ts-expect-error - TODO: Fix this const character = await characterService.create({ ...result.output, character: { @@ -49,7 +48,7 @@ export function createCharacterRoutes(characterService: CharacterService) { ownerId: user.id, creatorId: user.id, }, - }) + } as Parameters[0]) return c.json(character, 201) }) diff --git a/apps/server/src/routes/chat-ws.ts b/apps/server/src/routes/chat-ws.ts new file mode 100644 index 000000000..e43842f8f --- /dev/null +++ b/apps/server/src/routes/chat-ws.ts @@ -0,0 +1,164 @@ +import type { EventContext } from '@moeru/eventa' +import type Redis from 'ioredis' + +import type { EngagementMetrics } from '../libs/otel' +import type { ChatService } from '../services/chats' + +import { useLogger } from '@guiiai/logg' +import { defineInvokeHandler } from '@moeru/eventa' +import { newMessages, pullMessages, sendMessages } from '@proj-airi/server-sdk' + +import { createPeerHooks, wsDisconnectedEvent } from '../libs/eventa-hono-adapter' + +const log = useLogger('chat-ws').useGlobalConfig() + +const CHANNEL_PREFIX = 'chat:broadcast:' + +// --------------------------------------------------------------------------- +// Local connection registry (per-process) +// --------------------------------------------------------------------------- + +const userConnections = new Map>() + +function addConnection(userId: string, ctx: EventContext) { + let conns = userConnections.get(userId) + if (!conns) { + conns = new Set() + userConnections.set(userId, conns) + } + conns.add(ctx) +} + +function removeConnection(userId: string, ctx: EventContext) { + const conns = userConnections.get(userId) + if (conns) { + conns.delete(ctx) + if (conns.size === 0) + userConnections.delete(userId) + } +} + +function broadcastToLocalDevices(userId: string, excludeCtx: EventContext | null, event: any, payload: any) { + const conns = userConnections.get(userId) + if (!conns) + return + for (const ctx of conns) { + if (ctx !== excludeCtx) { + ctx.emit(event, payload) + } + } +} + +// --------------------------------------------------------------------------- +// Redis pub/sub for cross-instance broadcast +// --------------------------------------------------------------------------- + +interface BroadcastMessage { + userId: string + payload: { + chatId: string + messages: any[] + fromSeq: number + toSeq: number + } +} + +export function createChatWsHandlers( + chatService: ChatService, + redis: Redis, + metrics?: EngagementMetrics | null, +) { + // Dedicated subscriber connection (ioredis requires a separate connection for subscribe mode) + const sub = redis.duplicate() + + sub.on('message', (channel: string, message: string) => { + if (!channel.startsWith(CHANNEL_PREFIX)) + return + + try { + const data: BroadcastMessage = JSON.parse(message) + // Deliver to all local connections of this user (no excludeCtx since the + // sender is on a different instance) + broadcastToLocalDevices(data.userId, null, newMessages, data.payload) + } + catch (err) { + log.withError(err).error('Failed to parse broadcast message') + } + }) + + /** Subscribe to a user's broadcast channel when they first connect on this instance. */ + function ensureSubscribed(userId: string) { + const channel = `${CHANNEL_PREFIX}${userId}` + sub.subscribe(channel).catch((err) => { + log.withError(err).error('Failed to subscribe to broadcast channel') + }) + } + + /** Unsubscribe when the user has no more connections on this instance. */ + function maybeUnsubscribe(userId: string) { + if (!userConnections.has(userId)) { + const channel = `${CHANNEL_PREFIX}${userId}` + sub.unsubscribe(channel).catch((err) => { + log.withError(err).error('Failed to unsubscribe from broadcast channel') + }) + } + } + + /** Publish a broadcast message so other instances can deliver it. */ + function publishBroadcast(userId: string, payload: BroadcastMessage['payload']) { + const channel = `${CHANNEL_PREFIX}${userId}` + const message: BroadcastMessage = { userId, payload } + redis.publish(channel, JSON.stringify(message)).catch((err) => { + log.withError(err).error('Failed to publish broadcast message') + }) + } + + return function setupPeer(userId: string) { + const { hooks } = createPeerHooks({ + onContext: (ctx) => { + addConnection(userId, ctx) + ensureSubscribed(userId) + log.withFields({ userId }).log('WS connected') + metrics?.wsConnectionsActive.add(1) + + ctx.on(wsDisconnectedEvent, () => { + removeConnection(userId, ctx) + maybeUnsubscribe(userId) + log.withFields({ userId }).log('WS disconnected') + metrics?.wsConnectionsActive.add(-1) + }) + + // RPC: send messages + defineInvokeHandler(ctx, sendMessages, async (req) => { + log.withFields({ userId, chatId: req!.chatId, count: req!.messages.length }).log('sendMessages') + const result = await chatService.pushMessages(userId, req!.chatId, req!.messages) + + // Fetch the wire messages for broadcast + const wireMessages = await chatService.pullMessages(userId, req!.chatId, result.fromSeq - 1, result.toSeq - result.fromSeq + 1) + const broadcastPayload = { + chatId: req!.chatId, + messages: wireMessages.messages, + fromSeq: result.fromSeq, + toSeq: result.toSeq, + } + + // Local broadcast (other connections on this instance, exclude sender) + broadcastToLocalDevices(userId, ctx, newMessages, broadcastPayload) + + // Cross-instance broadcast via Redis pub/sub + publishBroadcast(userId, broadcastPayload) + + metrics?.wsMessagesSent.add(wireMessages.messages.length) + return { seq: result.seq } + }) + + // RPC: pull messages + defineInvokeHandler(ctx, pullMessages, async (req) => { + log.withFields({ userId, chatId: req!.chatId, afterSeq: req!.afterSeq }).log('pullMessages') + return chatService.pullMessages(userId, req!.chatId, req!.afterSeq, req!.limit) + }) + }, + }) + return hooks + } +} diff --git a/apps/server/src/routes/chats.ts b/apps/server/src/routes/chats.ts index b02f18a1a..d283a3f5b 100644 --- a/apps/server/src/routes/chats.ts +++ b/apps/server/src/routes/chats.ts @@ -4,23 +4,58 @@ import type { HonoEnv } from '../types/hono' import { Hono } from 'hono' import { safeParse } from 'valibot' -import { ChatSyncSchema } from '../api/chats.schema' +import { AddMemberSchema, CreateChatSchema, UpdateChatSchema } from '../api/chats.schema' import { authGuard } from '../middlewares/auth' import { createBadRequestError } from '../utils/error' export function createChatRoutes(chatService: ChatService) { return new Hono() .use('*', authGuard) - .post('/sync', async (c) => { + .post('/', async (c) => { const user = c.get('user')! - const body = await c.req.json() - const result = safeParse(ChatSyncSchema, body) - + const result = safeParse(CreateChatSchema, body) if (!result.success) throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues) - - const synced = await chatService.syncChat(user.id, result.output) - return c.json(synced) + const chat = await chatService.createChat(user.id, result.output) + return c.json(chat, 201) + }) + .get('/', async (c) => { + const user = c.get('user')! + const chats = await chatService.listChats(user.id) + return c.json({ chats }) + }) + .get('/:id', async (c) => { + const user = c.get('user')! + const chat = await chatService.getChat(user.id, c.req.param('id')) + return c.json(chat) + }) + .patch('/:id', async (c) => { + const user = c.get('user')! + const body = await c.req.json() + const result = safeParse(UpdateChatSchema, body) + if (!result.success) + throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues) + const updated = await chatService.updateChat(user.id, c.req.param('id'), result.output) + return c.json(updated) + }) + .delete('/:id', async (c) => { + const user = c.get('user')! + const deleted = await chatService.deleteChat(user.id, c.req.param('id')) + return c.json(deleted) + }) + .post('/:id/members', async (c) => { + const user = c.get('user')! + const body = await c.req.json() + const result = safeParse(AddMemberSchema, body) + if (!result.success) + throw createBadRequestError('Invalid Request', 'INVALID_REQUEST', result.issues) + const added = await chatService.addMember(user.id, c.req.param('id'), result.output) + return c.json(added) + }) + .delete('/:id/members/:memberId', async (c) => { + const user = c.get('user')! + const removed = await chatService.removeMember(user.id, c.req.param('id'), c.req.param('memberId')) + return c.json(removed) }) } diff --git a/apps/server/src/routes/stripe.ts b/apps/server/src/routes/stripe.ts index 0b6792c9c..f941d2bd0 100644 --- a/apps/server/src/routes/stripe.ts +++ b/apps/server/src/routes/stripe.ts @@ -1,4 +1,6 @@ import type { Env } from '../libs/env' +import type { RevenueMetrics } from '../libs/otel' +import type { BillingService } from '../services/billing-service' import type { ConfigKVService } from '../services/config-kv' import type { FluxService } from '../services/flux' import type { StripeService } from '../services/stripe' @@ -7,20 +9,32 @@ import type { HonoEnv } from '../types/hono' import Stripe from 'stripe' import { useLogger } from '@guiiai/logg' +import { errorMessageFrom } from '@moeru/std' import { Hono } from 'hono' -import { integer, minValue, number, object, pipe, safeParse } from 'valibot' +import { integer, maxValue, minValue, number, object, pipe, safeParse } from 'valibot' import { authGuard } from '../middlewares/auth' import { configGuard } from '../middlewares/config-guard' +import { rateLimiter } from '../middlewares/rate-limit' import { createBadRequestError, createServiceUnavailableError } from '../utils/error' const logger = useLogger('stripe') +// Max checkout amount: $10,000 (1,000,000 cents) +const MAX_CHECKOUT_AMOUNT = 1_000_000 + const CheckoutBodySchema = object({ - amount: pipe(number(), integer(), minValue(1)), + amount: pipe(number(), integer(), minValue(1), maxValue(MAX_CHECKOUT_AMOUNT)), }) -export function createStripeRoutes(fluxService: FluxService, stripeService: StripeService, configKV: ConfigKVService, env: Env) { +export function createStripeRoutes( + fluxService: FluxService, + stripeService: StripeService, + billingService: BillingService, + configKV: ConfigKVService, + env: Env, + metrics?: RevenueMetrics | null, +) { const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null const fluxConfigGuard = configGuard(configKV, ['FLUX_PER_CENT'], 'Top-up is not available yet') @@ -30,7 +44,7 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri const packages = await configKV.getOptional('FLUX_PACKAGES') return c.json(packages ?? []) }) - .post('/checkout', authGuard, fluxConfigGuard, async (c) => { + .post('/checkout', authGuard, rateLimiter({ max: 10, windowSec: 60 }), fluxConfigGuard, async (c) => { if (!stripe) throw createServiceUnavailableError('Stripe is not configured', 'STRIPE_NOT_CONFIGURED') @@ -89,6 +103,8 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null, }) + metrics?.stripeCheckoutCreated.add(1) + return c.json({ url: session.url }) }) @@ -139,15 +155,16 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET) } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Unknown error' - throw createBadRequestError(`Webhook Error: ${message}`, 'WEBHOOK_ERROR') + throw createBadRequestError(`Webhook Error: ${errorMessageFrom(err) ?? 'Unknown error'}`, 'WEBHOOK_ERROR') } logger.withFields({ type: event.type, id: event.id }).log('Webhook event received') + metrics?.stripeEvents.add(1, { event_type: event.type }) switch (event.type) { case 'checkout.session.completed': { - await handleCheckoutSessionCompleted(event.data.object, fluxService, stripeService, configKV) + await handleCheckoutSessionCompleted(event.id, event.data.object, fluxService, stripeService, billingService, configKV) + metrics?.stripeCheckoutCompleted.add(1) break } case 'customer.created': @@ -159,13 +176,17 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri case 'customer.subscription.updated': case 'customer.subscription.deleted': { await handleSubscriptionEvent(event.data.object, stripeService) + metrics?.stripeSubscriptionEvent.add(1, { event_type: event.type.replace('customer.subscription.', '') }) break } case 'invoice.created': case 'invoice.updated': case 'invoice.paid': case 'invoice.payment_failed': { - await handleInvoiceEvent(event.data.object, fluxService, stripeService, configKV) + await handleInvoiceEvent(event.id, event.data.object, stripeService, billingService, configKV) + if (event.type === 'invoice.payment_failed') { + metrics?.stripePaymentFailed.add(1) + } break } } @@ -177,9 +198,11 @@ export function createStripeRoutes(fluxService: FluxService, stripeService: Stri // ---- Webhook handlers ---- async function handleCheckoutSessionCompleted( + stripeEventId: string, session: Stripe.Checkout.Session, fluxService: FluxService, stripeService: StripeService, + billingService: BillingService, configKV: ConfigKVService, ) { const userId = session.metadata?.userId @@ -198,7 +221,6 @@ async function handleCheckoutSessionCompleted( stripeCustomerId, email: session.customer_email ?? undefined, }) - // Keep the legacy field in sync await fluxService.updateStripeCustomerId(userId, stripeCustomerId) } @@ -220,12 +242,29 @@ async function handleCheckoutSessionCompleted( expiresAt: session.expires_at ? new Date(session.expires_at * 1000) : null, }) - // Add flux for one-time payments + // Idempotent flux credit: use fluxCredited flag inside a transaction + // to prevent double-crediting on webhook replay if (session.mode === 'payment' && session.amount_total) { const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT') const fluxAmount = session.amount_total * fluxPerCent - logger.withFields({ userId, fluxAmount, fluxPerCent, amountTotal: session.amount_total }).log('Adding flux for one-time payment') - await fluxService.addFlux(userId, fluxAmount, `Stripe payment ${session.currency?.toUpperCase()} ${(session.amount_total / 100).toFixed(2)}`) + + const result = await billingService.creditFluxFromStripeCheckout({ + stripeEventId, + userId, + stripeSessionId: session.id, + amountTotal: session.amount_total, + currency: session.currency, + fluxAmount, + }) + + logger.withFields({ + userId, + fluxAmount, + fluxPerCent, + amountTotal: session.amount_total, + applied: result.applied, + balanceAfter: result.balanceAfter, + }).log('Processed flux credit for one-time payment') } } @@ -268,7 +307,7 @@ async function handleSubscriptionEvent( status: subscription.status, currentPeriodStart: firstItem?.current_period_start ? new Date(firstItem.current_period_start * 1000) : null, currentPeriodEnd: firstItem?.current_period_end ? new Date(firstItem.current_period_end * 1000) : null, - cancelAtPeriodEnd: String(subscription.cancel_at_period_end), + cancelAtPeriodEnd: subscription.cancel_at_period_end, canceledAt: subscription.canceled_at ? new Date(subscription.canceled_at * 1000) : null, endedAt: subscription.ended_at ? new Date(subscription.ended_at * 1000) : null, metadata: subscription.metadata ? JSON.stringify(subscription.metadata) : null, @@ -276,9 +315,10 @@ async function handleSubscriptionEvent( } async function handleInvoiceEvent( + stripeEventId: string, invoice: Stripe.Invoice, - fluxService: FluxService, stripeService: StripeService, + billingService: BillingService, configKV: ConfigKVService, ) { const stripeCustomerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id @@ -312,11 +352,20 @@ async function handleInvoiceEvent( metadata: invoice.metadata ? JSON.stringify(invoice.metadata) : null, }) - // Add flux when a subscription invoice is paid + // Idempotent flux credit for subscription invoice payments if (invoice.status === 'paid' && invoice.amount_paid && subscriptionId) { const fluxPerCent = await configKV.getOrThrow('FLUX_PER_CENT') const fluxAmount = invoice.amount_paid * fluxPerCent - logger.withFields({ userId: customer.userId, fluxAmount, invoiceId: invoice.id }).log('Adding flux for subscription invoice') - await fluxService.addFlux(customer.userId, fluxAmount, `Subscription invoice ${invoice.currency?.toUpperCase()} ${(invoice.amount_paid / 100).toFixed(2)}`) + + const result = await billingService.creditFluxFromInvoice({ + stripeEventId, + userId: customer.userId, + stripeInvoiceId: invoice.id, + amountPaid: invoice.amount_paid, + currency: invoice.currency ?? 'unknown', + fluxAmount, + }) + + logger.withFields({ userId: customer.userId, fluxAmount, invoiceId: invoice.id, applied: result.applied }).log('Processed flux credit for subscription invoice') } } diff --git a/apps/server/src/routes/v1completions.ts b/apps/server/src/routes/v1completions.ts index 6df2628eb..e6192c72b 100644 --- a/apps/server/src/routes/v1completions.ts +++ b/apps/server/src/routes/v1completions.ts @@ -1,6 +1,10 @@ +/* eslint-disable unused-imports/no-unused-vars */ + import type { Context } from 'hono' -import type { initOtel } from '../libs/otel' +import type { LlmMetrics } from '../libs/otel' +import type { UsageInfo } from '../services/billing' +import type { BillingService } from '../services/billing-service' import type { ConfigKVService } from '../services/config-kv' import type { FluxService } from '../services/flux' import type { RequestLogService } from '../services/request-log' @@ -12,9 +16,11 @@ 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' import { createPaymentRequiredError } from '../utils/error' +import { nanoid } from '../utils/id' -type OtelMetrics = ReturnType const tracer = trace.getTracer('v1-completions') const SAFE_RESPONSE_HEADERS = new Set([ @@ -26,10 +32,10 @@ const SAFE_RESPONSE_HEADERS = new Set([ function buildSafeResponseHeaders(response: Response): Headers { const headers = new Headers() - response.headers.forEach((value, key) => { + for (const [key, value] of response.headers) { if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase())) headers.set(key, value) - }) + } return headers } @@ -37,55 +43,28 @@ function normalizeBaseUrl(gatewayBaseUrl: string): string { return gatewayBaseUrl.endsWith('/') ? gatewayBaseUrl : `${gatewayBaseUrl}/` } -interface UsageInfo { - promptTokens?: number - completionTokens?: number -} - -function extractUsageFromBody(body: any): UsageInfo { - const usage = body?.usage - if (!usage) - return {} - return { - promptTokens: usage.prompt_tokens ?? undefined, - completionTokens: usage.completion_tokens ?? undefined, - } -} - -function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number { - const { promptTokens, completionTokens } = usage - if (promptTokens != null && completionTokens != null) { - const totalTokens = promptTokens + completionTokens - return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens)) - } - return fallbackRate -} - -function buildFluxAuditMetadata(usage: UsageInfo): Record | undefined { - const metadata: Record = {} - if (usage.promptTokens != null) - metadata.promptTokens = usage.promptTokens - if (usage.completionTokens != null) - metadata.completionTokens = usage.completionTokens - return Object.keys(metadata).length > 0 ? metadata : undefined -} - -export function createV1CompletionsRoutes(fluxService: FluxService, configKV: ConfigKVService, requestLogService: RequestLogService, otel: OtelMetrics | null) { +export function createV1CompletionsRoutes(fluxService: FluxService, billingService: BillingService, configKV: ConfigKVService, requestLogService: RequestLogService, llm: LlmMetrics | null) { const logger = useLogger('v1-completions').useGlobalConfig() function recordMetrics(opts: { model: string, status: number, type: string, durationMs: number, fluxConsumed: number, promptTokens?: number, completionTokens?: number }) { - if (!otel) + if (!llm) return const attrs = { model: opts.model, type: opts.type, status: opts.status } - otel.llmRequestCount.add(1, attrs) - otel.llmRequestDuration.record(opts.durationMs, attrs) - otel.fluxConsumed.add(opts.fluxConsumed, { model: opts.model, type: opts.type }) + llm.requestCount.add(1, attrs) + llm.requestDuration.record(opts.durationMs, attrs) + llm.fluxConsumed.add(opts.fluxConsumed, { model: opts.model, type: opts.type }) if (opts.promptTokens != null) - otel.llmTokensPrompt.add(opts.promptTokens, { model: opts.model }) + llm.tokensPrompt.add(opts.promptTokens, { model: opts.model }) if (opts.completionTokens != null) - otel.llmTokensCompletion.add(opts.completionTokens, { model: opts.model }) + llm.tokensCompletion.add(opts.completionTokens, { model: opts.model }) } + // NOTICE: Billing is best-effort — flux is debited AFTER the LLM response is sent. + // This is a deliberate tradeoff: users get lower latency and uninterrupted streaming, + // at the cost of a small revenue leak when debit fails (e.g. DB timeout). + // Failed debits are logged at error level for monitoring/alerting. + // A pre-debit model would require holding the response until billing confirms, + // which adds latency and complicates streaming. We accept the leak for now. async function handleCompletion(c: Context) { const user = c.get('user')! const flux = await fluxService.getFlux(user.id) @@ -180,21 +159,28 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co span.end() recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed, ...usage }) - // Best-effort billing — don't throw on insufficient flux during streaming + // Debit flux via DB transaction (source of truth) + // NOTICE: streaming response is already sent, so we cannot reject on failure. + // Log at error level so unpaid usage is visible in monitoring/alerts. + const requestId = nanoid() + let actualCharged = 0 try { - await fluxService.consumeFlux(user.id, fluxConsumed, { + await billingService.debitFlux({ + userId: user.id, + amount: fluxConsumed, + requestId, description: requestModel, - metadata: buildFluxAuditMetadata(usage), }) + actualCharged = fluxConsumed } - catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux after streaming') } + catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed, requestId }).error('Failed to debit flux after streaming — unpaid usage') } requestLogService.logRequest({ userId: user.id, model: requestModel, status: response.status, durationMs, - fluxConsumed, + fluxConsumed: actualCharged, promptTokens: usage.promptTokens, completionTokens: usage.completionTokens, }).catch(err => logger.withError(err).warn('Failed to log streaming request')) @@ -220,15 +206,15 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co span.end() recordMetrics({ model: requestModel, status: response.status, type: 'chat', durationMs, fluxConsumed, ...usage }) - // Best-effort billing — gateway already processed the request, - // don't return 402 after work is done - try { - await fluxService.consumeFlux(user.id, fluxConsumed, { - description: requestModel, - metadata: buildFluxAuditMetadata(usage), - }) - } - catch (err) { logger.withError(err).withFields({ userId: user.id, fluxConsumed }).warn('Failed to consume flux') } + // Debit flux via DB transaction (source of truth) + // NOTICE: no try/catch — debit failure (e.g. insufficient balance) must block the response + const requestId = nanoid() + await billingService.debitFlux({ + userId: user.id, + amount: fluxConsumed, + requestId, + description: requestModel, + }) requestLogService.logRequest({ userId: user.id, @@ -243,14 +229,146 @@ export function createV1CompletionsRoutes(fluxService: FluxService, configKV: Co return c.json(responseBody) } - // TODO: TTS and ASR handlers are implemented but routes are disabled until ready - // async function handleTTS(c: Context) { ... } - // async function handleTranscription(c: Context) { ... } + async function handleTTS(c: Context) { + const user = c.get('user')! + const flux = await fluxService.getFlux(user.id) + if (flux.flux <= 0) { + throw createPaymentRequiredError('Insufficient flux') + } + + const body = await c.req.json() + const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL') + const baseUrl = normalizeBaseUrl(gatewayBaseUrl) + const requestModel = body.model || 'auto' + + const span = tracer.startSpan('llm.gateway.tts', { + attributes: { 'llm.model': requestModel }, + }) + + const startedAt = Date.now() + + const response = await context.with(trace.setSpan(context.active(), span), () => + fetch(`${baseUrl}audio/speech`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + })) + + const durationMs = Date.now() - startedAt + span.setAttribute('http.response.status_code', response.status) + + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` }) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: 0 }) + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST_TTS') + await billingService.debitFlux({ + userId: user.id, + amount: fluxPerRequest, + requestId: nanoid(), + description: `tts:${requestModel}`, + }) + + span.setAttribute('llm.flux_consumed', fluxPerRequest) + span.end() + recordMetrics({ model: requestModel, status: response.status, type: 'tts', durationMs, fluxConsumed: fluxPerRequest }) + + requestLogService.logRequest({ + userId: user.id, + model: requestModel, + status: response.status, + durationMs, + fluxConsumed: fluxPerRequest, + }).catch(err => logger.withError(err).warn('Failed to log TTS request')) + + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + async function handleTranscription(c: Context) { + const user = c.get('user')! + const flux = await fluxService.getFlux(user.id) + if (flux.flux <= 0) { + throw createPaymentRequiredError('Insufficient flux') + } + + const gatewayBaseUrl = await configKV.getOrThrow('GATEWAY_BASE_URL') + const baseUrl = normalizeBaseUrl(gatewayBaseUrl) + + const span = tracer.startSpan('llm.gateway.asr', { + attributes: { 'llm.model': 'auto' }, + }) + + const startedAt = Date.now() + + const rawBody = await c.req.arrayBuffer() + const contentType = c.req.header('content-type') || 'multipart/form-data' + + const response = await context.with(trace.setSpan(context.active(), span), () => + fetch(`${baseUrl}audio/transcriptions`, { + method: 'POST', + headers: { 'Content-Type': contentType }, + body: rawBody, + })) + + const durationMs = Date.now() - startedAt + span.setAttribute('http.response.status_code', response.status) + + if (!response.ok) { + span.setStatus({ code: SpanStatusCode.ERROR, message: `Gateway ${response.status}` }) + span.end() + recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: 0 }) + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } + + const fluxPerRequest = await configKV.getOrThrow('FLUX_PER_REQUEST_ASR') + await billingService.debitFlux({ + userId: user.id, + amount: fluxPerRequest, + requestId: nanoid(), + description: `asr:auto`, + }) + + span.setAttribute('llm.flux_consumed', fluxPerRequest) + span.end() + recordMetrics({ model: 'auto', status: response.status, type: 'asr', durationMs, fluxConsumed: fluxPerRequest }) + + requestLogService.logRequest({ + userId: user.id, + model: 'auto', + status: response.status, + durationMs, + fluxConsumed: fluxPerRequest, + }).catch(err => logger.withError(err).warn('Failed to log ASR request')) + + return new Response(response.body, { + status: response.status, + headers: buildSafeResponseHeaders(response), + }) + } const chatGuard = configGuard(configKV, ['FLUX_PER_REQUEST', 'GATEWAY_BASE_URL', 'DEFAULT_CHAT_MODEL'], 'Service is not available yet') + const ttsGuard = configGuard(configKV, ['FLUX_PER_REQUEST_TTS', 'GATEWAY_BASE_URL'], 'TTS service is not available yet') + const asrGuard = configGuard(configKV, ['FLUX_PER_REQUEST_ASR', 'GATEWAY_BASE_URL'], 'ASR service is not available yet') + + // 60 requests per minute per user for LLM completions + const completionsRateLimit = rateLimiter({ max: 60, windowSec: 60 }) return new Hono() .use('*', authGuard) - .post('/chat/completions', chatGuard, handleCompletion) - .post('/chat/completion', chatGuard, handleCompletion) + .post('/chat/completions', completionsRateLimit, chatGuard, handleCompletion) + .post('/chat/completion', completionsRateLimit, chatGuard, handleCompletion) + // .post('/audio/speech', ttsGuard, handleTTS) + // .post('/audio/transcriptions', bodyLimit({ maxSize: 25 * 1024 * 1024 }), asrGuard, handleTranscription) } diff --git a/apps/server/src/schemas/chats.ts b/apps/server/src/schemas/chats.ts index efc034caa..64288d8de 100644 --- a/apps/server/src/schemas/chats.ts +++ b/apps/server/src/schemas/chats.ts @@ -74,8 +74,9 @@ export const messages = pgTable( id: text('id').primaryKey().$defaultFn(() => nanoid()), chatId: text('chat_id').notNull().references(() => chats.id, { onDelete: 'cascade' }), - senderId: text('sender_id').notNull(), + senderId: text('sender_id'), role: text('role').notNull(), + seq: integer('seq'), content: text('content').notNull(), mediaIds: text('media_ids').array().notNull(), diff --git a/apps/server/src/schemas/flux-ledger.ts b/apps/server/src/schemas/flux-ledger.ts new file mode 100644 index 000000000..fd749d25d --- /dev/null +++ b/apps/server/src/schemas/flux-ledger.ts @@ -0,0 +1,23 @@ +import { sql } from 'drizzle-orm' +import { index, integer, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' +import { user } from './accounts' + +export const fluxLedger = pgTable('flux_ledger', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + type: text('type').notNull(), // 'credit' | 'debit' | 'initial' + amount: integer('amount').notNull(), // always positive + balanceBefore: integer('balance_before').notNull(), + balanceAfter: integer('balance_after').notNull(), + requestId: text('request_id'), // nullable; used for idempotency on debit/credit + description: text('description').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), +}, table => [ + index('flux_ledger_user_id_idx').on(table.userId), + index('flux_ledger_created_at_idx').on(table.createdAt), + uniqueIndex('flux_ledger_user_request_uniq') + .on(table.userId, table.requestId) + .where(sql`request_id IS NOT NULL`), +]) diff --git a/apps/server/src/schemas/index.ts b/apps/server/src/schemas/index.ts index 1e0247be5..3d1363da6 100644 --- a/apps/server/src/schemas/index.ts +++ b/apps/server/src/schemas/index.ts @@ -3,7 +3,9 @@ export * from './characters' export * from './chats' export * from './flux' export * from './flux-audit-log' +export * from './flux-ledger' export * from './llm-request-log' +export * from './outbox-events' export * from './providers' export * from './stripe' export * from './user-character' diff --git a/apps/server/src/schemas/llm-request-log.ts b/apps/server/src/schemas/llm-request-log.ts index c102a592f..b0bb219b0 100644 --- a/apps/server/src/schemas/llm-request-log.ts +++ b/apps/server/src/schemas/llm-request-log.ts @@ -1,17 +1,15 @@ -import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' -import { user } from './accounts' export const llmRequestLog = pgTable('llm_request_log', { id: text('id').primaryKey().$defaultFn(() => nanoid()), - userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + userId: text('user_id').notNull(), // NOTICE: do NOT use foreign key constraint here to avoid potential performance issues on high-concurrency writes model: text('model').notNull(), status: integer('status').notNull(), durationMs: integer('duration_ms').notNull(), fluxConsumed: integer('flux_consumed').notNull(), promptTokens: integer('prompt_tokens'), completionTokens: integer('completion_tokens'), - settled: boolean('settled').notNull().default(false), createdAt: timestamp('created_at').defaultNow().notNull(), }) diff --git a/apps/server/src/schemas/outbox-events.ts b/apps/server/src/schemas/outbox-events.ts new file mode 100644 index 000000000..dabc7f8c7 --- /dev/null +++ b/apps/server/src/schemas/outbox-events.ts @@ -0,0 +1,31 @@ +import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' + +import { index, integer, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' + +import { nanoid } from '../utils/id' + +export const outboxEvents = pgTable('outbox_events', { + id: text('id').primaryKey().$defaultFn(() => nanoid()), + eventId: text('event_id').notNull(), + eventType: text('event_type').notNull(), + aggregateId: text('aggregate_id').notNull(), + userId: text('user_id').notNull(), + requestId: text('request_id'), + schemaVersion: integer('schema_version').notNull(), + payload: text('payload').notNull(), + occurredAt: timestamp('occurred_at').notNull(), + availableAt: timestamp('available_at').defaultNow().notNull(), + claimedBy: text('claimed_by'), + claimExpiresAt: timestamp('claim_expires_at'), + publishedAt: timestamp('published_at'), + streamMessageId: text('stream_message_id'), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().notNull(), +}, table => [ + uniqueIndex('outbox_events_event_id_idx').on(table.eventId), + index('outbox_events_publish_scan_idx').on(table.publishedAt, table.availableAt, table.claimExpiresAt, table.createdAt), + index('outbox_events_claimed_by_idx').on(table.claimedBy), +]) + +export type OutboxEvent = InferSelectModel +export type NewOutboxEvent = InferInsertModel diff --git a/apps/server/src/schemas/stripe.ts b/apps/server/src/schemas/stripe.ts index 0ea4b78aa..2b7193703 100644 --- a/apps/server/src/schemas/stripe.ts +++ b/apps/server/src/schemas/stripe.ts @@ -1,7 +1,7 @@ import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' import { relations } from 'drizzle-orm' -import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' +import { boolean, integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core' import { nanoid } from '../utils/id' import { user } from './accounts' @@ -36,6 +36,7 @@ export const stripeCheckoutSession = pgTable('stripe_checkout_session', { cancelUrl: text('cancel_url'), stripePaymentIntentId: text('stripe_payment_intent_id'), stripeSubscriptionId: text('stripe_subscription_id'), + fluxCredited: boolean('flux_credited').notNull().default(false), metadata: text('metadata'), // JSON stringified expiresAt: timestamp('expires_at'), createdAt: timestamp('created_at').defaultNow().notNull(), @@ -54,7 +55,7 @@ export const stripeSubscription = pgTable('stripe_subscription', { status: text('status').notNull(), // 'active' | 'past_due' | 'canceled' | 'incomplete' | etc currentPeriodStart: timestamp('current_period_start'), currentPeriodEnd: timestamp('current_period_end'), - cancelAtPeriodEnd: text('cancel_at_period_end'), // 'true' | 'false' + cancelAtPeriodEnd: boolean('cancel_at_period_end'), canceledAt: timestamp('canceled_at'), endedAt: timestamp('ended_at'), metadata: text('metadata'), // JSON stringified @@ -80,6 +81,7 @@ export const stripeInvoice = pgTable('stripe_invoice', { periodStart: timestamp('period_start'), periodEnd: timestamp('period_end'), paidAt: timestamp('paid_at'), + fluxCredited: boolean('flux_credited').notNull().default(false), metadata: text('metadata'), // JSON stringified createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), diff --git a/apps/server/src/services/__test__/billing-events.test.ts b/apps/server/src/services/__test__/billing-events.test.ts new file mode 100644 index 000000000..dc7b26c7d --- /dev/null +++ b/apps/server/src/services/__test__/billing-events.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' + +import { parseBillingEvent, serializeBillingEvent } from '../billing-events' + +describe('billingEvents', () => { + it('serializes and parses a flux debited event', () => { + const event = { + eventId: 'evt-1', + eventType: 'flux.debited' as const, + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 12, + balanceAfter: 88, + source: 'llm', + }, + } + + const serialized = serializeBillingEvent(event) + expect(serialized).toEqual({ + event_id: 'evt-1', + event_type: 'flux.debited', + aggregate_id: 'user-1', + user_id: 'user-1', + request_id: 'req-1', + occurred_at: '2026-03-24T00:00:00.000Z', + schema_version: '1', + payload: JSON.stringify({ + amount: 12, + balanceAfter: 88, + source: 'llm', + }), + }) + + expect(parseBillingEvent(serialized)).toEqual(event) + }) + + it('serializes and parses a flux credited event without request id', () => { + const event = { + eventId: 'evt-2', + eventType: 'flux.credited' as const, + aggregateId: 'user-2', + userId: 'user-2', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 20, + balanceAfter: 120, + source: 'stripe', + }, + } + + expect(parseBillingEvent(serializeBillingEvent(event))).toEqual(event) + }) + + it('parses stripe checkout completed payloads', () => { + const parsed = parseBillingEvent({ + event_id: 'evt-3', + event_type: 'stripe.checkout.completed', + aggregate_id: 'checkout-1', + user_id: 'user-3', + occurred_at: '2026-03-24T00:00:00.000Z', + schema_version: '1', + payload: JSON.stringify({ + stripeEventId: 'stripe-evt-1', + stripeSessionId: 'cs_test_123', + amount: 999, + currency: 'usd', + }), + }) + + expect(parsed).toEqual({ + eventId: 'evt-3', + eventType: 'stripe.checkout.completed', + aggregateId: 'checkout-1', + userId: 'user-3', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + stripeEventId: 'stripe-evt-1', + stripeSessionId: 'cs_test_123', + amount: 999, + currency: 'usd', + }, + }) + }) + + it('parses llm request completed payloads', () => { + const parsed = parseBillingEvent({ + event_id: 'evt-4', + event_type: 'llm.request.completed', + aggregate_id: 'req-4', + user_id: 'user-4', + request_id: 'req-4', + occurred_at: '2026-03-24T00:00:00.000Z', + schema_version: '1', + payload: JSON.stringify({ + model: 'gpt-5', + status: 200, + fluxConsumed: 3, + promptTokens: 100, + completionTokens: 200, + }), + }) + + expect(parsed).toEqual({ + eventId: 'evt-4', + eventType: 'llm.request.completed', + aggregateId: 'req-4', + userId: 'user-4', + requestId: 'req-4', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + model: 'gpt-5', + status: 200, + fluxConsumed: 3, + promptTokens: 100, + completionTokens: 200, + }, + }) + }) + + it('throws when payload json is invalid', () => { + expect(() => parseBillingEvent({ + event_id: 'evt-5', + event_type: 'flux.debited', + aggregate_id: 'user-5', + user_id: 'user-5', + occurred_at: '2026-03-24T00:00:00.000Z', + schema_version: '1', + payload: '{', + })).toThrow() + }) + + it('throws when payload shape does not match event type', () => { + expect(() => parseBillingEvent({ + event_id: 'evt-6', + event_type: 'stripe.checkout.completed', + aggregate_id: 'checkout-6', + user_id: 'user-6', + occurred_at: '2026-03-24T00:00:00.000Z', + schema_version: '1', + payload: JSON.stringify({ + amount: 100, + currency: 'usd', + }), + })).toThrow() + }) +}) diff --git a/apps/server/src/services/__test__/billing-mq-worker.test.ts b/apps/server/src/services/__test__/billing-mq-worker.test.ts new file mode 100644 index 000000000..1489ec979 --- /dev/null +++ b/apps/server/src/services/__test__/billing-mq-worker.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createBillingMqWorker } from '../billing-mq-worker' + +function createMessage() { + return { + streamMessageId: '1740000000000-0', + event: { + eventId: 'evt-1', + eventType: 'flux.debited' as const, + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 5, + balanceAfter: 95, + source: 'llm', + }, + }, + } +} + +describe('billingMqWorker', () => { + it('reclaims pending messages before reading new ones and acks after handling', async () => { + const controller = new AbortController() + const message = createMessage() + + const mq = { + ensureConsumerGroup: vi.fn(async () => true), + claimIdleMessages: vi.fn(async () => [message]), + consume: vi.fn(async () => []), + ack: vi.fn(async () => 1), + } + + const worker = createBillingMqWorker(mq as any) + const handled: string[] = [] + + await worker.run({ + group: 'billing', + consumer: 'billing-1', + signal: controller.signal, + onMessage: vi.fn(async (incomingMessage) => { + handled.push(incomingMessage.event.eventId) + controller.abort() + }), + }) + + expect(mq.ensureConsumerGroup).toHaveBeenCalledWith('billing') + expect(mq.claimIdleMessages).toHaveBeenCalledWith({ + group: 'billing', + consumer: 'billing-1', + minIdleTimeMs: 30000, + count: 10, + }) + expect(mq.consume).not.toHaveBeenCalled() + expect(mq.ack).toHaveBeenCalledWith('billing', '1740000000000-0') + expect(handled).toEqual(['evt-1']) + }) + + it('reads new messages when there are no idle pending messages', async () => { + const controller = new AbortController() + const message = createMessage() + + const mq = { + ensureConsumerGroup: vi.fn(async () => true), + claimIdleMessages: vi.fn(async () => []), + consume: vi.fn(async () => [message]), + ack: vi.fn(async () => 1), + } + + const worker = createBillingMqWorker(mq as any) + + await worker.run({ + group: 'billing', + consumer: 'billing-1', + signal: controller.signal, + batchSize: 5, + blockMs: 250, + minIdleTimeMs: 1000, + onMessage: vi.fn(async () => { + controller.abort() + }), + }) + + expect(mq.consume).toHaveBeenCalledWith({ + group: 'billing', + consumer: 'billing-1', + count: 5, + blockMs: 250, + }) + }) + + it('leaves failed messages pending by not acking them', async () => { + const controller = new AbortController() + + const mq = { + ensureConsumerGroup: vi.fn(async () => true), + claimIdleMessages: vi.fn(async () => [createMessage()]), + consume: vi.fn(async () => []), + ack: vi.fn(async () => 1), + } + + const worker = createBillingMqWorker(mq as any) + + await worker.run({ + group: 'billing', + consumer: 'billing-1', + signal: controller.signal, + onMessage: vi.fn(async () => { + controller.abort() + throw new Error('handler failed') + }), + }) + + expect(mq.ack).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/services/__test__/billing-mq.test.ts b/apps/server/src/services/__test__/billing-mq.test.ts new file mode 100644 index 000000000..a0398def5 --- /dev/null +++ b/apps/server/src/services/__test__/billing-mq.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createBillingMqService } from '../billing-mq' + +function createEvent() { + return { + eventId: 'evt-1', + eventType: 'flux.debited' as const, + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 5, + balanceAfter: 95, + source: 'llm', + }, + } +} + +describe('billingMqService', () => { + it('publishes an event to the configured stream', async () => { + const redis = { + call: vi.fn(async () => '1740000000000-0'), + } + + const mq = createBillingMqService(redis, { + stream: 'billing-events-test', + maxLength: 1_000, + }) + + await expect(mq.publish(createEvent())).resolves.toBe('1740000000000-0') + expect(redis.call).toHaveBeenCalledWith( + 'XADD', + 'billing-events-test', + 'MAXLEN', + '~', + 1000, + '*', + 'event_id', + 'evt-1', + 'event_type', + 'flux.debited', + 'aggregate_id', + 'user-1', + 'user_id', + 'user-1', + 'request_id', + 'req-1', + 'occurred_at', + '2026-03-24T00:00:00.000Z', + 'schema_version', + '1', + 'payload', + JSON.stringify({ + amount: 5, + balanceAfter: 95, + source: 'llm', + }), + ) + }) + + it('throws when publish does not return a stream message id', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => 123), + }) + + await expect(mq.publish(createEvent())).rejects.toThrow('Redis XADD did not return a stream message id') + }) + + it('creates a consumer group and returns true when the group is new', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => 'OK'), + }) + + await expect(mq.ensureConsumerGroup('billing')).resolves.toBe(true) + }) + + it('returns false when the consumer group already exists', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => { + throw new Error('BUSYGROUP Consumer Group name already exists') + }), + }) + + await expect(mq.ensureConsumerGroup('billing')).resolves.toBe(false) + }) + + it('rethrows non-BUSYGROUP errors when creating a consumer group', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => { + throw new Error('NOAUTH') + }), + }) + + await expect(mq.ensureConsumerGroup('billing')).rejects.toThrow('NOAUTH') + }) + + it('consumes stream entries from a consumer group', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => [[ + 'billing-events', + [[ + '1740000000000-0', + [ + 'event_id', + 'evt-1', + 'event_type', + 'flux.debited', + 'aggregate_id', + 'user-1', + 'user_id', + 'user-1', + 'request_id', + 'req-1', + 'occurred_at', + '2026-03-24T00:00:00.000Z', + 'schema_version', + '1', + 'payload', + JSON.stringify({ + amount: 5, + balanceAfter: 95, + source: 'llm', + }), + ], + ]], + ]]), + }) + + await expect(mq.consume({ + group: 'billing', + consumer: 'consumer-1', + count: 20, + blockMs: 100, + })).resolves.toEqual([{ + streamMessageId: '1740000000000-0', + event: createEvent(), + }]) + }) + + it('returns an empty array when no messages are available', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => null), + }) + + await expect(mq.consume({ + group: 'billing', + consumer: 'consumer-1', + })).resolves.toEqual([]) + }) + + it('throws when xreadgroup returns an invalid payload', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => ['not-an-array-entry']), + }) + + await expect(mq.consume({ + group: 'billing', + consumer: 'consumer-1', + })).rejects.toThrow('Redis XREADGROUP returned an invalid stream payload') + }) + + it('claims idle pending messages', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => [ + '1740000000001-0', + [[ + '1740000000000-0', + [ + 'event_id', + 'evt-1', + 'event_type', + 'flux.debited', + 'aggregate_id', + 'user-1', + 'user_id', + 'user-1', + 'request_id', + 'req-1', + 'occurred_at', + '2026-03-24T00:00:00.000Z', + 'schema_version', + '1', + 'payload', + JSON.stringify({ + amount: 5, + balanceAfter: 95, + source: 'llm', + }), + ], + ]], + [], + ]), + }) + + await expect(mq.claimIdleMessages({ + group: 'billing', + consumer: 'consumer-1', + minIdleTimeMs: 30_000, + })).resolves.toEqual([{ + streamMessageId: '1740000000000-0', + event: createEvent(), + }]) + }) + + it('throws when xautoclaim returns an invalid payload', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => ['1740000000001-0']), + }) + + await expect(mq.claimIdleMessages({ + group: 'billing', + consumer: 'consumer-1', + minIdleTimeMs: 30_000, + })).rejects.toThrow('Redis XAUTOCLAIM returned an invalid response') + }) + + it('acks one or more stream messages', async () => { + const redis = { + call: vi.fn(async () => 2), + } + + const mq = createBillingMqService(redis) + await expect(mq.ack('billing', ['1-0', '2-0'])).resolves.toBe(2) + expect(redis.call).toHaveBeenCalledWith('XACK', 'billing-events', 'billing', '1-0', '2-0') + }) + + it('returns zero when ack receives an empty message id list', async () => { + const redis = { + call: vi.fn(), + } + + const mq = createBillingMqService(redis) + await expect(mq.ack('billing', [])).resolves.toBe(0) + expect(redis.call).not.toHaveBeenCalled() + }) + + it('throws when ack does not return a number', async () => { + const mq = createBillingMqService({ + call: vi.fn(async () => '2'), + }) + + await expect(mq.ack('billing', '1-0')).rejects.toThrow('Redis XACK did not return an acknowledgement count') + }) +}) diff --git a/apps/server/src/services/__test__/billing-service.test.ts b/apps/server/src/services/__test__/billing-service.test.ts new file mode 100644 index 000000000..ecd115b47 --- /dev/null +++ b/apps/server/src/services/__test__/billing-service.test.ts @@ -0,0 +1,231 @@ +import type Redis from 'ioredis' + +import type { Database } from '../../libs/db' +import type { createConfigKVService } from '../config-kv' + +import { eq } from 'drizzle-orm' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createBillingService } from '../billing-service' +import { createOutboxService } from '../outbox-service' + +import * as schema from '../../schemas' + +function createMockConfigKV(overrides: Record = {}): ReturnType { + const defaults: Record = { INITIAL_USER_FLUX: 100, FLUX_PER_CENT: 1, FLUX_PER_REQUEST: 1, ...overrides } + return { + get: vi.fn(async (key: string) => defaults[key]), + getOrThrow: vi.fn(async (key: string) => defaults[key]), + getOptional: vi.fn(async (key: string) => defaults[key] ?? null), + set: vi.fn(), + } as any +} + +function createMockRedis(): Redis { + const store = new Map() + return { + get: vi.fn(async (key: string) => store.get(key) ?? null), + set: vi.fn(async (key: string, value: string) => { store.set(key, value); return 'OK' }), + } as unknown as Redis +} + +describe('billingService', () => { + let db: Database + let redis: Redis + let outboxService: ReturnType + let billingService: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + outboxService = createOutboxService(db) + + await db.insert(schema.user).values({ + id: 'user-billing-1', + name: 'Billing User', + email: 'billing@example.com', + }) + }) + + beforeEach(async () => { + redis = createMockRedis() + billingService = createBillingService(db, redis, outboxService, createMockConfigKV()) + + await db.delete(schema.outboxEvents) + await db.delete(schema.fluxAuditLog) + await db.delete(schema.fluxLedger) + await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1')) + await db.delete(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1')) + + await db.insert(schema.stripeCheckoutSession).values({ + userId: 'user-billing-1', + stripeSessionId: 'sess-billing-1', + mode: 'payment', + status: 'complete', + paymentStatus: 'paid', + amountTotal: 500, + currency: 'usd', + fluxCredited: false, + }) + }) + + describe('creditFluxFromStripeCheckout', () => { + it('credits flux, records ledger + audit, and enqueues outbox events in one transaction', async () => { + const result = await billingService.creditFluxFromStripeCheckout({ + stripeEventId: 'stripe-evt-1', + userId: 'user-billing-1', + stripeSessionId: 'sess-billing-1', + amountTotal: 500, + currency: 'usd', + fluxAmount: 50, + }) + + expect(result).toEqual({ applied: true, balanceAfter: 50 }) + + const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1')) + expect(fluxRecord?.flux).toBe(50) + + // Verify ledger entry + const ledgerRecords = await db.select().from(schema.fluxLedger).where(eq(schema.fluxLedger.userId, 'user-billing-1')) + expect(ledgerRecords).toHaveLength(1) + expect(ledgerRecords[0]?.type).toBe('credit') + expect(ledgerRecords[0]?.amount).toBe(50) + expect(ledgerRecords[0]?.balanceBefore).toBe(0) + expect(ledgerRecords[0]?.balanceAfter).toBe(50) + + // Verify audit log + const auditRecords = await db.select().from(schema.fluxAuditLog).where(eq(schema.fluxAuditLog.userId, 'user-billing-1')) + expect(auditRecords).toHaveLength(1) + expect(auditRecords[0]?.amount).toBe(50) + + // Verify outbox events + const outboxRecords = await db.select().from(schema.outboxEvents).orderBy(schema.outboxEvents.createdAt) + expect(outboxRecords).toHaveLength(2) + expect(outboxRecords.map(record => record.eventType)).toEqual(['flux.credited', 'stripe.checkout.completed']) + + // Verify stripe session marked as credited + const [sessionRecord] = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'sess-billing-1')) + expect(sessionRecord?.fluxCredited).toBe(true) + + // Verify Redis cache updated + expect(redis.set).toHaveBeenCalledWith('flux:user-billing-1', '50') + }) + + it('is idempotent when the checkout session was already credited', async () => { + await billingService.creditFluxFromStripeCheckout({ + stripeEventId: 'stripe-evt-1', + userId: 'user-billing-1', + stripeSessionId: 'sess-billing-1', + amountTotal: 500, + currency: 'usd', + fluxAmount: 50, + }) + + const second = await billingService.creditFluxFromStripeCheckout({ + stripeEventId: 'stripe-evt-1', + userId: 'user-billing-1', + stripeSessionId: 'sess-billing-1', + amountTotal: 500, + currency: 'usd', + fluxAmount: 50, + }) + + expect(second).toEqual({ applied: false }) + + const outboxRecords = await db.select().from(schema.outboxEvents) + expect(outboxRecords).toHaveLength(2) + }) + }) + + describe('debitFlux', () => { + it('deducts balance, writes ledger + audit + outbox, updates Redis', async () => { + // Setup: give user some flux first + await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 100 }) + + const result = await billingService.debitFlux({ + userId: 'user-billing-1', + amount: 30, + requestId: 'req-1', + description: 'gpt-4', + }) + + expect(result).toEqual({ userId: 'user-billing-1', flux: 70 }) + + // Verify DB balance + const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1')) + expect(fluxRecord?.flux).toBe(70) + + // Verify ledger + const ledgerRecords = await db.select().from(schema.fluxLedger).where(eq(schema.fluxLedger.userId, 'user-billing-1')) + expect(ledgerRecords).toHaveLength(1) + expect(ledgerRecords[0]).toMatchObject({ + type: 'debit', + amount: 30, + balanceBefore: 100, + balanceAfter: 70, + requestId: 'req-1', + }) + + // Verify audit log + const auditRecords = await db.select().from(schema.fluxAuditLog).where(eq(schema.fluxAuditLog.userId, 'user-billing-1')) + expect(auditRecords).toHaveLength(1) + expect(auditRecords[0]?.amount).toBe(-30) + + // Verify outbox event + const outboxRecords = await db.select().from(schema.outboxEvents) + expect(outboxRecords).toHaveLength(1) + expect(outboxRecords[0]?.eventType).toBe('flux.debited') + + // Verify Redis cache updated + expect(redis.set).toHaveBeenCalledWith('flux:user-billing-1', '70') + }) + + it('throws 402 when balance is insufficient', async () => { + await db.insert(schema.userFlux).values({ userId: 'user-billing-1', flux: 5 }) + + await expect(billingService.debitFlux({ + userId: 'user-billing-1', + amount: 10, + })).rejects.toThrow('Insufficient flux') + + // Verify no side effects + const [fluxRecord] = await db.select().from(schema.userFlux).where(eq(schema.userFlux.userId, 'user-billing-1')) + expect(fluxRecord?.flux).toBe(5) + + const ledgerRecords = await db.select().from(schema.fluxLedger) + expect(ledgerRecords).toHaveLength(0) + + const outboxRecords = await db.select().from(schema.outboxEvents) + expect(outboxRecords).toHaveLength(0) + }) + }) + + describe('creditFlux', () => { + it('credits balance with ledger + audit + outbox', async () => { + const result = await billingService.creditFlux({ + userId: 'user-billing-1', + amount: 50, + description: 'Admin grant', + source: 'admin', + }) + + expect(result.balanceAfter).toBe(50) + expect(result.balanceBefore).toBe(0) + + // Verify ledger + const ledgerRecords = await db.select().from(schema.fluxLedger).where(eq(schema.fluxLedger.userId, 'user-billing-1')) + expect(ledgerRecords).toHaveLength(1) + expect(ledgerRecords[0]).toMatchObject({ + type: 'credit', + amount: 50, + balanceBefore: 0, + balanceAfter: 50, + }) + + // Verify outbox + const outboxRecords = await db.select().from(schema.outboxEvents) + expect(outboxRecords).toHaveLength(1) + expect(outboxRecords[0]?.eventType).toBe('flux.credited') + }) + }) +}) diff --git a/apps/server/src/services/__test__/billing.test.ts b/apps/server/src/services/__test__/billing.test.ts new file mode 100644 index 000000000..ab601d621 --- /dev/null +++ b/apps/server/src/services/__test__/billing.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' + +import { calculateFluxFromUsage, extractUsageFromBody } from '../billing' + +describe('extractUsageFromBody', () => { + it('returns promptTokens and completionTokens from a normal body', () => { + const body = { usage: { prompt_tokens: 100, completion_tokens: 200 } } + expect(extractUsageFromBody(body)).toEqual({ promptTokens: 100, completionTokens: 200 }) + }) + + it('returns empty object when body has no usage field', () => { + expect(extractUsageFromBody({ model: 'gpt-4' })).toEqual({}) + }) + + it('returns empty object for null body', () => { + expect(extractUsageFromBody(null)).toEqual({}) + }) + + it('returns empty object for undefined body', () => { + expect(extractUsageFromBody(undefined)).toEqual({}) + }) + + it('returns empty object when usage is null', () => { + expect(extractUsageFromBody({ usage: null })).toEqual({}) + }) + + it('returns empty object when usage is falsy zero-like value (0)', () => { + expect(extractUsageFromBody({ usage: 0 })).toEqual({}) + }) + + it('returns only promptTokens when completion_tokens is missing', () => { + const body = { usage: { prompt_tokens: 50 } } + const result = extractUsageFromBody(body) + expect(result.promptTokens).toBe(50) + expect(result.completionTokens).toBeUndefined() + }) + + it('returns only completionTokens when prompt_tokens is missing', () => { + const body = { usage: { completion_tokens: 75 } } + const result = extractUsageFromBody(body) + expect(result.promptTokens).toBeUndefined() + expect(result.completionTokens).toBe(75) + }) + + it('treats explicit null fields in usage as undefined', () => { + const body = { usage: { prompt_tokens: null, completion_tokens: null } } + const result = extractUsageFromBody(body) + expect(result.promptTokens).toBeUndefined() + expect(result.completionTokens).toBeUndefined() + }) + + it('handles zero token values correctly', () => { + const body = { usage: { prompt_tokens: 0, completion_tokens: 0 } } + const result = extractUsageFromBody(body) + expect(result.promptTokens).toBe(0) + expect(result.completionTokens).toBe(0) + }) +}) + +describe('calculateFluxFromUsage', () => { + it('calculates flux based on total tokens and rate', () => { + const usage = { promptTokens: 500, completionTokens: 500 } + // 1000 tokens * 1 per 1k = 1 + expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1) + }) + + it('applies ceiling to fractional flux values', () => { + const usage = { promptTokens: 500, completionTokens: 501 } + // 1001 tokens * 1 per 1k = 1.001 → ceil → 2 + expect(calculateFluxFromUsage(usage, 1, 5)).toBe(2) + }) + + it('enforces a minimum of 1 flux even when calculation yields 0', () => { + const usage = { promptTokens: 1, completionTokens: 1 } + // 2 tokens * 1 per 1k = 0.002 → ceil → 1, max(1, 1) = 1 + expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1) + }) + + it('enforces minimum of 1 flux when tokens are zero', () => { + const usage = { promptTokens: 0, completionTokens: 0 } + // 0 tokens * anything = 0 → ceil → 0, max(1, 0) = 1 + expect(calculateFluxFromUsage(usage, 1, 5)).toBe(1) + }) + + it('falls back to fallbackRate when promptTokens is missing', () => { + const usage = { completionTokens: 500 } + expect(calculateFluxFromUsage(usage, 1, 7)).toBe(7) + }) + + it('falls back to fallbackRate when completionTokens is missing', () => { + const usage = { promptTokens: 500 } + expect(calculateFluxFromUsage(usage, 1, 7)).toBe(7) + }) + + it('falls back to fallbackRate when usage is empty', () => { + expect(calculateFluxFromUsage({}, 1, 3)).toBe(3) + }) + + it('uses a higher fluxPer1kTokens multiplier correctly', () => { + const usage = { promptTokens: 1000, completionTokens: 1000 } + // 2000 tokens * 5 per 1k = 10 + expect(calculateFluxFromUsage(usage, 5, 1)).toBe(10) + }) + + it('uses a fractional fluxPer1kTokens multiplier with ceiling', () => { + const usage = { promptTokens: 200, completionTokens: 200 } + // 400 tokens * 0.5 per 1k = 0.2 → ceil → 1, max(1, 1) = 1 + expect(calculateFluxFromUsage(usage, 0.5, 3)).toBe(1) + }) + + it('handles very large token counts', () => { + const usage = { promptTokens: 1_000_000, completionTokens: 1_000_000 } + // 2_000_000 tokens * 1 per 1k = 2000 + expect(calculateFluxFromUsage(usage, 1, 5)).toBe(2000) + }) + + it('handles exact 1k token boundary without ceiling', () => { + const usage = { promptTokens: 500, completionTokens: 500 } + // 1000 tokens * 2 per 1k = 2 (exact, no ceiling needed) + expect(calculateFluxFromUsage(usage, 2, 5)).toBe(2) + }) + + it('returns fallbackRate when both token fields are undefined (not null)', () => { + const usage = { promptTokens: undefined, completionTokens: undefined } + expect(calculateFluxFromUsage(usage, 1, 99)).toBe(99) + }) +}) diff --git a/apps/server/src/services/__test__/characters.test.ts b/apps/server/src/services/__test__/characters.test.ts index 7d2ccb8a7..67c618646 100644 --- a/apps/server/src/services/__test__/characters.test.ts +++ b/apps/server/src/services/__test__/characters.test.ts @@ -1,3 +1,5 @@ +import type { Database } from '../../libs/db' + import { beforeAll, describe, expect, it } from 'vitest' import { mockDB } from '../../libs/mock-db' @@ -6,7 +8,7 @@ import { createCharacterService } from '../characters' import * as schema from '../../schemas' describe('characterService', () => { - let db: any + let db: Database let service: ReturnType let testUser: any diff --git a/apps/server/src/services/__test__/chats.test.ts b/apps/server/src/services/__test__/chats.test.ts new file mode 100644 index 000000000..ffa3babb5 --- /dev/null +++ b/apps/server/src/services/__test__/chats.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { clampLimit, resolveSenderId } from '../chats' + +describe('resolveSenderId', () => { + it('returns userId for user role', () => { + expect(resolveSenderId('user', 'user-123', 'char-456')).toBe('user-123') + }) + it('returns characterId for non-user role when available', () => { + expect(resolveSenderId('assistant', 'user-123', 'char-456')).toBe('char-456') + }) + it('returns null for non-user role without characterId', () => { + expect(resolveSenderId('assistant', 'user-123')).toBeNull() + expect(resolveSenderId('system', 'user-123', null)).toBeNull() + }) +}) + +describe('clampLimit', () => { + it('returns default 100 when no limit', () => { + expect(clampLimit()).toBe(100) + expect(clampLimit(undefined)).toBe(100) + }) + it('returns default 100 for zero or negative', () => { + expect(clampLimit(0)).toBe(100) + expect(clampLimit(-5)).toBe(100) + }) + it('returns limit when within range', () => { + expect(clampLimit(50)).toBe(50) + expect(clampLimit(500)).toBe(500) + }) + it('clamps to max 500', () => { + expect(clampLimit(501)).toBe(500) + expect(clampLimit(1000)).toBe(500) + }) +}) diff --git a/apps/server/src/services/__test__/flux.test.ts b/apps/server/src/services/__test__/flux.test.ts index 1208080f1..484b7eb7a 100644 --- a/apps/server/src/services/__test__/flux.test.ts +++ b/apps/server/src/services/__test__/flux.test.ts @@ -1,12 +1,13 @@ import type Redis from 'ioredis' +import type { Database } from '../../libs/db' import type { createConfigKVService } from '../config-kv' +import { eq } from 'drizzle-orm' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDB } from '../../libs/mock-db' import { createFluxService } from '../flux' -import { createFluxAuditService } from '../flux-audit' import * as schema from '../../schemas' @@ -28,31 +29,17 @@ function createMockRedis(): Redis { store.set(key, value) return 'OK' }), - decrby: vi.fn(async (key: string, amount: number) => { - const current = Number.parseInt(store.get(key) ?? '0', 10) - const next = current - amount - store.set(key, String(next)) - return next - }), - incrby: vi.fn(async (key: string, amount: number) => { - const current = Number.parseInt(store.get(key) ?? '0', 10) - const next = current + amount - store.set(key, String(next)) - return next - }), } as unknown as Redis } -describe('fluxService (Redis-backed)', () => { - let db: any +describe('fluxService (DB-backed)', () => { + let db: Database let redis: Redis let service: ReturnType - let fluxAuditService: ReturnType let testUser: any beforeAll(async () => { db = await mockDB(schema) - fluxAuditService = createFluxAuditService(db) const [user] = await db.insert(schema.user).values({ id: 'user-1', @@ -62,170 +49,55 @@ describe('fluxService (Redis-backed)', () => { testUser = user }) - beforeEach(() => { + beforeEach(async () => { redis = createMockRedis() - service = createFluxService(db, redis, createMockConfigKV(), fluxAuditService) + service = createFluxService(db, redis, createMockConfigKV()) + + // Clean up flux-related tables + await db.delete(schema.fluxLedger).where(eq(schema.fluxLedger.userId, testUser.id)) + await db.delete(schema.fluxAuditLog).where(eq(schema.fluxAuditLog.userId, testUser.id)) + await db.delete(schema.userFlux).where(eq(schema.userFlux.userId, testUser.id)) }) - it('getFlux should load from DB on cache miss and populate Redis', async () => { + it('getFlux should initialize new user with INITIAL_USER_FLUX and populate Redis', async () => { const record = await service.getFlux(testUser.id) expect(record.flux).toBe(100) expect(redis.set).toHaveBeenCalledWith(`flux:${testUser.id}`, '100') }) - it('getFlux should return cached value on subsequent calls', async () => { + it('getFlux should write a ledger entry on initialization', async () => { + await service.getFlux(testUser.id) + + const ledgerRecords = await db.select().from(schema.fluxLedger).where(eq(schema.fluxLedger.userId, testUser.id)) + expect(ledgerRecords).toHaveLength(1) + expect(ledgerRecords[0]).toMatchObject({ + type: 'initial', + amount: 100, + balanceBefore: 0, + balanceAfter: 100, + }) + }) + + it('getFlux should return cached value from Redis on subsequent calls', async () => { await service.getFlux(testUser.id) await service.getFlux(testUser.id) + // Second call hits Redis cache expect(redis.get).toHaveBeenCalledTimes(2) }) - it('getFlux should create an initial audit entry for a new user', async () => { - const [user] = await db.insert(schema.user).values({ - id: 'user-audit-initial', - name: 'Audit Initial', - email: 'audit-initial@example.com', - }).returning() + it('getFlux should load from DB when Redis cache misses', async () => { + // Pre-insert user flux directly + await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 42 }) - await service.getFlux(user.id) - - const { records } = await fluxAuditService.getHistory(user.id, 10, 0) - expect(records).toHaveLength(1) - expect(records[0].type).toBe('initial') - expect(records[0].amount).toBe(100) - }) - - it('consumeFlux should deduct via Redis DECRBY', async () => { - await service.getFlux(testUser.id) - const result = await service.consumeFlux(testUser.id, 10) - expect(result.flux).toBe(90) - expect(redis.decrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 10) - }) - - it('consumeFlux should throw and rollback when insufficient', async () => { - await service.getFlux(testUser.id) - await expect(service.consumeFlux(testUser.id, 101)) - .rejects - .toThrow('Insufficient flux') - expect(redis.incrby).toHaveBeenCalledWith(`flux:${testUser.id}`, 101) - }) - - it('addFlux should update both DB and Redis', async () => { - const [user] = await db.insert(schema.user).values({ - id: 'user-add', - name: 'Add User', - email: 'add@example.com', - }).returning() - await service.getFlux(user.id) - const result = await service.addFlux(user.id, 50) - expect(result.flux).toBe(150) - expect(redis.incrby).toHaveBeenCalledWith(`flux:${user.id}`, 50) - }) - - it('consumeFlux should write a consumption audit entry with metadata', async () => { - const [user] = await db.insert(schema.user).values({ - id: 'user-audit-consume', - name: 'Audit Consume', - email: 'audit-consume@example.com', - }).returning() - - await service.getFlux(user.id) - await service.consumeFlux(user.id, 10, { - description: 'openai/gpt-5-mini', - metadata: { - promptTokens: 12, - completionTokens: 34, - }, - }) - - const { records } = await fluxAuditService.getHistory(user.id, 10, 0) - expect(records).toHaveLength(2) - const consumptionRecord = records.find(record => record.type === 'consumption') - expect(consumptionRecord).toBeDefined() - expect(consumptionRecord!.amount).toBe(-10) - expect(consumptionRecord!.description).toBe('openai/gpt-5-mini') - expect(consumptionRecord!.metadata).toEqual({ - promptTokens: 12, - completionTokens: 34, - }) - }) - - it('addFlux should write an addition audit entry', async () => { - const [user] = await db.insert(schema.user).values({ - id: 'user-audit-add', - name: 'Audit Add', - email: 'audit-add@example.com', - }).returning() - - await service.getFlux(user.id) - await service.addFlux(user.id, 50, 'Stripe payment USD 0.50') - - const { records } = await fluxAuditService.getHistory(user.id, 10, 0) - expect(records).toHaveLength(2) - const additionRecord = records.find(record => record.type === 'addition') - expect(additionRecord).toBeDefined() - expect(additionRecord!.amount).toBe(50) - expect(additionRecord!.description).toBe('Stripe payment USD 0.50') - }) - - it('consumeFlux should lazy-load cache if not preloaded', async () => { - const [user2] = await db.insert(schema.user).values({ - id: 'user-lazy', - name: 'Lazy User', - email: 'lazy@example.com', - }).returning() - const result = await service.consumeFlux(user2.id, 10) - expect(result.flux).toBe(90) - }) - - it('getFlux should return updated value after consumeFlux', async () => { - const [user] = await db.insert(schema.user).values({ - id: 'user-consume-then-get', - name: 'Consume Then Get', - email: 'consume-then-get@example.com', - }).returning() - await service.getFlux(user.id) - await service.consumeFlux(user.id, 25) - const record = await service.getFlux(user.id) - expect(record.flux).toBe(75) + const record = await service.getFlux(testUser.id) + expect(record.flux).toBe(42) + expect(redis.set).toHaveBeenCalledWith(`flux:${testUser.id}`, '42') }) it('updateStripeCustomerId should update DB only', async () => { - await service.getFlux(testUser.id) + await db.insert(schema.userFlux).values({ userId: testUser.id, flux: 100 }) + const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123') expect(result!.stripeCustomerId).toBe('cus_abc123') }) - - it('concurrent consumeFlux should not over-deduct flux', async () => { - const [user3] = await db.insert(schema.user).values({ - id: 'user-concurrent-consume', - name: 'Concurrent Consumer', - email: 'concurrent-consume@example.com', - }).returning() - await service.getFlux(user3.id) - const results = await Promise.allSettled( - Array.from({ length: 10 }).fill(service.consumeFlux(user3.id, 10)), - ) - const fulfilled = results.filter(r => r.status === 'fulfilled') - const rejected = results.filter(r => r.status === 'rejected') - const final = await service.getFlux(user3.id) - expect(final.flux).toBeGreaterThanOrEqual(0) - expect(final.flux).toBe(100 - fulfilled.length * 10) - for (const r of rejected) { - expect((r as PromiseRejectedResult).reason.message).toBe('Insufficient flux') - } - }) - - it('concurrent addFlux should accumulate correctly', async () => { - const [user4] = await db.insert(schema.user).values({ - id: 'user-concurrent-add', - name: 'Concurrent Adder', - email: 'concurrent-add@example.com', - }).returning() - await service.getFlux(user4.id) - await Promise.all( - Array.from({ length: 10 }).fill(service.addFlux(user4.id, 5)), - ) - const final = await service.getFlux(user4.id) - expect(final.flux).toBe(150) - }) }) diff --git a/apps/server/src/services/__test__/outbox-dispatcher.test.ts b/apps/server/src/services/__test__/outbox-dispatcher.test.ts new file mode 100644 index 000000000..b93964224 --- /dev/null +++ b/apps/server/src/services/__test__/outbox-dispatcher.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createOutboxDispatcher } from '../outbox-dispatcher' + +describe('outboxDispatcher', () => { + it('publishes claimed events and marks them as published', async () => { + const outboxService = { + claimPending: vi.fn(async () => ([ + { + id: 'outbox-1', + event: { + eventId: 'evt-1', + eventType: 'flux.credited' as const, + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 10, + balanceAfter: 110, + source: 'stripe.checkout.completed', + }, + }, + }, + ])), + markPublished: vi.fn(async () => {}), + releaseClaim: vi.fn(async () => {}), + } + + const billingMqService = { + publish: vi.fn(async () => '1740000000000-0'), + } + + const dispatcher = createOutboxDispatcher(outboxService as any, billingMqService as any) + await expect(dispatcher.dispatchBatch('dispatcher-1', 10, 30_000)).resolves.toBe(1) + + expect(billingMqService.publish).toHaveBeenCalled() + expect(outboxService.markPublished).toHaveBeenCalledWith('outbox-1', '1740000000000-0') + expect(outboxService.releaseClaim).not.toHaveBeenCalled() + }) + + it('releases claims when publish fails', async () => { + const outboxService = { + claimPending: vi.fn(async () => ([ + { + id: 'outbox-2', + event: { + eventId: 'evt-2', + eventType: 'flux.credited' as const, + aggregateId: 'user-2', + userId: 'user-2', + requestId: 'req-2', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 5, + balanceAfter: 15, + source: 'stripe.checkout.completed', + }, + }, + }, + ])), + markPublished: vi.fn(async () => {}), + releaseClaim: vi.fn(async () => {}), + } + + const billingMqService = { + publish: vi.fn(async () => { + throw new Error('redis down') + }), + } + + const dispatcher = createOutboxDispatcher(outboxService as any, billingMqService as any) + await expect(dispatcher.dispatchBatch('dispatcher-2', 10, 30_000)).resolves.toBe(1) + + expect(outboxService.releaseClaim).toHaveBeenCalledWith('outbox-2') + expect(outboxService.markPublished).not.toHaveBeenCalled() + }) +}) diff --git a/apps/server/src/services/__test__/outbox-service.test.ts b/apps/server/src/services/__test__/outbox-service.test.ts new file mode 100644 index 000000000..c7ede088d --- /dev/null +++ b/apps/server/src/services/__test__/outbox-service.test.ts @@ -0,0 +1,91 @@ +import type { Database } from '../../libs/db' + +import { eq } from 'drizzle-orm' +import { beforeAll, describe, expect, it } from 'vitest' + +import { mockDB } from '../../libs/mock-db' +import { createOutboxService } from '../outbox-service' + +import * as schema from '../../schemas' + +describe('outboxService', () => { + let db: Database + let outboxService: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + outboxService = createOutboxService(db) + }) + + it('enqueues and claims unpublished events', async () => { + await outboxService.enqueue(db, { + eventId: 'evt-1', + eventType: 'flux.credited', + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 10, + balanceAfter: 110, + source: 'stripe.checkout.completed', + }, + }) + + const claimed = await outboxService.claimPending({ + claimedBy: 'dispatcher-1', + limit: 10, + claimTtlMs: 30_000, + }) + + expect(claimed).toHaveLength(1) + expect(claimed[0]?.event).toEqual({ + eventId: 'evt-1', + eventType: 'flux.credited', + aggregateId: 'user-1', + userId: 'user-1', + requestId: 'req-1', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + amount: 10, + balanceAfter: 110, + source: 'stripe.checkout.completed', + }, + }) + }) + + it('marks events as published and can release claims', async () => { + await outboxService.enqueue(db, { + eventId: 'evt-2', + eventType: 'stripe.checkout.completed', + aggregateId: 'sess-2', + userId: 'user-2', + requestId: 'req-2', + occurredAt: '2026-03-24T00:00:00.000Z', + schemaVersion: 1, + payload: { + stripeEventId: 'stripe-evt-2', + stripeSessionId: 'sess-2', + amount: 500, + currency: 'usd', + }, + }) + + const [claimed] = await outboxService.claimPending({ + claimedBy: 'dispatcher-2', + limit: 10, + claimTtlMs: 30_000, + }) + + expect(claimed).toBeDefined() + await outboxService.releaseClaim(claimed!.id) + await outboxService.markPublished(claimed!.id, '1740000000000-0') + + const [published] = await db.select().from(schema.outboxEvents).where(eq(schema.outboxEvents.id, claimed!.id)) + expect(published?.streamMessageId).toBe('1740000000000-0') + expect(published?.publishedAt).toBeInstanceOf(Date) + expect(published?.claimedBy).toBeNull() + }) +}) diff --git a/apps/server/src/services/__test__/providers.test.ts b/apps/server/src/services/__test__/providers.test.ts index 073bd3ab4..56a3382dd 100644 --- a/apps/server/src/services/__test__/providers.test.ts +++ b/apps/server/src/services/__test__/providers.test.ts @@ -1,3 +1,5 @@ +import type { Database } from '../../libs/db' + import { beforeAll, describe, expect, it } from 'vitest' import { mockDB } from '../../libs/mock-db' @@ -6,7 +8,7 @@ import { createProviderService } from '../providers' import * as schema from '../../schemas' describe('providerService', () => { - let db: any + let db: Database let service: ReturnType let testUser: any diff --git a/apps/server/src/services/__test__/stripe.test.ts b/apps/server/src/services/__test__/stripe.test.ts new file mode 100644 index 000000000..2c7da9b28 --- /dev/null +++ b/apps/server/src/services/__test__/stripe.test.ts @@ -0,0 +1,468 @@ +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 { createStripeService } from '../stripe' + +import * as schema from '../../schemas' + +describe('stripeService', () => { + let db: Database + let stripeService: ReturnType + + beforeAll(async () => { + db = await mockDB(schema) + + await db.insert(schema.user).values([ + { id: 'user-stripe-1', name: 'Stripe User 1', email: 'stripe1@example.com' }, + { id: 'user-stripe-2', name: 'Stripe User 2', email: 'stripe2@example.com' }, + ]) + }) + + beforeEach(async () => { + stripeService = createStripeService(db) + + // Clean all stripe tables between tests + await db.delete(schema.stripeInvoice) + await db.delete(schema.stripeSubscription) + await db.delete(schema.stripeCheckoutSession) + await db.delete(schema.stripeCustomer) + }) + + // ---- Customer ---- + + describe('upsertCustomer', () => { + it('inserts a new customer', async () => { + const result = await stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_new_1', + email: 'stripe1@example.com', + }) + + expect(result.userId).toBe('user-stripe-1') + expect(result.stripeCustomerId).toBe('cus_new_1') + expect(result.email).toBe('stripe1@example.com') + }) + + it('updates an existing customer on conflict (atomic upsert)', async () => { + await stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_dup_1', + email: 'old@example.com', + }) + + const updated = await stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_dup_1', + email: 'new@example.com', + name: 'Updated Name', + }) + + expect(updated.email).toBe('new@example.com') + expect(updated.name).toBe('Updated Name') + + // Verify only one record exists + const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_dup_1')) + expect(all).toHaveLength(1) + }) + + it('handles concurrent upserts for the same customer without error', async () => { + // Simulate two webhook events arriving at the same time for the same customer + const results = await Promise.all([ + stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_race_1', + email: 'a@example.com', + }), + stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_race_1', + email: 'b@example.com', + }), + ]) + + // Both should succeed (no unique constraint violation) + expect(results).toHaveLength(2) + results.forEach(r => expect(r.stripeCustomerId).toBe('cus_race_1')) + + // Only one record should exist + const all = await db.select().from(schema.stripeCustomer).where(eq(schema.stripeCustomer.stripeCustomerId, 'cus_race_1')) + expect(all).toHaveLength(1) + }) + }) + + describe('getCustomerByUserId', () => { + it('returns the customer for a given userId', async () => { + await stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_lookup_1', + }) + + const found = await stripeService.getCustomerByUserId('user-stripe-1') + expect(found?.stripeCustomerId).toBe('cus_lookup_1') + }) + + it('returns undefined when no customer exists', async () => { + const found = await stripeService.getCustomerByUserId('user-nonexistent') + expect(found).toBeUndefined() + }) + }) + + describe('getCustomerByStripeId', () => { + it('returns the customer for a given stripeCustomerId', async () => { + await stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_sid_1', + }) + + const found = await stripeService.getCustomerByStripeId('cus_sid_1') + expect(found?.userId).toBe('user-stripe-1') + }) + + it('returns undefined when no customer exists', async () => { + const found = await stripeService.getCustomerByStripeId('cus_nonexistent') + expect(found).toBeUndefined() + }) + }) + + // ---- Checkout Session ---- + + describe('upsertCheckoutSession', () => { + it('inserts a new checkout session', async () => { + const result = await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_new_1', + mode: 'payment', + status: 'open', + paymentStatus: 'unpaid', + amountTotal: 1000, + currency: 'usd', + }) + + expect(result.stripeSessionId).toBe('cs_new_1') + expect(result.amountTotal).toBe(1000) + expect(result.fluxCredited).toBe(false) + }) + + it('updates an existing checkout session on conflict', async () => { + await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_upd_1', + mode: 'payment', + status: 'open', + paymentStatus: 'unpaid', + amountTotal: 1000, + currency: 'usd', + }) + + const updated = await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_upd_1', + mode: 'payment', + status: 'complete', + paymentStatus: 'paid', + amountTotal: 1000, + currency: 'usd', + }) + + expect(updated.status).toBe('complete') + expect(updated.paymentStatus).toBe('paid') + + const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_upd_1')) + expect(all).toHaveLength(1) + }) + + it('handles concurrent upserts without error', async () => { + const results = await Promise.all([ + stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_race_1', + mode: 'payment', + status: 'open', + paymentStatus: 'unpaid', + amountTotal: 500, + currency: 'usd', + }), + stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_race_1', + mode: 'payment', + status: 'complete', + paymentStatus: 'paid', + amountTotal: 500, + currency: 'usd', + }), + ]) + + expect(results).toHaveLength(2) + + const all = await db.select().from(schema.stripeCheckoutSession).where(eq(schema.stripeCheckoutSession.stripeSessionId, 'cs_race_1')) + expect(all).toHaveLength(1) + }) + }) + + describe('getCheckoutSessionsByUserId', () => { + it('returns all sessions for the user', async () => { + await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_list_1', + mode: 'payment', + amountTotal: 100, + currency: 'usd', + }) + await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_list_2', + mode: 'payment', + amountTotal: 200, + currency: 'usd', + }) + + const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1') + expect(sessions).toHaveLength(2) + const ids = sessions.map(s => s.stripeSessionId) + expect(ids).toContain('cs_list_1') + expect(ids).toContain('cs_list_2') + }) + + it('does not return sessions from other users', async () => { + await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-1', + stripeSessionId: 'cs_iso_1', + mode: 'payment', + }) + await stripeService.upsertCheckoutSession({ + userId: 'user-stripe-2', + stripeSessionId: 'cs_iso_2', + mode: 'payment', + }) + + const sessions = await stripeService.getCheckoutSessionsByUserId('user-stripe-1') + expect(sessions).toHaveLength(1) + expect(sessions[0]?.stripeSessionId).toBe('cs_iso_1') + }) + }) + + // ---- Subscription ---- + + describe('upsertSubscription', () => { + it('inserts a new subscription', async () => { + await stripeService.upsertCustomer({ + userId: 'user-stripe-1', + stripeCustomerId: 'cus_sub_1', + }) + + const result = await stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_new_1', + stripeCustomerId: 'cus_sub_1', + status: 'active', + }) + + expect(result.stripeSubscriptionId).toBe('sub_new_1') + expect(result.status).toBe('active') + }) + + it('updates an existing subscription on conflict', async () => { + await stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_upd_1', + stripeCustomerId: 'cus_sub_1', + status: 'active', + }) + + const updated = await stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_upd_1', + stripeCustomerId: 'cus_sub_1', + status: 'canceled', + }) + + expect(updated.status).toBe('canceled') + + const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_upd_1')) + expect(all).toHaveLength(1) + }) + + it('handles concurrent upserts without error', async () => { + const results = await Promise.all([ + stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_race_1', + stripeCustomerId: 'cus_sub_1', + status: 'active', + }), + stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_race_1', + stripeCustomerId: 'cus_sub_1', + status: 'past_due', + }), + ]) + + expect(results).toHaveLength(2) + + const all = await db.select().from(schema.stripeSubscription).where(eq(schema.stripeSubscription.stripeSubscriptionId, 'sub_race_1')) + expect(all).toHaveLength(1) + }) + }) + + describe('getActiveSubscription', () => { + it('returns only the active subscription', async () => { + await stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_active_1', + stripeCustomerId: 'cus_sub_1', + status: 'canceled', + }) + await stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_active_2', + stripeCustomerId: 'cus_sub_1', + status: 'active', + }) + + const active = await stripeService.getActiveSubscription('user-stripe-1') + expect(active?.stripeSubscriptionId).toBe('sub_active_2') + expect(active?.status).toBe('active') + }) + + it('returns undefined when no active subscription exists', async () => { + await stripeService.upsertSubscription({ + userId: 'user-stripe-1', + stripeSubscriptionId: 'sub_none_1', + stripeCustomerId: 'cus_sub_1', + status: 'canceled', + }) + + const active = await stripeService.getActiveSubscription('user-stripe-1') + expect(active).toBeUndefined() + }) + + it('does not return subscriptions from other users', async () => { + await stripeService.upsertSubscription({ + userId: 'user-stripe-2', + stripeSubscriptionId: 'sub_other_1', + stripeCustomerId: 'cus_other_1', + status: 'active', + }) + + const active = await stripeService.getActiveSubscription('user-stripe-1') + expect(active).toBeUndefined() + }) + }) + + // ---- Invoice ---- + + describe('upsertInvoice', () => { + it('inserts a new invoice', async () => { + const result = await stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_new_1', + stripeCustomerId: 'cus_inv_1', + status: 'open', + amountDue: 2000, + amountPaid: 0, + currency: 'usd', + }) + + expect(result.stripeInvoiceId).toBe('inv_new_1') + expect(result.status).toBe('open') + expect(result.fluxCredited).toBe(false) + }) + + it('updates an existing invoice on conflict', async () => { + await stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_upd_1', + status: 'open', + amountDue: 2000, + amountPaid: 0, + currency: 'usd', + }) + + const updated = await stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_upd_1', + status: 'paid', + amountDue: 2000, + amountPaid: 2000, + currency: 'usd', + }) + + expect(updated.status).toBe('paid') + expect(updated.amountPaid).toBe(2000) + + const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_upd_1')) + expect(all).toHaveLength(1) + }) + + it('handles concurrent upserts without error', async () => { + const results = await Promise.all([ + stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_race_1', + status: 'open', + amountDue: 1000, + currency: 'usd', + }), + stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_race_1', + status: 'paid', + amountPaid: 1000, + currency: 'usd', + }), + ]) + + expect(results).toHaveLength(2) + + const all = await db.select().from(schema.stripeInvoice).where(eq(schema.stripeInvoice.stripeInvoiceId, 'inv_race_1')) + expect(all).toHaveLength(1) + }) + }) + + describe('getInvoicesByUserId', () => { + it('returns all invoices for the user', async () => { + await stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_list_1', + status: 'paid', + currency: 'usd', + }) + await stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_list_2', + status: 'open', + currency: 'usd', + }) + + const invoices = await stripeService.getInvoicesByUserId('user-stripe-1') + expect(invoices).toHaveLength(2) + const ids = invoices.map(i => i.stripeInvoiceId) + expect(ids).toContain('inv_list_1') + expect(ids).toContain('inv_list_2') + }) + + it('does not return invoices from other users', async () => { + await stripeService.upsertInvoice({ + userId: 'user-stripe-1', + stripeInvoiceId: 'inv_iso_1', + status: 'paid', + currency: 'usd', + }) + await stripeService.upsertInvoice({ + userId: 'user-stripe-2', + stripeInvoiceId: 'inv_iso_2', + status: 'paid', + currency: 'usd', + }) + + const invoices = await stripeService.getInvoicesByUserId('user-stripe-1') + expect(invoices).toHaveLength(1) + expect(invoices[0]?.stripeInvoiceId).toBe('inv_iso_1') + }) + }) +}) diff --git a/apps/server/src/services/billing-events.ts b/apps/server/src/services/billing-events.ts new file mode 100644 index 000000000..e21912dd0 --- /dev/null +++ b/apps/server/src/services/billing-events.ts @@ -0,0 +1,157 @@ +import type { InferOutput } from 'valibot' + +import { + literal, + nonEmpty, + number, + object, + optional, + parse, + pipe, + string, + union, + unknown, +} from 'valibot' + +export const DEFAULT_BILLING_EVENTS_STREAM = 'billing-events' + +const BillingEventTypeSchema = union([ + literal('flux.debited'), + literal('flux.credited'), + literal('stripe.checkout.completed'), + literal('llm.request.completed'), +]) + +const BalanceChangePayloadSchema = object({ + amount: number(), + balanceAfter: optional(number()), + source: optional(pipe(string(), nonEmpty())), +}) + +const StripeCheckoutCompletedPayloadSchema = object({ + stripeEventId: pipe(string(), nonEmpty()), + stripeSessionId: pipe(string(), nonEmpty()), + amount: number(), + currency: pipe(string(), nonEmpty()), +}) + +const LlmRequestCompletedPayloadSchema = object({ + model: pipe(string(), nonEmpty()), + status: number(), + fluxConsumed: number(), + promptTokens: optional(number()), + completionTokens: optional(number()), +}) + +const BillingEventEnvelopeSchema = object({ + eventId: pipe(string(), nonEmpty()), + eventType: BillingEventTypeSchema, + aggregateId: pipe(string(), nonEmpty()), + userId: pipe(string(), nonEmpty()), + requestId: optional(pipe(string(), nonEmpty())), + occurredAt: pipe(string(), nonEmpty()), + schemaVersion: number(), + payload: unknown(), +}) + +export type BillingEventType = InferOutput + +type BillingEventEnvelope = InferOutput +type BalanceChangePayload = InferOutput +type StripeCheckoutCompletedPayload = InferOutput +type LlmRequestCompletedPayload = InferOutput + +export type FluxDebitedEvent = BillingEventEnvelope & { + eventType: 'flux.debited' + payload: BalanceChangePayload +} + +export type FluxCreditedEvent = BillingEventEnvelope & { + eventType: 'flux.credited' + payload: BalanceChangePayload +} + +export type StripeCheckoutCompletedEvent = BillingEventEnvelope & { + eventType: 'stripe.checkout.completed' + payload: StripeCheckoutCompletedPayload +} + +export type LlmRequestCompletedEvent = BillingEventEnvelope & { + eventType: 'llm.request.completed' + payload: LlmRequestCompletedPayload +} + +export type BillingEvent + = | FluxDebitedEvent + | FluxCreditedEvent + | StripeCheckoutCompletedEvent + | LlmRequestCompletedEvent + +export interface SerializedBillingEventFields extends Record { + event_id: string + event_type: BillingEventType + aggregate_id: string + user_id: string + request_id?: string + occurred_at: string + schema_version: string + payload: string +} + +export function serializeBillingEvent(event: BillingEvent): SerializedBillingEventFields { + return { + event_id: event.eventId, + event_type: event.eventType, + aggregate_id: event.aggregateId, + user_id: event.userId, + request_id: event.requestId, + occurred_at: event.occurredAt, + schema_version: String(event.schemaVersion), + payload: JSON.stringify(event.payload), + } +} + +export function parseBillingEvent(fields: Record): BillingEvent { + const payload = fields.payload + if (payload == null) { + throw new TypeError('Billing event payload is required') + } + + const parsedEnvelope = parse(BillingEventEnvelopeSchema, { + eventId: fields.event_id, + eventType: fields.event_type, + aggregateId: fields.aggregate_id, + userId: fields.user_id, + requestId: fields.request_id, + occurredAt: fields.occurred_at, + schemaVersion: Number(fields.schema_version), + payload: JSON.parse(payload), + }) + + switch (parsedEnvelope.eventType) { + case 'flux.debited': + return { + ...parsedEnvelope, + eventType: 'flux.debited', + payload: parse(BalanceChangePayloadSchema, parsedEnvelope.payload), + } + case 'flux.credited': + return { + ...parsedEnvelope, + eventType: 'flux.credited', + payload: parse(BalanceChangePayloadSchema, parsedEnvelope.payload), + } + case 'stripe.checkout.completed': + return { + ...parsedEnvelope, + eventType: 'stripe.checkout.completed', + payload: parse(StripeCheckoutCompletedPayloadSchema, parsedEnvelope.payload), + } + case 'llm.request.completed': + return { + ...parsedEnvelope, + eventType: 'llm.request.completed', + payload: parse(LlmRequestCompletedPayloadSchema, parsedEnvelope.payload), + } + } +} diff --git a/apps/server/src/services/billing-mq-worker.ts b/apps/server/src/services/billing-mq-worker.ts new file mode 100644 index 000000000..8563ecabc --- /dev/null +++ b/apps/server/src/services/billing-mq-worker.ts @@ -0,0 +1,62 @@ +import type { BillingMqService, BillingStreamMessage } from './billing-mq' + +import { useLogger } from '@guiiai/logg' + +export interface RunBillingMqWorkerOptions { + group: string + consumer: string + signal: AbortSignal + batchSize?: number + blockMs?: number + minIdleTimeMs?: number + onMessage: (message: BillingStreamMessage) => Promise +} + +const logger = useLogger('billing-mq-worker').useGlobalConfig() + +export function createBillingMqWorker(mq: BillingMqService) { + return { + async run(options: RunBillingMqWorkerOptions): Promise { + await mq.ensureConsumerGroup(options.group) + + while (!options.signal.aborted) { + const reclaimedMessages = await mq.claimIdleMessages({ + group: options.group, + consumer: options.consumer, + minIdleTimeMs: options.minIdleTimeMs ?? 30_000, + count: options.batchSize ?? 10, + }) + + const messages = reclaimedMessages.length > 0 + ? reclaimedMessages + : await mq.consume({ + group: options.group, + consumer: options.consumer, + count: options.batchSize ?? 10, + blockMs: options.blockMs ?? 5_000, + }) + + if (messages.length === 0) { + continue + } + + for (const message of messages) { + try { + await options.onMessage(message) + await mq.ack(options.group, message.streamMessageId) + } + catch (error) { + logger.withError(error).withFields({ + group: options.group, + consumer: options.consumer, + eventId: message.event.eventId, + streamMessageId: message.streamMessageId, + }).error('Billing MQ handler failed; leaving message pending') + } + } + } + }, + } +} + +export type BillingMqWorker = ReturnType diff --git a/apps/server/src/services/billing-mq.ts b/apps/server/src/services/billing-mq.ts new file mode 100644 index 000000000..ca22c53a5 --- /dev/null +++ b/apps/server/src/services/billing-mq.ts @@ -0,0 +1,227 @@ +import type { BillingEvent } from './billing-events' + +import { useLogger } from '@guiiai/logg' + +import { + DEFAULT_BILLING_EVENTS_STREAM, + parseBillingEvent, + serializeBillingEvent, +} from './billing-events' + +type RedisArgument = string | number + +export interface RedisCommandClient { + call: (command: string, ...args: RedisArgument[]) => Promise +} + +export interface BillingMqOptions { + stream?: string + maxLength?: number +} + +export interface ConsumeBillingMessagesOptions { + group: string + consumer: string + count?: number + blockMs?: number + startId?: string +} + +export interface ClaimIdleBillingMessagesOptions { + group: string + consumer: string + minIdleTimeMs: number + startId?: string + count?: number +} + +export interface BillingStreamMessage { + streamMessageId: string + event: BillingEvent +} + +type RedisStreamEntry = [streamMessageId: string, fieldValues: string[]] +type RedisReadGroupResponse = [stream: string, entries: RedisStreamEntry[]][] +type RedisAutoClaimResponse = [nextStartId: string, entries: RedisStreamEntry[], deletedIds?: string[]] + +const logger = useLogger('billing-mq').useGlobalConfig() + +export function createBillingMqService(redis: RedisCommandClient, options: BillingMqOptions = {}) { + const stream = options.stream ?? DEFAULT_BILLING_EVENTS_STREAM + + return { + stream, + + async publish(event: BillingEvent): Promise { + const serializedFields = serializeBillingEvent(event) + const xaddArgs: RedisArgument[] = [stream] + + if (options.maxLength != null) { + xaddArgs.push('MAXLEN', '~', options.maxLength) + } + + xaddArgs.push('*', ...toRedisFieldArguments(serializedFields)) + + const streamMessageId = await redis.call('XADD', ...xaddArgs) + if (typeof streamMessageId !== 'string') { + throw new TypeError('Redis XADD did not return a stream message id') + } + + logger.withFields({ + stream, + eventId: event.eventId, + eventType: event.eventType, + streamMessageId, + }).log('Published billing event to Redis Stream') + + return streamMessageId + }, + + async ensureConsumerGroup(group: string, startId = '0'): Promise { + try { + await redis.call('XGROUP', 'CREATE', stream, group, startId, 'MKSTREAM') + return true + } + catch (error) { + if (error instanceof Error && error.message.includes('BUSYGROUP')) { + return false + } + + throw error + } + }, + + async consume(options: ConsumeBillingMessagesOptions): Promise { + const response = await redis.call( + 'XREADGROUP', + 'GROUP', + options.group, + options.consumer, + 'COUNT', + options.count ?? 10, + 'BLOCK', + options.blockMs ?? 5_000, + 'STREAMS', + stream, + options.startId ?? '>', + ) + + return parseReadGroupResponse(response) + }, + + async claimIdleMessages(options: ClaimIdleBillingMessagesOptions): Promise { + const response = await redis.call( + 'XAUTOCLAIM', + stream, + options.group, + options.consumer, + options.minIdleTimeMs, + options.startId ?? '0-0', + 'COUNT', + options.count ?? 10, + ) + + return parseAutoClaimResponse(response) + }, + + async ack(group: string, streamMessageIds: string | string[]): Promise { + const ids = Array.isArray(streamMessageIds) ? streamMessageIds : [streamMessageIds] + + if (ids.length === 0) { + return 0 + } + + const acked = await redis.call('XACK', stream, group, ...ids) + if (typeof acked !== 'number') { + throw new TypeError('Redis XACK did not return an acknowledgement count') + } + + return acked + }, + } +} + +function toRedisFieldArguments(fields: Record): RedisArgument[] { + return Object.entries(fields) + .filter(([, value]) => value !== undefined) + .flatMap(([key, value]) => [key, value as string]) +} + +function parseReadGroupResponse(response: unknown): BillingStreamMessage[] { + if (response == null) { + return [] + } + + if (!Array.isArray(response)) { + throw new TypeError('Redis XREADGROUP returned an invalid response') + } + + return response.flatMap((streamResponse) => { + if (!Array.isArray(streamResponse) || streamResponse.length !== 2) { + throw new Error('Redis XREADGROUP returned an invalid stream payload') + } + + const [, entries] = streamResponse as RedisReadGroupResponse[number] + return entries.map(parseRedisStreamEntry) + }) +} + +function parseAutoClaimResponse(response: unknown): BillingStreamMessage[] { + if (response == null) { + return [] + } + + if (!Array.isArray(response) || response.length < 2) { + throw new Error('Redis XAUTOCLAIM returned an invalid response') + } + + const [, entries] = response as RedisAutoClaimResponse + if (!Array.isArray(entries)) { + throw new TypeError('Redis XAUTOCLAIM returned invalid entries') + } + + return entries.map(parseRedisStreamEntry) +} + +function parseRedisStreamEntry(entry: unknown): BillingStreamMessage { + if (!Array.isArray(entry) || entry.length !== 2) { + throw new Error('Redis Stream entry has an invalid shape') + } + + const [streamMessageId, rawFieldValues] = entry + if (typeof streamMessageId !== 'string') { + throw new TypeError('Redis Stream entry is missing a valid message id') + } + + if (!Array.isArray(rawFieldValues)) { + throw new TypeError('Redis Stream entry fields are invalid') + } + + const event = parseBillingEvent(toFieldRecord(rawFieldValues)) + return { + streamMessageId, + event, + } +} + +function toFieldRecord(fieldValues: string[]): Record { + if (fieldValues.length % 2 !== 0) { + throw new Error('Redis Stream entry fields must be key/value pairs') + } + + const fields: Record = {} + for (let index = 0; index < fieldValues.length; index += 2) { + const key = fieldValues[index] + const value = fieldValues[index + 1] + + if (typeof key !== 'string' || typeof value !== 'string') { + throw new TypeError('Redis Stream entry contains non-string field data') + } + + fields[key] = value + } + + return fields +} + +export type BillingMqService = ReturnType diff --git a/apps/server/src/services/billing-service.ts b/apps/server/src/services/billing-service.ts new file mode 100644 index 000000000..1b9c94974 --- /dev/null +++ b/apps/server/src/services/billing-service.ts @@ -0,0 +1,416 @@ +import type Redis from 'ioredis' + +import type { Database } from '../libs/db' +import type { RevenueMetrics } from '../libs/otel' +import type { ConfigKVService } from './config-kv' +import type { OutboxService } from './outbox-service' + +import { useLogger } from '@guiiai/logg' +import { eq } from 'drizzle-orm' + +import { createPaymentRequiredError } from '../utils/error' +import { nanoid } from '../utils/id' +import { fluxRedisKey } from './flux' + +import * as fluxSchema from '../schemas/flux' +import * as fluxAuditSchema from '../schemas/flux-audit-log' +import * as fluxLedgerSchema from '../schemas/flux-ledger' +import * as stripeSchema from '../schemas/stripe' + +const logger = useLogger('billing-service') + +export function createBillingService( + db: Database, + redis: Redis, + outboxService: OutboxService, + _configKV: ConfigKVService, + metrics?: RevenueMetrics | null, +) { + /** + * Update Redis cache after a successful DB transaction. + * Best-effort: cache loss is harmless since DB is the source of truth. + */ + async function updateRedisCache(userId: string, balance: number): Promise { + try { + await redis.set(fluxRedisKey(userId), String(balance)) + } + catch { + logger.withFields({ userId }).warn('Failed to update Redis cache after balance change') + } + } + + return { + /** + * Debit flux from a user's balance within a DB transaction. + * Writes flux_ledger + flux_audit_log + outbox event atomically. + */ + async debitFlux(input: { + userId: string + amount: number + requestId?: string + description?: string + }): Promise<{ userId: string, flux: number }> { + const result = await db.transaction(async (tx) => { + // 1. Lock the row and read current balance + const [row] = await tx + .select({ flux: fluxSchema.userFlux.flux }) + .from(fluxSchema.userFlux) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + .for('update') + + if (!row) { + throw new Error(`No flux record for user ${input.userId}`) + } + + const balanceBefore = row.flux + if (balanceBefore < input.amount) { + metrics?.fluxInsufficientBalance.add(1) + throw createPaymentRequiredError('Insufficient flux') + } + + const balanceAfter = balanceBefore - input.amount + + // 2. Update balance + await tx.update(fluxSchema.userFlux) + .set({ flux: balanceAfter, updatedAt: new Date() }) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + + // 3. Append ledger entry + await tx.insert(fluxLedgerSchema.fluxLedger).values({ + userId: input.userId, + type: 'debit', + amount: input.amount, + balanceBefore, + balanceAfter, + requestId: input.requestId, + description: input.description ?? 'LLM request', + }) + + // 4. Append audit log (user-facing history) + await tx.insert(fluxAuditSchema.fluxAuditLog).values({ + userId: input.userId, + type: 'consumption', + amount: -input.amount, + description: input.description ?? 'LLM request', + }) + + // 5. Enqueue outbox event + await outboxService.enqueue(tx, { + eventId: nanoid(), + eventType: 'flux.debited', + aggregateId: input.userId, + userId: input.userId, + requestId: input.requestId, + occurredAt: new Date().toISOString(), + schemaVersion: 1, + payload: { + amount: input.amount, + balanceAfter, + source: 'llm.request', + }, + }) + + return { userId: input.userId, flux: balanceAfter } + }) + + // 6. Update Redis cache after commit (best-effort) + await updateRedisCache(input.userId, result.flux) + + logger.withFields({ userId: input.userId, amount: input.amount, balance: result.flux }).log('Debited flux') + return result + }, + + /** + * Credit flux to a user's balance within a DB transaction. + * Generic credit method for non-Stripe flows (e.g. admin grants). + */ + async creditFlux(input: { + userId: string + amount: number + requestId?: string + description: string + source: string + auditMetadata?: Record + }): Promise<{ balanceBefore: number, balanceAfter: number }> { + const result = await db.transaction(async (tx) => { + // Ensure user record exists + await tx.insert(fluxSchema.userFlux) + .values({ userId: input.userId, flux: 0 }) + .onConflictDoNothing({ target: fluxSchema.userFlux.userId }) + + // Lock and read current balance + const [row] = await tx + .select({ flux: fluxSchema.userFlux.flux }) + .from(fluxSchema.userFlux) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + .for('update') + + const balanceBefore = row!.flux + const balanceAfter = balanceBefore + input.amount + + // Update balance + await tx.update(fluxSchema.userFlux) + .set({ flux: balanceAfter, updatedAt: new Date() }) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + + // Ledger entry + await tx.insert(fluxLedgerSchema.fluxLedger).values({ + userId: input.userId, + type: 'credit', + amount: input.amount, + balanceBefore, + balanceAfter, + requestId: input.requestId, + description: input.description, + }) + + // Audit log + await tx.insert(fluxAuditSchema.fluxAuditLog).values({ + userId: input.userId, + type: 'addition', + amount: input.amount, + description: input.description, + metadata: input.auditMetadata, + }) + + // Outbox event + await outboxService.enqueue(tx, { + eventId: nanoid(), + eventType: 'flux.credited', + aggregateId: input.userId, + userId: input.userId, + requestId: input.requestId, + occurredAt: new Date().toISOString(), + schemaVersion: 1, + payload: { + amount: input.amount, + balanceAfter, + source: input.source, + }, + }) + + return { balanceBefore, balanceAfter } + }) + + await updateRedisCache(input.userId, result.balanceAfter) + + logger.withFields({ userId: input.userId, amount: input.amount, balance: result.balanceAfter }).log('Credited flux') + return result + }, + + /** + * Credit flux from a Stripe checkout session (one-time payment). + * Idempotent: checks fluxCredited flag before applying. + */ + async creditFluxFromStripeCheckout(input: { + stripeEventId: string + userId: string + stripeSessionId: string + amountTotal: number + currency: string | null + fluxAmount: number + }): Promise<{ applied: boolean, balanceAfter?: number }> { + const txResult = await db.transaction(async (tx) => { + const record = await tx.query.stripeCheckoutSession.findFirst({ + where: (table, { eq }) => eq(table.stripeSessionId, input.stripeSessionId), + }) + + if (!record || record.fluxCredited) { + return { applied: false } + } + + // Ensure user record exists + await tx.insert(fluxSchema.userFlux) + .values({ userId: input.userId, flux: 0 }) + .onConflictDoNothing({ target: fluxSchema.userFlux.userId }) + + // Lock and read balance + const [currentFlux] = await tx + .select({ flux: fluxSchema.userFlux.flux }) + .from(fluxSchema.userFlux) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + .for('update') + + const balanceBefore = currentFlux!.flux + const balanceAfter = balanceBefore + input.fluxAmount + + // Update balance + await tx.update(fluxSchema.userFlux) + .set({ flux: balanceAfter, updatedAt: new Date() }) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + + // Mark checkout session as credited + await tx.update(stripeSchema.stripeCheckoutSession) + .set({ fluxCredited: true, updatedAt: new Date() }) + .where(eq(stripeSchema.stripeCheckoutSession.stripeSessionId, input.stripeSessionId)) + + const description = `Stripe payment ${input.currency?.toUpperCase() ?? 'UNKNOWN'} ${(input.amountTotal / 100).toFixed(2)}` + + // Ledger entry + await tx.insert(fluxLedgerSchema.fluxLedger).values({ + userId: input.userId, + type: 'credit', + amount: input.fluxAmount, + balanceBefore, + balanceAfter, + requestId: input.stripeEventId, + description, + }) + + // Audit log + await tx.insert(fluxAuditSchema.fluxAuditLog).values({ + userId: input.userId, + type: 'addition', + amount: input.fluxAmount, + description, + metadata: { + stripeEventId: input.stripeEventId, + stripeSessionId: input.stripeSessionId, + source: 'stripe.checkout.completed', + }, + }) + + // Outbox events + const occurredAt = new Date().toISOString() + await outboxService.enqueue(tx, { + eventId: nanoid(), + eventType: 'flux.credited', + aggregateId: input.userId, + userId: input.userId, + requestId: input.stripeEventId, + occurredAt, + schemaVersion: 1, + payload: { + amount: input.fluxAmount, + balanceAfter, + source: 'stripe.checkout.completed', + }, + }) + + await outboxService.enqueue(tx, { + eventId: nanoid(), + eventType: 'stripe.checkout.completed', + aggregateId: input.stripeSessionId, + userId: input.userId, + requestId: input.stripeEventId, + occurredAt, + schemaVersion: 1, + payload: { + stripeEventId: input.stripeEventId, + stripeSessionId: input.stripeSessionId, + amount: input.amountTotal, + currency: input.currency ?? 'unknown', + }, + }) + + return { applied: true, balanceAfter } + }) + + if (txResult.applied && txResult.balanceAfter != null) { + await updateRedisCache(input.userId, txResult.balanceAfter) + } + + return txResult + }, + + /** + * Credit flux from a Stripe invoice payment (subscription). + * Idempotent: checks fluxCredited flag on the invoice record. + */ + async creditFluxFromInvoice(input: { + stripeEventId: string + userId: string + stripeInvoiceId: string + amountPaid: number + currency: string + fluxAmount: number + }): Promise<{ applied: boolean, balanceAfter?: number }> { + const txResult = await db.transaction(async (tx) => { + const record = await tx.query.stripeInvoice.findFirst({ + where: (table, { eq }) => eq(table.stripeInvoiceId, input.stripeInvoiceId), + }) + + if (!record || record.fluxCredited) { + return { applied: false } + } + + // Ensure user record exists + await tx.insert(fluxSchema.userFlux) + .values({ userId: input.userId, flux: 0 }) + .onConflictDoNothing({ target: fluxSchema.userFlux.userId }) + + // Lock and read balance + const [currentFlux] = await tx + .select({ flux: fluxSchema.userFlux.flux }) + .from(fluxSchema.userFlux) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + .for('update') + + const balanceBefore = currentFlux!.flux + const balanceAfter = balanceBefore + input.fluxAmount + + // Update balance + await tx.update(fluxSchema.userFlux) + .set({ flux: balanceAfter, updatedAt: new Date() }) + .where(eq(fluxSchema.userFlux.userId, input.userId)) + + // Mark invoice as credited + await tx.update(stripeSchema.stripeInvoice) + .set({ fluxCredited: true, updatedAt: new Date() }) + .where(eq(stripeSchema.stripeInvoice.stripeInvoiceId, input.stripeInvoiceId)) + + const description = `Subscription invoice ${input.currency.toUpperCase()} ${(input.amountPaid / 100).toFixed(2)}` + + // Ledger entry + await tx.insert(fluxLedgerSchema.fluxLedger).values({ + userId: input.userId, + type: 'credit', + amount: input.fluxAmount, + balanceBefore, + balanceAfter, + requestId: input.stripeEventId, + description, + }) + + // Audit log + await tx.insert(fluxAuditSchema.fluxAuditLog).values({ + userId: input.userId, + type: 'addition', + amount: input.fluxAmount, + description, + metadata: { + stripeEventId: input.stripeEventId, + stripeInvoiceId: input.stripeInvoiceId, + source: 'invoice.paid', + }, + }) + + // Outbox event + await outboxService.enqueue(tx, { + eventId: nanoid(), + eventType: 'flux.credited', + aggregateId: input.userId, + userId: input.userId, + requestId: input.stripeEventId, + occurredAt: new Date().toISOString(), + schemaVersion: 1, + payload: { + amount: input.fluxAmount, + balanceAfter, + source: 'invoice.paid', + }, + }) + + return { applied: true, balanceAfter } + }) + + if (txResult.applied && txResult.balanceAfter != null) { + await updateRedisCache(input.userId, txResult.balanceAfter) + } + + return txResult + }, + } +} + +export type BillingService = ReturnType diff --git a/apps/server/src/services/billing.ts b/apps/server/src/services/billing.ts new file mode 100644 index 000000000..888d7805a --- /dev/null +++ b/apps/server/src/services/billing.ts @@ -0,0 +1,23 @@ +export interface UsageInfo { + promptTokens?: number + completionTokens?: number +} + +export function extractUsageFromBody(body: any): UsageInfo { + const usage = body?.usage + if (!usage) + return {} + return { + promptTokens: usage.prompt_tokens ?? undefined, + completionTokens: usage.completion_tokens ?? undefined, + } +} + +export function calculateFluxFromUsage(usage: UsageInfo, fluxPer1kTokens: number, fallbackRate: number): number { + const { promptTokens, completionTokens } = usage + if (promptTokens != null && completionTokens != null) { + const totalTokens = promptTokens + completionTokens + return Math.max(1, Math.ceil(totalTokens / 1000 * fluxPer1kTokens)) + } + return fallbackRate +} diff --git a/apps/server/src/services/characters.ts b/apps/server/src/services/characters.ts index b22cb3985..046be50e4 100644 --- a/apps/server/src/services/characters.ts +++ b/apps/server/src/services/characters.ts @@ -1,4 +1,5 @@ import type { Database } from '../libs/db' +import type { EngagementMetrics } from '../libs/otel' import { useLogger } from '@guiiai/logg' import { and, eq, isNull, sql } from 'drizzle-orm' @@ -8,7 +9,7 @@ import * as userCharacterSchema from '../schemas/user-character' const logger = useLogger('characters') -export function createCharacterService(db: Database) { +export function createCharacterService(db: Database, metrics?: EngagementMetrics | null) { return { async findById(id: string) { return await db.query.character.findFirst({ @@ -58,7 +59,7 @@ export function createCharacterService(db: Database) { }, async like(userId: string, characterId: string) { - return await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const existing = await tx.query.characterLikes.findFirst({ where: and( eq(userCharacterSchema.characterLikes.userId, userId), @@ -93,10 +94,13 @@ export function createCharacterService(db: Database) { return { liked: true } } }) + + metrics?.characterEngagement.add(1, { action: result.liked ? 'like' : 'unlike' }) + return result }, async bookmark(userId: string, characterId: string) { - return await db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const existing = await tx.query.characterBookmarks.findFirst({ where: and( eq(userCharacterSchema.characterBookmarks.userId, userId), @@ -131,6 +135,9 @@ export function createCharacterService(db: Database) { return { bookmarked: true } } }) + + metrics?.characterEngagement.add(1, { action: result.bookmarked ? 'bookmark' : 'unbookmark' }) + return result }, async create(data: { @@ -141,7 +148,7 @@ export function createCharacterService(db: Database) { i18n?: Omit[] prompts?: Omit[] }) { - return await db.transaction(async (tx) => { + const inserted = await db.transaction(async (tx) => { const [inserted] = await tx.insert(schema.character).values(data.character).returning() logger.withFields({ id: inserted.id, ownerId: data.character.ownerId }).log('Created character') @@ -178,6 +185,9 @@ export function createCharacterService(db: Database) { return inserted }) + + metrics?.characterCreated.add(1) + return inserted }, async update(id: string, data: Partial) { @@ -200,7 +210,11 @@ export function createCharacterService(db: Database) { isNull(schema.character.deletedAt), )) .returning() - logger.withFields({ id }).log('Deleted character') + + if (result.length > 0) { + logger.withFields({ id }).log('Deleted character') + metrics?.characterDeleted.add(1) + } return result }, } diff --git a/apps/server/src/services/chats.ts b/apps/server/src/services/chats.ts index ef6840722..56afb6acb 100644 --- a/apps/server/src/services/chats.ts +++ b/apps/server/src/services/chats.ts @@ -1,174 +1,329 @@ +import type { MessageRole, WireMessage } from '@proj-airi/server-sdk' + import type { Database } from '../libs/db' +import type { EngagementMetrics } from '../libs/otel' import { useLogger } from '@guiiai/logg' -import { and, eq, inArray, sql } from 'drizzle-orm' +import { and, eq, gt, inArray, isNull, sql } from 'drizzle-orm' -import { createConflictError, createForbiddenError } from '../utils/error' +import { createForbiddenError, createNotFoundError } from '../utils/error' +import { nanoid } from '../utils/id' import * as schema from '../schemas/chats' const logger = useLogger('chats') type ChatType = 'private' | 'bot' | 'group' | 'channel' -type MessageRole = 'system' | 'user' | 'assistant' | 'tool' | 'error' type ChatMemberType = 'user' | 'character' | 'bot' -interface SyncChatMessagePayload { +interface CreateChatPayload { + id?: string + type?: ChatType + title?: string + members?: { type: ChatMemberType, userId?: string, characterId?: string }[] +} + +interface PushMessage { id: string - role: MessageRole + role: string content: string - createdAt?: number } -interface SyncChatMemberPayload { - type: ChatMemberType - userId?: string - characterId?: string +// --------------------------------------------------------------------------- +// Pure helpers (exported for testing) +// --------------------------------------------------------------------------- + +export function clampLimit(limit?: number): number { + if (!limit || limit <= 0) + return 100 + return Math.min(limit, 500) } -interface SyncChatPayload { - chat: { - id: string - type?: ChatType - title?: string - createdAt?: number - updatedAt?: number - } - members?: SyncChatMemberPayload[] - messages: SyncChatMessagePayload[] -} - -function resolveSenderId(role: MessageRole, userId: string, characterId?: string) { +export function resolveSenderId(role: string, userId: string, characterId?: string | null): string | null { if (role === 'user') return userId - return characterId ?? role + return characterId ?? null } -function pickCharacterId(members: SyncChatMemberPayload[] | undefined) { - return members?.find(member => member.type === 'character' && member.characterId)?.characterId -} +// --------------------------------------------------------------------------- +// Service factory +// --------------------------------------------------------------------------- + +export function createChatService(db: Database, metrics?: EngagementMetrics | null) { + // ---- internal helpers --------------------------------------------------- + + async function verifyMembership(tx: Parameters[0]>[0], chatId: string, userId: string) { + const chat = await tx.query.chats.findFirst({ + where: and(eq(schema.chats.id, chatId), isNull(schema.chats.deletedAt)), + }) + if (!chat) + throw createNotFoundError('Chat not found') + + const member = await tx.query.chatMembers.findFirst({ + where: and( + eq(schema.chatMembers.chatId, chatId), + eq(schema.chatMembers.memberType, 'user'), + eq(schema.chatMembers.userId, userId), + ), + }) + if (!member) { + logger.withFields({ userId, chatId }).warn('User not a member of chat, forbidden') + throw createForbiddenError() + } + + return chat + } + + // ---- public API --------------------------------------------------------- -export function createChatService(db: Database) { return { - async syncChat(userId: string, payload: SyncChatPayload) { - return await db.transaction(async (tx) => { - const now = new Date() - const chatId = payload.chat.id - const members = payload.members ?? [] - const characterId = pickCharacterId(members) + // -- Chat management (REST) --------------------------------------------- - const existingChat = await tx.query.chats.findFirst({ - where: eq(schema.chats.id, chatId), + async createChat(userId: string, payload: CreateChatPayload) { + return db.transaction(async (tx) => { + const chatId = payload.id ?? nanoid() + const now = new Date() + + await tx.insert(schema.chats).values({ + id: chatId, + type: payload.type ?? 'group', + title: payload.title ?? null, + createdAt: now, + updatedAt: now, }) - if (existingChat) { - const member = await tx.query.chatMembers.findFirst({ - where: and( - eq(schema.chatMembers.chatId, chatId), - eq(schema.chatMembers.memberType, 'user'), - eq(schema.chatMembers.userId, userId), - ), - }) + // Always add creator as a user member + await tx.insert(schema.chatMembers).values({ + chatId, + memberType: 'user', + userId, + characterId: null, + }) - if (!member) { - logger.withFields({ userId, chatId }).warn('User not a member of chat, sync forbidden') - throw createForbiddenError() - } - } - - if (!existingChat) { - await tx.insert(schema.chats).values({ - id: chatId, - type: payload.chat.type ?? 'group', - title: payload.chat.title, - createdAt: payload.chat.createdAt ? new Date(payload.chat.createdAt) : now, - updatedAt: payload.chat.updatedAt ? new Date(payload.chat.updatedAt) : now, - }) - } - else { - const updates: Partial = { - updatedAt: payload.chat.updatedAt ? new Date(payload.chat.updatedAt) : now, - } - - if (payload.chat.type) - updates.type = payload.chat.type - if (payload.chat.title !== undefined) - updates.title = payload.chat.title - - await tx.update(schema.chats) - .set(updates) - .where(eq(schema.chats.id, chatId)) - } - - const desiredMembers: SyncChatMemberPayload[] = [ - { type: 'user', userId }, - ...members.filter(member => member.type !== 'user'), - ] - - for (const member of desiredMembers) { - if (member.type === 'user' && !member.userId) - continue - if (member.type === 'character' && !member.characterId) - continue - - const existingMember = await tx.query.chatMembers.findFirst({ - where: and( - eq(schema.chatMembers.chatId, chatId), - eq(schema.chatMembers.memberType, member.type), - member.type === 'user' - ? eq(schema.chatMembers.userId, member.userId!) - : eq(schema.chatMembers.characterId, member.characterId!), - ), - }) - - if (!existingMember) { - await tx.insert(schema.chatMembers).values({ + // Add additional members if provided + if (payload.members && payload.members.length > 0) { + const extra = payload.members + .filter(m => m.type !== 'user' || m.userId !== userId) // skip duplicate creator + .map(m => ({ chatId, - memberType: member.type, - userId: member.type === 'user' ? member.userId : null, - characterId: member.type === 'character' ? member.characterId : null, - }) + memberType: m.type, + userId: m.type === 'user' ? (m.userId ?? null) : null, + characterId: m.type !== 'user' ? (m.characterId ?? null) : null, + })) + + if (extra.length > 0) { + await tx.insert(schema.chatMembers).values(extra) } } - if (payload.messages.length > 0) { - const messageIds = payload.messages.map(m => m.id) - const existingMessages = await tx - .select({ id: schema.messages.id, chatId: schema.messages.chatId }) - .from(schema.messages) - .where(inArray(schema.messages.id, messageIds)) + return { id: chatId, type: payload.type ?? 'group', title: payload.title ?? null, createdAt: now, updatedAt: now } + }) + }, - const conflicting = existingMessages.find(m => m.chatId !== chatId) - if (conflicting) { - logger.withFields({ messageId: conflicting.id, expectedChatId: chatId, actualChatId: conflicting.chatId }).warn('Message conflict detected') - throw createConflictError('Message already belongs to another chat') - } + async getChat(userId: string, chatId: string) { + return db.transaction(async (tx) => { + const chat = await verifyMembership(tx, chatId, userId) + const members = await tx.query.chatMembers.findMany({ + where: eq(schema.chatMembers.chatId, chatId), + }) + return { ...chat, members } + }) + }, - await tx.insert(schema.messages) - .values(payload.messages.map(message => ({ - id: message.id, + async listChats(userId: string) { + const rows = await db + .select({ chat: schema.chats }) + .from(schema.chatMembers) + .innerJoin(schema.chats, eq(schema.chatMembers.chatId, schema.chats.id)) + .where(and( + eq(schema.chatMembers.memberType, 'user'), + eq(schema.chatMembers.userId, userId), + isNull(schema.chats.deletedAt), + )) + + return rows.map(r => r.chat) + }, + + async updateChat(userId: string, chatId: string, updates: { title?: string }) { + return db.transaction(async (tx) => { + await verifyMembership(tx, chatId, userId) + const now = new Date() + + const [updated] = await tx.update(schema.chats) + .set({ ...updates, updatedAt: now }) + .where(eq(schema.chats.id, chatId)) + .returning() + + return updated + }) + }, + + async deleteChat(userId: string, chatId: string) { + return db.transaction(async (tx) => { + await verifyMembership(tx, chatId, userId) + const now = new Date() + + const [deleted] = await tx.update(schema.chats) + .set({ deletedAt: now, updatedAt: now }) + .where(eq(schema.chats.id, chatId)) + .returning() + + return deleted + }) + }, + + async addMember(userId: string, chatId: string, member: { type: ChatMemberType, userId?: string, characterId?: string }) { + return db.transaction(async (tx) => { + await verifyMembership(tx, chatId, userId) + + const [added] = await tx.insert(schema.chatMembers).values({ + chatId, + memberType: member.type, + userId: member.type === 'user' ? (member.userId ?? null) : null, + characterId: member.type !== 'user' ? (member.characterId ?? null) : null, + }).returning() + + return added + }) + }, + + async removeMember(userId: string, chatId: string, memberId: string) { + return db.transaction(async (tx) => { + await verifyMembership(tx, chatId, userId) + + const [removed] = await tx.delete(schema.chatMembers) + .where(and( + eq(schema.chatMembers.id, memberId), + eq(schema.chatMembers.chatId, chatId), + )) + .returning() + + if (!removed) + throw createNotFoundError('Member not found') + return removed + }) + }, + + // -- Message sync (WS) -------------------------------------------------- + + async pushMessages(userId: string, chatId: string, messages: PushMessage[], characterId?: string) { + const result = await db.transaction(async (tx) => { + await verifyMembership(tx, chatId, userId) + + // Lock chat row to serialize seq assignment + const [chatRow] = await tx + .select({ id: schema.chats.id }) + .from(schema.chats) + .where(eq(schema.chats.id, chatId)) + .for('update') + + if (!chatRow) + throw createNotFoundError('Chat not found') + + // Get current max seq for this chat + const [{ maxSeq }] = await tx + .select({ maxSeq: sql`coalesce(max(${schema.messages.seq}), 0)` }) + .from(schema.messages) + .where(eq(schema.messages.chatId, chatId)) + + const now = new Date() + + // Split into new vs existing messages + const messageIds = messages.map(m => m.id) + const existingMessages = messageIds.length > 0 + ? await tx.select({ id: schema.messages.id }).from(schema.messages).where(inArray(schema.messages.id, messageIds)) + : [] + const existingIds = new Set(existingMessages.map(m => m.id)) + + const newMsgs = messages.filter(m => !existingIds.has(m.id)) + const updateMsgs = messages.filter(m => existingIds.has(m.id)) + + let currentSeq = maxSeq + + // Insert new messages with seq + if (newMsgs.length > 0) { + const values = newMsgs.map((m) => { + currentSeq++ + return { + id: m.id, chatId, - senderId: resolveSenderId(message.role, userId, characterId), - role: message.role, - content: message.content, + senderId: resolveSenderId(m.role, userId, characterId), + role: m.role, + seq: currentSeq, + content: m.content, mediaIds: [] as string[], stickerIds: [] as string[], - createdAt: message.createdAt ? new Date(message.createdAt) : now, + createdAt: now, updatedAt: now, - }))) - .onConflictDoUpdate({ - target: schema.messages.id, - set: { - senderId: sql`excluded.sender_id`, - role: sql`excluded.role`, - content: sql`excluded.content`, - updatedAt: sql`excluded.updated_at`, - }, - }) + } + }) + await tx.insert(schema.messages).values(values) } - logger.withFields({ userId, chatId, messageCount: payload.messages.length, isNew: !existingChat }).log('Synced chat') - return { chatId } + // Update existing messages (content + updatedAt only) + for (const m of updateMsgs) { + await tx.update(schema.messages) + .set({ content: m.content, updatedAt: now }) + .where(eq(schema.messages.id, m.id)) + } + + // Update chat updatedAt + await tx.update(schema.chats) + .set({ updatedAt: now }) + .where(eq(schema.chats.id, chatId)) + + return { + seq: currentSeq, + fromSeq: maxSeq + 1, + toSeq: currentSeq, + newCount: newMsgs.length, + totalCount: messages.length, + } + }) + + metrics?.chatSync.add(1) + if (result.totalCount > 0) { + metrics?.chatMessages.add(result.totalCount) + } + metrics?.wsMessagesReceived.add(result.totalCount) + + return { seq: result.seq, fromSeq: result.fromSeq, toSeq: result.toSeq } + }, + + async pullMessages(userId: string, chatId: string, afterSeq: number, limit?: number) { + return db.transaction(async (tx) => { + await verifyMembership(tx, chatId, userId) + + const clamped = clampLimit(limit) + + const rows = await tx + .select() + .from(schema.messages) + .where(and( + eq(schema.messages.chatId, chatId), + gt(schema.messages.seq, afterSeq), + )) + .orderBy(schema.messages.seq) + .limit(clamped) + + // Get current max seq + const [{ maxSeq }] = await tx + .select({ maxSeq: sql`coalesce(max(${schema.messages.seq}), 0)` }) + .from(schema.messages) + .where(eq(schema.messages.chatId, chatId)) + + const wireMessages: WireMessage[] = rows.map(r => ({ + id: r.id, + chatId: r.chatId, + senderId: r.senderId, + role: r.role as MessageRole, + content: r.content, + seq: r.seq!, + createdAt: r.createdAt.getTime(), + updatedAt: r.updatedAt.getTime(), + })) + + return { messages: wireMessages, seq: maxSeq } }) }, } diff --git a/apps/server/src/services/config-kv.ts b/apps/server/src/services/config-kv.ts index a7b73d5a2..69a233dee 100644 --- a/apps/server/src/services/config-kv.ts +++ b/apps/server/src/services/config-kv.ts @@ -1,4 +1,7 @@ import type Redis from 'ioredis' +import type { InferOutput } from 'valibot' + +import { array, number, object, optional, parse, string } from 'valibot' import { createServiceUnavailableError } from '../utils/error' @@ -11,19 +14,26 @@ export interface FluxPackage { price: string } -interface ConfigDefinitions { - FLUX_PER_CENT: number - FLUX_PER_REQUEST: number - FLUX_PER_REQUEST_TTS: number - FLUX_PER_REQUEST_ASR: number - INITIAL_USER_FLUX: number - FLUX_PACKAGES: FluxPackage[] - FLUX_PER_1K_TOKENS: number - GATEWAY_BASE_URL: string - DEFAULT_CHAT_MODEL: string -} +/** + * Config schema with valibot defaults. + * Keys with `optional(..., defaultValue)` will fall back when Redis returns null. + * Keys without a default will throw via `getOrThrow` if not set. + */ +const ConfigSchema = object({ + FLUX_PER_CENT: optional(number(), 10), + FLUX_PER_REQUEST: optional(number(), 5), + FLUX_PER_REQUEST_TTS: number(), + FLUX_PER_REQUEST_ASR: number(), + INITIAL_USER_FLUX: optional(number(), 0), + FLUX_PACKAGES: optional(array(object({ amount: number(), label: string(), price: string() })), []), + FLUX_PER_1K_TOKENS: optional(number(), 1), + GATEWAY_BASE_URL: string(), + DEFAULT_CHAT_MODEL: string(), +}) -const NUMERIC_KEYS = new Set(['FLUX_PER_CENT', 'FLUX_PER_REQUEST', 'FLUX_PER_REQUEST_TTS', 'FLUX_PER_REQUEST_ASR', 'INITIAL_USER_FLUX', 'FLUX_PER_1K_TOKENS']) +type ConfigDefinitions = InferOutput + +const NUMERIC_KEYS = new Set(['FLUX_PER_CENT', 'FLUX_PER_REQUEST', 'FLUX_PER_REQUEST_TTS', 'FLUX_PER_REQUEST_ASR', 'INITIAL_USER_FLUX', 'FLUX_PER_1K_TOKENS']) const KEY_PREFIX = 'config:' @@ -35,40 +45,48 @@ function parseValue(key: K, raw: string): Con return raw as ConfigDefinitions[K] } -function serializeValue(key: K, value: ConfigDefinitions[K]): string { - if (key === 'FLUX_PACKAGES') - return JSON.stringify(value) - return String(value) +/** + * Resolve a config value: read from Redis, then apply valibot default if missing. + * Returns `undefined` if both Redis and schema have no value (required key, not set). + */ +function resolveWithDefault(key: K, raw: string | null): ConfigDefinitions[K] | undefined { + if (raw !== null) + return parseValue(key, raw) + + // Use valibot parse with `undefined` to trigger the schema default + try { + const result = parse(ConfigSchema, { [key]: undefined }) + return result[key] as ConfigDefinitions[K] + } + catch { + return undefined + } } export function createConfigKVService(redis: Redis) { return { async getOptional(key: K): Promise { const raw = await redis.get(`${KEY_PREFIX}${key}`) - if (raw === null) - return null - - return parseValue(key, raw) + const value = resolveWithDefault(key, raw) + return value ?? null }, async getOrThrow(key: K): Promise { - const value = await this.getOptional(key) - if (value === null) - throw createServiceUnavailableError(`Config key "${key}" is not set in Redis`, 'CONFIG_NOT_SET') + const raw = await redis.get(`${KEY_PREFIX}${key}`) + const value = resolveWithDefault(key, raw) + if (value === undefined) + throw createServiceUnavailableError('Service configuration is incomplete', 'CONFIG_NOT_SET') return value }, async get(key: K): Promise { - const value = await this.getOptional(key) - if (value === null) - throw createServiceUnavailableError(`Config key "${key}" is not set in Redis`, 'CONFIG_NOT_SET') - - return value + return this.getOrThrow(key) }, async set(key: K, value: ConfigDefinitions[K]): Promise { - await redis.set(`${KEY_PREFIX}${key}`, serializeValue(key, value)) + const serialized = key === 'FLUX_PACKAGES' ? JSON.stringify(value) : String(value) + await redis.set(`${KEY_PREFIX}${key}`, serialized) }, } } diff --git a/apps/server/src/services/flux.ts b/apps/server/src/services/flux.ts index 6976eb494..6eaacae74 100644 --- a/apps/server/src/services/flux.ts +++ b/apps/server/src/services/flux.ts @@ -2,30 +2,24 @@ import type Redis from 'ioredis' import type { Database } from '../libs/db' import type { ConfigKVService } from './config-kv' -import type { FluxAuditService } from './flux-audit' import { useLogger } from '@guiiai/logg' -import { eq, sql } from 'drizzle-orm' - -import { createPaymentRequiredError } from '../utils/error' +import { eq } from 'drizzle-orm' import * as schema from '../schemas/flux' +import * as fluxLedgerSchema from '../schemas/flux-ledger' const logger = useLogger('flux-service') -function redisKey(userId: string): string { +export function fluxRedisKey(userId: string): string { return `flux:${userId}` } -function buildAuditMetadata(metadata?: Record) { - return metadata && Object.keys(metadata).length > 0 ? metadata : undefined -} - -export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService, fluxAuditService: FluxAuditService) { +export function createFluxService(db: Database, redis: Redis, configKV: ConfigKVService) { return { async getFlux(userId: string) { // 1. Try Redis cache - const cached = await redis.get(redisKey(userId)) + const cached = await redis.get(fluxRedisKey(userId)) if (cached !== null) { return { userId, flux: Number.parseInt(cached, 10) } } @@ -37,104 +31,45 @@ export function createFluxService(db: Database, redis: Redis, configKV: ConfigKV if (!record) { const initialFlux = await configKV.getOrThrow('INITIAL_USER_FLUX') - ;[record] = await db.insert(schema.userFlux).values({ - userId, - flux: initialFlux, - }).returning() + + // Transaction: create user_flux + flux_ledger atomically + await db.transaction(async (tx) => { + const [inserted] = await tx.insert(schema.userFlux) + .values({ userId, flux: initialFlux }) + .onConflictDoNothing({ target: schema.userFlux.userId }) + .returning() + + // Only write ledger if we actually created the record (not a conflict) + if (inserted) { + await tx.insert(fluxLedgerSchema.fluxLedger).values({ + userId, + type: 'initial', + amount: initialFlux, + balanceBefore: 0, + balanceAfter: initialFlux, + description: 'Initial grant', + }) + } + }) + + // Re-read to handle race condition (another request may have initialized first) + record = await db.query.userFlux.findFirst({ + where: eq(schema.userFlux.userId, userId), + }) + + if (!record) { + throw new Error(`Failed to initialize flux for user ${userId}`) + } logger.withFields({ userId, initialFlux }).log('Initialized new user flux') - - // Audit: initial grant - await fluxAuditService.log({ - userId, - type: 'initial', - amount: initialFlux, - description: 'Initial grant', - }) } // 3. Populate Redis cache - await redis.set(redisKey(userId), String(record.flux)) + await redis.set(fluxRedisKey(userId), String(record.flux)) return record }, - async consumeFlux(userId: string, amount: number, options?: { description?: string, metadata?: Record }) { - // Ensure Redis key exists before DECRBY - // (DECRBY on a nonexistent key creates it at 0, giving wrong balance) - await this.getFlux(userId) - - // Atomic decrement — check result. - // Note: there is a small race window between DECRBY returning negative - // and INCRBY rolling back, during which another concurrent request could - // see the negative balance and also attempt rollback. We accept this - // trade-off — the initial balance check is the real guard, and this - // DECRBY+rollback is a safety net, not a guarantee. - const newBalance = await redis.decrby(redisKey(userId), amount) - if (newBalance < 0) { - await redis.incrby(redisKey(userId), amount) - logger.withFields({ userId, amount }).warn('Insufficient flux, rolled back') - throw createPaymentRequiredError('Insufficient flux') - } - - try { - const [updated] = await db.update(schema.userFlux) - .set({ - flux: sql`${schema.userFlux.flux} - ${amount}`, - updatedAt: new Date(), - }) - .where(eq(schema.userFlux.userId, userId)) - .returning() - - if (!updated) { - throw new Error(`Flux record missing for user ${userId}`) - } - - await fluxAuditService.log({ - userId, - type: 'consumption', - amount: -amount, - description: options?.description ?? 'Usage', - metadata: buildAuditMetadata(options?.metadata), - }) - } - catch (error) { - await redis.incrby(redisKey(userId), amount) - throw error - } - - logger.withFields({ userId, amount, newBalance }).log('Consumed flux') - return { userId, flux: newBalance } - }, - - async addFlux(userId: string, amount: number, description = 'Top-up') { - // Ensure user record exists in DB - await this.getFlux(userId) - - // DB update (persistence for Stripe payments) - await db.update(schema.userFlux) - .set({ - flux: sql`${schema.userFlux.flux} + ${amount}`, - updatedAt: new Date(), - }) - .where(eq(schema.userFlux.userId, userId)) - - // Sync Redis cache - const newBalance = await redis.incrby(redisKey(userId), amount) - - logger.withFields({ userId, amount, newBalance, description }).log('Added flux') - - // Audit: addition - await fluxAuditService.log({ - userId, - type: 'addition', - amount, - description, - }) - - return { userId, flux: newBalance } - }, - async updateStripeCustomerId(userId: string, stripeCustomerId: string) { const [updated] = await db.update(schema.userFlux) .set({ diff --git a/apps/server/src/services/outbox-dispatcher.ts b/apps/server/src/services/outbox-dispatcher.ts new file mode 100644 index 000000000..92c5638c9 --- /dev/null +++ b/apps/server/src/services/outbox-dispatcher.ts @@ -0,0 +1,83 @@ +import type { BillingMqService } from './billing-mq' +import type { OutboxService } from './outbox-service' + +import { useLogger } from '@guiiai/logg' + +export interface RunOutboxDispatcherOptions { + claimedBy: string + signal: AbortSignal + batchSize?: number + claimTtlMs?: number + pollIntervalMs?: number +} + +const logger = useLogger('outbox-dispatcher').useGlobalConfig() + +export function createOutboxDispatcher(outboxService: OutboxService, billingMqService: BillingMqService) { + return { + async dispatchBatch(claimedBy: string, batchSize: number, claimTtlMs: number): Promise { + const claimedEvents = await outboxService.claimPending({ + claimedBy, + limit: batchSize, + claimTtlMs, + }) + + for (const claimedEvent of claimedEvents) { + try { + const streamMessageId = await billingMqService.publish(claimedEvent.event) + await outboxService.markPublished(claimedEvent.id, streamMessageId) + } + catch (error) { + await outboxService.releaseClaim(claimedEvent.id) + logger.withError(error).withFields({ + claimedBy, + outboxId: claimedEvent.id, + eventId: claimedEvent.event.eventId, + eventType: claimedEvent.event.eventType, + }).error('Failed to dispatch outbox event') + } + } + + return claimedEvents.length + }, + + async run(options: RunOutboxDispatcherOptions): Promise { + while (!options.signal.aborted) { + const dispatchedCount = await this.dispatchBatch( + options.claimedBy, + options.batchSize ?? 10, + options.claimTtlMs ?? 30_000, + ) + + if (dispatchedCount > 0) { + continue + } + + await sleep(options.pollIntervalMs ?? 1_000, options.signal) + } + }, + } +} + +async function sleep(timeoutMs: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return + } + + await new Promise((resolve) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, timeoutMs) + + const onAbort = () => { + clearTimeout(timeout) + signal.removeEventListener('abort', onAbort) + resolve() + } + + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + +export type OutboxDispatcher = ReturnType diff --git a/apps/server/src/services/outbox-service.ts b/apps/server/src/services/outbox-service.ts new file mode 100644 index 000000000..1e12e73c6 --- /dev/null +++ b/apps/server/src/services/outbox-service.ts @@ -0,0 +1,123 @@ +import type { Database } from '../libs/db' +import type { BillingEvent } from './billing-events' + +import { and, asc, eq, inArray, isNull, lte, or } from 'drizzle-orm' + +import { outboxEvents } from '../schemas/outbox-events' +import { parseBillingEvent, serializeBillingEvent } from './billing-events' + +interface OutboxWriter { + insert: Database['insert'] +} + +export interface ClaimOutboxEventsOptions { + claimedBy: string + limit: number + claimTtlMs: number +} + +export interface ClaimedOutboxEvent { + id: string + event: BillingEvent +} + +export function createOutboxService(db: Database) { + return { + async enqueue(writer: OutboxWriter, event: BillingEvent): Promise { + const serializedEvent = serializeBillingEvent(event) + await writer.insert(outboxEvents).values({ + eventId: serializedEvent.event_id, + eventType: serializedEvent.event_type, + aggregateId: serializedEvent.aggregate_id, + userId: serializedEvent.user_id, + requestId: serializedEvent.request_id, + schemaVersion: Number(serializedEvent.schema_version), + payload: serializedEvent.payload, + occurredAt: new Date(serializedEvent.occurred_at), + }) + }, + + async claimPending(options: ClaimOutboxEventsOptions): Promise { + const now = new Date() + const claimExpiresAt = new Date(now.getTime() + options.claimTtlMs) + + return db.transaction(async (tx) => { + const claimable = tx.$with('claimable').as( + tx + .select({ id: outboxEvents.id }) + .from(outboxEvents) + .where(and( + isNull(outboxEvents.publishedAt), + lte(outboxEvents.availableAt, now), + or( + isNull(outboxEvents.claimExpiresAt), + lte(outboxEvents.claimExpiresAt, now), + ), + )) + .orderBy(asc(outboxEvents.createdAt)) + .limit(options.limit) + .for('update', { skipLocked: true }), + ) + + const claimedRows = await tx + .with(claimable) + .update(outboxEvents) + .set({ + claimedBy: options.claimedBy, + claimExpiresAt, + updatedAt: now, + }) + .where(inArray(outboxEvents.id, tx.select({ id: claimable.id }).from(claimable))) + .returning({ + id: outboxEvents.id, + eventId: outboxEvents.eventId, + eventType: outboxEvents.eventType, + aggregateId: outboxEvents.aggregateId, + userId: outboxEvents.userId, + requestId: outboxEvents.requestId, + schemaVersion: outboxEvents.schemaVersion, + payload: outboxEvents.payload, + occurredAt: outboxEvents.occurredAt, + }) + + return claimedRows.map(row => ({ + id: row.id, + event: parseBillingEvent({ + event_id: row.eventId, + event_type: row.eventType, + aggregate_id: row.aggregateId, + user_id: row.userId, + request_id: row.requestId ?? undefined, + schema_version: String(row.schemaVersion), + payload: row.payload, + occurred_at: row.occurredAt.toISOString(), + }), + })) + }) + }, + + async markPublished(id: string, streamMessageId: string): Promise { + await db.update(outboxEvents) + .set({ + publishedAt: new Date(), + streamMessageId, + claimedBy: null, + claimExpiresAt: null, + updatedAt: new Date(), + }) + .where(eq(outboxEvents.id, id)) + }, + + async releaseClaim(id: string): Promise { + await db.update(outboxEvents) + .set({ + claimedBy: null, + claimExpiresAt: null, + updatedAt: new Date(), + }) + .where(eq(outboxEvents.id, id)) + }, + } +} + +export type OutboxService = ReturnType diff --git a/apps/server/src/services/stripe.ts b/apps/server/src/services/stripe.ts index 489087256..70cc20ea2 100644 --- a/apps/server/src/services/stripe.ts +++ b/apps/server/src/services/stripe.ts @@ -2,7 +2,7 @@ import type { Database } from '../libs/db' import type { NewStripeCheckoutSession, NewStripeCustomer, NewStripeInvoice, NewStripeSubscription } from '../schemas/stripe' import { useLogger } from '@guiiai/logg' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import * as schema from '../schemas/stripe' @@ -13,24 +13,15 @@ export function createStripeService(db: Database) { // ---- Customer ---- async upsertCustomer(data: NewStripeCustomer) { - const existing = await db.query.stripeCustomer.findFirst({ - where: eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId), - }) - - if (existing) { - const [updated] = await db.update(schema.stripeCustomer) - .set({ ...data, updatedAt: new Date() }) - .where(eq(schema.stripeCustomer.stripeCustomerId, data.stripeCustomerId)) - .returning() - logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Updated Stripe customer') - return updated - } - - const [created] = await db.insert(schema.stripeCustomer) + const [row] = await db.insert(schema.stripeCustomer) .values(data) + .onConflictDoUpdate({ + target: schema.stripeCustomer.stripeCustomerId, + set: { ...data, updatedAt: new Date() }, + }) .returning() - logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Created Stripe customer') - return created + logger.withFields({ userId: data.userId, stripeCustomerId: data.stripeCustomerId }).log('Upserted Stripe customer') + return row }, async getCustomerByUserId(userId: string) { @@ -48,24 +39,15 @@ export function createStripeService(db: Database) { // ---- Checkout Session ---- async upsertCheckoutSession(data: NewStripeCheckoutSession) { - const existing = await db.query.stripeCheckoutSession.findFirst({ - where: eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId), - }) - - if (existing) { - const [updated] = await db.update(schema.stripeCheckoutSession) - .set({ ...data, updatedAt: new Date() }) - .where(eq(schema.stripeCheckoutSession.stripeSessionId, data.stripeSessionId)) - .returning() - logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Updated checkout session') - return updated - } - - const [created] = await db.insert(schema.stripeCheckoutSession) + const [row] = await db.insert(schema.stripeCheckoutSession) .values(data) + .onConflictDoUpdate({ + target: schema.stripeCheckoutSession.stripeSessionId, + set: { ...data, updatedAt: new Date() }, + }) .returning() - logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Created checkout session') - return created + logger.withFields({ userId: data.userId, sessionId: data.stripeSessionId, status: data.status }).log('Upserted checkout session') + return row }, async getCheckoutSessionsByUserId(userId: string) { @@ -78,29 +60,23 @@ export function createStripeService(db: Database) { // ---- Subscription ---- async upsertSubscription(data: NewStripeSubscription) { - const existing = await db.query.stripeSubscription.findFirst({ - where: eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId), - }) - - if (existing) { - const [updated] = await db.update(schema.stripeSubscription) - .set({ ...data, updatedAt: new Date() }) - .where(eq(schema.stripeSubscription.stripeSubscriptionId, data.stripeSubscriptionId)) - .returning() - logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Updated subscription') - return updated - } - - const [created] = await db.insert(schema.stripeSubscription) + const [row] = await db.insert(schema.stripeSubscription) .values(data) + .onConflictDoUpdate({ + target: schema.stripeSubscription.stripeSubscriptionId, + set: { ...data, updatedAt: new Date() }, + }) .returning() - logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Created subscription') - return created + logger.withFields({ userId: data.userId, subscriptionId: data.stripeSubscriptionId, status: data.status }).log('Upserted subscription') + return row }, async getActiveSubscription(userId: string) { return db.query.stripeSubscription.findFirst({ - where: eq(schema.stripeSubscription.userId, userId), + where: and( + eq(schema.stripeSubscription.userId, userId), + eq(schema.stripeSubscription.status, 'active'), + ), orderBy: (t, { desc }) => [desc(t.createdAt)], }) }, @@ -108,24 +84,15 @@ export function createStripeService(db: Database) { // ---- Invoice ---- async upsertInvoice(data: NewStripeInvoice) { - const existing = await db.query.stripeInvoice.findFirst({ - where: eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId), - }) - - if (existing) { - const [updated] = await db.update(schema.stripeInvoice) - .set({ ...data, updatedAt: new Date() }) - .where(eq(schema.stripeInvoice.stripeInvoiceId, data.stripeInvoiceId)) - .returning() - logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Updated invoice') - return updated - } - - const [created] = await db.insert(schema.stripeInvoice) + const [row] = await db.insert(schema.stripeInvoice) .values(data) + .onConflictDoUpdate({ + target: schema.stripeInvoice.stripeInvoiceId, + set: { ...data, updatedAt: new Date() }, + }) .returning() - logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Created invoice') - return created + logger.withFields({ userId: data.userId, invoiceId: data.stripeInvoiceId, status: data.status }).log('Upserted invoice') + return row }, async getInvoicesByUserId(userId: string) { diff --git a/packages/server-sdk/package.json b/packages/server-sdk/package.json index f16d656f1..6d709e0e2 100644 --- a/packages/server-sdk/package.json +++ b/packages/server-sdk/package.json @@ -37,6 +37,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@moeru/eventa": "catalog:", "@moeru/std": "catalog:", "@proj-airi/server-shared": "workspace:^", "crossws": "^0.4.4", diff --git a/packages/server-sdk/src/events/chat.ts b/packages/server-sdk/src/events/chat.ts new file mode 100644 index 000000000..ad14cd3e8 --- /dev/null +++ b/packages/server-sdk/src/events/chat.ts @@ -0,0 +1,45 @@ +import { defineInvokeEventa, defineOutboundEventa } from '@moeru/eventa' + +export interface WireMessage { + id: string + chatId: string + senderId: string | null + role: 'system' | 'user' | 'assistant' | 'tool' | 'error' + content: string + seq: number + createdAt: number + updatedAt: number +} + +export type MessageRole = WireMessage['role'] + +export interface SendMessagesRequest { + chatId: string + messages: { id: string, role: string, content: string }[] +} + +export interface SendMessagesResponse { + seq: number +} + +export interface PullMessagesRequest { + chatId: string + afterSeq: number + limit?: number +} + +export interface PullMessagesResponse { + messages: WireMessage[] + seq: number +} + +export interface NewMessagesPayload { + chatId: string + messages: WireMessage[] + fromSeq: number + toSeq: number +} + +export const sendMessages = defineInvokeEventa('chat:send-messages') +export const pullMessages = defineInvokeEventa('chat:pull-messages') +export const newMessages = defineOutboundEventa('chat:new-messages') diff --git a/packages/server-sdk/src/index.ts b/packages/server-sdk/src/index.ts index 78508edf6..8b471545a 100644 --- a/packages/server-sdk/src/index.ts +++ b/packages/server-sdk/src/index.ts @@ -1,3 +1,4 @@ export * from './client' +export * from './events/chat' export type * from '@proj-airi/server-shared/types' export { ContextUpdateStrategy, WebSocketEventSource } from '@proj-airi/server-shared/types' diff --git a/packages/stage-ui/src/components/auth/LoginDrawer.vue b/packages/stage-ui/src/components/auth/LoginDrawer.vue index a7eb19e27..54cf2671f 100644 --- a/packages/stage-ui/src/components/auth/LoginDrawer.vue +++ b/packages/stage-ui/src/components/auth/LoginDrawer.vue @@ -7,7 +7,7 @@ import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot } import { ref } from 'vue' import { toast } from 'vue-sonner' -import { signIn } from '../../libs/auth' +import { fetchSession, signIn } from '../../libs/auth' const open = defineModel('open', { required: true }) @@ -22,7 +22,10 @@ const loading = ref>({ async function handleSignIn(provider: OAuthProvider) { loading.value[provider] = true try { - await signIn(provider) + await Promise.all([ + () => signIn(provider), + fetchSession, + ]) } catch (error) { toast.error(error instanceof Error ? error.message : 'An unknown error occurred') diff --git a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue index c6c3dce2d..c31fb1ad2 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/onboarding/step-welcome.vue @@ -2,7 +2,7 @@ import type { OnboardingStepNextHandler } from './types' import { all } from '@proj-airi/i18n' -import { Button, FieldCombobox } from '@proj-airi/ui' +import { Button, FieldSelect } from '@proj-airi/ui' import { storeToRefs } from 'pinia' import { computed } from 'vue' import { useI18n } from 'vue-i18n' @@ -75,9 +75,9 @@ function handleLocalSetup() { :enter="{ opacity: 1, y: 0 }" :duration="500" :delay="150" - :class="['mx-auto', 'mt-6', 'w-full', 'max-w-sm', 'rounded-2xl', 'bg-neutral-100/80', 'p-4', 'backdrop-blur-sm', 'dark:bg-neutral-800/80']" + :class="['mx-auto', 'mt-6', 'w-full', 'max-w-sm', 'rounded-2xl', 'bg-neutral-100/80', 'backdrop-blur-sm', 'dark:bg-neutral-800/80', 'p-4']" > - -
+
diff --git a/packages/stage-ui/src/libs/server.ts b/packages/stage-ui/src/libs/server.ts index 7b3cf2e10..dd27eb710 100644 --- a/packages/stage-ui/src/libs/server.ts +++ b/packages/stage-ui/src/libs/server.ts @@ -1 +1 @@ -export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai' +export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://api.airi.build' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afad31cb2..085b1f7a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,9 +6,6 @@ settings: catalogs: default: - '@better-auth/drizzle-adapter': - specifier: ^1.5.6 - version: 1.5.6 '@capacitor/android': specifier: ^8.2.0 version: 8.2.0 @@ -39,6 +36,9 @@ catalogs: '@histoire/plugin-vue': specifier: 1.0.0-beta.1 version: 1.0.0-beta.1 + '@hono/node-ws': + specifier: ^1.3.0 + version: 1.3.0 '@iconify-json/logos': specifier: ^1.2.10 version: 1.2.10 @@ -69,9 +69,6 @@ catalogs: '@proj-airi/drizzle-duckdb-wasm': specifier: ^0.4.29 version: 0.4.29 - '@proj-airi/drizzle-orm-browser-migrator': - specifier: ^0.1.6 - version: 0.1.6 '@proj-airi/iconify-meteocons': specifier: ^0.1.4 version: 0.1.4 @@ -183,6 +180,9 @@ catalogs: hono: specifier: 4.11.3 version: 4.11.3 + hono-rate-limiter: + specifier: ^0.5.3 + version: 0.5.3 idb-keyval: specifier: ^6.2.2 version: 6.2.2 @@ -438,7 +438,7 @@ importers: version: 11.3.3(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) vitest: specifier: ^4.1.1 - version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) yaml: specifier: ^2.8.3 version: 2.8.3 @@ -501,8 +501,8 @@ importers: apps/server: dependencies: '@better-auth/drizzle-adapter': - specifier: 'catalog:' - version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + specifier: ^1.5.6 + version: 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) '@dotenvx/dotenvx': specifier: ^1.57.2 version: 1.57.2 @@ -515,81 +515,93 @@ importers: '@hono/node-server': specifier: ^1.19.11 version: 1.19.11(hono@4.11.3) + '@hono/node-ws': + specifier: 'catalog:' + version: 1.3.0(@hono/node-server@1.19.11(hono@4.11.3))(bufferutil@4.1.0)(hono@4.11.3)(utf-8-validate@5.0.10) + '@moeru/eventa': + specifier: 'catalog:' + version: 1.0.0-beta.2(electron@41.0.3)(h3@2.0.1-rc.19(crossws@0.4.4(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d)))) + '@moeru/std': + specifier: 'catalog:' + version: 0.1.0-beta.17 '@opentelemetry/api': - specifier: ^1.9.0 - version: 1.9.0 + specifier: ^1.9.1 + version: 1.9.1 '@opentelemetry/exporter-logs-otlp-proto': - specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-metrics-otlp-proto': - specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-proto': - specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-http': - specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-ioredis': - specifier: ^0.61.0 - version: 0.61.0(@opentelemetry/api@1.9.0) + specifier: ^0.62.0 + version: 0.62.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-pg': - specifier: ^0.65.0 - version: 0.65.0(@opentelemetry/api@1.9.0) + specifier: ^0.66.0 + version: 0.66.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-runtime-node': - specifier: ^0.26.0 - version: 0.26.0(@opentelemetry/api@1.9.0) + specifier: ^0.27.0 + version: 0.27.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': - specifier: ^2.6.0 - version: 2.6.0(@opentelemetry/api@1.9.0) + specifier: ^2.6.1 + version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-logs': - specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': - specifier: ^2.6.0 - version: 2.6.0(@opentelemetry/api@1.9.0) + specifier: ^2.6.1 + version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': - specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + specifier: ^0.214.0 + version: 0.214.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-node': - specifier: ^2.6.0 - version: 2.6.0(@opentelemetry/api@1.9.0) + specifier: ^2.6.1 + version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': specifier: ^1.40.0 version: 1.40.0 '@proj-airi/drizzle-orm-browser-migrator': - specifier: 'catalog:' - version: 0.1.6(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + specifier: ^0.1.6 + version: 0.1.6(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) '@proj-airi/server-schema': specifier: workspace:* version: link:../../packages/server-schema + '@proj-airi/server-sdk': + specifier: 'workspace:' + version: link:../../packages/server-sdk better-auth: specifier: ^1.5.6 - version: 1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) + version: 1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) drizzle-orm: - specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + specifier: ^0.45.1 + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) drizzle-valibot: specifier: 'catalog:' - version: 0.4.2(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(valibot@1.2.0(typescript@5.9.3)) + version: 0.4.2(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(valibot@1.2.0(typescript@5.9.3)) hono: specifier: 'catalog:' version: 4.11.3 + hono-rate-limiter: + specifier: 'catalog:' + version: 0.5.3(hono@4.11.3)(unstorage@1.17.4(aws4fetch@1.0.20)(idb-keyval@6.2.2)(ioredis@5.10.1)) injeca: specifier: 'catalog:' version: 0.1.8(@guiiai/logg@1.2.11)(error-stack-parser@2.1.4)(nanoid@5.1.7) ioredis: - specifier: ^5.10.0 + specifier: ^5.10.1 version: 5.10.1 pg: specifier: ^8.20.0 version: 8.20.0 - postgres: - specifier: ^3.4.8 - version: 3.4.8 stripe: - specifier: ^20.4.0 - version: 20.4.1(@types/node@24.12.0) + specifier: ^21.0.0 + version: 21.0.0(@types/node@24.12.0) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -599,12 +611,12 @@ importers: devDependencies: '@better-auth/cli': specifier: ^1.4.21 - version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) + version: 1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) '@types/pg': specifier: ^8.20.0 version: 8.20.0 drizzle-kit: - specifier: 'catalog:' + specifier: ^0.31.10 version: 0.31.10 apps/stage-pocket: @@ -644,7 +656,7 @@ importers: version: link:../../packages/ccc '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) '@proj-airi/font-cjkfonts-allseto': specifier: workspace:^ version: link:../../packages/font-cjkfonts-allseto @@ -752,7 +764,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) gpuu: specifier: ^1.0.6 version: 1.0.6 @@ -1041,7 +1053,7 @@ importers: version: link:../../packages/ccc '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) '@proj-airi/font-cjkfonts-allseto': specifier: workspace:^ version: link:../../packages/font-cjkfonts-allseto @@ -1375,7 +1387,7 @@ importers: version: 3.2.3 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) electron: specifier: 'catalog:' version: 40.8.3 @@ -1465,7 +1477,7 @@ importers: version: link:../../packages/ccc '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) '@proj-airi/font-cjkfonts-allseto': specifier: workspace:^ version: link:../../packages/font-cjkfonts-allseto @@ -1555,7 +1567,7 @@ importers: version: 4.3.6 better-auth: specifier: ^1.5.6 - version: 1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) + version: 1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) colorjs.io: specifier: ^0.6.1 version: 0.6.1 @@ -1576,7 +1588,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) gpuu: specifier: ^1.0.6 version: 1.0.6 @@ -2012,7 +2024,7 @@ importers: version: 4.1.1(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) vitest: specifier: catalog:vitest - version: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) packages/cap-vite: dependencies: @@ -2143,7 +2155,7 @@ importers: version: link:../server-sdk drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) postgres: specifier: ^3.4.8 version: 3.4.8 @@ -2263,6 +2275,9 @@ importers: packages/server-sdk: dependencies: + '@moeru/eventa': + specifier: 'catalog:' + version: 1.0.0-beta.2(electron@41.0.3)(h3@2.0.1-rc.19(crossws@0.4.4(srvx@0.11.13(patch_hash=c761d25a70e22a0925c88fe91a9dd1c3153dd341d8fd4f260166678156c4df2d)))) '@moeru/std': specifier: 'catalog:' version: 0.1.0-beta.17 @@ -2562,7 +2577,7 @@ importers: version: link:../core-character '@proj-airi/drizzle-duckdb-wasm': specifier: 'catalog:' - version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + version: 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) '@proj-airi/font-cjkfonts-allseto': specifier: workspace:^ version: link:../font-cjkfonts-allseto @@ -2658,7 +2673,7 @@ importers: version: 4.3.6 better-auth: specifier: 'catalog:' - version: 1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) + version: 1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) culori: specifier: ^4.0.2 version: 4.0.2 @@ -3492,7 +3507,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) es-toolkit: specifier: ^1.45.1 version: 1.45.1 @@ -3535,31 +3550,31 @@ importers: version: 1.12.0 '@opentelemetry/api': specifier: ^1.9.0 - version: 1.9.0 + version: 1.9.1 '@opentelemetry/auto-instrumentations-node': specifier: ^0.71.0 - version: 0.71.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)) + version: 0.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)) '@opentelemetry/exporter-metrics-otlp-proto': specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + version: 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/exporter-trace-otlp-proto': specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + version: 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/instrumentation-pg': specifier: ^0.65.0 - version: 0.65.0(@opentelemetry/api@1.9.0) + version: 0.65.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': specifier: ^2.6.0 - version: 2.6.0(@opentelemetry/api@1.9.0) + version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-metrics': specifier: ^2.6.0 - version: 2.6.0(@opentelemetry/api@1.9.0) + version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-node': specifier: ^0.213.0 - version: 0.213.0(@opentelemetry/api@1.9.0) + version: 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/sdk-trace-node': specifier: ^2.6.0 - version: 2.6.0(@opentelemetry/api@1.9.0) + version: 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': specifier: ^1.40.0 version: 1.40.0 @@ -3586,7 +3601,7 @@ importers: version: 1.4.0 drizzle-orm: specifier: 'catalog:' - version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + version: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) es-toolkit: specifier: ^1.45.1 version: 1.45.1 @@ -5601,6 +5616,13 @@ packages: peerDependencies: hono: ^4 + '@hono/node-ws@1.3.0': + resolution: {integrity: sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@hono/node-server': ^1.19.2 + hono: ^4.6.0 + '@huggingface/jinja@0.5.3': resolution: {integrity: sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA==} engines: {node: '>=18'} @@ -6313,8 +6335,12 @@ packages: resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} engines: {node: '>=8.0.0'} - '@opentelemetry/api@1.9.0': - resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} '@opentelemetry/auto-instrumentations-node@0.71.0': @@ -6330,84 +6356,168 @@ packages: peerDependencies: '@opentelemetry/api': ^1.9.0 + '@opentelemetry/configuration@0.214.0': + resolution: {integrity: sha512-Q+awuEwxhETwIAXuxHvIY5ZMEP0ZqvxLTi9kclrkyVJppEUXYL3Bhiw3jYrxdHYMh0Y0tVInQH9FEZ1aMinvLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/context-async-hooks@2.6.0': resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/context-async-hooks@2.6.1': + resolution: {integrity: sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.6.0': resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0': resolution: {integrity: sha512-QiRZzvayEOFnenSXi85Eorgy5WTqyNQ+E7gjl6P6r+W3IUIwAIH8A9/BgMWfP056LwmdrBL6+qvnwaIEmug6Yg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.214.0': + resolution: {integrity: sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.213.0': resolution: {integrity: sha512-vqDVSpLp09ZzcFIdb7QZrEFPxUlO3GzdhBKLstq3jhYB5ow3+ZtV5V0ngSdi/0BZs+J5WPiN1+UDV4X5zD/GzA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.214.0': + resolution: {integrity: sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.213.0': resolution: {integrity: sha512-gQk41nqfK3KhDk8jbSo3LR/fQBlV7f6Q5xRcfDmL1hZlbgXQPdVFV9/rIfYUrCoq1OM+2NnKnFfGjBt6QpLSsA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.214.0': + resolution: {integrity: sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0': resolution: {integrity: sha512-Z8gYKUAU48qwm+a1tjnGv9xbE7a5lukVIwgF6Z5i3VPXPVMe4Sjra0nN3zU7m277h+V+ZpsPGZJ2Xf0OTkL7/w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.214.0': + resolution: {integrity: sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-http@0.214.0': + resolution: {integrity: sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-metrics-otlp-proto@0.214.0': + resolution: {integrity: sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-prometheus@0.213.0': resolution: {integrity: sha512-FyV3/JfKGAgx+zJUwCHdjQHbs+YeGd2fOWvBHYrW6dmfv/w89lb8WhJTSZEoWgP525jwv/gFeBttlGu1flebdA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-prometheus@0.214.0': + resolution: {integrity: sha512-4TGYoZKebUWVuYkV6r5wS2dUF4zH7EbWFw/Uqz1ZM1tGHQeFT9wzHGXq3iSIXMUrwu5jRdxjfMaXrYejPu2kpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0': resolution: {integrity: sha512-L8y6piP4jBIIx1Nv7/9hkx25ql6/Cro/kQrs+f9e8bPF0Ar5Dm991v7PnbtubKz6Q4fT872H56QXUWVnz/Cs4Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.214.0': + resolution: {integrity: sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.213.0': resolution: {integrity: sha512-tnRmJD39aWrE/Sp7F6AbRNAjKHToDkAqBi6i0lESpGWz3G+f4bhVAV6mgSXH2o18lrDVJXo6jf9bAywQw43wRA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.214.0': + resolution: {integrity: sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.214.0': + resolution: {integrity: sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-zipkin@2.6.0': resolution: {integrity: sha512-AFP77OQMLfw/Jzh6WT2PtrywstNjdoyT9t9lYrYdk1s4igsvnMZ8DkZKCwxsItC01D+4Lydgrb+Wy0bAvpp8xg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-zipkin@2.6.1': + resolution: {integrity: sha512-km2/hD3inLTqtLnUAHDGz7ZP/VOyZNslrC/iN66x4jkmpckwlONW54LRPNI6fm09/musDtZga9EWsxgwnjGUlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/instrumentation-amqplib@0.60.0': resolution: {integrity: sha512-q/B2IvoVXRm1M00MvhnzpMN6rKYOszPXVsALi6u0ss4AYHe+TidZEtLW9N1ZhrobI1dSriHnBqqtAOZVAv07sg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6511,12 +6621,24 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-http@0.214.0': + resolution: {integrity: sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-ioredis@0.61.0': resolution: {integrity: sha512-hsHDadUtAFbws1YSDc1XW0svGFKiUbqv2td1Cby+UAiwvojm1NyBo/taifH0t8CuFZ0x/2SDm0iuTwrM5pnVOg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-ioredis@0.62.0': + resolution: {integrity: sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-kafkajs@0.22.0': resolution: {integrity: sha512-wJU4IBQMUikdJAcTChLFqK5lo+flo7pahqd8DSLv7uMxsdOdAHj6RzKYAm8pPfUS6ItKYutYyuicwKaFwQKsoA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6601,6 +6723,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-pg@0.66.0': + resolution: {integrity: sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-pino@0.59.0': resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6631,6 +6759,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-runtime-node@0.27.0': + resolution: {integrity: sha512-5S/Xd03scYSSZX3Pg6qfxIgpq2CCUIqBoJPnIgE41NM1tLiCm9zplQw6+699Uhj97mIthBHsGTwgdJCBc1vzkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation-socket.io@0.60.0': resolution: {integrity: sha512-gzIkrN+hJzuQR87CA1zVCUQASOuuz0uC7kk7qDt9E/4sNvWrCIfI0YYa8ZTPgbaofqZE1fGWt0UqzIzQTb5BWQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6661,36 +6795,72 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.213.0': resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.214.0': + resolution: {integrity: sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.213.0': resolution: {integrity: sha512-XgRGuLE9usFNlnw2lgMIM4HTwpcIyjdU/xPoJ8v3LbBLBfjaDkIugjc9HoWa7ZSJ/9Bhzgvm/aD0bGdYUFgnTw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.214.0': + resolution: {integrity: sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.213.0': resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.214.0': + resolution: {integrity: sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/propagator-b3@2.6.0': resolution: {integrity: sha512-SguK4jMmRvQ0c0dxAMl6K+Eu1+01X0OP7RLiIuHFjOS8hlB23ZYNnhnbAdSQEh5xVXQmH0OAS0TnmVI+6vB2Kg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/propagator-b3@2.6.1': + resolution: {integrity: sha512-Dvz9TA6cPqIbxolSzQ5x9br6iQlqdGhVYrm+oYc7pfJ7LaVXz8F0XIqhWbnKB5YvfZ6SUmabBUUxnvHs/9uhxA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/propagator-jaeger@2.6.0': resolution: {integrity: sha512-KGWJuvp9X8X36bhHgIhWEnHAzXDInFr+Fvo9IQhhuu6pXLT8mF7HzFyx/X+auZUITvPaZhM39Phj3vK12MbhwA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/propagator-jaeger@2.6.1': + resolution: {integrity: sha512-kKFMxBcjBZAC1vBch5mtZ/dJQvcAEKWga+c+q5iGgRLPIE6Mc649zEwMaCIQCzalziMJQiyUadFYMHmELB7AFw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/redis-common@0.38.2': resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6731,36 +6901,72 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/resources@2.6.1': + resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-logs@0.213.0': resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-logs@0.214.0': + resolution: {integrity: sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.6.0': resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-metrics@2.6.1': + resolution: {integrity: sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + '@opentelemetry/sdk-node@0.213.0': resolution: {integrity: sha512-8s7SQtY8DIAjraXFrUf0+I90SBAUQbsMWMtUGKmusswRHWXtKJx42aJQMoxEtC82Csqj+IlBH6FoP8XmmUDSrQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-node@0.214.0': + resolution: {integrity: sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.6.0': resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.6.1': + resolution: {integrity: sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-node@2.6.0': resolution: {integrity: sha512-YhswtasmsbIGEFvLGvR9p/y3PVRTfFf+mgY8van4Ygpnv4sA3vooAjvh+qAn9PNWxs4/IwGGqiQS0PPsaRJ0vQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/sdk-trace-node@2.6.1': + resolution: {integrity: sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} @@ -12749,6 +12955,15 @@ packages: peerDependencies: vite: ^7.3.0 + hono-rate-limiter@0.5.3: + resolution: {integrity: sha512-M0DxbVMpPELEzLi0AJg1XyBHLGJXz7GySjsPoK+gc5YeeBsdGDGe+2RvVuCAv8ydINiwlbxqYMNxUEyYfRji/A==} + peerDependencies: + hono: ^4.10.8 + unstorage: ^1.17.3 + peerDependenciesMeta: + unstorage: + optional: true + hono@4.11.3: resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==} engines: {node: '>=16.9.0'} @@ -15796,11 +16011,11 @@ packages: resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} engines: {node: '>=0.10.0'} - stripe@20.4.1: - resolution: {integrity: sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==} - engines: {node: '>=16'} + stripe@21.0.0: + resolution: {integrity: sha512-VlQ1QhjQAhmCgY3g3BgfZFLo8AkJw1r9zTrG4hdjSDX2AcwS5sPz4eHJG0zfzCO8zO/md0Xe+zrkxRqg604ZWA==} + engines: {node: '>=18'} peerDependencies: - '@types/node': '>=16' + '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true @@ -18345,7 +18560,7 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))': + '@better-auth/cli@1.4.21(@better-fetch/fetch@1.1.21)(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(drizzle-kit@0.31.10)(jose@6.1.3)(kysely@0.28.14)(magicast@0.5.2)(nanostores@1.1.1)(postgres@3.4.8)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3))': dependencies: '@babel/core': 7.28.5 '@babel/preset-react': 7.28.5(@babel/core@7.28.5) @@ -18357,13 +18572,13 @@ snapshots: '@mrleebo/prisma-ast': 0.13.1 '@prisma/client': 5.22.0 '@types/pg': 8.20.0 - better-auth: 1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) + better-auth: 1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)) better-sqlite3: 12.5.0 c12: 3.3.3(magicast@0.5.2) chalk: 5.6.2 commander: 12.1.0 dotenv: 17.3.1 - drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) open: 10.2.0 pg: 8.20.0 prettier: 3.7.4 @@ -18428,11 +18643,11 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)': + '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 '@standard-schema/spec': 1.1.0 better-call: 1.1.8(zod@4.3.6) @@ -18441,11 +18656,11 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)': + '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)': dependencies: '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 '@standard-schema/spec': 1.1.0 better-call: 1.3.2(zod@4.3.6) @@ -18454,33 +18669,33 @@ snapshots: nanostores: 1.1.1 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': + '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: - drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) - '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)': + '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: kysely: 0.28.14 - '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': + '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 - '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0)': + '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 optionalDependencies: '@prisma/client': 5.22.0 @@ -18491,9 +18706,9 @@ snapshots: '@better-auth/utils': 0.3.0 '@better-fetch/fetch': 1.1.21 - '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))': + '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 @@ -19496,6 +19711,15 @@ snapshots: dependencies: hono: 4.12.2 + '@hono/node-ws@1.3.0(@hono/node-server@1.19.11(hono@4.11.3))(bufferutil@4.1.0)(hono@4.11.3)(utf-8-validate@5.0.10)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.11.3) + hono: 4.11.3 + ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@huggingface/jinja@0.5.3': {} '@huggingface/transformers@3.8.1': @@ -20287,687 +20511,959 @@ snapshots: '@opentelemetry/api-logs@0.213.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 - '@opentelemetry/api@1.9.0': {} - - '@opentelemetry/auto-instrumentations-node@0.71.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))': + '@opentelemetry/api-logs@0.214.0': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-amqplib': 0.60.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-aws-lambda': 0.65.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-aws-sdk': 0.68.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-bunyan': 0.58.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-cassandra-driver': 0.58.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-connect': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-cucumber': 0.29.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dataloader': 0.30.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-dns': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-express': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fastify': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-fs': 0.32.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-generic-pool': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-graphql': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-grpc': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-hapi': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-http': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-ioredis': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-kafkajs': 0.22.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-knex': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-koa': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-lru-memoizer': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-memcached': 0.56.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongodb': 0.66.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mongoose': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-mysql2': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-nestjs-core': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-net': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-openai': 0.11.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-oracledb': 0.38.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pg': 0.65.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-pino': 0.59.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-redis': 0.61.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-restify': 0.58.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-router': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-runtime-node': 0.26.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-socket.io': 0.60.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-tedious': 0.32.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-undici': 0.23.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-winston': 0.57.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-azure': 0.21.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.0) - '@opentelemetry/resource-detector-gcp': 0.48.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.71.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.30.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fastify': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.56.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.59.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.26.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.23.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.21.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.48.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) yaml: 2.8.3 - '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + yaml: 2.8.3 - '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.40.0 - - '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.214.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) - '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/instrumentation-amqplib@0.60.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-grpc@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-zipkin@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation-amqplib@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-aws-lambda@0.65.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-aws-lambda@0.65.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@types/aws-lambda': 8.10.159 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-aws-sdk@0.68.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-aws-sdk@0.68.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-bunyan@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-bunyan@0.58.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@types/bunyan': 1.8.11 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-cassandra-driver@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-cassandra-driver@0.58.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-connect@0.56.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-connect@0.56.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@types/connect': 3.4.38 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-cucumber@0.29.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-cucumber@0.29.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-dataloader@0.30.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-dataloader@0.30.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-dns@0.56.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-dns@0.56.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-express@0.61.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-express@0.61.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-fastify@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-fastify@0.57.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-fs@0.32.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-fs@0.32.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-generic-pool@0.56.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-generic-pool@0.56.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-graphql@0.61.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-graphql@0.61.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-grpc@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-grpc@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-hapi@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-hapi@0.59.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-ioredis@0.61.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.61.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/redis-common': 0.38.2 '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-kafkajs@0.22.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-ioredis@0.62.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.2 '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-knex@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-kafkajs@0.22.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-koa@0.61.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-knex@0.57.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-lru-memoizer@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-koa@0.61.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-memcached@0.56.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-lru-memoizer@0.57.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.56.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@types/memcached': 2.2.10 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mongodb@0.66.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mongodb@0.66.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mongoose@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mongoose@0.59.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql2@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql2@0.59.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-mysql@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-mysql@0.59.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@types/mysql': 2.15.27 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-nestjs-core@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-nestjs-core@0.59.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-net@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-net@0.57.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-openai@0.11.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-openai@0.11.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-oracledb@0.38.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-oracledb@0.38.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@types/oracledb': 6.5.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pg@0.65.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.65.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) '@types/pg': 8.15.6 '@types/pg-pool': 2.0.7 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pg@0.66.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-redis@0.61.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.61.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/redis-common': 0.38.2 '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-restify@0.58.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-restify@0.58.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-router@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-router@0.57.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-runtime-node@0.26.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-runtime-node@0.26.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-socket.io@0.60.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-runtime-node@0.27.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-tedious@0.32.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-socket.io@0.60.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.32.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 '@types/tedious': 4.0.14 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-undici@0.23.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-undici@0.23.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation-winston@0.57.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-winston@0.57.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 import-in-the-middle: 3.0.0 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color - '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.213.0(@opentelemetry/api@1.9.1)': dependencies: '@grpc/grpc-js': 1.14.3 - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.214.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) protobufjs: 7.5.4 - '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + protobufjs: 7.5.4 - '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.6.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-b3@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/redis-common@0.38.2': {} - '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/resource-detector-azure@0.21.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-azure@0.21.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) - '@opentelemetry/resource-detector-gcp@0.48.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resource-detector-gcp@0.48.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) gcp-metadata: 8.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - - '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@opentelemetry/api-logs': 0.213.0 - '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.213.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/configuration': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - supports-color - '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.214.0(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + '@opentelemetry/configuration': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions': 1.40.0 - '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace-node@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.6.1(@opentelemetry/api@1.9.1) '@opentelemetry/semantic-conventions@1.40.0': {} - '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) '@ota-meshi/ast-token-store@0.3.0': {} @@ -21670,25 +22166,25 @@ snapshots: dependencies: culori: 4.0.2 - '@proj-airi/drizzle-duckdb-wasm@0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': + '@proj-airi/drizzle-duckdb-wasm@0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': dependencies: '@date-fns/tz': 1.4.1 '@duckdb/duckdb-wasm': 1.29.1-dev68.0 '@moeru/std': 0.1.0-beta.1 - '@proj-airi/duckdb-wasm': 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + '@proj-airi/duckdb-wasm': 0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) apache-arrow: 21.1.0 date-fns: 4.1.0 - drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) es-toolkit: 1.45.1 transitivePeerDependencies: - '@75lb/nature' - '@proj-airi/drizzle-orm-browser-migrator@0.1.6(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': + '@proj-airi/drizzle-orm-browser-migrator@0.1.6(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': dependencies: '@guiiai/logg': 1.2.11 - drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) - '@proj-airi/duckdb-wasm@0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': + '@proj-airi/duckdb-wasm@0.4.29(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))': dependencies: '@date-fns/tz': 1.4.1 '@duckdb/duckdb-wasm': 1.29.1-dev68.0 @@ -21696,7 +22192,7 @@ snapshots: apache-arrow: 21.1.0 date-fns: 4.1.0 defu: 6.1.4 - drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) es-toolkit: 1.45.1 transitivePeerDependencies: - '@75lb/nature' @@ -23281,7 +23777,7 @@ snapshots: '@vitest/mocker': 4.1.1(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - bufferutil - msw @@ -23297,7 +23793,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -23317,7 +23813,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.0.3 - vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) optionalDependencies: '@vitest/browser': 4.1.1(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) @@ -23329,7 +23825,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.57.1(@typescript-eslint/parser@8.51.0(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.1.0(jiti@2.6.1))(typescript@5.9.3) typescript: 5.9.3 - vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) transitivePeerDependencies: - supports-color @@ -24441,7 +24937,7 @@ snapshots: best-effort-json-parser@1.4.0: {} - better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)): + better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) '@better-auth/telemetry': 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)) @@ -24459,21 +24955,21 @@ snapshots: '@prisma/client': 5.22.0 better-sqlite3: 12.5.0 drizzle-kit: 0.31.10 - drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) pg: 8.20.0 react: 19.2.3 - vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.30(typescript@5.9.3) - better-auth@1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)): + better-auth@1.5.6(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(pg@8.20.0)(react@19.2.3)(vitest@4.1.1)(vue@3.5.30(typescript@5.9.3)): dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) - '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) - '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14) - '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) - '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0) - '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)) + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1) + '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8)) + '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(kysely@0.28.14) + '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1) + '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@prisma/client@5.22.0) + '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.14)(nanostores@1.1.1)) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 '@noble/ciphers': 2.1.1 @@ -24488,10 +24984,10 @@ snapshots: '@prisma/client': 5.22.0 better-sqlite3: 12.5.0 drizzle-kit: 0.31.10 - drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) pg: 8.20.0 react: 19.2.3 - vitest: 4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) vue: 3.5.30(typescript@5.9.3) transitivePeerDependencies: - '@cloudflare/workers-types' @@ -25514,10 +26010,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8): + drizzle-orm@0.41.0(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8): optionalDependencies: '@electric-sql/pglite': 0.4.1 - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@prisma/client': 5.22.0 '@types/pg': 8.20.0 better-sqlite3: 12.5.0 @@ -25525,10 +26021,10 @@ snapshots: pg: 8.20.0 postgres: 3.4.8 - drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8): + drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8): optionalDependencies: '@electric-sql/pglite': 0.4.1 - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@prisma/client': 5.22.0 '@types/pg': 8.20.0 better-sqlite3: 12.5.0 @@ -25536,9 +26032,9 @@ snapshots: pg: 8.20.0 postgres: 3.4.8 - drizzle-valibot@0.4.2(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(valibot@1.2.0(typescript@5.9.3)): + drizzle-valibot@0.4.2(drizzle-orm@0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8))(valibot@1.2.0(typescript@5.9.3)): dependencies: - drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) + drizzle-orm: 0.45.1(@electric-sql/pglite@0.4.1)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.8) valibot: 1.2.0(typescript@5.9.3) dts-resolver@2.1.3(oxc-resolver@11.19.1): @@ -27259,6 +27755,12 @@ snapshots: - utf-8-validate - yaml + hono-rate-limiter@0.5.3(hono@4.11.3)(unstorage@1.17.4(aws4fetch@1.0.20)(idb-keyval@6.2.2)(ioredis@5.10.1)): + dependencies: + hono: 4.11.3 + optionalDependencies: + unstorage: 1.17.4(aws4fetch@1.0.20)(idb-keyval@6.2.2)(ioredis@5.10.1) + hono@4.11.3: {} hono@4.12.2: {} @@ -31008,7 +31510,7 @@ snapshots: dependencies: escape-string-regexp: 1.0.5 - stripe@20.4.1(@types/node@24.12.0): + stripe@21.0.0(@types/node@24.12.0): optionalDependencies: '@types/node': 24.12.0 @@ -32193,7 +32695,7 @@ snapshots: - sortablejs - universal-cookie - vitest@4.1.1(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.1(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/browser-playwright@4.1.1)(jsdom@27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10))(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.1 '@vitest/mocker': 4.1.1(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3)) @@ -32216,7 +32718,7 @@ snapshots: vite: 8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 '@types/node': 24.12.0 '@vitest/browser-playwright': 4.1.1(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.1) jsdom: 27.4.0(bufferutil@4.1.0)(canvas@3.2.2)(utf-8-validate@5.0.10) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0d3f749fb..cbd5116b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,6 +40,7 @@ catalog: '@electron/notarize': ^3.1.1 '@guiiai/logg': ^1.2.11 '@histoire/plugin-vue': 1.0.0-beta.1 + '@hono/node-ws': ^1.3.0 '@iconify-json/logos': ^1.2.10 '@iconify-json/tabler': ^1.2.32 '@intlify/core': ^11.3.0 @@ -88,6 +89,7 @@ catalog: es-toolkit: 1.43.0 histoire: 1.0.0-beta.1 hono: 4.11.3 + hono-rate-limiter: ^0.5.3 idb-keyval: ^6.2.2 injeca: ^0.1.8 is-network-error: ^1.3.1 @@ -124,8 +126,7 @@ catalogs: vitest: ^4.1.1 xsai: unspeech: ^0.1.11 - -# enableGlobalVirtualStore: true +enableGlobalVirtualStore: true ignoredBuiltDependencies: - '@prisma/client'