dsh-config-manager 0.2.3 → 0.3.0

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 (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1256 -1185
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +56 -2
  36. package/lib/sync/git/git-transport.js +344 -73
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/lib/utils/git-fast-forward.d.ts +7 -2
  56. package/lib/utils/git-fast-forward.js +8 -3
  57. package/package.json +2 -2
  58. package/src/adapters/self.test.ts +126 -85
  59. package/src/adapters/self.ts +73 -6
  60. package/src/client/ConfigManagerSection.tsx +0 -3
  61. package/src/client/common/Icon.tsx +3 -0
  62. package/src/client/common/SectionComposition.tsx +6 -1
  63. package/src/client/common/info-hint-guard.test.ts +7 -5
  64. package/src/client/config-manager.module.css +254 -13
  65. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  66. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  67. package/src/client/export/ExportView.tsx +3 -1
  68. package/src/client/home/HomePanel.tsx +25 -5
  69. package/src/client/import/ImportWizardView.tsx +1 -3
  70. package/src/client/import/import-wizard-steps.tsx +1 -17
  71. package/src/client/library/ArtifactRow.tsx +13 -2
  72. package/src/client/library/LibraryPanel.tsx +12 -2
  73. package/src/client/locales.ts +53 -34
  74. package/src/client/lucide-icons.d.ts +2 -0
  75. package/src/client/market/MarketPanel.tsx +12 -11
  76. package/src/client/market/market-locales.ts +16 -16
  77. package/src/client/recovery/RollbackCard.tsx +4 -2
  78. package/src/client/recovery/recovery-locales.test.ts +4 -2
  79. package/src/client/recovery/recovery-locales.ts +23 -23
  80. package/src/client/sync/AutosyncCard.tsx +36 -10
  81. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  82. package/src/client/sync/SyncPanel.tsx +32 -33
  83. package/src/client/sync/SyncProbeRow.tsx +23 -6
  84. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  85. package/src/client/sync/sync-locales.ts +24 -24
  86. package/src/core/analyzer.ts +37 -56
  87. package/src/core/backup.test.ts +139 -1
  88. package/src/core/backup.ts +145 -4
  89. package/src/core/import-defaults.ts +13 -0
  90. package/src/core/importer.ts +12 -2
  91. package/src/core/integrity-extra-entries.test.ts +205 -0
  92. package/src/core/messages.test.ts +3 -1
  93. package/src/core/messages.ts +8 -0
  94. package/src/core/model-tools.test.ts +33 -0
  95. package/src/core/model-tools.ts +20 -4
  96. package/src/index.ts +12 -21
  97. package/src/routes/import.ts +4 -1
  98. package/src/routes/recovery.test.ts +198 -1
  99. package/src/routes/recovery.ts +194 -1
  100. package/src/routes/sync.ts +27 -8
  101. package/src/security/integrity.ts +37 -0
  102. package/src/security/unverified-file-blocks.ts +192 -0
  103. package/src/sync/autosync-config.ts +9 -5
  104. package/src/sync/autosync-scheduler.test.ts +133 -6
  105. package/src/sync/autosync-scheduler.ts +118 -14
  106. package/src/sync/config-issue-reporting.test.ts +148 -0
  107. package/src/sync/config-issue-reporting.ts +75 -0
  108. package/src/sync/gc-skip.test.ts +66 -0
  109. package/src/sync/gc-skip.ts +64 -0
  110. package/src/sync/git/git-probe.test.ts +6 -1
  111. package/src/sync/git/git-transport.test.ts +351 -14
  112. package/src/sync/git/git-transport.ts +343 -79
  113. package/src/sync/merge-preview-text.ts +37 -0
  114. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  115. package/src/sync/s3/s3-transport.ts +23 -4
  116. package/src/sync/session-tombstones.ts +90 -0
  117. package/src/sync/sync-config.test.ts +170 -1
  118. package/src/sync/sync-config.ts +184 -14
  119. package/src/sync/sync-engine.test.ts +238 -0
  120. package/src/sync/sync-engine.ts +79 -83
  121. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  122. package/src/sync/webdav/webdav-transport.ts +26 -5
  123. package/src/ui/disk-usage-view.test.ts +2 -1
  124. package/src/ui/i18n.test.ts +47 -0
  125. package/src/ui/i18n.ts +58 -38
  126. package/src/ui/import-wizard.test.ts +18 -1
  127. package/src/ui/import-wizard.ts +3 -2
  128. package/src/ui/overview-view.test.ts +9 -0
  129. package/src/ui/overview-view.ts +9 -2
  130. package/src/ui/report.test.ts +4 -3
  131. package/src/ui/report.ts +2 -1
  132. package/src/utils/git-fast-forward.ts +8 -3
@@ -23,6 +23,7 @@ import path from 'node:path';
23
23
  import { createAdapters } from '../adapters/index.ts';
24
24
  import { defaultSecretScanner } from '../core/exporter.ts';
25
25
  import { Importer } from '../core/importer.ts';
26
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.ts';
26
27
  // issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
27
28
  import { isLocalPluginSpec } from '../core/local-plugin-pack.ts';
28
29
  import type {
@@ -45,7 +46,10 @@ import {
45
46
  encryptCredentialsPayload, encryptSectionsPayload,
46
47
  } from './snapshot-crypto.ts';
47
48
  import { OPT_IN_SYNC_SECTIONS } from './sync-selection.ts';
48
- import { nextSessionTombstones, sessionUnitIdsOfSection, stripTombstonedUnits } from './session-tombstones.ts';
49
+ import {
50
+ listRemoteSnapshotIndex, planSessionBookkeeping, remoteTombstonesOf, stripTombstonedSessionsInPlace,
51
+ } from './session-tombstones.ts';
52
+ import type { RemoteSnapshotIndex, SessionBookkeepingResult } from './session-tombstones.ts';
49
53
  import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './retention-policy.ts';
50
54
  import type { RetentionPolicy } from './retention-policy.ts';
51
55
  import { hashSection, loadSyncState, resolveDeviceIdentity, saveSyncState } from './sync-state.ts';
@@ -58,9 +62,10 @@ import type { MsgFunc } from '../core/messages.ts';
58
62
  import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor, writeEncryptedAncestor } from './ancestor.ts';
59
63
  import type { MergeConflictReason, MergePlan, MergeSectionResult } from './merge.ts';
60
64
  import { merge as mergeSections } from './merge.ts';
65
+ import { previewTextOf } from './merge-preview-text.ts';
66
+ import { gcSkipWarning, readGcSkipCode, reportGcSkipOnce } from './gc-skip.ts';
61
67
  import { classifyMergePlan } from './risk.ts';
62
68
  import type { SyncApplyPlan } from './risk.ts';
63
- import { redact } from '../security/redaction.ts';
64
69
  import { registerMessageCatalog } from '../core/engine-locale.ts';
65
70
  import { createSnapshot, selectPruneCandidates } from '../core/backup.ts';
66
71
  import { rollback } from '../core/rollback.ts';
@@ -79,6 +84,7 @@ import type { PlanItemProgress } from '../core/analyzer.ts';
79
84
  const zh = {
80
85
  'sync.pushRemoteAdvanced': '远端最新快照 {remote} 与本地基线 {base} 不一致:本次推送未执行(远端未写入、基线未改动)。请先同步远端改动;确认要用本机覆盖远端时,再显式选择强制推送。',
81
86
  'sync.pushAdvanceCheckFailed': '无法确认远端是否已被其它设备推进({reason}):本次推送按原样继续',
87
+ 'sync.sessionTombstonesInherited': '已从远端快照继承 {count} 条会话删除墓碑:删除继续跨设备传播(本机数据不动)',
82
88
  'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine 缺少 importer(需在 options 中注入)',
83
89
  'sync.mergePendingConflicts': '仍有 {count} 个分区未裁决({sections}):已裁决的部分已写入本机,但基线不推进,冲突下一轮会重新出现',
84
90
  'sync.baselineRexportFailed': '写回成功但重新导出本机分区失败({sections}):无法证明基线与本机一致,本次**不记基线**(远端指针保持原值,下一轮重新合并收敛)',
@@ -87,6 +93,7 @@ const zh = {
87
93
  const en: Record<keyof typeof zh, string> = {
88
94
  'sync.pushRemoteAdvanced': 'The newest remote snapshot {remote} differs from the local baseline {base}: this push did not run (nothing written remotely, baseline untouched). Sync the remote changes first, or explicitly force the push if this machine should overwrite the remote.',
89
95
  'sync.pushAdvanceCheckFailed': 'Cannot tell whether another device advanced the remote ({reason}): this push continues as-is',
96
+ 'sync.sessionTombstonesInherited': 'Inherited {count} session deletion tombstone(s) from the remote snapshot: deletions keep propagating across devices (local data untouched)',
90
97
  'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine is missing the importer (must be injected via options)',
91
98
  'sync.mergePendingConflicts': '{count} section(s) still have no decision ({sections}): the decided parts were written locally, but the baseline is not advanced, so the conflicts will reappear next round',
92
99
  'sync.baselineRexportFailed': 'The write succeeded but re-exporting the local section(s) failed ({sections}): the baseline cannot be proven to match this machine, so no baseline was recorded (the remote pointer keeps its old value and the next merge converges again)',
@@ -94,42 +101,6 @@ const en: Record<keyof typeof zh, string> = {
94
101
 
95
102
  registerMessageCatalog({ id: 'sync-engine-merge', zh, en });
96
103
 
97
- /**
98
- * 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。
99
- *
100
- * 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
101
- * (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
102
- */
103
- const MERGE_PREVIEW_TEXT_LIMIT = 2048;
104
-
105
- /** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
106
- const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
107
-
108
- /** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
109
- function previewTextOf(value: unknown): string {
110
- if (value === undefined) return '';
111
- if (typeof value === 'string') return finalizePreviewText(value);
112
- if (value instanceof Uint8Array) {
113
- try {
114
- return finalizePreviewText(STRICT_UTF8.decode(value));
115
- } catch {
116
- // 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
117
- return JSON.stringify({ binary: true, bytes: value.length });
118
- }
119
- }
120
- try {
121
- return finalizePreviewText(JSON.stringify(value) ?? '');
122
- } catch {
123
- return finalizePreviewText(String(value));
124
- }
125
- }
126
-
127
- /** 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。 */
128
- function finalizePreviewText(text: string): string {
129
- const masked = redact(text);
130
- return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
131
- }
132
-
133
104
  /** plan 的单个分区结果 → 预览视图(三侧都只回传可读文本,绝不回传原始值)。 */
134
105
  function mergePreviewSectionView(result: MergeSectionResult): MergePreviewSectionView {
135
106
  const conflicts: MergePreviewConflictView[] = result.conflicts.map((c) => ({
@@ -517,6 +488,15 @@ export type MergeResolution = { id: SectionId; action: 'keepLocal' | 'useRemote'
517
488
  /** applyMergeResolutions 报告:ApplyReport + 仍未裁决的冲突分区(非空 = 基线未推进)。 */
518
489
  export type ApplyMergeResolutionsReport = ApplyReport & { pendingConflicts: SectionId[] };
519
490
 
491
+ /**
492
+ * 删除远端快照的结果(F3:显式删除路径此前对「本轮 blob GC 被放弃」完全静默)。
493
+ * `warnings` 与 push 报告同风格(空数组 = 本轮什么都没被放弃):快照已按用户意图删除,
494
+ * 「孤儿 blob 没回收」不是失败(下一轮 GC 还会再试),所以进 warnings、不进 ok。
495
+ */
496
+ export interface DeleteSnapshotResult {
497
+ warnings: string[];
498
+ }
499
+
520
500
  export class SyncEngine {
521
501
  private readonly ctx: HostContext;
522
502
  private readonly transport: SyncTransport;
@@ -872,10 +852,13 @@ export class SyncEngine {
872
852
  if (encrypt && (opts.password === undefined || opts.password === '')) {
873
853
  throw new Error(this.msg('sync.encryptRequiresPassword'));
874
854
  }
855
+ // SYN-02:本轮只列一次远端 index(推进检测 + 继承会话删除墓碑共用;墓碑累积在最新快照里)
856
+ const remoteIndex = await listRemoteSnapshotIndex(this.transport);
857
+ const remoteTombstones = remoteIndex.reason === undefined ? remoteTombstonesOf(remoteIndex.metas) : [];
875
858
  // 远端推进检测:不一致 = 远端被别的设备推过(或本机基线已被裁剪)。
876
859
  // 此时推上去会覆盖/架空别人的改动 → 直接返回冲突(**不抛错**、不 upload、不记基线、不改 sync-state)。
877
860
  if (opts.force !== true) {
878
- const check = await this.remoteAdvancedCheck();
861
+ const check = await this.remoteAdvancedCheck(remoteIndex);
879
862
  if (check.reason !== undefined) {
880
863
  // 读不到远端列表 ⇒ 无法判定。按原样继续(上传本身会再暴露通道问题),但如实说明。
881
864
  warnings.push(this.msg('sync.pushAdvanceCheckFailed', { reason: check.reason }));
@@ -929,11 +912,15 @@ export class SyncEngine {
929
912
  ? await this.buildCredentialsPayload(opts.password!, warnings)
930
913
  : undefined;
931
914
 
932
- // P1-5:会话删除墓碑(上一次推过、本机已不存在的会话 = 用户删掉了)
933
- const bookkeeping = await this.computeSessionBookkeeping(targets, plainSections);
915
+ // P1-5 + SYN-02:本机墓碑(上次推过、本机已删)叠加上远端继承的墓碑 ——
916
+ // 不继承的话「A 删 → B 推」会漏掉墓碑,C 带旧快照就能把已删会话复活(删除只在本机传播)。
917
+ const bookkeeping = await this.computeSessionBookkeeping(targets, plainSections, remoteTombstones);
934
918
  if (bookkeeping.deletedNow.length > 0) {
935
919
  warnings.push(this.msg('sync.sessionDeletionsRecorded', { count: String(bookkeeping.deletedNow.length) }));
936
920
  }
921
+ if (bookkeeping.inherited > 0) {
922
+ warnings.push(this.msg('sync.sessionTombstonesInherited', { count: String(bookkeeping.inherited) }));
923
+ }
937
924
 
938
925
  // ⑥ 设备身份(cloud-sync 借鉴项 ⑥):首次生成后持久化在 sync-state.json(已有则纯读),
939
926
  // 随 manifest 上行 → 别的设备在历史列表里能看到「这份快照是哪台机器推的」。
@@ -1109,48 +1096,34 @@ export class SyncEngine {
1109
1096
  private async computeSessionBookkeeping(
1110
1097
  targets: readonly ConfigAdapter[],
1111
1098
  plainSections: Partial<Record<SectionId, SectionData>>,
1112
- ): Promise<{ tracked: boolean; sessionUnits: string[]; tombstones: string[]; deletedNow: string[] }> {
1099
+ remoteTombstones: readonly string[] = [],
1100
+ ): Promise<SessionBookkeepingResult> {
1113
1101
  const sessions = targets.find((a) => a.id === 'sessions');
1114
1102
  if (sessions === undefined || sessions.listAllUnitIds === undefined) {
1115
- return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
1103
+ return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
1116
1104
  }
1117
1105
  let localUnits: string[];
1118
1106
  try {
1119
1107
  localUnits = await sessions.listAllUnitIds(this.ctx);
1120
1108
  } catch {
1121
- return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
1109
+ return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
1122
1110
  }
1123
1111
  if (localUnits.length === 0) {
1124
- return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
1112
+ return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
1125
1113
  }
1126
1114
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
1127
- const { tombstones, deletedNow } = nextSessionTombstones({
1128
- previousUnits: state.sessionUnits ?? [],
1129
- localUnits,
1130
- previousTombstones: state.deletedSessions ?? [],
1131
- });
1132
1115
  return {
1133
1116
  tracked: true,
1134
- sessionUnits: sessionUnitIdsOfSection(plainSections['sessions']),
1135
- tombstones,
1136
- deletedNow,
1117
+ ...planSessionBookkeeping({
1118
+ previousUnits: state.sessionUnits ?? [],
1119
+ previousTombstones: state.deletedSessions ?? [],
1120
+ localUnits,
1121
+ exportedSessions: plainSections['sessions'],
1122
+ remoteTombstones,
1123
+ }),
1137
1124
  };
1138
1125
  }
1139
1126
 
1140
- /**
1141
- * P1-5:按远端墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。
1142
- * 只动载荷,不动本机已有数据 —— 墓碑不代用户删除本机文件(那是不可回滚的用户动作)。
1143
- * @returns 被剔除的会话单元 id(调用方据此出可见报告,绝不静默)
1144
- */
1145
- private stripTombstonedSessions(snapshot: SyncSnapshot): string[] {
1146
- const tombstones = snapshot.manifest.deletedSessions ?? [];
1147
- if (tombstones.length === 0 || isEncryptedSections(snapshot.sections)) return [];
1148
- const plain = snapshot.sections as Partial<Record<SectionId, SectionData>>;
1149
- const stripped = stripTombstonedUnits(plain['sessions'], tombstones);
1150
- if (stripped.removed.length === 0) return [];
1151
- plain['sessions'] = stripped.section as SectionData;
1152
- return stripped.removed;
1153
- }
1154
1127
 
1155
1128
  /**
1156
1129
  * 裁剪远端快照:调用 transport.list() 获取全部快照(按 createdAt 升序),
@@ -1186,6 +1159,10 @@ export class SyncEngine {
1186
1159
  );
1187
1160
  // 刚 push 的快照永不进删除集(防御性:策略三层全关时也保住它)
1188
1161
  const keep = new Set<string>([pushedId]);
1162
+ // SYN-04b:本轮 GC 是否已放弃 —— 每次 transport.delete() 内部会重跑一轮 blob GC,
1163
+ // 而放弃码是「本轮」的结论;同一次 push 里删 N 个快照会设置 N 次同一个原因,
1164
+ // 逐条报会把「一样的事实」刷 N 遍,所以本轮只报一次。
1165
+ const gcReported = { done: false };
1189
1166
  for (const m of metas) {
1190
1167
  if (doomed.includes(m.id) && !keep.has(m.id)) {
1191
1168
  try {
@@ -1194,6 +1171,7 @@ export class SyncEngine {
1194
1171
  const reason = err instanceof Error ? err.message : String(err);
1195
1172
  warnings.push(this.msg('sync.pruneDeleteFailed', { id: m.id, reason }));
1196
1173
  }
1174
+ reportGcSkipOnce(this.transport, this.msg, warnings, gcReported);
1197
1175
  }
1198
1176
  }
1199
1177
  }
@@ -1216,17 +1194,17 @@ export class SyncEngine {
1216
1194
  * - 其余:远端最新快照 id !== lastSnapshotId ⇒ 远端被推进(或本机基线已被裁剪)→ 拦。
1217
1195
  * 两种情况都**无法安全断言「远端没变」**,保守拒绝并给出 force 出口。
1218
1196
  * - list() 抛错 → 返回 reason(调用方按原样继续并告警),绝不把「读不到」当成「没变」。
1197
+ * - SYN-02:index 可由 push() 传入(同一轮只列一次远端),缺省自己 list(其它调用点行为不变)。
1219
1198
  */
1220
- private async remoteAdvancedCheck(): Promise<{ blocked: boolean; base: string; latest: string; reason?: string }> {
1199
+ private async remoteAdvancedCheck(
1200
+ index?: RemoteSnapshotIndex,
1201
+ ): Promise<{ blocked: boolean; base: string; latest: string; reason?: string }> {
1221
1202
  const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
1222
1203
  const base = state.lastSnapshotId;
1223
1204
  if (base === '') return { blocked: false, base, latest: '' };
1224
- let metas: SyncSnapshotMeta[];
1225
- try {
1226
- metas = await this.transport.list();
1227
- } catch (err) {
1228
- return { blocked: false, base, latest: '', reason: err instanceof Error ? err.message : String(err) };
1229
- }
1205
+ const listed = index ?? await listRemoteSnapshotIndex(this.transport);
1206
+ if (listed.reason !== undefined) return { blocked: false, base, latest: '', reason: listed.reason };
1207
+ const metas = listed.metas;
1230
1208
  if (metas.length === 0) return { blocked: false, base, latest: '' };
1231
1209
  const latest = metas[metas.length - 1]!.id; // list 按 createdAt 升序 → 最新在末
1232
1210
  return { blocked: latest !== base, base, latest };
@@ -1250,7 +1228,7 @@ export class SyncEngine {
1250
1228
  // 加密快照 → 用密码解密回明文(无密码明确报错);普通快照 → containsSecrets 拒绝
1251
1229
  await this.prepareSnapshot(snapshot, opts.password);
1252
1230
  // P1-5:按远端墓碑剔除已删除的会话(旧快照不复活)
1253
- const tombstoned = this.stripTombstonedSessions(snapshot);
1231
+ const tombstoned = stripTombstonedSessionsInPlace(snapshot);
1254
1232
  // issue #38:快照携带凭据载荷 → 解密出 ref→值(仅内存),供计划项与后续 apply 使用
1255
1233
  const credentials = await this.decryptSnapshotCredentials(snapshot, opts.password);
1256
1234
 
@@ -1315,7 +1293,7 @@ export class SyncEngine {
1315
1293
  await this.prepareSnapshot(snapshot, opts.password)
1316
1294
  // audit-sync sync-F6:剔除结果必须**可见**(与 pull/preview 同一口径)。
1317
1295
  // 此前返回值丢弃了它 —— 产物里少了哪些会话,调用方与用户都无从得知(绝不静默少带)。
1318
- const tombstonedSessions = this.stripTombstonedSessions(snapshot).length
1296
+ const tombstonedSessions = stripTombstonedSessionsInPlace(snapshot).length
1319
1297
  const portableIds = this.pullSectionIds()
1320
1298
  const zipPath = await this.snapshotToZip(snapshot, portableIds)
1321
1299
  // snapshotToZip 产出在临时目录;搬到调用方指定目录(同盘 rename,跨盘退化为复制)
@@ -1342,9 +1320,27 @@ export class SyncEngine {
1342
1320
  * - **与保留策略裁剪共用同一条 `transport.delete`**(两条通道的删除语义只有一份实现),
1343
1321
  * 区别只在「谁点名」:这里是用户显式指定的 id,pruneRemoteSnapshots 由 RetentionPolicy 挑;
1344
1322
  * - **不存在视为成功**(SyncTransport 契约),所以重复点删除 / 已被裁剪掉都不会报错。
1323
+ *
1324
+ * F3:通道侧的 blob GC 可能「放弃本轮」(SYN-04:引用快照 404 / 读不出 ⇒ 证明不了没人引用)。
1325
+ * 放弃不是删除失败,但**必须可见**(否则孤儿 blob 无人知晓地一直堆)⇒ 放弃码用与 push 报告
1326
+ * 同一条 `gcSkipWarning` 渲染进返回体的 warnings。
1345
1327
  */
1346
- async deleteSnapshot(id: string): Promise<void> {
1328
+ async deleteSnapshot(id: string): Promise<DeleteSnapshotResult> {
1347
1329
  await this.transport.delete(id)
1330
+ // transport.delete 内部是 await 完 gcBlobStore 才返回的(两条通道同型),所以此时取到的是
1331
+ // **本次删除那一轮**的放弃码,而不是上一轮的残留。
1332
+ const code = this.lastGcSkipCode()
1333
+ return code === null ? { warnings: [] } : { warnings: [gcSkipWarning(this.msg, code)] }
1334
+ }
1335
+
1336
+ /**
1337
+ * SYN-04b:通道最近一轮 GC 的放弃码(无则 null)。
1338
+ *
1339
+ * 保留为 public 只读出口(不删):属本类已发布的可见 API,路由/诊断不跑删除流程也能读到通道能力;
1340
+ * 引擎内已由 deleteSnapshot 消费(放弃码 → warnings),不再是死方法。读不到(通道没这能力)= null。
1341
+ */
1342
+ lastGcSkipCode(): string | null {
1343
+ return readGcSkipCode(this.transport)
1348
1344
  }
1349
1345
 
1350
1346
  /** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
@@ -1447,7 +1443,7 @@ export class SyncEngine {
1447
1443
  // 加密快照 → 用密码解密回明文(无密码明确报错);普通快照 → containsSecrets 拒绝
1448
1444
  await this.prepareSnapshot(snapshot, opts.password);
1449
1445
  // P1-5:按远端墓碑剔除已删除的会话(旧快照不复活;一键同步复用本 plan)
1450
- const tombstoned = this.stripTombstonedSessions(snapshot);
1446
+ const tombstoned = stripTombstonedSessionsInPlace(snapshot);
1451
1447
  // issue #38:凭据载荷解密(仅内存;由调用方登记进同步会话,apply-items 时写回本机)
1452
1448
  const credentials = await this.decryptSnapshotCredentials(snapshot, opts.password);
1453
1449
  const portableIds = this.pullSectionIds();
@@ -1493,7 +1489,7 @@ export class SyncEngine {
1493
1489
  // issue #107:记下远端快照是否加密 —— applyMergePlan 据此决定祖先副本也落密文
1494
1490
  this.lastMergedRemoteEncrypted = remote.manifest.encrypted === true;
1495
1491
  // P1-5:按远端墓碑剔除已删除的会话(三方合并的 remote 侧同样不许复活)
1496
- this.stripTombstonedSessions(remote);
1492
+ stripTombstonedSessionsInPlace(remote);
1497
1493
  // 本次合并的远端基准(P0-7):紧随其后的 applyMergePlan() 用它写基线远端指针
1498
1494
  this.lastMergedRemoteSnapshotId = targetId;
1499
1495
  // issue #45:把远端快照的 sourceHome 与用户映射一并备忘 —— applyMergePlan 重建临时 ZIP 时
@@ -1965,12 +1961,12 @@ export class SyncEngine {
1965
1961
  warnings: [this.msg('sync.preApplySnapshotFailed', { err: backupErr instanceof Error ? backupErr.message : String(backupErr) })],
1966
1962
  };
1967
1963
  }
1968
- // 3) 真正执行:Importer.executeImportPlan(rollbackOnError=true → 任一失败整体回滚)
1964
+ // 3) 真正执行:Importer.executeImportPlan(rollbackOnError 取单一缺省 → 任一失败整体回滚)
1969
1965
  // 自动同步路径没有密码(凭据载荷无法解密),因此恒不注入 decryptedCredentials;
1970
1966
  // 加密快照本就不会进入自动同步(无密码无法 prepareSnapshot)。
1971
1967
  const result = await this.importer.executeImportPlan(zipPath, plan, {
1972
1968
  confirm: true,
1973
- rollbackOnError: true,
1969
+ rollbackOnError: DEFAULT_ROLLBACK_ON_ERROR,
1974
1970
  secretInputs: undefined,
1975
1971
  decryptedCredentials: undefined,
1976
1972
  // 审计 P0-23:把 journal 绑定面透传给引擎(自动同步路径由 autosync-scheduler 提供),
@@ -2100,10 +2096,10 @@ export class SyncEngine {
2100
2096
  };
2101
2097
  }
2102
2098
 
2103
- // 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError:true → 任一失败整体回滚)
2099
+ // 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError 取单一缺省 → 任一失败整体回滚)
2104
2100
  const result = await this.importer.executeImportPlan(zipPath, subPlan, {
2105
2101
  confirm: true,
2106
- rollbackOnError: true,
2102
+ rollbackOnError: DEFAULT_ROLLBACK_ON_ERROR,
2107
2103
  secretInputs: undefined,
2108
2104
  // issue #38:快照凭据载荷解出的 ref→值(仅内存)→ credentials adapter 写回本机
2109
2105
  decryptedCredentials: opts.credentials,
@@ -10,7 +10,10 @@
10
10
  * 目标快照文件**仍按用户意图 DELETE**(DELETE 拿到 404 = 本来就不存在 = 成功,与另两个通道同口径);
11
11
  * ② index 读操作抛错 → 显式失败(F1 语义,绝不回落成空索引);
12
12
  * ③ index = 200 + [] → 索引权威地声明集合为空,保持既有语义;
13
- * ④ GC 遇到「索引有条目但快照文件读不出来(非 404)」→ 本轮放弃(读不出来 ≠ 没引用)。
13
+ * ④ GC 遇到「索引有条目但快照文件读不出来(非 404)」→ 本轮放弃(读不出来 ≠ 没引用);
14
+ * ⑤ SYN-04(P1):**404 同样放弃本轮** —— 索引有条目但快照文件 404(孤儿条目 / 假 404)时,旧实现
15
+ * 走 continue 把该快照当「引用集为空」,仍被它使用的 blob 就被判无人引用而删除(不可恢复)。
16
+ * 现在与「读不出来」同口径:本轮 0 删除,并记下放弃码(`lastGcSkip`,机器可判定)。
14
17
  */
15
18
  import test from 'node:test';
16
19
  import assert from 'node:assert/strict';
@@ -176,3 +179,28 @@ test('N1⑤:索引有条目但该快照文件读不出来(非 404)→ 本
176
179
  assert.deepEqual(r.seen.deletedBlobs, [], '读不出 sync-ccc-333 就无法证明它没引用 blob → 本轮不得删任何 blob');
177
180
  assert.equal(r.blobs.has(BLOB_HASH), true);
178
181
  });
182
+
183
+ test('N1⑥(SYN-04):索引有条目但该快照文件真 404 → 本轮放弃 GC(404 也证明不了「没引用」)+ 记放弃码', async () => {
184
+ // 孤儿条目 / 假 404:索引还列着 sync-ccc-333,文件却不在远端 —— 拿不到内容 ⇒ 无法证明它没引用 blob。
185
+ // base 上 404 走 continue,引用集只剩「读得出来的那几份」,于是在用的 BLOB_HASH 被判无人引用而删除。
186
+ const r = makeRemote({
187
+ index: JSON.stringify([meta('sync-aaa-111', '1'), meta('sync-ccc-333', '3')]),
188
+ snapshotFiles: { 'sync-aaa-111': snapOf('sync-aaa-111', PLAIN) },
189
+ });
190
+ const t = transportWith(r.request);
191
+ await t.delete('sync-aaa-111');
192
+ assert.deepEqual(r.seen.deletedBlobs, [], '404 与「读不出来」同属「无法证明没有引用」→ 本轮 0 删除');
193
+ assert.equal(r.blobs.has(BLOB_HASH), true, '没有被证明无引用的 blob 必须保留');
194
+ assert.equal(t.lastGcSkip, 'gc-skip-reference-missing', '必须记下本轮放弃码(机器可判定,文案由上层按码翻译)');
195
+ });
196
+
197
+ test('N1⑦(SYN-04):引用者 200 但内容坏掉 → 本轮放弃,放弃码 = snapshot-unparsable', async () => {
198
+ const r = makeRemote({
199
+ index: JSON.stringify([meta('sync-aaa-111', '1'), meta('sync-ccc-333', '3')]),
200
+ snapshotFiles: { 'sync-aaa-111': snapOf('sync-aaa-111', PLAIN), 'sync-ccc-333': 'not-json' },
201
+ });
202
+ const t = transportWith(r.request);
203
+ await t.delete('sync-aaa-111');
204
+ assert.deepEqual(r.seen.deletedBlobs, [], '解析不了 ⇒ 引用集不完整 ⇒ 本轮不得删任何 blob');
205
+ assert.equal(t.lastGcSkip, 'gc-skip-snapshot-unparsable');
206
+ });
@@ -28,6 +28,8 @@
28
28
  import { requestOnce, type RawResponse } from '../../utils/proxy.ts';
29
29
  import { zhMsg } from '../../core/messages.ts';
30
30
  import { composeMsg } from '../s3/messages.ts';
31
+ import { GC_SKIP_REFERENCE_MISSING, GC_SKIP_REFERENCE_UNREADABLE, GC_SKIP_SNAPSHOT_UNPARSABLE } from '../gc-skip.ts';
32
+ import type { GcSkipCode } from '../gc-skip.ts';
31
33
  // t6:「英文界面零中文」——注册同步域追加目录(this.o.msg 缺省 = zhMsg,其查找链会查注册目录;
32
34
  // sync.webdav.blobHashInvalid 这类新键必须能解析,绝不露裸键名)。
33
35
  import '../messages.ts';
@@ -302,6 +304,14 @@ export class WebDavTransport implements SyncTransport {
302
304
  msg: MsgFunc;
303
305
  };
304
306
 
307
+ /** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
308
+ private gcSkip: GcSkipCode | null = null;
309
+
310
+ /** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
311
+ get lastGcSkip(): GcSkipCode | null {
312
+ return this.gcSkip;
313
+ }
314
+
305
315
  constructor(options: WebDavTransportOptions) {
306
316
  this.o = {
307
317
  baseUrl: '',
@@ -662,22 +672,33 @@ export class WebDavTransport implements SyncTransport {
662
672
 
663
673
  /**
664
674
  * blob 仓 GC(P1-4):逐份读取**仍存在**的快照 JSON,收集被引用的哈希,删除无人引用且
665
- * 超过保护窗口的 blob。读坏任一份快照 → 本轮直接放弃(宁可留垃圾,不可删在用的)。
675
+ * 超过保护窗口的 blob。读不坏任何一份快照 → 正常回收;否则本轮整体放弃(宁可留垃圾,不可删在用的)。
676
+ *
677
+ * SYN-04(P1):**404 也放弃本轮**。旧实现把「索引有条目但快照文件 404」当作「文件不存在 ⇒ 不可能
678
+ * 引用任何 blob」直接 continue,于是引用集只剩「读得出来的那几份」,仍被该快照(假 404 / 孤儿条目)
679
+ * 使用的 blob 就被判成无人引用而删除 —— 不可恢复。拿不到内容就无法证明「没人引用」,与「读不出来」
680
+ * 是同一件事 ⇒ 一律放弃并记码(sync-N1 同族口径)。
666
681
  */
667
682
  private async gcBlobStore(pwd: string, remaining: SyncSnapshotMeta[]): Promise<void> {
683
+ this.gcSkip = null;
668
684
  const index = await this.readBlobIndex(pwd);
669
685
  if (Object.keys(index).length === 0) return;
670
686
  const referenced = new Set<string>();
671
687
  for (const meta of remaining) {
672
688
  const res = await this.send('GET', this.snapshotUrl(meta.id), pwd);
673
- // 索引有条目但快照文件 404 ⇒ 该文件不存在,不可能引用任何 blob(安全跳过)。
674
- // 其它失败 ⇒ **读不出来 ≠ 没引用**,本轮直接放弃(与「宁可留垃圾,不可删在用的」同口径;sync-N1 同族)。
675
- if (res.status === 404) continue;
676
- if (!res.ok) return;
689
+ if (res.status === 404) {
690
+ this.gcSkip = GC_SKIP_REFERENCE_MISSING;
691
+ return;
692
+ }
693
+ if (!res.ok) {
694
+ this.gcSkip = GC_SKIP_REFERENCE_UNREADABLE;
695
+ return;
696
+ }
677
697
  let snap: SyncSnapshot;
678
698
  try {
679
699
  snap = deserializeSnapshot(await res.text());
680
700
  } catch {
701
+ this.gcSkip = GC_SKIP_SNAPSHOT_UNPARSABLE;
681
702
  return;
682
703
  }
683
704
  const plain = snap.sections as Partial<Record<SectionId, unknown>>;
@@ -134,7 +134,8 @@ test('formatRetention:天 / 小时 / 缺失三档(en 与 zh 都走字典)'
134
134
  assert.equal(formatRetention(6 * 60 * 60 * 1000, zh), '6 小时');
135
135
  assert.equal(formatRetention(30 * 60 * 1000, zh), '30 分钟');
136
136
  assert.equal(formatRetention(0, zh), zh('diskUsage.retention.none'));
137
- assert.equal(formatRetention(2 * DAY, en), '2 day(s)');
137
+ assert.equal(formatRetention(2 * DAY, en), '2 days');
138
+ assert.equal(formatRetention(DAY, en), '1 day', '单数(今日起由 plural 段选形)');
138
139
  });
139
140
 
140
141
  test('diskUsageViewModel:英文界面下所有标签来自 en 字典(不回落中文)', () => {
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import test from 'node:test';
21
21
  import assert from 'node:assert/strict';
22
+ import { readFileSync } from 'node:fs';
22
23
 
23
24
  import { makeUiT, uiEn, uiZh, zhUiT } from './i18n.ts';
24
25
  import {
@@ -145,3 +146,49 @@ test('i18n-05 默认超时路径走 error.requestTimeout(真实请求:getJso
145
146
  return true;
146
147
  });
147
148
  });
149
+
150
+ // 复数语法(2026-11-01):`{n, plural, one|other}`。英文 (s)/(ies) 括号式复数全站清零后,
151
+ // 单复数由这条替换链决定 —— 它错了整站英文都会退回 "1 files"。
152
+ test('i18n-06 plural 段按数量选形:1/-1 取 one,0/2/非数取 other', () => {
153
+ assert.equal(enT('report.unit.files', { n: 1 }), 'file');
154
+ assert.equal(enT('report.unit.files', { n: 2 }), 'files');
155
+ assert.equal(enT('report.unit.files', { n: 0 }), 'files');
156
+ assert.equal(enT('report.unit.files', { n: -1 }), 'file', '负数是绝对值为 1,不当成复数');
157
+ assert.equal(enT('report.unit.files', { n: '1' }), 'file', '占位符值是字符串也要认');
158
+ assert.equal(enT('report.unit.files', { n: 'n/a' }), 'files', '无法解析成数字时按复数兜底,不抛错');
159
+ // 缺参时与普通占位符同口径:原样保留模板(调用点漏传会显形,而不是静默选错形)
160
+ assert.equal(enT('report.unit.files'), '{n, plural, file|files}');
161
+ });
162
+
163
+ test('i18n-07 plural 段与数字并存:数字仍由普通占位符渲染,且只出现一次', () => {
164
+ const en = enT('consult.attention', { count: 1 });
165
+ assert.equal(en, '1 item needs attention');
166
+ assert.equal(enT('consult.attention', { count: 3 }), '3 items need attention');
167
+ // 动词单复数必须跟着变:`3 item need` / `1 items did` 这类残留是这条断言的主要目标
168
+ assert.equal(
169
+ enT('sync.importPartial', { n: 1 }),
170
+ 'Partly successful: 1 item did not take effect (the rest were written; nothing rolled back)',
171
+ );
172
+ assert.equal(
173
+ enT('sync.importPartial', { n: 4 }),
174
+ 'Partly successful: 4 items did not take effect (the rest were written; nothing rolled back)',
175
+ );
176
+ });
177
+
178
+ test('i18n-08 英文字典不留 (s)/(ies) 括号式复数(今日起由 plural 段承担)', () => {
179
+ const offenders: string[] = [];
180
+ const collect = (dict: Record<string, string>, label: string) => {
181
+ for (const [key, value] of Object.entries(dict)) {
182
+ if (/\(s\)|\(ies\)/.test(value) && !/http\(s\)/.test(value)) offenders.push(label + ':' + key + ' = ' + value);
183
+ }
184
+ };
185
+ collect(uiEn, 'uiEn');
186
+ // 客户端四本字典同查:改的是源码文本,不 import(client 侧不保证可被 node 直接加载)
187
+ for (const rel of ['../client/locales.ts', '../client/sync/sync-locales.ts', '../client/market/market-locales.ts', '../client/recovery/recovery-locales.ts']) {
188
+ const text = readFileSync(new URL(rel, import.meta.url), 'utf8');
189
+ for (const line of text.split('\n')) {
190
+ if (/\((s|ies)\)/.test(line) && !/http\(s\)/.test(line) && /:\s*'/.test(line)) offenders.push(rel + ' :: ' + line.trim());
191
+ }
192
+ }
193
+ assert.deepEqual(offenders, [], '括号式复数会让 1 渲染成 "1 file(s)";改用 {n, plural, file|files}');
194
+ });