feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -39,6 +39,41 @@ async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode
|
||||
async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;}
|
||||
function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});}
|
||||
function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}}
|
||||
// The rollback build must leave every additive T-Bank dataset and work intent intact.
|
||||
function seedTbank(owner) {
|
||||
const db = new Database(file);
|
||||
const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision;
|
||||
const now = Date.now();
|
||||
const scope = {userId:owner,revision,accountId:'durable-account'};
|
||||
const exact = {currency:'RUB',value:'-9007199254740993.123456789'};
|
||||
const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map(key=>`"${key}"`).join(',')}) VALUES (${Object.keys(row).map(()=>'?').join(',')})`).run(...Object.values(row));
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now,owner);
|
||||
insert('TbankAccount',{...scope,type:'BROKERAGE',name:'Synthetic persisted account',upstreamOrder:0});
|
||||
insert('TbankPortfolio',{...scope,payload:JSON.stringify({positions:[],cashBalances:[],totalsByCurrency:[],structure:{currencies:[],unvaluedCategories:[]}}),fetchedAt:now});
|
||||
insert('TbankOperation',{...scope,brokerOperationId:'durable-income',occurredAt:now,category:'income',payload:JSON.stringify({id:'durable-income',payment:exact})});
|
||||
insert('TbankOperationCoverage',{...scope,from:now-1000,to:now+1000,fetchedAt:now,asOf:now});
|
||||
insert('TbankForecast',{userId:owner,revision,scopeKey:'durable-forecast',membershipRevision:1,payload:JSON.stringify({incomeTotalsByCurrency:[exact]}),dependencyRevisions:JSON.stringify({portfolio:1}),sourceTimestamps:JSON.stringify({portfolio:new Date(now).toISOString()}),calculatedAt:now});
|
||||
insert('TbankInstrument',{provider:'tbank',uid:'durable-instrument',instrumentType:'bond',metadata:JSON.stringify({name:'Synthetic'}),fetchedAt:now});
|
||||
insert('TbankCatalogDiscovery',{provider:'tbank',instrumentType:'bond',query:'SYN',instrumentUids:JSON.stringify(['durable-instrument']),fetchedAt:now});
|
||||
insert('TbankInstrumentLookup',{provider:'tbank',identifierType:'ticker',identifier:'SYN',matchedUids:JSON.stringify(['durable-instrument']),fetchedAt:now});
|
||||
insert('TbankInstrumentSource',{provider:'tbank',instrumentUid:'durable-instrument',source:'tbank',kind:'coupons',rangeKey:'durable',payload:JSON.stringify({events:[]}),fetchedAt:now});
|
||||
insert('TbankCandle',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',time:now,close:exact.value,payload:JSON.stringify({close:exact.value}),isComplete:1,fetchedAt:now});
|
||||
insert('TbankCandleCoverage',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',from:now-1000,to:now+1000,isComplete:1,fetchedAt:now});
|
||||
insert('TbankSyncWork',{id:'durable-work',userId:owner,revision,scopeKey:'durable-intent',intent:JSON.stringify({from:now-1000,to:now+1000}),state:'running',attemptId:'stopped-attempt',processRunId:'stopped-process',priority:0,progress:JSON.stringify({completed:1}),createdAt:now,updatedAt:now});
|
||||
insert('TbankSyncStage',{workId:'durable-work',attemptId:'stopped-attempt',sourceKey:'durable',itemKey:'next-row',payload:JSON.stringify({payment:exact})});
|
||||
})();
|
||||
db.close();
|
||||
}
|
||||
function tbankSnapshot() {
|
||||
const db = new Database(file,{readonly:true});
|
||||
db.defaultSafeIntegers(true);
|
||||
try {
|
||||
assert.equal(db.pragma('integrity_check',{simple:true}),'ok');
|
||||
assert.deepEqual(db.pragma('foreign_key_check'),[]);
|
||||
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'Tbank%' OR name='_prisma_migrations') ORDER BY name").all().map(({name})=>[name,db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()]));
|
||||
} finally {db.close();}
|
||||
}
|
||||
(async()=>{
|
||||
try{
|
||||
migrate(baseline); await start(baseline);
|
||||
@@ -62,6 +97,7 @@ function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeI
|
||||
await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204);
|
||||
card=await http(`bank-cards/${card.id}`,'GET',null,token);
|
||||
card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token);
|
||||
seedTbank(owner); const tbankBefore=tbankSnapshot();
|
||||
const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')};
|
||||
await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;
|
||||
assert.deepEqual(await http('bank-cards','POST',intent,token,201),card);
|
||||
@@ -69,17 +105,18 @@ function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeI
|
||||
await http('bank-cards/operations','POST',deletedIntent,token,410);
|
||||
assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);
|
||||
await stop();
|
||||
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||
assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||
const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close();
|
||||
await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop();
|
||||
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||
assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
|
||||
const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored;
|
||||
assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||
assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories);
|
||||
assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id));
|
||||
assert.deepEqual(tbankSnapshot(),tbankBefore);
|
||||
const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close();
|
||||
await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop();
|
||||
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
|
||||
writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore','foreign key and integrity checks after each boundary','baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2));
|
||||
console.log(`PASS durability ${dir}`);
|
||||
}finally{await stop();}
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
|
||||
Reference in New Issue
Block a user