@max-null/dsh-memory 0.20.0 → 0.22.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 +50 -4
- package/dist/backup.d.ts +8 -2
- package/dist/backup.js +18 -4
- package/dist/engine.d.ts +98 -0
- package/dist/engine.js +109 -7
- package/dist/index.d.ts +12 -0
- package/dist/index.js +128 -14
- package/dist/self.js +2 -1
- package/dist/sync-export.d.ts +65 -0
- package/dist/sync-export.js +150 -0
- package/dist/sync-git.d.ts +57 -0
- package/dist/sync-git.js +88 -0
- package/dist/sync-import.d.ts +83 -0
- package/dist/sync-import.js +82 -0
- package/dist/sync-remote.d.ts +81 -0
- package/dist/sync-remote.js +157 -0
- package/dist/sync-service.d.ts +108 -0
- package/dist/sync-service.js +347 -0
- package/dist/sync-tombstone.d.ts +67 -0
- package/dist/sync-tombstone.js +83 -0
- package/dist/sync-writer.d.ts +46 -0
- package/dist/sync-writer.js +85 -0
- package/package.json +2 -1
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步镜像的远端层:`push` / `pull` / 状态查询。
|
|
3
|
+
*
|
|
4
|
+
* **必须走 HTTP**:`isomorphic-git` 的 `push` 在没有 `http` 参数时直接抛
|
|
5
|
+
* `The function requires a "http" parameter but none was provided`(2026-11-11 实测,
|
|
6
|
+
* 想用本地 bare 仓库做离线测试也走不通)。所以本层天然依赖网络,与上一层的
|
|
7
|
+
* `sync-git.ts`(纯本地、可离线全测)分开。
|
|
8
|
+
*
|
|
9
|
+
* **认证用 PAT(https)而不是 SSH**:SSH key 在移动端的管理成本远高于 token,
|
|
10
|
+
* 而 dsh-go 那类环境正是目标场景之一。
|
|
11
|
+
*
|
|
12
|
+
* **token 不进 profile 配置**:由调用方从既有 credentials 机制取出后传入。本模块只
|
|
13
|
+
* 负责把它放进 http 请求,不负责存。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-memory/sync-remote
|
|
16
|
+
*/
|
|
17
|
+
import * as nodeFs from 'node:fs';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
/** 默认分支。 */
|
|
21
|
+
const DEFAULT_BRANCH = 'main';
|
|
22
|
+
/** 动态载入 isomorphic-git 与它的 Node http 实现。 */
|
|
23
|
+
async function loadGit() {
|
|
24
|
+
const [git, http] = await Promise.all([
|
|
25
|
+
import('isomorphic-git'),
|
|
26
|
+
import('isomorphic-git/http/node'),
|
|
27
|
+
]);
|
|
28
|
+
return { git: git.default, http: http.default };
|
|
29
|
+
}
|
|
30
|
+
/** 把原始错误归到三类之一,便于调用方决定是改凭据还是重试。 */
|
|
31
|
+
function classify(error) {
|
|
32
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
33
|
+
const text = raw.toLowerCase();
|
|
34
|
+
// 401/403、authentication failed、invalid credentials、权限不足
|
|
35
|
+
if (/401|403|auth|credential|permission|denied|token/u.test(text)) {
|
|
36
|
+
return { ok: false, error: raw, kind: 'auth' };
|
|
37
|
+
}
|
|
38
|
+
if (/enotfound|econnrefused|etimedout|timeout|network|dns|socket|offline/u.test(text)) {
|
|
39
|
+
return { ok: false, error: raw, kind: 'network' };
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, error: raw, kind: 'unknown' };
|
|
42
|
+
}
|
|
43
|
+
/** 组装 isomorphic-git 的 onAuth 回调。 */
|
|
44
|
+
function authFor(token) {
|
|
45
|
+
return () => (token === undefined || token === ''
|
|
46
|
+
? { username: 'git' }
|
|
47
|
+
: { username: 'x-access-token', password: token });
|
|
48
|
+
}
|
|
49
|
+
/** 镜像目录是不是一个已初始化的仓库。 */
|
|
50
|
+
function isRepo(dir) {
|
|
51
|
+
return existsSync(join(dir, '.git'));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 确保镜像目录是一个**与远端有共同历史**的仓库。
|
|
55
|
+
*
|
|
56
|
+
* 首次同步时如果只 `git init`,本地与远端是两条无关的历史线——push 会被拒
|
|
57
|
+
* (`Push rejected because it was not a simple fast-forward`)。这是 2026-11-11
|
|
58
|
+
* 第一次用真实仓库跑时撞出来的:单元测试里没有真远端,所以永远发现不了。
|
|
59
|
+
*
|
|
60
|
+
* 因此首次必须 clone,而不是 init + push。
|
|
61
|
+
*
|
|
62
|
+
* @param options - 目录、远端地址、分支与 token。
|
|
63
|
+
* @returns 是否执行了 clone;失败时带分类。
|
|
64
|
+
*/
|
|
65
|
+
export async function ensureMirrorRepo(options) {
|
|
66
|
+
if (isRepo(options.dir))
|
|
67
|
+
return { cloned: false };
|
|
68
|
+
const { git, http } = await loadGit();
|
|
69
|
+
try {
|
|
70
|
+
await git.clone({
|
|
71
|
+
fs: nodeFs,
|
|
72
|
+
http,
|
|
73
|
+
dir: options.dir,
|
|
74
|
+
url: options.url,
|
|
75
|
+
ref: options.branch ?? DEFAULT_BRANCH,
|
|
76
|
+
depth: 1,
|
|
77
|
+
onAuth: authFor(options.token),
|
|
78
|
+
});
|
|
79
|
+
return { cloned: true };
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return { cloned: false, ...classify(error) };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 把本地提交推到远端。
|
|
87
|
+
*
|
|
88
|
+
* @param options - 目录、远端地址、分支与 token。
|
|
89
|
+
* @returns 成功与否;失败时带分类。
|
|
90
|
+
*/
|
|
91
|
+
export async function pushMirror(options) {
|
|
92
|
+
if (!isRepo(options.dir)) {
|
|
93
|
+
return { ok: false, error: `not a git repository: ${options.dir}`, kind: 'unknown' };
|
|
94
|
+
}
|
|
95
|
+
const { git, http } = await loadGit();
|
|
96
|
+
try {
|
|
97
|
+
await git.push({
|
|
98
|
+
fs: nodeFs,
|
|
99
|
+
http,
|
|
100
|
+
dir: options.dir,
|
|
101
|
+
remote: 'origin',
|
|
102
|
+
ref: options.branch ?? DEFAULT_BRANCH,
|
|
103
|
+
onAuth: authFor(options.token),
|
|
104
|
+
});
|
|
105
|
+
return { ok: true };
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
return classify(error);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 从远端拉取并快进本地。
|
|
113
|
+
*
|
|
114
|
+
* `fastForwardOnly: true`:合并策略不该由这一层决定。本地有分叉时宁可失败让人看见,
|
|
115
|
+
* 也不要让 isomorphic-git 悄悄造出一个 merge commit。
|
|
116
|
+
*
|
|
117
|
+
* @param options - 目录、远端地址、分支与 token。
|
|
118
|
+
* @returns 成功与否;失败时带分类。
|
|
119
|
+
*/
|
|
120
|
+
export async function pullMirror(options) {
|
|
121
|
+
if (!isRepo(options.dir)) {
|
|
122
|
+
return { ok: false, error: `not a git repository: ${options.dir}`, kind: 'unknown' };
|
|
123
|
+
}
|
|
124
|
+
const { git, http } = await loadGit();
|
|
125
|
+
try {
|
|
126
|
+
await git.pull({
|
|
127
|
+
fs: nodeFs,
|
|
128
|
+
http,
|
|
129
|
+
dir: options.dir,
|
|
130
|
+
remote: 'origin',
|
|
131
|
+
ref: options.branch ?? DEFAULT_BRANCH,
|
|
132
|
+
fastForwardOnly: true,
|
|
133
|
+
author: { name: 'dsh-memory', email: 'dsh-memory@localhost' },
|
|
134
|
+
onAuth: authFor(options.token),
|
|
135
|
+
});
|
|
136
|
+
return { ok: true };
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
return classify(error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 确保 `origin` 指向给定地址。地址变化时重设(用户换了仓库)。
|
|
144
|
+
*
|
|
145
|
+
* @param dir - 镜像仓库目录。
|
|
146
|
+
* @param url - 期望的远端地址。
|
|
147
|
+
*/
|
|
148
|
+
export async function ensureRemote(dir, url) {
|
|
149
|
+
if (!isRepo(dir))
|
|
150
|
+
return;
|
|
151
|
+
const { git } = await loadGit();
|
|
152
|
+
const remotes = await git.listRemotes({ fs: nodeFs, dir });
|
|
153
|
+
const origin = remotes.find(remote => remote.remote === 'origin');
|
|
154
|
+
if (origin?.url === url)
|
|
155
|
+
return;
|
|
156
|
+
await git.addRemote({ fs: nodeFs, dir, remote: 'origin', url, force: true });
|
|
157
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步编排:把导出 / 写盘 / 墓碑 / git / 远端五层串成一次操作。
|
|
3
|
+
*
|
|
4
|
+
* 这一层没有自己的算法,只有**顺序**。顺序在这里是有意义的:
|
|
5
|
+
*
|
|
6
|
+
* push 时先写镜像与墓碑、再提交、最后推送——反过来的话,一次失败会留下
|
|
7
|
+
* 「远端已有、本地没有」的状态,下次 push 会把差异当成删除。
|
|
8
|
+
*
|
|
9
|
+
* pull 时先拉、再规划导入、最后逐条落地——导入是唯一会改本地记忆库的一步,
|
|
10
|
+
* 放在最后意味着前面任何一步失败都不会动数据。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/sync-service
|
|
13
|
+
*/
|
|
14
|
+
import type { MemoryEngine } from './engine.ts';
|
|
15
|
+
import type { SyncRecordFile } from './sync-export.ts';
|
|
16
|
+
import type { ImportConflict } from './sync-import.ts';
|
|
17
|
+
/** 同步配置。 */
|
|
18
|
+
export interface SyncConfig {
|
|
19
|
+
/** 镜像仓库目录。 */
|
|
20
|
+
readonly mirrorDir: string;
|
|
21
|
+
/** 远端地址(https);缺省时只做本地提交。 */
|
|
22
|
+
readonly remote?: string;
|
|
23
|
+
/** 分支名;缺省 `main`。 */
|
|
24
|
+
readonly branch?: string;
|
|
25
|
+
/** 环境短名,用于冲突留证与提交信息;缺省「本机」。 */
|
|
26
|
+
readonly envName?: string;
|
|
27
|
+
/**
|
|
28
|
+
* HTTPS token。**不从配置里读**——由调用方从环境变量或凭据机制取出后传入,
|
|
29
|
+
* 避免它落进 profile 配置或提交历史。
|
|
30
|
+
*/
|
|
31
|
+
readonly token?: string;
|
|
32
|
+
}
|
|
33
|
+
/** 一次 push 的结果。 */
|
|
34
|
+
export interface SyncPushRun {
|
|
35
|
+
readonly files: number;
|
|
36
|
+
readonly written: number;
|
|
37
|
+
readonly unchanged: number;
|
|
38
|
+
readonly removed: number;
|
|
39
|
+
readonly tombstones: number;
|
|
40
|
+
readonly committed: boolean;
|
|
41
|
+
readonly sha?: string;
|
|
42
|
+
readonly pushed: boolean;
|
|
43
|
+
readonly error?: string;
|
|
44
|
+
readonly errorKind?: 'auth' | 'network' | 'unknown';
|
|
45
|
+
}
|
|
46
|
+
/** 一次 pull 的结果。 */
|
|
47
|
+
export interface SyncPullRun {
|
|
48
|
+
readonly pulled: boolean;
|
|
49
|
+
readonly imported: number;
|
|
50
|
+
readonly conflicts: number;
|
|
51
|
+
readonly orphaned: number;
|
|
52
|
+
readonly error?: string;
|
|
53
|
+
readonly errorKind?: 'auth' | 'network' | 'unknown';
|
|
54
|
+
}
|
|
55
|
+
/** 同步状态快照(给 `memory_sync status`)。 */
|
|
56
|
+
export interface SyncStatus {
|
|
57
|
+
readonly mirrorDir: string;
|
|
58
|
+
readonly initialized: boolean;
|
|
59
|
+
readonly remote?: string;
|
|
60
|
+
readonly lastCommit?: {
|
|
61
|
+
sha: string;
|
|
62
|
+
message: string;
|
|
63
|
+
at: number;
|
|
64
|
+
};
|
|
65
|
+
readonly commits: number;
|
|
66
|
+
readonly conflicts: number;
|
|
67
|
+
readonly tombstones: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 收集当前记忆库应有的镜像文件。
|
|
71
|
+
*
|
|
72
|
+
* 空项目不产生目录——`records/project/<name>/` 里一个文件都没有时,那个目录本身
|
|
73
|
+
* 不该出现在仓库里(否则每加一个空项目就多一个空目录)。
|
|
74
|
+
*
|
|
75
|
+
* @param memory - 引擎实例。
|
|
76
|
+
* @returns 待写入的记录文件列表。
|
|
77
|
+
*/
|
|
78
|
+
export declare function collectMirror(memory: MemoryEngine): Promise<readonly SyncRecordFile[]>;
|
|
79
|
+
/**
|
|
80
|
+
* 推一次:收集 → 写镜像 → 写墓碑 → 提交 → 推送。
|
|
81
|
+
*
|
|
82
|
+
* @param memory - 引擎实例。
|
|
83
|
+
* @param config - 同步配置。
|
|
84
|
+
* @param now - 时间注入点(测试用)。
|
|
85
|
+
* @returns 各阶段统计;远端失败时保留本地提交已完成的事实。
|
|
86
|
+
*/
|
|
87
|
+
export declare function syncPush(memory: MemoryEngine, config: SyncConfig, now?: number): Promise<SyncPushRun>;
|
|
88
|
+
/**
|
|
89
|
+
* 拉一次:拉取 → 规划导入 → 逐条落地。
|
|
90
|
+
*
|
|
91
|
+
* 冲突不自动裁决:两边版本都写进 `conflicts/`,本地版本保留(§3.3)。
|
|
92
|
+
*
|
|
93
|
+
* @param memory - 引擎实例。
|
|
94
|
+
* @param config - 同步配置。
|
|
95
|
+
* @param now - 时间注入点。
|
|
96
|
+
* @returns 导入统计。
|
|
97
|
+
*/
|
|
98
|
+
export declare function syncPull(memory: MemoryEngine, config: SyncConfig, now?: number): Promise<SyncPullRun>;
|
|
99
|
+
/**
|
|
100
|
+
* 查同步状态。
|
|
101
|
+
*
|
|
102
|
+
* @param config - 同步配置。
|
|
103
|
+
* @returns 镜像目录、最近提交、冲突与墓碑数量。
|
|
104
|
+
*/
|
|
105
|
+
export declare function syncStatus(config: SyncConfig): Promise<SyncStatus>;
|
|
106
|
+
/** 便于测试:导出一份当前冲突的快照。 */
|
|
107
|
+
export declare function conflictFiles(config: SyncConfig): string[];
|
|
108
|
+
export type { ImportConflict };
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步编排:把导出 / 写盘 / 墓碑 / git / 远端五层串成一次操作。
|
|
3
|
+
*
|
|
4
|
+
* 这一层没有自己的算法,只有**顺序**。顺序在这里是有意义的:
|
|
5
|
+
*
|
|
6
|
+
* push 时先写镜像与墓碑、再提交、最后推送——反过来的话,一次失败会留下
|
|
7
|
+
* 「远端已有、本地没有」的状态,下次 push 会把差异当成删除。
|
|
8
|
+
*
|
|
9
|
+
* pull 时先拉、再规划导入、最后逐条落地——导入是唯一会改本地记忆库的一步,
|
|
10
|
+
* 放在最后意味着前面任何一步失败都不会动数据。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/sync-service
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { exportRecords } from "./sync-export.js";
|
|
17
|
+
import { commitMirror, readHistory } from "./sync-git.js";
|
|
18
|
+
import { planImport } from "./sync-import.js";
|
|
19
|
+
import { SYNC_STATE_FILE, TOMBSTONE_DIR, dropTombstoned, planTombstones } from "./sync-tombstone.js";
|
|
20
|
+
import { ensureMirrorRepo, ensureRemote, pullMirror, pushMirror } from "./sync-remote.js";
|
|
21
|
+
import { writeMirror } from "./sync-writer.js";
|
|
22
|
+
/** 作者信息——isomorphic-git 没有可读的全局配置,必须显式给。 */
|
|
23
|
+
const AUTHOR = { name: 'dsh-memory', email: 'dsh-memory@localhost' };
|
|
24
|
+
/** 把一份 unit 文档里的 block 取出来。 */
|
|
25
|
+
function blocksOfDocument(document) {
|
|
26
|
+
const doc = document;
|
|
27
|
+
return doc?.tables?.blocks ?? {};
|
|
28
|
+
}
|
|
29
|
+
/** 记录数组 → unit 文档形状(`exportRecords` 的入参)。 */
|
|
30
|
+
function recordsToDocument(records) {
|
|
31
|
+
const blocks = {};
|
|
32
|
+
for (const record of records) {
|
|
33
|
+
const { id, ...rest } = record;
|
|
34
|
+
blocks[id] = rest;
|
|
35
|
+
}
|
|
36
|
+
return { tables: { blocks } };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 收集当前记忆库应有的镜像文件。
|
|
40
|
+
*
|
|
41
|
+
* 空项目不产生目录——`records/project/<name>/` 里一个文件都没有时,那个目录本身
|
|
42
|
+
* 不该出现在仓库里(否则每加一个空项目就多一个空目录)。
|
|
43
|
+
*
|
|
44
|
+
* @param memory - 引擎实例。
|
|
45
|
+
* @returns 待写入的记录文件列表。
|
|
46
|
+
*/
|
|
47
|
+
export async function collectMirror(memory) {
|
|
48
|
+
const global = await memory.list({ namespace: 'global' });
|
|
49
|
+
const projectDocuments = [];
|
|
50
|
+
for (const cwd of memory.projectCwds()) {
|
|
51
|
+
const records = await memory.list({ namespace: 'project' }, cwd);
|
|
52
|
+
if (records.length === 0)
|
|
53
|
+
continue;
|
|
54
|
+
projectDocuments.push({ cwd, document: recordsToDocument(records) });
|
|
55
|
+
}
|
|
56
|
+
return exportRecords({ globalDocument: recordsToDocument(global), projectDocuments }).files;
|
|
57
|
+
}
|
|
58
|
+
/** 从镜像文件列表推出「当前有哪些 id」——墓碑比对的当前态。 */
|
|
59
|
+
function stateOfFiles(files) {
|
|
60
|
+
const global = [];
|
|
61
|
+
const project = {};
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const parts = file.path.replace(/\\/gu, '/').split('/');
|
|
64
|
+
// records/global/<id>.json 或 records/project/<name>/<id>.json
|
|
65
|
+
if (parts[0] !== 'records' || parts.length < 3)
|
|
66
|
+
continue;
|
|
67
|
+
const id = parts[parts.length - 1]?.replace(/\.json$/u, '') ?? '';
|
|
68
|
+
if (id === '')
|
|
69
|
+
continue;
|
|
70
|
+
if (parts[1] === 'global')
|
|
71
|
+
global.push(id);
|
|
72
|
+
else if (parts[1] === 'project' && parts.length >= 4) {
|
|
73
|
+
const name = parts[2] ?? '';
|
|
74
|
+
(project[name] ??= []).push(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { global, project };
|
|
78
|
+
}
|
|
79
|
+
/** 读上一次同步留下的状态;没有时返回 `undefined`(首次同步)。 */
|
|
80
|
+
function readState(dir) {
|
|
81
|
+
const file = join(dir, SYNC_STATE_FILE);
|
|
82
|
+
if (!existsSync(file))
|
|
83
|
+
return undefined;
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
86
|
+
return {
|
|
87
|
+
global: Array.isArray(parsed.global) ? parsed.global : [],
|
|
88
|
+
project: typeof parsed.project === 'object' && parsed.project !== null ? parsed.project : {},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// 坏了就当没有:宁可这轮不产生墓碑,也不要基于坏数据删东西。
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** 写状态文件。 */
|
|
97
|
+
function writeState(dir, state) {
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
writeFileSync(join(dir, SYNC_STATE_FILE), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
100
|
+
}
|
|
101
|
+
/** 把墓碑落成文件(`tombstones/<id>.json`)。 */
|
|
102
|
+
function writeTombstones(dir, tombstones) {
|
|
103
|
+
if (tombstones.length === 0)
|
|
104
|
+
return;
|
|
105
|
+
const target = join(dir, TOMBSTONE_DIR);
|
|
106
|
+
mkdirSync(target, { recursive: true });
|
|
107
|
+
for (const tombstone of tombstones) {
|
|
108
|
+
writeFileSync(join(target, `${tombstone.id}.json`), `${JSON.stringify(tombstone, null, 2)}\n`, 'utf8');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* 推一次:收集 → 写镜像 → 写墓碑 → 提交 → 推送。
|
|
113
|
+
*
|
|
114
|
+
* @param memory - 引擎实例。
|
|
115
|
+
* @param config - 同步配置。
|
|
116
|
+
* @param now - 时间注入点(测试用)。
|
|
117
|
+
* @returns 各阶段统计;远端失败时保留本地提交已完成的事实。
|
|
118
|
+
*/
|
|
119
|
+
export async function syncPush(memory, config, now = Date.now()) {
|
|
120
|
+
// 首次同步**必须先 clone**:本地 `git init` 出来的仓库与远端是两条无关的历史线,
|
|
121
|
+
// push 会被拒(`not a simple fast-forward`)。这一步得在写镜像之前,否则写好的内容
|
|
122
|
+
// 会被随后的 clone 覆盖掉。
|
|
123
|
+
if (config.remote !== undefined && config.remote !== '' && !existsSync(join(config.mirrorDir, '.git'))) {
|
|
124
|
+
const ready = await ensureMirrorRepo({
|
|
125
|
+
dir: config.mirrorDir,
|
|
126
|
+
url: config.remote,
|
|
127
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
128
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
129
|
+
});
|
|
130
|
+
if (!ready.cloned) {
|
|
131
|
+
return {
|
|
132
|
+
files: 0,
|
|
133
|
+
written: 0,
|
|
134
|
+
unchanged: 0,
|
|
135
|
+
removed: 0,
|
|
136
|
+
tombstones: 0,
|
|
137
|
+
committed: false,
|
|
138
|
+
pushed: false,
|
|
139
|
+
error: `首次同步无法 clone 远端:${ready.error ?? '未知原因'}`,
|
|
140
|
+
...ready.kind === undefined ? {} : { errorKind: ready.kind },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const files = await collectMirror(memory);
|
|
145
|
+
const write = writeMirror({ targetDir: config.mirrorDir, files });
|
|
146
|
+
// 墓碑:用「上次状态」与「本次要写的 id」对比。首次同步没有上次状态 → 不产生墓碑。
|
|
147
|
+
const previous = readState(config.mirrorDir);
|
|
148
|
+
const current = stateOfFiles(files);
|
|
149
|
+
const tombstones = planTombstones({ previous, current, at: now });
|
|
150
|
+
writeTombstones(config.mirrorDir, tombstones);
|
|
151
|
+
writeState(config.mirrorDir, dropTombstoned(current, tombstones));
|
|
152
|
+
const commit = await commitMirror({
|
|
153
|
+
dir: config.mirrorDir,
|
|
154
|
+
message: `sync: ${files.length} 条记录${tombstones.length > 0 ? `,${tombstones.length} 条墓碑` : ''}`,
|
|
155
|
+
author: AUTHOR,
|
|
156
|
+
});
|
|
157
|
+
if (config.remote === undefined || config.remote === '') {
|
|
158
|
+
return {
|
|
159
|
+
files: files.length,
|
|
160
|
+
written: write.written,
|
|
161
|
+
unchanged: write.unchanged,
|
|
162
|
+
removed: write.removed,
|
|
163
|
+
tombstones: tombstones.length,
|
|
164
|
+
committed: commit.committed,
|
|
165
|
+
...commit.sha === undefined ? {} : { sha: commit.sha },
|
|
166
|
+
pushed: false,
|
|
167
|
+
error: '未配置远端地址(memory.sync.remote)—— 已完成本地提交',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
await ensureRemote(config.mirrorDir, config.remote);
|
|
171
|
+
const remote = await pushMirror({
|
|
172
|
+
dir: config.mirrorDir,
|
|
173
|
+
url: config.remote,
|
|
174
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
175
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
files: files.length,
|
|
179
|
+
written: write.written,
|
|
180
|
+
unchanged: write.unchanged,
|
|
181
|
+
removed: write.removed,
|
|
182
|
+
tombstones: tombstones.length,
|
|
183
|
+
committed: commit.committed,
|
|
184
|
+
...commit.sha === undefined ? {} : { sha: commit.sha },
|
|
185
|
+
pushed: remote.ok,
|
|
186
|
+
...remote.error === undefined ? {} : { error: remote.error },
|
|
187
|
+
...remote.kind === undefined ? {} : { errorKind: remote.kind },
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/** 读镜像目录里的记录文件。 */
|
|
191
|
+
function readIncoming(mirrorDir) {
|
|
192
|
+
const root = join(mirrorDir, 'records');
|
|
193
|
+
if (!existsSync(root))
|
|
194
|
+
return [];
|
|
195
|
+
const incoming = [];
|
|
196
|
+
const walk = (dir, segments) => {
|
|
197
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
198
|
+
if (entry.isDirectory()) {
|
|
199
|
+
walk(join(dir, entry.name), [...segments, entry.name]);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!entry.name.endsWith('.json'))
|
|
203
|
+
continue;
|
|
204
|
+
const id = entry.name.replace(/\.json$/u, '');
|
|
205
|
+
try {
|
|
206
|
+
const block = JSON.parse(readFileSync(join(dir, entry.name), 'utf8'));
|
|
207
|
+
if (segments[0] === 'global')
|
|
208
|
+
incoming.push({ id, scope: 'global', block });
|
|
209
|
+
else if (segments[0] === 'project' && segments.length >= 2) {
|
|
210
|
+
incoming.push({ id, scope: 'project', projectName: segments[1] ?? '', block });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// 坏文件跳过:一条损坏不该挡住整次拉取。
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
walk(root, []);
|
|
219
|
+
return incoming;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* 拉一次:拉取 → 规划导入 → 逐条落地。
|
|
223
|
+
*
|
|
224
|
+
* 冲突不自动裁决:两边版本都写进 `conflicts/`,本地版本保留(§3.3)。
|
|
225
|
+
*
|
|
226
|
+
* @param memory - 引擎实例。
|
|
227
|
+
* @param config - 同步配置。
|
|
228
|
+
* @param now - 时间注入点。
|
|
229
|
+
* @returns 导入统计。
|
|
230
|
+
*/
|
|
231
|
+
export async function syncPull(memory, config, now = Date.now()) {
|
|
232
|
+
if (config.remote !== undefined && config.remote !== '') {
|
|
233
|
+
// 同 push:首次必须先 clone,否则本地 init 出来的仓库与远端没有共同祖先。
|
|
234
|
+
if (!existsSync(join(config.mirrorDir, '.git'))) {
|
|
235
|
+
const ready = await ensureMirrorRepo({
|
|
236
|
+
dir: config.mirrorDir,
|
|
237
|
+
url: config.remote,
|
|
238
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
239
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
240
|
+
});
|
|
241
|
+
if (!ready.cloned) {
|
|
242
|
+
return {
|
|
243
|
+
pulled: false,
|
|
244
|
+
imported: 0,
|
|
245
|
+
conflicts: 0,
|
|
246
|
+
orphaned: 0,
|
|
247
|
+
error: `首次同步无法 clone 远端:${ready.error ?? '未知原因'}`,
|
|
248
|
+
...ready.kind === undefined ? {} : { errorKind: ready.kind },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
await ensureRemote(config.mirrorDir, config.remote);
|
|
253
|
+
const remote = await pullMirror({
|
|
254
|
+
dir: config.mirrorDir,
|
|
255
|
+
url: config.remote,
|
|
256
|
+
...config.branch === undefined ? {} : { branch: config.branch },
|
|
257
|
+
...config.token === undefined ? {} : { token: config.token },
|
|
258
|
+
});
|
|
259
|
+
if (!remote.ok) {
|
|
260
|
+
return {
|
|
261
|
+
pulled: false,
|
|
262
|
+
imported: 0,
|
|
263
|
+
conflicts: 0,
|
|
264
|
+
orphaned: 0,
|
|
265
|
+
...remote.error === undefined ? {} : { error: remote.error },
|
|
266
|
+
...remote.kind === undefined ? {} : { errorKind: remote.kind },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const incoming = readIncoming(config.mirrorDir);
|
|
271
|
+
// 本地各层现状
|
|
272
|
+
const globalRecords = await memory.list({ namespace: 'global' });
|
|
273
|
+
const localGlobal = { blocks: blocksOfDocument(recordsToDocument(globalRecords)) };
|
|
274
|
+
const localProjects = {};
|
|
275
|
+
for (const cwd of memory.projectCwds()) {
|
|
276
|
+
const name = (cwd.replace(/\\/gu, '/').split('/').pop() ?? '').toLowerCase();
|
|
277
|
+
if (name === '')
|
|
278
|
+
continue;
|
|
279
|
+
const records = await memory.list({ namespace: 'project' }, cwd);
|
|
280
|
+
localProjects[name] = { blocks: blocksOfDocument(recordsToDocument(records)) };
|
|
281
|
+
}
|
|
282
|
+
const plan = planImport({ incoming, localGlobal, localProjects });
|
|
283
|
+
// 冲突留证:两边都写进 conflicts/
|
|
284
|
+
if (plan.conflicts.length > 0) {
|
|
285
|
+
const dir = join(config.mirrorDir, 'conflicts');
|
|
286
|
+
mkdirSync(dir, { recursive: true });
|
|
287
|
+
for (const conflict of plan.conflicts) {
|
|
288
|
+
const env = config.envName ?? 'local';
|
|
289
|
+
writeFileSync(join(dir, `${conflict.id}.${env}.json`), `${JSON.stringify(conflict, null, 2)}\n`, 'utf8');
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// 逐条落地(唯一会改本地记忆库的一步)
|
|
293
|
+
let imported = 0;
|
|
294
|
+
for (const upsert of plan.upserts) {
|
|
295
|
+
const cwd = upsert.scope === 'project' && upsert.projectName !== undefined
|
|
296
|
+
? memory.projectCwds().find(candidate => (candidate.replace(/\\/gu, '/').split('/').pop() ?? '').toLowerCase() === upsert.projectName?.toLowerCase())
|
|
297
|
+
: undefined;
|
|
298
|
+
if (upsert.scope === 'project' && cwd === undefined)
|
|
299
|
+
continue;
|
|
300
|
+
try {
|
|
301
|
+
await memory.adopt(upsert.id, upsert.block, cwd);
|
|
302
|
+
imported += 1;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
// 单条失败跳过——一条不合规不该挡住整次拉取。
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
pulled: true,
|
|
310
|
+
imported,
|
|
311
|
+
conflicts: plan.conflicts.length,
|
|
312
|
+
orphaned: plan.orphaned.length,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* 查同步状态。
|
|
317
|
+
*
|
|
318
|
+
* @param config - 同步配置。
|
|
319
|
+
* @returns 镜像目录、最近提交、冲突与墓碑数量。
|
|
320
|
+
*/
|
|
321
|
+
export async function syncStatus(config) {
|
|
322
|
+
const initialized = existsSync(join(config.mirrorDir, '.git'));
|
|
323
|
+
const history = initialized ? await readHistory(config.mirrorDir) : [];
|
|
324
|
+
const conflictsDir = join(config.mirrorDir, 'conflicts');
|
|
325
|
+
const tombstonesDir = join(config.mirrorDir, TOMBSTONE_DIR);
|
|
326
|
+
const countFiles = (dir) => {
|
|
327
|
+
if (!existsSync(dir))
|
|
328
|
+
return 0;
|
|
329
|
+
return readdirSync(dir).filter(name => name.endsWith('.json')).length;
|
|
330
|
+
};
|
|
331
|
+
return {
|
|
332
|
+
mirrorDir: config.mirrorDir,
|
|
333
|
+
initialized,
|
|
334
|
+
...config.remote === undefined ? {} : { remote: config.remote },
|
|
335
|
+
...history[0] === undefined ? {} : { lastCommit: history[0] },
|
|
336
|
+
commits: history.length,
|
|
337
|
+
conflicts: countFiles(conflictsDir),
|
|
338
|
+
tombstones: countFiles(tombstonesDir),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
/** 便于测试:导出一份当前冲突的快照。 */
|
|
342
|
+
export function conflictFiles(config) {
|
|
343
|
+
const dir = join(config.mirrorDir, 'conflicts');
|
|
344
|
+
if (!existsSync(dir))
|
|
345
|
+
return [];
|
|
346
|
+
return readdirSync(dir).filter(name => name.endsWith('.json'));
|
|
347
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步墓碑:把 `memory_forget` 的真删表达成「记录被修改」,而不是「文件消失」。
|
|
3
|
+
*
|
|
4
|
+
* **为什么需要**(设计 §3.1):多写者下,本地看着「git 里有、我这儿没有」有两种可能——
|
|
5
|
+
* 本机删了(该删远端),或另一端刚加、我还没拉到(该拉下来)。两种情况的正确动作**完全
|
|
6
|
+
* 相反**,靠猜一定出错。
|
|
7
|
+
*
|
|
8
|
+
* 区分它们的唯一依据是**上一次同步时本机有哪些 id**:曾同步过、现在没了 ⇒ 本机删的。
|
|
9
|
+
* 所以本模块配一个随仓库走的 `.sync-state.json`。
|
|
10
|
+
*
|
|
11
|
+
* **墓碑不删文件、只改文件**,还顺带避开 git 里最难处理的 modify/delete 冲突:删除与
|
|
12
|
+
* 「另一端改了同一条」于是变成同一类冲突。
|
|
13
|
+
*
|
|
14
|
+
* **不记删前内容**:内容在 git 历史里(文件被删除但仍在历史),墓碑只负责回答
|
|
15
|
+
* 「这条是被删的还是还没同步到」。省下的体积换的是快照体积可控。
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-memory/sync-tombstone
|
|
18
|
+
*/
|
|
19
|
+
/** 随同步仓库走的本地状态。 */
|
|
20
|
+
export interface SyncState {
|
|
21
|
+
/** 上次同步时 global 层有哪些 id。 */
|
|
22
|
+
readonly global: readonly string[];
|
|
23
|
+
/** 上次同步时各项目有哪些 id(项目名已小写)。 */
|
|
24
|
+
readonly project: Readonly<Record<string, readonly string[]>>;
|
|
25
|
+
}
|
|
26
|
+
/** 一条墓碑。 */
|
|
27
|
+
export interface Tombstone {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly scope: 'global' | 'project';
|
|
30
|
+
/** `project` 作用域下的项目名。 */
|
|
31
|
+
readonly projectName?: string;
|
|
32
|
+
readonly deletedAt: number;
|
|
33
|
+
}
|
|
34
|
+
/** {@link planTombstones} 的入参。 */
|
|
35
|
+
export interface TombstonePlanOptions {
|
|
36
|
+
/** 上一次同步留下的状态;首次同步时为 `undefined`。 */
|
|
37
|
+
readonly previous: SyncState | undefined;
|
|
38
|
+
/** 当前本地各层实际有哪些 id。 */
|
|
39
|
+
readonly current: SyncState;
|
|
40
|
+
/** 删除时刻(epoch ms);测试可注入。 */
|
|
41
|
+
readonly at: number;
|
|
42
|
+
}
|
|
43
|
+
/** 状态文件在同步仓库里的位置。 */
|
|
44
|
+
export declare const SYNC_STATE_FILE = ".sync-state.json";
|
|
45
|
+
/** 状态文件的相对路径(含墓碑目录)。 */
|
|
46
|
+
export declare const TOMBSTONE_DIR = "tombstones";
|
|
47
|
+
/**
|
|
48
|
+
* 对比上一次与当前的 id 集合,算出本次该写哪些墓碑。
|
|
49
|
+
*
|
|
50
|
+
* **首次同步(`previous` 为空)永远不产生墓碑**:那时没有「曾经有过」这个信息,
|
|
51
|
+
* 把所有"远端有、本地没有"都判成删除会把另一端刚记的东西全删掉。这是整个模块最容易
|
|
52
|
+
* 写错的一处。
|
|
53
|
+
*
|
|
54
|
+
* @param options - 上次状态、当前状态与删除时刻。
|
|
55
|
+
* @returns 待写墓碑列表。
|
|
56
|
+
*/
|
|
57
|
+
export declare function planTombstones(options: TombstonePlanOptions): Tombstone[];
|
|
58
|
+
/**
|
|
59
|
+
* 把墓碑合并进上一次的状态:写过的墓碑不该在下一轮又被判成"删除"。
|
|
60
|
+
*
|
|
61
|
+
* 具体做法是把墓碑 id 从 `previous` 里剔掉——它们的删除已经被记录过了。
|
|
62
|
+
*
|
|
63
|
+
* @param state - 刚刚用于比对的那份状态。
|
|
64
|
+
* @param written - 本次写下的墓碑。
|
|
65
|
+
* @returns 更新后的状态。
|
|
66
|
+
*/
|
|
67
|
+
export declare function dropTombstoned(state: SyncState, written: readonly Tombstone[]): SyncState;
|