@max-null/dsh-memory 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };