dsh-hams 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/index.js +11335 -0
- package/package.json +9 -5
- package/src/domain/check.ts +0 -193
- package/src/domain/errors.ts +0 -59
- package/src/domain/evidence.ts +0 -134
- package/src/domain/ids.ts +0 -31
- package/src/host/chat-runtime.ts +0 -489
- package/src/host/dsh-adapter.ts +0 -867
- package/src/host/session-adapter.ts +0 -276
- package/src/http/handler.ts +0 -1132
- package/src/index.js +0 -53
- package/src/service/audit.ts +0 -92
- package/src/service/chat.ts +0 -1100
- package/src/service/contracts.ts +0 -193
- package/src/service/defaults.ts +0 -182
- package/src/service/deliverables.ts +0 -240
- package/src/service/hams.ts +0 -3103
- package/src/service/knowledge.ts +0 -940
- package/src/service/member-config.ts +0 -39
- package/src/service/members.ts +0 -506
- package/src/service/memory.ts +0 -375
- package/src/service/permissions.ts +0 -118
- package/src/service/project-lifecycle.ts +0 -233
- package/src/service/projects.ts +0 -840
- package/src/service/receipts.ts +0 -63
- package/src/service/templates.ts +0 -140
- package/src/service/tick.ts +0 -751
- package/src/service/tools.ts +0 -711
- package/src/service/types.ts +0 -219
- package/src/service/work-item-create.ts +0 -159
- package/src/service/workspace-profile.ts +0 -443
- package/src/store/backup.ts +0 -239
- package/src/store/bootstrap.ts +0 -225
- package/src/store/db.ts +0 -179
- package/src/store/lock.ts +0 -194
- package/src/store/migrations.ts +0 -211
- package/src/store/schema/001_p1.sql +0 -846
- package/src/store/schema/001_p1.sql.ts +0 -865
- package/src/store/schema/002_knowledge.sql +0 -54
- package/src/store/schema/002_knowledge.sql.ts +0 -68
- package/src/store/schema/003_projects.sql.ts +0 -3
- package/src/store/schema/004_team.sql.ts +0 -50
- package/src/store/schema/005_target_check.sql.ts +0 -22
- package/src/store/schema/006_target_flow.sql.ts +0 -19
- package/src/store/schema/007_project_delete.sql.ts +0 -17
- package/src/store/schema/008_memory.sql.ts +0 -47
- package/src/store/schema/generate-projects.mjs +0 -16
- package/src/store/seed.ts +0 -114
package/src/store/bootstrap.ts
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 存储启动编排:严格按 dsh-compat §7 的顺序
|
|
3
|
-
*
|
|
4
|
-
* 拿锁 → 开可写库 → (库非空则)备份 → 迁移 → 种子元数据 → runtime_epoch +1 → 交付
|
|
5
|
-
*
|
|
6
|
-
* 细节取舍:
|
|
7
|
-
* - 空库(user_version=0)不备份:没有业务数据可保,备份只会制造噪音;
|
|
8
|
-
* - 非空库升级前必先备份,备份校验不过就**不迁移**,宁可起不来也不带着坏备份往前走;
|
|
9
|
-
* - 任何一步失败都要回滚并释放锁,绝不半启动接流量(错误原样上抛,由插件置诊断态);
|
|
10
|
-
* - `recover()` 冷启动对账属于 M4,这里不做,但 runtime_epoch 已经就位供它使用。
|
|
11
|
-
*/
|
|
12
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
13
|
-
import {
|
|
14
|
-
closeDatabase,
|
|
15
|
-
databasePath,
|
|
16
|
-
openDatabase,
|
|
17
|
-
resolveDataDir,
|
|
18
|
-
userVersion,
|
|
19
|
-
StoreError,
|
|
20
|
-
} from './db.ts'
|
|
21
|
-
import { acquireLock, lockPath, type LockHandle } from './lock.ts'
|
|
22
|
-
import { backupDir, createBackup, type BackupReport } from './backup.ts'
|
|
23
|
-
import {
|
|
24
|
-
MIGRATIONS,
|
|
25
|
-
P1_SCHEMA_META,
|
|
26
|
-
SchemaTooNewError,
|
|
27
|
-
TARGET_VERSION,
|
|
28
|
-
migrate,
|
|
29
|
-
type Migration,
|
|
30
|
-
} from './migrations.ts'
|
|
31
|
-
import {
|
|
32
|
-
assertBaselineSeed,
|
|
33
|
-
bumpRuntimeEpoch,
|
|
34
|
-
ensureMetaDefaults,
|
|
35
|
-
readMeta,
|
|
36
|
-
type SeedSummary,
|
|
37
|
-
} from './seed.ts'
|
|
38
|
-
|
|
39
|
-
export interface BootstrapOptions {
|
|
40
|
-
/** 数据目录;缺省 = $DSH_HOME/hams。 */
|
|
41
|
-
dataDir?: string
|
|
42
|
-
migrations?: readonly Migration[]
|
|
43
|
-
now?: () => number
|
|
44
|
-
backupKeep?: number
|
|
45
|
-
staleMs?: number
|
|
46
|
-
/** 默认 true;关掉只用于测试「不备份直接迁移」的失败路径。 */
|
|
47
|
-
backupBeforeMigrate?: boolean
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface BootstrapReport {
|
|
51
|
-
dataDir: string
|
|
52
|
-
dbPath: string
|
|
53
|
-
lockPath: string
|
|
54
|
-
fromVersion: number
|
|
55
|
-
toVersion: number
|
|
56
|
-
runtimeEpoch: number
|
|
57
|
-
backup: { name: string; path: string; bytes: number; tables: number } | null
|
|
58
|
-
migration: { migrated: boolean; applied: { version: number; name: string }[]; checkedBaseline: boolean }
|
|
59
|
-
seed: SeedSummary
|
|
60
|
-
meta: Record<string, string>
|
|
61
|
-
steps: string[]
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export interface StoreRuntime {
|
|
65
|
-
dataDir: string
|
|
66
|
-
dbPath: string
|
|
67
|
-
db: DatabaseSync
|
|
68
|
-
lock: LockHandle
|
|
69
|
-
report: BootstrapReport
|
|
70
|
-
readonly closed: boolean
|
|
71
|
-
close(): void
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function toBackupSummary(report: BackupReport | null): BootstrapReport['backup'] {
|
|
75
|
-
if (!report) return null
|
|
76
|
-
return { name: report.name, path: report.path, bytes: report.bytes, tables: report.tables }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function bootstrapStore(options: BootstrapOptions = {}): StoreRuntime {
|
|
80
|
-
const now = options.now ?? Date.now
|
|
81
|
-
const migrations = options.migrations ?? MIGRATIONS
|
|
82
|
-
const targetVersion = migrations[migrations.length - 1].version
|
|
83
|
-
const dataDir = resolveDataDir(options.dataDir)
|
|
84
|
-
const dbPath = databasePath(dataDir)
|
|
85
|
-
|
|
86
|
-
const steps: string[] = []
|
|
87
|
-
const lock = acquireLock(dataDir, { staleMs: options.staleMs, now: now() })
|
|
88
|
-
steps.push('acquire-lock')
|
|
89
|
-
|
|
90
|
-
let db: DatabaseSync | null = null
|
|
91
|
-
try {
|
|
92
|
-
db = openDatabase({ path: dbPath })
|
|
93
|
-
steps.push('open-db')
|
|
94
|
-
|
|
95
|
-
const fromVersion = userVersion(db)
|
|
96
|
-
let backupReport: BackupReport | null = null
|
|
97
|
-
|
|
98
|
-
if (fromVersion > 0 && fromVersion < targetVersion && options.backupBeforeMigrate !== false) {
|
|
99
|
-
backupReport = createBackup(db, {
|
|
100
|
-
dir: backupDir(dataDir),
|
|
101
|
-
keep: options.backupKeep,
|
|
102
|
-
now: new Date(now()),
|
|
103
|
-
})
|
|
104
|
-
steps.push(`backup-before-migrate:${backupReport.name}`)
|
|
105
|
-
} else if (fromVersion === 0) {
|
|
106
|
-
steps.push('skip-backup-fresh-db')
|
|
107
|
-
} else {
|
|
108
|
-
steps.push('skip-backup-no-upgrade')
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const migrationReport = migrate(db, {
|
|
112
|
-
migrations,
|
|
113
|
-
verifyBaseline: options.verifyBaseline ?? migrations === MIGRATIONS,
|
|
114
|
-
})
|
|
115
|
-
steps.push(
|
|
116
|
-
migrationReport.migrated
|
|
117
|
-
? `migrate:${migrationReport.applied.map((item) => item.version).join(',')}`
|
|
118
|
-
: 'migrate:noop',
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
ensureMetaDefaults(db, { now: now() })
|
|
122
|
-
steps.push('seed-meta')
|
|
123
|
-
|
|
124
|
-
const seed = assertBaselineSeed(db)
|
|
125
|
-
steps.push('assert-baseline-seed')
|
|
126
|
-
|
|
127
|
-
const runtimeEpoch = bumpRuntimeEpoch(db)
|
|
128
|
-
steps.push(`bump-runtime-epoch:${runtimeEpoch}`)
|
|
129
|
-
|
|
130
|
-
// 报告里的 meta 必须是加了启动代次之后的快照(否则界面显示 epoch 0)。
|
|
131
|
-
const finalMeta = readMeta(db)
|
|
132
|
-
|
|
133
|
-
const report: BootstrapReport = {
|
|
134
|
-
dataDir,
|
|
135
|
-
dbPath,
|
|
136
|
-
lockPath: lockPath(dataDir),
|
|
137
|
-
fromVersion,
|
|
138
|
-
toVersion: migrationReport.toVersion,
|
|
139
|
-
runtimeEpoch,
|
|
140
|
-
backup: toBackupSummary(backupReport),
|
|
141
|
-
migration: {
|
|
142
|
-
migrated: migrationReport.migrated,
|
|
143
|
-
applied: migrationReport.applied,
|
|
144
|
-
checkedBaseline: migrationReport.checkedBaseline,
|
|
145
|
-
},
|
|
146
|
-
seed,
|
|
147
|
-
meta: finalMeta,
|
|
148
|
-
steps,
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
let closed = false
|
|
152
|
-
const runtime: StoreRuntime = {
|
|
153
|
-
dataDir,
|
|
154
|
-
dbPath,
|
|
155
|
-
db,
|
|
156
|
-
lock,
|
|
157
|
-
report,
|
|
158
|
-
get closed() {
|
|
159
|
-
return closed
|
|
160
|
-
},
|
|
161
|
-
close(): void {
|
|
162
|
-
if (closed) return
|
|
163
|
-
closed = true
|
|
164
|
-
try {
|
|
165
|
-
closeDatabase(db as DatabaseSync)
|
|
166
|
-
} finally {
|
|
167
|
-
lock.release()
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
}
|
|
171
|
-
return runtime
|
|
172
|
-
} catch (error) {
|
|
173
|
-
if (db) {
|
|
174
|
-
try {
|
|
175
|
-
closeDatabase(db)
|
|
176
|
-
} catch {
|
|
177
|
-
// 关闭失败不能盖住原始错误。
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
lock.release()
|
|
181
|
-
throw error
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export interface ReadOnlyStore {
|
|
186
|
-
dataDir: string
|
|
187
|
-
db: DatabaseSync
|
|
188
|
-
userVersion: number
|
|
189
|
-
close(): void
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* 第二实例 / 运维查看:只读打开,不迁移、不写 runtime_epoch、不拿写锁。
|
|
194
|
-
* 版本必须与代码完全一致,否则拒绝(旧代码不许读新库,新代码也不假装能处理旧库)。
|
|
195
|
-
*/
|
|
196
|
-
export function openReadOnlyStore(dataDir?: string, expectedVersion: number = TARGET_VERSION): ReadOnlyStore {
|
|
197
|
-
const resolved = resolveDataDir(dataDir)
|
|
198
|
-
const db = openDatabase({ path: databasePath(resolved), readOnly: true })
|
|
199
|
-
const version = userVersion(db)
|
|
200
|
-
if (version > expectedVersion) {
|
|
201
|
-
closeDatabase(db)
|
|
202
|
-
throw new SchemaTooNewError(version, expectedVersion)
|
|
203
|
-
}
|
|
204
|
-
if (version < expectedVersion) {
|
|
205
|
-
closeDatabase(db)
|
|
206
|
-
throw new StoreError(
|
|
207
|
-
'SCHEMA_OUTDATED',
|
|
208
|
-
`只读实例遇到旧版本库 ${version} < ${expectedVersion};请先用可写实例完成迁移`,
|
|
209
|
-
)
|
|
210
|
-
}
|
|
211
|
-
let closed = false
|
|
212
|
-
return {
|
|
213
|
-
dataDir: resolved,
|
|
214
|
-
db,
|
|
215
|
-
userVersion: version,
|
|
216
|
-
close(): void {
|
|
217
|
-
if (closed) return
|
|
218
|
-
closed = true
|
|
219
|
-
closeDatabase(db)
|
|
220
|
-
},
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** 基线表数量,供诊断页显示「41 张 P1 表是否齐」。 */
|
|
225
|
-
export const P1_TABLE_COUNT = P1_SCHEMA_META.tables
|
package/src/store/db.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* node:sqlite 的唯一封装点。
|
|
3
|
-
*
|
|
4
|
-
* 约定(来自实测):
|
|
5
|
-
* - 连接参数固定 WAL + foreign_keys=ON + busy_timeout,由本文件统一设置,别处不再重复;
|
|
6
|
-
* - 写事务一律 BEGIN IMMEDIATE,禁止嵌套;
|
|
7
|
-
* - 本版运行时的 DatabaseSync 没有 backup(),备份走 VACUUM INTO(见 backup.ts)。
|
|
8
|
-
*
|
|
9
|
-
* 这一层只碰数据库,不解析宿主对象、不读 $DSH_HOME/sessions/。
|
|
10
|
-
*/
|
|
11
|
-
import { DatabaseSync } from 'node:sqlite'
|
|
12
|
-
import { mkdirSync } from 'node:fs'
|
|
13
|
-
import { homedir } from 'node:os'
|
|
14
|
-
import path from 'node:path'
|
|
15
|
-
import { createHash } from 'node:crypto'
|
|
16
|
-
|
|
17
|
-
export const DB_FILENAME = 'hams.db'
|
|
18
|
-
export const DEFAULT_BUSY_TIMEOUT_MS = 5000
|
|
19
|
-
|
|
20
|
-
export interface OpenDatabaseOptions {
|
|
21
|
-
/** 数据库文件的绝对路径。 */
|
|
22
|
-
path: string
|
|
23
|
-
/** 只读打开:第二实例与运维查看用;不建目录、不设 WAL。 */
|
|
24
|
-
readOnly?: boolean
|
|
25
|
-
busyTimeoutMs?: number
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export class StoreError extends Error {
|
|
29
|
-
code: string
|
|
30
|
-
|
|
31
|
-
constructor(code: string, message: string, options?: { cause?: unknown }) {
|
|
32
|
-
super(message, options)
|
|
33
|
-
this.code = code
|
|
34
|
-
this.name = 'StoreError'
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* 数据目录解析:显式传入 > $DSH_HOME/hams > ~/.dsh/hams。
|
|
40
|
-
* 只做路径计算,不创建目录——创建发生在 openDatabase 的可写分支。
|
|
41
|
-
*/
|
|
42
|
-
export function resolveDataDir(
|
|
43
|
-
explicit?: string,
|
|
44
|
-
env: Record<string, string | undefined> = process.env,
|
|
45
|
-
): string {
|
|
46
|
-
if (explicit && explicit.trim()) return path.resolve(explicit)
|
|
47
|
-
const dshHome = env.DSH_HOME && env.DSH_HOME.trim() ? env.DSH_HOME : path.join(homedir(), '.dsh')
|
|
48
|
-
return path.join(path.resolve(dshHome), 'hams')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function databasePath(dataDir: string): string {
|
|
52
|
-
return path.join(path.resolve(dataDir), DB_FILENAME)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function openDatabase(options: OpenDatabaseOptions): DatabaseSync {
|
|
56
|
-
const readOnly = options.readOnly === true
|
|
57
|
-
if (!readOnly) mkdirSync(path.dirname(options.path), { recursive: true })
|
|
58
|
-
|
|
59
|
-
const db = new DatabaseSync(options.path, { readOnly })
|
|
60
|
-
db.exec('PRAGMA foreign_keys = ON')
|
|
61
|
-
db.exec(`PRAGMA busy_timeout = ${options.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS}`)
|
|
62
|
-
|
|
63
|
-
if (!readOnly) {
|
|
64
|
-
const mode = db.prepare('PRAGMA journal_mode = WAL').get() as { journal_mode?: string }
|
|
65
|
-
if (String(mode?.journal_mode ?? '').toLowerCase() !== 'wal') {
|
|
66
|
-
db.close()
|
|
67
|
-
throw new StoreError('WAL_UNAVAILABLE', `无法把 journal_mode 切到 WAL(当前 ${mode?.journal_mode})`)
|
|
68
|
-
}
|
|
69
|
-
// 业务库按会计口径保守设置:崩溃后最后一次 tick 可以重跑,但不能丢已提交事务。
|
|
70
|
-
db.exec('PRAGMA synchronous = FULL')
|
|
71
|
-
}
|
|
72
|
-
return db
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** 当前事务状态以 node:sqlite 的 isTransaction 为准,不自己记账。 */
|
|
76
|
-
export function isInTransaction(db: DatabaseSync): boolean {
|
|
77
|
-
return db.isTransaction === true
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export function beginImmediate(db: DatabaseSync): void {
|
|
81
|
-
if (isInTransaction(db)) {
|
|
82
|
-
throw new StoreError('NESTED_TRANSACTION', 'BEGIN IMMEDIATE 不允许嵌套')
|
|
83
|
-
}
|
|
84
|
-
db.exec('BEGIN IMMEDIATE')
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function commit(db: DatabaseSync): void {
|
|
88
|
-
db.exec('COMMIT')
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function rollbackQuietly(db: DatabaseSync): void {
|
|
92
|
-
try {
|
|
93
|
-
db.exec('ROLLBACK')
|
|
94
|
-
} catch {
|
|
95
|
-
// 事务已经被 SQLite 自己回滚(例如语句级错误)时,这里无事可做。
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** 同步事务包装:回调抛错即回滚,返回值原样带回。 */
|
|
100
|
-
export function transaction<T>(db: DatabaseSync, fn: () => T): T {
|
|
101
|
-
beginImmediate(db)
|
|
102
|
-
try {
|
|
103
|
-
const result = fn()
|
|
104
|
-
commit(db)
|
|
105
|
-
return result
|
|
106
|
-
} catch (error) {
|
|
107
|
-
rollbackQuietly(db)
|
|
108
|
-
throw error
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function userVersion(db: DatabaseSync): number {
|
|
113
|
-
const row = db.prepare('PRAGMA user_version').get() as { user_version?: number }
|
|
114
|
-
return Number(row?.user_version ?? 0)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function setUserVersion(db: DatabaseSync, version: number): void {
|
|
118
|
-
if (!Number.isInteger(version) || version < 0) {
|
|
119
|
-
throw new StoreError('BAD_VERSION', `user_version 必须是非负整数,收到 ${version}`)
|
|
120
|
-
}
|
|
121
|
-
db.exec(`PRAGMA user_version = ${version}`)
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface DbObject {
|
|
125
|
-
type: 'table' | 'index' | 'trigger' | 'view'
|
|
126
|
-
name: string
|
|
127
|
-
table: string
|
|
128
|
-
sql: string
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** 用户对象清单(排除 sqlite_ 内部表,例如 AUTOINCREMENT 生成的 sqlite_sequence)。 */
|
|
132
|
-
export function listObjects(db: DatabaseSync): DbObject[] {
|
|
133
|
-
const rows = db
|
|
134
|
-
.prepare(
|
|
135
|
-
`SELECT type, name, tbl_name AS "table", COALESCE(sql, '') AS sql
|
|
136
|
-
FROM sqlite_master
|
|
137
|
-
WHERE type IN ('table','index','trigger','view')
|
|
138
|
-
AND name NOT LIKE 'sqlite_%'
|
|
139
|
-
ORDER BY type, name`,
|
|
140
|
-
)
|
|
141
|
-
.all() as DbObject[]
|
|
142
|
-
return rows.map((row) => ({ ...row }))
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export function tableNames(db: DatabaseSync): string[] {
|
|
146
|
-
return listObjects(db)
|
|
147
|
-
.filter((object) => object.type === 'table')
|
|
148
|
-
.map((object) => object.name)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** 每张用户表的行数;备份校验与冒烟检查共用。 */
|
|
152
|
-
export function tableRowCounts(db: DatabaseSync): Record<string, number> {
|
|
153
|
-
const counts: Record<string, number> = {}
|
|
154
|
-
for (const name of tableNames(db)) {
|
|
155
|
-
const row = db.prepare(`SELECT COUNT(*) AS c FROM "${name.replaceAll('"', '""')}"`).get() as { c: number }
|
|
156
|
-
counts[name] = Number(row.c)
|
|
157
|
-
}
|
|
158
|
-
return counts
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** 结构指纹:对象类型 + 名字 + 规范化 SQL 的哈希,用于「备份与源库结构一致」的判断。 */
|
|
162
|
-
export function schemaFingerprint(db: DatabaseSync): string {
|
|
163
|
-
const parts = listObjects(db).map((object) => `${object.type}|${object.name}|${normalizeSql(object.sql)}`)
|
|
164
|
-
return createHash('sha256').update(parts.join('\n')).digest('hex')
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** 只折叠空白,不改写 SQL 文本——注释也是结构的一部分(保存了分期标记)。 */
|
|
168
|
-
export function normalizeSql(sql: string): string {
|
|
169
|
-
return sql.replace(/\s+/g, ' ').trim()
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function objectByTypeAndName(db: DatabaseSync, type: string, name: string): DbObject | undefined {
|
|
173
|
-
return listObjects(db).find((object) => object.type === type && object.name === name)
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function closeDatabase(db: DatabaseSync): void {
|
|
177
|
-
if (isInTransaction(db)) rollbackQuietly(db)
|
|
178
|
-
db.close()
|
|
179
|
-
}
|
package/src/store/lock.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 单写者锁:$DSH_HOME/hams/hams.lock(JSON:pid / started_at / heartbeat_at / hostname / token)。
|
|
3
|
-
*
|
|
4
|
-
* 语义(dsh-compat §7):
|
|
5
|
-
* - 拿到锁的进程才允许迁移、tick 与写命令;拿不到的实例只读打开并在界面说明;
|
|
6
|
-
* - 持有进程已不存在,或心跳超过 staleMs(默认 30 秒),视为陈旧锁,可接管;
|
|
7
|
-
* - 接管与续期都用「写临时文件 + rename」的原子替换,rename 覆盖是原子的,
|
|
8
|
-
* 写完再读回校验 token,避免两个进程同时接管后都以为自己是写者;
|
|
9
|
-
* - 释放只删自己 token 的锁,绝不删别人的。
|
|
10
|
-
*/
|
|
11
|
-
import {
|
|
12
|
-
existsSync,
|
|
13
|
-
mkdirSync,
|
|
14
|
-
readFileSync,
|
|
15
|
-
renameSync,
|
|
16
|
-
unlinkSync,
|
|
17
|
-
writeFileSync,
|
|
18
|
-
} from 'node:fs'
|
|
19
|
-
import { hostname as osHostname } from 'node:os'
|
|
20
|
-
import { randomUUID } from 'node:crypto'
|
|
21
|
-
import path from 'node:path'
|
|
22
|
-
import { StoreError } from './db.ts'
|
|
23
|
-
|
|
24
|
-
export const LOCK_FILENAME = 'hams.lock'
|
|
25
|
-
export const DEFAULT_STALE_MS = 30_000
|
|
26
|
-
|
|
27
|
-
export interface LockInfo {
|
|
28
|
-
token: string
|
|
29
|
-
pid: number
|
|
30
|
-
hostname: string
|
|
31
|
-
startedAt: number
|
|
32
|
-
heartbeatAt: number
|
|
33
|
-
runtimeEpoch: number
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export class LockHeldError extends StoreError {
|
|
37
|
-
holder: LockInfo | null
|
|
38
|
-
|
|
39
|
-
constructor(holder: LockInfo | null, lockPath: string) {
|
|
40
|
-
super(
|
|
41
|
-
'LOCK_HELD',
|
|
42
|
-
holder
|
|
43
|
-
? `数据目录已被 pid=${holder.pid}(${holder.hostname},心跳 ${holder.heartbeatAt})持有:${lockPath}`
|
|
44
|
-
: `数据目录锁存在但内容不可解析,已按陈旧锁处理失败:${lockPath}`,
|
|
45
|
-
)
|
|
46
|
-
this.holder = holder
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface AcquireLockOptions {
|
|
51
|
-
staleMs?: number
|
|
52
|
-
now?: number
|
|
53
|
-
pid?: number
|
|
54
|
-
hostname?: string
|
|
55
|
-
runtimeEpoch?: number
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export interface LockHandle {
|
|
59
|
-
path: string
|
|
60
|
-
info: LockInfo
|
|
61
|
-
/** 刷新心跳;返回写入后的快照。 */
|
|
62
|
-
heartbeat(now?: number): LockInfo
|
|
63
|
-
/** 只删自己 token 的锁;返回是否真的删掉了。 */
|
|
64
|
-
release(): boolean
|
|
65
|
-
ageMs(now?: number): number
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function lockPath(dataDir: string): string {
|
|
69
|
-
return path.join(path.resolve(dataDir), LOCK_FILENAME)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function readLock(dataDir: string): LockInfo | null {
|
|
73
|
-
const file = lockPath(dataDir)
|
|
74
|
-
if (!existsSync(file)) return null
|
|
75
|
-
try {
|
|
76
|
-
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LockInfo>
|
|
77
|
-
if (
|
|
78
|
-
typeof parsed.token !== 'string' ||
|
|
79
|
-
typeof parsed.pid !== 'number' ||
|
|
80
|
-
typeof parsed.heartbeatAt !== 'number'
|
|
81
|
-
) {
|
|
82
|
-
return null
|
|
83
|
-
}
|
|
84
|
-
return {
|
|
85
|
-
token: parsed.token,
|
|
86
|
-
pid: parsed.pid,
|
|
87
|
-
hostname: typeof parsed.hostname === 'string' ? parsed.hostname : '',
|
|
88
|
-
startedAt: typeof parsed.startedAt === 'number' ? parsed.startedAt : 0,
|
|
89
|
-
heartbeatAt: parsed.heartbeatAt,
|
|
90
|
-
runtimeEpoch: typeof parsed.runtimeEpoch === 'number' ? parsed.runtimeEpoch : 0,
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function isProcessAlive(pid: number): boolean {
|
|
98
|
-
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
99
|
-
try {
|
|
100
|
-
process.kill(pid, 0)
|
|
101
|
-
return true
|
|
102
|
-
} catch (error) {
|
|
103
|
-
// EPERM 说明进程存在但无权发信号,仍算活着。
|
|
104
|
-
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function writeAtomic(target: string, info: LockInfo): void {
|
|
109
|
-
const tmp = `${target}.${info.token}.${process.pid}.tmp`
|
|
110
|
-
writeFileSync(tmp, `${JSON.stringify(info)}\n`, { mode: 0o600 })
|
|
111
|
-
renameSync(tmp, target)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function acquireLock(dataDir: string, options: AcquireLockOptions = {}): LockHandle {
|
|
115
|
-
const dir = path.resolve(dataDir)
|
|
116
|
-
const file = lockPath(dir)
|
|
117
|
-
const now = options.now ?? Date.now()
|
|
118
|
-
const staleMs = options.staleMs ?? DEFAULT_STALE_MS
|
|
119
|
-
const pid = options.pid ?? process.pid
|
|
120
|
-
const hostname = options.hostname ?? osHostname()
|
|
121
|
-
|
|
122
|
-
mkdirSync(dir, { recursive: true })
|
|
123
|
-
|
|
124
|
-
const candidate: LockInfo = {
|
|
125
|
-
token: randomUUID(),
|
|
126
|
-
pid,
|
|
127
|
-
hostname,
|
|
128
|
-
startedAt: now,
|
|
129
|
-
heartbeatAt: now,
|
|
130
|
-
runtimeEpoch: options.runtimeEpoch ?? 0,
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const current = readLock(dir)
|
|
134
|
-
if (current) {
|
|
135
|
-
const sameHost = current.hostname === hostname
|
|
136
|
-
const heartbeatAge = now - current.heartbeatAt
|
|
137
|
-
const dead = sameHost && !isProcessAlive(current.pid)
|
|
138
|
-
const stale = dead || heartbeatAge > staleMs
|
|
139
|
-
if (!stale) {
|
|
140
|
-
if (sameHost && current.pid === pid) {
|
|
141
|
-
throw new StoreError(
|
|
142
|
-
'LOCK_HELD_SELF',
|
|
143
|
-
`本进程(pid=${pid})已经持有该数据目录的锁:${file};不要重复获取`,
|
|
144
|
-
)
|
|
145
|
-
}
|
|
146
|
-
throw new LockHeldError(current, file)
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
writeAtomic(file, candidate)
|
|
151
|
-
const confirmed = readLock(dir)
|
|
152
|
-
if (!confirmed || confirmed.token !== candidate.token) {
|
|
153
|
-
throw new LockHeldError(confirmed, file)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
let released = false
|
|
157
|
-
|
|
158
|
-
const handle: LockHandle = {
|
|
159
|
-
path: file,
|
|
160
|
-
info: confirmed,
|
|
161
|
-
heartbeat(at?: number): LockInfo {
|
|
162
|
-
if (released) throw new StoreError('LOCK_RELEASED', '锁已释放,不能再续期')
|
|
163
|
-
const currentInfo = readLock(dir)
|
|
164
|
-
if (!currentInfo || currentInfo.token !== candidate.token) {
|
|
165
|
-
throw new StoreError('LOCK_LOST', '锁已被其它进程接管,续期失败')
|
|
166
|
-
}
|
|
167
|
-
const next: LockInfo = {
|
|
168
|
-
...currentInfo,
|
|
169
|
-
heartbeatAt: at ?? Date.now(),
|
|
170
|
-
runtimeEpoch: currentInfo.runtimeEpoch,
|
|
171
|
-
}
|
|
172
|
-
writeAtomic(file, next)
|
|
173
|
-
handle.info = next
|
|
174
|
-
return next
|
|
175
|
-
},
|
|
176
|
-
release(): boolean {
|
|
177
|
-
if (released) return false
|
|
178
|
-
const currentInfo = readLock(dir)
|
|
179
|
-
if (!currentInfo || currentInfo.token !== candidate.token) {
|
|
180
|
-
released = true
|
|
181
|
-
return false
|
|
182
|
-
}
|
|
183
|
-
unlinkSync(file)
|
|
184
|
-
released = true
|
|
185
|
-
return true
|
|
186
|
-
},
|
|
187
|
-
ageMs(at?: number): number {
|
|
188
|
-
return (at ?? Date.now()) - handle.info.heartbeatAt
|
|
189
|
-
},
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
handle.info = confirmed
|
|
193
|
-
return handle
|
|
194
|
-
}
|