Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b84b02f1b4 | |||
| c33ec53230 | |||
| 7dcca3a2ae | |||
| 70752b2caa | |||
| 8c5568bcf8 | |||
| 30b79ad1f7 | |||
| 87747d1782 | |||
| f7a182e51f | |||
| 9774abb296 | |||
| 724cd381cc | |||
| 5aa1a21a97 | |||
| 933e5b5ffe | |||
| d886d780fc | |||
| 68b37efbe9 | |||
| e810d3152f | |||
| 99c22b0064 | |||
| 4c0fbefca9 | |||
| 8fdc99a7f9 | |||
| 8d27c4e245 | |||
| 0e3ccb4356 | |||
| 306835d0fc | |||
| 10e5b83ac9 | |||
| 33d936e5c8 | |||
| a73f07fa0c | |||
| 77f358e865 | |||
| 2afc71c281 | |||
| 9cf19aa8fe | |||
| 4b8121b0b7 |
+160
-104
@@ -5,12 +5,8 @@ name: Release
|
||||
# Порядок (через jobs.needs):
|
||||
# 1) release — контракты + контейнеры + webhook деплоя (атомарно)
|
||||
# 2) publish-packages — npm publish через lerna (если не -alpha)
|
||||
# 3) trigger-coopenomics — workflow_dispatch сборки сайта C9S/coopenomics на
|
||||
# Gitea (если не -alpha + ветка main); он сам тянет
|
||||
# mono и пересобирает доки.
|
||||
#
|
||||
# publish-docs (gh-pages на github.com) удалён 2026-05-25 — после переезда на
|
||||
# Gitea Pages-публикация невалидна; доки деплоятся через C9S/coopenomics.
|
||||
# 3) publish-docs — mkdocs + standards-site → gh-pages (если не -alpha + ветка main)
|
||||
# 4) trigger-coopenomics — repository_dispatch в coopenomics/coopenomics (тот же гейт, что docs)
|
||||
#
|
||||
# Зачем последовательно: пакеты/доки/внешний триггер не должны уезжать,
|
||||
# если релиз контрактов или контейнеров провалился. Раньше четыре workflow'а
|
||||
@@ -38,7 +34,16 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
# Гейт TS-типов перед сборкой образов. Reusable workflow `typecheck.yaml`
|
||||
# запускает vue-tsc на desktop и tsc --noEmit на controller. Нужно потому,
|
||||
# что `lerna run build` в корневом Dockerfile НЕ ловит TS-ошибки:
|
||||
# quasar build идёт через esbuild с выключенным vueTsc, а у controller'а
|
||||
# build-скрипта вообще нет (ts-node в рантайме).
|
||||
typecheck:
|
||||
uses: ./.github/workflows/typecheck.yaml
|
||||
|
||||
release:
|
||||
needs: typecheck
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.resolve.outputs.tag_name }}
|
||||
@@ -122,41 +127,14 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# === Этап 1: контракты ===
|
||||
# CDT ставим из .deb (C9S/cdt v4.2.0) прямо в окружение job'а и
|
||||
# компилируем напрямую — БЕЗ вложенного docker. Почему не build-all.sh:
|
||||
# тот монтирует $(pwd):/project в sibling-контейнер, а под Gitea
|
||||
# act_runner сам job исполняется в контейнере → хостовый демон не видит
|
||||
# этот путь, /project пуст, cmake падает "no CMakeLists.txt".
|
||||
# build-all.sh остаётся для локальной сборки (оборачивает тот же
|
||||
# build_contracts_cdt.sh в docker).
|
||||
# CMakeLists хардкодит toolchain /cdt/build/...; .deb кладёт CDT в
|
||||
# /usr/opt/cdt/4.2.0, а сам CDTWasmToolchain.cmake указывает на /usr
|
||||
# абсолютно — поэтому симлинк /cdt/build → /usr/opt/cdt/4.2.0 сводит пути
|
||||
# без правки CMakeLists.
|
||||
|
||||
- name: Install CDT 4.2.0 toolchain (.deb)
|
||||
run: |
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo"
|
||||
$SUDO apt-get update
|
||||
# build-essential — для host-компилятора (project() в CMakeLists);
|
||||
# libz3-4/libtinfo6/libxml2/zlib1g — рантайм бинарей CDT (clang-9,
|
||||
# ld.lld и т.д.). Пакет cdt объявляет только libcurl4-gnutls-dev,
|
||||
# поэтому остальные .so ставим явно — в образе dicoop/blockchain они
|
||||
# были из сборки исходников, из .deb не тянутся.
|
||||
$SUDO apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates cmake make build-essential \
|
||||
libz3-4 libtinfo6 libxml2 zlib1g
|
||||
curl -fsSL -o /tmp/cdt.deb \
|
||||
https://git.coopenomics.world/C9S/cdt/releases/download/v4.2.0/cdt_4.2.0-1_amd64.deb
|
||||
$SUDO apt-get install -y /tmp/cdt.deb
|
||||
$SUDO mkdir -p /cdt
|
||||
$SUDO ln -sfn /usr/opt/cdt/4.2.0 /cdt/build
|
||||
cdt-cpp --version || true
|
||||
- name: Pull CDT toolchain image
|
||||
run: docker pull dicoop/blockchain_v5.1.1:dev
|
||||
|
||||
- name: Compile contracts
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
./build_contracts_cdt.sh "$BUILD_MODE"
|
||||
./build-all.sh "$BUILD_MODE"
|
||||
echo "--- build/contracts ---"
|
||||
ls -la build/contracts/
|
||||
|
||||
@@ -237,17 +215,6 @@ jobs:
|
||||
- name: Build and push contracts image
|
||||
working-directory: components/contracts
|
||||
run: |
|
||||
# Ретрай push'а: резолвинг registry-1.docker.io на runner'е изредка
|
||||
# моргает (DNS-таймаут к 127.0.0.53) — 3 попытки с паузой.
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
|
||||
IMAGE="dicoop/contracts"
|
||||
SHORT_SHA="${SHA::7}"
|
||||
docker build \
|
||||
@@ -256,14 +223,14 @@ jobs:
|
||||
--label "build.mode=$BUILD_MODE" \
|
||||
-t "$IMAGE:$CONTRACTS_TAG" \
|
||||
./docker/.context
|
||||
dpush "$IMAGE:$CONTRACTS_TAG"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG"
|
||||
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
dpush "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
docker push "$IMAGE:$CONTRACTS_TAG-$SHORT_SHA"
|
||||
|
||||
if [ -n "$CONTRACTS_EXTRA" ]; then
|
||||
docker tag "$IMAGE:$CONTRACTS_TAG" "$IMAGE:$CONTRACTS_EXTRA"
|
||||
dpush "$IMAGE:$CONTRACTS_EXTRA"
|
||||
docker push "$IMAGE:$CONTRACTS_EXTRA"
|
||||
fi
|
||||
|
||||
- name: Verify pushed contracts image
|
||||
@@ -276,31 +243,15 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
docker build --target runtime -t "dicoop/mono-base:$TAG_NAME" .
|
||||
dpush "dicoop/mono-base:$TAG_NAME"
|
||||
docker push "dicoop/mono-base:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/mono-base:$TAG_NAME" dicoop/mono-base:latest
|
||||
dpush dicoop/mono-base:latest
|
||||
docker push dicoop/mono-base:latest
|
||||
fi
|
||||
|
||||
- name: Build and push service images
|
||||
run: |
|
||||
dpush() {
|
||||
local ref="$1" n=1
|
||||
until docker push "$ref"; do
|
||||
[ "$n" -ge 3 ] && { echo "::error::docker push $ref не удался после $n попыток"; return 1; }
|
||||
echo "::warning::docker push $ref упал (попытка $n/3) — повтор через 10с"
|
||||
n=$((n+1)); sleep 10
|
||||
done
|
||||
}
|
||||
build_service() {
|
||||
local SVC="$1" PKG="$2" CMD="$3"
|
||||
local DOCKERFILE="Dockerfile.$SVC"
|
||||
@@ -309,10 +260,10 @@ jobs:
|
||||
echo "CMD [\"pnpm\", \"-F\", \"$PKG\", \"run\", \"$CMD\"]"
|
||||
} > "$DOCKERFILE"
|
||||
docker build -t "dicoop/$SVC:$TAG_NAME" -f "$DOCKERFILE" .
|
||||
dpush "dicoop/$SVC:$TAG_NAME"
|
||||
docker push "dicoop/$SVC:$TAG_NAME"
|
||||
if [ "$IS_PROD" = "true" ]; then
|
||||
docker tag "dicoop/$SVC:$TAG_NAME" "dicoop/$SVC:latest"
|
||||
dpush "dicoop/$SVC:latest"
|
||||
docker push "dicoop/$SVC:latest"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -363,44 +314,149 @@ jobs:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — пересборка сайта C9S/coopenomics.
|
||||
# publish-docs — mkdocs + standards-site → gh-pages (бывший publish-docs.yaml).
|
||||
# Гейт: не-alpha + ветка main.
|
||||
#
|
||||
# coopenomics переехал на Gitea (C9S/coopenomics); его publish-docs.yaml
|
||||
# клонирует mono и пересобирает сайт. Gitea НЕ имеет API для
|
||||
# repository_dispatch — только workflow_dispatch, поэтому дёргаем целевой
|
||||
# workflow через Gitea API с PAT (secret DOCS_DISPATCH_TOKEN — префикс GITEA_
|
||||
# у секретов зарезервирован Gitea, нельзя; права write на C9S/coopenomics).
|
||||
# Целевой workflow слушает workflow_dispatch с входами
|
||||
# mono_sha/mono_ref.
|
||||
# ВНИМАНИЕ: gh-pages step пушит на github.com/coopenomics/mono — это GitHub
|
||||
# Pages. При переезде на Gitea Actions нужно будет либо подложить PAT для
|
||||
# внешнего push'а в GitHub, либо переключить публикацию на Gitea Pages.
|
||||
# ============================================================================
|
||||
publish-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Версия pnpm берётся из `packageManager` корневого package.json,
|
||||
# синхронно с publish-packages job'ом и Dockerfile'ами.
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install Python requirements
|
||||
run: |
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install mkdocs-material mkdocs-macros-plugin mkdocs-section-index pymdown-extensions
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: pnpm install
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Patch spectaql-config.yml for CI
|
||||
run: |
|
||||
sed -i.bak "0,/url:.*/s|url:.*|url: 'https://testnet.coopenomics.world/backend/v1/graphql'|" spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Show patched spectaql-config.yml
|
||||
run: cat spectaql-config.yml
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Build cooptypes
|
||||
run: pnpm run build
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Generate controller docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/controller
|
||||
|
||||
- name: Copy controller docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/graphql
|
||||
cp -r ./components/controller/docs/* ./components/docs/docs/graphql/
|
||||
|
||||
- name: Generate sdk docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/sdk
|
||||
|
||||
- name: Copy sdk docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/sdk
|
||||
cp -r ./components/sdk/docs/* ./components/docs/docs/sdk/
|
||||
|
||||
- name: Generate cooptypes docs
|
||||
run: pnpm run docs
|
||||
working-directory: ./components/cooptypes
|
||||
|
||||
- name: Copy cooptypes docs
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/cooptypes
|
||||
cp -r ./components/cooptypes/docs/* ./components/docs/docs/cooptypes/
|
||||
|
||||
# ── standards-site → /standards/ на docs.цифровой-кооператив.рф ──
|
||||
# Отдельный Vue-сайт с BPMN-графом кооперативных стандартов,
|
||||
# публикуется как поддиректория на том же домене. Vite собирает
|
||||
# с base='/standards/' (см. vite.config.ts), относительные пути
|
||||
# внутри dist/ и hash-router работают корректно.
|
||||
# dist кладём в docs/standards/ ДО mkdocs build — так же, как
|
||||
# graphql/sdk/cooptypes; mkdocs сам включит его в итоговый site/.
|
||||
- name: Build standards-site
|
||||
run: pnpm run build
|
||||
working-directory: ./components/contracts/standards-site
|
||||
|
||||
- name: Copy standards-site into docs/standards
|
||||
run: |
|
||||
mkdir -p ./components/docs/docs/standards
|
||||
cp -r ./components/contracts/standards-site/dist/* ./components/docs/docs/standards/
|
||||
|
||||
- name: Build docs (mkdocs)
|
||||
run: |
|
||||
source venv/bin/activate
|
||||
mkdocs build
|
||||
working-directory: ./components/docs
|
||||
|
||||
- name: Remove specific large file before publishing
|
||||
run: |
|
||||
rm -f ./components/docs/site/sdk/typedoc.json
|
||||
if [ -f "./components/docs/site/sdk/typedoc.json" ]; then
|
||||
echo "ERROR: typedoc.json still exists!"
|
||||
exit 1
|
||||
else
|
||||
echo "SUCCESS: typedoc.json removed successfully"
|
||||
fi
|
||||
|
||||
- name: Publish to GitHub Pages
|
||||
run: npx gh-pages --nojekyll -d site --repo https://x-access-token:${GITHUB_TOKEN}@github.com/coopenomics/mono.git
|
||||
working-directory: ./components/docs
|
||||
env:
|
||||
GIT_AUTHOR_NAME: github-actions
|
||||
GIT_AUTHOR_EMAIL: github-actions@github.com
|
||||
GIT_COMMITTER_NAME: github-actions
|
||||
GIT_COMMITTER_EMAIL: github-actions@github.com
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Trigger docs deployment webhook
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
curl -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
|
||||
# ============================================================================
|
||||
# trigger-coopenomics-docs — repository_dispatch в coopenomics/coopenomics
|
||||
# (бывший build-contracts-docs.yaml). Гейт идентичен publish-docs.
|
||||
# ============================================================================
|
||||
trigger-coopenomics-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger coopenomics website rebuild (Gitea workflow_dispatch)
|
||||
run: |
|
||||
curl -fsSL -X POST \
|
||||
-H "Authorization: token ${{ secrets.DOCS_DISPATCH_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"ref":"master","inputs":{"mono_sha":"${{ github.sha }}","mono_ref":"${{ github.ref }}"}}' \
|
||||
"${{ github.server_url }}/api/v1/repos/C9S/coopenomics/actions/workflows/publish-docs.yaml/dispatches"
|
||||
|
||||
# ============================================================================
|
||||
# trigger-mono-docs — деплой ВТОРОЙ документации (сайт доков mono) через
|
||||
# webhook DOCS_DEPLOY_WEBHOOK_URL. Отдельная от coopenomics публикация —
|
||||
# обе доки уезжают синхронно по релизу. Извлечён из бывшего publish-docs:
|
||||
# gh-pages на github.com выпилен как мёртвый, остался реальный deploy-webhook
|
||||
# (приёмник деплоит доки на своей стороне). Гейт: не-alpha + ветка main.
|
||||
# ============================================================================
|
||||
trigger-mono-docs:
|
||||
needs: release
|
||||
if: ${{ !contains(github.ref, '-alpha') && needs.release.outputs.branch == 'main' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger docs deployment webhook
|
||||
run: |
|
||||
curl -fsSL -X POST "${{ vars.DOCS_DEPLOY_WEBHOOK_URL }}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"ref":"${{ github.ref }}","sha":"${{ github.sha }}","branch":"${{ github.ref_name }}"}'
|
||||
- name: Trigger Coopenomics deployment
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.COOPENOMICS_PAT }}
|
||||
repository: coopenomics/coopenomics
|
||||
event-type: deploy_from_mono
|
||||
client-payload: '{"repository": "${{ github.repository }}", "sha": "${{ github.sha }}", "ref": "${{ github.ref }}", "actor": "${{ github.actor }}"}'
|
||||
|
||||
@@ -13,11 +13,11 @@ name: Typecheck
|
||||
#
|
||||
# Триггеры:
|
||||
# - pull_request на dev — гейт перед мерджем в основную ветку;
|
||||
# - workflow_call — оставлен для переиспользования, но release.yaml его
|
||||
# больше НЕ вызывает (гейт из релиза убран как избыточный — типы
|
||||
# проверяются на PR в dev до того, как код доедет до тэга).
|
||||
# - workflow_call — переиспользуется из release.yaml как `needs:` у release-job,
|
||||
# чтобы битый тэг не уехал в DockerHub.
|
||||
#
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт на dev достаточен).
|
||||
# Push в dev/testnet/main НЕ триггерит — намеренно (PR-гейт достаточен,
|
||||
# а тэги покрывает workflow_call из release.yaml).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
node_modules/
|
||||
node_modules
|
||||
lerna-debug.log
|
||||
components/controller/graph.png
|
||||
blockchain-data/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/blago-cli",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -9,15 +9,12 @@ export interface CommunicationCursorsFile {
|
||||
messageLastTsByRoom: Record<string, number>
|
||||
/** project_hash → ISO instant: транскрипции с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByProject: Record<string, string>
|
||||
/** matrixRoomId → ISO instant: транскрипции непроектной комнаты с endedAt ≤ этого момента уже выгружены */
|
||||
transcriptionLastEndedExclusiveByRoom: Record<string, string>
|
||||
}
|
||||
|
||||
function empty(): CommunicationCursorsFile {
|
||||
return {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +32,6 @@ export async function loadCommunicationCursors(root: string): Promise<Communicat
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByProject === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByProject }
|
||||
: {},
|
||||
transcriptionLastEndedExclusiveByRoom:
|
||||
parsed.transcriptionLastEndedExclusiveByRoom !== undefined
|
||||
&& typeof parsed.transcriptionLastEndedExclusiveByRoom === 'object'
|
||||
? { ...parsed.transcriptionLastEndedExclusiveByRoom }
|
||||
: {},
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
transcriptionMeetingFileStemUtc,
|
||||
type CommunicationDayLine,
|
||||
} from './communication-markdown.js'
|
||||
import { generateSlug, workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { workspaceBasePath, type ProjectPathModel } from './layout.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
|
||||
interface ProjectRowLite {
|
||||
@@ -183,19 +183,6 @@ async function listRooms(ctx: AuthenticatedContext, projectHash: string) {
|
||||
return q[Queries.ChatCoop.ListProjectCommunicationRooms.name] ?? []
|
||||
}
|
||||
|
||||
/** Стабильная папка непроектной комнаты в `rooms/`. Системные — фиксированные, комнаты секретаря — slug + хвост id (уникальность). */
|
||||
function nonProjectRoomFolder(kind: string, matrixRoomId: string, displayLabel: string): string {
|
||||
if (kind === 'MEMBERS') {
|
||||
return 'komnata-paishchikov'
|
||||
}
|
||||
if (kind === 'COUNCIL') {
|
||||
return 'komnata-soveta'
|
||||
}
|
||||
const slug = generateSlug(displayLabel) || 'komnata'
|
||||
const shortId = createHash('sha256').update(matrixRoomId, 'utf8').digest('hex').slice(0, 6)
|
||||
return `${slug}-${shortId}`
|
||||
}
|
||||
|
||||
export async function pullProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
@@ -435,208 +422,3 @@ export async function pullProjectCommunicationArtifacts(
|
||||
warn(`Не удалось сохранить курсоры переписки: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull переписки и транскрипций из комнат ВНЕ проектов Capital (пайщики, совет, комнаты секретаря).
|
||||
* Раскладка — отдельная верхняя папка `rooms/<folder>/{messages,meetings}/`, чтобы не смешивать с
|
||||
* проектными `meetings/`. Логика идентична проектной, но bucket = одна комната, курсор транскрипций — по matrixRoomId.
|
||||
*/
|
||||
export async function pullNonProjectCommunicationArtifacts(
|
||||
ctx: AuthenticatedContext,
|
||||
index: IndexFile,
|
||||
): Promise<void> {
|
||||
let rooms: { matrixRoomId: string, displayLabel: string, kind: string }[]
|
||||
try {
|
||||
const q = await ctx.client.Query(Queries.ChatCoop.ListNonProjectCommunicationRooms.query, {})
|
||||
rooms = (q[Queries.ChatCoop.ListNonProjectCommunicationRooms.name] ?? []) as typeof rooms
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Список непроектных комнат (chatcoopListNonProjectCommunicationRooms): ${formatThrownValue(e)}`)
|
||||
return
|
||||
}
|
||||
if (rooms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let cursors: CommunicationCursorsFile
|
||||
try {
|
||||
cursors = await loadCommunicationCursors(ctx.root)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Курсоры переписки (комнаты): не удалось прочитать, начинаем с пустых: ${formatThrownValue(e)}`)
|
||||
cursors = {
|
||||
messageLastTsByRoom: {},
|
||||
transcriptionLastEndedExclusiveByProject: {},
|
||||
transcriptionLastEndedExclusiveByRoom: {},
|
||||
}
|
||||
}
|
||||
|
||||
for (const room of rooms) {
|
||||
const folder = nonProjectRoomFolder(room.kind, room.matrixRoomId, room.displayLabel)
|
||||
const basePath = `rooms/${folder}`
|
||||
const roomTitle = room.displayLabel || room.matrixRoomId
|
||||
|
||||
// Сообщения комнаты — по календарным суткам UTC новее курсора.
|
||||
try {
|
||||
const last = cursors.messageLastTsByRoom[room.matrixRoomId]
|
||||
const afterTs = last ?? 0
|
||||
const datesQ = await ctx.client.Query(Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, afterOriginServerTsExclusive: afterTs } },
|
||||
})
|
||||
const dates = (datesQ[Queries.ChatCoop.ListUtcDatesWithNewRoomMessages.name] ?? []).sort()
|
||||
for (const utcDate of dates) {
|
||||
const mq = await ctx.client.Query(Queries.ChatCoop.GetRoomMessagesForUtcDate.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, utcDate } },
|
||||
})
|
||||
const linesRaw = mq[Queries.ChatCoop.GetRoomMessagesForUtcDate.name] ?? []
|
||||
const lines: CommunicationDayLine[] = linesRaw.map(m => ({
|
||||
originServerTs: m.originServerTs,
|
||||
authorLabel: m.authorLabel,
|
||||
coopUsername: m.coopUsername,
|
||||
kind: String(m.kind),
|
||||
bodyText: m.bodyText,
|
||||
}))
|
||||
if (lines.length === 0) {
|
||||
continue
|
||||
}
|
||||
const content = projectCommunicationDayToMarkdown(roomTitle, room.matrixRoomId, utcDate, [
|
||||
{ displayLabel: room.displayLabel, matrixRoomId: room.matrixRoomId, lines },
|
||||
])
|
||||
const rel = `${basePath}/messages/${utcDate}.md`
|
||||
const entityHash = messageDayEntityHash(room.matrixRoomId, utcDate)
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'room_message_day',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content,
|
||||
remoteUpdatedAt: `${utcDate}T23:59:59.999Z`,
|
||||
label: `переписка ${utcDate} (${room.matrixRoomId})`,
|
||||
})
|
||||
}
|
||||
|
||||
const maxQ = await ctx.client.Query(Queries.ChatCoop.GetMaxOriginServerTsForRoom.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId } },
|
||||
})
|
||||
const maxTs = maxQ[Queries.ChatCoop.GetMaxOriginServerTsForRoom.name] as number | null | undefined
|
||||
if (maxTs !== undefined && maxTs !== null && Number.isFinite(maxTs)) {
|
||||
cursors.messageLastTsByRoom[room.matrixRoomId] = maxTs
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Переписка Matrix, комната ${room.matrixRoomId} (${roomTitle}): ${formatThrownValue(e)}`)
|
||||
}
|
||||
|
||||
// Транскрипции звонков комнаты + sibling memo.
|
||||
try {
|
||||
const tExIso = cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId]
|
||||
const lowerBoundExclusive = tExIso === undefined ? new Date(0) : new Date(tExIso)
|
||||
|
||||
interface TranscriptionCandidate {
|
||||
id: string
|
||||
endedAt: Date
|
||||
memo: string
|
||||
updatedAt: Date | undefined
|
||||
}
|
||||
const byId = new Map<string, TranscriptionCandidate>()
|
||||
const tq = await ctx.client.Query(Queries.ChatCoop.GetTranscriptions.query, {
|
||||
variables: { data: { matrixRoomId: room.matrixRoomId, limit: CHATCOOP_TRANSCRIPTIONS_QUERY_LIMIT, offset: 0 } },
|
||||
})
|
||||
const list = tq[Queries.ChatCoop.GetTranscriptions.name] ?? []
|
||||
for (const t of list) {
|
||||
const end = dateFromUnknown(t.endedAt)
|
||||
if (t.status !== Zeus.TranscriptionStatus.COMPLETED || !end) {
|
||||
continue
|
||||
}
|
||||
const prev = byId.get(t.id)
|
||||
if (!prev || end > prev.endedAt) {
|
||||
byId.set(t.id, {
|
||||
id: t.id,
|
||||
endedAt: end,
|
||||
memo: typeof t.memo === 'string' ? t.memo : '',
|
||||
updatedAt: dateFromUnknown(t.updatedAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
const allCompleted = [...byId.values()].sort((a, b) => a.endedAt.getTime() - b.endedAt.getTime())
|
||||
|
||||
const newMeetings = allCompleted.filter(c => c.endedAt.getTime() > lowerBoundExclusive.getTime())
|
||||
let maxEnded: Date | null = null
|
||||
for (const c of newMeetings) {
|
||||
const packQ = await ctx.client.Query(Queries.ChatCoop.GetTranscription.query, {
|
||||
variables: { data: { id: c.id } },
|
||||
})
|
||||
const pack = packQ[Queries.ChatCoop.GetTranscription.name]
|
||||
if (!pack?.transcription || pack.transcription.status !== Zeus.TranscriptionStatus.COMPLETED) {
|
||||
continue
|
||||
}
|
||||
const tr = pack.transcription
|
||||
const startedAt: Date | string = dateFromUnknown(tr.startedAt) ?? (tr.startedAt as Date | string)
|
||||
const endedAtTr: Date | string | null | undefined
|
||||
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
|
||||
const md = renderCallTranscriptionMarkdown(
|
||||
{
|
||||
matrixRoomId: String(tr.matrixRoomId),
|
||||
roomId: String(tr.roomId),
|
||||
startedAt,
|
||||
endedAt: endedAtTr,
|
||||
},
|
||||
pack.segments.map(s => ({
|
||||
speakerName: s.speakerName,
|
||||
text: s.text,
|
||||
startOffset: s.startOffset,
|
||||
endOffset: s.endOffset,
|
||||
})),
|
||||
)
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const rel = `${basePath}/meetings/${stem}.md`
|
||||
const entityHash = c.id.toLowerCase()
|
||||
await syncEntityFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
entityType: 'call_transcription',
|
||||
entityHash,
|
||||
relativePath: rel,
|
||||
content: md,
|
||||
remoteUpdatedAt: toUpdatedIso(c.endedAt),
|
||||
label: `транскрипция ${c.id}`,
|
||||
})
|
||||
if (!maxEnded || c.endedAt > maxEnded) {
|
||||
maxEnded = c.endedAt
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of allCompleted) {
|
||||
const stem = transcriptionMeetingFileStemUtc(c.endedAt)
|
||||
const memoRel = `${basePath}/meetings/${stem}.memo.md`
|
||||
const memoRemoteUpdatedAt = toUpdatedIso(c.updatedAt ?? c.endedAt)
|
||||
await syncTranscriptionMemoFile({
|
||||
root: ctx.root,
|
||||
index,
|
||||
transcriptionId: c.id,
|
||||
relativePath: memoRel,
|
||||
serverMemo: c.memo,
|
||||
remoteUpdatedAtIso: memoRemoteUpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if (maxEnded) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = maxEnded.toISOString()
|
||||
}
|
||||
else if (tExIso === undefined) {
|
||||
cursors.transcriptionLastEndedExclusiveByRoom[room.matrixRoomId] = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Транскрипции звонков, комната ${room.matrixRoomId}: ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await saveCommunicationCursors(ctx.root, cursors)
|
||||
}
|
||||
catch (e) {
|
||||
warn(`Не удалось сохранить курсоры переписки (комнаты): ${formatThrownValue(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
storyFileRelativePath,
|
||||
workspaceBasePath,
|
||||
} from './layout.js'
|
||||
import { pullNonProjectCommunicationArtifacts, pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { pullProjectCommunicationArtifacts } from './pull-communication.js'
|
||||
import { scaffoldBmadWorkspacesAfterPull } from './scaffold-bmad-workspace.js'
|
||||
import { syncEntityFile } from './sync-entity-file.js'
|
||||
import { writeWorkspaceIndexMarkdown } from './workspace-index.js'
|
||||
@@ -329,8 +329,6 @@ export async function runPull(ctx: AuthenticatedContext, options: RunPullOptions
|
||||
|
||||
await pullProjectCommunicationArtifacts(ctx, index, allProjects, projectByHash)
|
||||
|
||||
await pullNonProjectCommunicationArtifacts(ctx, index)
|
||||
|
||||
await scaffoldBmadWorkspacesAfterPull(ctx, allProjects, projectByHash)
|
||||
|
||||
await saveIndex(ctx.root, index)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@coopenomics/boot",
|
||||
"type": "module",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
"description": "CLI-утилита инициализации блокчейна и кооператива",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -12,29 +11,24 @@ fi
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres monoredis cooparser coopback || true
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data.
|
||||
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
|
||||
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
|
||||
# данные под root). Единый способ с reboot.sh / extra_reboot.sh.
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash.
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -48,7 +42,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (clean: только программы Благорост/маркетплейс)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:clean
|
||||
|
||||
@@ -57,6 +51,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose restart coopback || true
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Загружаем per-instance конфиг из корня репо (для CHAIN_URL/MONGODB_URL/API_URL,
|
||||
# которые читают TS-код boot и шелл-скрипты networks.sh/preactivate.sh).
|
||||
# Загружаем per-instance конфиг из корня репо.
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set -a
|
||||
@@ -10,32 +9,30 @@ if [ -f "$ROOT_DIR/.env" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# Останавливаем контроллер перед очисткой данных
|
||||
echo "Останавливаем контроллер..."
|
||||
docker compose down coopback || true
|
||||
|
||||
# Останавливаем и удаляем контейнеры вместе с volumes
|
||||
echo "Останавливаем и удаляем контейнеры с volumes..."
|
||||
docker compose down -v mongo postgres monoredis cooparser coopback || true
|
||||
docker compose down -v mongo postgres cooparser || true
|
||||
|
||||
# Останавливаем blockchain контейнер перед удалением данных
|
||||
echo "Останавливаем blockchain контейнер..."
|
||||
docker compose stop node || true
|
||||
|
||||
# Удаляем blockchain data.
|
||||
# Контейнерный wipe (alpine под root) стирает данные независимо от их владельца —
|
||||
# без sudo на любой ноде (на Pi нет passwordless sudo; на проде nodeos пишет
|
||||
# данные под root). Единый способ с reboot.sh / clean_reboot.sh.
|
||||
# Удаляем blockchain data
|
||||
echo "Удаляем blockchain data..."
|
||||
# sudo chmod -R 755 ../blockchain-data/ 2>/dev/null || true
|
||||
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
|
||||
sudo rm -rf ../blockchain-data/
|
||||
|
||||
# Пересоздаем и запускаем базы данных + Redis (monoredis).
|
||||
# monoredis ОБЯЗАТЕЛЕН: без него coopback падает на старте с
|
||||
# `getaddrinfo EAI_AGAIN monoredis` → MaxRetriesPerRequestError → nodemon crash,
|
||||
# и провайдер не может взять org-данные partner1 (PROVIDER_URL=coopback:2998).
|
||||
# Пересоздаем и запускаем базы данных
|
||||
echo "Пересоздаем и запускаем базы данных..."
|
||||
docker compose up -d mongo postgres monoredis
|
||||
docker compose up -d mongo postgres
|
||||
|
||||
# Ждем готовности MongoDB (standalone, ping вместо ожидания PRIMARY).
|
||||
# Ждем готовности MongoDB
|
||||
echo "Ждем готовности MongoDB..."
|
||||
until docker compose exec -T mongo mongosh --quiet --eval "db.adminCommand({ping:1}).ok" > /dev/null 2>&1; do
|
||||
until docker compose exec -T mongo mongosh --eval "db.adminCommand('ping')" --quiet > /dev/null 2>&1; do
|
||||
echo "MongoDB еще не готов, ждем..."
|
||||
sleep 2
|
||||
done
|
||||
@@ -49,7 +46,7 @@ until docker compose exec -T postgres pg_isready -U postgres -d voskhod > /dev/n
|
||||
done
|
||||
echo "PostgreSQL готов!"
|
||||
|
||||
# Запускаем boot процесс (расширенный: совет + пайщики; partner1 — при EXTRA_RENT=1)
|
||||
# Запускаем boot процесс
|
||||
echo "Запускаем boot процесс..."
|
||||
pnpm run boot:extra
|
||||
|
||||
@@ -58,6 +55,6 @@ echo "Запускаем parser..."
|
||||
docker compose up -d cooparser
|
||||
|
||||
echo "Запускаем контроллер..."
|
||||
docker compose up -d --force-recreate coopback || true
|
||||
docker compose up -d coopback
|
||||
|
||||
echo "Перезапуск завершен!"
|
||||
|
||||
@@ -291,46 +291,31 @@ export default class Blockchain {
|
||||
async activateFeature(feature: Feature) {
|
||||
await this.update_pass_instance()
|
||||
|
||||
try {
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
await this.api.transact(
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
account: 'eosio',
|
||||
name: 'activate',
|
||||
authorization: [
|
||||
{
|
||||
actor: 'eosio',
|
||||
permission: 'active',
|
||||
},
|
||||
],
|
||||
data: {
|
||||
feature_digest: feature.hash,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
blocksBehind: 3,
|
||||
expireSeconds: 30,
|
||||
},
|
||||
)
|
||||
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
catch (e: any) {
|
||||
// На уже забутстрапленном (не обнулённом) чейне протокол-фича уже активна —
|
||||
// nodeos отдаёт protocol_feature_exception (code 3250000). Это не повод
|
||||
// ронять весь boot: фича на месте, нужный результат достигнут, продолжаем.
|
||||
const errName = e?.json?.error?.name
|
||||
const errCode = e?.json?.error?.code
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (errName === 'protocol_feature_exception' || errCode === 3250000 || /already activated/i.test(msg)) {
|
||||
console.warn(`Фича уже активирована, пропускаю: ${feature.name}`)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
console.log('Фича активирована: ', feature.name)
|
||||
}
|
||||
|
||||
async createToken(params: TokenContract.Interfaces.ICreate) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import config from '../configs'
|
||||
import { initExtensionsInPostgres, initSystemStatus } from '../postgres-init'
|
||||
import { installExtraData, installInitialData, startInfra } from './infra'
|
||||
import { startCoop } from './cooperative'
|
||||
import { CooperativeClass, startCoop } from './cooperative'
|
||||
|
||||
export async function boot() {
|
||||
const blockchain = await startInfra()
|
||||
@@ -13,29 +14,18 @@ export async function boot() {
|
||||
}
|
||||
|
||||
export async function bootClean() {
|
||||
// Только инфраструктура: контракты, фичи, токен, системные параметры.
|
||||
// Ни совета, ни программ — программы (createPrograms) требуют существующий
|
||||
// совет и создаются в boot/bootExtra внутри installInitialData. В clean их
|
||||
// не делаем (иначе soviet::createprog падает с «Совет не найден»).
|
||||
await startInfra()
|
||||
const blockchain = await startInfra()
|
||||
|
||||
console.log('Создаём программы (Благорост и маркетплейс)')
|
||||
|
||||
const cooperative = new CooperativeClass(blockchain)
|
||||
await cooperative.createPrograms(config.provider)
|
||||
}
|
||||
|
||||
export async function bootExtra() {
|
||||
const blockchain = await startInfra()
|
||||
await installInitialData(blockchain, true) // Создать расширенный совет (устанавливает статус 'active' в MongoDB)
|
||||
|
||||
// installExtraData регистрирует partner1 как coop с auto-approve от провайдера —
|
||||
// это и есть программная on-chain активация, которая триггерит аренду VM в
|
||||
// провайдере (PENDING→RENT). boot:extra используется НЕ только для аренды
|
||||
// сервера (например, просто пересев совета/чейна для других задач), поэтому
|
||||
// провижининг partner1 включается ТОЛЬКО под флагом EXTRA_RENT=1.
|
||||
if (process.env.EXTRA_RENT === '1') {
|
||||
console.log('EXTRA_RENT=1 → installExtraData: провижининг partner1 (триггер аренды)')
|
||||
await installExtraData(blockchain) // Регистрируем partner1 как coop (active)
|
||||
}
|
||||
else {
|
||||
console.log('EXTRA_RENT не задан → пропускаем провижининг partner1 (аренда не запускается)')
|
||||
}
|
||||
await installExtraData(blockchain) // Добавить дополнительных пайщиков
|
||||
|
||||
console.log('Инициализируем статус системы в PostgreSQL')
|
||||
await initSystemStatus() // Устанавливает статус 'active' в PostgreSQL
|
||||
|
||||
@@ -15,9 +15,6 @@ import { sleep } from '../utils'
|
||||
import { generateRandomSHA256 } from '../utils/randomHash'
|
||||
import { initUsersInPostgres, initVaultInPostgres } from '../postgres-init'
|
||||
import { CooperativeClass } from './cooperative'
|
||||
import { signProgramAgreement } from './sign-program-agreement'
|
||||
import { fakeDocument } from '../tests/shared/fakeDocument'
|
||||
import { walletDraftId, walletProgramId } from '../tests/capital/consts'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
@@ -677,17 +674,10 @@ export async function installExtraData(blockchain: Blockchain) {
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// Записываем partner1 в ЦПП Кошелька оператора (voskhod, program_id=1).
|
||||
// БЕЗ этого провайдерский performInitialTransfers (150 AXON на partner1 ДО
|
||||
// перехода в ACTIVE, см. provider CLAUDE.md §13) падает ассертом
|
||||
// eosio.token::is_can_transfer «Получатель не является участником целевой
|
||||
// потребительской программы кошелька» — и инстанс навсегда застревает в
|
||||
// INSTALL. wallet::signagree (auth voskhod@active) делает partner1 членом
|
||||
// ЦПП Кошелька. Та же механика, что для обычных пайщиков в participant.ts.
|
||||
console.log('Подписываем wallet-соглашение за partner1 (членство в ЦПП Кошелька)')
|
||||
await signProgramAgreement(blockchain, config.provider, account.username, walletProgramId, walletDraftId, fakeDocument)
|
||||
|
||||
// Ресурсы для партнёра: powerup CPU/NET.
|
||||
// Ресурсы для партнёра: powerup CPU/NET. transfer токенов пропускаем —
|
||||
// eosio.token::transfer падает на проверке membership в wallet program,
|
||||
// которая для partner1 не настроена. Для provider sync + Hostkey-flow
|
||||
// токены не нужны: tx от имени partner1 пойдут от soviet/admin.
|
||||
await blockchain.powerup({
|
||||
payer: 'eosio',
|
||||
receiver: account.username,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/cleos",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Локальная сборка всех контрактов: оборачивает build_contracts_cdt.sh в
|
||||
# CDT-образ. Сам cmake/make живёт в build_contracts_cdt.sh — он же
|
||||
# используется в CI (там CDT ставится из .deb, без docker), поэтому
|
||||
# флаги сборки в одном месте и не разъезжаются.
|
||||
|
||||
mode="${1:-prod}"
|
||||
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
docker run --rm --name cdt \
|
||||
--volume "$(pwd):/project" \
|
||||
-w /project \
|
||||
dicoop/blockchain:latest \
|
||||
/bin/bash -c "./build_contracts_cdt.sh $mode"
|
||||
/bin/bash -c "mkdir -p build && cd build && cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET=$is_testnet .. && make"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Компиляция всех контрактов кооперативной экономики через CDT.
|
||||
#
|
||||
# ВАЖНО: скрипт запускается УЖЕ внутри окружения с установленным CDT —
|
||||
# тулчейн ожидается по пути /cdt/build/lib/cmake/cdt/CDTWasmToolchain.cmake
|
||||
# (он захардкожен в CMakeLists.txt). Здесь НЕТ docker — только cmake/make.
|
||||
#
|
||||
# Два способа подготовить это окружение:
|
||||
# - локально: build-all.sh оборачивает скрипт в `docker run dicoop/blockchain:latest`
|
||||
# (в образе CDT уже лежит в /cdt/build);
|
||||
# - в CI: CDT ставится из .deb (C9S/cdt), а /cdt/build симлинкуется на
|
||||
# /usr/opt/cdt/<ver> (см. .github/workflows/release.yaml).
|
||||
#
|
||||
# Один источник cmake-флагов для обоих путей — чтобы локальная и CI-сборка
|
||||
# не разъезжались.
|
||||
set -euo pipefail
|
||||
|
||||
mode="${1:-prod}"
|
||||
if [ "$mode" = "test" ]; then
|
||||
is_testnet="ON"
|
||||
else
|
||||
is_testnet="OFF"
|
||||
fi
|
||||
|
||||
mkdir -p build
|
||||
cd build
|
||||
cmake -DBUILD_TARGET= -DTEST_TARGET= -DVERBOSE=ON -DBUILD_TESTS=OFF -DIS_TESTNET="$is_testnet" ..
|
||||
make
|
||||
+2
-2
@@ -72,8 +72,8 @@ void capital::importcontrib(eosio::name coopname, eosio::name username, checksum
|
||||
auto idx = user_wallets.get_index<"byuserwallet"_n>();
|
||||
auto preimp_it = idx.find(combine_ids(ledger2_wallets::PREIMP_FUND.value, username.value));
|
||||
if (preimp_it != idx.end() && preimp_it->available.amount > 0) {
|
||||
// Поле blocked упразднено (2026-05-24): резерв/блокировка на кошельках
|
||||
// больше не используется, проверка preimp.blocked == 0 излишня.
|
||||
eosio::check(preimp_it->blocked.amount == 0,
|
||||
"preimp.blocked > 0 не поддерживается при импорте — обратиться в поддержку");
|
||||
Ledger2::apply(
|
||||
_capital, coopname, operations::capital::DROP_PREIMP,
|
||||
preimp_it->available, username, contributor_hash,
|
||||
|
||||
@@ -9,26 +9,23 @@ namespace Capital::Core {
|
||||
* @brief Совокупный баланс программы Благорост на уровне кооператива.
|
||||
*
|
||||
* Источник — ledger2 L2 (`wallets[BLAGOROST_FUND]`), агрегированный по всем
|
||||
* пайщикам. Σ L3.available[w.cap.blago] == L2.available (Эпик 3 invariant),
|
||||
* поэтому L2 — авторитетная сумма для CRPS-знаменателя. Если кошелёк ещё не
|
||||
* создан (никто не инвестировал) — нулевой баланс.
|
||||
*
|
||||
* Поле blocked упразднено (2026-05-24): субсчёт «заблокировано» больше не
|
||||
* используется, баланс кошелька = available.
|
||||
* пайщикам. Σ L3.{available, blocked}[w.cap.blago] == L2.{available, blocked}
|
||||
* (Эпик 3 invariant), поэтому L2 — авторитетная сумма для CRPS-знаменателя.
|
||||
* Если кошелёк ещё не создан (никто не инвестировал) — нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_share_balance(eosio::name coopname) {
|
||||
wallets2_index wallets(_ledger2, coopname.value);
|
||||
auto it = wallets.find(ledger2_wallets::BLAGOROST_FUND.value);
|
||||
if (it == wallets.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Доля пайщика в Благоросте (available) из ledger2 L3.
|
||||
* @brief Доля пайщика в Благоросте (available + blocked) из ledger2 L3.
|
||||
*
|
||||
* Источник — `userwallets[(w.cap.blago, username)]`. Запись существует
|
||||
* только пока available > 0 (Эпик 3 §6: auto-delete на нуле), поэтому
|
||||
* отсутствие = нулевой баланс. Поле blocked упразднено (2026-05-24).
|
||||
* только пока available + blocked > 0 (Эпик 3 §6: auto-delete на нуле),
|
||||
* поэтому отсутствие = нулевой баланс.
|
||||
*/
|
||||
eosio::asset get_capital_program_user_share_balance(eosio::name coopname, eosio::name username) {
|
||||
userwallets_index user_wallets(_ledger2, coopname.value);
|
||||
@@ -36,6 +33,6 @@ namespace Capital::Core {
|
||||
auto key = combine_ids(ledger2_wallets::BLAGOROST_FUND.value, username.value);
|
||||
auto it = idx.find(key);
|
||||
if (it == idx.end()) return eosio::asset(0, _root_govern_symbol);
|
||||
return it->available;
|
||||
return it->available + it->blocked;
|
||||
}
|
||||
}
|
||||
@@ -67,12 +67,12 @@ void ledger2::revert(eosio::name coopname,
|
||||
eosio::check(original_operation_id != 0, "revert: original_operation_id обязателен");
|
||||
|
||||
// -------- validate mirror_wallet_op --------
|
||||
// Зеркало revert — только TRANSFER (обмен wallet_from/wallet_to) либо BURN
|
||||
// (зеркало ISSUE: изъятие с wallet_from). Бывшие BLOCK/UNBLOCK упразднены
|
||||
// (2026-05-24); ISSUE/NONE как зеркало смысла не имеют.
|
||||
eosio::check(mirror_wallet_op == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
mirror_wallet_op == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"revert: mirror_wallet_op должен быть TRANSFER или BURN");
|
||||
eosio::check(mirror_wallet_op <= 5, "revert: неизвестный mirror_wallet_op");
|
||||
// Не позволяем откатывать через BLOCK/UNBLOCK — они асимметричны и нет
|
||||
// адекватного зеркала в одной операции (BLOCK + UNBLOCK — обратные сами по себе).
|
||||
eosio::check(mirror_wallet_op != static_cast<uint8_t>(WalletOp::BLOCK) &&
|
||||
mirror_wallet_op != static_cast<uint8_t>(WalletOp::UNBLOCK),
|
||||
"revert: BLOCK/UNBLOCK не подлежат откату через revert (они симметричны сами себе)");
|
||||
|
||||
// -------- validate mirror wallets/accounts --------
|
||||
if (mirror_wallet_from.value != 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/burn).
|
||||
* @brief Атомарная операция по кошельку (issue/transfer/block/unblock/burn/burn_blocked).
|
||||
*
|
||||
* Внутренний action ledger2 — вызывается только через inline из apply().
|
||||
* Auth: только сам ledger2 (require_auth(get_self())).
|
||||
@@ -53,15 +53,13 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(amount.symbol == _root_govern_symbol,
|
||||
"walletop: некорректный символ валюты");
|
||||
eosio::check(memo.size() < 256, "walletop: memo > 255");
|
||||
// Допустимы только ISSUE(0), TRANSFER(1), BURN(4). NONE(5) — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp для неё walletop не диспатчит;
|
||||
// прямой вызов был бы no-op и сбил бы parity. Бывшие BLOCK(2)/UNBLOCK(3)/
|
||||
// BURN_BLOCKED(6) упразднены (2026-05-24): резерв возврата теперь TRANSFER на
|
||||
// w.wal.wpend, их op_code больше не валиден.
|
||||
eosio::check(op_code == static_cast<uint8_t>(WalletOp::ISSUE) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::TRANSFER) ||
|
||||
op_code == static_cast<uint8_t>(WalletOp::BURN),
|
||||
"walletop: недопустимый op_code");
|
||||
// op_code = 5 (NONE) намеренно не допускается: NONE-операции — это только
|
||||
// бухпроводка без кошелькового движения, apply.cpp не диспатчит для них walletop.
|
||||
// Прямой вызов с op_code=5 был бы no-op и сбил бы инвариант parity.
|
||||
// Допустимы 0..4 (ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN) и 6 (BURN_BLOCKED).
|
||||
eosio::check(op_code <= static_cast<uint8_t>(WalletOp::BURN_BLOCKED) &&
|
||||
op_code != static_cast<uint8_t>(WalletOp::NONE),
|
||||
"walletop: неизвестный op_code");
|
||||
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
@@ -174,7 +172,7 @@ void ledger2::walletop(eosio::name coopname,
|
||||
// миграций 048/049.
|
||||
//
|
||||
// walletop по построению применяет одно и то же `amount` к L2 и L3 (см.
|
||||
// ниже case'ы ISSUE/TRANSFER/BURN), а sender-guard на
|
||||
// ниже case'ы ISSUE/TRANSFER/BLOCK/UNBLOCK/BURN/BURN_BLOCKED), а sender-guard на
|
||||
// строке 46-47 запрещает обход. Поэтому инвариант сохраняется по
|
||||
// конструкции; полную сверку выполняет бэкенд («стол бухгалтера»),
|
||||
// вне транзакционного hot path.
|
||||
@@ -229,6 +227,60 @@ void ledger2::walletop(eosio::name coopname,
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
case WalletOp::BLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно available на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.available -= amount;
|
||||
w.blocked += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->available >= amount,
|
||||
std::string{"walletop BLOCK: недостаточно L3-available у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.available -= amount;
|
||||
r.blocked += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::UNBLOCK: {
|
||||
eosio::check(wallet_from.value != 0, "walletop UNBLOCK: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop UNBLOCK: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) {
|
||||
w.blocked -= amount;
|
||||
w.available += amount;
|
||||
});
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop UNBLOCK: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) {
|
||||
r.blocked -= amount;
|
||||
r.available += amount;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN: wallet_to должен быть пустым");
|
||||
@@ -259,6 +311,30 @@ void ledger2::walletop(eosio::name coopname,
|
||||
eosio::check(false, "walletop NONE: запрещённый op_code");
|
||||
break;
|
||||
}
|
||||
case WalletOp::BURN_BLOCKED: {
|
||||
eosio::check(wallet_from.value != 0, "walletop BURN_BLOCKED: требуется wallet_from");
|
||||
eosio::check(wallet_to.value == 0, "walletop BURN_BLOCKED: wallet_to должен быть пустым");
|
||||
|
||||
auto it = wallets.find(wallet_from.value);
|
||||
eosio::check(it != wallets.end() && it->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно blocked на кошельке "} +
|
||||
wallet_from.to_string());
|
||||
wallets.modify(it, payer, [&](auto& w) { w.blocked -= amount; });
|
||||
|
||||
if (is_user_shared_l3(wallet_from)) {
|
||||
auto uw = find_l3(wallet_from);
|
||||
eosio::check(uw != user_wallets.get_index<"byuserwallet"_n>().end() &&
|
||||
uw->blocked >= amount,
|
||||
std::string{"walletop BURN_BLOCKED: недостаточно L3-blocked у пайщика "} +
|
||||
username.to_string() + " на " + wallet_from.to_string());
|
||||
auto uw_pri = user_wallets.find(uw->id);
|
||||
user_wallets.modify(uw_pri, payer, [&](auto& r) { r.blocked -= amount; });
|
||||
}
|
||||
|
||||
cleanup_l2_if_empty(wallet_from);
|
||||
cleanup_l3_if_empty(wallet_from);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Post-mutation Σ L3 == L2: см. блок выше (строки 163-175). Снято
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#include <vector>
|
||||
|
||||
/**
|
||||
* @brief Универсальное миграционное действие контракта ledger2 — точка
|
||||
* расширения для разовых исправлений состояния, которые можно провести
|
||||
@@ -7,33 +9,23 @@
|
||||
* после её прогона на проде тело очищается до пустого `require_auth(get_self())`
|
||||
* (как в `capital::migrate`).
|
||||
*
|
||||
* Текущая задача (2026-05-24): свёртка `blocked → available` по ВСЕМ коопам.
|
||||
* Текущая задача (2026-05-21): чистка осиротевших L3-записей `w.cap.gen`
|
||||
* на voskhod после смены WalletKind GENERATOR_FUND с USER_SHARED на
|
||||
* COOPERATIVE (см. `lib/core/ledger2/wallets.hpp`).
|
||||
*
|
||||
* Контекст: механика «заблокированного» баланса упразднена (см.
|
||||
* `lib/core/ledger2/operations.hpp` — удалены WalletOp BLOCK/UNBLOCK/BURN_BLOCKED;
|
||||
* резерв возврата паевого теперь выражается переводом на кошелёк-резерв
|
||||
* `w.wal.wpend`). Поле `blocked` остаётся в таблицах `wallets2`/`userwallets`
|
||||
* как deprecated (физическое удаление поля = небезопасная смена layout таблицы
|
||||
* на живых коопах, выносится в отдельный cleanup-деплой). Перед тем как поле
|
||||
* перестанет поддерживаться кодом, накопленные `blocked`-остатки нужно вернуть
|
||||
* в `available`, чтобы средства не «зависли» на упразднённом субсчёте.
|
||||
* Контекст: на voskhod `convertsegm` падал «недостаточно L3-средств у
|
||||
* пайщика» в проектах, где CRPS перераспределял доли между сегментами.
|
||||
* w.cap.gen был USER_SHARED, а CRPS в approvecmmt не делал per-user
|
||||
* компенсирующих TRANSFER между сегментами: `Σ COMMIT_RID == Σ ACCEPT_RID`
|
||||
* соблюдался только на проекте, не на пайщике.
|
||||
*
|
||||
* Действие: пройти всех кооперативов (cooperatives2 в scope registrator) и для
|
||||
* каждого свернуть `blocked → available` на уровнях L2 (`wallets2`) и L3
|
||||
* (`userwallets`): `available += blocked; blocked = 0`. Сумма средств на кошельке
|
||||
* не меняется — только субсчёт.
|
||||
* Архитектурный фикс: w.cap.gen — COOPERATIVE-пул без L3. Чтобы UI/бэкенд
|
||||
* не врали остатками по «личным» w.cap.gen, удаляем осиротевшие L3-записи
|
||||
* прямым `userwallets.erase`. L2-баланс `wallets2[w.cap.gen]` уже верен
|
||||
* (синхронен с Σ старых userwallets), его не трогаем.
|
||||
*
|
||||
* Идемпотентно: после свёртки `blocked == 0`, повторный прогон — no-op.
|
||||
*
|
||||
* Сигнатура без аргументов — действие вызывается автоматически при деплое
|
||||
* контракта (как и прочие задачи migrate); проходит по всем кооперативам сам.
|
||||
*
|
||||
* ПРЕДУСЛОВИЕ (операционное): на момент прогона не должно быть заявок на возврат
|
||||
* «в полёте» (статусы pending/authorized в `wallet::withdraws`) — их `blocked`
|
||||
* относится к старой механике и при свёртке в `available` вернётся пайщику как
|
||||
* свободные средства, а последующий `completewthd` (BURN с `w.wal.wpend`) не
|
||||
* найдёт резерва. Незавершённые заявки нужно довести (complete/decline) ДО
|
||||
* деплоя с этой миграцией.
|
||||
* Идемпотентно: повторный вызов на чистой БД — no-op (lower_bound пуст).
|
||||
* Только voskhod: на остальных кооперативах w.cap.gen не использовался.
|
||||
*
|
||||
* @ingroup public_ledger2_actions
|
||||
*
|
||||
@@ -42,31 +34,24 @@
|
||||
void ledger2::migrate() {
|
||||
require_auth(get_self());
|
||||
|
||||
cooperatives2_index coops(_registrator, _registrator.value);
|
||||
const eosio::name target_coop = "voskhod"_n;
|
||||
|
||||
for (auto c = coops.begin(); c != coops.end(); ++c) {
|
||||
const eosio::name coopname = c->username;
|
||||
userwallets_index user_wallets(get_self(), target_coop.value);
|
||||
auto idx = user_wallets.get_index<"bywallet"_n>();
|
||||
|
||||
// --- L3: userwallets[coopname] ---
|
||||
// Модифицируем только не-ключевые поля (available/blocked) — итерация по
|
||||
// первичному индексу с modify безопасна (порядок строк не меняется).
|
||||
userwallets_index user_wallets(get_self(), coopname.value);
|
||||
for (auto it = user_wallets.begin(); it != user_wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
user_wallets.modify(it, get_self(), [&](auto& r) {
|
||||
r.available += r.blocked;
|
||||
r.blocked = eosio::asset(0, r.blocked.symbol);
|
||||
});
|
||||
}
|
||||
// Собираем primary id записей до erase (модификация контейнера при
|
||||
// итерации через secondary index — небезопасна).
|
||||
std::vector<uint64_t> ids_to_erase;
|
||||
for (auto it = idx.lower_bound(ledger2_wallets::GENERATOR_FUND.value);
|
||||
it != idx.end() && it->wallet_name == ledger2_wallets::GENERATOR_FUND;
|
||||
++it) {
|
||||
ids_to_erase.push_back(it->id);
|
||||
}
|
||||
|
||||
// --- L2: wallets2[coopname] ---
|
||||
wallets2_index wallets(get_self(), coopname.value);
|
||||
for (auto it = wallets.begin(); it != wallets.end(); ++it) {
|
||||
if (it->blocked.amount <= 0) continue;
|
||||
wallets.modify(it, get_self(), [&](auto& w) {
|
||||
w.available += w.blocked;
|
||||
w.blocked = eosio::asset(0, w.blocked.symbol);
|
||||
});
|
||||
for (uint64_t id : ids_to_erase) {
|
||||
auto pri = user_wallets.find(id);
|
||||
if (pri != user_wallets.end()) {
|
||||
user_wallets.erase(pri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
* - `WalletOp::WALLET_ONLY` удалён (ADR-003): «без бухпроводок» определяется
|
||||
* парой `(debit_account_id == 0, credit_account_id == 0)` на уровне записи.
|
||||
* Для `o.cap.invest` теперь TRANSFER без проводок (оба account_id == 0).
|
||||
* - `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. Используется в `o.wal.wthcpl` (сжигание резерва возврата
|
||||
* с `w.wal.wpend`), `o.cap.drppre` и как зеркало ISSUE в `revert`.
|
||||
* - Добавлен `WalletOp::BURN` (ADR-003): `available -= amount` на `wallet_from`,
|
||||
* без `wallet_to`. На текущем этапе в `OPERATION_REGISTRY` не используется —
|
||||
* зарезервирован под будущие операции штатного сжигания.
|
||||
* - Единые программные кошельки: `BLAGOROST_FUND` (`w.cap.blago`) и
|
||||
* `GENERATOR_FUND` (`w.cap.gen`) — заменили ранее декомпозированные
|
||||
* `bginv/bgprop/bgrid/bgmem` и `gncom/gnmem` (ADR-009).
|
||||
@@ -61,9 +61,9 @@ namespace operations {
|
||||
// wallet
|
||||
namespace wallet {
|
||||
inline constexpr eosio::name COMPLETE_DEPOSIT = "o.wal.depcpl"_n; ///< Завершение внесения паевого взноса (Dr 51 / Cr 80, ISSUE SHARE_FUND_PAY).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN с WITHDRAW_PENDING — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING (резерв, без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY (снятие резерва, без Dr/Cr).
|
||||
inline constexpr eosio::name COMPLETE_WITHDRAW = "o.wal.wthcpl"_n; ///< Завершение возврата паевого взноса (Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY — деньги уходят из системы, без wallet_to).
|
||||
inline constexpr eosio::name REQUEST_WITHDRAW = "o.wal.wthreq"_n; ///< Запрос на возврат паевого: BLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
inline constexpr eosio::name DECLINE_WITHDRAW = "o.wal.wthdec"_n; ///< Отклонение запроса на возврат: UNBLOCK на SHARE_FUND_PAY (без Dr/Cr).
|
||||
}
|
||||
|
||||
// capital
|
||||
@@ -129,23 +129,14 @@ namespace operations {
|
||||
* `(debit_account_id == 0, credit_account_id == 0)` на уровне записи реестра.
|
||||
* Compile-time правило `(debit==0) ⇔ (credit==0)` ловит смешанные пары.
|
||||
*/
|
||||
//
|
||||
// Удаление BLOCK/UNBLOCK/BURN_BLOCKED (2026-05-24): механика «заблокированного»
|
||||
// баланса упразднена. Резерв средств под заявку на возврат паевого теперь
|
||||
// выражается переводом на отдельный кошелёк-резерв `w.wal.wpend` (TRANSFER),
|
||||
// возврат резерва — обратным TRANSFER, завершение — BURN с резерва. Поле
|
||||
// `blocked` в таблицах wallets2/userwallets оставлено deprecated (всегда 0
|
||||
// после ledger2::migrate-свёртки) — физическое удаление поля = небезопасная
|
||||
// смена layout таблицы на живых коопах, выносится в отдельный cleanup-деплой.
|
||||
//
|
||||
// Числовые значения ISSUE/TRANSFER/BURN/NONE СОХРАНЕНЫ (не перенумерованы),
|
||||
// чтобы исторические op_code в blockchain_actions читались бэкендом без сдвига
|
||||
// смысла (2 и 3 — бывшие BLOCK/UNBLOCK — больше не выдаются и невалидны на входе).
|
||||
enum class WalletOp : uint8_t {
|
||||
ISSUE = 0, ///< первичный вход средств на кошелёк wallet_to (wallet_from = empty)
|
||||
TRANSFER = 1, ///< перемещение wallet_from → wallet_to (с Dr/Cr ИЛИ без — по парам account_id)
|
||||
BLOCK = 2, ///< available-=amount, blocked+=amount на wallet_from
|
||||
UNBLOCK = 3, ///< blocked-=amount, available+=amount на wallet_from
|
||||
BURN = 4, ///< изъятие amount с wallet_from->available, без wallet_to. Покрывает оба кейса: (a) штатное сжигание как бизнес-операция в OPERATION_REGISTRY; (b) зеркало ISSUE при `ledger2::revert` (различие — через operation_code: `o.adj.rev` для adjustment-mirror).
|
||||
NONE = 5, ///< только бухпроводка без перемещения средств (wallet_from = empty, wallet_to = empty, debit ≠ 0, credit ≠ 0). Покрывает кейсы внутрибалансовых проводок типа Dr 04 / Cr 08 (приём РИД в НМА), когда кошелёк уже на нужном программном фонде.
|
||||
BURN_BLOCKED = 6, ///< изъятие amount из wallet_from->blocked, без wallet_to. Используется при завершении возврата паевого (`o.wal.wthcpl`): средства предварительно заблокированы через BLOCK (REQUEST_WITHDRAW), на завершении сжигаются — выходят из системы (получателя на цепи нет). На L3 синхронно: from_uw->blocked -= amount.
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -154,7 +145,7 @@ enum class WalletOp : uint8_t {
|
||||
* Семантика полей по `wallet_op`:
|
||||
* - ISSUE: wallet_from = eosio::name{}, wallet_to = required.
|
||||
* - TRANSFER: wallet_from = required, wallet_to = required (≠ from).
|
||||
* - BURN: wallet_from = required, wallet_to = eosio::name{}.
|
||||
* - BLOCK / UNBLOCK / BURN / BURN_BLOCKED: wallet_from = required, wallet_to = eosio::name{}.
|
||||
*
|
||||
* Семантика бух.проводки:
|
||||
* - Без проводок: debit_account_id == 0 И credit_account_id == 0.
|
||||
@@ -166,7 +157,7 @@ struct OperationRegistryEntry {
|
||||
eosio::name process_type; ///< тип процесса с префиксом `p.<contract>.<noun>`
|
||||
WalletOp wallet_op;
|
||||
eosio::name wallet_from; ///< пустое имя для ISSUE
|
||||
eosio::name wallet_to; ///< пустое имя для BURN
|
||||
eosio::name wallet_to; ///< пустое имя для BLOCK/UNBLOCK/BURN/BURN_BLOCKED
|
||||
uint64_t debit_account_id; ///< 0 если без бухпроводки (тогда credit_account_id тоже == 0)
|
||||
uint64_t credit_account_id; ///< 0 если без бухпроводки (тогда debit_account_id тоже == 0)
|
||||
const char* human_name;
|
||||
@@ -193,12 +184,12 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::BANK_ACCOUNT, ledger2_accounts::SHARE_FUND,
|
||||
"Внесение пайщиком паевого взноса" },
|
||||
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN WITHDRAW_PENDING.
|
||||
// Сжигание из кошелька-резерва (TRANSFER в резерв был на REQUEST_WITHDRAW):
|
||||
// 4. Возврат паевого взноса: Dr 80 / Cr 51, BURN_BLOCKED SHARE_FUND_PAY.
|
||||
// Сжигание из заблокированной суммы пайщика (BLOCK был на REQUEST_WITHDRAW):
|
||||
// деньги уходят из системы (банковский перевод пайщику), получателя на цепи нет.
|
||||
// Бухгалтерия: паевой фонд уменьшается (Дт 80), расчётный счёт уменьшается (Кт 51).
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN,
|
||||
ledger2_wallets::WITHDRAW_PENDING, eosio::name{},
|
||||
{ operations::wallet::COMPLETE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BURN_BLOCKED,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::BANK_ACCOUNT,
|
||||
"Возврат паевого взноса пайщику" },
|
||||
|
||||
@@ -282,19 +273,17 @@ static constexpr OperationRegistryEntry OPERATION_REGISTRY[] = {
|
||||
ledger2_accounts::SHARE_FUND, ledger2_accounts::TARGET_RECEIPTS,
|
||||
"Трансляция паевого взноса из ЦПП «Цифровой Кошелёк» в членский взнос за пользование инфраструктурой" },
|
||||
|
||||
// 15. Запрос на возврат паевого: TRANSFER SHARE_FUND_PAY → WITHDRAW_PENDING
|
||||
// (без Dr/Cr — оба кошелька на счёте 80; резерв средств на время рассмотрения).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::SHARE_FUND_PAY, ledger2_wallets::WITHDRAW_PENDING,
|
||||
// 15. Запрос на возврат паевого: BLOCK SHARE_FUND_PAY (без Dr/Cr — внутри одного бухсчёта 80).
|
||||
{ operations::wallet::REQUEST_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::BLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Резервирование паевого под запрос на возврат" },
|
||||
"Блокировка паевого под запрос на возврат" },
|
||||
|
||||
// 16. Отклонение запроса на возврат: TRANSFER WITHDRAW_PENDING → SHARE_FUND_PAY
|
||||
// (без Dr/Cr — зеркало REQUEST_WITHDRAW; возврат резерва пайщику).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::TRANSFER,
|
||||
ledger2_wallets::WITHDRAW_PENDING, ledger2_wallets::SHARE_FUND_PAY,
|
||||
// 16. Отклонение запроса на возврат: UNBLOCK SHARE_FUND_PAY (без Dr/Cr — зеркало REQUEST_WITHDRAW).
|
||||
{ operations::wallet::DECLINE_WITHDRAW, processes::wallet::WITHDRAW, WalletOp::UNBLOCK,
|
||||
ledger2_wallets::SHARE_FUND_PAY, eosio::name{},
|
||||
0, 0,
|
||||
"Снятие резерва паевого после отклонения запроса на возврат" },
|
||||
"Разблокировка паевого после отклонения запроса на возврат" },
|
||||
|
||||
// 17. Возврат из ЦПП «Благорост» в Цифровой Кошелёк: TRANSFER BLAGOROST_FUND → SHARE_FUND_PAY (без Dr/Cr — оба счёта 80, зеркало INVEST).
|
||||
{ operations::capital::WITHDRAW_FROM_CAPITAL, processes::capital::WTHCAP, WalletOp::TRANSFER,
|
||||
@@ -351,7 +340,7 @@ static constexpr size_t OPERATION_REGISTRY_SIZE = sizeof(OPERATION_REGISTRY) / s
|
||||
// оба существуют в `LEDGER2_ACCOUNT_MAP`.
|
||||
// 4. Для TRANSFER: `wallet_from` ≠ `wallet_to`, оба ≠ 0.
|
||||
// 5. Для ISSUE: `wallet_from` == 0 и `wallet_to` ≠ 0.
|
||||
// 6. Для BURN: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 6. Для BLOCK / UNBLOCK / BURN / BURN_BLOCKED: `wallet_from` ≠ 0, `wallet_to` == 0.
|
||||
// 7. Все id кошельков из записей существуют в `LEDGER2_WALLET_REGISTRY`.
|
||||
namespace ledger2_registry_detail {
|
||||
constexpr bool operation_codes_unique() {
|
||||
@@ -395,11 +384,11 @@ namespace ledger2_registry_detail {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Правило 6: BURN — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
// Правило 6: BURN / BURN_BLOCKED — wallet_from required, wallet_to == 0 (ADR-003).
|
||||
constexpr bool burn_pattern_correct() {
|
||||
for (size_t i = 0; i < OPERATION_REGISTRY_SIZE; ++i) {
|
||||
const auto& e = OPERATION_REGISTRY[i];
|
||||
if (e.wallet_op != WalletOp::BURN) continue;
|
||||
if (e.wallet_op != WalletOp::BURN && e.wallet_op != WalletOp::BURN_BLOCKED) continue;
|
||||
if (e.wallet_from.value == 0) return false;
|
||||
if (e.wallet_to.value != 0) return false;
|
||||
}
|
||||
@@ -451,7 +440,7 @@ static_assert(ledger2_registry_detail::dr_ne_cr_when_posting(),
|
||||
static_assert(ledger2_registry_detail::transfer_wallet_from_ne_to(),
|
||||
"OPERATION_REGISTRY: TRANSFER с wallet_from == wallet_to или одним из них == 0");
|
||||
static_assert(ledger2_registry_detail::burn_pattern_correct(),
|
||||
"OPERATION_REGISTRY: BURN требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
"OPERATION_REGISTRY: BURN / BURN_BLOCKED требует wallet_from ≠ 0 и wallet_to == 0");
|
||||
static_assert(ledger2_registry_detail::none_pattern_correct(),
|
||||
"OPERATION_REGISTRY: NONE требует wallet_from == 0, wallet_to == 0 и обе проводки заполненными");
|
||||
static_assert(ledger2_registry_detail::accounts_exist_in_map(),
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* w.mkt.* — Маркетплейс (выплаты поставщикам)
|
||||
*
|
||||
* Sentinel `eosio::name{}` (пустое имя, value=0) — «кошелёк вне системы»
|
||||
* для ISSUE (нет wallet_from) и для BURN (нет wallet_to).
|
||||
* для ISSUE (нет wallet_from) и для BURN/BLOCK/UNBLOCK (нет wallet_to).
|
||||
*
|
||||
* При первом ISSUE/TRANSFER кошелёк создаётся автоматически по записи
|
||||
* из WALLET_REGISTRY. При обнулении available+blocked запись удаляется.
|
||||
@@ -46,8 +46,7 @@ struct ledger2_wallets {
|
||||
// wallet — паевой фонд + возвраты + ЦК
|
||||
static constexpr eosio::name SHARE_FUND_PAY = "w.wal.share"_n; ///< Паевой взнос пайщика (USER_SHARED)
|
||||
static constexpr eosio::name CK_MEMBER = "w.wal.member"_n; ///< ЦК — членская часть пайщика (USER_SHARED)
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: исторический sink возвратов. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
static constexpr eosio::name WITHDRAW_PENDING = "w.wal.wpend"_n; ///< Резерв паевого под заявку на возврат (COOPERATIVE-пул). o.wal.wthreq переводит сюда с w.wal.share, o.wal.wthdec возвращает обратно, o.wal.wthcpl сжигает отсюда. Заменил механику blocked/BLOCK/UNBLOCK 2026-05-24.
|
||||
static constexpr eosio::name WITHDRAWALS_SINK = "w.wal.wthdrw"_n; ///< DEPRECATED 2026-05-21: после переключения o.wal.wthcpl с TRANSFER на BURN_BLOCKED кошелёк больше не получает новых средств. Оставлен в реестре для исторических L2-балансов (накопленные возвраты до перехода). Не использовать в новых операциях.
|
||||
|
||||
// registrator — минимальный паевой + вступительные
|
||||
static constexpr eosio::name MIN_SHARE_FUND = "w.reg.minshr"_n; ///< Минимальный паевой взнос пайщика (USER_SHARED, без сверки соглашений)
|
||||
@@ -95,7 +94,7 @@ struct Ledger2WalletMeta {
|
||||
WalletKind kind;
|
||||
};
|
||||
|
||||
inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
inline constexpr std::array<Ledger2WalletMeta, 14> LEDGER2_WALLET_REGISTRY = {{
|
||||
// USER_SHARED (5) — L3-разрез по пайщику
|
||||
{ ledger2_wallets::MIN_SHARE_FUND, "Минимальный паевой взнос", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::SHARE_FUND_PAY, "Паевой взнос пайщика", WalletKind::USER_SHARED },
|
||||
@@ -103,7 +102,7 @@ inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::BLAGOROST_FUND, "ЦПП «Благорост» — единый кошелёк программы у пайщика", WalletKind::USER_SHARED },
|
||||
{ ledger2_wallets::PREIMP_FUND, "Первичный учёт РИД-взносов до перехода на электронный учёт", WalletKind::USER_SHARED },
|
||||
|
||||
// COOPERATIVE (10) — единый кооперативный баланс, без L3
|
||||
// COOPERATIVE (9) — единый кооперативный баланс, без L3
|
||||
// GENERATOR_FUND переведён сюда из USER_SHARED (см. wallets.hpp:64) —
|
||||
// CRPS-распределение между сегментами проекта не поддерживает per-user
|
||||
// компенсирующие TRANSFER на approvecmmt, поэтому L3-проверка walletop
|
||||
@@ -111,7 +110,6 @@ inline constexpr std::array<Ledger2WalletMeta, 15> LEDGER2_WALLET_REGISTRY = {{
|
||||
{ ledger2_wallets::GENERATOR_FUND, "ЦПП «Генератор» — единый кошелёк программы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::ENTRANCE_FEES, "Вступительные взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAWALS_SINK, "Возвраты паевых взносов пайщикам (deprecated, не используется в новых операциях)", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::WITHDRAW_PENDING, "Резерв паевого под заявку на возврат", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::INFRA_FEES, "Членские взносы за инфраструктуру кооп. платформы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::DELEGATE_FEES, "Делегатские членские взносы", WalletKind::COOPERATIVE },
|
||||
{ ledger2_wallets::SOV_EXPENSES, "Хозяйственные расходы из числа целевого финансирования", WalletKind::COOPERATIVE },
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
# программу «Цифровой Кошелёк» (SHARE_FUND_PAY).
|
||||
#
|
||||
# Четырёхактовый процесс с одной авторизацией советом. Запись заявки живёт в
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На подаче заявки сумма
|
||||
# переводится в кошелёк-резерв (WITHDRAW_PENDING, w.wal.wpend). На закрывающем
|
||||
# действии completewthd срабатывает операция o.wal.wthcpl — сжигание суммы из
|
||||
# резерва (BURN w.wal.wpend): деньги уходят из системы пайщику банковским
|
||||
# переводом, получателя на цепи нет.
|
||||
# таблице `withdraws` от создания до выплаты или отказа. На закрывающем
|
||||
# действии completewthd срабатывает одна операция o.wal.wthcpl — сжигание
|
||||
# заблокированной суммы (BURN_BLOCKED w.wal.share): деньги уходят из системы
|
||||
# пайщику банковским переводом, получателя на цепи нет.
|
||||
#
|
||||
# Источники правды в коде:
|
||||
# • cpp/wallet/wallet.hpp — actions
|
||||
@@ -44,8 +43,8 @@ actions:
|
||||
actor: contributor
|
||||
role: opener
|
||||
purpose: >
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт переводит
|
||||
сумму с кошелька SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING и
|
||||
Пайщик создаёт заявку на возврат паевого взноса. Контракт блокирует
|
||||
сумму на кошельке SHARE_FUND_PAY (статус available → blocked) и
|
||||
создаёт повестку в совете о возврате с callback'ом авторизации.
|
||||
- name: wallet::authwthd
|
||||
human: Авторизовать выплату
|
||||
@@ -61,17 +60,16 @@ actions:
|
||||
role: closer
|
||||
purpose: >
|
||||
Gateway подтвердил списание средств в пользу пайщика. Это закрывающее
|
||||
действие: применяется операция o.wal.wthcpl (BURN w.wal.wpend)
|
||||
— сумма из резерва сжигается, в бухгалтерии проходит обратная
|
||||
действие: применяется операция o.wal.wthcpl (BURN_BLOCKED w.wal.share)
|
||||
— заблокированная сумма сжигается, в бухгалтерии проходит обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удаляется.
|
||||
- name: wallet::declinewthd
|
||||
human: Отклонить
|
||||
actor: soviet
|
||||
role: reject
|
||||
purpose: >
|
||||
Отказ на любом из этапов до выплаты. Сумма возвращается из резерва на
|
||||
кошелёк пайщика (o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY),
|
||||
запись заявки удаляется.
|
||||
Отказ на любом из этапов до выплаты. Сумма разблокируется на кошельке
|
||||
пайщика (UNBLOCK через o.wal.wthdec), запись заявки удаляется.
|
||||
|
||||
# ── Секция 3. Граф состояний ────────────────────────────────────────────────
|
||||
entity: wallet::withdraw
|
||||
@@ -82,8 +80,8 @@ states:
|
||||
- name: pending
|
||||
human: Ожидает решения совета
|
||||
description: >
|
||||
Заявка создана, сумма переведена в кошелёк-резерв (SHARE_FUND_PAY →
|
||||
WITHDRAW_PENDING), повестка отправлена в совет.
|
||||
Заявка создана, сумма заблокирована на кошельке пайщика (available → blocked),
|
||||
повестка отправлена в совет.
|
||||
kind: normal
|
||||
- name: authorized
|
||||
human: Выплата отправлена
|
||||
@@ -94,15 +92,15 @@ states:
|
||||
- name: completed
|
||||
human: Возврат выплачен
|
||||
description: >
|
||||
Gateway подтвердил списание, сумма из резерва сжигается
|
||||
(BURN w.wal.wpend). В бухгалтерии прошла обратная
|
||||
Gateway подтвердил списание, заблокированная сумма сжигается на кошельке
|
||||
пайщика (BURN_BLOCKED w.wal.share). В бухгалтерии прошла обратная
|
||||
проводка Дт 80 / Кт 51. Запись заявки удалена.
|
||||
kind: final
|
||||
- name: removed
|
||||
human: Отклонено
|
||||
description: >
|
||||
Возврат не состоялся: сумма возвращена из резерва на кошелёк пайщика
|
||||
(o.wal.wthdec: WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Возврат не состоялся: сумма разблокирована (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
kind: virtual
|
||||
virtual: true
|
||||
|
||||
@@ -174,7 +172,7 @@ scenario:
|
||||
- Сумма возврата ≤ доступного остатка.
|
||||
post:
|
||||
- В таблице withdraws создана запись со статусом `pending`.
|
||||
- Сумма переведена с SHARE_FUND_PAY в кошелёк-резерв WITHDRAW_PENDING.
|
||||
- На кошельке пайщика сумма переведена available → blocked.
|
||||
- Совет получил повестку о возврате.
|
||||
|
||||
- step: 2
|
||||
@@ -199,13 +197,13 @@ scenario:
|
||||
action: wallet::completewthd
|
||||
description: >
|
||||
Gateway подтверждает списание средств в пользу пайщика. Контракт
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN w.wal.wpend с
|
||||
применяет ledger2-операцию o.wal.wthcpl (BURN_BLOCKED w.wal.share с
|
||||
обратной проводкой Дт 80 / Кт 51) и удаляет запись заявки.
|
||||
pre:
|
||||
- Заявка в статусе `authorized`.
|
||||
- Gateway подтвердил списание.
|
||||
post:
|
||||
- Сумма из резерва сожжена (WITHDRAW_PENDING, w.wal.wpend).
|
||||
- Заблокированная сумма сожжена с SHARE_FUND_PAY (w.wal.share).
|
||||
- В ledger2 применена операция o.wal.wthcpl.
|
||||
- Запись заявки удалена.
|
||||
|
||||
@@ -216,8 +214,8 @@ scenario:
|
||||
actor: soviet
|
||||
description: >
|
||||
Совет принял отрицательное решение либо Gateway отклонил платёж.
|
||||
Сумма возвращена из резерва на кошелёк пайщика (o.wal.wthdec:
|
||||
WITHDRAW_PENDING → SHARE_FUND_PAY), запись удалена.
|
||||
Сумма разблокирована на кошельке пайщика (UNBLOCK через o.wal.wthdec),
|
||||
запись удалена.
|
||||
|
||||
# ── Секция 5. Документы и подписи ───────────────────────────────────────────
|
||||
documents:
|
||||
@@ -236,45 +234,45 @@ documents:
|
||||
# ── Секция 6. Операции ──────────────────────────────────────────────────────
|
||||
operations:
|
||||
- ledger_code: o.wal.wthreq
|
||||
human_name: Резервирование паевого под запрос на возврат
|
||||
wallet_op: TRANSFER
|
||||
human_name: Блокировка паевого под запрос на возврат
|
||||
wallet_op: BLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: w.wal.wpend
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::createwthd
|
||||
description: >
|
||||
На создании заявки контракт переводит сумму с кошелька пайщика
|
||||
(SHARE_FUND_PAY) в кошелёк-резерв (WITHDRAW_PENDING). Бухгалтерская
|
||||
проводка не создаётся — движение внутри одного бухсчёта 80.
|
||||
На создании заявки контракт блокирует сумму на кошельке пайщика
|
||||
(SHARE_FUND_PAY): available → blocked. Бухгалтерская проводка не
|
||||
создаётся — движение внутри одного бухсчёта 80.
|
||||
|
||||
- ledger_code: o.wal.wthdec
|
||||
human_name: Снятие резерва паевого после отклонения запроса на возврат
|
||||
wallet_op: TRANSFER
|
||||
wallet_from: w.wal.wpend
|
||||
wallet_to: w.wal.share
|
||||
human_name: Разблокировка паевого после отклонения запроса на возврат
|
||||
wallet_op: UNBLOCK
|
||||
wallet_from: w.wal.share
|
||||
wallet_to: ~
|
||||
debit: ~
|
||||
credit: ~
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::declinewthd
|
||||
description: >
|
||||
Зеркало REQUEST_WITHDRAW: сумма возвращается из резерва (WITHDRAW_PENDING)
|
||||
на кошелёк пайщика (SHARE_FUND_PAY). Бухгалтерская проводка не создаётся.
|
||||
Зеркало REQUEST_WITHDRAW: blocked → available на кошельке пайщика.
|
||||
Бухгалтерская проводка не создаётся.
|
||||
|
||||
- ledger_code: o.wal.wthcpl
|
||||
human_name: Возврат паевого взноса пайщику
|
||||
wallet_op: BURN
|
||||
wallet_from: w.wal.wpend # кошелёк-резерв возвратов
|
||||
wallet_op: BURN_BLOCKED
|
||||
wallet_from: w.wal.share # ЦПП «Цифровой Кошелёк» — паевые взносы деньгами
|
||||
wallet_to: ~ # деньги уходят из системы, получателя на цепи нет
|
||||
debit: 80 # Паевой фонд (складочный капитал)
|
||||
credit: 51 # Расчётный счёт
|
||||
amount_ref: withdraw.quantity
|
||||
triggered_by: wallet::completewthd
|
||||
description: >
|
||||
Обратная операция к o.wal.depcpl: зарезервированная сумма сжигается с
|
||||
кошелька-резерва (w.wal.wpend) — деньги уходят из системы банковским
|
||||
переводом пайщику. На цепи получателя нет.
|
||||
Обратная операция к o.wal.depcpl: заблокированная сумма сжигается с
|
||||
кошелька пайщика «ЦПП Цифровой Кошелёк» (w.wal.share) — деньги уходят
|
||||
из системы банковским переводом пайщику. На цепи получателя нет.
|
||||
В бухгалтерии прошла проводка Дт 80 / Кт 51 — паевой фонд уменьшился,
|
||||
деньги ушли с расчётного счёта пайщику.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/contracts",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -58,6 +58,7 @@ _Критичные правила и паттерны для AI-агентов
|
||||
### Language-Specific (TypeScript)
|
||||
|
||||
- **Bigint из PostgreSQL приходит как STRING.** Всегда `Number(blockNum) < Number(currentBlockNum)`, никогда не полагаться на `<`/`>` для `bigint`-колонок напрямую.
|
||||
- **block_num — разнобой типов в текущей кодовой базе (Story 4.3 audit).** `BaseTypeormEntity` (capital + shared) использует `@Column({ type: 'integer' }) block_num!: number` (PG возвращает number — корректно). Инфраструктурные entity (`ActionEntity`, `DeltaEntity`, `ForkEntity`, `SyncStateEntity`) используют `@Column({ type: 'bigint' }) block_num!: number` — **type-mismatch** (runtime будет string, TS говорит number). `ConsumerDedupEntity` — `bigint` + `string | null` (корректно). Это технический долг (Epic 9 backlog: bigint Transformer). Hot-path везде явно делает `Number()` либо использует TypeORM bind, так что runtime safe. При добавлении нового блокчейн-зеркала — выбрать одно из: (a) `integer` + `number`; (b) `bigint` + Transformer возвращающий `number`; (c) `bigint` + `string` с явным `Number()` в кодe. НЕ `bigint` + declaration `number` без Transformer.
|
||||
- **`Object.assign(this, blockchainData)` запрещено** в sync-сущностях — ломает типизацию. Только явное копирование полей.
|
||||
- **Lowercase hash полей (`project_hash`, `listing_hash`)** — нормализация **в конструкторе/mapValue**, НЕ в mapper'ах и НЕ повторно в `updateFromBlockchain`.
|
||||
- **Discriminated union для write-mutation response:** `{ status: 'applied' | 'pending' | 'failed' | 'conflict' }`. Клиент должен switch на статусе.
|
||||
@@ -76,6 +77,8 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `@Column({ type: 'bigint', nullable: true })` для `block_num`. Для `jsonb` — `@Column({ type: 'jsonb' })`.
|
||||
- `ADD COLUMN NOT NULL` на больших таблицах — **двухэтапно**: ADD nullable → backfill → ALTER NOT NULL.
|
||||
- Repository `extends BaseBlockchainRepository<DomainEntity, TypeormEntity>`. `findBySyncKey`, `createIfNotExists`, `deleteByBlockNumGreaterThan`, `restoreFromVersions` — **наследуются**, не реализовывать руками.
|
||||
- **Контракт «entity с block_num → repo extends BaseBlockchainRepository» (Story 4.3).** Любая `*.typeorm-entity.ts` extends `BaseTypeormEntity` ОБЯЗАНА иметь репозиторий extends `BaseBlockchainRepository` — иначе `entity_versions` не пишется (silent), а форк-rollback превращается в hard delete без восстановления. CI grep-guard: `tests/unit/blockchain/base-blockchain-repository.contract.test.ts`. Allowlist для 5 off-chain entity (`comment`, `cycle`, `issue`, `story`, `time-entry` — vestigial block_num, не блокчейн-зеркала). Новое исключение в allowlist — только с обоснованием в audit-report.
|
||||
- **Архив форка вместо hard-delete (Story 4.4).** `handleFork(N, eventId?)` НЕ удаляет live-ряды и снимки версий — переносит в `invalidated_entities` / `invalidated_entity_versions` (атомарно через DataSource.transaction). Порядок: archiveInvalidatedSince → restoreFromVersions → archiveInvalidatedVersionsSince. `fork_event_id` группирует записи одного форка (для forensic). Retention — `BlockchainArchiveRetentionService` ежечасно удаляет архив старше `LIB - 1000` блоков. LIB читается через `BlockchainService.getInfo()` (вариант C, RPC `/v1/chain/get_info`). Окно `RETENTION_HORIZON_BLOCKS = 1000` ХАРДКОД (свойство сети, не оператора). Env-переключатели: `BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED` (default true), `BLOCKCHAIN_ARCHIVE_RETENTION_CRON` (default `0 * * * *`).
|
||||
|
||||
**parser2 integration:**
|
||||
- `ParserClient` subscribe с `subscriptionId = "controller-${coopname}"`, `consumerName = "primary"` (детерминирован), `startFromBlock: 'last_known'`.
|
||||
@@ -87,14 +90,16 @@ _Критичные правила и паттерны для AI-агентов
|
||||
- `{contract}{Entity}Updated` / `Deleted` / `RolledBack` / `PendingRetry` / `Failed` — **pubsub канал** для GraphQL subscriptions. Per-contract, не global.
|
||||
- `entitysynced::{contract}::{table}` — для business-side-effect listeners (Matrix / Notification / и т.п.).
|
||||
|
||||
### Composite-Entity (ADR-008) — СТРОГО
|
||||
### Composite-Entity (ADR-008) — будущая цель, не сейчас
|
||||
|
||||
- Namespaced: `entity.db.X` (DB-поля) / `entity.bc?.Y` (blockchain, nullable) / `entity.derived.Z` (computed getters).
|
||||
- **НЕ** писать `entity.X` напрямую — ломает изоляцию.
|
||||
- Конструктор `(databaseData, blockchainData?)` — обязан `throw` на sync-key mismatch.
|
||||
- `updateFromBlockchain` возвращает **новый экземпляр** (immutable) или мутирует только `this.bc`, `this.block_num`, `this.present` — БЕЗ `Object.assign`.
|
||||
- `derived` getter — детерминирован (NO `new Date()` в конструкторе / getter — ломает snapshot tests).
|
||||
- Все signed-document поля нормализуются через `AbstractDeltaMapper.normalizeSignedDocuments` на основе `signedDocumentFields: SignedDocField[]` декларативно, НЕ руками в mapper.
|
||||
ADR-008 описывает целевой паттерн `entity.db.X` / `entity.bc?.Y` / `entity.derived.Z`, заменяющий
|
||||
`Object.assign(this, blockchainData)`. Переход вынесен за пределы MVP-релиза parser2 в отдельный
|
||||
sync-arch sanitation-эпик: blast radius на 22 entity + потребители «плоских» полей в resolver'ах
|
||||
делают эту миграцию большой и рискованной задачей, несвязанной с заменой транспорта parser1→parser2.
|
||||
|
||||
До отдельного эпика — текущий код продолжает использовать flat-namespace + `Object.assign` в
|
||||
`updateFromBlockchain`. Не вводить namespace частично на одной entity — двойной канон хуже единого
|
||||
старого.
|
||||
|
||||
### Dispatch pipeline (ADR-002, ADR-009) — СТРОГО
|
||||
|
||||
@@ -216,7 +221,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `BLOCKCHAIN_RECONCILE_CRON` default `'0 * * * *'`
|
||||
- `BLOCKCHAIN_RECONCILE_SAMPLE_SIZE` default 100
|
||||
- `BLOCKCHAIN_RECONCILE_TOLERANCE_BLOCKS` default 10
|
||||
- `BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS` default 30000
|
||||
- `BLOCKCHAIN_DLQ_MAX_RETRIES` default 5
|
||||
- `BLOCKCHAIN_MAX_TX_RETRIES` default 3
|
||||
- `BLOCKCHAIN_PENDING_TX_MAX_AGE_SECONDS` default 3600
|
||||
@@ -251,12 +255,13 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
- `await capitalBlockchainPort.getProject(hash)` в resolver / application — **retire**. Только `repository.findBySyncKey`.
|
||||
- RPC fallback "если PG не отдал" — **запрещено**. Если PG null → pending status наверх.
|
||||
|
||||
### ❌ Composite-entity anti-patterns
|
||||
### ❌ Domain-entity anti-patterns
|
||||
|
||||
- `project.matrix_room_id` (flat access) — **запрещено**. Правильно: `project.db.matrix_room_id`.
|
||||
- `project.master` (flat access) — **запрещено**. Правильно: `project.bc?.master`.
|
||||
- `Object.assign(this, blockchainData)` в update — **запрещено**.
|
||||
- Новый `new Date()` в конструкторе или derived-getter — **запрещено** (ломает snapshot-tests).
|
||||
- Миграция на namespace `entity.db.X` / `entity.bc?.Y` — целевой паттерн ADR-008, но отложен
|
||||
в отдельный sync-arch sanitation-эпик: текущий код всё ещё использует flat-namespace +
|
||||
`Object.assign(this, blockchainData)` в `updateFromBlockchain`. Не разводить два стандарта
|
||||
частично.
|
||||
|
||||
### ❌ Fork handling anti-patterns
|
||||
|
||||
@@ -296,7 +301,6 @@ return { tx_hash: tx.tx_hash, status: 'pending' };
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- `JSON.stringify` на сущностях с nested signed-documents — использовать `json-stable-stringify` для canonical checksum.
|
||||
- Reconciliation cron — sample N=100 rows, **не** full scan на hot path.
|
||||
- IPFS fetch для signed-doc — **lazy resolver вне consumer critical path**, не в mapper.
|
||||
- `waitForDelta` memory leak — timer cleanup обязателен (см. INV-T10).
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
{
|
||||
"watch": ["./src"],
|
||||
"ext": "ts",
|
||||
"delay": 2000,
|
||||
"signal": "SIGTERM"
|
||||
"ext": "ts"
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "create-nodejs-express-app",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@a2seven/yoo-checkout": "^1.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/controller",
|
||||
"version": "2026.5.25-3",
|
||||
"version": "2026.5.23-4",
|
||||
"description": "Бэкенд GraphQL API кооператива на NestJS",
|
||||
"private": true,
|
||||
"bin": "bin/createNodejsApp.js",
|
||||
@@ -76,6 +76,7 @@
|
||||
"@coopenomics/factory": "workspace:*",
|
||||
"@coopenomics/inter": "workspace:*",
|
||||
"@coopenomics/notifications": "workspace:*",
|
||||
"@coopenomics/parser2": "^1.2.0",
|
||||
"@coopenomics/provider-client": "2025.11.12-alpha-1",
|
||||
"@coopenomics/sdk": "workspace:*",
|
||||
"@graphql-codegen/typescript-graphql-request": "^6.2.0",
|
||||
@@ -193,12 +194,12 @@
|
||||
"@antfu/eslint-config": "^2.24.1",
|
||||
"@graphql-codegen/cli": "^5.0.3",
|
||||
"@graphql-codegen/typescript": "^4.1.1",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
|
||||
"@graphql-codegen/typescript-operations": "^4.3.1",
|
||||
"@nestjs/testing": "^11.1.19",
|
||||
"@redocly/cli": "^1.18.0",
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.26",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
|
||||
+169
-1002
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
// app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
|
||||
// Infrastructure modules
|
||||
@@ -9,6 +10,7 @@ import { GraphqlModule } from './infrastructure/graphql/graphql.module';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import config from '~/config/config';
|
||||
import { BlockchainModule } from './infrastructure/blockchain/blockchain.module';
|
||||
import { ForkRegistryModule } from './shared/sync/fork';
|
||||
import { GeneratorInfrastructureModule } from './infrastructure/generator/generator.module';
|
||||
import { RedisModule } from './infrastructure/redis/redis.module';
|
||||
import { NovuModule } from './infrastructure/novu/novu.module';
|
||||
@@ -85,6 +87,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Чтобы .env был доступен глобально
|
||||
}),
|
||||
ScheduleModule.forRoot(), // @Cron / @Interval / @Timeout (Story 4.4 retention)
|
||||
ThrottlerModule.forRoot([
|
||||
{
|
||||
ttl: 60000,
|
||||
@@ -95,6 +98,7 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
|
||||
MongooseModule.forRoot(config.mongoose.url),
|
||||
DatabaseModule,
|
||||
GraphqlModule,
|
||||
ForkRegistryModule,
|
||||
BlockchainModule,
|
||||
GeneratorInfrastructureModule,
|
||||
RedisModule,
|
||||
|
||||
@@ -17,4 +17,7 @@ export class Ledger2WalletDTO {
|
||||
|
||||
@Field(() => String, { description: 'Доступный баланс' })
|
||||
available!: string;
|
||||
|
||||
@Field(() => String, { description: 'Заблокированный баланс' })
|
||||
blocked!: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ export class ProgramWalletDTO {
|
||||
@Field(() => String, { description: 'Доступный баланс (формат: "100.0000 RUB")' })
|
||||
available!: string;
|
||||
|
||||
@Field(() => String, { description: 'Заблокированный баланс (формат: "100.0000 RUB")' })
|
||||
blocked!: string;
|
||||
|
||||
@Field(() => String, { description: 'Паевой взнос (формат: "100.0000 RUB")' })
|
||||
membership_contribution!: string;
|
||||
|
||||
@@ -46,6 +49,7 @@ export class ProgramWalletDTO {
|
||||
dto.agreement_id = entity.agreement_id;
|
||||
dto.username = entity.username;
|
||||
dto.available = entity.available;
|
||||
dto.blocked = entity.blocked;
|
||||
dto.membership_contribution = entity.membership_contribution;
|
||||
dto.blockNum = entity.blockNum;
|
||||
return dto;
|
||||
|
||||
@@ -49,12 +49,4 @@ export class ProgramWalletSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации программных кошельков полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для программных кошельков
|
||||
* Подписывается на все форки независимо от контракта
|
||||
*/
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,41 @@ const envVarsSchema = z.object({
|
||||
.string()
|
||||
.default('1000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
|
||||
/**
|
||||
* Задержка (мс) перед emit'ом action-события во внутреннюю шину. Даёт
|
||||
* дельтам того же блока сохраниться в БД раньше, чем обработчики action
|
||||
* полезут читать состояние (DEC-007, ранее хардкод-константа 3000).
|
||||
*/
|
||||
BLOCKCHAIN_ACTION_EMIT_DELAY_MS: z
|
||||
.string()
|
||||
.default('3000')
|
||||
.transform((val) => parseInt(val, 10)),
|
||||
/**
|
||||
* Story 4.4: глобальный выключатель ежечасного retention-крона архива
|
||||
* invalidated_entities/invalidated_entity_versions. На малом объёме (нынешний
|
||||
* кооператив) данные могут копиться годами — отключить кроном.
|
||||
*/
|
||||
BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED: z
|
||||
.string()
|
||||
.default('true')
|
||||
.transform((v) => v === 'true'),
|
||||
/**
|
||||
* Story 4.4: cron-расписание retention. Default — ежечасно. RETENTION_HORIZON_BLOCKS
|
||||
* (=1000) хардкод в BlockchainArchiveRetentionService — не вынесен в env намеренно
|
||||
* (свойство сети, не оператора).
|
||||
*/
|
||||
BLOCKCHAIN_ARCHIVE_RETENTION_CRON: z.string().default('0 * * * *'),
|
||||
/**
|
||||
* Story 6.5: при `true` mapper-fail (mapDeltaToBlockchainData → null) перестаёт
|
||||
* быть silent loss и поднимается `UnsupportedContractVersionError` из
|
||||
* `AbstractEntitySyncService.processDelta`. Парсер не ACK'ает delta — DLQ
|
||||
* сработает. Default `false` для не-ломать-прод-немедленно; включается после
|
||||
* подтверждения, что schema drift отсутствует (например на стенде).
|
||||
*/
|
||||
BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
// Параметры NOVU
|
||||
NOVU_APP_ID: z.string().min(1, { message: 'Не должно быть пустым' }),
|
||||
NOVU_BACKEND_URL: z.string().min(1, { message: 'Не должно быть пустым' }).default('https://novu.coopenomics.world/api'),
|
||||
@@ -231,6 +265,10 @@ export default {
|
||||
root_precision: envVars.data.ROOT_PRECISION,
|
||||
root_govern_precision: envVars.data.ROOT_GOVERN_PRECISION,
|
||||
post_transact_chain_read_delay_ms: envVars.data.POST_TRANSACT_CHAIN_READ_DELAY_MS,
|
||||
action_emit_delay_ms: envVars.data.BLOCKCHAIN_ACTION_EMIT_DELAY_MS,
|
||||
archive_retention_enabled: envVars.data.BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED,
|
||||
archive_retention_cron: envVars.data.BLOCKCHAIN_ARCHIVE_RETENTION_CRON,
|
||||
unsupported_version_strict: envVars.data.BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT,
|
||||
},
|
||||
mongoose: {
|
||||
url: envVars.data.MONGODB_URL + (envVars.data.NODE_ENV === 'test' ? '-test' : ''),
|
||||
|
||||
@@ -11,10 +11,12 @@ import type { ActionRepositoryPort } from '../ports/action-repository.port';
|
||||
import type { DeltaRepositoryPort } from '../ports/delta-repository.port';
|
||||
import type { ForkRepositoryPort } from '../ports/fork-repository.port';
|
||||
import type { SyncStateRepositoryPort } from '../ports/sync-state-repository.port';
|
||||
import type { ConsumerDedupRepositoryPort } from '../ports/consumer-dedup-repository.port';
|
||||
import { ACTION_REPOSITORY_PORT } from '../ports/action-repository.port';
|
||||
import { DELTA_REPOSITORY_PORT } from '../ports/delta-repository.port';
|
||||
import { FORK_REPOSITORY_PORT } from '../ports/fork-repository.port';
|
||||
import { SYNC_STATE_REPOSITORY_PORT } from '../ports/sync-state-repository.port';
|
||||
import { CONSUMER_DEDUP_REPOSITORY_PORT } from '../ports/consumer-dedup-repository.port';
|
||||
|
||||
/**
|
||||
* Интерактор парсера блокчейна
|
||||
@@ -30,9 +32,36 @@ export class ParserInteractor {
|
||||
@Inject(FORK_REPOSITORY_PORT)
|
||||
private readonly forkRepository: ForkRepositoryPort,
|
||||
@Inject(SYNC_STATE_REPOSITORY_PORT)
|
||||
private readonly syncStateRepository: SyncStateRepositoryPort
|
||||
private readonly syncStateRepository: SyncStateRepositoryPort,
|
||||
@Inject(CONSUMER_DEDUP_REPOSITORY_PORT)
|
||||
private readonly consumerDedupRepository: ConsumerDedupRepositoryPort
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Отмечено ли событие как уже применённое (Story 2.3, INV-09).
|
||||
*/
|
||||
async isEventApplied(eventId: string): Promise<boolean> {
|
||||
return await this.consumerDedupRepository.isApplied(eventId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Отметить событие применённым в consumer_dedup (Story 2.2 dual-write).
|
||||
* Идемпотентно: повтор после краха между save и mark не падает.
|
||||
* blockNum (Story 4.1) — для последующего deleteAfterBlock при форке;
|
||||
* опциональный, если вызов из мест без контекста блока.
|
||||
*/
|
||||
async markEventApplied(eventId: string, blockNum?: number): Promise<void> {
|
||||
await this.consumerDedupRepository.markApplied(eventId, blockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить из consumer_dedup записи с block_num > forkBlockNum — очистка дедупа
|
||||
* на форке (Story 4.1, ADR-005). Возвращает число удалённых строк.
|
||||
*/
|
||||
async deleteDedupAfterBlock(forkBlockNum: number): Promise<number> {
|
||||
return await this.consumerDedupRepository.deleteAfterBlock(forkBlockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранение действия блокчейна
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Порт списка применённых событий (consumer_dedup) — фундамент идемпотентности
|
||||
* (Story 2.1, INV-09).
|
||||
*/
|
||||
export interface ConsumerDedupRepositoryPort {
|
||||
/** Отмечено ли событие как уже применённое. Используется dedup-gate (Story 2.3). */
|
||||
isApplied(eventId: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Отметить событие применённым. Идемпотентно (ON CONFLICT DO NOTHING): повтор
|
||||
* после краха между save и mark не должен падать. blockNum — номер блока события
|
||||
* (Story 4.1, для последующего deleteAfterBlock на форке); опциональный для
|
||||
* backward-compat с местами, где блок неизвестен (например, ручные тесты).
|
||||
*/
|
||||
markApplied(eventId: string, blockNum?: number): Promise<void>;
|
||||
|
||||
/** Очистка меток старше cutoff (retention). Возвращает число удалённых строк. */
|
||||
deleteOlderThan(cutoff: Date): Promise<number>;
|
||||
|
||||
/**
|
||||
* Удалить метки событий с block_num > blockNum — для очистки дедупа на форке
|
||||
* (Story 4.1, ADR-005). Записи с NULL block_num (legacy до Epic 4) НЕ затрагиваются:
|
||||
* PG сравнение NULL > N даёт unknown и не попадает под WHERE. Возвращает число строк.
|
||||
*/
|
||||
deleteAfterBlock(blockNum: number): Promise<number>;
|
||||
}
|
||||
|
||||
export const CONSUMER_DEDUP_REPOSITORY_PORT = Symbol('ConsumerDedupRepositoryPort');
|
||||
@@ -94,27 +94,4 @@ export class BlockchainEventHandlerService implements OnModuleInit {
|
||||
throw error; // Перебрасываем ошибку для корректной обработки
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка события форка блокчейна
|
||||
* Сохраняет форк в базу данных
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleForkEvent(data: { block_num: number }): Promise<void> {
|
||||
try {
|
||||
this.logger.debug(`Handling fork event at block: ${data.block_num}`);
|
||||
|
||||
// Преобразование данных форка для сохранения
|
||||
const forkData = {
|
||||
chain_id: config.blockchain.id, // Используем chain_id из конфигурации
|
||||
block_num: data.block_num,
|
||||
};
|
||||
|
||||
await this.parserInteractor.saveFork(forkData);
|
||||
this.logger.debug(`Fork saved at block: ${data.block_num} for chain: ${config.blockchain.id}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обработки события форка: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку для корректной обработки
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-8
@@ -75,12 +75,4 @@ export class AppendixSyncService
|
||||
this.logger.error(`Ошибка при обработке отклонения приложения: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для приложений
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleAppendixFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -104,13 +104,4 @@ export class CommitSyncService
|
||||
this.logger.error(`Ошибка при обработке отклонения коммита: ${error?.message}`, error?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для коммитов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleCommitFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ContributorDomainEntity } from '../../domain/entities/contributor.entity';
|
||||
@@ -84,13 +84,4 @@ export class ContributorSyncService
|
||||
|
||||
return contributorEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для участников
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleContributorFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { DebtDomainEntity } from '../../domain/entities/debt.entity';
|
||||
@@ -49,13 +49,4 @@ export class DebtSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации долгов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для долгов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleDebtFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ExpenseDomainEntity } from '../../domain/entities/expense.entity';
|
||||
@@ -49,13 +49,4 @@ export class ExpenseSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации расходов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для расходов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleExpenseFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { InvestDomainEntity } from '../../domain/entities/invest.entity';
|
||||
@@ -49,13 +49,4 @@ export class InvestSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации инвестиций полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для инвестиций
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleInvestFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramPropertyDomainEntity } from '../../domain/entities/program-property.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProgramPropertySyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для программных имущественных взносов
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramPropertyFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramWalletDomainEntity } from '../../domain/entities/program-wallet.entity';
|
||||
@@ -47,12 +47,4 @@ export class ProgramWalletSyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для программных кошельков
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProgramWithdrawDomainEntity } from '../../domain/entities/program-withdraw.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProgramWithdrawSyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для возвратов из программы
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProgramWithdrawFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-9
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProjectPropertyDomainEntity } from '../../domain/entities/project-property.entity';
|
||||
@@ -50,12 +50,4 @@ export class ProjectPropertySyncService
|
||||
this.eventEmitter.on(pattern, this.processDelta.bind(this));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для проектных имущественных взносов
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProjectPropertyFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ProjectDomainEntity } from '../../domain/entities/project.entity';
|
||||
@@ -139,13 +139,4 @@ export class ProjectSyncService
|
||||
|
||||
return projectEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для проектов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleProjectFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { ResultDomainEntity } from '../../domain/entities/result.entity';
|
||||
@@ -102,13 +102,4 @@ export class ResultSyncService
|
||||
|
||||
return resultEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для результатов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleResultFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-11
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { SegmentDomainEntity } from '../../domain/entities/segment.entity';
|
||||
@@ -54,16 +54,6 @@ export class SegmentSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации сегментов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для сегментов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleSegmentFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизация сегмента между блокчейном и базой данных
|
||||
*/
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { StateDomainEntity } from '../../domain/entities/state.entity';
|
||||
@@ -49,13 +49,4 @@ export class StateSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации состояния полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для состояния
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleStateFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../shared/services/abstract-entity-sync.service';
|
||||
import { VoteDomainEntity } from '../../domain/entities/vote.entity';
|
||||
@@ -49,13 +49,4 @@ export class VoteSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации голосов полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для голосов
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleVoteFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,13 @@ import type {
|
||||
import type { IProjectDomainInterfaceBlockchainData } from '../interfaces/project-blockchain.interface';
|
||||
import type { IBlockchainSynchronizable } from '~/shared/interfaces/blockchain-sync.interface';
|
||||
import { BaseDomainEntity } from '~/shared/sync/entities/base-domain.entity';
|
||||
import { auditUnknownStatus } from '~/shared/sync/errors/audit-unknown-status';
|
||||
import { IssueIdGenerationService } from '../services/issue-id-generation.service';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
|
||||
const PROJECT_STATUS_AUDIT_LOGGER = new WinstonLoggerService();
|
||||
PROJECT_STATUS_AUDIT_LOGGER.setContext('ProjectDomainEntity');
|
||||
|
||||
/**
|
||||
* Доменная сущность проекта
|
||||
*
|
||||
@@ -210,7 +216,16 @@ export class ProjectDomainEntity
|
||||
case 'cancelled':
|
||||
return ProjectStatus.CANCELLED;
|
||||
default:
|
||||
// По умолчанию считаем статус неопределенным
|
||||
// Story 6.5: silent fallback на UNDEFINED заменён audit-trail'ом.
|
||||
// Если контракт ввёл новый статус — drift всплывёт в логе как error.
|
||||
auditUnknownStatus('ProjectDomainEntity', blockchainStatus, PROJECT_STATUS_AUDIT_LOGGER, [
|
||||
'pending',
|
||||
'active',
|
||||
'voting',
|
||||
'result',
|
||||
'finalized',
|
||||
'cancelled',
|
||||
]);
|
||||
return ProjectStatus.UNDEFINED;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -29,10 +29,9 @@ export class AppendixDeltaMapper extends AbstractBlockchainDeltaMapper<IAppendix
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔥 ВАЖНО: Парсим документы ПЕРЕД возвратом
|
||||
// Парсим документы ПЕРЕД возвратом
|
||||
const appendix = DomainToBlockchainUtils.convertChainDocumentToDomainFormat(value.appendix);
|
||||
|
||||
// Парсим документы
|
||||
return { ...value, appendix };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error mapping delta to blockchain data: ${error.message}`, error.stack);
|
||||
|
||||
+3
-11
@@ -256,20 +256,12 @@ export class DecisionExpiredNotificationService implements OnModuleInit, OnModul
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем дату последней проверки. ВАЖНО: read-modify-write по СВЕЖЕМУ
|
||||
// config из БД, а не по захваченному при initialize() снимку `plugin`.
|
||||
// Cron-замыкание держит in-memory снимок с момента boot; за время между
|
||||
// boot и тиком онбординг-флаги (`onboarding_*_done/_hash`) и прочие поля
|
||||
// могли быть записаны в БД другими сервисами. Перезапись устаревшего
|
||||
// снимка стёрла бы их (lost update). Поэтому берём актуальный config и
|
||||
// трогаем только lastCheckDate.
|
||||
const fresh = await this.extensionRepository.findByName('chairman');
|
||||
const baseConfig = fresh?.config ?? plugin.config;
|
||||
// Обновляем дату последней проверки (только это поле, чтобы не перезаписать другие параметры конфига)
|
||||
const updatedConfig = {
|
||||
...baseConfig,
|
||||
...plugin.config,
|
||||
lastCheckDate: new Date().toISOString(),
|
||||
};
|
||||
await this.extensionRepository.update({ name: 'chairman', config: updatedConfig });
|
||||
await this.extensionRepository.update({ ...plugin, config: updatedConfig });
|
||||
|
||||
this.logger.debug('Проверка истекших решений завершена');
|
||||
} catch (error) {
|
||||
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { IDelta } from '~/types/common';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '../../../../../shared/services/abstract-entity-sync.service';
|
||||
@@ -56,15 +56,6 @@ export class ApprovalSyncService
|
||||
async handleApprovalDelta(delta: IDelta): Promise<void> {
|
||||
await this.processDelta(delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработчик форков для одобрений
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleApprovalFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение поддерживаемых версий контрактов и таблиц
|
||||
*/
|
||||
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Matrix: комната секретаря — создаётся вручную председателем/советом из desktop.
|
||||
* Всегда незашифрована (E2EE секретарь транскрибировать не может). Тип (public/private)
|
||||
* выбирается при создании, поэтому isPrivate здесь не фиксируем — задаёт сервис.
|
||||
*/
|
||||
|
||||
import type { MatrixChatRoomPreset } from './matrix-chat-room-preset.types';
|
||||
|
||||
/**
|
||||
* Те же права, что у комнаты проекта Capital: модераторы (50) почти по всем настройкам;
|
||||
* 100 только у Matrix-админа для критичных state.
|
||||
*/
|
||||
function buildPowerLevels(adminUserId: string): Record<string, unknown> {
|
||||
return {
|
||||
users_default: 0,
|
||||
invite: 50,
|
||||
kick: 50,
|
||||
ban: 50,
|
||||
redact: 50,
|
||||
state_default: 50,
|
||||
events_default: 0,
|
||||
users: {
|
||||
[adminUserId]: 100,
|
||||
},
|
||||
events: {
|
||||
'm.room.name': 50,
|
||||
'm.room.topic': 50,
|
||||
'm.room.avatar': 50,
|
||||
'm.room.pinned_events': 50,
|
||||
'm.room.canonical_alias': 50,
|
||||
'm.room.aliases': 50,
|
||||
'm.room.power_levels': 100,
|
||||
'm.room.history_visibility': 50,
|
||||
'm.room.encryption': 100,
|
||||
'm.room.tombstone': 100,
|
||||
'm.room.server_acl': 100,
|
||||
|
||||
'im.vector.modular.widgets': 50,
|
||||
'm.widget': 50,
|
||||
'org.matrix.msc2876.widgets': 50,
|
||||
'io.element.widgets.layout': 50,
|
||||
|
||||
'io.element.call': 50,
|
||||
'io.element.call.member': 0,
|
||||
'org.matrix.msc3401.call': 50,
|
||||
'org.matrix.msc3401.call.member': 0,
|
||||
'm.call': 50,
|
||||
'm.call.invite': 50,
|
||||
'm.call.answer': 0,
|
||||
'm.call.hangup': 0,
|
||||
'm.call.candidates': 0,
|
||||
'm.call.select_answer': 0,
|
||||
'm.call.reject': 0,
|
||||
'm.call.negotiate': 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Базовый пресет комнаты секретаря. `isPrivate` переопределяется сервисом по флагу публичности.
|
||||
* `encrypt` всегда false — иначе секретарь не сможет читать сообщения и транскрибировать.
|
||||
*/
|
||||
export const SECRETARY_ROOM_MATRIX: MatrixChatRoomPreset = {
|
||||
label: 'Комната секретаря',
|
||||
isPrivate: true,
|
||||
encrypt: false,
|
||||
roomType: undefined,
|
||||
initialState: [],
|
||||
buildPowerLevels,
|
||||
};
|
||||
-26
@@ -23,32 +23,6 @@ export class ChatcoopProjectCommunicationRoomDTO {
|
||||
displayLabel!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип непроектной комнаты: пайщики, совет или комната секретаря.
|
||||
*/
|
||||
export enum NonProjectRoomKindGql {
|
||||
MEMBERS = 'MEMBERS',
|
||||
COUNCIL = 'COUNCIL',
|
||||
SECRETARY = 'SECRETARY',
|
||||
}
|
||||
|
||||
registerEnumType(NonProjectRoomKindGql, {
|
||||
name: 'NonProjectRoomKind',
|
||||
description: 'Тип комнаты вне проекта: пайщики, совет, комната секретаря',
|
||||
});
|
||||
|
||||
@ObjectType('ChatcoopNonProjectCommunicationRoom')
|
||||
export class ChatcoopNonProjectCommunicationRoomDTO {
|
||||
@Field({ description: 'Идентификатор комнаты Matrix' })
|
||||
matrixRoomId!: string;
|
||||
|
||||
@Field({ description: 'Подпись для отображения комнаты' })
|
||||
displayLabel!: string;
|
||||
|
||||
@Field(() => NonProjectRoomKindGql, { description: 'Тип комнаты (пайщики / совет / секретарь)' })
|
||||
kind!: NonProjectRoomKindGql;
|
||||
}
|
||||
|
||||
@ObjectType('ChatcoopRoomMessageLine')
|
||||
export class ChatcoopRoomMessageLineDTO {
|
||||
@Field(() => Float, { description: 'origin_server_ts из Matrix (мс)' })
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Field, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
import { IsBoolean, IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Тип комнаты в реестре ChatCoop. Редактировать (удалять) можно только `SECRETARY`;
|
||||
* системные (`MEMBERS`/`COUNCIL`) и проектные (`CAPITAL_PROJECT`) защищены.
|
||||
*/
|
||||
export enum ManagedRoomKindGql {
|
||||
MEMBERS = 'MEMBERS',
|
||||
COUNCIL = 'COUNCIL',
|
||||
CAPITAL_PROJECT = 'CAPITAL_PROJECT',
|
||||
SECRETARY = 'SECRETARY',
|
||||
}
|
||||
|
||||
registerEnumType(ManagedRoomKindGql, {
|
||||
name: 'ManagedRoomKind',
|
||||
description: 'Тип комнаты: пайщики, совет, проект Capital, комната секретаря',
|
||||
});
|
||||
|
||||
@ObjectType('ChatcoopSecretaryRoom')
|
||||
export class ChatcoopSecretaryRoomDTO {
|
||||
@Field({ description: 'Идентификатор комнаты Matrix' })
|
||||
matrixRoomId!: string;
|
||||
|
||||
@Field({ description: 'Название комнаты' })
|
||||
displayLabel!: string;
|
||||
|
||||
@Field(() => ManagedRoomKindGql, { description: 'Тип комнаты' })
|
||||
kind!: ManagedRoomKindGql;
|
||||
|
||||
@Field({ description: 'Комната зашифрована (E2EE) — секретарь не транскрибирует такие комнаты' })
|
||||
encrypted!: boolean;
|
||||
|
||||
@Field({ description: 'Секретарь присутствует в комнате' })
|
||||
secretaryInRoom!: boolean;
|
||||
|
||||
@Field({ description: 'Можно ли удалить комнату из интерфейса (только комнаты секретаря)' })
|
||||
editable!: boolean;
|
||||
}
|
||||
|
||||
@InputType('CreateSecretaryRoomInput')
|
||||
export class CreateSecretaryRoomInputDTO {
|
||||
@Field({ description: 'Название комнаты' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(240)
|
||||
displayName!: string;
|
||||
|
||||
@Field({ description: 'Публичная комната (любой может войти) либо приватная (вход по приглашению создателя)' })
|
||||
@IsBoolean()
|
||||
isPublic!: boolean;
|
||||
}
|
||||
|
||||
@InputType('RemoveSecretaryRoomInput')
|
||||
export class RemoveSecretaryRoomInputDTO {
|
||||
@Field({ description: 'Идентификатор комнаты Matrix, которую нужно удалить' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
matrixRoomId!: string;
|
||||
}
|
||||
-32
@@ -11,33 +11,19 @@ import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import {
|
||||
ChatcoopNonProjectCommunicationRoomDTO,
|
||||
ChatcoopProjectCommunicationRoomDTO,
|
||||
ChatcoopRoomMessageLineDTO,
|
||||
GetMaxOriginServerTsForRoomInputDTO,
|
||||
GetProjectCommunicationRoomsInputDTO,
|
||||
GetRoomMessagesForUtcDateInputDTO,
|
||||
ListUtcDatesWithNewRoomMessagesInputDTO,
|
||||
NonProjectRoomKindGql,
|
||||
RoomMessageKindGql,
|
||||
} from '../dto/project-communication.dto';
|
||||
import type { InterNonProjectRoomKind } from '@coopenomics/inter';
|
||||
|
||||
function mapKind(kind: 'text' | 'audio'): RoomMessageKindGql {
|
||||
return kind === 'text' ? RoomMessageKindGql.TEXT : RoomMessageKindGql.AUDIO;
|
||||
}
|
||||
|
||||
function mapNonProjectKind(kind: InterNonProjectRoomKind): NonProjectRoomKindGql {
|
||||
switch (kind) {
|
||||
case 'members':
|
||||
return NonProjectRoomKindGql.MEMBERS;
|
||||
case 'council':
|
||||
return NonProjectRoomKindGql.COUNCIL;
|
||||
case 'secretary':
|
||||
return NonProjectRoomKindGql.SECRETARY;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Доступ к данным переписки Capital↔Matrix для синхронизации (blago-cli, секретарь).
|
||||
* Источник — порт `INTER_PROJECT_COMMUNICATION_ARTIFACTS` (пакет inter).
|
||||
@@ -75,24 +61,6 @@ export class ProjectCommunicationResolver {
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => [ChatcoopNonProjectCommunicationRoomDTO], {
|
||||
name: 'chatcoopListNonProjectCommunicationRooms',
|
||||
description: 'Комнаты Matrix вне проектов Capital (пайщики/совет/секретарь) — для синхронизации в blago',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member', 'user'])
|
||||
async listNonProjectCommunicationRooms(
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<ChatcoopNonProjectCommunicationRoomDTO[]> {
|
||||
this.ensureComm();
|
||||
this.logger.debug(`chatcoopListNonProjectCommunicationRooms user=${user.username}`);
|
||||
const rooms = await this.comm!.listNonProjectCommunicationRooms();
|
||||
return rooms.map((r) => ({
|
||||
matrixRoomId: r.matrixRoomId,
|
||||
displayLabel: r.displayLabel,
|
||||
kind: mapNonProjectKind(r.kind),
|
||||
}));
|
||||
}
|
||||
|
||||
@Query(() => [String], {
|
||||
name: 'chatcoopListUtcDatesWithNewRoomMessages',
|
||||
description:
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||
import { Logger, UseGuards } from '@nestjs/common';
|
||||
import { ActiveUserStatusGuard } from '~/application/auth/guards/active-user-status.guard';
|
||||
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
|
||||
import { RolesGuard } from '~/application/auth/guards/roles.guard';
|
||||
import { AuthRoles } from '~/application/auth/decorators/auth.decorator';
|
||||
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
|
||||
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
|
||||
import { SecretaryRoomManagementService } from '../services/secretary-room-management.service';
|
||||
import {
|
||||
ChatcoopSecretaryRoomDTO,
|
||||
CreateSecretaryRoomInputDTO,
|
||||
ManagedRoomKindGql,
|
||||
RemoveSecretaryRoomInputDTO,
|
||||
} from '../dto/secretary-room.dto';
|
||||
import type { ChatcoopManagedMatrixRoomKind } from '../../domain/entities/managed-matrix-room.entity';
|
||||
import type { ManagedMatrixRoomDomainEntity } from '../../domain/entities/managed-matrix-room.entity';
|
||||
|
||||
function mapManagedKind(kind: ChatcoopManagedMatrixRoomKind): ManagedRoomKindGql {
|
||||
switch (kind) {
|
||||
case 'members':
|
||||
return ManagedRoomKindGql.MEMBERS;
|
||||
case 'council':
|
||||
return ManagedRoomKindGql.COUNCIL;
|
||||
case 'capital_project':
|
||||
return ManagedRoomKindGql.CAPITAL_PROJECT;
|
||||
case 'secretary':
|
||||
return ManagedRoomKindGql.SECRETARY;
|
||||
}
|
||||
}
|
||||
|
||||
function toDto(room: ManagedMatrixRoomDomainEntity): ChatcoopSecretaryRoomDTO {
|
||||
return {
|
||||
matrixRoomId: room.matrixRoomId,
|
||||
displayLabel: room.displayLabel || room.matrixRoomId,
|
||||
kind: mapManagedKind(room.kind),
|
||||
encrypted: room.encrypted,
|
||||
secretaryInRoom: room.secretaryInRoom,
|
||||
editable: room.kind === 'secretary',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Управление комнатами секретаря (стол связи desktop).
|
||||
* Доступ — председатель и члены совета: они создают комнаты для звонков с секретарём и удаляют свои.
|
||||
*/
|
||||
@Resolver()
|
||||
@UseGuards(GqlJwtAuthGuard, RolesGuard, ActiveUserStatusGuard)
|
||||
export class SecretaryRoomsResolver {
|
||||
private readonly logger = new Logger(SecretaryRoomsResolver.name);
|
||||
|
||||
constructor(private readonly service: SecretaryRoomManagementService) {}
|
||||
|
||||
@Query(() => [ChatcoopSecretaryRoomDTO], {
|
||||
name: 'chatcoopListSecretaryRooms',
|
||||
description: 'Все комнаты реестра ChatCoop (системные/проектные — read-only, комнаты секретаря — удаляемые)',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async listSecretaryRooms(
|
||||
@CurrentUser() user: MonoAccountDomainInterface
|
||||
): Promise<ChatcoopSecretaryRoomDTO[]> {
|
||||
this.logger.debug(`chatcoopListSecretaryRooms user=${user.username}`);
|
||||
const rooms = await this.service.listRooms();
|
||||
return rooms.map(toDto);
|
||||
}
|
||||
|
||||
@Mutation(() => ChatcoopSecretaryRoomDTO, {
|
||||
name: 'chatcoopCreateSecretaryRoom',
|
||||
description: 'Создать комнату с секретарём (публичную или приватную); секретарь подключается сразу',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async createSecretaryRoom(
|
||||
@CurrentUser() user: MonoAccountDomainInterface,
|
||||
@Args('data', { type: () => CreateSecretaryRoomInputDTO }) data: CreateSecretaryRoomInputDTO
|
||||
): Promise<ChatcoopSecretaryRoomDTO> {
|
||||
this.logger.log(`chatcoopCreateSecretaryRoom user=${user.username} isPublic=${data.isPublic}`);
|
||||
const room = await this.service.createSecretaryRoom({
|
||||
creatorUsername: user.username,
|
||||
creatorRole: user.role,
|
||||
displayName: data.displayName,
|
||||
isPublic: data.isPublic,
|
||||
});
|
||||
return toDto(room);
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
name: 'chatcoopRemoveSecretaryRoom',
|
||||
description: 'Удалить комнату секретаря: вывести секретаря и снять комнату с синхронизации (возвращает matrixRoomId)',
|
||||
})
|
||||
@AuthRoles(['chairman', 'member'])
|
||||
async removeSecretaryRoom(
|
||||
@CurrentUser() user: MonoAccountDomainInterface,
|
||||
@Args('data', { type: () => RemoveSecretaryRoomInputDTO }) data: RemoveSecretaryRoomInputDTO
|
||||
): Promise<string> {
|
||||
this.logger.log(`chatcoopRemoveSecretaryRoom user=${user.username} room=${data.matrixRoomId}`);
|
||||
return this.service.removeSecretaryRoom(data.matrixRoomId);
|
||||
}
|
||||
}
|
||||
-42
@@ -818,48 +818,6 @@ export class MatrixApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Приглашает пользователя в комнату (от имени Matrix-админа, который состоит в комнате).
|
||||
* Пользователь увидит приглашение и войдёт сам — в отличие от {@link joinRoom} (force-join).
|
||||
*/
|
||||
async inviteUser(userId: string, roomId: string): Promise<void> {
|
||||
try {
|
||||
const adminToken = await this.loginAdmin();
|
||||
await this.httpClient.post(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`,
|
||||
{ user_id: userId },
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } }
|
||||
);
|
||||
this.logger.log(`Пользователь ${userId} приглашён в комнату ${roomId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Не удалось пригласить пользователя ${userId} в комнату ${roomId}: ${JSON.stringify(error?.response?.data)}`
|
||||
);
|
||||
throw new Error('Не удалось пригласить пользователя в комнату');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Исключает пользователя из комнаты (от имени Matrix-админа с power 100).
|
||||
* Используется для вывода секретаря из комнаты по требованию председателя/совета.
|
||||
*/
|
||||
async kickUser(userId: string, roomId: string, reason?: string): Promise<void> {
|
||||
try {
|
||||
const adminToken = await this.loginAdmin();
|
||||
await this.httpClient.post(
|
||||
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`,
|
||||
reason ? { user_id: userId, reason } : { user_id: userId },
|
||||
{ headers: { Authorization: `Bearer ${adminToken}` } }
|
||||
);
|
||||
this.logger.log(`Пользователь ${userId} исключён из комнаты ${roomId}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Не удалось исключить пользователя ${userId} из комнаты ${roomId}: ${JSON.stringify(error?.response?.data)}`
|
||||
);
|
||||
throw new Error('Не удалось исключить пользователя из комнаты');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текущие права пользователей в комнате
|
||||
*/
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { MatrixApiService } from './matrix-api.service';
|
||||
import { ChatCoopApplicationService } from './chatcoop-application.service';
|
||||
import { MatrixUserManagementService } from '../../domain/services/matrix-user-management.service';
|
||||
import { SECRETARY_ROOM_MATRIX } from '../config/matrix-secretary-room.config';
|
||||
import {
|
||||
CHATCOOP_MANAGED_MATRIX_ROOM_REPOSITORY,
|
||||
type ChatcoopManagedMatrixRoomRepository,
|
||||
} from '../../domain/repositories/managed-matrix-room.repository';
|
||||
import {
|
||||
CHATCOOP_STATE_REPOSITORY,
|
||||
type ChatcoopStateRepository,
|
||||
} from '../../domain/repositories/chatcoop-state.repository';
|
||||
import type { ManagedMatrixRoomDomainEntity } from '../../domain/entities/managed-matrix-room.entity';
|
||||
|
||||
export interface CreateSecretaryRoomInput {
|
||||
/** Логин пайщика-создателя (председатель или член совета) */
|
||||
creatorUsername: string;
|
||||
/** Роль создателя в кооперативе — для прав в комнате (chairman / member) */
|
||||
creatorRole: string;
|
||||
/** Название комнаты */
|
||||
displayName: string;
|
||||
/** true — публичная (любой может войти), false — приватная (вход по приглашению) */
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Управление комнатами секретаря: создание/удаление и список всех комнат реестра.
|
||||
*
|
||||
* Принцип безопасности: секретарь присутствует ТОЛЬКО в комнатах, созданных нашим бэкендом
|
||||
* (kind `secretary`, а также системные members/council и проектные capital_project). Произвольные
|
||||
* «чужие» Matrix-комнаты сюда не подключаются — Synapse общий на все кооперативы, и force-join в
|
||||
* чужую комнату был бы дырой.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SecretaryRoomManagementService {
|
||||
private readonly logger = new Logger(SecretaryRoomManagementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly matrixApi: MatrixApiService,
|
||||
private readonly chatCoopApplicationService: ChatCoopApplicationService,
|
||||
private readonly matrixUserManagement: MatrixUserManagementService,
|
||||
@Inject(CHATCOOP_MANAGED_MATRIX_ROOM_REPOSITORY)
|
||||
private readonly managedRooms: ChatcoopManagedMatrixRoomRepository,
|
||||
@Inject(CHATCOOP_STATE_REPOSITORY)
|
||||
private readonly chatcoopState: ChatcoopStateRepository
|
||||
) {}
|
||||
|
||||
async listRooms(): Promise<ManagedMatrixRoomDomainEntity[]> {
|
||||
return this.managedRooms.findAll();
|
||||
}
|
||||
|
||||
async createSecretaryRoom(input: CreateSecretaryRoomInput): Promise<ManagedMatrixRoomDomainEntity> {
|
||||
const st = await this.chatcoopState.getSingleton();
|
||||
if (!st.isInitialized || !st.spaceId || st.spaceId.trim().length === 0) {
|
||||
throw new BadRequestException('ChatCoop не инициализирован — нельзя создать комнату секретаря');
|
||||
}
|
||||
const secretaryId = st.secretaryMatrixUserId;
|
||||
if (typeof secretaryId !== 'string' || secretaryId.trim().length === 0) {
|
||||
throw new BadRequestException('Секретарь не инициализирован — нельзя создать комнату секретаря');
|
||||
}
|
||||
const displayName = input.displayName.trim();
|
||||
if (displayName.length === 0) {
|
||||
throw new BadRequestException('Название комнаты не может быть пустым');
|
||||
}
|
||||
|
||||
const adminUserId = this.matrixApi.getAdminUserId();
|
||||
const powerLevels = SECRETARY_ROOM_MATRIX.buildPowerLevels(adminUserId);
|
||||
const isPrivate = !input.isPublic;
|
||||
|
||||
const roomId = await this.matrixApi.createRoom(
|
||||
displayName.slice(0, 240),
|
||||
`Комната секретаря — создал ${input.creatorUsername}`,
|
||||
isPrivate,
|
||||
SECRETARY_ROOM_MATRIX.roomType,
|
||||
SECRETARY_ROOM_MATRIX.initialState.length > 0 ? SECRETARY_ROOM_MATRIX.initialState : undefined,
|
||||
// Комната секретаря всегда незашифрована — иначе секретарь не читает сообщения и не транскрибирует.
|
||||
false,
|
||||
powerLevels as Record<string, unknown>
|
||||
);
|
||||
|
||||
await this.matrixApi.addRoomToSpace(st.spaceId.trim(), roomId);
|
||||
|
||||
// Force-join только секретарь.
|
||||
await this.matrixApi.joinRoom(secretaryId.trim(), roomId);
|
||||
|
||||
// Создатель сразу входит в свою комнату с правами модератора.
|
||||
const creatorMatrix = await this.matrixUserManagement.getMatrixUserByCoopUsername(input.creatorUsername);
|
||||
if (creatorMatrix) {
|
||||
try {
|
||||
await this.matrixApi.joinRoom(creatorMatrix.matrixUserId, roomId);
|
||||
await this.chatCoopApplicationService.applyMembersRoomStylePowerForUser(
|
||||
creatorMatrix.matrixUserId,
|
||||
roomId,
|
||||
input.creatorRole
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Не удалось ввести создателя ${input.creatorUsername} в комнату ${roomId}: ${String(err)}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn(`Нет Matrix-аккаунта для создателя ${input.creatorUsername} — он войдёт позже сам`);
|
||||
}
|
||||
|
||||
const room = await this.managedRooms.upsertRoom({
|
||||
matrixRoomId: roomId,
|
||||
encrypted: false,
|
||||
kind: 'secretary',
|
||||
displayLabel: displayName,
|
||||
projectHash: null,
|
||||
secretaryInRoom: true,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Создана комната секретаря ${roomId} (${isPrivate ? 'приватная' : 'публичная'}) создателем ${input.creatorUsername}`
|
||||
);
|
||||
return room;
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет комнату секретаря: выводит секретаря из Matrix-комнаты и убирает запись из реестра ChatCoop.
|
||||
* Сама Matrix-комната не уничтожается — её участники сохраняют доступ и историю, но транскрипция/синхронизация прекращаются.
|
||||
* Разрешено только для kind `secretary` — системные и проектные комнаты так удалять нельзя.
|
||||
*/
|
||||
async removeSecretaryRoom(matrixRoomId: string): Promise<string> {
|
||||
const room = await this.managedRooms.findByMatrixRoomId(matrixRoomId);
|
||||
if (!room) {
|
||||
throw new NotFoundException('Комната не найдена в реестре ChatCoop');
|
||||
}
|
||||
if (room.kind !== 'secretary') {
|
||||
throw new BadRequestException('Удалять можно только комнаты секретаря (системные и проектные защищены)');
|
||||
}
|
||||
|
||||
const st = await this.chatcoopState.getSingleton();
|
||||
const secretaryId = st.secretaryMatrixUserId;
|
||||
if (typeof secretaryId === 'string' && secretaryId.trim().length > 0) {
|
||||
try {
|
||||
await this.matrixApi.kickUser(secretaryId.trim(), matrixRoomId, 'Комната секретаря удалена');
|
||||
} catch (err) {
|
||||
this.logger.warn(`Не удалось вывести секретаря из ${matrixRoomId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.managedRooms.setSecretaryInRoom(matrixRoomId, false);
|
||||
await this.managedRooms.deleteByMatrixRoomId(matrixRoomId);
|
||||
this.logger.log(`Комната секретаря ${matrixRoomId} удалена из реестра`);
|
||||
return matrixRoomId;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,6 @@ import { ChatCoopCalendarResolver } from './application/resolvers/chatcoop-calen
|
||||
import { ChatCoopCalendarFeedController } from './application/controllers/chatcoop-calendar-feed.controller';
|
||||
import { TranscriptionResolver } from './application/resolvers/transcription.resolver';
|
||||
import { ProjectCommunicationResolver } from './application/resolvers/project-communication.resolver';
|
||||
import { SecretaryRoomsResolver } from './application/resolvers/secretary-rooms.resolver';
|
||||
import { SecretaryRoomManagementService } from './application/services/secretary-room-management.service';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { z } from 'zod';
|
||||
@@ -529,7 +527,6 @@ export class ChatCoopPlugin extends BaseExtModule {
|
||||
// Application Services
|
||||
ChatCoopApplicationService,
|
||||
CapitalProjectMatrixSyncService,
|
||||
SecretaryRoomManagementService,
|
||||
MatrixApiService,
|
||||
ChatCoopSecretaryMatrixTokenService,
|
||||
SecretaryAgentService,
|
||||
@@ -607,7 +604,6 @@ export class ChatCoopPlugin extends BaseExtModule {
|
||||
TranscriptionResolver,
|
||||
ActiveUserStatusGuard,
|
||||
ProjectCommunicationResolver,
|
||||
SecretaryRoomsResolver,
|
||||
],
|
||||
exports: [
|
||||
ChatCoopPlugin,
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Тип комнаты Matrix, зарегистрированной в реестре ChatCoop (централизованное хранение).
|
||||
*/
|
||||
export type ChatcoopManagedMatrixRoomKind = 'members' | 'council' | 'capital_project' | 'secretary';
|
||||
export type ChatcoopManagedMatrixRoomKind = 'members' | 'council' | 'capital_project';
|
||||
|
||||
/**
|
||||
* Запись о Matrix-комнате кооператива: источник правды для LiveKit/секретаря; совет при миграции v3 из legacy-конфига.
|
||||
|
||||
-4
@@ -24,10 +24,6 @@ export interface ChatcoopManagedMatrixRoomRepository {
|
||||
findByKind(kind: ChatcoopManagedMatrixRoomKind): Promise<ManagedMatrixRoomDomainEntity[]>;
|
||||
/** Комнаты проекта Capital (kind capital_project, projectHash задан). */
|
||||
findByProjectHash(projectHash: string): Promise<ManagedMatrixRoomDomainEntity[]>;
|
||||
/** Все комнаты реестра (для интерфейса управления присутствием секретаря). */
|
||||
findAll(): Promise<ManagedMatrixRoomDomainEntity[]>;
|
||||
/** Комнаты, не привязанные к проекту Capital (members/council/secretary) — для синхронизации в blago. */
|
||||
findNonProjectCommunicationRooms(): Promise<ManagedMatrixRoomDomainEntity[]>;
|
||||
/** Комнаты, в которых секретарь может участвовать в звонке и писать plaintext в Matrix */
|
||||
findEligibleForSecretaryTranscription(): Promise<ManagedMatrixRoomDomainEntity[]>;
|
||||
/** Обновить флаг членства секретаря (после успешного join или проверки Matrix) */
|
||||
|
||||
-15
@@ -1,8 +1,6 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
InterCompletedCallTranscriptionHead,
|
||||
InterNonProjectCommunicationRoomRef,
|
||||
InterNonProjectRoomKind,
|
||||
InterProjectCommunicationArtifactsPort,
|
||||
InterProjectCommunicationRoomRef,
|
||||
InterRoomMessageLine,
|
||||
@@ -50,19 +48,6 @@ export class ChatcoopInterProjectCommunicationArtifactsAdapter implements InterP
|
||||
}));
|
||||
}
|
||||
|
||||
async listNonProjectCommunicationRooms(): Promise<InterNonProjectCommunicationRoomRef[]> {
|
||||
const rooms = await this.managedRooms.findNonProjectCommunicationRooms();
|
||||
return rooms
|
||||
.filter((r): r is typeof r & { kind: InterNonProjectRoomKind } =>
|
||||
r.kind === 'members' || r.kind === 'council' || r.kind === 'secretary'
|
||||
)
|
||||
.map((r) => ({
|
||||
matrixRoomId: r.matrixRoomId,
|
||||
displayLabel: r.displayLabel || r.matrixRoomId,
|
||||
kind: r.kind,
|
||||
}));
|
||||
}
|
||||
|
||||
async listUtcDatesWithNewMessages(
|
||||
matrixRoomId: string,
|
||||
afterOriginServerTsExclusive: number
|
||||
|
||||
+1
-11
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type {
|
||||
ChatcoopManagedMatrixRoomRepository,
|
||||
UpsertManagedMatrixRoomInput,
|
||||
@@ -56,16 +56,6 @@ export class ManagedMatrixRoomTypeormRepository implements ChatcoopManagedMatrix
|
||||
return rows.map(ManagedMatrixRoomMapper.toDomain);
|
||||
}
|
||||
|
||||
async findAll(): Promise<ManagedMatrixRoomDomainEntity[]> {
|
||||
const rows = await this.repository.find();
|
||||
return rows.map(ManagedMatrixRoomMapper.toDomain);
|
||||
}
|
||||
|
||||
async findNonProjectCommunicationRooms(): Promise<ManagedMatrixRoomDomainEntity[]> {
|
||||
const rows = await this.repository.find({ where: { roomKind: Not('capital_project') } });
|
||||
return rows.map(ManagedMatrixRoomMapper.toDomain);
|
||||
}
|
||||
|
||||
async findEligibleForSecretaryTranscription(): Promise<ManagedMatrixRoomDomainEntity[]> {
|
||||
const rows = await this.repository.find({ where: { encrypted: false } });
|
||||
return rows.map(ManagedMatrixRoomMapper.toDomain);
|
||||
|
||||
@@ -64,29 +64,6 @@ export class MeetTrackerService {
|
||||
this.logger.info('MeetTrackerService инициализирован');
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет состояние трекинга собраний read-modify-write по СВЕЖЕМУ config.
|
||||
*
|
||||
* `this.pluginConfig` — снимок, захваченный при initialize() (boot), а
|
||||
* cron-проверка крутится часами; update() заменяет весь config JSONB целиком.
|
||||
* Если писать захваченный снимок, затрутся поля, записанные в БД после boot
|
||||
* другими сервисами (онбординг-флаги и т.п.) — это и есть наблюдавшийся
|
||||
* сброс онбординга. Поэтому берём актуальный config из БД и накладываем
|
||||
* только поля, которыми владеет meet-tracker.
|
||||
*/
|
||||
private async persistTrackedState(): Promise<void> {
|
||||
const fresh = await this.extensionRepository.findByName(this.extensionName);
|
||||
const baseConfig = fresh?.config ?? this.pluginConfig.config;
|
||||
const nextConfig: IConfig = {
|
||||
...baseConfig,
|
||||
trackedMeets: this.pluginConfig.config.trackedMeets,
|
||||
closedMeetIds: this.pluginConfig.config.closedMeetIds,
|
||||
lastCheckTimestamp: this.pluginConfig.config.lastCheckTimestamp,
|
||||
};
|
||||
await this.extensionRepository.update({ name: this.extensionName, config: nextConfig });
|
||||
this.pluginConfig = { ...this.pluginConfig, config: nextConfig };
|
||||
}
|
||||
|
||||
// Приватная функция для обновления trackedMeet на основе свежих данных
|
||||
private getUpdatedTrackedMeet(trackedMeet: TrackedMeet, meetData: any, extendedStatus: string): TrackedMeet {
|
||||
return {
|
||||
@@ -168,7 +145,7 @@ export class MeetTrackerService {
|
||||
this.pluginConfig.config.trackedMeets[trackedMeetIndex].notifications.endNotification = true;
|
||||
if (!closedMeetIds.includes(meetID)) {
|
||||
closedMeetIds.push(meetID);
|
||||
await this.persistTrackedState();
|
||||
await this.extensionRepository.update(this.pluginConfig);
|
||||
}
|
||||
}
|
||||
this.logger.info(`Удаление закрытого собрания ${meetHash} (№${meetID}) из списка отслеживаемых`);
|
||||
@@ -333,7 +310,7 @@ export class MeetTrackerService {
|
||||
this.pluginConfig.config.lastCheckTimestamp = now.toISOString();
|
||||
|
||||
// Сохраняем изменения в конфигурации
|
||||
await this.persistTrackedState();
|
||||
await this.extensionRepository.update(this.pluginConfig);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка при проверке собраний: ${error.message}`, error.stack);
|
||||
}
|
||||
|
||||
@@ -215,18 +215,8 @@ export class PowerupPlugin extends BaseExtModule implements OnModuleDestroy {
|
||||
|
||||
await this.blockchainPort.powerUp(username, quantity);
|
||||
|
||||
// read-modify-write по СВЕЖЕМУ config: daily-cron держит in-memory снимок
|
||||
// `this.plugin` с момента boot, а update() заменяет весь config JSONB
|
||||
// целиком. Перезапись устаревшего снимка стёрла бы поля, записанные за
|
||||
// сутки другими сервисами (онбординг и т.п.). Берём актуальный config и
|
||||
// трогаем только lastDailyReplenishmentDate.
|
||||
const fresh = await this.extensionRepository.findByName(this.name);
|
||||
const nextConfig = {
|
||||
...(fresh?.config ?? this.plugin.config),
|
||||
lastDailyReplenishmentDate: new Date().toISOString(),
|
||||
};
|
||||
await this.extensionRepository.update({ name: this.name, config: nextConfig });
|
||||
this.plugin = { ...this.plugin, config: nextConfig };
|
||||
this.plugin.config.lastDailyReplenishmentDate = new Date().toISOString();
|
||||
await this.extensionRepository.update(this.plugin);
|
||||
|
||||
await this.log({
|
||||
type: 'daily',
|
||||
|
||||
+186
-215
@@ -1,225 +1,169 @@
|
||||
// infrastructure/blockchain/blockchain-consumer.service.ts
|
||||
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ParserClient, type ParserEvent } from '@coopenomics/parser2';
|
||||
import { IAction, IDelta } from '~/types/common';
|
||||
import { RedisStreamService, StreamMessage } from '~/infrastructure/redis/redis-stream.service';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { EventsService } from '~/infrastructure/events/events.service';
|
||||
import { ParserInteractor } from '~/domain/parser/interactors/parser.interactor';
|
||||
import { ForkRegistryService } from '~/shared/sync/fork';
|
||||
import { computeActionEventId, computeDeltaEventId, computeForkEventId } from './event-id.util';
|
||||
import { mapParserActionToIAction, mapParserDeltaToIDelta } from './parser2-event.mapper';
|
||||
import { config } from '~/config';
|
||||
|
||||
export interface BlockchainEventData {
|
||||
type: string;
|
||||
event?: IAction;
|
||||
delta?: IDelta;
|
||||
block_num?: number;
|
||||
}
|
||||
|
||||
// Выносим исключения в конфиг или отдельный файл
|
||||
const ACTION_EXCEPTIONS = {
|
||||
'eosio.token': ['transfer', 'issue'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Инфраструктурный сервис потребления событий блокчейна из Redis
|
||||
* Читает события из Redis стрима и публикует их во внутреннюю шину событий
|
||||
* Не содержит бизнес-логики, только предварительную фильтрацию
|
||||
* Потребление событий блокчейна из parser2 (@coopenomics/parser2).
|
||||
*
|
||||
* Транспорт: единственный — ParserClient поверх Redis Stream parser2
|
||||
* (`ce:parser2:<chain_id>:events`). Старый самодельный consumer поверх стрима
|
||||
* `notifications` (его писал parser1, components/parser) удалён. Никаких флагов и
|
||||
* параллельной работы двух движков: либо контроллер работает на parser2, либо нет.
|
||||
*
|
||||
* ParserClient берёт на себя то, что раньше делала ручная обвязка консьюмера:
|
||||
* consumer-group, XREADGROUP/XACK, single-active-lock, recover-own-pending,
|
||||
* dead-letter после N провалов, XTRIM. Контроллеру остаётся только обработка.
|
||||
*
|
||||
* event_id (дедуп, INV-09) вычисляется локально из полей события — формат
|
||||
* action/delta (см. event-id.util.ts). parser2 кладёт свой event_id в событие,
|
||||
* но контроллер ведёт собственный consumer_dedup в привычном формате.
|
||||
*
|
||||
* Обработчики processAction/processDelta/processFork не изменились при смене
|
||||
* транспорта — маппер переводит ParserEvent → IDelta/IAction (DEC-T09).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly streamName = 'notifications';
|
||||
private readonly consumerGroup = 'blockchain-consumer';
|
||||
/**
|
||||
* Стабильное имя consumer'а. Раньше было `consumer-${random}`, и каждый
|
||||
* рестарт coopback создавал нового consumer'а, а прежний оставался в
|
||||
* группе со своими pending-сообщениями — зомби-consumer. Со стабильным
|
||||
* именем мы всегда возвращаемся к «своему» pending-списку после рестарта
|
||||
* и можем его доиграть (readOwnPending на старте).
|
||||
*
|
||||
* Если понадобится horizontal-scaling (несколько реплик coopback'а) —
|
||||
* имя можно расширить до `coopback-${HOSTNAME}` через env.
|
||||
*/
|
||||
private readonly consumerName = 'coopback-main';
|
||||
/** Имя подписки = имя consumer-group parser2. Детерминировано по кооперативу. */
|
||||
private readonly subscriptionId = `controller-${config.coopname}`;
|
||||
|
||||
/** Как долго pending другого consumer'а должен висеть, прежде чем его можно забрать. */
|
||||
private readonly staleClaimIdleMs = 5 * 60 * 1000; // 5 минут
|
||||
/** Период XAUTOCLAIM поиска stale pending. */
|
||||
private readonly claimIntervalMs = 60 * 1000; // 1 минута
|
||||
/** Период XTRIM MINID для освобождения памяти Redis от consumed сообщений. */
|
||||
private readonly trimIntervalMs = 30 * 1000; // 30 секунд
|
||||
/** Пауза перед переподключением, если поток ParserClient неожиданно упал. */
|
||||
private readonly reconnectDelayMs = 5000;
|
||||
|
||||
private claimTimer?: NodeJS.Timeout;
|
||||
private trimTimer?: NodeJS.Timeout;
|
||||
private client?: ParserClient;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly redisStreamService: RedisStreamService,
|
||||
private readonly logger: WinstonLoggerService,
|
||||
private readonly eventsService: EventsService,
|
||||
private readonly parserInteractor: ParserInteractor
|
||||
private readonly parserInteractor: ParserInteractor,
|
||||
private readonly forkRegistry: ForkRegistryService
|
||||
) {
|
||||
this.logger.setContext(BlockchainConsumerService.name);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('Инициализация сервиса потребителя блокчейна');
|
||||
await this.redisStreamService.createConsumerGroup(this.streamName, this.consumerGroup);
|
||||
|
||||
// 1) Сначала доиграть свои pending (могли остаться после crash'а между
|
||||
// handleMessage и xack). Если их нет — мгновенно пройдёт.
|
||||
await this.recoverOwnPending();
|
||||
|
||||
// 2) Забрать pending у зомби-consumer'ов предыдущих рестартов,
|
||||
// чтобы они не висели вечно. XAUTOCLAIM idempotent.
|
||||
await this.reclaimStalePending();
|
||||
|
||||
// 3) Запустить основной consumer loop (на новые сообщения, `>`).
|
||||
this.startConsuming();
|
||||
|
||||
// 4) Фоновые задачи: периодический XAUTOCLAIM + XTRIM MINID.
|
||||
this.claimTimer = setInterval(
|
||||
() => this.reclaimStalePending().catch((e) => this.logger.error(`claim tick: ${e?.message}`, e?.stack)),
|
||||
this.claimIntervalMs,
|
||||
);
|
||||
this.trimTimer = setInterval(
|
||||
() => this.trimConsumed().catch((e) => this.logger.error(`trim tick: ${e?.message}`, e?.stack)),
|
||||
this.trimIntervalMs,
|
||||
);
|
||||
this.logger.log('Инициализация потребителя событий parser2');
|
||||
this.running = true;
|
||||
// Не await: цикл живёт всё время работы приложения.
|
||||
void this.runConsumeLoop();
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.logger.log('Остановка сервиса потребителя блокчейна');
|
||||
if (this.claimTimer) clearInterval(this.claimTimer);
|
||||
if (this.trimTimer) clearInterval(this.trimTimer);
|
||||
this.redisStreamService.stopConsumer(this.streamName, this.consumerGroup, this.consumerName);
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('Остановка потребителя событий parser2');
|
||||
this.running = false;
|
||||
if (this.client) {
|
||||
await this.client.close().catch((e) => this.logger.error(`Ошибка close ParserClient: ${e?.message}`, e?.stack));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* После рестарта контроллера читаем pending, адресованные нашему consumer'у.
|
||||
* Это сообщения, которые Redis считает выданными нам, но ещё не ACK'нутыми —
|
||||
* например, процесс упал после handleMessage, до xack. Доигрываем в том же
|
||||
* порядке, ACK'аем — и дальше работает `>`-поток.
|
||||
* Внешний цикл: держит подписку живой. Если поток ParserClient завершился с
|
||||
* ошибкой (обрыв Redis и т.п.) — пауза и переподключение, пока сервис running.
|
||||
*/
|
||||
private async recoverOwnPending(): Promise<void> {
|
||||
let total = 0;
|
||||
// Читаем порциями до тех пор, пока pending не закончится.
|
||||
while (true) {
|
||||
const messages = await this.redisStreamService.readOwnPending(
|
||||
this.streamName,
|
||||
this.consumerGroup,
|
||||
this.consumerName,
|
||||
100,
|
||||
);
|
||||
if (messages.length === 0) break;
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
await this.handleMessage(msg);
|
||||
await this.redisStreamService.acknowledgeMessage(this.streamName, this.consumerGroup, msg.messageId);
|
||||
total += 1;
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`recoverOwnPending: не удалось переобработать ${msg.messageId}: ${err?.message}`,
|
||||
err?.stack,
|
||||
);
|
||||
// Оставляем pending — claim retry по idle разберёт.
|
||||
}
|
||||
private async runConsumeLoop(): Promise<void> {
|
||||
while (this.running) {
|
||||
try {
|
||||
await this.consume();
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Поток ParserClient прерван: ${err?.message}`, err?.stack);
|
||||
}
|
||||
if (this.client) {
|
||||
await this.client.close().catch(() => undefined);
|
||||
this.client = undefined;
|
||||
}
|
||||
if (this.running) {
|
||||
await new Promise((r) => setTimeout(r, this.reconnectDelayMs));
|
||||
this.logger.warn('Переподключение к parser2…');
|
||||
}
|
||||
}
|
||||
if (total > 0) this.logger.log(`recoverOwnPending: доиграно ${total} сообщений`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Забрать pending другого consumer'а, который idle > staleClaimIdleMs.
|
||||
* Обычный кейс: зомби-consumer из прошлой сессии (если в БД Redis остались
|
||||
* следы старого случайного имени `consumer-${random}` до этого фикса).
|
||||
* Также защита от split-brain, если когда-нибудь появится несколько реплик.
|
||||
* Один проход подписки. Управляем генератором вручную: it.next() подтверждает
|
||||
* (XACK внутри ParserClient) успешно обработанное событие, it.throw(err) при
|
||||
* ошибке обработчика запускает учёт провалов parser2 (PEL-retry / dead-letter
|
||||
* после порога) — событие НЕ ACK'ается молча. Наивный `for await` тут неверен:
|
||||
* проброс из тела вызывает iterator.return(), catch вокруг yield не срабатывает,
|
||||
* и одна ошибка убила бы консьюмер.
|
||||
*/
|
||||
private async reclaimStalePending(): Promise<void> {
|
||||
let claimed = 0;
|
||||
try {
|
||||
const messages = await this.redisStreamService.autoClaimStale(
|
||||
this.streamName,
|
||||
this.consumerGroup,
|
||||
this.consumerName,
|
||||
this.staleClaimIdleMs,
|
||||
100,
|
||||
);
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
await this.handleMessage(msg);
|
||||
await this.redisStreamService.acknowledgeMessage(this.streamName, this.consumerGroup, msg.messageId);
|
||||
claimed += 1;
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`reclaimStalePending: ошибка ${msg.messageId}: ${err?.message}`,
|
||||
err?.stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (claimed > 0) this.logger.warn(`reclaimStalePending: перехвачено и обработано ${claimed} stale-сообщений`);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`reclaimStalePending: ${err?.message}`, err?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* XTRIM MINID: освобождаем Redis от ACK-нутых сообщений.
|
||||
* Parser больше не делает XTRIM MAXLEN (burst'ы удаляли не-consumed), так что
|
||||
* обрезка — обязанность consumer'а, который _точно знает_ границу.
|
||||
*
|
||||
* Граница = first-pending-id (если pending не пусто) ИЛИ last-generated-id
|
||||
* (если ВСЕ сообщения consumed — тогда stream можно почистить полностью).
|
||||
* Всё что ≤ first-pending, уже ACK'нуто кем-то в группе — безопасно.
|
||||
*/
|
||||
private async trimConsumed(): Promise<void> {
|
||||
try {
|
||||
const firstPending = await this.redisStreamService.getFirstPendingId(this.streamName, this.consumerGroup);
|
||||
const trimId = firstPending ?? (await this.redisStreamService.getStreamLastId(this.streamName));
|
||||
if (!trimId || trimId === '0-0') return;
|
||||
await this.redisStreamService.trimUpTo(this.streamName, trimId);
|
||||
} catch (err: any) {
|
||||
this.logger.error(`trimConsumed: ${err?.message}`, err?.stack);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Запуск потребления сообщений из Redis стрима
|
||||
*/
|
||||
private async startConsuming(): Promise<void> {
|
||||
this.logger.log(`Starting consumer ${this.consumerName} for stream ${this.streamName}`);
|
||||
|
||||
await this.redisStreamService.startConsumer(
|
||||
{
|
||||
stream: this.streamName,
|
||||
group: this.consumerGroup,
|
||||
consumer: this.consumerName,
|
||||
count: 1,
|
||||
block: 1000,
|
||||
private async consume(): Promise<void> {
|
||||
this.client = new ParserClient({
|
||||
subscriptionId: this.subscriptionId,
|
||||
// Без фильтров: получаем все события, фильтрация по coopname — в processDelta/processAction.
|
||||
startFrom: 'last_known',
|
||||
redis: {
|
||||
url: `redis://${config.redis.host}:${config.redis.port}`,
|
||||
password: config.redis.password || undefined,
|
||||
},
|
||||
this.handleMessage.bind(this)
|
||||
);
|
||||
chain: { id: config.blockchain.id },
|
||||
// Жизненным циклом управляет NestJS (onModuleDestroy), не SIGTERM-хуки parser2.
|
||||
noSignalHandlers: true,
|
||||
});
|
||||
|
||||
this.logger.log(`Подписка parser2 "${this.subscriptionId}" на цепь ${config.blockchain.id}`);
|
||||
|
||||
const iterator = this.client.stream();
|
||||
let result = await iterator.next();
|
||||
while (!result.done && this.running) {
|
||||
try {
|
||||
await this.handleEvent(result.value);
|
||||
result = await iterator.next(); // успех → XACK внутри ParserClient
|
||||
} catch (err: any) {
|
||||
this.logger.error(`Ошибка обработки события parser2: ${err?.message}`, err?.stack);
|
||||
result = await iterator.throw(err); // провал → FailureTracker / dead-letter parser2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка входящего сообщения из стрима
|
||||
* Диспетчеризация события parser2 на обработчики контроллера.
|
||||
* native-delta контроллер не потребляет (нет легаси-пути) — пропускаем.
|
||||
*
|
||||
* fork-event (Story 4.1, AC INV-09): dedup-gate в handleEvent — повторно
|
||||
* доставленный fork с уже отмеченным event_id делает ранний return. Иначе
|
||||
* runAll/deleteAfterBlock/saveFork выполнятся повторно, что для ForkEntity
|
||||
* без UNIQUE-constraint породит дубль и нагрузку на репозитории syncer'ов.
|
||||
* markApplied идёт ПОСЛЕ успешного processFork (порядок симметричен dispatch'у
|
||||
* action/delta: save → mark, иначе сбой между save и mark = silent loss).
|
||||
*/
|
||||
private async handleMessage(message: StreamMessage): Promise<void> {
|
||||
try {
|
||||
const eventData: BlockchainEventData = JSON.parse(
|
||||
message.fields.event || message.fields.delta || message.fields.fork || '{}'
|
||||
);
|
||||
|
||||
if (eventData.event) {
|
||||
await this.processAction(eventData.event);
|
||||
} else if (eventData.delta) {
|
||||
await this.processDelta(eventData.delta);
|
||||
} else if (eventData.block_num !== undefined) {
|
||||
await this.processFork(eventData.block_num);
|
||||
} else {
|
||||
this.logger.warn(`Unknown message format: ${JSON.stringify(message.fields)}`);
|
||||
private async handleEvent(event: ParserEvent): Promise<void> {
|
||||
switch (event.kind) {
|
||||
case 'action':
|
||||
return this.processAction(mapParserActionToIAction(event));
|
||||
case 'delta':
|
||||
return this.processDelta(mapParserDeltaToIDelta(event));
|
||||
case 'fork': {
|
||||
// event_id вычисляем локально в controller-формате (chain:fork:...), а НЕ
|
||||
// берём event.event_id из parser2 (его формат chain:f:...) — иначе в
|
||||
// consumer_dedup смешаются две формулы, и дедуп между controller и
|
||||
// транспортом расползётся. См. event-id.util.ts.
|
||||
const eventId = computeForkEventId(event.chain_id, event.forked_from_block, event.new_head_block_id);
|
||||
if (await this.parserInteractor.isEventApplied(eventId)) {
|
||||
this.logger.debug(`Fork-дубликат пропущен (no-op): ${eventId}`);
|
||||
return;
|
||||
}
|
||||
await this.processFork(event.forked_from_block, eventId);
|
||||
await this.parserInteractor.markEventApplied(eventId, event.forked_from_block);
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Ошибка обработки сообщения ${message.messageId}: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку чтобы сообщение не было подтверждено
|
||||
case 'native-delta':
|
||||
return;
|
||||
default:
|
||||
this.logger.warn(`Неизвестный тип события parser2: ${JSON.stringify(event)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +172,7 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
* Выполняет минимальную предварительную фильтрацию, сохраняет в базу и
|
||||
* с задержкой публикует событие во внутреннюю шину.
|
||||
*
|
||||
* Порядок writes: сохранение → ACK (по возврату из handleMessage) →
|
||||
* Порядок writes: сохранение → ACK (по возврату из handleEvent) →
|
||||
* отложенный emit события через ACTION_EMIT_DELAY_MS. Задержка нужна
|
||||
* чтобы дельты, попавшие в стрим из того же блока что и action, успели
|
||||
* пройти обработчики и прописаться в БД ДО того, как обработчики action
|
||||
@@ -236,9 +180,8 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
* apprvappndx ищет appendix в capital_appendixes — без задержки гонится
|
||||
* с дельтой capital::appendixes того же блока). См. задачу #53.
|
||||
*
|
||||
* Ошибка saveAction бросается наверх в handleMessage → сообщение НЕ
|
||||
* подтверждается и остаётся pending (consumer перечитает его через
|
||||
* XCLAIM/re-delivery). Emit'ится только то, что успешно сохранено.
|
||||
* Ошибка saveAction бросается наверх в consume → событие НЕ подтверждается
|
||||
* (iterator.throw → parser2 учитывает провал). Emit'ится только сохранённое.
|
||||
*/
|
||||
private async processAction(action: IAction): Promise<void> {
|
||||
if (action.receiver != action.account) {
|
||||
@@ -248,14 +191,6 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
await this.processActionDelayed(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Задержка emit'а action-события — даёт время дельтам того же блока
|
||||
* (capital_appendixes, capital_projects, ...) сохраниться раньше, чем
|
||||
* обработчики action полезут их читать. Подобрана опытно: при <1.5с
|
||||
* на быстрой машине race ещё происходит, при ~3с гонок не наблюдаем.
|
||||
*/
|
||||
private static readonly ACTION_EMIT_DELAY_MS = 3000;
|
||||
|
||||
private async processActionDelayed(action: IAction): Promise<void> {
|
||||
// Проверяем, является ли действие исключением
|
||||
const isException = this.isActionException(action.account, action.name);
|
||||
@@ -266,6 +201,14 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: признак уникальности события (INV-09). Повторно доставленное
|
||||
// событие с уже отмеченным event_id игнорируется как no-op.
|
||||
const eventId = computeActionEventId(action);
|
||||
if (await this.parserInteractor.isEventApplied(eventId)) {
|
||||
this.logger.debug(`Action-дубликат пропущен (no-op): ${eventId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Сохраняем действие в базу данных через интерактор
|
||||
await this.parserInteractor.saveAction(action);
|
||||
@@ -274,13 +217,19 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Не удалось сохранить действие ${action.account}::${action.name}: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку чтобы сообщение не было подтверждено
|
||||
throw error; // Перебрасываем ошибку чтобы событие не было подтверждено
|
||||
}
|
||||
|
||||
// Публикуем событие с задержкой — пусть сначала прокатятся дельты этого же блока.
|
||||
// saveAction уже выполнен, так что данные не потеряем; задерживаем только emit.
|
||||
// Метка в consumer_dedup ПОСЛЕ save, ДО отложенного emit.
|
||||
// block_num пишем для последующего deleteAfterBlock на форке (Story 4.1).
|
||||
await this.parserInteractor.markEventApplied(eventId, action.block_num);
|
||||
|
||||
// Публикуем событие с задержкой — пусть сначала прокатятся дельты этого же блока
|
||||
// (capital_appendixes, capital_projects, ...), чтобы обработчики action видели
|
||||
// уже персистентное состояние. saveAction уже выполнен, так что данные не
|
||||
// потеряем; задерживаем только emit. Задержка вынесена в конфиг (DEC-007).
|
||||
const eventName = `action::${action.account}::${action.name}`;
|
||||
const delayMs = BlockchainConsumerService.ACTION_EMIT_DELAY_MS;
|
||||
const delayMs = config.blockchain.action_emit_delay_ms;
|
||||
setTimeout(() => {
|
||||
this.eventsService.emit(eventName, action);
|
||||
this.logger.debug(
|
||||
@@ -304,10 +253,8 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
* Обработка дельты (delta) из блокчейна
|
||||
* Выполняет минимальную предварительную фильтрацию, сохраняет в базу и публикует событие во внутреннюю шину.
|
||||
*
|
||||
* Ошибка saveDelta поднимается наверх в handleMessage → сообщение остаётся
|
||||
* pending в consumer group. См. processAction — тот же контракт.
|
||||
* Раньше здесь был try/catch, который глотал ошибки и log-only'ил их: это
|
||||
* приводило к silent data loss (ACK шёл, дельта в PG не попадала).
|
||||
* Ошибка saveDelta поднимается наверх в consume → событие остаётся pending в
|
||||
* consumer-group parser2 (iterator.throw). См. processAction — тот же контракт.
|
||||
*/
|
||||
private async processDelta(delta: IDelta): Promise<void> {
|
||||
this.logger.debug(`Обработка дельты: ${delta.table} от ${delta.code}`);
|
||||
@@ -331,15 +278,28 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
return;
|
||||
}
|
||||
|
||||
// Idempotency: признак уникальности события (INV-09).
|
||||
const eventId = computeDeltaEventId(delta);
|
||||
if (await this.parserInteractor.isEventApplied(eventId)) {
|
||||
this.logger.debug(`Дельта-дубликат пропущена (no-op): ${eventId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Сохраняем дельту в базу данных через интерактор
|
||||
await this.parserInteractor.saveDelta(delta);
|
||||
this.logger.log(`Дельта сохранена в базу: ${delta.code}::${delta.table} с primary_key ${delta.primary_key}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Не удалось сохранить дельту ${delta.code}::${delta.table}: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку чтобы сообщение не было подтверждено
|
||||
throw error; // Перебрасываем ошибку чтобы событие не было подтверждено
|
||||
}
|
||||
|
||||
// Метка в consumer_dedup ПОСЛЕ save. Если markApplied упадёт — consume
|
||||
// пробросит ошибку, событие останется pending и переиграется (saveDelta
|
||||
// идемпотентен через block_num-guard, mark — через ON CONFLICT DO NOTHING).
|
||||
// block_num пишем для последующего deleteAfterBlock на форке (Story 4.1).
|
||||
await this.parserInteractor.markEventApplied(eventId, delta.block_num);
|
||||
|
||||
// Публикуем событие во внутреннюю шину с типизированным именем
|
||||
const eventName = `delta::${delta.code}::${delta.table}`;
|
||||
this.eventsService.emit(eventName, delta);
|
||||
@@ -348,28 +308,39 @@ export class BlockchainConsumerService implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форка (fork) из блокчейна
|
||||
* Сохраняет форк в базу и публикует событие форка во внутреннюю шину
|
||||
* Обработка форка (fork) из блокчейна (ADR-005).
|
||||
*
|
||||
* Порядок шагов (контрактный):
|
||||
* 1) ForkRegistry.runAll(blockNum) — sequential откат сущностей всех syncer'ов.
|
||||
* Любая ошибка re-throw, parser2 не ACK'нет, повторная доставка пересыграет.
|
||||
* 2) consumer_dedup.deleteAfterBlock(blockNum) — очистка дедупа отрезанной ветки.
|
||||
* Делается ПОСЛЕ успешного rollback (иначе при сбое syncer'ов мы потеряем
|
||||
* возможность повторить весь форк по тому же event_id).
|
||||
* 3) saveFork(blockNum) — фиксация форка для аудита и future-pool re-submit (Epic 5).
|
||||
*
|
||||
* INV-T03: к моменту, когда handleEvent resolves и parser2 берёт следующее событие,
|
||||
* вся цепочка rollback завершена (sequential XREADGROUP = natural barrier).
|
||||
*/
|
||||
private async processFork(block_num: number): Promise<void> {
|
||||
this.logger.debug(`Обработка форка на блоке: ${block_num}`);
|
||||
private async processFork(block_num: number, forkEventId?: string | null): Promise<void> {
|
||||
this.logger.log(`Обработка форка на блоке ${block_num} (eventId=${forkEventId ?? 'n/a'}): запуск ForkRegistry rollback`);
|
||||
|
||||
try {
|
||||
// Сохраняем форк в базу данных через интерактор
|
||||
await this.parserInteractor.saveFork({
|
||||
chain_id: config.blockchain.id, // Используем chain id из конфига
|
||||
block_num: block_num,
|
||||
});
|
||||
this.logger.debug(`Форк сохранен в базу данных на блоке: ${block_num}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Не удалось сохранить форк на блоке ${block_num}: ${error.message}`, error.stack);
|
||||
throw error; // Перебрасываем ошибку чтобы сообщение не было подтверждено
|
||||
}
|
||||
// 1. Sequential rollback всех зарегистрированных syncer'ов.
|
||||
// Story 4.4: forkEventId пробрасывается syncer'ам — они кладут его в архив
|
||||
// invalidated_entities для forensic-группировки.
|
||||
await this.forkRegistry.runAll(block_num, forkEventId);
|
||||
this.logger.debug(`ForkRegistry: rollback завершён для ${this.forkRegistry.size()} syncer(s)`);
|
||||
|
||||
// Публикуем событие во внутреннюю шину с типизированным именем
|
||||
const eventName = `fork::${block_num}`;
|
||||
this.eventsService.emit(eventName, { block_num });
|
||||
// 2. Очистка consumer_dedup для блоков отрезанной ветки.
|
||||
const purged = await this.parserInteractor.deleteDedupAfterBlock(block_num);
|
||||
this.logger.debug(`consumer_dedup: удалено ${purged} записей с block_num > ${block_num}`);
|
||||
|
||||
this.logger.debug(`Форк опубликован в событийную шину: ${eventName}`);
|
||||
// 3. Фиксация форка для аудита.
|
||||
await this.parserInteractor.saveFork({
|
||||
chain_id: config.blockchain.id,
|
||||
block_num: block_num,
|
||||
});
|
||||
this.logger.debug(`Форк сохранён в БД на блоке ${block_num}`);
|
||||
|
||||
this.logger.log(`Форк обработан на блоке ${block_num}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { LEDGER2_BLOCKCHAIN_PORT } from '~/domain/ledger2/ports/ledger2-blockcha
|
||||
import { SovietContractInfoService } from './services/soviet-contract-info.service';
|
||||
import { WalletContractInfoService } from './services/wallet-contract-info.service';
|
||||
import { Ledger2ContractInfoService } from './services/ledger2-contract-info.service';
|
||||
import { BlockchainArchiveRetentionService } from '~/shared/sync/services/blockchain-archive-retention.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
@@ -92,6 +93,7 @@ import { Ledger2ContractInfoService } from './services/ledger2-contract-info.ser
|
||||
SovietContractInfoService,
|
||||
WalletContractInfoService,
|
||||
Ledger2ContractInfoService,
|
||||
BlockchainArchiveRetentionService,
|
||||
],
|
||||
exports: [
|
||||
BlockchainService,
|
||||
@@ -112,6 +114,7 @@ import { Ledger2ContractInfoService } from './services/ledger2-contract-info.ser
|
||||
SovietContractInfoService,
|
||||
WalletContractInfoService,
|
||||
Ledger2ContractInfoService,
|
||||
BlockchainArchiveRetentionService,
|
||||
],
|
||||
})
|
||||
export class BlockchainModule {}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IAction, IDelta } from '~/types/common';
|
||||
|
||||
/**
|
||||
* Локальное вычисление event_id — основа идемпотентности (INV-09).
|
||||
*
|
||||
* Формат: `${chain}:${kind}:${block_num}:${block_id_short}:${natural_key}`, где
|
||||
* kind = action|delta|fork. Контроллер ведёт собственный consumer_dedup в этом
|
||||
* формате (parser2 кладёт свой event_id в событие — другая формула
|
||||
* `chain:a:...`/`chain:d:...`/`chain:f:...`, мы её не используем). Дедуп
|
||||
* безусловный: повторно доставленное событие с уже отмеченным event_id
|
||||
* игнорируется как no-op. Полный «kind» — чтобы человек, глядя в consumer_dedup,
|
||||
* сразу видел тип события без расшифровки префикса.
|
||||
*/
|
||||
|
||||
/** Длина короткого префикса block_id в event_id. */
|
||||
const BLOCK_ID_SHORT_LEN = 8;
|
||||
|
||||
function shortBlockId(blockId: string | undefined): string {
|
||||
return (blockId ?? '').slice(0, BLOCK_ID_SHORT_LEN);
|
||||
}
|
||||
|
||||
/**
|
||||
* event_id дельты. natural_key = code:scope:table:primary_key — естественная
|
||||
* идентичность строки таблицы в конкретном блоке.
|
||||
*/
|
||||
export function computeDeltaEventId(delta: IDelta): string {
|
||||
const naturalKey = `${delta.code}:${delta.scope}:${delta.table}:${delta.primary_key}`;
|
||||
return `${delta.chain_id}:delta:${delta.block_num}:${shortBlockId(delta.block_id)}:${naturalKey}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* event_id действия. natural_key = global_sequence — глобально-монотонный
|
||||
* идентификатор action'а в цепи, уникален сам по себе.
|
||||
*/
|
||||
export function computeActionEventId(action: IAction): string {
|
||||
return `${action.chain_id}:action:${action.block_num}:${shortBlockId(action.block_id)}:${action.global_sequence}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* event_id форка. natural_key — short new_head_block_id (новая голова цепи
|
||||
* после rollback). Симметрично action/delta-формуле: `chain:fork:block_num:short_id`.
|
||||
* block_num = forked_from_block (последний безопасный блок до отката).
|
||||
* short_id различает форк-эпохи — две разных «новых ветки» того же forked_from_block
|
||||
* дают разный event_id, что корректно с точки зрения retry / breach-сценариев.
|
||||
*/
|
||||
export function computeForkEventId(chainId: string, forkedFromBlock: number, newHeadBlockId: string): string {
|
||||
return `${chainId}:fork:${forkedFromBlock}:${shortBlockId(newHeadBlockId)}`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ActionEvent, DeltaEvent } from '@coopenomics/parser2';
|
||||
import { IAction, IDelta } from '~/types/common';
|
||||
|
||||
/**
|
||||
* Преобразование событий parser2 (ParserEvent) во внутренние формы контроллера
|
||||
* IDelta / IAction. Транспорт сменился (parser1 Redis-стрим → parser2 ParserClient),
|
||||
* но обработчики (processDelta/processAction → syncer'ы) остаются прежними: они
|
||||
* работают с IDelta/IAction. Маппер — единственная точка перевода (DEC-T09).
|
||||
*
|
||||
* Все поля действия — РЕАЛЬНЫЕ из SHiP-трейса (parser2 их отдаёт): transaction_id,
|
||||
* creator_action_ordinal, receipt (с auth_sequence), console, elapsed, context_free,
|
||||
* account_ram_deltas. Это полный паритет с тем, что давал parser1, — ledger2
|
||||
* (cross-link родительского apply по transaction_id + action_ordinal) и
|
||||
* blockchain-explorer работают без потерь.
|
||||
*
|
||||
* bigint-поля (global_sequence, receipt.*Sequence) приходят по проводу строками
|
||||
* (parser2 сериализует bigint→string), поэтому String() безопасен и для bigint, и
|
||||
* для string.
|
||||
*/
|
||||
|
||||
export function mapParserDeltaToIDelta(event: DeltaEvent): IDelta {
|
||||
return {
|
||||
chain_id: event.chain_id,
|
||||
block_num: event.block_num,
|
||||
block_id: event.block_id,
|
||||
block_time: event.block_time,
|
||||
present: event.present,
|
||||
code: event.code,
|
||||
scope: event.scope,
|
||||
table: event.table,
|
||||
primary_key: event.primary_key,
|
||||
value: event.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapParserActionToIAction(event: ActionEvent): IAction {
|
||||
const globalSequence = String(event.global_sequence);
|
||||
const r = event.receipt;
|
||||
const receipt = r
|
||||
? {
|
||||
receiver: r.receiver,
|
||||
act_digest: r.actDigest,
|
||||
global_sequence: String(r.globalSequence),
|
||||
recv_sequence: String(r.recvSequence),
|
||||
auth_sequence: r.authSequence.map((s) => ({ account: s.account, sequence: String(s.sequence) })),
|
||||
code_sequence: r.codeSequence,
|
||||
abi_sequence: r.abiSequence,
|
||||
}
|
||||
: {
|
||||
// Трассировки нет — receipt не null (read-path explorer'а и фильтр
|
||||
// notification по receipt.receiver не должны падать).
|
||||
receiver: event.account,
|
||||
act_digest: '',
|
||||
global_sequence: globalSequence,
|
||||
recv_sequence: '0',
|
||||
auth_sequence: [],
|
||||
code_sequence: 0,
|
||||
abi_sequence: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
transaction_id: event.transaction_id,
|
||||
account: event.account,
|
||||
block_num: event.block_num,
|
||||
block_id: event.block_id,
|
||||
block_time: event.block_time,
|
||||
chain_id: event.chain_id,
|
||||
name: event.name,
|
||||
// parser2 эмитит action уже единожды (дедуп по global_sequence); receiver=account,
|
||||
// чтобы guard processAction (receiver != account → skip) пропускал событие.
|
||||
receiver: receipt.receiver,
|
||||
authorization: event.authorization.map((a) => ({ actor: a.actor, permission: a.permission })),
|
||||
data: event.data,
|
||||
action_ordinal: event.action_ordinal,
|
||||
global_sequence: globalSequence,
|
||||
account_ram_deltas: event.account_ram_deltas.map((d) => ({ account: d.account, delta: d.delta })),
|
||||
console: event.console,
|
||||
receipt,
|
||||
creator_action_ordinal: event.creator_action_ordinal,
|
||||
context_free: event.context_free,
|
||||
elapsed: event.elapsed,
|
||||
};
|
||||
}
|
||||
+1
-10
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
|
||||
import { AgreementDomainEntity } from '~/domain/agreement/entities/agreement.entity';
|
||||
@@ -49,13 +49,4 @@ export class AgreementSyncService
|
||||
|
||||
this.logger.debug('Сервис синхронизации соглашений полностью инициализирован с подписками на паттерны');
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форков для соглашений
|
||||
* Теперь подписывается на все форки независимо от контракта
|
||||
*/
|
||||
@OnEvent('fork::*')
|
||||
async handleAgreementFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
|
||||
import { UserAgreementDomainEntity } from '~/domain/wallet/entities/user-agreement-domain.entity';
|
||||
@@ -49,8 +49,4 @@ export class UserAgreementSyncService
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('fork::*')
|
||||
async handleUserAgreementFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -1,5 +1,5 @@
|
||||
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
|
||||
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
|
||||
import { UserWalletDomainEntity } from '~/domain/wallet/entities/user-wallet-domain.entity';
|
||||
@@ -51,8 +51,4 @@ export class UserWalletSyncService
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('fork::*')
|
||||
async handleUserWalletFork(forkData: { block_num: number }): Promise<void> {
|
||||
await this.handleFork(forkData.block_num);
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Entity, PrimaryColumn, Column, Index, CreateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Список уже применённых событий блокчейна — фундамент идемпотентности
|
||||
* (Story 2.1, INV-09, NFR5). Повторно доставленное событие с уже отмеченным
|
||||
* event_id распознаётся dispatch-путём как no-op (Story 2.3), что защищает от
|
||||
* двойного применения при at-least-once доставке Redis Streams.
|
||||
*
|
||||
* Без тяжёлых constraints (RT-03): event_id — PK, индекс по applied_at нужен
|
||||
* для retention-очистки старых меток (ориентир OQ-T04: Rollback Horizon × 2).
|
||||
*
|
||||
* event_id вычисляется локально по формуле parser2 (Story 2.2); после миграции
|
||||
* на parser2 (Epic 3) формула сверяется с авторитетной из движка.
|
||||
*
|
||||
* Story 4.1: добавлена колонка block_num — для очистки дедупа на форке
|
||||
* (deleteAfterBlock). Колонка nullable: старые записи (до Epic 4) её не имеют,
|
||||
* они доживут до своего retention по applied_at и не будут попадать под
|
||||
* WHERE block_num > N (PG NULL-сравнения возвращают unknown, строка не удалится).
|
||||
* Новые записи всегда несут block_num.
|
||||
*/
|
||||
@Entity('consumer_dedup')
|
||||
export class ConsumerDedupEntity {
|
||||
@PrimaryColumn({ type: 'varchar', length: 512 })
|
||||
event_id!: string;
|
||||
|
||||
@Index('idx_consumer_dedup_applied_at')
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
applied_at!: Date;
|
||||
|
||||
@Index('idx_consumer_dedup_block_num')
|
||||
@Column({ type: 'bigint', nullable: true })
|
||||
block_num!: string | null;
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import type { ConsumerDedupRepositoryPort } from '~/domain/parser/ports/consumer-dedup-repository.port';
|
||||
import { ConsumerDedupEntity } from '../entities/consumer-dedup.entity';
|
||||
|
||||
/**
|
||||
* TypeORM-реализация списка применённых событий (Story 2.1).
|
||||
*/
|
||||
@Injectable()
|
||||
export class TypeOrmConsumerDedupRepository implements ConsumerDedupRepositoryPort {
|
||||
constructor(
|
||||
@InjectRepository(ConsumerDedupEntity)
|
||||
private readonly repository: Repository<ConsumerDedupEntity>
|
||||
) {}
|
||||
|
||||
async isApplied(eventId: string): Promise<boolean> {
|
||||
const found = await this.repository.findOne({
|
||||
where: { event_id: eventId },
|
||||
select: { event_id: true },
|
||||
});
|
||||
return found != null;
|
||||
}
|
||||
|
||||
async markApplied(eventId: string, blockNum?: number): Promise<void> {
|
||||
// ON CONFLICT DO NOTHING: повторная отметка (re-delivery / crash-recovery)
|
||||
// не должна падать на нарушении PK.
|
||||
// block_num хранится как bigint → string в TypeORM; NULL для legacy-вызовов без блока.
|
||||
await this.repository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(ConsumerDedupEntity)
|
||||
.values({
|
||||
event_id: eventId,
|
||||
block_num: typeof blockNum === 'number' ? String(blockNum) : null,
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteOlderThan(cutoff: Date): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(ConsumerDedupEntity)
|
||||
.where('applied_at < :cutoff', { cutoff })
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
async deleteAfterBlock(blockNum: number): Promise<number> {
|
||||
// Сравнение bigint > N — оба операнда числа на стороне PG; параметр приводится в bigint.
|
||||
// NULL-записи (legacy до Story 4.1) не попадают: NULL > N = UNKNOWN, строка не удаляется.
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.from(ConsumerDedupEntity)
|
||||
.where('block_num > :blockNum', { blockNum })
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,10 @@ import { SyncStateEntity } from './entities/sync-state.entity';
|
||||
import { EntityVersionTypeormEntity } from '~/shared/sync/entities/entity-version.typeorm-entity';
|
||||
import { EntityVersionRepository } from '~/shared/sync/repositories/entity-version.repository';
|
||||
import { EntityVersioningService } from '~/shared/sync/services/entity-versioning.service';
|
||||
import { InvalidatedEntityTypeormEntity } from '~/shared/sync/entities/invalidated-entity.typeorm-entity';
|
||||
import { InvalidatedEntityVersionTypeormEntity } from '~/shared/sync/entities/invalidated-entity-version.typeorm-entity';
|
||||
import { InvalidatedEntityRepository } from '~/shared/sync/repositories/invalidated-entity.repository';
|
||||
import { InvalidatedEntityVersionRepository } from '~/shared/sync/repositories/invalidated-entity-version.repository';
|
||||
import { ACTION_REPOSITORY_PORT } from '~/domain/parser/ports/action-repository.port';
|
||||
import { DELTA_REPOSITORY_PORT } from '~/domain/parser/ports/delta-repository.port';
|
||||
import { FORK_REPOSITORY_PORT } from '~/domain/parser/ports/fork-repository.port';
|
||||
@@ -50,6 +54,9 @@ import { TypeOrmActionRepository } from './repositories/typeorm-action.repositor
|
||||
import { TypeOrmDeltaRepository } from './repositories/typeorm-delta.repository';
|
||||
import { TypeOrmForkRepository } from './repositories/typeorm-fork.repository';
|
||||
import { TypeOrmSyncStateRepository } from './repositories/typeorm-sync-state.repository';
|
||||
import { ConsumerDedupEntity } from './entities/consumer-dedup.entity';
|
||||
import { CONSUMER_DEDUP_REPOSITORY_PORT } from '~/domain/parser/ports/consumer-dedup-repository.port';
|
||||
import { TypeOrmConsumerDedupRepository } from './repositories/typeorm-consumer-dedup.repository';
|
||||
import { SettingsEntity } from './entities/settings.entity';
|
||||
import { SETTINGS_REPOSITORY } from '~/domain/settings/repositories/settings.repository';
|
||||
import { SettingsTypeormRepository } from './repositories/settings.typeorm-repository';
|
||||
@@ -122,7 +129,10 @@ import { UserWalletIndexInitializer } from './blockchain/services/user-wallet-in
|
||||
DeltaEntity,
|
||||
ForkEntity,
|
||||
SyncStateEntity,
|
||||
ConsumerDedupEntity,
|
||||
EntityVersionTypeormEntity,
|
||||
InvalidatedEntityTypeormEntity,
|
||||
InvalidatedEntityVersionTypeormEntity,
|
||||
SettingsEntity,
|
||||
TokenEntity,
|
||||
UserEntity,
|
||||
@@ -197,6 +207,10 @@ import { UserWalletIndexInitializer } from './blockchain/services/user-wallet-in
|
||||
provide: SYNC_STATE_REPOSITORY_PORT,
|
||||
useClass: TypeOrmSyncStateRepository,
|
||||
},
|
||||
{
|
||||
provide: CONSUMER_DEDUP_REPOSITORY_PORT,
|
||||
useClass: TypeOrmConsumerDedupRepository,
|
||||
},
|
||||
{
|
||||
provide: SETTINGS_REPOSITORY,
|
||||
useClass: SettingsTypeormRepository,
|
||||
@@ -251,6 +265,8 @@ import { UserWalletIndexInitializer } from './blockchain/services/user-wallet-in
|
||||
UserWalletIndexInitializer,
|
||||
EntityVersionRepository,
|
||||
EntityVersioningService,
|
||||
InvalidatedEntityRepository,
|
||||
InvalidatedEntityVersionRepository,
|
||||
],
|
||||
exports: [
|
||||
NestTypeOrmModule,
|
||||
@@ -269,6 +285,7 @@ import { UserWalletIndexInitializer } from './blockchain/services/user-wallet-in
|
||||
DELTA_REPOSITORY_PORT,
|
||||
FORK_REPOSITORY_PORT,
|
||||
SYNC_STATE_REPOSITORY_PORT,
|
||||
CONSUMER_DEDUP_REPOSITORY_PORT,
|
||||
SETTINGS_REPOSITORY,
|
||||
TOKEN_REPOSITORY,
|
||||
USER_REPOSITORY,
|
||||
@@ -286,6 +303,8 @@ import { UserWalletIndexInitializer } from './blockchain/services/user-wallet-in
|
||||
UserWalletSyncService,
|
||||
EntityVersionRepository,
|
||||
EntityVersioningService,
|
||||
InvalidatedEntityRepository,
|
||||
InvalidatedEntityVersionRepository,
|
||||
],
|
||||
})
|
||||
export class TypeOrmModule {}
|
||||
|
||||
@@ -17,4 +17,34 @@ export class EventsService {
|
||||
emit(eventName: string, data: any): void {
|
||||
this.eventEmitter.emit(eventName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Публикация с ожиданием завершения всех async-обработчиков.
|
||||
* В отличие от emit (fire-and-forget) дожидается, пока @OnEvent-листенеры
|
||||
* (в т.ч. async) отработают. Если любой обработчик бросает — промис
|
||||
* отклоняется (ошибку не глотаем).
|
||||
*/
|
||||
async emitAsync(eventName: string, data: any): Promise<unknown[]> {
|
||||
return this.eventEmitter.emitAsync(eventName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Барьер: публикация с ожиданием обработчиков, но не дольше timeoutMs.
|
||||
* Возвращает true, если все обработчики завершились в срок; false — если
|
||||
* сработал TTL force-resume (какой-то обработчик завис). Используется для
|
||||
* обработки форка: дождаться откатов до продолжения consumer'а, но не
|
||||
* блокировать поток навсегда из-за зависшего синкера (Story 1.3).
|
||||
*/
|
||||
async emitAsyncWithTimeout(eventName: string, data: any, timeoutMs: number): Promise<boolean> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<false>((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), timeoutMs);
|
||||
});
|
||||
try {
|
||||
const settled = this.eventEmitter.emitAsync(eventName, data).then(() => true);
|
||||
return await Promise.race([settled, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS_PROVIDER } from './redis.provider';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
|
||||
export interface StreamMessage {
|
||||
messageId: string;
|
||||
fields: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface StreamConsumerOptions {
|
||||
stream: string;
|
||||
group: string;
|
||||
consumer: string;
|
||||
count?: number;
|
||||
block?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сервис для работы с Redis Streams
|
||||
* Обеспечивает надежное потребление событий от parser
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisStreamService implements OnModuleDestroy {
|
||||
private consumers: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(
|
||||
@Inject(REDIS_PROVIDER)
|
||||
private readonly redisClient: { subscriber: Redis; publisher: Redis; streamManager: Redis; streamReader: Redis },
|
||||
private readonly logger: WinstonLoggerService
|
||||
) {
|
||||
this.logger.setContext(RedisStreamService.name);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
// Останавливаем всех потребителей
|
||||
this.consumers.forEach((_, key) => {
|
||||
this.consumers.set(key, false);
|
||||
});
|
||||
// Закрываем дополнительные соединения
|
||||
this.redisClient.streamManager.quit();
|
||||
this.redisClient.streamReader.quit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создание группы потребителей
|
||||
*/
|
||||
async createConsumerGroup(stream: string, group: string, startId = '0'): Promise<void> {
|
||||
try {
|
||||
await this.redisClient.streamManager.xgroup('CREATE', stream, group, startId, 'MKSTREAM');
|
||||
this.logger.log(`Consumer group created: ${group} for stream: ${stream}`);
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('BUSYGROUP')) {
|
||||
this.logger.log(`Consumer group ${group} already exists for stream ${stream}`);
|
||||
} else {
|
||||
this.logger.error(`Error creating consumer group: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Потребление сообщений из stream
|
||||
*/
|
||||
async startConsumer(
|
||||
options: StreamConsumerOptions,
|
||||
messageHandler: (message: StreamMessage) => Promise<void>
|
||||
): Promise<void> {
|
||||
const { stream, group, consumer, count = 1, block = 1000 } = options;
|
||||
const consumerKey = `${stream}:${group}:${consumer}`;
|
||||
|
||||
// Отмечаем потребителя как активного
|
||||
this.consumers.set(consumerKey, true);
|
||||
|
||||
this.logger.log(`Starting consumer: ${consumerKey}`);
|
||||
|
||||
while (this.consumers.get(consumerKey)) {
|
||||
try {
|
||||
const result: any = await this.redisClient.streamReader.xreadgroup(
|
||||
'GROUP',
|
||||
group,
|
||||
consumer,
|
||||
'COUNT',
|
||||
count.toString(),
|
||||
'BLOCK',
|
||||
block.toString(),
|
||||
'STREAMS',
|
||||
stream,
|
||||
'>'
|
||||
);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
for (const [streamName, messages] of result) {
|
||||
if (streamName === stream) {
|
||||
for (const [messageId, messageData] of messages) {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
// Преобразуем массив [key, value, key, value] в объект
|
||||
for (let i = 0; i < messageData.length; i += 2) {
|
||||
fields[messageData[i]] = messageData[i + 1];
|
||||
}
|
||||
|
||||
try {
|
||||
await messageHandler({ messageId, fields });
|
||||
await this.acknowledgeMessage(stream, group, messageId);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error processing message ${messageId}: ${error.message}`);
|
||||
// Не подтверждаем сообщение при ошибке - оно останется в pending
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (this.consumers.get(consumerKey)) {
|
||||
this.logger.error(`Consumer ${consumerKey} error: ${error.message}`);
|
||||
// Небольшая пауза перед повторной попыткой
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Consumer stopped: ${consumerKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Подтверждение обработки сообщения
|
||||
*/
|
||||
async acknowledgeMessage(stream: string, group: string, messageId: string): Promise<void> {
|
||||
try {
|
||||
await this.redisClient.streamManager.xack(stream, group, messageId);
|
||||
this.logger.debug(`Message acknowledged: ${messageId} in stream ${stream}`);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error acknowledging message ${messageId} in stream ${stream}, group ${group}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Остановка потребителя
|
||||
*/
|
||||
stopConsumer(stream: string, group: string, consumer: string): void {
|
||||
const consumerKey = `${stream}:${group}:${consumer}`;
|
||||
this.consumers.set(consumerKey, false);
|
||||
this.logger.log(`Stopping consumer: ${consumerKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации о pending сообщениях
|
||||
*/
|
||||
async getPendingMessages(stream: string, group: string): Promise<any> {
|
||||
try {
|
||||
return await this.redisClient.streamManager.xpending(stream, group);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error getting pending messages: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получение информации о группах потребителей
|
||||
*/
|
||||
async getConsumerGroups(stream: string): Promise<any> {
|
||||
try {
|
||||
return await this.redisClient.streamManager.xinfo('GROUPS', stream);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error getting consumer groups: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Прочитать pending-сообщения конкретного consumer'а (uncacknowledged).
|
||||
* Используется при старте для восстановления работы после рестарта:
|
||||
* сообщения, которые были выданы этому consumer'у, но не подтверждены
|
||||
* (например, coopback упал между handleMessage и acknowledgeMessage),
|
||||
* должны быть перечитаны и допоставлены.
|
||||
*
|
||||
* XREADGROUP с ID '0' означает «все pending этого consumer'а»,
|
||||
* а не новые — этим отличается от обычного '>'.
|
||||
*/
|
||||
async readOwnPending(stream: string, group: string, consumer: string, count = 100): Promise<StreamMessage[]> {
|
||||
const result: any = await this.redisClient.streamReader.xreadgroup(
|
||||
'GROUP',
|
||||
group,
|
||||
consumer,
|
||||
'COUNT',
|
||||
count.toString(),
|
||||
'STREAMS',
|
||||
stream,
|
||||
'0',
|
||||
);
|
||||
return this.parseStreamResult(result, stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* XAUTOCLAIM: переназначить себе pending-сообщения других consumer'ов,
|
||||
* idle которых превысил minIdleMs. Это защита от зомби-consumer'ов:
|
||||
* предыдущий рестарт coopback оставил consumer "consumer-abc123" со своими
|
||||
* pending; новый consumer "coopback-main" заберёт их через XAUTOCLAIM.
|
||||
*
|
||||
* Redis 6.2+. Возвращает [nextCursor, claimedEntries, deletedIds?].
|
||||
*/
|
||||
async autoClaimStale(
|
||||
stream: string,
|
||||
group: string,
|
||||
consumer: string,
|
||||
minIdleMs: number,
|
||||
count = 100,
|
||||
): Promise<StreamMessage[]> {
|
||||
const result: any = await this.redisClient.streamManager.xautoclaim(
|
||||
stream,
|
||||
group,
|
||||
consumer,
|
||||
minIdleMs.toString(),
|
||||
'0',
|
||||
'COUNT',
|
||||
count.toString(),
|
||||
);
|
||||
// Redis 7+: [cursor, entries, deletedIds]. Redis 6.2: [cursor, entries].
|
||||
if (!Array.isArray(result) || result.length < 2) return [];
|
||||
const entries = result[1] as any[];
|
||||
const messages: StreamMessage[] = [];
|
||||
for (const [messageId, messageData] of entries) {
|
||||
if (!Array.isArray(messageData)) continue; // deleted entry
|
||||
const fields: Record<string, string> = {};
|
||||
for (let i = 0; i < messageData.length; i += 2) {
|
||||
fields[messageData[i]] = messageData[i + 1];
|
||||
}
|
||||
messages.push({ messageId, fields });
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* XTRIM MINID: удалить из stream записи с ID меньше minId.
|
||||
* Используется controller'ом для освобождения памяти Redis от уже
|
||||
* consumed сообщений. minId должен быть ≤ first-pending-id, иначе
|
||||
* удалим ещё не обработанные сообщения — всё, что в pending, защищено
|
||||
* этим граничным условием.
|
||||
*
|
||||
* `~` (approximate trim) — Redis удаляет эффективно по блокам radix tree,
|
||||
* реально удалённых записей может быть чуть больше или чуть меньше minId.
|
||||
*/
|
||||
async trimUpTo(stream: string, minId: string): Promise<number> {
|
||||
return (await this.redisClient.streamManager.xtrim(stream, 'MINID', '~', minId)) as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный pending ID в consumer-group (или null, если pending пусто).
|
||||
* XPENDING summary form возвращает [count, minId, maxId, consumers].
|
||||
*/
|
||||
async getFirstPendingId(stream: string, group: string): Promise<string | null> {
|
||||
const summary: any = await this.redisClient.streamManager.xpending(stream, group);
|
||||
if (!Array.isArray(summary) || !summary[0]) return null;
|
||||
const count = Number(summary[0]);
|
||||
if (count === 0) return null;
|
||||
return (summary[1] as string) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Последний ID в stream (или '0-0', если stream пуст).
|
||||
* Нужен для trim'а когда pending пусто и last-delivered-id бесполезен:
|
||||
* безопасно обрезать всё до нынешнего конца stream'а только если ВСЕ
|
||||
* сообщения consumed. Проверка pending.count=0 — гарантия этого.
|
||||
*/
|
||||
async getStreamLastId(stream: string): Promise<string> {
|
||||
const info: any = await this.redisClient.streamManager.xinfo('STREAM', stream);
|
||||
if (!Array.isArray(info)) return '0-0';
|
||||
// XINFO STREAM возвращает массив [key, value, key, value, ...].
|
||||
for (let i = 0; i < info.length; i += 2) {
|
||||
if (info[i] === 'last-generated-id') return String(info[i + 1] || '0-0');
|
||||
}
|
||||
return '0-0';
|
||||
}
|
||||
|
||||
private parseStreamResult(result: any, expectedStream: string): StreamMessage[] {
|
||||
if (!result || !Array.isArray(result) || result.length === 0) return [];
|
||||
const messages: StreamMessage[] = [];
|
||||
for (const [streamName, streamMessages] of result) {
|
||||
if (streamName !== expectedStream) continue;
|
||||
for (const [messageId, messageData] of streamMessages) {
|
||||
const fields: Record<string, string> = {};
|
||||
for (let i = 0; i < messageData.length; i += 2) {
|
||||
fields[messageData[i]] = messageData[i + 1];
|
||||
}
|
||||
messages.push({ messageId, fields });
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
import { RedisStreamService } from './redis-stream.service';
|
||||
import { RedisProvider, REDIS_PROVIDER } from './redis.provider';
|
||||
import { REDIS_PORT } from '~/domain/common/ports/redis.port';
|
||||
|
||||
@@ -10,12 +9,11 @@ import { REDIS_PORT } from '~/domain/common/ports/redis.port';
|
||||
providers: [
|
||||
RedisProvider,
|
||||
RedisService,
|
||||
RedisStreamService,
|
||||
{
|
||||
provide: REDIS_PORT,
|
||||
useClass: RedisService,
|
||||
},
|
||||
],
|
||||
exports: [RedisService, RedisStreamService, REDIS_PORT, REDIS_PROVIDER],
|
||||
exports: [RedisService, REDIS_PORT, REDIS_PROVIDER],
|
||||
})
|
||||
export class RedisModule {}
|
||||
|
||||
@@ -78,6 +78,18 @@ export interface IBlockchainSyncRepository<TEntity extends IBlockchainSynchroniz
|
||||
|
||||
/** Восстановить сущности из версий после форка */
|
||||
restoreFromVersions?(forkBlockNum: number): Promise<void>;
|
||||
|
||||
/**
|
||||
* Story 4.4: атомарно перенести live-сущности WHERE block_num > forkBlockNum в архив
|
||||
* (invalidated_entities) и удалить из исходной таблицы. Возвращает count.
|
||||
*/
|
||||
archiveInvalidatedSince?(forkBlockNum: number, forkEventId?: string | null): Promise<number>;
|
||||
|
||||
/**
|
||||
* Story 4.4: атомарно перенести версии этой entity_table WHERE block_num > forkBlockNum
|
||||
* в архив (invalidated_entity_versions) и удалить из entity_versions. Возвращает count.
|
||||
*/
|
||||
archiveInvalidatedVersionsSince?(forkBlockNum: number, forkEventId?: string | null): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
IBlockchainSyncRepository,
|
||||
ISyncResult,
|
||||
} from '~/shared/interfaces/blockchain-sync.interface';
|
||||
import { FORK_AWARE_MARKER, type IForkAwareSyncer } from '~/shared/sync/fork';
|
||||
import { UnsupportedContractVersionError } from '~/shared/sync/errors/unsupported-contract-version.error';
|
||||
import config from '~/config/config';
|
||||
|
||||
/**
|
||||
* Абстрактный сервис для синхронизации сущностей с блокчейном
|
||||
@@ -14,12 +17,23 @@ import type {
|
||||
* Предоставляет базовую логику для:
|
||||
* - Обработки дельт блокчейна
|
||||
* - Создания/обновления сущностей
|
||||
* - Обработки форков
|
||||
* - Обработки форков (Story 4.1: реализует IForkAwareSyncer — ForkRegistryService
|
||||
* собирает наследников через DiscoveryService по symbol-маркеру и обходит
|
||||
* sequential при форке)
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynchronizable, TBlockchainData = any> {
|
||||
export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynchronizable, TBlockchainData = any>
|
||||
implements IForkAwareSyncer
|
||||
{
|
||||
protected abstract readonly entityName: string;
|
||||
|
||||
/**
|
||||
* Symbol-маркер для ForkRegistryService (Story 4.1). Все 20+ наследников
|
||||
* автоматически попадают в реестр через bootstrap-сканирование Discovery —
|
||||
* без правок их onModuleInit.
|
||||
*/
|
||||
readonly [FORK_AWARE_MARKER] = true;
|
||||
|
||||
constructor(
|
||||
protected readonly repository: IBlockchainSyncRepository<TEntity>,
|
||||
protected readonly mapper: IBlockchainDeltaMapper<TBlockchainData>,
|
||||
@@ -44,7 +58,21 @@ export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynch
|
||||
// Маппинг дельты в блокчейн-данные
|
||||
const blockchainData = this.mapper.mapDeltaToBlockchainData(delta);
|
||||
if (!blockchainData) {
|
||||
this.logger.warn(`Failed to map delta to blockchain data for ${this.entityName} ${syncValue}`);
|
||||
// Story 6.5: silent loss заменён на audit-trail error. В strict-mode дополнительно
|
||||
// throw UnsupportedContractVersionError — парсер не ACK'нет дельту, dead-letter сработает.
|
||||
const ctx = {
|
||||
contract: (delta as any).contract ?? (delta as any).code,
|
||||
table: (delta as any).table,
|
||||
primary_key: (delta as any).primary_key,
|
||||
block_num: Number((delta as any).block_num),
|
||||
};
|
||||
this.logger.error(
|
||||
`UNSUPPORTED_CONTRACT_VERSION: mapDeltaToBlockchainData returned null for ${this.entityName} ${syncValue}`,
|
||||
{ entity: this.entityName, syncValue, ...ctx }
|
||||
);
|
||||
if (config.blockchain.unsupported_version_strict) {
|
||||
throw new UnsupportedContractVersionError(this.entityName, ctx);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -54,6 +82,9 @@ export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynch
|
||||
// Обработка создания/обновления сущности
|
||||
return await this.handleSyncDelta(syncKey, syncValue, blockchainData, blockNum, present);
|
||||
} catch (error: any) {
|
||||
// Story 6.5: UnsupportedContractVersionError пробрасываем дальше, чтобы парсер
|
||||
// не ACK'нул дельту в strict-mode.
|
||||
if (error instanceof UnsupportedContractVersionError) throw error;
|
||||
this.logger.error(`Error processing ${this.entityName} delta: ${error.message}`, error.stack);
|
||||
// Не перебрасываем ошибку, чтобы не падало приложение
|
||||
return null;
|
||||
@@ -133,37 +164,56 @@ export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynch
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка форка - удаление данных после указанного блока
|
||||
* Обработка форка — архивирование снесённых сущностей + восстановление из versions
|
||||
* + архивирование инвалидированных версий.
|
||||
*
|
||||
* Story 4.1: ошибки больше НЕ глотаются — обязательный re-throw для контракта
|
||||
* sequential ForkRegistry.runAll (INV-T03). Если rollback упадёт — parser2 не
|
||||
* ACK'нет fork-event, повторная доставка пересыграет цепочку. Уже отработавшие
|
||||
* syncer'ы в цепи будут no-op (versions уже подняты), сбойный — попробует ещё раз.
|
||||
*
|
||||
* Story 4.4: hard-delete заменён на «архив + delete» атомарно. Порядок:
|
||||
* 1) archiveInvalidatedSince — live-ряды WHERE block_num > N переезжают в
|
||||
* invalidated_entities, оригинал удаляется (одна транзакция).
|
||||
* 2) restoreFromVersions — поднять previous_data из ещё-живых entity_versions.
|
||||
* 3) archiveInvalidatedVersionsSince — entity_versions WHERE entity_table=... AND
|
||||
* block_num > N переезжают в invalidated_entity_versions, оригинал удаляется.
|
||||
* Запускается ПОСЛЕ restore, иначе restore не сможет прочитать живые версии.
|
||||
* Если репо не реализует archive методы (off-chain) — graceful no-op + fallback
|
||||
* на старую findByBlockNumGreaterThan/deleteByBlockNumGreaterThan для бэк-совместимости.
|
||||
*/
|
||||
async handleFork(forkBlockNum: number): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`Handling fork for ${this.entityName} at block ${forkBlockNum}`);
|
||||
async handleFork(forkBlockNum: number, forkEventId?: string | null): Promise<void> {
|
||||
this.logger.log(`Handling fork for ${this.entityName} at block ${forkBlockNum} (eventId=${forkEventId ?? 'n/a'})`);
|
||||
|
||||
// Находим все сущности, обновленные после форка
|
||||
const affectedEntities = await this.repository.findByBlockNumGreaterThan(forkBlockNum);
|
||||
|
||||
this.logger.debug(
|
||||
`Found ${affectedEntities.length} ${this.entityName} entities affected by fork at block ${forkBlockNum}`
|
||||
let archivedLive = 0;
|
||||
if (this.repository.archiveInvalidatedSince) {
|
||||
archivedLive = await this.repository.archiveInvalidatedSince(forkBlockNum, forkEventId);
|
||||
this.logger.log(
|
||||
`Архивировано ${archivedLive} live-рядов ${this.entityName} на форке ${forkBlockNum}`
|
||||
);
|
||||
|
||||
// Удаляем затронутые форком сущности
|
||||
} else {
|
||||
// Бэк-совместимость для off-chain репозиториев без архива (Story 4.3 allowlist)
|
||||
const affected = await this.repository.findByBlockNumGreaterThan(forkBlockNum);
|
||||
await this.repository.deleteByBlockNumGreaterThan(forkBlockNum);
|
||||
|
||||
this.logger.log(`Removed ${affectedEntities.length} ${this.entityName} entities after fork at block ${forkBlockNum}`);
|
||||
|
||||
// Восстанавливаем сущности из версий
|
||||
if (this.repository.restoreFromVersions) {
|
||||
await this.repository.restoreFromVersions(forkBlockNum);
|
||||
this.logger.log(`Restored ${this.entityName} entities from versions after fork at block ${forkBlockNum}`);
|
||||
}
|
||||
|
||||
// Вызываем метод для дополнительных действий после форка
|
||||
await this.afterForkProcessing(forkBlockNum, affectedEntities);
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error handling fork for ${this.entityName}: ${error.message}`, error.stack);
|
||||
// Не перебрасываем ошибку, чтобы не падало приложение
|
||||
return;
|
||||
archivedLive = affected.length;
|
||||
this.logger.warn(
|
||||
`${this.entityName}: archiveInvalidatedSince не реализован — fallback на hard-delete (${archivedLive} рядов)`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.repository.restoreFromVersions) {
|
||||
await this.repository.restoreFromVersions(forkBlockNum);
|
||||
this.logger.log(`Restored ${this.entityName} entities from versions after fork at block ${forkBlockNum}`);
|
||||
}
|
||||
|
||||
if (this.repository.archiveInvalidatedVersionsSince) {
|
||||
const archivedVersions = await this.repository.archiveInvalidatedVersionsSince(forkBlockNum, forkEventId);
|
||||
this.logger.log(
|
||||
`Архивировано ${archivedVersions} версий ${this.entityName} на форке ${forkBlockNum}`
|
||||
);
|
||||
}
|
||||
|
||||
await this.afterForkProcessing(forkBlockNum, []);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,7 +19,6 @@ export class BaseTypeormEntity {
|
||||
@UpdateDateColumn({ type: 'timestamp' })
|
||||
_updated_at!: Date;
|
||||
|
||||
|
||||
/**
|
||||
* Получить имя таблицы для сущности
|
||||
* ДОЛЖЕН БЫТЬ ПЕРЕОПРЕДЕЛЕН в каждом наследнике!
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Архив версий-снимков, потерявших инвалидирующий блок при форке. Story 4.4.
|
||||
*
|
||||
* Каждый ряд entity_versions хранит previous_data + block_num (блок, в котором данное
|
||||
* previous_data перестало быть актуальным). При форке на N все entity_versions
|
||||
* WHERE block_num > N теряют свой инвалидирующий блок (он на снесённой ветке) и
|
||||
* становятся «осиротевшими». Если не убрать — при повторном форке restoreFromVersions
|
||||
* подберёт их и поднимет не ту ветку.
|
||||
*
|
||||
* `original_block_num` = блок-инвалидатор из исходного entity_versions ряда (может быть null
|
||||
* для локальных pre-blockchain изменений). `invalidated_by_block` = блок форка.
|
||||
*/
|
||||
@Entity('invalidated_entity_versions')
|
||||
@Index('idx_invalidated_versions_block', ['invalidated_by_block'])
|
||||
@Index('idx_invalidated_versions_fork_event', ['fork_event_id'])
|
||||
@Index('idx_invalidated_versions_table_id', ['entity_table', 'entity_id'])
|
||||
export class InvalidatedEntityVersionTypeormEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
entity_table!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
entity_id!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
previous_data!: Record<string, any>;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
original_block_num?: number | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
invalidated_by_block!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
fork_event_id?: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50 })
|
||||
change_type!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, any> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Entity, Column, Index, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Архив сущностей, снесённых форком. Каждый ряд = одна live-запись, которая была
|
||||
* в зеркале блокчейна на момент форка (block_num > forkBlockNum). Story 4.4.
|
||||
*
|
||||
* `invalidated_by_block` = block_num форка (т.е. forked_from_block из ForkEvent).
|
||||
* `fork_event_id` группирует все снесённые одним форком ряды (опционально — старые форки до Story 4.4 без id).
|
||||
*
|
||||
* Retention: BlockchainArchiveRetentionService раз в час удаляет WHERE invalidated_by_block < LIB - 1000.
|
||||
*/
|
||||
@Entity('invalidated_entities')
|
||||
@Index('idx_invalidated_entities_block', ['invalidated_by_block'])
|
||||
@Index('idx_invalidated_entities_fork_event', ['fork_event_id'])
|
||||
@Index('idx_invalidated_entities_table_id', ['entity_table', 'entity_id'])
|
||||
export class InvalidatedEntityTypeormEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100 })
|
||||
entity_table!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 64 })
|
||||
entity_id!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
data!: Record<string, any>;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
invalidated_by_block!: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 128, nullable: true })
|
||||
fork_event_id?: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp' })
|
||||
created_at!: Date;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Story 6.5 (Epic 6): helper для эталонной точки `mapStatusToDomain`.
|
||||
* При попадании на default-ветку (unknown статус из цепи) пишет `logger.error`
|
||||
* с контекстом (entity, статус, ожидаемые статусы) — это audit-trail для schema drift.
|
||||
*
|
||||
* Возврата нет — caller сам решает, какой UNDEFINED-fallback использовать.
|
||||
*/
|
||||
|
||||
export interface AuditLoggerLike {
|
||||
error(message: string, ...meta: any[]): void;
|
||||
}
|
||||
|
||||
export function auditUnknownStatus(
|
||||
entityName: string,
|
||||
receivedStatus: unknown,
|
||||
logger: AuditLoggerLike,
|
||||
allowedStatuses?: ReadonlyArray<string>
|
||||
): void {
|
||||
const expected = allowedStatuses && allowedStatuses.length > 0 ? `[${allowedStatuses.join(', ')}]` : 'не указано';
|
||||
logger.error(
|
||||
`UNKNOWN_ENTITY_STATUS ${entityName}: получен '${String(receivedStatus)}', ожидаются ${expected}`,
|
||||
{ entityName, receivedStatus, allowedStatuses }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Story 6.5 (Epic 6): сигнализирует, что mapper не смог разобрать дельту блокчейна
|
||||
* (mapDeltaToBlockchainData вернул null). Бросается из `AbstractEntitySyncService.processDelta`
|
||||
* в strict-mode (`config.blockchain.unsupported_version_strict=true`).
|
||||
*
|
||||
* В non-strict режиме (default) ошибка не бросается — пишется только `logger.error` для
|
||||
* аудита; парсер ACK'нет fork-event-like (поведение совместимое с текущим).
|
||||
*/
|
||||
export class UnsupportedContractVersionError extends Error {
|
||||
constructor(
|
||||
public readonly entityName: string,
|
||||
public readonly context: {
|
||||
contract?: string;
|
||||
table?: string;
|
||||
primary_key?: string | number;
|
||||
block_num?: number;
|
||||
}
|
||||
) {
|
||||
super(
|
||||
`Unsupported contract version while mapping delta for ${entityName}: ${JSON.stringify(context)}`
|
||||
);
|
||||
this.name = 'UnsupportedContractVersionError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Контракт syncer'а, который умеет откатывать свои сущности при форке (ADR-005, Story 4.1).
|
||||
*
|
||||
* Реализуется один раз — в AbstractEntitySyncService, поэтому каждый наследник (capital,
|
||||
* agreements, wallet и пр.) получает поведение автоматически через `implements` родителя.
|
||||
*
|
||||
* ForkRegistryService собирает реализующих через DiscoveryService по symbol-маркеру
|
||||
* FORK_AWARE_MARKER на onApplicationBootstrap — без правок onModuleInit у наследников,
|
||||
* без instanceof-зависимости (Symbol на прототипе работает кросс-extension).
|
||||
*
|
||||
* ForkRegistryService обходит зарегистрированных syncer'ов **последовательно** (for-of await)
|
||||
* для каждой `handleFork(blockNum)`: re-throw любой ошибки останавливает дальнейший обход
|
||||
* и не даёт parser2 ACK'нуть форк-событие (повторная доставка пересыграет цепочку).
|
||||
*/
|
||||
export interface IForkAwareSyncer {
|
||||
/**
|
||||
* Откатить сущности этого syncer'а до состояния на блок forkBlockNum включительно.
|
||||
* При ошибке — обязан re-throw (silent catch ломает контракт sequential apply).
|
||||
*
|
||||
* Story 4.4: `forkEventId` (optional) — локально-вычисленный controller-формат
|
||||
* event_id (см. computeForkEventId), пробрасывается syncer'ом в архив инвалидированных
|
||||
* сущностей (invalidated_entities.fork_event_id) для группировки по форкам. Старые
|
||||
* вызовы без второго параметра остаются валидными — поле в архиве записывается NULL.
|
||||
*/
|
||||
handleFork(forkBlockNum: number, forkEventId?: string | null): Promise<void>;
|
||||
|
||||
/**
|
||||
* Опциональный приоритет для FK-зависимостей внутри одного контракта (меньше = раньше).
|
||||
* Если не задан — порядок берётся из обхода DiscoveryService (отражает DI-граф Nest).
|
||||
*/
|
||||
readonly forkRollbackPriority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker symbol для отделения форк-aware syncer'ов от прочих провайдеров при сканировании
|
||||
* DiscoveryService. Класс-родитель AbstractEntitySyncService выставляет marker = true на
|
||||
* своих экземплярах, поэтому все 20+ наследников автоматически попадают в обход без
|
||||
* правок их onModuleInit.
|
||||
*/
|
||||
export const FORK_AWARE_MARKER = Symbol.for('mono.controller.shared.sync.ForkAware');
|
||||
|
||||
/**
|
||||
* Type guard для проверки, что произвольный провайдер реализует IForkAwareSyncer.
|
||||
* Проверяет наличие symbol-маркера на инстансе и метода handleFork — duck typing
|
||||
* с защитой от ложных срабатываний.
|
||||
*/
|
||||
export function isForkAware(candidate: unknown): candidate is IForkAwareSyncer {
|
||||
if (candidate == null) return false;
|
||||
const obj = candidate as Record<PropertyKey, unknown>;
|
||||
return obj[FORK_AWARE_MARKER] === true && typeof obj.handleFork === 'function';
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { LoggerModule } from '~/application/logger/logger-app.module';
|
||||
import { ForkRegistryService } from './fork-registry.service';
|
||||
|
||||
/**
|
||||
* Глобальный модуль реестра форк-обработчиков (ADR-005, Story 4.1).
|
||||
*
|
||||
* @Global — чтобы любой extension (capital, agreements, wallet, future) мог инжектить
|
||||
* ForkRegistryService без явного импорта; сбор syncer'ов идёт pull-моделью через
|
||||
* DiscoveryService на onApplicationBootstrap (никаких правок наследников
|
||||
* AbstractEntitySyncService не требуется).
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [DiscoveryModule, LoggerModule],
|
||||
providers: [ForkRegistryService],
|
||||
exports: [ForkRegistryService],
|
||||
})
|
||||
export class ForkRegistryModule {}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { DiscoveryService } from '@nestjs/core';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { isForkAware, type IForkAwareSyncer } from './fork-aware-syncer.interface';
|
||||
|
||||
/**
|
||||
* Реестр syncer'ов, откатывающих свои сущности на форке (ADR-005, Story 4.1).
|
||||
*
|
||||
* Заменяет старый `@OnEvent('fork::*')` broadcast: тот вызывал handler'ы параллельно через
|
||||
* `EventEmitter2.emitAsync` + Promise.all, ломая per-aggregate ordering (NFR10) и оставляя
|
||||
* гонку «fork-vs-следующая-delta». ForkRegistry обходит syncer'ы строго sequential
|
||||
* (for-of await), что в сочетании с single-active XREADGROUP даёт натуральный барьер форка.
|
||||
*
|
||||
* Сбор syncer'ов — pull-модель через DiscoveryService на onApplicationBootstrap: проходим
|
||||
* по всем providers Nest, отбираем по FORK_AWARE_MARKER. Не требует super.onModuleInit() в
|
||||
* наследниках (которые свободно переопределяют onModuleInit для собственных подписок).
|
||||
*
|
||||
* INV-T03: rollback всех syncer'ов завершён до того, как BlockchainConsumerService двинется
|
||||
* к следующему событию того же stream'а. Любая ошибка в handleFork пробрасывается наверх —
|
||||
* parser2 не ACK'ает форк-событие и повторит доставку; уже отработавшие syncer'ы будут no-op
|
||||
* (versions уже подняты), сбойный — переиграет.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ForkRegistryService implements OnApplicationBootstrap {
|
||||
private readonly registered = new Set<IForkAwareSyncer>();
|
||||
|
||||
constructor(
|
||||
private readonly discoveryService: DiscoveryService,
|
||||
private readonly logger: WinstonLoggerService
|
||||
) {
|
||||
this.logger.setContext(ForkRegistryService.name);
|
||||
}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const providers = this.discoveryService.getProviders();
|
||||
let scanned = 0;
|
||||
for (const wrapper of providers) {
|
||||
const instance = wrapper.instance;
|
||||
if (isForkAware(instance)) {
|
||||
this.register(instance);
|
||||
scanned += 1;
|
||||
}
|
||||
}
|
||||
this.logger.log(`ForkRegistry: bootstrap discovered ${scanned} fork-aware syncer(s)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Зарегистрировать syncer вручную. Идемпотентно: повторная регистрация — no-op.
|
||||
* В рантайме обычно не вызывается напрямую — bootstrap-сканер сам всё подберёт;
|
||||
* метод оставлен публичным для тестов и динамических расширений.
|
||||
*/
|
||||
register(syncer: IForkAwareSyncer): void {
|
||||
if (this.registered.has(syncer)) return;
|
||||
this.registered.add(syncer);
|
||||
this.logger.debug(
|
||||
`ForkRegistry: registered ${syncer.constructor?.name ?? '<anonymous>'} (total=${this.registered.size})`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Снять syncer с регистрации (для тестов / hot-reload).
|
||||
*/
|
||||
unregister(syncer: IForkAwareSyncer): void {
|
||||
if (this.registered.delete(syncer)) {
|
||||
this.logger.debug(
|
||||
`ForkRegistry: unregistered ${syncer.constructor?.name ?? '<anonymous>'} (total=${this.registered.size})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очистить реестр (для тестов). В рантайме не вызывается.
|
||||
*/
|
||||
clear(): void {
|
||||
this.registered.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Текущее число зарегистрированных syncer'ов. Для логов / health-check'ов / тестов.
|
||||
*/
|
||||
size(): number {
|
||||
return this.registered.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequential rollback всех зарегистрированных syncer'ов. Re-throw первой ошибки —
|
||||
* BlockchainConsumerService прервёт processFork, parser2 не ACK'нет, форк переиграется.
|
||||
*
|
||||
* Порядок: сначала syncer'ы с заданным `forkRollbackPriority` (по возрастанию),
|
||||
* затем — без приоритета (в порядке обхода Discovery, что обычно соответствует DI-графу).
|
||||
*
|
||||
* Story 4.4: `forkEventId` пробрасывается в каждый syncer.handleFork — syncer кладёт
|
||||
* его в архив invalidated_entities для группировки по форкам.
|
||||
*/
|
||||
async runAll(forkBlockNum: number, forkEventId?: string | null): Promise<void> {
|
||||
const ordered = this.orderedForRollback();
|
||||
this.logger.debug(
|
||||
`ForkRegistry: runAll(blockNum=${forkBlockNum}, eventId=${forkEventId ?? 'n/a'}) — ${ordered.length} syncer(s)`
|
||||
);
|
||||
for (const syncer of ordered) {
|
||||
await syncer.handleFork(forkBlockNum, forkEventId);
|
||||
}
|
||||
}
|
||||
|
||||
private orderedForRollback(): IForkAwareSyncer[] {
|
||||
const withPriority: IForkAwareSyncer[] = [];
|
||||
const withoutPriority: IForkAwareSyncer[] = [];
|
||||
for (const syncer of this.registered) {
|
||||
if (typeof syncer.forkRollbackPriority === 'number') withPriority.push(syncer);
|
||||
else withoutPriority.push(syncer);
|
||||
}
|
||||
withPriority.sort((a, b) => (a.forkRollbackPriority as number) - (b.forkRollbackPriority as number));
|
||||
return [...withPriority, ...withoutPriority];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './fork-aware-syncer.interface';
|
||||
export * from './fork-registry.service';
|
||||
export * from './fork-registry.module';
|
||||
@@ -2,3 +2,4 @@ export * from './entities/base-domain.entity';
|
||||
export * from './entities/base-typeorm.entity';
|
||||
export * from './interfaces/base-database.interface';
|
||||
export * from './repositories/base-blockchain.repository';
|
||||
export * from './fork';
|
||||
|
||||
@@ -82,6 +82,15 @@ export abstract class BaseBlockchainRepository<
|
||||
// Проверяем, существует ли уже по кастомному ключу
|
||||
const existing = await this.findBySyncKey(syncKey, syncValue);
|
||||
if (existing) {
|
||||
// Guard монотонности block_num (DEC-008, Story 1.1): на create-пути не
|
||||
// даём устаревшей дельте (из более раннего блока) затереть более свежую
|
||||
// запись — иначе состояние в БД откатывается назад при гонке дельт.
|
||||
// block_num из PG может прийти строкой (bigint), поэтому сравниваем
|
||||
// через Number (см. controller/CLAUDE.md, bigint-as-string).
|
||||
const existingBlockNum = existing.getBlockNum();
|
||||
if (existingBlockNum != null && Number(blockNum) < Number(existingBlockNum)) {
|
||||
return existing; // stale overwrite предотвращён
|
||||
}
|
||||
// Обновляем существующую сущность
|
||||
existing.updateFromBlockchain(blockchainData, blockNum, present);
|
||||
return await this.save(existing);
|
||||
@@ -118,6 +127,33 @@ export abstract class BaseBlockchainRepository<
|
||||
await this.entityVersioningService.restoreVersionsAfterFork(this.repository, this.getEntityTableName(), forkBlockNum);
|
||||
}
|
||||
|
||||
/**
|
||||
* Story 4.4: архивировать live-ряды WHERE block_num > forkBlockNum в invalidated_entities
|
||||
* и удалить из исходной таблицы (атомарно). Возвращает count. Заменяет в hot-path
|
||||
* handleFork прежнюю пару findByBlockNumGreaterThan + deleteByBlockNumGreaterThan.
|
||||
*/
|
||||
async archiveInvalidatedSince(forkBlockNum: number, forkEventId?: string | null): Promise<number> {
|
||||
return this.entityVersioningService.archiveAndDeleteLiveAfterFork(
|
||||
this.repository,
|
||||
this.getEntityTableName(),
|
||||
forkBlockNum,
|
||||
forkEventId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Story 4.4: архивировать entity_versions WHERE entity_table=... AND block_num > forkBlockNum
|
||||
* в invalidated_entity_versions и удалить из entity_versions (атомарно). Возвращает count.
|
||||
* Должен вызываться ПОСЛЕ restoreFromVersions — иначе restore не сможет прочитать ещё-живые версии.
|
||||
*/
|
||||
async archiveInvalidatedVersionsSince(forkBlockNum: number, forkEventId?: string | null): Promise<number> {
|
||||
return this.entityVersioningService.archiveAndDeleteVersionsAfterFork(
|
||||
this.getEntityTableName(),
|
||||
forkBlockNum,
|
||||
forkEventId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить сущность
|
||||
*/
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { InvalidatedEntityVersionTypeormEntity } from '../entities/invalidated-entity-version.typeorm-entity';
|
||||
|
||||
export interface InvalidatedEntityVersionRecord {
|
||||
entity_table: string;
|
||||
entity_id: string;
|
||||
previous_data: Record<string, any>;
|
||||
original_block_num?: number | null;
|
||||
invalidated_by_block: number;
|
||||
fork_event_id?: string | null;
|
||||
change_type: string;
|
||||
metadata?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Репозиторий архива снесённых форком версий-снимков entity_versions (Story 4.4).
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvalidatedEntityVersionRepository {
|
||||
constructor(
|
||||
@InjectRepository(InvalidatedEntityVersionTypeormEntity)
|
||||
private readonly repository: Repository<InvalidatedEntityVersionTypeormEntity>
|
||||
) {}
|
||||
|
||||
async bulkInsert(records: InvalidatedEntityVersionRecord[]): Promise<number> {
|
||||
if (records.length === 0) return 0;
|
||||
const entities = records.map((r) => this.repository.create(r));
|
||||
const saved = await this.repository.save(entities);
|
||||
return saved.length;
|
||||
}
|
||||
|
||||
async deleteOlderThan(minInvalidatedByBlock: number): Promise<number> {
|
||||
const result = await this.repository.delete({
|
||||
invalidated_by_block: LessThan(minInvalidatedByBlock),
|
||||
});
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThan } from 'typeorm';
|
||||
import { InvalidatedEntityTypeormEntity } from '../entities/invalidated-entity.typeorm-entity';
|
||||
|
||||
export interface InvalidatedEntityRecord {
|
||||
entity_table: string;
|
||||
entity_id: string;
|
||||
data: Record<string, any>;
|
||||
invalidated_by_block: number;
|
||||
fork_event_id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Репозиторий архива снесённых форком live-сущностей (Story 4.4).
|
||||
*/
|
||||
@Injectable()
|
||||
export class InvalidatedEntityRepository {
|
||||
constructor(
|
||||
@InjectRepository(InvalidatedEntityTypeormEntity)
|
||||
private readonly repository: Repository<InvalidatedEntityTypeormEntity>
|
||||
) {}
|
||||
|
||||
async bulkInsert(records: InvalidatedEntityRecord[]): Promise<number> {
|
||||
if (records.length === 0) return 0;
|
||||
const entities = records.map((r) => this.repository.create(r));
|
||||
const saved = await this.repository.save(entities);
|
||||
return saved.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retention: удалить архив старше указанного блока. Делается отдельной транзакцией,
|
||||
* не транзакционно с архивированием — это фоновая очистка.
|
||||
*/
|
||||
async deleteOlderThan(minInvalidatedByBlock: number): Promise<number> {
|
||||
const result = await this.repository.delete({
|
||||
invalidated_by_block: LessThan(minInvalidatedByBlock),
|
||||
});
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forensic-read для AC «список из invalidated_entities, сгруппированный по fork_event_id».
|
||||
* UI/CLI обёртка — Epic 9 (out of scope 4.4); сам repository-метод доступен из backend-кода.
|
||||
*/
|
||||
async findGroupedByForkEventId(opts: {
|
||||
blockNum?: number;
|
||||
limit?: number;
|
||||
}): Promise<Map<string | null, InvalidatedEntityTypeormEntity[]>> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('inv')
|
||||
.orderBy('inv.fork_event_id', 'ASC')
|
||||
.addOrderBy('inv.created_at', 'DESC');
|
||||
|
||||
if (opts.blockNum != null) {
|
||||
qb.where('inv.invalidated_by_block = :blockNum', { blockNum: opts.blockNum });
|
||||
}
|
||||
if (opts.limit != null) {
|
||||
qb.limit(opts.limit);
|
||||
}
|
||||
|
||||
const rows = await qb.getMany();
|
||||
const grouped = new Map<string | null, InvalidatedEntityTypeormEntity[]>();
|
||||
for (const row of rows) {
|
||||
const key = row.fork_event_id ?? null;
|
||||
const bucket = grouped.get(key) ?? [];
|
||||
bucket.push(row);
|
||||
grouped.set(key, bucket);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { WinstonLoggerService } from '~/application/logger/logger-app.service';
|
||||
import { BlockchainService } from '~/infrastructure/blockchain/blockchain.service';
|
||||
import { InvalidatedEntityRepository } from '../repositories/invalidated-entity.repository';
|
||||
import { InvalidatedEntityVersionRepository } from '../repositories/invalidated-entity-version.repository';
|
||||
import config from '~/config/config';
|
||||
|
||||
/**
|
||||
* Story 4.4: фоновая очистка архивов invalidated_entities / invalidated_entity_versions.
|
||||
*
|
||||
* Логика retention:
|
||||
* - LIB = chain.get_info().last_irreversible_block_num — авторитативный last irreversible
|
||||
* block ноды (вариант C, без локальной эвристики «head − N»; точность важна потому что
|
||||
* при глубоком форке архив — единственный источник восстановления, его срез до LIB
|
||||
* означал бы потерю данных).
|
||||
* - threshold = LIB - RETENTION_HORIZON_BLOCKS (1000 блоков запаса сверху). Хардкод,
|
||||
* не env: окно отражает property сети EOSIO, не оператора. Перенастройка — отдельная
|
||||
* Epic 9 story.
|
||||
* - Удаляем WHERE invalidated_by_block < threshold. Архив со старшими блоками остаётся.
|
||||
* - Если LIB ≤ RETENTION_HORIZON_BLOCKS (свежезапущенная testnet или ошибка ноды) —
|
||||
* threshold ≤ 0, skip + log.
|
||||
* - Cron-расписание: `BLOCKCHAIN_ARCHIVE_RETENTION_CRON` (default `0 * * * *` = ежечасно).
|
||||
* - Глобальный выключатель: `BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED` (default true).
|
||||
*/
|
||||
@Injectable()
|
||||
export class BlockchainArchiveRetentionService {
|
||||
/**
|
||||
* Запас сверху над LIB. Срез ровно по LIB опасен — если нода ошибётся с LIB,
|
||||
* срежем потенциально нужное для восстановления. 1000 блоков (~8 минут на 0.5s блоке)
|
||||
* даёт буфер на любую BP-нелинейность irreversibility.
|
||||
*/
|
||||
private static readonly RETENTION_HORIZON_BLOCKS = 1000;
|
||||
|
||||
constructor(
|
||||
private readonly blockchainService: BlockchainService,
|
||||
private readonly invalidatedEntityRepository: InvalidatedEntityRepository,
|
||||
private readonly invalidatedEntityVersionRepository: InvalidatedEntityVersionRepository,
|
||||
private readonly logger: WinstonLoggerService
|
||||
) {
|
||||
this.logger.setContext(BlockchainArchiveRetentionService.name);
|
||||
}
|
||||
|
||||
@Cron(process.env.BLOCKCHAIN_ARCHIVE_RETENTION_CRON || '0 * * * *')
|
||||
async cleanup(): Promise<void> {
|
||||
if (!config.blockchain.archive_retention_enabled) {
|
||||
this.logger.debug('Archive retention disabled — skipping cleanup');
|
||||
return;
|
||||
}
|
||||
|
||||
let lib: number;
|
||||
try {
|
||||
const info = await this.blockchainService.getInfo();
|
||||
lib = info.last_irreversible_block_num;
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Archive retention: не удалось получить LIB из chain.get_info — skip cleanup: ${e?.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const horizon = BlockchainArchiveRetentionService.RETENTION_HORIZON_BLOCKS;
|
||||
const threshold = lib - horizon;
|
||||
|
||||
if (threshold <= 0) {
|
||||
this.logger.log(
|
||||
`Archive retention: LIB=${lib} ≤ horizon ${horizon} — нечего удалять (свежий chain)`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedEntities = await this.invalidatedEntityRepository.deleteOlderThan(threshold);
|
||||
const deletedVersions = await this.invalidatedEntityVersionRepository.deleteOlderThan(threshold);
|
||||
|
||||
this.logger.log(
|
||||
`Archive retention: LIB=${lib}, threshold=${threshold} (LIB-${horizon}); удалено ${deletedEntities} invalidated_entities + ${deletedVersions} invalidated_entity_versions`
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user