Files
dsh_shenxian/src/db/schema.ts
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

518 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Schema migrations, dual-dialect. Each migration carries SQLite and Postgres
* DDL; the active adapter runs only its own dialect. `schema_migrations` is
* shared (same table shape), so a SQLite↔Postgres dump/restore round-trips the
* applied-version marker too.
*
* Dialect notes (kept out of the route layer):
* - timestamps are epoch **milliseconds** (Date.now()), which exceeds 32-bit
* `INTEGER`; SQLite `INTEGER` is 64-bit, Postgres uses `BIGINT`.
* - `enabled`/`verified` are `INTEGER 0/1` in *both* dialects so the row mappers
* stay byte-identical across backends (no boolean/0/1 branch).
* - `audit_log.id` uses SQLite `AUTOINCREMENT` vs Postgres `IDENTITY`.
* @module dshs/db/schema
*/
import type { Database } from './connection.js'
import type { Pool } from 'pg'
const SQLITE_V1 = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pending'
CHECK (role IN ('admin','pending','active','disabled')),
home_dir TEXT NOT NULL,
api_key_ref TEXT,
created_at INTEGER NOT NULL,
approved_by TEXT REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
ip TEXT,
user_agent TEXT
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
rel_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (user_id, rel_path)
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS folder_plugins (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
plugin_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
description TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (workspace_id, plugin_id)
);
CREATE TABLE IF NOT EXISTS dsh_instances (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id),
role TEXT NOT NULL CHECK (role IN ('main','watchdog')),
pid INTEGER,
port INTEGER,
status TEXT NOT NULL
CHECK (status IN ('starting','running','crashed','repairing','stopped')),
started_at INTEGER,
last_exit INTEGER,
exit_code INTEGER,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT
);
CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY,
user_id TEXT UNIQUE REFERENCES users(id),
domain TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
nginx_config TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credential_vault (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
key_name TEXT NOT NULL,
secret_ref TEXT NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (user_id, key_name)
);
`
const SQLITE_V2 = `
ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
`
const PG_V1 = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pending'
CHECK (role IN ('admin','pending','active','disabled')),
home_dir TEXT NOT NULL,
api_key_ref TEXT,
created_at BIGINT NOT NULL,
approved_by TEXT REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
ip TEXT,
user_agent TEXT
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
rel_path TEXT NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE (user_id, rel_path)
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS folder_plugins (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
plugin_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
description TEXT,
updated_at BIGINT NOT NULL,
PRIMARY KEY (workspace_id, plugin_id)
);
CREATE TABLE IF NOT EXISTS dsh_instances (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id),
role TEXT NOT NULL CHECK (role IN ('main','watchdog')),
pid INTEGER,
port INTEGER,
status TEXT NOT NULL
CHECK (status IN ('starting','running','crashed','repairing','stopped')),
started_at BIGINT,
last_exit BIGINT,
exit_code INTEGER,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ts BIGINT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT
);
CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY,
user_id TEXT UNIQUE REFERENCES users(id),
domain TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
nginx_config TEXT,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS credential_vault (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
key_name TEXT NOT NULL,
secret_ref TEXT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE (user_id, key_name)
);
`
const PG_V2 = `
ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
`
// v3: per-user Linux uid (non-colliding for new users via an identity column).
// SQLite reuses its implicit `rowid` for the incrementing integer, so only the
// `uid` column is added here; Postgres adds an explicit identity `row_id`.
const SQLITE_V3 = `
ALTER TABLE users ADD COLUMN uid INTEGER;
`
const PG_V3 = `
ALTER TABLE users ADD COLUMN row_id BIGINT GENERATED ALWAYS AS IDENTITY;
ALTER TABLE users ADD COLUMN uid BIGINT;
`
// v4: desired-state columns on `dsh_instances`. The table has existed since v1
// but was never read or written; the k8s controller uses it as the reconcile
// target (docs/k8s.md §5.7), which needs the launch folder and the rendered
// Cordis patch to rebuild a Pod that went missing.
const SQLITE_V4 = `
ALTER TABLE dsh_instances ADD COLUMN folder TEXT;
ALTER TABLE dsh_instances ADD COLUMN patch TEXT;
`
const PG_V4 = `
ALTER TABLE dsh_instances ADD COLUMN folder TEXT;
ALTER TABLE dsh_instances ADD COLUMN patch TEXT;
`
// v5: business-plugin candidate pool (系统外插件 = 功能插件). Admin uploads a
// tgz bundle into the pool; users enable it per-instance from the dsh settings
// section (档案 16). Same-name upload REPLACES the row (not overwrite — the old
// tgz file is removed first, so deleted files cannot survive).
const SQLITE_V5 = `
CREATE TABLE IF NOT EXISTS business_plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
version TEXT,
tgz_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
uploaded_by TEXT REFERENCES users(id),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`
const PG_V5 = `
CREATE TABLE IF NOT EXISTS business_plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
version TEXT,
tgz_path TEXT NOT NULL,
file_size BIGINT NOT NULL,
uploaded_by TEXT REFERENCES users(id),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
`
// v6: 用户自配「模型厂家」支持(档案 87)。
// 原先 `credential_vault` 只存一把 key(`key_name` 兼作展示名);用户要按**厂家**配模型,
// 平台还需要知道:**route**(= settings.yaml 里 `llm-pi-ai.providers` 的 dict 键)、
// **endpoint**(`baseURL`)、**协议**(`api`)、**模型清单**(`models`)—— spawn 时按这四样写
// `$DSH_HOME/settings.yaml` 的 `llm-pi-ai.providers.<route>` 与
// `$DSH_HOME/.credentials.yaml` 的 `refs.<REF>`。
// · `base_url` 为空 = **内置 DeepSeek**(只写 refs,不写 settings.yaml)。
// · REF 命名:内置 = `DEEPSEEK_API_KEY`;自定义 = `<ROUTE 大写化>_API_KEY`
// (须匹配 `@deepseek-ai/dsh-credentials` 的 `REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/`)。
// · `api` 的合法值只有三个(`dsh-llm-pi-ai` 的 `PROTOCOLS` 键,顺序即默认优先级):
// `openai-completions` / `openai-responses` / `anthropic-messages`。
// · ⚠️ 字段名是 **`api` 不是 `protocol`**;`apiKeyEnv`(不是 `apiKey`);且该 profile
// **不接受** `provider` / `maxRetries` / `maxRetryDelayMs`(会直接抛错)。
// 以上全部为 2026-09-13 读官方包 `[email protected]` 的 `lib/index.js`
// (`const NS = "llm-pi-ai"` / `profile` schema / `PROTOCOLS`)实测结论。
// 另:`users.shared_model_enabled` = 用户**要不要用平台共享模型**(admin 配的那把)——
// 属于用户侧偏好,开关它**不动** admin 的配置(用户口径:条目各自开关,都能同时启用;
// 具体用哪个模型是在 dsh 对话框的模型选择器里挑)。
const SQLITE_V6 = `
ALTER TABLE credential_vault ADD COLUMN route TEXT;
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
ALTER TABLE credential_vault ADD COLUMN api TEXT;
ALTER TABLE credential_vault ADD COLUMN models TEXT;
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
`
const PG_V6 = `
ALTER TABLE credential_vault ADD COLUMN route TEXT;
ALTER TABLE credential_vault ADD COLUMN base_url TEXT;
ALTER TABLE credential_vault ADD COLUMN api TEXT;
ALTER TABLE credential_vault ADD COLUMN models TEXT;
ALTER TABLE users ADD COLUMN shared_model_enabled INTEGER NOT NULL DEFAULT 1;
`
// v7: 集群化 —— worker 注册表 + 实例归属/租约(T08 S2;设计 §3.1/§3.2)。
//
// 为什么需要它:local 模式靠"进程内 Map + 单机"天然保证「一个用户只有一个活实例」;
// 多机后这个保证必须落到 DB 的**原子 CAS** 上,否则两个 worker 会同时写同一个
// `$DSH_HOME`(会话日志 append 冲突 ⇒ 数据损坏)。
//
// · `dsh_hosts` = worker 注册表:agent 地址、容量、水位、心跳时间。
// · `dsh_instances.{host_id, epoch, heartbeat_at, lease_until}` = 归属与租约。
// `epoch` 是 **fencing token**:抢占时 +1,旧持有者的写入据此被拒(防脑裂双写)。
//
// 抢占语义(两方言同款,见 `repo.ts` 的 claimInstance / `pg.ts` 同名方法):
// `INSERT … ON CONFLICT(id) DO UPDATE SET … WHERE host_id IS NULL OR lease_until < now`
// —— 冲突时仅在"无人持有或租约过期"才更新;否则**不动行也不报错**,
// 调用方以「受影响行数 0」判定"有人在管"。
// ⚠️ 时间戳一律 **epoch 毫秒 BIGINT**(与全库一致,勿用 timestamptz)。
const SQLITE_V7 = `
CREATE TABLE IF NOT EXISTS dsh_hosts (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
agent_token TEXT NOT NULL,
capacity_mb INTEGER NOT NULL DEFAULT 0,
used_mb INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'up'
CHECK (status IN ('up','draining','down')),
last_heartbeat INTEGER
);
ALTER TABLE dsh_instances ADD COLUMN host_id TEXT;
ALTER TABLE dsh_instances ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0;
ALTER TABLE dsh_instances ADD COLUMN heartbeat_at INTEGER NOT NULL DEFAULT 0;
ALTER TABLE dsh_instances ADD COLUMN lease_until INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dsh_instances_host ON dsh_instances (host_id);
CREATE INDEX IF NOT EXISTS idx_dsh_instances_lease ON dsh_instances (lease_until);
`
const PG_V7 = `
CREATE TABLE IF NOT EXISTS dsh_hosts (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
agent_token TEXT NOT NULL,
capacity_mb BIGINT NOT NULL DEFAULT 0,
used_mb BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'up'
CHECK (status IN ('up','draining','down')),
last_heartbeat BIGINT
);
ALTER TABLE dsh_instances ADD COLUMN host_id TEXT;
ALTER TABLE dsh_instances ADD COLUMN epoch BIGINT NOT NULL DEFAULT 0;
ALTER TABLE dsh_instances ADD COLUMN heartbeat_at BIGINT NOT NULL DEFAULT 0;
ALTER TABLE dsh_instances ADD COLUMN lease_until BIGINT NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_dsh_instances_host ON dsh_instances (host_id);
CREATE INDEX IF NOT EXISTS idx_dsh_instances_lease ON dsh_instances (lease_until);
`
// v8(覆盖网络 S2):`dsh_hosts.via` = **"这台 worker 经谁可达"**(`Reachability.via` 词表)。
//
// 为什么必须加列:现网两条 `endpoint` 字符串**同形、语义不同**,表里无法表达"经谁中转"
// (会合中继拆分方案 §2 C3)⇒ 换会合 / 中继组件时无处安放,越晚改代价越大。
//
// 加列**带默认值** `manager-ssh` ⇒ **先加列 → 再改代码 → 最后回填**,任一步中断都不崩;
// 旧代码只读 `endpoint`,完全不受影响(列可留着不删 ⇒ 零风险回滚)。
// ⚠️ 默认值对 `w-106`(隧道落点)正确、对 `w-47`(同机直连)**不正确** ⇒ 回填由部署步骤
// 显式 `UPDATE ... WHERE id='w-47'` 完成,**不写进迁移**(迁移是静态 SQL,写死 hostId 在别的部署上会错)。
const SQLITE_V8 = `
ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';
`
const PG_V8 = `
ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';
`
// v9(覆盖网络 ②·P0-1):`dsh_hosts.network_id` = **这台 worker 属于哪张网**。
//
// 为什么必须加列:relay 的会话表 / 端点表原先只按 `hostId` 索引 —— 即平台自己的 Worker 隧道与
// 未来的用户设备挤进**同一个扁平命名空间**。今天只有 1 张网,问题不显形;一进第二类节点就会变成
// 「一张巨网 + 靠 ACL 兜」,而写错一条 ACL 就泄露 ⇒ 与「**权限只准收窄**」直接冲突。
// ⇒ 网维度必须做成**结构性**的:控制面这一侧落成显式列(本迁移),数据面落成 relay 的
// 「按网络分桶白名单 + 同网校验」(`src/net/relay/server.ts`)。
//
// 加列**带默认值** `ops`(运维网)⇒ **存量行天然正确**(现网 47 / 106 / manager 全在运维网),
// 且顺序是「**先加列 → 再改代码 → 最后才谈回填**」,任一步中断都不崩;旧代码只读旧列,零影响。
// ⚠️ 与 v8 的 `via` 同一条纪律:**迁移里不写 `UPDATE ... WHERE id='...'`**(迁移是静态 SQL,
// 写死 hostId 在别的部署上会错)—— 真要改某台机的网络归属,由部署步骤显式 `UPDATE` 完成。
const SQLITE_V9 = `
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
`
const PG_V9 = `
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
`
// v10(注册页人机验证 + 邮箱验证码):`users.email` + `email_codes` 事件表。
//
// 为什么放 DB 而不是进程内 Map:① 本表既是**验证码存储**,也是**防爆破计数器的唯一来源**
// (冷却 / 每时每刻配额 / 试错次数全靠 `COUNT(*)` 现算)—— 进程内的计数器**一次 restart 即清零**,
// 而 deployment 恰好天天重启,等于把限流关掉;② 发码是被外部触发的花钱动作(邮件配额),
// 必须可审计(`audit_log` 只记"发生过",记不了"每分钟多少次");③ PG/SQLite 双后端可查。
//
// 表设计取舍:**只建一张事件表**,把"发送请求"与"校验失败"都记成行(`status` 区分)——
// 比"验证码表 + 计数器表 + 黑名单表"三张表少两次 JOIN、且天然是取证时间线。
// · `status`:`sent`(已发出,可校验)/ `throttled`(被限流,占位计入配额)/ `failed`(驱动发信失败)
// · 只有 `sent` 且 `consumed_at IS NULL` 且未过期的那一行可被校验通过。
// · `code_hash` **不存明文**:sha256(email:purpose:code:pepper),pepper = 平台 `encryptionSecret`。
// · `attempts` 记该码**已被试错几次**,达上限即 `consumed_at` 打上(作废,必须重新获取)。
// ⚠️ 时间戳仍是 **epoch 毫秒 BIGINT**(与全库一致,勿用 timestamptz)。
// ⚠️ `users.email` 唯一索引:存量行全为 NULL ⇒ 两方言都允许多个 NULL ⇒ 迁移不会失败;
// 用 `LOWER(email)` 保证大小写不敏感唯一(注册时不区分大小写)。
const SQLITE_V10 = `
ALTER TABLE users ADD COLUMN email TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS email_codes (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT,
status TEXT NOT NULL DEFAULT 'sent'
CHECK (status IN ('sent','throttled','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
ip TEXT,
username TEXT,
reason TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER,
consumed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
`
const PG_V10 = `
ALTER TABLE users ADD COLUMN email TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS email_codes (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT,
status TEXT NOT NULL DEFAULT 'sent'
CHECK (status IN ('sent','throttled','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
ip TEXT,
username TEXT,
reason TEXT,
created_at BIGINT NOT NULL,
expires_at BIGINT,
consumed_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
`
interface Migration {
version: number
name: string
sqlite: string
pg: string
}
const MIGRATIONS: readonly Migration[] = [
{ version: 1, name: 'initial schema', sqlite: SQLITE_V1, pg: PG_V1 },
{ version: 2, name: 'credential vault enabled flag', sqlite: SQLITE_V2, pg: PG_V2 },
{ version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 },
{ version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 },
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
{ version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 },
{ version: 7, name: 'cluster host registry + instance lease', sqlite: SQLITE_V7, pg: PG_V7 },
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
{ version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 },
]
/** Apply unapplied SQLite migrations inside a single transaction. */
export function runSqliteMigrations(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
`)
const rows = db.prepare('SELECT version FROM schema_migrations').all() as Array<{ version: number }>
const applied = new Set(rows.map((row) => row.version))
const apply = db.transaction(() => {
for (const migration of MIGRATIONS) {
if (applied.has(migration.version)) continue
db.exec(migration.sqlite)
db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(
migration.version,
Date.now(),
)
}
})
apply()
}
/** Apply unapplied Postgres migrations inside a single transaction. */
export async function runPgMigrations(pool: Pool): Promise<void> {
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at BIGINT NOT NULL
);
`)
const { rows } = await client.query('SELECT version FROM schema_migrations')
const applied = new Set(rows.map((row) => row.version as number))
for (const migration of MIGRATIONS) {
if (applied.has(migration.version)) continue
await client.query(migration.pg)
await client.query('INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)', [
migration.version,
Date.now(),
])
}
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}