dsh-session-ops 0.1.4 → 0.2.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/README.md +7 -3
- package/README.zh.md +7 -3
- package/lib/admin.js +723 -774
- package/lib/client.js +1767 -2065
- package/lib/engine.js +181 -182
- package/lib/index.js +125 -166
- package/lib/memory.js +137 -132
- package/lib/move.js +630 -644
- package/lib/sessionlog.js +2 -18
- package/lib/store/frames.js +183 -170
- package/lib/store/host.js +41 -42
- package/lib/store/http.js +64 -59
- package/lib/store/index.js +5 -6
- package/lib/store/paths.js +79 -76
- package/lib/store/registry.js +100 -89
- package/lib/store/util.js +19 -0
- package/lib/types/admin.d.ts +187 -0
- package/lib/types/engine.d.ts +136 -0
- package/lib/types/index.d.ts +281 -0
- package/lib/types/memory.d.ts +106 -0
- package/lib/types/move.d.ts +272 -0
- package/lib/types/sessionlog.d.ts +14 -0
- package/lib/types/store/frames.d.ts +128 -0
- package/lib/types/store/host.d.ts +88 -0
- package/lib/types/store/http.d.ts +105 -0
- package/lib/types/store/index.d.ts +30 -0
- package/lib/types/store/paths.d.ts +100 -0
- package/lib/types/store/registry.d.ts +206 -0
- package/lib/types/store/util.d.ts +16 -0
- package/package.json +12 -2
package/lib/engine.js
CHANGED
|
@@ -11,34 +11,21 @@
|
|
|
11
11
|
*
|
|
12
12
|
* @module dsh-session-ops/engine
|
|
13
13
|
*/
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
19
|
-
readHeaderFromBuffer,
|
|
20
|
-
rewriteHeaderCwd,
|
|
21
|
-
scanZstdFrames,
|
|
22
|
-
} from './store/frames.js'
|
|
23
|
-
import {
|
|
24
|
-
findGeneration,
|
|
25
|
-
parseGenerationName,
|
|
26
|
-
projectKey,
|
|
27
|
-
sessionDir,
|
|
28
|
-
} from './store/paths.js'
|
|
29
|
-
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, rmSync, statSync, openSync, writeSync, fsyncSync, closeSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { ZSTD_MAGIC, readHeaderFromBuffer, rewriteHeaderCwd, scanZstdFrames, } from './store/frames.js';
|
|
17
|
+
import { findGeneration, parseGenerationName, projectKey, sessionDir, } from './store/paths.js';
|
|
18
|
+
import { messageOf } from './store/util.js';
|
|
30
19
|
/** 迁移中使用的临时文件名后缀(都**不**匹配 generation 规范名)。 */
|
|
31
|
-
export const TMP_SUFFIX = '.session-move-tmp'
|
|
32
|
-
export const PARKED_SUFFIX = '.session-move-old'
|
|
33
|
-
|
|
20
|
+
export const TMP_SUFFIX = '.session-move-tmp';
|
|
21
|
+
export const PARKED_SUFFIX = '.session-move-old';
|
|
34
22
|
/**
|
|
35
23
|
* `findGeneration` / `parseGenerationName`(「目录里哪份才算规范 generation」)已经搬到
|
|
36
24
|
* `lib/store/paths.js`——删除路径也要认得同一套命名,规则只该有一份。
|
|
37
25
|
* 这里保留再导出,是为了不打断本仓库既有的导入者(`test/core.test.mjs` 就按
|
|
38
26
|
* `../lib/engine.js` 导入它们)。
|
|
39
27
|
*/
|
|
40
|
-
export { findGeneration, parseGenerationName }
|
|
41
|
-
|
|
28
|
+
export { findGeneration, parseGenerationName };
|
|
42
29
|
/**
|
|
43
30
|
* 计算一次搬迁的计划:读源日志、改写 cwd、给出目标路径。**只读**,不写任何东西。
|
|
44
31
|
*
|
|
@@ -47,89 +34,98 @@ export { findGeneration, parseGenerationName }
|
|
|
47
34
|
* @throws 源日志缺失、header 与请求不符、目标已存在、源与目标同目录时。
|
|
48
35
|
*/
|
|
49
36
|
export function planMove(options) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
37
|
+
const { root, sessionId, fromCwd, toCwd } = options;
|
|
38
|
+
const compression = options.compression ?? 'zstd';
|
|
39
|
+
if (typeof root !== 'string' || root.length === 0)
|
|
40
|
+
throw new Error('session root is unknown');
|
|
41
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0)
|
|
42
|
+
throw new Error('session id is required');
|
|
43
|
+
if (typeof fromCwd !== 'string' || fromCwd.length === 0)
|
|
44
|
+
throw new Error('source cwd is required');
|
|
45
|
+
if (typeof toCwd !== 'string' || toCwd.length === 0)
|
|
46
|
+
throw new Error('target cwd is required');
|
|
47
|
+
if (fromCwd === toCwd)
|
|
48
|
+
throw new Error('source and target cwd are identical');
|
|
49
|
+
const fromKey = projectKey(fromCwd);
|
|
50
|
+
const toKey = projectKey(toCwd);
|
|
51
|
+
if (fromKey === toKey)
|
|
52
|
+
throw new Error('source and target resolve to the same project directory');
|
|
53
|
+
const sourceDir = sessionDir(root, fromCwd, sessionId);
|
|
54
|
+
const targetDir = sessionDir(root, toCwd, sessionId);
|
|
55
|
+
const found = findGeneration(sourceDir);
|
|
56
|
+
if ('error' in found) {
|
|
57
|
+
throw new Error(`${found.error}${found.others !== undefined && found.others.length > 0 ? ` (found: ${found.others.join(', ')})` : ''}`);
|
|
58
|
+
}
|
|
59
|
+
const sourcePath = join(sourceDir, found.selected.name);
|
|
60
|
+
const targetPath = join(targetDir, found.selected.name);
|
|
61
|
+
const buffer = readFileSync(sourcePath);
|
|
62
|
+
const compressed = found.selected.compressed;
|
|
63
|
+
const header = readHeaderFromBuffer(buffer, compressed);
|
|
64
|
+
if (header === undefined)
|
|
65
|
+
throw new Error(`cannot read a session header from ${sourcePath}`);
|
|
66
|
+
if (String(header.id) !== sessionId)
|
|
67
|
+
throw new Error(`header id ${String(header.id)} does not match requested session ${sessionId}`);
|
|
68
|
+
if (typeof header.cwd === 'string' && header.cwd !== fromCwd) {
|
|
69
|
+
throw new Error(`header cwd ${header.cwd} does not match the stored cwd ${fromCwd}`);
|
|
70
|
+
}
|
|
71
|
+
const rewritten = rewriteHeaderCwd(buffer, toCwd, { compressed });
|
|
72
|
+
if (rewritten.rewritten !== true)
|
|
73
|
+
throw new Error(`cannot rewrite the header cwd: ${rewritten.reason ?? 'unknown reason'}`);
|
|
74
|
+
let targetExists = false;
|
|
75
|
+
try {
|
|
76
|
+
statSync(targetPath);
|
|
77
|
+
targetExists = true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
targetExists = false;
|
|
81
|
+
}
|
|
82
|
+
if (targetExists)
|
|
83
|
+
throw new Error(`target log already exists (refusing to overwrite): ${targetPath}`);
|
|
84
|
+
// 目标目录里已有租约文件 = 有人正持有该目录的写租约(或上一次搬迁没搬完)。
|
|
85
|
+
// 此时再搬会把「已持有的锁」和「日志」拆到两个目录,不如直接拒绝。
|
|
86
|
+
if (existsSync(join(targetDir, 'session.lock'))) {
|
|
87
|
+
throw new Error(`target session directory already holds a write lease (refusing to merge): ${join(targetDir, 'session.lock')}`);
|
|
88
|
+
}
|
|
89
|
+
const frames = compressed ? scanZstdFrames(buffer) : { frames: [], incomplete: false };
|
|
90
|
+
if (compressed && frames.incomplete)
|
|
91
|
+
throw new Error(`source log has an incomplete trailing frame: ${frames.reason}`);
|
|
92
|
+
return {
|
|
93
|
+
root,
|
|
94
|
+
compression,
|
|
95
|
+
sessionId,
|
|
96
|
+
fromCwd,
|
|
97
|
+
toCwd,
|
|
98
|
+
fromKey,
|
|
99
|
+
toKey,
|
|
100
|
+
generation: found.selected,
|
|
101
|
+
/** 源目录里除 generation 与 `session.lock` 之外的文件(真正会留在原地的)。 */
|
|
102
|
+
otherFiles: found.others.filter((name) => name !== 'session.lock'),
|
|
103
|
+
/** 源目录里是否有写租约文件;有则由 {@link applyPlan} 随目录一起搬走。 */
|
|
104
|
+
leaseFile: found.others.includes('session.lock') ? 'session.lock' : null,
|
|
105
|
+
otherGenerations: found.all.slice(1).map((entry) => entry.name),
|
|
106
|
+
sourceDir,
|
|
107
|
+
targetDir,
|
|
108
|
+
sourcePath,
|
|
109
|
+
targetPath,
|
|
110
|
+
bytesBefore: buffer.length,
|
|
111
|
+
bytesAfter: rewritten.buffer.length,
|
|
112
|
+
frameCount: compressed ? frames.frames.length : null,
|
|
113
|
+
header,
|
|
114
|
+
rewrittenHeader: rewritten.header,
|
|
115
|
+
buffer: rewritten.buffer,
|
|
116
|
+
};
|
|
120
117
|
}
|
|
121
|
-
|
|
122
118
|
/** 原子落盘:写 + fsync(崩在中间也不会留下半个文件)。 */
|
|
123
119
|
function writeDurable(path, buffer) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
120
|
+
const fd = openSync(path, 'w');
|
|
121
|
+
try {
|
|
122
|
+
writeSync(fd, buffer, 0, buffer.length, 0);
|
|
123
|
+
fsyncSync(fd);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
}
|
|
131
128
|
}
|
|
132
|
-
|
|
133
129
|
/**
|
|
134
130
|
* 执行计划。三步改名保证「任何时刻都不存在两份规范名日志」:
|
|
135
131
|
* 1. 新日志先以临时名写进目标目录(此时还不是 generation);
|
|
@@ -141,97 +137,100 @@ function writeDurable(path, buffer) {
|
|
|
141
137
|
* @returns 搬迁报告(含 `dshReadbackSafe` 之外的事实字段,供 HTTP/工具直接回话)。
|
|
142
138
|
*/
|
|
143
139
|
export function applyPlan(plan) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
140
|
+
const report = {
|
|
141
|
+
sessionId: plan.sessionId,
|
|
142
|
+
from: plan.fromCwd,
|
|
143
|
+
to: plan.toCwd,
|
|
144
|
+
generation: plan.generation.name,
|
|
145
|
+
bytesBefore: plan.bytesBefore,
|
|
146
|
+
bytesAfter: plan.bytesAfter,
|
|
147
|
+
frames: plan.frameCount,
|
|
148
|
+
sourcePath: plan.sourcePath,
|
|
149
|
+
targetPath: plan.targetPath,
|
|
150
|
+
moved: true,
|
|
151
|
+
};
|
|
152
|
+
mkdirSync(plan.targetDir, { recursive: true });
|
|
153
|
+
const tmpPath = plan.targetPath + TMP_SUFFIX;
|
|
154
|
+
const parkedPath = plan.sourcePath + PARKED_SUFFIX;
|
|
155
|
+
writeDurable(tmpPath, plan.buffer);
|
|
156
|
+
renameSync(plan.sourcePath, parkedPath);
|
|
157
|
+
renameSync(tmpPath, plan.targetPath);
|
|
158
|
+
rmSync(parkedPath, { force: true });
|
|
159
|
+
// 0.2.2 起每个会话目录里有 `session.lock`(POSIX flock,写入句柄整个生命周期持有;
|
|
160
|
+
// 内核约定这个文件永不删除、靠稳定 inode 判断归属)。搬迁必须把它一起搬过去:
|
|
161
|
+
// rename 保留 inode,本进程已持有的 flock 会跟着到新路径,仍能挡住第二个写入者;
|
|
162
|
+
// 留在源目录会得到「源目录有锁、新目录无锁」的半套租约。
|
|
163
|
+
const leaseName = 'session.lock';
|
|
164
|
+
const sourceLease = join(plan.sourceDir, leaseName);
|
|
165
|
+
const targetLease = join(plan.targetDir, leaseName);
|
|
166
|
+
let leaseMoved = false;
|
|
167
|
+
if (existsSync(sourceLease)) {
|
|
168
|
+
if (existsSync(targetLease)) {
|
|
169
|
+
report.leaseConflict = targetLease;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
try {
|
|
173
|
+
renameSync(sourceLease, targetLease);
|
|
174
|
+
leaseMoved = true;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
report.leaseMoveError = messageOf(error);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
182
180
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
let removedSourceDir = false
|
|
187
|
-
let leftover = []
|
|
188
|
-
try {
|
|
189
|
-
leftover = readdirSync(plan.sourceDir)
|
|
190
|
-
} catch {
|
|
191
|
-
leftover = []
|
|
192
|
-
}
|
|
193
|
-
if (leftover.length === 0) {
|
|
181
|
+
report.leaseMoved = leaseMoved;
|
|
182
|
+
let removedSourceDir = false;
|
|
183
|
+
let leftover = [];
|
|
194
184
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
185
|
+
leftover = readdirSync(plan.sourceDir);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
leftover = [];
|
|
189
|
+
}
|
|
190
|
+
if (leftover.length === 0) {
|
|
191
|
+
try {
|
|
192
|
+
rmdirSync(plan.sourceDir);
|
|
193
|
+
removedSourceDir = true;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
report.sourceDirCleanupError = messageOf(error);
|
|
197
|
+
}
|
|
199
198
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
report.ok = report.verifiedHeader !== null && report.verifiedHeader.cwd === plan.toCwd
|
|
212
|
-
return report
|
|
199
|
+
report.removedSourceDir = removedSourceDir;
|
|
200
|
+
report.leftInSourceDir = leftover;
|
|
201
|
+
// 落盘之后再读一遍目标文件:确认帧结构可解、header 已是新 cwd。
|
|
202
|
+
const after = readFileSync(plan.targetPath);
|
|
203
|
+
const header = readHeaderFromBuffer(after, plan.generation.compressed);
|
|
204
|
+
report.verifiedHeader = header === undefined
|
|
205
|
+
? null
|
|
206
|
+
: { id: String(header.id), cwd: typeof header.cwd === 'string' ? header.cwd : null, version: header.version };
|
|
207
|
+
report.verifiedBytes = after.length;
|
|
208
|
+
report.ok = report.verifiedHeader !== null && report.verifiedHeader.cwd === plan.toCwd;
|
|
209
|
+
return report;
|
|
213
210
|
}
|
|
214
|
-
|
|
215
211
|
/**
|
|
216
212
|
* 只读校验:目标位置上是否有一份可解析、id/cwd 都对得上的日志。
|
|
217
213
|
* @param options - `{root, sessionId, cwd, compression}`。
|
|
218
214
|
* @returns `{ok, header, bytes}` 或 `{ok:false, error}`。
|
|
219
215
|
*/
|
|
220
216
|
export function verifyStoredLog(options) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
217
|
+
try {
|
|
218
|
+
const dir = sessionDir(options.root, options.cwd, options.sessionId);
|
|
219
|
+
const found = findGeneration(dir);
|
|
220
|
+
if ('error' in found)
|
|
221
|
+
return { ok: false, error: found.error };
|
|
222
|
+
const buffer = readFileSync(join(dir, found.selected.name));
|
|
223
|
+
const header = readHeaderFromBuffer(buffer, found.selected.compressed);
|
|
224
|
+
if (header === undefined)
|
|
225
|
+
return { ok: false, error: 'header is unreadable' };
|
|
226
|
+
return {
|
|
227
|
+
ok: String(header.id) === options.sessionId && header.cwd === options.cwd,
|
|
228
|
+
header: { id: String(header.id), cwd: typeof header.cwd === 'string' ? header.cwd : null },
|
|
229
|
+
bytes: buffer.length,
|
|
230
|
+
magic: buffer.length >= 4 && buffer.readUInt32LE(0) === ZSTD_MAGIC ? 'zstd' : 'plain',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
return { ok: false, error: messageOf(error) };
|
|
233
235
|
}
|
|
234
|
-
} catch (error) {
|
|
235
|
-
return { ok: false, error: String(error?.message ?? error) }
|
|
236
|
-
}
|
|
237
236
|
}
|