@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,266 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 这台机器的设备身份,落在 `<syncRoot>/device.json`。
|
|
8
|
+
*
|
|
9
|
+
* 它只服务两件事:**同步提交的作者**(于是用户那个私有仓库的 `git log` 能看出哪台机器写了什么),
|
|
10
|
+
* 与后续 `status` 报告里那行「你是哪台设备」。因此 `id` 必须在多次运行之间保持不变 ——
|
|
11
|
+
* 每次启动换一个,提交作者与报告就都成了噪声,人也没法据此判断一条改动来自哪台机器。
|
|
12
|
+
* 「跨进程稳定」是本模块存在的**全部**理由:只在同一个进程内稳定,一个模块级缓存就能做到,
|
|
13
|
+
* 那对提交作者毫无意义。
|
|
14
|
+
*
|
|
15
|
+
* **`id` 绝不参与冲突副本的命名。** 另存为新条目的名字必须完全由冲突内容的字节派生
|
|
16
|
+
* (见 `conflict.ts`):两台设备各自独立收敛同一次冲突,只有名字是内容的确定性函数,
|
|
17
|
+
* 它们才能算出逐字节相同的文件名、收敛成一条。一旦设备 id 漏进派生名字,两台设备就会
|
|
18
|
+
* 各造一份副本,下一轮再互相把对方的副本拉过来 —— 同一条记忆变成三份、四份,永不收敛。
|
|
19
|
+
* 所以本模块的产物只用于「谁提交的」与「给人看的报告」,不进入任何名字推导。
|
|
20
|
+
*
|
|
21
|
+
* 它本身**不同步**:每台设备有自己的身份,把 `device.json` 推到远端等于让两台机器共用一个 id。
|
|
22
|
+
*/
|
|
23
|
+
export interface DeviceIdentity {
|
|
24
|
+
/** 持久 id:这台机器的身份,创建后不再变(我们写出去的是 uuid,但读回时不强制这个形状)。 */
|
|
25
|
+
id: string;
|
|
26
|
+
/** 创建那一刻的主机名,纯粹给人看(唯一性不依赖它)。 */
|
|
27
|
+
hostname: string;
|
|
28
|
+
/** 身份创建时刻,ISO 字符串。 */
|
|
29
|
+
createdAt: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `<syncRoot>/device.json`。`syncRoot` 就是记忆根下的 `.sync` 目录。 */
|
|
33
|
+
export function devicePath(syncRoot: string): string {
|
|
34
|
+
return join(syncRoot, "device.json");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
38
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 只认 `Date#toISOString` 那种形态(我们写的就是它):不是「任意能被 Date 解析的字符串」。 */
|
|
42
|
+
function isIsoInstant(value: unknown): value is string {
|
|
43
|
+
if (typeof value !== "string") return false;
|
|
44
|
+
const ms = Date.parse(value);
|
|
45
|
+
return !Number.isNaN(ms) && new Date(ms).toISOString() === value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 身份字段(`id` 与 `hostname`)的长度上限。
|
|
50
|
+
*
|
|
51
|
+
* 这个上限不服务于合法性,只服务于**可读性**:两个字段都会进 git 提交作者与 `status` 文案,
|
|
52
|
+
* 而这两处都按短标识设计。一个几 KB 的身份会让提交头的作者栏与报告第一屏全是它。
|
|
53
|
+
*/
|
|
54
|
+
const MAX_IDENTITY_FIELD_LENGTH = 128;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* C0(含 `\n`、`\r`、`\t`、`ESC`)与 DEL、C1 控制字符:逐字写出去的 `ESC[31m` 会在终端里
|
|
58
|
+
* 把状态报告染色(或更糟,清屏),换行会把一行报告、一行提交头拆成两行。
|
|
59
|
+
*/
|
|
60
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentionally matching C0/C1 control characters — this id ends up in a git commit author and in status output
|
|
61
|
+
const CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 身份字段的形状**下限**:非空、非纯空白、无控制字符、长度 ≤ 128。
|
|
65
|
+
*
|
|
66
|
+
* 刻意只是下限,**不要求 uuid 形状**:手工改过但仍可用的身份不该被换掉 —— 换掉才是提交作者
|
|
67
|
+
* 漂移的原因,而这一整条链(提交作者、报告)唯一在乎的是「稳定」。所以 `device-a` 这种手写值
|
|
68
|
+
* 照样接受,只把「当不了作者标识」的值挡在外面:空串会让提交作者变空,纯空白会被 git 当作无作者,
|
|
69
|
+
* 控制字符会被 git 拒掉或让报告乱码。
|
|
70
|
+
*/
|
|
71
|
+
function isUsableIdentityField(value: unknown): value is string {
|
|
72
|
+
if (typeof value !== "string") return false;
|
|
73
|
+
if (value.length === 0 || value.length > MAX_IDENTITY_FIELD_LENGTH) return false;
|
|
74
|
+
// trim 之后再判空:`" "` 不是身份,而 `" device-a"` 是(原样保留,不替用户改值)。
|
|
75
|
+
if (value.trim().length === 0) return false;
|
|
76
|
+
return !CONTROL_CHARS.test(value);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 把解析出来的 JSON 收窄成 `DeviceIdentity`;任一处不对返回 `null`。
|
|
81
|
+
*
|
|
82
|
+
* 逐个字段查是刻意的:`device.json` 是人可能手改、云盘可能半截落盘的文件,而它是**身份**。
|
|
83
|
+
* 半信半疑地接受一个残缺身份,比重新生成一份更糟 —— 缺 `id` 的身份会让提交作者变空,
|
|
84
|
+
* 缺 `createdAt` 会让报告露馅。`id` / `hostname` 的判据见 `isUsableIdentityField`。
|
|
85
|
+
*/
|
|
86
|
+
function asDeviceIdentity(value: unknown): DeviceIdentity | null {
|
|
87
|
+
if (!isPlainObject(value)) return null;
|
|
88
|
+
|
|
89
|
+
const { id, hostname: host, createdAt } = value;
|
|
90
|
+
if (!isUsableIdentityField(id)) return null;
|
|
91
|
+
if (!isUsableIdentityField(host)) return null;
|
|
92
|
+
if (!isIsoInstant(createdAt)) return null;
|
|
93
|
+
|
|
94
|
+
return { id, hostname: host, createdAt };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 读到的三种状态。区分「文件不存在」与「文件在那儿但不能用」是本模块的核心:
|
|
99
|
+
* 前者该创建,后者只该**替换坏内容**,而「读不了」(权限、IO、网络盘)既不是前者也不是后者,
|
|
100
|
+
* 它必须上抛。
|
|
101
|
+
*/
|
|
102
|
+
type DeviceRead = { kind: "ok"; device: DeviceIdentity } | { kind: "absent" } | { kind: "invalid" };
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 读已存在的身份。
|
|
106
|
+
*
|
|
107
|
+
* **只有 `ENOENT` / `ENOTDIR`(确实没有这个文件)算 `absent`;其他 errno 一律上抛。**
|
|
108
|
+
* 这是数据安全线,不是风格问题:`EACCES` / `EIO` / 网络挂载抖动都不是「没有身份」,把它们
|
|
109
|
+
* 当成「没有」就会走进创建分支、覆盖掉一份合法身份文件,然后返回一个新 `id` ——
|
|
110
|
+
* 提交作者被**静默重铸**,正是本模块注释反对的那件事。抛出去之后调用方至少能看到失败。
|
|
111
|
+
* (`EISDIR` 同理上抛:目标是个目录,靠猜不如报错。)
|
|
112
|
+
*
|
|
113
|
+
* 内容问题(JSON 坏掉、不是对象、字段不全、`createdAt` 形状不对)才算 `invalid`:
|
|
114
|
+
* 那些是「这个文件的字节当不了身份」,重写一份无损。
|
|
115
|
+
*/
|
|
116
|
+
async function readDevice(path: string): Promise<DeviceRead> {
|
|
117
|
+
const raw = await readFile(path, "utf8").catch((err: unknown) => {
|
|
118
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
119
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
120
|
+
throw err;
|
|
121
|
+
});
|
|
122
|
+
if (raw === null) return { kind: "absent" };
|
|
123
|
+
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
} catch {
|
|
128
|
+
return { kind: "invalid" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const device = asDeviceIdentity(parsed);
|
|
132
|
+
return device === null ? { kind: "invalid" } : { kind: "ok", device };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 同目录下的临时文件名:`<path>.tmp-<pid>-<随机>`,不带目录层级,发布因此不会跨卷。 */
|
|
136
|
+
function tempPath(path: string): string {
|
|
137
|
+
return `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 把身份写进一个临时文件,返回临时文件路径。
|
|
142
|
+
*
|
|
143
|
+
* `wx`:临时名已带 pid + 随机后缀,正常不会撞。真撞上说明有东西在乱写这个目录,
|
|
144
|
+
* 宁可让 `EEXIST` 冒出来,也不要覆盖别人的临时文件。
|
|
145
|
+
*
|
|
146
|
+
* 先写临时文件再发布,是为了「目标名一旦出现就指向写完整的字节」:记忆根可能就是云盘目录 /
|
|
147
|
+
* 网络盘,直接写目标文件会留下「写了一半的 JSON」这一窗口 —— 撞上它的那次启动读不懂 →
|
|
148
|
+
* 重新生成 → 又有了新 `id`,而两台设备的云盘还在互相覆盖这个文件。
|
|
149
|
+
*
|
|
150
|
+
* **这里刻意不抽一个公用的 `writeJsonAtomic`**(已经提过两次了,把决定写在代码里):
|
|
151
|
+
* 本文件要的是「**排他创建** + 发布后读回 + 无硬链接时退回覆盖」三种发布形态,而
|
|
152
|
+
* `manifest.ts` 要的只是「原子替换」(目标名上没有需要保护的东西,`rename` 就够了)。
|
|
153
|
+
* 两套语义合成一个函数必然带 flag,而 flag 恰好会把下面 `createDevice` 里那条论证清楚的
|
|
154
|
+
* 排他性边界(何时排他、何时不排他、不排他时靠什么兼容)掩盖成一个参数默认值。
|
|
155
|
+
* 重复的只是「写临时文件 + `unlink(temp).catch(() => {})`」这几行尾巴,代价比一个带 flag
|
|
156
|
+
* 的公用原语低。
|
|
157
|
+
*/
|
|
158
|
+
async function writeTemp(path: string, device: DeviceIdentity): Promise<string> {
|
|
159
|
+
await mkdir(dirname(path), { recursive: true });
|
|
160
|
+
const temp = tempPath(path);
|
|
161
|
+
await writeFile(temp, `${JSON.stringify(device, null, "\t")}\n`, { encoding: "utf8", flag: "wx" });
|
|
162
|
+
return temp;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 发布之后再读一遍盘上内容:调用方拿到的必须是「盘上那份」,而不是「我以为我写了什么」。 */
|
|
166
|
+
async function readBack(path: string): Promise<DeviceIdentity> {
|
|
167
|
+
const after = await readDevice(path);
|
|
168
|
+
if (after.kind !== "ok") throw new Error(`写完身份文件之后仍读不出身份:${path}`);
|
|
169
|
+
return after.device;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 「这个文件系统不支持硬链接」的 errno 集合。
|
|
174
|
+
*
|
|
175
|
+
* 记忆根可能就是网络盘 / FUSE 挂载的云盘目录,那里 `link` 可能直接不支持。那种情况下退回
|
|
176
|
+
* `rename`(覆盖语义),而不是让同步一启动就失败。
|
|
177
|
+
*/
|
|
178
|
+
const NO_HARDLINK_CODES = new Set(["EPERM", "EACCES", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EMLINK"]);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 目标还不存在(文件确实没有)时创建身份,并把盘上那份返回。
|
|
182
|
+
*
|
|
183
|
+
* 发布用 `link` 而不是 `rename`,因为 `link` **排他**:目标已存在时它原子地失败(`EEXIST`),
|
|
184
|
+
* 于是并发的两个首次运行里只有一个能挂上自己的身份,另一个立刻回到「读盘上那份」——
|
|
185
|
+
* 两边返回同一个 `id`,与盘上一致。
|
|
186
|
+
*
|
|
187
|
+
* `rename` 是无条件覆盖的:并发的两个首次运行各写各的,败者会把赢者刚挂上去的 id 盖掉,
|
|
188
|
+
* 可赢者早已返回了那个 id,那一轮的提交就挂在一个盘上已经不存在的作者上。
|
|
189
|
+
*
|
|
190
|
+
* 注意这条排他性**只在硬链接可用的文件系统上成立**,而下面那个退回分支正好是部署目标
|
|
191
|
+
* (云盘 / 网络挂载)会走到的地方。
|
|
192
|
+
*/
|
|
193
|
+
async function createDevice(path: string, device: DeviceIdentity): Promise<DeviceIdentity> {
|
|
194
|
+
const temp = await writeTemp(path, device);
|
|
195
|
+
try {
|
|
196
|
+
await link(temp, path);
|
|
197
|
+
return device;
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const code = (err as NodeJS.ErrnoException).code ?? "";
|
|
200
|
+
if (code === "EEXIST") {
|
|
201
|
+
// 有人抢先了:以盘上那份为准。它是被 `link` 原子挂上去的整份字节,所以这里读得到;
|
|
202
|
+
// 读不到就抛错,绝不覆盖 —— 覆盖等于把别人的身份换掉。
|
|
203
|
+
return readBack(path);
|
|
204
|
+
}
|
|
205
|
+
if (!NO_HARDLINK_CODES.has(code)) throw err;
|
|
206
|
+
// 没有硬链接可用:退回 rename 覆盖,再从盘上读回返回值。这里**失去了排他性** —— 两个并发的
|
|
207
|
+
// 首次运行可能各覆盖一次(窗口极窄,且两边写出的都是合法身份,没人会丢掉可用身份)。
|
|
208
|
+
// `readBack` 只是**收窄**不一致窗口,没有关掉它:它保证的是「返回的那一份在读的那一刻
|
|
209
|
+
// 确实在盘上」,但另一个进程可以在读回之后又覆盖一次 —— 于是本轮拿到的 id 与盘上最终的
|
|
210
|
+
// 那一份仍可能不同,那一次的提交作者就对不上盘。这是无硬链接文件系统上的固有残余风险
|
|
211
|
+
//(不是可以再写几行关掉的),与裁定接受的那个窗口相同:两边写的都是合法身份,不会丢身份,
|
|
212
|
+
// 只是提交作者可能漂移一次。它不会把一具自己凭空造出、盘上从未存在过的 id 拿去当作者。
|
|
213
|
+
await rename(temp, path);
|
|
214
|
+
return readBack(path);
|
|
215
|
+
} finally {
|
|
216
|
+
// 两条路径都收尾:`link` 成功后临时名还在,`rename` 之后它已不在(ENOENT 忽略即可)。
|
|
217
|
+
await unlink(temp).catch(() => {});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 文件存在但内容当不了身份(半截落盘的 JSON、手改坏了的字段)时重写一份。
|
|
223
|
+
*
|
|
224
|
+
* 这里是 `rename` 覆盖而不是 `link`:目标名已经被一份**不可用**的字节占着,没有需要保护的
|
|
225
|
+
* 身份,覆盖它是无损的。返回值同样取自盘上(`readBack`),所以「返回的 id 与盘上一致」这条
|
|
226
|
+
* 要求在两条发布路径上都成立。
|
|
227
|
+
*/
|
|
228
|
+
async function replaceBrokenDevice(path: string, device: DeviceIdentity): Promise<DeviceIdentity> {
|
|
229
|
+
const temp = await writeTemp(path, device);
|
|
230
|
+
try {
|
|
231
|
+
await rename(temp, path);
|
|
232
|
+
} finally {
|
|
233
|
+
await unlink(temp).catch(() => {});
|
|
234
|
+
}
|
|
235
|
+
return readBack(path);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 读这台设备的身份,没有(或读不懂)就生成一份并落盘。
|
|
240
|
+
*
|
|
241
|
+
* **调用方不需要持有根 sync 锁**:本函数自己收敛。并发的首次运行各自生成,而发布在支持硬链接的
|
|
242
|
+
* 文件系统上是**排他**的(见 `createDevice`),所以最终只有一个 `id` 落盘,两个调用方都返回它 ——
|
|
243
|
+
* 不会有人返回一个盘上已不存在、却已经被拿去当提交作者的 id。
|
|
244
|
+
*
|
|
245
|
+
* **云盘 / 网络挂载上这条排他性不成立**(那里 `link` 往往直接不支持,`createDevice` 退回 `rename`):
|
|
246
|
+
* 两个并发的首次运行可能各覆盖一次,于是本轮拿到的 `id` 与盘上最终那份可能不同,后果是那**一次**
|
|
247
|
+
* 提交的作者对不上盘(身份本身不会丢、也不会变成两份,下一轮开始就稳定了)。这是故意接受的残余
|
|
248
|
+
* 风险:两条发布路径写出的都是合法身份,而加锁也治不了它 —— 根 sync 锁是 try 语义、允许被跳过的,
|
|
249
|
+
* 靠它保证身份的稳定性并不成立。
|
|
250
|
+
*
|
|
251
|
+
* `now` 只为测试能固定 `createdAt`;生产调用不需要传。`syncRoot` 可能还不存在
|
|
252
|
+
* (首次同步时 `.sync` 目录可能还没人建过),这里自己 `mkdir`。
|
|
253
|
+
*/
|
|
254
|
+
export async function loadOrCreateDevice(
|
|
255
|
+
syncRoot: string,
|
|
256
|
+
now: () => Date = () => new Date(),
|
|
257
|
+
): Promise<DeviceIdentity> {
|
|
258
|
+
const path = devicePath(syncRoot);
|
|
259
|
+
await mkdir(syncRoot, { recursive: true });
|
|
260
|
+
|
|
261
|
+
const current = await readDevice(path);
|
|
262
|
+
if (current.kind === "ok") return current.device;
|
|
263
|
+
|
|
264
|
+
const created: DeviceIdentity = { id: randomUUID(), hostname: hostname(), createdAt: now().toISOString() };
|
|
265
|
+
return current.kind === "absent" ? createDevice(path, created) : replaceBrokenDevice(path, created);
|
|
266
|
+
}
|
package/src/sync/diff.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { SyncFileInfo, SyncFileSet } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一条冲突:同一个 entry 文件名在两侧都「有话说」且内容不同。
|
|
5
|
+
*
|
|
6
|
+
* `null` = 这一侧已经删除(删除 vs 修改的冲突)。两侧不可能同时为 null —— 那是「两边都删」,
|
|
7
|
+
* 属于无动作行,不进 conflicts。
|
|
8
|
+
* **不要用哨兵值**(例如 `hash: ""`)表示删除:冲突收敛与同步引擎都要判这一侧,
|
|
9
|
+
* 显式 null 让 TypeScript 强制每个消费者处理它,哨兵值只靠约定 —— 约定错一次就会静默吞掉用户的删除,
|
|
10
|
+
* 让那条记忆在两台设备上一起复活。
|
|
11
|
+
*/
|
|
12
|
+
export interface SyncConflict {
|
|
13
|
+
/** entry 文件名(单层,与 `SyncFileSet` 的 key 同口径)。 */
|
|
14
|
+
file: string;
|
|
15
|
+
/** `null` = 本地这一侧已删除。 */
|
|
16
|
+
ours: SyncFileInfo | null;
|
|
17
|
+
/** `null` = 远端这一侧已删除。 */
|
|
18
|
+
theirs: SyncFileInfo | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 一次三方合并的完整结论:四张单侧动作清单,外加要交给冲突收敛的那些路径。 */
|
|
22
|
+
export interface SyncDiff {
|
|
23
|
+
/** 远端胜:要用远端内容写入本地。 */
|
|
24
|
+
pullFromRemote: string[];
|
|
25
|
+
/** 本地胜:要发布到远端。 */
|
|
26
|
+
pushLocal: string[];
|
|
27
|
+
/** 本地要删。 */
|
|
28
|
+
deleteLocal: string[];
|
|
29
|
+
/** 远端要删。 */
|
|
30
|
+
deleteRemote: string[];
|
|
31
|
+
/** 交给冲突收敛。 */
|
|
32
|
+
conflicts: SyncConflict[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 「内容相同」的唯一判据。`modified` / `bytes` 是载荷,不是比较条件。 */
|
|
36
|
+
function same(left: SyncFileInfo | undefined, right: SyncFileInfo | undefined): boolean {
|
|
37
|
+
return left?.hash === right?.hash;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 码点序,不是 locale 序。
|
|
42
|
+
*
|
|
43
|
+
* 排序是为了确定性(便于断言、快照与"两台设备排出同一个顺序"),locale 序会随语言环境漂移,
|
|
44
|
+
* 那正好破坏了它存在的理由。
|
|
45
|
+
*/
|
|
46
|
+
function compareNames(left: string, right: string): number {
|
|
47
|
+
if (left === right) return 0;
|
|
48
|
+
return left < right ? -1 : 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 三方差集:同步的合并语义全部集中在这一个函数里。
|
|
53
|
+
*
|
|
54
|
+
* 三路输入:`base` 是上一次成功同步时记下的清单(首次同步为空集),`ours` 是本地此刻的扫描结果,
|
|
55
|
+
* `theirs` 是远端此刻的状态。输出的是**动作**而不是结果:谁来写盘、谁来发布由调用方决定。
|
|
56
|
+
*
|
|
57
|
+
* 判据只有 `hash`。`modified` 与 `bytes` 在这里不参与比较 —— 它们是载荷,留给冲突收敛去决定
|
|
58
|
+
* 「谁留在原路径」;把时间戳拉进比较会让同一份内容因读取时刻不同而永远判不出「没变」。
|
|
59
|
+
*
|
|
60
|
+
* 逐路径的完整规则(「同」= hash 与 `base` 相等,「改」= 不相等):
|
|
61
|
+
* - 两侧当前内容一致 → 无动作。包括两侧都删了、两侧都改成了同一份内容:后者让「两台设备各自跑了
|
|
62
|
+
* 同一次整理」成为无动作,而不是凭空生出两份互相冲突的副本。
|
|
63
|
+
* - `base` 里没有、只有一侧有 → 那一侧是新增:本地有就推,远端有就拉。
|
|
64
|
+
* - `base` 里有、一侧删了、另一侧没动 → 删除传播过去:本地删了删远端,远端删了删本地。
|
|
65
|
+
* - `base` 里有、一侧删了、另一侧改了 → 冲突:删除照办,被改的那份由冲突收敛按新 entry 留下,
|
|
66
|
+
* 没有任何一方丢内容。这条的删除半边不在这四张清单里 —— 原路径的删与留由冲突收敛一并决定,
|
|
67
|
+
* 在这里先删一次会让原路径被「删掉又写回」。
|
|
68
|
+
* - 两侧都在:只有一侧相对 `base` 变了 → 变了的那侧胜(本地变则推,远端变则拉);两侧都变了 → 冲突。
|
|
69
|
+
*
|
|
70
|
+
* 纯函数:零 IO、不读文件、不认识锁与 backend、不改动任何入参(连返回的数组都是新建的)。
|
|
71
|
+
*
|
|
72
|
+
* **这里是一张纯真值表,不保护任何读不出的名字。** 一个文件在本地解析不出/读不了时,扫描不会把它放进
|
|
73
|
+
* `ours`(它落在 `unparsable` 里),于是这里只读到「这一侧没有它」,走上面「二」的 `!inOurs` 分支。
|
|
74
|
+
* 这样的名字落在哪一条,取决于它在 `base` 里有没有(前两条要求有,第三条恰恰要求没有):
|
|
75
|
+
* - `base` 里有、远端没动 → `deleteRemote`:判成**本地删除**,删除传到远端 —— 一个坏文件就能删掉两台
|
|
76
|
+
* 设备上的同一条记忆,而本地那份还躺在盘上;
|
|
77
|
+
* - `base` 里有、远端改过 → `conflicts`(`ours: null`):判成**「删 vs 改」冲突**,而删除那半边是照办的 ——
|
|
78
|
+
* `resolveConflict` 对单侧删除返回 `winnerRaw: null`,引擎据此把原路径推入 `plan.deletes`,于是
|
|
79
|
+
* **读不出的本地文件从原路径被删掉**,远端那份字节只以一个派生的 `… (冲突 …)` 名字留下,
|
|
80
|
+
* 原件只能从 `-sync` 快照里捞回来;
|
|
81
|
+
* - `base` 里没有、远端有 → `pullFromRemote`:判成**远端新增**,直接盖掉同名的本地文件。
|
|
82
|
+
* 所以调用方必须自己把这类名字从**全部五张清单**
|
|
83
|
+
* (`pullFromRemote`/`pushLocal`/`deleteLocal`/`deleteRemote`/`conflicts`)里剔除,只报告、不动手 ——
|
|
84
|
+
* 这个函数没有、也不该有判断「哪些名字读不出」的信息。
|
|
85
|
+
*
|
|
86
|
+
* 四张清单都按文件名码点序排序;`conflicts` 同样按文件名排,让调用方的落盘与上报顺序也确定。
|
|
87
|
+
*/
|
|
88
|
+
export function diffThreeWay(base: SyncFileSet, ours: SyncFileSet, theirs: SyncFileSet): SyncDiff {
|
|
89
|
+
const diff: SyncDiff = {
|
|
90
|
+
pullFromRemote: [],
|
|
91
|
+
pushLocal: [],
|
|
92
|
+
deleteLocal: [],
|
|
93
|
+
deleteRemote: [],
|
|
94
|
+
conflicts: [],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
for (const file of new Set([...base.keys(), ...ours.keys(), ...theirs.keys()])) {
|
|
98
|
+
const inBase = base.get(file);
|
|
99
|
+
const inOurs = ours.get(file);
|
|
100
|
+
const inTheirs = theirs.get(file);
|
|
101
|
+
|
|
102
|
+
// 一、两侧当前一致(含两侧都不存在)→ 收敛情形,无动作。
|
|
103
|
+
if (same(inOurs, inTheirs)) continue;
|
|
104
|
+
|
|
105
|
+
// 二、只剩一侧有:新增、单侧删除的传播,或者「删 vs 改」。
|
|
106
|
+
if (!inOurs || !inTheirs) {
|
|
107
|
+
const oursSurvives = inOurs !== undefined;
|
|
108
|
+
const survivor = oursSurvives ? inOurs : inTheirs;
|
|
109
|
+
if (!inBase) {
|
|
110
|
+
// base 里没有过 = 纯新增:本地有就推,远端有就拉。
|
|
111
|
+
(oursSurvives ? diff.pushLocal : diff.pullFromRemote).push(file);
|
|
112
|
+
} else if (same(inBase, survivor)) {
|
|
113
|
+
// 活着的那侧没动过它,这边的删除就是唯一的变更 → 把删除传播到对侧。
|
|
114
|
+
(oursSurvives ? diff.deleteLocal : diff.deleteRemote).push(file);
|
|
115
|
+
} else {
|
|
116
|
+
// 一侧删、另一侧改:谁都不许丢内容,交给冲突收敛。被删的那侧是 null。
|
|
117
|
+
diff.conflicts.push({ file, ours: inOurs ?? null, theirs: inTheirs ?? null });
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 三、两侧都有且内容不同:只有一侧变了就那侧胜,两侧都变才是冲突。
|
|
123
|
+
if (same(inBase, inOurs)) diff.pullFromRemote.push(file);
|
|
124
|
+
else if (same(inBase, inTheirs)) diff.pushLocal.push(file);
|
|
125
|
+
else diff.conflicts.push({ file, ours: inOurs, theirs: inTheirs });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
diff.pullFromRemote.sort(compareNames);
|
|
129
|
+
diff.pushLocal.sort(compareNames);
|
|
130
|
+
diff.deleteLocal.sort(compareNames);
|
|
131
|
+
diff.deleteRemote.sort(compareNames);
|
|
132
|
+
diff.conflicts.sort((left, right) => compareNames(left.file, right.file));
|
|
133
|
+
|
|
134
|
+
return diff;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 本轮是否无事可做。
|
|
139
|
+
*
|
|
140
|
+
* 冲突也算「有事」:它要么产出磁盘写入(新 entry + 原路径的内容变更),要么至少要报给用户,
|
|
141
|
+
* 所以空差集必须是五个字段全空 —— 只看四张清单会让「只剩冲突」的一轮被当成无事发生,
|
|
142
|
+
* 静默吞掉冲突。
|
|
143
|
+
*/
|
|
144
|
+
export function isEmptyDiff(diff: SyncDiff): boolean {
|
|
145
|
+
return (
|
|
146
|
+
diff.pullFromRemote.length === 0 &&
|
|
147
|
+
diff.pushLocal.length === 0 &&
|
|
148
|
+
diff.deleteLocal.length === 0 &&
|
|
149
|
+
diff.deleteRemote.length === 0 &&
|
|
150
|
+
diff.conflicts.length === 0
|
|
151
|
+
);
|
|
152
|
+
}
|