mrmd-sync 0.3.1 → 0.3.3

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.
Files changed (3) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +1 -1
  3. package/src/index.js +348 -39
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Maxime Rivest
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-sync",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Production-ready sync server for mrmd - real-time collaboration with file persistence",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.js CHANGED
@@ -24,11 +24,31 @@ import {
24
24
  mkdirSync,
25
25
  statSync,
26
26
  rmSync,
27
+ readdirSync,
27
28
  } from 'fs';
28
29
  import { join, dirname, relative, resolve } from 'path';
29
30
  import { createHash } from 'crypto';
30
31
  import { tmpdir } from 'os';
31
32
 
33
+ const DOC_EXTENSIONS = ['.md', '.qmd'];
34
+
35
+ function getDocExtension(docName) {
36
+ if (!docName) return '';
37
+ const lower = docName.toLowerCase();
38
+ for (const ext of DOC_EXTENSIONS) {
39
+ if (lower.endsWith(ext)) return ext;
40
+ }
41
+ return '';
42
+ }
43
+
44
+ function resolveDocFilePath(docName, resolvedDir) {
45
+ const hasDocExt = !!getDocExtension(docName);
46
+ if (docName.startsWith('/')) {
47
+ return hasDocExt ? docName : `${docName}.md`;
48
+ }
49
+ return hasDocExt ? join(resolvedDir, docName) : join(resolvedDir, `${docName}.md`);
50
+ }
51
+
32
52
  // =============================================================================
33
53
  // PID FILE - Prevents multiple instances on same directory
34
54
  // =============================================================================
@@ -279,6 +299,89 @@ const DEFAULT_CONFIG = {
279
299
  // Paths that require dangerouslyAllowSystemPaths: true
280
300
  const DANGEROUS_PATHS = ['/', '/etc', '/usr', '/var', '/bin', '/sbin', '/root', '/home'];
281
301
 
302
+ // =============================================================================
303
+ // DATA LOSS PREVENTION - Memory Monitoring
304
+ // =============================================================================
305
+ // Added after investigating unexplained data loss on 2026-01-16.
306
+ // The sync server crashed with OOM after ~9 minutes, consuming 4GB for a 2.5KB
307
+ // document. User lost ~2.5 hours of work because the editor gave no warning.
308
+ //
309
+ // These safeguards ensure:
310
+ // 1. Memory usage is monitored and warnings are logged
311
+ // 2. Y.Doc compaction runs periodically to bound memory growth
312
+ // 3. Server fails fast (512MB limit in electron main.js) rather than OOM later
313
+ // =============================================================================
314
+
315
+ const MEMORY_WARNING_MB = 200; // Warn at 200MB
316
+ // DISABLED: Compaction causes document duplication!
317
+ // When clients reconnect after compaction, Yjs merges their state with the
318
+ // server's fresh state, causing content to double. Need a different approach.
319
+ // Keeping memory monitoring for warnings only.
320
+ const MEMORY_COMPACT_MB = Infinity; // Disabled
321
+ const MEMORY_CHECK_INTERVAL_MS = 30000; // Check every 30 seconds
322
+ const COMPACTION_INTERVAL_MS = Infinity; // Disabled
323
+
324
+ /**
325
+ * Get current memory usage in MB
326
+ */
327
+ function getMemoryUsageMB() {
328
+ const usage = process.memoryUsage();
329
+ return {
330
+ heapUsed: Math.round(usage.heapUsed / 1024 / 1024),
331
+ heapTotal: Math.round(usage.heapTotal / 1024 / 1024),
332
+ rss: Math.round(usage.rss / 1024 / 1024),
333
+ external: Math.round(usage.external / 1024 / 1024),
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Compact a Y.Doc by creating a fresh doc with only current content.
339
+ * This discards all operation history and tombstones, dramatically reducing memory.
340
+ *
341
+ * NOTE: This will disconnect all clients, who will need to reconnect.
342
+ * The content itself is preserved via the file and snapshot.
343
+ *
344
+ * @param {Object} docData - The document data object from getDoc()
345
+ * @param {Object} log - Logger instance
346
+ * @returns {Object} - New Y.Doc and Y.Text
347
+ */
348
+ function compactYDoc(docData, log) {
349
+ const { ydoc, ytext, docName } = docData;
350
+
351
+ // Get current content before compaction
352
+ const currentContent = ytext.toString();
353
+ const oldStateSize = Y.encodeStateAsUpdate(ydoc).length;
354
+
355
+ log.info('Compacting Y.Doc', {
356
+ doc: docName,
357
+ contentLength: currentContent.length,
358
+ oldStateSize,
359
+ });
360
+
361
+ // Create fresh Y.Doc
362
+ const newYdoc = new Y.Doc();
363
+ newYdoc.name = docName;
364
+ const newYtext = newYdoc.getText('content');
365
+
366
+ // Insert current content into fresh doc
367
+ if (currentContent.length > 0) {
368
+ newYdoc.transact(() => {
369
+ newYtext.insert(0, currentContent);
370
+ });
371
+ }
372
+
373
+ const newStateSize = Y.encodeStateAsUpdate(newYdoc).length;
374
+
375
+ log.info('Y.Doc compacted', {
376
+ doc: docName,
377
+ oldStateSize,
378
+ newStateSize,
379
+ reduction: `${Math.round((1 - newStateSize / oldStateSize) * 100)}%`,
380
+ });
381
+
382
+ return { newYdoc, newYtext };
383
+ }
384
+
282
385
  // =============================================================================
283
386
  // UTILITY FUNCTIONS
284
387
  // =============================================================================
@@ -352,6 +455,87 @@ function atomicWriteFile(filePath, content) {
352
455
  }
353
456
  }
354
457
 
458
+ /**
459
+ * Clean up stale temp files left behind by crashed processes.
460
+ * Temp files have pattern: {filename}.tmp.{pid}.{timestamp}
461
+ * A file is stale if:
462
+ * - The PID no longer exists (process died)
463
+ * - OR the timestamp is older than maxAgeMs (fallback safety)
464
+ */
465
+ function cleanupStaleTempFiles(dir, log, maxAgeMs = 3600000) {
466
+ const tmpPattern = /\.tmp\.(\d+)\.(\d+)$/;
467
+ let cleaned = 0;
468
+ let errors = 0;
469
+
470
+ function walkDir(currentDir) {
471
+ let entries;
472
+ try {
473
+ entries = readdirSync(currentDir, { withFileTypes: true });
474
+ } catch {
475
+ return; // Skip directories we can't read
476
+ }
477
+
478
+ for (const entry of entries) {
479
+ const fullPath = join(currentDir, entry.name);
480
+
481
+ if (entry.isDirectory()) {
482
+ // Skip hidden directories and node_modules
483
+ if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
484
+ walkDir(fullPath);
485
+ }
486
+ continue;
487
+ }
488
+
489
+ // Check if this is a temp file
490
+ const match = entry.name.match(tmpPattern);
491
+ if (!match) continue;
492
+
493
+ const pid = parseInt(match[1], 10);
494
+ const timestamp = parseInt(match[2], 10);
495
+ const age = Date.now() - timestamp;
496
+
497
+ // Check if process is dead
498
+ let processIsDead = false;
499
+ try {
500
+ process.kill(pid, 0); // Signal 0 = check if process exists
501
+ } catch (err) {
502
+ if (err.code === 'ESRCH') {
503
+ processIsDead = true; // Process doesn't exist
504
+ }
505
+ // EPERM means process exists but we can't signal it
506
+ }
507
+
508
+ // Remove if process is dead OR file is older than maxAgeMs
509
+ if (processIsDead || age > maxAgeMs) {
510
+ try {
511
+ unlinkSync(fullPath);
512
+ cleaned++;
513
+ log.info('Removed stale temp file', {
514
+ path: fullPath,
515
+ pid,
516
+ processIsDead,
517
+ ageMs: age,
518
+ });
519
+ } catch (err) {
520
+ errors++;
521
+ log.warn('Failed to remove stale temp file', {
522
+ path: fullPath,
523
+ error: err.message,
524
+ });
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ walkDir(dir);
531
+
532
+ if (cleaned > 0 || errors > 0) {
533
+ log.info('Temp file cleanup complete', { cleaned, errors });
534
+ }
535
+
536
+ return { cleaned, errors };
537
+ }
538
+
355
539
  /**
356
540
  * Save Yjs document state for crash recovery
357
541
  */
@@ -495,6 +679,9 @@ export function createServer(options = {}) {
495
679
  // Acquire PID lock to prevent multiple instances on same directory
496
680
  const releasePidLock = acquirePidLock(snapshotDir, port, log);
497
681
 
682
+ // Clean up stale temp files from previous crashed processes
683
+ cleanupStaleTempFiles(resolvedDir, log);
684
+
498
685
  // Document storage
499
686
  const docs = new Map();
500
687
 
@@ -521,16 +708,8 @@ export function createServer(options = {}) {
521
708
  const mutex = new AsyncMutex();
522
709
 
523
710
  // Support absolute paths for files outside the docs directory
524
- let filePath;
525
- let isAbsolutePath = false;
526
- if (docName.startsWith('/')) {
527
- // Absolute path - use directly
528
- isAbsolutePath = true;
529
- filePath = docName.endsWith('.md') ? docName : `${docName}.md`;
530
- } else {
531
- // Relative path - join with docs directory
532
- filePath = join(resolvedDir, docName.endsWith('.md') ? docName : `${docName}.md`);
533
- }
711
+ const isAbsolutePath = docName.startsWith('/');
712
+ const filePath = resolveDocFilePath(docName, resolvedDir);
534
713
 
535
714
  // For snapshots, always use the snapshot dir with a safe name
536
715
  const safeSnapshotName = docName.replace(/\//g, '__').replace(/^_+/, '');
@@ -539,6 +718,21 @@ export function createServer(options = {}) {
539
718
  : null;
540
719
 
541
720
  const ytext = ydoc.getText('content');
721
+ const docData = {
722
+ docName,
723
+ ydoc,
724
+ ytext,
725
+ awareness,
726
+ conns,
727
+ mutex,
728
+ filePath,
729
+ snapshotPath,
730
+ applyFileChange: null,
731
+ flushWrite: null,
732
+ scheduleCleanup: null,
733
+ cancelCleanup: null,
734
+ scheduleWrite: null,
735
+ };
542
736
 
543
737
  // Track state
544
738
  let lastFileHash = null;
@@ -604,7 +798,7 @@ export function createServer(options = {}) {
604
798
  if (isShuttingDown) return;
605
799
 
606
800
  isWritingToFile = true;
607
- const content = ytext.toString();
801
+ const content = docData.ytext.toString();
608
802
  const hash = contentHash(content);
609
803
 
610
804
  // Skip if content unchanged
@@ -614,7 +808,7 @@ export function createServer(options = {}) {
614
808
  return;
615
809
  }
616
810
 
617
- const { success, error } = atomicWriteFile(filePath, content);
811
+ const { success, error } = atomicWriteFile(docData.filePath, content);
618
812
  if (error) {
619
813
  log.error('Error saving file', { path: filePath, error });
620
814
  metrics.errorOccurred();
@@ -630,15 +824,17 @@ export function createServer(options = {}) {
630
824
  }, debounceMs);
631
825
  };
632
826
 
827
+ docData.scheduleWrite = scheduleWrite;
828
+
633
829
  // Listen for Yjs updates
634
- ydoc.on('update', scheduleWrite);
830
+ docData.ydoc.on('update', scheduleWrite);
635
831
 
636
832
  // Schedule Yjs snapshot saves
637
833
  if (persistYjsState && snapshotPath) {
638
834
  const scheduleSnapshot = () => {
639
835
  clearTimeout(snapshotTimeout);
640
836
  snapshotTimeout = setTimeout(() => {
641
- const { success, error } = saveYjsSnapshot(snapshotPath, ydoc);
837
+ const { success, error } = saveYjsSnapshot(snapshotPath, docData.ydoc);
642
838
  if (error) {
643
839
  log.warn('Failed to save Yjs snapshot', { doc: docName, error });
644
840
  }
@@ -656,7 +852,7 @@ export function createServer(options = {}) {
656
852
  const newHash = contentHash(newContent);
657
853
  if (newHash === lastFileHash) return;
658
854
 
659
- const oldContent = ytext.toString();
855
+ const oldContent = docData.ytext.toString();
660
856
  if (oldContent === newContent) {
661
857
  lastFileHash = newHash;
662
858
  return;
@@ -665,14 +861,14 @@ export function createServer(options = {}) {
665
861
  isWritingToYjs = true;
666
862
  const changes = diffChars(oldContent, newContent);
667
863
 
668
- ydoc.transact(() => {
864
+ docData.ydoc.transact(() => {
669
865
  let pos = 0;
670
866
  for (const change of changes) {
671
867
  if (change.added) {
672
- ytext.insert(pos, change.value);
868
+ docData.ytext.insert(pos, change.value);
673
869
  pos += change.value.length;
674
870
  } else if (change.removed) {
675
- ytext.delete(pos, change.value.length);
871
+ docData.ytext.delete(pos, change.value.length);
676
872
  } else {
677
873
  pos += change.value.length;
678
874
  }
@@ -689,10 +885,10 @@ export function createServer(options = {}) {
689
885
  const flushWrite = async () => {
690
886
  clearTimeout(writeTimeout);
691
887
  await mutex.withLock(async () => {
692
- const content = ytext.toString();
888
+ const content = docData.ytext.toString();
693
889
  const hash = contentHash(content);
694
890
  if (hash !== lastFileHash) {
695
- const { error } = atomicWriteFile(filePath, content);
891
+ const { error } = atomicWriteFile(docData.filePath, content);
696
892
  if (error) {
697
893
  log.error('Error flushing file', { path: filePath, error });
698
894
  } else {
@@ -701,7 +897,7 @@ export function createServer(options = {}) {
701
897
  }
702
898
  // Save final snapshot
703
899
  if (snapshotPath) {
704
- saveYjsSnapshot(snapshotPath, ydoc);
900
+ saveYjsSnapshot(snapshotPath, docData.ydoc);
705
901
  }
706
902
  });
707
903
  pendingWrites.delete(docName);
@@ -714,8 +910,8 @@ export function createServer(options = {}) {
714
910
  if (conns.size === 0) {
715
911
  await flushWrite();
716
912
  clearTimeout(snapshotTimeout);
717
- awareness.destroy();
718
- ydoc.destroy();
913
+ docData.awareness.destroy();
914
+ docData.ydoc.destroy();
719
915
  docs.delete(docName);
720
916
  log.info('Cleaned up document', { doc: docName });
721
917
  }
@@ -726,19 +922,10 @@ export function createServer(options = {}) {
726
922
  clearTimeout(cleanupTimeout);
727
923
  };
728
924
 
729
- const docData = {
730
- ydoc,
731
- ytext,
732
- awareness,
733
- conns,
734
- mutex,
735
- filePath,
736
- snapshotPath,
737
- applyFileChange,
738
- flushWrite,
739
- scheduleCleanup,
740
- cancelCleanup,
741
- };
925
+ docData.applyFileChange = applyFileChange;
926
+ docData.flushWrite = flushWrite;
927
+ docData.scheduleCleanup = scheduleCleanup;
928
+ docData.cancelCleanup = cancelCleanup;
742
929
 
743
930
  docs.set(docName, docData);
744
931
  return docData;
@@ -748,9 +935,18 @@ export function createServer(options = {}) {
748
935
  // FILE WATCHER
749
936
  // =============================================================================
750
937
 
751
- const watcher = watch(join(resolvedDir, '**/*.md'), {
938
+ const watcher = watch([
939
+ join(resolvedDir, '**/*.md'),
940
+ join(resolvedDir, '**/*.qmd'),
941
+ ], {
752
942
  ignoreInitial: true,
753
943
  awaitWriteFinish: { stabilityThreshold: 300 },
944
+ ignored: [
945
+ '**/node_modules/**',
946
+ '**/.venv/**',
947
+ '**/__pycache__/**',
948
+ '**/.git/**',
949
+ ],
754
950
  });
755
951
 
756
952
  watcher.on('change', async (filePath) => {
@@ -770,7 +966,9 @@ export function createServer(options = {}) {
770
966
 
771
967
  watcher.on('add', async (filePath) => {
772
968
  const relativePath = relative(resolvedDir, filePath);
773
- const docName = relativePath.replace(/\.md$/, '');
969
+ const docName = relativePath.toLowerCase().endsWith('.md')
970
+ ? relativePath.replace(/\.md$/i, '')
971
+ : relativePath;
774
972
 
775
973
  if (docs.has(docName)) {
776
974
  const { content, error } = safeReadFile(filePath, maxFileSize);
@@ -1045,7 +1243,7 @@ export function createServer(options = {}) {
1045
1243
  // GRACEFUL SHUTDOWN
1046
1244
  // =============================================================================
1047
1245
 
1048
- const gracefulShutdown = async (signal) => {
1246
+ let gracefulShutdown = async (signal) => {
1049
1247
  if (isShuttingDown) return;
1050
1248
  isShuttingDown = true;
1051
1249
 
@@ -1100,6 +1298,117 @@ export function createServer(options = {}) {
1100
1298
  });
1101
1299
  });
1102
1300
 
1301
+ // =============================================================================
1302
+ // DATA LOSS PREVENTION - Memory Monitoring
1303
+ // =============================================================================
1304
+ // Added after investigating unexplained data loss on 2026-01-16.
1305
+ // Monitors memory usage and triggers compaction if needed.
1306
+ // =============================================================================
1307
+
1308
+ let lastCompactionTime = Date.now();
1309
+ let memoryWarningLogged = false;
1310
+ let compactionInProgress = false;
1311
+
1312
+ const memoryMonitorInterval = setInterval(async () => {
1313
+ if (isShuttingDown) return;
1314
+
1315
+ const mem = getMemoryUsageMB();
1316
+
1317
+ // Log warning if memory is getting high
1318
+ if (mem.heapUsed >= MEMORY_WARNING_MB && !memoryWarningLogged) {
1319
+ log.warn('High memory usage detected', {
1320
+ heapUsed: `${mem.heapUsed}MB`,
1321
+ heapTotal: `${mem.heapTotal}MB`,
1322
+ rss: `${mem.rss}MB`,
1323
+ threshold: `${MEMORY_WARNING_MB}MB`,
1324
+ });
1325
+ memoryWarningLogged = true;
1326
+ } else if (mem.heapUsed < MEMORY_WARNING_MB) {
1327
+ memoryWarningLogged = false;
1328
+ }
1329
+
1330
+ if (compactionInProgress) return;
1331
+
1332
+ // Trigger compaction if memory is critical OR if enough time has passed
1333
+ const timeSinceLastCompaction = Date.now() - lastCompactionTime;
1334
+ const shouldCompact =
1335
+ mem.heapUsed >= MEMORY_COMPACT_MB ||
1336
+ timeSinceLastCompaction >= COMPACTION_INTERVAL_MS;
1337
+
1338
+ if (shouldCompact && docs.size > 0) {
1339
+ compactionInProgress = true;
1340
+ try {
1341
+ log.info('Triggering Y.Doc compaction', {
1342
+ reason: mem.heapUsed >= MEMORY_COMPACT_MB ? 'memory-pressure' : 'periodic',
1343
+ heapUsed: `${mem.heapUsed}MB`,
1344
+ docsCount: docs.size,
1345
+ });
1346
+
1347
+ // Compact all documents
1348
+ for (const [docName, docData] of docs) {
1349
+ try {
1350
+ await docData.flushWrite();
1351
+
1352
+ // Disconnect all clients (they will reconnect and get fresh state)
1353
+ for (const ws of docData.conns) {
1354
+ try {
1355
+ ws.close(4000, 'Document compacted - please reconnect');
1356
+ } catch (e) {
1357
+ // Ignore close errors
1358
+ }
1359
+ }
1360
+ docData.conns.clear();
1361
+
1362
+ // Destroy old doc and create fresh one
1363
+ const oldYdoc = docData.ydoc;
1364
+ const oldAwareness = docData.awareness;
1365
+ const { newYdoc, newYtext } = compactYDoc(docData, log);
1366
+ const newAwareness = new awarenessProtocol.Awareness(newYdoc);
1367
+
1368
+ oldYdoc.off('update', docData.scheduleWrite);
1369
+
1370
+ // Update the document entry
1371
+ docData.ydoc = newYdoc;
1372
+ docData.ytext = newYtext;
1373
+ docData.awareness = newAwareness;
1374
+
1375
+ // Re-register the update listener
1376
+ newYdoc.on('update', docData.scheduleWrite);
1377
+
1378
+ // Clean up old doc
1379
+ oldAwareness.destroy();
1380
+ oldYdoc.destroy();
1381
+
1382
+ log.info('Document compacted successfully', { doc: docName });
1383
+ } catch (e) {
1384
+ log.error('Error compacting document', { doc: docName, error: e.message });
1385
+ }
1386
+ }
1387
+
1388
+ lastCompactionTime = Date.now();
1389
+
1390
+ // Force garbage collection if available (--expose-gc flag)
1391
+ if (global.gc) {
1392
+ global.gc();
1393
+ const afterMem = getMemoryUsageMB();
1394
+ log.info('GC completed', {
1395
+ heapUsed: `${afterMem.heapUsed}MB`,
1396
+ freed: `${mem.heapUsed - afterMem.heapUsed}MB`,
1397
+ });
1398
+ }
1399
+ } finally {
1400
+ compactionInProgress = false;
1401
+ }
1402
+ }
1403
+ }, MEMORY_CHECK_INTERVAL_MS);
1404
+
1405
+ // Clean up interval on shutdown
1406
+ const originalGracefulShutdown = gracefulShutdown;
1407
+ gracefulShutdown = async (signal) => {
1408
+ clearInterval(memoryMonitorInterval);
1409
+ return originalGracefulShutdown(signal);
1410
+ };
1411
+
1103
1412
  // =============================================================================
1104
1413
  // PUBLIC API
1105
1414
  // =============================================================================