dsh-config-manager 0.2.4 → 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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1263 -1192
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +10 -0
- package/lib/sync/git/git-transport.js +72 -14
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +3 -2
- package/src/sync/git/git-transport.test.ts +168 -7
- package/src/sync/git/git-transport.ts +70 -14
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
|
@@ -2094,6 +2094,113 @@ test('P1-5: pull 按远端墓碑剔除已删除的会话(旧快照不复活)
|
|
|
2094
2094
|
}
|
|
2095
2095
|
});
|
|
2096
2096
|
|
|
2097
|
+
test('SYN-02: 远端墓碑被本机继承 → 下一次 push 仍带它(删除跨设备传播,旧快照不复活)', async () => {
|
|
2098
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-tombstone-inherit-'));
|
|
2099
|
+
try {
|
|
2100
|
+
const ctx = makeContext('win32', 'C:\\Users\\bob');
|
|
2101
|
+
seedSource(ctx);
|
|
2102
|
+
// 本机只有 b;a 是**另一台设备**删掉的 —— 本机从来没推过 a,靠 previousUnits 推断不出这条删除
|
|
2103
|
+
await ctx.fs.writeFile('sessions/--p--/b/session.jsonl.zstd', Buffer.from('b', 'utf8'));
|
|
2104
|
+
ctx.fs.setMtime('sessions/--p--/b/session.jsonl.zstd', 2000);
|
|
2105
|
+
const transport = new MemSyncTransport();
|
|
2106
|
+
const remote: SyncSnapshot = {
|
|
2107
|
+
id: 'remote-with-tombstones',
|
|
2108
|
+
createdAt: '2026-08-15T10:00:00.000Z',
|
|
2109
|
+
manifest: {
|
|
2110
|
+
schemaVersion: 1, dshVersion: '1.2.3', platform: 'linux',
|
|
2111
|
+
sectionIds: ['sessions'], containsSecrets: false,
|
|
2112
|
+
// 重复项:远端清单本身可能有重复,继承时必须去重保序
|
|
2113
|
+
deletedSessions: ['sessions:--p--/a', 'sessions:--p--/a', 'sessions:--q--/legacy'],
|
|
2114
|
+
},
|
|
2115
|
+
sections: { sessions: { version: 1, files: [] } },
|
|
2116
|
+
};
|
|
2117
|
+
transport.snapshots.set(remote.id, remote);
|
|
2118
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
2119
|
+
// 基线停在远端最新 → 本次 push 不被「远端已推进」拦下(继承墓碑与是否 force 无关)
|
|
2120
|
+
await saveSyncState(tmp, {
|
|
2121
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
2122
|
+
lastSyncAt: '2026-08-15T10:00:00.000Z', sections: {},
|
|
2123
|
+
lastSnapshotId: remote.id, ancestorId: '',
|
|
2124
|
+
sessionUnits: ['sessions:--p--/b'],
|
|
2125
|
+
deletedSessions: [],
|
|
2126
|
+
});
|
|
2127
|
+
const adapters = createAdapters({ namespaces: NS, includeSessions: true });
|
|
2128
|
+
const engine = new SyncEngine({
|
|
2129
|
+
ctx, transport, stateDir: tmp, adapters,
|
|
2130
|
+
importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
|
|
2131
|
+
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
2132
|
+
optInSections: ['sessions'],
|
|
2133
|
+
} as ConstructorParameters<typeof SyncEngine>[0]);
|
|
2134
|
+
|
|
2135
|
+
const report = await engine.push({ snapshotId: 'sync-inherit', sections: ['sessions'], sessions: {} });
|
|
2136
|
+
assert.equal(report.ok, true);
|
|
2137
|
+
assert.deepEqual(
|
|
2138
|
+
transport.snapshots.get('sync-inherit')!.manifest.deletedSessions,
|
|
2139
|
+
['sessions:--p--/a', 'sessions:--q--/legacy'],
|
|
2140
|
+
'远端墓碑必须被继承进新快照(否则第三台设备会复活已删会话)',
|
|
2141
|
+
);
|
|
2142
|
+
const state = await loadSyncState(tmp);
|
|
2143
|
+
assert.deepEqual(
|
|
2144
|
+
state.deletedSessions,
|
|
2145
|
+
['sessions:--p--/a', 'sessions:--q--/legacy'],
|
|
2146
|
+
'继承来的墓碑必须进 sync-state(下轮继续传播)',
|
|
2147
|
+
);
|
|
2148
|
+
assert.ok(report.warnings.length > 0, '继承墓碑必须可见(绝不静默): 无任何告警');
|
|
2149
|
+
} finally {
|
|
2150
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
|
|
2154
|
+
test('SYN-02: 远端墓碑命中本机仍有实体的会话 → 继承时撤销墓碑(有实体 = 没删,绝不记成本机删除)', async () => {
|
|
2155
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-tombstone-revoke-'));
|
|
2156
|
+
try {
|
|
2157
|
+
const ctx = makeContext('win32', 'C:\\Users\\bob');
|
|
2158
|
+
seedSource(ctx);
|
|
2159
|
+
// a 在本机**存在**(从别处恢复/未删)→ 远端那条墓碑必须被撤销,不能继续传播
|
|
2160
|
+
await ctx.fs.writeFile('sessions/--p--/a/session.jsonl.zstd', Buffer.from('a', 'utf8'));
|
|
2161
|
+
ctx.fs.setMtime('sessions/--p--/a/session.jsonl.zstd', 1000);
|
|
2162
|
+
const transport = new MemSyncTransport();
|
|
2163
|
+
const remote: SyncSnapshot = {
|
|
2164
|
+
id: 'remote-stale-tombstone',
|
|
2165
|
+
createdAt: '2026-08-15T10:00:00.000Z',
|
|
2166
|
+
manifest: {
|
|
2167
|
+
schemaVersion: 1, dshVersion: '1.2.3', platform: 'linux',
|
|
2168
|
+
sectionIds: ['sessions'], containsSecrets: false,
|
|
2169
|
+
deletedSessions: ['sessions:--p--/a'],
|
|
2170
|
+
},
|
|
2171
|
+
sections: { sessions: { version: 1, files: [] } },
|
|
2172
|
+
};
|
|
2173
|
+
transport.snapshots.set(remote.id, remote);
|
|
2174
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
2175
|
+
await saveSyncState(tmp, {
|
|
2176
|
+
schemaVersion: SYNC_STATE_SCHEMA_VERSION,
|
|
2177
|
+
lastSyncAt: '2026-08-15T10:00:00.000Z', sections: {},
|
|
2178
|
+
lastSnapshotId: remote.id, ancestorId: '',
|
|
2179
|
+
sessionUnits: ['sessions:--p--/a'],
|
|
2180
|
+
deletedSessions: [],
|
|
2181
|
+
});
|
|
2182
|
+
const adapters = createAdapters({ namespaces: NS, includeSessions: true });
|
|
2183
|
+
const engine = new SyncEngine({
|
|
2184
|
+
ctx, transport, stateDir: tmp, adapters,
|
|
2185
|
+
importer: new Importer({ ctx, adapters, snapshotStore: new MemSnapshotStore() }),
|
|
2186
|
+
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
2187
|
+
optInSections: ['sessions'],
|
|
2188
|
+
} as ConstructorParameters<typeof SyncEngine>[0]);
|
|
2189
|
+
|
|
2190
|
+
const report = await engine.push({ snapshotId: 'sync-revoked', sections: ['sessions'], sessions: {} });
|
|
2191
|
+
assert.equal(report.ok, true);
|
|
2192
|
+
assert.deepEqual(
|
|
2193
|
+
transport.snapshots.get('sync-revoked')!.manifest.deletedSessions ?? [],
|
|
2194
|
+
[],
|
|
2195
|
+
'本机还有实体 → 墓碑撤销,绝不把「本机有数据」记成删除',
|
|
2196
|
+
);
|
|
2197
|
+
const state = await loadSyncState(tmp);
|
|
2198
|
+
assert.deepEqual(state.deletedSessions ?? [], [], 'sync-state 里也不该留下这条墓碑');
|
|
2199
|
+
} finally {
|
|
2200
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2201
|
+
}
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2097
2204
|
test('push: 快照 manifest 携带导出机 sourceHome(跨机重定基的数据源)', async () => {
|
|
2098
2205
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-sourcehome-'));
|
|
2099
2206
|
try {
|
|
@@ -2764,3 +2871,134 @@ test('applyMergeResolutions: 远端无快照 → 空报告(ok:true,不构造
|
|
|
2764
2871
|
}
|
|
2765
2872
|
});
|
|
2766
2873
|
|
|
2874
|
+
// ─── SYN-04b:GC 放弃(lastGcSkip)必须冒泡成 push 报告里的一条 warning ────────────────
|
|
2875
|
+
//
|
|
2876
|
+
// 背景:SYN-04 修掉了「引用 404 就当无引用继续删」的静默丢数据路径 —— 现在 webdav/s3 通道会
|
|
2877
|
+
// **放弃本轮 blob GC** 并记下放弃码(lastGcSkip:gc-skip-reference-missing / -unreadable /
|
|
2878
|
+
// -snapshot-unparsable)。但通道层没有 logger、delete() 又把 GC 异常吃掉,引擎侧此前零引用
|
|
2879
|
+
// ⇒ 用户看到的是「push 成功、裁剪成功」,实际这一轮 blob 回收根本没跑(孤儿文件会一直堆)。
|
|
2880
|
+
// 这里钉三件事:① 有放弃码 ⇒ warnings 里出现该码;② 同一轮多次 delete 只报一次;
|
|
2881
|
+
// ③ 能力探测:通道没有该字段 / 字段不是非空字符串 ⇒ 当没有,绝不凭空造告警。
|
|
2882
|
+
|
|
2883
|
+
/** 给内存通道挂一个「GC 放弃码」出口:形态与 webdav/s3 transport 的 lastGcSkip 一致(只读字段)。 */
|
|
2884
|
+
function attachGcSkip(transport: MemSyncTransport, initial: unknown): { code: unknown } {
|
|
2885
|
+
const box = { code: initial };
|
|
2886
|
+
Object.defineProperty(transport, 'lastGcSkip', { get: () => box.code, configurable: true });
|
|
2887
|
+
return box;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
test('SYN-04b/①:GC 放弃 ⇒ push 的 warnings 含放弃码,且同一轮只报一次', async () => {
|
|
2891
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-gcskip-'));
|
|
2892
|
+
try {
|
|
2893
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2894
|
+
seedSource(ctx);
|
|
2895
|
+
const transport = new MemSyncTransport();
|
|
2896
|
+
seedRemoteSnapshots(transport, 13); // 最旧 4 个会被裁剪 ⇒ 4 次 delete
|
|
2897
|
+
const box = attachGcSkip(transport, null);
|
|
2898
|
+
const origDelete = transport.delete.bind(transport);
|
|
2899
|
+
// 每次删除都触发一轮 GC,而这一轮的 GC 都放弃(真实场景:引用快照 404 / 读不出)
|
|
2900
|
+
transport.delete = async (id: string) => {
|
|
2901
|
+
box.code = 'gc-skip-reference-missing';
|
|
2902
|
+
return origDelete(id);
|
|
2903
|
+
};
|
|
2904
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2905
|
+
|
|
2906
|
+
const report = await engine.push({ snapshotId: 'sync-new' });
|
|
2907
|
+
assert.equal(report.ok, true, 'GC 放弃不影响 push 主流程');
|
|
2908
|
+
assert.equal(transport.calls.filter((c) => c === 'delete').length, 4, '四次裁剪删除都发生了');
|
|
2909
|
+
const hits = report.warnings.filter((w) => w.includes('gc-skip-reference-missing'));
|
|
2910
|
+
assert.equal(hits.length, 1, '同一轮只报一次(否则每删一个快照就刷一条同义告警):' + JSON.stringify(report.warnings));
|
|
2911
|
+
} finally {
|
|
2912
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2913
|
+
}
|
|
2914
|
+
});
|
|
2915
|
+
|
|
2916
|
+
test('SYN-04b/②:通道没有 lastGcSkip 字段 ⇒ 不产生任何 GC 告警(能力探测,字段不存在当没有)', async () => {
|
|
2917
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-gcskip-absent-'));
|
|
2918
|
+
try {
|
|
2919
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2920
|
+
seedSource(ctx);
|
|
2921
|
+
const transport = new MemSyncTransport(); // 裸 Mem 通道:无该字段
|
|
2922
|
+
seedRemoteSnapshots(transport, 13);
|
|
2923
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2924
|
+
|
|
2925
|
+
const report = await engine.push({ snapshotId: 'sync-new' });
|
|
2926
|
+
assert.equal(report.ok, true);
|
|
2927
|
+
assert.equal(transport.calls.filter((c) => c === 'delete').length, 4);
|
|
2928
|
+
assert.ok(!report.warnings.some((w) => w.includes('gc-skip-')), '无放弃码就不得凭空告警:' + JSON.stringify(report.warnings));
|
|
2929
|
+
} finally {
|
|
2930
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2931
|
+
}
|
|
2932
|
+
});
|
|
2933
|
+
|
|
2934
|
+
test('SYN-04b/③:lastGcSkip 不是非空字符串(null / 数字 / 空串)⇒ 一律当没有', async () => {
|
|
2935
|
+
for (const bogus of [null, 42, '']) {
|
|
2936
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-gcskip-bogus-'));
|
|
2937
|
+
try {
|
|
2938
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2939
|
+
seedSource(ctx);
|
|
2940
|
+
const transport = new MemSyncTransport();
|
|
2941
|
+
seedRemoteSnapshots(transport, 13);
|
|
2942
|
+
attachGcSkip(transport, bogus);
|
|
2943
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2944
|
+
|
|
2945
|
+
const report = await engine.push({ snapshotId: 'sync-new' });
|
|
2946
|
+
assert.equal(report.ok, true);
|
|
2947
|
+
assert.ok(
|
|
2948
|
+
!report.warnings.some((w) => w.includes('gc-skip-')),
|
|
2949
|
+
'非法放弃码(' + JSON.stringify(bogus) + ')不得变成告警:' + JSON.stringify(report.warnings),
|
|
2950
|
+
);
|
|
2951
|
+
} finally {
|
|
2952
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
});
|
|
2956
|
+
|
|
2957
|
+
// ─── F3:**手动**删快照撞上 GC 放弃时不得静默(RV1 复核:deleteSnapshot 此前 Promise<void>) ───
|
|
2958
|
+
//
|
|
2959
|
+
// 与上面 SYN-04b 的差别在触发路径:push 的 warnings 只覆盖「保留策略裁剪」那一轮;用户在产品库里
|
|
2960
|
+
// 显式点删除走的是 SyncEngine.deleteSnapshot → transport.delete → gcBlobStore,通道同样会放弃本轮
|
|
2961
|
+
// 回收,但旧实现把放弃码丢在原地 ⇒ 孤儿 blob 无人可见地累积。这里钉:放弃要有 warnings,没有就空。
|
|
2962
|
+
|
|
2963
|
+
test('F3/①:手动删快照 + 通道放弃本轮 GC ⇒ 返回体带该放弃码的 warning(快照仍按意图删除)', async () => {
|
|
2964
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-del-gcskip-'));
|
|
2965
|
+
try {
|
|
2966
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2967
|
+
seedSource(ctx);
|
|
2968
|
+
const transport = new MemSyncTransport();
|
|
2969
|
+
seedRemoteSnapshots(transport, 2);
|
|
2970
|
+
attachGcSkip(transport, 'gc-skip-reference-missing');
|
|
2971
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2972
|
+
|
|
2973
|
+
const result = await engine.deleteSnapshot('remote-01');
|
|
2974
|
+
|
|
2975
|
+
assert.deepEqual(transport.calls.filter((c) => c === 'delete'), ['delete'], '删除只发生一次');
|
|
2976
|
+
assert.ok(transport.metas.every((m) => m.id !== 'remote-01'), '快照本身已按用户意图删除');
|
|
2977
|
+
assert.equal(result.warnings.length, 1, '放弃本轮 GC 必须给出恰好一条 warning:' + JSON.stringify(result.warnings));
|
|
2978
|
+
assert.ok(result.warnings[0]!.includes('gc-skip-reference-missing'), 'warning 必须含放弃码明文(字典未补键也能 grep)');
|
|
2979
|
+
} finally {
|
|
2980
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
2981
|
+
}
|
|
2982
|
+
});
|
|
2983
|
+
|
|
2984
|
+
test('F3/②:手动删快照但没有放弃码(通道无该字段 / 非非空字符串)⇒ 零 warning', async () => {
|
|
2985
|
+
for (const spec of ['absent', null, 42, ''] as const) {
|
|
2986
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-del-gcskip-none-'));
|
|
2987
|
+
try {
|
|
2988
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
2989
|
+
seedSource(ctx);
|
|
2990
|
+
const transport = new MemSyncTransport();
|
|
2991
|
+
seedRemoteSnapshots(transport, 1);
|
|
2992
|
+
if (spec !== 'absent') attachGcSkip(transport, spec);
|
|
2993
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
2994
|
+
|
|
2995
|
+
const result = await engine.deleteSnapshot('remote-01');
|
|
2996
|
+
assert.deepEqual(result.warnings, [], '无放弃码(' + String(spec) + ')不得凭空造告警');
|
|
2997
|
+
assert.ok(transport.metas.every((m) => m.id !== 'remote-01'), '删除照常生效');
|
|
2998
|
+
} finally {
|
|
2999
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
|
package/src/sync/sync-engine.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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(
|
|
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
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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 =
|
|
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 =
|
|
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<
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
2099
|
+
// 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError 取单一缺省 → 任一失败整体回滚)
|
|
2104
2100
|
const result = await this.importer.executeImportPlan(zipPath, subPlan, {
|
|
2105
2101
|
confirm: true,
|
|
2106
|
-
rollbackOnError:
|
|
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
|
+
});
|