swarm-deploy 0.3.1 → 0.4.2

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,667 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ARCHIVE_LEGACY_MARKER_NAME = exports.ARCHIVE_MARKER_NAME = exports.ARCHIVE_DIRECTORY_NAME = exports.METADATA_DIRECTORIES = void 0;
7
+ exports.inspectStorageReset = inspectStorageReset;
8
+ exports.resetStorage = resetStorage;
9
+ const b4a_1 = __importDefault(require("b4a"));
10
+ const error_code_js_1 = require("../error-code.js");
11
+ const _fs_1 = __importDefault(require("#fs"));
12
+ const _path_1 = __importDefault(require("#path"));
13
+ const _process_1 = __importDefault(require("#process"));
14
+ const sodium_native_1 = __importDefault(require("sodium-native"));
15
+ const errors_js_1 = require("../errors.js");
16
+ const hash_js_1 = require("../tar-protocol/hash.js");
17
+ const atomic_file_js_1 = require("./atomic-file.js");
18
+ const commit_journal_js_1 = require("./commit-journal.js");
19
+ const layout_js_1 = require("./layout.js");
20
+ /** Every private metadata directory a reset archives, in archive order. */
21
+ exports.METADATA_DIRECTORIES = [
22
+ 'staging',
23
+ 'sessions',
24
+ 'commits',
25
+ 'journals',
26
+ 'publications',
27
+ 'links',
28
+ 'trash'
29
+ ];
30
+ exports.ARCHIVE_DIRECTORY_NAME = 'archive';
31
+ exports.ARCHIVE_MARKER_NAME = 'reset.json';
32
+ /** Verbatim copy of a version 1 marker, kept when a retry upgrades it to the bounded format. */
33
+ exports.ARCHIVE_LEGACY_MARKER_NAME = 'reset.v1.json';
34
+ const ARCHIVE_LOCKS_NAME = 'locks';
35
+ /** The pending pointer is fixed-size: a kind, a version, and one archive name. */
36
+ const MAX_PENDING_BYTES = 16 * 1024;
37
+ /**
38
+ * Read bound for archive markers. Version 1 markers listed every lock residue
39
+ * entry and so grew without limit; the shared metadata bound keeps those
40
+ * readable while still capping allocation.
41
+ */
42
+ const MAX_ARCHIVE_MARKER_BYTES = atomic_file_js_1.MAX_SESSION_METADATA_BYTES;
43
+ /**
44
+ * Write bound for archive markers. The capped fields below keep the worst case
45
+ * (a 4 KiB root and sixteen fully escaped failures) far inside this, so
46
+ * reaching it is a programming error, never an operating condition.
47
+ */
48
+ const MAX_MARKER_WRITE_BYTES = 64 * 1024;
49
+ const MARKER_VERSION = 2;
50
+ /** Newest attempts kept in a marker; the first is the top-level `startedAt`/`pid`. */
51
+ const MAX_ATTEMPTS_KEPT = 16;
52
+ const MAX_FAILURE_CHARS = 256;
53
+ /** Lock residue moves between marker writes; the archive itself is the exact record. */
54
+ const LOCK_PROGRESS_BATCH = 32;
55
+ const MAX_LINK_RECORD_BYTES = 4 * 1024;
56
+ const DEFAULT_POLL_INTERVAL = 250;
57
+ const METADATA_RECORD = /^[0-9a-f]{64}\.json$/;
58
+ const ARCHIVE_NAME = /^reset-[0-9TZ]+-[0-9a-f]+$/;
59
+ /** Lock residue no running server can own: retired tombstones and abandoned candidates. */
60
+ const OBSOLETE_LOCK = /^lock\.(?:retired|candidate)-[0-9a-f]+$/;
61
+ function storageError(message, cause = null) {
62
+ return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.PROTOCOL_INVALID, message, cause);
63
+ }
64
+ function busyError(message) {
65
+ return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.FILE_BUSY, message);
66
+ }
67
+ function defaultSleep(ms) {
68
+ return new Promise((resolve) => setTimeout(resolve, ms));
69
+ }
70
+ function randomSuffix(bytes = 4) {
71
+ const buffer = b4a_1.default.allocUnsafe(bytes);
72
+ sodium_native_1.default.randombytes_buf(buffer);
73
+ return b4a_1.default.toString(buffer, 'hex');
74
+ }
75
+ function settle({ storageDir, wait = 0, storage = _fs_1.default.promises, isProcessAlive = layout_js_1.isProcessAlive, clock = Date, pid = _process_1.default.pid, pollInterval = DEFAULT_POLL_INTERVAL, sleep = defaultSleep }) {
76
+ const layout = (0, layout_js_1.resolveLayout)(storageDir);
77
+ if (!Number.isSafeInteger(wait) || wait < 0)
78
+ throw storageError('Invalid reset wait');
79
+ if (!Number.isSafeInteger(pid) || pid <= 0)
80
+ throw storageError('Invalid reset PID');
81
+ if (!Number.isSafeInteger(pollInterval) || pollInterval <= 0) {
82
+ throw storageError('Invalid reset poll interval');
83
+ }
84
+ return { layout, storage, isProcessAlive, clock, pid, wait, pollInterval, sleep };
85
+ }
86
+ /** The root must be a real directory that is neither a filesystem root nor the internal directory. */
87
+ function assertSafeRootPath(root) {
88
+ if (_path_1.default.dirname(root) === root) {
89
+ throw storageError(`Refusing to reset a filesystem root: ${root}`);
90
+ }
91
+ if (_path_1.default.basename(root) === layout_js_1.INTERNAL_DIRECTORY_NAME) {
92
+ throw storageError(`Refusing to reset an internal directory as a storage root: ${root}`);
93
+ }
94
+ }
95
+ async function safeDirectoryOrMissing(directory, storage) {
96
+ try {
97
+ await (0, layout_js_1.assertSafeDirectory)(directory, storage);
98
+ return true;
99
+ }
100
+ catch (error) {
101
+ if ((0, error_code_js_1.isMissing)(error))
102
+ return false;
103
+ throw error;
104
+ }
105
+ }
106
+ async function lstatOrNull(target, storage) {
107
+ try {
108
+ return await storage.lstat(target);
109
+ }
110
+ catch (error) {
111
+ if ((0, error_code_js_1.errorCode)(error) === 'ENOENT')
112
+ return null;
113
+ throw error;
114
+ }
115
+ }
116
+ async function syncDirectory(directory, storage) {
117
+ const handle = await storage.open(directory, 'r');
118
+ try {
119
+ await handle.sync();
120
+ }
121
+ finally {
122
+ await handle.close();
123
+ }
124
+ }
125
+ async function validateLayout(layout, storage) {
126
+ assertSafeRootPath(layout.root);
127
+ await (0, layout_js_1.assertSafeDirectory)(layout.root, storage);
128
+ for (let parent = _path_1.default.dirname(layout.root); _path_1.default.dirname(parent) !== parent; parent = _path_1.default.dirname(parent)) {
129
+ await (0, layout_js_1.assertSafeDirectory)(parent, storage);
130
+ }
131
+ try {
132
+ await (0, layout_js_1.assertSafeDirectory)(layout.internal, storage);
133
+ }
134
+ catch (error) {
135
+ if (!(0, error_code_js_1.isMissing)(error))
136
+ throw error;
137
+ throw storageError(`Not an initialized storage root: ${layout.root}`, error);
138
+ }
139
+ // A missing metadata directory is an interrupted reset the server recreates;
140
+ // a symlink or file at one of these paths is unsafe and fails closed.
141
+ for (const name of exports.METADATA_DIRECTORIES)
142
+ await safeDirectoryOrMissing(layout[name], storage);
143
+ }
144
+ async function countEntries(directory, storage) {
145
+ if (!(await safeDirectoryOrMissing(directory, storage)))
146
+ return 0;
147
+ return (await storage.readdir(directory)).length;
148
+ }
149
+ async function inspectCommits(layout, storage) {
150
+ const result = { records: 0, missingPayloads: [], invalid: 0 };
151
+ if (!(await safeDirectoryOrMissing(layout.commits, storage)))
152
+ return result;
153
+ for (const name of (await storage.readdir(layout.commits)).sort()) {
154
+ if (!METADATA_RECORD.test(name))
155
+ continue;
156
+ let recordName;
157
+ try {
158
+ const record = (0, commit_journal_js_1.assertCommitRecord)(await (0, atomic_file_js_1.readJson)(_path_1.default.join(layout.commits, name), storage, commit_journal_js_1.MAX_COMMIT_METADATA_BYTES));
159
+ if (`${record.transferId}.json` !== name)
160
+ throw storageError('Foreign commit record');
161
+ recordName = record.name;
162
+ }
163
+ catch {
164
+ result.invalid++;
165
+ continue;
166
+ }
167
+ result.records++;
168
+ if ((await lstatOrNull(_path_1.default.join(layout.root, recordName), storage)) === null) {
169
+ result.missingPayloads.push(recordName);
170
+ }
171
+ }
172
+ return result;
173
+ }
174
+ async function inspectLinks(layout, storage) {
175
+ const result = { records: 0, invalid: 0 };
176
+ if (!(await safeDirectoryOrMissing(layout.links, storage)))
177
+ return result;
178
+ for (const name of (await storage.readdir(layout.links)).sort()) {
179
+ if (!METADATA_RECORD.test(name))
180
+ continue;
181
+ try {
182
+ await (0, atomic_file_js_1.readJson)(_path_1.default.join(layout.links, name), storage, MAX_LINK_RECORD_BYTES);
183
+ result.records++;
184
+ }
185
+ catch {
186
+ result.invalid++;
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+ async function inspectRoot(layout, storage) {
192
+ const remaining = { payloads: [], symlinks: [], other: [] };
193
+ for (const name of (await storage.readdir(layout.root)).sort()) {
194
+ if (name === layout_js_1.INTERNAL_DIRECTORY_NAME)
195
+ continue;
196
+ const stat = await lstatOrNull(_path_1.default.join(layout.root, name), storage);
197
+ if (stat === null)
198
+ continue;
199
+ if (stat.isSymbolicLink())
200
+ remaining.symlinks.push(name);
201
+ else if (stat.isFile() || stat.isDirectory())
202
+ remaining.payloads.push(name);
203
+ else
204
+ remaining.other.push(name);
205
+ }
206
+ return remaining;
207
+ }
208
+ async function archiveStatus(archive, storage) {
209
+ try {
210
+ const marker = await (0, atomic_file_js_1.readJson)(_path_1.default.join(archive, exports.ARCHIVE_MARKER_NAME), storage, MAX_ARCHIVE_MARKER_BYTES);
211
+ if (marker.status === 'completed')
212
+ return 'completed';
213
+ if (marker.status === 'in-progress')
214
+ return 'in-progress';
215
+ }
216
+ catch { }
217
+ return 'unknown';
218
+ }
219
+ const NO_HOLDINGS = { directories: [], locks: [] };
220
+ function summarize(held) {
221
+ return { directories: held.directories, lockEntries: held.locks.length };
222
+ }
223
+ /**
224
+ * What an archive physically holds: the metadata directories and lock residue
225
+ * moved into it. This is the truth about a reset's progress; a marker is only
226
+ * a record of it that a hard stop can leave behind.
227
+ */
228
+ async function archiveContents(archive, storage) {
229
+ const names = new Set(await storage.readdir(archive));
230
+ const held = {
231
+ directories: exports.METADATA_DIRECTORIES.filter((name) => names.has(name)),
232
+ locks: []
233
+ };
234
+ if (names.has(ARCHIVE_LOCKS_NAME)) {
235
+ const locks = _path_1.default.join(archive, ARCHIVE_LOCKS_NAME);
236
+ if (await safeDirectoryOrMissing(locks, storage)) {
237
+ held.locks = (await storage.readdir(locks)).sort();
238
+ }
239
+ }
240
+ return held;
241
+ }
242
+ /**
243
+ * SHA-256 over each sorted lock residue name followed by a newline, so an
244
+ * operator can verify it with `ls locks | LC_ALL=C sort | sha256sum`.
245
+ */
246
+ function lockDigest(names) {
247
+ const hash = new hash_js_1.SodiumSha256();
248
+ for (const name of names)
249
+ hash.update(b4a_1.default.from(`${name}\n`));
250
+ return b4a_1.default.toString(hash.digest(), 'hex');
251
+ }
252
+ async function listArchives(layout, storage) {
253
+ const directory = _path_1.default.join(layout.internal, exports.ARCHIVE_DIRECTORY_NAME);
254
+ if (!(await safeDirectoryOrMissing(directory, storage)))
255
+ return [];
256
+ const archives = [];
257
+ for (const name of (await storage.readdir(directory)).sort()) {
258
+ const full = _path_1.default.join(directory, name);
259
+ const stat = await lstatOrNull(full, storage);
260
+ if (stat === null || stat.isSymbolicLink() || !stat.isDirectory())
261
+ continue;
262
+ archives.push({
263
+ name,
264
+ path: full,
265
+ status: await archiveStatus(full, storage),
266
+ held: summarize(await archiveContents(full, storage).catch(() => NO_HOLDINGS))
267
+ });
268
+ }
269
+ return archives;
270
+ }
271
+ /**
272
+ * Reads the pending-reset marker. `archive` is null when the marker exists but
273
+ * is not one this version wrote, which every caller must treat as unsafe.
274
+ */
275
+ async function readPendingReset(layout, storage) {
276
+ const file = _path_1.default.join(layout.internal, layout_js_1.RESET_PENDING_NAME);
277
+ if ((await lstatOrNull(file, storage)) === null)
278
+ return { present: false, archive: null };
279
+ try {
280
+ const marker = await (0, atomic_file_js_1.readJson)(file, storage, MAX_PENDING_BYTES);
281
+ if (marker.kind === 'storage-reset' &&
282
+ marker.version === 1 &&
283
+ typeof marker.archive === 'string' &&
284
+ ARCHIVE_NAME.test(marker.archive)) {
285
+ return { present: true, archive: marker.archive };
286
+ }
287
+ }
288
+ catch { }
289
+ return { present: true, archive: null };
290
+ }
291
+ async function lockState(settings) {
292
+ const { layout, storage, isProcessAlive, clock } = settings;
293
+ const state = await (0, layout_js_1.inspectStorageLock)(layout, storage);
294
+ if (state.status !== 'held')
295
+ return state;
296
+ const ageMs = Math.max(0, clock.now() - state.startedAt);
297
+ const alive = isProcessAlive(state.pid);
298
+ return { status: alive ? 'held' : 'stale', pid: state.pid, ageMs };
299
+ }
300
+ async function inspect(settings) {
301
+ const { layout, storage } = settings;
302
+ await validateLayout(layout, storage);
303
+ const metadata = {};
304
+ for (const name of exports.METADATA_DIRECTORIES) {
305
+ metadata[name] = await countEntries(layout[name], storage);
306
+ }
307
+ let obsoleteLocks = 0;
308
+ const unknownInternal = [];
309
+ for (const name of (await storage.readdir(layout.internal)).sort()) {
310
+ if (exports.METADATA_DIRECTORIES.includes(name))
311
+ continue;
312
+ if (name === _path_1.default.basename(layout.lock) ||
313
+ name === exports.ARCHIVE_DIRECTORY_NAME ||
314
+ name === layout_js_1.RESET_PENDING_NAME) {
315
+ continue;
316
+ }
317
+ if (OBSOLETE_LOCK.test(name))
318
+ obsoleteLocks++;
319
+ else
320
+ unknownInternal.push(name);
321
+ }
322
+ const remaining = await inspectRoot(layout, storage);
323
+ const pending = await readPendingReset(layout, storage);
324
+ return {
325
+ root: layout.root,
326
+ applied: false,
327
+ lock: await lockState(settings),
328
+ metadata,
329
+ commits: await inspectCommits(layout, storage),
330
+ links: await inspectLinks(layout, storage),
331
+ obsoleteLocks,
332
+ unknownInternal,
333
+ archives: await listArchives(layout, storage),
334
+ remaining,
335
+ resetPending: pending.present,
336
+ pendingArchive: pending.archive,
337
+ freshStart: remaining.payloads.length + remaining.symlinks.length + remaining.other.length === 0,
338
+ archive: null,
339
+ resumed: null
340
+ };
341
+ }
342
+ function lockAge(ageMs) {
343
+ return `${Math.round(ageMs / 1000)}s`;
344
+ }
345
+ /**
346
+ * Blocks until no live server holds the lock, then takes it. A live holder is
347
+ * never signalled: this version cannot prove which process owns the root, so
348
+ * stopping it is the operator's job and the reset only waits for the release.
349
+ */
350
+ async function acquireExclusive(settings) {
351
+ const { layout, storage, clock, pid, wait, isProcessAlive, pollInterval, sleep } = settings;
352
+ const deadline = clock.now() + wait;
353
+ for (;;) {
354
+ const state = await lockState(settings);
355
+ if (state.status === 'unsafe')
356
+ throw storageError(`Unsafe storage lock path: ${layout.lock}`);
357
+ if (state.status === 'held') {
358
+ if (clock.now() >= deadline) {
359
+ throw busyError(`Storage is locked by pid ${state.pid} (lock age ${lockAge(state.ageMs)}); ` +
360
+ 'stop that server and retry, or wait for it with --wait');
361
+ }
362
+ await sleep(pollInterval);
363
+ continue;
364
+ }
365
+ if (state.status === 'malformed') {
366
+ throw busyError('Storage lock is unreadable; stop the server and reconcile its lock before resetting');
367
+ }
368
+ for (const name of exports.METADATA_DIRECTORIES) {
369
+ if (!(await safeDirectoryOrMissing(layout[name], storage))) {
370
+ await storage.mkdir(layout[name], { mode: 0o700 });
371
+ }
372
+ }
373
+ try {
374
+ return await (0, layout_js_1.acquireStorageLock)(layout, {
375
+ pid,
376
+ storage,
377
+ isProcessAlive
378
+ });
379
+ }
380
+ catch (error) {
381
+ if ((0, error_code_js_1.errorCode)(error) !== errors_js_1.ERRORS.FILE_BUSY)
382
+ throw error;
383
+ if (clock.now() >= deadline)
384
+ throw busyError('Storage became locked while resetting');
385
+ await sleep(pollInterval);
386
+ }
387
+ }
388
+ }
389
+ async function releaseExclusive(settings, release) {
390
+ const { layout, storage, pid } = settings;
391
+ const own = await (0, layout_js_1.inspectStorageLock)(layout, storage).catch(() => null);
392
+ await release();
393
+ // Our own retired tombstone is not operator metadata; drop it so the next
394
+ // preview does not report lock residue this command created.
395
+ if (own?.status === 'held' && own.pid === pid) {
396
+ await storage.rm(`${layout.lock}.retired-${own.token}`, { recursive: true, force: true });
397
+ }
398
+ }
399
+ function archiveName(now) {
400
+ const stamp = new Date(now).toISOString().replace(/[-:.]/g, '');
401
+ return `reset-${stamp}-${randomSuffix()}`;
402
+ }
403
+ function isSafeCount(value, minimum) {
404
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum;
405
+ }
406
+ function clip(reason) {
407
+ return reason.length > MAX_FAILURE_CHARS ? `${reason.slice(0, MAX_FAILURE_CHARS - 3)}...` : reason;
408
+ }
409
+ function parseAttempt(value) {
410
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
411
+ return null;
412
+ const { startedAt, pid, failed } = value;
413
+ if (!isSafeCount(startedAt, 0) || !isSafeCount(pid, 1))
414
+ return null;
415
+ if (failed === undefined)
416
+ return { startedAt, pid };
417
+ return typeof failed === 'string' ? { startedAt, pid, failed: clip(failed) } : null;
418
+ }
419
+ /**
420
+ * Reads a marker this tool wrote, in the bounded version 2 shape or the
421
+ * version 1 shape (whose `moved` list held every lock residue entry and whose
422
+ * latest failure sat beside `attempts`). Returns null for anything else, which
423
+ * every caller treats as unsafe. Nothing is carried over except these fields.
424
+ */
425
+ function parseMarker(raw) {
426
+ if (raw.version !== 1 && raw.version !== MARKER_VERSION)
427
+ return null;
428
+ if (raw.status !== 'in-progress' && raw.status !== 'completed')
429
+ return null;
430
+ const { root, startedAt, pid } = raw;
431
+ if (typeof root !== 'string' || !isSafeCount(startedAt, 0) || !isSafeCount(pid, 1))
432
+ return null;
433
+ let attempts;
434
+ if (raw.attempts === undefined) {
435
+ attempts = [{ startedAt, pid }];
436
+ }
437
+ else {
438
+ if (!Array.isArray(raw.attempts) || raw.attempts.length === 0)
439
+ return null;
440
+ attempts = [];
441
+ for (const entry of raw.attempts) {
442
+ const attempt = parseAttempt(entry);
443
+ if (attempt === null)
444
+ return null;
445
+ attempts.push(attempt);
446
+ }
447
+ }
448
+ const last = attempts.length - 1;
449
+ if (typeof raw.failed === 'string' && attempts[last].failed === undefined) {
450
+ attempts[last] = { ...attempts[last], failed: clip(raw.failed) };
451
+ }
452
+ const attemptCount = raw.attemptCount === undefined ? attempts.length : raw.attemptCount;
453
+ if (!isSafeCount(attemptCount, attempts.length))
454
+ return null;
455
+ return { root, startedAt, pid, attempts: attempts.slice(-MAX_ATTEMPTS_KEPT), attemptCount };
456
+ }
457
+ function nextAttempt(state, attempt) {
458
+ return {
459
+ ...state,
460
+ attempts: [...state.attempts, attempt].slice(-MAX_ATTEMPTS_KEPT),
461
+ attemptCount: state.attemptCount + 1
462
+ };
463
+ }
464
+ function failLatest(state, reason) {
465
+ const last = state.attempts.length - 1;
466
+ return {
467
+ ...state,
468
+ attempts: state.attempts.map((attempt, index) => index === last ? { ...attempt, failed: clip(reason) } : attempt)
469
+ };
470
+ }
471
+ /**
472
+ * Writes the bounded marker. `moved` names only the metadata directories, at
473
+ * most seven; lock residue is summarized by a count and a digest of its names,
474
+ * which stay physically in the archive's `locks/` directory.
475
+ */
476
+ async function writeMarker(archive, state, status, held, storage, completedAt) {
477
+ const bytes = b4a_1.default.from(JSON.stringify({
478
+ version: MARKER_VERSION,
479
+ status,
480
+ root: state.root,
481
+ startedAt: state.startedAt,
482
+ pid: state.pid,
483
+ attemptCount: state.attemptCount,
484
+ attempts: state.attempts,
485
+ moved: held.directories,
486
+ locks: { count: held.locks.length, sha256: lockDigest(held.locks) },
487
+ ...(completedAt === undefined ? {} : { completedAt })
488
+ }));
489
+ if (bytes.byteLength > MAX_MARKER_WRITE_BYTES) {
490
+ throw storageError('Reset marker exceeds its size bound');
491
+ }
492
+ await (0, atomic_file_js_1.writeAtomic)(_path_1.default.join(archive, exports.ARCHIVE_MARKER_NAME), bytes, storage);
493
+ }
494
+ /**
495
+ * Opens the archive a pending reset names so the retry continues it. The archive
496
+ * must be a plain directory with a marker this tool wrote; anything else means
497
+ * an operator changed it and the retry fails closed rather than starting a
498
+ * second archive that would hide where the first one's data went.
499
+ */
500
+ async function openPendingArchive(archive, storage) {
501
+ const name = _path_1.default.basename(archive);
502
+ let raw;
503
+ try {
504
+ await (0, layout_js_1.assertSafeDirectory)(archive, storage);
505
+ raw = await (0, atomic_file_js_1.readJson)(_path_1.default.join(archive, exports.ARCHIVE_MARKER_NAME), storage, MAX_ARCHIVE_MARKER_BYTES);
506
+ }
507
+ catch (error) {
508
+ throw storageError(`Pending reset archive ${name} is missing or unreadable; restore it or reconcile ` +
509
+ `${layout_js_1.RESET_PENDING_NAME} by hand before resetting`, error);
510
+ }
511
+ const state = parseMarker(raw);
512
+ if (state === null) {
513
+ throw storageError(`Pending reset archive ${name} has an unrecognized marker; reconcile it before resetting`);
514
+ }
515
+ return { raw, state };
516
+ }
517
+ /** Keeps a version 1 marker's full entry list before the bounded format replaces it. */
518
+ async function preserveLegacyMarker(archive, raw, storage) {
519
+ if (raw.version !== 1)
520
+ return;
521
+ const file = _path_1.default.join(archive, exports.ARCHIVE_LEGACY_MARKER_NAME);
522
+ if ((await lstatOrNull(file, storage)) !== null)
523
+ return;
524
+ await (0, atomic_file_js_1.writeAtomic)(file, b4a_1.default.from(JSON.stringify(raw)), storage);
525
+ }
526
+ /**
527
+ * Archives the metadata, or resumes the archive an interrupted reset left
528
+ * pending. Every move is a rename, so after a hard stop each directory is
529
+ * either still live or already inside the archive; progress is therefore
530
+ * reconciled from what the archive holds, never trusted from the marker.
531
+ */
532
+ async function archiveMetadata(settings) {
533
+ const { layout, storage, clock, pid } = settings;
534
+ const archives = _path_1.default.join(layout.internal, exports.ARCHIVE_DIRECTORY_NAME);
535
+ if (!(await safeDirectoryOrMissing(archives, storage))) {
536
+ await storage.mkdir(archives, { mode: 0o700 });
537
+ }
538
+ const pendingFile = _path_1.default.join(layout.internal, layout_js_1.RESET_PENDING_NAME);
539
+ const pending = await readPendingReset(layout, storage);
540
+ if (pending.present && pending.archive === null) {
541
+ throw storageError('Unrecognized pending reset marker; reconcile it before resetting');
542
+ }
543
+ const startedAt = clock.now();
544
+ const attempt = { startedAt, pid };
545
+ let archive;
546
+ let state;
547
+ let held = NO_HOLDINGS;
548
+ let resumed = null;
549
+ if (pending.archive !== null) {
550
+ archive = _path_1.default.join(archives, pending.archive);
551
+ const prior = await openPendingArchive(archive, storage);
552
+ held = await archiveContents(archive, storage);
553
+ resumed = summarize(held);
554
+ // Refuse before moving anything if the archive and the live directory both hold data.
555
+ for (const name of exports.METADATA_DIRECTORIES) {
556
+ if (!held.directories.includes(name))
557
+ continue;
558
+ await (0, layout_js_1.assertSafeDirectory)(_path_1.default.join(archive, name), storage);
559
+ if ((await safeDirectoryOrMissing(layout[name], storage)) &&
560
+ (await storage.readdir(layout[name])).length > 0) {
561
+ throw storageError(`Reset archive ${pending.archive} already holds ${name} but the live ${name} ` +
562
+ 'directory is not empty; reconcile the two by hand before resetting');
563
+ }
564
+ }
565
+ await preserveLegacyMarker(archive, prior.raw, storage);
566
+ state = nextAttempt(prior.state, attempt);
567
+ await writeMarker(archive, state, 'in-progress', held, storage);
568
+ }
569
+ else {
570
+ archive = _path_1.default.join(archives, archiveName(startedAt));
571
+ await storage.mkdir(archive, { mode: 0o700 });
572
+ state = { root: layout.root, startedAt, pid, attempts: [attempt], attemptCount: 1 };
573
+ await writeMarker(archive, state, 'in-progress', held, storage);
574
+ await syncDirectory(archives, storage);
575
+ await (0, atomic_file_js_1.writeAtomic)(pendingFile, b4a_1.default.from(JSON.stringify({ kind: 'storage-reset', version: 1, archive: _path_1.default.basename(archive) })), storage);
576
+ await syncDirectory(layout.internal, storage);
577
+ }
578
+ const recordProgress = async () => {
579
+ held = await archiveContents(archive, storage);
580
+ await writeMarker(archive, state, 'in-progress', held, storage);
581
+ };
582
+ try {
583
+ for (const name of exports.METADATA_DIRECTORIES) {
584
+ const source = layout[name];
585
+ let live = await safeDirectoryOrMissing(source, storage);
586
+ if (live && !held.directories.includes(name)) {
587
+ await storage.rename(source, _path_1.default.join(archive, name));
588
+ live = false;
589
+ await recordProgress();
590
+ }
591
+ if (!live)
592
+ await storage.mkdir(source, { mode: 0o700 });
593
+ await syncDirectory(layout.internal, storage);
594
+ }
595
+ const locks = _path_1.default.join(archive, ARCHIVE_LOCKS_NAME);
596
+ let sinceRecord = 0;
597
+ for (const name of (await storage.readdir(layout.internal)).sort()) {
598
+ if (!OBSOLETE_LOCK.test(name))
599
+ continue;
600
+ if (!(await safeDirectoryOrMissing(locks, storage))) {
601
+ await storage.mkdir(locks, { mode: 0o700 });
602
+ }
603
+ await storage.rename(_path_1.default.join(layout.internal, name), _path_1.default.join(locks, name));
604
+ // Residue is recorded in batches: the archive holds the exact list, so a
605
+ // marker that lags by one batch after a hard stop is reconciled, not trusted.
606
+ if (++sinceRecord % LOCK_PROGRESS_BATCH === 0)
607
+ await recordProgress();
608
+ }
609
+ await syncDirectory(layout.internal, storage);
610
+ held = await archiveContents(archive, storage);
611
+ await writeMarker(archive, state, 'completed', held, storage, clock.now());
612
+ await syncDirectory(archive, storage);
613
+ await storage.unlink(pendingFile);
614
+ await syncDirectory(layout.internal, storage);
615
+ }
616
+ catch (error) {
617
+ const reason = error instanceof Error ? error.message : String(error);
618
+ const moved = await archiveContents(archive, storage).catch(() => held);
619
+ await writeMarker(archive, failLatest(state, reason), 'in-progress', moved, storage).catch(() => { });
620
+ throw error;
621
+ }
622
+ return { archive, resumed };
623
+ }
624
+ /** Read-only inspection of a storage root: never creates, moves, or locks anything. */
625
+ function inspectStorageReset(options) {
626
+ try {
627
+ return inspect(settle(options));
628
+ }
629
+ catch (error) {
630
+ return Promise.reject(error);
631
+ }
632
+ }
633
+ /**
634
+ * Archives every private metadata directory of a storage root into
635
+ * `.swarm-deploy/archive/<reset-stamp>/` and recreates them empty, under the
636
+ * storage lock. Visible payloads and symlinks in the root are never touched,
637
+ * and nothing is deleted: the archive is restorable by moving it back.
638
+ */
639
+ async function resetStorage(options) {
640
+ const settings = settle(options);
641
+ const { layout, storage } = settings;
642
+ const preview = await inspect(settings);
643
+ if (preview.lock.status === 'unsafe') {
644
+ throw storageError(`Unsafe storage lock path: ${layout.lock}`);
645
+ }
646
+ const release = await acquireExclusive(settings);
647
+ let result;
648
+ let before;
649
+ try {
650
+ before = await inspect(settings);
651
+ result = await archiveMetadata(settings);
652
+ }
653
+ finally {
654
+ await releaseExclusive(settings, release);
655
+ }
656
+ return {
657
+ ...before,
658
+ lock: preview.lock,
659
+ resetPending: false,
660
+ pendingArchive: null,
661
+ applied: true,
662
+ archives: await listArchives(layout, storage),
663
+ archive: result.archive,
664
+ resumed: result.resumed
665
+ };
666
+ }
667
+ //# sourceMappingURL=reset.js.map