meta-sovereign 0.49.8 → 0.49.9

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.
@@ -8,6 +8,7 @@
8
8
  import { createHash, randomBytes } from 'node:crypto';
9
9
  import { createWriteStream, promises as fs } from 'node:fs';
10
10
  import path from 'node:path';
11
+ import { renameDurable } from '../../storage/durable.js';
11
12
  import { pipeline } from 'node:stream/promises';
12
13
 
13
14
  const extensionOf = (name) => {
@@ -39,7 +40,7 @@ export const saveMedia = async ({ mediaDir, network, stream, name }) => {
39
40
  );
40
41
  const sha256 = hash.digest('hex');
41
42
  const file = `${sha256}${extensionOf(name)}`;
42
- await fs.rename(temp, path.join(dir, file));
43
+ await renameDurable(temp, path.join(dir, file)); // (R-Z19)
43
44
  await fs.chmod(path.join(dir, file), 0o600);
44
45
  return { path: `media/${network}/${file}`, sha256, size };
45
46
  } catch (error) {
@@ -28,6 +28,7 @@
28
28
  import { promises as fs } from 'node:fs';
29
29
  import os from 'node:os';
30
30
  import path from 'node:path';
31
+ import { renameDurable } from '../../storage/durable.js';
31
32
  import { execFile } from 'node:child_process';
32
33
  import { autoImportRecipes } from './recipes.js';
33
34
 
@@ -229,7 +230,7 @@ export const createSignIns = ({
229
230
  const file = fileOf(provider);
230
231
  const temp = `${file}.${process.pid}.tmp`;
231
232
  await fs.writeFile(temp, JSON.stringify(seed), { mode: 0o600 });
232
- await fs.rename(temp, file);
233
+ await renameDurable(temp, file); // (R-Z19)
233
234
  };
234
235
 
235
236
  const access = () => probeFullDiskAccess({ platform, homeDir, env });
@@ -32,6 +32,7 @@
32
32
  import { promises as fs } from 'node:fs';
33
33
  import os from 'node:os';
34
34
  import path from 'node:path';
35
+ import { renameDurable } from '../../storage/durable.js';
35
36
  import crypto from 'node:crypto';
36
37
  import { timedRequest } from '../http.js';
37
38
 
@@ -765,7 +766,7 @@ export const createTelegramCreds = ({
765
766
  typeof v === 'bigint' ? v.toString() : v
766
767
  );
767
768
  await fs.writeFile(temp, text, { mode: 0o600 });
768
- await fs.rename(temp, cacheFile);
769
+ await renameDurable(temp, cacheFile); // (R-Z19)
769
770
  };
770
771
 
771
772
  const verifyAll = (records) =>
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { promises as fs } from 'node:fs';
12
12
  import path from 'node:path';
13
+ import { renameDurable } from '../storage/durable.js';
13
14
  import { rawMessage, floodSeconds } from './telegram-history.js';
14
15
  import { bigSize, locationOf, fileLocation } from './telegram-media.js';
15
16
  import { failureOf } from './telegram-stickers.js';
@@ -149,7 +150,7 @@ const download = async (r, info, target) => {
149
150
  outputFile: part,
150
151
  })
151
152
  );
152
- await fs.rename(part, target);
153
+ await renameDurable(part, target); // (R-Z19)
153
154
  } catch (error) {
154
155
  await fs.rm(part, { force: true });
155
156
  throw error;
@@ -45,6 +45,46 @@ export const fsyncDir = async (dir) => {
45
45
  }
46
46
  };
47
47
 
48
+ const WINDOWS_BUSY = new Set(['EPERM', 'EBUSY', 'EACCES']);
49
+ const defaultSleep = (ms) =>
50
+ new Promise((resolve) => {
51
+ globalThis.setTimeout(resolve, ms);
52
+ });
53
+
54
+ /**
55
+ * Rename `from` over `to`, retrying while another handle holds the target
56
+ * (R-Z19). On Windows a rename fails with EPERM/EBUSY/EACCES while any
57
+ * process (antivirus, the search indexer, our own snapshot worker) has the
58
+ * target open, usually for a few milliseconds: retried up to `attempts`
59
+ * times with a growing pause (about a second in all, like graceful-fs).
60
+ * Elsewhere rename is atomic and those codes are real errors, so only
61
+ * EBUSY is retried. After the last attempt the error is thrown and the
62
+ * target is untouched. `rename`, `platform` and `sleep` are injectable.
63
+ */
64
+ export const renameDurable = async (
65
+ from,
66
+ to,
67
+ {
68
+ rename = fs.rename,
69
+ platform = process.platform,
70
+ sleep = defaultSleep,
71
+ attempts = 10,
72
+ delayMs = 20,
73
+ } = {}
74
+ ) => {
75
+ const codes = platform === 'win32' ? WINDOWS_BUSY : new Set(['EBUSY']);
76
+ for (let attempt = 1; ; attempt += 1) {
77
+ try {
78
+ return await rename(from, to);
79
+ } catch (error) {
80
+ if (attempt >= attempts || !codes.has(error?.code)) {
81
+ throw error;
82
+ }
83
+ await sleep(delayMs * attempt);
84
+ }
85
+ }
86
+ };
87
+
48
88
  /** Create `dir` (and parents) private to the user. */
49
89
  export const ensurePrivateDir = (dir) =>
50
90
  fs.mkdir(dir, { recursive: true, mode: DIR_MODE });
@@ -74,7 +114,7 @@ export const tighten = async (target, mode = FILE_MODE) => {
74
114
  export const writeFileDurable = async (
75
115
  file,
76
116
  data,
77
- { utime, tag = '' } = {}
117
+ { utime, tag = '', rename } = {}
78
118
  ) => {
79
119
  const dir = path.dirname(file);
80
120
  await ensurePrivateDir(dir);
@@ -90,7 +130,7 @@ export const writeFileDurable = async (
90
130
  } finally {
91
131
  await handle.close();
92
132
  }
93
- await fs.rename(tmp, file);
133
+ await renameDurable(tmp, file, { rename });
94
134
  } catch (error) {
95
135
  // A full disk: the half-written temp file goes at once, not at the
96
136
  // next start (R-Z14).
@@ -116,7 +156,7 @@ export const writeStreamDurable = async (file, ...streams) => {
116
156
  createWriteStream(tmp, { flags: 'w', mode: FILE_MODE })
117
157
  );
118
158
  await fsyncFile(tmp);
119
- await fs.rename(tmp, file);
159
+ await renameDurable(tmp, file);
120
160
  } catch (error) {
121
161
  await fs.unlink(tmp).catch(() => {});
122
162
  throw error;
@@ -28,7 +28,13 @@ import { createInterface } from 'node:readline';
28
28
  import { decodeLine, encodeLine } from 'lino-objects-codec';
29
29
  import { createMemoryStore } from './universal.js';
30
30
  import { parseLino } from './lino.js';
31
- import { FILE_MODE, fsyncDir, fsyncFile, tighten } from './durable.js';
31
+ import {
32
+ FILE_MODE,
33
+ fsyncDir,
34
+ fsyncFile,
35
+ renameDurable,
36
+ tighten,
37
+ } from './durable.js';
32
38
  import { enoughSpace, skippedEvent } from './disk-space.js';
33
39
  import { createWriteQueue, storeError, writeFailed } from './write-queue.js';
34
40
 
@@ -380,7 +386,7 @@ export const readLinoFile = async (filePath) => {
380
386
  // Write every link to a temp file in chunks, flush it to disk, swap it
381
387
  // in, and flush the directory: after a power cut the file is the old log
382
388
  // or the whole new one, never an empty or half-written one (R-Z2).
383
- const rewriteFile = async (filePath, links) => {
389
+ const rewriteFile = async (filePath, links, rename) => {
384
390
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
385
391
  const tmp = `${filePath}.${process.pid}.tmp`;
386
392
  try {
@@ -393,7 +399,7 @@ const rewriteFile = async (filePath, links) => {
393
399
  } finally {
394
400
  await handle.close();
395
401
  }
396
- await fs.rename(tmp, filePath);
402
+ await renameDurable(tmp, filePath, { rename });
397
403
  } catch (error) {
398
404
  // The half-written copy goes at once: on a full disk it is what
399
405
  // keeps the disk full (R-Z14).
@@ -461,6 +467,9 @@ const SYNC_MS = 200;
461
467
 
462
468
  // A compaction skipped for lack of space is asked again after this long (R-Z14).
463
469
  const COMPACT_RETRY_MS = 10 * 60_000;
470
+ // A compaction that failed (a busy file on Windows) leaves the old log intact
471
+ // and is asked again after this, not on every batch (R-Z19).
472
+ const COMPACT_FAILED_RETRY_MS = 30_000;
464
473
 
465
474
  // Flushes appends to disk together, `syncMs` after the first (R-Z2).
466
475
  const createSyncer = (filePath, syncMs, enqueue) => {
@@ -506,7 +515,15 @@ const compactionFailed = (onEvent) => (error) =>
506
515
  // The log rewritten with one line per live link, after the unreadable
507
516
  // lines were set aside (R-Z2); skipped when the disk has no room for the
508
517
  // copy (R-Z14). `{ skipped }`.
509
- const compactLog = async ({ filePath, bad, mem, onCorrupt, onEvent, free }) => {
518
+ const compactLog = async ({
519
+ filePath,
520
+ bad,
521
+ mem,
522
+ onCorrupt,
523
+ onEvent,
524
+ free,
525
+ rename,
526
+ }) => {
510
527
  if (bad.overflow) {
511
528
  return { skipped: false }; // more unreadable lines than can be kept
512
529
  }
@@ -522,7 +539,7 @@ const compactLog = async ({ filePath, bad, mem, onCorrupt, onEvent, free }) => {
522
539
  bad.lines = [];
523
540
  bad.count = 0;
524
541
  }
525
- await rewriteFile(filePath, await mem.query());
542
+ await rewriteFile(filePath, await mem.query(), rename);
526
543
  return { skipped: false };
527
544
  };
528
545
 
@@ -588,6 +605,14 @@ const createHold = () => {
588
605
  };
589
606
  };
590
607
 
608
+ // A compaction skipped for lack of space, as an error for the write queue.
609
+ const throwingWhenSkipped = (compact) => async () => {
610
+ const done = await compact();
611
+ if (done?.skipped) {
612
+ throw storeError('ENOSPC', 'not enough free space to rewrite the log');
613
+ }
614
+ };
615
+
591
616
  const readOnlyError = () =>
592
617
  Object.assign(new Error('the store was opened read-only'), {
593
618
  code: 'STORE_READ_ONLY',
@@ -607,6 +632,7 @@ export const createLinoTextStore = async (
607
632
  // (tests inject an append handle that fails).
608
633
  queue: queueOptions = {},
609
634
  measureFree,
635
+ rename, // injectable fs.rename (R-Z19)
610
636
  } = {}
611
637
  ) => {
612
638
  const mem = createMemoryStore();
@@ -642,6 +668,7 @@ export const createLinoTextStore = async (
642
668
  onCorrupt,
643
669
  onEvent,
644
670
  free: measureFree,
671
+ rename,
645
672
  });
646
673
  if (done.skipped) {
647
674
  compactAfterAt = Date.now() + COMPACT_RETRY_MS;
@@ -676,16 +703,9 @@ export const createLinoTextStore = async (
676
703
  attempt: () => (rewrite ? compactOrThrow() : wq.write()),
677
704
  onRecovered: syncer.soon,
678
705
  });
679
- const compactOrThrow = async () => {
680
- const done = await compact();
681
- if (done?.skipped) {
682
- throw storeError('ENOSPC', 'not enough free space to rewrite the log');
683
- }
684
- };
706
+ const compactOrThrow = throwingWhenSkipped(compact);
685
707
  const hold = createHold();
686
708
  const maintain = async () => {
687
- // The batch is on disk by now; a failed compaction costs nothing but
688
- // the rewrite, so it never fails the writes (R-Z14).
689
709
  try {
690
710
  if (
691
711
  !hold.active() &&
@@ -695,6 +715,7 @@ export const createLinoTextStore = async (
695
715
  await compact();
696
716
  }
697
717
  } catch (error) {
718
+ compactAfterAt = Date.now() + COMPACT_FAILED_RETRY_MS;
698
719
  compactionFailed(onEvent)(error);
699
720
  }
700
721
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meta-sovereign",
3
- "version": "0.49.8",
3
+ "version": "0.49.9",
4
4
  "description": "Personal meta profile sovereign system - fully local, privacy-respecting personal CRM and unified messenger across services",
5
5
  "type": "module",
6
6
  "main": "js/src/index.js",