@wpmoo/toolkit 0.9.25 → 0.9.27
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.
- package/README.md +24 -5
- package/dist/audit-log.js +78 -0
- package/dist/daily-actions.js +112 -67
- package/dist/databases.js +57 -0
- package/dist/doctor.js +6 -266
- package/dist/environment-policy.js +219 -0
- package/dist/migrations.js +112 -0
- package/dist/postgres-diagnostics.js +646 -0
- package/dist/templates.js +61 -0
- package/docs/generated-environment-verification.md +11 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -170,12 +170,31 @@ npx @wpmoo/toolkit doctor --json --postgres
|
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
JSON output is optional; human-readable output remains the default.
|
|
173
|
-
`doctor --postgres`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
`doctor --postgres` runs read-only PostgreSQL diagnostics as advisory checks only; it
|
|
174
|
+
does not perform automatic tuning.
|
|
175
|
+
Incomplete or malformed PostgreSQL metric rows are reported as unavailable diagnostics
|
|
176
|
+
instead of being treated as successful checks.
|
|
177
|
+
|
|
178
|
+
Current advisory checks include:
|
|
179
|
+
|
|
180
|
+
- sessions currently running queries where `pg_stat_activity.state = 'active'`;
|
|
181
|
+
- connection utilization against `max_connections`;
|
|
182
|
+
- long transaction / idle-in-transaction warnings from `pg_stat_activity`;
|
|
183
|
+
- table health visibility (for example table and index bloat signals, index scan
|
|
184
|
+
efficiency, and vacuum-related blockers);
|
|
185
|
+
- optional unused index advisory output when index usage data is available;
|
|
186
|
+
- WAL and capacity visibility including WAL activity and disk-level pressure context;
|
|
187
|
+
- slow-query and query-plan readiness checks for common `log_min_duration_statement`
|
|
188
|
+
and `pg_stat_statements` prerequisites.
|
|
189
|
+
|
|
190
|
+
`npx @wpmoo/toolkit doctor --postgres` and
|
|
191
|
+
`npx @wpmoo/toolkit doctor --json --postgres` use the same checks, and the
|
|
192
|
+
JSON variant exposes a versioned PostgreSQL diagnostics contract.
|
|
193
|
+
|
|
177
194
|
`doctor --json --postgres` includes a structured `postgres` object for automation.
|
|
178
|
-
|
|
195
|
+
`doctor --json --postgres` keeps the JSON contract stable by versioning the
|
|
196
|
+
`postgres` payload; individual fields are optional so automation can safely handle
|
|
197
|
+
environments where PostgreSQL does not expose a metric.
|
|
179
198
|
|
|
180
199
|
## Release Artifacts
|
|
181
200
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
const secretFlagNames = new Set([
|
|
4
|
+
'password',
|
|
5
|
+
'api-key',
|
|
6
|
+
'token',
|
|
7
|
+
'secret',
|
|
8
|
+
'api_key',
|
|
9
|
+
]);
|
|
10
|
+
function isSecretFlagToken(token) {
|
|
11
|
+
return token === '--password'
|
|
12
|
+
? '--password'
|
|
13
|
+
: token === '--api-key'
|
|
14
|
+
? '--api-key'
|
|
15
|
+
: token === '--token'
|
|
16
|
+
? '--token'
|
|
17
|
+
: token === '--secret'
|
|
18
|
+
? '--secret'
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
function isSecretKVToken(token) {
|
|
22
|
+
const firstEquals = token.indexOf('=');
|
|
23
|
+
if (firstEquals < 0) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const [name] = [token.slice(0, firstEquals), token.slice(firstEquals + 1)];
|
|
27
|
+
return secretFlagNames.has(name.replace(/^--+/, '').toLowerCase()) ? name : undefined;
|
|
28
|
+
}
|
|
29
|
+
function normalizeFlag(flag) {
|
|
30
|
+
return flag.startsWith('--') ? flag : `--${flag}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Redacts values for common secret-like arguments.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeCommandArgs(args) {
|
|
36
|
+
const sanitized = [];
|
|
37
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
38
|
+
const arg = args[index];
|
|
39
|
+
const secretKV = isSecretKVToken(arg);
|
|
40
|
+
if (secretKV) {
|
|
41
|
+
sanitized.push(`${secretKV}=***`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const secretFlag = isSecretFlagToken(arg);
|
|
45
|
+
if (secretFlag && index + 1 < args.length && !args[index + 1].startsWith('--')) {
|
|
46
|
+
sanitized.push(arg);
|
|
47
|
+
sanitized.push('***');
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
sanitized.push(arg);
|
|
52
|
+
}
|
|
53
|
+
return sanitized;
|
|
54
|
+
}
|
|
55
|
+
export function extractApprovedFlags(args, approvedFlagNames) {
|
|
56
|
+
const present = [];
|
|
57
|
+
const argsByIndex = args.map((arg) => arg.toLowerCase());
|
|
58
|
+
approvedFlagNames.forEach((name) => {
|
|
59
|
+
const normalized = normalizeFlag(name).toLowerCase();
|
|
60
|
+
if (argsByIndex.some((arg) => arg === normalized || arg.startsWith(`${normalized}=`))) {
|
|
61
|
+
present.push(name);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return present;
|
|
65
|
+
}
|
|
66
|
+
export async function appendAuditLog(options) {
|
|
67
|
+
const logPath = join(options.environmentPath, '.wpmoo', 'audit.log');
|
|
68
|
+
const event = {
|
|
69
|
+
timestamp: (options.timestamp ?? new Date()).toISOString(),
|
|
70
|
+
command: options.command,
|
|
71
|
+
environment: options.environment,
|
|
72
|
+
dryRun: options.dryRun,
|
|
73
|
+
approvedFlags: [...(options.approvedFlags ?? extractApprovedFlags(options.args, options.approvedFlagNames))],
|
|
74
|
+
args: sanitizeCommandArgs(options.args),
|
|
75
|
+
};
|
|
76
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
77
|
+
await appendFile(logPath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
78
|
+
}
|
package/dist/daily-actions.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { access } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { appendAuditLog } from './audit-log.js';
|
|
4
5
|
import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
|
-
import { normalizeDatabaseName } from './databases.js';
|
|
6
|
+
import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName } from './databases.js';
|
|
7
|
+
import { evaluateDailyActionPolicy, } from './environment-policy.js';
|
|
6
8
|
import { markerPath } from './environment.js';
|
|
9
|
+
import { scanMigrationRisks } from './migrations.js';
|
|
7
10
|
export const dailyActionCommands = [
|
|
8
11
|
'start',
|
|
9
12
|
'stop',
|
|
@@ -185,68 +188,6 @@ function scriptArgs(command, argv) {
|
|
|
185
188
|
return ensureNoArgs(command, argv);
|
|
186
189
|
return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
|
|
187
190
|
}
|
|
188
|
-
function isDestructiveCommand(command, args) {
|
|
189
|
-
if (command === 'resetdb')
|
|
190
|
-
return true;
|
|
191
|
-
return command === 'restore-snapshot' && args[0] !== '--dry-run';
|
|
192
|
-
}
|
|
193
|
-
function isProductionLifecycleCommand(command) {
|
|
194
|
-
return command === 'install' || command === 'update' || command === 'test';
|
|
195
|
-
}
|
|
196
|
-
function isStageLifecycleCommand(command) {
|
|
197
|
-
return command === 'install' || command === 'update';
|
|
198
|
-
}
|
|
199
|
-
function destructiveCommandError(command, envName) {
|
|
200
|
-
return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
|
|
201
|
-
}
|
|
202
|
-
function stageLifecycleCommandError(command) {
|
|
203
|
-
return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
|
|
204
|
-
}
|
|
205
|
-
function productionLifecycleCommandError(command) {
|
|
206
|
-
return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
|
|
207
|
-
}
|
|
208
|
-
async function assertDestructiveCommandAllowed(command, args, cwd) {
|
|
209
|
-
if (!isDestructiveCommand(command, args)) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const env = await readEnvFile(cwd);
|
|
213
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
214
|
-
if (envName !== 'stage' && envName !== 'prod') {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
|
|
218
|
-
if (allowDestructive !== '1') {
|
|
219
|
-
throw new Error(destructiveCommandError(command, envName));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
async function assertProductionLifecycleCommandAllowed(command, cwd) {
|
|
223
|
-
if (!isProductionLifecycleCommand(command)) {
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
const env = await readEnvFile(cwd);
|
|
227
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
228
|
-
if (envName !== 'prod') {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
|
|
232
|
-
if (allowProdLifecycle !== '1') {
|
|
233
|
-
throw new Error(productionLifecycleCommandError(command));
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
async function assertStageLifecycleCommandAllowed(command, cwd) {
|
|
237
|
-
if (!isStageLifecycleCommand(command)) {
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
const env = await readEnvFile(cwd);
|
|
241
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
242
|
-
if (envName !== 'stage') {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const allowStageLifecycle = process.env.WPMOO_ALLOW_STAGE_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_STAGE_LIFECYCLE')?.trim();
|
|
246
|
-
if (allowStageLifecycle !== '1') {
|
|
247
|
-
throw new Error(stageLifecycleCommandError(command));
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
191
|
async function assertEnvironmentRoot(cwd) {
|
|
251
192
|
try {
|
|
252
193
|
await access(join(cwd, markerPath));
|
|
@@ -265,17 +206,121 @@ async function assertScriptExists(cwd, script) {
|
|
|
265
206
|
}
|
|
266
207
|
return scriptPath;
|
|
267
208
|
}
|
|
268
|
-
|
|
209
|
+
function envValue(env, key) {
|
|
210
|
+
return process.env[key]?.trim() || env?.get(key)?.trim();
|
|
211
|
+
}
|
|
212
|
+
function flagEnabled(env, key) {
|
|
213
|
+
return envValue(env, key) === '1';
|
|
214
|
+
}
|
|
215
|
+
function approvedFlags(env) {
|
|
216
|
+
return [
|
|
217
|
+
'WPMOO_ALLOW_DESTRUCTIVE',
|
|
218
|
+
'WPMOO_ALLOW_STAGE_LIFECYCLE',
|
|
219
|
+
'WPMOO_ALLOW_PROD_LIFECYCLE',
|
|
220
|
+
'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
|
|
221
|
+
'WPMOO_ALLOW_MIGRATIONS',
|
|
222
|
+
].filter((key) => flagEnabled(env, key));
|
|
223
|
+
}
|
|
224
|
+
function requiresMigrationApproval(command) {
|
|
225
|
+
return command === 'install' || command === 'update' || command === 'test';
|
|
226
|
+
}
|
|
227
|
+
function noRecentSnapshotMessage(command, environment) {
|
|
228
|
+
return `Refusing destructive command '${command}' in WPMOO_ENV=${environment} without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally.`;
|
|
229
|
+
}
|
|
230
|
+
function migrationRiskMessage(command, environment) {
|
|
231
|
+
return `Refusing migration-risk command '${command}' in WPMOO_ENV=${environment}. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally.`;
|
|
232
|
+
}
|
|
233
|
+
async function auditDailyActionPreview(preview) {
|
|
234
|
+
if (preview.environment !== 'prod' || !preview.auditWorthy) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
await appendAuditLog({
|
|
238
|
+
environmentPath: preview.cwd,
|
|
239
|
+
command: preview.command,
|
|
240
|
+
environment: preview.environment,
|
|
241
|
+
dryRun: preview.dryRun,
|
|
242
|
+
args: preview.args,
|
|
243
|
+
approvedFlagNames: [],
|
|
244
|
+
approvedFlags: preview.approvedFlags,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd()) {
|
|
269
248
|
await assertEnvironmentRoot(cwd);
|
|
270
249
|
const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
|
|
271
250
|
const args = scriptArgs(command, argv);
|
|
272
|
-
await
|
|
273
|
-
|
|
274
|
-
|
|
251
|
+
const env = await readEnvFile(cwd);
|
|
252
|
+
const envName = envValue(env, 'WPMOO_ENV') || selectedComposeEnvironment(env);
|
|
253
|
+
const policy = evaluateDailyActionPolicy(command, args, {
|
|
254
|
+
envName,
|
|
255
|
+
allowDestructive: envValue(env, 'WPMOO_ALLOW_DESTRUCTIVE'),
|
|
256
|
+
allowStageLifecycle: envValue(env, 'WPMOO_ALLOW_STAGE_LIFECYCLE'),
|
|
257
|
+
allowProdLifecycle: envValue(env, 'WPMOO_ALLOW_PROD_LIFECYCLE'),
|
|
258
|
+
});
|
|
259
|
+
const warnings = [];
|
|
260
|
+
const snapshotRequired = policy.isDestructive && (policy.env === 'stage' || policy.env === 'prod');
|
|
261
|
+
const snapshot = snapshotRequired ? findDatabaseSnapshots(cwd) : undefined;
|
|
262
|
+
const noRecentSnapshot = snapshotRequired &&
|
|
263
|
+
snapshot &&
|
|
264
|
+
(snapshot.newestSnapshotAgeMs === null || snapshot.newestSnapshotAgeMs > defaultDatabaseSnapshotMaxAgeMs) &&
|
|
265
|
+
!flagEnabled(env, 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT');
|
|
266
|
+
if (noRecentSnapshot) {
|
|
267
|
+
warnings.push({
|
|
268
|
+
kind: 'no-recent-snapshot',
|
|
269
|
+
requiredFlag: 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
|
|
270
|
+
blocking: true,
|
|
271
|
+
message: noRecentSnapshotMessage(command, policy.env),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const migrations = requiresMigrationApproval(command) && (policy.env === 'stage' || policy.env === 'prod')
|
|
275
|
+
? await scanMigrationRisks(cwd)
|
|
276
|
+
: undefined;
|
|
277
|
+
if (migrations?.risk && !flagEnabled(env, 'WPMOO_ALLOW_MIGRATIONS')) {
|
|
278
|
+
warnings.push({
|
|
279
|
+
kind: 'migration-risk',
|
|
280
|
+
requiredFlag: 'WPMOO_ALLOW_MIGRATIONS',
|
|
281
|
+
blocking: true,
|
|
282
|
+
message: migrationRiskMessage(command, policy.env),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
275
285
|
return {
|
|
276
286
|
cwd,
|
|
277
287
|
scriptPath,
|
|
278
288
|
args,
|
|
289
|
+
command,
|
|
290
|
+
environment: policy.env,
|
|
291
|
+
dryRun: policy.isDryRunPreview,
|
|
292
|
+
destructive: policy.isDestructive,
|
|
293
|
+
auditWorthy: policy.isAuditWorthy,
|
|
294
|
+
allowed: policy.allowed,
|
|
295
|
+
deny: policy.allowed ? undefined : { ...policy.deny, message: policy.message },
|
|
296
|
+
refusalMessage: policy.allowed ? undefined : policy.message,
|
|
297
|
+
requiredFlag: policy.allowed ? undefined : policy.deny.requiredFlag,
|
|
298
|
+
warnings,
|
|
299
|
+
snapshot: snapshot
|
|
300
|
+
? {
|
|
301
|
+
requiredRecent: true,
|
|
302
|
+
newestSnapshotAgeMs: snapshot.newestSnapshotAgeMs,
|
|
303
|
+
snapshotPaths: snapshot.snapshotPaths,
|
|
304
|
+
}
|
|
305
|
+
: undefined,
|
|
306
|
+
migrations,
|
|
307
|
+
approvedFlags: approvedFlags(env),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
|
|
311
|
+
const preview = await dailyActionSafetyPreview(command, argv, cwd);
|
|
312
|
+
await auditDailyActionPreview(preview);
|
|
313
|
+
if (!preview.allowed) {
|
|
314
|
+
throw new Error(preview.refusalMessage);
|
|
315
|
+
}
|
|
316
|
+
const blockingWarning = preview.warnings.find((warning) => warning.blocking);
|
|
317
|
+
if (blockingWarning) {
|
|
318
|
+
throw new Error(blockingWarning.message);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
cwd: preview.cwd,
|
|
322
|
+
scriptPath: preview.scriptPath,
|
|
323
|
+
args: preview.args,
|
|
279
324
|
};
|
|
280
325
|
}
|
|
281
326
|
async function spawnDailyAction(plan) {
|
package/dist/databases.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
|
|
5
|
+
export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
|
|
6
|
+
export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
|
|
7
|
+
function isDatabaseSnapshotFile(fileName, extensions) {
|
|
8
|
+
const normalized = fileName.toLowerCase();
|
|
9
|
+
return extensions.some((extension) => normalized.endsWith(extension));
|
|
10
|
+
}
|
|
2
11
|
const maintenanceDatabases = new Set(['postgres']);
|
|
3
12
|
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
4
13
|
export function isValidDatabaseName(value) {
|
|
@@ -46,6 +55,54 @@ export function parseDatabaseListOutput(output, options = {}) {
|
|
|
46
55
|
}
|
|
47
56
|
return databases;
|
|
48
57
|
}
|
|
58
|
+
export function findDatabaseSnapshots(targetDirectory, options = {}) {
|
|
59
|
+
const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], } = options;
|
|
60
|
+
const snapshots = [];
|
|
61
|
+
for (const directoryName of snapshotDirectories) {
|
|
62
|
+
const directory = join(targetDirectory, directoryName);
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT') {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!isDatabaseSnapshotFile(entry.name, snapshotExtensions)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const path = join(directory, entry.name);
|
|
78
|
+
let stats;
|
|
79
|
+
try {
|
|
80
|
+
stats = statSync(path);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!stats.isFile()) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const mtimeMs = stats.mtimeMs;
|
|
89
|
+
const ageMs = Math.max(0, nowMs - mtimeMs);
|
|
90
|
+
snapshots.push({ path, mtimeMs, ageMs });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
|
|
94
|
+
const newestSnapshot = snapshots[0] ?? null;
|
|
95
|
+
return {
|
|
96
|
+
snapshots,
|
|
97
|
+
snapshotPaths: snapshots.map((snapshot) => snapshot.path),
|
|
98
|
+
newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
|
|
102
|
+
const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
|
|
103
|
+
const result = findDatabaseSnapshots(targetDirectory, scanOptions);
|
|
104
|
+
return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
|
|
105
|
+
}
|
|
49
106
|
export function normalizeDatabaseListResult(result) {
|
|
50
107
|
if (Array.isArray(result)) {
|
|
51
108
|
return { ok: true, databases: result };
|