Compare commits
4
Commits
b9b76bab91
..
v2.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d50db7903 | ||
|
|
8d5569a3c7 | ||
|
|
90486d4237 | ||
|
|
52fa3a36ba |
@@ -34,7 +34,11 @@ jobs:
|
|||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
run: |
|
run: |
|
||||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||||
|
else
|
||||||
|
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
fi
|
||||||
|
|
||||||
security:
|
security:
|
||||||
needs: commits
|
needs: commits
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
|
||||||
|
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
|
||||||
|
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
|
||||||
|
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
|
||||||
|
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
|
||||||
|
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
|
||||||
|
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
|
||||||
|
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
|
||||||
|
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
|
||||||
|
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
|
||||||
|
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
|
||||||
|
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
|
||||||
|
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
|
||||||
|
|
||||||
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
||||||
|
|
||||||
### ⚠ BREAKING CHANGES
|
### ⚠ BREAKING CHANGES
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.5",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const WORK_POLL_MAX_MS = 5_000;
|
|||||||
// ponytail: fixed 15-minute step wait bound (900_000 ms), no config knob;
|
// ponytail: fixed 15-minute step wait bound (900_000 ms), no config knob;
|
||||||
// make it configurable if slow steps become regular.
|
// make it configurable if slow steps become regular.
|
||||||
const WORK_WAIT_BOUND_MS = 900_000;
|
const WORK_WAIT_BOUND_MS = 900_000;
|
||||||
|
// An await that outlived WORK_WAIT_BOUND_MS. The underlying call keeps
|
||||||
|
// running; its late durable result is fenced like any other late publication.
|
||||||
|
const BOUND_EXPIRED = Symbol('hourly-bound-expired');
|
||||||
const FIRST_DAY_OF_MONTH = 1;
|
const FIRST_DAY_OF_MONTH = 1;
|
||||||
const NO_MILLISECONDS = 0;
|
const NO_MILLISECONDS = 0;
|
||||||
|
|
||||||
@@ -155,7 +158,10 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
);
|
);
|
||||||
await this.waitWorks(userId, children.map((child) => child.id));
|
await this.waitWorks(userId, children.map((child) => child.id));
|
||||||
for (const account of accounts) {
|
for (const account of accounts) {
|
||||||
await this.events.get(userId, account.accountId).catch(() => undefined);
|
const pending = this.events
|
||||||
|
.get(userId, account.accountId)
|
||||||
|
.catch(() => undefined);
|
||||||
|
await this.withBound(pending);
|
||||||
}
|
}
|
||||||
await this.settle(attempt, null, dispatchProblems);
|
await this.settle(attempt, null, dispatchProblems);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -173,11 +179,14 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
) {
|
) {
|
||||||
const { userId, revision } = attempt;
|
const { userId, revision } = attempt;
|
||||||
const member = await this.membership.request(userId, revision, true);
|
const member = await this.withBound(
|
||||||
if (member) {
|
this.membership.request(userId, revision, true),
|
||||||
|
);
|
||||||
|
if (member && member !== BOUND_EXPIRED) {
|
||||||
await dispatch({ id: member.id, section: 'membership' });
|
await dispatch({ id: member.id, section: 'membership' });
|
||||||
await member.done;
|
const done = await this.withBound(member.done);
|
||||||
if (
|
if (
|
||||||
|
done !== BOUND_EXPIRED &&
|
||||||
(await this.repository.workError(userId, member.id)) ===
|
(await this.repository.workError(userId, member.id)) ===
|
||||||
TbankErrorCode.InvalidToken
|
TbankErrorCode.InvalidToken
|
||||||
) {
|
) {
|
||||||
@@ -185,19 +194,45 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// No durable step id (rejected request or an expired wait): record the
|
||||||
|
// observable problem; a late row stays fenced by its durable attempt id.
|
||||||
dispatchProblems.push({
|
dispatchProblems.push({
|
||||||
section: 'membership',
|
section: 'membership',
|
||||||
errorCode: TbankErrorCode.Unavailable,
|
errorCode: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const current = await this.access.local(userId);
|
const current = await this.withBound(this.access.local(userId));
|
||||||
if (current.revision !== revision) {
|
if (current === BOUND_EXPIRED || current.revision !== revision) {
|
||||||
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
|
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return current.accounts;
|
return current.accounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Races a step await against the same per-step bound the row wait uses, so
|
||||||
|
// a dispatch that never resolves cannot hold the attempt running forever.
|
||||||
|
// The call itself is not cancelled; a rejection landing after the expiry is
|
||||||
|
// swallowed here because the expiry already accounts the step as a problem.
|
||||||
|
private withBound<T>(step: Promise<T>): Promise<T | typeof BOUND_EXPIRED> {
|
||||||
|
step.catch(() => undefined);
|
||||||
|
return new Promise<T | typeof BOUND_EXPIRED>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => resolve(BOUND_EXPIRED),
|
||||||
|
WORK_WAIT_BOUND_MS,
|
||||||
|
);
|
||||||
|
step.then(
|
||||||
|
(value) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(value);
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(error instanceof Error ? error : new Error(String(error)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async refreshAccounts(
|
private async refreshAccounts(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
@@ -241,8 +276,10 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const work = await this.portfolio.request(userId, revision, accountId, true);
|
const work = await this.withBound(
|
||||||
if (work) {
|
this.portfolio.request(userId, revision, accountId, true),
|
||||||
|
);
|
||||||
|
if (work && work !== BOUND_EXPIRED) {
|
||||||
await dispatch({ accountId, id: work.id, section: 'portfolio' });
|
await dispatch({ accountId, id: work.id, section: 'portfolio' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -264,27 +301,33 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const id = await this.operations.request(
|
const id = await this.withBound(
|
||||||
userId,
|
this.operations.request(
|
||||||
[
|
userId,
|
||||||
{
|
[
|
||||||
accountId,
|
{
|
||||||
from: monthStartOf(new Date()),
|
accountId,
|
||||||
to: new Date(),
|
from: monthStartOf(new Date()),
|
||||||
},
|
to: new Date(),
|
||||||
],
|
},
|
||||||
true,
|
],
|
||||||
'screen',
|
true,
|
||||||
revision,
|
'screen',
|
||||||
|
revision,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (id) await dispatch({ accountId, id, section: 'operations' });
|
if (id && id !== BOUND_EXPIRED) {
|
||||||
|
await dispatch({ accountId, id, section: 'operations' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
dispatchProblems.push({
|
// A failed dispatch has no durable step row; record the problem.
|
||||||
accountId,
|
|
||||||
errorCode: TbankErrorCode.Unavailable,
|
|
||||||
section: 'operations',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
dispatchProblems.push({
|
||||||
|
accountId,
|
||||||
|
errorCode: TbankErrorCode.Unavailable,
|
||||||
|
section: 'operations',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dispatchEvents(
|
private async dispatchEvents(
|
||||||
@@ -294,17 +337,21 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
|
|||||||
dispatchProblems: HourlySection[],
|
dispatchProblems: HourlySection[],
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const ids = await this.events.refresh(userId, accountId);
|
const ids = await this.withBound(this.events.refresh(userId, accountId));
|
||||||
for (const id of ids) {
|
if (ids !== BOUND_EXPIRED) {
|
||||||
await dispatch({ accountId, id, section: 'events' });
|
for (const id of ids) {
|
||||||
|
await dispatch({ accountId, id, section: 'events' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
dispatchProblems.push({
|
// A failed dispatch has no durable step row; record the problem.
|
||||||
accountId,
|
|
||||||
errorCode: TbankErrorCode.Unavailable,
|
|
||||||
section: 'events',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
dispatchProblems.push({
|
||||||
|
accountId,
|
||||||
|
errorCode: TbankErrorCode.Unavailable,
|
||||||
|
section: 'events',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async settle(
|
private async settle(
|
||||||
|
|||||||
@@ -96,6 +96,19 @@ describe('hourly attempt lifecycle (SQLite)', () => {
|
|||||||
throw new Error('hourly attempt did not settle');
|
throw new Error('hourly attempt did not settle');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sequential per-step bounds: advance past each bound until the attempt
|
||||||
|
// closes. Covers walks whose awaits hang before any durable step row exists.
|
||||||
|
async function runBoundedWalk(scheduler: TbankHourlyScheduler) {
|
||||||
|
await scheduler.tick();
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
await pump(5);
|
||||||
|
await jest.advanceTimersByTimeAsync(16 * 60 * 1000);
|
||||||
|
const row = await hourlyRow();
|
||||||
|
if (row && row.state !== 'running') return row;
|
||||||
|
}
|
||||||
|
throw new Error('hourly attempt did not settle');
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.$connect();
|
await prisma.$connect();
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
@@ -259,6 +272,50 @@ describe('hourly attempt lifecycle (SQLite)', () => {
|
|||||||
assert.ok(stuck, 'the wait bound does not cancel the upstream call');
|
assert.ok(stuck, 'the wait bound does not cancel the upstream call');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('settles the attempt when a portfolio dispatch never resolves', async () => {
|
||||||
|
portfolio.request.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
const row = await runBoundedWalk(createScheduler());
|
||||||
|
const progress = row.progress as HourlyProgressView;
|
||||||
|
expect(progress.result).toBe('partial');
|
||||||
|
const problems = progress.sections.filter(
|
||||||
|
(entry) => entry.section === 'portfolio',
|
||||||
|
);
|
||||||
|
expect(problems).toHaveLength(2);
|
||||||
|
expect(problems[0].errorCode).toBe('TBANK_UNAVAILABLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles the attempt when the membership dispatch never resolves', async () => {
|
||||||
|
membership.request.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
const row = await runBoundedWalk(createScheduler());
|
||||||
|
const progress = row.progress as HourlyProgressView;
|
||||||
|
expect(progress.result).toBe('partial');
|
||||||
|
expect(progress.sections).toHaveLength(1);
|
||||||
|
expect(progress.sections[0].section).toBe('membership');
|
||||||
|
expect(progress.sections[0].errorCode).toBe('TBANK_UNAVAILABLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds a membership step promise that never resolves', async () => {
|
||||||
|
membership.request.mockImplementation(async () => {
|
||||||
|
const row = await child('running', null, 'membership');
|
||||||
|
return { id: row.id, done: new Promise(() => undefined) };
|
||||||
|
});
|
||||||
|
const row = await runBoundedWalk(createScheduler());
|
||||||
|
const progress = row.progress as HourlyProgressView;
|
||||||
|
expect(progress.result).toBe('partial');
|
||||||
|
const membershipSection = progress.sections.find(
|
||||||
|
(entry) => entry.section === 'membership',
|
||||||
|
);
|
||||||
|
expect(membershipSection?.errorCode).toBe('DEADLINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles the attempt when a post-wait refresh never resolves', async () => {
|
||||||
|
events.get.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
const row = await runBoundedWalk(createScheduler());
|
||||||
|
const progress = row.progress as HourlyProgressView;
|
||||||
|
expect(progress.result).toBe('succeeded');
|
||||||
|
expect(progress.sections).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('recovers a lost settle and unblocks the next attempt at its term', async () => {
|
it('recovers a lost settle and unblocks the next attempt at its term', async () => {
|
||||||
const settleSpy = jest
|
const settleSpy = jest
|
||||||
.spyOn(repository, 'settleHourly')
|
.spyOn(repository, 'settleHourly')
|
||||||
|
|||||||
Reference in New Issue
Block a user