dsh-config-manager 0.1.78 → 0.1.80
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/lib/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +401 -112
- package/lib/client.js +2899 -1525
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/ancestor.d.ts +22 -0
- package/lib/sync/ancestor.js +66 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +18 -1
- package/lib/sync/gist/gist-transport.js +42 -3
- package/lib/sync/gist/messages.d.ts +2 -0
- package/lib/sync/gist/messages.js +5 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/s3/sigv4.js +11 -2
- package/lib/sync/sync-engine.d.ts +212 -8
- package/lib/sync/sync-engine.js +514 -58
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +9 -0
- package/lib/ui/i18n.js +22 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/api-base.test.ts +36 -0
- package/src/client/common/http.test.ts +12 -1
- package/src/client/common/http.ts +8 -2
- package/src/client/common/routes.ts +46 -3
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/market/market-api.test.ts +3 -2
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +220 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +248 -0
- package/src/client/sync/merge-view.ts +398 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.test.ts +5 -3
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/ancestor.ts +76 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +203 -0
- package/src/sync/gist/gist-transport.ts +53 -5
- package/src/sync/gist/messages.ts +5 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.test.ts +2 -1
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/s3/sigv4.test.ts +4 -1
- package/src/sync/s3/sigv4.ts +11 -2
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +410 -19
- package/src/sync/sync-engine.ts +602 -57
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +22 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 回退前像 journal 单测(T2:中断回退的崩溃可检测 / 可重放)。
|
|
3
|
+
*
|
|
4
|
+
* 纪律:真实临时目录(os.tmpdir)里真读真写,**不 mock 文件系统**;绝不触碰任何真实用户数据目录。
|
|
5
|
+
* 覆盖:
|
|
6
|
+
* - J-01 record 先落 bin 再记索引(含 0 字节前像与 null 前像)
|
|
7
|
+
* - J-02 尾行被截断 ⇒ 前序行照读 + truncatedTail、绝不抛异常(含切在多字节字符中间)
|
|
8
|
+
* - J-03 没有 journal ⇒ null
|
|
9
|
+
* - J-04 finish 写 done(原子);有 done 就不是中断残留
|
|
10
|
+
* - J-05 findInterruptedUndos 判据:形态 + 有 journal + 无 done + pid 已死(两种 root 口径)
|
|
11
|
+
* - J-06 replay 逆序(同路径两次 ⇒ 最后写的是最早状态)+ bin===null ⇒ 删除
|
|
12
|
+
* - J-07 当前状态 != after 镜像 ⇒ skipped 且**绝不写盘**(用户后来的修改不被覆盖)
|
|
13
|
+
* - J-08 前像缺失 / 损坏 / 写失败 / 读当前态失败 ⇒ 逐条进 failed,绝不静默
|
|
14
|
+
* - J-09 相对路径 / NUL / 越权 bin ⇒ failed 且不写盘
|
|
15
|
+
* - J-10 record 写盘失败 ⇒ 抛错且 journal 不留半行;create 建不出目录 ⇒ 拒绝;dispose 后 record ⇒ 抛错
|
|
16
|
+
* - J-11 create 续写:截掉残缺尾行 + 序号接续(bin 不重号、旧前像不被覆盖)
|
|
17
|
+
* - J-12 静态:只 import node: 内置与仓内 .ts(零运行时依赖)
|
|
18
|
+
*/
|
|
19
|
+
import test from 'node:test'
|
|
20
|
+
import assert from 'node:assert/strict'
|
|
21
|
+
import fs from 'node:fs/promises'
|
|
22
|
+
import os from 'node:os'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
|
|
25
|
+
import { sha256Hex } from '../utils/hashing.ts'
|
|
26
|
+
import {
|
|
27
|
+
findInterruptedUndos,
|
|
28
|
+
readUndoJournal,
|
|
29
|
+
replayUndoJournal,
|
|
30
|
+
UndoJournalWriter,
|
|
31
|
+
UNDO_DONE_FILE_NAME,
|
|
32
|
+
UNDO_JOURNAL_FILE_NAME,
|
|
33
|
+
type InterruptedUndo,
|
|
34
|
+
} from './rollback-undo-journal.ts'
|
|
35
|
+
|
|
36
|
+
/* ------------------------------------------------------------------ 工具 */
|
|
37
|
+
|
|
38
|
+
const REAL_NOW = 1_700_000_000_000
|
|
39
|
+
const enc = new TextEncoder()
|
|
40
|
+
|
|
41
|
+
function text(value: string): Uint8Array {
|
|
42
|
+
return enc.encode(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asText(bytes: Uint8Array): string {
|
|
46
|
+
return Buffer.from(bytes).toString('utf8')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function makeRoot(tag: string): Promise<string> {
|
|
50
|
+
// realpath:os.tmpdir() 的拼写可能未规范化,而 path.resolve/join 的结果要逐字比较
|
|
51
|
+
return await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-undo-journal-' + tag + '-')))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function journalPath(dir: string): string {
|
|
55
|
+
return path.join(dir, UNDO_JOURNAL_FILE_NAME)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function readLines(dir: string): Promise<string[]> {
|
|
59
|
+
const raw = await fs.readFile(journalPath(dir), 'utf8')
|
|
60
|
+
return raw.split('\n').filter((line) => line !== '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 形态合法的 undo 目录名:<pid>-<ts36>-<8hex>(与 rollback-ledger.ts 的 UNDO_DIR_RE 同形) */
|
|
64
|
+
function undoDirName(pid: number, seed: number): string {
|
|
65
|
+
const stamp = (REAL_NOW + seed).toString(36)
|
|
66
|
+
return String(pid) + '-' + stamp + '-' + sha256Hex('undo-dir-seed:' + String(seed)).slice(0, 8)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function interruptedOnly(dir: string): Promise<InterruptedUndo> {
|
|
70
|
+
const found = await findInterruptedUndos(dir, () => false)
|
|
71
|
+
assert.equal(found.length, 1)
|
|
72
|
+
const first = found[0]
|
|
73
|
+
if (first === undefined) throw new Error('expected exactly one interrupted undo in ' + dir)
|
|
74
|
+
return first
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 手写一条 JSONL(可故意缺字段,验证读取端对损坏行的归一化) */
|
|
78
|
+
function rawLine(entry: Record<string, unknown>): string {
|
|
79
|
+
return JSON.stringify({ at: REAL_NOW, bin: null, bytes: 0, sha256: null, ...entry }) + '\n'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ------------------------------------------------------------------ 用例 */
|
|
83
|
+
|
|
84
|
+
test('J-01 record 先落前像 bin 再记索引:索引逐字段可读,bin 内容 = 前像', async () => {
|
|
85
|
+
const root = await makeRoot('j01')
|
|
86
|
+
try {
|
|
87
|
+
const dir = path.join(root, 'undo', undoDirName(process.pid, 1))
|
|
88
|
+
const target = path.join(root, 'ws', 'a.txt')
|
|
89
|
+
const empty = path.join(root, 'ws', 'empty.txt')
|
|
90
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
91
|
+
await writer.record(target, text('A0'), { sha256: sha256Hex('A1') })
|
|
92
|
+
await writer.record(target, null, { deleted: true })
|
|
93
|
+
await writer.record(empty, new Uint8Array(0), { sha256: sha256Hex('') })
|
|
94
|
+
await writer.dispose()
|
|
95
|
+
|
|
96
|
+
const snapshot = await readUndoJournal(dir)
|
|
97
|
+
assert.ok(snapshot !== null)
|
|
98
|
+
assert.equal(snapshot.truncatedTail, false)
|
|
99
|
+
assert.deepEqual(snapshot.entries, [
|
|
100
|
+
{
|
|
101
|
+
seq: 0,
|
|
102
|
+
path: target,
|
|
103
|
+
bin: '0.bin',
|
|
104
|
+
bytes: 2,
|
|
105
|
+
sha256: sha256Hex('A0'),
|
|
106
|
+
afterSha256: sha256Hex('A1'),
|
|
107
|
+
afterDeleted: false,
|
|
108
|
+
at: REAL_NOW,
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
seq: 1,
|
|
112
|
+
path: target,
|
|
113
|
+
bin: null,
|
|
114
|
+
bytes: 0,
|
|
115
|
+
sha256: null,
|
|
116
|
+
afterSha256: null,
|
|
117
|
+
afterDeleted: true,
|
|
118
|
+
at: REAL_NOW,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
seq: 2,
|
|
122
|
+
path: empty,
|
|
123
|
+
bin: '2.bin',
|
|
124
|
+
bytes: 0,
|
|
125
|
+
sha256: sha256Hex(''),
|
|
126
|
+
afterSha256: sha256Hex(''),
|
|
127
|
+
afterDeleted: false,
|
|
128
|
+
at: REAL_NOW,
|
|
129
|
+
},
|
|
130
|
+
])
|
|
131
|
+
assert.equal(await fs.readFile(path.join(dir, '0.bin'), 'utf8'), 'A0')
|
|
132
|
+
assert.equal((await fs.stat(path.join(dir, '2.bin'))).size, 0)
|
|
133
|
+
} finally {
|
|
134
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('J-02 尾行被截断(崩在 append 中途)⇒ 前序行照读 + truncatedTail + 绝不抛异常', async () => {
|
|
139
|
+
const root = await makeRoot('j02')
|
|
140
|
+
try {
|
|
141
|
+
const dir = path.join(root, 'undo', undoDirName(process.pid, 2))
|
|
142
|
+
const first = path.join(root, '工作区', '一.txt')
|
|
143
|
+
const second = path.join(root, 'ws', 'b.txt')
|
|
144
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
145
|
+
await writer.record(first, text('A0'), { sha256: sha256Hex('A1') })
|
|
146
|
+
await writer.record(second, null, { deleted: true })
|
|
147
|
+
await writer.dispose()
|
|
148
|
+
|
|
149
|
+
const full = await fs.readFile(journalPath(dir))
|
|
150
|
+
await fs.writeFile(journalPath(dir), full.subarray(0, full.length - 12))
|
|
151
|
+
const torn = await readUndoJournal(dir)
|
|
152
|
+
assert.ok(torn !== null)
|
|
153
|
+
assert.equal(torn.truncatedTail, true)
|
|
154
|
+
assert.equal(torn.entries.length, 1)
|
|
155
|
+
assert.equal(torn.entries[0]?.path, first)
|
|
156
|
+
assert.equal(torn.entries[0]?.bin, '0.bin')
|
|
157
|
+
|
|
158
|
+
// 第二轮:把切点放在多字节字符**中间** —— utf8 解出替换字符,JSON.parse 必然失败,同样不抛
|
|
159
|
+
const chinesePath = path.join(root, '工作区', '一.txt')
|
|
160
|
+
const raw = Buffer.from(
|
|
161
|
+
rawLine({ seq: 0, path: chinesePath, bin: null, bytes: 0, sha256: null, afterSha256: null, afterDeleted: true }),
|
|
162
|
+
'utf8',
|
|
163
|
+
)
|
|
164
|
+
const cut = raw.indexOf(Buffer.from('工作区', 'utf8')) + 1
|
|
165
|
+
assert.ok(cut > 1)
|
|
166
|
+
await fs.writeFile(journalPath(dir), raw.subarray(0, cut))
|
|
167
|
+
const halfChar = await readUndoJournal(dir)
|
|
168
|
+
assert.deepEqual(halfChar, { entries: [], truncatedTail: true })
|
|
169
|
+
} finally {
|
|
170
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test('J-03 本来就没有 journal ⇒ readUndoJournal 返回 null(而不是抛错)', async () => {
|
|
175
|
+
const root = await makeRoot('j03')
|
|
176
|
+
try {
|
|
177
|
+
const dir = path.join(root, 'undo', undoDirName(process.pid, 3))
|
|
178
|
+
assert.equal(await readUndoJournal(dir), null)
|
|
179
|
+
await fs.mkdir(dir, { recursive: true })
|
|
180
|
+
assert.equal(await readUndoJournal(dir), null)
|
|
181
|
+
} finally {
|
|
182
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test('J-04 finish 写 done 标记(原子);已完成的目录不是中断残留', async () => {
|
|
187
|
+
const root = await makeRoot('j04')
|
|
188
|
+
try {
|
|
189
|
+
const undoRoot = path.join(root, 'undo')
|
|
190
|
+
const dir = path.join(undoRoot, undoDirName(4242, 4))
|
|
191
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
192
|
+
await writer.record(path.join(root, 'a.txt'), null, { deleted: true })
|
|
193
|
+
await writer.finish()
|
|
194
|
+
await writer.dispose()
|
|
195
|
+
|
|
196
|
+
const marker = await fs.readFile(path.join(dir, UNDO_DONE_FILE_NAME), 'utf8')
|
|
197
|
+
assert.equal(JSON.parse(marker).finishedAt, REAL_NOW)
|
|
198
|
+
assert.deepEqual(await findInterruptedUndos(undoRoot, () => false), [])
|
|
199
|
+
} finally {
|
|
200
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('J-05 findInterruptedUndos 只认「形态 + 有 journal + 无 done + pid 已死」(两种 root 口径)', async () => {
|
|
205
|
+
const root = await makeRoot('j05')
|
|
206
|
+
try {
|
|
207
|
+
const undoRoot = path.join(root, 'undo')
|
|
208
|
+
await fs.mkdir(undoRoot, { recursive: true })
|
|
209
|
+
|
|
210
|
+
// ① 真中断:有 journal、无 done、pid 已死
|
|
211
|
+
const dead = path.join(undoRoot, undoDirName(900001, 5))
|
|
212
|
+
const w1 = await UndoJournalWriter.create(dead, { now: () => REAL_NOW })
|
|
213
|
+
await w1.record(path.join(root, 'x.txt'), text('X0'), { sha256: sha256Hex('X1') })
|
|
214
|
+
await w1.dispose()
|
|
215
|
+
|
|
216
|
+
// ② 已完成(有 done)
|
|
217
|
+
const finished = path.join(undoRoot, undoDirName(900002, 6))
|
|
218
|
+
const w2 = await UndoJournalWriter.create(finished, { now: () => REAL_NOW })
|
|
219
|
+
await w2.record(path.join(root, 'y.txt'), null, { deleted: true })
|
|
220
|
+
await w2.finish()
|
|
221
|
+
await w2.dispose()
|
|
222
|
+
|
|
223
|
+
// ③ 目录名形态不符
|
|
224
|
+
await fs.mkdir(path.join(undoRoot, 'not-an-undo-dir'), { recursive: true })
|
|
225
|
+
// ④ 形态相符但没有 journal
|
|
226
|
+
await fs.mkdir(path.join(undoRoot, undoDirName(900003, 7)), { recursive: true })
|
|
227
|
+
// ⑤ pid 还活着
|
|
228
|
+
const alive = path.join(undoRoot, undoDirName(900004, 8))
|
|
229
|
+
const w3 = await UndoJournalWriter.create(alive, { now: () => REAL_NOW })
|
|
230
|
+
await w3.record(path.join(root, 'z.txt'), null, { deleted: true })
|
|
231
|
+
await w3.dispose()
|
|
232
|
+
|
|
233
|
+
const isAlive = (pid: number): boolean => pid === 900004
|
|
234
|
+
const fromUndoRoot = await findInterruptedUndos(undoRoot, isAlive)
|
|
235
|
+
assert.deepEqual(fromUndoRoot.map((undo) => undo.dir), [dead])
|
|
236
|
+
assert.equal(fromUndoRoot[0]?.pid, 900001)
|
|
237
|
+
assert.equal(fromUndoRoot[0]?.entries.length, 1)
|
|
238
|
+
assert.equal(fromUndoRoot[0]?.truncatedTail, false)
|
|
239
|
+
|
|
240
|
+
// root 传上层 dataRoot 也同样认(自动再看 <root>/undo)
|
|
241
|
+
const fromDataRoot = await findInterruptedUndos(root, isAlive)
|
|
242
|
+
assert.deepEqual(fromDataRoot.map((undo) => undo.dir), [dead])
|
|
243
|
+
} finally {
|
|
244
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
test('J-06 replay 逆序写回:同一路径记两次时最后写的是最早状态;bin===null ⇒ 删除', async () => {
|
|
249
|
+
const root = await makeRoot('j06')
|
|
250
|
+
try {
|
|
251
|
+
const undoRoot = path.join(root, 'undo')
|
|
252
|
+
const dir = path.join(undoRoot, undoDirName(900011, 9))
|
|
253
|
+
const target = path.join(root, 'ws', 'a.txt')
|
|
254
|
+
const gone = path.join(root, 'ws', 'gone.txt')
|
|
255
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
256
|
+
await writer.record(target, text('A'), { sha256: sha256Hex('B') })
|
|
257
|
+
await writer.record(target, text('B'), { sha256: sha256Hex('C') })
|
|
258
|
+
await writer.record(gone, null, { sha256: sha256Hex('created') })
|
|
259
|
+
await writer.dispose()
|
|
260
|
+
|
|
261
|
+
// 目标当前处于「回退动作之后」的状态:a.txt = C,gone.txt = created
|
|
262
|
+
await fs.mkdir(path.dirname(target), { recursive: true })
|
|
263
|
+
await fs.writeFile(target, 'C')
|
|
264
|
+
await fs.writeFile(gone, 'created')
|
|
265
|
+
|
|
266
|
+
const undo = await interruptedOnly(undoRoot)
|
|
267
|
+
const order: string[] = []
|
|
268
|
+
const result = await replayUndoJournal(undo, {
|
|
269
|
+
write: async (p, bytes) => {
|
|
270
|
+
order.push(path.basename(p) + '=' + asText(bytes))
|
|
271
|
+
await fs.writeFile(p, bytes)
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
assert.deepEqual(order, ['a.txt=B', 'a.txt=A'])
|
|
276
|
+
assert.equal(await fs.readFile(target, 'utf8'), 'A')
|
|
277
|
+
assert.equal(result.restored, 3)
|
|
278
|
+
assert.deepEqual(result.skipped, [])
|
|
279
|
+
assert.deepEqual(result.failed, [])
|
|
280
|
+
await assert.rejects(fs.stat(gone))
|
|
281
|
+
} finally {
|
|
282
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
283
|
+
}
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
test('J-07 当前状态 != after 镜像 ⇒ skipped 且绝不写盘(用户后来的修改不被覆盖)', async () => {
|
|
287
|
+
const root = await makeRoot('j07')
|
|
288
|
+
try {
|
|
289
|
+
const undoRoot = path.join(root, 'undo')
|
|
290
|
+
const dir = path.join(undoRoot, undoDirName(900021, 10))
|
|
291
|
+
const changed = path.join(root, 'ws', 'changed.txt')
|
|
292
|
+
const recreated = path.join(root, 'ws', 'recreated.txt')
|
|
293
|
+
const missing = path.join(root, 'ws', 'missing.txt')
|
|
294
|
+
const unknown = path.join(root, 'ws', 'unknown.txt')
|
|
295
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
296
|
+
await writer.record(changed, text('A1'), { sha256: sha256Hex('C') })
|
|
297
|
+
await writer.record(recreated, null, { deleted: true })
|
|
298
|
+
await writer.record(missing, text('A3'), { sha256: sha256Hex('X') })
|
|
299
|
+
await writer.dispose()
|
|
300
|
+
// 手写一条「after 信息缺失」的损坏行:重放必须判定为无证明,绝不写盘
|
|
301
|
+
await fs.appendFile(journalPath(dir), rawLine({ seq: 9, path: unknown, afterSha256: null }))
|
|
302
|
+
|
|
303
|
+
await fs.mkdir(path.dirname(changed), { recursive: true })
|
|
304
|
+
await fs.writeFile(changed, 'D') // 用户后来又改了
|
|
305
|
+
await fs.writeFile(recreated, 'again') // 用户又建回来了
|
|
306
|
+
await fs.writeFile(unknown, 'keep')
|
|
307
|
+
|
|
308
|
+
const undo = await interruptedOnly(undoRoot)
|
|
309
|
+
let wrote = 0
|
|
310
|
+
const result = await replayUndoJournal(undo, {
|
|
311
|
+
write: async (p, bytes) => {
|
|
312
|
+
wrote += 1
|
|
313
|
+
await fs.writeFile(p, bytes)
|
|
314
|
+
},
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
assert.equal(result.restored, 0)
|
|
318
|
+
assert.equal(wrote, 0)
|
|
319
|
+
assert.equal(result.failed.length, 0)
|
|
320
|
+
assert.equal(result.skipped.length, 4)
|
|
321
|
+
const reasons = result.skipped.join('\n')
|
|
322
|
+
assert.ok(reasons.includes(path.resolve(changed) + ': after-state-changed: sha256-mismatch'), reasons)
|
|
323
|
+
assert.ok(reasons.includes(path.resolve(recreated) + ': after-state-changed: expected-absent'), reasons)
|
|
324
|
+
assert.ok(reasons.includes(path.resolve(missing) + ': after-state-changed: missing'), reasons)
|
|
325
|
+
assert.ok(reasons.includes(path.resolve(unknown) + ': after-state-unknown'), reasons)
|
|
326
|
+
assert.equal(await fs.readFile(changed, 'utf8'), 'D')
|
|
327
|
+
assert.equal(await fs.readFile(recreated, 'utf8'), 'again')
|
|
328
|
+
await assert.rejects(fs.stat(missing))
|
|
329
|
+
} finally {
|
|
330
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
331
|
+
}
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
test('J-08 前像缺失 / 损坏 / 写失败 / 读当前态失败 ⇒ 逐条进 failed,绝不静默', async () => {
|
|
335
|
+
const root = await makeRoot('j08')
|
|
336
|
+
try {
|
|
337
|
+
const undoRoot = path.join(root, 'undo')
|
|
338
|
+
const dir = path.join(undoRoot, undoDirName(900031, 11))
|
|
339
|
+
const t1 = path.join(root, 'ws', 'f1.txt')
|
|
340
|
+
const t2 = path.join(root, 'ws', 'f2.txt')
|
|
341
|
+
const t3 = path.join(root, 'ws', 'f3.txt')
|
|
342
|
+
const t4 = path.join(root, 'ws', 'f4.txt')
|
|
343
|
+
const t5 = path.join(root, 'ws', 'f5.txt')
|
|
344
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
345
|
+
await writer.record(t1, text('A1'), { sha256: sha256Hex('AFTER1') })
|
|
346
|
+
await writer.record(t2, text('A2'), { sha256: sha256Hex('AFTER2') })
|
|
347
|
+
await writer.record(t3, text('A3'), { sha256: sha256Hex('AFTER3') })
|
|
348
|
+
await writer.record(t4, text('A4'), { sha256: sha256Hex('AFTER4') })
|
|
349
|
+
await writer.record(t5, text('A5'), { sha256: sha256Hex('AFTER5') })
|
|
350
|
+
await writer.dispose()
|
|
351
|
+
await fs.rm(path.join(dir, '0.bin')) // 前像丢失
|
|
352
|
+
await fs.writeFile(path.join(dir, '1.bin'), 'TAMPERED') // 前像损坏
|
|
353
|
+
|
|
354
|
+
await fs.mkdir(path.dirname(t1), { recursive: true })
|
|
355
|
+
const afterImages: [string, string][] = [
|
|
356
|
+
[t1, 'AFTER1'],
|
|
357
|
+
[t2, 'AFTER2'],
|
|
358
|
+
[t3, 'AFTER3'],
|
|
359
|
+
[t4, 'AFTER4'],
|
|
360
|
+
[t5, 'AFTER5'],
|
|
361
|
+
]
|
|
362
|
+
for (const [target, content] of afterImages) await fs.writeFile(target, content)
|
|
363
|
+
|
|
364
|
+
const undo = await interruptedOnly(undoRoot)
|
|
365
|
+
const result = await replayUndoJournal(undo, {
|
|
366
|
+
readCurrent: async (target) => {
|
|
367
|
+
if (target === path.resolve(t4)) throw new Error('read-boom')
|
|
368
|
+
return await fs.readFile(target)
|
|
369
|
+
},
|
|
370
|
+
write: async (target, bytes) => {
|
|
371
|
+
if (target === path.resolve(t3)) throw new Error('write-boom')
|
|
372
|
+
await fs.writeFile(target, bytes)
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
assert.equal(result.restored, 1)
|
|
377
|
+
assert.equal(result.skipped.length, 0)
|
|
378
|
+
assert.equal(result.failed.length, 4)
|
|
379
|
+
const failuresFor = (target: string): string[] =>
|
|
380
|
+
result.failed.filter((line) => line.startsWith(path.resolve(target) + ': '))
|
|
381
|
+
assert.equal(failuresFor(t1).length, 1)
|
|
382
|
+
assert.match(failuresFor(t1)[0] ?? '', /ENOENT|no such file/i)
|
|
383
|
+
assert.ok((failuresFor(t2)[0] ?? '').includes('before-checksum-mismatch'))
|
|
384
|
+
assert.ok((failuresFor(t3)[0] ?? '').includes('write-boom'))
|
|
385
|
+
assert.ok((failuresFor(t4)[0] ?? '').includes('read-current-failed'))
|
|
386
|
+
assert.equal(await fs.readFile(t5, 'utf8'), 'A5')
|
|
387
|
+
} finally {
|
|
388
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
test('J-09 相对路径 / NUL / 越权 bin ⇒ failed 且绝不写盘', async () => {
|
|
393
|
+
const root = await makeRoot('j09')
|
|
394
|
+
try {
|
|
395
|
+
const undoRoot = path.join(root, 'undo')
|
|
396
|
+
const dir = path.join(undoRoot, undoDirName(900041, 12))
|
|
397
|
+
await fs.mkdir(dir, { recursive: true })
|
|
398
|
+
const absolute = path.join(root, 'ws', 'c.txt')
|
|
399
|
+
await fs.mkdir(path.dirname(absolute), { recursive: true })
|
|
400
|
+
await fs.writeFile(absolute, 'AFTERC')
|
|
401
|
+
|
|
402
|
+
const lines = [
|
|
403
|
+
rawLine({ seq: 0, path: 'relative/file.txt', afterDeleted: true }),
|
|
404
|
+
rawLine({ seq: 1, path: path.join(root, 'ws', 'nul\0.txt'), afterDeleted: true }),
|
|
405
|
+
rawLine({ seq: 2, path: absolute, bin: '../evil.bin', afterSha256: sha256Hex('AFTERC') }),
|
|
406
|
+
rawLine({ seq: 3, path: absolute, bin: 'evil.bin', afterSha256: sha256Hex('AFTERC') }),
|
|
407
|
+
]
|
|
408
|
+
await fs.writeFile(journalPath(dir), lines.join(''))
|
|
409
|
+
|
|
410
|
+
const undo = await interruptedOnly(undoRoot)
|
|
411
|
+
let wrote = false
|
|
412
|
+
const result = await replayUndoJournal(undo, {
|
|
413
|
+
write: async (p, bytes) => {
|
|
414
|
+
wrote = true
|
|
415
|
+
await fs.writeFile(p, bytes)
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
assert.equal(result.restored, 0)
|
|
420
|
+
assert.equal(result.skipped.length, 0)
|
|
421
|
+
assert.equal(result.failed.length, 4)
|
|
422
|
+
assert.ok(
|
|
423
|
+
result.failed.every((line) => line.includes('unsafe-path') || line.includes('unsafe-bin')),
|
|
424
|
+
result.failed.join('\n'),
|
|
425
|
+
)
|
|
426
|
+
assert.equal(wrote, false)
|
|
427
|
+
assert.equal(await fs.readFile(absolute, 'utf8'), 'AFTERC')
|
|
428
|
+
} finally {
|
|
429
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
test('J-10 record 写盘失败 ⇒ 抛错且 journal 不留半行;create 建不出目录 ⇒ 拒绝', async () => {
|
|
434
|
+
const root = await makeRoot('j10')
|
|
435
|
+
try {
|
|
436
|
+
const dir = path.join(root, 'undo', undoDirName(900051, 13))
|
|
437
|
+
const target = path.join(root, 'ws', 'a.txt')
|
|
438
|
+
const writer = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
439
|
+
await writer.record(target, text('A0'), { sha256: sha256Hex('A1') })
|
|
440
|
+
|
|
441
|
+
// 让下一条的前像写不进去:bin 目标路径先占成目录(磁盘满/EACCES 的可复现替身)
|
|
442
|
+
await fs.mkdir(path.join(dir, '1.bin'))
|
|
443
|
+
await assert.rejects(writer.record(target, text('A1'), { sha256: sha256Hex('A2') }))
|
|
444
|
+
assert.equal((await readLines(dir)).length, 1)
|
|
445
|
+
assert.equal((await fs.readFile(journalPath(dir), 'utf8')).endsWith('\n'), true)
|
|
446
|
+
const snapshot = await readUndoJournal(dir)
|
|
447
|
+
assert.equal(snapshot?.truncatedTail, false)
|
|
448
|
+
assert.equal(snapshot?.entries.length, 1)
|
|
449
|
+
assert.equal(snapshot?.entries[0]?.seq, 0)
|
|
450
|
+
assert.equal(await fs.readFile(path.join(dir, '0.bin'), 'utf8'), 'A0')
|
|
451
|
+
|
|
452
|
+
await writer.dispose()
|
|
453
|
+
await assert.rejects(writer.record(target, text('A9'), { sha256: sha256Hex('A9') }))
|
|
454
|
+
assert.equal((await readLines(dir)).length, 1)
|
|
455
|
+
|
|
456
|
+
const asFile = path.join(root, 'not-a-dir')
|
|
457
|
+
await fs.writeFile(asFile, 'x')
|
|
458
|
+
await assert.rejects(UndoJournalWriter.create(asFile))
|
|
459
|
+
} finally {
|
|
460
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
461
|
+
}
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
test('J-11 create 续写:截掉残缺尾行 + 序号接续(旧前像不被覆盖)', async () => {
|
|
465
|
+
const root = await makeRoot('j11')
|
|
466
|
+
try {
|
|
467
|
+
const undoRoot = path.join(root, 'undo')
|
|
468
|
+
const dir = path.join(undoRoot, undoDirName(900061, 14))
|
|
469
|
+
const t1 = path.join(root, 'ws', 'a.txt')
|
|
470
|
+
const t2 = path.join(root, 'ws', 'b.txt')
|
|
471
|
+
const first = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
472
|
+
await first.record(t1, text('A0'), { sha256: sha256Hex('A1') })
|
|
473
|
+
await first.record(t2, text('B0'), { sha256: sha256Hex('B1') })
|
|
474
|
+
await first.dispose()
|
|
475
|
+
|
|
476
|
+
const full = await fs.readFile(journalPath(dir))
|
|
477
|
+
await fs.writeFile(journalPath(dir), full.subarray(0, full.length - 20)) // 第二行只写了一半
|
|
478
|
+
|
|
479
|
+
const second = await UndoJournalWriter.create(dir, { now: () => REAL_NOW })
|
|
480
|
+
await second.record(t2, text('B0b'), { sha256: sha256Hex('B1') })
|
|
481
|
+
await second.dispose()
|
|
482
|
+
|
|
483
|
+
const snapshot = await readUndoJournal(dir)
|
|
484
|
+
assert.ok(snapshot !== null)
|
|
485
|
+
assert.equal(snapshot.truncatedTail, false)
|
|
486
|
+
assert.deepEqual(
|
|
487
|
+
snapshot.entries.map((entry) => [entry.seq, entry.path, entry.bin]),
|
|
488
|
+
[
|
|
489
|
+
[0, t1, '0.bin'],
|
|
490
|
+
[1, t2, '1.bin'],
|
|
491
|
+
],
|
|
492
|
+
)
|
|
493
|
+
assert.equal(await fs.readFile(path.join(dir, '0.bin'), 'utf8'), 'A0')
|
|
494
|
+
assert.equal(await fs.readFile(path.join(dir, '1.bin'), 'utf8'), 'B0b')
|
|
495
|
+
} finally {
|
|
496
|
+
await fs.rm(root, { recursive: true, force: true })
|
|
497
|
+
}
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('J-12 静态:只 import node: 内置与仓内 .ts(零运行时依赖)', async () => {
|
|
501
|
+
const source = await fs.readFile(new URL('./rollback-undo-journal.ts', import.meta.url), 'utf8')
|
|
502
|
+
const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1] ?? '')
|
|
503
|
+
assert.ok(specifiers.length > 0)
|
|
504
|
+
for (const specifier of specifiers) {
|
|
505
|
+
const ok = specifier.startsWith('node:') || (specifier.startsWith('.') && specifier.endsWith('.ts'))
|
|
506
|
+
assert.ok(ok, '非法 import(必须 node: 内置或仓内 .ts):' + specifier)
|
|
507
|
+
}
|
|
508
|
+
})
|