dsh-markdown-typora 0.3.5 → 0.3.11

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,524 @@
1
+ 'use strict';
2
+ // Local editor hosts share an ordered log, not an editing lock. The disk-write
3
+ // reservation is separate: a peer can keep typing while another window saves.
4
+ const fs = require('node:fs');
5
+ const fsp = require('node:fs/promises');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+ const crypto = require('node:crypto');
9
+ const {AsyncLocalStorage} = require('node:async_hooks');
10
+ const {decodeDocument} = require('./text-codec');
11
+
12
+ const fault = (code, message) => Object.assign(new Error(message), {code});
13
+ const conflict = message => fault('MP_FILE_CONFLICT', message);
14
+ const uuid = () => crypto.randomUUID();
15
+ const databases = new Map();
16
+ const reservations = new AsyncLocalStorage();
17
+ const hosts = new Set(); // 本代(本模块实例)仍有协同成员的 host;join 成功登记,leave 清空即除名
18
+ let storageOperations = 0; // 在途文件租约操作数(join/save/external/refresh),兼防在途 join 注册前窗口
19
+ const defaultDirectory = () => path.join(process.platform === 'win32' && process.env.LOCALAPPDATA || os.homedir(), '.markpilot', 'live-v1');
20
+ const fileKey = file => process.platform === 'win32' ? file.toLowerCase() : file;
21
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
22
+
23
+ function database(directory) {
24
+ const location = path.resolve(directory);
25
+ if (databases.has(location)) return databases.get(location);
26
+ // Load lazily: startup and non-local providers do not need this facility.
27
+ const {DatabaseSync} = require('node:sqlite');
28
+ fs.mkdirSync(location, {recursive:true, mode:0o700});
29
+ const filename = path.join(location, 'rooms.sqlite');
30
+ const db = new DatabaseSync(filename);
31
+ if (process.platform !== 'win32') fs.chmodSync(filename, 0o600);
32
+ try {
33
+ db.exec(`PRAGMA busy_timeout=2000; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
34
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
35
+ CREATE TABLE IF NOT EXISTS rooms (
36
+ file TEXT PRIMARY KEY, epoch TEXT UNIQUE NOT NULL, version INTEGER NOT NULL, saved INTEGER NOT NULL,
37
+ disk TEXT NOT NULL, length INTEGER NOT NULL, floor INTEGER NOT NULL, checkpoint BLOB NOT NULL);
38
+ CREATE TABLE IF NOT EXISTS clients (
39
+ id TEXT PRIMARY KEY, epoch TEXT NOT NULL REFERENCES rooms(epoch) ON DELETE CASCADE,
40
+ pid INTEGER NOT NULL, seq INTEGER NOT NULL DEFAULT 0, ack INTEGER NOT NULL);
41
+ CREATE INDEX IF NOT EXISTS room_clients ON clients(epoch);
42
+ CREATE TABLE IF NOT EXISTS changes (
43
+ epoch TEXT NOT NULL REFERENCES rooms(epoch) ON DELETE CASCADE, version INTEGER NOT NULL,
44
+ client TEXT NOT NULL, seq INTEGER NOT NULL, delta TEXT NOT NULL, PRIMARY KEY(epoch,version));
45
+ CREATE TABLE IF NOT EXISTS recovery (
46
+ source TEXT NOT NULL, recoveryPath TEXT PRIMARY KEY, epoch TEXT NOT NULL,
47
+ version INTEGER NOT NULL, saved INTEGER NOT NULL, at INTEGER NOT NULL,
48
+ bytes INTEGER NOT NULL, sha256 TEXT NOT NULL);
49
+ CREATE TABLE IF NOT EXISTS writers (file TEXT PRIMARY KEY, owner TEXT NOT NULL, pid INTEGER NOT NULL, since INTEGER NOT NULL);`);
50
+ // Rooms kept by an earlier draft release held leases without timestamps.
51
+ if (!db.prepare('PRAGMA table_info(writers)').all().some(column => column.name === 'since'))
52
+ db.exec('DROP TABLE writers; CREATE TABLE writers (file TEXT PRIMARY KEY, owner TEXT NOT NULL, pid INTEGER NOT NULL, since INTEGER NOT NULL);');
53
+ // The rooms store is per machine and account. A database carried here from
54
+ // another device or profile (synced or copied LOCALAPPDATA) must not seat
55
+ // stale authority over files it has never seen on this host.
56
+ const stamp = hostStamp(), stored = db.prepare("SELECT value FROM meta WHERE key='host'").get();
57
+ if (stored && stored.value !== stamp)
58
+ atomic(db, () => {
59
+ db.exec('DELETE FROM writers; DELETE FROM changes; DELETE FROM clients; DELETE FROM rooms;');
60
+ db.prepare("INSERT OR REPLACE INTO meta VALUES('host',?)").run(stamp);
61
+ });
62
+ else if (!stored) atomic(db, () => db.prepare("INSERT OR REPLACE INTO meta VALUES('host',?)").run(stamp));
63
+ } catch (error) { db.close(); throw error; }
64
+ databases.set(location, db);
65
+ return db;
66
+ }
67
+ function hostStamp() {
68
+ let machine = '', account = '';
69
+ try { machine = os.hostname(); } catch {}
70
+ try { account = os.userInfo().username; } catch {}
71
+ return machine.toLowerCase() + '\n' + account;
72
+ }
73
+ const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
74
+
75
+ // Abnormal exits (crash, kill, power loss) leave client rows behind and, with
76
+ // them, rooms whose last member never said goodbye. Clean orphans are plain
77
+ // garbage; dirty orphans may hold the only surviving copy of an unsaved draft,
78
+ // so they are parked as locatable recovery files before the room is retired.
79
+ // Each pass retires at most RECOVERY_BATCH rooms and leaves every other
80
+ // room's dead client rows in place, so unprocessed orphans stay discoverable
81
+ // by the next pass instead of hanging forever. Parked recovery files are
82
+ // never removed by housekeeping: an unacknowledged user draft must not be
83
+ // silently destroyed; only the recovery index is capped.
84
+ const sweptLocations = new Set(), RECOVERY_BATCH = 4, RECOVERY_ROWS = 500;
85
+ function recoveryFile(directory, room) {
86
+ const name = path.basename(room.file).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 64) || 'document';
87
+ return path.join(path.resolve(directory), 'recovery',
88
+ name + '.' + digest(fileKey(room.file)).slice(0, 8) + '.' + room.epoch.slice(0, 8) + '.md');
89
+ }
90
+ function orphanCandidates(db) {
91
+ const out = [];
92
+ for (const {epoch} of db.prepare('SELECT epoch FROM rooms').all()) {
93
+ const clients = db.prepare('SELECT pid FROM clients WHERE epoch=?').all(epoch);
94
+ if (clients.some(({pid}) => alive(pid))) continue; // live session, untouched
95
+ const room = db.prepare('SELECT file,epoch,version,saved FROM rooms WHERE epoch=?').get(epoch);
96
+ if (room) out.push(room);
97
+ if (out.length >= RECOVERY_BATCH) break;
98
+ }
99
+ return out;
100
+ }
101
+ function retireClean(db, room) {
102
+ atomic(db, () => {
103
+ const clients = db.prepare('SELECT pid FROM clients WHERE epoch=?').all(room.epoch);
104
+ if (clients.some(({pid}) => alive(pid))) return false;
105
+ const now = db.prepare('SELECT version,saved FROM rooms WHERE epoch=?').get(room.epoch);
106
+ if (!now || now.version !== room.version || now.saved !== room.saved) return false;
107
+ db.prepare('DELETE FROM clients WHERE epoch=?').run(room.epoch);
108
+ db.prepare('DELETE FROM rooms WHERE epoch=?').run(room.epoch);
109
+ return true;
110
+ });
111
+ }
112
+ // Parks a dirty orphan's draft, then retires the room. Every failure path
113
+ // (write error, readback mismatch, session revival, room advanced) leaves
114
+ // the room exactly as it was — the draft is never dropped without a copy.
115
+ async function parkRecovery(db, directory, room) {
116
+ let target, bytes;
117
+ try {
118
+ const live = roomFor(db, room.file);
119
+ if (!live || live.epoch !== room.epoch || live.version !== room.version || live.saved !== room.saved) return null;
120
+ bytes = Buffer.from(materialize(db, live), 'utf8');
121
+ target = recoveryFile(directory, room);
122
+ await fsp.mkdir(path.dirname(target), {recursive:true});
123
+ await fsp.writeFile(target, bytes);
124
+ if (Buffer.compare(Buffer.from(await fsp.readFile(target)), bytes) !== 0) return null; // 校验失败:房间保留
125
+ } catch (error) { return null; } // 恢复文件写不进:房间保留,绝不丢唯一草稿
126
+ let done = false;
127
+ atomic(db, () => {
128
+ const clients = db.prepare('SELECT pid FROM clients WHERE epoch=?').all(room.epoch);
129
+ if (clients.some(({pid}) => alive(pid))) return; // 同路径会话复活:不动
130
+ const now = db.prepare('SELECT version,saved FROM rooms WHERE epoch=?').get(room.epoch);
131
+ if (!now || now.version !== room.version || now.saved !== room.saved) return;
132
+ db.prepare('INSERT OR REPLACE INTO recovery VALUES(?,?,?,?,?,?,?,?)')
133
+ .run(fileKey(room.file), target, room.epoch, room.version, room.saved, Date.now(), bytes.length, digest(bytes));
134
+ db.prepare('DELETE FROM clients WHERE epoch=?').run(room.epoch);
135
+ db.prepare('DELETE FROM rooms WHERE epoch=?').run(room.epoch);
136
+ // Cap only the index. Parked files stay on disk until the user acts.
137
+ const rows = db.prepare('SELECT recoveryPath FROM recovery ORDER BY at DESC').all();
138
+ for (const row of rows.slice(RECOVERY_ROWS)) db.prepare('DELETE FROM recovery WHERE recoveryPath=?').run(row.recoveryPath);
139
+ done = true;
140
+ });
141
+ return done ? target : null;
142
+ }
143
+ async function sweepOrphans(db, directory) {
144
+ const location = path.resolve(directory);
145
+ if (sweptLocations.has(location)) return;
146
+ sweptLocations.add(location);
147
+ try {
148
+ for (const room of orphanCandidates(db)) {
149
+ if (room.version === room.saved) retireClean(db, room);
150
+ else await parkRecovery(db, directory, room);
151
+ }
152
+ } catch (error) { /* housekeeping must never break a join or a write */ }
153
+ }
154
+
155
+ function atomic(db, action) {
156
+ db.exec('BEGIN IMMEDIATE');
157
+ try { const result = action(); db.exec('COMMIT'); return result; }
158
+ catch (error) { db.exec('ROLLBACK'); throw error; }
159
+ }
160
+ function alive(pid) {
161
+ if (pid === process.pid) return true;
162
+ try { process.kill(pid, 0); return true; }
163
+ catch (error) { return error.code !== 'ESRCH'; }
164
+ }
165
+
166
+ // Serializes only cooperating file writers, across Electron and DSH processes.
167
+ // Reentrant for the DocumentStore commit inside the same scoped save. A lease
168
+ // is only ever reclaimed from a holder whose process no longer answers pid
169
+ // probes; a living but slow or suspended holder makes callers time out with
170
+ // MP_SAVE_BUSY instead. Refusing to write is always safe — a second
171
+ // concurrent writer never is — so lease age alone never deposes a holder,
172
+ // and a recycled pid is bounded by the busy timeout, not by stealing.
173
+ async function withFileCommit(file, action, directory = defaultDirectory(), options = {}) {
174
+ storageOperations++;
175
+ try {
176
+ const canonical = await canonicalFile(file);
177
+ const key = fileKey(canonical), id = path.resolve(directory) + '\n' + key;
178
+ const inherited = reservations.getStore();
179
+ if (inherited?.active && inherited.id === id) return action();
180
+ const db = database(directory), token = uuid();
181
+ const until = Date.now() + (options.busyTimeoutMs ?? 30000);
182
+ for (;;) {
183
+ const got = atomic(db, () => {
184
+ const row = db.prepare('SELECT owner,pid FROM writers WHERE file=?').get(key);
185
+ if (row && alive(row.pid)) return false;
186
+ if (row) db.prepare('DELETE FROM writers WHERE file=? AND owner=?').run(key, row.owner);
187
+ db.prepare('INSERT INTO writers VALUES(?,?,?,?)').run(key, token, process.pid, Date.now());
188
+ return true;
189
+ });
190
+ if (got) break;
191
+ if (Date.now() >= until) throw fault('MP_SAVE_BUSY', '其他窗口仍在保存,修改已保留,请稍后重试');
192
+ await pause(20);
193
+ }
194
+ const scope = {id, active:true};
195
+ try { return await reservations.run(scope, action); }
196
+ finally { scope.active = false; db.prepare('DELETE FROM writers WHERE file=? AND owner=?').run(key, token); }
197
+ } finally { storageOperations--; }
198
+ }
199
+
200
+ async function canonicalFile(file) {
201
+ const absolute = path.resolve(file);
202
+ try { return await fsp.realpath(absolute); } catch (error) {
203
+ if (error.code !== 'ENOENT') throw error;
204
+ }
205
+ // Missing target or missing ancestors: resolve from the closest existing
206
+ // ancestor and join the remaining tail back — a Save As into a brand-new
207
+ // nested directory keeps one canonical identity for the lease and the room,
208
+ // exactly like DocumentStore.canonical has always granted such targets.
209
+ // Existing ancestors still resolve through realpath, so symlinked path
210
+ // segments keep the alias-collapsing identity they have today.
211
+ let parent = path.dirname(absolute), tail = [path.basename(absolute)];
212
+ for (;;) {
213
+ try { return path.join(await fsp.realpath(parent), ...tail); } catch (error) {
214
+ if (error.code !== 'ENOENT') throw error;
215
+ const next = path.dirname(parent);
216
+ if (next === parent) return absolute;
217
+ tail.unshift(path.basename(parent));
218
+ parent = next;
219
+ }
220
+ }
221
+ }
222
+ async function readDisk(file) {
223
+ // Same stable inode/byte checks and decoder as opening a desktop document.
224
+ // A vanished source surfaces as the store's friendly missing-file failure,
225
+ // never as a raw ENOENT bubbling into the GUI.
226
+ const {DocumentStore} = require('./document-store');
227
+ let snap;
228
+ try { snap = await new DocumentStore().snapshot(file); }
229
+ catch (error) {
230
+ if (error.code === 'ENOENT') throw fault('MP_FILE_MISSING', '原文件已不在磁盘上(可能被重命名或删除),草稿已保留:' + file);
231
+ throw error;
232
+ }
233
+ return {hash:snap.baseline.sha256, text:decodeDocument(snap.bytes, file).text.replace(/\r\n?/g, '\n')};
234
+ }
235
+ function checkedLength(changes, length) {
236
+ if (!Array.isArray(changes) || !changes.length) throw fault('MP_LIVE_CHANGE', '无效的协同编辑');
237
+ let end = 0, result = length;
238
+ for (const part of changes) {
239
+ if (!part || !Number.isSafeInteger(part.from) || !Number.isSafeInteger(part.to) ||
240
+ part.from < end || part.to < part.from || part.to > length || typeof part.insert !== 'string' || part.insert.includes('\r'))
241
+ throw fault('MP_LIVE_CHANGE', '协同编辑的位置或内容无效');
242
+ end = part.to;
243
+ result += part.insert.length - (part.to - part.from);
244
+ }
245
+ return result;
246
+ }
247
+ function apply(text, changes) {
248
+ let end = 0;
249
+ const parts = [];
250
+ for (const change of changes) { parts.push(text.slice(end, change.from), change.insert); end = change.to; }
251
+ parts.push(text.slice(end));
252
+ return parts.join('');
253
+ }
254
+ function materialize(db, room, version = room.version) {
255
+ let text = Buffer.from(db.prepare('SELECT checkpoint FROM rooms WHERE epoch=?').get(room.epoch).checkpoint).toString('utf16le');
256
+ for (const row of db.prepare('SELECT delta FROM changes WHERE epoch=? AND version<=? ORDER BY version').iterate(room.epoch, version))
257
+ text = apply(text, JSON.parse(row.delta));
258
+ return text;
259
+ }
260
+ const roomColumns = 'file,epoch,version,saved,disk,length,floor';
261
+ function roomFor(db, file) { return db.prepare('SELECT '+roomColumns+' FROM rooms WHERE file=?').get(file); }
262
+ function head(room) { return {epoch:room.epoch,version:room.version,savedVersion:room.saved,diskHash:room.disk}; }
263
+ function updateRoom(db, room, client, seq, changes) {
264
+ const length = checkedLength(changes, room.length), version = room.version + 1;
265
+ db.prepare('INSERT INTO changes VALUES(?,?,?,?,?)').run(room.epoch, version, client, seq, JSON.stringify(changes));
266
+ db.prepare('UPDATE rooms SET version=?,length=? WHERE epoch=?').run(version, length, room.epoch);
267
+ room.version = version; room.length = length;
268
+ }
269
+ function compact(db, room) {
270
+ // Large documents pay megabytes per checkpoint rewrite; compact them less
271
+ // eagerly so sustained typing never triggers repeated full-document churn.
272
+ const span = room.length > 1000000 ? 512 : 128;
273
+ if (room.version - room.floor < span) return;
274
+ for (const lease of db.prepare('SELECT id,pid FROM clients WHERE epoch=?').all(room.epoch))
275
+ if (!alive(lease.pid)) db.prepare('DELETE FROM clients WHERE id=?').run(lease.id);
276
+ const floor = db.prepare('SELECT MIN(ack) AS v FROM clients WHERE epoch=?').get(room.epoch).v ?? room.version;
277
+ if (floor - room.floor < span) return;
278
+ const text = materialize(db, room, floor);
279
+ db.prepare('UPDATE rooms SET floor=?,checkpoint=? WHERE epoch=?').run(floor, Buffer.from(text,'utf16le'), room.epoch);
280
+ db.prepare('DELETE FROM changes WHERE epoch=? AND version<=?').run(room.epoch, floor);
281
+ }
282
+ function adoptDisk(db, room, disk) {
283
+ if (room.disk === disk.hash) return;
284
+ const current = materialize(db, room);
285
+ if (current !== disk.text) {
286
+ // A draft that diverges from the new disk text is a genuine both-changed
287
+ // conflict — unless the external writer landed this very draft, which the
288
+ // equality check above already turned into a save.
289
+ if (room.version !== room.saved) throw conflict('原文件和未保存的协同草稿均已改变,请另存草稿或重新载入');
290
+ let from = 0, end = current.length, nextEnd = disk.text.length;
291
+ while (from < end && from < nextEnd && current.charCodeAt(from) === disk.text.charCodeAt(from)) from++;
292
+ while (end > from && nextEnd > from && current.charCodeAt(end-1) === disk.text.charCodeAt(nextEnd-1)) { end--; nextEnd--; }
293
+ updateRoom(db, room, 'disk', 0, [{from,to:end,insert:disk.text.slice(from,nextEnd)}]);
294
+ }
295
+ room.saved = room.version; room.disk = disk.hash;
296
+ db.prepare('UPDATE rooms SET saved=?,disk=? WHERE epoch=?').run(room.saved,room.disk,room.epoch);
297
+ }
298
+
299
+ // 已提交的本地编辑(非重发/空操作)后台预热一次安全写者:准备与输入重叠,保存仍等待同一已校验工作进程。
300
+ let writerWarm = false;
301
+ function warmWriterAfterEdit(accepted) {
302
+ if (!accepted || writerWarm) return;
303
+ writerWarm = true;
304
+ require('./safe-file').prepareFileWorker();
305
+ }
306
+
307
+ class LiveDocuments {
308
+ constructor({directory = defaultDirectory(), busyTimeoutMs} = {}) {
309
+ this.directory = directory; this.busyTimeoutMs = busyTimeoutMs;
310
+ this.clients = new Map();
311
+ }
312
+ get db() { return database(this.directory); }
313
+ leaseOptions() { return {busyTimeoutMs:this.busyTimeoutMs}; }
314
+ async join(filePath, initialText, expectedDiskHash) {
315
+ if (typeof initialText !== 'string') throw fault('MP_LIVE_JOIN','缺少已打开的正文');
316
+ const file = await canonicalFile(filePath), key = fileKey(file);
317
+ return withFileCommit(file, async () => {
318
+ // Opening a file is also the bounded housekeeping moment: crash orphans
319
+ // (dead clients, their clean rooms, dirty rooms with parked recovery)
320
+ // are retired here so un-reopened files cannot accumulate note copies.
321
+ await sweepOrphans(this.db, this.directory);
322
+ const disk = await readDisk(file);
323
+ if (initialText.replace(/\r\n?/g,'\n') !== disk.text || expectedDiskHash && expectedDiskHash !== disk.hash)
324
+ throw conflict('打开后磁盘内容已改变,请重新载入或另存草稿');
325
+ const db = this.db, client = uuid();
326
+ // A dead room for this very file is retired under this lease with the
327
+ // sweeper's semantics: clean rooms go, dirty rooms park a recovery copy
328
+ // first. A failed park keeps the room — and with it the draft.
329
+ let existing = roomFor(db, key);
330
+ if (existing && existing.version !== existing.saved &&
331
+ !db.prepare('SELECT pid FROM clients WHERE epoch=?').all(existing.epoch).some(({pid}) => alive(pid)))
332
+ await parkRecovery(db, this.directory, existing);
333
+ const joined = atomic(db, () => {
334
+ let room = roomFor(db, key);
335
+ if (room) {
336
+ for (const lease of db.prepare('SELECT id,pid FROM clients WHERE epoch=?').all(room.epoch))
337
+ if (!alive(lease.pid)) db.prepare('DELETE FROM clients WHERE id=?').run(lease.id);
338
+ if (!db.prepare('SELECT 1 FROM clients WHERE epoch=? LIMIT 1').get(room.epoch)) {
339
+ if (room.version === room.saved) { db.prepare('DELETE FROM rooms WHERE epoch=?').run(room.epoch); room = null; }
340
+ // A dirty room whose park failed stays: adoptDisk below conflicts
341
+ // safely if the disk text changed, or resumes the draft if not.
342
+ }
343
+ }
344
+ if (!room) {
345
+ db.prepare('INSERT INTO rooms VALUES(?,?,0,0,?,?,0,?)').run(key,uuid(),disk.hash,disk.text.length,Buffer.from(disk.text,'utf16le'));
346
+ room = roomFor(db, key);
347
+ } else adoptDisk(db, room, disk);
348
+ const text = room.version === room.saved ? disk.text : materialize(db,room);
349
+ db.prepare('INSERT INTO clients(id,epoch,pid,ack) VALUES(?,?,?,?)').run(client,room.epoch,process.pid,room.version);
350
+ return {...head(room),client,text};
351
+ });
352
+ this.clients.set(client, {key,file,epoch:joined.epoch});
353
+ hosts.add(this); // 首个成员入座才登记 host;从未加入/全部离开的实例不占强引用
354
+ // Surface parked drafts for this file so the UI can offer restoring
355
+ // them instead of silently discarding a crashed window's work.
356
+ const recovered = db.prepare('SELECT source,recoveryPath,epoch AS recoveryEpoch,version,saved,at,bytes,sha256 FROM recovery WHERE source=? ORDER BY at DESC LIMIT 3').all(key);
357
+ return recovered.length ? {...joined, recovered} : joined;
358
+ }, this.directory, this.leaseOptions());
359
+ }
360
+ member(client, epoch) {
361
+ const member = this.clients.get(client);
362
+ if (!member || member.epoch !== epoch) throw fault('MP_LIVE_SESSION','文档协同会话已结束,草稿已保留');
363
+ return member;
364
+ }
365
+ exchange(client, {epoch,version,seq,changes}) {
366
+ const {key} = this.member(client,epoch), db = this.db;
367
+ let accepted = false; // 仅真正接受的新 seq 才算一次“有修改的本地编辑”
368
+ const transact = () => {
369
+ const room = roomFor(db,key);
370
+ if (!room || room.epoch !== epoch || !Number.isSafeInteger(version) || version < room.floor || version > room.version)
371
+ throw fault('MP_LIVE_SESSION','无法关联这一版编辑,请保留本地草稿');
372
+ const lease = db.prepare('SELECT ack,seq FROM clients WHERE id=? AND epoch=?').get(client,epoch);
373
+ if (!lease) throw fault('MP_LIVE_SESSION','文档协同会话已结束');
374
+ if (changes !== undefined) {
375
+ if (!Number.isSafeInteger(seq) || seq < 1 || seq > lease.seq + 1) throw fault('MP_LIVE_CHANGE','协同编辑序号不连续');
376
+ if (version === room.version && seq === lease.seq + 1) {
377
+ updateRoom(db, room, client, seq, changes);
378
+ db.prepare('UPDATE clients SET seq=? WHERE id=?').run(seq,client);
379
+ // 全为空 splice 的协议合法 no-op 不做任何字符操作,不算会写盘的编辑。
380
+ accepted = changes.some(part => part.from !== part.to || part.insert.length > 0);
381
+ }
382
+ }
383
+ if (version > lease.ack) db.prepare('UPDATE clients SET ack=? WHERE id=?').run(version,client);
384
+ const updates = db.prepare('SELECT version,client,seq,delta FROM changes WHERE epoch=? AND version>? ORDER BY version').all(epoch,version)
385
+ .map(({delta,...row}) => ({...row,changes:JSON.parse(delta)}));
386
+ if (changes !== undefined) compact(db,room);
387
+ return {...head(room),updates};
388
+ };
389
+ // Most polls are reads with no edits or acknowledgment to record.
390
+ if (changes === undefined) {
391
+ const cached = db.prepare('SELECT r.epoch,r.version,r.saved,r.disk FROM rooms r JOIN clients c ON c.epoch=r.epoch WHERE c.id=? AND c.ack=? AND r.version=?').get(client,version,version);
392
+ if (cached?.epoch === epoch) return {...head(cached),updates:[]};
393
+ }
394
+ const result = atomic(db,transact);
395
+ warmWriterAfterEdit(accepted); // 事务提交后再触发;失败自吞,不影响 exchange
396
+ return result;
397
+ }
398
+ async save(client, {epoch,version,text}, writer) {
399
+ const {key,file} = this.member(client,epoch);
400
+ return withFileCommit(file, async () => {
401
+ const db = this.db, room = roomFor(db,key);
402
+ if (!room || room.epoch !== epoch || room.version !== version || materialize(db,room) !== text)
403
+ throw fault('MP_LIVE_STALE','其他窗口刚有新编辑,请同步后保存;当前正文已保留');
404
+ if ((await readDisk(file)).hash !== room.disk) throw conflict('磁盘已被其他程序修改,当前草稿已保留');
405
+ this.member(client,epoch);
406
+ const receipt = await writer({diskHash:room.disk});
407
+ if (!receipt || receipt.ok === false) return receipt;
408
+ const disk = await readDisk(file);
409
+ if (disk.text !== text || receipt.writtenDiskHash && receipt.writtenDiskHash !== disk.hash)
410
+ throw conflict('文件已再次改变,不能把此次保存标记为成功');
411
+ db.prepare('UPDATE rooms SET saved=?,disk=? WHERE epoch=?').run(version,disk.hash,epoch);
412
+ // The provider receipt stays authoritative: live metadata is added under
413
+ // its own names, so a provider version field is never overwritten.
414
+ const live = roomFor(db,key);
415
+ return {...receipt, epoch, liveVersion:(live||room).version, savedVersion:version, diskHash:disk.hash};
416
+ }, this.directory, this.leaseOptions());
417
+ }
418
+ // Focus-time disk probe for live members: detect non-cooperating writes.
419
+ // The snapshot and the adoption run under the same file-write lease as
420
+ // save/external, so a cooperating window's commit can never fall between
421
+ // the disk read and the room update — an interleaved save would otherwise
422
+ // be adopted as a stale disk state and overwrite the newer room contents.
423
+ async refresh(client, epoch) {
424
+ const {key,file} = this.member(client,epoch);
425
+ return withFileCommit(file, async () => {
426
+ this.member(client,epoch);
427
+ const disk = await readDisk(file);
428
+ return atomic(this.db, () => {
429
+ const room = roomFor(this.db,key);
430
+ if (!room || room.epoch !== epoch) throw fault('MP_LIVE_SESSION','文档协同会话已结束,草稿已保留');
431
+ if (room.disk === disk.hash) return {...head(room),changed:false};
432
+ if (room.version !== room.saved) return {...head(room),changed:true,unsavedDraft:true};
433
+ try { adoptDisk(this.db, room, disk); }
434
+ catch (error) {
435
+ if (error.code !== 'MP_FILE_CONFLICT') throw error;
436
+ return {...head(roomFor(this.db,key) || room),changed:true,unsavedDraft:true};
437
+ }
438
+ return {...head(roomFor(this.db,key) || room),changed:true};
439
+ });
440
+ }, this.directory, this.leaseOptions());
441
+ }
442
+ // External/model writes keep their own permission checks and must not inspect
443
+ // or supersede the unsaved shared draft. Publish only the actual disk result.
444
+ //
445
+ // The one exception is an explicit, user-confirmed "保留我的修改" from the very
446
+ // window that owns this room (options.keepMineText + options.client set by the
447
+ // host after its own one-shot overwrite grant). Even then only the room's own
448
+ // authoritative text may reach disk: the caller's captured text must still
449
+ // equal materialize(room), the caller must be a live member of this epoch, and
450
+ // the host writer keeps its baseline CAS. A peer edit arriving first is a real
451
+ // conflict and stays a conflict — never laundered into a successful save.
452
+ async external(filePath, writer, options = {}) {
453
+ const file = await canonicalFile(filePath), key = fileKey(file);
454
+ const keepMineText = options && typeof options.keepMineText === 'string' ? options.keepMineText.replace(/\r\n?/g,'\n') : null;
455
+ return withFileCommit(file, async () => {
456
+ const db = this.db;
457
+ await sweepOrphans(db, this.directory);
458
+ const before = roomFor(db,key);
459
+ let sealed = null;
460
+ if (before && before.saved !== before.version) {
461
+ if (keepMineText === null) throw conflict('此文件有未保存的编辑,请先保存或保留草稿');
462
+ const member = options.client && this.clients.get(options.client);
463
+ if (!member || member.epoch !== before.epoch || member.key !== key)
464
+ throw fault('MP_LIVE_SESSION','协同会话不属于当前文件,修改已保留');
465
+ if (materialize(db, before) !== keepMineText)
466
+ throw fault('MP_LIVE_STALE','其他窗口刚有新编辑,请同步后再保留修改;当前草稿已保留');
467
+ sealed = {...head(before), text:keepMineText};
468
+ }
469
+ const result = await writer();
470
+ let sealedHead = null;
471
+ if (before) {
472
+ const disk = await readDisk(file);
473
+ atomic(db, () => {
474
+ const room = roomFor(db,key);
475
+ if (room?.epoch !== before.epoch) return;
476
+ if (sealed && room.version === sealed.version) {
477
+ // The room authorized this exact draft; these bytes are its own
478
+ // valid CAS-covered write, so the room becomes clean and keeps the
479
+ // disk identity the writer just produced.
480
+ if (disk.text !== sealed.text) throw conflict('文件已再次改变,不能把此次保留标记为成功');
481
+ room.saved = room.version; room.disk = disk.hash;
482
+ db.prepare('UPDATE rooms SET saved=?,disk=? WHERE epoch=?').run(room.saved,room.disk,room.epoch);
483
+ sealedHead = {...head(room),clean:true};
484
+ return;
485
+ }
486
+ try { adoptDisk(db,room,disk); }
487
+ catch (error) {
488
+ if (error.code !== 'MP_FILE_CONFLICT') throw error;
489
+ // New input arrived while the external writer was running. Those
490
+ // bytes did land on disk, so the writer's own result stands; the
491
+ // unsaved draft stays protected and refresh()/exchange() report
492
+ // the disk change instead of failing a write that succeeded.
493
+ }
494
+ });
495
+ }
496
+ return sealedHead ? {...result, ...sealedHead, liveVersion:sealedHead.version} : result;
497
+ }, this.directory, this.leaseOptions());
498
+ }
499
+ leave(client) {
500
+ const member = this.clients.get(client);
501
+ if (!member) return;
502
+ const db = this.db;
503
+ atomic(db, () => {
504
+ db.prepare('DELETE FROM clients WHERE id=? AND epoch=?').run(client,member.epoch);
505
+ db.prepare('DELETE FROM rooms WHERE epoch=? AND NOT EXISTS(SELECT 1 FROM clients WHERE epoch=?)').run(member.epoch,member.epoch);
506
+ });
507
+ this.clients.delete(client);
508
+ if (!this.clients.size) hosts.delete(this);
509
+ }
510
+ }
511
+ // 宿主(如插件 HMR)在确认本代 LiveDocuments 全部实例已 leave、已接纳写作业
512
+ // drained 后调用:仅 close 本代打开的 SQLite 连接并清 Map——不删库、不动
513
+ // 房间/草稿/恢复数据。仍有成员在场或有在途租约操作时拒绝(MP_LIVE_ACTIVE);
514
+ // 重复调用幂等。调用顺序约定:先 retireLiveStorage(),再旧代 safeFile 的
515
+ // shutdownFileWorker()。
516
+ function retireLiveStorage() {
517
+ if (storageOperations > 0) throw fault('MP_LIVE_ACTIVE', '协同存储仍有在途操作,稍后再试');
518
+ for (const host of hosts) if (host.clients.size > 0) throw fault('MP_LIVE_ACTIVE', '仍有协同会话在场,全部离开后再退休存储');
519
+ const closed = [];
520
+ for (const [location, db] of [...databases]) { db.close(); databases.delete(location); closed.push(location); }
521
+ hosts.clear();
522
+ return {ok: true, closed};
523
+ }
524
+ module.exports = {LiveDocuments,withFileCommit,defaultDirectory,retireLiveStorage};
Binary file
@@ -0,0 +1,10 @@
1
+ {
2
+ "file": "shell\\native\\file-worker.exe",
3
+ "bytes": 19968,
4
+ "sha256": "c6f1bf4e5a0335ff066eca2597b273a32e39d7fe9762c566b393ea01c542ce22",
5
+ "inputs": {
6
+ "source": "fe41411e00d4e93b5642ed4e79a269e98915a990723127cdc29f3b212c9f61b7",
7
+ "compiler": "46809206887326d2d24db1eff1f3064de972c3451abe766b49111450a5e08e00",
8
+ "script": "52b6a967f29980b79bca28186f6c5a9dcb7e1bb1ebc2972b3fd7e37616b6997e"
9
+ }
10
+ }