Remove markets

This commit is contained in:
2026-10-28 18:33:52 -04:00
parent eea2deb1fb
commit b3a1179703
141 changed files with 554 additions and 17365 deletions
+29 -44
View File
@@ -6,14 +6,19 @@ import { DatabaseSync } from 'node:sqlite';
import { z } from 'zod';
import { NETWORK, PROFILE } from '../shared/network.ts';
import { CHAIN_ID } from './testnet/consensus.ts';
import { CUSTODY_POLICY } from '../shared/custody.ts';
const files=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const;
const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1),
const ledgerFile='ledger.sqlite';
const legacyFiles=['ledger.sqlite','market.sqlite','custody-sandbox.sqlite'] as const;
const legacyManifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(1),
network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(),
files:z.array(z.object({name:z.enum(files),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict()
files:z.array(z.object({name:z.enum(legacyFiles),bytes:z.number().int().positive().max(64*1024**3),sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()).min(2).max(3)}).strict()
.refine(m => new Set(m.files.map(f=>f.name)).size === m.files.length &&
m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate backup database');
m.files.some(f=>f.name==='ledger.sqlite') && m.files.some(f=>f.name==='market.sqlite'), 'Missing or duplicate legacy backup database');
const manifestSchema=z.object({format:z.literal('wikideck-stopped-backup'),version:z.literal(2),
network:z.literal(NETWORK),chainId:z.literal(CHAIN_ID),profile:z.literal(PROFILE),createdAt:z.string().datetime(),
files:z.tuple([z.object({name:z.literal(ledgerFile),bytes:z.number().int().positive().max(64*1024**3),
sha256:z.string().regex(/^[a-f0-9]{64}$/)}).strict()])}).strict();
const supportedManifestSchema=z.union([manifestSchema,legacyManifestSchema]);
async function regular(path:string){const s=await lstat(path);if(!s.isFile()||s.isSymbolicLink())throw new Error('Expected regular non-symlink database file');return s;}
async function digest(path:string){const hash=createHash('sha256');for await(const chunk of createReadStream(path))hash.update(chunk);return hash.digest('hex');}
async function flush(path:string){const handle=await open(path,'r+');try{await handle.sync();}finally{await handle.close();}}
@@ -21,7 +26,7 @@ async function flushDirectory(path:string){
if(process.platform==='win32')return; // Windows directory durability requires filesystem-specific operational validation.
const handle=await open(path,'r');try{await handle.sync();}finally{await handle.close();}
}
function inspect(file:string,name:typeof files[number]) {
function inspect(file:string,name:string) {
const db=new DatabaseSync(file,{readOnly:true});
try {
const result=db.prepare('PRAGMA quick_check').all();
@@ -29,18 +34,6 @@ function inspect(file:string,name:typeof files[number]) {
if(name==='ledger.sqlite'){
const meta=(key:string)=>(db.prepare('SELECT value FROM metadata WHERE key=?').get(key) as {value:string}|undefined)?.value;
if(meta('schema_version')!=='1'||meta('network')!==NETWORK||meta('chain_id')!==CHAIN_ID)throw new Error('Ledger network/schema mismatch');
}else if(name==='custody-sandbox.sqlite'){
if(db.prepare("SELECT 1 FROM custody_meta WHERE key='postgres_cutover'").get()) {
throw new Error('PostgreSQL cutover source cannot be backed up as current custody; coordinated database backup required');
}
const row=db.prepare("SELECT value FROM custody_meta WHERE key='identity'").get() as {value:string}|undefined;
const identity=row?JSON.parse(row.value):undefined;
if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||identity.mode!=='sandbox'||
identity.policy!==CUSTODY_POLICY||!z.string().uuid().safeParse(identity.id).success)throw new Error('Custody identity mismatch');
}else{
const row=db.prepare("SELECT value FROM market_meta WHERE key='identity'").get() as {value:string}|undefined;
const identity=row?JSON.parse(row.value):undefined;
if(identity?.version!==1||identity.network!==NETWORK||identity.chainId!==CHAIN_ID||!z.string().uuid().safeParse(identity.id).success)throw new Error('Market identity mismatch');
}
}finally{db.close();}
}
@@ -57,35 +50,27 @@ export async function withStoppedNodeLock<T>(dataDir:string,operation:(source:st
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){
export async function createNodeSnapshotUnlocked(source:string,destination:string){
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');
// 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;}
}
await regular(src);inspect(src,name);
const db=new DatabaseSync(src,{readOnly:true});
try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();}
if(process.platform!=='win32')await chmod(target,0o600);
await flush(target);
inspect(target,name);const info=await regular(target);
entries.push({name,bytes:info.size,sha256:await digest(target)});
}
const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:1,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE,
createdAt:new Date().toISOString(),files:entries});
// Never create a misleading ledger-only backup when retired financial journals remain.
for(const name of ['market.sqlite','custody-sandbox.sqlite','custody-postgres.json']){
try { await lstat(join(source,PROFILE,name)); throw new Error(`Legacy financial data (${name}) requires a separately retained pre-retirement backup`); }
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 src=join(source,PROFILE,ledgerFile),target=join(dest,ledgerFile);
await regular(src);inspect(src,ledgerFile);
const db=new DatabaseSync(src,{readOnly:true});
try{db.prepare('VACUUM INTO ?').run(target);}finally{db.close();}
if(process.platform!=='win32')await chmod(target,0o600);
await flush(target);
inspect(target,ledgerFile);const info=await regular(target);
const manifest=manifestSchema.parse({format:'wikideck-stopped-backup',version:2,network:NETWORK,chainId:CHAIN_ID,profile:PROFILE,
createdAt:new Date().toISOString(),files:[{name:ledgerFile,bytes:info.size,sha256:await digest(target)}]});
// Completion marker written last. Partial output is preserved for operator inspection, never treated as usable.
await writeFile(join(dest,'manifest.json'),JSON.stringify(manifest,null,2),{flag:'wx',mode:0o600});
await flush(join(dest,'manifest.json'));
@@ -98,7 +83,7 @@ export async function createBackup(dataDir:string,destination:string){
export async function verifyBackup(directory:string){
const root=await realpath(directory),manifestFile=join(root,'manifest.json');
if((await regular(manifestFile)).size>16384)throw new Error('Backup manifest too large');
const manifest=manifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8')));
const manifest=supportedManifestSchema.parse(JSON.parse(await readFile(manifestFile,'utf8')));
if(new Set(manifest.files.map(f=>f.name)).size!==manifest.files.length)throw new Error('Duplicate backup database');
for(const file of manifest.files){
const path=join(root,file.name),info=await regular(path);