@max-null/dsh-memory 0.21.0 → 0.22.1
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 +52 -2
- package/dist/engine.d.ts +68 -0
- package/dist/engine.js +38 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +104 -4
- package/dist/self.d.ts +3 -0
- package/dist/self.js +30 -19
- package/dist/sync-export.d.ts +10 -5
- package/dist/sync-export.js +21 -12
- 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
package/dist/sync-git.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步镜像的本地 git 仓库层。
|
|
3
|
+
*
|
|
4
|
+
* **这一步不联网**:只做 `init` / `add` / `commit`。远端 push 是下一步,单独一层——
|
|
5
|
+
* 分层的理由是可验证性:本地仓库能在完全离线的情况下验通「记忆库 → 镜像 → 有历史的
|
|
6
|
+
* 仓库」整条链,网络问题留到那一层单独排查。
|
|
7
|
+
*
|
|
8
|
+
* `isomorphic-git` 走**动态 import**(体积理由见设计 §五):没开同步的人不加载它。
|
|
9
|
+
*
|
|
10
|
+
* **无改动不产生空提交**:定时跑一次同步是常态,每次都留一个空提交会让 `git log`
|
|
11
|
+
* 变成时间戳噪声,真正的变更被淹掉。
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-memory/sync-git
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import * as nodeFs from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
/** 仓库默认分支——与 GitHub 侧的约定一致。 */
|
|
19
|
+
const DEFAULT_BRANCH = 'main';
|
|
20
|
+
/** 动态载入 isomorphic-git(体积理由见文件头)。 */
|
|
21
|
+
async function loadGit() {
|
|
22
|
+
return await import('isomorphic-git');
|
|
23
|
+
}
|
|
24
|
+
/** 确保目录是一个 git 仓库;已初始化时什么都不做。 */
|
|
25
|
+
async function ensureRepo(dir) {
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
if (existsSync(join(dir, '.git')))
|
|
28
|
+
return;
|
|
29
|
+
const git = await loadGit();
|
|
30
|
+
await git.init({ fs: nodeFs, dir, defaultBranch: DEFAULT_BRANCH });
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 把镜像目录的当前状态提交成一个 commit。
|
|
34
|
+
*
|
|
35
|
+
* 三种路径逐个处理:新增/修改走 `add`,**删除走 `remove`**——只 `add` 的话,
|
|
36
|
+
* 镜像里被删掉的记录在 git 看来会一直存在。
|
|
37
|
+
*
|
|
38
|
+
* @param options - 目录、提交信息与作者。
|
|
39
|
+
* @returns 是否提交、sha 与涉及的路径数。
|
|
40
|
+
*/
|
|
41
|
+
export async function commitMirror(options) {
|
|
42
|
+
await ensureRepo(options.dir);
|
|
43
|
+
const git = await loadGit();
|
|
44
|
+
// statusMatrix 每行是 [filepath, head, workdir, stage]:
|
|
45
|
+
// head=1 表示上一个 commit 里有;workdir=0 表示工作区里已经没有。
|
|
46
|
+
const matrix = await git.statusMatrix({ fs: nodeFs, dir: options.dir });
|
|
47
|
+
const changed = matrix.filter(([, head, workdir, stage]) => head !== workdir || workdir !== stage);
|
|
48
|
+
if (changed.length === 0) {
|
|
49
|
+
return { committed: false, filesChanged: 0 };
|
|
50
|
+
}
|
|
51
|
+
for (const [filepath, , workdir] of changed) {
|
|
52
|
+
if (workdir === 0)
|
|
53
|
+
await git.remove({ fs: nodeFs, dir: options.dir, filepath });
|
|
54
|
+
else
|
|
55
|
+
await git.add({ fs: nodeFs, dir: options.dir, filepath });
|
|
56
|
+
}
|
|
57
|
+
const sha = await git.commit({
|
|
58
|
+
fs: nodeFs,
|
|
59
|
+
dir: options.dir,
|
|
60
|
+
message: options.message,
|
|
61
|
+
author: options.author,
|
|
62
|
+
});
|
|
63
|
+
return { committed: true, sha, filesChanged: changed.length };
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* 读仓库的提交历史(从新到旧)。测试与 `memory_sync status` 共用。
|
|
67
|
+
*
|
|
68
|
+
* @param dir - 镜像仓库目录。
|
|
69
|
+
* @param depth - 最多取多少条;缺省不限。
|
|
70
|
+
* @returns 提交列表(sha / message / 时间)。仓库尚未建立时返回空数组。
|
|
71
|
+
*/
|
|
72
|
+
export async function readHistory(dir, depth) {
|
|
73
|
+
if (!existsSync(join(dir, '.git')))
|
|
74
|
+
return [];
|
|
75
|
+
const git = await loadGit();
|
|
76
|
+
try {
|
|
77
|
+
const commits = await git.log({ fs: nodeFs, dir, ...depth === undefined ? {} : { depth } });
|
|
78
|
+
return commits.map(entry => ({
|
|
79
|
+
sha: entry.oid,
|
|
80
|
+
message: entry.commit.message.trim(),
|
|
81
|
+
at: entry.commit.author.timestamp * 1000,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// 空仓库(init 之后一个 commit 都没有)会在这里抛;那是正常状态。
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步导入层:把 `records/` 里的记录**规划**成一组待写入的操作。
|
|
3
|
+
*
|
|
4
|
+
* **这一层只出计划,不写任何东西。** 真正落库由调用方走引擎已有的写入路径完成,
|
|
5
|
+
* 理由:直接改 `memory.json` 会与内存缓存打架(引擎的写路径是「读—改—写全量覆盖」,
|
|
6
|
+
* 绕过它等于让两边各持一份真相)。分层之后,这一层是纯函数,可以在不碰数据的前提下
|
|
7
|
+
* 穷举冲突场景。
|
|
8
|
+
*
|
|
9
|
+
* 冲突策略见设计 §3.3:**同一条被两边改时不自动裁决**,两个版本都交出去让调用方写进
|
|
10
|
+
* `conflicts/`。记忆是长期资产,被静默覆盖的那份就没了。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/sync-import
|
|
13
|
+
*/
|
|
14
|
+
import type { z } from 'zod';
|
|
15
|
+
import type { blockSchema } from './schema.ts';
|
|
16
|
+
/** 与存储层同构的一条记录。 */
|
|
17
|
+
export type ImportedBlock = z.infer<typeof blockSchema>;
|
|
18
|
+
/** 一条来自 `records/` 的记录及其归属。 */
|
|
19
|
+
export interface IncomingRecord {
|
|
20
|
+
/** 记录 id(文件名去 `.json`)。 */
|
|
21
|
+
readonly id: string;
|
|
22
|
+
/** `global` 或 `project`。 */
|
|
23
|
+
readonly scope: 'global' | 'project';
|
|
24
|
+
/** `project` 作用域下的项目名;`global` 时缺省。 */
|
|
25
|
+
readonly projectName?: string;
|
|
26
|
+
/** 解析后的记录体。 */
|
|
27
|
+
readonly block: ImportedBlock;
|
|
28
|
+
}
|
|
29
|
+
/** 本地某一层现有的记录。 */
|
|
30
|
+
export interface LocalLayer {
|
|
31
|
+
/** 该层现有的记录:id → block。 */
|
|
32
|
+
readonly blocks: Readonly<Record<string, ImportedBlock>>;
|
|
33
|
+
}
|
|
34
|
+
/** {@link planImport} 的入参。 */
|
|
35
|
+
export interface ImportPlanOptions {
|
|
36
|
+
readonly incoming: readonly IncomingRecord[];
|
|
37
|
+
/** 本地 global 层。 */
|
|
38
|
+
readonly localGlobal: LocalLayer;
|
|
39
|
+
/** 本地的项目层:项目名(已小写)→ 该项目的记录。 */
|
|
40
|
+
readonly localProjects: Readonly<Record<string, LocalLayer>>;
|
|
41
|
+
}
|
|
42
|
+
/** 一条待写入的操作。 */
|
|
43
|
+
export interface ImportUpsert {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly scope: 'global' | 'project';
|
|
46
|
+
readonly projectName?: string;
|
|
47
|
+
readonly block: ImportedBlock;
|
|
48
|
+
}
|
|
49
|
+
/** 一条需要人工裁决的冲突。 */
|
|
50
|
+
export interface ImportConflict {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly scope: 'global' | 'project';
|
|
53
|
+
readonly projectName?: string;
|
|
54
|
+
/** 本地版本。 */
|
|
55
|
+
readonly mine: ImportedBlock;
|
|
56
|
+
/** 远端版本。 */
|
|
57
|
+
readonly theirs: ImportedBlock;
|
|
58
|
+
/** 判据:哪一边的 `updatedAt` 更新(仅作参考,决定写不写 `upserts`)。 */
|
|
59
|
+
readonly newer: 'mine' | 'theirs';
|
|
60
|
+
}
|
|
61
|
+
/** 一次导入规划的结果。 */
|
|
62
|
+
export interface ImportPlan {
|
|
63
|
+
/** 需要新增或覆盖的条目。 */
|
|
64
|
+
readonly upserts: readonly ImportUpsert[];
|
|
65
|
+
/** 两边都改过、需要人裁决的条目。 */
|
|
66
|
+
readonly conflicts: readonly ImportConflict[];
|
|
67
|
+
/** 本地没有对应项目、无法落地的条目(留在远端等它出现)。 */
|
|
68
|
+
readonly orphaned: readonly string[];
|
|
69
|
+
/** 本地已一致、无需动作的条数。 */
|
|
70
|
+
readonly unchanged: number;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* 规划一次导入。**不读写磁盘、不碰引擎状态。**
|
|
74
|
+
*
|
|
75
|
+
* 三种归属分别处理:
|
|
76
|
+
* - `global`:永远有落点。
|
|
77
|
+
* - `project` 且本地有同名项目:落到该项目。
|
|
78
|
+
* - `project` 但本地没有该项目 → 进 `orphaned`,**不丢**(留在远端等它出现)。
|
|
79
|
+
*
|
|
80
|
+
* @param options - 远端记录、本地各层、项目映射。
|
|
81
|
+
* @returns 计划:要写的、要裁决的、落不了的、不需要动的。
|
|
82
|
+
*/
|
|
83
|
+
export declare function planImport(options: ImportPlanOptions): ImportPlan;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同步导入层:把 `records/` 里的记录**规划**成一组待写入的操作。
|
|
3
|
+
*
|
|
4
|
+
* **这一层只出计划,不写任何东西。** 真正落库由调用方走引擎已有的写入路径完成,
|
|
5
|
+
* 理由:直接改 `memory.json` 会与内存缓存打架(引擎的写路径是「读—改—写全量覆盖」,
|
|
6
|
+
* 绕过它等于让两边各持一份真相)。分层之后,这一层是纯函数,可以在不碰数据的前提下
|
|
7
|
+
* 穷举冲突场景。
|
|
8
|
+
*
|
|
9
|
+
* 冲突策略见设计 §3.3:**同一条被两边改时不自动裁决**,两个版本都交出去让调用方写进
|
|
10
|
+
* `conflicts/`。记忆是长期资产,被静默覆盖的那份就没了。
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-memory/sync-import
|
|
13
|
+
*/
|
|
14
|
+
/** 把一条记录投影成可比较的形状:只比内容,不比本地瞬态字段。 */
|
|
15
|
+
function comparable(block) {
|
|
16
|
+
return JSON.stringify([
|
|
17
|
+
block.content,
|
|
18
|
+
block.keywords,
|
|
19
|
+
block.namespace,
|
|
20
|
+
block.status,
|
|
21
|
+
block.injected ?? null,
|
|
22
|
+
block.topic ?? null,
|
|
23
|
+
block.kind ?? null,
|
|
24
|
+
block.stale ?? null,
|
|
25
|
+
block.staleReason ?? null,
|
|
26
|
+
block.anchor ?? null,
|
|
27
|
+
]);
|
|
28
|
+
}
|
|
29
|
+
/** 本地层里找一条。 */
|
|
30
|
+
function findLocal(layer, id) {
|
|
31
|
+
return layer?.blocks[id];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 规划一次导入。**不读写磁盘、不碰引擎状态。**
|
|
35
|
+
*
|
|
36
|
+
* 三种归属分别处理:
|
|
37
|
+
* - `global`:永远有落点。
|
|
38
|
+
* - `project` 且本地有同名项目:落到该项目。
|
|
39
|
+
* - `project` 但本地没有该项目 → 进 `orphaned`,**不丢**(留在远端等它出现)。
|
|
40
|
+
*
|
|
41
|
+
* @param options - 远端记录、本地各层、项目映射。
|
|
42
|
+
* @returns 计划:要写的、要裁决的、落不了的、不需要动的。
|
|
43
|
+
*/
|
|
44
|
+
export function planImport(options) {
|
|
45
|
+
const upserts = [];
|
|
46
|
+
const conflicts = [];
|
|
47
|
+
const orphaned = [];
|
|
48
|
+
let unchanged = 0;
|
|
49
|
+
for (const record of options.incoming) {
|
|
50
|
+
const { id, scope, projectName, block } = record;
|
|
51
|
+
let localLayer;
|
|
52
|
+
if (scope === 'global') {
|
|
53
|
+
localLayer = options.localGlobal;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const key = projectName?.toLowerCase() ?? '';
|
|
57
|
+
localLayer = key === '' ? undefined : options.localProjects[key];
|
|
58
|
+
if (localLayer === undefined) {
|
|
59
|
+
// 本机没有这个项目:记下来但不导入,也不报错。
|
|
60
|
+
orphaned.push(id);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const local = findLocal(localLayer, id);
|
|
65
|
+
if (local === undefined) {
|
|
66
|
+
upserts.push({ id, scope, ...projectName === undefined ? {} : { projectName }, block });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (comparable(local) === comparable(block)) {
|
|
70
|
+
unchanged += 1;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// 两边内容不同:不自动裁决,两边都交出去。
|
|
74
|
+
const newer = (block.updatedAt ?? 0) > (local.updatedAt ?? 0) ? 'theirs' : 'mine';
|
|
75
|
+
conflicts.push({ id, scope, ...projectName === undefined ? {} : { projectName }, mine: local, theirs: block, newer });
|
|
76
|
+
// 远端更新的那份仍然写进去(否则用户看到的永远是旧内容),但冲突记录同时留下。
|
|
77
|
+
if (newer === 'theirs') {
|
|
78
|
+
upserts.push({ id, scope, ...projectName === undefined ? {} : { projectName }, block });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { upserts, conflicts, orphaned, unchanged };
|
|
82
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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
|
+
/** 远端操作共用的参数。 */
|
|
18
|
+
export interface RemoteOptions {
|
|
19
|
+
/** 镜像仓库目录。 */
|
|
20
|
+
readonly dir: string;
|
|
21
|
+
/** 远端地址(https)。 */
|
|
22
|
+
readonly url: string;
|
|
23
|
+
/** 分支名;缺省 `main`。 */
|
|
24
|
+
readonly branch?: string;
|
|
25
|
+
/** GitHub PAT;缺省时只尝试匿名(私有仓库会失败)。 */
|
|
26
|
+
readonly token?: string;
|
|
27
|
+
}
|
|
28
|
+
/** 一次远端操作的结果。 */
|
|
29
|
+
export interface RemoteRun {
|
|
30
|
+
readonly ok: boolean;
|
|
31
|
+
/** 失败原因;成功时缺省。 */
|
|
32
|
+
readonly error?: string;
|
|
33
|
+
/**
|
|
34
|
+
* 失败分类——排查时最要紧的一件事:「认证错」和「连不上」的正确应对完全不同,
|
|
35
|
+
* 而它们的原始报错常常长得差不多。
|
|
36
|
+
*/
|
|
37
|
+
readonly kind?: 'auth' | 'network' | 'unknown';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* 确保镜像目录是一个**与远端有共同历史**的仓库。
|
|
41
|
+
*
|
|
42
|
+
* 首次同步时如果只 `git init`,本地与远端是两条无关的历史线——push 会被拒
|
|
43
|
+
* (`Push rejected because it was not a simple fast-forward`)。这是 2026-11-11
|
|
44
|
+
* 第一次用真实仓库跑时撞出来的:单元测试里没有真远端,所以永远发现不了。
|
|
45
|
+
*
|
|
46
|
+
* 因此首次必须 clone,而不是 init + push。
|
|
47
|
+
*
|
|
48
|
+
* @param options - 目录、远端地址、分支与 token。
|
|
49
|
+
* @returns 是否执行了 clone;失败时带分类。
|
|
50
|
+
*/
|
|
51
|
+
export declare function ensureMirrorRepo(options: RemoteOptions): Promise<MirrorRepoRun>;
|
|
52
|
+
/** {@link ensureMirrorRepo} 的结果。 */
|
|
53
|
+
export interface MirrorRepoRun {
|
|
54
|
+
readonly cloned: boolean;
|
|
55
|
+
readonly error?: string;
|
|
56
|
+
readonly kind?: 'auth' | 'network' | 'unknown';
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 把本地提交推到远端。
|
|
60
|
+
*
|
|
61
|
+
* @param options - 目录、远端地址、分支与 token。
|
|
62
|
+
* @returns 成功与否;失败时带分类。
|
|
63
|
+
*/
|
|
64
|
+
export declare function pushMirror(options: RemoteOptions): Promise<RemoteRun>;
|
|
65
|
+
/**
|
|
66
|
+
* 从远端拉取并快进本地。
|
|
67
|
+
*
|
|
68
|
+
* `fastForwardOnly: true`:合并策略不该由这一层决定。本地有分叉时宁可失败让人看见,
|
|
69
|
+
* 也不要让 isomorphic-git 悄悄造出一个 merge commit。
|
|
70
|
+
*
|
|
71
|
+
* @param options - 目录、远端地址、分支与 token。
|
|
72
|
+
* @returns 成功与否;失败时带分类。
|
|
73
|
+
*/
|
|
74
|
+
export declare function pullMirror(options: RemoteOptions): Promise<RemoteRun>;
|
|
75
|
+
/**
|
|
76
|
+
* 确保 `origin` 指向给定地址。地址变化时重设(用户换了仓库)。
|
|
77
|
+
*
|
|
78
|
+
* @param dir - 镜像仓库目录。
|
|
79
|
+
* @param url - 期望的远端地址。
|
|
80
|
+
*/
|
|
81
|
+
export declare function ensureRemote(dir: string, url: string): Promise<void>;
|
|
@@ -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 };
|