Compare commits

..

28 Commits

Author SHA1 Message Date
ant b84b02f1b4 Merge pull request 'sync-arch: parser2 ^1.2.0 + прокидывание block_time из ActionEvent/DeltaEvent [C28-13]' (#92) from parser2-block-time-and-version into parser2
Typecheck / desktop (pull_request) Successful in 12m46s
Typecheck / controller (pull_request) Successful in 12m28s
Reviewed-on: #92
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-05 10:45:08 +00:00
coopops c33ec53230 [C28-13][@ant] feat(controller): parser2 ^1.2.0 + прокидывание block_time из ActionEvent/DeltaEvent
- @coopenomics/parser2: "1.1.0" → "^1.2.0" — снят точный pin, апгрейд парсера = npm install
- IAction/IDelta: + block_time?: string (ISO-8601, parser1 не давал, parser2 1.2.0 отдаёт)
- parser2-event.mapper.ts: пробрасывает event.block_time в IAction.block_time и IDelta.block_time
- mapper.test: 2 новых проверки на block_time (delta + action) — 9/9 зелёные

Поверх ветки parser2 (#68). Native-delta остаётся явно проигнорированной (как у parser1), это отдельное расширение интерфейса при необходимости.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:05:31 +00:00
ant 7dcca3a2ae Merge pull request 'Эпик 4 sync-arch: правильная обработка форков блокчейна (релиз 1.1.2) [C28-14]' (#54) from parser2-epic-4-forks into parser2
Typecheck / desktop (pull_request) Successful in 13m20s
Typecheck / controller (pull_request) Successful in 13m21s
Reviewed-on: #54
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-03 08:32:46 +00:00
ant 70752b2caa Merge pull request 'sync-arch: явные ошибки при unknown contract version / status (Story 6.5) [C28-16]' (#57) from parser2-epic-6-canonical-storage into parser2-epic-4-forks
Reviewed-on: #57
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-06-03 08:31:35 +00:00
coopops 8c5568bcf8 [C28-16][@ant] revert: откат Story 6.1 (namespace bc + replaceBc) + Story 6.2 (signedDocumentFields) — вынос за MVP-релиз parser2
Цель релиза 1.1.2 — «затащить parser2 нормально», т.е. заменить транспорт parser1→parser2.
Эпик 6 «единый порядок хранения данных» — отдельная архитектурная санитация, не связана с
заменой транспорта. Включение её в MVP-релиз привнесло инвазивные изменения:

- Story 6.1 (namespace `db`/`bc` + `replaceBc` + переписанный ProjectDomainEntity) вводит
  двойной стандарт: одна entity на новом паттерне, 21 entity на старом. Потребители
  читают `project.master` (плоский флэт) — частичная миграция создаёт регрессии у resolver'ов.
  Полная миграция = большой blast radius на 22 entity + Epic 9.5 backlog. Откладывается
  в отдельный sync-arch sanitation-эпик.

- Story 6.2 (declarative `signedDocumentFields` + `normalizeSignedDocuments` + path-parser)
  — DRY-рефакторинг без функциональной выгоды. AppendixDeltaMapper и так руками вызывал
  `convertChainDocumentToDomainFormat`. Польза появится когда подписанных полей станет
  много — пока их одно поле в одной точке.

Остаётся: Story 6.5 (UnsupportedContractVersionError + auditUnknownStatus +
`BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT`). Очевидный фикс silent loss при schema drift,
не инвазивный.

Удалено: composite-entity.contract.test.ts, signed-document-normalization.test.ts,
delta-mapper-signed-doc.contract.test.ts. CLAUDE.md секция Composite-Entity ADR-008
помечена как «будущая цель, не сейчас».

10 jest blockchain unit suites / 64 tests зелёные. tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 10:37:48 +00:00
coopops 30b79ad1f7 [C28-16][@ant] revert: откат Story 6.3 (zod) + 6.4 (_checksum) — pre-mature и за гранью sync-слоя
Story 6.3 (per-doc-type zod schemas): валидация `signatures.length === 1/2` зашивала
документную семантику в нормализатор parser-event. Число и валидность подписей — домен
контракта (`require_auth`), не бэкенда. При расширении доктайпов контракта (1→2 подписи)
sync падал бы на структурно-валидной цепи. Структурный transform `meta: JSON-string → object`
(Story 6.2) остаётся.

Story 6.4 (`_checksum` колонка + sha256(canonical-json(bc))): потребитель — Epic 7 nightly
snapshot и Epic 8 reconciliation — ещё не реализованы. Колонка хранила бы пустую нагрузку
+ +20% storage + лишний sha256 на каждом save/update. Принцип «не добавлять контрольных
полей до появления потребителя». Алгоритм canonicalStringify заведём как часть Epic 8.

Удалено: src/shared/sync/{checksum.util.ts, signed-document-schemas.ts} + 3 теста +
applyBcChecksum + колонка _checksum + поле _checksum на BaseDomainEntity. Story 6.1
(namespace db/bc + replaceBc), 6.2 (declarative signedDocumentFields + meta transform),
6.5 (UnsupportedContractVersionError + auditUnknownStatus) сохраняются.

13 jest blockchain unit suites / 196 tests зелёные. tsc зелёный.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 08:37:57 +00:00
coopops 87747d1782 [C28-16][@ant] feat: UnsupportedContractVersionError + auditUnknownStatus — Story 6.5
Новые shared/sync/errors:
- UnsupportedContractVersionError(entityName, ctx{contract,table,primary_key,
  block_num}) — носит контекст для DLQ-операторской диагностики.
- auditUnknownStatus(entityName, receivedStatus, logger, allowedStatuses?) —
  фиксирует unknown-status drift в audit-trail с ожидаемыми вариантами.

AbstractEntitySyncService.processDelta: mapper вернул null → logger.error
("UNSUPPORTED_CONTRACT_VERSION", ctx) ВСЕГДА (раньше был silent warn). В strict-mode
(config.blockchain.unsupported_version_strict=true) дополнительно throw — парсер не
ACK'нет дельту, DLQ сработает. UnsupportedContractVersionError из try/catch
пробрасывается дальше; остальные ошибки по-прежнему логируются и return null.

config.ts: BLOCKCHAIN_UNSUPPORTED_VERSION_STRICT (default false) → blockchain
.unsupported_version_strict. Default false — не ломать прод немедленно;
включается на стенде после подтверждения отсутствия schema drift.

ProjectDomainEntity.mapStatusToDomain эталонно — default ветка вместо silent
UNDEFINED-возврата вызывает auditUnknownStatus с полным списком ожидаемых:
[pending,active,voting,result,finalized,cancelled]. Остальные mapStatusToDomain
(state, vote, segment, debt и т.д.) — Epic 9.5.

unsupported-version-explicit-error.test.ts (6) — UnsupportedContractVersionError
конструктор/контекст; auditUnknownStatus с/без allowedStatuses;
processDelta non-strict (logger.error + null), strict (throw),
happy-path (handleSyncDelta вызывается).

229/229 blockchain unit зелёные.

Эпик 6 закрыт целиком: 6.1 namespace, 6.2 normalizeSignedDocuments, 6.3 Zod schemas,
6.4 checksum, 6.5 explicit errors. Release 1.1.2 движется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:52:40 +00:00
coopops f7a182e51f [C28-16][@ant] feat: detrministic _checksum sha256(bc canonical-json) — Story 6.4
checksum.util.ts: canonicalStringify(value) (рекурсивная сортировка ключей объектов,
порядок массивов сохраняется, bigint → string, undefined → "null"); computeBcChecksum
→ sha256-hex 64 chars от canonical-stringified bc-namespace. Без внешних зависимостей.

BaseTypeormEntity получает колонку `_checksum varchar(64) nullable` —
legacy записи без bc-namespace получают хэш от "null" (стабильно, но не несёт
информации); после миграции Epic 9.5 на namespace все блокчейн-зеркала получат
содержательный checksum. BaseDomainEntity получает поле `_checksum?: string | null`.

BaseBlockchainRepository.save и .update вызывают protected applyBcChecksum(domain,
typeormEntity) ПОСЛЕ mapper.toEntity и ДО repository.save — proseться через единую
точку, не лезем в 22 mapper'а. Поле bc-namespace → reconciliation Epic 8.2
сравнивает только то, что реально в цепи (локальные db-поля типа matrix_room_id
из цепи не получаются и в checksum их быть не должно).

checksum.util.test.ts (14) — canonicalStringify: primitives/array/nested/bigint/sort;
computeBcChecksum: 64 hex chars / детерминизм / change-detection / null-stable /
порядок массивов / контрольный фиксированный хэш на nested-структуре (catch для
несовместимого изменения алгоритма).

base-repo-checksum.contract.test.ts (3) — bc заданный → checksum по нему;
bc undefined → checksum от null; повторный вызов → одинаковый _checksum.

223/223 blockchain unit зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:44:55 +00:00
coopops 9774abb296 [C28-16][@ant] feat: Zod per-doc-type schemas + valid-pre-transform — Story 6.3
Новый файл shared/sync/signed-document-schemas.ts: signatureInfoSchema (ISignatureInfo)
+ три per-doc-type схемы IChainDocument2:
- chainDocumentSchema — default ≥1 подпись (стандартный мультисиг кооператива).
- singleSignatureChainDocumentSchema — ровно 1 (одиночный автор).
- twoSignatureChainDocumentSchema — ровно 2 (двухподписные акты signsupp/signchair
  и signact1/signact2; OQ-13: ВСЕ подписанты массивом, primary не выделяется).

SignedDocField (Story 6.2) расширен optional schema?: ZodTypeAny — backward-compat
для legacy полей без валидации. AbstractBlockchainDeltaMapper.normalizeSignedDocuments
делает schema.parse ДО transform: при schema-drift цепи (новая структура IChainDocument2
из коопконтракта) — Zod бросает ZodError, mapper.try/catch отдаёт null + warn, Story 6.5
повысит до alert. До этого PR drift молча проходил, meta оставался JSON-строкой.

Эталон: appendix-delta.mapper.ts — добавлен singleSignatureChainDocumentSchema
(приложение к ТЭМ имеет одного автора).

signed-document-schemas.test.ts (16) — каждая схема: valid → ok, обязательные поля
отсутствуют → fail, неверное число подписей → fail. signed-document-normalization
расширен 3 тестами на schema-pre-transform: валидная schema → нормализация проходит,
single-signature на 2 подписи → ZodError, missing hash → ZodError + meta не обновлена
(transform не вызвался).

206/206 blockchain unit зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:27:00 +00:00
coopops 724cd381cc [C28-16][@ant] feat: AbstractDeltaMapper.normalizeSignedDocuments + SignedDocField — Story 6.2
AbstractBlockchainDeltaMapper получает protected readonly signedDocumentFields
ReadonlyArray<SignedDocField> (default []) + helper normalizeSignedDocuments(data).
SignedDocField описывает путь к IChainDocument2-полю в TBlockchainData; bracket-
нотация поддерживает массивы: `appendix` (top-level), `statement.attachments[]
.signed_attachment` (nested-array, E12). parseSignedDocPath → PathSegment[],
applyAtPath in-place трансформирует через
DomainToBlockchainUtils.convertChainDocumentToDomainFormat.

appendix-delta.mapper.ts — эталон: ручной convertChainDocumentToDomainFormat
(value.appendix) заменён декларацией signedDocumentFields = [{ path: 'appendix' }]
+ this.normalizeSignedDocuments({ ...value }).

signed-document-normalization.test.ts (16) — парсер 4 кейса + normalize 7 кейсов
(top-level, nested-array, missing-field no-op, multiple fields, empty config,
null/undefined, immutability caveat).

delta-mapper-signed-doc.contract.test.ts (3) — guard: mapper с непустым
signedDocumentFields обязан вызвать this.normalizeSignedDocuments — иначе
silent data corruption (meta остаётся JSON-строкой, Story 6.4 checksum
не сходится).

IPFS lazy resolver (E14) и миграция остальных mapper'ов с ручной нормализацией —
вне scope, тречится Epic 9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:21:09 +00:00
coopops 5aa1a21a97 [C28-16][@ant] feat: namespace db/bc/derived + replaceBc helper — Story 6.1
BaseDomainEntity получает generic <TDb, TBc>: this.db (shallow-copy databaseData),
this.bc (nullable, заполняется через utility replaceBc()), block_num/present/status
остаются на базе (hot-path stale-delta guard). Backward-compat: TBc=unknown по
дефолту — 22 legacy entity компилируются без изменений.

replaceBc вынесен utility-функцией, не методом класса — protected/public метод
ломает structural typing там, где Omit<XxxDomainEntity,...> используется в сигнатурах
репо (state-typeorm-repo).

ProjectDomainEntity — эталон: PROJECT_BC_KEYS как const satisfies, updateFromBlockchain
вызывает replaceBc(this, blockchainData, PROJECT_BC_KEYS) вместо запрещённого
Object.assign(this, blockchainData). Плоские поля остаются для legacy compat —
миграция остальных потребителей на entity.bc.* трекется Epic 9.5.

composite-entity.contract.test.ts — рекурсивно сканит src/**/*.entity.ts, для
не-legacy entity запрещает Object.assign(this, ...). Legacy allowlist на 22 файла
с явной отсылкой на Epic 9.5. 99/99 entity prove'нуты + Project явно проверен
на replaceBc + PROJECT_BC_KEYS.

controller/CLAUDE.md — секция Composite-Entity (ADR-008) актуализирована: BC_KEYS
с satisfies ReadonlyArray<keyof IXxxBlockchainData> — canonical-ordered источник
для Story 6.4 checksum.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 07:11:34 +00:00
coopops 933e5b5ffe [C28-14][@ant] feat: архив инвалидированных сущностей + retention LIB-1000 — Story 4.4
handleFork теперь не делает hard-delete, а атомарно переносит снесённые форком live-ряды
в invalidated_entities и инвалидированные снимки entity_versions в invalidated_entity_versions
(две таблицы — разные семантики). Порядок: archiveInvalidatedSince → restoreFromVersions →
archiveInvalidatedVersionsSince. forkEventId (controller-формат chain:fork:N:short) пробрасывается
от handleEvent → processFork → ForkRegistry.runAll → syncer.handleFork → архив.fork_event_id
для forensic-группировки.

BlockchainArchiveRetentionService (@Cron, default ежечасно) читает LIB через
BlockchainService.getInfo() и удаляет архив старше LIB-1000 блоков. RETENTION_HORIZON_BLOCKS=1000
хардкод (свойство сети, не оператора). Env-переключатели:
BLOCKCHAIN_ARCHIVE_RETENTION_ENABLED (default true), BLOCKCHAIN_ARCHIVE_RETENTION_CRON.

EntityVersioningService расширен archive-методами под DataSource.transaction. Контракт
IForkAwareSyncer.handleFork(forkBlockNum, forkEventId?) — обратно совместим (2-й optional).
IBlockchainSyncRepository.archive* — optional, fallback на старую find+delete для off-chain
(allowlist Story 4.3).

ScheduleModule.forRoot() добавлен в app.module (раньше не было).

Tests: 14 новых unit (entity-archive, retention, handleFork contract) + 52 регрессионных
(processFork, fork-registry, base-repo-contract, no-onevent) — 66 зелёных, tsc=0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 16:43:10 +00:00
coopops d886d780fc [C28-14][@ant] test: contract guard BaseBlockchainRepository + block_num типизация — Story 4.3
Audit-проход без миграции рантайма. Полная карта entity/repo и сравнений
block_num — в blago _bmad-output/tracks/sync-arch/.../audit-report-4-3.md.

Изменения в коде:

1. tests/unit/blockchain/base-blockchain-repository.contract.test.ts — НОВЫЙ
   regression-guard. Сканирует все *.typeorm-entity.ts extends BaseTypeormEntity
   и для каждой ищет *.typeorm-repository.ts extends BaseBlockchainRepository.
   Allowlist OFF_CHAIN_BASE_ENTITIES для 5 off-chain артефактов (comment,
   cycle, issue, story, time-entry) — у них block_num унаследован vestigial,
   синкера нет, форк их не откатывает (корректно). Тест ловит регрессию,
   если кто-то добавит блокчейн-mirror entity без BaseBlockchainRepository
   — иначе entity_versions молча перестанут писаться, форк превратится в
   hard delete (silent data loss).

2. controller/CLAUDE.md — два новых правила:
   (a) разнобой типов block_num: BaseTypeormEntity (capital+shared) использует
       integer+number — корректно; ActionEntity/DeltaEntity/ForkEntity/
       SyncStateEntity — bigint+number type-mismatch (PG возвращает string,
       TS говорит number). Hot-path везде явно Number() либо PG bind, так
       что runtime safe. Технический долг в Epic 9 — bigint Transformer.
   (b) контракт «entity с block_num → repo extends BaseBlockchainRepository»
       + ссылка на regression test + allowlist OFF_CHAIN_BASE_ENTITIES.

3. tests/unit/blockchain/base-blockchain-repository.contract.test.ts:
   нюанс — entityKindFromFileName нормализует суффикс -typeorm (chairman
   approval-typeorm.entity.ts vs approval.typeorm-repository.ts mismatch
   имён файлов).

Findings (полное в audit-report-4-3.md):
- 25 entity extends BaseTypeormEntity; 20 имеют BaseBlockchainRepository.
- 5 без BaseBlockchainRepository — off-chain артефакты (легитимно).
- 4 infra entity с bigint+number type-mismatch (runtime safe, тех. долг).
- Direct typeormRepo.save для блокчейн-зеркал — НЕ найдено (контракт соблюдён).
- Все сравнения block_num в sync-core используют Number() или PG bind.

Tests: 2/2 contract-test зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:51:22 +00:00
coopops 68b37efbe9 [C28-14][@ant] refactor: снять DEC-013 pause-barrier и @OnEvent('fork::*') — Story 4.2
После Story 4.1 ForkRegistry уже даёт sequential rollback всех syncer'ов через
pull-DiscoveryService, а saveFork идёт прямым вызовом из processFork. Старый
broadcast-путь через EventEmitter ('fork::*' + emitAsyncWithTimeout с TTL
force-resume из Story 1.3 DEC-013) — рудимент: дублирует saveFork (через
BlockchainEventHandlerService.handleForkEvent), повторно зовёт handleFork в
каждом syncer'е (idempotent no-op после ForkRegistry, но лишний DB-traffic).

Удалено:
- @OnEvent('fork::*') + handle*Fork методы в 20 файлах:
  · 3 в infrastructure/database/typeorm/blockchain/services/ (user-agreement, user-wallet, agreement)
  · 16 в extensions/capital/application/syncers/
  · 1 в extensions/chairman/infrastructure/blockchain/services/ (approval)
  · 1 дубль saveFork в domain/parser/services/blockchain-event-handler.service.ts
  · orphan dead-метод в application/wallet/services/program-wallet-sync.service.ts
- Шаг 4 (deprecated emitAsyncWithTimeout) из BlockchainConsumerService.processFork.
- BLOCKCHAIN_FORK_PAUSE_TIMEOUT_MS из config/config.ts (zod + config объект).
- Упоминание env-var из controller/CLAUDE.md.

Что осталось:
- EventsService.emitAsyncWithTimeout — определение в infrastructure/events/events.service.ts.
  Каллеров в src/ нет; не удаляю утилиту — возможно пригодится для Epic 5
  (waitForDelta / pool retry). Удалят на следующем рефакторе если не использована.
- AbstractEntitySyncService.handleFork остаётся: его теперь зовёт только ForkRegistry.
- @OnEvent('action::*') и @OnEvent('delta::*') в BlockchainEventHandlerService —
  валидный dispatch pipeline (ADR-002), не трогаем.

Tests:
- processFork.test.ts: убраны expect'ы на emit; новый regression test что
  events.emit/emitAsyncWithTimeout НЕ вызываются с fork:: префиксом.
- НОВЫЙ tests/unit/blockchain/no-onevent-fork.test.ts: grep-guard по src/
  на @OnEvent('fork::*') (комментарии отфильтрованы) → fail на регрессии.
- Все 6 suites зелёные (52 теста), tsc --noEmit 0 errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 15:42:39 +00:00
coopops e810d3152f [C28-14][@ant] feat: ForkRegistry sequential apply форка — Story 4.1
Цель — снять параллельный @OnEvent('fork::*') broadcast в 20+ syncer-ов и
заменить на sequential обход через ForkRegistryService, что в сочетании
с single-active XREADGROUP parser2 даёт натуральный барьер форка (INV-T03,
NFR10). Старый broadcast путь оставлен deprecated на этот релиз — Story 4.2
удалит вместе с pause-barrier из DEC-013.

Реализация:
- shared/sync/fork/{interface,service,module}: IForkAwareSyncer + symbol-marker
  FORK_AWARE_MARKER, ForkRegistryService с pull-сбором через DiscoveryService
  на onApplicationBootstrap, sequential for-of await + priority-ordering.
- AbstractEntitySyncService: implements IForkAwareSyncer (marker через class
  field), handleFork теперь re-throw (catch удалён) — контракт sequential
  ForkRegistry.runAll. Не требует super.onModuleInit() у 20 наследников
  (DiscoveryService снимает push-зависимость).
- BlockchainConsumerService.handleEvent для fork: dedup-gate через
  computeForkEventId → processFork (runAll → deleteDedupAfterBlock → saveFork
  → deprecated emit) → markEventApplied. Для action/delta — markEventApplied
  теперь пишет block_num.
- ConsumerDedup: новая колонка block_num (bigint nullable) + индекс +
  deleteAfterBlock(blockNum). Старые NULL-записи не затрагиваются.
- event-id.util: новый computeForkEventId в формате chain:fork:block:short_id
  (полное «fork», единый стиль с action/delta — parser2-формат chain:f:... не
  используется).

Тесты (51 кейс, 5 suites):
- unit fork-registry: sequential apply, error propagation, priority, bootstrap
  discovery (15 кейсов).
- unit blockchain-consumer.processFork: порядок шагов, dedup-gate, mark после
  processFork, fail-paths, parser2-format isolation (11 кейсов).
- unit consumer-dedup repository: markApplied с/без blockNum, deleteAfterBlock
  edge-cases (8 кейсов).
- unit event-id.util: computeForkEventId детерминизм + поведение на коротком
  block_id (5 новых кейсов).
- integration fork-flow: bootstrap → delta(N+1)→fork(N)→delta(N+2), rollback
  не трогает <=N, error propagation (4 кейса с реальным Nest DI).

tsc 0 ошибок, jest 51/51 зелёные.

Spec: blago/production/13-platforma-tsifrovogo-kooperativa/components/14-versiya-3/_bmad-output/tracks/sync-arch/implementation-artifacts/spec-4-1-fork-kak-event-v-unified-stream.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:52:38 +00:00
coopops 99c22b0064 [C28-13][@ant] chore: обновить pnpm-lock.yaml до @coopenomics/parser2@1.1.0
Lockfile висел на 1.0.3, потому что 1.1.0 ещё не был опубликован
в npm в момент bump'а package.json. Сейчас 1.1.0 в реестре, pnpm
install подхватил его + новую транзитивную @coopenomics/coopos-ship-reader@0.2.0
и @napi-rs/nice@1.1.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:26:30 +00:00
ant 4c0fbefca9 Merge pull request 'Эпик 3 sync-arch: переезд транспорта controller с parser1 на parser2 ParserClient [C28-13]' (#35) from parser2-epic-3-transport into parser2
Reviewed-on: #35
2026-05-28 11:57:20 +00:00
coopops 8fdc99a7f9 [C28-13][@ant] feat: маппер parser2 отдаёт реальные поля action-трейса + dep parser2 1.1.0 — паритет с parser1, без заглушек
transaction_id/creator_action_ordinal/context_free/elapsed/console/
account_ram_deltas + receipt.auth_sequence теперь идут из ActionEvent
parser2 1.1.0 (добавлены в сам пакет + ship-reader 0.2.0). Нужны
ledger2 для cross-link родительского apply (transaction_id+action_ordinal)
и blockchain-explorer. tsc --noEmit контроллера против новых типов — чисто.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 17:08:24 +00:00
coopops 8d27c4e245 [C28-13][@ant] feat: заменить транспорт parser1 на parser2 ParserClient — единый движок без флагов
Контроллер читает события через @coopenomics/parser2 ParserClient вместо самодельного
consumer'а поверх Redis-стрима notifications (его писал parser1). Никаких флагов и
параллельной работы двух движков: одна система — либо работает на parser2, либо нет.

Удалена ручная обвязка (RedisStreamService, consumer-group, recoverOwnPending, XAUTOCLAIM,
XTRIM) — всё это ParserClient делает сам (single-active-lock, recover, dead-letter после
N провалов). Новый consume-loop ведёт генератор stream() вручную: next() при успехе
(XACK внутри parser2), throw() при ошибке (учёт провалов / dead-letter) — наивный for-await
неверен, проброс из тела не доходит до catch вокруг yield и убивал бы консьюмер.

Маппер parser2-event.mapper переводит ParserEvent → IDelta/IAction (DEC-T09), обработчики
processAction/processDelta/processFork не тронуты. Дедуп по event_id теперь безусловный
(флаг BLOCKCHAIN_DEDUP_ENABLED убран), формат event_id оставлен прежним (action/delta).

ВНИМАНИЕ: parser2 ActionEvent не несёт transaction_id/creator_action_ordinal — ledger2
cross-link родительского apply на них опирается; в маппере заглушки, проверить на прогоне.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 15:58:12 +00:00
coopops 0e3ccb4356 Revert "[C28-13][@ant] fix: выровнять формулу event_id под parser2 v1.0.3 — сверка перед cutover"
This reverts commit 306835d0fc.
2026-05-25 15:42:46 +00:00
coopops 306835d0fc [C28-13][@ant] fix: выровнять формулу event_id под parser2 v1.0.3 — сверка перед cutover
Локальная формула computeDeltaEventId/computeActionEventId расходилась с движком
parser2 (дискриминанты delta/action вместо a/d, block_id[0..8] вместо [0..16]).
Это закрывает сверку из Story 3.3 phase 2, перенесённую вперёд: computeEventId
теперь импортируем из опубликованного пакета. Выровнял байт-в-байт, golden-тест
сверен против исходника parser2 (delta и action совпадают). Без выравнивания при
overlap dual-consume один event получал бы два разных id в legacy и в движке и
dedup-gate промахнулся бы = silent data loss. Поправил формат event_id в CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 10:18:41 +00:00
ant 10e5b83ac9 Merge pull request 'Эпик 2 sync-arch: фундамент идемпотентности — event_id + consumer_dedup (релиз 1.1.1) [C28-12]' (#33) from parser2-epic-2-idempotency into parser2
Reviewed-on: #33
Reviewed-by: Алексей Муравьев <chairman.voskhod@gmail.com>
2026-05-25 10:05:56 +00:00
coopops 33d936e5c8 [C28-12][@ant] refactor: убрать DDL-миграцию consumer_dedup — таблицу создаёт synchronize:true через entity, отдельная миграция избыточна по текущей конвенции typeorm (ревью PR #33)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 07:45:54 +00:00
coopops a73f07fa0c [C28-12][@ant] feat: локальное вычисление event_id с dual-write и dedup-gate за флагом — повтор события распознаётся как no-op, gate выключен до сверки формулы с parser2 в Epic 3 (Stories 2.2, 2.3, DEC-T08, DEC-020)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:31:00 +00:00
coopops 77f358e865 [C28-12][@ant] feat: добавить таблицу consumer_dedup с миграцией и репозиторием — фундамент идемпотентности для распознавания повторно применённых событий блокчейна (Story 2.1, INV-09)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 18:30:53 +00:00
ant 2afc71c281 Merge pull request 'Эпик 1 sync-arch: срочные фиксы синхронизации (релиз 1.1.1) [C28-11]' (#32) from parser2-epic-1-hardening into parser2
Reviewed-on: #32
2026-05-24 18:17:23 +00:00
coopops 9cf19aa8fe [C28-11][@ant] fix: барьер форка и вынос задержки action-emit в конфиг — откаты форка завершаются до обработки следующей дельты, исчезает хардкод 3000мс (Stories 1.3, 1.4, DEC-007)
Stories 1.3 + 1.4 затрагивают общие файлы (config.ts, blockchain-consumer.service.ts), поэтому одним коммитом:
- 1.4: ACTION_EMIT_DELAY_MS=3000 → config.blockchain.action_emit_delay_ms (env BLOCKCHAIN_ACTION_EMIT_DELAY_MS). Порядок save→ACK→setTimeout(emit) уже был корректен.
- 1.3: EventsService.emitAsyncWithTimeout + await в processFork = пауза consumer'а до завершения @OnEvent('fork::*')-откатов; TTL force-resume = config.blockchain.fork_pause_timeout_ms. Временно до Epic 3/ForkRegistry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:38:42 +00:00
coopops 4b8121b0b7 [C28-11][@ant] fix: guard монотонности block_num в createIfNotExists — устаревшая дельта из раннего блока больше не затирает более свежую запись на create-пути (Story 1.1, DEC-008)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:38:41 +00:00
564 changed files with 14398 additions and 24978 deletions
+160 -104
View File
@@ -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 }}"}'
+4 -4
View File
@@ -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
View File
@@ -1,5 +1,4 @@
node_modules/
node_modules
lerna-debug.log
components/controller/graph.png
blockchain-data/
+1 -1
View File
@@ -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)}`)
}
}
+1 -3
View File
@@ -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 -1
View File
@@ -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-утилита инициализации блокчейна и кооператива",
+10 -16
View File
@@ -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 "Перезапуск завершен!"
+14 -17
View File
@@ -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 "Перезапуск завершен!"
+22 -37
View File
@@ -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) {
+9 -19
View File
@@ -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
+4 -14
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/cleos",
"version": "2026.5.25-3",
"version": "2026.5.23-4",
"private": true,
"description": "Обёртка над кошельком cleos для EOSIO блокчейна",
"scripts": {
+7 -5
View File
@@ -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
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/contracts",
"version": "2026.5.25-3",
"version": "2026.5.23-4",
"private": true,
"type": "module",
"scripts": {
+17 -13
View File
@@ -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 -3
View File
@@ -1,6 +1,4 @@
{
"watch": ["./src"],
"ext": "ts",
"delay": 2000,
"signal": "SIGTERM"
"ext": "ts"
}
+2 -2
View File
@@ -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",
+4 -3
View File
@@ -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",
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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);
}
}
+39 -1
View File
@@ -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; // Перебрасываем ошибку для корректной обработки
}
}
}
@@ -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);
}
}
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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;
}
}
@@ -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);
@@ -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,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);
}
/**
* Получение поддерживаемых версий контрактов и таблиц
*/
@@ -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,
};
@@ -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;
}
@@ -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:
@@ -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);
}
}
@@ -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('Не удалось исключить пользователя из комнаты');
}
}
/**
* Получает текущие права пользователей в комнате
*/
@@ -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,7 +1,7 @@
/**
* Тип комнаты Matrix, зарегистрированной в реестре ChatCoop (централизованное хранение).
*/
export type ChatcoopManagedMatrixRoomKind = 'members' | 'council' | 'capital_project' | 'secretary';
export type ChatcoopManagedMatrixRoomKind = 'members' | 'council' | 'capital_project';
/**
* Запись о Matrix-комнате кооператива: источник правды для LiveKit/секретаря; совет при миграции v3 из legacy-конфига.
@@ -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) */
@@ -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,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',
@@ -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,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 @@
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 @@
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);
}
}
@@ -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;
}
@@ -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;
/**
* Получить имя таблицы для сущности
* ДОЛЖЕН БЫТЬ ПЕРЕОПРЕДЕЛЕН в каждом наследнике!
@@ -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
);
}
/**
* Обновить сущность
*/
@@ -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;
}
}
@@ -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