79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
import { mkdir, open, realpath, rm } from 'node:fs/promises';
|
|
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
|
export interface FixtureOptions {
|
|
dbPath: string;
|
|
now: number;
|
|
watch: boolean;
|
|
}
|
|
|
|
export function parseFixtureOptions(args: string[], clock = Date.now()): FixtureOptions {
|
|
let dbPath = '',
|
|
now = clock,
|
|
watch = false,
|
|
explicitTime = false;
|
|
const seen = new Set<string>();
|
|
for (let index = 0; index < args.length; index++) {
|
|
const option = args[index];
|
|
if (seen.has(option)) throw new Error(`Duplicate option: ${option}`);
|
|
seen.add(option);
|
|
if (option === '--watch') {
|
|
watch = true;
|
|
continue;
|
|
}
|
|
if (option !== '--db' && option !== '--now') throw new Error(`Unknown option: ${option}`);
|
|
const value = args[++index];
|
|
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}.`);
|
|
if (option === '--db') dbPath = value;
|
|
else {
|
|
if (
|
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(
|
|
value
|
|
) ||
|
|
!Number.isFinite(Date.parse(value))
|
|
)
|
|
throw new Error('--now must be an ISO timestamp with a timezone.');
|
|
now = Date.parse(value);
|
|
explicitTime = true;
|
|
}
|
|
}
|
|
if (!dbPath || !isAbsolute(dbPath))
|
|
throw new Error(
|
|
'Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.'
|
|
);
|
|
if (!Number.isFinite(now)) throw new Error('Invalid fixture clock.');
|
|
if (watch && explicitTime)
|
|
throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
|
|
return { dbPath: resolve(dbPath), now, watch };
|
|
}
|
|
|
|
/** An atomic new-directory reservation rejects existing DBs, sidecars and symlinks together. */
|
|
export async function reserveFixture(dbPath: string): Promise<{ root: string; dbPath: string }> {
|
|
if (!isAbsolute(dbPath)) throw new Error('Fixture database path must be absolute.');
|
|
const requested = resolve(dbPath);
|
|
const requestedRoot = dirname(requested);
|
|
// The destination's parent must already exist. Canonicalize /tmp on macOS,
|
|
// but never follow a pre-existing fixture directory or database symlink.
|
|
const parent = await realpath(dirname(requestedRoot));
|
|
const root = join(parent, basename(requestedRoot));
|
|
try {
|
|
await mkdir(root, { mode: 0o700 });
|
|
} catch (cause) {
|
|
if ((cause as NodeJS.ErrnoException).code === 'EEXIST')
|
|
throw new Error(
|
|
'Fixture directory already exists. Choose a new directory; existing data is never overwritten.',
|
|
{ cause }
|
|
);
|
|
throw cause;
|
|
}
|
|
const target = join(root, basename(requested));
|
|
try {
|
|
const file = await open(target, 'wx', 0o600);
|
|
await file.close();
|
|
} catch (error) {
|
|
await rm(root, { recursive: true, force: true });
|
|
throw error;
|
|
}
|
|
return { root, dbPath: target };
|
|
}
|