diff --git a/drizzle/0118_bright_sunspot.sql b/drizzle/0118_bright_sunspot.sql new file mode 100644 index 000000000..0456be602 --- /dev/null +++ b/drizzle/0118_bright_sunspot.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS "idx_message_request_proxy_status_active" ON "message_request" USING btree ("created_at" DESC NULLS LAST,"user_id") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."is_replay" = false AND "message_request"."status_code" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_proxy_status_latest" ON "message_request" USING btree ("user_id","updated_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."is_replay" = false AND "message_request"."status_code" IS NOT NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> 'warmup');--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_usage_ledger_user_id_reset" ON "usage_ledger" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_message_request_session_identity_created_at" ON "message_request" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_usage_ledger_session_identity_created_at" ON "usage_ledger" USING btree (COALESCE("session_identity", "session_id"),"user_id","created_at" DESC NULLS LAST) WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false; diff --git a/drizzle/meta/0118_snapshot.json b/drizzle/meta/0118_snapshot.json new file mode 100644 index 000000000..c828516f4 --- /dev/null +++ b/drizzle/meta/0118_snapshot.json @@ -0,0 +1,5397 @@ +{ + "id": "0a61db74-24d2-408d-b247-974f2413fa50", + "prevId": "f20d88c1-dfee-4399-ae01-147936dc1177", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "action_category": { + "name": "action_category", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "before_value": { + "name": "before_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_value": { + "name": "after_value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "operator_user_id": { + "name": "operator_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_user_name": { + "name": "operator_user_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_key_id": { + "name": "operator_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "operator_key_name": { + "name": "operator_key_name", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "operator_ip": { + "name": "operator_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_log_category_created_at": { + "name": "idx_audit_log_category_created_at", + "columns": [ + { + "expression": "action_category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_user_created_at": { + "name": "idx_audit_log_operator_user_created_at", + "columns": [ + { + "expression": "operator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_operator_ip_created_at": { + "name": "idx_audit_log_operator_ip_created_at", + "columns": [ + { + "expression": "operator_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"operator_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_target": { + "name": "idx_audit_log_target", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"audit_log\".\"target_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_log_created_at_id": { + "name": "idx_audit_log_created_at_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_pricing_catalog": { + "name": "cloud_pricing_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "refreshed_at": { + "name": "refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "providers": { + "name": "providers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "vendors": { + "name": "vendors", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "model_count": { + "name": "model_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_key": { + "name": "idx_keys_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "cost_breakdown": { + "name": "cost_breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity": { + "name": "session_identity", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity_kind": { + "name": "session_identity_kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "affinity_scope_tag": { + "name": "affinity_scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint": { + "name": "affinity_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint_chain": { + "name": "affinity_fingerprint_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_replay": { + "name": "is_replay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "replay_source_request_id": { + "name": "replay_source_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "routing_trace": { + "name": "routing_trace", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_ms": { + "name": "first_byte_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "hedge_losers": { + "name": "hedge_losers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_compatibility_key": { + "name": "cache_compatibility_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "cache_score_eligible": { + "name": "cache_score_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_score_excluded_reason": { + "name": "cache_score_excluded_reason", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_created_at_cost_stats": { + "name": "idx_message_request_user_created_at_cost_stats", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_active": { + "name": "idx_message_request_provider_created_at_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_created_at_finalized_active": { + "name": "idx_message_request_provider_created_at_finalized_active", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_proxy_status_active": { + "name": "idx_message_request_proxy_status_active", + "columns": [ + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"is_replay\" = false AND \"message_request\".\"status_code\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_proxy_status_latest": { + "name": "idx_message_request_proxy_status_latest", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"updated_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"is_replay\" = false AND \"message_request\".\"status_code\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id_prefix": { + "name": "idx_message_request_session_id_prefix", + "columns": [ + { + "expression": "\"session_id\" varchar_pattern_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_identity_created_at": { + "name": "idx_message_request_session_identity_created_at", + "columns": [ + { + "expression": "COALESCE(\"session_identity\", \"session_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_created_at_id": { + "name": "idx_message_request_key_created_at_id", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_model_active": { + "name": "idx_message_request_key_model_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_endpoint_active": { + "name": "idx_message_request_key_endpoint_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"endpoint\" IS NOT NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at_id_active": { + "name": "idx_message_request_created_at_id_active", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_model_active": { + "name": "idx_message_request_model_active", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_status_code_active": { + "name": "idx_message_request_status_code_active", + "columns": [ + { + "expression": "status_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"status_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_last_active": { + "name": "idx_message_request_key_last_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key_cost_active": { + "name": "idx_message_request_key_cost_active", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND (\"message_request\".\"blocked_by\" IS NULL OR \"message_request\".\"blocked_by\" <> 'warmup')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_user_info": { + "name": "idx_message_request_session_user_info", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_client_ip_created_at": { + "name": "idx_message_request_client_ip_created_at", + "columns": [ + { + "expression": "client_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL AND \"message_request\".\"client_ip\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'cloud'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_vendor": { + "name": "idx_model_prices_vendor", + "columns": [ + { + "expression": "((\"price_data\" ->> 'vendor'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_aliases": { + "name": "idx_model_prices_aliases", + "columns": [ + { + "expression": "((\"price_data\" -> 'aliases'))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "cache_hit_rate_alert_enabled": { + "name": "cache_hit_rate_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cache_hit_rate_alert_webhook": { + "name": "cache_hit_rate_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cache_hit_rate_alert_window_mode": { + "name": "cache_hit_rate_alert_window_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "cache_hit_rate_alert_check_interval": { + "name": "cache_hit_rate_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cache_hit_rate_alert_historical_lookback_days": { + "name": "cache_hit_rate_alert_historical_lookback_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 7 + }, + "cache_hit_rate_alert_min_eligible_requests": { + "name": "cache_hit_rate_alert_min_eligible_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 20 + }, + "cache_hit_rate_alert_min_eligible_tokens": { + "name": "cache_hit_rate_alert_min_eligible_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cache_hit_rate_alert_abs_min": { + "name": "cache_hit_rate_alert_abs_min", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "cache_hit_rate_alert_drop_rel": { + "name": "cache_hit_rate_alert_drop_rel", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.3'" + }, + "cache_hit_rate_alert_drop_abs": { + "name": "cache_hit_rate_alert_drop_abs", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.1'" + }, + "cache_hit_rate_alert_cooldown_minutes": { + "name": "cache_hit_rate_alert_cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cache_hit_rate_alert_top_n": { + "name": "cache_hit_rate_alert_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_batch_apply_operations": { + "name": "provider_batch_apply_operations", + "schema": "", + "columns": { + "claim_key": { + "name": "claim_key", + "type": "varchar(256)", + "primaryKey": true, + "notNull": true + }, + "preview_token": { + "name": "preview_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "payload_fingerprint": { + "name": "payload_fingerprint", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "operation_id": { + "name": "operation_id", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_token": { + "name": "undo_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "undo_expires_at": { + "name": "undo_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "undo_consumed_at": { + "name": "undo_consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_batch_apply_operations_preview_token": { + "name": "uniq_provider_batch_apply_operations_preview_token", + "columns": [ + { + "expression": "preview_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_operation_id": { + "name": "uniq_provider_batch_apply_operations_operation_id", + "columns": [ + { + "expression": "operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_provider_batch_apply_operations_undo_token": { + "name": "uniq_provider_batch_apply_operations_undo_token", + "columns": [ + { + "expression": "undo_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_batch_apply_operations_expires_at": { + "name": "idx_provider_batch_apply_operations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_cache_effectiveness": { + "name": "provider_cache_effectiveness", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "cache_ttl_bucket": { + "name": "cache_ttl_bucket", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sample_count": { + "name": "sample_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "eligible_count": { + "name": "eligible_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "theoretical_cache_tokens": { + "name": "theoretical_cache_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observed_cache_read_tokens": { + "name": "observed_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "raw_effectiveness_bp": { + "name": "raw_effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confidence_bp": { + "name": "confidence_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "effectiveness_bp": { + "name": "effectiveness_bp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_cache_effectiveness_window": { + "name": "idx_provider_cache_effectiveness_window", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoint_probe_logs": { + "name": "provider_endpoint_probe_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "ok": { + "name": "ok", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error_type": { + "name": "error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_provider_endpoint_probe_logs_endpoint_created_at": { + "name": "idx_provider_endpoint_probe_logs_endpoint_created_at", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoint_probe_logs_created_at": { + "name": "idx_provider_endpoint_probe_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk": { + "name": "provider_endpoint_probe_logs_endpoint_id_provider_endpoints_id_fk", + "tableFrom": "provider_endpoint_probe_logs", + "tableTo": "provider_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_endpoints": { + "name": "provider_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vendor_id": { + "name": "vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_probed_at": { + "name": "last_probed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_probe_ok": { + "name": "last_probe_ok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "last_probe_status_code": { + "name": "last_probe_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_latency_ms": { + "name": "last_probe_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_type": { + "name": "last_probe_error_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "last_probe_error_message": { + "name": "last_probe_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "uniq_provider_endpoints_vendor_type_url": { + "name": "uniq_provider_endpoints_vendor_type_url", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_vendor_type": { + "name": "idx_provider_endpoints_vendor_type", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_enabled": { + "name": "idx_provider_endpoints_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_pick_enabled": { + "name": "idx_provider_endpoints_pick_enabled", + "columns": [ + { + "expression": "vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"provider_endpoints\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_created_at": { + "name": "idx_provider_endpoints_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_endpoints_deleted_at": { + "name": "idx_provider_endpoints_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "provider_endpoints_vendor_id_provider_vendors_id_fk": { + "name": "provider_endpoints_vendor_id_provider_vendors_id_fk", + "tableFrom": "provider_endpoints", + "tableTo": "provider_vendors", + "columnsFrom": [ + "vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_groups": { + "name": "provider_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": true, + "default": "'1.0'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "provider_groups_name_unique": { + "name": "provider_groups_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.provider_vendors": { + "name": "provider_vendors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "website_domain": { + "name": "website_domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uniq_provider_vendors_website_domain": { + "name": "uniq_provider_vendors_website_domain", + "columns": [ + { + "expression": "website_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_provider_vendors_created_at": { + "name": "idx_provider_vendors_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_vendor_id": { + "name": "provider_vendor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "group_priorities": { + "name": "group_priorities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disable_session_reuse": { + "name": "disable_session_reuse", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "active_time_start": { + "name": "active_time_start", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "active_time_end": { + "name": "active_time_end", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "swap_cache_ttl_billing": { + "name": "swap_cache_ttl_billing", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_image_generation_preference": { + "name": "codex_image_generation_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_service_tier_preference": { + "name": "codex_service_tier_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_max_tokens_preference": { + "name": "anthropic_max_tokens_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_thinking_budget_preference": { + "name": "anthropic_thinking_budget_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "anthropic_adaptive_thinking": { + "name": "anthropic_adaptive_thinking", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "gemini_google_search_preference": { + "name": "gemini_google_search_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type_url_active": { + "name": "idx_providers_vendor_type_url_active", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_vendor_type": { + "name": "idx_providers_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_enabled_vendor_type": { + "name": "idx_providers_enabled_vendor_type", + "columns": [ + { + "expression": "provider_vendor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL AND \"providers\".\"is_enabled\" = true AND \"providers\".\"provider_vendor_id\" IS NOT NULL AND \"providers\".\"provider_vendor_id\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "providers_provider_vendor_id_provider_vendors_id_fk": { + "name": "providers_provider_vendor_id_provider_vendors_id_fk", + "tableFrom": "providers", + "tableTo": "provider_vendors", + "columnsFrom": [ + "provider_vendor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.replay_payloads": { + "name": "replay_payloads", + "schema": "", + "columns": { + "replay_id": { + "name": "replay_id", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "verifier": { + "name": "verifier", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "scope_tag": { + "name": "scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "headers_json": { + "name": "headers_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_message_request_id": { + "name": "source_message_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_replay_payloads_key_id": { + "name": "idx_replay_payloads_key_id", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_replay_payloads_expires_at": { + "name": "idx_replay_payloads_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rule_mode": { + "name": "rule_mode", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "execution_phase": { + "name": "execution_phase", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'guard'" + }, + "operations": { + "name": "operations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_phase": { + "name": "idx_request_filters_phase", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_phase", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'CC Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "codex_priority_billing_source": { + "name": "codex_priority_billing_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "bill_non_successful_requests": { + "name": "bill_non_successful_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bill_hedge_losers": { + "name": "bill_hedge_losers", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "discovery_enabled": { + "name": "discovery_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "discovery_concurrency": { + "name": "discovery_concurrency", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "max_discovery_rounds": { + "name": "max_discovery_rounds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "discovery_sla_ms": { + "name": "discovery_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "sticky_sla_ms": { + "name": "sticky_sla_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 20000 + }, + "racing_total_timeout_ms": { + "name": "racing_total_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 60000 + }, + "sticky_timeout_cooldown_ms": { + "name": "sticky_timeout_cooldown_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300000 + }, + "timezone": { + "name": "timezone", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pass_through_upstream_error_message": { + "name": "pass_through_upstream_error_message", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_openai_responses_websocket": { + "name": "enable_openai_responses_websocket", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_high_concurrency_mode": { + "name": "enable_high_concurrency_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_budget_rectifier": { + "name": "enable_thinking_budget_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_thinking_effort_conflict_rectifier": { + "name": "enable_thinking_effort_conflict_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_gemini_function_id_rectifier": { + "name": "enable_gemini_function_id_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_billing_header_rectifier": { + "name": "enable_billing_header_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_input_rectifier": { + "name": "enable_response_input_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_non_conversation_endpoint_provider_fallback": { + "name": "allow_non_conversation_endpoint_provider_fallback", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "fake_streaming_whitelist": { + "name": "fake_streaming_whitelist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enable_codex_session_id_completion": { + "name": "enable_codex_session_id_completion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_claude_metadata_user_id_injection": { + "name": "enable_claude_metadata_user_id_injection", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "quota_db_refresh_interval_seconds": { + "name": "quota_db_refresh_interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "quota_lease_percent_5h": { + "name": "quota_lease_percent_5h", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_daily": { + "name": "quota_lease_percent_daily", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_weekly": { + "name": "quota_lease_percent_weekly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_percent_monthly": { + "name": "quota_lease_percent_monthly", + "type": "numeric(5, 4)", + "primaryKey": false, + "notNull": false, + "default": "'0.05'" + }, + "quota_lease_cap_usd": { + "name": "quota_lease_cap_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "ip_extraction_config": { + "name": "ip_extraction_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip_geo_lookup_enabled": { + "name": "ip_geo_lookup_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "public_status_window_hours": { + "name": "public_status_window_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "public_status_aggregation_interval_minutes": { + "name": "public_status_aggregation_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "stream_gate_mode": { + "name": "stream_gate_mode", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'enforce'" + }, + "affinity_ignore_client_session_id": { + "name": "affinity_ignore_client_session_id", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "replay_enabled": { + "name": "replay_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cache_effectiveness_enabled": { + "name": "cache_effectiveness_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_ledger": { + "name": "usage_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "final_provider_id": { + "name": "final_provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "actual_response_model": { + "name": "actual_response_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity": { + "name": "session_identity", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_identity_kind": { + "name": "session_identity_kind", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "affinity_scope_tag": { + "name": "affinity_scope_tag", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint": { + "name": "affinity_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "affinity_fingerprint_chain": { + "name": "affinity_fingerprint_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_replay": { + "name": "is_replay", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "replay_source_request_id": { + "name": "replay_source_request_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_success": { + "name": "is_success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "success_rate_outcome": { + "name": "success_rate_outcome", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "group_cost_multiplier": { + "name": "group_cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "swap_cache_ttl_applied": { + "name": "swap_cache_ttl_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_byte_ms": { + "name": "first_byte_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_usage_ledger_request_id": { + "name": "idx_usage_ledger_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_created_at": { + "name": "idx_usage_ledger_user_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_id_reset": { + "name": "idx_usage_ledger_user_id_reset", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at": { + "name": "idx_usage_ledger_key_created_at", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_created_at": { + "name": "idx_usage_ledger_provider_created_at", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_minute": { + "name": "idx_usage_ledger_created_at_minute", + "columns": [ + { + "expression": "date_trunc('minute', \"created_at\" AT TIME ZONE 'UTC')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_created_at_desc_id": { + "name": "idx_usage_ledger_created_at_desc_id", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_id": { + "name": "idx_usage_ledger_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_identity_created_at": { + "name": "idx_usage_ledger_session_identity_created_at", + "columns": [ + { + "expression": "COALESCE(\"session_identity\", \"session_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_session_identity": { + "name": "idx_usage_ledger_session_identity", + "columns": [ + { + "expression": "COALESCE(\"session_identity\", \"session_id\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_model": { + "name": "idx_usage_ledger_model", + "columns": [ + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"model\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_cost": { + "name": "idx_usage_ledger_key_cost", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_user_cost_cover": { + "name": "idx_usage_ledger_user_cost_cover", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_provider_cost_cover": { + "name": "idx_usage_ledger_provider_cost_cover", + "columns": [ + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_usage_ledger_key_created_at_desc_cover": { + "name": "idx_usage_ledger_key_created_at_desc_cover", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"created_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "final_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_ledger\".\"blocked_by\" IS NULL AND \"usage_ledger\".\"is_replay\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_reset_mode": { + "name": "limit_5h_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'rolling'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "cost_reset_at": { + "name": "cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_5h_cost_reset_at": { + "name": "limit_5h_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "blocked_clients": { + "name": "blocked_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_tags_gin": { + "name": "idx_users_tags_gin", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert", + "cache_hit_rate_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 025658d86..f09e1f133 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -827,6 +827,13 @@ "when": 1785635169798, "tag": "0117_loving_whiplash", "breakpoints": true + }, + { + "idx": 118, + "version": "7", + "when": 1785688550789, + "tag": "0118_bright_sunspot", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 02fffca1e..5ed3ad430 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -928,7 +928,11 @@ "storageNotEnabledHint": "Tip: Check REDIS_URL and ENABLE_RATE_LIMIT=true (session details cache). To store unredacted messages, set STORE_SESSION_MESSAGES=true." }, "errors": { - "copyFailed": "Copy Failed" + "copyFailed": "Copy Failed", + "fetchSessionsFailed": "Failed to load sessions", + "fetchSessionsTimeout": "The sessions request timed out", + "fetchSettingsFailed": "Failed to load system settings", + "retry": "Retry" }, "requestList": { "title": "Requests", @@ -1895,6 +1899,12 @@ "confirmDescription": "This will permanently delete all request logs and usage statistics for this user. This action cannot be undone.", "confirm": "Yes, Reset All", "loading": "Resetting...", + "queued": "Reset queued", + "running": "Reset in progress", + "completed": "Reset completed", + "failed": "Reset failed. You can retry.", + "statusUnavailable": "Unable to refresh the reset status", + "retryStatus": "Retry status", "success": "All statistics have been reset" } }, diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 2ec011c52..5bfb26738 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -928,7 +928,11 @@ "storageNotEnabledHint": "ヒント: REDIS_URL と ENABLE_RATE_LIMIT=true を確認してください (セッション詳細キャッシュ)。未マスクの messages を保存するには STORE_SESSION_MESSAGES=true を設定してください。" }, "errors": { - "copyFailed": "コピー失敗" + "copyFailed": "コピー失敗", + "fetchSessionsFailed": "Session 一覧の取得に失敗しました", + "fetchSessionsTimeout": "Session 一覧の取得がタイムアウトしました", + "fetchSettingsFailed": "システム設定の取得に失敗しました", + "retry": "再試行" }, "requestList": { "title": "リクエスト一覧", @@ -1873,6 +1877,12 @@ "confirmDescription": "このユーザーのすべてのリクエストログと使用統計を完全に削除します。この操作は取り消せません。", "confirm": "はい、すべてリセット", "loading": "リセット中...", + "queued": "リセットをキューに追加しました", + "running": "統計をリセットしています", + "completed": "リセットが完了しました", + "failed": "リセットに失敗しました。再試行できます。", + "statusUnavailable": "リセット状態を更新できません", + "retryStatus": "状態を再取得", "success": "すべての統計がリセットされました" } }, diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 14a281b3f..453a0e427 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -928,7 +928,11 @@ "storageNotEnabledHint": "Подсказка: проверьте REDIS_URL и ENABLE_RATE_LIMIT=true (кэш деталей сессии). Чтобы сохранять сообщения без маскировки, установите STORE_SESSION_MESSAGES=true." }, "errors": { - "copyFailed": "Не удалось скопировать" + "copyFailed": "Не удалось скопировать", + "fetchSessionsFailed": "Не удалось загрузить список Session", + "fetchSessionsTimeout": "Время ожидания списка Session истекло", + "fetchSettingsFailed": "Не удалось загрузить системные настройки", + "retry": "Повторить" }, "requestList": { "title": "Список запросов", @@ -1878,6 +1882,12 @@ "confirmDescription": "Это навсегда удалит все логи запросов и статистику использования для этого пользователя. Это действие нельзя отменить.", "confirm": "Да, сбросить все", "loading": "Сброс...", + "queued": "Сброс поставлен в очередь", + "running": "Статистика сбрасывается", + "completed": "Сброс завершен", + "failed": "Сброс не выполнен. Можно повторить.", + "statusUnavailable": "Не удалось обновить статус сброса", + "retryStatus": "Повторить проверку", "success": "Вся статистика сброшена" } }, diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index ea3d3885c..9b1d50786 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -928,7 +928,11 @@ "storageNotEnabledHint": "提示:请检查 REDIS_URL 与 ENABLE_RATE_LIMIT=true(用于会话详情缓存);如需保存未脱敏 messages,请设置 STORE_SESSION_MESSAGES=true。" }, "errors": { - "copyFailed": "复制失败" + "copyFailed": "复制失败", + "fetchSessionsFailed": "获取 Session 列表失败", + "fetchSessionsTimeout": "获取 Session 列表超时", + "fetchSettingsFailed": "获取系统设置失败", + "retry": "重试" }, "requestList": { "title": "请求列表", @@ -1896,6 +1900,12 @@ "confirmDescription": "这将永久删除该用户的所有请求日志和使用统计。此操作无法撤销。", "confirm": "是的,重置全部", "loading": "重置中...", + "queued": "重置任务已排队", + "running": "正在重置统计", + "completed": "重置已完成", + "failed": "重置失败,可以重试", + "statusUnavailable": "暂时无法更新重置状态", + "retryStatus": "重试状态查询", "success": "所有统计已重置" } }, diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 3f338fc5c..112d7afde 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -928,7 +928,11 @@ "storageNotEnabledHint": "提示:請檢查 REDIS_URL 與 ENABLE_RATE_LIMIT=true(用於 Session 詳情快取);如需儲存未脫敏的 messages,請設定 STORE_SESSION_MESSAGES=true。" }, "errors": { - "copyFailed": "複製失敗" + "copyFailed": "複製失敗", + "fetchSessionsFailed": "取得 Session 清單失敗", + "fetchSessionsTimeout": "取得 Session 清單逾時", + "fetchSettingsFailed": "取得系統設定失敗", + "retry": "重試" }, "requestList": { "title": "請求列表", @@ -1881,6 +1885,12 @@ "confirmDescription": "這將永久刪除該使用者的所有請求日誌和使用統計。此操作無法撤銷。", "confirm": "是的,重設全部", "loading": "重設中...", + "queued": "重設工作已排入佇列", + "running": "正在重設統計", + "completed": "重設已完成", + "failed": "重設失敗,可以重試", + "statusUnavailable": "暫時無法更新重設狀態", + "retryStatus": "重試狀態查詢", "success": "所有統計已重置" } }, diff --git a/src/actions/users.ts b/src/actions/users.ts index 3bf948f4c..e8b05102e 100644 --- a/src/actions/users.ts +++ b/src/actions/users.ts @@ -1,11 +1,11 @@ "use server"; import { randomBytes } from "node:crypto"; -import { and, eq, inArray, isNull } from "drizzle-orm"; +import { and, inArray, isNull } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getLocale, getTranslations } from "next-intl/server"; import { db } from "@/drizzle/db"; -import { messageRequest, usageLedger, users as usersTable } from "@/drizzle/schema"; +import { users as usersTable } from "@/drizzle/schema"; import { emitActionAudit } from "@/lib/audit/emit"; import { getSession } from "@/lib/auth"; import { PROVIDER_GROUP } from "@/lib/constants/provider.constants"; @@ -14,6 +14,7 @@ import { getUnauthorizedFields } from "@/lib/permissions/user-field-permissions" import { clipStartByResetAt, resolveUser5hCostResetAt } from "@/lib/rate-limit/cost-reset-utils"; import { getRedisClient } from "@/lib/redis"; import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; +import type { UserStatisticsResetRecord } from "@/lib/user-statistics-reset/types"; import { parseDateInputAsTimezone } from "@/lib/utils/date-input"; import { ERROR_CODES } from "@/lib/utils/error-messages"; import { normalizeProviderGroup, parseProviderGroups } from "@/lib/utils/provider-group"; @@ -2338,12 +2339,15 @@ export async function resetUserLimitsOnly(userId: number): Promise } /** - * Reset ALL user statistics (logs + Redis cache + sessions) - * This is IRREVERSIBLE - deletes all messageRequest logs for the user + * Queue an irreversible reset of logs and cost caches created before the request cutoff. + * Active Session state is intentionally preserved. * * Admin only. */ -export async function resetUserAllStatistics(userId: number): Promise { +export async function resetUserAllStatistics( + userId: number +): Promise> { + let enqueueStarted = false; try { const tError = await getTranslations("errors"); @@ -2361,96 +2365,25 @@ export async function resetUserAllStatistics(userId: number): Promise k.id); - const keyHashes = keys.map((k) => k.key); - const requiresRedisForFixed5h = - ((user.limit5hUsd ?? 0) > 0 && (user.limit5hResetMode ?? "rolling") === "fixed") || - keys.some( - (key) => (key.limit5hUsd ?? 0) > 0 && (key.limit5hResetMode ?? "rolling") === "fixed" - ); - - if (requiresRedisForFixed5h) { - const redis = getRedisClient(); - if (redis?.status !== "ready") { - return { - ok: false, - error: tError("USER_5H_FIXED_RESET_REQUIRES_REDIS"), - errorCode: ERROR_CODES.OPERATION_FAILED, - }; - } - } - - // 1. Delete all messageRequest logs for this user - // Atomic: delete logs + ledger + clear costResetAt in a single transaction - await db.transaction(async (tx) => { - await tx.delete(messageRequest).where(eq(messageRequest.userId, userId)); - await tx.delete(usageLedger).where(eq(usageLedger.userId, userId)); - await tx - .update(usersTable) - .set({ costResetAt: null, limit5hCostResetAt: null, updatedAt: new Date() }) - .where(and(eq(usersTable.id, userId), isNull(usersTable.deletedAt))); + const { enqueueUserStatisticsReset } = await import("@/lib/user-statistics-reset/reset-queue"); + enqueueStarted = true; + const reset = await enqueueUserStatisticsReset(userId, { + fixed5hKeyIds: keys.map((key) => key.id), }); - // Invalidate auth cache outside transaction (Redis, not DB) - await invalidateCachedUser(userId).catch(() => {}); - - // 2. Clear Redis cache (cost keys + active sessions) - try { - const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); - const cacheResult = await clearUserCostCache({ - userId, - keyIds, - keyHashes, - includeActiveSessions: true, - }); - if (!cacheResult) { - logger.error("Reset user statistics committed DB changes without Redis cleanup", { - userId, - requiresRedisForFixed5h, - }); - return { - ok: false, - error: tError("USER_STATS_RESET_PARTIAL_FAILURE"), - errorCode: ERROR_CODES.USER_STATS_RESET_PARTIAL_FAILURE, - }; - } - - logger.info("Reset user statistics - Redis cache cleared", { - userId, - keyCount: keyIds.length, - ...cacheResult, - }); - if (cacheResult.cleanupFailed) { - return { - ok: false, - error: tError("USER_STATS_RESET_PARTIAL_FAILURE"), - errorCode: ERROR_CODES.USER_STATS_RESET_PARTIAL_FAILURE, - }; - } - } catch (error) { - logger.error("Failed to clear Redis cache during user statistics reset", { - userId, - error: error instanceof Error ? error.message : String(error), - }); - return { - ok: false, - error: tError("USER_STATS_RESET_PARTIAL_FAILURE"), - errorCode: ERROR_CODES.USER_STATS_RESET_PARTIAL_FAILURE, - }; - } - - logger.info("Reset all user statistics", { userId, keyCount: keyIds.length }); - revalidatePath("/dashboard/users"); - - return { ok: true }; + logger.info("Queued user statistics reset", { + userId, + resetId: reset.resetId, + status: reset.status, + }); + return { ok: true, data: reset }; } catch (error) { logger.error("Failed to reset all user statistics:", error); const tError = await getTranslations("errors"); return { ok: false, - error: tError("OPERATION_FAILED"), - errorCode: ERROR_CODES.OPERATION_FAILED, + error: tError(enqueueStarted ? "CONNECTION_FAILED" : "OPERATION_FAILED"), + errorCode: enqueueStarted ? ERROR_CODES.CONNECTION_FAILED : ERROR_CODES.OPERATION_FAILED, }; } } diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 16a5f1442..7791e093d 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -1,10 +1,10 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { Loader2, RotateCcw, Trash2, UserCog } from "lucide-react"; +import { Loader2, RefreshCw, RotateCcw, Trash2, UserCog } from "lucide-react"; import { useRouter } from "next/navigation"; import { useLocale, useTranslations } from "next-intl"; -import { useMemo, useState, useTransition } from "react"; +import { useCallback, useEffect, useMemo, useState, useTransition } from "react"; import { toast } from "sonner"; import { z } from "zod"; import { @@ -29,12 +29,14 @@ import { } from "@/components/ui/dialog"; import { editUser, + getUserStatisticsReset, removeUser, resetUserAllStatistics, resetUserLimitsOnly, toggleUserEnabled, } from "@/lib/api-client/v1/actions/users"; import { useZodForm } from "@/lib/hooks/use-zod-form"; +import type { UserStatisticsResetRecord } from "@/lib/user-statistics-reset/types"; import { cn } from "@/lib/utils"; import { UpdateUserSchema } from "@/lib/validation/schemas"; import type { UserDisplay } from "@/types/user"; @@ -64,6 +66,11 @@ const EditUserSchema = UpdateUserSchema.extend({ type EditUserValues = z.infer; +const STATISTICS_RESET_POLL_INTERVAL_MS = 1_000; +const STATISTICS_RESET_REQUEST_TIMEOUT_MS = 15_000; +const STATISTICS_RESET_MAX_RETRIES = 5; +const STATISTICS_RESET_RETRY_MAX_DELAY_MS = 16_000; + function buildDefaultValues(user: UserDisplay): EditUserValues { return { name: user.name || "", @@ -96,6 +103,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const [isPending, startTransition] = useTransition(); const [isResettingAll, setIsResettingAll] = useState(false); const [resetAllDialogOpen, setResetAllDialogOpen] = useState(false); + const [statisticsReset, setStatisticsReset] = useState(null); + const [statisticsResetPollFailed, setStatisticsResetPollFailed] = useState(false); const [isResetting5h, setIsResetting5h] = useState(false); const [reset5hDialogOpen, setReset5hDialogOpen] = useState(false); const [isResettingLimits, setIsResettingLimits] = useState(false); @@ -236,27 +245,104 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr router.refresh(); }; + const applyStatisticsResetStatus = useCallback( + (reset: UserStatisticsResetRecord): boolean => { + setStatisticsReset(reset); + if (reset.status === "completed") { + setIsResettingAll(false); + toast.success(t("editDialog.resetData.success")); + onSuccess?.(); + queryClient.invalidateQueries({ queryKey: ["users"] }); + router.refresh(); + return true; + } + if (reset.status === "failed") { + setIsResettingAll(false); + toast.error(t("editDialog.resetData.failed")); + return true; + } + return false; + }, + [onSuccess, queryClient, router, t] + ); + const handleResetAllStatistics = async () => { setIsResettingAll(true); + setStatisticsResetPollFailed(false); try { const res = await resetUserAllStatistics(user.id); if (!res.ok) { + setIsResettingAll(false); toast.error(res.error || t("editDialog.resetData.error")); return; } - toast.success(t("editDialog.resetData.success")); + applyStatisticsResetStatus(res.data as UserStatisticsResetRecord); setResetAllDialogOpen(false); - - // Full page reload to ensure all cached data is refreshed - window.location.reload(); } catch (error) { + setIsResettingAll(false); console.error("[EditUserDialog] reset all statistics failed", error); toast.error(t("editDialog.resetData.error")); - } finally { - setIsResettingAll(false); } }; + useEffect(() => { + if ( + !statisticsReset || + statisticsResetPollFailed || + !["queued", "running"].includes(statisticsReset.status) + ) + return; + let cancelled = false; + let timer: ReturnType | undefined; + let activeRequestController: AbortController | undefined; + let consecutiveRetryableFailures = 0; + + const poll = async () => { + const requestController = new AbortController(); + activeRequestController = requestController; + const requestTimeout = window.setTimeout( + () => requestController.abort(), + STATISTICS_RESET_REQUEST_TIMEOUT_MS + ); + const result = await getUserStatisticsReset(user.id, statisticsReset.resetId, { + signal: requestController.signal, + }); + window.clearTimeout(requestTimeout); + if (activeRequestController === requestController) activeRequestController = undefined; + if (cancelled) return; + if (!result.ok) { + const resultErrorCode = requestController.signal.aborted ? "TIMEOUT" : result.errorCode; + const retryable = ["CONNECTION_FAILED", "NETWORK_ERROR", "TIMEOUT"].includes( + resultErrorCode ?? "" + ); + if (retryable && consecutiveRetryableFailures < STATISTICS_RESET_MAX_RETRIES) { + const delay = Math.min( + STATISTICS_RESET_POLL_INTERVAL_MS * 2 ** consecutiveRetryableFailures, + STATISTICS_RESET_RETRY_MAX_DELAY_MS + ); + consecutiveRetryableFailures += 1; + timer = setTimeout(poll, delay); + return; + } + setStatisticsResetPollFailed(true); + return; + } + + consecutiveRetryableFailures = 0; + setStatisticsResetPollFailed(false); + const next = result.data as UserStatisticsResetRecord; + if (applyStatisticsResetStatus(next)) return; + timer = setTimeout(poll, STATISTICS_RESET_POLL_INTERVAL_MS); + }; + + timer = setTimeout(poll, STATISTICS_RESET_POLL_INTERVAL_MS); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + activeRequestController?.abort(); + }; + }, [applyStatisticsResetStatus, statisticsReset, statisticsResetPollFailed, user.id]); + const handleResetLimitsOnly = async () => { setIsResettingLimits(true); try { @@ -489,13 +575,43 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr

{t("editDialog.resetData.description")}

+ {statisticsReset ? ( +

+ {t(`editDialog.resetData.${statisticsReset.status}`)} +

+ ) : null} + {statisticsResetPollFailed ? ( +
+

+ {t("editDialog.resetData.statusUnavailable")} +

+ +
+ ) : null} - diff --git a/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.test.tsx b/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.test.tsx new file mode 100644 index 000000000..3839af3c2 --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.test.tsx @@ -0,0 +1,148 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const queryState = vi.hoisted(() => ({ + sessions: {} as Record, + refetch: vi.fn(), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: (options: { queryKey: string[] }) => + options.queryKey[0] === "all-sessions" + ? queryState.sessions + : { data: { currencyDisplay: "USD" } }, +})); +vi.mock("@/i18n/routing", () => ({ useRouter: () => ({ back: vi.fn() }) })); +vi.mock("@/components/section", () => ({ + Section: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); +vi.mock("./active-sessions-table", () => ({ + ActiveSessionsTable: ({ + sessions, + isLoading, + }: { + sessions: { sessionId: string }[]; + isLoading: boolean; + }) =>
{isLoading ? "loading" : sessions.map((session) => session.sessionId).join(",")}
, +})); + +import { ActiveSessionsClient } from "./active-sessions-client"; + +const messages = { + dashboard: { + sessions: { + back: "Back", + monitoring: "Sessions", + monitoringDescription: "Live status", + loadingError: "Loading failed", + refreshing: "Refreshing...", + activeSessions: "Active Sessions", + inactiveSessions: "Inactive Sessions", + pagination: { total: "total" }, + errors: { + fetchSessionsFailed: "Failed to load sessions", + fetchSessionsTimeout: "The sessions request timed out", + fetchSettingsFailed: "Failed to load settings", + retry: "Retry", + }, + }, + }, +}; + +describe("ActiveSessionsClient", () => { + let container: HTMLDivElement; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + queryState.refetch.mockReset(); + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => container.remove()); + + async function render() { + const root = createRoot(container); + await act(async () => { + root.render( + + + + ); + }); + return root; + } + + it("shows a localized timeout and an explicit retry command", async () => { + queryState.sessions = { + data: undefined, + isLoading: false, + isFetching: false, + error: new Error("FETCH_SESSIONS_TIMEOUT"), + refetch: queryState.refetch, + }; + const root = await render(); + + expect(container.textContent).toContain("The sessions request timed out"); + const retry = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Retry" + ); + await act(async () => retry?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(queryState.refetch).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); + }); + + it("keeps existing rows visible during a background refresh", async () => { + queryState.sessions = { + data: { + active: [{ sessionId: "session-existing" }], + inactive: [], + totalActive: 1, + totalInactive: 0, + hasMoreActive: false, + hasMoreInactive: false, + }, + isLoading: false, + isFetching: true, + error: null, + refetch: queryState.refetch, + }; + const root = await render(); + + expect(container.textContent).toContain("session-existing"); + expect(container.textContent).toContain("Refreshing..."); + expect(container.textContent).not.toContain("loading"); + await act(async () => root.unmount()); + }); + + it("keeps existing rows visible when a background refresh fails", async () => { + queryState.sessions = { + data: { + active: [{ sessionId: "session-existing" }], + inactive: [], + totalActive: 1, + totalInactive: 0, + hasMoreActive: false, + hasMoreInactive: false, + }, + isLoading: false, + isFetching: false, + error: new Error("FETCH_SESSIONS_FAILED"), + refetch: queryState.refetch, + }; + const root = await render(); + + expect(container.textContent).toContain("session-existing"); + expect(container.textContent).toContain("Failed to load sessions"); + const retry = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Retry" + ); + await act(async () => retry?.dispatchEvent(new MouseEvent("click", { bubbles: true }))); + expect(queryState.refetch).toHaveBeenCalledTimes(1); + await act(async () => root.unmount()); + }); +}); diff --git a/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.tsx b/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.tsx index 14dd12312..69ddd29d0 100644 --- a/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.tsx +++ b/src/app/[locale]/dashboard/sessions/_components/active-sessions-client.tsx @@ -1,29 +1,19 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, ChevronLeft, ChevronRight } from "lucide-react"; +import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { Section } from "@/components/section"; import { Button } from "@/components/ui/button"; import { useRouter } from "@/i18n/routing"; -import { getAllSessions } from "@/lib/api-client/v1/actions/active-sessions"; import { getSystemSettings } from "@/lib/api-client/v1/actions/system-config"; -import type { ActiveSessionInfo } from "@/types/session"; +import { fetchAllSessionsPage, type PaginatedSessionsData } from "./active-sessions-query"; import { ActiveSessionsTable } from "./active-sessions-table"; const REFRESH_INTERVAL = 3000; // 3秒刷新一次 const PAGE_SIZE = 20; -interface PaginatedSessionsData { - active: ActiveSessionInfo[]; - inactive: ActiveSessionInfo[]; - totalActive: number; - totalInactive: number; - hasMoreActive: boolean; - hasMoreInactive: boolean; -} - /** * 活跃 Session 实时监控页面 */ @@ -35,15 +25,11 @@ export function ActiveSessionsClient() { const [activePage, setActivePage] = useState(1); const [inactivePage, setInactivePage] = useState(1); - const { data, isLoading, error, refetch } = useQuery({ + const { data, isLoading, isFetching, error, refetch } = useQuery({ queryKey: ["all-sessions", activePage, inactivePage], - queryFn: async () => { - const result = await getAllSessions(activePage, inactivePage, PAGE_SIZE); - if (!result.ok) { - throw new Error(result.error || "FETCH_SESSIONS_FAILED"); - } - return result.data; - }, + queryFn: ({ signal }) => + fetchAllSessionsPage({ activePage, inactivePage, pageSize: PAGE_SIZE, signal }), + retry: false, refetchInterval: REFRESH_INTERVAL, }); @@ -65,6 +51,9 @@ export function ActiveSessionsClient() { if (error.message === "FETCH_SESSIONS_FAILED") { return t("errors.fetchSessionsFailed"); } + if (error.message === "FETCH_SESSIONS_TIMEOUT") { + return t("errors.fetchSessionsTimeout"); + } if (error.message === "FETCH_SETTINGS_FAILED") { return t("errors.fetchSettingsFailed"); } @@ -126,14 +115,34 @@ export function ActiveSessionsClient() { - {error ? ( -
- {t("loadingError")}: {getErrorMessage(error)} + {error && !data ? ( +
+

+ {t("loadingError")}: {getErrorMessage(error)} +

+
) : ( <> + {error ? ( +
+

+ {t("loadingError")}: {getErrorMessage(error)} +

+ +
+ ) : null} {/* 活跃 Session 区域 */}
+ {isFetching && !isLoading ? ( +

{t("refreshing")}

+ ) : null} ({ getAllSessions: vi.fn() })); + +vi.mock("@/lib/api-client/v1/actions/active-sessions", () => ({ + getAllSessions: api.getAllSessions, +})); +vi.mock("@/actions/active-sessions", () => ({ + getAllSessions: api.getAllSessions, +})); + +import { fetchAllSessionsPage } from "./active-sessions-query"; + +describe("fetchAllSessionsPage", () => { + afterEach(() => { + vi.useRealTimers(); + api.getAllSessions.mockReset(); + }); + + it("fails with a stable timeout error after 15 seconds", async () => { + vi.useFakeTimers(); + api.getAllSessions.mockImplementation( + (_active: number, _inactive: number, _size: number, options: RequestInit) => + new Promise((resolve) => { + options.signal?.addEventListener("abort", () => resolve({ ok: false, error: "aborted" })); + }) + ); + const request = fetchAllSessionsPage({ + activePage: 1, + inactivePage: 1, + pageSize: 20, + signal: new AbortController().signal, + }); + const rejection = expect(request).rejects.toThrow("FETCH_SESSIONS_TIMEOUT"); + + await vi.advanceTimersByTimeAsync(15_000); + await rejection; + }); + + it("propagates caller cancellation to the browser request", async () => { + let requestSignal: AbortSignal | null | undefined; + api.getAllSessions.mockImplementation( + (_active: number, _inactive: number, _size: number, options: RequestInit) => { + requestSignal = options.signal; + return new Promise((resolve) => { + options.signal?.addEventListener("abort", () => resolve({ ok: false, error: "aborted" })); + }); + } + ); + const controller = new AbortController(); + const request = fetchAllSessionsPage({ + activePage: 1, + inactivePage: 1, + pageSize: 20, + signal: controller.signal, + }); + const rejection = expect(request).rejects.toThrow("FETCH_SESSIONS_CANCELLED"); + + controller.abort(); + await rejection; + expect(requestSignal?.aborted).toBe(true); + }); + + it("normalizes a rejected browser abort to the stable caller cancellation error", async () => { + api.getAllSessions.mockImplementation( + (_active: number, _inactive: number, _size: number, options: RequestInit) => + new Promise((_resolve, reject) => { + options.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + }) + ); + const controller = new AbortController(); + const request = fetchAllSessionsPage({ + activePage: 1, + inactivePage: 1, + pageSize: 20, + signal: controller.signal, + }); + + controller.abort(); + + await expect(request).rejects.toThrow("FETCH_SESSIONS_CANCELLED"); + }); + + it("normalizes transport failures instead of exposing browser error text", async () => { + api.getAllSessions.mockRejectedValue(new TypeError("Failed to fetch")); + + await expect( + fetchAllSessionsPage({ + activePage: 1, + inactivePage: 1, + pageSize: 20, + signal: new AbortController().signal, + }) + ).rejects.toThrow("FETCH_SESSIONS_FAILED"); + }); + + it("normalizes structured backend failures", async () => { + api.getAllSessions.mockResolvedValue({ + ok: false, + error: "Bad request", + errorCode: "INVALID_FORMAT", + }); + + await expect( + fetchAllSessionsPage({ + activePage: 1, + inactivePage: 1, + pageSize: 20, + signal: new AbortController().signal, + }) + ).rejects.toThrow("FETCH_SESSIONS_FAILED"); + }); +}); diff --git a/src/app/[locale]/dashboard/sessions/_components/active-sessions-query.ts b/src/app/[locale]/dashboard/sessions/_components/active-sessions-query.ts new file mode 100644 index 000000000..df9d781eb --- /dev/null +++ b/src/app/[locale]/dashboard/sessions/_components/active-sessions-query.ts @@ -0,0 +1,56 @@ +import { getAllSessions } from "@/lib/api-client/v1/actions/active-sessions"; +import type { ActiveSessionInfo } from "@/types/session"; + +export const SESSION_FETCH_TIMEOUT_MS = 15_000; + +export interface PaginatedSessionsData { + active: ActiveSessionInfo[]; + inactive: ActiveSessionInfo[]; + totalActive: number; + totalInactive: number; + hasMoreActive: boolean; + hasMoreInactive: boolean; +} + +export async function fetchAllSessionsPage(input: { + activePage: number; + inactivePage: number; + pageSize: number; + signal: AbortSignal; +}): Promise { + const timeoutController = new AbortController(); + const timeoutId = window.setTimeout(() => timeoutController.abort(), SESSION_FETCH_TIMEOUT_MS); + const abort = () => timeoutController.abort(); + input.signal.addEventListener("abort", abort, { once: true }); + + try { + const combinedSignal = + typeof AbortSignal.any === "function" + ? AbortSignal.any([input.signal, timeoutController.signal]) + : timeoutController.signal; + const result = await getAllSessions(input.activePage, input.inactivePage, input.pageSize, { + signal: combinedSignal, + }); + if (input.signal.aborted) { + throw new Error("FETCH_SESSIONS_CANCELLED"); + } + if (timeoutController.signal.aborted && !input.signal.aborted) { + throw new Error("FETCH_SESSIONS_TIMEOUT"); + } + if (!result.ok) { + throw new Error("FETCH_SESSIONS_FAILED"); + } + return result.data; + } catch { + if (timeoutController.signal.aborted && !input.signal.aborted) { + throw new Error("FETCH_SESSIONS_TIMEOUT"); + } + if (input.signal.aborted) { + throw new Error("FETCH_SESSIONS_CANCELLED"); + } + throw new Error("FETCH_SESSIONS_FAILED"); + } finally { + window.clearTimeout(timeoutId); + input.signal.removeEventListener("abort", abort); + } +} diff --git a/src/app/api/v1/resources/users/handlers.ts b/src/app/api/v1/resources/users/handlers.ts index 9216c90a4..75494aca8 100644 --- a/src/app/api/v1/resources/users/handlers.ts +++ b/src/app/api/v1/resources/users/handlers.ts @@ -20,10 +20,12 @@ import { UserIdParamSchema, UserListQuerySchema, UserRenewSchema, + UserStatisticsResetParamsSchema, UsersBatchUpdateSchema, UsersUsageBatchSchema, UserUpdateSchema, } from "@/lib/api/v1/schemas/users"; +import type { UserStatisticsResetRecord } from "@/lib/user-statistics-reset/types"; export async function listUsers(c: Context): Promise { const query = UserListQuerySchema.safeParse({ @@ -225,7 +227,39 @@ export async function resetUserStatistics(c: Context): Promise { c.get("auth") ); if (!result.ok) return actionError(c, result); - return noContentResponse(); + const reset = result.data as { resetId: string }; + const location = `/api/v1/users/${params.id}/statistics-resets/${reset.resetId}`; + return jsonResponse(result.data, { status: 202, headers: { Location: location } }); +} + +export async function getUserStatisticsReset(c: Context): Promise { + const params = UserStatisticsResetParamsSchema.safeParse({ + id: c.req.param("id"), + resetId: c.req.param("resetId"), + }); + if (!params.success) return fromZodError(params.error, new URL(c.req.url).pathname); + + const { findUserStatisticsReset } = await import("@/lib/user-statistics-reset/reset-queue"); + let reset: UserStatisticsResetRecord | null; + try { + reset = await findUserStatisticsReset(params.data.id, params.data.resetId); + } catch { + return createProblemResponse({ + status: 503, + instance: new URL(c.req.url).pathname, + errorCode: "dependency.unavailable", + detail: publicActionErrorDetail(503), + }); + } + if (!reset) { + return createProblemResponse({ + status: 404, + instance: new URL(c.req.url).pathname, + errorCode: "user.statistics_reset_not_found", + detail: publicActionErrorDetail(404), + }); + } + return jsonResponse(reset); } export async function getUserTags(c: Context): Promise { diff --git a/src/app/api/v1/resources/users/router.ts b/src/app/api/v1/resources/users/router.ts index d08cd15aa..98eac99eb 100644 --- a/src/app/api/v1/resources/users/router.ts +++ b/src/app/api/v1/resources/users/router.ts @@ -15,6 +15,8 @@ import { UserListQuerySchema, UserListResponseSchema, UserRenewSchema, + UserStatisticsResetParamsSchema, + UserStatisticsResetResponseSchema, UsersBatchUpdateSchema, UsersUsageBatchSchema, UserUpdateSchema, @@ -29,6 +31,7 @@ import { getUserAllLimitUsage, getUserKeyGroups, getUserLimitUsage, + getUserStatisticsReset, getUsersUsage, getUserTags, listCurrentUser, @@ -462,7 +465,41 @@ usersRouter.openapi( "x-required-access": "admin", security, request: { params: UserIdParamSchema }, - responses: { 204: { description: "User statistics reset." }, ...problemResponses }, + responses: { + 202: { + description: "User statistics reset queued.", + headers: { + Location: { + description: "Status resource for the queued statistics reset.", + schema: { type: "string" }, + }, + }, + content: { "application/json": { schema: UserStatisticsResetResponseSchema } }, + }, + ...problemResponses, + }, }), resetUserStatistics as never ); + +usersRouter.openapi( + createRoute({ + method: "get", + path: "/users/{id}/statistics-resets/{resetId}", + middleware: requireAuth("admin"), + tags: ["Users"], + summary: "Get user statistics reset status", + description: "Returns the durable status of an asynchronous statistics reset.", + "x-required-access": "admin", + security, + request: { params: UserStatisticsResetParamsSchema }, + responses: { + 200: { + description: "User statistics reset status.", + content: { "application/json": { schema: UserStatisticsResetResponseSchema } }, + }, + ...problemResponses, + }, + }), + getUserStatisticsReset as never +); diff --git a/src/app/v1/_lib/proxy/replay/replay-spool.ts b/src/app/v1/_lib/proxy/replay/replay-spool.ts index 73303eb1c..3691494c3 100644 --- a/src/app/v1/_lib/proxy/replay/replay-spool.ts +++ b/src/app/v1/_lib/proxy/replay/replay-spool.ts @@ -3,7 +3,12 @@ import { logger } from "@/lib/logger"; import type { ProxySession } from "../session"; import { captureReplayResponseHeaders } from "./replay-headers"; import { isReplayEnabled, type ReplayIdentity } from "./replay-identity"; -import { getReplayStore, type ReplayDelivery, type ReplayMeta } from "./replay-store"; +import { + getReplayStore, + type ReplayDelivery, + ReplayDurableConflictError, + type ReplayMeta, +} from "./replay-store"; /** * F2 owner 侧 spool:把客户端可见字节(pump 处理后流)以 write-behind 方式 @@ -251,7 +256,7 @@ export class ReplaySpool { this.chunkCount = appended; this.metaWritten = true; // 先写 PG(持久 payload),再翻 Redis meta 为 completed(热层可服务) - await this.store.persistCompleted({ + const persistResult = await this.store.persistCompleted({ replayId: this.identity.replayId, verifier: this.identity.verifier, scopeTag: this.identity.scopeTag, @@ -274,12 +279,25 @@ export class ReplaySpool { if (!completed) { throw new Error("replay owner lease lost before completed meta"); } - logger.info("[ReplaySpool] replay entry completed", { - replayId: this.identity.replayId.slice(0, 12), - chunkCount: this.chunkCount, - byteSize: this.totalBytes, - }); + if (persistResult === "existing") { + logger.info("[ReplaySpool] reused existing durable replay winner", { + replayId: this.identity.replayId.slice(0, 12), + }); + } else { + logger.info("[ReplaySpool] replay entry completed", { + replayId: this.identity.replayId.slice(0, 12), + chunkCount: this.chunkCount, + byteSize: this.totalBytes, + }); + } } catch (error) { + if (error instanceof ReplayDurableConflictError) { + logger.warn("[ReplaySpool] discarded conflicting durable replay candidate", { + replayId: this.identity.replayId.slice(0, 12), + }); + await this.store.discardOwned(this.identity.replayId, this.ownerToken).catch(() => false); + return; + } // pgPersisted=true:payload 已 durable,仅 completed 翻转失败——热层封死为 // aborted 仍正确(meta 过期后可由 PG 持久层继续服务);false 则未持久化,整体作废 logger.warn("[ReplaySpool] complete failed, aborting entry", { diff --git a/src/app/v1/_lib/proxy/replay/replay-store.ts b/src/app/v1/_lib/proxy/replay/replay-store.ts index 64dc6590d..a58875a96 100644 --- a/src/app/v1/_lib/proxy/replay/replay-store.ts +++ b/src/app/v1/_lib/proxy/replay/replay-store.ts @@ -1,6 +1,6 @@ import "server-only"; -import { and, eq, gt, lt } from "drizzle-orm"; +import { and, eq, gt, lte, sql } from "drizzle-orm"; import type Redis from "ioredis"; import { db } from "@/drizzle/db"; import { replayPayloads } from "@/drizzle/schema"; @@ -81,6 +81,15 @@ redis.call('DEL', KEYS[3]) redis.call('DEL', KEYS[1]) return 1`; +const LUA_DISCARD_OWNED = ` +if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 +end +redis.call('DEL', KEYS[2]) +redis.call('DEL', KEYS[3]) +redis.call('DEL', KEYS[1]) +return 1`; + const LUA_COMPLETE_OWNED = ` if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 @@ -108,6 +117,46 @@ export interface ReplayPersistedRow { sourceMessageRequestId: number | null; } +export class ReplayDurableConflictError extends Error { + constructor(replayId: string) { + super(`durable replay conflict for ${replayId.slice(0, 12)}`); + this.name = "ReplayDurableConflictError"; + } +} + +export const REPLAY_CLEANUP_BATCH_SIZE = 100; + +function hasMatchingHeaders( + expected: Record, + actual: Record | null +): boolean { + if (!actual) return false; + const expectedKeys = Object.keys(expected); + const actualKeys = Object.keys(actual); + return ( + expectedKeys.length === actualKeys.length && + expectedKeys.every((key) => actual[key] === expected[key]) + ); +} + +function isMatchingPersistedReplay( + expected: ReplayPersistedRow, + actual: typeof replayPayloads.$inferSelect +): boolean { + return ( + actual.verifier === expected.verifier && + actual.scopeTag === expected.scopeTag && + actual.keyId === expected.keyId && + actual.userId === expected.userId && + actual.format === expected.format && + actual.model === expected.model && + actual.statusCode === expected.statusCode && + hasMatchingHeaders(expected.headers, actual.headersJson) && + actual.payload === expected.payload && + actual.byteSize === expected.byteSize + ); +} + export class ReplayStore { private readonly meta: RedisKVStore; private readonly chunks: RedisListStore; @@ -269,6 +318,29 @@ export class ReplayStore { } } + /** 当前 owner 放弃热层候选,但不写 aborted,避免遮蔽已存在的 PG winner。 */ + async discardOwned(replayId: string, ownerToken: string): Promise { + const redis = this.getRawRedis(); + if (!redis) return false; + try { + const result = await redis.eval( + LUA_DISCARD_OWNED, + 3, + `cch:replay:owner:${replayId}`, + `cch:replay:meta:${replayId}`, + `cch:replay:chunks:${replayId}`, + ownerToken + ); + return result === 1; + } catch (error) { + logger.debug("[ReplayStore] fenced discard failed", { + replayId: replayId.slice(0, 12), + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + } + /** 仅当前 token 仍持有租约时,原子翻转 completed meta 并释放租约。 */ async completeOwned(replayId: string, ownerToken: string, meta: ReplayMeta): Promise { const redis = this.getRawRedis(); @@ -300,28 +372,53 @@ export class ReplayStore { * 走 abort——payload 未 durable 时绝不能把 meta 翻成 completed。 * (过期行清理由 instrumentation 定时调度器负责,不在写路径顺带执行。) */ - async persistCompleted(row: ReplayPersistedRow): Promise { + async persistCompleted(row: ReplayPersistedRow): Promise<"persisted" | "existing"> { const env = getEnvConfig(); - const expiresAt = new Date(Date.now() + env.REPLAY_COMPLETED_TTL_SECONDS * 1000); + const now = new Date(); + const expiresAt = new Date(now.getTime() + env.REPLAY_COMPLETED_TTL_SECONDS * 1000); + const persistedValues = { + verifier: row.verifier, + scopeTag: row.scopeTag, + keyId: row.keyId, + userId: row.userId, + format: row.format, + model: row.model, + statusCode: row.statusCode, + headersJson: row.headers, + payload: row.payload, + byteSize: row.byteSize, + sourceMessageRequestId: row.sourceMessageRequestId, + expiresAt, + }; try { - await db + const upserted = await db .insert(replayPayloads) .values({ replayId: row.replayId, - verifier: row.verifier, - scopeTag: row.scopeTag, - keyId: row.keyId, - userId: row.userId, - format: row.format, - model: row.model, - statusCode: row.statusCode, - headersJson: row.headers, - payload: row.payload, - byteSize: row.byteSize, - sourceMessageRequestId: row.sourceMessageRequestId, - expiresAt, + ...persistedValues, }) - .onConflictDoNothing(); + .onConflictDoUpdate({ + target: replayPayloads.replayId, + set: { + ...persistedValues, + createdAt: now, + }, + setWhere: lte(replayPayloads.expiresAt, now), + }) + .returning({ replayId: replayPayloads.replayId }); + + if (upserted.length > 0) return "persisted"; + + const existingRows = await db + .select() + .from(replayPayloads) + .where(and(eq(replayPayloads.replayId, row.replayId), gt(replayPayloads.expiresAt, now))) + .limit(1); + const existing = existingRows[0]; + if (!existing || !isMatchingPersistedReplay(row, existing)) { + throw new ReplayDurableConflictError(row.replayId); + } + return "existing"; } catch (error) { logger.warn("[ReplayStore] persistCompleted failed", { error: error instanceof Error ? error.message : String(error), @@ -331,13 +428,24 @@ export class ReplayStore { } } - /** 删除 PG 持久层已过期行;返回删除数(错误由调用方处理)。 */ - async cleanupExpired(): Promise { - const deleted = await db - .delete(replayPayloads) - .where(lt(replayPayloads.expiresAt, new Date())) - .returning({ replayId: replayPayloads.replayId }); - return deleted.length; + /** 删除单批 PG 持久层过期行;返回删除数(错误由调用方处理)。 */ + async cleanupExpired(cutoff = new Date()): Promise { + const deleted = await db.execute(sql` + WITH doomed AS ( + SELECT replay_id + FROM replay_payloads + WHERE expires_at < ${cutoff} + ORDER BY expires_at, replay_id + LIMIT ${REPLAY_CLEANUP_BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM replay_payloads AS rp + USING doomed + WHERE rp.replay_id = doomed.replay_id + RETURNING 1 + `); + + return Array.isArray(deleted) ? deleted.length : 0; } async findCompleted(replayId: string): Promise { diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index ebb4d600a..f0684ff8e 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -661,6 +661,16 @@ export const messageRequest = pgTable('message_request', { ) .on(table.providerId, table.createdAt.desc()) .where(sql`${table.deletedAt} IS NULL AND ${table.statusCode} IS NOT NULL`), + messageRequestProxyStatusActiveIdx: index('idx_message_request_proxy_status_active') + .on(sql`${table.createdAt} DESC NULLS LAST`, table.userId) + .where( + sql`${table.deletedAt} IS NULL AND ${table.isReplay} = false AND ${table.statusCode} IS NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')` + ), + messageRequestProxyStatusLatestIdx: index('idx_message_request_proxy_status_latest') + .on(table.userId, sql`${table.updatedAt} DESC NULLS LAST`, table.id.desc()) + .where( + sql`${table.deletedAt} IS NULL AND ${table.isReplay} = false AND ${table.statusCode} IS NOT NULL AND (${table.blockedBy} IS NULL OR ${table.blockedBy} <> 'warmup')` + ), // Session 查询索引(按 session 聚合查看对话) messageRequestSessionIdIdx: index('idx_message_request_session_id').on(table.sessionId).where(sql`${table.deletedAt} IS NULL`), // Session ID 前缀查询索引(LIKE 'prefix%',可稳定命中 B-tree) @@ -670,7 +680,11 @@ export const messageRequest = pgTable('message_request', { messageRequestSessionIdentityCreatedAtIdx: index( 'idx_message_request_session_identity_created_at' ) - .on(sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`, table.createdAt.desc()) + .on( + sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`, + sql`${table.createdAt} DESC NULLS LAST`, + table.id.desc() + ) .where(sql`${table.deletedAt} IS NULL`), // Endpoint 过滤查询索引(仅针对未删除数据) messageRequestEndpointIdx: index('idx_message_request_endpoint').on(table.endpoint).where(sql`${table.deletedAt} IS NULL`), @@ -1211,6 +1225,7 @@ export const usageLedger = pgTable('usage_ledger', { usageLedgerUserCreatedAtIdx: index('idx_usage_ledger_user_created_at') .on(table.userId, table.createdAt) .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), + usageLedgerUserIdResetIdx: index('idx_usage_ledger_user_id_reset').on(table.userId), usageLedgerKeyCreatedAtIdx: index('idx_usage_ledger_key_created_at') .on(table.key, table.createdAt) .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), @@ -1228,7 +1243,11 @@ export const usageLedger = pgTable('usage_ledger', { usageLedgerSessionIdentityCreatedAtIdx: index( 'idx_usage_ledger_session_identity_created_at' ) - .on(sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`, table.createdAt.desc()) + .on( + sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`, + table.userId, + sql`${table.createdAt} DESC NULLS LAST` + ) .where(sql`${table.blockedBy} IS NULL AND ${table.isReplay} = false`), usageLedgerSessionIdentityIdx: index('idx_usage_ledger_session_identity') .on(sql`COALESCE(${table.sessionIdentity}, ${table.sessionId})`), diff --git a/src/instrumentation.ts b/src/instrumentation.ts index fd2a896ac..192eb0ac1 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -317,32 +317,26 @@ export function describeSchedulerError(error: unknown): { }; } -/** - * F2:Replay PG 持久层过期行清理(每 10 分钟,ENABLE_REQUEST_REPLAY 开启时)。 - * 写入路径已有机会式扫尾,此任务兜底低流量期无写入的场景。 - */ -async function startReplayCleanupScheduler(): Promise { +/** Replay PG 持久层过期行清理:数据库就绪后立即执行,之后每 10 分钟执行。 */ +export async function startReplayCleanupScheduler(): Promise { if (instrumentationState.__CCH_REPLAY_CLEANUP_STARTED__) { return; } try { - const { getEnvConfig } = await import("@/lib/config/env.schema"); - if (!getEnvConfig().ENABLE_REQUEST_REPLAY) { - return; - } - const { getReplayStore } = await import("@/app/v1/_lib/proxy/replay/replay-store"); + const { runReplayCleanupTick } = await import("@/lib/replay-cleanup"); const intervalMs = 10 * 60 * 1000; - instrumentationState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = setInterval(() => { - void getReplayStore() - .cleanupExpired() - .catch((error) => { - logger.warn("[Instrumentation] Replay cleanup tick failed", { - error: error instanceof Error ? error.message : String(error), - }); + const runTick = () => { + void runReplayCleanupTick().catch((error) => { + logger.warn("[Instrumentation] Replay cleanup tick failed", { + error: error instanceof Error ? error.message : String(error), }); - }, intervalMs); + }); + }; + + runTick(); + instrumentationState.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = setInterval(runTick, intervalMs); instrumentationState.__CCH_REPLAY_CLEANUP_STARTED__ = true; logger.info("[Instrumentation] Replay cleanup scheduler started", { @@ -573,16 +567,27 @@ export async function register() { // 初始化通知任务队列(如果启用) const { scheduleNotifications } = await import("@/lib/notification/notification-queue"); await scheduleNotifications(); + + const { startUserStatisticsResetQueue } = await import( + "@/lib/user-statistics-reset/reset-queue" + ); + startUserStatisticsResetQueue(); ( globalThis as typeof globalThis & { __CCH_STOP_BACKGROUND_QUEUES__?: () => Promise; } ).__CCH_STOP_BACKGROUND_QUEUES__ = async () => { - const [{ stopCleanupQueue }, { stopNotificationQueue }] = await Promise.all([ - import("@/lib/log-cleanup/cleanup-queue"), - import("@/lib/notification/notification-queue"), + const [{ stopCleanupQueue }, { stopNotificationQueue }, { stopUserStatisticsResetQueue }] = + await Promise.all([ + import("@/lib/log-cleanup/cleanup-queue"), + import("@/lib/notification/notification-queue"), + import("@/lib/user-statistics-reset/reset-queue"), + ]); + const results = await Promise.allSettled([ + stopCleanupQueue(), + stopNotificationQueue(), + stopUserStatisticsResetQueue(), ]); - const results = await Promise.allSettled([stopCleanupQueue(), stopNotificationQueue()]); const failures = results.flatMap((result) => result.status === "rejected" ? [result.reason] : [] ); diff --git a/src/lib/api-client/v1/actions/active-sessions.ts b/src/lib/api-client/v1/actions/active-sessions.ts index e68163925..aa0993fbd 100644 --- a/src/lib/api-client/v1/actions/active-sessions.ts +++ b/src/lib/api-client/v1/actions/active-sessions.ts @@ -1,4 +1,5 @@ import type { ActiveSessionInfo } from "@/types/session"; +import type { ApiFetchOptions } from "../fetcher"; import { apiDelete, apiGet, @@ -15,7 +16,12 @@ export function getActiveSessions() { ); } -export function getAllSessions(activePage?: number, inactivePage?: number, pageSize?: number) { +export function getAllSessions( + activePage?: number, + inactivePage?: number, + pageSize?: number, + options?: ApiFetchOptions +) { return toActionResult( apiGet( `/api/v1/sessions${searchParams({ @@ -23,7 +29,8 @@ export function getAllSessions(activePage?: number, inactivePage?: number, pageS activePage, inactivePage, pageSize, - })}` + })}`, + options ) ); } diff --git a/src/lib/api-client/v1/actions/users.ts b/src/lib/api-client/v1/actions/users.ts index e4da0b95b..5d3a81529 100644 --- a/src/lib/api-client/v1/actions/users.ts +++ b/src/lib/api-client/v1/actions/users.ts @@ -164,7 +164,17 @@ export function resetUserLimitsOnly(userId: number) { } export function resetUserAllStatistics(userId: number) { - return toVoidActionResult(apiPost(`/api/v1/users/${userId}/statistics:reset`)); + return toActionResult(apiPost(`/api/v1/users/${userId}/statistics:reset`)); +} + +export async function getUserStatisticsReset( + userId: number, + resetId: string, + options?: { signal?: AbortSignal } +) { + const path = `/api/v1/users/${userId}/statistics-resets/${encodeURIComponent(resetId)}`; + const result = await toActionResult(options ? apiGet(path, options) : apiGet(path)); + return !result.ok && !result.errorCode ? { ...result, errorCode: "NETWORK_ERROR" } : result; } export function batchUpdateUsers(data: BatchUpdateUsersParams) { diff --git a/src/lib/api-client/v1/errors.ts b/src/lib/api-client/v1/errors.ts index 993b427b0..bb124488d 100644 --- a/src/lib/api-client/v1/errors.ts +++ b/src/lib/api-client/v1/errors.ts @@ -38,6 +38,7 @@ const API_ERROR_MESSAGE_KEYS: Record = { "auth.api_key_admin_disabled": "PERMISSION_DENIED", "auth.csrf_invalid": "PERMISSION_DENIED", "request.validation_failed": "INVALID_FORMAT", + "dependency.unavailable": "CONNECTION_FAILED", "resource.not_found": "NOT_FOUND", "provider.not_found": "NOT_FOUND", "provider.action_failed": "OPERATION_FAILED", diff --git a/src/lib/api-client/v1/openapi-types.gen.ts b/src/lib/api-client/v1/openapi-types.gen.ts index da3070baa..82c1038ef 100644 --- a/src/lib/api-client/v1/openapi-types.gen.ts +++ b/src/lib/api-client/v1/openapi-types.gen.ts @@ -2484,6 +2484,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/users/{id}/statistics-resets/{resetId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get user statistics reset status + * @description Returns the durable status of an asynchronous statistics reset. + */ + get: operations["getUsersByIdStatisticsResetsByResetid"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/users/{userId}/keys": { parameters: { query?: never; @@ -32789,12 +32809,293 @@ export interface operations { }; requestBody?: never; responses: { - /** @description User statistics reset. */ - 204: { + /** @description User statistics reset queued. */ + 202: { headers: { + /** @description Status resource for the queued statistics reset. */ + Location?: string; [name: string]: unknown; }; - content?: never; + content: { + "application/json": { + /** Format: uuid */ + resetId: string; + userId: number; + /** @enum {string} */ + status: "queued" | "running" | "completed" | "failed"; + /** Format: date-time */ + requestedAt: string; + /** Format: date-time */ + startedAt: string | null; + /** Format: date-time */ + completedAt: string | null; + deletedMessageRequests: number; + deletedUsageLedger: number; + errorCode: string | null; + }; + }; + }; + /** @description Invalid request. */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Authentication required. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Admin access required. */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description User not found. */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Internal server error. */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + /** @description Dependency unavailable. */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": { + /** @description Stable problem type URI or URN. */ + type: string; + /** @description Short problem title. */ + title: string; + /** @description HTTP status code. */ + status: number; + /** @description Human-readable error detail. */ + detail: string; + /** @description Request path that produced the problem. */ + instance: string; + /** @description Application error code for frontend i18n. */ + errorCode: string; + /** @description Optional i18n parameters. */ + errorParams?: { + [key: string]: unknown; + }; + /** @description Optional request trace identifier. */ + traceId?: string; + /** @description Validation failure details. */ + invalidParams?: { + /** @description Path to the invalid input field. */ + path: (string | number)[]; + /** @description Machine-readable validation error code. */ + code: string; + /** @description Validation error message. */ + message: string; + }[]; + }; + }; + }; + }; + }; + getUsersByIdStatisticsResetsByResetid: { + parameters: { + query?: never; + header?: never; + path: { + /** @description User id. */ + id: number; + /** @description Statistics reset id. */ + resetId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description User statistics reset status. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** Format: uuid */ + resetId: string; + userId: number; + /** @enum {string} */ + status: "queued" | "running" | "completed" | "failed"; + /** Format: date-time */ + requestedAt: string; + /** Format: date-time */ + startedAt: string | null; + /** Format: date-time */ + completedAt: string | null; + deletedMessageRequests: number; + deletedUsageLedger: number; + errorCode: string | null; + }; + }; }; /** @description Invalid request. */ 400: { diff --git a/src/lib/api/v1/schemas/users.ts b/src/lib/api/v1/schemas/users.ts index 6a22b9d35..8a88f6e0e 100644 --- a/src/lib/api/v1/schemas/users.ts +++ b/src/lib/api/v1/schemas/users.ts @@ -66,6 +66,22 @@ export const UserIdParamSchema = z.object({ id: z.coerce.number().int().positive().describe("User id."), }); +export const UserStatisticsResetParamsSchema = UserIdParamSchema.extend({ + resetId: z.string().uuid().describe("Statistics reset id."), +}); + +export const UserStatisticsResetResponseSchema = z.object({ + resetId: z.string().uuid(), + userId: z.number().int().positive(), + status: z.enum(["queued", "running", "completed", "failed"]), + requestedAt: z.string().datetime(), + startedAt: z.string().datetime().nullable(), + completedAt: z.string().datetime().nullable(), + deletedMessageRequests: z.number().int().nonnegative(), + deletedUsageLedger: z.number().int().nonnegative(), + errorCode: z.string().nullable(), +}); + export const UserListQuerySchema = z.object({ cursor: z.string().optional().describe("Cursor for admin batch listing."), limit: z.coerce.number().int().min(1).max(100).default(50).describe("Page size."), diff --git a/src/lib/migrate.ts b/src/lib/migrate.ts index 722cad64c..b2e44bcf7 100644 --- a/src/lib/migrate.ts +++ b/src/lib/migrate.ts @@ -214,6 +214,7 @@ export async function runMigrations() { logger.info("Waiting for database migration lock..."); await migrationClient`SELECT pg_advisory_lock(hashtext(${MIGRATION_ADVISORY_LOCK_NAME}))`; logger.info("Database migration lock acquired"); + await migrationClient`SET search_path TO public`; // 获取迁移文件路径 const migrationsFolder = path.join(process.cwd(), "drizzle"); diff --git a/src/lib/migrations/session-replay-index-preflight.ts b/src/lib/migrations/session-replay-index-preflight.ts index 618487bb6..97de7ba2a 100644 --- a/src/lib/migrations/session-replay-index-preflight.ts +++ b/src/lib/migrations/session-replay-index-preflight.ts @@ -1,6 +1,8 @@ export const SESSION_REPLAY_MIGRATION_CREATED_AT = 1785563419224; export const SESSION_IDENTITY_INDEX_MIGRATION_CREATED_AT = 1785635169798; +export const DATABASE_TIMEOUT_INDEX_MIGRATION_CREATED_AT = 1785688550789; export const SESSION_REPLAY_INDEX_MARKER = "cch:migration:0116:session-replay-index:v1"; +export const DATABASE_TIMEOUT_INDEX_MARKER = "cch:migration:0118:database-timeout-index:v2"; export type MigrationIndexState = { exists: boolean; @@ -16,73 +18,104 @@ export type MigrationIndexPreflightExecutor = { export type SessionReplayIndexSpec = { canonicalName: string; temporaryName: string; + marker: string; definition: string; }; export const SESSION_REPLAY_INDEX_SPECS: readonly SessionReplayIndexSpec[] = [ { canonicalName: "idx_message_request_session_identity_created_at", - temporaryName: "cch_0116_tmp_01", + temporaryName: "cch_0118_tmp_01", + marker: DATABASE_TIMEOUT_INDEX_MARKER, definition: - 'ON "message_request" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL', + 'ON "public"."message_request" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL', }, { canonicalName: "idx_usage_ledger_session_identity_created_at", - temporaryName: "cch_0116_tmp_02", + temporaryName: "cch_0118_tmp_02", + marker: DATABASE_TIMEOUT_INDEX_MARKER, definition: - 'ON "usage_ledger" USING btree (COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST) WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + 'ON "public"."usage_ledger" USING btree (COALESCE("session_identity", "session_id"),"user_id","created_at" DESC NULLS LAST) WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', + }, + { + canonicalName: "idx_message_request_proxy_status_active", + temporaryName: "cch_0118_tmp_03", + marker: DATABASE_TIMEOUT_INDEX_MARKER, + definition: + 'ON "public"."message_request" USING btree ("created_at" DESC NULLS LAST,"user_id") WHERE "message_request"."deleted_at" IS NULL AND "message_request"."is_replay" = false AND "message_request"."status_code" IS NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> \'warmup\')', + }, + { + canonicalName: "idx_message_request_proxy_status_latest", + temporaryName: "cch_0118_tmp_04", + marker: DATABASE_TIMEOUT_INDEX_MARKER, + definition: + 'ON "public"."message_request" USING btree ("user_id","updated_at" DESC NULLS LAST,"id" DESC NULLS LAST) WHERE "message_request"."deleted_at" IS NULL AND "message_request"."is_replay" = false AND "message_request"."status_code" IS NOT NULL AND ("message_request"."blocked_by" IS NULL OR "message_request"."blocked_by" <> \'warmup\')', + }, + { + canonicalName: "idx_usage_ledger_user_id_reset", + temporaryName: "cch_0118_tmp_05", + marker: DATABASE_TIMEOUT_INDEX_MARKER, + definition: 'ON "public"."usage_ledger" USING btree ("user_id")', }, { canonicalName: "idx_usage_ledger_session_identity", temporaryName: "cch_0117_tmp_01", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree (COALESCE("session_identity", "session_id"))', }, { canonicalName: "idx_usage_ledger_user_created_at", temporaryName: "cch_0116_tmp_03", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("user_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, { canonicalName: "idx_usage_ledger_key_created_at", temporaryName: "cch_0116_tmp_04", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("key","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, { canonicalName: "idx_usage_ledger_provider_created_at", temporaryName: "cch_0116_tmp_05", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("final_provider_id","created_at") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, { canonicalName: "idx_usage_ledger_key_cost", temporaryName: "cch_0116_tmp_06", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("key","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, { canonicalName: "idx_usage_ledger_user_cost_cover", temporaryName: "cch_0116_tmp_07", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("user_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, { canonicalName: "idx_usage_ledger_provider_cost_cover", temporaryName: "cch_0116_tmp_08", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("final_provider_id","created_at","cost_usd","endpoint") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, { canonicalName: "idx_usage_ledger_key_created_at_desc_cover", temporaryName: "cch_0116_tmp_09", + marker: SESSION_REPLAY_INDEX_MARKER, definition: 'ON "usage_ledger" USING btree ("key","created_at" DESC NULLS LAST,"final_provider_id") WHERE "usage_ledger"."blocked_by" IS NULL AND "usage_ledger"."is_replay" = false', }, ]; -function isValidated0116Index(state: MigrationIndexState): boolean { - return state.exists && state.valid && state.marker === SESSION_REPLAY_INDEX_MARKER; +function isValidatedIndex(state: MigrationIndexState, marker: string): boolean { + return state.exists && state.valid && state.marker === marker; } async function ensurePreflightColumns(executor: MigrationIndexPreflightExecutor): Promise { @@ -90,6 +123,8 @@ async function ensurePreflightColumns(executor: MigrationIndexPreflightExecutor) try { await executor.execute(`ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "session_identity" varchar(64); +ALTER TABLE "message_request" + ADD COLUMN IF NOT EXISTS "is_replay" boolean DEFAULT false NOT NULL; ALTER TABLE "usage_ledger" ADD COLUMN IF NOT EXISTS "session_identity" varchar(64); ALTER TABLE "usage_ledger" @@ -110,7 +145,7 @@ export async function runSessionReplayIndexPreflight( for (const spec of specs) { const canonical = await executor.inspectIndex(spec.canonicalName); - if (isValidated0116Index(canonical)) { + if (isValidatedIndex(canonical, spec.marker)) { const staleTemp = await executor.inspectIndex(spec.temporaryName); if (staleTemp.exists) { await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.temporaryName}"`); @@ -119,7 +154,7 @@ export async function runSessionReplayIndexPreflight( } let temporary = await executor.inspectIndex(spec.temporaryName); - if (!isValidated0116Index(temporary)) { + if (!isValidatedIndex(temporary, spec.marker)) { if (temporary.exists) { await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.temporaryName}"`); } @@ -127,22 +162,24 @@ export async function runSessionReplayIndexPreflight( `CREATE INDEX CONCURRENTLY "${spec.temporaryName}" ${spec.definition}` ); await executor.execute( - `COMMENT ON INDEX "${spec.temporaryName}" IS '${SESSION_REPLAY_INDEX_MARKER}'` + `COMMENT ON INDEX "public"."${spec.temporaryName}" IS '${spec.marker}'` ); temporary = await executor.inspectIndex(spec.temporaryName); - if (!isValidated0116Index(temporary)) { - throw new Error(`0116 preflight produced an invalid index: ${spec.temporaryName}`); + if (!isValidatedIndex(temporary, spec.marker)) { + throw new Error(`Concurrent preflight produced an invalid index: ${spec.temporaryName}`); } } if (canonical.exists) { - await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.canonicalName}"`); + await executor.execute(`DROP INDEX CONCURRENTLY IF EXISTS "public"."${spec.canonicalName}"`); } - await executor.execute(`ALTER INDEX "${spec.temporaryName}" RENAME TO "${spec.canonicalName}"`); + await executor.execute( + `ALTER INDEX "public"."${spec.temporaryName}" RENAME TO "${spec.canonicalName}"` + ); const replaced = await executor.inspectIndex(spec.canonicalName); - if (!isValidated0116Index(replaced)) { - throw new Error(`0116 preflight failed to install index: ${spec.canonicalName}`); + if (!isValidatedIndex(replaced, spec.marker)) { + throw new Error(`Concurrent preflight failed to install index: ${spec.canonicalName}`); } } } @@ -159,7 +196,7 @@ export async function runSessionReplayMigrationPlan(input: { baseTablesReady && (latestMigrationCreatedAt == null || !Number.isFinite(latestMigrationCreatedAt) || - latestMigrationCreatedAt < SESSION_IDENTITY_INDEX_MIGRATION_CREATED_AT) + latestMigrationCreatedAt < DATABASE_TIMEOUT_INDEX_MIGRATION_CREATED_AT) ) { await runIndexPreflight({ ensureColumns: true }); } diff --git a/src/lib/proxy-status-tracker.ts b/src/lib/proxy-status-tracker.ts index 478a1a8cd..18a9f1888 100644 --- a/src/lib/proxy-status-tracker.ts +++ b/src/lib/proxy-status-tracker.ts @@ -51,6 +51,8 @@ function toTimestamp(value: Date | string | number | null | undefined): number | */ export class ProxyStatusTracker { private static instance: ProxyStatusTracker | null = null; + private cachedStatus: { value: ProxyStatusResponse; expiresAt: number } | null = null; + private inFlight: Promise | null = null; static getInstance(): ProxyStatusTracker { if (!ProxyStatusTracker.instance) { @@ -76,7 +78,27 @@ export class ProxyStatusTracker { void requestId; } - async getAllUsersStatus(): Promise { + getAllUsersStatus(): Promise { + const now = Date.now(); + if (this.cachedStatus && this.cachedStatus.expiresAt > now) { + return Promise.resolve(this.cachedStatus.value); + } + if (this.inFlight) { + return this.inFlight; + } + + this.inFlight = this.fetchAllUsersStatus() + .then((value) => { + this.cachedStatus = { value, expiresAt: Date.now() + 2_000 }; + return value; + }) + .finally(() => { + this.inFlight = null; + }); + return this.inFlight; + } + + private async fetchAllUsersStatus(): Promise { const now = Date.now(); const [dbUsers, activeRequestRows, lastRequestRows] = await Promise.all([ @@ -163,8 +185,10 @@ export class ProxyStatusTracker { .where( and( isNull(messageRequest.deletedAt), - isNull(messageRequest.durationMs), + isNull(messageRequest.statusCode), + sql`"message_request".created_at >= now() - interval '24 hours'`, eq(messageRequest.isReplay, false), + sql`("message_request".blocked_by IS NULL OR "message_request".blocked_by <> 'warmup')`, isNull(providers.deletedAt) ) ); @@ -188,8 +212,9 @@ export class ProxyStatusTracker { LEFT JOIN keys k ON k.key = mr.key AND k.deleted_at IS NULL WHERE mr.deleted_at IS NULL AND mr.is_replay = false + AND mr.status_code IS NOT NULL AND (mr.blocked_by IS NULL OR mr.blocked_by <> 'warmup') - ORDER BY mr.user_id, mr.updated_at DESC + ORDER BY mr.user_id, mr.updated_at DESC NULLS LAST, mr.id DESC `; const result = await db.execute(query); diff --git a/src/lib/redis/cost-cache-cleanup.ts b/src/lib/redis/cost-cache-cleanup.ts index c0e384557..d054cf198 100644 --- a/src/lib/redis/cost-cache-cleanup.ts +++ b/src/lib/redis/cost-cache-cleanup.ts @@ -9,6 +9,8 @@ export interface ClearUserCostCacheOptions { keyIds: number[]; keyHashes: string[]; includeActiveSessions?: boolean; + allowWhenRateLimitDisabled?: boolean; + preserveFixed5hCostKeys?: boolean; } export interface ClearUserCostCacheResult { @@ -41,6 +43,55 @@ export interface ClearUser5hCostCacheResult { errorCount?: number; } +const STATISTICS_RESET_PREPARE_TTL_SECONDS = 7 * 24 * 60 * 60; +const PREPARE_FIXED_5H_RESET_LUA = ` +if redis.call('EXISTS', KEYS[1]) == 1 then + return redis.call('GET', KEYS[1]) +end +local now = redis.call('TIME') +local cutoff_ms = (tonumber(now[1]) * 1000) + math.floor(tonumber(now[2]) / 1000) +for index = 2, #KEYS do + redis.call('DEL', KEYS[index]) +end +redis.call('SETEX', KEYS[1], ARGV[1], tostring(cutoff_ms)) +return tostring(cutoff_ms)`; + +export async function prepareUserStatisticsResetFixed5h(input: { + resetId: string; + userId: number; + keyIds: number[]; +}): Promise { + const redis = getRedisClient({ allowWhenRateLimitDisabled: true }); + if (redis?.status !== "ready") return null; + + const keys = [ + `cch:user-statistics-reset:fixed5h:${input.resetId}`, + `user:${input.userId}:cost_5h_fixed`, + buildLeaseKey("user", input.userId, "5h", "fixed"), + ...input.keyIds.flatMap((keyId) => [ + `key:${keyId}:cost_5h_fixed`, + buildLeaseKey("key", keyId, "5h", "fixed"), + ]), + ]; + + const cutoffMilliseconds = await redis.eval( + PREPARE_FIXED_5H_RESET_LUA, + keys.length, + ...keys, + STATISTICS_RESET_PREPARE_TTL_SECONDS + ); + if ( + (typeof cutoffMilliseconds !== "number" && typeof cutoffMilliseconds !== "string") || + cutoffMilliseconds === "" + ) { + return null; + } + const cutoffValue = Number(cutoffMilliseconds); + if (!Number.isFinite(cutoffValue) || cutoffValue <= 0) return null; + const cutoff = new Date(cutoffValue); + return Number.isFinite(cutoff.getTime()) ? cutoff.toISOString() : null; +} + /** * Scan and delete all Redis cost-cache keys for a user and their API keys. * @@ -52,29 +103,40 @@ export interface ClearUser5hCostCacheResult { export async function clearUserCostCache( options: ClearUserCostCacheOptions ): Promise { - const { userId, keyIds, keyHashes, includeActiveSessions = false } = options; - - const redis = getRedisClient(); + const { + userId, + keyIds, + keyHashes, + includeActiveSessions = false, + allowWhenRateLimitDisabled = false, + preserveFixed5hCostKeys = false, + } = options; + + const redis = getRedisClient({ allowWhenRateLimitDisabled }); if (redis?.status !== "ready") { return null; } const startTime = Date.now(); + let scanErrorCount = 0; // Scan all cost patterns in parallel const scanResults = await Promise.all([ ...keyIds.map((keyId) => scanPattern(redis, `key:${keyId}:cost_*`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan key cost pattern", { keyId, error: err }); return []; }) ), scanPattern(redis, `user:${userId}:cost_*`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan user cost pattern", { userId, error: err }); return []; }), // Total cost cache keys (with optional resetAt suffix) scanPattern(redis, `total_cost:user:${userId}`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan total cost pattern", { userId, pattern: `total_cost:user:${userId}`, @@ -83,6 +145,7 @@ export async function clearUserCostCache( return []; }), scanPattern(redis, `total_cost:user:${userId}:*`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan total cost pattern", { userId, pattern: `total_cost:user:${userId}:*`, @@ -92,6 +155,7 @@ export async function clearUserCostCache( }), ...keyHashes.map((keyHash) => scanPattern(redis, `total_cost:key:${keyHash}`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan total cost key pattern", { keyHash, error: err instanceof Error ? err.message : String(err), @@ -101,6 +165,7 @@ export async function clearUserCostCache( ), ...keyHashes.map((keyHash) => scanPattern(redis, `total_cost:key:${keyHash}:*`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan total cost key pattern", { keyHash, error: err instanceof Error ? err.message : String(err), @@ -111,6 +176,7 @@ export async function clearUserCostCache( // Lease cache keys (budget slices cached by LeaseService) ...keyIds.map((keyId) => scanPattern(redis, `lease:key:${keyId}:*`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan lease key pattern", { keyId, error: err instanceof Error ? err.message : String(err), @@ -119,6 +185,7 @@ export async function clearUserCostCache( }) ), scanPattern(redis, `lease:user:${userId}:*`).catch((err) => { + scanErrorCount += 1; logger.warn("Failed to scan lease user pattern", { userId, error: err instanceof Error ? err.message : String(err), @@ -127,7 +194,9 @@ export async function clearUserCostCache( }), ]); - const allCostKeys = scanResults.flat(); + const allCostKeys = scanResults + .flat() + .filter((key) => !preserveFixed5hCostKeys || !key.endsWith(":cost_5h_fixed")); let activeSessionsDeleted = 0; // Only create pipeline if there is work to do @@ -136,6 +205,8 @@ export async function clearUserCostCache( costKeysDeleted: 0, activeSessionsDeleted: 0, durationMs: Date.now() - startTime, + cleanupFailed: scanErrorCount > 0, + errorCount: scanErrorCount, }; } @@ -165,14 +236,16 @@ export async function clearUserCostCache( activeSessionsDeleted, durationMs: Date.now() - startTime, cleanupFailed: true, + errorCount: scanErrorCount + 1, }; } // Check for pipeline errors const errors = results?.filter(([err]) => err); - if (errors && errors.length > 0) { + const errorCount = scanErrorCount + (errors?.length ?? 0); + if (errorCount > 0) { logger.warn("Some Redis deletes failed during cost cache cleanup", { - errorCount: errors.length, + errorCount, userId, }); } @@ -181,8 +254,8 @@ export async function clearUserCostCache( costKeysDeleted: allCostKeys.length, activeSessionsDeleted, durationMs: Date.now() - startTime, - cleanupFailed: !!errors && errors.length > 0, - errorCount: errors?.length || 0, + cleanupFailed: errorCount > 0, + errorCount, }; } diff --git a/src/lib/replay-cleanup.ts b/src/lib/replay-cleanup.ts new file mode 100644 index 000000000..7d3aee37b --- /dev/null +++ b/src/lib/replay-cleanup.ts @@ -0,0 +1,55 @@ +import "server-only"; + +import { getReplayStore, REPLAY_CLEANUP_BATCH_SIZE } from "@/app/v1/_lib/proxy/replay/replay-store"; +import { withAdvisoryLock } from "@/lib/migrate"; + +const REPLAY_CLEANUP_LOCK_NAME = "claude-code-hub:replay-cleanup"; +const REPLAY_CLEANUP_MAX_BATCHES = 5; +const REPLAY_CLEANUP_MAX_DURATION_MS = 30_000; + +export type ReplayCleanupTickResult = + | { status: "completed"; batches: number; deleted: number } + | { status: "skipped_locked"; batches: 0; deleted: 0 } + | { status: "skipped_running"; batches: 0; deleted: 0 }; + +let running = false; + +export async function runReplayCleanupTick(): Promise { + if (running) { + return { status: "skipped_running", batches: 0, deleted: 0 }; + } + + running = true; + try { + const locked = await withAdvisoryLock( + REPLAY_CLEANUP_LOCK_NAME, + async () => { + const cutoff = new Date(); + const startedAt = Date.now(); + let batches = 0; + let deleted = 0; + + while ( + batches < REPLAY_CLEANUP_MAX_BATCHES && + Date.now() - startedAt < REPLAY_CLEANUP_MAX_DURATION_MS + ) { + const batchDeleted = await getReplayStore().cleanupExpired(cutoff); + batches += 1; + deleted += batchDeleted; + if (batchDeleted < REPLAY_CLEANUP_BATCH_SIZE) { + break; + } + } + + return { status: "completed" as const, batches, deleted }; + }, + { skipIfLocked: true } + ); + + return locked.ran + ? (locked.result as ReplayCleanupTickResult) + : { status: "skipped_locked", batches: 0, deleted: 0 }; + } finally { + running = false; + } +} diff --git a/src/lib/user-statistics-reset/reset-queue.ts b/src/lib/user-statistics-reset/reset-queue.ts new file mode 100644 index 000000000..cde10a70f --- /dev/null +++ b/src/lib/user-statistics-reset/reset-queue.ts @@ -0,0 +1,446 @@ +import "server-only"; + +import { randomUUID } from "node:crypto"; +import type { Job } from "bull"; +import Queue from "bull"; +import { logger } from "@/lib/logger"; +import { buildRedisQueueOptions } from "@/lib/redis/bull-queue-options"; +import { prepareUserStatisticsResetFixed5h } from "@/lib/redis/cost-cache-cleanup"; +import { + executeUserStatisticsReset, + findUserStatisticsResetKeyIds, + UserStatisticsResetError, +} from "./reset-service"; +import { + claimActiveUserStatisticsReset, + deleteUserStatisticsResetStatus, + getUserStatisticsResetStatus, + releaseActiveUserStatisticsReset, + setUserStatisticsResetStatus, +} from "./reset-status-store"; +import type { + UserStatisticsResetJobData, + UserStatisticsResetRecord, + UserStatisticsResetStoredRecord, +} from "./types"; + +let resetQueue: Queue.Queue | null = null; +const RESET_JOB_NAME = "reset"; +const STALLED_FAILURE_REASON = "job stalled more than allowable limit"; + +function errorCode(error: unknown): string { + return error instanceof UserStatisticsResetError + ? error.code + : "USER_STATISTICS_RESET_OPERATION_FAILED"; +} + +function createQueuedRecord(input: UserStatisticsResetJobData): UserStatisticsResetStoredRecord { + return { + ...input, + fixed5hKeyIds: input.fixed5hKeyIds ?? [], + fixed5hPreparationVersion: input.fixed5hPreparationVersion ?? null, + status: "queued", + startedAt: null, + completedAt: null, + deletedMessageRequests: 0, + deletedUsageLedger: 0, + errorCode: null, + }; +} + +function toPublicRecord(record: UserStatisticsResetStoredRecord): UserStatisticsResetRecord { + const { + fixed5hKeyIds: _fixed5hKeyIds, + fixed5hPreparationVersion: _fixed5hPreparationVersion, + ...publicRecord + } = record; + return publicRecord; +} + +type PreparedResetJobData = UserStatisticsResetJobData & { + fixed5hKeyIds: number[]; + fixed5hPreparationVersion: 1; +}; + +async function ensurePreparedReset( + jobData: UserStatisticsResetJobData, + current: UserStatisticsResetStoredRecord +): Promise<{ jobData: PreparedResetJobData; record: UserStatisticsResetStoredRecord }> { + if (current.fixed5hPreparationVersion === 1) { + return { + jobData: { + resetId: current.resetId, + userId: current.userId, + requestedAt: current.requestedAt, + fixed5hKeyIds: current.fixed5hKeyIds, + fixed5hPreparationVersion: 1, + }, + record: current, + }; + } + + if (jobData.fixed5hPreparationVersion === 1) { + const preparedRecord: UserStatisticsResetStoredRecord = { + ...current, + requestedAt: jobData.requestedAt, + fixed5hKeyIds: jobData.fixed5hKeyIds ?? [], + fixed5hPreparationVersion: 1, + }; + await setUserStatisticsResetStatus(preparedRecord); + return { + jobData: { + resetId: jobData.resetId, + userId: jobData.userId, + requestedAt: jobData.requestedAt, + fixed5hKeyIds: preparedRecord.fixed5hKeyIds, + fixed5hPreparationVersion: 1, + }, + record: preparedRecord, + }; + } + + const fixed5hKeyIds = await findUserStatisticsResetKeyIds(jobData.userId); + const requestedAt = await prepareUserStatisticsResetFixed5h({ + resetId: jobData.resetId, + userId: jobData.userId, + keyIds: fixed5hKeyIds, + }); + if (!requestedAt) throw new Error("USER_STATISTICS_RESET_FIXED_5H_PREPARE_FAILED"); + + const preparedJobData: PreparedResetJobData = { + ...jobData, + requestedAt, + fixed5hKeyIds, + fixed5hPreparationVersion: 1, + }; + const preparedRecord: UserStatisticsResetStoredRecord = { + ...current, + ...preparedJobData, + }; + await setUserStatisticsResetStatus(preparedRecord); + return { jobData: preparedJobData, record: preparedRecord }; +} + +function getResetQueue(): Queue.Queue { + if (process.env.NODE_ENV === "development") { + throw new Error("USER_STATISTICS_RESET_QUEUE_DISABLED_IN_DEVELOPMENT"); + } + if (resetQueue) return resetQueue; + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + throw new Error("REDIS_URL environment variable is required for user statistics reset queue"); + } + + resetQueue = new Queue("user-statistics-reset", { + redis: buildRedisQueueOptions(redisUrl, "[UserStatisticsResetQueue]"), + defaultJobOptions: { + attempts: 5, + backoff: { type: "exponential", delay: 30_000 }, + removeOnComplete: 100, + removeOnFail: 100, + }, + }); + resetQueue.process(RESET_JOB_NAME, processUserStatisticsReset); + resetQueue.on("failed", async (job, error) => { + if (!job?.data?.resetId || !Number.isInteger(job.data.userId)) { + logger.error("[UserStatisticsResetQueue] job failed without recoverable data", { + error: error.message, + }); + return; + } + logger.error("[UserStatisticsResetQueue] job failed", { + resetId: job.data.resetId, + userId: job.data.userId, + attemptsMade: job.attemptsMade, + error: error.message, + }); + const attempts = job.opts?.attempts ?? 1; + const isTerminal = + (job.attemptsMade ?? 0) >= attempts || error.message === STALLED_FAILURE_REASON; + if (isTerminal) { + try { + await recordFinalFailure(job.data, error); + } catch (statusError) { + logger.error("[UserStatisticsResetQueue] failed to persist terminal status", { + resetId: job.data.resetId, + userId: job.data.userId, + error: statusError instanceof Error ? statusError.message : String(statusError), + }); + } + } + }); + return resetQueue; +} + +async function recordFinalFailure( + jobData: UserStatisticsResetJobData, + error: unknown +): Promise { + const current = + (await getUserStatisticsResetStatus(jobData.resetId)) ?? createQueuedRecord(jobData); + await setUserStatisticsResetStatus({ + ...current, + status: "failed", + completedAt: new Date().toISOString(), + errorCode: + current.status === "failed" && current.errorCode ? current.errorCode : errorCode(error), + }); + try { + await releaseActiveUserStatisticsReset(jobData.userId, jobData.resetId); + } catch (releaseError) { + logger.warn("[UserStatisticsResetQueue] failed reset retained an active claim", { + resetId: jobData.resetId, + userId: jobData.userId, + error: releaseError instanceof Error ? releaseError.message : String(releaseError), + }); + } +} + +async function processUserStatisticsReset(job: Job) { + let current = + (await getUserStatisticsResetStatus(job.data.resetId)) ?? createQueuedRecord(job.data); + const startedAt = current.startedAt ?? new Date().toISOString(); + let baseProgress = { + deletedMessageRequests: current.deletedMessageRequests, + deletedUsageLedger: current.deletedUsageLedger, + }; + let attemptProgress = { deletedMessageRequests: 0, deletedUsageLedger: 0 }; + let preparedJobData: PreparedResetJobData | null = null; + + try { + const prepared = await ensurePreparedReset(job.data, current); + preparedJobData = prepared.jobData; + current = { + ...prepared.record, + status: "running", + startedAt, + errorCode: null, + }; + baseProgress = { + deletedMessageRequests: current.deletedMessageRequests, + deletedUsageLedger: current.deletedUsageLedger, + }; + await setUserStatisticsResetStatus(current); + const deleted = await executeUserStatisticsReset(preparedJobData, async (progress) => { + attemptProgress = progress; + current = { + ...current, + deletedMessageRequests: + baseProgress.deletedMessageRequests + progress.deletedMessageRequests, + deletedUsageLedger: baseProgress.deletedUsageLedger + progress.deletedUsageLedger, + }; + await setUserStatisticsResetStatus(current); + }); + attemptProgress = deleted; + const completed: UserStatisticsResetStoredRecord = { + ...current, + deletedMessageRequests: baseProgress.deletedMessageRequests + deleted.deletedMessageRequests, + deletedUsageLedger: baseProgress.deletedUsageLedger + deleted.deletedUsageLedger, + status: "completed", + startedAt, + completedAt: new Date().toISOString(), + errorCode: null, + }; + await setUserStatisticsResetStatus(completed); + try { + await releaseActiveUserStatisticsReset(job.data.userId, job.data.resetId); + } catch (error) { + logger.warn("[UserStatisticsResetQueue] completed reset retained an active claim", { + resetId: job.data.resetId, + userId: job.data.userId, + error: error instanceof Error ? error.message : String(error), + }); + } + return completed; + } catch (error) { + const attempts = job.opts.attempts ?? 1; + const isFinalAttempt = job.attemptsMade + 1 >= attempts; + const errorProgress = + error instanceof UserStatisticsResetError + ? error.progress + : { deletedMessageRequests: 0, deletedUsageLedger: 0 }; + const progress = { + deletedMessageRequests: Math.max( + attemptProgress.deletedMessageRequests, + errorProgress.deletedMessageRequests + ), + deletedUsageLedger: Math.max( + attemptProgress.deletedUsageLedger, + errorProgress.deletedUsageLedger + ), + }; + try { + await setUserStatisticsResetStatus({ + ...current, + deletedMessageRequests: + baseProgress.deletedMessageRequests + progress.deletedMessageRequests, + deletedUsageLedger: baseProgress.deletedUsageLedger + progress.deletedUsageLedger, + status: isFinalAttempt ? "failed" : "queued", + startedAt, + completedAt: isFinalAttempt ? new Date().toISOString() : null, + errorCode: isFinalAttempt ? errorCode(error) : null, + }); + } catch (statusError) { + logger.error("[UserStatisticsResetQueue] failed to persist attempt status", { + resetId: job.data.resetId, + userId: job.data.userId, + error: statusError instanceof Error ? statusError.message : String(statusError), + }); + } + if (isFinalAttempt) { + try { + await releaseActiveUserStatisticsReset(job.data.userId, job.data.resetId); + } catch (releaseError) { + logger.warn("[UserStatisticsResetQueue] failed reset retained an active claim", { + resetId: job.data.resetId, + userId: job.data.userId, + error: releaseError instanceof Error ? releaseError.message : String(releaseError), + }); + } + } + throw error; + } +} + +export async function enqueueUserStatisticsReset( + userId: number, + options: { requestedAt?: string; fixed5hKeyIds?: number[] } = {} +): Promise { + return enqueueUserStatisticsResetWithReconciliation(userId, options, true); +} + +async function enqueueUserStatisticsResetWithReconciliation( + userId: number, + options: { requestedAt?: string; fixed5hKeyIds?: number[] }, + allowReconciliation: boolean +): Promise { + const queue = getResetQueue(); + const jobData: UserStatisticsResetJobData = { + resetId: randomUUID(), + userId, + requestedAt: options.requestedAt ?? new Date().toISOString(), + fixed5hKeyIds: options.fixed5hKeyIds ?? [], + }; + const queued = createQueuedRecord(jobData); + await setUserStatisticsResetStatus(queued); + + const claim = await claimActiveUserStatisticsReset(userId, jobData.resetId); + if (!claim.acquired) { + await deleteUserStatisticsResetStatus(jobData.resetId); + const existing = await getUserStatisticsResetStatus(claim.resetId); + const existingIsActive = + existing?.userId === userId && ["queued", "running"].includes(existing.status); + if (!existingIsActive) { + await releaseActiveUserStatisticsReset(userId, claim.resetId); + if (allowReconciliation) { + return enqueueUserStatisticsResetWithReconciliation(userId, options, false); + } + throw new Error("USER_STATISTICS_RESET_ACTIVE_STATUS_MISSING"); + } + + const existingJob = await queue.getJob(existing.resetId); + const existingJobState = existingJob ? await existingJob.getState() : null; + if (existingJobState === "failed" || existingJobState === "completed") { + await releaseActiveUserStatisticsReset(userId, existing.resetId); + if (allowReconciliation) { + return enqueueUserStatisticsResetWithReconciliation(userId, options, false); + } + throw new Error("USER_STATISTICS_RESET_ACTIVE_JOB_TERMINAL"); + } + if (!existingJob) { + const recovered = await ensurePreparedReset( + { + resetId: existing.resetId, + userId: existing.userId, + requestedAt: existing.requestedAt, + fixed5hKeyIds: existing.fixed5hKeyIds, + ...(existing.fixed5hPreparationVersion === 1 + ? { fixed5hPreparationVersion: 1 as const } + : {}), + }, + existing + ); + await queue.add(RESET_JOB_NAME, recovered.jobData, { jobId: existing.resetId }); + return toPublicRecord(recovered.record); + } + return toPublicRecord(existing); + } + + let preparedRecord: UserStatisticsResetStoredRecord | null = null; + let enqueueAttempted = false; + try { + const requestedAt = await prepareUserStatisticsResetFixed5h({ + resetId: jobData.resetId, + userId, + keyIds: jobData.fixed5hKeyIds ?? [], + }); + if (!requestedAt) throw new Error("USER_STATISTICS_RESET_FIXED_5H_PREPARE_FAILED"); + const preparedJobData: PreparedResetJobData = { + ...jobData, + requestedAt, + fixed5hKeyIds: jobData.fixed5hKeyIds ?? [], + fixed5hPreparationVersion: 1, + }; + preparedRecord = { + ...queued, + ...preparedJobData, + }; + await setUserStatisticsResetStatus(preparedRecord); + enqueueAttempted = true; + await queue.add(RESET_JOB_NAME, preparedJobData, { jobId: jobData.resetId }); + return toPublicRecord(preparedRecord); + } catch (error) { + if (enqueueAttempted && preparedRecord) { + try { + if (await queue.getJob(jobData.resetId)) { + return toPublicRecord(preparedRecord); + } + } catch (reconciliationError) { + logger.warn("[UserStatisticsResetQueue] ambiguous enqueue could not be reconciled", { + resetId: jobData.resetId, + userId, + error: + reconciliationError instanceof Error + ? reconciliationError.message + : String(reconciliationError), + }); + } + } + logger.warn("[UserStatisticsResetQueue] reset remains queued after enqueue failure", { + resetId: jobData.resetId, + userId, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} + +export async function findUserStatisticsReset( + userId: number, + resetId: string +): Promise { + const record = await getUserStatisticsResetStatus(resetId); + return record?.userId === userId ? toPublicRecord(record) : null; +} + +export function startUserStatisticsResetQueue(): boolean { + if (!process.env.REDIS_URL) { + logger.warn("[UserStatisticsResetQueue] disabled because REDIS_URL is not configured"); + return false; + } + try { + getResetQueue(); + return true; + } catch (error) { + logger.error("[UserStatisticsResetQueue] failed to start", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +export async function stopUserStatisticsResetQueue(): Promise { + if (!resetQueue) return; + await resetQueue.close(); + resetQueue = null; +} diff --git a/src/lib/user-statistics-reset/reset-service.ts b/src/lib/user-statistics-reset/reset-service.ts new file mode 100644 index 000000000..4384af7b6 --- /dev/null +++ b/src/lib/user-statistics-reset/reset-service.ts @@ -0,0 +1,245 @@ +import "server-only"; + +import { and, eq, isNull, sql } from "drizzle-orm"; +import { db } from "@/drizzle/db"; +import { keys, users } from "@/drizzle/schema"; +import { clearUserCostCache } from "@/lib/redis/cost-cache-cleanup"; +import { invalidateCachedUser } from "@/lib/security/api-key-auth-cache"; + +const RESET_BATCH_SIZE = 1000; + +export async function findUserStatisticsResetKeyIds(userId: number): Promise { + const userKeys = await db + .select({ id: keys.id }) + .from(keys) + .where(and(eq(keys.userId, userId), isNull(keys.deletedAt))); + return userKeys.map((key) => key.id); +} + +export class UserStatisticsResetError extends Error { + constructor( + readonly code: string, + readonly progress: { + deletedMessageRequests: number; + deletedUsageLedger: number; + } = { deletedMessageRequests: 0, deletedUsageLedger: 0 } + ) { + super(code); + this.name = "UserStatisticsResetError"; + } +} + +function affectedRows(result: unknown): number { + if (Array.isArray(result)) return result.length; + if (!result || typeof result !== "object") return 0; + const count = + (result as { count?: unknown; rowCount?: unknown }).count ?? + (result as { rowCount?: unknown }).rowCount; + return count === undefined ? 0 : Number(count); +} + +function firstRow(result: unknown): Record | undefined { + if (Array.isArray(result)) return result[0] as Record | undefined; + if (result && typeof result === "object" && Symbol.iterator in result) { + return Array.from(result as Iterable>)[0]; + } + return undefined; +} + +async function deleteMessageRequestBatch(userId: number, cutoff: Date): Promise { + return db.transaction(async (tx) => { + const result = await tx.execute(sql` + WITH doomed AS ( + SELECT id + FROM message_request + WHERE user_id = ${userId} + AND (created_at IS NULL OR created_at <= ${cutoff}) + LIMIT ${RESET_BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM message_request mr + USING doomed + WHERE mr.id = doomed.id + RETURNING 1 + `); + return affectedRows(result); + }); +} + +async function deleteUsageLedgerBatch(userId: number, cutoff: Date): Promise { + return db.transaction(async (tx) => { + const result = await tx.execute(sql` + WITH doomed AS ( + SELECT id + FROM usage_ledger + WHERE user_id = ${userId} + AND created_at <= ${cutoff} + LIMIT ${RESET_BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM usage_ledger ul + USING doomed + WHERE ul.id = doomed.id + RETURNING 1 + `); + return affectedRows(result); + }); +} + +async function hasRemainingRows( + table: "message_request" | "usage_ledger", + userId: number, + cutoff: Date +): Promise { + const tableName = + table === "message_request" ? sql.raw("message_request") : sql.raw("usage_ledger"); + const cutoffPredicate = + table === "message_request" + ? sql`(created_at IS NULL OR created_at <= ${cutoff})` + : sql`created_at <= ${cutoff}`; + const result = await db.execute(sql` + SELECT EXISTS ( + SELECT 1 FROM ${tableName} + WHERE user_id = ${userId} + AND ${cutoffPredicate} + ) AS "exists" + `); + return firstRow(result)?.exists === true; +} + +async function drainTable(input: { + table: "message_request" | "usage_ledger"; + userId: number; + cutoff: Date; + onProgress?: (deleted: number) => Promise; +}): Promise { + let deleted = 0; + try { + while (true) { + const batchDeleted = + input.table === "message_request" + ? await deleteMessageRequestBatch(input.userId, input.cutoff) + : await deleteUsageLedgerBatch(input.userId, input.cutoff); + deleted += batchDeleted; + if (batchDeleted > 0) { + await input.onProgress?.(deleted); + } + if (batchDeleted < RESET_BATCH_SIZE) break; + } + + if (!(await hasRemainingRows(input.table, input.userId, input.cutoff))) { + return deleted; + } + throw new UserStatisticsResetError( + "USER_STATISTICS_RESET_ROWS_LOCKED", + input.table === "message_request" + ? { deletedMessageRequests: deleted, deletedUsageLedger: 0 } + : { deletedMessageRequests: 0, deletedUsageLedger: deleted } + ); + } catch (error) { + if (error instanceof UserStatisticsResetError) throw error; + throw new UserStatisticsResetError( + "USER_STATISTICS_RESET_OPERATION_FAILED", + input.table === "message_request" + ? { deletedMessageRequests: deleted, deletedUsageLedger: 0 } + : { deletedMessageRequests: 0, deletedUsageLedger: deleted } + ); + } +} + +export async function executeUserStatisticsReset( + input: { + userId: number; + requestedAt: string; + }, + onProgress?: (progress: { + deletedMessageRequests: number; + deletedUsageLedger: number; + }) => Promise +): Promise<{ deletedMessageRequests: number; deletedUsageLedger: number }> { + const cutoff = new Date(input.requestedAt); + if (!Number.isFinite(cutoff.getTime())) { + throw new UserStatisticsResetError("USER_STATISTICS_RESET_INVALID_CUTOFF"); + } + + let deletedMessageRequests = 0; + let deletedUsageLedger = 0; + try { + deletedMessageRequests = await drainTable({ + table: "message_request", + userId: input.userId, + cutoff, + onProgress: async (deleted) => { + deletedMessageRequests = deleted; + await onProgress?.({ deletedMessageRequests, deletedUsageLedger }); + }, + }); + deletedUsageLedger = await drainTable({ + table: "usage_ledger", + userId: input.userId, + cutoff, + onProgress: async (deleted) => { + deletedUsageLedger = deleted; + await onProgress?.({ deletedMessageRequests, deletedUsageLedger }); + }, + }); + } catch (error) { + if (error instanceof UserStatisticsResetError) { + throw new UserStatisticsResetError(error.code, { + deletedMessageRequests: Math.max( + deletedMessageRequests, + error.progress.deletedMessageRequests + ), + deletedUsageLedger: Math.max(deletedUsageLedger, error.progress.deletedUsageLedger), + }); + } + if (deletedMessageRequests > 0 || deletedUsageLedger > 0) { + throw new UserStatisticsResetError("USER_STATISTICS_RESET_OPERATION_FAILED", { + deletedMessageRequests, + deletedUsageLedger, + }); + } + throw error; + } + + try { + const userKeys = await db + .select({ id: keys.id, key: keys.key }) + .from(keys) + .where(and(eq(keys.userId, input.userId), isNull(keys.deletedAt))); + + await db + .update(users) + .set({ + costResetAt: sql`CASE WHEN ${users.costResetAt} IS NULL OR ${users.costResetAt} <= ${cutoff} THEN NULL ELSE ${users.costResetAt} END`, + limit5hCostResetAt: sql`CASE WHEN ${users.limit5hCostResetAt} IS NULL OR ${users.limit5hCostResetAt} <= ${cutoff} THEN NULL ELSE ${users.limit5hCostResetAt} END`, + updatedAt: new Date(), + }) + .where(and(eq(users.id, input.userId), isNull(users.deletedAt))); + await invalidateCachedUser(input.userId); + + const cacheResult = await clearUserCostCache({ + userId: input.userId, + keyIds: userKeys.map((key) => key.id), + keyHashes: userKeys.map((key) => key.key), + includeActiveSessions: false, + allowWhenRateLimitDisabled: true, + preserveFixed5hCostKeys: true, + }); + if (!cacheResult || cacheResult.cleanupFailed) { + throw new UserStatisticsResetError("USER_STATISTICS_RESET_CACHE_CLEANUP_FAILED"); + } + } catch (error) { + throw new UserStatisticsResetError( + error instanceof UserStatisticsResetError + ? error.code + : "USER_STATISTICS_RESET_OPERATION_FAILED", + { + deletedMessageRequests, + deletedUsageLedger, + } + ); + } + + return { deletedMessageRequests, deletedUsageLedger }; +} diff --git a/src/lib/user-statistics-reset/reset-status-store.ts b/src/lib/user-statistics-reset/reset-status-store.ts new file mode 100644 index 000000000..ee189ec02 --- /dev/null +++ b/src/lib/user-statistics-reset/reset-status-store.ts @@ -0,0 +1,112 @@ +import "server-only"; + +import type Redis from "ioredis"; +import { getEnvConfig } from "@/lib/config/env.schema"; +import { getRedisClient } from "@/lib/redis/client"; +import { RedisKVStore } from "@/lib/redis/redis-kv-store"; +import type { UserStatisticsResetStoredRecord } from "./types"; + +const RESET_STATUS_TTL_SECONDS = 7 * 24 * 60 * 60; +const ACTIVE_RESET_PREFIX = "cch:user-statistics-reset:active:"; +const RESET_STATUS_PREFIX = "cch:user-statistics-reset:status:"; +const statusStore = new RedisKVStore({ + prefix: RESET_STATUS_PREFIX, + defaultTtlSeconds: RESET_STATUS_TTL_SECONDS, +}); + +type ResetRedis = Pick & { + eval(...args: [script: string, numkeys: number, ...keysAndArgs: string[]]): Promise; +}; + +const LUA_COMPARE_DELETE = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) +end +return 0`; + +async function getReadyRedis(): Promise { + const redis = getRedisClient({ allowWhenRateLimitDisabled: true }) as ResetRedis | null; + if (!redis || redis.status === "end") { + throw new Error("USER_STATISTICS_RESET_REDIS_UNAVAILABLE"); + } + if (redis.status === "ready") return redis; + + return new Promise((resolve, reject) => { + let settled = false; + const finish = (result: { redis: ResetRedis } | { error: Error }) => { + if (settled) return; + settled = true; + clearTimeout(timeoutId); + redis.removeListener("ready", onReady); + redis.removeListener("end", onEnd); + if ("redis" in result) resolve(result.redis); + else reject(result.error); + }; + const onReady = () => finish({ redis }); + const onEnd = () => finish({ error: new Error("USER_STATISTICS_RESET_REDIS_UNAVAILABLE") }); + const timeoutId = setTimeout(onEnd, getEnvConfig().REDIS_COMMAND_TIMEOUT_MS); + + redis.once("ready", onReady); + redis.once("end", onEnd); + if (redis.status === "ready") onReady(); + else if (redis.status === "end") onEnd(); + }); +} + +export async function setUserStatisticsResetStatus( + record: UserStatisticsResetStoredRecord +): Promise { + await getReadyRedis(); + if (!(await statusStore.set(record.resetId, record))) { + throw new Error("USER_STATISTICS_RESET_STATUS_WRITE_FAILED"); + } +} + +export async function getUserStatisticsResetStatus( + resetId: string +): Promise { + const redis = await getReadyRedis(); + const raw = await redis.get(`${RESET_STATUS_PREFIX}${resetId}`); + if (!raw) return null; + try { + const record = JSON.parse(raw) as UserStatisticsResetStoredRecord; + return { + ...record, + fixed5hKeyIds: record.fixed5hKeyIds ?? [], + fixed5hPreparationVersion: record.fixed5hPreparationVersion === 1 ? 1 : null, + }; + } catch { + throw new Error("USER_STATISTICS_RESET_STATUS_INVALID"); + } +} + +export async function deleteUserStatisticsResetStatus(resetId: string): Promise { + const redis = await getReadyRedis(); + await redis.del(`${RESET_STATUS_PREFIX}${resetId}`); +} + +export async function claimActiveUserStatisticsReset( + userId: number, + resetId: string +): Promise<{ acquired: boolean; resetId: string }> { + const redis = await getReadyRedis(); + const key = `${ACTIVE_RESET_PREFIX}${userId}`; + const result = await redis.set(key, resetId, "EX", RESET_STATUS_TTL_SECONDS, "NX"); + if (result === "OK") { + return { acquired: true, resetId }; + } + + const existing = await redis.get(key); + if (!existing) { + throw new Error("USER_STATISTICS_RESET_ACTIVE_CLAIM_FAILED"); + } + return { acquired: false, resetId: existing }; +} + +export async function releaseActiveUserStatisticsReset( + userId: number, + resetId: string +): Promise { + const redis = await getReadyRedis(); + await redis.eval(LUA_COMPARE_DELETE, 1, `${ACTIVE_RESET_PREFIX}${userId}`, resetId); +} diff --git a/src/lib/user-statistics-reset/types.ts b/src/lib/user-statistics-reset/types.ts new file mode 100644 index 000000000..dcceafcc5 --- /dev/null +++ b/src/lib/user-statistics-reset/types.ts @@ -0,0 +1,26 @@ +export type UserStatisticsResetStatus = "queued" | "running" | "completed" | "failed"; + +export interface UserStatisticsResetRecord { + resetId: string; + userId: number; + status: UserStatisticsResetStatus; + requestedAt: string; + startedAt: string | null; + completedAt: string | null; + deletedMessageRequests: number; + deletedUsageLedger: number; + errorCode: string | null; +} + +export interface UserStatisticsResetJobData { + resetId: string; + userId: number; + requestedAt: string; + fixed5hKeyIds?: number[]; + fixed5hPreparationVersion?: 1; +} + +export interface UserStatisticsResetStoredRecord extends UserStatisticsResetRecord { + fixed5hKeyIds: number[]; + fixed5hPreparationVersion: 1 | null; +} diff --git a/src/repository/message.ts b/src/repository/message.ts index f6a5d6b9b..605614a31 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -37,14 +37,18 @@ const POST_TERMINAL_ROUTING_TRACE_ACK_TIMEOUT_MS = 3_000; const ledgerSessionIdentity = sql`COALESCE(${usageLedger.sessionIdentity}, ${usageLedger.sessionId})`; const messageSessionIdentity = sql`COALESCE(${messageRequest.sessionIdentity}, ${messageRequest.sessionId})`; +function ledgerCanonicalSessionCondition(identity: string) { + return isReservedSessionIdentity(identity) + ? and(eq(ledgerSessionIdentity, identity), eq(usageLedger.sessionIdentity, identity)) + : eq(ledgerSessionIdentity, identity); +} + function ledgerSessionLookupForOwner(identityOrPhysicalId: string, ownerUserId?: number) { - const canonicalCondition = isReservedSessionIdentity(identityOrPhysicalId) - ? eq(usageLedger.sessionIdentity, identityOrPhysicalId) - : eq(ledgerSessionIdentity, identityOrPhysicalId); - const lookupCondition = - ownerUserId !== undefined || !isReservedSessionIdentity(identityOrPhysicalId) - ? or(canonicalCondition, eq(usageLedger.sessionId, identityOrPhysicalId)) - : canonicalCondition; + const reservedIdentity = isReservedSessionIdentity(identityOrPhysicalId); + const canonicalCondition = ledgerCanonicalSessionCondition(identityOrPhysicalId); + const lookupCondition = reservedIdentity + ? canonicalCondition + : or(canonicalCondition, eq(usageLedger.sessionId, identityOrPhysicalId)); return and( lookupCondition, @@ -53,11 +57,7 @@ function ledgerSessionLookupForOwner(identityOrPhysicalId: string, ownerUserId?: } function ledgerCanonicalSessionLookup(identity: string, ownerUserId: number) { - const canonicalCondition = isReservedSessionIdentity(identity) - ? eq(usageLedger.sessionIdentity, identity) - : eq(ledgerSessionIdentity, identity); - - return and(canonicalCondition, eq(usageLedger.userId, ownerUserId)); + return and(ledgerCanonicalSessionCondition(identity), eq(usageLedger.userId, ownerUserId)); } function messageSessionLookup(identityOrPhysicalId: string, ownerUserId?: number) { @@ -1692,7 +1692,7 @@ export async function findSessionRequestLocator( isNull(messageRequest.deletedAt) ) ) - .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) + .orderBy(sql`${messageRequest.createdAt} DESC NULLS LAST`, desc(messageRequest.id)) .limit(1); if ( @@ -1793,17 +1793,50 @@ export async function aggregateMultipleSessionStats( affinity_fingerprint, user_agent, api_type - FROM message_request - WHERE - ( - session_identity = sid - OR session_id = sid - ) - AND deleted_at IS NULL - ${ownerCondition} + FROM ( + SELECT + id, + session_id, + session_identity, + user_id, + key, + session_identity_kind, + affinity_fingerprint, + user_agent, + api_type, + created_at, + CASE WHEN session_identity = sid THEN 0 ELSE 1 END AS identity_priority + FROM message_request + WHERE COALESCE(session_identity, session_id) = sid + AND deleted_at IS NULL + ${ownerCondition} + + UNION ALL + + SELECT + id, + session_id, + session_identity, + user_id, + key, + session_identity_kind, + affinity_fingerprint, + user_agent, + api_type, + created_at, + 1 AS identity_priority + FROM message_request + WHERE sid NOT LIKE 'pfx:%' + AND sid NOT LIKE 'sid:%' + AND session_identity IS NOT NULL + AND session_identity <> sid + AND session_id = sid + AND deleted_at IS NULL + ${ownerCondition} + ) candidates ORDER BY - CASE WHEN session_identity = sid THEN 0 ELSE 1 END, - created_at DESC, + identity_priority, + created_at DESC NULLS LAST, id DESC LIMIT 1 ) mr @@ -2349,7 +2382,9 @@ export async function findRequestsBySessionIdentity( .from(messageRequest) .where(where) .orderBy( - order === "asc" ? asc(messageRequest.createdAt) : desc(messageRequest.createdAt), + order === "asc" + ? asc(messageRequest.createdAt) + : sql`${messageRequest.createdAt} DESC NULLS LAST`, order === "asc" ? asc(messageRequest.id) : desc(messageRequest.id) ) .limit(limit) @@ -2478,7 +2513,7 @@ export async function findAdjacentSessionRequests( ) ) ) - .orderBy(desc(messageRequest.createdAt), desc(messageRequest.id)) + .orderBy(sql`${messageRequest.createdAt} DESC NULLS LAST`, desc(messageRequest.id)) .limit(1); const [next] = await db .select(selection) diff --git a/tests/api/v1/users/users.test.ts b/tests/api/v1/users/users.test.ts index 4b80db618..3eab3be4f 100644 --- a/tests/api/v1/users/users.test.ts +++ b/tests/api/v1/users/users.test.ts @@ -17,6 +17,7 @@ const getUserLimitUsageMock = vi.hoisted(() => vi.fn()); const getUserAllLimitUsageMock = vi.hoisted(() => vi.fn()); const resetUserLimitsOnlyMock = vi.hoisted(() => vi.fn()); const resetUserAllStatisticsMock = vi.hoisted(() => vi.fn()); +const findUserStatisticsResetMock = vi.hoisted(() => vi.fn()); const getAllUserTagsMock = vi.hoisted(() => vi.fn()); const getAllUserKeyGroupsMock = vi.hoisted(() => vi.fn()); const searchUsersForFilterMock = vi.hoisted(() => vi.fn()); @@ -51,6 +52,10 @@ vi.mock("@/actions/users", () => ({ batchUpdateUsers: batchUpdateUsersMock, })); +vi.mock("@/lib/user-statistics-reset/reset-queue", () => ({ + findUserStatisticsReset: findUserStatisticsResetMock, +})); + const { callV1Route } = await import("../test-utils"); const adminSession = { @@ -115,7 +120,31 @@ describe("v1 users endpoints", () => { data: { limitDaily: { usage: 1, limit: 10 } }, }); resetUserLimitsOnlyMock.mockResolvedValue({ ok: true }); - resetUserAllStatisticsMock.mockResolvedValue({ ok: true }); + resetUserAllStatisticsMock.mockResolvedValue({ + ok: true, + data: { + resetId: "00000000-0000-4000-8000-000000000001", + userId: 1, + status: "queued", + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: null, + completedAt: null, + deletedMessageRequests: 0, + deletedUsageLedger: 0, + errorCode: null, + }, + }); + findUserStatisticsResetMock.mockResolvedValue({ + resetId: "00000000-0000-4000-8000-000000000001", + userId: 1, + status: "running", + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: "2026-08-02T12:00:01.000Z", + completedAt: null, + deletedMessageRequests: 1000, + deletedUsageLedger: 0, + errorCode: null, + }); getAllUserTagsMock.mockResolvedValue({ ok: true, data: ["team-a"] }); getAllUserKeyGroupsMock.mockResolvedValue({ ok: true, data: ["default"] }); searchUsersForFilterMock.mockResolvedValue({ ok: true, data: [{ id: 1, name: "user-1" }] }); @@ -346,7 +375,35 @@ describe("v1 users endpoints", () => { pathname: "/api/v1/users/1/statistics:reset", headers, }); - expect(resetStats.response.status).toBe(204); + expect(resetStats.response.status).toBe(202); + expect(resetStats.response.headers.get("location")).toBe( + "/api/v1/users/1/statistics-resets/00000000-0000-4000-8000-000000000001" + ); + expect(resetStats.json).toMatchObject({ status: "queued" }); + + const resetStatus = await callV1Route({ + method: "GET", + pathname: "/api/v1/users/1/statistics-resets/00000000-0000-4000-8000-000000000001", + headers, + }); + expect(resetStatus.response.status).toBe(200); + expect(resetStatus.json).toMatchObject({ status: "running", deletedMessageRequests: 1000 }); + }); + + test("uses the standard public detail when a statistics reset is not found", async () => { + findUserStatisticsResetMock.mockResolvedValueOnce(null); + + const result = await callV1Route({ + method: "GET", + pathname: "/api/v1/users/1/statistics-resets/00000000-0000-4000-8000-000000000001", + headers, + }); + + expect(result.response.status).toBe(404); + expect(result.json).toMatchObject({ + errorCode: "user.statistics_reset_not_found", + detail: "Not found", + }); }); test("maps structured authorization action errors to HTTP status codes", async () => { @@ -500,6 +557,7 @@ describe("v1 users endpoints", () => { expect(doc.paths).toHaveProperty("/api/v1/users/{id}/limit-usage:all"); expect(doc.paths).toHaveProperty("/api/v1/users/{id}/limits:reset"); expect(doc.paths).toHaveProperty("/api/v1/users/{id}/statistics:reset"); + expect(doc.paths).toHaveProperty("/api/v1/users/{id}/statistics-resets/{resetId}"); expect(doc.paths).toHaveProperty("/api/v1/users:batchUpdate"); expect(doc.paths).toHaveProperty("/api/v1/users:usageBatch"); expect(doc.paths).toHaveProperty("/api/v1/users:filter-search"); @@ -509,5 +567,9 @@ describe("v1 users endpoints", () => { expect(JSON.stringify(userDetail.get?.responses?.["200"])).toContain("createdAt"); expect(userDetail.get?.responses).toHaveProperty("500"); expect(userDetail.get?.responses).toHaveProperty("503"); + const resetStatistics = doc.paths["/api/v1/users/{id}/statistics:reset"] as { + post?: { responses?: Record }> }; + }; + expect(resetStatistics.post?.responses?.["202"]?.headers).toHaveProperty("Location"); }); }); diff --git a/tests/unit/actions/users-reset-5h-only.test.ts b/tests/unit/actions/users-reset-5h-only.test.ts index e167d7bdf..ac366abc4 100644 --- a/tests/unit/actions/users-reset-5h-only.test.ts +++ b/tests/unit/actions/users-reset-5h-only.test.ts @@ -66,6 +66,11 @@ vi.mock("@/lib/security/api-key-auth-cache", () => ({ invalidateCachedUser: invalidateCachedUserMock, })); +const enqueueUserStatisticsResetMock = vi.fn(); +vi.mock("@/lib/user-statistics-reset/reset-queue", () => ({ + enqueueUserStatisticsReset: enqueueUserStatisticsResetMock, +})); + const txDeleteWhereMock = vi.fn(); const txDeleteMock = vi.fn(() => ({ where: txDeleteWhereMock })); const txUpdateWhereMock = vi.fn(); @@ -286,6 +291,17 @@ describe("full reset compatibility with user 5h marker", () => { updateUserCostResetMarkersMock.mockResolvedValue(true); txUpdateWhereMock.mockResolvedValue([{ id: 123 }]); txDeleteWhereMock.mockResolvedValue([]); + enqueueUserStatisticsResetMock.mockResolvedValue({ + resetId: "00000000-0000-4000-8000-000000000001", + userId: 123, + status: "queued", + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: null, + completedAt: null, + deletedMessageRequests: 0, + deletedUsageLedger: 0, + errorCode: null, + }); }); test("full reset still resets all amount windows and advances 5h marker", async () => { @@ -301,21 +317,19 @@ describe("full reset compatibility with user 5h marker", () => { expect(clearUserCostCacheMock).toHaveBeenCalled(); }); - test("full statistics reset does not leave stale 5h marker", async () => { + test("full statistics reset queues marker cleanup in the background worker", async () => { const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); expect(result.ok).toBe(true); - expect(txUpdateSetMock).toHaveBeenCalledWith( - expect.objectContaining({ - costResetAt: null, - limit5hCostResetAt: null, - }) - ); - expect(invalidateCachedUserMock).toHaveBeenCalledWith(123); + expect(enqueueUserStatisticsResetMock).toHaveBeenCalledWith(123, { + fixed5hKeyIds: [11], + }); + expect(txUpdateSetMock).not.toHaveBeenCalled(); + expect(invalidateCachedUserMock).not.toHaveBeenCalled(); }); - test("full statistics reset fails when fixed 5h state exists but Redis is unavailable", async () => { + test("full statistics reset delegates fixed user 5h readiness to the background queue", async () => { findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User", @@ -328,12 +342,15 @@ describe("full reset compatibility with user 5h marker", () => { const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED); + expect(result.ok).toBe(true); + expect(getRedisClientMock).not.toHaveBeenCalled(); + expect(enqueueUserStatisticsResetMock).toHaveBeenCalledWith(123, { + fixed5hKeyIds: [], + }); expect(txUpdateSetMock).not.toHaveBeenCalled(); }); - test("full statistics reset fails when a child key has fixed 5h state and Redis is unavailable", async () => { + test("full statistics reset delegates fixed child-key 5h readiness to the background queue", async () => { findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User", @@ -353,8 +370,11 @@ describe("full reset compatibility with user 5h marker", () => { const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED); + expect(result.ok).toBe(true); + expect(getRedisClientMock).not.toHaveBeenCalled(); + expect(enqueueUserStatisticsResetMock).toHaveBeenCalledWith(123, { + fixed5hKeyIds: [11], + }); expect(txUpdateSetMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/actions/users-reset-all-statistics.test.ts b/tests/unit/actions/users-reset-all-statistics.test.ts index a6280bf0a..616cca122 100644 --- a/tests/unit/actions/users-reset-all-statistics.test.ts +++ b/tests/unit/actions/users-reset-all-statistics.test.ts @@ -1,266 +1,111 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { ERROR_CODES } from "@/lib/utils/error-messages"; -// Mock getSession -const getSessionMock = vi.fn(); -vi.mock("@/lib/auth", () => ({ - getSession: getSessionMock, +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + findUserById: vi.fn(), + findKeyList: vi.fn(), + getRedisClient: vi.fn(), + enqueue: vi.fn(), })); -// Mock next-intl -const getTranslationsMock = vi.fn(async () => (key: string) => key); +vi.mock("@/lib/auth", () => ({ getSession: mocks.getSession })); vi.mock("next-intl/server", () => ({ - getTranslations: getTranslationsMock, + getTranslations: vi.fn(async () => (key: string) => key), getLocale: vi.fn(async () => "en"), })); - -// Mock next/cache -const revalidatePathMock = vi.fn(); -vi.mock("next/cache", () => ({ - revalidatePath: revalidatePathMock, -})); - -// Mock repository/user -const findUserByIdMock = vi.fn(); -const resetUserCostResetAtMock = vi.fn(); -vi.mock("@/repository/user", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - findUserById: findUserByIdMock, - resetUserCostResetAt: resetUserCostResetAtMock, - }; -}); - -// Mock repository/key -const findKeyListMock = vi.fn(); -vi.mock("@/repository/key", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - findKeyList: findKeyListMock, - }; -}); - -// Mock drizzle db -const txDeleteWhereMock = vi.fn(); -const txDeleteMock = vi.fn(() => ({ where: txDeleteWhereMock })); -const txUpdateSetMock = vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })); -const txUpdateMock = vi.fn(() => ({ set: txUpdateSetMock })); -const txMock = { - delete: txDeleteMock, - update: txUpdateMock, -}; -const dbTransactionMock = vi.fn(async (fn: (tx: typeof txMock) => Promise) => { - await fn(txMock); -}); -vi.mock("@/drizzle/db", () => ({ - db: { - transaction: dbTransactionMock, - }, +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); +vi.mock("@/repository/user", async (importOriginal) => ({ + ...(await importOriginal()), + findUserById: mocks.findUserById, })); - -// Mock logger -const loggerMock = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}; -vi.mock("@/lib/logger", () => ({ - logger: loggerMock, +vi.mock("@/repository/key", async (importOriginal) => ({ + ...(await importOriginal()), + findKeyList: mocks.findKeyList, })); - -// Mock invalidateCachedUser (called directly after transaction) -const invalidateCachedUserMock = vi.fn(); -vi.mock("@/lib/security/api-key-auth-cache", () => ({ - invalidateCachedUser: invalidateCachedUserMock, +vi.mock("@/lib/redis", () => ({ getRedisClient: mocks.getRedisClient })); +vi.mock("@/lib/user-statistics-reset/reset-queue", () => ({ + enqueueUserStatisticsReset: mocks.enqueue, })); -// Mock Redis -const redisPipelineMock = { - del: vi.fn().mockReturnThis(), - exec: vi.fn(), +const queuedReset = { + resetId: "00000000-0000-4000-8000-000000000001", + userId: 123, + status: "queued" as const, + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: null, + completedAt: null, + deletedMessageRequests: 0, + deletedUsageLedger: 0, + errorCode: null, }; -const redisMock = { - status: "ready", - pipeline: vi.fn(() => redisPipelineMock), -}; -const getRedisClientMock = vi.fn(() => redisMock); -vi.mock("@/lib/redis", () => ({ - getRedisClient: getRedisClientMock, -})); - -// Mock scanPattern -const scanPatternMock = vi.fn(); -vi.mock("@/lib/redis/scan-helper", () => ({ - scanPattern: scanPatternMock, -})); describe("resetUserAllStatistics", () => { beforeEach(() => { vi.clearAllMocks(); - // Reset redis mock to ready state - redisMock.status = "ready"; - redisPipelineMock.exec.mockResolvedValue([]); - // DB delete returns resolved promise - txDeleteWhereMock.mockResolvedValue(undefined); - resetUserCostResetAtMock.mockResolvedValue(true); - invalidateCachedUserMock.mockResolvedValue(undefined); + mocks.getSession.mockResolvedValue({ user: { id: 1, role: "admin" } }); + mocks.findUserById.mockResolvedValue({ + id: 123, + limit5hUsd: null, + limit5hResetMode: "rolling", + }); + mocks.findKeyList.mockResolvedValue([]); + mocks.getRedisClient.mockReturnValue({ status: "ready" }); + mocks.enqueue.mockResolvedValue(queuedReset); }); - test("should return PERMISSION_DENIED for non-admin user", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "user" } }); - + test("returns PERMISSION_DENIED for non-admin users", async () => { + mocks.getSession.mockResolvedValue({ user: { id: 1, role: "user" } }); const { resetUserAllStatistics } = await import("@/actions/users"); - const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); - expect(findUserByIdMock).not.toHaveBeenCalled(); - }); - - test("should return PERMISSION_DENIED when no session", async () => { - getSessionMock.mockResolvedValue(null); - - const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.PERMISSION_DENIED); + expect(result).toMatchObject({ ok: false, errorCode: ERROR_CODES.PERMISSION_DENIED }); + expect(mocks.enqueue).not.toHaveBeenCalled(); }); - test("should return NOT_FOUND for non-existent user", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue(null); - + test("returns NOT_FOUND for a missing user", async () => { + mocks.findUserById.mockResolvedValue(null); const { resetUserAllStatistics } = await import("@/actions/users"); - const result = await resetUserAllStatistics(999); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.NOT_FOUND); - expect(dbTransactionMock).not.toHaveBeenCalled(); - }); - - test("should successfully reset all user statistics", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); - findKeyListMock.mockResolvedValue([{ id: 1 }, { id: 2 }]); - scanPatternMock.mockResolvedValue(["key:1:cost_daily", "key:2:cost_weekly"]); - redisPipelineMock.exec.mockResolvedValue([]); - - const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(true); - // DB transaction called (delete + update wrapped in transaction) - expect(dbTransactionMock).toHaveBeenCalled(); - expect(txDeleteMock).toHaveBeenCalled(); - expect(txDeleteWhereMock).toHaveBeenCalled(); - // Redis operations - expect(redisMock.pipeline).toHaveBeenCalled(); - expect(redisPipelineMock.del).toHaveBeenCalled(); - expect(redisPipelineMock.exec).toHaveBeenCalled(); - // Revalidate path - expect(revalidatePathMock).toHaveBeenCalledWith("/dashboard/users"); - // Logging - expect(loggerMock.info).toHaveBeenCalled(); + expect(result).toMatchObject({ ok: false, errorCode: ERROR_CODES.NOT_FOUND }); }); - test("should return partial failure when Redis is not ready after DB reset", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); - findKeyListMock.mockResolvedValue([{ id: 1 }]); - redisMock.status = "connecting"; - + test("delegates cold Redis readiness handling to the reset queue", async () => { + mocks.findUserById.mockResolvedValue({ + id: 123, + limit5hUsd: 10, + limit5hResetMode: "fixed", + }); + mocks.getRedisClient.mockReturnValue({ status: "connecting" }); const { resetUserAllStatistics } = await import("@/actions/users"); - const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.USER_STATS_RESET_PARTIAL_FAILURE); - expect(dbTransactionMock).toHaveBeenCalled(); - expect(redisMock.pipeline).not.toHaveBeenCalled(); - }); - - test("should return partial failure when Redis has partial failures", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); - findKeyListMock.mockResolvedValue([{ id: 1 }]); - scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); - // Simulate partial failure - some commands return errors - redisPipelineMock.exec.mockResolvedValue([ - [null, 1], // success - [new Error("Connection reset"), null], // failure - ]); - - const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.USER_STATS_RESET_PARTIAL_FAILURE); - expect(loggerMock.warn).toHaveBeenCalledWith( - "Some Redis deletes failed during cost cache cleanup", - expect.objectContaining({ errorCount: 1, userId: 123 }) - ); + expect(result).toEqual({ ok: true, data: queuedReset }); + expect(mocks.getRedisClient).not.toHaveBeenCalled(); + expect(mocks.enqueue).toHaveBeenCalledWith(123, { fixed5hKeyIds: [] }); }); - test("should succeed with warning when scanPattern fails", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); - findKeyListMock.mockResolvedValue([{ id: 1 }]); - // scanPattern fails but is caught by .catch() in Promise.all - scanPatternMock.mockRejectedValue(new Error("Redis connection lost")); - redisPipelineMock.exec.mockResolvedValue([]); - + test("queues the reset and returns its durable status", async () => { const { resetUserAllStatistics } = await import("@/actions/users"); - const result = await resetUserAllStatistics(123); - - // Should still succeed - error is caught inside Promise.all - expect(result.ok).toBe(true); - expect(loggerMock.warn).toHaveBeenCalled(); - }); - test("should return partial failure when pipeline.exec throws", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); - findKeyListMock.mockResolvedValue([{ id: 1 }]); - scanPatternMock.mockResolvedValue(["key:1:cost_daily"]); - // pipeline.exec throws - caught inside clearUserCostCache (never-throws contract) - redisPipelineMock.exec.mockRejectedValue(new Error("Pipeline failed")); - - const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.USER_STATS_RESET_PARTIAL_FAILURE); - expect(loggerMock.warn).toHaveBeenCalledWith( - "Redis pipeline.exec() failed during cost cache cleanup", - expect.objectContaining({ userId: 123 }) - ); + expect(result).toEqual({ ok: true, data: queuedReset }); + expect(mocks.enqueue).toHaveBeenCalledWith(123, { + fixed5hKeyIds: [], + }); }); - test("should return OPERATION_FAILED on unexpected error", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockRejectedValue(new Error("Database connection failed")); - + test("maps queue failures to a retryable dependency error", async () => { + mocks.enqueue.mockRejectedValue(new Error("redis unavailable")); const { resetUserAllStatistics } = await import("@/actions/users"); - const result = await resetUserAllStatistics(123); - - expect(result.ok).toBe(false); - expect(result.errorCode).toBe(ERROR_CODES.OPERATION_FAILED); - expect(loggerMock.error).toHaveBeenCalled(); - }); - test("should handle user with no keys", async () => { - getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); - findUserByIdMock.mockResolvedValue({ id: 123, name: "Test User" }); - findKeyListMock.mockResolvedValue([]); // No keys - scanPatternMock.mockResolvedValue([]); - redisPipelineMock.exec.mockResolvedValue([]); - - const { resetUserAllStatistics } = await import("@/actions/users"); const result = await resetUserAllStatistics(123); - expect(result.ok).toBe(true); - expect(dbTransactionMock).toHaveBeenCalled(); + expect(result).toMatchObject({ ok: false, errorCode: ERROR_CODES.CONNECTION_FAILED }); }); }); diff --git a/tests/unit/api/v1/api-client-actions.test.ts b/tests/unit/api/v1/api-client-actions.test.ts index 4bad78fa2..36307abee 100644 --- a/tests/unit/api/v1/api-client-actions.test.ts +++ b/tests/unit/api/v1/api-client-actions.test.ts @@ -68,6 +68,38 @@ describe("v1 action compatibility client", () => { ); }); + test("passes an AbortSignal to the all-sessions request", async () => { + getMock.mockResolvedValue({ active: [], inactive: [] }); + const controller = new AbortController(); + + await activeSessions.getAllSessions(2, 3, 20, { signal: controller.signal }); + + expect(getMock).toHaveBeenCalledWith( + "/api/v1/sessions?state=all&activePage=2&inactivePage=3&pageSize=20", + { signal: controller.signal } + ); + }); + + test("marks statistics reset polling transport failures as retryable network errors", async () => { + getMock.mockRejectedValue(new TypeError("Failed to fetch")); + + await expect(users.getUserStatisticsReset(42, "reset-id")).resolves.toMatchObject({ + ok: false, + errorCode: "NETWORK_ERROR", + }); + }); + + test("passes an AbortSignal to statistics reset polling", async () => { + getMock.mockResolvedValue({ status: "running" }); + const controller = new AbortController(); + + await users.getUserStatisticsReset(42, "reset-id", { signal: controller.signal }); + + expect(getMock).toHaveBeenCalledWith("/api/v1/users/42/statistics-resets/reset-id", { + signal: controller.signal, + }); + }); + test("preserves the physical request locator for every Session payload endpoint", async () => { getMock.mockResolvedValue({ exists: true, response: "ok" }); diff --git a/tests/unit/drizzle/database-timeout-migration.test.ts b/tests/unit/drizzle/database-timeout-migration.test.ts new file mode 100644 index 000000000..1d9716e42 --- /dev/null +++ b/tests/unit/drizzle/database-timeout-migration.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "vitest"; + +const migration = readFileSync( + resolve(process.cwd(), "drizzle/0118_bright_sunspot.sql"), + "utf-8" +); + +describe("0118 database timeout indexes", () => { + test.each([ + "idx_message_request_session_identity_created_at", + "idx_usage_ledger_session_identity_created_at", + "idx_message_request_proxy_status_active", + "idx_message_request_proxy_status_latest", + "idx_usage_ledger_user_id_reset", + ])("keeps %s idempotent for fresh databases", (indexName) => { + expect(migration).toContain(`CREATE INDEX IF NOT EXISTS "${indexName}"`); + }); + + test("does not drop or rebuild large indexes transactionally", () => { + expect(migration).not.toMatch(/DROP\s+INDEX/i); + expect(migration).not.toMatch(/CREATE\s+INDEX\s+(?!IF\s+NOT\s+EXISTS)/i); + }); + + test("records the approved Session and Proxy Status index shapes", () => { + expect(migration).toContain( + 'COALESCE("session_identity", "session_id"),"created_at" DESC NULLS LAST,"id" DESC NULLS LAST' + ); + expect(migration).toContain( + 'COALESCE("session_identity", "session_id"),"user_id","created_at" DESC NULLS LAST' + ); + expect(migration).toContain('"created_at" DESC NULLS LAST,"user_id"'); + expect(migration).toContain( + '"user_id","updated_at" DESC NULLS LAST,"id" DESC NULLS LAST' + ); + expect(migration).toContain( + 'CREATE INDEX IF NOT EXISTS "idx_usage_ledger_user_id_reset" ON "usage_ledger" USING btree ("user_id")' + ); + }); +}); diff --git a/tests/unit/drizzle/proxy-status-indexes.test.ts b/tests/unit/drizzle/proxy-status-indexes.test.ts new file mode 100644 index 000000000..4fb75d21e --- /dev/null +++ b/tests/unit/drizzle/proxy-status-indexes.test.ts @@ -0,0 +1,37 @@ +import type { SQL } from "drizzle-orm"; +import { getTableConfig, PgDialect } from "drizzle-orm/pg-core"; +import { describe, expect, it } from "vitest"; +import { messageRequest } from "@/drizzle/schema"; + +const dialect = new PgDialect(); +const compile = (value: unknown) => dialect.sqlToQuery(value as SQL).sql.toLowerCase(); + +describe("Proxy Status indexes", () => { + it("indexes bounded active requests", () => { + const index = getTableConfig(messageRequest).indexes.find( + (entry) => entry.config.name === "idx_message_request_proxy_status_active" + ); + expect(index).toBeDefined(); + expect(compile(index?.config.columns[0])).toContain("created_at"); + expect(compile(index?.config.columns[0])).toContain("desc nulls last"); + expect(index?.config.columns[1]).toMatchObject({ name: "user_id" }); + const predicate = compile(index?.config.where); + expect(predicate).toContain('"status_code" is null'); + expect(predicate).toContain('"is_replay" = false'); + expect(predicate).toContain("warmup"); + }); + + it("indexes the deterministic latest finalized request per user", () => { + const index = getTableConfig(messageRequest).indexes.find( + (entry) => entry.config.name === "idx_message_request_proxy_status_latest" + ); + expect(index).toBeDefined(); + expect(index?.config.columns[0]).toMatchObject({ name: "user_id" }); + expect(compile(index?.config.columns[1])).toContain("updated_at"); + expect(compile(index?.config.columns[1])).toContain("desc nulls last"); + expect(index?.config.columns[2]).toMatchObject({ name: "id", indexConfig: { order: "desc" } }); + const predicate = compile(index?.config.where); + expect(predicate).toContain('"status_code" is not null'); + expect(predicate).toContain('"is_replay" = false'); + }); +}); diff --git a/tests/unit/drizzle/session-identity-indexes.test.ts b/tests/unit/drizzle/session-identity-indexes.test.ts index 5d5841191..e3719d872 100644 --- a/tests/unit/drizzle/session-identity-indexes.test.ts +++ b/tests/unit/drizzle/session-identity-indexes.test.ts @@ -34,6 +34,33 @@ describe("Session identity query indexes", () => { expect(compileSql(index?.config.where as SQL)).toContain(predicate); }); + test("message_request identity index supports deterministic latest-row lookup", () => { + const index = getTableConfig(messageRequest).indexes.find( + (entry) => entry.config.name === "idx_message_request_session_identity_created_at" + ); + expect(index).toBeDefined(); + expect(index?.config.columns).toHaveLength(3); + const createdAt = compileSql(index?.config.columns[1] as SQL); + expect(createdAt).toContain("created_at"); + expect(createdAt).toContain("desc nulls last"); + expect(index?.config.columns[2]).toMatchObject({ + name: "id", + indexConfig: { order: "desc" }, + }); + }); + + test("usage_ledger identity index includes owner before creation time", () => { + const index = getTableConfig(usageLedger).indexes.find( + (entry) => entry.config.name === "idx_usage_ledger_session_identity_created_at" + ); + expect(index).toBeDefined(); + expect(index?.config.columns).toHaveLength(3); + expect(index?.config.columns[1]).toMatchObject({ name: "user_id" }); + const createdAt = compileSql(index?.config.columns[2] as SQL); + expect(createdAt).toContain("created_at"); + expect(createdAt).toContain("desc nulls last"); + }); + test("usage_ledger has an unfiltered identity index for grouped source-ID hydration", () => { const index = getTableConfig(usageLedger).indexes.find( (entry) => entry.config.name === "idx_usage_ledger_session_identity" diff --git a/tests/unit/frontend/api-error-i18n.test.ts b/tests/unit/frontend/api-error-i18n.test.ts index b3349d27c..bb92af8f1 100644 --- a/tests/unit/frontend/api-error-i18n.test.ts +++ b/tests/unit/frontend/api-error-i18n.test.ts @@ -42,6 +42,16 @@ describe("v1 API error i18n mapping", () => { expect(getApiErrorMessageKey(error)).toBe("INTERNAL_ERROR"); }); + test("maps dependency outages to a retryable connection failure", () => { + const error = new ApiError({ + status: 503, + errorCode: "dependency.unavailable", + detail: "Service unavailable", + }); + + expect(getApiErrorMessageKey(error)).toBe("CONNECTION_FAILED"); + }); + test("maps provider endpoint and vendor REST codes to existing translation keys", () => { expect( getApiErrorMessageKey( diff --git a/tests/unit/instrumentation-replay-cleanup.test.ts b/tests/unit/instrumentation-replay-cleanup.test.ts new file mode 100644 index 000000000..54bd81e77 --- /dev/null +++ b/tests/unit/instrumentation-replay-cleanup.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const cleanupControl = vi.hoisted(() => ({ + runReplayCleanupTick: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + }, +})); + +vi.mock("@/lib/replay-cleanup", () => ({ + runReplayCleanupTick: cleanupControl.runReplayCleanupTick, +})); + +import { startReplayCleanupScheduler } from "@/instrumentation"; + +describe("startReplayCleanupScheduler", () => { + beforeEach(() => { + vi.useFakeTimers(); + cleanupControl.runReplayCleanupTick.mockReset().mockResolvedValue({ + status: "completed", + batches: 1, + deleted: 0, + }); + const state = globalThis as typeof globalThis & { + __CCH_REPLAY_CLEANUP_STARTED__?: boolean; + __CCH_REPLAY_CLEANUP_INTERVAL_ID__?: ReturnType; + }; + state.__CCH_REPLAY_CLEANUP_STARTED__ = false; + state.__CCH_REPLAY_CLEANUP_INTERVAL_ID__ = undefined; + }); + + afterEach(() => { + const state = globalThis as typeof globalThis & { + __CCH_REPLAY_CLEANUP_INTERVAL_ID__?: ReturnType; + }; + if (state.__CCH_REPLAY_CLEANUP_INTERVAL_ID__) { + clearInterval(state.__CCH_REPLAY_CLEANUP_INTERVAL_ID__); + } + vi.useRealTimers(); + }); + + it("starts cleanup immediately and every ten minutes regardless of Replay enablement", async () => { + const setIntervalSpy = vi.spyOn(globalThis, "setInterval"); + await startReplayCleanupScheduler(); + await vi.runOnlyPendingTimersAsync(); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10 * 60 * 1000); + expect(cleanupControl.runReplayCleanupTick).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/lib/proxy-status-tracker.test.ts b/tests/unit/lib/proxy-status-tracker.test.ts index 911d1576a..606526e59 100644 --- a/tests/unit/lib/proxy-status-tracker.test.ts +++ b/tests/unit/lib/proxy-status-tracker.test.ts @@ -39,4 +39,69 @@ describe("ProxyStatusTracker", () => { expect(sqlText(activeQuery.trace.where[0])).toContain("is_replay = false"); expect(sqlText(boundary.execute.mock.calls[0]?.[0])).toContain("mr.is_replay = false"); }); + + it("uses a bounded active window and status_code as the active marker", async () => { + const usersQuery = createDrizzleQuery([]); + const activeQuery = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(usersQuery).mockReturnValueOnce(activeQuery); + boundary.execute.mockResolvedValueOnce([]); + + const { ProxyStatusTracker } = await import("@/lib/proxy-status-tracker"); + await ProxyStatusTracker.getInstance().getAllUsersStatus(); + + const activeWhere = sqlText(activeQuery.trace.where[0]); + expect(activeWhere).toContain("status_code is null"); + expect(activeWhere).toContain("created_at >= now() - interval '24 hours'"); + expect(activeWhere).toContain("blocked_by"); + expect(activeWhere).toContain("warmup"); + }); + + it("selects only finalized latest requests with a deterministic tie-break", async () => { + const usersQuery = createDrizzleQuery([]); + const activeQuery = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(usersQuery).mockReturnValueOnce(activeQuery); + boundary.execute.mockResolvedValueOnce([]); + + const { ProxyStatusTracker } = await import("@/lib/proxy-status-tracker"); + await ProxyStatusTracker.getInstance().getAllUsersStatus(); + + const latestSql = sqlText(boundary.execute.mock.calls[0]?.[0]); + expect(latestSql).toContain("mr.status_code is not null"); + expect(latestSql).toContain("order by mr.user_id, mr.updated_at desc nulls last, mr.id desc"); + }); + + it("coalesces concurrent calls and caches the response for two seconds", async () => { + const usersQuery = createDrizzleQuery([]); + const activeQuery = createDrizzleQuery([]); + let resolveLatest: ((rows: readonly unknown[]) => void) | undefined; + boundary.select.mockReturnValueOnce(usersQuery).mockReturnValueOnce(activeQuery); + boundary.execute.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveLatest = resolve; + }) + ); + + const { ProxyStatusTracker } = await import("@/lib/proxy-status-tracker"); + const tracker = ProxyStatusTracker.getInstance(); + const first = tracker.getAllUsersStatus(); + const second = tracker.getAllUsersStatus(); + expect(first).toBe(second); + resolveLatest?.([]); + await Promise.all([first, second]); + expect(boundary.select).toHaveBeenCalledTimes(2); + expect(boundary.execute).toHaveBeenCalledTimes(1); + + await tracker.getAllUsersStatus(); + expect(boundary.select).toHaveBeenCalledTimes(2); + + vi.spyOn(Date, "now").mockReturnValue(Date.now() + 2001); + boundary.select + .mockReturnValueOnce(createDrizzleQuery([])) + .mockReturnValueOnce(createDrizzleQuery([])); + boundary.execute.mockResolvedValueOnce([]); + await tracker.getAllUsersStatus(); + expect(boundary.select).toHaveBeenCalledTimes(4); + vi.restoreAllMocks(); + }); }); diff --git a/tests/unit/lib/redis/cost-cache-cleanup.test.ts b/tests/unit/lib/redis/cost-cache-cleanup.test.ts index 3fef0f4c7..2e9663a1e 100644 --- a/tests/unit/lib/redis/cost-cache-cleanup.test.ts +++ b/tests/unit/lib/redis/cost-cache-cleanup.test.ts @@ -18,6 +18,7 @@ const redisPipelineMock = { const redisMock = { status: "ready" as string, pipeline: vi.fn(() => redisPipelineMock), + eval: vi.fn(), }; const getRedisClientMock = vi.fn(() => redisMock); vi.mock("@/lib/redis", () => ({ @@ -43,6 +44,7 @@ describe("clearUserCostCache", () => { getRedisClientMock.mockReturnValue(redisMock); redisMock.status = "ready"; redisMock.pipeline.mockReturnValue(redisPipelineMock); + redisMock.eval.mockResolvedValue(1); redisPipelineMock.del.mockReturnThis(); redisPipelineMock.exec.mockResolvedValue([]); scanPatternMock.mockResolvedValue([]); @@ -105,6 +107,68 @@ describe("clearUserCostCache", () => { expect(redisPipelineMock.exec).toHaveBeenCalled(); }); + test("preserves fixed 5h cost windows while invalidating other cost and lease keys", async () => { + scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { + if (pattern === "key:1:cost_*") { + return ["key:1:cost_5h_fixed", "key:1:cost_daily_rolling"]; + } + if (pattern === "user:10:cost_*") return ["user:10:cost_5h_fixed"]; + if (pattern === "lease:key:1:*") return ["lease:key:1:5h:fixed"]; + return []; + }); + redisPipelineMock.exec.mockResolvedValue([ + [null, 1], + [null, 1], + ]); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + preserveFixed5hCostKeys: true, + }); + + expect(result?.costKeysDeleted).toBe(2); + expect(redisPipelineMock.del).not.toHaveBeenCalledWith("key:1:cost_5h_fixed"); + expect(redisPipelineMock.del).not.toHaveBeenCalledWith("user:10:cost_5h_fixed"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("key:1:cost_daily_rolling"); + expect(redisPipelineMock.del).toHaveBeenCalledWith("lease:key:1:5h:fixed"); + }); + + test("prepares fixed 5h reset atomically and idempotently by reset id", async () => { + redisMock.eval.mockResolvedValue(1_775_304_000_123); + const { prepareUserStatisticsResetFixed5h } = await import("@/lib/redis/cost-cache-cleanup"); + + await expect( + prepareUserStatisticsResetFixed5h({ resetId: "reset-1", userId: 10, keyIds: [1, 2] }) + ).resolves.toBe("2026-04-04T12:00:00.123Z"); + + expect(redisMock.eval).toHaveBeenCalledWith( + expect.stringMatching(/EXISTS[\s\S]*TIME[\s\S]*SETEX/), + 7, + "cch:user-statistics-reset:fixed5h:reset-1", + "user:10:cost_5h_fixed", + "lease:user:10:5h:fixed", + "key:1:cost_5h_fixed", + "lease:key:1:5h:fixed", + "key:2:cost_5h_fixed", + "lease:key:2:5h:fixed", + 604_800 + ); + }); + + test("rejects invalid fixed 5h cutoff values returned by Redis", async () => { + const { prepareUserStatisticsResetFixed5h } = await import("@/lib/redis/cost-cache-cleanup"); + + for (const invalid of [null, undefined, "", 0, "not-a-timestamp", []]) { + redisMock.eval.mockResolvedValueOnce(invalid); + await expect( + prepareUserStatisticsResetFixed5h({ resetId: "reset-1", userId: 10, keyIds: [] }) + ).resolves.toBeNull(); + } + }); + test("returns metrics (costKeysDeleted, activeSessionsDeleted, durationMs)", async () => { scanPatternMock.mockImplementation(async (_redis: unknown, pattern: string) => { if (pattern === "key:1:cost_*") return ["key:1:cost_daily"]; @@ -159,6 +223,22 @@ describe("clearUserCostCache", () => { expect(result).toBeNull(); }); + test("uses maintenance Redis and reports scan failures", async () => { + scanPatternMock.mockRejectedValue(new Error("scan failed")); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [], + keyHashes: [], + allowWhenRateLimitDisabled: true, + }); + + expect(getRedisClientMock).toHaveBeenCalledWith({ allowWhenRateLimitDisabled: true }); + expect(result).toMatchObject({ cleanupFailed: true, errorCount: 4 }); + expect(redisMock.pipeline).not.toHaveBeenCalled(); + }); + test("includeActiveSessions=true adds session key DELs", async () => { scanPatternMock.mockResolvedValue([]); @@ -243,6 +323,29 @@ describe("clearUserCostCache", () => { ); }); + test("pipeline exceptions include scan failures in the returned error count", async () => { + let scanCall = 0; + scanPatternMock.mockImplementation(async () => { + scanCall += 1; + if (scanCall === 1) throw new Error("scan failed"); + if (scanCall === 2) return ["key:1:cost_daily"]; + return []; + }); + redisPipelineMock.exec.mockRejectedValue(new Error("Connection reset")); + + const { clearUserCostCache } = await import("@/lib/redis/cost-cache-cleanup"); + const result = await clearUserCostCache({ + userId: 10, + keyIds: [1], + keyHashes: [], + }); + + expect(result).toMatchObject({ + cleanupFailed: true, + errorCount: 2, + }); + }); + test("no keys (empty keyIds/keyHashes) -- only user patterns scanned", async () => { scanPatternMock.mockResolvedValue([]); diff --git a/tests/unit/lib/replay-cleanup.test.ts b/tests/unit/lib/replay-cleanup.test.ts new file mode 100644 index 000000000..b40c1147e --- /dev/null +++ b/tests/unit/lib/replay-cleanup.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const cleanupControl = vi.hoisted(() => ({ + cleanupExpired: vi.fn(), + withAdvisoryLock: vi.fn(), +})); + +vi.mock("@/app/v1/_lib/proxy/replay/replay-store", () => ({ + REPLAY_CLEANUP_BATCH_SIZE: 100, + getReplayStore: () => ({ cleanupExpired: cleanupControl.cleanupExpired }), +})); + +vi.mock("@/lib/migrate", () => ({ + withAdvisoryLock: cleanupControl.withAdvisoryLock, +})); + +import { runReplayCleanupTick } from "@/lib/replay-cleanup"; + +describe("runReplayCleanupTick", () => { + beforeEach(() => { + cleanupControl.cleanupExpired.mockReset(); + cleanupControl.withAdvisoryLock.mockReset(); + cleanupControl.withAdvisoryLock.mockImplementation( + async (_lockName: string, callback: () => Promise) => ({ + ran: true, + result: await callback(), + }) + ); + }); + + it("holds one advisory lock while deleting at most five batches", async () => { + cleanupControl.cleanupExpired.mockResolvedValue(100); + + await expect(runReplayCleanupTick()).resolves.toEqual({ + status: "completed", + batches: 5, + deleted: 500, + }); + + expect(cleanupControl.withAdvisoryLock).toHaveBeenCalledWith( + "claude-code-hub:replay-cleanup", + expect.any(Function), + { skipIfLocked: true } + ); + expect(cleanupControl.cleanupExpired).toHaveBeenCalledTimes(5); + const cutoffs = cleanupControl.cleanupExpired.mock.calls.map(([cutoff]) => cutoff); + expect(cutoffs.every((cutoff) => cutoff === cutoffs[0])).toBe(true); + }); + + it("stops after a partial batch", async () => { + cleanupControl.cleanupExpired.mockResolvedValue(42); + + await expect(runReplayCleanupTick()).resolves.toEqual({ + status: "completed", + batches: 1, + deleted: 42, + }); + expect(cleanupControl.cleanupExpired).toHaveBeenCalledTimes(1); + }); + + it("skips database work when another Pod holds the advisory lock", async () => { + cleanupControl.withAdvisoryLock.mockResolvedValue({ ran: false }); + + await expect(runReplayCleanupTick()).resolves.toEqual({ + status: "skipped_locked", + batches: 0, + deleted: 0, + }); + expect(cleanupControl.cleanupExpired).not.toHaveBeenCalled(); + }); + + it("stops before starting another batch after the 30 second budget", async () => { + cleanupControl.cleanupExpired.mockResolvedValue(100); + const now = vi.spyOn(Date, "now"); + now.mockReturnValueOnce(0).mockReturnValueOnce(0).mockReturnValue(30_000); + + await expect(runReplayCleanupTick()).resolves.toEqual({ + status: "completed", + batches: 1, + deleted: 100, + }); + now.mockRestore(); + }); + + it("skips reentrant ticks in the same process", async () => { + let releaseBatch: ((deleted: number) => void) | undefined; + cleanupControl.cleanupExpired.mockImplementation( + () => + new Promise((resolve) => { + releaseBatch = resolve; + }) + ); + + const first = runReplayCleanupTick(); + await expect(runReplayCleanupTick()).resolves.toEqual({ + status: "skipped_running", + batches: 0, + deleted: 0, + }); + + releaseBatch?.(0); + await expect(first).resolves.toEqual({ status: "completed", batches: 1, deleted: 0 }); + }); + + it("releases the in-process guard after a failed tick", async () => { + cleanupControl.cleanupExpired.mockRejectedValueOnce(new Error("database unavailable")); + await expect(runReplayCleanupTick()).rejects.toThrow("database unavailable"); + + cleanupControl.cleanupExpired.mockResolvedValueOnce(0); + await expect(runReplayCleanupTick()).resolves.toEqual({ + status: "completed", + batches: 1, + deleted: 0, + }); + }); +}); diff --git a/tests/unit/lib/session-replay-index-preflight.test.ts b/tests/unit/lib/session-replay-index-preflight.test.ts index 9e24e02be..d71eadaca 100644 --- a/tests/unit/lib/session-replay-index-preflight.test.ts +++ b/tests/unit/lib/session-replay-index-preflight.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { + DATABASE_TIMEOUT_INDEX_MARKER, + DATABASE_TIMEOUT_INDEX_MIGRATION_CREATED_AT, SESSION_REPLAY_INDEX_MARKER, SESSION_REPLAY_INDEX_SPECS, SESSION_REPLAY_MIGRATION_CREATED_AT, @@ -17,21 +19,22 @@ function createFakeExecutor(initial: Record = {}) { return; } - const commentName = sql.match(/^COMMENT ON INDEX "([^"]+)"/)?.[1]; - if (commentName) { + const comment = sql.match(/^COMMENT ON INDEX (?:"public"\.)?"([^"]+)" IS '([^']+)'/)?.slice(1); + if (comment) { + const [commentName, marker] = comment; const state = states.get(commentName); if (!state) throw new Error(`missing index ${commentName}`); - states.set(commentName, { ...state, marker: SESSION_REPLAY_INDEX_MARKER }); + states.set(commentName, { ...state, marker }); return; } - const dropName = sql.match(/^DROP INDEX CONCURRENTLY IF EXISTS "([^"]+)"/)?.[1]; + const dropName = sql.match(/^DROP INDEX CONCURRENTLY IF EXISTS (?:"public"\.)?"([^"]+)"/)?.[1]; if (dropName) { states.delete(dropName); return; } - const rename = sql.match(/^ALTER INDEX "([^"]+)" RENAME TO "([^"]+)"/)?.slice(1); + const rename = sql.match(/^ALTER INDEX (?:"public"\.)?"([^"]+)" RENAME TO "([^"]+)"/)?.slice(1); if (rename) { const [from, to] = rename; const state = states.get(from); @@ -46,7 +49,7 @@ function createFakeExecutor(initial: Record = {}) { return { executor: { execute, inspectIndex }, execute, inspectIndex, states }; } -describe("0116 concurrent index preflight", () => { +describe("database index concurrent preflight", () => { const spec = SESSION_REPLAY_INDEX_SPECS[0]; const hydrationSpec = SESSION_REPLAY_INDEX_SPECS.find( (candidate) => candidate.canonicalName === "idx_usage_ledger_session_identity" @@ -76,7 +79,7 @@ describe("0116 concurrent index preflight", () => { expect(states.get(spec.canonicalName)).toEqual({ exists: true, valid: true, - marker: SESSION_REPLAY_INDEX_MARKER, + marker: spec.marker, }); expect(states.has(spec.temporaryName)).toBe(false); @@ -85,10 +88,10 @@ describe("0116 concurrent index preflight", () => { statement.startsWith("CREATE INDEX CONCURRENTLY") ); const dropAt = sql.findIndex((statement) => - statement.includes(`DROP INDEX CONCURRENTLY IF EXISTS "${spec.canonicalName}"`) + statement.includes(`DROP INDEX CONCURRENTLY IF EXISTS "public"."${spec.canonicalName}"`) ); const renameAt = sql.findIndex((statement) => - statement.includes(`ALTER INDEX "${spec.temporaryName}" RENAME TO`) + statement.includes(`ALTER INDEX "public"."${spec.temporaryName}" RENAME TO`) ); expect(createAt).toBeGreaterThanOrEqual(0); expect(dropAt).toBeGreaterThan(createAt); @@ -100,13 +103,13 @@ describe("0116 concurrent index preflight", () => { [spec.temporaryName]: { exists: true, valid: true, - marker: SESSION_REPLAY_INDEX_MARKER, + marker: spec.marker, }, }); await runSessionReplayIndexPreflight(executor, [spec]); - expect(states.get(spec.canonicalName)?.marker).toBe(SESSION_REPLAY_INDEX_MARKER); + expect(states.get(spec.canonicalName)?.marker).toBe(spec.marker); expect(execute.mock.calls.flat().some((sql) => sql.startsWith("CREATE INDEX"))).toBe(false); }); @@ -141,14 +144,14 @@ describe("0116 concurrent index preflight", () => { [spec.canonicalName]: { exists: true, valid: true, - marker: SESSION_REPLAY_INDEX_MARKER, + marker: spec.marker, }, [spec.temporaryName]: { exists: true, valid: false, marker: null }, }); await runSessionReplayIndexPreflight(executor, [spec]); - expect(states.get(spec.canonicalName)?.marker).toBe(SESSION_REPLAY_INDEX_MARKER); + expect(states.get(spec.canonicalName)?.marker).toBe(spec.marker); expect(states.has(spec.temporaryName)).toBe(false); expect( execute.mock.calls @@ -162,7 +165,7 @@ describe("0116 concurrent index preflight", () => { [spec.canonicalName]: { exists: true, valid: true, - marker: SESSION_REPLAY_INDEX_MARKER, + marker: spec.marker, }, }); @@ -172,9 +175,71 @@ describe("0116 concurrent index preflight", () => { expect(statements.some((statement) => statement.includes("ALTER TABLE"))).toBe(false); expect(statements.some((statement) => statement.includes("lock_timeout"))).toBe(false); }); + + test("adds pre-0116 Replay columns before building timeout indexes", async () => { + const { executor, execute } = createFakeExecutor(); + + await runSessionReplayIndexPreflight(executor, [spec]); + + const statements = execute.mock.calls.map(([statement]) => statement); + const ensureColumns = statements.find((statement) => statement.includes("ALTER TABLE")); + const createIndexAt = statements.findIndex((statement) => + statement.startsWith("CREATE INDEX CONCURRENTLY") + ); + const ensureColumnsAt = ensureColumns ? statements.indexOf(ensureColumns) : -1; + + expect(ensureColumns).toContain('ALTER TABLE "message_request"'); + expect(ensureColumns).toContain( + 'ADD COLUMN IF NOT EXISTS "is_replay" boolean DEFAULT false NOT NULL' + ); + expect(ensureColumnsAt).toBeGreaterThanOrEqual(0); + expect(createIndexAt).toBeGreaterThan(ensureColumnsAt); + }); + + test("keeps validated v1 indexes while upgrading only the v2 timeout indexes", async () => { + const legacySpec = SESSION_REPLAY_INDEX_SPECS.find( + (candidate) => candidate.marker === SESSION_REPLAY_INDEX_MARKER + ); + if (!legacySpec) throw new Error("missing v1 index spec"); + const { executor, execute } = createFakeExecutor({ + [legacySpec.canonicalName]: { + exists: true, + valid: true, + marker: SESSION_REPLAY_INDEX_MARKER, + }, + [spec.canonicalName]: { + exists: true, + valid: true, + marker: SESSION_REPLAY_INDEX_MARKER, + }, + }); + + await runSessionReplayIndexPreflight(executor, [legacySpec, spec]); + + const createStatements = execute.mock.calls + .map(([statement]) => statement) + .filter((statement) => statement.startsWith("CREATE INDEX CONCURRENTLY")); + expect(createStatements).toHaveLength(1); + expect(createStatements[0]).toContain(spec.temporaryName); + expect(createStatements[0]).not.toContain(legacySpec.temporaryName); + }); + + test("uses the v2 marker and public-qualified definitions for timeout indexes", () => { + const timeoutSpecs = SESSION_REPLAY_INDEX_SPECS.filter( + (candidate) => candidate.marker === DATABASE_TIMEOUT_INDEX_MARKER + ); + + expect(timeoutSpecs).toHaveLength(5); + expect( + timeoutSpecs.every((candidate) => candidate.temporaryName.startsWith("cch_0118_tmp_")) + ).toBe(true); + expect(timeoutSpecs.every((candidate) => candidate.definition.includes('ON "public".'))).toBe( + true + ); + }); }); -describe("0116 migration orchestration", () => { +describe("database index migration orchestration", () => { test("preflights the unfiltered ledger identity index before migration 0117", async () => { const events: string[] = []; await runSessionReplayMigrationPlan({ @@ -237,6 +302,32 @@ describe("0116 migration orchestration", () => { expect(calls).toEqual(["indexes", "migrate", "indexes"]); }); + test("prebuilds timeout indexes before upgrading an existing 0117 database", async () => { + const calls: string[] = []; + + await runSessionReplayMigrationPlan({ + baseTablesReady: true, + latestMigrationCreatedAt: DATABASE_TIMEOUT_INDEX_MIGRATION_CREATED_AT - 1, + migrate: async () => calls.push("migrate"), + runIndexPreflight: async () => calls.push("indexes"), + }); + + expect(calls).toEqual(["indexes", "migrate", "indexes"]); + }); + + test("runs only postflight after migration 0118 is already recorded", async () => { + const calls: string[] = []; + + await runSessionReplayMigrationPlan({ + baseTablesReady: true, + latestMigrationCreatedAt: DATABASE_TIMEOUT_INDEX_MIGRATION_CREATED_AT, + migrate: async () => calls.push("migrate"), + runIndexPreflight: async () => calls.push("indexes"), + }); + + expect(calls).toEqual(["migrate", "indexes"]); + }); + test("fails the migration flow when concurrent index postflight fails", async () => { await expect( runSessionReplayMigrationPlan({ diff --git a/tests/unit/lib/user-statistics-reset-queue.test.ts b/tests/unit/lib/user-statistics-reset-queue.test.ts new file mode 100644 index 000000000..ae4886b8d --- /dev/null +++ b/tests/unit/lib/user-statistics-reset-queue.test.ts @@ -0,0 +1,575 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + processHandler: null as null | ((job: any) => Promise), + failedHandler: null as null | ((job: any, error: Error) => Promise), + processName: null as string | null, + queueOptions: null as any, + add: vi.fn(), + getJob: vi.fn(), + close: vi.fn(), + claim: vi.fn(), + getStatus: vi.fn(), + setStatus: vi.fn(), + deleteStatus: vi.fn(), + release: vi.fn(), + prepareFixed5h: vi.fn(), + findKeyIds: vi.fn(), + execute: vi.fn(), +})); + +vi.mock("bull", () => ({ + default: class MockQueue { + constructor(_name: string, options: unknown) { + boundary.queueOptions = options; + } + process(name: string, handler: (job: any) => Promise) { + boundary.processName = name; + boundary.processHandler = handler; + } + on(event: string, handler: (job: any, error: Error) => Promise) { + if (event === "failed") boundary.failedHandler = handler; + } + add = boundary.add; + getJob = boundary.getJob; + close = boundary.close; + }, +})); +vi.mock("@/lib/redis/bull-queue-options", () => ({ + buildRedisQueueOptions: () => ({ host: "redis" }), +})); +vi.mock("@/lib/redis/cost-cache-cleanup", () => ({ + prepareUserStatisticsResetFixed5h: boundary.prepareFixed5h, +})); +vi.mock("@/lib/user-statistics-reset/reset-status-store", () => ({ + claimActiveUserStatisticsReset: boundary.claim, + getUserStatisticsResetStatus: boundary.getStatus, + setUserStatisticsResetStatus: boundary.setStatus, + deleteUserStatisticsResetStatus: boundary.deleteStatus, + releaseActiveUserStatisticsReset: boundary.release, +})); +vi.mock("@/lib/user-statistics-reset/reset-service", () => ({ + executeUserStatisticsReset: boundary.execute, + findUserStatisticsResetKeyIds: boundary.findKeyIds, + UserStatisticsResetError: class UserStatisticsResetError extends Error { + constructor( + readonly code: string, + readonly progress = { deletedMessageRequests: 0, deletedUsageLedger: 0 } + ) { + super(code); + } + }, +})); + +import { + enqueueUserStatisticsReset, + startUserStatisticsResetQueue, + stopUserStatisticsResetQueue, +} from "@/lib/user-statistics-reset/reset-queue"; +import { UserStatisticsResetError } from "@/lib/user-statistics-reset/reset-service"; + +const existing = { + resetId: "00000000-0000-4000-8000-000000000002", + userId: 42, + status: "running" as const, + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: "2026-08-02T12:00:01.000Z", + completedAt: null, + deletedMessageRequests: 1000, + deletedUsageLedger: 0, + errorCode: null, + fixed5hKeyIds: [9], + fixed5hPreparationVersion: 1 as const, +}; + +describe("user statistics reset queue", () => { + const originalNodeEnv = process.env.NODE_ENV; + + beforeEach(async () => { + await stopUserStatisticsResetQueue(); + process.env.REDIS_URL = "redis://localhost:6379"; + boundary.processHandler = null; + boundary.failedHandler = null; + boundary.processName = null; + boundary.queueOptions = null; + for (const mock of [ + boundary.add, + boundary.getJob, + boundary.close, + boundary.claim, + boundary.getStatus, + boundary.setStatus, + boundary.deleteStatus, + boundary.release, + boundary.prepareFixed5h, + boundary.findKeyIds, + boundary.execute, + ]) + mock.mockReset(); + boundary.add.mockResolvedValue({ id: "job" }); + boundary.getJob.mockResolvedValue({ + id: "job", + getState: vi.fn().mockResolvedValue("waiting"), + }); + boundary.setStatus.mockResolvedValue(undefined); + boundary.deleteStatus.mockResolvedValue(undefined); + boundary.release.mockResolvedValue(undefined); + boundary.prepareFixed5h.mockResolvedValue("2026-08-02T12:00:00.000Z"); + boundary.findKeyIds.mockResolvedValue([9]); + }); + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv; + }); + + it("returns the existing active reset instead of enqueueing a competitor", async () => { + boundary.claim.mockResolvedValue({ acquired: false, resetId: existing.resetId }); + boundary.getStatus.mockResolvedValue(existing); + + await expect(enqueueUserStatisticsReset(42)).resolves.toEqual( + expect.not.objectContaining({ fixed5hKeyIds: expect.anything() }) + ); + expect(boundary.deleteStatus).toHaveBeenCalledTimes(1); + expect(boundary.add).not.toHaveBeenCalled(); + }); + + it("configures five attempts with exponential 30 second backoff", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + + const queued = await enqueueUserStatisticsReset(42); + + expect(queued.status).toBe("queued"); + expect(boundary.queueOptions.defaultJobOptions).toMatchObject({ + attempts: 5, + backoff: { type: "exponential", delay: 30_000 }, + }); + expect(boundary.processName).toBe("reset"); + expect(boundary.add).toHaveBeenCalledWith( + "reset", + expect.objectContaining({ userId: 42, resetId: queued.resetId }), + { jobId: queued.resetId } + ); + }); + + it("recreates a missing Bull job for an existing queued claim", async () => { + const queued = { ...existing, status: "queued" as const, startedAt: null }; + boundary.claim.mockResolvedValue({ acquired: false, resetId: queued.resetId }); + boundary.getStatus.mockResolvedValue(queued); + boundary.getJob.mockResolvedValue(null); + + await expect(enqueueUserStatisticsReset(42)).resolves.toEqual( + expect.not.objectContaining({ fixed5hKeyIds: expect.anything() }) + ); + + expect(boundary.add).toHaveBeenCalledWith( + "reset", + { + resetId: queued.resetId, + userId: queued.userId, + requestedAt: "2026-08-02T12:00:00.000Z", + fixed5hKeyIds: queued.fixed5hKeyIds, + fixed5hPreparationVersion: 1, + }, + { jobId: queued.resetId } + ); + }); + + it("reconciles legacy queued records with current child key ids", async () => { + const { + fixed5hKeyIds: _fixed5hKeyIds, + fixed5hPreparationVersion: _fixed5hPreparationVersion, + ...legacyQueued + } = { + ...existing, + status: "queued" as const, + startedAt: null, + }; + boundary.claim.mockResolvedValue({ acquired: false, resetId: legacyQueued.resetId }); + boundary.getStatus.mockResolvedValue(legacyQueued); + boundary.getJob.mockResolvedValue(null); + + await expect(enqueueUserStatisticsReset(42)).resolves.toMatchObject({ + resetId: legacyQueued.resetId, + requestedAt: "2026-08-02T12:00:00.000Z", + }); + + expect(boundary.prepareFixed5h).toHaveBeenCalledWith({ + resetId: legacyQueued.resetId, + userId: 42, + keyIds: [9], + }); + expect(boundary.add).toHaveBeenCalledWith( + "reset", + expect.objectContaining({ fixed5hKeyIds: [9], fixed5hPreparationVersion: 1 }), + { jobId: legacyQueued.resetId } + ); + }); + + it("keeps a prepared claim recoverable when Bull enqueue fails", async () => { + let activeResetId = ""; + const storedRecords = new Map(); + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => { + if (!activeResetId) { + activeResetId = resetId; + return { acquired: true, resetId }; + } + return { acquired: false, resetId: activeResetId }; + }); + boundary.setStatus.mockImplementation(async (record) => { + storedRecords.set(record.resetId, record); + }); + boundary.getStatus.mockImplementation(async (resetId: string) => storedRecords.get(resetId)); + boundary.getJob.mockResolvedValue(null); + boundary.add.mockRejectedValueOnce(new Error("redis timeout")).mockResolvedValueOnce({ + id: "recovered-job", + }); + + await expect(enqueueUserStatisticsReset(42, { fixed5hKeyIds: [9] })).rejects.toThrow( + "redis timeout" + ); + + expect(storedRecords.get(activeResetId)).toMatchObject({ + resetId: activeResetId, + status: "queued", + requestedAt: "2026-08-02T12:00:00.000Z", + fixed5hKeyIds: [9], + fixed5hPreparationVersion: 1, + }); + expect(boundary.release).not.toHaveBeenCalled(); + + await expect(enqueueUserStatisticsReset(42)).resolves.toMatchObject({ + resetId: activeResetId, + requestedAt: "2026-08-02T12:00:00.000Z", + }); + + expect(boundary.prepareFixed5h).toHaveBeenCalledTimes(1); + expect(boundary.add).toHaveBeenLastCalledWith( + "reset", + expect.objectContaining({ + resetId: activeResetId, + fixed5hKeyIds: [9], + fixed5hPreparationVersion: 1, + }), + { jobId: activeResetId } + ); + }); + + it("accepts an ambiguous enqueue when the deterministic Bull job exists", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + boundary.add.mockRejectedValueOnce(new Error("connection closed")); + boundary.getJob.mockResolvedValueOnce({ id: "persisted-job" }); + + await expect(enqueueUserStatisticsReset(42, { fixed5hKeyIds: [9] })).resolves.toMatchObject({ + status: "queued", + requestedAt: "2026-08-02T12:00:00.000Z", + }); + + expect(boundary.release).not.toHaveBeenCalled(); + }); + + it("releases a stale terminal claim and creates a new reset", async () => { + boundary.claim + .mockResolvedValueOnce({ acquired: false, resetId: existing.resetId }) + .mockImplementationOnce(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + boundary.getStatus.mockResolvedValue({ ...existing, status: "completed" }); + + const queued = await enqueueUserStatisticsReset(42); + + expect(queued.status).toBe("queued"); + expect(queued.resetId).not.toBe(existing.resetId); + expect(boundary.release).toHaveBeenCalledWith(42, existing.resetId); + expect(boundary.add).toHaveBeenCalledWith( + "reset", + expect.objectContaining({ resetId: queued.resetId }), + { jobId: queued.resetId } + ); + expect(boundary.prepareFixed5h).toHaveBeenCalledWith({ + resetId: queued.resetId, + userId: 42, + keyIds: [], + }); + }); + + it("releases a claim whose retained Bull job is already terminal", async () => { + boundary.claim + .mockResolvedValueOnce({ acquired: false, resetId: existing.resetId }) + .mockImplementationOnce(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + boundary.getStatus.mockResolvedValue({ ...existing, status: "running" }); + boundary.getJob.mockResolvedValue({ + id: existing.resetId, + getState: vi.fn().mockResolvedValue("failed"), + }); + + const queued = await enqueueUserStatisticsReset(42); + + expect(queued.resetId).not.toBe(existing.resetId); + expect(boundary.release).toHaveBeenCalledWith(42, existing.resetId); + }); + + it("does not fail application startup when Redis is not configured", async () => { + await stopUserStatisticsResetQueue(); + delete process.env.REDIS_URL; + + expect(startUserStatisticsResetQueue()).toBe(false); + expect(boundary.processHandler).toBeNull(); + }); + + it("does not construct a Bull processor on demand in development", async () => { + process.env.NODE_ENV = "development"; + + await expect(enqueueUserStatisticsReset(42)).rejects.toThrow( + "USER_STATISTICS_RESET_QUEUE_DISABLED_IN_DEVELOPMENT" + ); + + expect(boundary.processHandler).toBeNull(); + expect(boundary.add).not.toHaveBeenCalled(); + expect(boundary.setStatus).not.toHaveBeenCalled(); + }); + + it("moves a Bull job through running to completed and releases the active claim", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + const queued = await enqueueUserStatisticsReset(42); + boundary.getStatus.mockResolvedValue(queued); + boundary.execute.mockResolvedValue({ + deletedMessageRequests: 2000, + deletedUsageLedger: 1500, + }); + + await boundary.processHandler?.({ + data: queued, + attemptsMade: 0, + opts: { attempts: 5 }, + }); + + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: "completed", + deletedMessageRequests: 2000, + deletedUsageLedger: 1500, + }) + ); + expect(boundary.release).toHaveBeenCalledWith(42, queued.resetId); + }); + + it("prepares a legacy Bull job before deleting statistics", async () => { + startUserStatisticsResetQueue(); + const { + fixed5hKeyIds: _fixed5hKeyIds, + fixed5hPreparationVersion: _fixed5hPreparationVersion, + ...legacyJobData + } = existing; + const legacyStatus = { ...legacyJobData, status: "queued" as const, startedAt: null }; + boundary.getStatus.mockResolvedValue(legacyStatus); + boundary.findKeyIds.mockResolvedValue([9, 10]); + boundary.execute.mockResolvedValue({ + deletedMessageRequests: 2, + deletedUsageLedger: 3, + }); + + await boundary.processHandler?.({ + data: legacyJobData, + attemptsMade: 0, + opts: { attempts: 5 }, + }); + + expect(boundary.findKeyIds).toHaveBeenCalledWith(42); + expect(boundary.prepareFixed5h).toHaveBeenCalledWith({ + resetId: existing.resetId, + userId: 42, + keyIds: [9, 10], + }); + expect(boundary.execute).toHaveBeenCalledWith( + expect.objectContaining({ + requestedAt: "2026-08-02T12:00:00.000Z", + fixed5hKeyIds: [9, 10], + fixed5hPreparationVersion: 1, + }), + expect.any(Function) + ); + }); + + it("persists per-batch progress and does not regress completed state when claim release fails", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + const queued = await enqueueUserStatisticsReset(42); + boundary.getStatus.mockResolvedValue({ ...queued, fixed5hKeyIds: [] }); + boundary.execute.mockImplementation(async (_data, onProgress) => { + await onProgress({ deletedMessageRequests: 1000, deletedUsageLedger: 0 }); + return { deletedMessageRequests: 1000, deletedUsageLedger: 5 }; + }); + boundary.release.mockRejectedValueOnce(new Error("redis unavailable")); + + await expect( + boundary.processHandler?.({ data: queued, attemptsMade: 0, opts: { attempts: 5 } }) + ).resolves.toEqual(expect.objectContaining({ status: "completed" })); + + expect(boundary.setStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: "running", deletedMessageRequests: 1000 }) + ); + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: "completed", + deletedMessageRequests: 1000, + deletedUsageLedger: 5, + }) + ); + }); + + it("keeps retryable failures active and records a stable final error", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + const queued = await enqueueUserStatisticsReset(42); + boundary.getStatus.mockResolvedValue(queued); + boundary.execute.mockRejectedValue(new Error("database timeout")); + + await expect( + boundary.processHandler?.({ data: queued, attemptsMade: 0, opts: { attempts: 5 } }) + ).rejects.toThrow("database timeout"); + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ status: "queued", errorCode: null }) + ); + expect(boundary.release).not.toHaveBeenCalled(); + + boundary.setStatus.mockClear(); + await expect( + boundary.processHandler?.({ data: queued, attemptsMade: 4, opts: { attempts: 5 } }) + ).rejects.toThrow("database timeout"); + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: "failed", + errorCode: "USER_STATISTICS_RESET_OPERATION_FAILED", + }) + ); + expect(boundary.release).toHaveBeenCalledWith(42, queued.resetId); + }); + + it("preserves the final business error when active claim release fails", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + const queued = await enqueueUserStatisticsReset(42); + boundary.getStatus.mockResolvedValue({ + ...queued, + fixed5hKeyIds: [], + fixed5hPreparationVersion: 1, + }); + const resetError = new UserStatisticsResetError("USER_STATISTICS_RESET_CACHE_CLEANUP_FAILED", { + deletedMessageRequests: 5, + deletedUsageLedger: 7, + }); + boundary.execute.mockRejectedValue(resetError); + boundary.release.mockRejectedValueOnce(new Error("redis unavailable")); + + await expect( + boundary.processHandler?.({ data: queued, attemptsMade: 4, opts: { attempts: 5 } }) + ).rejects.toBe(resetError); + + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: "failed", + errorCode: "USER_STATISTICS_RESET_CACHE_CLEANUP_FAILED", + }) + ); + }); + + it("preserves the final business error and releases the claim when status persistence fails", async () => { + boundary.claim.mockImplementation(async (_userId: number, resetId: string) => ({ + acquired: true, + resetId, + })); + const queued = await enqueueUserStatisticsReset(42); + boundary.getStatus.mockResolvedValue({ + ...queued, + fixed5hKeyIds: [], + fixed5hPreparationVersion: 1, + }); + const resetError = new UserStatisticsResetError("USER_STATISTICS_RESET_CACHE_CLEANUP_FAILED", { + deletedMessageRequests: 5, + deletedUsageLedger: 7, + }); + boundary.execute.mockRejectedValue(resetError); + boundary.setStatus + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("status store unavailable")); + + await expect( + boundary.processHandler?.({ data: queued, attemptsMade: 4, opts: { attempts: 5 } }) + ).rejects.toBe(resetError); + + expect(boundary.release).toHaveBeenCalledWith(42, queued.resetId); + }); + + it("ignores failed events that do not include recoverable job data", async () => { + startUserStatisticsResetQueue(); + + await expect( + boundary.failedHandler?.(undefined, new Error("job stalled more than allowable limit")) + ).resolves.toBeUndefined(); + + expect(boundary.getStatus).not.toHaveBeenCalled(); + expect(boundary.setStatus).not.toHaveBeenCalled(); + expect(boundary.release).not.toHaveBeenCalled(); + }); + + it("marks max-stalled jobs failed even when attemptsMade did not advance", async () => { + startUserStatisticsResetQueue(); + boundary.getStatus.mockResolvedValue(existing); + + await boundary.failedHandler?.( + { + data: existing, + attemptsMade: 0, + opts: { attempts: 5 }, + }, + new Error("job stalled more than allowable limit") + ); + + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: "failed", + errorCode: "USER_STATISTICS_RESET_OPERATION_FAILED", + }) + ); + expect(boundary.release).toHaveBeenCalledWith(42, existing.resetId); + }); + + it("keeps failed terminal status when releasing the active claim fails", async () => { + startUserStatisticsResetQueue(); + boundary.getStatus.mockResolvedValue(existing); + boundary.release.mockRejectedValueOnce(new Error("redis unavailable")); + + await expect( + boundary.failedHandler?.( + { data: existing, attemptsMade: 5, opts: { attempts: 5 } }, + new Error("database timeout") + ) + ).resolves.toBeUndefined(); + + expect(boundary.setStatus).toHaveBeenLastCalledWith( + expect.objectContaining({ + status: "failed", + errorCode: "USER_STATISTICS_RESET_OPERATION_FAILED", + }) + ); + }); +}); diff --git a/tests/unit/lib/user-statistics-reset-service.test.ts b/tests/unit/lib/user-statistics-reset-service.test.ts new file mode 100644 index 000000000..38226136a --- /dev/null +++ b/tests/unit/lib/user-statistics-reset-service.test.ts @@ -0,0 +1,192 @@ +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + transactionResults: [] as unknown[], + transactionQueries: [] as unknown[], + executeResults: [] as unknown[], + executeQueries: [] as unknown[], + userKeys: [] as { id: number; key: string }[], + cacheResult: { costKeysDeleted: 0, activeSessionsDeleted: 0, durationMs: 1 } as unknown, + clearUserCostCache: vi.fn(), + invalidateCachedUser: vi.fn(), + updateSet: null as Record | null, + updateError: null as Error | null, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + transaction: async ( + callback: (tx: { execute: (query: unknown) => Promise }) => unknown + ) => + callback({ + execute: async (query: unknown) => { + boundary.transactionQueries.push(query); + const result = boundary.transactionResults.shift(); + if (result instanceof Error) throw result; + return result; + }, + }), + execute: async (query: unknown) => { + boundary.executeQueries.push(query); + const result = boundary.executeResults.shift(); + if (result instanceof Error) throw result; + return result; + }, + select: () => ({ from: () => ({ where: async () => boundary.userKeys }) }), + update: () => ({ + set: (value: Record) => { + boundary.updateSet = value; + return { + where: async () => { + if (boundary.updateError) throw boundary.updateError; + }, + }; + }, + }), + }, +})); +vi.mock("@/lib/redis/cost-cache-cleanup", () => ({ + clearUserCostCache: boundary.clearUserCostCache, +})); +vi.mock("@/lib/security/api-key-auth-cache", () => ({ + invalidateCachedUser: boundary.invalidateCachedUser, +})); + +import { + executeUserStatisticsReset, + type UserStatisticsResetError, +} from "@/lib/user-statistics-reset/reset-service"; + +const dialect = new PgDialect(); +const sqlText = (query: unknown) => dialect.sqlToQuery(query as SQL).sql.toLowerCase(); + +describe("executeUserStatisticsReset", () => { + beforeEach(() => { + boundary.transactionResults = []; + boundary.transactionQueries = []; + boundary.executeResults = []; + boundary.executeQueries = []; + boundary.userKeys = [{ id: 9, key: "key-hash" }]; + boundary.updateSet = null; + boundary.updateError = null; + boundary.clearUserCostCache.mockReset().mockResolvedValue(boundary.cacheResult); + boundary.invalidateCachedUser.mockReset().mockResolvedValue(undefined); + }); + + it("deletes both tables in independent cutoff batches and preserves active sessions", async () => { + boundary.transactionResults = [{ count: 1000 }, { count: 7 }, { count: 3 }]; + boundary.executeResults = [[{ exists: false }], [{ exists: false }]]; + + await expect( + executeUserStatisticsReset({ userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }) + ).resolves.toEqual({ deletedMessageRequests: 1007, deletedUsageLedger: 3 }); + + expect(boundary.transactionQueries).toHaveLength(3); + expect(sqlText(boundary.transactionQueries[0])).toContain("from message_request"); + expect(sqlText(boundary.transactionQueries[0])).toContain("created_at is null"); + expect(sqlText(boundary.transactionQueries[0])).toContain("created_at <= $2"); + expect(sqlText(boundary.transactionQueries[0])).toContain("limit $3"); + expect(sqlText(boundary.transactionQueries[0])).toContain("for update skip locked"); + expect(sqlText(boundary.transactionQueries[2])).toContain("from usage_ledger"); + expect(boundary.clearUserCostCache).toHaveBeenCalledWith({ + userId: 42, + keyIds: [9], + keyHashes: ["key-hash"], + includeActiveSessions: false, + allowWhenRateLimitDisabled: true, + preserveFixed5hCostKeys: true, + }); + expect(sqlText(boundary.updateSet?.costResetAt)).toContain("case when"); + expect(sqlText(boundary.updateSet?.limit5hCostResetAt)).toContain("case when"); + }); + + it("reports progress after every committed batch", async () => { + boundary.transactionResults = [{ count: 1000 }, { count: 2 }, { count: 3 }]; + boundary.executeResults = [[{ exists: false }], [{ exists: false }]]; + const progress = vi.fn().mockResolvedValue(undefined); + + await executeUserStatisticsReset( + { userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }, + progress + ); + + expect(progress.mock.calls).toEqual([ + [{ deletedMessageRequests: 1000, deletedUsageLedger: 0 }], + [{ deletedMessageRequests: 1002, deletedUsageLedger: 0 }], + [{ deletedMessageRequests: 1002, deletedUsageLedger: 3 }], + ]); + }); + + it("reports deleted rows when cache cleanup fails so retries preserve progress", async () => { + boundary.transactionResults = [{ count: 4 }, { count: 6 }]; + boundary.executeResults = [[{ exists: false }], [{ exists: false }]]; + boundary.clearUserCostCache.mockResolvedValue({ cleanupFailed: true }); + + await expect( + executeUserStatisticsReset({ userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }) + ).rejects.toEqual( + expect.objectContaining({ + code: "USER_STATISTICS_RESET_CACHE_CLEANUP_FAILED", + progress: { deletedMessageRequests: 4, deletedUsageLedger: 6 }, + }) + ); + }); + + it("fails retryably when an empty batch still has locked cutoff rows", async () => { + boundary.transactionResults = [{ count: 0 }]; + boundary.executeResults = [[{ exists: true }]]; + + await expect( + executeUserStatisticsReset({ userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }) + ).rejects.toEqual( + expect.objectContaining({ + code: "USER_STATISTICS_RESET_ROWS_LOCKED", + }) + ); + expect(boundary.clearUserCostCache).not.toHaveBeenCalled(); + }); + + it("preserves completed message deletion progress when ledger deletion throws", async () => { + boundary.transactionResults = [{ count: 4 }, new Error("ledger unavailable")]; + boundary.executeResults = [[{ exists: false }]]; + + await expect( + executeUserStatisticsReset({ userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }) + ).rejects.toEqual( + expect.objectContaining({ + code: "USER_STATISTICS_RESET_OPERATION_FAILED", + progress: { deletedMessageRequests: 4, deletedUsageLedger: 0 }, + }) + ); + }); + + it("preserves progress when a later batch fails", async () => { + boundary.transactionResults = [{ count: 1000 }, new Error("statement timeout")]; + + await expect( + executeUserStatisticsReset({ userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }) + ).rejects.toEqual( + expect.objectContaining({ + code: "USER_STATISTICS_RESET_OPERATION_FAILED", + progress: { deletedMessageRequests: 1000, deletedUsageLedger: 0 }, + }) + ); + }); + + it("preserves both table counts when marker cleanup fails", async () => { + boundary.transactionResults = [{ count: 4 }, { count: 6 }]; + boundary.executeResults = [[{ exists: false }], [{ exists: false }]]; + boundary.updateError = new Error("database unavailable"); + + await expect( + executeUserStatisticsReset({ userId: 42, requestedAt: "2026-08-02T12:00:00.000Z" }) + ).rejects.toEqual( + expect.objectContaining({ + code: "USER_STATISTICS_RESET_OPERATION_FAILED", + progress: { deletedMessageRequests: 4, deletedUsageLedger: 6 }, + }) + ); + }); +}); diff --git a/tests/unit/lib/user-statistics-reset-status-store.test.ts b/tests/unit/lib/user-statistics-reset-status-store.test.ts new file mode 100644 index 000000000..5b2935cb5 --- /dev/null +++ b/tests/unit/lib/user-statistics-reset-status-store.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const boundary = vi.hoisted(() => ({ + listeners: new Map void>>(), + redis: { + status: "ready", + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), + eval: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + }, + storeSet: vi.fn(), +})); + +function emitRedis(event: string, ...args: unknown[]) { + const listeners = [...(boundary.listeners.get(event) ?? [])]; + boundary.listeners.delete(event); + for (const listener of listeners) listener(...args); +} + +vi.mock("@/lib/redis/client", () => ({ + getRedisClient: () => boundary.redis, +})); + +vi.mock("@/lib/redis/redis-kv-store", () => ({ + RedisKVStore: class MockRedisKVStore { + set = boundary.storeSet; + }, +})); + +import { + claimActiveUserStatisticsReset, + deleteUserStatisticsResetStatus, + getUserStatisticsResetStatus, + releaseActiveUserStatisticsReset, + setUserStatisticsResetStatus, +} from "@/lib/user-statistics-reset/reset-status-store"; + +const record = { + resetId: "00000000-0000-4000-8000-000000000001", + userId: 42, + status: "queued" as const, + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: null, + completedAt: null, + deletedMessageRequests: 0, + deletedUsageLedger: 0, + errorCode: null, + fixed5hKeyIds: [9], + fixed5hPreparationVersion: null, +}; + +describe("user statistics reset status store", () => { + beforeEach(() => { + boundary.redis.status = "ready"; + boundary.listeners.clear(); + for (const mock of [ + boundary.redis.get, + boundary.redis.set, + boundary.redis.del, + boundary.redis.eval, + boundary.redis.once, + boundary.redis.removeListener, + boundary.storeSet, + ]) { + mock.mockReset(); + } + boundary.redis.once.mockImplementation( + (event: string, listener: (...args: unknown[]) => void) => { + const listeners = boundary.listeners.get(event) ?? new Set(); + listeners.add(listener); + boundary.listeners.set(event, listeners); + return boundary.redis; + } + ); + boundary.redis.removeListener.mockImplementation( + (event: string, listener: (...args: unknown[]) => void) => { + boundary.listeners.get(event)?.delete(listener); + return boundary.redis; + } + ); + boundary.storeSet.mockResolvedValue(true); + }); + + it("writes status records and fails closed when Redis rejects the write", async () => { + await expect(setUserStatisticsResetStatus(record)).resolves.toBeUndefined(); + expect(boundary.storeSet).toHaveBeenCalledWith(record.resetId, record); + + boundary.storeSet.mockResolvedValue(false); + await expect(setUserStatisticsResetStatus(record)).rejects.toThrow( + "USER_STATISTICS_RESET_STATUS_WRITE_FAILED" + ); + }); + + it("reads valid records and distinguishes missing, corrupt, and unavailable state", async () => { + boundary.redis.get.mockResolvedValueOnce(JSON.stringify(record)).mockResolvedValueOnce(null); + + await expect(getUserStatisticsResetStatus(record.resetId)).resolves.toEqual(record); + await expect(getUserStatisticsResetStatus(record.resetId)).resolves.toBeNull(); + + boundary.redis.get.mockResolvedValue("not-json"); + await expect(getUserStatisticsResetStatus(record.resetId)).rejects.toThrow( + "USER_STATISTICS_RESET_STATUS_INVALID" + ); + }); + + it("waits for the shared Redis client to become ready before reading and writing", async () => { + boundary.redis.status = "connecting"; + boundary.redis.get.mockResolvedValue(JSON.stringify(record)); + + const read = getUserStatisticsResetStatus(record.resetId); + const write = setUserStatisticsResetStatus(record); + expect(boundary.redis.get).not.toHaveBeenCalled(); + expect(boundary.storeSet).not.toHaveBeenCalled(); + + boundary.redis.status = "ready"; + emitRedis("ready"); + + await expect(read).resolves.toEqual(record); + await expect(write).resolves.toBeUndefined(); + expect(boundary.redis.get).toHaveBeenCalledOnce(); + expect(boundary.storeSet).toHaveBeenCalledWith(record.resetId, record); + }); + + it("normalizes legacy records without fixed 5h key ids", async () => { + const { + fixed5hKeyIds: _fixed5hKeyIds, + fixed5hPreparationVersion: _fixed5hPreparationVersion, + ...legacyRecord + } = record; + boundary.redis.get.mockResolvedValue(JSON.stringify(legacyRecord)); + + await expect(getUserStatisticsResetStatus(record.resetId)).resolves.toEqual({ + ...legacyRecord, + fixed5hKeyIds: [], + fixed5hPreparationVersion: null, + }); + }); + + it("claims one active reset and returns the existing owner on contention", async () => { + boundary.redis.set.mockResolvedValueOnce("OK").mockResolvedValueOnce(null); + boundary.redis.get.mockResolvedValue(record.resetId); + + await expect(claimActiveUserStatisticsReset(42, record.resetId)).resolves.toEqual({ + acquired: true, + resetId: record.resetId, + }); + await expect(claimActiveUserStatisticsReset(42, "new-reset")).resolves.toEqual({ + acquired: false, + resetId: record.resetId, + }); + expect(boundary.redis.set).toHaveBeenCalledWith( + "cch:user-statistics-reset:active:42", + record.resetId, + "EX", + 604_800, + "NX" + ); + }); + + it("fails contention without an owner and deletes status or claims by exact key", async () => { + boundary.redis.set.mockResolvedValue(null); + boundary.redis.get.mockResolvedValue(null); + + await expect(claimActiveUserStatisticsReset(42, record.resetId)).rejects.toThrow( + "USER_STATISTICS_RESET_ACTIVE_CLAIM_FAILED" + ); + + await deleteUserStatisticsResetStatus(record.resetId); + await releaseActiveUserStatisticsReset(42, record.resetId); + + expect(boundary.redis.del).toHaveBeenCalledWith( + `cch:user-statistics-reset:status:${record.resetId}` + ); + expect(boundary.redis.eval).toHaveBeenCalledWith( + expect.stringContaining("redis.call('GET'"), + 1, + "cch:user-statistics-reset:active:42", + record.resetId + ); + }); +}); diff --git a/tests/unit/proxy/replay-spool.test.ts b/tests/unit/proxy/replay-spool.test.ts index 07dcdfe35..f3988350e 100644 --- a/tests/unit/proxy/replay-spool.test.ts +++ b/tests/unit/proxy/replay-spool.test.ts @@ -6,7 +6,10 @@ import { getActiveReplaySpoolCount, ReplaySpool, } from "@/app/v1/_lib/proxy/replay/replay-spool"; -import type { ReplayDelivery } from "@/app/v1/_lib/proxy/replay/replay-store"; +import { + ReplayDurableConflictError, + type ReplayDelivery, +} from "@/app/v1/_lib/proxy/replay/replay-store"; import type { ProxySession } from "@/app/v1/_lib/proxy/session"; import { logger } from "@/lib/logger"; @@ -64,6 +67,11 @@ const storeControl = vi.hoisted(() => { }), persistCompleted: vi.fn(async () => { order.push("persist"); + return "persisted" as const; + }), + discardOwned: vi.fn(async () => { + order.push("discard"); + return true; }), deleteEntry: vi.fn(async () => { order.push("deleteEntry"); @@ -114,6 +122,7 @@ vi.mock("@/lib/config/env.schema", async (importOriginal) => { vi.mock("@/app/v1/_lib/proxy/replay/replay-store", () => ({ getReplayStore: () => storeControl.store, + ReplayDurableConflictError: class ReplayDurableConflictError extends Error {}, })); const identity: ReplayIdentity = { @@ -431,6 +440,36 @@ describe("ReplaySpool:completeAfterBilling 终态屏障", () => { expect(getActiveReplaySpoolCount()).toBe(0); }); + it("复用已有 durable winner 时发布 completed 终态并保留 live attachment", async () => { + storeControl.store.persistCompleted.mockResolvedValueOnce("existing"); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await spool.completeAfterBilling(7); + + expect(storeControl.store.completeOwned).toHaveBeenCalledWith( + identity.replayId, + "owner-token", + expect.objectContaining({ status: "completed", messageRequestId: 7, chunkCount: 1 }) + ); + expect(storeControl.store.discardOwned).not.toHaveBeenCalled(); + expect(storeControl.store.abortOwned).not.toHaveBeenCalled(); + }); + + it("durable payload 冲突时丢弃当前候选,不用 aborted 遮蔽 PG winner", async () => { + storeControl.store.persistCompleted.mockRejectedValueOnce( + new ReplayDurableConflictError(identity.replayId) + ); + const spool = makeSpool(); + spool.observe(encoder.encode("data: a\n\n")); + + await spool.completeAfterBilling(7); + + expect(storeControl.store.discardOwned).toHaveBeenCalledWith(identity.replayId, "owner-token"); + expect(storeControl.store.completeOwned).not.toHaveBeenCalled(); + expect(storeControl.store.abortOwned).not.toHaveBeenCalled(); + }); + it("尾批 fenced write 返回 null(Redis 不可用)时终止为 aborted,绝不置 completed 也不写 PG", async () => { storeControl.store.writeOwned.mockResolvedValueOnce(null); const spool = makeSpool(); diff --git a/tests/unit/proxy/replay-store.test.ts b/tests/unit/proxy/replay-store.test.ts index 83369b272..0b2b9dcc3 100644 --- a/tests/unit/proxy/replay-store.test.ts +++ b/tests/unit/proxy/replay-store.test.ts @@ -31,7 +31,11 @@ const redisControl = vi.hoisted(() => ({ const dbState = vi.hoisted(() => ({ insertValues: [] as Record[], onConflictCalls: 0, + upsertConfigs: [] as Record[], + upsertRows: [] as Record[], deleteWheres: [] as unknown[], + executeQueries: [] as unknown[], + executeRows: [] as Record[], selectWheres: [] as unknown[], selectRows: [] as Record[], insertError: null as Error | null, @@ -71,6 +75,10 @@ vi.mock("@/lib/redis/client", () => ({ vi.mock("@/drizzle/db", () => ({ db: { + execute: async (query: unknown) => { + dbState.executeQueries.push(query); + return dbState.executeRows; + }, insert: () => ({ values: (values: Record) => { if (dbState.insertError) throw dbState.insertError; @@ -79,6 +87,11 @@ vi.mock("@/drizzle/db", () => ({ onConflictDoNothing: async () => { dbState.onConflictCalls += 1; }, + onConflictDoUpdate: (config: Record) => { + dbState.onConflictCalls += 1; + dbState.upsertConfigs.push(config); + return { returning: async () => dbState.upsertRows }; + }, }; }, }), @@ -173,6 +186,14 @@ function createFakeRedis() { kv.delete(key); return Number(ttl) > 0 ? 1 : 0; } + if (script.includes("KEYS[3]") && script.includes("DEL") && _numkeys === 3) { + const [metaKey, chunksKey, token] = args; + if (kv.get(key) !== token) return 0; + kv.delete(String(metaKey)); + lists.delete(String(chunksKey)); + kv.delete(key); + return 1; + } const token = args[0] as string; if (script.includes("EXPIRE")) { return kv.get(key) === token ? 1 : 0; @@ -242,7 +263,11 @@ beforeEach(() => { redisControl.client = createFakeRedis(); dbState.insertValues = []; dbState.onConflictCalls = 0; + dbState.upsertConfigs = []; + dbState.upsertRows = [{ replayId: "persisted" }]; dbState.deleteWheres = []; + dbState.executeQueries = []; + dbState.executeRows = []; dbState.selectWheres = []; dbState.selectRows = []; dbState.insertError = null; @@ -501,17 +526,36 @@ describe("ReplayStore:owner 租约", () => { expect(currentRedis().kv.get("cch:replay:owner:r1")).toBe("tok-new"); }); - it("completeOwned 仅在 token 匹配时原子写 completed meta 并释放租约", async () => { + it("discardOwned 仅在 token 匹配时删除当前热层候选且不写终态", async () => { + const store = new ReplayStore(); + await store.tryClaimOwner("r1", "tok-a"); + await store.setMeta("r1", makeMeta()); + await store.appendChunks("r1", ["partial"]); + + await expect(store.discardOwned("r1", "tok-other")).resolves.toBe(false); + await expect(store.getMeta("r1")).resolves.not.toBeNull(); + await expect(store.readChunks("r1", 0)).resolves.toEqual(["partial"]); + + await expect(store.discardOwned("r1", "tok-a")).resolves.toBe(true); + await expect(store.getMeta("r1")).resolves.toBeNull(); + await expect(store.readChunks("r1", 0)).resolves.toEqual([]); + expect(currentRedis().kv.has("cch:replay:owner:r1")).toBe(false); + }); + + it("completeOwned 仅在 token 匹配时原子写 completed meta、保留 chunks 并释放租约", async () => { const store = new ReplayStore(); const completedMeta = makeMeta({ status: "completed", chunkCount: 2 }); await store.tryClaimOwner("r1", "tok-a"); + await store.appendChunks("r1", ["first", "second"]); await expect(store.completeOwned("r1", "tok-other", completedMeta)).resolves.toBe(false); expect(currentRedis().kv.get("cch:replay:owner:r1")).toBe("tok-a"); await expect(store.getMeta("r1")).resolves.toBeNull(); + await expect(store.readChunks("r1", 0)).resolves.toEqual(["first", "second"]); await expect(store.completeOwned("r1", "tok-a", completedMeta)).resolves.toBe(true); await expect(store.getMeta("r1")).resolves.toEqual(completedMeta); + await expect(store.readChunks("r1", 0)).resolves.toEqual(["first", "second"]); expect(currentRedis().kv.has("cch:replay:owner:r1")).toBe(false); }); }); @@ -523,7 +567,7 @@ describe("ReplayStore:PG 完成持久层", () => { const row = makePersistedRow(); const before = Date.now(); - await store.persistCompleted(row); + await expect(store.persistCompleted(row)).resolves.toBe("persisted"); const after = Date.now(); expect(dbState.insertValues).toHaveLength(1); @@ -551,6 +595,64 @@ describe("ReplayStore:PG 完成持久层", () => { expect(dbState.deleteWheres).toHaveLength(0); }); + it("persistCompleted 条件替换已过期的确定性主键行", async () => { + const store = new ReplayStore(); + const row = makePersistedRow({ payload: "data: replacement\n\n" }); + + await expect(store.persistCompleted(row)).resolves.toBe("persisted"); + + expect(dbState.upsertConfigs).toHaveLength(1); + const config = dbState.upsertConfigs[0] as { + set: Record; + setWhere: unknown; + }; + expect(config.set).toMatchObject({ + verifier: row.verifier, + scopeTag: row.scopeTag, + keyId: row.keyId, + userId: row.userId, + format: row.format, + payload: row.payload, + byteSize: row.byteSize, + sourceMessageRequestId: row.sourceMessageRequestId, + }); + expect(toSqlText(config.setWhere).toLowerCase()).toContain('"expires_at" <='); + expect(dbState.selectWheres).toHaveLength(0); + }); + + it("persistCompleted 接受内容一致但 source request 不同的未过期 durable 行", async () => { + dbState.upsertRows = []; + const row = makePersistedRow(); + dbState.selectRows = [ + { + ...row, + headersJson: row.headers, + sourceMessageRequestId: 999, + expiresAt: new Date(Date.now() + 60_000), + }, + ]; + const store = new ReplayStore(); + + await expect(store.persistCompleted(row)).resolves.toBe("existing"); + expect(dbState.selectWheres).toHaveLength(1); + }); + + it("persistCompleted 拒绝内容冲突的未过期 durable 行", async () => { + dbState.upsertRows = []; + const row = makePersistedRow(); + dbState.selectRows = [ + { + ...row, + headersJson: row.headers, + payload: "data: conflicting\n\n", + expiresAt: new Date(Date.now() + 60_000), + }, + ]; + const store = new ReplayStore(); + + await expect(store.persistCompleted(row)).rejects.toThrow("durable replay conflict"); + }); + it("persistCompleted 遇 PG 异常必须抛出(complete 屏障依赖异常走 abort)", async () => { dbState.insertError = new Error("pg down"); const store = new ReplayStore(); @@ -558,13 +660,22 @@ describe("ReplayStore:PG 完成持久层", () => { await expect(store.persistCompleted(makePersistedRow())).rejects.toThrow("pg down"); }); - it("cleanupExpired 删除过期行(供定时调度器调用)", async () => { + it("cleanupExpired 单批锁定并删除最多 100 条过期行", async () => { + dbState.executeRows = [{ deleted: 1 }, { deleted: 1 }]; const store = new ReplayStore(); - await expect(store.cleanupExpired()).resolves.toBe(0); - - expect(dbState.deleteWheres).toHaveLength(1); - const deleteSql = toSqlText(dbState.deleteWheres[0]); - expect(deleteSql).toContain('"expires_at" <'); + const cutoff = new Date("2026-08-02T12:00:00.000Z"); + + await expect(store.cleanupExpired(cutoff)).resolves.toBe(2); + + expect(dbState.executeQueries).toHaveLength(1); + const deleteSql = toSqlText(dbState.executeQueries[0]).toLowerCase(); + expect(deleteSql).toContain("with doomed as"); + expect(deleteSql).toContain("expires_at < $1"); + expect(deleteSql).toContain("order by expires_at, replay_id"); + expect(deleteSql).toContain("limit $2"); + expect(deleteSql).toContain("for update skip locked"); + expect(deleteSql).toContain("delete from replay_payloads"); + expect(deleteSql).toContain("returning 1"); }); it("findCompleted 只按 replayId + 未过期条件查询并返回首行", async () => { diff --git a/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts b/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts index 2894d2f78..b77ac0bca 100644 --- a/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts +++ b/tests/unit/repository/message-aggregate-multiple-session-stats.test.ts @@ -199,8 +199,11 @@ describe("message repository aggregateMultipleSessionStats", () => { const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)); expect(ownerQuery).toContain("unnest"); expect(ownerQuery).toContain("session-without-owner"); - expect(ownerQuery).toContain("order by case when session_identity"); - expect(ownerQuery).toContain("created_at desc, id desc"); + expect(ownerQuery).toContain("union all"); + expect(ownerQuery).toContain("coalesce(session_identity, session_id) = sid"); + expect(ownerQuery).toContain("sid not like 'pfx:%'"); + expect(ownerQuery).toContain("sid not like 'sid:%'"); + expect(ownerQuery).toContain("created_at desc nulls last, id desc"); }); test("returns Replay-only owners with zero billing aggregates", async () => { @@ -271,11 +274,13 @@ describe("message repository aggregateMultipleSessionStats", () => { const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)).toLowerCase(); expect(ownerQuery).toContain("session_id = sid"); expect(ownerQuery).toContain("requested_session_id"); + expect(ownerQuery).toContain("union all"); }); test("resolves a physical Session id inside the authenticated owner scope", async () => { const canonicalId = "pfx:scope:root"; - boundary.select.mockReturnValueOnce(createDrizzleQuery([statsRow(canonicalId, 1)])); + const stats = createDrizzleQuery([statsRow(canonicalId, 1)]); + boundary.select.mockReturnValueOnce(stats); boundary.selectDistinct .mockReturnValueOnce(createDrizzleQuery([])) .mockReturnValueOnce(createDrizzleQuery([])) @@ -301,6 +306,7 @@ describe("message repository aggregateMultipleSessionStats", () => { expect(ownerQuery).toContain("session_id = sid"); expect(ownerQuery).toContain("and user_id ="); expect(ownerQuery).toContain(" 7"); + expect(sqlText(stats.trace.where)).toContain("coalesce"); }); test("deduplicates current public and physical aliases using the latest identity", async () => { @@ -344,8 +350,8 @@ describe("message repository aggregateMultipleSessionStats", () => { }), ]); const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)).toLowerCase(); - expect(ownerQuery).toContain("order by case when session_identity"); - expect(ownerQuery).toContain("created_at desc, id desc"); + expect(ownerQuery).toContain("union all"); + expect(ownerQuery).toContain("created_at desc nulls last, id desc"); }); test("prefers an exact public identity over a newer same-named physical Session", async () => { @@ -373,9 +379,50 @@ describe("message repository aggregateMultipleSessionStats", () => { await aggregateMultipleSessionStats([canonicalId]); const ownerQuery = sqlText(boundary.execute.mock.calls.at(0)?.at(0)).toLowerCase(); - expect(ownerQuery).toContain("or session_id = sid"); - expect(ownerQuery).toContain("case when session_identity"); - expect(ownerQuery).toContain("= sid then 0 else 1 end"); - expect(ownerQuery).toContain("created_at desc, id desc"); + expect(ownerQuery).toContain("union all"); + expect(ownerQuery).toContain("coalesce(session_identity, session_id) = sid"); + expect(ownerQuery).toContain("sid not like 'pfx:%'"); + expect(ownerQuery).not.toContain("session_identity = sid or session_id = sid"); + expect(ownerQuery).toContain("created_at desc nulls last, id desc"); }); + + test.each(["pfx:scope:root", "sid:owner-root"])( + "aggregates reserved canonical identity from explicit ledger identity only: %s", + async (canonicalId) => { + const stats = createDrizzleQuery([statsRow(canonicalId, 1)]); + const providerList = createDrizzleQuery([]); + const modelList = createDrizzleQuery([]); + const cacheTtlList = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(stats); + boundary.selectDistinct + .mockReturnValueOnce(providerList) + .mockReturnValueOnce(modelList) + .mockReturnValueOnce(cacheTtlList); + boundary.execute.mockResolvedValueOnce([ + { + requested_session_id: canonicalId, + session_id: canonicalId, + session_identity_kind: "prefix_affinity", + session_fingerprint: "root", + user_name: "Alice", + user_id: 1, + key_name: "Key A", + key_id: 101, + user_agent: null, + api_type: "claude", + }, + ]); + + await aggregateMultipleSessionStats([canonicalId]); + + for (const query of [stats, providerList, modelList, cacheTtlList]) { + const whereSql = sqlText(query.trace.where).toLowerCase(); + expect(whereSql).toContain("coalesce"); + expect(whereSql).toContain("session_identity"); + expect(whereSql.match(new RegExp(canonicalId, "g"))).toHaveLength(2); + expect(whereSql).not.toContain(`session_id = ${canonicalId}`); + expect(whereSql).toContain("user_id"); + } + } + ); }); diff --git a/tests/unit/repository/message-aggregate-session-stats.test.ts b/tests/unit/repository/message-aggregate-session-stats.test.ts index 4f955dcf5..e8e17ce05 100644 --- a/tests/unit/repository/message-aggregate-session-stats.test.ts +++ b/tests/unit/repository/message-aggregate-session-stats.test.ts @@ -137,11 +137,40 @@ describe("message repository aggregateSessionStats", () => { await aggregateSessionStats("pfx:scope123:fp-deep"); const whereSql = sqlText(stats.trace.where); + expect(whereSql).toContain("coalesce"); expect(whereSql).toContain("session_identity"); expect(whereSql).toContain("session_id"); - expect(whereSql.match(/pfx:scope123:fp-deep/g)).toHaveLength(1); + expect(whereSql.match(/pfx:scope123:fp-deep/g)).toHaveLength(2); + expect(whereSql).not.toContain("session_id = pfx:scope123:fp-deep"); }); + test.each(["pfx:scope123:fp-deep", "sid:owner-session"])( + "owner-scoped reserved identity does not alias a physical ledger Session: %s", + async (reservedIdentity) => { + const stats = createDrizzleQuery([]); + const providerList = createDrizzleQuery([]); + const modelList = createDrizzleQuery([]); + const cacheTtlList = createDrizzleQuery([]); + boundary.select.mockReturnValueOnce(stats).mockReturnValueOnce(createDrizzleQuery([])); + boundary.selectDistinct + .mockReturnValueOnce(providerList) + .mockReturnValueOnce(modelList) + .mockReturnValueOnce(cacheTtlList); + + await aggregateSessionStats(reservedIdentity, 17); + + for (const query of [stats, providerList, modelList, cacheTtlList]) { + const whereSql = sqlText(query.trace.where); + expect(whereSql).toContain("coalesce"); + expect(whereSql).toContain("session_identity"); + expect(whereSql.match(new RegExp(reservedIdentity, "g"))).toHaveLength(2); + expect(whereSql).not.toContain(`session_id = ${reservedIdentity}`); + expect(whereSql).toContain("user_id"); + expect(whereSql).toContain("17"); + } + } + ); + test("returns populated statistics and preserves a single cache TTL", async () => { const queries = queuePopulatedAggregate(["1h"]); diff --git a/tests/unit/repository/message-session-request-query.test.ts b/tests/unit/repository/message-session-request-query.test.ts index 4de3c23aa..c05a81e5c 100644 --- a/tests/unit/repository/message-session-request-query.test.ts +++ b/tests/unit/repository/message-session-request-query.test.ts @@ -213,7 +213,7 @@ describe("message repository session request queries", () => { expect(sqlText(rows.trace.where)).toContain("pfx:scope:fingerprint"); expect(sqlText(rows.trace.where)).toContain("is_replay"); expect(sqlText(rows.trace.where)).toContain("false"); - expect(sqlText(rows.trace.orderBy)).toContain("created_at desc"); + expect(sqlText(rows.trace.orderBy)).toContain("created_at desc nulls last"); expect(sqlText(rows.trace.orderBy)).toContain("id desc"); const selection = sqlText(boundary.select.mock.calls.at(1)?.at(0)).toLowerCase(); expect(selection).toContain("then row_number()"); @@ -322,6 +322,8 @@ describe("message repository session request queries", () => { expect(where).toContain("session_identity"); expect(where).toContain("session_id"); expect(where.match(/physical-a/g)).toHaveLength(2); + expect(sqlText(locator.trace.orderBy)).toContain("created_at desc nulls last"); + expect(sqlText(locator.trace.orderBy)).toContain("id desc"); }); test("filters requests without a stable selector from navigable request lists", async () => { @@ -471,7 +473,7 @@ describe("message repository session request queries", () => { expect(sqlText(previous.trace.where)).toContain("created_at"); expect(sqlText(previous.trace.where)).toContain("is_replay"); expect(sqlText(previous.trace.where)).toContain("false"); - expect(sqlText(previous.trace.orderBy)).toContain("created_at desc"); + expect(sqlText(previous.trace.orderBy)).toContain("created_at desc nulls last"); expect(sqlText(previous.trace.orderBy)).toContain("id desc"); expect(sqlText(next.trace.where)).toContain("created_at"); expect(sqlText(next.trace.where)).toContain("is_replay"); diff --git a/tests/unit/usage-ledger/cleanup-immunity.test.ts b/tests/unit/usage-ledger/cleanup-immunity.test.ts index c9a7f7657..7d3602eaf 100644 --- a/tests/unit/usage-ledger/cleanup-immunity.test.ts +++ b/tests/unit/usage-ledger/cleanup-immunity.test.ts @@ -4,6 +4,10 @@ import { describe, expect, it } from "vitest"; const serviceTs = readFileSync(resolve(process.cwd(), "src/lib/log-cleanup/service.ts"), "utf-8"); const usersTs = readFileSync(resolve(process.cwd(), "src/actions/users.ts"), "utf-8"); +const resetServiceTs = readFileSync( + resolve(process.cwd(), "src/lib/user-statistics-reset/reset-service.ts"), + "utf-8" +); describe("usage_ledger cleanup immunity", () => { it("log cleanup service never imports or queries usageLedger", () => { @@ -21,21 +25,14 @@ describe("usage_ledger cleanup immunity", () => { expect(removeUserBody).not.toContain("db.delete(usageLedger)"); }); - it("resetUserAllStatistics deletes from both tables (inside transaction)", () => { - const resetMatch = usersTs.match(/export async function resetUserAllStatistics[\s\S]*?^}/m); - expect(resetMatch).not.toBeNull(); - const resetBody = resetMatch![0]; - expect(resetBody).toContain("tx.delete(messageRequest)"); - expect(resetBody).toContain("tx.delete(usageLedger)"); + it("the dedicated statistics reset service deletes both tables in batches", () => { + expect(resetServiceTs).toContain("DELETE FROM message_request"); + expect(resetServiceTs).toContain("DELETE FROM usage_ledger"); + expect(resetServiceTs).toContain("FOR UPDATE SKIP LOCKED"); }); - it("resetUserAllStatistics is the only usageLedger delete path in users.ts", () => { - // Transaction-based: tx.delete(usageLedger) - const allDeleteMatches = [...usersTs.matchAll(/\.delete\(usageLedger\)/g)]; - expect(allDeleteMatches).toHaveLength(1); - - const deleteIndex = usersTs.indexOf(".delete(usageLedger)"); - const precedingChunk = usersTs.slice(Math.max(0, deleteIndex - 2000), deleteIndex); - expect(precedingChunk).toContain("resetUserAllStatistics"); + it("users actions enqueue the reset instead of deleting usageLedger inline", () => { + expect(usersTs).not.toContain(".delete(usageLedger)"); + expect(usersTs).toContain("enqueueUserStatisticsReset"); }); }); diff --git a/tests/unit/user-dialogs.test.tsx b/tests/unit/user-dialogs.test.tsx index 199298b87..16e521898 100644 --- a/tests/unit/user-dialogs.test.tsx +++ b/tests/unit/user-dialogs.test.tsx @@ -45,6 +45,7 @@ const mockToggleUserEnabled = vi.fn().mockResolvedValue({ ok: true }); const mockResetUser5hLimitOnly = vi.fn().mockResolvedValue({ ok: true }); const mockResetUserLimitsOnly = vi.fn().mockResolvedValue({ ok: true }); const mockResetUserAllStatistics = vi.fn().mockResolvedValue({ ok: true }); +const mockGetUserStatisticsReset = vi.fn(); const mockAddKey = vi.fn().mockResolvedValue({ ok: true, data: { key: "sk-test-key" } }); const mockEditKey = vi.fn().mockResolvedValue({ ok: true }); const mockCreateUserOnly = vi.fn().mockResolvedValue({ ok: true, data: { user: { id: 1 } } }); @@ -58,6 +59,34 @@ vi.mock("@/actions/users", () => ({ createUserOnly: (...args: unknown[]) => mockCreateUserOnly(...args), })); +vi.mock("@/lib/api-client/v1/actions/users", () => ({ + editUser: (...args: unknown[]) => mockEditUser(...args), + getUserStatisticsReset: (...args: unknown[]) => mockGetUserStatisticsReset(...args), + removeUser: (...args: unknown[]) => mockRemoveUser(...args), + resetUserLimitsOnly: (...args: unknown[]) => mockResetUserLimitsOnly(...args), + resetUserAllStatistics: (...args: unknown[]) => mockResetUserAllStatistics(...args), + toggleUserEnabled: (...args: unknown[]) => mockToggleUserEnabled(...args), +})); + +vi.mock("@/components/ui/alert-dialog", () => { + type PropsWithChildren = { children?: ReactNode }; + const Wrap = ({ children }: PropsWithChildren) =>
{children}
; + const Button = ({ children, ...props }: PropsWithChildren & Record) => ( + + ); + return { + AlertDialog: Wrap, + AlertDialogAction: Button, + AlertDialogCancel: Button, + AlertDialogContent: Wrap, + AlertDialogDescription: Wrap, + AlertDialogFooter: Wrap, + AlertDialogHeader: Wrap, + AlertDialogTitle: Wrap, + AlertDialogTrigger: Wrap, + }; +}); + vi.mock("@/app/[locale]/dashboard/_components/user/actions/reset-user-5h-limit", () => ({ resetUser5hLimitOnly: (...args: unknown[]) => mockResetUser5hLimitOnly(...args), })); @@ -258,6 +287,20 @@ describe("EditUserDialog", () => { defaultOptions: { queries: { retry: false } }, }); vi.clearAllMocks(); + mockResetUserAllStatistics.mockResolvedValue({ + ok: true, + data: { + resetId: "00000000-0000-4000-8000-000000000001", + userId: 1, + status: "queued", + requestedAt: "2026-08-02T12:00:00.000Z", + startedAt: null, + completedAt: null, + deletedMessageRequests: 0, + deletedUsageLedger: 0, + errorCode: null, + }, + }); }); afterEach(() => { @@ -381,6 +424,94 @@ describe("EditUserDialog", () => { unmount(); }); + + test("keeps the authoritative reset state after polling retries are exhausted", async () => { + vi.useFakeTimers(); + mockGetUserStatisticsReset.mockResolvedValue({ + ok: false, + error: "Service unavailable", + errorCode: "CONNECTION_FAILED", + }); + const { container, unmount } = renderWithProviders( + + ); + const buttons = Array.from(container.querySelectorAll("button")); + const resetButton = buttons.find( + (button) => + button.textContent?.trim() === messages.dashboard.userManagement.editDialog.resetData.button + ); + const confirmButton = buttons.find( + (button) => + button.textContent?.trim() === + messages.dashboard.userManagement.editDialog.resetData.confirm + ); + + act(() => resetButton?.click()); + await act(async () => confirmButton?.click()); + await act(async () => vi.advanceTimersByTimeAsync(32_000)); + + expect(mockGetUserStatisticsReset).toHaveBeenCalledTimes(6); + expect(container.querySelector('[data-testid="statistics-reset-status"]')?.textContent).toBe( + messages.dashboard.userManagement.editDialog.resetData.queued + ); + expect( + container.querySelector('[data-testid="statistics-reset-poll-error"]')?.textContent + ).toBe(messages.dashboard.userManagement.editDialog.resetData.statusUnavailable); + expect(mockResetUserAllStatistics).toHaveBeenCalledTimes(1); + + const retryStatusButton = Array.from(container.querySelectorAll("button")).find( + (button) => + button.textContent?.trim() === + messages.dashboard.userManagement.editDialog.resetData.retryStatus + ); + act(() => retryStatusButton?.click()); + await act(async () => vi.advanceTimersByTimeAsync(1_000)); + + expect(mockGetUserStatisticsReset).toHaveBeenCalledTimes(7); + expect(mockResetUserAllStatistics).toHaveBeenCalledTimes(1); + + unmount(); + vi.useRealTimers(); + }); + + test("aborts a hung statistics reset status request and continues polling", async () => { + vi.useFakeTimers(); + const requestSignals: AbortSignal[] = []; + mockGetUserStatisticsReset.mockImplementation( + (_userId: number, _resetId: string, options: { signal: AbortSignal }) => { + requestSignals.push(options.signal); + return new Promise((resolve) => { + options.signal.addEventListener("abort", () => { + resolve({ ok: false, error: "aborted", errorCode: "NETWORK_ERROR" }); + }); + }); + } + ); + const { container, unmount } = renderWithProviders( + + ); + const buttons = Array.from(container.querySelectorAll("button")); + const resetButton = buttons.find( + (button) => + button.textContent?.trim() === messages.dashboard.userManagement.editDialog.resetData.button + ); + const confirmButton = buttons.find( + (button) => + button.textContent?.trim() === + messages.dashboard.userManagement.editDialog.resetData.confirm + ); + + act(() => resetButton?.click()); + await act(async () => confirmButton?.click()); + await act(async () => vi.advanceTimersByTimeAsync(17_000)); + + expect(requestSignals[0]?.aborted).toBe(true); + expect(mockGetUserStatisticsReset).toHaveBeenCalledTimes(2); + + unmount(); + expect(requestSignals[1]?.aborted).toBe(true); + vi.useRealTimers(); + }); }); describe("EditKeyDialog", () => {