dsh-config-manager 0.1.72 → 0.1.76
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 +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +103 -10
- package/lib/client.js +10748 -10119
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +5 -3
- package/lib/ui/i18n.js +10 -6
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +10 -6
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回滚点台账**存储层**单测(issue #89):账本 v3 = 小清单 + 单条记录文件 + 内容寻址载荷。
|
|
3
|
+
*
|
|
4
|
+
* 覆盖(对应交付物的 8 条required测试):
|
|
5
|
+
* - L-01 清单不含大数组(序列化字节数与文件条目数无关)
|
|
6
|
+
* - L-02 载荷去重(同一工作区态只落一份)
|
|
7
|
+
* - L-03 引用解析逐字段相等;载荷缺失 / 哈希不符一律如实失败(绝不返回半个记录)
|
|
8
|
+
* - L-04 v2 单文件**流式**迁移 + 中断可重试且不产生重复
|
|
9
|
+
* - L-05 字节预算只淘汰最旧 auto(guard / protected 保留,evicted 计数正确)
|
|
10
|
+
* - L-06 孤儿原子写 tmp 清理(判不准就不删)
|
|
11
|
+
* - L-07 purgeLedger(删自动点 + 回收载荷与对象;拒绝路径不静默)
|
|
12
|
+
* - L-08 内存护栏(单次留点不读别人的记录文件;堆增长不随账本呈线性放大)
|
|
13
|
+
*
|
|
14
|
+
* 纪律:真实临时目录 + 真实读写(与 rollback-point.test.ts 同款);不 mock 文件系统。
|
|
15
|
+
*/
|
|
16
|
+
import test from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import fs from 'node:fs/promises'
|
|
19
|
+
import os from 'node:os'
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
isRollbackPointRecord,
|
|
24
|
+
RollbackPointEngine,
|
|
25
|
+
ROLLBACK_POINTS_DIR_NAME,
|
|
26
|
+
summarizeRollbackPoint,
|
|
27
|
+
type RollbackEngineOptions,
|
|
28
|
+
type RollbackPointRecord,
|
|
29
|
+
type RollbackSessionPayload,
|
|
30
|
+
type RollbackTrack,
|
|
31
|
+
type RollbackWorkspaceFile,
|
|
32
|
+
type RollbackWorkspacePayload,
|
|
33
|
+
} from './rollback-point.ts'
|
|
34
|
+
import {
|
|
35
|
+
cleanupOrphanTempFiles,
|
|
36
|
+
RollbackLedger,
|
|
37
|
+
ROLLBACK_DEFAULT_LEDGER_BYTES,
|
|
38
|
+
ROLLBACK_LEDGER_BACKUP_NAME,
|
|
39
|
+
ROLLBACK_LEDGER_SCHEMA_VERSION,
|
|
40
|
+
type RollbackLedgerManifestEntry,
|
|
41
|
+
type RollbackLedgerOptions,
|
|
42
|
+
} from './rollback-ledger.ts'
|
|
43
|
+
import { sha256Hex } from '../utils/hashing.ts'
|
|
44
|
+
|
|
45
|
+
/* --------------------------------------------------------------- fixture */
|
|
46
|
+
|
|
47
|
+
interface Fixture {
|
|
48
|
+
root: string
|
|
49
|
+
homeDir: string
|
|
50
|
+
dataDir: string
|
|
51
|
+
storeDir: string
|
|
52
|
+
sessionsDir: string
|
|
53
|
+
unitDir: string
|
|
54
|
+
logFile: string
|
|
55
|
+
workspaceDir: string
|
|
56
|
+
cleanup: () => Promise<void>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function makeFixture(): Promise<Fixture> {
|
|
60
|
+
// realpath:Windows/macOS 的 os.tmpdir() 可能是未规范化拼写,而引擎内部一律 realpath
|
|
61
|
+
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-rollback-ledger-')))
|
|
62
|
+
const homeDir = path.join(root, 'home')
|
|
63
|
+
const dataDir = path.join(homeDir, 'dsh-config-manager')
|
|
64
|
+
const storeDir = path.join(dataDir, ROLLBACK_POINTS_DIR_NAME)
|
|
65
|
+
const sessionsDir = path.join(homeDir, 'sessions')
|
|
66
|
+
const unitDir = path.join(sessionsDir, 'proj-key', 'session-1111')
|
|
67
|
+
const workspaceDir = path.join(root, 'ws')
|
|
68
|
+
const logFile = path.join(unitDir, 'session-1111.jsonl.zstd')
|
|
69
|
+
await fs.mkdir(unitDir, { recursive: true })
|
|
70
|
+
await fs.mkdir(path.join(workspaceDir, 'src'), { recursive: true })
|
|
71
|
+
await fs.writeFile(logFile, 'frame-one')
|
|
72
|
+
await fs.writeFile(path.join(workspaceDir, 'a.txt'), 'A1')
|
|
73
|
+
await fs.writeFile(path.join(workspaceDir, 'src', 'b.txt'), 'B1')
|
|
74
|
+
return {
|
|
75
|
+
root, homeDir, dataDir, storeDir, sessionsDir, unitDir, logFile, workspaceDir,
|
|
76
|
+
cleanup: async () => { await fs.rm(root, { recursive: true, force: true }) },
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeEngine(fx: Fixture, overrides: Partial<RollbackEngineOptions> = {}): RollbackPointEngine {
|
|
81
|
+
return new RollbackPointEngine({ dataDir: fx.dataDir, homeDir: fx.homeDir, sessionQuiescentMs: 0, ...overrides })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function makeLedger(storeDir: string, overrides: Partial<RollbackLedgerOptions> = {}): RollbackLedger {
|
|
85
|
+
return new RollbackLedger({
|
|
86
|
+
root: storeDir,
|
|
87
|
+
summarize: summarizeRollbackPoint,
|
|
88
|
+
isLedgerRecord: isRollbackPointRecord,
|
|
89
|
+
...overrides,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* --------------------------------------------------- 合成记录(纯内存) */
|
|
94
|
+
|
|
95
|
+
function makeFiles(root: string, seed: string, count: number): RollbackWorkspaceFile[] {
|
|
96
|
+
const out: RollbackWorkspaceFile[] = []
|
|
97
|
+
for (let i = 0; i < count; i += 1) {
|
|
98
|
+
out.push({
|
|
99
|
+
path: root + path.sep + 'f' + String(i) + '-' + seed,
|
|
100
|
+
chunk: root,
|
|
101
|
+
bytes: 8 + (i % 97),
|
|
102
|
+
sha256: sha256Hex(seed + ':' + String(i)),
|
|
103
|
+
mtimeMs: 1_700_000_000_000 + i,
|
|
104
|
+
object: sha256Hex('obj:' + seed + ':' + String(i)),
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function makeWorkspacePayload(root: string, seed: string, count: number): RollbackWorkspacePayload {
|
|
111
|
+
const files = makeFiles(root, seed, count)
|
|
112
|
+
let bytes = 0
|
|
113
|
+
for (const file of files) bytes += file.bytes
|
|
114
|
+
return {
|
|
115
|
+
chunks: [root],
|
|
116
|
+
files,
|
|
117
|
+
skipped: [],
|
|
118
|
+
excludedDirs: [],
|
|
119
|
+
unreadableDirs: [],
|
|
120
|
+
unreadableFiles: [],
|
|
121
|
+
skippedCount: 0,
|
|
122
|
+
skippedTruncated: false,
|
|
123
|
+
ignoredDirs: [],
|
|
124
|
+
ignoredDirCount: 0,
|
|
125
|
+
truncated: false,
|
|
126
|
+
bytes,
|
|
127
|
+
pathIndex: null,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function makeSessionPayload(sessionId: string, turnSeq: number): RollbackSessionPayload {
|
|
132
|
+
return {
|
|
133
|
+
sessionId,
|
|
134
|
+
logPath: null,
|
|
135
|
+
cursorSeq: turnSeq * 3,
|
|
136
|
+
cursorBytes: 64,
|
|
137
|
+
mtimeMs: 1_700_000_000_000,
|
|
138
|
+
fingerprintKind: 'cursor',
|
|
139
|
+
fingerprint: 'fp-' + String(turnSeq),
|
|
140
|
+
object: null,
|
|
141
|
+
bytes: 64,
|
|
142
|
+
turnSeq,
|
|
143
|
+
boundarySeq: turnSeq * 3 - 1,
|
|
144
|
+
boundaryEndSeq: turnSeq * 3 + 2,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface RecordOptions {
|
|
149
|
+
id: string
|
|
150
|
+
createdAt: number
|
|
151
|
+
root: string
|
|
152
|
+
kind?: 'auto' | 'guard'
|
|
153
|
+
protected?: boolean
|
|
154
|
+
files?: number
|
|
155
|
+
seed?: string
|
|
156
|
+
sessionId?: string | null
|
|
157
|
+
turnSeq?: number
|
|
158
|
+
workspaceOk?: boolean
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeRecord(opts: RecordOptions): RollbackPointRecord {
|
|
162
|
+
const seed = opts.seed ?? opts.id
|
|
163
|
+
const sessionId = opts.sessionId === undefined ? 'session-1' : opts.sessionId
|
|
164
|
+
const turnSeq = opts.turnSeq ?? 1
|
|
165
|
+
const payload = opts.workspaceOk === false ? null : makeWorkspacePayload(opts.root, seed, opts.files ?? 2)
|
|
166
|
+
const sessionPayload = sessionId === null ? null : makeSessionPayload(sessionId, turnSeq)
|
|
167
|
+
const workspace: RollbackTrack<RollbackWorkspacePayload> = {
|
|
168
|
+
kind: 'workspace',
|
|
169
|
+
capturedAt: opts.createdAt,
|
|
170
|
+
ok: payload !== null,
|
|
171
|
+
degraded: false,
|
|
172
|
+
fingerprint: payload === null ? null : sha256Hex(seed),
|
|
173
|
+
reasonCode: payload === null ? 'capture-failed' : null,
|
|
174
|
+
reasonDetail: null,
|
|
175
|
+
payload,
|
|
176
|
+
}
|
|
177
|
+
const session: RollbackTrack<RollbackSessionPayload> = {
|
|
178
|
+
kind: 'session',
|
|
179
|
+
capturedAt: opts.createdAt + 5,
|
|
180
|
+
ok: sessionPayload !== null,
|
|
181
|
+
degraded: false,
|
|
182
|
+
fingerprint: sessionPayload === null ? null : sessionPayload.fingerprint,
|
|
183
|
+
reasonCode: sessionPayload === null ? 'session-missing' : null,
|
|
184
|
+
reasonDetail: null,
|
|
185
|
+
payload: sessionPayload,
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
schemaVersion: 2,
|
|
189
|
+
id: opts.id,
|
|
190
|
+
kind: opts.kind ?? 'auto',
|
|
191
|
+
createdAt: opts.createdAt,
|
|
192
|
+
spreadMs: 5,
|
|
193
|
+
protected: opts.protected === true,
|
|
194
|
+
incomplete: !(workspace.ok && session.ok),
|
|
195
|
+
note: null,
|
|
196
|
+
sessionId,
|
|
197
|
+
unitId: sessionId === null ? null : 'proj-key/' + sessionId,
|
|
198
|
+
turnSeq: sessionPayload === null ? null : sessionPayload.turnSeq,
|
|
199
|
+
boundarySeq: sessionPayload === null ? null : sessionPayload.boundarySeq,
|
|
200
|
+
workspace,
|
|
201
|
+
session,
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** 按真实写入路径落一条记录并推进清单(与引擎 appendRecord 的核心两步一致) */
|
|
206
|
+
async function writeRecord(ledger: RollbackLedger, record: RollbackPointRecord, entries: RollbackLedgerManifestEntry[]): Promise<RollbackLedgerManifestEntry> {
|
|
207
|
+
const prepared = ledger.prepareRecord(record)
|
|
208
|
+
await ledger.writePrepared(prepared)
|
|
209
|
+
entries.push(prepared.entry)
|
|
210
|
+
await ledger.writeManifest(entries)
|
|
211
|
+
return prepared.entry
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function readJson(file: string): Promise<unknown> {
|
|
215
|
+
return JSON.parse(await fs.readFile(file, 'utf8'))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function listNames(dir: string): Promise<string[]> {
|
|
219
|
+
try {
|
|
220
|
+
return (await fs.readdir(dir)).sort()
|
|
221
|
+
} catch {
|
|
222
|
+
return []
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/* ============================================================ L-01 */
|
|
227
|
+
|
|
228
|
+
test('L-01 清单不含大数组:index.json 字节数与文件条目数无关(3 条 × 2000 文件 < 64 KiB)', async (t) => {
|
|
229
|
+
const fx = await makeFixture()
|
|
230
|
+
t.after(fx.cleanup)
|
|
231
|
+
const ledger = makeLedger(fx.storeDir)
|
|
232
|
+
const entries: RollbackLedgerManifestEntry[] = []
|
|
233
|
+
for (let i = 0; i < 3; i += 1) {
|
|
234
|
+
await writeRecord(ledger, makeRecord({ id: 'rb-big-' + String(i), createdAt: 1000 + i, root: fx.workspaceDir, files: 2000 }), entries)
|
|
235
|
+
}
|
|
236
|
+
const manifestPath = path.join(fx.storeDir, 'index.json')
|
|
237
|
+
const text = await fs.readFile(manifestPath, 'utf8')
|
|
238
|
+
const size = Buffer.byteLength(text, 'utf8')
|
|
239
|
+
assert.ok(size < 64 * 1024, 'index.json 必须 < 64 KiB,实测 ' + String(size) + ' 字节')
|
|
240
|
+
assert.equal(text.includes('"files"'), false, '清单里绝不含 workspace.files 这类大数组')
|
|
241
|
+
const parsed = await readJson(manifestPath) as { schemaVersion: number; records: unknown[] }
|
|
242
|
+
assert.equal(parsed.schemaVersion, ROLLBACK_LEDGER_SCHEMA_VERSION)
|
|
243
|
+
assert.equal(parsed.records.length, 3)
|
|
244
|
+
// 大数组只是**搬到**内容寻址载荷里,不是丢掉了
|
|
245
|
+
assert.deepEqual(await listNames(path.join(fx.storeDir, 'payloads')), entries.map((e) => e.workspace.payload + '.json').sort())
|
|
246
|
+
|
|
247
|
+
// 对照:条目数 2000 -> 20 时清单几乎不变(字节数与文件数**无关**)
|
|
248
|
+
const fx2 = await makeFixture()
|
|
249
|
+
t.after(fx2.cleanup)
|
|
250
|
+
const ledger2 = makeLedger(fx2.storeDir)
|
|
251
|
+
const entries2: RollbackLedgerManifestEntry[] = []
|
|
252
|
+
for (let i = 0; i < 3; i += 1) {
|
|
253
|
+
await writeRecord(ledger2, makeRecord({ id: 'rb-small-' + String(i), createdAt: 1000 + i, root: fx2.workspaceDir, files: 20 }), entries2)
|
|
254
|
+
}
|
|
255
|
+
const size2 = Buffer.byteLength(await fs.readFile(path.join(fx2.storeDir, 'index.json'), 'utf8'), 'utf8')
|
|
256
|
+
assert.ok(size - size2 < 1024, '清单字节数必须与文件条目数无关(差 ' + String(size - size2) + ' 字节)')
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
/* ============================================================ L-02 */
|
|
260
|
+
|
|
261
|
+
test('L-02 载荷去重:同一 workspace 载荷写两次 -> payloads/ 只有一个文件', async (t) => {
|
|
262
|
+
const fx = await makeFixture()
|
|
263
|
+
t.after(fx.cleanup)
|
|
264
|
+
const ledger = makeLedger(fx.storeDir)
|
|
265
|
+
const entries: RollbackLedgerManifestEntry[] = []
|
|
266
|
+
const first = makeRecord({ id: 'rb-dedupe-a', createdAt: 1000, root: fx.workspaceDir, files: 50, seed: 'same' })
|
|
267
|
+
const second = makeRecord({ id: 'rb-dedupe-b', createdAt: 2000, root: fx.workspaceDir, files: 50, seed: 'same' })
|
|
268
|
+
assert.deepEqual(first.workspace.payload, second.workspace.payload, '前提:两条记录的工作区态逐字段相同')
|
|
269
|
+
await writeRecord(ledger, first, entries)
|
|
270
|
+
const payloadsDir = path.join(fx.storeDir, 'payloads')
|
|
271
|
+
assert.equal((await listNames(payloadsDir)).length, 1)
|
|
272
|
+
await writeRecord(ledger, second, entries)
|
|
273
|
+
assert.equal((await listNames(payloadsDir)).length, 1, '同一工作区态只落一份载荷')
|
|
274
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'records'))).length, 2, '记录文件是两条(元数据各一份)')
|
|
275
|
+
|
|
276
|
+
// 引擎级:连续两个回合工作区未变 ⇒ 载荷目录零新增(零额外字节)
|
|
277
|
+
const fx3 = await makeFixture()
|
|
278
|
+
t.after(fx3.cleanup)
|
|
279
|
+
const engine = makeEngine(fx3)
|
|
280
|
+
const capture = async (): Promise<void> => {
|
|
281
|
+
const res = await engine.capturePoint({
|
|
282
|
+
chunks: [fx3.workspaceDir],
|
|
283
|
+
sessionId: 'session-1111',
|
|
284
|
+
unitId: 'proj-key/session-1111',
|
|
285
|
+
sessionLogPath: fx3.logFile,
|
|
286
|
+
cursorSeq: 3,
|
|
287
|
+
turnSeq: 2,
|
|
288
|
+
boundarySeq: 1,
|
|
289
|
+
boundaryEndSeq: 6,
|
|
290
|
+
})
|
|
291
|
+
assert.equal(res.ok, true, String(res.detail ?? ''))
|
|
292
|
+
}
|
|
293
|
+
await capture()
|
|
294
|
+
const enginePayloads = path.join(fx3.storeDir, 'payloads')
|
|
295
|
+
const before = await listNames(enginePayloads)
|
|
296
|
+
await capture()
|
|
297
|
+
assert.ok(before.length >= 1)
|
|
298
|
+
assert.deepEqual(await listNames(enginePayloads), before, '工作区未变时第二个回合必须零新增载荷')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
/* ============================================================ L-03 */
|
|
302
|
+
|
|
303
|
+
test('L-03 引用解析:readRecord 逐字段还原;载荷缺失 / 哈希不符一律明确失败', async (t) => {
|
|
304
|
+
const fx = await makeFixture()
|
|
305
|
+
t.after(fx.cleanup)
|
|
306
|
+
const ledger = makeLedger(fx.storeDir)
|
|
307
|
+
const record = makeRecord({ id: 'rb-ref-1', createdAt: 1000, root: fx.workspaceDir, files: 30 })
|
|
308
|
+
const prepared = ledger.prepareRecord(record)
|
|
309
|
+
await ledger.writePrepared(prepared)
|
|
310
|
+
await ledger.writeManifest([prepared.entry])
|
|
311
|
+
|
|
312
|
+
const resolved = await ledger.readRecord(record.id)
|
|
313
|
+
assert.equal(resolved.error, undefined, String(resolved.error ?? ''))
|
|
314
|
+
assert.deepEqual(resolved.record, record, '读回来必须与写入时**逐字段相等**')
|
|
315
|
+
|
|
316
|
+
// 载荷被删 ⇒ 明确失败(绝不返回半个记录)
|
|
317
|
+
const payloadFile = path.join(fx.storeDir, 'payloads', String(prepared.payloadHash) + '.json')
|
|
318
|
+
await fs.rm(payloadFile)
|
|
319
|
+
const missing = await ledger.readRecord(record.id)
|
|
320
|
+
assert.equal(missing.record, undefined, '载荷缺失时绝不能返回一个 workspace.payload=null 的半个记录')
|
|
321
|
+
assert.ok((missing.error ?? '').startsWith('record-incomplete'), String(missing.error ?? ''))
|
|
322
|
+
|
|
323
|
+
// 载荷被改(哈希不符)⇒ 同样明确失败
|
|
324
|
+
await fs.writeFile(payloadFile, '{"chunks":[],"files":[]}')
|
|
325
|
+
const mismatched = await ledger.readRecord(record.id)
|
|
326
|
+
assert.equal(mismatched.record, undefined)
|
|
327
|
+
assert.ok((mismatched.error ?? '').includes('hash mismatch'), String(mismatched.error ?? ''))
|
|
328
|
+
|
|
329
|
+
// 引擎侧同样如实失败:preview / rewind 报 record-incomplete,且零写入
|
|
330
|
+
const fx2 = await makeFixture()
|
|
331
|
+
t.after(fx2.cleanup)
|
|
332
|
+
const engine = makeEngine(fx2)
|
|
333
|
+
const captured = await engine.capturePoint({ chunks: [fx2.workspaceDir], sessionId: 'session-1111', sessionLogPath: fx2.logFile, turnSeq: 2, boundarySeq: 1 })
|
|
334
|
+
const id = captured.point?.id ?? ''
|
|
335
|
+
await fs.writeFile(path.join(fx2.workspaceDir, 'a.txt'), 'A2')
|
|
336
|
+
const meta = JSON.parse(await fs.readFile(path.join(fx2.storeDir, 'records', id + '.json'), 'utf8')) as { workspace: { payload: string } }
|
|
337
|
+
await fs.rm(path.join(fx2.storeDir, 'payloads', meta.workspace.payload + '.json'))
|
|
338
|
+
const preview = await engine.preview(id)
|
|
339
|
+
assert.equal(preview.ok, false)
|
|
340
|
+
assert.equal(preview.code, 'record-incomplete')
|
|
341
|
+
const rewound = await engine.rewind({ id, userConfirmed: true, guardPolicy: 'off' })
|
|
342
|
+
assert.equal(rewound.outcome, 'failed')
|
|
343
|
+
assert.equal(rewound.code, 'record-incomplete')
|
|
344
|
+
assert.equal(await fs.readFile(path.join(fx2.workspaceDir, 'a.txt'), 'utf8'), 'A2', '记录读不全 ⇒ 零写入')
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
/* ============================================================ L-04 */
|
|
348
|
+
|
|
349
|
+
/** 造一个 v2 单文件台账(schemaVersion 2 + records 数组) */
|
|
350
|
+
async function writeV2Ledger(storeDir: string, records: RollbackPointRecord[]): Promise<string> {
|
|
351
|
+
const manifestPath = path.join(storeDir, 'index.json')
|
|
352
|
+
await fs.mkdir(storeDir, { recursive: true })
|
|
353
|
+
await fs.writeFile(manifestPath, JSON.stringify({ schemaVersion: 2, records }, null, 2))
|
|
354
|
+
return manifestPath
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
test('L-04 v2 单文件流式迁移:list 逐条一致、旧文件改名保留;中断可重试且不产生重复', async (t) => {
|
|
358
|
+
const fx = await makeFixture()
|
|
359
|
+
t.after(fx.cleanup)
|
|
360
|
+
const records = [
|
|
361
|
+
makeRecord({ id: 'rb-v2-a', createdAt: 1000, root: fx.workspaceDir, files: 3, turnSeq: 1 }),
|
|
362
|
+
makeRecord({ id: 'rb-v2-big', createdAt: 2000, root: fx.workspaceDir, files: 3000, turnSeq: 2 }),
|
|
363
|
+
makeRecord({ id: 'rb-v2-c', createdAt: 3000, root: fx.workspaceDir, files: 5, kind: 'guard', turnSeq: 3 }),
|
|
364
|
+
]
|
|
365
|
+
const manifestPath = await writeV2Ledger(fx.storeDir, records)
|
|
366
|
+
const v2Bytes = await fs.readFile(manifestPath)
|
|
367
|
+
|
|
368
|
+
// ① 注入写失败(第 3 次写开始全失败)⇒ 迁移中断、原文件**一动不动**
|
|
369
|
+
let writes = 0
|
|
370
|
+
const failing = makeLedger(fx.storeDir, {
|
|
371
|
+
writeFile: async (target, bytes) => {
|
|
372
|
+
writes += 1
|
|
373
|
+
if (writes >= 3) throw new Error('injected write failure')
|
|
374
|
+
await fs.writeFile(target, bytes)
|
|
375
|
+
},
|
|
376
|
+
})
|
|
377
|
+
const failed = await failing.migrateIfNeeded()
|
|
378
|
+
assert.notEqual(failed.error, undefined, '注入写失败后必须如实回报错误')
|
|
379
|
+
assert.deepEqual(await fs.readFile(manifestPath), v2Bytes, '迁移失败 ⇒ 原文件一个字节都不许动')
|
|
380
|
+
assert.deepEqual(await listNames(path.join(fx.storeDir, ROLLBACK_LEDGER_BACKUP_NAME)).catch(() => []), [], '失败时不留备份名')
|
|
381
|
+
|
|
382
|
+
// ② 重启(新引擎 / 新存储层)⇒ 重试成功,且不产生重复
|
|
383
|
+
const engine = makeEngine(fx)
|
|
384
|
+
const listed = await engine.list()
|
|
385
|
+
assert.equal(listed.error, undefined, String(listed.error ?? ''))
|
|
386
|
+
assert.equal(listed.points.length, records.length, '迁移后条数不变')
|
|
387
|
+
const expected = records
|
|
388
|
+
.map((record) => summarizeRollbackPoint(record))
|
|
389
|
+
.sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
390
|
+
const actual = listed.points.slice().sort((a, b) => (a.id < b.id ? -1 : 1))
|
|
391
|
+
assert.deepEqual(actual, expected, '迁移后 list 的每条摘要必须与 v2 记录逐字段一致')
|
|
392
|
+
assert.equal(new Set(listed.points.map((p) => p.id)).size, records.length, '绝不产生重复 id')
|
|
393
|
+
|
|
394
|
+
// ③ 旧文件改名保留 + 清单变 v3(且不含大数组)
|
|
395
|
+
assert.deepEqual(await fs.readFile(path.join(fx.storeDir, ROLLBACK_LEDGER_BACKUP_NAME)), v2Bytes, '旧 v2 文件逐字节保留')
|
|
396
|
+
const manifestText = await fs.readFile(manifestPath, 'utf8')
|
|
397
|
+
assert.equal(manifestText.includes('"files"'), false, '迁移后的清单里绝不含大数组')
|
|
398
|
+
assert.ok(Buffer.byteLength(manifestText, 'utf8') < 64 * 1024)
|
|
399
|
+
const manifest = JSON.parse(manifestText) as { schemaVersion: number; records: unknown[] }
|
|
400
|
+
assert.equal(manifest.schemaVersion, ROLLBACK_LEDGER_SCHEMA_VERSION)
|
|
401
|
+
assert.equal(manifest.records.length, records.length)
|
|
402
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'records'))).length, records.length)
|
|
403
|
+
|
|
404
|
+
// ④ 幂等:再建一个引擎(又一次启动)不会重复迁移
|
|
405
|
+
const again = makeEngine(fx)
|
|
406
|
+
const second = await again.list()
|
|
407
|
+
assert.equal(second.points.length, records.length)
|
|
408
|
+
assert.equal(second.error, undefined)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
/* ============================================================ L-05 */
|
|
412
|
+
|
|
413
|
+
test('L-05 字节预算:只淘汰最旧 auto,guard / protected 保留,evicted 计数正确', async (t) => {
|
|
414
|
+
const fx = await makeFixture()
|
|
415
|
+
t.after(fx.cleanup)
|
|
416
|
+
const engine = makeEngine(fx, { ledgerBytesBudget: 1, maxRecords: 50, maxRecordsPerSession: 50 })
|
|
417
|
+
const capture = async (extra: Record<string, unknown>): Promise<string> => {
|
|
418
|
+
const res = await engine.capturePoint({ chunks: [fx.workspaceDir], sessionId: 's-budget', sessionLogPath: fx.logFile, ...extra })
|
|
419
|
+
assert.equal(res.ok, true, String(res.detail ?? ''))
|
|
420
|
+
return res.point?.id ?? ''
|
|
421
|
+
}
|
|
422
|
+
const auto1 = await capture({ turnSeq: 1, seed: 'a1' } as Record<string, unknown>)
|
|
423
|
+
const guard1 = await capture({ kind: 'guard', turnSeq: 2 })
|
|
424
|
+
const protected1 = await capture({ protect: true, turnSeq: 3 })
|
|
425
|
+
const auto2 = await capture({ turnSeq: 4 })
|
|
426
|
+
const auto3 = await capture({ turnSeq: 5 })
|
|
427
|
+
|
|
428
|
+
const listed = await engine.list()
|
|
429
|
+
const ids = listed.points.map((p) => p.id)
|
|
430
|
+
assert.equal(ids.includes(auto1), false, '最旧的 auto 必须被字节预算淘汰')
|
|
431
|
+
assert.equal(ids.includes(auto2), false, '次旧的 auto 也会被淘汰(预算极小)')
|
|
432
|
+
assert.equal(ids.includes(auto3), true, '最新 auto 保留(本次写入的那一条不参与本轮淘汰)')
|
|
433
|
+
assert.equal(ids.includes(guard1), true, 'guard 不受字节预算淘汰')
|
|
434
|
+
assert.equal(ids.includes(protected1), true, 'protected 永不淘汰')
|
|
435
|
+
|
|
436
|
+
const status = await engine.storageStatus()
|
|
437
|
+
assert.equal(status.available, true)
|
|
438
|
+
assert.equal(status.ledger?.budgetBytes, 1)
|
|
439
|
+
assert.equal(status.ledger?.evicted, 2, 'evicted 必须精确等于被预算淘汰的自动点数')
|
|
440
|
+
assert.equal(status.ledger?.records, 3)
|
|
441
|
+
|
|
442
|
+
// 淘汰的记录文件与无人引用的载荷必须真的从磁盘消失
|
|
443
|
+
const recordNames = await listNames(path.join(fx.storeDir, 'records'))
|
|
444
|
+
assert.equal(recordNames.includes(auto1 + '.json'), false)
|
|
445
|
+
assert.equal(recordNames.includes(auto2 + '.json'), false)
|
|
446
|
+
assert.equal(recordNames.includes(auto3 + '.json'), true)
|
|
447
|
+
assert.equal(recordNames.includes(guard1 + '.json'), true)
|
|
448
|
+
|
|
449
|
+
// 缺省预算就是导出的常量(不注入时生效)
|
|
450
|
+
const def = makeEngine(fx, { maxRecords: 1 })
|
|
451
|
+
assert.equal(ROLLBACK_DEFAULT_LEDGER_BYTES, 256 * 1024 * 1024)
|
|
452
|
+
assert.equal((await def.storageStatus()).ledger?.budgetBytes, ROLLBACK_DEFAULT_LEDGER_BYTES)
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
/* ============================================================ L-09 */
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* L-09(issue #89 可观测性):预算淘汰累计数**必须持久化**。
|
|
459
|
+
*
|
|
460
|
+
* 为什么:宿主每个 HTTP 请求都新建一个引擎(路由侧),只记在实例上的话这个数字永远是 0 ——
|
|
461
|
+
* 界面就永远看不到「已经因为预算淘汰过 N 个」。判据 = 换一个全新实例读同一个 storeDir,
|
|
462
|
+
* 计数必须仍在,而且**只增不减**(读清单不得把计数抹回去)。
|
|
463
|
+
*/
|
|
464
|
+
test('L-09 预算淘汰累计数持久化:新实例仍读得到,且只增不减', async (t) => {
|
|
465
|
+
const fx = await makeFixture()
|
|
466
|
+
t.after(fx.cleanup)
|
|
467
|
+
const engine = makeEngine(fx, { ledgerBytesBudget: 1, maxRecords: 50, maxRecordsPerSession: 50 })
|
|
468
|
+
const capture = async (turn: number): Promise<void> => {
|
|
469
|
+
const res = await engine.capturePoint({
|
|
470
|
+
chunks: [fx.workspaceDir], sessionId: 's-persist', sessionLogPath: fx.logFile, turnSeq: turn,
|
|
471
|
+
})
|
|
472
|
+
assert.equal(res.ok, true, String(res.detail ?? ''))
|
|
473
|
+
}
|
|
474
|
+
await capture(1)
|
|
475
|
+
await capture(2)
|
|
476
|
+
await capture(3)
|
|
477
|
+
|
|
478
|
+
const first = await engine.storageStatus()
|
|
479
|
+
const evicted = first.ledger?.evicted ?? 0
|
|
480
|
+
assert.ok(evicted > 0, '本用例必须先真的发生淘汰(否则测不到东西)')
|
|
481
|
+
|
|
482
|
+
// 全新实例(= 路由每请求新建引擎的场景):计数必须还在
|
|
483
|
+
const fresh = makeEngine(fx, { ledgerBytesBudget: 1 })
|
|
484
|
+
assert.equal((await fresh.storageStatus()).ledger?.evicted, evicted, '计数必须随清单落盘,跨实例可见')
|
|
485
|
+
// 只增不减:再读一次不得回退
|
|
486
|
+
assert.equal((await fresh.storageStatus()).ledger?.evicted, evicted)
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
/* ============================================================ L-06 */
|
|
491
|
+
|
|
492
|
+
test('L-06 孤儿 tmp 清理:只删形状可自证的崩溃残留,返回字节数正确', async (t) => {
|
|
493
|
+
const fx = await makeFixture()
|
|
494
|
+
t.after(fx.cleanup)
|
|
495
|
+
await fs.mkdir(fx.storeDir, { recursive: true })
|
|
496
|
+
// ① 真形态(atomic-write.ts tmpPath():.dshcm.<base>.<pid>.<16hex>.tmp)+ 一个几乎不可能存在的 pid
|
|
497
|
+
const orphan = path.join(fx.storeDir, '.dshcm.index.json.2147483646.0123456789abcdef.tmp')
|
|
498
|
+
await fs.writeFile(orphan, 'x'.repeat(1234))
|
|
499
|
+
// ② 形态合法但 pid 是本进程 ⇒ 可能正在写,绝不删
|
|
500
|
+
const own = path.join(fx.storeDir, '.dshcm.index.json.' + String(process.pid) + '.fedcba9876543210.tmp')
|
|
501
|
+
await fs.writeFile(own, 'y'.repeat(10))
|
|
502
|
+
// ③ 名字不符合规则(不是 16 位 hex)⇒ 连候选都不算
|
|
503
|
+
const bogus = path.join(fx.storeDir, '.dshcm.index.json.4242.zzzz.tmp')
|
|
504
|
+
await fs.writeFile(bogus, 'zz')
|
|
505
|
+
// ④ 普通文件绝不能被误伤
|
|
506
|
+
const keeper = path.join(fx.storeDir, 'index.json')
|
|
507
|
+
await fs.writeFile(keeper, '{"schemaVersion":3,"records":[]}')
|
|
508
|
+
|
|
509
|
+
const result = await cleanupOrphanTempFiles(fx.storeDir)
|
|
510
|
+
assert.equal(result.removed, 1)
|
|
511
|
+
assert.equal(result.bytes, 1234)
|
|
512
|
+
assert.equal(result.skipped, 1, '形似但判据不足(本进程 pid)的必须计入 skipped 且不删')
|
|
513
|
+
await assert.rejects(fs.stat(orphan), { code: 'ENOENT' })
|
|
514
|
+
assert.equal((await fs.stat(own)).size, 10, '本进程的 tmp 绝不删')
|
|
515
|
+
assert.equal((await fs.stat(bogus)).size, 2, '名字不合规则的一律不删')
|
|
516
|
+
assert.equal(await fs.readFile(keeper, 'utf8'), '{"schemaVersion":3,"records":[]}')
|
|
517
|
+
|
|
518
|
+
// 启动期(引擎惰性初始化)会真的调用它 —— 残留必须在第一次用到引擎时消失
|
|
519
|
+
const fx2 = await makeFixture()
|
|
520
|
+
t.after(fx2.cleanup)
|
|
521
|
+
await fs.mkdir(fx2.storeDir, { recursive: true })
|
|
522
|
+
const residue = path.join(fx2.storeDir, '.dshcm.index.json.2147483645.aaaaaaaaaaaaaaaa.tmp')
|
|
523
|
+
await fs.writeFile(residue, 'r'.repeat(64))
|
|
524
|
+
const engine = makeEngine(fx2)
|
|
525
|
+
await engine.storageStatus()
|
|
526
|
+
await assert.rejects(fs.stat(residue), { code: 'ENOENT' })
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
/* ============================================================ L-07 */
|
|
530
|
+
|
|
531
|
+
test('L-07 purgeLedger:删自动点并回收载荷与对象,guard / protected 保留;拒绝路径不静默', async (t) => {
|
|
532
|
+
const fx = await makeFixture()
|
|
533
|
+
t.after(fx.cleanup)
|
|
534
|
+
const engine = makeEngine(fx, { maxRecords: 50, maxRecordsPerSession: 50 })
|
|
535
|
+
const capture = async (extra: Record<string, unknown>): Promise<string> => {
|
|
536
|
+
const res = await engine.capturePoint({ chunks: [fx.workspaceDir], sessionId: 's-purge', sessionLogPath: fx.logFile, ...extra })
|
|
537
|
+
assert.equal(res.ok, true, String(res.detail ?? ''))
|
|
538
|
+
return res.point?.id ?? ''
|
|
539
|
+
}
|
|
540
|
+
// 让每个点的工作区态**互不相同** ⇒ 载荷文件各一份,才能验证「无人引用的载荷真的被回收」
|
|
541
|
+
const guard1 = await capture({ kind: 'guard', turnSeq: 1 })
|
|
542
|
+
await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A2')
|
|
543
|
+
const auto1 = await capture({ turnSeq: 2 })
|
|
544
|
+
await fs.writeFile(path.join(fx.workspaceDir, 'src', 'b.txt'), 'B2')
|
|
545
|
+
const auto2 = await capture({ turnSeq: 3 })
|
|
546
|
+
await fs.writeFile(path.join(fx.workspaceDir, 'a.txt'), 'A3')
|
|
547
|
+
const protected1 = await capture({ protect: true, turnSeq: 4 })
|
|
548
|
+
|
|
549
|
+
const objectsDir = path.join(fx.storeDir, 'objects')
|
|
550
|
+
assert.ok((await listNames(objectsDir)).length > 0)
|
|
551
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'payloads'))).length, 4, '四个不同的工作区态 ⇒ 四份载荷')
|
|
552
|
+
|
|
553
|
+
const purged = await engine.purgeLedger()
|
|
554
|
+
assert.equal(purged.removedRecords, 2, '两个自动点被删')
|
|
555
|
+
assert.ok(purged.bytes > 0, '必须如实回报释放字节')
|
|
556
|
+
assert.equal(purged.removedPayloads, 2, '只被删掉的自动点引用的载荷文件必须回收')
|
|
557
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'payloads'))).length, 2, '仍被 guard/protected 引用的载荷绝不能被删')
|
|
558
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'records'))).includes(auto1 + '.json'), false, 'auto1 记录文件必须消失')
|
|
559
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'records'))).includes(auto2 + '.json'), false, 'auto2 记录文件必须消失')
|
|
560
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'records'))).includes(guard1 + '.json'), true)
|
|
561
|
+
|
|
562
|
+
const afterFirst = await engine.list()
|
|
563
|
+
const ids = afterFirst.points.map((p) => p.id)
|
|
564
|
+
assert.deepEqual(ids.slice().sort(), [guard1, protected1].sort(), 'guard 与 protected 必须保留')
|
|
565
|
+
// guard 的载荷还有引用 ⇒ 不能删;auto 的载荷没人引用 ⇒ 必须删
|
|
566
|
+
const payloadNames = await listNames(path.join(fx.storeDir, 'payloads'))
|
|
567
|
+
assert.equal(payloadNames.length, afterFirst.points.length, '载荷文件数必须等于仍被引用的记录数')
|
|
568
|
+
|
|
569
|
+
// includeGuards=true ⇒ 连 guard 一起删,但 protected 永不删
|
|
570
|
+
const purged2 = await engine.purgeLedger({ includeGuards: true })
|
|
571
|
+
assert.equal(purged2.removedRecords, 1)
|
|
572
|
+
const afterSecond = await engine.list()
|
|
573
|
+
assert.deepEqual(afterSecond.points.map((p) => p.id), [protected1])
|
|
574
|
+
assert.equal((await listNames(path.join(fx.storeDir, 'records'))).length, 1)
|
|
575
|
+
|
|
576
|
+
// 拒绝路径:清单读不出来 ⇒ 抛带 code 的错误,一个字节都不动
|
|
577
|
+
const fx2 = await makeFixture()
|
|
578
|
+
t.after(fx2.cleanup)
|
|
579
|
+
const engine2 = makeEngine(fx2)
|
|
580
|
+
await engine2.capturePoint({ chunks: [fx2.workspaceDir], sessionId: 'session-1111', sessionLogPath: fx2.logFile })
|
|
581
|
+
const broken = path.join(fx2.storeDir, 'index.json')
|
|
582
|
+
await fs.writeFile(broken, '{ broken-json')
|
|
583
|
+
const recordsBefore = await listNames(path.join(fx2.storeDir, 'records'))
|
|
584
|
+
await assert.rejects(
|
|
585
|
+
engine2.purgeLedger(),
|
|
586
|
+
(error: unknown) => (error as { code?: string }).code === 'ledger-unreadable',
|
|
587
|
+
)
|
|
588
|
+
assert.deepEqual(await listNames(path.join(fx2.storeDir, 'records')), recordsBefore, '拒绝路径必须零写入')
|
|
589
|
+
assert.equal(await fs.readFile(broken, 'utf8'), '{ broken-json')
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
/* ============================================================ L-08 */
|
|
593
|
+
|
|
594
|
+
test('L-08 内存护栏:单次留点不读别人的记录文件;堆增长不随账本线性放大', async (t) => {
|
|
595
|
+
// ① 确定性证据:一次 append 只读清单(index.json),**绝不**读任何记录文件或载荷文件
|
|
596
|
+
const fx = await makeFixture()
|
|
597
|
+
t.after(fx.cleanup)
|
|
598
|
+
let reads: string[] = []
|
|
599
|
+
const isLedgerBodyRead = (file: string): boolean => file.includes(path.sep + 'records' + path.sep) || file.includes(path.sep + 'payloads' + path.sep)
|
|
600
|
+
const engines = makeEngine(fx, { maxRecords: 1000, maxRecordsPerSession: 1000, ledgerReadFile: (file) => reads.push(file) })
|
|
601
|
+
for (let i = 0; i < 12; i += 1) {
|
|
602
|
+
reads = []
|
|
603
|
+
const res = await engines.capturePoint({
|
|
604
|
+
chunks: [fx.workspaceDir], sessionId: 's-read', sessionLogPath: fx.logFile, turnSeq: i + 1, boundarySeq: i,
|
|
605
|
+
})
|
|
606
|
+
assert.equal(res.ok, true, String(res.detail ?? ''))
|
|
607
|
+
// 一次留点只读**清单**:读任何记录文件/载荷文件都是 issue #89 要消灭的那条路
|
|
608
|
+
assert.deepEqual(reads.filter(isLedgerBodyRead), [], '一次留点读了记录/载荷文件:' + reads.join(','))
|
|
609
|
+
}
|
|
610
|
+
reads = []
|
|
611
|
+
await engines.list()
|
|
612
|
+
assert.deepEqual(reads.filter((file) => file.includes(path.sep + 'records' + path.sep)), [], 'list 必须只读清单(一个记录文件都不读)')
|
|
613
|
+
reads = []
|
|
614
|
+
await engines.storageStatus()
|
|
615
|
+
assert.deepEqual(reads.filter(isLedgerBodyRead), [], 'storageStatus 也不许读记录/载荷')
|
|
616
|
+
|
|
617
|
+
// ② 近似护栏(真正防回归的是上面那条确定性断言):**同一次 append** 的堆增量与账本规模无关。
|
|
618
|
+
// 为什么这样判:旧实现每个回合都要把整份台账 JSON 读进内存再 parse ⇒ 单次 append 的堆增量
|
|
619
|
+
// ∝ 账本总字节(本机 50 条 = 473 MB 时直接 OOM)。新实现单次 append 只驻留「这一条载荷 + 清单」,
|
|
620
|
+
// 所以 200 条账本里的那次 append 应当与 5 条账本里的那次同量级。
|
|
621
|
+
// 阈值取 4 倍 + 8 MiB(宽松):V8 的垃圾回收时机不可控,用 3 次取最小来抵消抖动;
|
|
622
|
+
// 而「线性放大」在这里是 40 倍(200/5),4 倍阈值足以区分二者,又不会因抖动假红。
|
|
623
|
+
const small = await measureAppendHeap(5, 2000)
|
|
624
|
+
const big = await measureAppendHeap(200, 2000)
|
|
625
|
+
assert.equal(big.entries, 203, '大账本场景必须真的攒下 200 条(否则测的不是大账本)')
|
|
626
|
+
// small.min 可能是负数(这一次 append 期间 V8 顺手回收了垃圾):负增量按 0 处理,避免把阈值算成负数
|
|
627
|
+
const smallFloor = Math.max(small.min, 0)
|
|
628
|
+
assert.ok(
|
|
629
|
+
big.min <= smallFloor * 4 + 8 * 1024 * 1024,
|
|
630
|
+
'单次 append 的堆增量随账本线性放大:small=' + String(small.min) + ' big=' + String(big.min),
|
|
631
|
+
)
|
|
632
|
+
assert.ok(big.min < 32 * 1024 * 1024, '单次 append 的堆增量必须保持在几十 MB 以内(旧实现 = 整份账本),实测 ' + String(big.min))
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
/** 造 count 条记录(每条 files 个文件条目)后,量 3 次 append 的堆增量并取最小值 */
|
|
636
|
+
async function measureAppendHeap(count: number, files: number): Promise<{ min: number; entries: number }> {
|
|
637
|
+
const fx = await makeFixture()
|
|
638
|
+
try {
|
|
639
|
+
const ledger = makeLedger(fx.storeDir, { bytesBudget: 1024 * 1024 * 1024 })
|
|
640
|
+
const entries: RollbackLedgerManifestEntry[] = []
|
|
641
|
+
for (let i = 0; i < count; i += 1) {
|
|
642
|
+
await writeRecord(ledger, makeRecord({ id: 'rb-heap-' + String(i), createdAt: 1000 + i, root: fx.workspaceDir, files, seed: 'heap-' + String(i) }), entries)
|
|
643
|
+
}
|
|
644
|
+
let min = Number.POSITIVE_INFINITY
|
|
645
|
+
for (let i = 0; i < 3; i += 1) {
|
|
646
|
+
const before = process.memoryUsage().heapUsed
|
|
647
|
+
await writeRecord(ledger, makeRecord({ id: 'rb-probe-' + String(i), createdAt: 9000 + i, root: fx.workspaceDir, files, seed: 'probe-' + String(i) }), entries)
|
|
648
|
+
const delta = process.memoryUsage().heapUsed - before
|
|
649
|
+
if (delta < min) min = delta
|
|
650
|
+
}
|
|
651
|
+
return { min, entries: entries.length }
|
|
652
|
+
} finally {
|
|
653
|
+
await fx.cleanup()
|
|
654
|
+
}
|
|
655
|
+
}
|