@oar-sh/oar 0.9.19 → 0.9.20

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +317 -0
  4. package/README.md +10 -10
  5. package/bin/oar.js +391 -97
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +27 -0
  9. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  10. package/server/claude-worker/claude-session-process.mjs +3 -0
  11. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  12. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  13. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  14. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  15. package/server/copilot-worker/copilot-sdk-session-process.mjs +57 -4
  16. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  17. package/server/db-schema.mjs +18 -0
  18. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  19. package/server/grok-worker/grok-turn-runner.mjs +44 -6
  20. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  21. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  22. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  23. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  24. package/server/migrations/0005-remote-relays.mjs +5 -7
  25. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  26. package/server/migrations/0007-claude-cloud.mjs +5 -7
  27. package/server/migrations/0008-message-pins.mjs +5 -7
  28. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  29. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  30. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  31. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  32. package/server/migrations/0013-previews.mjs +5 -7
  33. package/server/migrations/0014-conversation-groups.mjs +5 -7
  34. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  35. package/server/migrations/0016-share-access-log.mjs +5 -7
  36. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  37. package/server/migrations/0018-background-task-rows.mjs +5 -7
  38. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  39. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  40. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  41. package/server/migrations/0022-relay-schedules.mjs +5 -7
  42. package/server/migrations/0023-relay-asks.mjs +5 -7
  43. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  44. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  45. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  46. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  47. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  48. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  49. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  50. package/server/migrations/0031-queue-provider.mjs +5 -7
  51. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  52. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  53. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  54. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  55. package/server/migrations/0036-token-usage.mjs +267 -0
  56. package/server/public/app/about-view.mjs +87 -3
  57. package/server/public/app/api-client.js +2 -1
  58. package/server/public/app/bootstrap.js +55 -2
  59. package/server/public/app/composer-control-state.mjs +12 -2
  60. package/server/public/app/context-usage-view.mjs +16 -0
  61. package/server/public/app/conversation-view.js +17 -2
  62. package/server/public/app/inbox-ui.js +32 -0
  63. package/server/public/app/inbox-view.mjs +141 -5
  64. package/server/public/app/plan-board-shared.mjs +49 -0
  65. package/server/public/app/plan-usage-view.mjs +1 -1
  66. package/server/public/app/relay-board-inline.mjs +4 -1
  67. package/server/public/app/relay-board-view.js +38 -10
  68. package/server/public/app/relay-event-view.mjs +4 -0
  69. package/server/public/app/socket-handlers.js +10 -2
  70. package/server/public/index.html +14 -0
  71. package/server/public/sw.js +8 -0
  72. package/server/relay-heap-guard.mjs +14 -1
  73. package/server/relay-self-restart.mjs +83 -2
  74. package/server/relay-tools.md +4 -3
  75. package/server/repositories/message-repository.mjs +31 -1
  76. package/server/repositories/remote-relay-repository.mjs +10 -0
  77. package/server/repositories/session-repository.mjs +6 -1
  78. package/server/routes/messages-routes.mjs +87 -0
  79. package/server/routes/relay-ask-routes.mjs +138 -8
  80. package/server/routes/relay-board-routes.mjs +55 -40
  81. package/server/routes/sessions-routes.mjs +103 -7
  82. package/server/server-runtime.mjs +132 -11
  83. package/server/services/about-service.mjs +59 -0
  84. package/server/services/cache-rebuild-service.mjs +7 -0
  85. package/server/services/conversation-preferences-service.mjs +31 -0
  86. package/server/services/database-file.mjs +413 -0
  87. package/server/services/db-backup-service.mjs +809 -0
  88. package/server/services/deepseek-usage-import.mjs +6 -9
  89. package/server/services/delete-archive-service.mjs +3 -0
  90. package/server/services/earlier-database-service.mjs +69 -126
  91. package/server/services/oar-cli-helpers.mjs +214 -12
  92. package/server/services/oar-cli-menu.mjs +1 -0
  93. package/server/services/oar-state-migration-service.mjs +33 -13
  94. package/server/services/package-data-rescue.mjs +4 -2
  95. package/server/services/plan-usage-claude-api.mjs +14 -15
  96. package/server/services/plan-usage-contract.mjs +6 -2
  97. package/server/services/plan-usage-deepseek.mjs +1 -1
  98. package/server/services/plan-usage-tokens.mjs +248 -0
  99. package/server/services/push-dispatch-service.mjs +134 -9
  100. package/server/services/relay-cli-launcher-service.mjs +3 -0
  101. package/server/services/relay-event-service.mjs +30 -0
  102. package/server/services/relay-question-context.mjs +11 -0
  103. package/server/services/relay-restart-picture.mjs +67 -22
  104. package/server/services/remote-inbox-watch-service.mjs +204 -0
  105. package/server/services/remote-relay-caller-context.mjs +80 -7
  106. package/server/services/remote-relay-dispatcher.mjs +40 -5
  107. package/server/services/restart-resume-service.mjs +5 -2
  108. package/server/services/session-worker-launch-service.mjs +12 -3
  109. package/server/services/session-worker-stop-service.mjs +27 -6
  110. package/server/services/token-usage-service.mjs +430 -0
  111. package/server/services/update-install-service.mjs +13 -0
  112. package/server/services/usage-limit-pause-service.mjs +8 -1
  113. package/server/services/worker-scope.mjs +77 -0
  114. package/shared/plan-board-actions.mjs +130 -0
  115. package/shared/plan-only.mjs +43 -0
  116. package/shared/relay-asks.mjs +3 -0
  117. package/shared/relay-events.mjs +38 -4
  118. package/shared/remote-relay-contract.mjs +66 -11
  119. package/shared/turn-token-usage.mjs +144 -0
@@ -0,0 +1,809 @@
1
+ /**
2
+ * The relay's own database backups. At start, when the version
3
+ * (on a checkout: the commit) differs from the one that last ran on the
4
+ * database, a copy is written before the new version's migrations
5
+ * (maybeBackupBeforeStart); `oar backup` writes one on demand. The same
6
+ * folder, name, manifest and rotation for both.
7
+ *
8
+ * A backup is one file, `oar-<version>-<YYYYMMDD-HHMMSS UTC>-<reason>.db`
9
+ * (the version of the data: the one to go back to; a first start that cannot
10
+ * tell names the running one, reason `pre-update-from-unknown`), written as
11
+ * `.partial` in the backup folder itself (created 0600 before the copy),
12
+ * checked (quick_check), turned into a single file (journal_mode=DELETE),
13
+ * synced and linked into place under a free name. The last N are kept; the
14
+ * relay deletes only files it wrote itself: recorded in the manifest of its
15
+ * own data folder (`db-backups.json`) and named by the pattern, in that
16
+ * folder, with the size it had. Nothing else in the folder is ever counted,
17
+ * rotated or deleted. A skip (disk, a DO_NOT_TOUCH folder, another backup
18
+ * running) or a failure never stops a start and never pushes out a good backup.
19
+ */
20
+ import fs from 'fs';
21
+ import path from 'path';
22
+
23
+ import { diskBytes, freeBytes } from '../relay-heap-guard.mjs';
24
+ import { RETRY_CODES, oneLine, renameWithRetry, sleepSync } from './database-file.mjs';
25
+ import { defaultIsProcessAlive, lockHolderMayRun, processIdentity } from './relay-singleton-guard.mjs';
26
+
27
+ export const BACKUP_MANIFEST_FILE = 'db-backups.json';
28
+ // While a backup is written, with the writer's pid: `oar start` tells a relay
29
+ // busy with it from one that did not come up, and `oar backup` waits its turn.
30
+ export const BACKUP_PROGRESS_FILE = 'db-backup-progress.json';
31
+ // While a start waits for an `oar backup` of the same database, with the
32
+ // relay's pid: `oar start` leaves a relay that does so running.
33
+ export const BACKUP_WAIT_FILE = 'db-backup-wait.json';
34
+ // app_settings: { version, commit, at } of the version that last ran.
35
+ export const DB_LAST_RUN_SETTING_KEY = 'db_last_run';
36
+ export const DEFAULT_BACKUP_KEEP = 2;
37
+ // The in-app update's record (update-install-service.mjs), read before
38
+ // reconcileUpdateAttempt removes it at boot.
39
+ const UPDATE_ATTEMPT_SETTING_KEY = 'update_attempt';
40
+ const UPDATE_ATTEMPT_MAX_AGE_MS = 7 * 24 * 3_600_000;
41
+ const GIB = 1024 ** 3;
42
+ // A version that can go into a file name: a release, maybe a pre-release
43
+ // and build metadata.
44
+ const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
45
+ export const BACKUP_NAME_RE = /^oar-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+?)?(?:\+[0-9A-Za-z.-]+?)?)-(\d{8}-\d{6})(?:-([a-z0-9-]+))?\.db$/;
46
+ // One step for all pages: a step-by-step online backup starts over whenever
47
+ // another connection writes, and on a busy relay may never finish.
48
+ export const ALL_PAGES = 0x7fffffff;
49
+ // A filesystem without hard links (vfat, some network shares): the final
50
+ // name is taken by a checked rename instead.
51
+ const NO_LINK_CODES = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EINVAL']);
52
+
53
+ /** `[a-z0-9-]{1,40}`; nothing left of it: `manual`. */
54
+ export function sanitizeReason(text) {
55
+ const reason = String(text ?? '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-')
56
+ .replace(/^-|-$/g, '').slice(0, 40).replace(/-$/, '');
57
+ return reason || 'manual';
58
+ }
59
+
60
+ /** `YYYYMMDD-HHMMSS` in UTC. */
61
+ export function backupStamp(date) {
62
+ return date.toISOString().replace(/\.\d{3}Z$/, '').replace(/-|:/g, '').replace('T', '-');
63
+ }
64
+
65
+ export function backupName({ version, at, reason }) {
66
+ return `oar-${version}-${backupStamp(at)}-${sanitizeReason(reason)}.db`;
67
+ }
68
+
69
+ /** The `-2`, `-3` … form of a name taken already. */
70
+ function numbered(name, n) {
71
+ return n < 2 ? name : name.replace(/\.db$/, `-${n}.db`);
72
+ }
73
+
74
+ export function formatMegabytes(bytes) {
75
+ return `${(Math.max(0, Number(bytes) || 0) / 1048576).toFixed(1)} MB`;
76
+ }
77
+
78
+ /** `keep`: a whole number of at least 1; anything else (said by configWarnings) is the default. */
79
+ export function normalizeKeep(value) {
80
+ return Number.isInteger(value) && value >= 1 ? value : DEFAULT_BACKUP_KEEP;
81
+ }
82
+
83
+ function backupSettings(config) {
84
+ const settings = config?.dbBackups && typeof config.dbBackups === 'object' ? config.dbBackups : {};
85
+ return { enabled: settings.enabled !== false, keep: normalizeKeep(settings.keep), dir: settings.dir };
86
+ }
87
+
88
+ /** `[config]` lines for `dbBackups` values that are not used as written. */
89
+ export function configWarnings(config) {
90
+ const settings = config?.dbBackups;
91
+ if (settings === undefined || settings === null) return [];
92
+ if (typeof settings !== 'object' || Array.isArray(settings)) return [`[config] dbBackups must be an object (it is ${JSON.stringify(settings)}); using the defaults.`];
93
+ const warnings = [];
94
+ if (settings.keep !== undefined && settings.keep !== null && normalizeKeep(settings.keep) !== settings.keep) {
95
+ warnings.push(`[config] dbBackups.keep must be a whole number of at least 1 (it is ${JSON.stringify(settings.keep)}); using ${DEFAULT_BACKUP_KEEP}.`);
96
+ }
97
+ if (settings.enabled !== undefined && settings.enabled !== null && typeof settings.enabled !== 'boolean') {
98
+ warnings.push(`[config] dbBackups.enabled must be true or false (it is ${JSON.stringify(settings.enabled)}); using true.`);
99
+ }
100
+ return warnings;
101
+ }
102
+
103
+ // The real path of a file or folder that may not exist yet: its nearest
104
+ // existing parent resolved, the rest appended. Compared case-insensitively
105
+ // on Windows.
106
+ function realish(target, { fsImpl = fs, platform = process.platform } = {}) {
107
+ let existing = path.resolve(String(target || ''));
108
+ const rest = [];
109
+ for (;;) {
110
+ try {
111
+ existing = fsImpl.realpathSync.native ? fsImpl.realpathSync.native(existing) : fsImpl.realpathSync(existing);
112
+ break;
113
+ } catch {
114
+ const parent = path.dirname(existing);
115
+ if (parent === existing) break;
116
+ rest.unshift(path.basename(existing));
117
+ existing = parent;
118
+ }
119
+ }
120
+ const resolved = path.join(existing, ...rest);
121
+ return platform === 'win32' ? resolved.toLowerCase() : resolved;
122
+ }
123
+
124
+ function nearestExisting(target, fsImpl) {
125
+ let dir = path.resolve(String(target || ''));
126
+ while (!fsImpl.existsSync(dir)) {
127
+ const parent = path.dirname(dir);
128
+ if (parent === dir) break;
129
+ dir = parent;
130
+ }
131
+ return dir;
132
+ }
133
+
134
+ // The folder or one above it that holds a DO_NOT_TOUCH file, by real path.
135
+ function guardedBy(dir, { fsImpl, platform }) {
136
+ let current = realish(dir, { fsImpl, platform });
137
+ for (;;) {
138
+ let names = [];
139
+ try { names = fsImpl.readdirSync(current); } catch {}
140
+ const guard = names.find((name) => /^DO_NOT_TOUCH/i.test(name));
141
+ if (guard) return path.join(current, guard);
142
+ const parent = path.dirname(current);
143
+ if (parent === current) return null;
144
+ current = parent;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * The backup folder: `dbBackups.dir` (absolute only), else `backups` in the
150
+ * state root (npm) or `server/backups` (a checkout), outside the data
151
+ * folder. `{ dir, warnings }`, or `{ status: 'failed' | 'skipped', reason }`
152
+ * when no backup may be written there.
153
+ */
154
+ export function resolveBackupDir({ layout, serverDir, dataDir, config, fsImpl = fs, platform = process.platform } = {}) {
155
+ const { dir: configured } = backupSettings(config);
156
+ let dir;
157
+ if (configured !== undefined && configured !== null && configured !== '') {
158
+ if (typeof configured !== 'string' || !path.isAbsolute(configured)) {
159
+ return { status: 'failed', reason: `dbBackups.dir in config.json must be an absolute path (it is ${JSON.stringify(configured)})` };
160
+ }
161
+ dir = path.resolve(configured);
162
+ } else {
163
+ dir = layout?.checkout || !layout?.root ? path.join(String(serverDir || ''), 'backups') : path.join(layout.root, 'backups');
164
+ }
165
+ const warnings = [];
166
+ if (dataDir) {
167
+ const real = realish(dir, { fsImpl, platform });
168
+ const data = realish(dataDir, { fsImpl, platform });
169
+ if (real === data) return { status: 'skipped', reason: `the backup folder ${dir} is the data folder itself`, dir };
170
+ if (real.startsWith(data + path.sep)) {
171
+ warnings.push(`the backup folder ${dir} is inside the data folder ${dataDir}: copies of the data folder take the backups along`);
172
+ }
173
+ }
174
+ const guard = guardedBy(dir, { fsImpl, platform });
175
+ if (guard) return { status: 'skipped', reason: `${guard} guards ${dir}: nothing is written there`, dir };
176
+ return { dir, warnings };
177
+ }
178
+
179
+ /**
180
+ * Room for one more copy of the database: free − its size ≥ max(1 GiB, 5 %
181
+ * of the disk). Unknown (no statfs): `ok`, a full disk fails the write
182
+ * cleanly anyway.
183
+ */
184
+ export function checkBackupSpace({ dir, dbBytes, fsImpl = fs }) {
185
+ const at = nearestExisting(dir, fsImpl);
186
+ const free = freeBytes(at, fsImpl);
187
+ const total = diskBytes(at, fsImpl);
188
+ if (free === null || total === null || !Number.isFinite(free) || !Number.isFinite(total)) return { ok: true, unknown: true };
189
+ const need = Math.max(GIB, 0.05 * total);
190
+ return { ok: free - dbBytes >= need, free, total, need };
191
+ }
192
+
193
+ export function emptyManifest() {
194
+ return { seq: 0, backups: [], lastResult: null, pending: [] };
195
+ }
196
+
197
+ export function readManifest(dataDir, { fsImpl = fs, logger = null } = {}) {
198
+ const file = path.join(dataDir, BACKUP_MANIFEST_FILE);
199
+ let text;
200
+ try { text = fsImpl.readFileSync(file, 'utf8'); } catch { return emptyManifest(); }
201
+ try {
202
+ const parsed = JSON.parse(text);
203
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.backups)) throw new Error('not a manifest');
204
+ const pending = Array.isArray(parsed.pending) ? parsed.pending : (parsed.pending ? [parsed.pending] : []);
205
+ return {
206
+ seq: Number.isInteger(parsed.seq) ? parsed.seq : parsed.backups.length,
207
+ backups: parsed.backups.filter((entry) => entry && typeof entry === 'object'),
208
+ lastResult: parsed.lastResult && typeof parsed.lastResult === 'object' ? parsed.lastResult : null,
209
+ pending: pending.filter((entry) => entry && typeof entry === 'object'),
210
+ };
211
+ } catch {
212
+ // Read as empty: what it listed is never deleted, only forgotten.
213
+ logger?.warn?.(`[backup] ${file} could not be read; starting a new one (the backups it listed stay, not rotated).`);
214
+ return emptyManifest();
215
+ }
216
+ }
217
+
218
+ // A temp file of this process's own: two writers never share one.
219
+ export function writeManifest(dataDir, manifest, { fsImpl = fs, sleep = sleepSync, pid = process.pid } = {}) {
220
+ const file = path.join(dataDir, BACKUP_MANIFEST_FILE);
221
+ const temporary = `${file}.${pid}.tmp`;
222
+ fsImpl.writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`);
223
+ renameWithRetry(fsImpl, temporary, file, sleep);
224
+ }
225
+
226
+ // Read, changed and written at once: another process's change in between
227
+ // is kept, not overwritten with an old copy.
228
+ function updateManifest(dataDir, change, { fsImpl, sleep, pid, logger }) {
229
+ const manifest = readManifest(dataDir, { fsImpl, logger });
230
+ const result = change(manifest);
231
+ writeManifest(dataDir, manifest, { fsImpl, sleep, pid });
232
+ return result;
233
+ }
234
+
235
+ function removeQuietly(fsImpl, file) {
236
+ for (const each of [file, `${file}-wal`, `${file}-shm`, `${file}-journal`]) {
237
+ try { fsImpl.rmSync(each, { force: true }); } catch {}
238
+ }
239
+ }
240
+
241
+ // `.partial` files a killed writer left: the manifest named each, with the
242
+ // writer's pid, before it was written. Only those of a pid that no longer
243
+ // runs go; a live one is still writing.
244
+ function removeDeadPending(manifest, { fsImpl, platform, isProcessAlive, pid }) {
245
+ manifest.pending = manifest.pending.filter((entry) => {
246
+ const owner = Number(entry.pid);
247
+ if (owner !== pid && Number.isInteger(owner) && owner > 0 && isProcessAlive(owner)) return true;
248
+ const name = String(entry.file || '');
249
+ if (entry.dir && name === path.basename(name) && name.endsWith('.partial') && BACKUP_NAME_RE.test(name.slice(0, -'.partial'.length))) {
250
+ removeQuietly(fsImpl, path.join(realish(entry.dir, { fsImpl, platform }), name));
251
+ }
252
+ return false;
253
+ });
254
+ }
255
+
256
+ const sameDir = (a, b, options) => realish(a, options) === realish(b, options);
257
+
258
+ /**
259
+ * Writes the copy: `<name>.partial` in `dir` (created 0600 with `wx`: the
260
+ * name is ours, and the copy is never readable by others) from `source`
261
+ * (an open better-sqlite3 database), checked, made one file, synced, then
262
+ * linked to a free name (`-2`, `-3` when taken; never over a file).
263
+ * `onReserved(partialName)` runs once the name is ours, before the copy.
264
+ * `{ ok, file, bytes, phases }`, or `{ ok: false, reason }` with nothing
265
+ * left behind.
266
+ */
267
+ export async function writeBackup({
268
+ source, Database, dir, name, onReserved = () => {}, fsImpl = fs, platform = process.platform, sleep = sleepSync,
269
+ clock = Date.now,
270
+ rename = (from, to) => renameWithRetry(fsImpl, from, to, sleep),
271
+ link = (from, to) => fsImpl.linkSync(from, to),
272
+ }) {
273
+ let n = 1;
274
+ let partial = null;
275
+ for (;;) {
276
+ const candidate = path.join(dir, `${numbered(name, n)}.partial`);
277
+ if (!fsImpl.existsSync(path.join(dir, numbered(name, n)))) {
278
+ try {
279
+ fsImpl.closeSync(fsImpl.openSync(candidate, 'wx', 0o600));
280
+ partial = candidate;
281
+ break;
282
+ } catch (error) {
283
+ if (error?.code !== 'EEXIST') return { ok: false, reason: error?.message || String(error) };
284
+ }
285
+ }
286
+ n += 1;
287
+ }
288
+ const phases = {};
289
+ let at = clock();
290
+ const phase = (label) => { const now = clock(); phases[label] = now - at; at = now; };
291
+ try {
292
+ onReserved(path.basename(partial));
293
+ await source.backup(partial, { progress: () => ALL_PAGES });
294
+ phase('copy');
295
+ // The copied header may say WAL: the check makes -wal/-shm beside it,
296
+ // DELETE takes them away again.
297
+ const check = new Database(partial, { fileMustExist: true });
298
+ let integrity;
299
+ try {
300
+ integrity = String(check.pragma('quick_check', { simple: true }) || '');
301
+ if (integrity === 'ok') check.pragma('journal_mode = DELETE');
302
+ } finally {
303
+ check.close();
304
+ }
305
+ for (const side of ['-wal', '-shm']) { try { fsImpl.rmSync(`${partial}${side}`, { force: true }); } catch {} }
306
+ phase('check');
307
+ if (integrity !== 'ok') {
308
+ removeQuietly(fsImpl, partial);
309
+ return { ok: false, reason: `the copy failed its check (${oneLine(integrity)})` };
310
+ }
311
+ // Best effort: a folder on vfat or a network share has no such modes.
312
+ if (platform !== 'win32') { try { fsImpl.chmodSync(partial, 0o600); } catch {} }
313
+ const fd = fsImpl.openSync(partial, 'r+');
314
+ try { fsImpl.fsyncSync(fd); } finally { fsImpl.closeSync(fd); }
315
+ phase('sync');
316
+ const bytes = fsImpl.statSync(partial).size;
317
+ let file;
318
+ for (;; n += 1) {
319
+ file = numbered(name, n);
320
+ const target = path.join(dir, file);
321
+ try {
322
+ link(partial, target);
323
+ fsImpl.rmSync(partial, { force: true });
324
+ break;
325
+ } catch (error) {
326
+ if (error?.code === 'EEXIST') continue;
327
+ if (!NO_LINK_CODES.has(error?.code)) throw error;
328
+ // No hard links here: a rename, checked just before.
329
+ if (fsImpl.existsSync(target)) continue;
330
+ rename(partial, target);
331
+ break;
332
+ }
333
+ }
334
+ phase('rename');
335
+ return { ok: true, file, bytes, phases };
336
+ } catch (error) {
337
+ removeQuietly(fsImpl, partial);
338
+ return { ok: false, reason: error?.message || String(error) };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Keeps the `keep` newest of this relay's own backups in `dir` (newest by
344
+ * the manifest's order, never by clock) and deletes the rest. Own: in its
345
+ * manifest, in this folder, checked, named by the pattern, a plain file
346
+ * with the size recorded. `keepFile` (this run's) is never deleted.
347
+ * Entries whose files are gone are dropped from the manifest.
348
+ */
349
+ export function rotateBackups({ manifest, dir, keep, keepFile = null, fsImpl = fs, platform = process.platform, logger = console }) {
350
+ const rotated = [];
351
+ const warnings = [];
352
+ manifest.backups = manifest.backups.filter((entry) => {
353
+ try { fsImpl.lstatSync(path.join(String(entry.dir || ''), String(entry.file || ''))); return true; } catch { return false; }
354
+ });
355
+ const own = manifest.backups.filter((entry) => isOwnFile(entry, dir, { fsImpl, platform }))
356
+ .sort((a, b) => (Number(b.seq) || 0) - (Number(a.seq) || 0) || String(b.file).localeCompare(String(a.file)));
357
+ const keepers = new Set(keepFile ? [keepFile] : []);
358
+ for (const entry of own) {
359
+ if (keepers.size >= keep) break;
360
+ keepers.add(entry.file);
361
+ }
362
+ for (const entry of own) {
363
+ if (keepers.has(entry.file)) continue;
364
+ const file = path.join(dir, entry.file);
365
+ try {
366
+ fsImpl.unlinkSync(file);
367
+ manifest.backups = manifest.backups.filter((other) => other !== entry);
368
+ rotated.push(entry.file);
369
+ logger.log?.(`[backup] rotated out ${file}`);
370
+ } catch (error) {
371
+ const why = RETRY_CODES.has(error?.code) ? 'held by another program' : (error?.message || String(error));
372
+ warnings.push(`${file} could not be deleted (${why}); tried again at the next backup`);
373
+ logger.warn?.(`[backup] ${file} could not be deleted (${why}); it is tried again at the next backup.`);
374
+ }
375
+ }
376
+ return { rotated, warnings };
377
+ }
378
+
379
+ function isOwnFile(entry, dir, { fsImpl, platform }) {
380
+ const name = String(entry.file || '');
381
+ if (name !== path.basename(name) || !BACKUP_NAME_RE.test(name) || entry.quickCheck !== 'ok') return false;
382
+ if (!entry.dir || !sameDir(entry.dir, dir, { fsImpl, platform })) return false;
383
+ try {
384
+ const stat = fsImpl.lstatSync(path.join(dir, name));
385
+ return stat.isFile() && stat.size === Number(entry.bytes);
386
+ } catch {
387
+ return false;
388
+ }
389
+ }
390
+
391
+ function folderBytes(fsImpl, dir) {
392
+ let bytes = 0;
393
+ let entries = [];
394
+ try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
395
+ for (const entry of entries) {
396
+ const full = path.join(dir, entry.name);
397
+ if (entry.isDirectory()) bytes += folderBytes(fsImpl, full);
398
+ else { try { bytes += fsImpl.lstatSync(full).size; } catch {} }
399
+ }
400
+ return bytes;
401
+ }
402
+
403
+ /**
404
+ * What is in the backup folder: this relay's backups (`own`), those of its
405
+ * manifest whose size changed since (`changed`, never rotated), and
406
+ * everything else (`others`, with sizes; folders summed unless `sizes` is
407
+ * false, then null). Read only.
408
+ */
409
+ export function listBackupDir({ dir, manifest, sizes = true, fsImpl = fs, platform = process.platform }) {
410
+ const own = [];
411
+ const changed = [];
412
+ const others = [];
413
+ let entries = [];
414
+ try { entries = fsImpl.readdirSync(dir, { withFileTypes: true }); } catch { return { own, changed, others }; }
415
+ const listed = new Map(manifest.backups
416
+ .filter((entry) => entry.dir && sameDir(entry.dir, dir, { fsImpl, platform }))
417
+ .map((entry) => [String(entry.file), entry]));
418
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
419
+ const full = path.join(dir, entry.name);
420
+ const record = listed.get(entry.name);
421
+ if (record && isOwnFile(record, dir, { fsImpl, platform })) {
422
+ own.push({ file: entry.name, bytes: Number(record.bytes), at: record.at, version: record.version, reason: record.reason, seq: record.seq });
423
+ } else if (record && entry.isFile()) {
424
+ let bytes = 0;
425
+ try { bytes = fsImpl.lstatSync(full).size; } catch {}
426
+ changed.push({ file: entry.name, bytes, recordedBytes: Number(record.bytes) });
427
+ } else {
428
+ let bytes = null;
429
+ if (entry.isDirectory()) bytes = sizes ? folderBytes(fsImpl, full) : null;
430
+ else { try { bytes = fsImpl.lstatSync(full).size; } catch { bytes = 0; } }
431
+ others.push({ name: entry.name, bytes, folder: entry.isDirectory() });
432
+ }
433
+ }
434
+ own.sort((a, b) => (Number(b.seq) || 0) - (Number(a.seq) || 0));
435
+ return { own, changed, others };
436
+ }
437
+
438
+ function fileSize(fsImpl, file) {
439
+ try { return fsImpl.statSync(file).size; } catch { return 0; }
440
+ }
441
+
442
+ function readJson(fsImpl, file) {
443
+ try { return JSON.parse(fsImpl.readFileSync(file, 'utf8')); } catch { return null; }
444
+ }
445
+
446
+ const seconds = (ms) => `${(Math.max(0, ms) / 1000).toFixed(1)} s`;
447
+
448
+ // How long a start waits for an `oar backup` that is writing a copy of the
449
+ // same database, before it goes on without its own.
450
+ export const BACKUP_HOLDER_WAIT_MS = 15 * 60_000;
451
+
452
+ /**
453
+ * Whether the process a progress file names still writes that backup: its
454
+ * pid lives and is the same process (its recorded start and boot id, as for
455
+ * the relay's lock: a pid reused after a crash or a reboot is not). True
456
+ * when that cannot be told.
457
+ */
458
+ export function backupHolderMayRun(progress, { isProcessAlive = defaultIsProcessAlive, identityOf = processIdentity } = {}) {
459
+ return lockHolderMayRun({ lock: progress, isProcessAlive, identityOf });
460
+ }
461
+
462
+ function progressHeldByOther(progressFile, { pid, fsImpl, holderMayRun }) {
463
+ const held = readJson(fsImpl, progressFile);
464
+ const holder = Number(held?.pid);
465
+ return Number.isInteger(holder) && holder > 0 && holder !== pid && holderMayRun(held) ? held : null;
466
+ }
467
+
468
+ /**
469
+ * At start, under the relay's lock: a progress file whose writer no longer
470
+ * runs (killed mid-copy, its pid gone or reused) is removed, said with its
471
+ * pid. Returns whether one was removed.
472
+ */
473
+ export function removeStaleBackupProgress({ dataDir, pid = process.pid, holderMayRun = backupHolderMayRun, fsImpl = fs, logger = console }) {
474
+ const progressFile = path.join(dataDir, BACKUP_PROGRESS_FILE);
475
+ const held = readJson(fsImpl, progressFile);
476
+ if (!held) return false;
477
+ const holder = Number(held.pid);
478
+ if (holder !== pid && Number.isInteger(holder) && holder > 0 && holderMayRun(held)) return false;
479
+ try { fsImpl.rmSync(progressFile, { force: true }); } catch { return false; }
480
+ logger.log?.(`[backup] removed ${progressFile}: process ${Number.isInteger(holder) ? holder : '?'} that wrote it no longer runs (a backup it left unfinished).`);
481
+ return true;
482
+ }
483
+
484
+ /**
485
+ * One backup of `source` (the database at `dbPath`) into the backup folder,
486
+ * then the rotation. The start step and `oar backup` both run it. Returns
487
+ * `{ status: 'written' | 'skipped' | 'failed' | 'busy', reason?, file?,
488
+ * bytes?, dir?, rotated, warnings }`; the manifest's lastResult says the
489
+ * same (not for `busy`: another backup of this database runs, with `pid`).
490
+ */
491
+ export async function backupDatabase({
492
+ source,
493
+ Database,
494
+ dbPath,
495
+ dataDir,
496
+ layout,
497
+ serverDir,
498
+ config,
499
+ version,
500
+ commit = null,
501
+ reason,
502
+ now = () => new Date(),
503
+ clock = Date.now,
504
+ pid = process.pid,
505
+ isProcessAlive = defaultIsProcessAlive,
506
+ identityOf = processIdentity,
507
+ holderMayRun = (progress) => backupHolderMayRun(progress, { isProcessAlive, identityOf }),
508
+ // The start's own copy, after it waited: written beside another writer
509
+ // (two writers never share a name or a temp file).
510
+ besideHolder = false,
511
+ fsImpl = fs,
512
+ platform = process.platform,
513
+ sleep = sleepSync,
514
+ logger = console,
515
+ } = {}) {
516
+ const at = now();
517
+ const progressFile = path.join(dataDir, BACKUP_PROGRESS_FILE);
518
+ const options = { fsImpl, sleep, pid, logger };
519
+ const settled = (result) => {
520
+ try {
521
+ updateManifest(dataDir, (manifest) => {
522
+ removeDeadPending(manifest, { fsImpl, platform, isProcessAlive, pid });
523
+ manifest.lastResult = {
524
+ status: result.status,
525
+ ...(result.reason ? { reason: result.reason } : {}),
526
+ at: at.toISOString(),
527
+ ...(result.file ? { file: result.file, dir: result.dir, bytes: result.bytes } : {}),
528
+ };
529
+ }, options);
530
+ } catch (error) {
531
+ logger.warn?.(`[backup] ${path.join(dataDir, BACKUP_MANIFEST_FILE)} could not be written (${error?.message || error}).`);
532
+ }
533
+ if (result.status === 'skipped') logger.warn?.(`[backup] skipped: ${result.reason}`);
534
+ if (result.status === 'failed') logger.warn?.(`[backup] failed: ${result.reason}`);
535
+ if (result.status === 'busy') logger.warn?.(`[backup] busy: ${result.reason}`);
536
+ return { rotated: [], warnings: [], ...result };
537
+ };
538
+ // One backup of a database at a time: another process's progress file,
539
+ // its writer still the same live process, means it is copying now. Never
540
+ // removed here; recorded, so About and doctor show it.
541
+ const held = besideHolder ? null : progressHeldByOther(progressFile, { pid, fsImpl, holderMayRun });
542
+ const ownProgressFile = besideHolder ? path.join(dataDir, BACKUP_WAIT_FILE) : progressFile;
543
+ if (held) return settled({ status: 'busy', pid: Number(held.pid), reason: `another backup of this database is running (process ${held.pid})` });
544
+ const settings = backupSettings(config);
545
+ const place = resolveBackupDir({ layout, serverDir, dataDir, config, fsImpl, platform });
546
+ if (place.status) return settled({ status: place.status, reason: place.reason });
547
+ const { dir } = place;
548
+ for (const warning of place.warnings) logger.warn?.(`[backup] ${warning}.`);
549
+ const dbBytes = fileSize(fsImpl, dbPath) + fileSize(fsImpl, `${dbPath}-wal`);
550
+ const space = checkBackupSpace({ dir, dbBytes, fsImpl });
551
+ if (!space.ok) {
552
+ return settled({
553
+ status: 'skipped',
554
+ reason: `not enough disk space in ${dir} (${formatMegabytes(space.free)} free, a copy needs ${formatMegabytes(dbBytes)} and ${formatMegabytes(space.need)} must stay free)`,
555
+ });
556
+ }
557
+ try {
558
+ // Only the owner may look into a new backup folder.
559
+ fsImpl.mkdirSync(dir, { recursive: true, mode: 0o700 });
560
+ } catch (error) {
561
+ return settled({ status: 'failed', reason: `${dir} could not be made (${error?.message || error})` });
562
+ }
563
+ const name = backupName({ version, at, reason });
564
+ const started = clock();
565
+ let pendingFile = null;
566
+ let written;
567
+ try {
568
+ // With this process's identity: a pid reused later is not this writer.
569
+ const progress = { pid, format: 2, holder: identityOf(pid), task: 'backup', from: dbPath, bytes: dbBytes, at: at.toISOString() };
570
+ // Beside another writer its progress file stays its own: this one's goes
571
+ // into the wait note, which `oar start` reads as well.
572
+ try { fsImpl.writeFileSync(ownProgressFile, `${JSON.stringify(progress, null, 2)}\n`); } catch {}
573
+ logger.log?.(`[backup] backing up ${dbPath} (${formatMegabytes(dbBytes)}) to ${dir}`);
574
+ written = await writeBackup({
575
+ source, Database, dir, name, fsImpl, platform, sleep, clock,
576
+ // The exact name, with this pid, before a byte is copied: a kill
577
+ // leaves a .partial the next backup knows to be a dead one's.
578
+ onReserved: (partialName) => {
579
+ pendingFile = partialName;
580
+ updateManifest(dataDir, (manifest) => {
581
+ removeDeadPending(manifest, { fsImpl, platform, isProcessAlive, pid });
582
+ manifest.pending.push({ file: partialName, dir, pid });
583
+ }, options);
584
+ },
585
+ });
586
+ } catch (error) {
587
+ written = { ok: false, reason: error?.message || String(error) };
588
+ } finally {
589
+ if (readJson(fsImpl, ownProgressFile)?.pid === pid) { try { fsImpl.rmSync(ownProgressFile, { force: true }); } catch {} }
590
+ }
591
+ const clearOwnPending = (manifest) => {
592
+ manifest.pending = manifest.pending.filter((entry) => !(Number(entry.pid) === pid && entry.file === pendingFile));
593
+ };
594
+ // A failed backup never pushes out a good one: no rotation.
595
+ if (!written.ok) {
596
+ try { updateManifest(dataDir, clearOwnPending, options); } catch {}
597
+ return settled({ status: 'failed', reason: written.reason });
598
+ }
599
+ logger.log?.(`[backup] written ${path.join(dir, written.file)} (${formatMegabytes(written.bytes)}, quick_check ok, ${seconds(clock() - started)})`);
600
+ logger.log?.(`[backup] phases: copy ${seconds(written.phases.copy)}, check ${seconds(written.phases.check)}, sync ${seconds(written.phases.sync)}, rename ${seconds(written.phases.rename)}`);
601
+ let rotation = { rotated: [], warnings: [] };
602
+ try {
603
+ rotation = updateManifest(dataDir, (manifest) => {
604
+ clearOwnPending(manifest);
605
+ manifest.seq += 1;
606
+ manifest.backups.push({
607
+ file: written.file, dir, version, commit: commit || null, reason: sanitizeReason(reason), at: at.toISOString(), bytes: written.bytes, quickCheck: 'ok', seq: manifest.seq,
608
+ });
609
+ return rotateBackups({ manifest, dir, keep: settings.keep, keepFile: written.file, fsImpl, platform, logger });
610
+ }, options);
611
+ } catch (error) {
612
+ logger.warn?.(`[backup] ${path.join(dataDir, BACKUP_MANIFEST_FILE)} could not be written (${error?.message || error}); ${written.file} is kept, not rotated.`);
613
+ }
614
+ const result = settled({ status: 'written', file: written.file, dir, bytes: written.bytes });
615
+ return { ...result, rotated: rotation.rotated, warnings: [...place.warnings, ...rotation.warnings] };
616
+ }
617
+
618
+ function readSetting(db, key) {
619
+ try {
620
+ return db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key)?.value ?? null;
621
+ } catch {
622
+ return null;
623
+ }
624
+ }
625
+
626
+ function hasSettingsTable(db) {
627
+ try {
628
+ return !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'app_settings'").get();
629
+ } catch {
630
+ return false;
631
+ }
632
+ }
633
+
634
+ /** db_last_run = `record` ({ version, commit, at }). */
635
+ export function writeDbLastRun(db, record) {
636
+ db.prepare('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at')
637
+ .run(DB_LAST_RUN_SETTING_KEY, JSON.stringify(record), record.at);
638
+ }
639
+
640
+ function parseJson(text) {
641
+ try {
642
+ const value = JSON.parse(String(text ?? ''));
643
+ return value && typeof value === 'object' ? value : null;
644
+ } catch {
645
+ return null;
646
+ }
647
+ }
648
+
649
+ const versionOf = (value) => {
650
+ const text = String(value ?? '').trim().replace(/^v/, '');
651
+ return VERSION_RE.test(text) ? text : null;
652
+ };
653
+
654
+ /**
655
+ * The version that ran on the data before, when db_last_run does not say:
656
+ * the in-app update's record, if it is this update's (its target is the
657
+ * running version) and at most a week old, as reconcileUpdateAttempt trusts it.
658
+ */
659
+ function updateAttemptFromVersion(db, runningVersion, nowMs) {
660
+ const attempt = parseJson(readSetting(db, UPDATE_ATTEMPT_SETTING_KEY));
661
+ if (!attempt) return null;
662
+ if (versionOf(attempt.attemptedVersion) !== versionOf(runningVersion)) return null;
663
+ const startedAt = Date.parse(String(attempt.startedAt || ''));
664
+ if (!Number.isFinite(startedAt) || nowMs - startedAt > UPDATE_ATTEMPT_MAX_AGE_MS || startedAt - nowMs > UPDATE_ATTEMPT_MAX_AGE_MS) return null;
665
+ return versionOf(attempt.fromVersion);
666
+ }
667
+
668
+ /**
669
+ * The start step, between opening the database and applySchema: a backup
670
+ * when the version (on a checkout also the commit) that last ran on this
671
+ * database differs from the running one, or is unknown. Skipped for a
672
+ * database just created and with `dbBackups.enabled: false`.
673
+ *
674
+ * db_last_run is written as soon as the step is done, also after a skip or
675
+ * a failure (a crash loop or a full disk never repeats the copy at every
676
+ * start); with no app_settings table yet (a new or very old database) it is
677
+ * returned as `deferredLastRun`, for the caller to write after applySchema.
678
+ */
679
+ export async function maybeBackupBeforeStart({
680
+ db,
681
+ Database,
682
+ dbPath,
683
+ dataDir,
684
+ databaseCreated = false,
685
+ layout,
686
+ serverDir,
687
+ config,
688
+ runningVersion,
689
+ runningCommit = null,
690
+ checkout = !!layout?.checkout,
691
+ now = () => new Date(),
692
+ clock = Date.now,
693
+ pid = process.pid,
694
+ isProcessAlive = defaultIsProcessAlive,
695
+ identityOf = processIdentity,
696
+ holderMayRun = (progress) => backupHolderMayRun(progress, { isProcessAlive, identityOf }),
697
+ holderWaitMs = BACKUP_HOLDER_WAIT_MS,
698
+ pollMs = 1000,
699
+ wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
700
+ fsImpl = fs,
701
+ platform = process.platform,
702
+ sleep = sleepSync,
703
+ logger = console,
704
+ } = {}) {
705
+ for (const warning of configWarnings(config)) logger.warn?.(warning);
706
+ removeStaleBackupProgress({ dataDir, pid, holderMayRun, fsImpl, logger });
707
+ // Only the relay that holds the lock writes the wait note: one there now
708
+ // is a killed start's.
709
+ try { fsImpl.rmSync(path.join(dataDir, BACKUP_WAIT_FILE), { force: true }); } catch {}
710
+ const at = now();
711
+ const record = { version: String(runningVersion), commit: checkout ? (runningCommit || null) : null, at: at.toISOString() };
712
+ const settingsTable = hasSettingsTable(db);
713
+ const finish = (result) => {
714
+ if (!settingsTable) return { ...result, deferredLastRun: record };
715
+ writeDbLastRun(db, record);
716
+ return result;
717
+ };
718
+ if (databaseCreated) return finish({ status: 'new-database' });
719
+
720
+ const last = settingsTable ? parseJson(readSetting(db, DB_LAST_RUN_SETTING_KEY)) : null;
721
+ const lastVersion = versionOf(last?.version);
722
+ const lastCommit = typeof last?.commit === 'string' && /^[0-9a-f]{7,64}$/i.test(last.commit) ? last.commit.toLowerCase() : null;
723
+ if (lastVersion && lastVersion === versionOf(runningVersion)) {
724
+ // On a checkout the commit too, when it can be read now.
725
+ if (!checkout || !record.commit || lastCommit === record.commit.toLowerCase()) return { status: 'same' };
726
+ }
727
+ if (!backupSettings(config).enabled) return finish({ status: 'disabled' });
728
+
729
+ let version;
730
+ let reason;
731
+ if (lastVersion) {
732
+ version = lastVersion;
733
+ reason = checkout && lastCommit ? `pre-update-${lastCommit.slice(0, 7)}` : 'pre-update';
734
+ } else {
735
+ const fromVersion = settingsTable ? updateAttemptFromVersion(db, runningVersion, at.getTime()) : null;
736
+ version = fromVersion || versionOf(runningVersion) || '0.0.0';
737
+ reason = fromVersion ? 'pre-update' : 'pre-update-from-unknown';
738
+ }
739
+ // An `oar backup` writing a copy of this database now: wait for it (it may
740
+ // be the copy before this very update), then make this start's own, in
741
+ // any case: a holder taken for live by mistake never costs a start its
742
+ // backup. The wait note tells `oar start` to leave this relay running.
743
+ const progressFile = path.join(dataDir, BACKUP_PROGRESS_FILE);
744
+ const held = progressHeldByOther(progressFile, { pid, fsImpl, holderMayRun });
745
+ let besideHolder = false;
746
+ if (held) {
747
+ const waitFile = path.join(dataDir, BACKUP_WAIT_FILE);
748
+ logger.log?.(`[backup] process ${held.pid} is backing up this database; waiting for it (up to ${Math.round(holderWaitMs / 60_000)} min) before this start's own backup.`);
749
+ try { fsImpl.writeFileSync(waitFile, `${JSON.stringify({ pid, format: 2, holder: identityOf(pid), task: 'wait-backup', waitingFor: Number(held.pid), at: new Date(clock()).toISOString() }, null, 2)}\n`); } catch {}
750
+ try {
751
+ const until = clock() + holderWaitMs;
752
+ while (clock() < until && progressHeldByOther(progressFile, { pid, fsImpl, holderMayRun })) await wait(pollMs);
753
+ } finally {
754
+ try { fsImpl.rmSync(waitFile, { force: true }); } catch {}
755
+ }
756
+ if (progressHeldByOther(progressFile, { pid, fsImpl, holderMayRun })) {
757
+ besideHolder = true;
758
+ logger.warn?.(`[backup] process ${held.pid} still holds ${progressFile} after ${Math.round(holderWaitMs / 60_000)} min; this start makes its own backup beside it.`);
759
+ }
760
+ }
761
+ const result = await backupDatabase({
762
+ source: db, Database, dbPath, dataDir, layout, serverDir, config, version, commit: lastCommit, reason,
763
+ now: () => at, clock, pid, isProcessAlive, identityOf, holderMayRun, besideHolder, fsImpl, platform, sleep, logger,
764
+ });
765
+ if (result.status !== 'written') logger.warn?.('[backup] This start goes on without a backup of the version before; run `oar backup` for one.');
766
+ // Never expected here (the start backs up beside a holder), but a busy
767
+ // result would make no copy: db_last_run stays, the next start tries again.
768
+ if (result.status === 'busy') return result;
769
+ return finish(result);
770
+ }
771
+
772
+ /**
773
+ * What doctor and About show: the folder (or why there is none), the
774
+ * settings, this relay's backups, the files there it does not rotate, and
775
+ * the last result. Read only. `sizes: false` (About) leaves the sizes of
776
+ * the folders there out: no walk through them.
777
+ */
778
+ export function describeBackups({ layout, serverDir, dataDir, config, sizes = true, fsImpl = fs, platform = process.platform } = {}) {
779
+ const settings = backupSettings(config);
780
+ const manifest = readManifest(dataDir, { fsImpl });
781
+ const place = resolveBackupDir({ layout, serverDir, dataDir, config, fsImpl, platform });
782
+ const dir = place.dir || null;
783
+ const listing = dir && !place.status ? listBackupDir({ dir, manifest, sizes, fsImpl, platform }) : { own: [], changed: [], others: [] };
784
+ return {
785
+ enabled: settings.enabled,
786
+ keep: settings.keep,
787
+ dir,
788
+ problem: place.status ? place.reason : null,
789
+ warnings: [...configWarnings(config), ...(place.warnings || [])],
790
+ lastResult: manifest.lastResult,
791
+ ...listing,
792
+ };
793
+ }
794
+
795
+ /**
796
+ * The in-app update's line, before npm installs: whether the next start
797
+ * backs up the database, and where, or why it will not.
798
+ */
799
+ export function describeNextBackup({ layout, serverDir, dataDir, dbPath, config, fsImpl = fs, platform = process.platform } = {}) {
800
+ if (!backupSettings(config).enabled) return '[backup] The next start does not back up the database: dbBackups.enabled is false in config.json.';
801
+ const place = resolveBackupDir({ layout, serverDir, dataDir, config, fsImpl, platform });
802
+ if (place.status) return `[backup] The next start does not back up the database: ${place.reason}. Run \`oar backup\` now for a copy.`;
803
+ const dbBytes = fileSize(fsImpl, dbPath) + fileSize(fsImpl, `${dbPath}-wal`);
804
+ const space = checkBackupSpace({ dir: place.dir, dbBytes, fsImpl });
805
+ if (!space.ok) {
806
+ return `[backup] The next start does not back up the database: not enough disk space in ${place.dir} (${formatMegabytes(space.free)} free, a copy needs ${formatMegabytes(dbBytes)}). Free some space, or run \`oar backup\` once there is room.`;
807
+ }
808
+ return `[backup] The next start backs up the database (${formatMegabytes(dbBytes)}) to ${place.dir} before the new version's migrations.`;
809
+ }