@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
package/src/sync/hash.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 内容指纹。**这是端口契约,不是实现细节**:manifest 与三方 diff 直接比较它的输出,
|
|
5
|
+
* 任何 backend(本地目录、对象存储、……)都必须对同样的内容产出同样的值 —— 否则「没变」
|
|
6
|
+
* 会被判成「变了」,每次同步都凭空生出冲突副本。
|
|
7
|
+
*
|
|
8
|
+
* 因此不借用任何 backend 自带的摘要:git 的 blob sha1 掺了 `blob <长度>\0` 头,对象存储的
|
|
9
|
+
* ETag 掺了分片规则,两者不同源、也与这里的值不同源。换算责任在各自的 backend 身上,
|
|
10
|
+
* core 只认这一个函数。
|
|
11
|
+
*/
|
|
12
|
+
export function sha256Hex(raw: string): string {
|
|
13
|
+
return createHash("sha256").update(raw, "utf8").digest("hex");
|
|
14
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { sha256Hex } from "./hash.js";
|
|
5
|
+
import type { SyncFileInfo } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* manifest 的**格式**版本。形状一旦变化就加一 —— 读到不认识的版本按「无 base」处理,
|
|
9
|
+
* 见 `readManifest` 的说明。
|
|
10
|
+
*/
|
|
11
|
+
export const MANIFEST_VERSION = 2;
|
|
12
|
+
|
|
13
|
+
/** 基线所属的传输端点。只存 origin 的摘要,绝不把可能带凭据的 URL 写进 state。 */
|
|
14
|
+
export interface SyncTransport {
|
|
15
|
+
remoteHash: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 用 fetch 时读到的真实 origin 与配置分支构造传输身份。 */
|
|
20
|
+
export function syncTransport(remote: string, branch: string): SyncTransport {
|
|
21
|
+
return { remoteHash: sha256Hex(remote), branch };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 只有同一 origin 与同一 branch 的基线才能参与三方差集。 */
|
|
25
|
+
export function manifestMatchesTransport(manifest: SyncManifest, transport: SyncTransport): boolean {
|
|
26
|
+
return manifest.transport.remoteHash === transport.remoteHash && manifest.transport.branch === transport.branch;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 上次成功同步留下的基线,即三方合并里「共同祖先」的那一侧。
|
|
31
|
+
*
|
|
32
|
+
* `base.revision` 是那次同步时远端的版本标识(空串表示还没有过基线),`base.syncedAt` 是
|
|
33
|
+
* 记录下来的同步时刻;`files` 是逐文件的指纹,diff 拿本地扫描、远端清单与它三者比对。
|
|
34
|
+
* 它只是**基线**:文件内容永远以 entry 文件本身为准,manifest 丢了不会丢内容。
|
|
35
|
+
*/
|
|
36
|
+
export interface SyncManifest {
|
|
37
|
+
version: number;
|
|
38
|
+
transport: SyncTransport;
|
|
39
|
+
base: { revision: string; syncedAt: string };
|
|
40
|
+
files: Record<string, SyncFileInfo>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 没有基线的 manifest:版本已认、传输身份明确,但一个文件都不记得(等价首次同步)。 */
|
|
44
|
+
export function emptyManifest(transport: SyncTransport): SyncManifest {
|
|
45
|
+
return { version: MANIFEST_VERSION, transport, base: { revision: "", syncedAt: "" }, files: {} };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 一个项目一份基线,落在 `<syncRoot>/state/<projectKey>.json`:key 里不含分隔符(远端布局
|
|
50
|
+
* 已把它压平),所以这里不会创建子目录,state 目录下永远是一层平铺的文件。
|
|
51
|
+
*/
|
|
52
|
+
export function manifestPath(syncRoot: string, projectKey: string): string {
|
|
53
|
+
return join(syncRoot, "state", `${projectKey}.json`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
57
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isFileInfo(value: unknown): value is SyncFileInfo {
|
|
61
|
+
if (!isPlainObject(value)) return false;
|
|
62
|
+
return typeof value.hash === "string" && typeof value.modified === "string" && typeof value.bytes === "number";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 把解析出来的 JSON 收窄成 `SyncManifest`;任一处不对返回 `null`。
|
|
67
|
+
* 逐个字段查是刻意的:manifest 是上次同步的**推断**,不是内容的权威来源,
|
|
68
|
+
* 半信半疑地用它比不用它危险得多。
|
|
69
|
+
*/
|
|
70
|
+
function asManifest(value: unknown): SyncManifest | null {
|
|
71
|
+
if (!isPlainObject(value)) return null;
|
|
72
|
+
if (value.version !== MANIFEST_VERSION) return null;
|
|
73
|
+
|
|
74
|
+
const transport = value.transport;
|
|
75
|
+
if (
|
|
76
|
+
!isPlainObject(transport) ||
|
|
77
|
+
typeof transport.remoteHash !== "string" ||
|
|
78
|
+
!/^[0-9a-f]{64}$/.test(transport.remoteHash) ||
|
|
79
|
+
typeof transport.branch !== "string" ||
|
|
80
|
+
transport.branch === ""
|
|
81
|
+
) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const base = value.base;
|
|
86
|
+
if (!isPlainObject(base) || typeof base.revision !== "string" || typeof base.syncedAt !== "string") return null;
|
|
87
|
+
|
|
88
|
+
const files = value.files;
|
|
89
|
+
if (!isPlainObject(files)) return null;
|
|
90
|
+
for (const info of Object.values(files)) {
|
|
91
|
+
if (!isFileInfo(info)) return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
version: MANIFEST_VERSION,
|
|
96
|
+
transport: { remoteHash: transport.remoteHash, branch: transport.branch },
|
|
97
|
+
base: { revision: base.revision, syncedAt: base.syncedAt },
|
|
98
|
+
files: files as Record<string, SyncFileInfo>,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 读基线。**任何读不懂的情况一律返回 `null`(= 没有基线体系),从不抛错**:
|
|
104
|
+
* 文件不存在、读不到(权限/损坏的盘)、JSON 坏掉、版本不认识、形状不对。
|
|
105
|
+
*
|
|
106
|
+
* 这不是吞错,是一条裁定:把「版本未知」降级成「首次同步」最坏只是多产生几条冲突副本
|
|
107
|
+
* (两边内容都还在,人可以选择),而报错会让同步彻底停摆,猜一份半可信的基线则可能
|
|
108
|
+
* 静默覆盖掉一端的内容。三者里只有降级是不丢文件的。
|
|
109
|
+
*/
|
|
110
|
+
export async function readManifest(path: string): Promise<SyncManifest | null> {
|
|
111
|
+
const raw = await readFile(path, "utf8").catch(() => null);
|
|
112
|
+
if (raw === null) return null;
|
|
113
|
+
|
|
114
|
+
let parsed: unknown;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return asManifest(parsed);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 同目录下的临时文件名:`<path>.tmp-<pid>-<随机>`,不带目录层级,`rename` 因此不会跨卷。 */
|
|
125
|
+
function tempPath(path: string): string {
|
|
126
|
+
return `${path}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 写基线,**原子**:先写同目录临时文件、再 `rename` 覆盖目标。
|
|
131
|
+
*
|
|
132
|
+
* 直接写目标文件会留下「写了一半的 JSON」这一窗口,而本地目录就可能是云盘同步目录 /
|
|
133
|
+
* 网络盘:读者(另一次同步、另一个进程)撞上它只会得到「读不懂 → 无 base」,
|
|
134
|
+
* 白白丢掉一次基线并多写出冲突副本。`rename` 在同一目录内是原子的替换,
|
|
135
|
+
* 读者要么看到旧文件、要么看到完整的新文件。
|
|
136
|
+
*
|
|
137
|
+
* **这里刻意不与 `device.ts` 共用一个 `writeJsonAtomic`**(这件事已经被提过两次,所以把决定写下来):
|
|
138
|
+
* 基线要的就是上面这一句「原子替换」—— 目标名上没有需要保护的东西,盖掉旧的即可;而身份文件要的是
|
|
139
|
+
* **排他创建**(`link` 原子失败于 `EEXIST`)+ 发布后读回 + 无硬链接时退回覆盖,那是三种发布形态。
|
|
140
|
+
* 合成一个函数必然带 flag,而 flag 恰好会把 `device.ts` 里那条论证清楚的排他性边界
|
|
141
|
+
*(何时排他、何时不排他、不排他时靠什么兜)压成一个参数默认值。两处重复的只有「写临时文件」这几行。
|
|
142
|
+
*/
|
|
143
|
+
export async function writeManifest(path: string, manifest: SyncManifest): Promise<void> {
|
|
144
|
+
await mkdir(dirname(path), { recursive: true });
|
|
145
|
+
const temp = tempPath(path);
|
|
146
|
+
await writeFile(temp, `${JSON.stringify(manifest, null, "\t")}\n`, "utf8");
|
|
147
|
+
await rename(temp, path);
|
|
148
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { normalizeRemoteUrl, projectDirName } from "../store/paths.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 远端的项目布局恒用 POSIX 命名规则,与宿主平台无关。
|
|
7
|
+
*
|
|
8
|
+
* 远端布局是用户自己仓库里的**数据格式**:写下去之后每台设备都按它找文件,迁移代价极高。
|
|
9
|
+
* 若跟随宿主平台,win32 上的 `windowsSafeName`(会把结尾的 `.`、空格改写成 `_2e`、`_20`)
|
|
10
|
+
* 与 cygwin git 打印的 POSIX 形态路径会让同一个仓库在两台设备上算出两个远端目录 ——
|
|
11
|
+
* 不报错,只静默分叉成两份记忆。见设计文档。
|
|
12
|
+
*
|
|
13
|
+
* 因此这里不含 `platform` 参数:平台无关是它存在的全部理由。
|
|
14
|
+
*/
|
|
15
|
+
export function remoteProjectKey(identityKey: string): string {
|
|
16
|
+
// `NodeJS.Platform` 没有 `posix` 这个值,而 `projectDirName` 只在 win32 上分支 ——
|
|
17
|
+
// 取任一非 win32 值(与 `store/paths.ts` 的测试同用 `linux`)得到的就是 POSIX 规则。
|
|
18
|
+
return projectDirName(identityKey, { platform: "linux" });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 两个 remote 是否指向「同一个地方」。
|
|
23
|
+
*
|
|
24
|
+
* `normalizeRemoteUrl` 统一处理 http(s)/ssh/git 与 scp 写法;`file://` 与本地路径按 resolved 字节比较。
|
|
25
|
+
* 这是初始化共存检查与宿主配置漂移提示共用的唯一实现:两份比较器一旦漂移,一边会误拒初始化,
|
|
26
|
+
* 另一边却可能连提醒都不给。比不出来宁可归成「不同」(多一条 warning),不要拿猜测去拒绝用户。
|
|
27
|
+
*/
|
|
28
|
+
export function sameSyncRemote(left: string, right: string): boolean {
|
|
29
|
+
const key = (value: string): string => {
|
|
30
|
+
const normalized = normalizeRemoteUrl(value);
|
|
31
|
+
if (normalized !== null) return `url:${normalized}`;
|
|
32
|
+
const raw = value.trim();
|
|
33
|
+
if (/^file:\/\//i.test(raw)) {
|
|
34
|
+
try {
|
|
35
|
+
return `path:${resolve(fileURLToPath(raw))}`;
|
|
36
|
+
} catch {
|
|
37
|
+
return `path:${resolve(raw.replace(/^file:\/\//i, ""))}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return `path:${resolve(raw)}`;
|
|
41
|
+
};
|
|
42
|
+
return key(left) === key(right);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 远端布局里「全部记忆都放在这个目录下」的那一段:`git/`。
|
|
47
|
+
*
|
|
48
|
+
* 字面值只存这一份:引擎算 scope、适配层渲染远端路径、以及将来可能的「列出远端有哪些项目」
|
|
49
|
+
* 都靠它。多一处硬编码就多一处布局改动时会静默分叉的地方(与 `SYNC_DIR_NAME` 同一个理由)。
|
|
50
|
+
*/
|
|
51
|
+
const REMOTE_ROOT_DIR = "git";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 已经是 `remoteProjectKey` 输出的 key → 它在远端的前缀。
|
|
55
|
+
*
|
|
56
|
+
* 与 `remotePrefix` 的区别就在入参:那边收的是**身份 key**(会再派生一次),这边收的是
|
|
57
|
+
* 已经派生好的项目 key。拿错一个就是二次转义(`github.com__o__r` 变成 `github.com____o____r`),
|
|
58
|
+
* 而这个错不报错:只是远端从此多出一个谁也不认的目录,同步看起来正常但两边各存一份。
|
|
59
|
+
*/
|
|
60
|
+
export function remotePrefixFromKey(projectKey: string): string {
|
|
61
|
+
return `${REMOTE_ROOT_DIR}/${projectKey}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 单个项目在远端的前缀:`git/<远端 key>`。入参是**身份 key**,在这里派生成项目 key。 */
|
|
65
|
+
export function remotePrefix(identityKey: string): string {
|
|
66
|
+
return remotePrefixFromKey(remoteProjectKey(identityKey));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 同步状态目录在记忆根下的名字:`<记忆根>/.sync`。
|
|
71
|
+
*
|
|
72
|
+
* 这个字面值是**用户可见的布局契约**:它出现在记忆根下(用户自己的备份与清理脚本会撞上它),
|
|
73
|
+
* 而引擎、git backend、设备身份与 manifest 都把它当入参 `syncRoot` 收发。
|
|
74
|
+
* 它**不是**记忆内容:`createSnapshot` 收的是显式文件清单,扫的是项目记忆目录那一层,
|
|
75
|
+
* 所以快照里本来就不会有它 —— 不要为了「排除」而在哪里再加一条规则。适配层若各自写一遍
|
|
76
|
+
* `join(memoryRoot, ".sync")`,布局一改就要同步改若干处 —— 所以派生只留在这里一份,
|
|
77
|
+
* 适配层一律调 `syncRootPath`。
|
|
78
|
+
*/
|
|
79
|
+
export const SYNC_DIR_NAME = ".sync";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 记忆根 → 同步状态目录(`syncRoot`)。所有同步入口的 `syncRoot` 入参都由它派生,不要就地拼字符串。
|
|
83
|
+
*
|
|
84
|
+
* **入参是记忆根,不是某个项目的记忆目录**,也就是 `config.memoryDir` / {@link defaultMemoryRoot}
|
|
85
|
+
* 那一个(`resolveMemoryDir` 在它下面再派生出 `<kind>/<项目>` 两层)。这两者在代码里都叫过
|
|
86
|
+
* 「memoryDir」,传错**不会报错**,只会静默降级,而且**没有任何测试会红**(这正是它危险的地方):
|
|
87
|
+
*
|
|
88
|
+
* - `.sync` 是**整棵树共享一份**的状态:设备身份(`<hostname>-<id>`)、基线 manifest、以及那一个
|
|
89
|
+
* git 克隆(远端按 `git/<项目 key>/` 前缀区分项目)。传项目目录的话,每个项目各自克隆一遍整个
|
|
90
|
+
* 远端、各自生成一份设备身份、各自持一把根锁 —— 同一台机器在 `git log` 里变成好几台设备,
|
|
91
|
+
* 磁盘上多出 N 份整棵树大小的副本,而「一份 clone 服务全部项目」这个前提也没了。
|
|
92
|
+
* - 扫描器**看不见**它:`.sync` 以点开头,而 `#entryFiles` / `scanLocalEntries` / `snapshotFiles`
|
|
93
|
+
* 全都按「非点开头」过滤,所以没有任何扫描会把这份误放的状态报出来。别指望「跑一次测试或
|
|
94
|
+
* 看一次备份清单就能发现」。
|
|
95
|
+
*
|
|
96
|
+
* 所以参数名刻意不叫 `memoryDir`:调用点写出来必须看得出传的是根。
|
|
97
|
+
*/
|
|
98
|
+
export function syncRootPath(memoryRoot: string): string {
|
|
99
|
+
return join(memoryRoot, SYNC_DIR_NAME);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 根级 sync 锁的路径:`<syncRoot>/sync.lock`。
|
|
104
|
+
*
|
|
105
|
+
* 引擎的传输段与发布段各取一次这把锁(两段之间放开),而适配层还需要一个「清除遗弃根锁」的
|
|
106
|
+
* 入口:`tryWithLock` 对遗弃锁是**抛错**而不是返回 null(刻意设计),所以一次崩溃会让此后每一轮
|
|
107
|
+
* 同步都失败,而那句 “delete the file to clear it” 没人够得着。两边必须算出**同一个文件** ——
|
|
108
|
+
* 各自拼字符串的话,那条命令删的是一个没人用的文件,而真正卡住的锁永远在那里。
|
|
109
|
+
*/
|
|
110
|
+
export function syncLockPath(syncRoot: string): string {
|
|
111
|
+
return join(syncRoot, "sync.lock");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 项目内某个文件在远端的路径。 */
|
|
115
|
+
export function remoteEntryPath(prefix: string, file: string): string {
|
|
116
|
+
return `${prefix}/${file}`;
|
|
117
|
+
}
|
package/src/sync/scan.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parseEntryFile } from "../store/entry-file.js";
|
|
4
|
+
import { isEntryFileNameForRead } from "../store/filename.js";
|
|
5
|
+
import { sha256Hex } from "./hash.js";
|
|
6
|
+
import type { SyncFileInfo, SyncFileSet } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 本地扫描的结果。`files` 是三方合并里「ours」这一侧;`unparsable` 是「看得见但同步不了」的清单,
|
|
10
|
+
* 单独列出来是刻意的:它们必须能被报给用户(「本地有 N 个 .md 解析不出,未同步」),
|
|
11
|
+
* 而不是既不在 `files` 里、也不在任何地方出现 —— 那就成了静默丢记忆。
|
|
12
|
+
*/
|
|
13
|
+
export interface LocalScan {
|
|
14
|
+
files: SyncFileSet;
|
|
15
|
+
unparsable: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 扫描 memory 目录,产出每个 entry 的内容指纹、语义修改时间与字节数。
|
|
20
|
+
*
|
|
21
|
+
* 文件名过滤**只用**读方向的谓词(`isEntryFileNameForRead`),不在这里另抄一份判据:读路径(`MemoryStore`
|
|
22
|
+
* 的目录扫描)与同步扫描口径一旦漂移,就会出现「store 看得到、同步看不到」(或反过来)的记忆 ——
|
|
23
|
+
* 静默丢记忆。注意方向:这里扫的是**本机已经存在的文件**,所以用读口径(保留名按平台分支);
|
|
24
|
+
* 从网络进来的名字一律走写口径,理由写在引擎那一侧。
|
|
25
|
+
* **调用方必须传 store 配置里的 platform(`store.cfg.platform`),不是宿主平台**:`MemoryStore` 按
|
|
26
|
+
* `cfg.platform ?? process.platform` 解析,配置值没被透传时,保留设备名的口径会在两条路径上分叉。
|
|
27
|
+
* 保留设备名在 win32 上必须剔除(按名打开会命中控制台),而在 Linux / macOS 上 `con.md` 是
|
|
28
|
+
* 一条合法记忆,全平台剔除等于把它抹掉。
|
|
29
|
+
*
|
|
30
|
+
* 目录级失败**只有 `ENOENT` 降级成空结果**(首次同步前目录可能还没建,「目录不存在」正是「本机什么
|
|
31
|
+
* 都没有」的正确语义);其余 errno(`EACCES`/`EIO`/`EMFILE`/网络盘…)**必须抛出去**。这是数据安全线,
|
|
32
|
+
* 不是风格问题:扫描结果是三方合并的「ours」一侧,而「base 里有、ours 里没有、theirs 没动」在合并
|
|
33
|
+
* 语义里等于**用户在本地删了它**,引擎会把该删除传播到远端。一次瞬时或权限性的 readdir 失败若被
|
|
34
|
+
* 当成「目录是空的」,等于宣布「本机删光了全部记忆」,会把对端设备上的记忆全部删除 —— 而端口没有
|
|
35
|
+
* 别的通道能表达「扫描失败」,调用方无从分辨。抛出去之后由引擎捕获、把本轮变成跳过(fail-closed)。
|
|
36
|
+
*
|
|
37
|
+
* 文件级的两件「不抛」,是为了让一轮同步不因为一个文件而整体失败:
|
|
38
|
+
* - **单个文件读失败**(权限、刚被删掉)→ 该文件进 `unparsable`,其余照常扫描。同一口径也适用于符号
|
|
39
|
+
* 链接:目标`stat`失败时,只有 `ENOENT`/`ENOTDIR`(目标确实不存在)算「不是 entry」而跳过,
|
|
40
|
+
* 其余 errno 进 `unparsable` —— 理由与目录级相同,名字不能同时从两侧消失。
|
|
41
|
+
* - **`parseEntryFile` 返回 null** → 同样进 `unparsable`:解析不出 `modified` 就没法定序,也就无法安全参与合并。
|
|
42
|
+
* 单个坏文件与整个目录读不了是两件事:前者可报告可跳过,后者必须中止。
|
|
43
|
+
*
|
|
44
|
+
* 行尾算内容的一部分:`parseEntryFile` 解析前会归一 CRLF,但 hash 打的是**原始字节** —— 这是有意的,
|
|
45
|
+
* 归一再 hash 等于把归一化义务悄悄推给每个 backend 的字节契约。
|
|
46
|
+
*
|
|
47
|
+
* `bytes` 是 UTF-8 字节数而不是 JS 字符串长度:同一份记忆在不同设备上字符数必然相同,字节数才反映落盘体积。
|
|
48
|
+
* `modified` 取 frontmatter 里的值而不是文件系统 mtime —— mtime 会随复制、检出、`touch` 一起漂移。
|
|
49
|
+
*/
|
|
50
|
+
export async function scanLocalEntries(
|
|
51
|
+
memoryDir: string,
|
|
52
|
+
platform: NodeJS.Platform = process.platform,
|
|
53
|
+
): Promise<LocalScan> {
|
|
54
|
+
const files: SyncFileSet = new Map();
|
|
55
|
+
const unparsable: string[] = [];
|
|
56
|
+
|
|
57
|
+
// 只有 ENOENT 降级成空结果,其余 errno 原样抛出 —— 三方合并 ours 侧的数据安全线,见 doc comment。
|
|
58
|
+
const dirents = await readdir(memoryDir, { withFileTypes: true }).catch((err: unknown) => {
|
|
59
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
60
|
+
throw err;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const entries: string[] = [];
|
|
64
|
+
for (const dirent of dirents) {
|
|
65
|
+
if (!isEntryFileNameForRead(dirent.name, platform)) continue;
|
|
66
|
+
if (dirent.isSymbolicLink()) {
|
|
67
|
+
// store 的 stat 跟随链接:指向普通文件的符号链接在 store 眼里是合法 entry,扫描也必须看得见,
|
|
68
|
+
// 且只对符号链接多付一次 stat。
|
|
69
|
+
const target = await stat(join(memoryDir, dirent.name)).catch((err: unknown) => {
|
|
70
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
71
|
+
// 只有「目标确实不存在」才等于「不是 entry」,与 listEntries() 的 stat + isFile() 同口径:
|
|
72
|
+
// 断链是 ENOENT,链接路径中某一段不是目录是 ENOTDIR。
|
|
73
|
+
if (code === "ENOENT" || code === "ENOTDIR") return null;
|
|
74
|
+
// 其余 errno(EMFILE / EIO / EACCES / 网络盘……)是「这一次读不出目标」,不是「目标不存在」,
|
|
75
|
+
// 必须进 unparsable。若跟着静默跳过,这个名字会同时从 files 与 unparsable 里消失 —— 而扫描是
|
|
76
|
+
// 三方合并的 ours 侧,「base 有、ours 没有」读作**用户删了它**,引擎会把这条删除传播到远端,
|
|
77
|
+
// 删掉对端设备上仍在盘的那份记忆。进了 unparsable,调用方才能判成「不可判定」而不是「删除」。
|
|
78
|
+
unparsable.push(dirent.name);
|
|
79
|
+
return null;
|
|
80
|
+
});
|
|
81
|
+
if (!target?.isFile()) continue;
|
|
82
|
+
} else if (!dirent.isFile()) {
|
|
83
|
+
// 目录、FIFO、socket 一律跳过:与 listEntries() 的 stat + isFile() 同口径;也是安全要求 ——
|
|
84
|
+
// 对 FIFO 调 readFile 会永久阻塞,而扫描挂在会话启动路径上。
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
entries.push(dirent.name);
|
|
88
|
+
}
|
|
89
|
+
// 排序只为稳定:同一目录两次扫描、两台设备之间,`unparsable` 的报告顺序都不该随机跳动。
|
|
90
|
+
entries.sort();
|
|
91
|
+
|
|
92
|
+
for (const name of entries) {
|
|
93
|
+
const raw = await readFile(join(memoryDir, name), "utf8").catch(() => null);
|
|
94
|
+
if (raw === null) {
|
|
95
|
+
unparsable.push(name);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const parsed = parseEntryFile(raw);
|
|
99
|
+
if (!parsed) {
|
|
100
|
+
unparsable.push(name);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const info: SyncFileInfo = {
|
|
104
|
+
hash: sha256Hex(raw),
|
|
105
|
+
modified: parsed.meta.modified,
|
|
106
|
+
bytes: Buffer.byteLength(raw, "utf8"),
|
|
107
|
+
};
|
|
108
|
+
files.set(name, info);
|
|
109
|
+
}
|
|
110
|
+
// 符号链接 stat 失败的名字在 dirent 循环里就已进 unparsable(早于 entries 排序),所以这里再排一次,
|
|
111
|
+
// 才能让「同一目录两次扫描、两台设备之间」的报告顺序稳定。
|
|
112
|
+
unparsable.sort();
|
|
113
|
+
|
|
114
|
+
return { files, unparsable };
|
|
115
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写触发的去抖调度器:把「本地刚写过」收敛成「一轮推送」。
|
|
3
|
+
*
|
|
4
|
+
* **为什么触发源是写入,不是时钟。** 按固定间隔盲推,一个「写一条、14 分钟后结束」的会话
|
|
5
|
+
* 可能一条都没推出去;而每次写入都推,则一轮整理记忆的任务重写了十几个条目,就会变成十几次
|
|
6
|
+
* 提交与十几个网络往返。所以:任何一次成功的记忆写入都标脏,再用一段静默期把这一串合并成一次。
|
|
7
|
+
* 逻辑锁还免费帮了一手 —— 整理记忆的那一轮全程持着项目的进程内锁,期间到点的推送会在应用段
|
|
8
|
+
* 直接跳过,而定时器到点的时间在那一轮结束之后:一轮整理,一次推送。
|
|
9
|
+
*
|
|
10
|
+
* 这里只有四样东西:一个脏标记、一个「上一轮没发布成功」的标记、一个待跑的去抖定时器、
|
|
11
|
+
* 一个在途的 promise 串行闸。
|
|
12
|
+
*
|
|
13
|
+
* **对外的 `dirty` 读数是「有内容尚未成功发布」,不是「定时器还没到点」。** 一轮推送完全可能白跑
|
|
14
|
+
* (撞上锁被跳过、远端不可达、传输失败),那种情况下内容仍然只在本地;若把它算成干净,宿主退出前
|
|
15
|
+
* 问一句「还有没有没推出去的」会得到「没有」,那几条记忆就再也没有自动补推的机会 —— 内容没丢,
|
|
16
|
+
* 但触发器丢了,只能等下一次写入或下一次会话的补推。
|
|
17
|
+
*
|
|
18
|
+
* 四条宿主侧的硬要求在实现处各写了一段「为什么不能改」(`schedule` 里的 `unref`、`noteWrite` 里的
|
|
19
|
+
* 「已有定时器不重置」、`settle` 里的「不因未发布而自动重排」与「整个收尾包在 try 里」);
|
|
20
|
+
* 它们看起来都像可以顺手「优化」掉的多余代码。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface SyncSchedulerOptions<T = unknown> {
|
|
24
|
+
/** 静默期:从**第一次**标脏起算。`0` 表示「下一个 tick 就推」。 */
|
|
25
|
+
debounceMs: number;
|
|
26
|
+
/** 一次推送的时限,原样交给 `run`。 */
|
|
27
|
+
timeoutMs: number;
|
|
28
|
+
/**
|
|
29
|
+
* 真正干活的一次推送。**返回 `null` 或 reject 都表示「这一次没有发布成功」**(被锁跳过、传输失败、
|
|
30
|
+
* 未配置……),调度器据此保留「有内容待发布」的读数。
|
|
31
|
+
*
|
|
32
|
+
* 所以调用方必须自己把「跳过或失败的一轮」映射成 `null`:引擎那边一轮同步永远 resolve 出一个结果
|
|
33
|
+
* 对象(失败也写在 `skipped.reason` 里,从不 reject),原样交过来的话调度器看到的就是「永远成功」,
|
|
34
|
+
* 于是退出兜底会谎报干净。
|
|
35
|
+
*/
|
|
36
|
+
run: (timeoutMs: number) => Promise<T | null>;
|
|
37
|
+
/*
|
|
38
|
+
* 这里刻意**没有**时钟注入位。去抖只靠定时器,没有任何一处决策要读时间;留一个没人读的
|
|
39
|
+
* `now?` 只会让调用方以为时间语义可注入(进而以为可以在测试里拨快静默期)。
|
|
40
|
+
*/
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 泛型 `T` 是「一轮推送的结果」,由调用方定义。带上它是为了让引擎那一侧不必写
|
|
45
|
+
* `scheduler.flush(t) as Promise<SyncRoundResult | null>` —— 那个向下转型是这道接缝上唯一
|
|
46
|
+
* 一处「编译器不再帮忙」的地方。
|
|
47
|
+
*/
|
|
48
|
+
export interface SyncScheduler<T = unknown> {
|
|
49
|
+
/** 记下「本地刚写过」:置脏并排一次去抖推送。收摊之后空转。 */
|
|
50
|
+
noteWrite(): void;
|
|
51
|
+
/**
|
|
52
|
+
* 立刻执行一次(不等去抖),供退出兜底与启动补推用。`timeoutMs` 显式给出时覆盖配置的自动档时限。
|
|
53
|
+
*
|
|
54
|
+
* 返回 `null` 的**四种**情形,按控制流顺序:
|
|
55
|
+
* (1)调度器已经 `dispose()` —— 这一条**立刻**返回,**不等** `inFlight`(收摊之后谁都不等,
|
|
56
|
+
* 也不启动任何新的推送 I/O);
|
|
57
|
+
* (2)调用时有在途的一轮(见下):等它收尾,而等的过程中**又**起了一轮(另一个 `flush`,或去抖
|
|
58
|
+
* 定时器到点)→ 返回 `null`,既不插一脚,也**不冒领**那一轮的结果(它成功也照样是 `null`);
|
|
59
|
+
* (3)没有在途轮次、或等它收尾之后没有新的在途轮次,且 `dirty` 与 `unpublished` 都为空
|
|
60
|
+
* → 没有待发布的内容,连 `run` 都不调,返回 `null`;
|
|
61
|
+
* (4)本次自己跑的那一轮也没发布成功(`run` 返回 `null` 或 reject)。
|
|
62
|
+
* **`null` 从不表示成功**,只是「本次没有可回报的结果」;成败要看返回值本身。
|
|
63
|
+
* **绝不把「调用时恰好在途的那一轮」的结果当作本次的返回值**:宿主问的是「我还有没有没推出去的」,
|
|
64
|
+
* 拿一个它没要求过的轮次的结果回答,等于把「那一轮成功了」冒充成「现在干净了」。情形(2)里
|
|
65
|
+
* 连「那一轮成功了」都不回报,是同一条理由:它不是本次 flush 发起的那一轮。
|
|
66
|
+
* 除 `dispose()` 那一情形之外,flush 会等那一轮收尾(绝不并发);收尾之后若没有新的在途轮次,
|
|
67
|
+
* 且仍有待发布内容,就接着跑属于自己的那一轮并报它的结果。
|
|
68
|
+
*/
|
|
69
|
+
flush(timeoutMs?: number): Promise<T | null>;
|
|
70
|
+
/**
|
|
71
|
+
* 取消定时器并丢弃「尚未发布」的两个标记。绝不抛错。可重复调用。
|
|
72
|
+
*
|
|
73
|
+
* 在途的那一次不在这里取消(调度器够不到传输层):取消由调用方做 —— 引擎的 `dispose()`
|
|
74
|
+
* 会先 abort 那一轮的中止信号,再转发到这里。
|
|
75
|
+
*/
|
|
76
|
+
dispose(): void;
|
|
77
|
+
/**
|
|
78
|
+
* 在途的那一次推送(没有则 `null`)—— 宿主把它挂进自己的「退出前等在途写」账本。
|
|
79
|
+
* 值类型里带 `null` 是必须的:一轮推送可能没发布成功,而那个结果正是经由这份 promise 流出去的
|
|
80
|
+
* (写成 `Promise<T>` 就得在赋值处撒谎)。宿主用它来**等**,不用它来判断成败。
|
|
81
|
+
*/
|
|
82
|
+
readonly inFlight: Promise<T | null> | null;
|
|
83
|
+
/** 有内容尚未成功发布:要么还没推过,要么上一次推送被跳过/失败了。 */
|
|
84
|
+
readonly dirty: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createSyncScheduler<T = unknown>(options: SyncSchedulerOptions<T>): SyncScheduler<T> {
|
|
88
|
+
const { debounceMs, timeoutMs, run } = options;
|
|
89
|
+
|
|
90
|
+
/** 待跑的那一次(去抖定时器)。 */
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
92
|
+
/** 静默期里攒下、还没开始推的写入。 */
|
|
93
|
+
let dirty = false;
|
|
94
|
+
/**
|
|
95
|
+
* 上一次推送**没发布成功**(`run` 返回 `null` 或 reject)。与 `dirty` 分开存,因为两者要的行为不同:
|
|
96
|
+
* `dirty` 是「有新内容」,到点就该推;它是「旧内容还压在本地」,**不许**触发自动重排(见 `settle`)。
|
|
97
|
+
*/
|
|
98
|
+
let unpublished = false;
|
|
99
|
+
/** 在途的那一次推送:既是串行闸,也是暴露给宿主的账本条目。 */
|
|
100
|
+
let inFlight: Promise<T | null> | null = null;
|
|
101
|
+
/** 收摊之后不再接新写入、也不再排定时器。 */
|
|
102
|
+
let disposed = false;
|
|
103
|
+
|
|
104
|
+
function cancelTimer(): void {
|
|
105
|
+
if (timer === null) return;
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
timer = null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function schedule(): void {
|
|
111
|
+
// 已有定时器就不重建:静默期从第一次写入起算,见 noteWrite
|
|
112
|
+
if (disposed || timer !== null) return;
|
|
113
|
+
timer = setTimeout(onTimer, debounceMs);
|
|
114
|
+
/*
|
|
115
|
+
* 定时器必须 unref。ref 着的定时器会**吊住事件循环**:一个写完一条就关掉的进程,
|
|
116
|
+
* 退出会被硬拖到静默期到点为止(窗口里什么都没发生,就是退不掉)。宿主侧那个
|
|
117
|
+
* 「退出前等在途写」的兜底定时器出于同样的理由也做了这一步。
|
|
118
|
+
* 可选调用:宿主若把 core 打进非 Node 的运行时,那边的定时器没有 unref。
|
|
119
|
+
*/
|
|
120
|
+
timer.unref?.();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onTimer(): void {
|
|
124
|
+
timer = null;
|
|
125
|
+
// 已有推送在跑:绝不起第二次。同一份远端暂存 clone 上两个并发 push 会互相
|
|
126
|
+
// `reset --hard` 掉对方刚铺好的内容。脏标记留着,在途那一次收尾时重排(见 runNow)。
|
|
127
|
+
if (inFlight !== null || !dirty) return;
|
|
128
|
+
void runNow(timeoutMs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 跑一次。调用方负责串行(本函数不做并发检查),成功后由收尾负责补排。 */
|
|
132
|
+
function runNow(runTimeoutMs: number): Promise<T | null> {
|
|
133
|
+
cancelTimer(); // 无论是定时器到点还是 flush 抢先,都不留待跑的那一个
|
|
134
|
+
// 两个标记都先清掉:这一轮就是它们的出口。跑完按结果重新置(见下面的 catch 与 null 分支)。
|
|
135
|
+
// 到点就清 dirty 是必须的,否则收尾会一直重排、永远推不完。
|
|
136
|
+
dirty = false;
|
|
137
|
+
unpublished = false;
|
|
138
|
+
const started: Promise<T | null> = (async () => {
|
|
139
|
+
try {
|
|
140
|
+
const result = await run(runTimeoutMs);
|
|
141
|
+
// 白跑的一轮(被锁跳过、传输失败)不算发布:内容仍然只在本地,得活到 flush 再试一次。
|
|
142
|
+
// **收摊之后不再置起来**:dispose-while-in-flight 是被显式支持的,而这一轮很可能正因为
|
|
143
|
+
// 被中止而失败 —— 那时把标记重新置起,等于在宿主已经明说「不再等了」之后又留了一个
|
|
144
|
+
// 「有东西没推出去」,接着一次 flush() 就会启动全新的推送 I/O(见 flush 的守卫)。
|
|
145
|
+
if (result === null && !disposed) unpublished = true;
|
|
146
|
+
return result;
|
|
147
|
+
} catch {
|
|
148
|
+
/*
|
|
149
|
+
* 就地吞掉。后台推送的失败一旦冒泡成未处理拒绝,掀掉的是**宿主进程**,
|
|
150
|
+
* 而失败本身引擎已经处理了:基线不前进、内容还在本地、下一次写入或 flush 再试。
|
|
151
|
+
* 所以这里连一条日志都不打 —— 用户该看到的是状态报告,不是一行栈。
|
|
152
|
+
*/
|
|
153
|
+
// 同上:收摊之后不再置起来(被 dispose 中止的那一轮正是从这里回来的)
|
|
154
|
+
if (!disposed) unpublished = true;
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
inFlight = started;
|
|
159
|
+
const settle = (): void => {
|
|
160
|
+
/*
|
|
161
|
+
* 整个收尾包在 try 里。settle 挂在 `started.then(settle, settle)` 上,而那个 promise 被
|
|
162
|
+
* `void` 掉了:收尾里逃出去的任何异常都会变成一个**没人接的拒绝**,正好绕过上面
|
|
163
|
+
* 「推送失败绝不冒泡」的承诺(`schedule()` 里的 `setTimeout` 在某些运行时是会抛的)。
|
|
164
|
+
*/
|
|
165
|
+
try {
|
|
166
|
+
if (inFlight === started) inFlight = null;
|
|
167
|
+
/*
|
|
168
|
+
* 跑的过程中又被标脏(比如整理记忆那一轮还在继续写)→ 收尾后再去抖一次。
|
|
169
|
+
*
|
|
170
|
+
* **只看 `dirty`,绝不看 `unpublished`。** 少这个条件不是漏写:未发布就自动重排,
|
|
171
|
+
* 在远端持续不可达时会变成每 `debounceMs` 一次、永不停止的重试风暴(每次都真去连一遍网、
|
|
172
|
+
* 每次都真去抢一遍锁)。未发布的那份内容只有两个补推入口 —— 下一次写入(`noteWrite`),
|
|
173
|
+
* 以及宿主显式 `flush()`(退出兜底、下次会话补推)。看到这一行想「顺手修好」之前,
|
|
174
|
+
* 请先想清楚谁负责停下来。
|
|
175
|
+
*/
|
|
176
|
+
if (dirty) schedule();
|
|
177
|
+
} catch {
|
|
178
|
+
// 收尾失败最多是少排一次定时器:下一次写入照样会排。它绝不能变成未处理拒绝。
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
// 两个回调都给 settle:started 自己已经吞掉全部错误,派生出的 promise 也就不会拒
|
|
182
|
+
void started.then(settle, settle);
|
|
183
|
+
return started;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function flush(runTimeoutMs?: number): Promise<T | null> {
|
|
187
|
+
// 收摊之后的 flush 一律是「没有待发布的内容」(返回 null):宿主退出路径上问的就是这一句,
|
|
188
|
+
// 而 dispose 已经明说不再等了。少了这道守卫,在途那一轮收尾时重新置起的 `unpublished`
|
|
189
|
+
// 会让这里启动一次**全新的**推送 I/O —— 拆除之后又开始联网,正是宿主最不能接受的那件事。
|
|
190
|
+
if (disposed) return null;
|
|
191
|
+
const budget = runTimeoutMs ?? timeoutMs;
|
|
192
|
+
// flush 跑的是「此刻该跑的那一次」:先取消待跑的去抖定时器,别让它在同一份工作上空转
|
|
193
|
+
cancelTimer();
|
|
194
|
+
const foreign = inFlight;
|
|
195
|
+
if (foreign !== null) {
|
|
196
|
+
// 已有推送在跑时绝不并发起第二次:等它收尾(started 自己吞掉全部错误,不会拒)
|
|
197
|
+
await foreign;
|
|
198
|
+
// 等的过程中又有一轮起来了(另一个 flush,或去抖定时器到点)→ 不插一脚,也不冒领它的结果
|
|
199
|
+
if (inFlight !== null) return null;
|
|
200
|
+
}
|
|
201
|
+
// 没有待发布的内容:连 run 都不调。宿主正是靠这个 null 判断「可以退了」
|
|
202
|
+
if (!dirty && !unpublished) return null;
|
|
203
|
+
return runNow(budget);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
noteWrite(): void {
|
|
208
|
+
if (disposed) return;
|
|
209
|
+
dirty = true;
|
|
210
|
+
/*
|
|
211
|
+
* 已有定时器就**不重置**。静默期要按第一次写入起算:若每次写入都刷新定时器,
|
|
212
|
+
* 一段持续的写入流(整理记忆、批量导入)会把推送无限延后 —— 那不去抖,是饿死。
|
|
213
|
+
* 反过来,用「最近一次写入 + 静默期」在当前实现里得不到任何好处:写入结束时
|
|
214
|
+
* 要么已经没有新写入(不重置也照样到点),要么还在写(重置只会更晚)。
|
|
215
|
+
*/
|
|
216
|
+
schedule();
|
|
217
|
+
},
|
|
218
|
+
flush,
|
|
219
|
+
dispose(): void {
|
|
220
|
+
// 绝不抛错:宿主在退出路径上调用它,那里再抛一次就是「退不掉」。
|
|
221
|
+
// 在途的那一次**调度器自己**取消不了:它手上只有一个 `run` 回调,够不到传输层。
|
|
222
|
+
// 取消是调用方的事 —— 引擎的 `dispose()` 会 abort 那一轮交给 backend 的中止信号。
|
|
223
|
+
// 宿主的账本仍要先等它(`await inFlight`):中止是「尽快收尾」,不是「立刻就不在了」。
|
|
224
|
+
disposed = true;
|
|
225
|
+
cancelTimer();
|
|
226
|
+
dirty = false;
|
|
227
|
+
// 「没发布成功」这个标记一起丢:收摊就是宿主明说「不再等了」,
|
|
228
|
+
// 留着它只会让 `dirty` 在收摊之后继续报「有东西没推出去」。
|
|
229
|
+
// 在途那一轮收尾时会试图把它置回来(它多半正是被这次中止弄失败的),
|
|
230
|
+
// 所以那两处都带 `!disposed` 守卫、flush 也有一道 —— 三者是同一条不变量的三个面。
|
|
231
|
+
unpublished = false;
|
|
232
|
+
},
|
|
233
|
+
get inFlight(): Promise<T | null> | null {
|
|
234
|
+
return inFlight;
|
|
235
|
+
},
|
|
236
|
+
get dirty(): boolean {
|
|
237
|
+
// 对外语义是「有内容尚未成功发布」:还没推的、以及推了但没发出去的,都算
|
|
238
|
+
return dirty || unpublished;
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步的共享词汇:单个 entry 文件的内容指纹,以及「文件名 → 指纹」的集合。
|
|
3
|
+
*
|
|
4
|
+
* 刻意只有类型、没有运行时代码:这些名字被扫描、diff、冲突解决与同步引擎共同引用,
|
|
5
|
+
* 任一环引入运行时依赖都会把整条链绑死。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 某个 entry 文件在某一时刻的样子(manifest 与远端清单比对的就是这三个字段)。 */
|
|
9
|
+
export interface SyncFileInfo {
|
|
10
|
+
/** 内容哈希(`sha256Hex` 的输出)。 */
|
|
11
|
+
hash: string;
|
|
12
|
+
/** 文件 frontmatter 里的 `modified`,即语义上的「何时被改」,不是文件系统 mtime。 */
|
|
13
|
+
modified: string;
|
|
14
|
+
/** 内容字节数(UTF-8),用于在哈希偶然相同时仍能看出体积异常。 */
|
|
15
|
+
bytes: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 一组 entry 文件。key 是**单层** entry 文件名(如 `Use-real-DB-in-tests.md`),不含目录 ——
|
|
20
|
+
* 记忆目录是扁平的,key 带路径会让两侧(本地扫描与远端清单)拿不同形态的 key 互相找不到。
|
|
21
|
+
*/
|
|
22
|
+
export type SyncFileSet = Map<string, SyncFileInfo>;
|