Separate one-shot utility

This commit is contained in:
2026-10-20 23:04:29 -04:00
parent ccc6eef951
commit 5b21074462
18 changed files with 462 additions and 30 deletions
+18 -7
View File
@@ -51,19 +51,28 @@ async function offlineLock<T>(dataDir:string,fn:()=>Promise<T>):Promise<T>{
try{await handle.writeFile(`maintenance:${process.pid}`);return await fn();}
finally{await handle.close();await unlink(lockPath);}
}
export async function createBackup(dataDir:string,destination:string){
const source=await realpath(dataDir),dest=resolve(destination);
/** Run a pair-backup operation while holding the exact exclusive lock used by node startup. */
export async function withStoppedNodeLock<T>(dataDir:string,operation:(source:string)=>Promise<T>):Promise<T>{
const source=await realpath(dataDir);
return offlineLock(source,()=>operation(source));
}
/** Snapshot the local SQLite half while the caller holds withStoppedNodeLock(). */
export async function createNodeSnapshotUnlocked(source:string,destination:string,allowPostgresBinding=false){
const dest=resolve(destination);
const rel=relative(source,dest);
if(!rel||(!rel.startsWith('..')&&!isAbsolute(rel)))throw new Error('Backup destination must be outside the node data directory');
return offlineLock(source,async()=>{
// File-only backups cannot represent an external financial database. Fail rather than omit liabilities.
try { await lstat(join(source,PROFILE,'custody-postgres.json')); throw new Error('PostgreSQL custody requires coordinated database and node backup; SQLite-only backup refused'); }
catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
// File-only backups cannot represent an external financial database. Fail rather than omit liabilities.
try {
await lstat(join(source,PROFILE,'custody-postgres.json'));
if(!allowPostgresBinding)throw new Error('PostgreSQL custody requires coordinated database and node backup; SQLite-only backup refused');
} catch(e) { if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e; }
if(await realpath(join(source,PROFILE))!==join(source,PROFILE))throw new Error('Network data directory must not be symlinked');
await mkdir(dest,{mode:0o700}); // Exclusive: never overwrite or recursively create a surprising destination.
const entries=[];
for(const name of files){
const src=join(source,PROFILE,name),target=join(dest,name);
// PostgreSQL is authoritative after cutover. Do not reinterpret or copy the frozen SQLite source as current custody.
if(name==='custody-sandbox.sqlite'&&allowPostgresBinding)continue;
if(name==='custody-sandbox.sqlite'){
try{await lstat(src);}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')continue;throw e;}
}
@@ -82,7 +91,9 @@ export async function createBackup(dataDir:string,destination:string){
await flush(join(dest,'manifest.json'));
await flushDirectory(dest);
return manifest;
});
}
export async function createBackup(dataDir:string,destination:string){
return withStoppedNodeLock(dataDir,source=>createNodeSnapshotUnlocked(source,destination));
}
export async function verifyBackup(directory:string){
const root=await realpath(directory),manifestFile=join(root,'manifest.json');