@yandy0725/memory-core 2.0.1 → 2.1.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/dist/config.d.ts +66 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +69 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +28 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -5
- package/dist/index.js.map +1 -1
- package/dist/injection.d.ts.map +1 -1
- package/dist/injection.js +1 -1
- package/dist/injection.js.map +1 -1
- package/dist/orchestration/conversation.d.ts.map +1 -1
- package/dist/orchestration/conversation.js +1 -2
- package/dist/orchestration/conversation.js.map +1 -1
- package/dist/orchestration/ports.d.ts.map +1 -1
- package/dist/orchestration/task.d.ts.map +1 -1
- package/dist/orchestration/task.js +1 -1
- package/dist/orchestration/task.js.map +1 -1
- package/dist/store/filename.d.ts +43 -0
- package/dist/store/filename.d.ts.map +1 -1
- package/dist/store/filename.js +72 -0
- package/dist/store/filename.js.map +1 -1
- package/dist/store/fs-lock.d.ts.map +1 -1
- package/dist/store/fs-lock.js +1 -3
- package/dist/store/fs-lock.js.map +1 -1
- package/dist/store/memory-store.d.ts +37 -0
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +169 -17
- package/dist/store/memory-store.js.map +1 -1
- package/dist/store/paths.d.ts.map +1 -1
- package/dist/store/paths.js +8 -2
- package/dist/store/paths.js.map +1 -1
- package/dist/store/process-lock.d.ts.map +1 -1
- package/dist/store/process-lock.js.map +1 -1
- package/dist/sync/backend-git.d.ts +27 -0
- package/dist/sync/backend-git.d.ts.map +1 -0
- package/dist/sync/backend-git.js +815 -0
- package/dist/sync/backend-git.js.map +1 -0
- package/dist/sync/backend.d.ts +213 -0
- package/dist/sync/backend.d.ts.map +1 -0
- package/dist/sync/backend.js +31 -0
- package/dist/sync/backend.js.map +1 -0
- package/dist/sync/conflict.d.ts +74 -0
- package/dist/sync/conflict.d.ts.map +1 -0
- package/dist/sync/conflict.js +164 -0
- package/dist/sync/conflict.js.map +1 -0
- package/dist/sync/device.d.ts +45 -0
- package/dist/sync/device.d.ts.map +1 -0
- package/dist/sync/device.js +227 -0
- package/dist/sync/device.js.map +1 -0
- package/dist/sync/diff.d.ts +78 -0
- package/dist/sync/diff.d.ts.map +1 -0
- package/dist/sync/diff.js +115 -0
- package/dist/sync/diff.js.map +1 -0
- package/dist/sync/engine.d.ts +142 -0
- package/dist/sync/engine.d.ts.map +1 -0
- package/dist/sync/engine.js +960 -0
- package/dist/sync/engine.js.map +1 -0
- package/dist/sync/hash.d.ts +11 -0
- package/dist/sync/hash.d.ts.map +1 -0
- package/dist/sync/hash.js +14 -0
- package/dist/sync/hash.js.map +1 -0
- package/dist/sync/manifest.d.ts +63 -0
- package/dist/sync/manifest.d.ts.map +1 -0
- package/dist/sync/manifest.js +117 -0
- package/dist/sync/manifest.js.map +1 -0
- package/dist/sync/paths.d.ts +70 -0
- package/dist/sync/paths.d.ts.map +1 -0
- package/dist/sync/paths.js +111 -0
- package/dist/sync/paths.js.map +1 -0
- package/dist/sync/scan.d.ts +44 -0
- package/dist/sync/scan.d.ts.map +1 -0
- package/dist/sync/scan.js +102 -0
- package/dist/sync/scan.js.map +1 -0
- package/dist/sync/scheduler.d.ts +81 -0
- package/dist/sync/scheduler.d.ts.map +1 -0
- package/dist/sync/scheduler.js +183 -0
- package/dist/sync/scheduler.js.map +1 -0
- package/dist/sync/types.d.ts +21 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +8 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/tool/handler.d.ts.map +1 -1
- package/dist/tool/handler.js +1 -3
- package/dist/tool/handler.js.map +1 -1
- package/package.json +13 -3
- package/src/config.ts +121 -0
- package/src/index.ts +101 -5
- package/src/injection.ts +1 -3
- package/src/orchestration/conversation.ts +1 -2
- package/src/orchestration/dream.ts +0 -1
- package/src/orchestration/extract.ts +0 -1
- package/src/orchestration/ports.ts +1 -5
- package/src/orchestration/prompts.ts +0 -1
- package/src/orchestration/surfacing.ts +0 -1
- package/src/orchestration/task.ts +2 -10
- package/src/store/filename.ts +71 -0
- package/src/store/fs-lock.ts +2 -6
- package/src/store/memory-store.ts +311 -134
- package/src/store/paths.ts +12 -3
- package/src/store/process-lock.ts +4 -1
- package/src/sync/backend-git.ts +902 -0
- package/src/sync/backend.ts +220 -0
- package/src/sync/conflict.ts +247 -0
- package/src/sync/device.ts +266 -0
- package/src/sync/diff.ts +152 -0
- package/src/sync/engine.ts +1258 -0
- package/src/sync/hash.ts +14 -0
- package/src/sync/manifest.ts +148 -0
- package/src/sync/paths.ts +117 -0
- package/src/sync/scan.ts +115 -0
- package/src/sync/scheduler.ts +241 -0
- package/src/sync/types.ts +22 -0
- package/src/tool/handler.ts +1 -3
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
/** `<syncRoot>/device.json`。`syncRoot` 就是记忆根下的 `.sync` 目录。 */
|
|
6
|
+
export function devicePath(syncRoot) {
|
|
7
|
+
return join(syncRoot, "device.json");
|
|
8
|
+
}
|
|
9
|
+
function isPlainObject(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
/** 只认 `Date#toISOString` 那种形态(我们写的就是它):不是「任意能被 Date 解析的字符串」。 */
|
|
13
|
+
function isIsoInstant(value) {
|
|
14
|
+
if (typeof value !== "string")
|
|
15
|
+
return false;
|
|
16
|
+
const ms = Date.parse(value);
|
|
17
|
+
return !Number.isNaN(ms) && new Date(ms).toISOString() === value;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 身份字段(`id` 与 `hostname`)的长度上限。
|
|
21
|
+
*
|
|
22
|
+
* 这个上限不服务于合法性,只服务于**可读性**:两个字段都会进 git 提交作者与 `status` 文案,
|
|
23
|
+
* 而这两处都按短标识设计。一个几 KB 的身份会让提交头的作者栏与报告第一屏全是它。
|
|
24
|
+
*/
|
|
25
|
+
const MAX_IDENTITY_FIELD_LENGTH = 128;
|
|
26
|
+
/**
|
|
27
|
+
* C0(含 `\n`、`\r`、`\t`、`ESC`)与 DEL、C1 控制字符:逐字写出去的 `ESC[31m` 会在终端里
|
|
28
|
+
* 把状态报告染色(或更糟,清屏),换行会把一行报告、一行提交头拆成两行。
|
|
29
|
+
*/
|
|
30
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching C0/C1 control characters — this id ends up in a git commit author and in status output
|
|
31
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
32
|
+
/**
|
|
33
|
+
* 身份字段的形状**下限**:非空、非纯空白、无控制字符、长度 ≤ 128。
|
|
34
|
+
*
|
|
35
|
+
* 刻意只是下限,**不要求 uuid 形状**:手工改过但仍可用的身份不该被换掉 —— 换掉才是提交作者
|
|
36
|
+
* 漂移的原因,而这一整条链(提交作者、报告)唯一在乎的是「稳定」。所以 `device-a` 这种手写值
|
|
37
|
+
* 照样接受,只把「当不了作者标识」的值挡在外面:空串会让提交作者变空,纯空白会被 git 当作无作者,
|
|
38
|
+
* 控制字符会被 git 拒掉或让报告乱码。
|
|
39
|
+
*/
|
|
40
|
+
function isUsableIdentityField(value) {
|
|
41
|
+
if (typeof value !== "string")
|
|
42
|
+
return false;
|
|
43
|
+
if (value.length === 0 || value.length > MAX_IDENTITY_FIELD_LENGTH)
|
|
44
|
+
return false;
|
|
45
|
+
// trim 之后再判空:`" "` 不是身份,而 `" device-a"` 是(原样保留,不替用户改值)。
|
|
46
|
+
if (value.trim().length === 0)
|
|
47
|
+
return false;
|
|
48
|
+
return !CONTROL_CHARS.test(value);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 把解析出来的 JSON 收窄成 `DeviceIdentity`;任一处不对返回 `null`。
|
|
52
|
+
*
|
|
53
|
+
* 逐个字段查是刻意的:`device.json` 是人可能手改、云盘可能半截落盘的文件,而它是**身份**。
|
|
54
|
+
* 半信半疑地接受一个残缺身份,比重新生成一份更糟 —— 缺 `id` 的身份会让提交作者变空,
|
|
55
|
+
* 缺 `createdAt` 会让报告露馅。`id` / `hostname` 的判据见 `isUsableIdentityField`。
|
|
56
|
+
*/
|
|
57
|
+
function asDeviceIdentity(value) {
|
|
58
|
+
if (!isPlainObject(value))
|
|
59
|
+
return null;
|
|
60
|
+
const { id, hostname: host, createdAt } = value;
|
|
61
|
+
if (!isUsableIdentityField(id))
|
|
62
|
+
return null;
|
|
63
|
+
if (!isUsableIdentityField(host))
|
|
64
|
+
return null;
|
|
65
|
+
if (!isIsoInstant(createdAt))
|
|
66
|
+
return null;
|
|
67
|
+
return { id, hostname: host, createdAt };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 读已存在的身份。
|
|
71
|
+
*
|
|
72
|
+
* **只有 `ENOENT` / `ENOTDIR`(确实没有这个文件)算 `absent`;其他 errno 一律上抛。**
|
|
73
|
+
* 这是数据安全线,不是风格问题:`EACCES` / `EIO` / 网络挂载抖动都不是「没有身份」,把它们
|
|
74
|
+
* 当成「没有」就会走进创建分支、覆盖掉一份合法身份文件,然后返回一个新 `id` ——
|
|
75
|
+
* 提交作者被**静默重铸**,正是本模块注释反对的那件事。抛出去之后调用方至少能看到失败。
|
|
76
|
+
* (`EISDIR` 同理上抛:目标是个目录,靠猜不如报错。)
|
|
77
|
+
*
|
|
78
|
+
* 内容问题(JSON 坏掉、不是对象、字段不全、`createdAt` 形状不对)才算 `invalid`:
|
|
79
|
+
* 那些是「这个文件的字节当不了身份」,重写一份无损。
|
|
80
|
+
*/
|
|
81
|
+
async function readDevice(path) {
|
|
82
|
+
const raw = await readFile(path, "utf8").catch((err) => {
|
|
83
|
+
const code = err.code;
|
|
84
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
85
|
+
return null;
|
|
86
|
+
throw err;
|
|
87
|
+
});
|
|
88
|
+
if (raw === null)
|
|
89
|
+
return { kind: "absent" };
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(raw);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return { kind: "invalid" };
|
|
96
|
+
}
|
|
97
|
+
const device = asDeviceIdentity(parsed);
|
|
98
|
+
return device === null ? { kind: "invalid" } : { kind: "ok", device };
|
|
99
|
+
}
|
|
100
|
+
/** 同目录下的临时文件名:`<path>.tmp-<pid>-<随机>`,不带目录层级,发布因此不会跨卷。 */
|
|
101
|
+
function tempPath(path) {
|
|
102
|
+
return `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* 把身份写进一个临时文件,返回临时文件路径。
|
|
106
|
+
*
|
|
107
|
+
* `wx`:临时名已带 pid + 随机后缀,正常不会撞。真撞上说明有东西在乱写这个目录,
|
|
108
|
+
* 宁可让 `EEXIST` 冒出来,也不要覆盖别人的临时文件。
|
|
109
|
+
*
|
|
110
|
+
* 先写临时文件再发布,是为了「目标名一旦出现就指向写完整的字节」:记忆根可能就是云盘目录 /
|
|
111
|
+
* 网络盘,直接写目标文件会留下「写了一半的 JSON」这一窗口 —— 撞上它的那次启动读不懂 →
|
|
112
|
+
* 重新生成 → 又有了新 `id`,而两台设备的云盘还在互相覆盖这个文件。
|
|
113
|
+
*
|
|
114
|
+
* **这里刻意不抽一个公用的 `writeJsonAtomic`**(已经提过两次了,把决定写在代码里):
|
|
115
|
+
* 本文件要的是「**排他创建** + 发布后读回 + 无硬链接时退回覆盖」三种发布形态,而
|
|
116
|
+
* `manifest.ts` 要的只是「原子替换」(目标名上没有需要保护的东西,`rename` 就够了)。
|
|
117
|
+
* 两套语义合成一个函数必然带 flag,而 flag 恰好会把下面 `createDevice` 里那条论证清楚的
|
|
118
|
+
* 排他性边界(何时排他、何时不排他、不排他时靠什么兼容)掩盖成一个参数默认值。
|
|
119
|
+
* 重复的只是「写临时文件 + `unlink(temp).catch(() => {})`」这几行尾巴,代价比一个带 flag
|
|
120
|
+
* 的公用原语低。
|
|
121
|
+
*/
|
|
122
|
+
async function writeTemp(path, device) {
|
|
123
|
+
await mkdir(dirname(path), { recursive: true });
|
|
124
|
+
const temp = tempPath(path);
|
|
125
|
+
await writeFile(temp, `${JSON.stringify(device, null, "\t")}\n`, { encoding: "utf8", flag: "wx" });
|
|
126
|
+
return temp;
|
|
127
|
+
}
|
|
128
|
+
/** 发布之后再读一遍盘上内容:调用方拿到的必须是「盘上那份」,而不是「我以为我写了什么」。 */
|
|
129
|
+
async function readBack(path) {
|
|
130
|
+
const after = await readDevice(path);
|
|
131
|
+
if (after.kind !== "ok")
|
|
132
|
+
throw new Error(`写完身份文件之后仍读不出身份:${path}`);
|
|
133
|
+
return after.device;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* 「这个文件系统不支持硬链接」的 errno 集合。
|
|
137
|
+
*
|
|
138
|
+
* 记忆根可能就是网络盘 / FUSE 挂载的云盘目录,那里 `link` 可能直接不支持。那种情况下退回
|
|
139
|
+
* `rename`(覆盖语义),而不是让同步一启动就失败。
|
|
140
|
+
*/
|
|
141
|
+
const NO_HARDLINK_CODES = new Set(["EPERM", "EACCES", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EMLINK"]);
|
|
142
|
+
/**
|
|
143
|
+
* 目标还不存在(文件确实没有)时创建身份,并把盘上那份返回。
|
|
144
|
+
*
|
|
145
|
+
* 发布用 `link` 而不是 `rename`,因为 `link` **排他**:目标已存在时它原子地失败(`EEXIST`),
|
|
146
|
+
* 于是并发的两个首次运行里只有一个能挂上自己的身份,另一个立刻回到「读盘上那份」——
|
|
147
|
+
* 两边返回同一个 `id`,与盘上一致。
|
|
148
|
+
*
|
|
149
|
+
* `rename` 是无条件覆盖的:并发的两个首次运行各写各的,败者会把赢者刚挂上去的 id 盖掉,
|
|
150
|
+
* 可赢者早已返回了那个 id,那一轮的提交就挂在一个盘上已经不存在的作者上。
|
|
151
|
+
*
|
|
152
|
+
* 注意这条排他性**只在硬链接可用的文件系统上成立**,而下面那个退回分支正好是部署目标
|
|
153
|
+
* (云盘 / 网络挂载)会走到的地方。
|
|
154
|
+
*/
|
|
155
|
+
async function createDevice(path, device) {
|
|
156
|
+
const temp = await writeTemp(path, device);
|
|
157
|
+
try {
|
|
158
|
+
await link(temp, path);
|
|
159
|
+
return device;
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
const code = err.code ?? "";
|
|
163
|
+
if (code === "EEXIST") {
|
|
164
|
+
// 有人抢先了:以盘上那份为准。它是被 `link` 原子挂上去的整份字节,所以这里读得到;
|
|
165
|
+
// 读不到就抛错,绝不覆盖 —— 覆盖等于把别人的身份换掉。
|
|
166
|
+
return readBack(path);
|
|
167
|
+
}
|
|
168
|
+
if (!NO_HARDLINK_CODES.has(code))
|
|
169
|
+
throw err;
|
|
170
|
+
// 没有硬链接可用:退回 rename 覆盖,再从盘上读回返回值。这里**失去了排他性** —— 两个并发的
|
|
171
|
+
// 首次运行可能各覆盖一次(窗口极窄,且两边写出的都是合法身份,没人会丢掉可用身份)。
|
|
172
|
+
// `readBack` 只是**收窄**不一致窗口,没有关掉它:它保证的是「返回的那一份在读的那一刻
|
|
173
|
+
// 确实在盘上」,但另一个进程可以在读回之后又覆盖一次 —— 于是本轮拿到的 id 与盘上最终的
|
|
174
|
+
// 那一份仍可能不同,那一次的提交作者就对不上盘。这是无硬链接文件系统上的固有残余风险
|
|
175
|
+
//(不是可以再写几行关掉的),与裁定接受的那个窗口相同:两边写的都是合法身份,不会丢身份,
|
|
176
|
+
// 只是提交作者可能漂移一次。它不会把一具自己凭空造出、盘上从未存在过的 id 拿去当作者。
|
|
177
|
+
await rename(temp, path);
|
|
178
|
+
return readBack(path);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
// 两条路径都收尾:`link` 成功后临时名还在,`rename` 之后它已不在(ENOENT 忽略即可)。
|
|
182
|
+
await unlink(temp).catch(() => { });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 文件存在但内容当不了身份(半截落盘的 JSON、手改坏了的字段)时重写一份。
|
|
187
|
+
*
|
|
188
|
+
* 这里是 `rename` 覆盖而不是 `link`:目标名已经被一份**不可用**的字节占着,没有需要保护的
|
|
189
|
+
* 身份,覆盖它是无损的。返回值同样取自盘上(`readBack`),所以「返回的 id 与盘上一致」这条
|
|
190
|
+
* 要求在两条发布路径上都成立。
|
|
191
|
+
*/
|
|
192
|
+
async function replaceBrokenDevice(path, device) {
|
|
193
|
+
const temp = await writeTemp(path, device);
|
|
194
|
+
try {
|
|
195
|
+
await rename(temp, path);
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await unlink(temp).catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
return readBack(path);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* 读这台设备的身份,没有(或读不懂)就生成一份并落盘。
|
|
204
|
+
*
|
|
205
|
+
* **调用方不需要持有根 sync 锁**:本函数自己收敛。并发的首次运行各自生成,而发布在支持硬链接的
|
|
206
|
+
* 文件系统上是**排他**的(见 `createDevice`),所以最终只有一个 `id` 落盘,两个调用方都返回它 ——
|
|
207
|
+
* 不会有人返回一个盘上已不存在、却已经被拿去当提交作者的 id。
|
|
208
|
+
*
|
|
209
|
+
* **云盘 / 网络挂载上这条排他性不成立**(那里 `link` 往往直接不支持,`createDevice` 退回 `rename`):
|
|
210
|
+
* 两个并发的首次运行可能各覆盖一次,于是本轮拿到的 `id` 与盘上最终那份可能不同,后果是那**一次**
|
|
211
|
+
* 提交的作者对不上盘(身份本身不会丢、也不会变成两份,下一轮开始就稳定了)。这是故意接受的残余
|
|
212
|
+
* 风险:两条发布路径写出的都是合法身份,而加锁也治不了它 —— 根 sync 锁是 try 语义、允许被跳过的,
|
|
213
|
+
* 靠它保证身份的稳定性并不成立。
|
|
214
|
+
*
|
|
215
|
+
* `now` 只为测试能固定 `createdAt`;生产调用不需要传。`syncRoot` 可能还不存在
|
|
216
|
+
* (首次同步时 `.sync` 目录可能还没人建过),这里自己 `mkdir`。
|
|
217
|
+
*/
|
|
218
|
+
export async function loadOrCreateDevice(syncRoot, now = () => new Date()) {
|
|
219
|
+
const path = devicePath(syncRoot);
|
|
220
|
+
await mkdir(syncRoot, { recursive: true });
|
|
221
|
+
const current = await readDevice(path);
|
|
222
|
+
if (current.kind === "ok")
|
|
223
|
+
return current.device;
|
|
224
|
+
const created = { id: randomUUID(), hostname: hostname(), createdAt: now().toISOString() };
|
|
225
|
+
return current.kind === "absent" ? createDevice(path, created) : replaceBrokenDevice(path, created);
|
|
226
|
+
}
|
|
227
|
+
//# sourceMappingURL=device.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device.js","sourceRoot":"","sources":["../../src/sync/device.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { SyncFileInfo, SyncFileSet } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 一条冲突:同一个 entry 文件名在两侧都「有话说」且内容不同。
|
|
4
|
+
*
|
|
5
|
+
* `null` = 这一侧已经删除(删除 vs 修改的冲突)。两侧不可能同时为 null —— 那是「两边都删」,
|
|
6
|
+
* 属于无动作行,不进 conflicts。
|
|
7
|
+
* **不要用哨兵值**(例如 `hash: ""`)表示删除:冲突收敛与同步引擎都要判这一侧,
|
|
8
|
+
* 显式 null 让 TypeScript 强制每个消费者处理它,哨兵值只靠约定 —— 约定错一次就会静默吞掉用户的删除,
|
|
9
|
+
* 让那条记忆在两台设备上一起复活。
|
|
10
|
+
*/
|
|
11
|
+
export interface SyncConflict {
|
|
12
|
+
/** entry 文件名(单层,与 `SyncFileSet` 的 key 同口径)。 */
|
|
13
|
+
file: string;
|
|
14
|
+
/** `null` = 本地这一侧已删除。 */
|
|
15
|
+
ours: SyncFileInfo | null;
|
|
16
|
+
/** `null` = 远端这一侧已删除。 */
|
|
17
|
+
theirs: SyncFileInfo | null;
|
|
18
|
+
}
|
|
19
|
+
/** 一次三方合并的完整结论:四张单侧动作清单,外加要交给冲突收敛的那些路径。 */
|
|
20
|
+
export interface SyncDiff {
|
|
21
|
+
/** 远端胜:要用远端内容写入本地。 */
|
|
22
|
+
pullFromRemote: string[];
|
|
23
|
+
/** 本地胜:要发布到远端。 */
|
|
24
|
+
pushLocal: string[];
|
|
25
|
+
/** 本地要删。 */
|
|
26
|
+
deleteLocal: string[];
|
|
27
|
+
/** 远端要删。 */
|
|
28
|
+
deleteRemote: string[];
|
|
29
|
+
/** 交给冲突收敛。 */
|
|
30
|
+
conflicts: SyncConflict[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 三方差集:同步的合并语义全部集中在这一个函数里。
|
|
34
|
+
*
|
|
35
|
+
* 三路输入:`base` 是上一次成功同步时记下的清单(首次同步为空集),`ours` 是本地此刻的扫描结果,
|
|
36
|
+
* `theirs` 是远端此刻的状态。输出的是**动作**而不是结果:谁来写盘、谁来发布由调用方决定。
|
|
37
|
+
*
|
|
38
|
+
* 判据只有 `hash`。`modified` 与 `bytes` 在这里不参与比较 —— 它们是载荷,留给冲突收敛去决定
|
|
39
|
+
* 「谁留在原路径」;把时间戳拉进比较会让同一份内容因读取时刻不同而永远判不出「没变」。
|
|
40
|
+
*
|
|
41
|
+
* 逐路径的完整规则(「同」= hash 与 `base` 相等,「改」= 不相等):
|
|
42
|
+
* - 两侧当前内容一致 → 无动作。包括两侧都删了、两侧都改成了同一份内容:后者让「两台设备各自跑了
|
|
43
|
+
* 同一次整理」成为无动作,而不是凭空生出两份互相冲突的副本。
|
|
44
|
+
* - `base` 里没有、只有一侧有 → 那一侧是新增:本地有就推,远端有就拉。
|
|
45
|
+
* - `base` 里有、一侧删了、另一侧没动 → 删除传播过去:本地删了删远端,远端删了删本地。
|
|
46
|
+
* - `base` 里有、一侧删了、另一侧改了 → 冲突:删除照办,被改的那份由冲突收敛按新 entry 留下,
|
|
47
|
+
* 没有任何一方丢内容。这条的删除半边不在这四张清单里 —— 原路径的删与留由冲突收敛一并决定,
|
|
48
|
+
* 在这里先删一次会让原路径被「删掉又写回」。
|
|
49
|
+
* - 两侧都在:只有一侧相对 `base` 变了 → 变了的那侧胜(本地变则推,远端变则拉);两侧都变了 → 冲突。
|
|
50
|
+
*
|
|
51
|
+
* 纯函数:零 IO、不读文件、不认识锁与 backend、不改动任何入参(连返回的数组都是新建的)。
|
|
52
|
+
*
|
|
53
|
+
* **这里是一张纯真值表,不保护任何读不出的名字。** 一个文件在本地解析不出/读不了时,扫描不会把它放进
|
|
54
|
+
* `ours`(它落在 `unparsable` 里),于是这里只读到「这一侧没有它」,走上面「二」的 `!inOurs` 分支。
|
|
55
|
+
* 这样的名字落在哪一条,取决于它在 `base` 里有没有(前两条要求有,第三条恰恰要求没有):
|
|
56
|
+
* - `base` 里有、远端没动 → `deleteRemote`:判成**本地删除**,删除传到远端 —— 一个坏文件就能删掉两台
|
|
57
|
+
* 设备上的同一条记忆,而本地那份还躺在盘上;
|
|
58
|
+
* - `base` 里有、远端改过 → `conflicts`(`ours: null`):判成**「删 vs 改」冲突**,而删除那半边是照办的 ——
|
|
59
|
+
* `resolveConflict` 对单侧删除返回 `winnerRaw: null`,引擎据此把原路径推入 `plan.deletes`,于是
|
|
60
|
+
* **读不出的本地文件从原路径被删掉**,远端那份字节只以一个派生的 `… (冲突 …)` 名字留下,
|
|
61
|
+
* 原件只能从 `-sync` 快照里捞回来;
|
|
62
|
+
* - `base` 里没有、远端有 → `pullFromRemote`:判成**远端新增**,直接盖掉同名的本地文件。
|
|
63
|
+
* 所以调用方必须自己把这类名字从**全部五张清单**
|
|
64
|
+
* (`pullFromRemote`/`pushLocal`/`deleteLocal`/`deleteRemote`/`conflicts`)里剔除,只报告、不动手 ——
|
|
65
|
+
* 这个函数没有、也不该有判断「哪些名字读不出」的信息。
|
|
66
|
+
*
|
|
67
|
+
* 四张清单都按文件名码点序排序;`conflicts` 同样按文件名排,让调用方的落盘与上报顺序也确定。
|
|
68
|
+
*/
|
|
69
|
+
export declare function diffThreeWay(base: SyncFileSet, ours: SyncFileSet, theirs: SyncFileSet): SyncDiff;
|
|
70
|
+
/**
|
|
71
|
+
* 本轮是否无事可做。
|
|
72
|
+
*
|
|
73
|
+
* 冲突也算「有事」:它要么产出磁盘写入(新 entry + 原路径的内容变更),要么至少要报给用户,
|
|
74
|
+
* 所以空差集必须是五个字段全空 —— 只看四张清单会让「只剩冲突」的一轮被当成无事发生,
|
|
75
|
+
* 静默吞掉冲突。
|
|
76
|
+
*/
|
|
77
|
+
export declare function isEmptyDiff(diff: SyncDiff): boolean;
|
|
78
|
+
//# sourceMappingURL=diff.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../../src/sync/diff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE5D;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAY;IAC5B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,yBAAyB;IACzB,IAAI,EAAE,YAAY,GAAG,IAAI,CAAC;IAC1B,yBAAyB;IACzB,MAAM,EAAE,YAAY,GAAG,IAAI,CAAC;CAC5B;AAED,2CAA2C;AAC3C,MAAM,WAAW,QAAQ;IACxB,sBAAsB;IACtB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,kBAAkB;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,YAAY;IACZ,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,YAAY;IACZ,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,cAAc;IACd,SAAS,EAAE,YAAY,EAAE,CAAC;CAC1B;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,GAAG,QAAQ,CA+ChG;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAQnD"}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** 「内容相同」的唯一判据。`modified` / `bytes` 是载荷,不是比较条件。 */
|
|
2
|
+
function same(left, right) {
|
|
3
|
+
return left?.hash === right?.hash;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* 码点序,不是 locale 序。
|
|
7
|
+
*
|
|
8
|
+
* 排序是为了确定性(便于断言、快照与"两台设备排出同一个顺序"),locale 序会随语言环境漂移,
|
|
9
|
+
* 那正好破坏了它存在的理由。
|
|
10
|
+
*/
|
|
11
|
+
function compareNames(left, right) {
|
|
12
|
+
if (left === right)
|
|
13
|
+
return 0;
|
|
14
|
+
return left < right ? -1 : 1;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 三方差集:同步的合并语义全部集中在这一个函数里。
|
|
18
|
+
*
|
|
19
|
+
* 三路输入:`base` 是上一次成功同步时记下的清单(首次同步为空集),`ours` 是本地此刻的扫描结果,
|
|
20
|
+
* `theirs` 是远端此刻的状态。输出的是**动作**而不是结果:谁来写盘、谁来发布由调用方决定。
|
|
21
|
+
*
|
|
22
|
+
* 判据只有 `hash`。`modified` 与 `bytes` 在这里不参与比较 —— 它们是载荷,留给冲突收敛去决定
|
|
23
|
+
* 「谁留在原路径」;把时间戳拉进比较会让同一份内容因读取时刻不同而永远判不出「没变」。
|
|
24
|
+
*
|
|
25
|
+
* 逐路径的完整规则(「同」= hash 与 `base` 相等,「改」= 不相等):
|
|
26
|
+
* - 两侧当前内容一致 → 无动作。包括两侧都删了、两侧都改成了同一份内容:后者让「两台设备各自跑了
|
|
27
|
+
* 同一次整理」成为无动作,而不是凭空生出两份互相冲突的副本。
|
|
28
|
+
* - `base` 里没有、只有一侧有 → 那一侧是新增:本地有就推,远端有就拉。
|
|
29
|
+
* - `base` 里有、一侧删了、另一侧没动 → 删除传播过去:本地删了删远端,远端删了删本地。
|
|
30
|
+
* - `base` 里有、一侧删了、另一侧改了 → 冲突:删除照办,被改的那份由冲突收敛按新 entry 留下,
|
|
31
|
+
* 没有任何一方丢内容。这条的删除半边不在这四张清单里 —— 原路径的删与留由冲突收敛一并决定,
|
|
32
|
+
* 在这里先删一次会让原路径被「删掉又写回」。
|
|
33
|
+
* - 两侧都在:只有一侧相对 `base` 变了 → 变了的那侧胜(本地变则推,远端变则拉);两侧都变了 → 冲突。
|
|
34
|
+
*
|
|
35
|
+
* 纯函数:零 IO、不读文件、不认识锁与 backend、不改动任何入参(连返回的数组都是新建的)。
|
|
36
|
+
*
|
|
37
|
+
* **这里是一张纯真值表,不保护任何读不出的名字。** 一个文件在本地解析不出/读不了时,扫描不会把它放进
|
|
38
|
+
* `ours`(它落在 `unparsable` 里),于是这里只读到「这一侧没有它」,走上面「二」的 `!inOurs` 分支。
|
|
39
|
+
* 这样的名字落在哪一条,取决于它在 `base` 里有没有(前两条要求有,第三条恰恰要求没有):
|
|
40
|
+
* - `base` 里有、远端没动 → `deleteRemote`:判成**本地删除**,删除传到远端 —— 一个坏文件就能删掉两台
|
|
41
|
+
* 设备上的同一条记忆,而本地那份还躺在盘上;
|
|
42
|
+
* - `base` 里有、远端改过 → `conflicts`(`ours: null`):判成**「删 vs 改」冲突**,而删除那半边是照办的 ——
|
|
43
|
+
* `resolveConflict` 对单侧删除返回 `winnerRaw: null`,引擎据此把原路径推入 `plan.deletes`,于是
|
|
44
|
+
* **读不出的本地文件从原路径被删掉**,远端那份字节只以一个派生的 `… (冲突 …)` 名字留下,
|
|
45
|
+
* 原件只能从 `-sync` 快照里捞回来;
|
|
46
|
+
* - `base` 里没有、远端有 → `pullFromRemote`:判成**远端新增**,直接盖掉同名的本地文件。
|
|
47
|
+
* 所以调用方必须自己把这类名字从**全部五张清单**
|
|
48
|
+
* (`pullFromRemote`/`pushLocal`/`deleteLocal`/`deleteRemote`/`conflicts`)里剔除,只报告、不动手 ——
|
|
49
|
+
* 这个函数没有、也不该有判断「哪些名字读不出」的信息。
|
|
50
|
+
*
|
|
51
|
+
* 四张清单都按文件名码点序排序;`conflicts` 同样按文件名排,让调用方的落盘与上报顺序也确定。
|
|
52
|
+
*/
|
|
53
|
+
export function diffThreeWay(base, ours, theirs) {
|
|
54
|
+
const diff = {
|
|
55
|
+
pullFromRemote: [],
|
|
56
|
+
pushLocal: [],
|
|
57
|
+
deleteLocal: [],
|
|
58
|
+
deleteRemote: [],
|
|
59
|
+
conflicts: [],
|
|
60
|
+
};
|
|
61
|
+
for (const file of new Set([...base.keys(), ...ours.keys(), ...theirs.keys()])) {
|
|
62
|
+
const inBase = base.get(file);
|
|
63
|
+
const inOurs = ours.get(file);
|
|
64
|
+
const inTheirs = theirs.get(file);
|
|
65
|
+
// 一、两侧当前一致(含两侧都不存在)→ 收敛情形,无动作。
|
|
66
|
+
if (same(inOurs, inTheirs))
|
|
67
|
+
continue;
|
|
68
|
+
// 二、只剩一侧有:新增、单侧删除的传播,或者「删 vs 改」。
|
|
69
|
+
if (!inOurs || !inTheirs) {
|
|
70
|
+
const oursSurvives = inOurs !== undefined;
|
|
71
|
+
const survivor = oursSurvives ? inOurs : inTheirs;
|
|
72
|
+
if (!inBase) {
|
|
73
|
+
// base 里没有过 = 纯新增:本地有就推,远端有就拉。
|
|
74
|
+
(oursSurvives ? diff.pushLocal : diff.pullFromRemote).push(file);
|
|
75
|
+
}
|
|
76
|
+
else if (same(inBase, survivor)) {
|
|
77
|
+
// 活着的那侧没动过它,这边的删除就是唯一的变更 → 把删除传播到对侧。
|
|
78
|
+
(oursSurvives ? diff.deleteLocal : diff.deleteRemote).push(file);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// 一侧删、另一侧改:谁都不许丢内容,交给冲突收敛。被删的那侧是 null。
|
|
82
|
+
diff.conflicts.push({ file, ours: inOurs ?? null, theirs: inTheirs ?? null });
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
// 三、两侧都有且内容不同:只有一侧变了就那侧胜,两侧都变才是冲突。
|
|
87
|
+
if (same(inBase, inOurs))
|
|
88
|
+
diff.pullFromRemote.push(file);
|
|
89
|
+
else if (same(inBase, inTheirs))
|
|
90
|
+
diff.pushLocal.push(file);
|
|
91
|
+
else
|
|
92
|
+
diff.conflicts.push({ file, ours: inOurs, theirs: inTheirs });
|
|
93
|
+
}
|
|
94
|
+
diff.pullFromRemote.sort(compareNames);
|
|
95
|
+
diff.pushLocal.sort(compareNames);
|
|
96
|
+
diff.deleteLocal.sort(compareNames);
|
|
97
|
+
diff.deleteRemote.sort(compareNames);
|
|
98
|
+
diff.conflicts.sort((left, right) => compareNames(left.file, right.file));
|
|
99
|
+
return diff;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 本轮是否无事可做。
|
|
103
|
+
*
|
|
104
|
+
* 冲突也算「有事」:它要么产出磁盘写入(新 entry + 原路径的内容变更),要么至少要报给用户,
|
|
105
|
+
* 所以空差集必须是五个字段全空 —— 只看四张清单会让「只剩冲突」的一轮被当成无事发生,
|
|
106
|
+
* 静默吞掉冲突。
|
|
107
|
+
*/
|
|
108
|
+
export function isEmptyDiff(diff) {
|
|
109
|
+
return (diff.pullFromRemote.length === 0 &&
|
|
110
|
+
diff.pushLocal.length === 0 &&
|
|
111
|
+
diff.deleteLocal.length === 0 &&
|
|
112
|
+
diff.deleteRemote.length === 0 &&
|
|
113
|
+
diff.conflicts.length === 0);
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=diff.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"diff.js","sourceRoot":"","sources":["../../src/sync/diff.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { type SyncConfig } from "../config.js";
|
|
2
|
+
import { type MemoryStore } from "../store/memory-store.js";
|
|
3
|
+
import { type SyncBackend } from "./backend.js";
|
|
4
|
+
import type { DeviceIdentity } from "./device.js";
|
|
5
|
+
/**
|
|
6
|
+
* 同步引擎:把「远端快照 + 本地扫描 + 上一次的基线」收敛成一次可解释的动作,并把它报出来。
|
|
7
|
+
*
|
|
8
|
+
* 一轮严格分三段,两把锁都是 **try 语义**(忙即跳过,绝不等待),而且**永不嵌套**:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* ① 传输段 根级 sync 锁(<syncRoot>/sync.lock)→ backend.fetch() → 出段即释放
|
|
12
|
+
* ② 应用段 项目的进程内逻辑锁 → 扫描 / 三方差集 / 快照 / 落盘 / 重建索引 / 写基线 → 释放
|
|
13
|
+
* ③ 发布段 再取一次根级 sync 锁 → backend.publish(增量) → 释放
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* 根锁不跨网络持有是刻意的:整轮持一把根锁会把远端往返的时间算进去,同一台机器上开着两个项目
|
|
17
|
+
* 会话时后启动的那个会整轮被跳过,而一次会话里拉取只有一次机会。因为两把锁从不嵌套,
|
|
18
|
+
* 也就不存在锁序成环的问题。
|
|
19
|
+
*
|
|
20
|
+
* pull 轮 = ①②;push 轮 = ①②③(先收敛再发布,所以 push 轮也可能改本地、也要快照)。
|
|
21
|
+
*
|
|
22
|
+
* **铁律:任何同步失败都不得阻塞会话、不得损坏本地记忆,最坏结果永远是「退回手动」。**
|
|
23
|
+
* 因此这里没有一处会把异常抛给调用方:所有失败都变成 `ok: false` + 一句可读的原因。
|
|
24
|
+
* 五条数据安全线(基线只记「两侧达成一致的状态」、读不出的本地文件既不是删除也不是可覆盖的
|
|
25
|
+
* 旧版本、远端 `modified` 必须先验形状、冲突收敛抛错只跳过该条、根级 sync 锁真的存在)
|
|
26
|
+
* 各自在实现处写了「为什么不能删掉这段」。
|
|
27
|
+
*/
|
|
28
|
+
/** 引擎的装配参数。全部由适配层算好交进来:core 不读配置文件,也不猜项目身份。 */
|
|
29
|
+
export interface SyncEngineOptions {
|
|
30
|
+
/** 本功能自己的状态目录(记忆根下的那个隐藏目录):基线、设备身份、根级锁都在里面。 */
|
|
31
|
+
syncRoot: string;
|
|
32
|
+
/** 这个项目同步的目录(记忆根下的 `git/<project>`)。报告用;落盘一律以 `store.cfg.memoryDir` 为准。 */
|
|
33
|
+
projectDir: string;
|
|
34
|
+
/** 远端布局里这个项目的 key(`remoteProjectKey` 的输出)。 */
|
|
35
|
+
projectKey: string;
|
|
36
|
+
store: MemoryStore;
|
|
37
|
+
backend: SyncBackend;
|
|
38
|
+
device: DeviceIdentity;
|
|
39
|
+
config: SyncConfig;
|
|
40
|
+
/** 只为测试可固定:基线的 `syncedAt` 与快照目录名都用它。 */
|
|
41
|
+
now?: () => Date;
|
|
42
|
+
}
|
|
43
|
+
/** 一轮同步的结果。`ok: false` 时 `skipped.reason` 一定有一句人话。 */
|
|
44
|
+
export interface SyncRoundResult {
|
|
45
|
+
ok: boolean;
|
|
46
|
+
/** 本轮为什么没做完(配置未开、锁忙、远端不可达……)。只在 `ok: false` 时出现。 */
|
|
47
|
+
skipped?: {
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
/** 本轮从远端落到本地的 entry 文件名。 */
|
|
51
|
+
pulled: string[];
|
|
52
|
+
/** 本轮发布到远端的 entry 文件名(pull 轮不发布,恒为空)。 */
|
|
53
|
+
pushed: string[];
|
|
54
|
+
/** 本轮在本地删掉的文件名(含「删除 vs 修改」里照办的那次删除)。 */
|
|
55
|
+
deletedLocal: string[];
|
|
56
|
+
/**
|
|
57
|
+
* 本轮从远端删掉的文件名。pull 轮不发布,那里报的是**本轮判定、随下一次发布生效**的删除 ——
|
|
58
|
+
* 判定了却没报出来,用户就无从知道自己的删除还没生效。
|
|
59
|
+
*/
|
|
60
|
+
deletedRemote: string[];
|
|
61
|
+
/** 收敛掉的冲突:`file` 是原路径,`keptAsNew` 是被挤出来那一份的新文件名。 */
|
|
62
|
+
conflicts: Array<{
|
|
63
|
+
file: string;
|
|
64
|
+
keptAsNew: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** 被拒收的内容(远端那份解析不出、`modified` 形状不对、文件名碰撞……),带原因。
|
|
67
|
+
* 原因词与 `applyEntryFiles` 同一口径:引擎自己判定的那几种是**稳定词汇**
|
|
68
|
+
* (`unparsable frontmatter` / `invalid modified` / `unpublishable file name`),异常路径(读不到字节、
|
|
69
|
+
* 冲突没收敛掉)则直接把底层错误拼进去 —— 那些不是词汇,是诊断信息。 */
|
|
70
|
+
rejected: Array<{
|
|
71
|
+
file: string;
|
|
72
|
+
reason: string;
|
|
73
|
+
}>;
|
|
74
|
+
/** 本地解析不出/读不了的 `.md`:本轮不参与判定,但必须让用户知道它没同步。 */
|
|
75
|
+
unparsableLocal: string[];
|
|
76
|
+
/**
|
|
77
|
+
* 远端有、但本轮不会同步的路径(远端根相对):传输端口报上来的**异常**路径(手工推上来的
|
|
78
|
+
* 索引、本前缀底下的嵌套目录……),加上引擎自己判定的「这个名字在任何设备上都写不下去」。
|
|
79
|
+
* 别的项目的记忆**不在这里** —— 那是设计规定的常态,报出来就等于每轮上千条噪声。
|
|
80
|
+
*/
|
|
81
|
+
outOfScopeRemote: string[];
|
|
82
|
+
/**
|
|
83
|
+
* 本轮结束时基线指向的远端版本(发布成功则是发布后的那一个)。
|
|
84
|
+
*
|
|
85
|
+
* **不变量:只有基线真写进盘了才有值。** 写基线失败、或在写基线之前就失败的那些轮
|
|
86
|
+
* (锁忙、未初始化、未配置、传输失败、扫描抛错)一律是 `undefined` —— 基线是适配层
|
|
87
|
+
* 渲染「同步到哪了」的唯一依据,报一个盘上根本不存在的 revision,等于让用户对着一个
|
|
88
|
+
* 假状态去排查(而 `pulled` / `conflicts` 那一堆部分结果反而可能非空:失败轮也可能已经改过本地)。
|
|
89
|
+
*/
|
|
90
|
+
revision?: string;
|
|
91
|
+
}
|
|
92
|
+
/** `status` 的报告。**dry-run**:算得出一切,但盘上一个字都不改。 */
|
|
93
|
+
export interface SyncStatusReport {
|
|
94
|
+
configured: boolean;
|
|
95
|
+
initialized: boolean;
|
|
96
|
+
remote: string;
|
|
97
|
+
branch: string;
|
|
98
|
+
device: DeviceIdentity;
|
|
99
|
+
/** 本地目录(可能与远端 key 不同名:报出两者,映射关系才看得见)。 */
|
|
100
|
+
projectDir: string;
|
|
101
|
+
projectKey: string;
|
|
102
|
+
base: {
|
|
103
|
+
revision: string;
|
|
104
|
+
syncedAt: string;
|
|
105
|
+
} | null;
|
|
106
|
+
/** 本地领先远端的那些文件名(待发布的新增/修改,以及待发布的本地删除)。 */
|
|
107
|
+
ahead: string[];
|
|
108
|
+
/** 远端领先本地的那些文件名(待拉取的内容,以及待执行的远端删除)。 */
|
|
109
|
+
behind: string[];
|
|
110
|
+
conflicts: string[];
|
|
111
|
+
rejected: Array<{
|
|
112
|
+
file: string;
|
|
113
|
+
reason: string;
|
|
114
|
+
}>;
|
|
115
|
+
unparsableLocal: string[];
|
|
116
|
+
/** 同一轮结果里那个字段的口径(异常路径,不含别的项目的记忆)。 */
|
|
117
|
+
outOfScopeRemote: string[];
|
|
118
|
+
/** 每一步失败的可读原因(未配置、未初始化、锁忙、远端不可达、目录读不了……)。 */
|
|
119
|
+
errors: string[];
|
|
120
|
+
}
|
|
121
|
+
export interface SyncEngine {
|
|
122
|
+
pull(timeoutMs?: number): Promise<SyncRoundResult>;
|
|
123
|
+
push(timeoutMs?: number): Promise<SyncRoundResult>;
|
|
124
|
+
status(timeoutMs?: number): Promise<SyncStatusReport>;
|
|
125
|
+
/** 记下「本地刚写过」,由写触发调度器接线(去抖之后调 `flush`)。 */
|
|
126
|
+
noteWrite(): void;
|
|
127
|
+
/**
|
|
128
|
+
* 立刻跑一次去抖中的推送;没有尚未发布的内容则返回 `null`。
|
|
129
|
+
* 「尚未发布」包括上一次自动推送被跳过/失败的那一份:撞上锁而白跑的一轮不算干净,
|
|
130
|
+
* 宿主退出前调它就能把那一推补上。
|
|
131
|
+
*/
|
|
132
|
+
flush(timeoutMs?: number): Promise<SyncRoundResult | null>;
|
|
133
|
+
/** 释放调度器持有的资源。绝不抛错。 */
|
|
134
|
+
dispose(): void;
|
|
135
|
+
/**
|
|
136
|
+
* 在途的那一次自动推送(没有则 `null`)。宿主把它挂进自己的「退出前等在途写」账本:
|
|
137
|
+
* 看不到它,退出时正好跑着的一次推送就会被静默截断。
|
|
138
|
+
*/
|
|
139
|
+
readonly inFlight: Promise<unknown> | null;
|
|
140
|
+
}
|
|
141
|
+
export declare function createSyncEngine(options: SyncEngineOptions): SyncEngine;
|
|
142
|
+
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/sync/engine.ts"],"names":[],"mappings":"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"}
|