Files
dsh_shenxian/src/db/schema.ts
T

329 lines
11 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
);
`
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 },
]
/** 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()
}
}