2026-09-13 16:18:10 +08:00
/**
* Synchronous SQLite data access. This is the raw layer behind
* {@link SqliteAdapter}; the route layer must never import these functions
* directly — it goes through {@link DbAdapter} so Postgres can be substituted.
*
* All access is parameterized (prepared statements). Functions take the
* connection explicitly so they stay free of Fastify/app state and testable.
* @module dshs/db/repo
*/
import { randomUUID } from 'node:crypto'
import type { Database } from './connection.js'
import { prepare } from './prepared.js'
import {
2026-09-15 18:47:02 +08:00
clusterInstanceId ,
2026-09-13 16:18:10 +08:00
toBusinessPlugin ,
toDomain ,
2026-09-15 18:47:02 +08:00
toDshHost ,
2026-09-13 16:18:10 +08:00
toDshInstance ,
toPublicUser ,
toSession ,
toUser ,
toWorkspace ,
type BusinessPlugin ,
2026-09-15 18:47:02 +08:00
type ClaimResult ,
2026-09-13 16:18:10 +08:00
type CredentialKey ,
2026-09-14 00:00:15 +08:00
type CredentialKeyMeta ,
type CredentialLandingRow ,
2026-09-13 16:18:10 +08:00
type CreateSessionInput ,
type CreateUserInput ,
type Domain ,
2026-09-15 18:47:02 +08:00
type DshHost ,
type DshHostStatus ,
2026-09-13 16:18:10 +08:00
type DshInstance ,
type DshInstanceRole ,
type DshInstanceStatus ,
type PublicUser ,
type SessionRow ,
type SessionUser ,
type UpsertBusinessPluginInput ,
2026-09-15 18:47:02 +08:00
type UpsertDshHostInput ,
2026-09-13 16:18:10 +08:00
type UpsertDshInstanceInput ,
type User ,
type UserRole ,
type Workspace ,
} from './types.js'
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const INSTANCE_COLS =
2026-09-15 18:47:02 +08:00
'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch, '
+ 'host_id, epoch, heartbeat_at, lease_until' // v7 集群化归属/租约(T08 S2)—— 漏了它们会让 hostId 恒为 null
2026-09-13 16:18:10 +08:00
export function createUser ( db : Database , input : CreateUserInput , baseUid : number ) : User {
const createdAt = Date . now ()
return db . transaction (() : User => {
const info = prepare ( db ,
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)' ,
). run ( input . id , input . username , input . passHash , input . role , input . homeDir , createdAt )
// SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
const uid = baseUid + Number ( info . lastInsertRowid )
prepare ( db , 'UPDATE users SET uid = ? WHERE id = ?' ). run ( uid , input . id )
return {
id : input.id ,
username : input.username ,
pass_hash : input.passHash ,
role : input.role ,
home_dir : input.homeDir ,
api_key_ref : null ,
created_at : createdAt ,
approved_by : null ,
uid ,
}
})()
}
export function findUserByUsername ( db : Database , username : string ) : User | undefined {
const row = prepare ( db , `SELECT ${ USER_COLS } FROM users WHERE username = ?` ). get ( username )
return row ? toUser ( row as Record < string , unknown >) : undefined
}
/** Case-insensitive username lookup (for subdomain routing). */
export function findUserBySlug ( db : Database , slug : string ) : User | undefined {
const row = prepare ( db , `SELECT ${ USER_COLS } FROM users WHERE LOWER(username) = ?` ). get ( slug . toLowerCase ())
return row ? toUser ( row as Record < string , unknown >) : undefined
}
export function findUserById ( db : Database , id : string ) : User | undefined {
const row = prepare ( db , `SELECT ${ USER_COLS } FROM users WHERE id = ?` ). get ( id )
return row ? toUser ( row as Record < string , unknown >) : undefined
}
export function listPublicUsers ( db : Database ) : PublicUser [] {
const rows = prepare ( db , `SELECT ${ USER_COLS } FROM users ORDER BY created_at ASC` ). all () as Array <
Record < string , unknown >
>
return rows . map (( row ) => toPublicUser ( toUser ( row )))
}
export function countAdmins ( db : Database ) : number {
const row = prepare ( db , `SELECT COUNT(*) AS n FROM users WHERE role = 'admin'` ). get () as { n : number }
return row . n
}
export function setUserRole ( db : Database , id : string , role : UserRole , approvedBy? : string ) : boolean {
const info =
approvedBy === undefined
? prepare ( db , 'UPDATE users SET role = ? WHERE id = ?' ). run ( role , id )
: prepare ( db , 'UPDATE users SET role = ?, approved_by = ? WHERE id = ?' ). run ( role , approvedBy , id )
return info . changes > 0
}
export function createSession ( db : Database , input : CreateSessionInput ) : void {
prepare ( db ,
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)' ,
). run ( input . tokenHash , input . userId , Date . now (), input . expiresAt , input . ip ?? null , input . userAgent ?? null )
}
export function findSession ( db : Database , tokenHash : string ) : SessionRow | undefined {
const row = prepare ( db , 'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = ?' )
. get ( tokenHash )
return row ? toSession ( row as Record < string , unknown >) : undefined
}
export function deleteSession ( db : Database , tokenHash : string ) : void {
prepare ( db , 'DELETE FROM sessions WHERE token_hash = ?' ). run ( tokenHash )
}
export function deleteUserSessions ( db : Database , userId : string ) : void {
prepare ( db , 'DELETE FROM sessions WHERE user_id = ?' ). run ( userId )
}
/** Append an audit entry. `actor` is a user id or `'system'`. */
export function audit ( db : Database , actor : string | null , action : string , detail? : string | null ) : void {
prepare ( db , 'INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)' ). run (
Date . now (),
actor ,
action ,
detail ?? null ,
)
}
export function findWorkspaceByPath ( db : Database , userId : string , relPath : string ) : Workspace | undefined {
const row = prepare ( db , 'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = ? AND rel_path = ?' )
. get ( userId , relPath )
return row ? toWorkspace ( row as Record < string , unknown >) : undefined
}
/** Upsert a workspace row by (user, relPath); create with a derived name. */
export function getOrCreateWorkspace ( db : Database , userId : string , relPath : string ) : Workspace {
const existing = findWorkspaceByPath ( db , userId , relPath )
if ( existing !== undefined ) return existing
const id = randomUUID ()
const segments = relPath . split ( '/' ). filter ( Boolean )
const name = segments . at ( - 1 ) ?? 'root'
prepare ( db , 'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES (?, ?, ?, ?, ?)' ). run (
id ,
userId ,
name ,
relPath ,
Date . now (),
)
return { id , userId , name , relPath , createdAt : Date.now () }
}
/** Replace a workspace's plugin selection (insert/delete in one transaction). */
export function setFolderPlugins (
db : Database ,
workspaceId : string ,
selections : ReadonlyArray < { id : string ; enabled : boolean } > ,
) : void {
const tx = db . transaction (() => {
prepare ( db , 'DELETE FROM folder_plugins WHERE workspace_id = ?' ). run ( workspaceId )
const insert = prepare ( db ,
'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES (?, ?, ?, ?)' ,
)
for ( const selection of selections ) {
insert . run ( workspaceId , selection . id , selection . enabled ? 1 : 0 , Date . now ())
}
})
tx ()
}
/** Enabled plugin ids for a workspace. */
export function getEnabledPluginIds ( db : Database , workspaceId : string ) : string [] {
const rows = prepare ( db , 'SELECT plugin_id FROM folder_plugins WHERE workspace_id = ? AND enabled = 1' )
. all ( workspaceId ) as Array < { plugin_id : string } >
return rows . map (( row ) => row . plugin_id )
}
export function findDomainByUser ( db : Database , userId : string ) : Domain | undefined {
const row = prepare ( db , `SELECT ${ DOMAIN_COLS } FROM domains WHERE user_id = ?` ). get ( userId )
return row ? toDomain ( row as Record < string , unknown >) : undefined
}
export function findDomainById ( db : Database , id : string ) : Domain | undefined {
const row = prepare ( db , `SELECT ${ DOMAIN_COLS } FROM domains WHERE id = ?` ). get ( id )
return row ? toDomain ( row as Record < string , unknown >) : undefined
}
export function listDomains ( db : Database ) : Domain [] {
const rows = prepare ( db , `SELECT ${ DOMAIN_COLS } FROM domains ORDER BY updated_at DESC` ). all () as Array <
Record < string , unknown >
>
return rows . map (( row ) => toDomain ( row ))
}
/** Upsert a user's custom domain (resetting `verified` to 0). */
export function upsertDomain ( db : Database , userId : string , domain : string , nginxConfig : string ) : Domain {
prepare ( db , `
INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at)
VALUES (?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
domain = excluded.domain,
verified = 0,
nginx_config = excluded.nginx_config,
updated_at = excluded.updated_at
` ). run ( randomUUID (), userId , domain , nginxConfig , Date . now ())
return findDomainByUser ( db , userId ) !
}
2026-09-14 00:00:15 +08:00
/**
* 两个凭据列表共用的列清单与行映射(档案 87)—— 抽出来是为了**两处不可能漂**:
* 之前 `listCredentialKeys` 与 `listEnabledCredentialKeys` 各写一份 SELECT,
* 加一次列就要改两处,漏一处就是"一个列表有 route、另一个没有"。
*/
const CREDENTIAL_COLS = 'id, key_name, enabled, updated_at, route, base_url, api, models'
interface CredentialRow {
id : string
key_name : string
enabled : number
updated_at : number
route : string | null
base_url : string | null
api : string | null
models : string | null
}
function toCredentialKey ( r : CredentialRow ) : CredentialKey {
return {
id : r.id ,
name : r.key_name ,
enabled : r.enabled === 1 ,
updatedAt : r.updated_at ,
route : r.route ,
baseUrl : r.base_url ,
api : r.api ,
models : r.models ,
}
}
2026-09-13 16:18:10 +08:00
/** List a user's named credential keys (metadata only). */
export function listCredentialKeys ( db : Database , userId : string ) : CredentialKey [] {
const rows = prepare (
db ,
2026-09-14 00:00:15 +08:00
`SELECT ${ CREDENTIAL_COLS } FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC` ,
). all ( userId ) as CredentialRow []
return rows . map ( toCredentialKey )
2026-09-13 16:18:10 +08:00
}
2026-09-14 00:00:15 +08:00
/**
* **内置 DeepSeek 条目**的 encrypted ref(档案 87 的**语义重定义**,必须读这一条)。
*
* 老语义是「那一把启用的 key」—— 当时 `setCredentialKey` 会先 `SET enabled = 0` 全关,
* 所以 `enabled = 1` **最多一行**,不带 ORDER BY 也唯一。
* 用户口径改成「条目各自开关、都能同时启用」后,互斥被删(见 `setCredentialKey`)⇒
* `WHERE enabled = 1` 可能命中**多行**,而**没有 ORDER BY 就是"任取一条"** ——
* `auth.ts` 的 `keySourceOf()` 与 `server.ts` 的 `resolveApiKey()` 会因此**随机飘**。
*
* ⇒ 这里把语义钉死为:**已启用、且是内置 DeepSeek( `base_url` 为空)的最新一条**。
* 自定义厂家**不算**"自己的 DeepSeek key"(它们各自有自己的 ref 与 settings 段)。
* @returns 该 ref,或 `null`(用户没有任何启用的内置条目)。
*/
2026-09-13 16:18:10 +08:00
export function getEnabledCredentialKeyRef ( db : Database , userId : string ) : string | null {
2026-09-14 00:00:15 +08:00
const row = prepare (
db ,
"SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 AND (base_url IS NULL OR base_url = '') ORDER BY updated_at DESC, id DESC LIMIT 1" ,
). get ( userId ) as { secret_ref : string } | undefined
2026-09-13 16:18:10 +08:00
return row ? . secret_ref ?? null
}
2026-09-14 00:00:15 +08:00
/**
* Upsert a named key by `name` and enable it —— **不动**其它条目(档案 87)。
*
* ⚠️ 老行为是「先 `SET enabled = 0` 全关,再开这一个」(单选)。用户口径已改为
* 「条目各自开关、都能同时启用」,所以那一行**已删**:否则用户每加一把 key,
* 别的厂家就被静默关掉。要"只开这一个"请显式先关别的(`toggleCredentialKey`)。
* @param meta - 模型厂家元数据(route / endpoint / 协议 / 模型清单),见 {@link CredentialKeyMeta}。
*/
export function setCredentialKey (
db : Database ,
userId : string ,
name : string ,
encryptedRef : string ,
meta : CredentialKeyMeta = {},
) : CredentialKey {
const route = meta . route ?? null
const baseUrl = meta . baseUrl ?? null
const api = meta . api ?? null
const models = meta . models ?? null
2026-09-13 16:18:10 +08:00
const id = db . transaction (() => {
const existing = prepare ( db , 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?' ). get (
userId ,
name ,
) as { id : string } | undefined
if ( existing !== undefined ) {
2026-09-14 00:00:15 +08:00
prepare (
db ,
'UPDATE credential_vault SET secret_ref = ?, route = ?, base_url = ?, api = ?, models = ?, enabled = 1, updated_at = ? WHERE id = ?' ,
). run ( encryptedRef , route , baseUrl , api , models , Date . now (), existing . id )
2026-09-13 16:18:10 +08:00
return existing . id
}
2026-09-14 00:00:15 +08:00
const newId = randomUUID ()
2026-09-13 16:18:10 +08:00
prepare (
db ,
2026-09-14 00:00:15 +08:00
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, route, base_url, api, models, enabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?)' ,
). run ( newId , userId , name , encryptedRef , route , baseUrl , api , models , Date . now ())
return newId
2026-09-13 16:18:10 +08:00
})()
2026-09-14 00:00:15 +08:00
return { id , name , enabled : true , updatedAt : Date.now (), route , baseUrl , api , models }
2026-09-13 16:18:10 +08:00
}
2026-09-14 00:00:15 +08:00
/**
* 启用某一个条目(档案 87:**改为非互斥**)。
*
* 老语义是「单选」:先 `SET enabled = 0` 把该用户所有条目关掉,再开这一个。
* 用户口径改成「各自开关、可同时启用」之后,那一步会**悄悄关掉别的已启用条目**
* (用户看不出为什么换了个厂家另一个就不生效了),所以这里退化成
* `toggleCredentialKey(id, true)` 的同义实现 —— **保留函数名只为接口兼容**。
*/
2026-09-13 16:18:10 +08:00
export function selectCredentialKey ( db : Database , userId : string , id : string ) : boolean {
2026-09-14 00:00:15 +08:00
return toggleCredentialKey ( db , userId , id , true )
}
/**
* 打开/关闭**单个**条目(档案 87;用户口径:条目各自开关、可同时启用)。
* 与 `selectCredentialKey`(名字带"单选"但已改为非互斥)的差别:这里**只碰这一行**。
* @returns 是否命中了该用户下的这一行(false = 不存在或不属于他)。
*/
export function toggleCredentialKey ( db : Database , userId : string , id : string , enabled : boolean ) : boolean {
const info = prepare ( db , 'UPDATE credential_vault SET enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?' ). run (
enabled ? 1 : 0 ,
Date . now (),
id ,
userId ,
)
return info . changes > 0
}
/** 该用户**所有已启用**的条目(含 route / base_url / api / models)—— spawn 时按它们写实例配置。 */
export function listEnabledCredentialKeys ( db : Database , userId : string ) : CredentialKey [] {
const rows = prepare (
db ,
`SELECT ${ CREDENTIAL_COLS } FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC` ,
). all ( userId ) as CredentialRow []
return rows . map ( toCredentialKey )
}
/**
* 该用户**所有已启用**的条目 + 各自 encrypted ref —— **仅供落地层**(档案 87)。
* 与 `listEnabledCredentialKeys` 的差别只有一个:多返回 `secret_ref`。
* 单独一个函数是为了让"密文"这件事**不可能**顺着 `/api/me/keys` 漏出去。
*/
export function listCredentialLandingRows ( db : Database , userId : string ) : CredentialLandingRow [] {
const rows = prepare (
db ,
'SELECT key_name, route, base_url, api, models, secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1 ORDER BY updated_at DESC' ,
). all ( userId ) as Array < {
key_name : string
route : string | null
base_url : string | null
api : string | null
models : string | null
secret_ref : string
} >
return rows . map (( r ) => ({
name : r.key_name ,
route : r.route ,
baseUrl : r.base_url ,
api : r.api ,
models : r.models ,
encryptedRef : r.secret_ref ,
}))
}
/**
* 该用户是否启用「平台共享模型」(档案 87)—— **用户侧偏好**,开关它**不动** admin 的配置。
*
* 缺失行一律按 `true` 处理:V6 迁移给所有老用户填了默认 1,而"查不到这个人"时
* 也不该因为一个开关把共享 key 断掉(宁可多给,不可少给)。
*/
export function getSharedModelEnabled ( db : Database , userId : string ) : boolean {
const row = prepare ( db , 'SELECT shared_model_enabled FROM users WHERE id = ?' ). get ( userId ) as
| { shared_model_enabled : number }
| undefined
return row === undefined ? true : row . shared_model_enabled !== 0
}
/** 打开/关闭「平台共享模型」(档案 87)。@returns 是否命中该用户。 */
export function setSharedModelEnabled ( db : Database , userId : string , enabled : boolean ) : boolean {
const info = prepare ( db , 'UPDATE users SET shared_model_enabled = ? WHERE id = ?' ). run ( enabled ? 1 : 0 , userId )
return info . changes > 0
2026-09-13 16:18:10 +08:00
}
/** Delete a named key (by id, scoped to the user). */
export function deleteCredentialKey ( db : Database , userId : string , id : string ) : boolean {
const info = prepare ( db , 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?' ). run ( id , userId )
return info . changes > 0
}
/**
* Permanently delete a user and all owned rows. Child tables are removed
* explicitly (in dependency order) rather than relying on FK cascade, so the
* cleanup works even when `PRAGMA foreign_keys` is off. Returns false when the
* user does not exist.
*/
export function deleteUser ( db : Database , userId : string ) : boolean {
const deleted = db . transaction (() => {
prepare ( db , 'DELETE FROM credential_vault WHERE user_id = ?' ). run ( userId )
prepare ( db , 'DELETE FROM domains WHERE user_id = ?' ). run ( userId )
prepare ( db , 'DELETE FROM sessions WHERE user_id = ?' ). run ( userId )
prepare ( db , 'DELETE FROM dsh_instances WHERE user_id = ?' ). run ( userId )
prepare ( db , 'DELETE FROM audit_log WHERE actor = ?' ). run ( userId )
prepare (
db ,
'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = ?)' ,
). run ( userId )
prepare ( db , 'DELETE FROM workspaces WHERE user_id = ?' ). run ( userId )
const info = prepare ( db , 'DELETE FROM users WHERE id = ?' ). run ( userId )
return info . changes > 0
})()
return deleted as boolean
}
/** Set the verified flag on a domain. */
export function setDomainVerified ( db : Database , id : string , verified : boolean ) : boolean {
const info = prepare ( db , 'UPDATE domains SET verified = ?, updated_at = ? WHERE id = ?' )
. run ( verified ? 1 : 0 , Date . now (), id )
return info . changes > 0
}
/** Whether any of a user's sessions is still unexpired. */
export function hasActiveSession ( db : Database , userId : string ) : boolean {
const row = prepare ( db , 'SELECT 1 FROM sessions WHERE user_id = ? AND expires_at > ? LIMIT 1' )
. get ( userId , Date . now ()) as { 1 : number } | undefined
return row !== undefined
}
/** Look up a session and its user in a single join. */
export function findSessionWithUser ( db : Database , tokenHash : string ) : SessionUser | undefined {
const row = prepare (
db ,
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
s.expires_at
FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token_hash = ?` ,
). get ( tokenHash ) as Record < string , unknown > | undefined
if ( row === undefined ) return undefined
return { expiresAt : row.expires_at as number , user : toUser ( row ) }
}
/** Assign a Linux uid to a user (legacy backfill). */
export function setUserUid ( db : Database , userId : string , uid : number ) : void {
prepare ( db , 'UPDATE users SET uid = ? WHERE id = ?' ). run ( uid , userId )
}
/** Ids of users whose uid is still null (legacy rows awaiting backfill). */
export function listUsersWithoutUid ( db : Database ) : string [] {
const rows = prepare ( db , 'SELECT id FROM users WHERE uid IS NULL' ). all () as Array < { id : string } >
return rows . map (( row ) => row . id )
}
/** Record (or re-record) an instance's desired state. Keyed on the caller's
* deterministic id, so a relaunch overwrites rather than duplicating. */
export function upsertInstance ( db : Database , input : UpsertDshInstanceInput ) : void {
prepare ( db , `
INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
workspace_id = excluded.workspace_id,
pid = excluded.pid,
port = excluded.port,
status = excluded.status,
started_at = excluded.started_at,
folder = excluded.folder,
patch = excluded.patch
` ). run (
input . id ,
input . userId ,
input . workspaceId ?? null ,
input . role ,
input . pid ?? null ,
input . port ?? null ,
input . status ,
Date . now (),
input . folder ?? null ,
input . patch ?? null ,
)
}
export function findInstance ( db : Database , id : string ) : DshInstance | undefined {
const row = prepare ( db , `SELECT ${ INSTANCE_COLS } FROM dsh_instances WHERE id = ?` ). get ( id )
return row ? toDshInstance ( row as Record < string , unknown >) : undefined
}
export function findUserInstance ( db : Database , userId : string , role : DshInstanceRole ) : DshInstance | undefined {
const row = prepare ( db , `SELECT ${ INSTANCE_COLS } FROM dsh_instances WHERE user_id = ? AND role = ?` ). get ( userId , role )
return row ? toDshInstance ( row as Record < string , unknown >) : undefined
}
export function listInstancesByRole ( db : Database , role : DshInstanceRole ) : DshInstance [] {
const rows = prepare ( db , `SELECT ${ INSTANCE_COLS } FROM dsh_instances WHERE role = ? ORDER BY started_at ASC` )
. all ( role ) as Array < Record < string , unknown >>
return rows . map (( row ) => toDshInstance ( row ))
}
/** Record a state transition; an `outcome` also stamps `last_exit`. */
export function setInstanceStatus (
db : Database ,
id : string ,
status : DshInstanceStatus ,
outcome ?: { exitCode? : number ; lastError? : string },
) : boolean {
const info =
outcome === undefined
? prepare ( db , 'UPDATE dsh_instances SET status = ? WHERE id = ?' ). run ( status , id )
: prepare ( db , 'UPDATE dsh_instances SET status = ?, last_exit = ?, exit_code = ?, last_error = ? WHERE id = ?' )
. run ( status , Date . now (), outcome . exitCode ?? null , outcome . lastError ?? null , id )
return info . changes > 0
}
export function deleteInstance ( db : Database , id : string ) : boolean {
const info = prepare ( db , 'DELETE FROM dsh_instances WHERE id = ?' ). run ( id )
return info . changes > 0
}
export function deleteUserInstances ( db : Database , userId : string ) : void {
prepare ( db , 'DELETE FROM dsh_instances WHERE user_id = ?' ). run ( userId )
}
// ── business plugins (系统外插件候选池) ────────────────────────────────
export function listBusinessPlugins ( db : Database ) : BusinessPlugin [] {
const rows = prepare ( db , `SELECT ${ BUSINESS_PLUGIN_COLS } FROM business_plugins ORDER BY name ASC` ). all () as Array <
Record < string , unknown >
>
return rows . map (( row ) => toBusinessPlugin ( row ))
}
export function findBusinessPlugin ( db : Database , id : string ) : BusinessPlugin | undefined {
const row = prepare ( db , `SELECT ${ BUSINESS_PLUGIN_COLS } FROM business_plugins WHERE id = ?` ). get ( id )
return row ? toBusinessPlugin ( row as Record < string , unknown >) : undefined
}
/**
* Upsert a candidate-pool row keyed on the bundle package name (`id`). Same-name
* upload REPLACES the row — the caller removes the previous tgz file first so no
* stale artifact survives.
*/
export function upsertBusinessPlugin ( db : Database , input : UpsertBusinessPluginInput ) : BusinessPlugin {
prepare ( db , `
INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
version = excluded.version,
tgz_path = excluded.tgz_path,
file_size = excluded.file_size,
uploaded_by = excluded.uploaded_by,
updated_at = excluded.updated_at
` ). run (
input . id ,
input . name ,
input . description ?? null ,
input . version ?? null ,
input . tgzPath ,
input . fileSize ,
input . uploadedBy ,
Date . now (),
Date . now (),
)
return findBusinessPlugin ( db , input . id ) !
}
export function deleteBusinessPlugin ( db : Database , id : string ) : boolean {
const info = prepare ( db , 'DELETE FROM business_plugins WHERE id = ?' ). run ( id )
return info . changes > 0
}
2026-09-15 18:47:02 +08:00
// ── 集群化:worker 注册表 + 实例归属/租约(v7; T08 S2 / 设计 §3.1–§3.2)────────
//
// ⚠️ local 模式**不调用**这些函数(`LocalSpawner` 靠进程内 Map + 单机互斥),
// 所以它们的存在不会改变现有单机行为。
2026-09-17 17:03:27 +08:00
const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat'
2026-09-15 18:47:02 +08:00
2026-09-17 17:03:27 +08:00
/** 注册/更新一台 worker。join 幂等:同 id 重复执行 = 更新(并把它标回 `up`)。
*
* ⚠️ `via` 与 `networkId` **省略时不覆盖已有值**( `COALESCE` 到旧值):否则一次不带它们的
* join 会把回填好的 `local` / 已划定的网络归属冲回列默认值(那种漂移在换中继、建第二张网之前
* **看不出症状**)。
*/
2026-09-15 18:47:02 +08:00
export function upsertDshHost ( db : Database , input : UpsertDshHostInput ) : DshHost {
prepare ( db , `
2026-09-17 17:03:27 +08:00
INSERT INTO dsh_hosts (id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat)
VALUES (?, ?, COALESCE(?, 'manager-ssh'), COALESCE(?, 'ops'), ?, ?, 0, ?, NULL)
2026-09-15 18:47:02 +08:00
ON CONFLICT(id) DO UPDATE SET
endpoint = excluded.endpoint,
2026-09-17 17:03:27 +08:00
via = COALESCE(?, dsh_hosts.via),
network_id = COALESCE(?, dsh_hosts.network_id),
2026-09-15 18:47:02 +08:00
agent_token = excluded.agent_token,
capacity_mb = excluded.capacity_mb,
status = excluded.status
2026-09-17 17:03:27 +08:00
` ). run (
input . id ,
input . endpoint ,
input . via ?? null ,
input . networkId ?? null ,
input . agentToken ,
input . capacityMb ,
input . status ?? 'up' ,
input . via ?? null ,
input . networkId ?? null ,
)
2026-09-15 18:47:02 +08:00
const row = prepare ( db , `SELECT ${ HOST_COLS } FROM dsh_hosts WHERE id = ?` ). get ( input . id )
return toDshHost ( row as Record < string , unknown >)
}
export function findDshHost ( db : Database , id : string ) : DshHost | undefined {
const row = prepare ( db , `SELECT ${ HOST_COLS } FROM dsh_hosts WHERE id = ?` ). get ( id )
return row ? toDshHost ( row as Record < string , unknown >) : undefined
}
export function listDshHosts ( db : Database ) : DshHost [] {
const rows = prepare ( db , `SELECT ${ HOST_COLS } FROM dsh_hosts ORDER BY id ASC` ). all () as Array <
Record < string , unknown >
>
return rows . map (( row ) => toDshHost ( row ))
}
/** 心跳/状态上报(只更新显式给出的字段,避免 heartbeat 覆盖 status)。 */
export function setDshHostStatus (
db : Database ,
id : string ,
status : DshHostStatus ,
usedMb? : number ,
heartbeatAt? : number ,
) : boolean {
const info = prepare ( db , `
UPDATE dsh_hosts
SET status = ?,
used_mb = COALESCE(?, used_mb),
last_heartbeat = COALESCE(?, last_heartbeat)
WHERE id = ?
` ). run ( status , usedMb ?? null , heartbeatAt ?? null , id )
return info . changes > 0
}
/**
* **原子抢占**某用户 main 实例的归属(承重墙)。仅当"无人持有 **或** 租约已过期"才成功,
* 成功时 `epoch` +1( fencing token)。失败时返回当前持有者与租约到期时刻。
*/
export function claimInstance (
db : Database ,
userId : string ,
hostId : string ,
ttlMs : number ,
meta ?: { folder? : string ; patch? : string },
) : ClaimResult {
const now = Date . now ()
const id = clusterInstanceId ( userId )
// 新用户没有 dsh_instances 行 ⇒ 先保证行存在(否则 UPDATE 影响 0 行被误判为"有人在管")。
prepare ( db , `
INSERT INTO dsh_instances (id, user_id, role, status)
VALUES (?, ?, 'main', 'starting')
ON CONFLICT(id) DO NOTHING
` ). run ( id , userId )
// ⚠️ **必须把 folder/patch 一起落库**( 2026-09-15 实测踩到):集群模式下实例行是这里建的,
// 而 local 模式不写库 ⇒ 若这里不记,`folder` 永远是 NULL,**迁移时复现不了启动参数**
// (表现为 `bwrap: Can't chdir to :` 空路径 ⇒ 崩溃循环)。用 COALESCE 保证不覆盖已有值。
const info = prepare ( db , `
UPDATE dsh_instances
SET host_id = ?, epoch = epoch + 1, heartbeat_at = ?, lease_until = ?,
folder = COALESCE(?, folder), patch = COALESCE(?, patch)
WHERE id = ? AND (host_id IS NULL OR lease_until < ?)
` ). run ( hostId , now , now + ttlMs , meta ? . folder ?? null , meta ? . patch ?? null , id , now )
const row = prepare ( db , 'SELECT host_id, epoch, lease_until FROM dsh_instances WHERE id = ?' ). get ( id ) as
| { host_id : string | null ; epoch : number ; lease_until : number }
| undefined
if ( row === undefined ) return { ok : false , holder : null , leaseUntil : 0 }
return info . changes > 0
? { ok : true , epoch : row.epoch , leaseUntil : row.lease_until }
: { ok : false , holder : row.host_id , leaseUntil : row.lease_until }
}
/** 续租。**必须带 epoch**:不匹配说明已被他人抢占 ⇒ 返回 false(fencing 生效)。 */
export function renewInstanceLease (
db : Database ,
userId : string ,
hostId : string ,
epoch : number ,
ttlMs : number ,
) : boolean {
const now = Date . now ()
const info = prepare ( db , `
UPDATE dsh_instances SET heartbeat_at = ?, lease_until = ?
WHERE id = ? AND host_id = ? AND epoch = ?
` ). run ( now , now + ttlMs , clusterInstanceId ( userId ), hostId , epoch )
return info . changes > 0
}
/**
* 主动释放**租约**(停实例时)。带 epoch 校验,避免误清他人的归属。
*
* ⚠️ **只清 `lease_until`,保留 `host_id`**( 2026-09-15 生产切换暴露):
* `host_id` 的语义是「**这个用户的数据在哪台机器**」—— 用户的工作区在**本地盘**上,
* 把归属一起清掉就等于**丢掉粘性锚点**,下次启动可能被调度到没有他数据的机器上(工作区看起来是空的)。
* 「谁现在在托管」是**租约**(`lease_until`)的语义,所以释放只该清租约。
*/
export function releaseInstanceLease ( db : Database , userId : string , hostId : string , epoch : number ) : boolean {
const info = prepare ( db , `
UPDATE dsh_instances SET lease_until = 0
WHERE id = ? AND host_id = ? AND epoch = ?
` ). run ( clusterInstanceId ( userId ), hostId , epoch )
return info . changes > 0
}
/**
* **钉住**某用户的归属(首次触达其工作区时用):只写 `host_id`,不动 epoch/租约。
*
* 为什么需要:新用户还没有归属,`selectHost` 会在**写文件那一步**与**launch 那一步**各自选一次,
* 两次可能选到不同机器 ⇒ 「文件写到 A、实例起在 B」⇒ 实例看不到自己的文件(2026-09-15 实测)。
* 首次触达就把归属钉住,后续(含 launch)都走粘性,两面必然一致。
*/
export function pinInstanceHost ( db : Database , userId : string , hostId : string ) : void {
const now = Date . now ()
const id = clusterInstanceId ( userId )
prepare ( db , `
INSERT INTO dsh_instances (id, user_id, role, status, host_id, epoch, heartbeat_at, lease_until)
VALUES (?, ?, 'main', 'stopped', ?, 0, 0, 0)
ON CONFLICT(id) DO NOTHING
` ). run ( id , userId , hostId )
prepare ( db , `
UPDATE dsh_instances SET host_id = ?
WHERE id = ? AND (host_id IS NULL OR lease_until < ?)
` ). run ( hostId , id , now )
}
/** 租约过期但仍标着归属的 main 实例(供巡检/自愈;**不等于可以立即接管**,见 R9)。 */
export function listExpiredInstanceLeases ( db : Database , now : number ) : DshInstance [] {
const rows = prepare ( db , `
SELECT ${ INSTANCE_COLS } FROM dsh_instances
WHERE role = 'main' AND host_id IS NOT NULL AND lease_until < ? AND status <> 'stopped'
ORDER BY lease_until ASC
` ). all ( now ) as Array < Record < string , unknown >>
return rows . map (( row ) => toDshInstance ( row ))
}
/** 某 worker 上的全部实例 —— 对账**一次拿回整机**(替代逐用户查询)。 */
export function listInstancesByHost ( db : Database , hostId : string ) : DshInstance [] {
const rows = prepare ( db , `SELECT ${ INSTANCE_COLS } FROM dsh_instances WHERE host_id = ? ORDER BY user_id ASC` ). all (
hostId ,
) as Array < Record < string , unknown >>
return rows . map (( row ) => toDshInstance ( row ))
}