From 203cc72cb2d1aac34f39c5ba528820b511666f17 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 08:12:51 +0300 Subject: [PATCH 01/11] chore: align SQLite driver version (WealthLedger/backend#67) Closes #67 --- package-lock.json | 28 ++++++++-------------------- package.json | 5 ++--- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7fa4121..5aac605 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@prisma/adapter-better-sqlite3": "7.10.0", "@prisma/client": "7.10.0", "bcrypt": "6.0.0", - "better-sqlite3": "13.0.3", + "better-sqlite3": "12.11.1", "cheerio": "1.2.0", "class-transformer": "0.5.1", "class-validator": "0.15.1", @@ -3769,20 +3769,6 @@ "better-sqlite3": "^12.6.0" } }, - "node_modules/@prisma/adapter-better-sqlite3/node_modules/better-sqlite3": { - "version": "12.11.1", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", - "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" - } - }, "node_modules/@prisma/client": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz", @@ -6282,15 +6268,17 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "13.0.3", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", - "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", + "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==", + "hasInstallScript": true, "license": "MIT", "dependencies": { - "node-addon-api": "^8.0.0" + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" }, "engines": { - "node": ">=22" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { diff --git a/package.json b/package.json index 5da73b0..95ca29e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@prisma/adapter-better-sqlite3": "7.10.0", "@prisma/client": "7.10.0", "bcrypt": "6.0.0", - "better-sqlite3": "13.0.3", + "better-sqlite3": "12.11.1", "cheerio": "1.2.0", "class-transformer": "0.5.1", "class-validator": "0.15.1", @@ -144,7 +144,6 @@ "better-sqlite3@12.11.1": true, "fsevents@2.3.3": true, "protobufjs@7.6.5": true, - "unrs-resolver@1.12.2": true, - "better-sqlite3@13.0.3": true + "unrs-resolver@1.12.2": true } } From 1033d2114e99232a90c5d5c321749a9c6e3fbff0 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 08:34:37 +0300 Subject: [PATCH 02/11] test: slim sqlite runner regression workload (WealthLedger/backend#72) Closes #72 --- README.md | 5 +++-- test/sqlite-regression.cjs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ea8c573..3d77af0 100644 --- a/README.md +++ b/README.md @@ -248,8 +248,9 @@ breaking changes относительно предыдущего выпуска. настраивается отдельно. История migrations применяется штатным `migrate deploy`. Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет -три последовательных и два параллельных полных e2e, проверяет уникальность DB, -сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup. +три последовательных и два параллельных запуска двух обычных e2e suites, +проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration +и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`. Логи остаются в каталоге из вывода. Для одной suite: `node test/sqlite.cjs app.e2e-spec.ts`. diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index 13e1298..2b1ad0c 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -18,7 +18,7 @@ writeFileSync(sentinel, 'fictional user database: must never be opened'); const before = readFileSync(sentinel); const databases = new Set(); async function check(name, overrides = {}, signal) { - const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], { + const child = spawn(process.execPath, ['test/sqlite.cjs'], { cwd, env: { ...process.env, From 285dd08472d9acb66fab8d46d511d9ce607b110f Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 08:48:37 +0300 Subject: [PATCH 03/11] perf: batch bank operation page relations (WealthLedger/backend#68) Closes #68 --- .../application/bank-operations.service.ts | 99 ++++++++++++++++++- 1 file changed, 96 insertions(+), 3 deletions(-) diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 870748b..e562780 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -91,6 +91,101 @@ export class BankOperationsService { }; } + private async pageViews(tx: Tx, rows: BankCardOperation[]) { + const userId = rows[0]?.userId; + if (!userId) return []; + const cardIds = new Set( + rows.flatMap((row) => + [row.cardId, row.targetCardId].filter( + (id): id is string => id !== null, + ), + ), + ); + const sourceIds = new Set( + rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])), + ); + const [cards, sources] = await Promise.all([ + tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }), + tx.bankCardOperation.findMany({ + where: { userId, id: { in: [...sourceIds] } }, + }), + ]); + const categoryIds = new Set( + rows + .map( + (row) => + sources.find((source) => source.id === row.originalExpenseId) + ?.categoryId ?? row.categoryId, + ) + .filter((id): id is string => id !== null), + ); + const expenseIds = new Set( + rows.flatMap((row) => { + const source = sources.find((item) => item.id === row.originalExpenseId); + return row.kind === BankOperationKind.Expense || source + ? [source?.id ?? row.id] + : []; + }), + ); + const [categories, refunds] = await Promise.all([ + tx.expenseCategory.findMany({ + where: { userId, id: { in: [...categoryIds] } }, + }), + tx.bankCardOperation.findMany({ + where: { + userId, + originalExpenseId: { in: [...expenseIds] }, + deletedAt: null, + }, + }), + ]); + const cardsById = new Map(cards.map((card) => [card.id, card])); + const sourcesById = new Map(sources.map((source) => [source.id, source])); + const categoriesById = new Map(categories.map((category) => [category.id, category])); + return rows.map((row) => { + const card = cardsById.get(row.cardId) as { name: string }; + const target = row.targetCardId ? cardsById.get(row.targetCardId) : null; + const source = row.originalExpenseId + ? sourcesById.get(row.originalExpenseId) + : null; + const categoryId = source?.categoryId ?? row.categoryId; + const relatedRefunds = + row.kind === BankOperationKind.Expense || source + ? refunds.filter( + (refund) => + refund.originalExpenseId === (source?.id ?? row.id) && + (!source || refund.id !== row.id), + ) + : []; + return { + id: row.id, + kind: row.kind as BankOperationDto['kind'], + cardId: row.cardId, + cardName: card.name, + targetCardId: row.targetCardId, + targetCardName: target?.name ?? null, + categoryId, + categoryName: categoriesById.get(categoryId ?? '')?.name ?? null, + originalExpenseId: row.originalExpenseId, + amount: rubMoney(row.amountKopecks), + availableRefund: + row.kind === BankOperationKind.Expense || source + ? rubMoney( + (source?.amountKopecks ?? row.amountKopecks) - + relatedRefunds.reduce( + (sum, refund) => sum + refund.amountKopecks, + ZERO_KOPECKS, + ), + ) + : null, + date: row.date, + description: row.description, + version: row.version, + createdAt: row.createdAt.toISOString(), + }; + }); + } + private async validateOperation( tx: Tx, userId: string, @@ -359,9 +454,7 @@ export class BankOperationsService { take: limit, }); return { - items: await Promise.all( - rows.map((row) => this.operationView(tx, row)), - ), + items: await this.pageViews(tx, rows), total, page, hasNext: page * limit < total, From bbb5afff842720374feecfcd74eea83283e0b2ae Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 09:02:53 +0300 Subject: [PATCH 04/11] ci: validate edited PR titles (WealthLedger/backend#74) Closes #74 --- .gitea/workflows/verify.yml | 6 ++++-- README.md | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index c5fd66b..d1d3318 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -4,7 +4,7 @@ on: push: branches: [develop, main] pull_request: - types: [opened, synchronize, reopened] + types: [opened, synchronize, reopened, edited] schedule: - cron: '47 5 * * *' @@ -40,6 +40,7 @@ jobs: needs: commits if: >- !cancelled() && + gitea.event.action != 'edited' && (needs.commits.result == 'success' || needs.commits.result == 'skipped') timeout-minutes: 15 runs-on: ubuntu-latest @@ -67,7 +68,8 @@ jobs: verify: needs: [commits, security] if: >- - !cancelled() && needs.security.result == 'success' && + !cancelled() && gitea.event.action != 'edited' && + needs.security.result == 'success' && gitea.event_name != 'schedule' timeout-minutes: 45 runs-on: ubuntu-latest diff --git a/README.md b/README.md index 3d77af0..3d730e0 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` последовательно на PR; на push в `develop`/`main` — `security → verify`. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. -Изменение заголовка PR тоже повторяет проверки Conventional Commits. +Изменение заголовка PR повторяет только проверку Conventional Commits; `security` +и `verify` на metadata-only событии не запускаются. Новый запуск отменяет устаревший для того же PR/ветки и типа события (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. Job `verify` запускает `npm ci`, генерацию Prisma client, From da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 14:04:07 +0300 Subject: [PATCH 05/11] perf: start bond search prices with details (WealthLedger/backend#71) (#89) --- .../application/catalog-discovery.service.ts | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index 93901be..f1c62c4 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -304,16 +304,18 @@ export class CatalogDiscoveryService { input.query, input.instrumentType, ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); - const details = await Promise.all( - instruments.map((item) => - input.instrumentType === 'share' - ? this.tbank.getShare(token, item.uid) - : this.tbank.getBond(token, item.uid), + const [details, prices] = await Promise.all([ + Promise.all( + instruments.map((item) => + input.instrumentType === 'share' + ? this.tbank.getShare(token, item.uid) + : this.tbank.getBond(token, item.uid), + ), ), - ); - const prices = instruments.length - ? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) - : []; + instruments.length + ? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) + : [], + ]); const fetchedAt = new Date(); const data = instruments.flatMap((item, index) => { const detail = details.at(index); From b90cedd580321fb560a4ad0d9f9f5ba03303248e Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 17:01:53 +0300 Subject: [PATCH 06/11] chore: unify verification preparation (WealthLedger/backend#66) --- .gitea/workflows/image.yml | 3 +-- .gitea/workflows/verify.yml | 4 +--- Dockerfile | 3 ++- README.md | 13 +++++++++---- package.json | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.gitea/workflows/image.yml b/.gitea/workflows/image.yml index 15ed222..3e9dc7a 100644 --- a/.gitea/workflows/image.yml +++ b/.gitea/workflows/image.yml @@ -28,9 +28,8 @@ jobs: docker version docker buildx version - run: npm ci - - run: npx --no-install prisma generate - run: npm run verify - - run: npm run test:e2e + - run: node test/sqlite.cjs --all - name: Login with dedicated Nexus writer env: REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }} diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index d1d3318..2280f3c 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -83,12 +83,10 @@ jobs: with: node-version-file: .nvmrc - run: npm ci - - name: Generate Prisma client before typechecks - run: npx --no-install prisma generate - name: Run all backend gates run: npm run verify - name: Run isolated SQLite and runtime OpenAPI suites - run: npm run test:e2e + run: node test/sqlite.cjs --all - name: Analyze develop with SonarQube if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop' # v2.3.0: same Docker-based scanner used for SonarQube 9.9. diff --git a/Dockerfile b/Dockerfile index 56f6250..9dd0866 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,8 @@ EXPOSE 3000 CMD ["npm", "run", "start:dev"] FROM dependencies AS build -RUN npm run build && npm prune --omit=dev --omit=optional +# The client is already generated at the shared dependencies stage (same revision). +RUN npx --no-install nest build && npm prune --omit=dev --omit=optional FROM base AS production RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \ diff --git a/README.md b/README.md index 3d730e0..64221dc 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ cp .env.example .env nvm use --silent npm ci npx prisma migrate deploy +npx prisma generate npm run start:dev ``` @@ -60,7 +61,7 @@ Compose topology принадлежит workspace, эксплуатационн npm run verify ``` -Он запускает zero-warning lint, +Он запускает генерацию Prisma client, zero-warning lint, application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная записывающая команда. `npm run test:cov` также доступен отдельно; общего числового threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites @@ -84,8 +85,9 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и и `verify` на metadata-only событии не запускаются. Новый запуск отменяет устаревший для того же PR/ветки и типа события (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. -Job `verify` запускает `npm ci`, генерацию Prisma client, -`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout +Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client +внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all` +на уже собранных артефактах. Используется только checkout backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. @@ -253,7 +255,10 @@ breaking changes относительно предыдущего выпуска. проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`. Логи остаются в каталоге из вывода. Для одной suite: -`node test/sqlite.cjs app.e2e-spec.ts`. +`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в +`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед +адресным запуском этой suite выполните `npm run build`, иначе child/dist может +расходиться с parent/src. | Риск | Проверка реальной DB | | --- | --- | diff --git a/package.json b/package.json index 95ca29e..ddcfd8c 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", - "start:debug": "nest start --debug --watch --path tsconfig.dev.json", + "start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json", "start:prod": "node dist/src/main.js", "lint": "npm run lint:check", "lint:check": "eslint . --max-warnings=0", @@ -31,7 +31,7 @@ "test:container": "node test/container-smoke.cjs", "test:durability": "node test/bank-cards-durability.cjs", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", - "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", + "verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build", "release:preview": "release-it --dry-run --ci", "release:prepare": "release-it --ci", "commitlint": "commitlint", From 14bcb9da5ff081710d672d140175d348a03a2fe5 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 18:00:20 +0300 Subject: [PATCH 07/11] fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58) --- .../brokerage-accounts.service.ts | 3 +- .../income/income-analytics.service.ts | 1 + .../brokerage-operations.service.ts | 2 + .../tbank-sync/tbank-sync.repository.ts | 5 +- test/tbank-income-local.e2e-spec.ts | 4 +- test/tbank-local-reads.e2e-spec.ts | 69 +++++++++++++++++++ 6 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 9a003ca..8ad93bd 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -3,7 +3,7 @@ import { createHash } from 'node:crypto'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { PortfolioService } from '../portfolio/portfolio.api'; -import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; +import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto'; @Injectable() @@ -61,6 +61,7 @@ export class BrokerageAccountsService { : null, freshForMs: 3_600_000, work: null, + lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']), dependencies: saved.accounts.map((account) => account.meta), }), sync: hourly diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index e654c62..0e4f4bd 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -98,6 +98,7 @@ export class IncomeAnalyticsService { work: work.id ? { id: work.id, state: work.state, errorCode: work.errorCode } : null, + lastAttempt: current.meta.lastAttempt, coverage: current.meta.coverage, dependencies, }), diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 193cfd0..2a1ef46 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -435,6 +435,7 @@ export class BrokerageOperationsService { saved = await this.repository.readPage(input); } const now = new Date(); + const hourly = await this.hourlySync.hourly(userId); const membership = investmentResourceMeta({ resourceKey: 'accounts/membership', dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), @@ -503,6 +504,7 @@ export class BrokerageOperationsService { fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, work, + lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId), dependencies: [membership], coverage: { requested: targets.map(serialize), diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index cebd86f..b10fbcc 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -43,16 +43,17 @@ type HourlyAttempt = { export function hourlyAttempt( hourly: HourlyAttempt | null, - section: HourlySection['section'], + section: HourlySection['section'] | readonly HourlySection['section'][], accountId?: string, ): InvestmentAttemptDto { if (!hourly) { return { state: 'none', startedAt: null, finishedAt: null, problems: [] }; } + const sections = Array.isArray(section) ? section : [section]; const problems = hourly.sections .filter( (entry) => - entry.section === section && + sections.includes(entry.section) && (accountId === undefined || entry.accountId === accountId), ) .map((entry) => ({ diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 2e16feb..121ebab 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -266,7 +266,9 @@ describe('local income completeness and exact money (SQLite)', () => { await repo.fail(fence, false); const after = await read(); expect(after.data).toEqual(before.data); - expect(after.meta.lastAttempt.state).toBe('failed'); + // Without an hourly walk the durable attempt is absent; the failed + // source work stays a technical observation, not the hourly attempt. + expect(after.meta.lastAttempt.state).toBe('none'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('retains current-month observed completeness and does not renew source timestamps', async () => { diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index a90401a..bdea5a7 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -15,6 +15,7 @@ import { } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; +import { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; import { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.service'; import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; import { PrismaService } from '../src/prisma/prisma.service'; @@ -325,6 +326,74 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { expect(upstream.getPortfolio).toHaveBeenCalledTimes(1); }); + it('reports the durable hourly attempt in accounts and income meta with section relevance', async () => { + await put().expect(200); + await seedMembership(); + await requestPortfolio('account'); + await settled(); + const connection = await prisma.tbankConnection.findUniqueOrThrow({ + where: { userId: 'owner' }, + }); + const startedAt = new Date('2026-10-10T10:00:00.000Z'); + const finishedAt = new Date('2026-10-10T10:05:00.000Z'); + await prisma.tbankSyncWork.create({ + data: { + id: 'hourly-attempt', + userId: 'owner', + revision: connection.revision, + scopeKey: 'hourly', + intent: {}, + state: 'complete', + attemptId: 'hourly-attempt', + processRunId: 'test', + priority: 0, + progress: { + result: 'partial', + sections: [ + { + section: 'portfolio', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + { + section: 'operations', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ], + }, + createdAt: startedAt, + updatedAt: finishedAt, + }, + }); + const accounts = await get('brokerage-accounts').expect(200); + const accountsMeta = (accounts.body as BrokerageAccountsResourceDto).meta; + expect(accountsMeta.lastAttempt.state).toBe('partial'); + expect(accountsMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); + expect(accountsMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); + expect(accountsMeta.lastAttempt.problems).toEqual([ + { + section: 'portfolio', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ]); + const income = await get( + 'brokerage-accounts/account/income-analytics?sync=observe', + ).expect(200); + const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta; + expect(incomeMeta.lastAttempt.state).toBe('partial'); + expect(incomeMeta.lastAttempt.startedAt).toBe(startedAt.toISOString()); + expect(incomeMeta.lastAttempt.finishedAt).toBe(finishedAt.toISOString()); + expect(incomeMeta.lastAttempt.problems).toEqual([ + { + section: 'operations', + accountId: 'account', + errorCode: 'TBANK_UNAVAILABLE', + }, + ]); + }); + it('recovers abandoned discovery only on a data request and coalesces concurrent reads', async () => { await put().expect(200); const connection = await prisma.tbankConnection.update({ From 6975853656698b61781ca056f35c0d68e47ea32a Mon Sep 17 00:00:00 2001 From: codex-bot Date: Wed, 14 Oct 2026 22:43:08 +0300 Subject: [PATCH 08/11] fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54) --- .../tbank-sync/tbank-sync.repository.ts | 14 +++- test/tbank-hourly-lifecycle.e2e-spec.ts | 77 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index b10fbcc..dbeab81 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -183,7 +183,8 @@ export class TbankSyncRepository { // The timer only proposes work; this reservation is the durable fence. The // attempt start (createdAt) is the basis of the next hourly term, including - // failed and interrupted attempts. + // failed and interrupted attempts. After downtime the next tick is due at + // once, so no catch-up queue forms. reserveHourly(userId: string, runId: string, hourMs: number) { return this.prisma.$transaction(async (tx) => { const connection = await tx.tbankConnection.findFirst({ @@ -201,7 +202,16 @@ export class TbankSyncRepository { }); const now = new Date(); if (previous) { - const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs; + // Terms are hourMs steps from the attempt start; a walk still running + // at a term consumes it. A dead process's attempt has no known end, so + // it consumes only its own term. + const started = previous.createdAt.valueOf(); + const ended = + previous.state === 'running' ? started : previous.updatedAt.valueOf(); + const dueAt = + started + + Math.max(hourMs, Math.ceil((ended - started) / hourMs) * hourMs); + const due = now.valueOf() >= dueAt; if (previous.state === 'running') { if (previous.processRunId === runId) return null; // A dead process cannot finish its attempt; close it from its diff --git a/test/tbank-hourly-lifecycle.e2e-spec.ts b/test/tbank-hourly-lifecycle.e2e-spec.ts index 08a1509..b9bdae5 100644 --- a/test/tbank-hourly-lifecycle.e2e-spec.ts +++ b/test/tbank-hourly-lifecycle.e2e-spec.ts @@ -385,6 +385,83 @@ describe('hourly attempt lifecycle (SQLite)', () => { expect(next?.state).not.toBe('running'); }); + describe('hourly term eligibility', () => { + const at = (h: number, m = 0) => new Date(Date.UTC(2026, 0, 1, h, m)); + const seed = ( + state: 'complete' | 'failed' | 'running', + start: Date, + end: Date, + processRunId = 'other-process', + ) => + prisma.tbankSyncWork.create({ + data: { + id: randomUUID(), + userId: USER, + revision: REVISION, + scopeKey: 'hourly', + attemptId: randomUUID(), + processRunId, + priority: 0, + intent: {}, + progress: {}, + state, + createdAt: start, + updatedAt: end, + }, + }); + const reserve = async (when: Date) => { + jest.setSystemTime(when); + return repository.reserveHourly(USER, 'this-process', 3_600_000); + }; + + it.each([ + ['short attempt', 'complete', at(10), at(10, 5), at(10, 59), at(11)], + ['failed attempt', 'failed', at(10), at(10, 5), at(10, 59), at(11)], + ['walk past one term', 'complete', at(10), at(11, 10), at(11, 59), at(12)], + ['walk past two terms', 'complete', at(10), at(12, 10), at(12, 59), at(13)], + ['walk ending on a term', 'complete', at(10), at(11), at(10, 59), at(11)], + ] as const)('%s', async (_name, state, start, end, early, due) => { + await seed(state, start, end); + expect(await reserve(early)).toBeNull(); + const reserved = await reserve(due); + expect(reserved?.work.state).toBe('running'); + expect(reserved?.work.createdAt).toEqual(due); + }); + + it('starts a new revision at once', async () => { + await seed('complete', at(10), at(10, 5)); + await prisma.tbankConnection.deleteMany({ where: { userId: USER } }); + await prisma.tbankConnection.create({ + data: { + userId: USER, + revision: 'next-revision', + encryptedToken: tokens.encrypt('synthetic'), + connectedAt: at(10, 10), + }, + }); + expect((await reserve(at(10, 10)))?.work.revision).toBe('next-revision'); + }); + + it('closes a dead process attempt without early retry and runs one catch-up after downtime', async () => { + await seed('running', at(10), at(10, 1), 'dead-process'); + expect(await reserve(at(10, 30))).toBeNull(); + expect((await hourlyRow())?.errorCode).toBe('INTERRUPTED'); + expect(await reserve(at(10, 59))).toBeNull(); + expect((await reserve(at(14, 30)))?.work.createdAt).toEqual(at(14, 30)); + expect(await reserve(at(14, 31))).toBeNull(); + expect( + await prisma.tbankSyncWork.count({ + where: { userId: USER, scopeKey: 'hourly' }, + }), + ).toBe(1); + }); + + it('does not reserve while this process still runs the attempt', async () => { + await seed('running', at(10), at(10, 1), 'this-process'); + expect(await reserve(at(12, 30))).toBeNull(); + }); + }); + it('does not overlap a running attempt with a second walk', async () => { operations.request.mockImplementation(async () => { const row = await child('running', null, 'operations'); From 0d7839cf79669768bba55f420ed43304545ac8d9 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 05:41:21 +0300 Subject: [PATCH 09/11] fix: separate T-Bank RPC network time from queue wait (WealthLedger/backend#81) (#94) --- src/common/logging/structured-logger.service.ts | 1 + src/integrations/tbank/tbank.client.spec.ts | 2 ++ src/integrations/tbank/tbank.client.ts | 8 ++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 095e896..a05baaa 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -18,6 +18,7 @@ const allowedFields = new Set([ 'grpcCode', 'headersReceived', 'method', + 'networkMs', 'operation', 'queueWaitMs', 'requestId', diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index a35da59..4f73c6b 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -165,6 +165,8 @@ describe('TbankClient operations', () => { operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED, queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false, })); + const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }]; + expect(typeof fields.networkMs).toBe('number'); expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret'); } finally { jest.useRealTimers(); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index c6100b9..e3be72a 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -64,6 +64,7 @@ type TbankClientService = Client & Record>; type RpcDiagnostics = { channelState?: string; headersReceived?: boolean; + networkMs?: number; queueWaitMs?: number; trackingId?: string; }; @@ -524,10 +525,13 @@ export class TbankClient { throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); } const response = await this.queue.run(() => { - diagnostics.queueWaitMs = Math.round(performance.now() - startedAt); + const sentAt = performance.now(); + diagnostics.queueWaitMs = Math.round(sentAt - startedAt); return this.request( token, client, method, request, diagnostics, - ); + ).finally(() => { + diagnostics.networkMs = Math.round(performance.now() - sentAt); + }); }); this.logger.event('debug', 'tbank.rpc.completed', { operation: method, From 2ad3517d87bffa8af9285d72fd6f78b96b3eed9f Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 06:00:39 +0300 Subject: [PATCH 10/11] fix: restore screener OpenAPI constraints (WealthLedger/backend#70) (#95) --- .../instruments/stocks/presentation/stock.dto.ts | 2 +- src/openapi.ts | 13 ++++++++++++- test/openapi-contract.e2e-spec.ts | 7 +++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index c4d47f4..14b0e60 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -93,7 +93,7 @@ export class StockScreenerItemDto { @ApiProperty({ nullable: true, type: String }) declare currency: null | string; @ApiProperty({ nullable: true, type: String }) declare realExchange: null | string; @ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string; - @ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number; + @ApiProperty({ nullable: true, minimum: MIN_TICKER_LENGTH, type: 'integer' }) declare lot: null | number; } export class StockScreenerPageDto { diff --git a/src/openapi.ts b/src/openapi.ts index 46e13fb..3ea3fda 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -3,9 +3,20 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; export const API_PREFIX = 'api/v1'; +// Nest Swagger cannot express additionalProperties on a class, so closed request +// bodies are restored here after generation; the runtime allowlist stays in the controller. +const CLOSED_SCHEMAS = ['StockScreenerCriteriaDto']; + export function createOpenApiDocument(app: INestApplication) { - return SwaggerModule.createDocument( + const document = SwaggerModule.createDocument( app, new DocumentBuilder().setTitle('WealthLedger API').setVersion('v1').build(), ); + const schemas = document.components?.schemas ?? {}; + for (const [name, schema] of Object.entries(schemas)) { + if (CLOSED_SCHEMAS.includes(name) && 'properties' in schema) { + schema.additionalProperties = false; + } + } + return document; } diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index d95b945..f9eef12 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -122,6 +122,13 @@ describe('OpenAPI contract (integration)', () => { }); it('generates the runtime contract without server startup or deployment configuration', async () => { + const schemas = createOpenApiDocument(app).components?.schemas; + expect(schemas?.StockScreenerCriteriaDto).toMatchObject({ + additionalProperties: false, + }); + expect(schemas?.StockScreenerItemDto).toMatchObject({ + properties: { lot: { type: 'integer', minimum: 1, nullable: true } }, + }); expect( createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto, ).toMatchObject({ From c789e33c41281f2fc3de8851f21ae897a81e2b5d Mon Sep 17 00:00:00 2001 From: codex-bot Date: Thu, 15 Oct 2026 06:15:47 +0300 Subject: [PATCH 11/11] chore: drop unused direct source-map-support dependency (WealthLedger/backend#4) (#96) --- package-lock.json | 3 ++- package.json | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5aac605..9b08ac3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,6 @@ "globals": "17.12.0", "jest": "30.5.1", "release-it": "21.0.2", - "source-map-support": "0.5.21", "supertest": "7.2.2", "ts-jest": "29.4.12", "ts-loader": "9.6.2", @@ -13624,6 +13623,7 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -13635,6 +13635,7 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index ddcfd8c..4f195a1 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,6 @@ "globals": "17.12.0", "jest": "30.5.1", "release-it": "21.0.2", - "source-map-support": "0.5.21", "supertest": "7.2.2", "ts-jest": "29.4.12", "ts-loader": "9.6.2",