From 544b97ccbc034918d25e5c4ab9e72b0180b3401b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 11:29:05 +0000 Subject: [PATCH] feat(db): add SQLite deployment option alongside PostgreSQL Let users run the service without deploying PostgreSQL to cut deployment cost. The target database is selected from DATABASE_URL at deploy time and is fixed at runtime (no switching) to avoid data being split across stores: postgresql:// / postgres:// -> PostgreSQL (unchanged default) sqlite: / file: -> SQLite (embedded, bun:sqlite) - config: getDbDriver()/isSqliteUrl()/getSqliteFilePath() infer the driver from the connection string (defaults to postgres). - schema: split into schema.pg.ts (pg-core) and schema.sqlite.ts (sqlite-core) with identical columns/defaults/indexes; schema.ts exports the active driver's tables at runtime so the store layer is unchanged. - client: SQLite uses the built-in bun:sqlite + drizzle-orm/bun-sqlite (WAL, busy_timeout, auto-created parent dir), one shared connection. - migrate: per-driver migration folder (drizzle/ vs drizzle/sqlite/) and migrator; SQLite skips the pg advisory lock and readiness probe. Adds the generated SQLite baseline migration; drizzle.config.ts is driver-aware. - console-store: API-key quota charge uses portable stepwise atomic writes on SQLite instead of the pg-only UPDATE...FROM RETURNING/FOR UPDATE CTE; the console-clear guard is bypassed for SQLite (local embedded file). - server: database reset enumerates tables via sqlite_master on SQLite. - docs/ops: docker-compose.sqlite.yml, .env.example, READMEs, db/README; ignore local *.db/*.sqlite files. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U5dS4D3toAwqRG63xF97p1 --- .env.example | 7 +- .gitignore | 7 + README.en.md | 27 +- README.md | 27 +- changelog/2026-07-08.md | 13 + docker-compose.sqlite.yml | 21 + drizzle.config.ts | 31 +- drizzle/sqlite/0000_many_the_hand.sql | 137 ++++ drizzle/sqlite/meta/0000_snapshot.json | 946 +++++++++++++++++++++++++ drizzle/sqlite/meta/_journal.json | 13 + src/console-store.ts | 41 +- src/db/README.md | 26 +- src/db/client.ts | 45 +- src/db/config.ts | 54 ++ src/db/migrate.ts | 85 ++- src/db/schema.pg.ts | 151 ++++ src/db/schema.sqlite.ts | 155 ++++ src/db/schema.ts | 166 +---- src/server.ts | 35 +- 19 files changed, 1789 insertions(+), 198 deletions(-) create mode 100644 changelog/2026-07-08.md create mode 100644 docker-compose.sqlite.yml create mode 100644 drizzle/sqlite/0000_many_the_hand.sql create mode 100644 drizzle/sqlite/meta/0000_snapshot.json create mode 100644 drizzle/sqlite/meta/_journal.json create mode 100644 src/db/schema.pg.ts create mode 100644 src/db/schema.sqlite.ts diff --git a/.env.example b/.env.example index d5325b0..ca28592 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,12 @@ # Gateway authentication key — clients must pass this as x-api-key or Bearer token GATEWAY_API_KEY=your-gateway-api-key -# PostgreSQL connection string +# Database connection string. Two drivers are supported and the target is fixed +# at deploy time (it cannot be switched later without re-migrating data): +# PostgreSQL: postgresql://user:password@localhost:5432/ai_proxy +# SQLite: sqlite:./data/llm-relay.db (embedded, no external DB needed — +# use an absolute path like sqlite:///data/llm-relay.db in Docker +# and mount a volume so the file persists) DATABASE_URL=postgresql://user:password@localhost:5432/ai_proxy # ── Optional ────────────────────────────────────────────────────────────────── diff --git a/.gitignore b/.gitignore index 82a8945..072333b 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,13 @@ dist *.local.* data/ +# Local SQLite database files +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + # Personal test scripts (not for public distribution) scripts/ diff --git a/README.en.md b/README.en.md index 220bff3..7d888c0 100644 --- a/README.en.md +++ b/README.en.md @@ -78,7 +78,10 @@ LRS is a lightweight LLM relay service built on **Bun + Hono**. It unifies multi ### Prerequisites - [Bun](https://bun.sh) >= 1.1 -- A PostgreSQL database +- A database: PostgreSQL **or** SQLite (pick one) + - **SQLite**: embedded in the process, no separate database to deploy — cheapest option; just set `DATABASE_URL=sqlite:./data/llm-relay.db` + - **PostgreSQL**: better for higher concurrency / multiple instances + - ⚠️ The target database is fixed at deploy time and cannot be switched at runtime (to avoid data ending up split across two stores) ### Install and run @@ -169,6 +172,14 @@ docker compose pull && docker compose up -d > **Tip**: If you already have an external PostgreSQL, just remove the `postgres` service from `docker-compose.yml` and point `DATABASE_URL` at your connection string. +#### SQLite (no PostgreSQL, cheapest) + +If you'd rather not run PostgreSQL, use the bundled SQLite compose file — the database is a single file persisted on a named volume: + +```bash +GATEWAY_API_KEY=your-key docker compose -f docker-compose.sqlite.yml up -d +``` + ### Single-container Docker If you already have your own PostgreSQL, run a single container (it listens on `3300` by default): @@ -182,6 +193,18 @@ docker run -d \ ghcr.io/gojam11/llmrelayservice:main ``` +With SQLite (mount a volume so the database file persists): + +```bash +docker run -d \ + --name lrs \ + -p 3300:3300 \ + -e GATEWAY_API_KEY=your-key \ + -e DATABASE_URL=sqlite:///data/llm-relay.db \ + -v lrs_sqlite:/data \ + ghcr.io/gojam11/llmrelayservice:main +``` + Open `http://localhost:3300` to access the console. ### Build from source @@ -218,7 +241,7 @@ Open the root path `/` to access the console. Features include: | Variable | Required | Description | |----------|----------|-------------| -| `DATABASE_URL` | ✅ | PostgreSQL connection string | +| `DATABASE_URL` | ✅ | Database connection string. PostgreSQL: `postgresql://...`; SQLite: `sqlite:./data/llm-relay.db` (embedded, no separate database). Fixed at deploy time, not switchable at runtime | | `GATEWAY_API_KEY` | ✅ | The key clients use to access the gateway; also the console login password | | `PORT` | — | Listening port, default `3300` | | `UPSTREAM_DEFAULT_FIRST_BYTE_TIMEOUT_MS` | — | Default timeout for normal requests waiting on upstream response headers, default `300000` ms; can be overridden persistently in the console Settings page | diff --git a/README.md b/README.md index 2fbba7e..2a5a704 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,10 @@ LRS 是一个基于 **Bun + Hono** 的轻量 LLM 中继服务,把多个 AI 服 ### 前置条件 - [Bun](https://bun.sh) >= 1.1 -- PostgreSQL 数据库 +- 数据库:PostgreSQL **或** SQLite(二选一) + - **SQLite**:内嵌于进程,无需额外部署数据库,最省成本;设 `DATABASE_URL=sqlite:./data/llm-relay.db` 即可 + - **PostgreSQL**:适合更高并发/多实例场景 + - ⚠️ 目标数据库在部署时确定,运行时不支持切换(避免数据分散在两套库中) ### 安装与启动 @@ -169,6 +172,14 @@ docker compose pull && docker compose up -d > **提示**:如已有外部 PostgreSQL,只需删除 `docker-compose.yml` 中的 `postgres` 服务,并将 `DATABASE_URL` 改为对应连接字符串。 +#### SQLite(无需 PostgreSQL,最省成本) + +不想部署 PostgreSQL 时,可使用内置的 SQLite 编排文件,数据库为单个文件并持久化到命名卷: + +```bash +GATEWAY_API_KEY=your-key docker compose -f docker-compose.sqlite.yml up -d +``` + ### 单容器 Docker 如果你已经有自己的 PostgreSQL,可以直接运行单个容器(容器默认监听 `3300`): @@ -182,6 +193,18 @@ docker run -d \ ghcr.io/gojam11/llmrelayservice:main ``` +使用 SQLite(挂载卷以持久化数据库文件): + +```bash +docker run -d \ + --name lrs \ + -p 3300:3300 \ + -e GATEWAY_API_KEY=your-key \ + -e DATABASE_URL=sqlite:///data/llm-relay.db \ + -v lrs_sqlite:/data \ + ghcr.io/gojam11/llmrelayservice:main +``` + 访问 `http://localhost:3300` 打开控制台。 ### 从源码构建 @@ -218,7 +241,7 @@ Railway / Render 等平台部署时构建命令同上。 | 变量 | 必填 | 说明 | |------|------|------| -| `DATABASE_URL` | ✅ | PostgreSQL 连接字符串 | +| `DATABASE_URL` | ✅ | 数据库连接字符串。PostgreSQL:`postgresql://...`;SQLite:`sqlite:./data/llm-relay.db`(内嵌,无需额外数据库)。部署时确定,运行时不可切换 | | `GATEWAY_API_KEY` | ✅ | 客户端访问网关所需的 key,同时用作控制台登录密码 | | `PORT` | — | 监听端口,默认 `3300` | | `UPSTREAM_DEFAULT_FIRST_BYTE_TIMEOUT_MS` | — | 普通请求等待上游响应头的默认超时时间,默认 `300000` 毫秒;可在控制台配置页持久化覆盖 | diff --git a/changelog/2026-07-08.md b/changelog/2026-07-08.md new file mode 100644 index 0000000..934a258 --- /dev/null +++ b/changelog/2026-07-08.md @@ -0,0 +1,13 @@ +# 2026-07-08 + +### 新增 + +- **SQLite 部署方式**:允许在不部署 PostgreSQL 的情况下运行本项目,降低部署成本。目标数据库由 `DATABASE_URL` 协议自动选择(`postgresql://` → PostgreSQL;`sqlite:` / `file:` → SQLite),部署时确定、运行时不支持切换以避免数据分散。实现方式: + - 新增 `src/db/config.ts` 中的 `getDbDriver()` / `isSqliteUrl()` / `getSqliteFilePath()`,从连接串推断驱动。 + - 拆分 schema 为 `schema.pg.ts`(`pg-core`)与 `schema.sqlite.ts`(`sqlite-core`),列名/默认值/索引保持一致;`schema.ts` 按当前驱动运行时导出对应表对象,store 层无需改动。 + - `client.ts` 在 SQLite 下使用内置 `bun:sqlite` + `drizzle-orm/bun-sqlite`(WAL、busy_timeout、自动创建父目录),进程内共享单连接。 + - `migrate.ts` 按驱动选择迁移目录(PostgreSQL `drizzle/`,SQLite `drizzle/sqlite/`)并用对应 migrator 执行;SQLite 无需 advisory lock 与就绪探针。 + - 新增 SQLite 基线迁移 `drizzle/sqlite/0000_*.sql`(drizzle-kit 生成);`drizzle.config.ts` 按驱动切换 dialect / schema / out。 + - `console-store.ts` 的 API Key 配额扣费在 SQLite 下改用等价的分步原子写(替代 PostgreSQL 专有的 `UPDATE...FROM ... RETURNING` + `FOR UPDATE` CTE);控制台清库保护对 SQLite 直接放行(本地内嵌文件无远程误删风险)。 + - `server.ts` 的数据库重置在 SQLite 下改用 `sqlite_master` 枚举并 DROP 表后重迁移。 + - 新增 `docker-compose.sqlite.yml`(单容器 + 命名卷持久化),并在 `.env.example`、`README.md`、`README.en.md`、`src/db/README.md` 补充 SQLite 部署说明。 diff --git a/docker-compose.sqlite.yml b/docker-compose.sqlite.yml new file mode 100644 index 0000000..fe1bf9e --- /dev/null +++ b/docker-compose.sqlite.yml @@ -0,0 +1,21 @@ +# SQLite deployment — no external database container required. The database is a +# single embedded file persisted on the `sqlite_data` volume. Start with: +# GATEWAY_API_KEY=your-key docker compose -f docker-compose.sqlite.yml up -d +services: + app: + image: ghcr.io/gojam11/llmrelayservice:main + ports: + - "3001:3000" + environment: + PORT: 3000 + # Absolute path inside the container; the parent dir is created on startup. + DATABASE_URL: sqlite:///data/llm-relay.db + GATEWAY_API_KEY: ${GATEWAY_API_KEY:?GATEWAY_API_KEY is required} + PASSWORD: ${PASSWORD:-} + DEBUG_DB_MAX_RECORDS: ${DEBUG_DB_MAX_RECORDS:-50000} + volumes: + - sqlite_data:/data + restart: unless-stopped + +volumes: + sqlite_data: diff --git a/drizzle.config.ts b/drizzle.config.ts index 1f84207..1113939 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,11 +1,24 @@ import { defineConfig } from 'drizzle-kit'; -import { getDatabaseUrl } from './src/db/config'; +import { getDatabaseUrl, getDbDriver, getSqliteFilePath } from './src/db/config'; -export default defineConfig({ - schema: './src/db/schema.ts', - out: './drizzle', - dialect: 'postgresql', - dbCredentials: { - url: getDatabaseUrl(), - }, -}); +// The dialect is selected from DATABASE_URL. To generate/apply SQLite migrations: +// DATABASE_URL=sqlite:./data/llm-relay.db bun run db:generate +// and for PostgreSQL (the default): +// DATABASE_URL=postgresql://... bun run db:generate +export default getDbDriver() === 'sqlite' + ? defineConfig({ + schema: './src/db/schema.sqlite.ts', + out: './drizzle/sqlite', + dialect: 'sqlite', + dbCredentials: { + url: getSqliteFilePath(), + }, + }) + : defineConfig({ + schema: './src/db/schema.pg.ts', + out: './drizzle', + dialect: 'postgresql', + dbCredentials: { + url: getDatabaseUrl(), + }, + }); diff --git a/drizzle/sqlite/0000_many_the_hand.sql b/drizzle/sqlite/0000_many_the_hand.sql new file mode 100644 index 0000000..e102b2b --- /dev/null +++ b/drizzle/sqlite/0000_many_the_hand.sql @@ -0,0 +1,137 @@ +CREATE TABLE `console_api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `key_hash` text NOT NULL, + `key_value` text NOT NULL, + `prefix` text NOT NULL, + `created_at` integer NOT NULL, + `last_used_at` integer, + `revoked` integer DEFAULT 0 NOT NULL, + `allowed_models_json` text DEFAULT '[]' NOT NULL, + `cost_quota_microusd` integer, + `cost_used_microusd` integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_console_api_keys_key_hash` ON `console_api_keys` (`key_hash`);--> statement-breakpoint +CREATE INDEX `idx_console_api_keys_created_at` ON `console_api_keys` (`created_at`);--> statement-breakpoint +CREATE TABLE `console_providers` ( + `channel_name` text PRIMARY KEY NOT NULL, + `provider_uuid` text DEFAULT '' NOT NULL, + `type` text NOT NULL, + `target_base_url` text NOT NULL, + `system_prompt` text, + `models_json` text DEFAULT '[]' NOT NULL, + `priority` integer DEFAULT 0 NOT NULL, + `auth_header` text, + `auth_value` text, + `extra_fields_json` text DEFAULT '' NOT NULL, + `routing_visibility` text DEFAULT 'direct' NOT NULL, + `enabled` integer DEFAULT 1 NOT NULL, + `auto_sync_models` integer DEFAULT 0 NOT NULL, + `models_synced_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_console_providers_created_at` ON `console_providers` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_console_providers_updated_at` ON `console_providers` (`updated_at`);--> statement-breakpoint +CREATE TABLE `console_requests` ( + `request_id` text PRIMARY KEY NOT NULL, + `created_at` integer NOT NULL, + `route_prefix` text NOT NULL, + `upstream_type` text DEFAULT 'anthropic' NOT NULL, + `method` text NOT NULL, + `path` text NOT NULL, + `target_url` text NOT NULL, + `request_model` text NOT NULL, + `api_key_id` text, + `api_key_name` text, + `original_payload` text, + `original_payload_truncated` integer DEFAULT 0 NOT NULL, + `original_summary_json` text, + `forwarded_payload` text, + `forwarded_payload_truncated` integer DEFAULT 0 NOT NULL, + `forwarded_summary_json` text, + `original_headers_json` text, + `forward_headers_json` text, + `response_headers_json` text, + `response_status` integer, + `response_status_text` text, + `response_payload` text, + `response_payload_truncated` integer DEFAULT 0 NOT NULL, + `response_payload_truncation_reason` text, + `response_body_bytes` integer DEFAULT 0 NOT NULL, + `first_chunk_at` integer, + `first_token_at` integer, + `completed_at` integer, + `has_streaming_content` integer DEFAULT 0 NOT NULL, + `response_model` text, + `stop_reason` text, + `input_tokens` integer DEFAULT 0 NOT NULL, + `output_tokens` integer DEFAULT 0 NOT NULL, + `total_tokens` integer DEFAULT 0 NOT NULL, + `cache_creation_input_tokens` integer DEFAULT 0 NOT NULL, + `cache_read_input_tokens` integer DEFAULT 0 NOT NULL, + `cached_input_tokens` integer DEFAULT 0 NOT NULL, + `reasoning_output_tokens` integer DEFAULT 0 NOT NULL, + `ephemeral_5m_input_tokens` integer DEFAULT 0 NOT NULL, + `ephemeral_1h_input_tokens` integer DEFAULT 0 NOT NULL, + `quota_charged_microusd` integer DEFAULT 0 NOT NULL, + `cost_pricing_json` text, + `failover_from` text, + `failover_chain_json` text, + `original_route_prefix` text, + `original_request_model` text, + `failover_reason` text, + `retry_attempt` integer DEFAULT 0 NOT NULL, + `source_request_type` text DEFAULT 'unknown' NOT NULL, + `token_usage_estimated` integer DEFAULT 0 NOT NULL +); +--> statement-breakpoint +CREATE INDEX `idx_console_requests_created_at` ON `console_requests` (`created_at`);--> statement-breakpoint +CREATE INDEX `idx_console_requests_compare` ON `console_requests` (`route_prefix`,`method`,`path`,`request_model`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_console_requests_route_prefix` ON `console_requests` (`route_prefix`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_console_requests_response_model` ON `console_requests` (`response_model`,`created_at`);--> statement-breakpoint +CREATE INDEX `idx_console_requests_api_key_id` ON `console_requests` (`api_key_id`);--> statement-breakpoint +CREATE TABLE `gateway_settings` ( + `key` text PRIMARY KEY NOT NULL, + `value_json` text DEFAULT '{}' NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `model_aliases` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `alias` text NOT NULL, + `provider` text NOT NULL, + `model` text NOT NULL, + `targets_json` text DEFAULT '' NOT NULL, + `description` text, + `visible` integer DEFAULT 1 NOT NULL, + `enabled` integer DEFAULT 1 NOT NULL, + `return_real_model` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `model_aliases_alias_unique` ON `model_aliases` (`alias`);--> statement-breakpoint +CREATE INDEX `idx_model_aliases_alias` ON `model_aliases` (`alias`);--> statement-breakpoint +CREATE INDEX `idx_model_aliases_created_at` ON `model_aliases` (`created_at`);--> statement-breakpoint +CREATE TABLE `model_catalog_cache` ( + `model_id` text PRIMARY KEY NOT NULL, + `context_window` integer, + `pricing_json` text, + `fetched_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `model_metadata_overrides` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `channel_name` text NOT NULL, + `model_id` text NOT NULL, + `context_window` integer, + `pricing_json` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `idx_model_metadata_overrides_channel_model` ON `model_metadata_overrides` (`channel_name`,`model_id`);--> statement-breakpoint +CREATE INDEX `idx_model_metadata_overrides_updated_at` ON `model_metadata_overrides` (`updated_at`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json new file mode 100644 index 0000000..a97f2f7 --- /dev/null +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -0,0 +1,946 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8ff92e0f-f5a4-47a5-8ddc-eca6d9bfdf8e", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "console_api_keys": { + "name": "console_api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_value": { + "name": "key_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "allowed_models_json": { + "name": "allowed_models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "cost_quota_microusd": { + "name": "cost_quota_microusd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_used_microusd": { + "name": "cost_used_microusd", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_console_api_keys_key_hash": { + "name": "idx_console_api_keys_key_hash", + "columns": [ + "key_hash" + ], + "isUnique": false + }, + "idx_console_api_keys_created_at": { + "name": "idx_console_api_keys_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "console_providers": { + "name": "console_providers", + "columns": { + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider_uuid": { + "name": "provider_uuid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_base_url": { + "name": "target_base_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "system_prompt": { + "name": "system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "models_json": { + "name": "models_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "auth_header": { + "name": "auth_header", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auth_value": { + "name": "auth_value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_fields_json": { + "name": "extra_fields_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "routing_visibility": { + "name": "routing_visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'direct'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "auto_sync_models": { + "name": "auto_sync_models", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "models_synced_at": { + "name": "models_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_console_providers_created_at": { + "name": "idx_console_providers_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_console_providers_updated_at": { + "name": "idx_console_providers_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "console_requests": { + "name": "console_requests", + "columns": { + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "route_prefix": { + "name": "route_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_type": { + "name": "upstream_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'anthropic'" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_url": { + "name": "target_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_model": { + "name": "request_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_name": { + "name": "api_key_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_payload": { + "name": "original_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_payload_truncated": { + "name": "original_payload_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "original_summary_json": { + "name": "original_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forwarded_payload": { + "name": "forwarded_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forwarded_payload_truncated": { + "name": "forwarded_payload_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "forwarded_summary_json": { + "name": "forwarded_summary_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_headers_json": { + "name": "original_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "forward_headers_json": { + "name": "forward_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_headers_json": { + "name": "response_headers_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_status_text": { + "name": "response_status_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_payload": { + "name": "response_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_payload_truncated": { + "name": "response_payload_truncated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_payload_truncation_reason": { + "name": "response_payload_truncation_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_body_bytes": { + "name": "response_body_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "first_chunk_at": { + "name": "first_chunk_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_token_at": { + "name": "first_token_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "has_streaming_content": { + "name": "has_streaming_content", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_model": { + "name": "response_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cached_input_tokens": { + "name": "cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reasoning_output_tokens": { + "name": "reasoning_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ephemeral_5m_input_tokens": { + "name": "ephemeral_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "ephemeral_1h_input_tokens": { + "name": "ephemeral_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "quota_charged_microusd": { + "name": "quota_charged_microusd", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cost_pricing_json": { + "name": "cost_pricing_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failover_from": { + "name": "failover_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failover_chain_json": { + "name": "failover_chain_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_route_prefix": { + "name": "original_route_prefix", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "original_request_model": { + "name": "original_request_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failover_reason": { + "name": "failover_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_attempt": { + "name": "retry_attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_request_type": { + "name": "source_request_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "token_usage_estimated": { + "name": "token_usage_estimated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_console_requests_created_at": { + "name": "idx_console_requests_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "idx_console_requests_compare": { + "name": "idx_console_requests_compare", + "columns": [ + "route_prefix", + "method", + "path", + "request_model", + "created_at" + ], + "isUnique": false + }, + "idx_console_requests_route_prefix": { + "name": "idx_console_requests_route_prefix", + "columns": [ + "route_prefix", + "created_at" + ], + "isUnique": false + }, + "idx_console_requests_response_model": { + "name": "idx_console_requests_response_model", + "columns": [ + "response_model", + "created_at" + ], + "isUnique": false + }, + "idx_console_requests_api_key_id": { + "name": "idx_console_requests_api_key_id", + "columns": [ + "api_key_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "gateway_settings": { + "name": "gateway_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_aliases": { + "name": "model_aliases", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "alias": { + "name": "alias", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "targets_json": { + "name": "targets_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "visible": { + "name": "visible", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "return_real_model": { + "name": "return_real_model", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "model_aliases_alias_unique": { + "name": "model_aliases_alias_unique", + "columns": [ + "alias" + ], + "isUnique": true + }, + "idx_model_aliases_alias": { + "name": "idx_model_aliases_alias", + "columns": [ + "alias" + ], + "isUnique": false + }, + "idx_model_aliases_created_at": { + "name": "idx_model_aliases_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_catalog_cache": { + "name": "model_catalog_cache", + "columns": { + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pricing_json": { + "name": "pricing_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_metadata_overrides": { + "name": "model_metadata_overrides", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pricing_json": { + "name": "pricing_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_model_metadata_overrides_channel_model": { + "name": "idx_model_metadata_overrides_channel_model", + "columns": [ + "channel_name", + "model_id" + ], + "isUnique": true + }, + "idx_model_metadata_overrides_updated_at": { + "name": "idx_model_metadata_overrides_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json new file mode 100644 index 0000000..34b1c29 --- /dev/null +++ b/drizzle/sqlite/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1783509514553, + "tag": "0000_many_the_hand", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/src/console-store.ts b/src/console-store.ts index 600561c..0e6f72e 100644 --- a/src/console-store.ts +++ b/src/console-store.ts @@ -2,10 +2,10 @@ import { calculateCostWithPricing, ensurePricingLoaded, getModelPricing, type Co import { detectRequestKindForProvider } from './providers'; import type { DetectedRequestKind } from './providers'; import { createDbClient } from './db/client'; -import { getDatabaseUrl } from './db/config'; +import { getDatabaseUrl, getDbDriver } from './db/config'; import { isTrustedTestDatabaseUrl } from './db/test-database'; -import { consoleRequests } from './db/schema'; +import { consoleRequests, consoleApiKeys } from './db/schema'; import { eq, desc, asc, and, or, sql, count, gte, isNotNull, isNull, like, notInArray, type SQL } from 'drizzle-orm'; import { elapsedPerfMs, getMaxPerfPhase, nowPerfMs, shouldLogBackgroundPerf } from './perf-detail'; import { recordBackgroundPerfSample } from './perf-monitor'; @@ -370,6 +370,10 @@ const consoleStoreReady = Promise.resolve(); function assertConsoleClearAllowed(): void { if (process.env.ALLOW_CONSOLE_DB_CLEAR === '1') return; + // SQLite is an embedded, single-file local database — there is no remote host + // to accidentally wipe, so console clears are always permitted. + if (getDbDriver() === 'sqlite') return; + const databaseUrl = getDatabaseUrl(); const parsed = new URL(databaseUrl); const host = parsed.hostname.toLowerCase(); @@ -1421,6 +1425,11 @@ async function syncApiKeyQuotaCharge( const cost = calculateCostWithPricing(responseUsage, pricing, upstreamType); const chargedMicrousd = usdCostToChargeMicrousd(cost.total_cost); + if (getDbDriver() === 'sqlite') { + await syncApiKeyQuotaChargeSqlite(requestId, chargedMicrousd); + return; + } + await db.execute(sql` WITH target AS ( SELECT request_id, api_key_id, quota_charged_microusd @@ -1445,6 +1454,34 @@ async function syncApiKeyQuotaCharge( `); } +// SQLite lacks `UPDATE ... FROM ... RETURNING` in a CTE, `FOR UPDATE` row locks +// and `GREATEST`. bun:sqlite serializes writes, and each statement below is +// individually atomic — the key increment is a single read-modify-write SQL +// statement, so concurrent charges against the same key still compose without +// lost updates. `delta` is derived from the request's own persisted charge, so +// re-running for the same request is idempotent. +async function syncApiKeyQuotaChargeSqlite(requestId: string, chargedMicrousd: number): Promise { + const rows = await db + .select({ apiKeyId: consoleRequests.apiKeyId, quotaCharged: consoleRequests.quotaChargedMicrousd }) + .from(consoleRequests) + .where(eq(consoleRequests.requestId, requestId)); + const row = rows[0]; + if (!row) return; + + await db + .update(consoleRequests) + .set({ quotaChargedMicrousd: chargedMicrousd }) + .where(eq(consoleRequests.requestId, requestId)); + + const delta = chargedMicrousd - row.quotaCharged; + if (row.apiKeyId && delta !== 0) { + await db + .update(consoleApiKeys) + .set({ costUsedMicrousd: sql`MAX(0, ${consoleApiKeys.costUsedMicrousd} + ${delta})` }) + .where(eq(consoleApiKeys.id, row.apiKeyId)); + } +} + export async function saveConsoleRequest(record: ConsoleRequestSnapshotInput): Promise { try { const totalStart = nowPerfMs(); diff --git a/src/db/README.md b/src/db/README.md index c06fcd2..61a0f99 100644 --- a/src/db/README.md +++ b/src/db/README.md @@ -1,19 +1,35 @@ # 数据库迁移指南 -## 使用 Drizzle ORM 管理 PostgreSQL Schema +## 使用 Drizzle ORM 管理 Schema(支持 PostgreSQL 与 SQLite) + +本项目同时支持 **PostgreSQL** 与 **SQLite** 两种部署方式,通过 `DATABASE_URL` 的协议自动选择驱动: + +- `postgresql://...` / `postgres://...` → PostgreSQL +- `sqlite:./data/app.db` / `file:./app.db` / `sqlite::memory:` → SQLite(内嵌,无需额外数据库) + +> 目标数据库在部署时固定,运行时不支持切换,避免数据割裂问题。 + +### 双方言结构 + +- `schema.pg.ts` — PostgreSQL 表定义(`pg-core`) +- `schema.sqlite.ts` — SQLite 表定义(`sqlite-core`),列名/默认值/索引与 PG 保持一致 +- `schema.ts` — 按当前驱动运行时导出对应的一套表对象,store 层统一 import 这里 +- 迁移文件:PostgreSQL 在 `drizzle/`,SQLite 在 `drizzle/sqlite/` ### 命令 -- `bun run db:generate` - 根据 schema 定义生成迁移 SQL 文件 +- `bun run db:generate` - 根据 schema 定义生成迁移 SQL 文件(按 `DATABASE_URL` 选择方言) - `bun run db:migrate` - 执行迁移(将 SQL 应用到数据库) - `bun run db:push` - 直接推送 schema 到数据库(开发环境快速同步) - `bun run db:studio` - 启动 Drizzle Studio 可视化管理界面 ### 工作流程 -1. **修改 schema**: 编辑 `src/db/schema.ts` -2. **生成迁移**: `bun run db:generate` -3. **应用迁移**: `bun run db:migrate` 或在代码中调用 `runMigrations()` +1. **修改 schema**: 同步编辑 `src/db/schema.pg.ts` 与 `src/db/schema.sqlite.ts`(保持列名/约束一致) +2. **生成迁移**(两套都要生成并一起提交): + - PostgreSQL: `DATABASE_URL=postgresql://... bun run db:generate` + - SQLite: `DATABASE_URL=sqlite:./local.db bun run db:generate` +3. **应用迁移**: `bun run db:migrate` 或在代码中调用 `runMigrations()`(启动时自动执行,按驱动选择迁移目录) 前提: diff --git a/src/db/client.ts b/src/db/client.ts index 130dc9f..a74ef53 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,7 +1,10 @@ +import { dirname } from 'node:path'; +import { mkdirSync } from 'node:fs'; import { drizzle } from 'drizzle-orm/postgres-js'; +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; import postgres, { type Sql } from 'postgres'; import * as schema from './schema'; -import { getDatabaseUrl } from './config'; +import { getDatabaseUrl, getDbDriver, getSqliteFilePath } from './config'; let sharedSqlClient: Sql | null = null; @@ -18,8 +21,42 @@ export function getSqlClient(databaseUrl = getDatabaseUrl()): Sql { return sharedSqlClient; } -export function createDbClient(databaseUrl = getDatabaseUrl()) { - return drizzle(getSqlClient(databaseUrl), { schema }); +// A single bun:sqlite connection is shared process-wide. SQLite is embedded, so +// unlike PostgreSQL there is no connection pool; concurrent access is serialized +// by the driver (WAL mode + busy_timeout let readers and the single writer +// coexist without spurious SQLITE_BUSY errors). +let sharedSqliteDb: import('bun:sqlite').Database | null = null; + +function getSqliteDatabase(filePath: string): import('bun:sqlite').Database { + if (!sharedSqliteDb) { + const { Database } = require('bun:sqlite') as typeof import('bun:sqlite'); + if (filePath !== ':memory:') { + // Ensure the parent directory exists (e.g. a mounted /data volume). + mkdirSync(dirname(filePath), { recursive: true }); + } + sharedSqliteDb = new Database(filePath, { create: true }); + sharedSqliteDb.exec('PRAGMA journal_mode = WAL;'); + sharedSqliteDb.exec('PRAGMA busy_timeout = 5000;'); + sharedSqliteDb.exec('PRAGMA foreign_keys = ON;'); + } + return sharedSqliteDb; } -export type DbClient = ReturnType; +// The stores are typed against the PostgreSQL client surface. The subset of the +// query builder they use (select / insert / update / delete / onConflictDoUpdate +// / returning) is shared across dialects, so we cast the SQLite client to the +// same type. Dialect-specific raw SQL is branched explicitly at the call sites. +export type DbClient = PostgresJsDatabase; + +function createSqliteClient(databaseUrl = getDatabaseUrl()): DbClient { + const { drizzle: drizzleSqlite } = require('drizzle-orm/bun-sqlite') as typeof import('drizzle-orm/bun-sqlite'); + const sqliteDb = getSqliteDatabase(getSqliteFilePath(databaseUrl)); + return drizzleSqlite(sqliteDb, { schema }) as unknown as DbClient; +} + +export function createDbClient(databaseUrl = getDatabaseUrl()): DbClient { + if (getDbDriver() === 'sqlite') { + return createSqliteClient(databaseUrl); + } + return drizzle(getSqlClient(databaseUrl), { schema }); +} diff --git a/src/db/config.ts b/src/db/config.ts index 2731c87..678d834 100644 --- a/src/db/config.ts +++ b/src/db/config.ts @@ -1,5 +1,7 @@ import { TEST_DATABASE_URL } from './test-database'; +export type DbDriver = 'postgres' | 'sqlite'; + function isTestProcess(): boolean { if (process.env.USE_TEST_DATABASE === '1') return true; if (process.env.NODE_ENV === 'test') return true; @@ -21,3 +23,55 @@ export function getDatabaseUrl(): string { return databaseUrl; } + +/** + * A DATABASE_URL is treated as SQLite when it uses the `sqlite:` / `file:` scheme. + * Examples: + * sqlite:./data/llm-relay.db → relative file + * sqlite:///data/llm-relay.db → absolute file (/data/llm-relay.db) + * sqlite::memory: → in-memory database + * file:./llm-relay.db → relative file + * Anything else (postgres://, postgresql://, …) is treated as PostgreSQL. + */ +export function isSqliteUrl(databaseUrl: string): boolean { + const trimmed = databaseUrl.trim().toLowerCase(); + return trimmed.startsWith('sqlite:') || trimmed.startsWith('file:'); +} + +/** + * Resolve the target database driver from DATABASE_URL. Defaults to `postgres` + * (including when DATABASE_URL is unset, e.g. during tooling that only needs the + * default). The target database is fixed at deploy time — the driver is derived + * purely from the connection string and cannot be switched at runtime. + */ +export function getDbDriver(): DbDriver { + let url: string; + try { + url = getDatabaseUrl(); + } catch { + return 'postgres'; + } + return isSqliteUrl(url) ? 'sqlite' : 'postgres'; +} + +/** + * Extract the on-disk file path (or `:memory:`) from a SQLite DATABASE_URL. + * Throws when called with a non-SQLite URL. + */ +export function getSqliteFilePath(databaseUrl = getDatabaseUrl()): string { + if (!isSqliteUrl(databaseUrl)) { + throw new Error(`Not a SQLite DATABASE_URL: ${databaseUrl}`); + } + const trimmed = databaseUrl.trim(); + // Strip scheme (sqlite: / file:) + let rest = trimmed.replace(/^sqlite:/i, '').replace(/^file:/i, ''); + // In-memory database + if (rest === '' || rest === ':memory:' || rest === '//:memory:') { + return ':memory:'; + } + // `sqlite:///abs/path` → `/abs/path`; `sqlite://./rel` → `./rel`; `sqlite:./rel` → `./rel` + if (rest.startsWith('//')) { + rest = rest.slice(2); + } + return rest; +} diff --git a/src/db/migrate.ts b/src/db/migrate.ts index 6bf1a9d..93d4a64 100644 --- a/src/db/migrate.ts +++ b/src/db/migrate.ts @@ -2,7 +2,9 @@ import { drizzle } from 'drizzle-orm/postgres-js'; import { sql as drizzleSql } from 'drizzle-orm'; import { migrate } from 'drizzle-orm/postgres-js/migrator'; import postgres from 'postgres'; -import { getDatabaseUrl } from './config'; +import { dirname } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { getDatabaseUrl, getDbDriver, getSqliteFilePath } from './config'; import { TEST_DATABASE_URL } from './test-database'; export type MigrationStatus = @@ -10,6 +12,9 @@ export type MigrationStatus = | { state: 'skipped'; reason: string } | { state: 'failed'; error: string }; +export const PG_MIGRATIONS_FOLDER = './drizzle'; +export const SQLITE_MIGRATIONS_FOLDER = './drizzle/sqlite'; + const MIGRATION_LOCK_NAMESPACE = 20817; const MIGRATION_LOCK_KEY = 1; const migrationPromises = new Map>(); @@ -65,6 +70,59 @@ async function waitForDbReady(databaseUrl: string): Promise { } } +async function runSqliteMigrations(databaseUrl: string): Promise { + const { Database } = require('bun:sqlite') as typeof import('bun:sqlite'); + const { drizzle: drizzleSqlite } = require('drizzle-orm/bun-sqlite') as typeof import('drizzle-orm/bun-sqlite'); + const { migrate: migrateSqlite } = require('drizzle-orm/bun-sqlite/migrator') as typeof import('drizzle-orm/bun-sqlite/migrator'); + + const filePath = getSqliteFilePath(databaseUrl); + if (filePath !== ':memory:') { + mkdirSync(dirname(filePath), { recursive: true }); + } + const sqliteDb = new Database(filePath, { create: true }); + try { + sqliteDb.exec('PRAGMA journal_mode = WAL;'); + sqliteDb.exec('PRAGMA busy_timeout = 5000;'); + const db = drizzleSqlite(sqliteDb); + console.info('[DB] Running migrations (sqlite)...'); + migrateSqlite(db, { migrationsFolder: SQLITE_MIGRATIONS_FOLDER }); + console.info('[DB] Migrations complete.'); + return { state: 'success' }; + } catch (err: any) { + const errorMessage = err?.message ?? String(err); + console.error('[DB] Migration failed:', errorMessage); + return { state: 'failed', error: errorMessage }; + } finally { + sqliteDb.close(); + } +} + +async function runPostgresMigrations(databaseUrl: string): Promise { + // Wait for PostgreSQL to be accepting connections + await waitForDbReady(databaseUrl); + + const sql = postgres(databaseUrl, { max: 1, prepare: false }); + const db = drizzle(sql); + + try { + await db.execute(drizzleSql`SELECT pg_advisory_lock(${MIGRATION_LOCK_NAMESPACE}, ${MIGRATION_LOCK_KEY})`); + console.info('[DB] Running migrations...'); + await migrate(db, { migrationsFolder: PG_MIGRATIONS_FOLDER }); + console.info('[DB] Migrations complete.'); + return { state: 'success' }; + } catch (err: any) { + const errorMessage = err?.message ?? String(err); + console.error('[DB] Migration failed:', errorMessage); + return { state: 'failed', error: errorMessage }; + } finally { + try { + await db.execute(drizzleSql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_NAMESPACE}, ${MIGRATION_LOCK_KEY})`); + } finally { + await sql.end(); + } + } +} + export async function runMigrations(databaseUrl = getDatabaseUrl(), force = false): Promise { if (!force) { const existingPromise = migrationPromises.get(databaseUrl); @@ -76,29 +134,10 @@ export async function runMigrations(databaseUrl = getDatabaseUrl(), force = fals return { state: 'skipped', reason: 'Test database detected' }; } - // Wait for PostgreSQL to be accepting connections - await waitForDbReady(databaseUrl); - - const sql = postgres(databaseUrl, { max: 1, prepare: false }); - const db = drizzle(sql); - - try { - await db.execute(drizzleSql`SELECT pg_advisory_lock(${MIGRATION_LOCK_NAMESPACE}, ${MIGRATION_LOCK_KEY})`); - console.info('[DB] Running migrations...'); - await migrate(db, { migrationsFolder: './drizzle' }); - console.info('[DB] Migrations complete.'); - return { state: 'success' }; - } catch (err: any) { - const errorMessage = err?.message ?? String(err); - console.error('[DB] Migration failed:', errorMessage); - return { state: 'failed', error: errorMessage }; - } finally { - try { - await db.execute(drizzleSql`SELECT pg_advisory_unlock(${MIGRATION_LOCK_NAMESPACE}, ${MIGRATION_LOCK_KEY})`); - } finally { - await sql.end(); - } + if (getDbDriver() === 'sqlite') { + return runSqliteMigrations(databaseUrl); } + return runPostgresMigrations(databaseUrl); })(); migrationPromises.set(databaseUrl, migrationPromise); diff --git a/src/db/schema.pg.ts b/src/db/schema.pg.ts new file mode 100644 index 0000000..cd236c8 --- /dev/null +++ b/src/db/schema.pg.ts @@ -0,0 +1,151 @@ +import { bigint, index, integer, pgTable, serial, text, uniqueIndex } from 'drizzle-orm/pg-core'; + +export const consoleRequests = pgTable('console_requests', { + requestId: text('request_id').primaryKey(), + createdAt: bigint('created_at', { mode: 'number' }).notNull(), + routePrefix: text('route_prefix').notNull(), + upstreamType: text('upstream_type').notNull().default('anthropic'), + method: text('method').notNull(), + path: text('path').notNull(), + targetUrl: text('target_url').notNull(), + requestModel: text('request_model').notNull(), + apiKeyId: text('api_key_id'), + apiKeyName: text('api_key_name'), + originalPayload: text('original_payload'), + originalPayloadTruncated: integer('original_payload_truncated').notNull().default(0), + originalSummaryJson: text('original_summary_json'), + forwardedPayload: text('forwarded_payload'), + forwardedPayloadTruncated: integer('forwarded_payload_truncated').notNull().default(0), + forwardedSummaryJson: text('forwarded_summary_json'), + originalHeadersJson: text('original_headers_json'), + forwardHeadersJson: text('forward_headers_json'), + responseHeadersJson: text('response_headers_json'), + responseStatus: integer('response_status'), + responseStatusText: text('response_status_text'), + responsePayload: text('response_payload'), + responsePayloadTruncated: integer('response_payload_truncated').notNull().default(0), + responsePayloadTruncationReason: text('response_payload_truncation_reason'), + responseBodyBytes: integer('response_body_bytes').notNull().default(0), + firstChunkAt: bigint('first_chunk_at', { mode: 'number' }), + firstTokenAt: bigint('first_token_at', { mode: 'number' }), + completedAt: bigint('completed_at', { mode: 'number' }), + hasStreamingContent: integer('has_streaming_content').notNull().default(0), + responseModel: text('response_model'), + stopReason: text('stop_reason'), + inputTokens: integer('input_tokens').notNull().default(0), + outputTokens: integer('output_tokens').notNull().default(0), + totalTokens: integer('total_tokens').notNull().default(0), + cacheCreationInputTokens: integer('cache_creation_input_tokens').notNull().default(0), + cacheReadInputTokens: integer('cache_read_input_tokens').notNull().default(0), + cachedInputTokens: integer('cached_input_tokens').notNull().default(0), + reasoningOutputTokens: integer('reasoning_output_tokens').notNull().default(0), + ephemeral5mInputTokens: integer('ephemeral_5m_input_tokens').notNull().default(0), + ephemeral1hInputTokens: integer('ephemeral_1h_input_tokens').notNull().default(0), + quotaChargedMicrousd: bigint('quota_charged_microusd', { mode: 'number' }).notNull().default(0), + // Per-unit pricing (ModelPricing JSON) effective at response time. Persisted so log / + // usage cost is computed from the price that applied when the request ran, not the + // current price — later price edits don't rewrite historical cost. Null for legacy rows. + costPricingJson: text('cost_pricing_json'), + failoverFrom: text('failover_from'), + failoverChainJson: text('failover_chain_json'), + originalRoutePrefix: text('original_route_prefix'), + originalRequestModel: text('original_request_model'), + failoverReason: text('failover_reason'), + retryAttempt: integer('retry_attempt').notNull().default(0), + sourceRequestType: text('source_request_type').notNull().default('unknown'), + tokenUsageEstimated: integer('token_usage_estimated').notNull().default(0), +}, (table) => ({ + createdAtIdx: index('idx_console_requests_created_at').on(table.createdAt), + compareIdx: index('idx_console_requests_compare').on( + table.routePrefix, + table.method, + table.path, + table.requestModel, + table.createdAt, + ), + routePrefixIdx: index('idx_console_requests_route_prefix').on(table.routePrefix, table.createdAt), + responseModelIdx: index('idx_console_requests_response_model').on(table.responseModel, table.createdAt), + apiKeyIdIdx: index('idx_console_requests_api_key_id').on(table.apiKeyId), +})); + +export const consoleApiKeys = pgTable('console_api_keys', { + id: text('id').primaryKey(), + name: text('name').notNull(), + keyHash: text('key_hash').notNull(), + keyValue: text('key_value').notNull(), + prefix: text('prefix').notNull(), + createdAt: bigint('created_at', { mode: 'number' }).notNull(), + lastUsedAt: bigint('last_used_at', { mode: 'number' }), + revoked: integer('revoked').notNull().default(0), + allowedModelsJson: text('allowed_models_json').notNull().default('[]'), + costQuotaMicrousd: bigint('cost_quota_microusd', { mode: 'number' }), + costUsedMicrousd: bigint('cost_used_microusd', { mode: 'number' }).notNull().default(0), +}, (table) => ({ + keyHashIdx: index('idx_console_api_keys_key_hash').on(table.keyHash), + createdAtIdx: index('idx_console_api_keys_created_at').on(table.createdAt), +})); + +export const consoleProviders = pgTable('console_providers', { + channelName: text('channel_name').primaryKey(), + providerUuid: text('provider_uuid').notNull().default(''), + type: text('type').notNull(), + targetBaseUrl: text('target_base_url').notNull(), + systemPrompt: text('system_prompt'), + modelsJson: text('models_json').notNull().default('[]'), + priority: integer('priority').notNull().default(0), + authHeader: text('auth_header'), + authValue: text('auth_value'), + extraFieldsJson: text('extra_fields_json').notNull().default(''), + routingVisibility: text('routing_visibility').notNull().default('direct'), + enabled: integer('enabled').notNull().default(1), + autoSyncModels: integer('auto_sync_models').notNull().default(0), + modelsSyncedAt: bigint('models_synced_at', { mode: 'number' }), + createdAt: bigint('created_at', { mode: 'number' }).notNull(), + updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), +}, (table) => ({ + createdAtIdx: index('idx_console_providers_created_at').on(table.createdAt), + updatedAtIdx: index('idx_console_providers_updated_at').on(table.updatedAt), +})); + +export const modelAliases = pgTable('model_aliases', { + id: serial('id').primaryKey(), + alias: text('alias').notNull().unique(), + provider: text('provider').notNull(), + model: text('model').notNull(), + targetsJson: text('targets_json').notNull().default(''), + description: text('description'), + visible: integer('visible').notNull().default(1), + enabled: integer('enabled').notNull().default(1), + returnRealModel: integer('return_real_model').notNull().default(0), + createdAt: bigint('created_at', { mode: 'number' }).notNull(), + updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), +}, (table) => ({ + aliasIdx: index('idx_model_aliases_alias').on(table.alias), + createdAtIdx: index('idx_model_aliases_created_at').on(table.createdAt), +})); + +export const modelCatalogCache = pgTable('model_catalog_cache', { + modelId: text('model_id').primaryKey(), + contextWindow: integer('context_window'), + pricingJson: text('pricing_json'), + fetchedAt: bigint('fetched_at', { mode: 'number' }).notNull(), +}); + +export const modelMetadataOverrides = pgTable('model_metadata_overrides', { + id: serial('id').primaryKey(), + channelName: text('channel_name').notNull(), + modelId: text('model_id').notNull(), + contextWindow: integer('context_window'), + pricingJson: text('pricing_json'), + createdAt: bigint('created_at', { mode: 'number' }).notNull(), + updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), +}, (table) => ({ + channelModelIdx: uniqueIndex('idx_model_metadata_overrides_channel_model').on(table.channelName, table.modelId), + updatedAtIdx: index('idx_model_metadata_overrides_updated_at').on(table.updatedAt), +})); + +export const gatewaySettings = pgTable('gateway_settings', { + key: text('key').primaryKey(), + valueJson: text('value_json').notNull().default('{}'), + updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), +}); diff --git a/src/db/schema.sqlite.ts b/src/db/schema.sqlite.ts new file mode 100644 index 0000000..4730358 --- /dev/null +++ b/src/db/schema.sqlite.ts @@ -0,0 +1,155 @@ +import { index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; + +// SQLite mirror of ./schema.pg.ts. Column names, defaults and indexes are kept +// identical so the shared Drizzle query-builder code in the stores works against +// either dialect. Type mapping vs PostgreSQL: +// pg `bigint({mode:'number'})` → sqlite `integer({mode:'number'})` (64-bit int) +// pg `serial` → sqlite `integer(primaryKey autoIncrement)` +// pg `integer` / `text` → sqlite `integer` / `text` + +export const consoleRequests = sqliteTable('console_requests', { + requestId: text('request_id').primaryKey(), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + routePrefix: text('route_prefix').notNull(), + upstreamType: text('upstream_type').notNull().default('anthropic'), + method: text('method').notNull(), + path: text('path').notNull(), + targetUrl: text('target_url').notNull(), + requestModel: text('request_model').notNull(), + apiKeyId: text('api_key_id'), + apiKeyName: text('api_key_name'), + originalPayload: text('original_payload'), + originalPayloadTruncated: integer('original_payload_truncated').notNull().default(0), + originalSummaryJson: text('original_summary_json'), + forwardedPayload: text('forwarded_payload'), + forwardedPayloadTruncated: integer('forwarded_payload_truncated').notNull().default(0), + forwardedSummaryJson: text('forwarded_summary_json'), + originalHeadersJson: text('original_headers_json'), + forwardHeadersJson: text('forward_headers_json'), + responseHeadersJson: text('response_headers_json'), + responseStatus: integer('response_status'), + responseStatusText: text('response_status_text'), + responsePayload: text('response_payload'), + responsePayloadTruncated: integer('response_payload_truncated').notNull().default(0), + responsePayloadTruncationReason: text('response_payload_truncation_reason'), + responseBodyBytes: integer('response_body_bytes').notNull().default(0), + firstChunkAt: integer('first_chunk_at', { mode: 'number' }), + firstTokenAt: integer('first_token_at', { mode: 'number' }), + completedAt: integer('completed_at', { mode: 'number' }), + hasStreamingContent: integer('has_streaming_content').notNull().default(0), + responseModel: text('response_model'), + stopReason: text('stop_reason'), + inputTokens: integer('input_tokens').notNull().default(0), + outputTokens: integer('output_tokens').notNull().default(0), + totalTokens: integer('total_tokens').notNull().default(0), + cacheCreationInputTokens: integer('cache_creation_input_tokens').notNull().default(0), + cacheReadInputTokens: integer('cache_read_input_tokens').notNull().default(0), + cachedInputTokens: integer('cached_input_tokens').notNull().default(0), + reasoningOutputTokens: integer('reasoning_output_tokens').notNull().default(0), + ephemeral5mInputTokens: integer('ephemeral_5m_input_tokens').notNull().default(0), + ephemeral1hInputTokens: integer('ephemeral_1h_input_tokens').notNull().default(0), + quotaChargedMicrousd: integer('quota_charged_microusd', { mode: 'number' }).notNull().default(0), + costPricingJson: text('cost_pricing_json'), + failoverFrom: text('failover_from'), + failoverChainJson: text('failover_chain_json'), + originalRoutePrefix: text('original_route_prefix'), + originalRequestModel: text('original_request_model'), + failoverReason: text('failover_reason'), + retryAttempt: integer('retry_attempt').notNull().default(0), + sourceRequestType: text('source_request_type').notNull().default('unknown'), + tokenUsageEstimated: integer('token_usage_estimated').notNull().default(0), +}, (table) => ({ + createdAtIdx: index('idx_console_requests_created_at').on(table.createdAt), + compareIdx: index('idx_console_requests_compare').on( + table.routePrefix, + table.method, + table.path, + table.requestModel, + table.createdAt, + ), + routePrefixIdx: index('idx_console_requests_route_prefix').on(table.routePrefix, table.createdAt), + responseModelIdx: index('idx_console_requests_response_model').on(table.responseModel, table.createdAt), + apiKeyIdIdx: index('idx_console_requests_api_key_id').on(table.apiKeyId), +})); + +export const consoleApiKeys = sqliteTable('console_api_keys', { + id: text('id').primaryKey(), + name: text('name').notNull(), + keyHash: text('key_hash').notNull(), + keyValue: text('key_value').notNull(), + prefix: text('prefix').notNull(), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + lastUsedAt: integer('last_used_at', { mode: 'number' }), + revoked: integer('revoked').notNull().default(0), + allowedModelsJson: text('allowed_models_json').notNull().default('[]'), + costQuotaMicrousd: integer('cost_quota_microusd', { mode: 'number' }), + costUsedMicrousd: integer('cost_used_microusd', { mode: 'number' }).notNull().default(0), +}, (table) => ({ + keyHashIdx: index('idx_console_api_keys_key_hash').on(table.keyHash), + createdAtIdx: index('idx_console_api_keys_created_at').on(table.createdAt), +})); + +export const consoleProviders = sqliteTable('console_providers', { + channelName: text('channel_name').primaryKey(), + providerUuid: text('provider_uuid').notNull().default(''), + type: text('type').notNull(), + targetBaseUrl: text('target_base_url').notNull(), + systemPrompt: text('system_prompt'), + modelsJson: text('models_json').notNull().default('[]'), + priority: integer('priority').notNull().default(0), + authHeader: text('auth_header'), + authValue: text('auth_value'), + extraFieldsJson: text('extra_fields_json').notNull().default(''), + routingVisibility: text('routing_visibility').notNull().default('direct'), + enabled: integer('enabled').notNull().default(1), + autoSyncModels: integer('auto_sync_models').notNull().default(0), + modelsSyncedAt: integer('models_synced_at', { mode: 'number' }), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), +}, (table) => ({ + createdAtIdx: index('idx_console_providers_created_at').on(table.createdAt), + updatedAtIdx: index('idx_console_providers_updated_at').on(table.updatedAt), +})); + +export const modelAliases = sqliteTable('model_aliases', { + id: integer('id').primaryKey({ autoIncrement: true }), + alias: text('alias').notNull().unique(), + provider: text('provider').notNull(), + model: text('model').notNull(), + targetsJson: text('targets_json').notNull().default(''), + description: text('description'), + visible: integer('visible').notNull().default(1), + enabled: integer('enabled').notNull().default(1), + returnRealModel: integer('return_real_model').notNull().default(0), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), +}, (table) => ({ + aliasIdx: index('idx_model_aliases_alias').on(table.alias), + createdAtIdx: index('idx_model_aliases_created_at').on(table.createdAt), +})); + +export const modelCatalogCache = sqliteTable('model_catalog_cache', { + modelId: text('model_id').primaryKey(), + contextWindow: integer('context_window'), + pricingJson: text('pricing_json'), + fetchedAt: integer('fetched_at', { mode: 'number' }).notNull(), +}); + +export const modelMetadataOverrides = sqliteTable('model_metadata_overrides', { + id: integer('id').primaryKey({ autoIncrement: true }), + channelName: text('channel_name').notNull(), + modelId: text('model_id').notNull(), + contextWindow: integer('context_window'), + pricingJson: text('pricing_json'), + createdAt: integer('created_at', { mode: 'number' }).notNull(), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), +}, (table) => ({ + channelModelIdx: uniqueIndex('idx_model_metadata_overrides_channel_model').on(table.channelName, table.modelId), + updatedAtIdx: index('idx_model_metadata_overrides_updated_at').on(table.updatedAt), +})); + +export const gatewaySettings = sqliteTable('gateway_settings', { + key: text('key').primaryKey(), + valueJson: text('value_json').notNull().default('{}'), + updatedAt: integer('updated_at', { mode: 'number' }).notNull(), +}); diff --git a/src/db/schema.ts b/src/db/schema.ts index cd236c8..173ae1f 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,151 +1,19 @@ -import { bigint, index, integer, pgTable, serial, text, uniqueIndex } from 'drizzle-orm/pg-core'; +import { getDbDriver } from './config'; +import * as pgSchema from './schema.pg'; +import * as sqliteSchema from './schema.sqlite'; -export const consoleRequests = pgTable('console_requests', { - requestId: text('request_id').primaryKey(), - createdAt: bigint('created_at', { mode: 'number' }).notNull(), - routePrefix: text('route_prefix').notNull(), - upstreamType: text('upstream_type').notNull().default('anthropic'), - method: text('method').notNull(), - path: text('path').notNull(), - targetUrl: text('target_url').notNull(), - requestModel: text('request_model').notNull(), - apiKeyId: text('api_key_id'), - apiKeyName: text('api_key_name'), - originalPayload: text('original_payload'), - originalPayloadTruncated: integer('original_payload_truncated').notNull().default(0), - originalSummaryJson: text('original_summary_json'), - forwardedPayload: text('forwarded_payload'), - forwardedPayloadTruncated: integer('forwarded_payload_truncated').notNull().default(0), - forwardedSummaryJson: text('forwarded_summary_json'), - originalHeadersJson: text('original_headers_json'), - forwardHeadersJson: text('forward_headers_json'), - responseHeadersJson: text('response_headers_json'), - responseStatus: integer('response_status'), - responseStatusText: text('response_status_text'), - responsePayload: text('response_payload'), - responsePayloadTruncated: integer('response_payload_truncated').notNull().default(0), - responsePayloadTruncationReason: text('response_payload_truncation_reason'), - responseBodyBytes: integer('response_body_bytes').notNull().default(0), - firstChunkAt: bigint('first_chunk_at', { mode: 'number' }), - firstTokenAt: bigint('first_token_at', { mode: 'number' }), - completedAt: bigint('completed_at', { mode: 'number' }), - hasStreamingContent: integer('has_streaming_content').notNull().default(0), - responseModel: text('response_model'), - stopReason: text('stop_reason'), - inputTokens: integer('input_tokens').notNull().default(0), - outputTokens: integer('output_tokens').notNull().default(0), - totalTokens: integer('total_tokens').notNull().default(0), - cacheCreationInputTokens: integer('cache_creation_input_tokens').notNull().default(0), - cacheReadInputTokens: integer('cache_read_input_tokens').notNull().default(0), - cachedInputTokens: integer('cached_input_tokens').notNull().default(0), - reasoningOutputTokens: integer('reasoning_output_tokens').notNull().default(0), - ephemeral5mInputTokens: integer('ephemeral_5m_input_tokens').notNull().default(0), - ephemeral1hInputTokens: integer('ephemeral_1h_input_tokens').notNull().default(0), - quotaChargedMicrousd: bigint('quota_charged_microusd', { mode: 'number' }).notNull().default(0), - // Per-unit pricing (ModelPricing JSON) effective at response time. Persisted so log / - // usage cost is computed from the price that applied when the request ran, not the - // current price — later price edits don't rewrite historical cost. Null for legacy rows. - costPricingJson: text('cost_pricing_json'), - failoverFrom: text('failover_from'), - failoverChainJson: text('failover_chain_json'), - originalRoutePrefix: text('original_route_prefix'), - originalRequestModel: text('original_request_model'), - failoverReason: text('failover_reason'), - retryAttempt: integer('retry_attempt').notNull().default(0), - sourceRequestType: text('source_request_type').notNull().default('unknown'), - tokenUsageEstimated: integer('token_usage_estimated').notNull().default(0), -}, (table) => ({ - createdAtIdx: index('idx_console_requests_created_at').on(table.createdAt), - compareIdx: index('idx_console_requests_compare').on( - table.routePrefix, - table.method, - table.path, - table.requestModel, - table.createdAt, - ), - routePrefixIdx: index('idx_console_requests_route_prefix').on(table.routePrefix, table.createdAt), - responseModelIdx: index('idx_console_requests_response_model').on(table.responseModel, table.createdAt), - apiKeyIdIdx: index('idx_console_requests_api_key_id').on(table.apiKeyId), -})); +// The target database is fixed at deploy time (derived from DATABASE_URL). We +// expose a single set of table objects for the active driver; the store layer +// imports these and uses the shared Drizzle query builder, which emits the SQL +// dialect matching the connected client. Column names/shapes are identical +// across ./schema.pg.ts and ./schema.sqlite.ts, so we surface the PostgreSQL +// types as the canonical shape while swapping the runtime objects. +const active = getDbDriver() === 'sqlite' ? sqliteSchema : pgSchema; -export const consoleApiKeys = pgTable('console_api_keys', { - id: text('id').primaryKey(), - name: text('name').notNull(), - keyHash: text('key_hash').notNull(), - keyValue: text('key_value').notNull(), - prefix: text('prefix').notNull(), - createdAt: bigint('created_at', { mode: 'number' }).notNull(), - lastUsedAt: bigint('last_used_at', { mode: 'number' }), - revoked: integer('revoked').notNull().default(0), - allowedModelsJson: text('allowed_models_json').notNull().default('[]'), - costQuotaMicrousd: bigint('cost_quota_microusd', { mode: 'number' }), - costUsedMicrousd: bigint('cost_used_microusd', { mode: 'number' }).notNull().default(0), -}, (table) => ({ - keyHashIdx: index('idx_console_api_keys_key_hash').on(table.keyHash), - createdAtIdx: index('idx_console_api_keys_created_at').on(table.createdAt), -})); - -export const consoleProviders = pgTable('console_providers', { - channelName: text('channel_name').primaryKey(), - providerUuid: text('provider_uuid').notNull().default(''), - type: text('type').notNull(), - targetBaseUrl: text('target_base_url').notNull(), - systemPrompt: text('system_prompt'), - modelsJson: text('models_json').notNull().default('[]'), - priority: integer('priority').notNull().default(0), - authHeader: text('auth_header'), - authValue: text('auth_value'), - extraFieldsJson: text('extra_fields_json').notNull().default(''), - routingVisibility: text('routing_visibility').notNull().default('direct'), - enabled: integer('enabled').notNull().default(1), - autoSyncModels: integer('auto_sync_models').notNull().default(0), - modelsSyncedAt: bigint('models_synced_at', { mode: 'number' }), - createdAt: bigint('created_at', { mode: 'number' }).notNull(), - updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), -}, (table) => ({ - createdAtIdx: index('idx_console_providers_created_at').on(table.createdAt), - updatedAtIdx: index('idx_console_providers_updated_at').on(table.updatedAt), -})); - -export const modelAliases = pgTable('model_aliases', { - id: serial('id').primaryKey(), - alias: text('alias').notNull().unique(), - provider: text('provider').notNull(), - model: text('model').notNull(), - targetsJson: text('targets_json').notNull().default(''), - description: text('description'), - visible: integer('visible').notNull().default(1), - enabled: integer('enabled').notNull().default(1), - returnRealModel: integer('return_real_model').notNull().default(0), - createdAt: bigint('created_at', { mode: 'number' }).notNull(), - updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), -}, (table) => ({ - aliasIdx: index('idx_model_aliases_alias').on(table.alias), - createdAtIdx: index('idx_model_aliases_created_at').on(table.createdAt), -})); - -export const modelCatalogCache = pgTable('model_catalog_cache', { - modelId: text('model_id').primaryKey(), - contextWindow: integer('context_window'), - pricingJson: text('pricing_json'), - fetchedAt: bigint('fetched_at', { mode: 'number' }).notNull(), -}); - -export const modelMetadataOverrides = pgTable('model_metadata_overrides', { - id: serial('id').primaryKey(), - channelName: text('channel_name').notNull(), - modelId: text('model_id').notNull(), - contextWindow: integer('context_window'), - pricingJson: text('pricing_json'), - createdAt: bigint('created_at', { mode: 'number' }).notNull(), - updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), -}, (table) => ({ - channelModelIdx: uniqueIndex('idx_model_metadata_overrides_channel_model').on(table.channelName, table.modelId), - updatedAtIdx: index('idx_model_metadata_overrides_updated_at').on(table.updatedAt), -})); - -export const gatewaySettings = pgTable('gateway_settings', { - key: text('key').primaryKey(), - valueJson: text('value_json').notNull().default('{}'), - updatedAt: bigint('updated_at', { mode: 'number' }).notNull(), -}); +export const consoleRequests = active.consoleRequests as unknown as typeof pgSchema.consoleRequests; +export const consoleApiKeys = active.consoleApiKeys as unknown as typeof pgSchema.consoleApiKeys; +export const consoleProviders = active.consoleProviders as unknown as typeof pgSchema.consoleProviders; +export const modelAliases = active.modelAliases as unknown as typeof pgSchema.modelAliases; +export const modelCatalogCache = active.modelCatalogCache as unknown as typeof pgSchema.modelCatalogCache; +export const modelMetadataOverrides = active.modelMetadataOverrides as unknown as typeof pgSchema.modelMetadataOverrides; +export const gatewaySettings = active.gatewaySettings as unknown as typeof pgSchema.gatewaySettings; diff --git a/src/server.ts b/src/server.ts index a6a7625..fcc6f2d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,7 @@ import { fetchModelsDevData } from './model-catalog'; import { saveCatalogToDb } from './catalog-db'; import { initializeTokenEstimator } from './token-estimator'; import { runMigrations, type MigrationStatus } from './db/migrate'; -import { getDatabaseUrl } from './db/config'; +import { getDatabaseUrl, getDbDriver, getSqliteFilePath } from './db/config'; import postgres from 'postgres'; import { createCorsPreflightResponse, withCorsHeaders } from './cors'; @@ -60,7 +60,40 @@ function showMigrationGuide(status: Extract { + const databaseUrl = getDatabaseUrl(); + try { + const { Database } = require('bun:sqlite') as typeof import('bun:sqlite'); + const db = new Database(getSqliteFilePath(databaseUrl), { create: true }); + try { + db.exec('PRAGMA foreign_keys = OFF;'); + const tables = db + .query("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'") + .all() as { name: string }[]; + for (const { name } of tables) { + db.exec(`DROP TABLE IF EXISTS "${name}"`); + console.log(`[DB] Dropped table: ${name}`); + } + } finally { + db.close(); + } + + // 重新执行迁移(强制重新执行,不走缓存) + const result = await runMigrations(undefined, true); + if (result.state === 'success') { + return { success: true, message: '数据库已重置并重新迁移' }; + } + return { success: false, error: result.state === 'failed' ? result.error : '迁移失败' }; + } catch (err: any) { + return { success: false, error: err?.message ?? String(err) }; + } +} + async function resetDatabase(): Promise<{ success: boolean; message?: string; error?: string }> { + if (getDbDriver() === 'sqlite') { + return resetDatabaseSqlite(); + } + const databaseUrl = getDatabaseUrl(); const sql = postgres(databaseUrl, { max: 1, prepare: false });