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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +11335 -0
  3. package/package.json +9 -5
  4. package/src/domain/check.ts +0 -193
  5. package/src/domain/errors.ts +0 -59
  6. package/src/domain/evidence.ts +0 -134
  7. package/src/domain/ids.ts +0 -31
  8. package/src/host/chat-runtime.ts +0 -489
  9. package/src/host/dsh-adapter.ts +0 -867
  10. package/src/host/session-adapter.ts +0 -276
  11. package/src/http/handler.ts +0 -1132
  12. package/src/index.js +0 -53
  13. package/src/service/audit.ts +0 -92
  14. package/src/service/chat.ts +0 -1100
  15. package/src/service/contracts.ts +0 -193
  16. package/src/service/defaults.ts +0 -182
  17. package/src/service/deliverables.ts +0 -240
  18. package/src/service/hams.ts +0 -3103
  19. package/src/service/knowledge.ts +0 -940
  20. package/src/service/member-config.ts +0 -39
  21. package/src/service/members.ts +0 -506
  22. package/src/service/memory.ts +0 -375
  23. package/src/service/permissions.ts +0 -118
  24. package/src/service/project-lifecycle.ts +0 -233
  25. package/src/service/projects.ts +0 -840
  26. package/src/service/receipts.ts +0 -63
  27. package/src/service/templates.ts +0 -140
  28. package/src/service/tick.ts +0 -751
  29. package/src/service/tools.ts +0 -711
  30. package/src/service/types.ts +0 -219
  31. package/src/service/work-item-create.ts +0 -159
  32. package/src/service/workspace-profile.ts +0 -443
  33. package/src/store/backup.ts +0 -239
  34. package/src/store/bootstrap.ts +0 -225
  35. package/src/store/db.ts +0 -179
  36. package/src/store/lock.ts +0 -194
  37. package/src/store/migrations.ts +0 -211
  38. package/src/store/schema/001_p1.sql +0 -846
  39. package/src/store/schema/001_p1.sql.ts +0 -865
  40. package/src/store/schema/002_knowledge.sql +0 -54
  41. package/src/store/schema/002_knowledge.sql.ts +0 -68
  42. package/src/store/schema/003_projects.sql.ts +0 -3
  43. package/src/store/schema/004_team.sql.ts +0 -50
  44. package/src/store/schema/005_target_check.sql.ts +0 -22
  45. package/src/store/schema/006_target_flow.sql.ts +0 -19
  46. package/src/store/schema/007_project_delete.sql.ts +0 -17
  47. package/src/store/schema/008_memory.sql.ts +0 -47
  48. package/src/store/schema/generate-projects.mjs +0 -16
  49. package/src/store/seed.ts +0 -114
@@ -1,443 +0,0 @@
1
- /**
2
- * 工作区画像(workspace profile):把「我现在在做的事」一次性灌进 HAMS。
3
- *
4
- * 为什么不是把项目写死在代码里:这些内容属于用户,不属于插件。画像是一个
5
- * 放在数据目录下的 JSON(默认 `$DSH_HOME/hams/workspace-profile.json`),
6
- * 记录当前工作区里真实存在的项目、角色、下一步和文档路径;代码只负责读它、
7
- * 校验它、幂等地落库,并在 `hams_meta` 里记内容哈希,避免每次启动重复写。
8
- *
9
- * 三条纪律:
10
- * 1. **只补缺失,不覆盖**:成员按名字、项目按名字、工作项按 `profile:<key>`
11
- * 去重、文档按绝对路径去重;已经有的不动,用户改过的内容不会被画像覆盖。
12
- * 2. **来源可追溯**:工作项带 `source` 链接到工作区里的真实文件,知识文档
13
- * 登记绝对路径与 sha256,不复制正文。
14
- * 3. **失败不阻断启动**:某一篇文档路径不在、某个成员名冲突,只记进报告的
15
- * `errors`,其余内容照常落库;适配器只打日志,不让插件起不来。
16
- *
17
- * 画像里可以出现 `cleanupTemplates`,用于删除设计期留下的示例模板(例如
18
- * `tpl-battery-alert`);删除前仍由 `removeTemplateIfUnused` 检查有没有单据在用。
19
- */
20
- import { createHash } from 'node:crypto'
21
- import { readFileSync, existsSync } from 'node:fs'
22
- import path from 'node:path'
23
- import type { DatabaseSync } from 'node:sqlite'
24
- import { transaction } from '../store/db.ts'
25
- import type { IdFactory } from '../domain/ids.ts'
26
- import { insertMember, type CreateMemberInput } from './members.ts'
27
- import { insertProject, type MetricInput } from './projects.ts'
28
- import { insertWorkItem } from './work-item-create.ts'
29
- import { KnowledgeService } from './knowledge.ts'
30
- import { removeTemplateIfUnused, upsertTemplate, STARTER_TEMPLATE, type TemplateDefinition } from './templates.ts'
31
- import { writeAudit, writeEvent } from './audit.ts'
32
-
33
- const DAY = 86_400_000
34
- const DEFAULT_DOC_STATUS = 'published' as const
35
-
36
- export interface ProfileMember {
37
- key: string
38
- kind: 'human' | 'bot'
39
- name: string
40
- role?: string
41
- domain?: string
42
- responsibility?: string
43
- positionId?: string
44
- capabilities?: string[]
45
- }
46
-
47
- export interface ProfileMetric extends MetricInput {
48
- /** 画像里用指标名引用;落库后换成 metric_id。 */
49
- observations?: { value: number | null; sourceRef?: string }[]
50
- }
51
-
52
- export interface ProfileProject {
53
- key: string
54
- name: string
55
- type: 'one_off' | 'continuous'
56
- status?: 'draft' | 'active'
57
- goalText: string
58
- coordinatorKey?: string
59
- metrics?: ProfileMetric[]
60
- }
61
-
62
- export interface ProfileWorkItem {
63
- key: string
64
- projectKey?: string
65
- title: string
66
- domain: string
67
- category: string
68
- priority?: number
69
- dueInDays?: number
70
- source?: string
71
- }
72
-
73
- export interface ProfileDoc {
74
- path: string
75
- title?: string
76
- }
77
-
78
- export interface WorkspaceProfile {
79
- version: number
80
- generatedAt?: string
81
- workspaceRoot?: string
82
- template?: TemplateDefinition
83
- members?: ProfileMember[]
84
- projects?: ProfileProject[]
85
- workItems?: ProfileWorkItem[]
86
- docs?: ProfileDoc[]
87
- overview?: { title: string; body: string }
88
- cleanupTemplates?: string[]
89
- }
90
-
91
- export interface WorkspaceProfileReport {
92
- status: 'missing' | 'unchanged' | 'applied' | 'failed'
93
- file: string
94
- hash: string | null
95
- workspaceRoot: string | null
96
- created: {
97
- template: boolean
98
- members: number
99
- projects: number
100
- workItems: number
101
- docs: number
102
- observations: number
103
- skills: number
104
- tables: number
105
- removedTemplates: number
106
- }
107
- errors: string[]
108
- /** 不阻止记录哈希的告警(例如还没有技能目录)。 */
109
- warnings: string[]
110
- }
111
-
112
- function blankReport(file: string, status: WorkspaceProfileReport['status']): WorkspaceProfileReport {
113
- return {
114
- status,
115
- file,
116
- hash: null,
117
- workspaceRoot: null,
118
- created: {
119
- template: false,
120
- members: 0,
121
- projects: 0,
122
- workItems: 0,
123
- docs: 0,
124
- observations: 0,
125
- skills: 0,
126
- tables: 0,
127
- removedTemplates: 0,
128
- },
129
- errors: [],
130
- warnings: [],
131
- }
132
- }
133
-
134
- export function defaultProfilePath(dataDir: string): string {
135
- return path.join(path.resolve(dataDir), 'workspace-profile.json')
136
- }
137
-
138
- function readProfile(file: string): { profile: WorkspaceProfile; hash: string; raw: string } {
139
- const raw = readFileSync(file, 'utf8')
140
- const hash = createHash('sha256').update(raw).digest('hex')
141
- let parsed: unknown
142
- try {
143
- parsed = JSON.parse(raw)
144
- } catch (error) {
145
- throw new Error(`画像不是合法 JSON:${(error as Error).message}`)
146
- }
147
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('画像必须是 JSON 对象')
148
- const profile = parsed as WorkspaceProfile
149
- if (profile.version !== 1) throw new Error(`画像版本不支持:${String(profile.version)},当前只支持 1`)
150
- if (profile.template && !profile.template.key) throw new Error('template.key 不能为空')
151
- return { profile, hash, raw }
152
- }
153
-
154
- export interface ApplyWorkspaceProfileOptions {
155
- db: DatabaseSync
156
- dataDir: string
157
- operatorId: string
158
- now?: number
159
- idFactory: IdFactory
160
- file?: string
161
- /** 覆盖画像里的 workspaceRoot(测试或换机器时用)。 */
162
- workspaceRoot?: string
163
- /** 知识屏的真实技能目录;不传就只同步业务表。 */
164
- skillDirs?: string[]
165
- }
166
-
167
- /**
168
- * 读取并应用工作区画像。整个应用过程在一个 `BEGIN IMMEDIATE` 里;
169
- * 任何一类内容出错只记 error,不中断其它类别;最后写审计与哈希。
170
- */
171
- export function applyWorkspaceProfile(options: ApplyWorkspaceProfileOptions): WorkspaceProfileReport {
172
- const now = options.now ?? Date.now()
173
- const file = path.resolve(options.file ?? defaultProfilePath(options.dataDir))
174
- if (!existsSync(file)) return blankReport(file, 'missing')
175
-
176
- let loaded: { profile: WorkspaceProfile; hash: string }
177
- try {
178
- loaded = readProfile(file)
179
- } catch (error) {
180
- const report = blankReport(file, 'failed')
181
- report.errors.push((error as Error).message)
182
- return report
183
- }
184
- const { profile, hash } = loaded
185
- const workspaceRoot = path.resolve(options.workspaceRoot ?? profile.workspaceRoot ?? process.cwd())
186
- const prior = options.db
187
- .prepare("SELECT value FROM hams_meta WHERE key = 'workspace_profile_sha256'")
188
- .get() as { value: string } | undefined
189
- const report = blankReport(file, 'applied')
190
- report.hash = hash
191
- report.workspaceRoot = workspaceRoot
192
- if (prior?.value === hash) {
193
- report.status = 'unchanged'
194
- return report
195
- }
196
-
197
- const knowledge = new KnowledgeService({
198
- db: options.db,
199
- now: () => now,
200
- idFactory: options.idFactory,
201
- dataDir: options.dataDir,
202
- skillDirs: options.skillDirs,
203
- })
204
-
205
- try {
206
- transaction(options.db, () => {
207
- const templateKey = (profile.template ?? STARTER_TEMPLATE).key
208
- const templateBefore = options.db
209
- .prepare('SELECT published_version_id FROM work_template WHERE key = ?')
210
- .get(templateKey) as { published_version_id: string | null } | undefined
211
- const templateVersionId = upsertTemplate(
212
- options.db,
213
- options.operatorId,
214
- now,
215
- profile.template ?? STARTER_TEMPLATE,
216
- )
217
- report.created.template = !templateBefore?.published_version_id
218
-
219
- // 1) 成员:按名字去重;bot 责任人固定为本机操作者。
220
- const memberIds = new Map<string, string>()
221
- for (const member of profile.members ?? []) {
222
- const existing = options.db.prepare('SELECT id FROM member WHERE name = ?').get(member.name) as
223
- | { id: string }
224
- | undefined
225
- if (existing) {
226
- memberIds.set(member.key, existing.id)
227
- continue
228
- }
229
- try {
230
- const input: CreateMemberInput = {
231
- kind: member.kind,
232
- name: member.name,
233
- role: member.role,
234
- domain: member.domain,
235
- responsibility: member.responsibility,
236
- positionId: member.positionId,
237
- ownerHumanId: member.kind === 'bot' ? options.operatorId : undefined,
238
- capabilities: member.capabilities,
239
- }
240
- const created = insertMember(options.db, options.idFactory, now, options.operatorId, input)
241
- memberIds.set(member.key, created.id)
242
- report.created.members += 1
243
- } catch (error) {
244
- report.errors.push(`成员「${member.name}」:${(error as Error).message}`)
245
- }
246
- }
247
-
248
- // 2) 项目:按名字去重;指标随目标版本一起建,读数按名称回填。
249
- const projectIds = new Map<string, string>()
250
- for (const project of profile.projects ?? []) {
251
- const existing = options.db.prepare('SELECT id FROM project WHERE name = ? AND deleted_at IS NULL').get(project.name) as
252
- | { id: string }
253
- | undefined
254
- if (existing) {
255
- projectIds.set(project.key, existing.id)
256
- continue
257
- }
258
- try {
259
- const metrics = (project.metrics ?? []).map((metric) => ({
260
- name: metric.name,
261
- direction: metric.direction,
262
- targetValue: metric.targetValue,
263
- targetMax: metric.targetMax,
264
- unit: metric.unit,
265
- windowKind: metric.windowKind,
266
- definitionText: metric.definitionText,
267
- sourceKind: metric.sourceKind,
268
- }))
269
- const created = insertProject(options.db, options.idFactory, now, options.operatorId, {
270
- name: project.name,
271
- type: project.type,
272
- goalText: project.goalText,
273
- coordinatorId: project.coordinatorKey ? memberIds.get(project.coordinatorKey) ?? null : null,
274
- metrics,
275
- publish: project.status !== 'draft',
276
- })
277
- projectIds.set(project.key, created.id)
278
- report.created.projects += 1
279
-
280
- for (const metric of project.metrics ?? []) {
281
- const metricRow = options.db
282
- .prepare('SELECT id FROM project_metric WHERE project_id = ? AND name = ?')
283
- .get(created.id, metric.name) as { id: string } | undefined
284
- if (!metricRow) continue
285
- for (const observation of metric.observations ?? []) {
286
- const exists = options.db
287
- .prepare('SELECT 1 AS ok FROM metric_observation WHERE metric_id = ? AND source_ref = ? LIMIT 1')
288
- .get(metricRow.id, observation.sourceRef ?? '') as { ok: number } | undefined
289
- if (exists) continue
290
- options.db
291
- .prepare(
292
- `INSERT INTO metric_observation
293
- (id, metric_id, observed_at, window_start, window_end, value, source_ref, ingested_at)
294
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
295
- )
296
- .run(
297
- options.idFactory(),
298
- metricRow.id,
299
- now,
300
- now,
301
- now,
302
- observation.value,
303
- observation.sourceRef ?? null,
304
- now,
305
- )
306
- report.created.observations += 1
307
- }
308
- }
309
- } catch (error) {
310
- report.errors.push(`项目「${project.name}」:${(error as Error).message}`)
311
- }
312
- }
313
-
314
- // 3) 工作项:按 profile:<key> 去重;负责人是本机操作者,绑通用模板。
315
- for (const item of profile.workItems ?? []) {
316
- const dedupeKey = `profile:${item.key}`
317
- const existing = options.db.prepare('SELECT id FROM work_item WHERE dedupe_key = ?').get(dedupeKey) as
318
- | { id: string }
319
- | undefined
320
- if (existing) continue
321
- try {
322
- const projectId = item.projectKey ? projectIds.get(item.projectKey) ?? null : null
323
- const inserted = insertWorkItem(
324
- { db: options.db, ids: options.idFactory, now: () => now, timeZone: 'Asia/Shanghai' },
325
- {
326
- domain: item.domain,
327
- category: item.category,
328
- title: item.title,
329
- ownerHumanId: options.operatorId,
330
- projectId,
331
- templateVersionId,
332
- dedupeKey,
333
- priority: item.priority,
334
- dueAt: now + (item.dueInDays ?? 7) * DAY,
335
- createdBy: options.operatorId,
336
- actorKind: 'human',
337
- },
338
- )
339
- if (item.source) {
340
- writeEvent(options.db, options.idFactory(), {
341
- workItemId: inserted.id,
342
- kind: 'profile_source',
343
- actorId: options.operatorId,
344
- actorKind: 'human',
345
- payload: { source: item.source },
346
- revision: inserted.revision,
347
- executionEpoch: inserted.execution_epoch,
348
- at: now,
349
- })
350
- }
351
- report.created.workItems += 1
352
- } catch (error) {
353
- report.errors.push(`工作项「${item.title}」:${(error as Error).message}`)
354
- }
355
- }
356
-
357
- // 4) 文档:登记绝对路径 + 内容哈希,不复制正文。
358
- for (const doc of profile.docs ?? []) {
359
- const absolute = path.isAbsolute(doc.path) ? doc.path : path.join(workspaceRoot, doc.path)
360
- if (!existsSync(absolute)) {
361
- report.errors.push(`文档不存在:${absolute}`)
362
- continue
363
- }
364
- try {
365
- const outcome = knowledge.importFile(options.operatorId, absolute, {
366
- title: doc.title,
367
- status: DEFAULT_DOC_STATUS,
368
- })
369
- if (outcome !== 'skipped') report.created.docs += 1
370
- } catch (error) {
371
- report.errors.push(`文档「${doc.title ?? doc.path}」:${(error as Error).message}`)
372
- }
373
- }
374
-
375
- // 5) 画像自带的总览:面板内撰写,不进源文件。
376
- if (profile.overview?.title && profile.overview?.body) {
377
- const exists = options.db
378
- .prepare("SELECT 1 AS ok FROM kb_doc WHERE title = ? AND source_kind = 'hams' LIMIT 1")
379
- .get(profile.overview.title) as { ok: number } | undefined
380
- if (!exists) {
381
- try {
382
- knowledge.createDoc(options.operatorId, {
383
- title: profile.overview.title,
384
- body: profile.overview.body,
385
- status: 'draft',
386
- })
387
- report.created.docs += 1
388
- } catch (error) {
389
- report.errors.push(`总览文档:${(error as Error).message}`)
390
- }
391
- }
392
- }
393
-
394
- // 6) 真实技能与业务表结构:与知识屏「同步真实数据」同一条路径。
395
- try {
396
- const sync = knowledge.sync(options.operatorId)
397
- report.created.skills = sync.skills.created + sync.skills.updated
398
- report.created.tables = sync.tables.created + sync.tables.updated
399
- report.warnings.push(...sync.skills.errors.slice(0, 5))
400
- report.warnings.push(...sync.tables.errors.slice(0, 5))
401
- } catch (error) {
402
- report.warnings.push(`知识同步:${(error as Error).message}`)
403
- }
404
-
405
- // 7) 清理设计期示例模板(有单据引用时保留)。
406
- for (const legacy of profile.cleanupTemplates ?? []) {
407
- if (removeTemplateIfUnused(options.db, legacy)) report.created.removedTemplates += 1
408
- }
409
-
410
- writeAudit(options.db, options.idFactory(), {
411
- actorId: 'system',
412
- actorKind: 'system',
413
- channel: 'system',
414
- action: 'workspace/profileApplied',
415
- subjectKind: 'workspace_profile',
416
- subjectId: hash,
417
- after: { file, workspaceRoot, created: report.created, errors: report.errors.length },
418
- correlationId: `profile-${hash.slice(0, 12)}`,
419
- at: now,
420
- })
421
-
422
- // 有错时不记哈希:下次启动会再试一次;已落库的内容按去重键跳过,不会重复。
423
- if (!report.errors.length) {
424
- options.db
425
- .prepare(
426
- `INSERT INTO hams_meta (key, value) VALUES ('workspace_profile_sha256', ?)
427
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
428
- )
429
- .run(hash)
430
- options.db
431
- .prepare(
432
- `INSERT INTO hams_meta (key, value) VALUES ('workspace_profile_applied_at', ?)
433
- ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
434
- )
435
- .run(String(now))
436
- }
437
- })
438
- } catch (error) {
439
- report.status = 'failed'
440
- report.errors.push((error as Error).message)
441
- }
442
- return report
443
- }
@@ -1,239 +0,0 @@
1
- /**
2
- * 备份与校验。
3
- *
4
- * 实测结论:本版 DatabaseSync 没有 `db.backup`,
5
- * 因此备份 = `PRAGMA wal_checkpoint(TRUNCATE)` + `VACUUM INTO '<backups/...>'`,
6
- * 然后**必须重新打开副本**校验:integrity_check、user_version、结构指纹、每张表行数,
7
- * 三项全对才算成功。校验失败就删掉这一份候选文件(只删刚写出的那一个),不让坏备份混进保留集。
8
- *
9
- * 目录约定:$DSH_HOME/hams/backups/,默认保留最近 3 份(dsh-compat §7)。
10
- * 恢复是人工流程,不提供自动脚本。
11
- */
12
- import { DatabaseSync } from 'node:sqlite'
13
- import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'
14
- import path from 'node:path'
15
- import {
16
- closeDatabase,
17
- isInTransaction,
18
- schemaFingerprint,
19
- tableRowCounts,
20
- tableNames,
21
- userVersion,
22
- StoreError,
23
- } from './db.ts'
24
-
25
- export const BACKUP_DIRNAME = 'backups'
26
- export const BACKUP_NAME_RE = /^hams-\d{8}-\d{6}-\d{2}\.db$/
27
- export const DEFAULT_KEEP = 3
28
-
29
- export class BackupError extends StoreError {
30
- constructor(code: string, message: string, options?: { cause?: unknown }) {
31
- super(code, message, options)
32
- this.name = 'BackupError'
33
- }
34
- }
35
-
36
- export interface BackupFileInfo {
37
- name: string
38
- path: string
39
- bytes: number
40
- mtimeMs: number
41
- }
42
-
43
- export interface CheckpointResult {
44
- busy: number
45
- log: number
46
- checkpointed: number
47
- }
48
-
49
- export interface BackupReport {
50
- name: string
51
- path: string
52
- bytes: number
53
- checkpoint: CheckpointResult
54
- userVersion: number
55
- tables: number
56
- rows: Record<string, number>
57
- integrity: string
58
- schemaFingerprint: string
59
- pruned: string[]
60
- }
61
-
62
- export interface CreateBackupOptions {
63
- /** 备份目录(通常是 <dataDir>/backups)。 */
64
- dir: string
65
- keep?: number
66
- now?: Date
67
- }
68
-
69
- export function backupDir(dataDir: string): string {
70
- return path.join(path.resolve(dataDir), BACKUP_DIRNAME)
71
- }
72
-
73
- function stamp(now: Date): string {
74
- const iso = now.toISOString() // 2026-11-14T09:05:00.000Z
75
- const date = iso.slice(0, 10).replaceAll('-', '')
76
- const time = iso.slice(11, 19).replaceAll(':', '')
77
- return `${date}-${time}`
78
- }
79
-
80
- function pickName(dir: string, now: Date): string {
81
- // 序号永远带两位,既解决同秒碰撞,又保证文件名按字典序等于按生成顺序:
82
- // hams-20261114-091048-01.db < ...-02.db < hams-20261114-091049-01.db
83
- const base = `hams-${stamp(now)}`
84
- let seq = 1
85
- while (existsSync(path.join(dir, `${base}-${String(seq).padStart(2, '0')}.db`))) seq += 1
86
- return `${base}-${String(seq).padStart(2, '0')}.db`
87
- }
88
-
89
- export function listBackups(dir: string): BackupFileInfo[] {
90
- const resolved = path.resolve(dir)
91
- if (!existsSync(resolved)) return []
92
- const files: BackupFileInfo[] = []
93
- for (const name of readdirSync(resolved)) {
94
- if (!BACKUP_NAME_RE.test(name)) continue
95
- const full = path.join(resolved, name)
96
- const stat = statSync(full)
97
- if (!stat.isFile()) continue
98
- files.push({ name, path: full, bytes: stat.size, mtimeMs: stat.mtimeMs })
99
- }
100
- // 文件名里的时间戳可排序,等价于按生成时间排序。
101
- files.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
102
- return files
103
- }
104
-
105
- export function checkpoint(db: DatabaseSync, mode: 'PASSIVE' | 'TRUNCATE' = 'TRUNCATE'): CheckpointResult {
106
- const row = db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as CheckpointResult
107
- return { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) }
108
- }
109
-
110
- function rowCountsEqual(a: Record<string, number>, b: Record<string, number>): string[] {
111
- const mismatches: string[] = []
112
- for (const table of Object.keys(a)) {
113
- if (b[table] !== a[table]) mismatches.push(`${table}: ${a[table]} -> ${b[table] ?? 'missing'}`)
114
- }
115
- for (const table of Object.keys(b)) {
116
- if (!(table in a)) mismatches.push(`${table}: 副本多出该表`)
117
- }
118
- return mismatches
119
- }
120
-
121
- export interface VerifyReport {
122
- path: string
123
- integrity: string
124
- userVersion: number
125
- sourceUserVersion: number
126
- tables: number
127
- rowsMatch: boolean
128
- schemaMatch: boolean
129
- mismatches: string[]
130
- }
131
-
132
- /** 重新打开副本做四项核对;它不信任写入方,也不复用源连接。 */
133
- export function verifyBackup(sourceDb: DatabaseSync, backupPath: string): VerifyReport {
134
- const copy = new DatabaseSync(backupPath, { readOnly: true })
135
- try {
136
- const integrityRow = copy.prepare('PRAGMA integrity_check').get() as { integrity_check: string }
137
- const integrity = String(integrityRow.integrity_check)
138
- const sourceRows = tableRowCounts(sourceDb)
139
- const copyRows = tableRowCounts(copy)
140
- const mismatches = rowCountsEqual(sourceRows, copyRows)
141
- const rowsMatch = mismatches.length === 0
142
- const schemaMatch = schemaFingerprint(sourceDb) === schemaFingerprint(copy)
143
- if (!schemaMatch) mismatches.push('结构指纹不一致')
144
- return {
145
- path: backupPath,
146
- integrity,
147
- userVersion: userVersion(copy),
148
- sourceUserVersion: userVersion(sourceDb),
149
- tables: tableNames(copy).length,
150
- rowsMatch,
151
- schemaMatch,
152
- mismatches,
153
- }
154
- } finally {
155
- closeDatabase(copy)
156
- }
157
- }
158
-
159
- /**
160
- * 生成一份可恢复的备份并校验。返回报告;校验失败时抛错且不留下坏文件。
161
- */
162
- export function createBackup(db: DatabaseSync, options: CreateBackupOptions): BackupReport {
163
- const dir = path.resolve(options.dir)
164
- const keep = options.keep ?? DEFAULT_KEEP
165
- const now = options.now ?? new Date()
166
-
167
- if (isInTransaction(db)) {
168
- throw new BackupError('BACKUP_IN_TRANSACTION', '备份不能在事务内做:先提交当前写事务')
169
- }
170
- if (!Number.isInteger(keep) || keep < 1) {
171
- throw new BackupError('BAD_KEEP', `keep 必须是 ≥1 的整数,收到 ${keep}`)
172
- }
173
-
174
- mkdirSync(dir, { recursive: true })
175
- const checkpointResult = checkpoint(db, 'TRUNCATE')
176
- const name = pickName(dir, now)
177
- const target = path.join(dir, name)
178
-
179
- try {
180
- db.prepare('VACUUM INTO ?').run(target)
181
- } catch (error) {
182
- if (existsSync(target)) unlinkSync(target)
183
- throw new BackupError('BACKUP_FAILED', `VACUUM INTO 失败:${(error as Error).message}`, { cause: error })
184
- }
185
-
186
- let verification: VerifyReport
187
- try {
188
- verification = verifyBackup(db, target)
189
- } catch (error) {
190
- unlinkSync(target)
191
- throw new BackupError('BACKUP_UNVERIFIED', `备份无法重新打开校验,已丢弃 ${name}`, { cause: error })
192
- }
193
- const ok =
194
- verification.integrity === 'ok' &&
195
- verification.rowsMatch &&
196
- verification.schemaMatch &&
197
- verification.userVersion === verification.sourceUserVersion
198
- if (!ok) {
199
- unlinkSync(target)
200
- throw new BackupError(
201
- 'BACKUP_UNVERIFIED',
202
- `备份校验失败,已丢弃 ${name}:${verification.mismatches.join('; ') || `integrity=${verification.integrity}`}`,
203
- )
204
- }
205
-
206
- const stat = statSync(target)
207
- const pruned = pruneBackups(dir, keep)
208
- if (!existsSync(target)) {
209
- throw new BackupError('BACKUP_UNEXPECTEDLY_PRUNED', `保留策略误删了刚生成的备份:${target}`)
210
- }
211
-
212
- return {
213
- name,
214
- path: target,
215
- bytes: stat.size,
216
- checkpoint: checkpointResult,
217
- userVersion: verification.userVersion,
218
- tables: verification.tables,
219
- rows: tableRowCounts(db),
220
- integrity: verification.integrity,
221
- schemaFingerprint: schemaFingerprint(db),
222
- pruned,
223
- }
224
- }
225
-
226
- /** 按文件名(= 生成顺序)保留最新 keep 份;只删 backups/ 下符合命名规则的普通文件。 */
227
- export function pruneBackups(dir: string, keep: number): string[] {
228
- const resolved = path.resolve(dir)
229
- const files = listBackups(resolved)
230
- const pruned: string[] = []
231
- files.forEach((file, index) => {
232
- if (index < keep) return
233
- if (path.dirname(file.path) !== resolved) return
234
- if (!BACKUP_NAME_RE.test(file.name)) return
235
- unlinkSync(file.path)
236
- pruned.push(file.name)
237
- })
238
- return pruned
239
- }