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
759 changed files with 30145 additions and 32135 deletions
-7
View File
@@ -39,10 +39,3 @@ MINIO_ROOT_PASSWORD=minioadmin
CHAIN_URL=http://127.0.0.1:8888
API_URL=http://127.0.0.1:2998/v1/graphql
MONGODB_URL=mongodb://127.0.0.1:27017
# apps-catalog proxy (Story 9.5.b). Если оба пустые — мы в degraded mode,
# резолвер appsCatalogRemotePackages вернёт пустой список без ошибки.
# URL = DNS-имя контейнера ca-admin в общей сети mono-shared (см.
# docker network inspect mono-shared).
APPS_CATALOG_URL=http://apps-catalog-ca-admin:3002
APPS_CATALOG_API_KEY=dev-fixture-admin-api-key-DO-NOT-USE-IN-PROD-12345
+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 -12
View File
@@ -13,25 +13,17 @@ 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:
branches: [dev]
workflow_call:
# Новый push в PR-ветку отменяет ещё бегущий typecheck по той же ветке —
# иначе 30-минутные прогоны копятся очередью. Группа по head_ref (исходная
# ветка PR), для workflow_call fallback на ref. Отмена только своих же
# прежних прогонов, разные PR друг друга не трогают.
concurrency:
group: typecheck-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
desktop:
runs-on: ubuntu-latest
-3
View File
@@ -1,5 +1,4 @@
node_modules/
node_modules
lerna-debug.log
components/controller/graph.png
blockchain-data/
@@ -23,5 +22,3 @@ components/contracts/cpp/ledger2/scripts/out/
.env.testnet
.pnpm-store
schema.gql
.claude
-29
View File
@@ -184,35 +184,6 @@ await sendPOST('/v1/graphql', { query: QUERY, variables });
Канон — `features/Branch/CreateBranch/{api,model}/index.ts`: тип в model `export type IXxxInput = Mutations.X.Y.IInput['data']`; функция в api `function (data: IXxxInput) { ... variables: { data } }`. Не делать `as` cast'ов.
## ⚠️ ДИЗАЙН-КАНОН desktop — ОБЯЗАТЕЛЕН К ИСПОЛЬЗОВАНИЮ
**ПРИ ЛЮБОЙ ВЁРСТКЕ В `components/desktop/` НЕ ВЫДУМЫВАТЬ СТИЛИ.** Всегда сначала свериться с каноном. Не строй гипотез о цветах/радиусах/типографике/паттернах из памяти — иди и читай.
**Полная спецификация канона — в skill `/mono-desktop-canon`** (правила обёрток, цвета/токены, иконки, структура страницы, stop-signals). Здесь — короткая выжимка; при расхождении побеждает skill.
**Источник истины — в самом репозитории** (НЕ внешние HTML/прототипы):
| Что | Путь |
|---|---|
| Токены палитры/типографики/spacing/radii (`--p-*`) | `components/desktop/src/css/mono-platform/tokens.css` |
| Прототип-классы компонентов + typography-утилиты `.t-*` | `components/desktop/src/css/mono-platform/components.css` |
| Оверрайды Quasar под канон | `components/desktop/src/css/mono-platform/quasar-canon.css` |
| Минимальная SCSS-палитра Quasar (`$primary`/`$dark`) | `components/desktop/src/css/quasar.variables.scss` |
| Обёртки / доменные блоки / layout | `components/desktop/src/shared/ui/{base,domain,layout}/` |
| **Живой эталон** (`/_dev/ui` в dev-сборке) | `components/desktop/src/pages/_dev/ui/index.vue` |
**При сомнении — открыть `tokens.css` и `_dev/ui/index.vue`, смотреть как сделано там.** Внешний `shared/MONO Design System.html` и `auth-prototype/` каноном НЕ являются — это устаревшие прототипы.
**Запреты:**
- Экран собирается из готовых компонентов: `shared/ui/base` (вместо сырых `q-input`/`q-btn`/`q-card`/`q-table`/`q-chip`/`q-dialog`/`q-select`), `shared/ui/domain` (WalletCard, DataRow, DocumentRow, IdentityPanel…), `shared/ui/layout` (PageHead, PageTabs, AppHeader/AppDrawer). Голый Quasar — только где обёртки нет (`q-icon`, `q-toggle`, `q-list`, `q-menu`, `q-tooltip`, `q-tabs`, `q-separator`, `q-spinner`, `q-inner-loading`…). Props обёрток не угадывать — читать `*.types.ts` рядом.
- Цвет — только токены `var(--p-*)` (поверхности `--p-surface*`, текст `--p-ink*`, линии `--p-line*`, акцент `--p-primary`, статусы `--p-pos/neg/warn/info`) либо utility-классы/color-props. Никаких сырых hex/rgb. Темы light/dark переключаются через `[data-theme]` на `<html>` — токены следуют сами.
- Spacing/радиусы/типографика — токены `--p-1..--p-10` (4px…72px), `--p-r-sm/md/lg/xl`, `--p-fs-*`/`--p-lh-*` либо классы `.t-*`. Без хардкод-px.
- Иконки — `q-icon(name='…')` именами Material Icons. **FontAwesome (`fa-*`) запрещён** — заменять на Material-эквивалент попутно.
- Запрещённые/удаляемые токены: `--mp-*` (старый `marketplace-tokens.scss`), `--tr-*`, `--ds-*`. Легитимны только `--p-*`, `--q-*` (Quasar автоген), `--prog-*`.
- Никаких локальных переопределений `.q-btn`/`.q-card`/`.q-notification` в feature-файлах — Quasar-overrides централизованы в `quasar-canon.css`.
**Кейс 2026-05-28:** при сомнении в каноне пошёл искать его во внешнем HTML/auth-prototype вместо репозитория — перевёрстал не туда, переделывал. SoT — репо (`tokens.css` + `_dev/ui`) и skill `/mono-desktop-canon`, не внешние файлы.
## Frontend desktop — English имена
В `components/desktop/` и любом Vue/TS frontend коде **все имена идентификаторов — английские**:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/blago-cli",
"version": "2026.5.30-alpha-2",
"version": "2026.5.23-4",
"description": "CLI синхронизации артефактов Благорост с бэкендом через @coopenomics/sdk",
"type": "module",
"private": true,
+2 -2
View File
@@ -362,9 +362,9 @@ export async function runCli(argv: string[]): Promise<void> {
)
.action(async (paths: string[]) => {
const root = requireRoot()
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity } = await runAdd(root, paths)
const { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts } = await runAdd(root, paths)
success(
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}; не blago-сущности (нет type=project/issue/story во frontmatter): ${skippedNonEntity}.`,
`Staging обновлён: в списке ${stagedPaths.length} путь(ей). Пропущено без изменений относительно индекса: ${skippedUnchanged}; по .blagoignore: ${skippedIgnored}; артефакты только pull (messages/ и meetings/): ${skippedPullOnlyArtifacts}.`,
)
})
-16
View File
@@ -38,22 +38,6 @@ export function parseBlagoMarkdown(raw: string): ParsedBlagoFile {
}
}
/** Не бросает: возвращает тип сущности blago, если файл — корректный project/issue/story; иначе undefined. */
export function peekBlagoEntityType(raw: string): EntityFrontmatterType | undefined {
let file: ReturnType<typeof matter>
try {
file = matter(raw)
}
catch {
return undefined
}
const t = file.data?.type
if (t === 'project' || t === 'issue' || t === 'story') {
return t
}
return undefined
}
export function serializeBlagoMarkdown(data: Record<string, unknown>, body: string): string {
const trimmed = body.endsWith('\n') ? body : `${body}\n`
// matter.stringify(string, data) сначала парсит string как frontmatter — body, начинающийся
+12 -41
View File
@@ -3,7 +3,6 @@
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { peekBlagoEntityType } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { expandBlagoUserTargetsToRelativePaths } from './capital-target-expand.js'
import { isBlagoSyncExcludedDirName, isIgnoredRelativePath, loadBlagoIgnoreRules } from './ignore.js'
@@ -29,39 +28,19 @@ async function collectMarkdownFiles(absDir: string): Promise<string[]> {
return out
}
type FileCheck =
| { kind: 'unreadable' }
| { kind: 'non-entity' }
| { kind: 'clean' }
| { kind: 'dirty' }
/**
* Решение по одному файлу для staging:
* — нечитаемый → dirty (push сам сообщит вменяемую ошибку),
* — без корректного frontmatter type → non-entity (README/notes/CLAUDE/AGENTS и пр. не отправляем),
* — есть в индексе и sha256 совпал → clean,
* — иначе dirty.
*/
async function classifyFileForAdd(
root: string,
rel: string,
index: Awaited<ReturnType<typeof loadIndex>>,
): Promise<FileCheck> {
let raw: string
try {
raw = await fs.readFile(path.join(root, rel), 'utf8')
}
catch {
return { kind: 'unreadable' }
}
if (peekBlagoEntityType(raw) === undefined) {
return { kind: 'non-entity' }
}
/** true — файл стоит поставить в staging: нет в индексе или SHA256 на диске ≠ content_etag_local. */
async function isDirtyVsIndex(root: string, rel: string, index: Awaited<ReturnType<typeof loadIndex>>): Promise<boolean> {
const entry = findByRelativePath(index, rel)
if (!entry) {
return { kind: 'dirty' }
return true
}
try {
const raw = await fs.readFile(path.join(root, rel), 'utf8')
return sha256Hex(raw) !== entry.content_etag_local
}
catch {
return true
}
return sha256Hex(raw) === entry.content_etag_local ? { kind: 'clean' } : { kind: 'dirty' }
}
export interface RunAddResult {
@@ -71,8 +50,6 @@ export interface RunAddResult {
skippedIgnored: number
/** messages/ и meetings/ — только синхронизация с сервера, в push не идут */
skippedPullOnlyArtifacts: number
/** Файлы без корректного blago-frontmatter (README, notes, CLAUDE/AGENTS и пр.) — в staging не попадают */
skippedNonEntity: number
}
export async function runAdd(root: string, targets: string[]): Promise<RunAddResult> {
@@ -88,7 +65,6 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
let skippedUnchanged = 0
let skippedIgnored = 0
let skippedPullOnlyArtifacts = 0
let skippedNonEntity = 0
for (const t of expanded) {
const abs = path.resolve(root, t)
@@ -110,12 +86,7 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
skippedPullOnlyArtifacts += 1
continue
}
const check = await classifyFileForAdd(root, rel, index)
if (check.kind === 'non-entity') {
skippedNonEntity += 1
continue
}
if (check.kind === 'clean') {
if (!(await isDirtyVsIndex(root, rel, index))) {
skippedUnchanged += 1
continue
}
@@ -125,5 +96,5 @@ export async function runAdd(root: string, targets: string[]): Promise<RunAddRes
const stagedPaths = [...set].sort()
await saveStaging(root, { paths: stagedPaths })
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts, skippedNonEntity }
return { stagedPaths, skippedUnchanged, skippedIgnored, skippedPullOnlyArtifacts }
}
@@ -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 {
@@ -43,6 +43,7 @@ export function projectCommunicationDayToMarkdown(
}
export interface TranscriptionHeaderModel {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -70,6 +71,7 @@ export function renderCallTranscriptionMarkdown(
const header = [
`# Транскрипция звонка`,
``,
`- Matrix room: \`${transcription.matrixRoomId}\``,
`- LiveKit room: \`${transcription.roomId}\``,
`- Начало: ${start.toISOString()}`,
`- Окончание: ${end ? end.toISOString() : '—'}`,
@@ -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,
@@ -211,7 +198,6 @@ export async function pullProjectCommunicationArtifacts(
cursors = {
messageLastTsByRoom: {},
transcriptionLastEndedExclusiveByProject: {},
transcriptionLastEndedExclusiveByRoom: {},
}
}
@@ -370,6 +356,7 @@ export async function pullProjectCommunicationArtifacts(
= dateFromUnknown(tr.endedAt) ?? (tr.endedAt as Date | string | null | undefined)
const md = renderCallTranscriptionMarkdown(
{
matrixRoomId: String(tr.matrixRoomId),
roomId: String(tr.roomId),
startedAt,
endedAt: endedAtTr,
@@ -435,207 +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(
{
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)
+3 -43
View File
@@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { Mutations, Queries } from '@coopenomics/sdk'
import { parseBlagoMarkdown, peekBlagoEntityType } from '../format/index.js'
import { parseBlagoMarkdown } from '../format/index.js'
import { sha256Hex } from '../lib/hash.js'
import { effectiveParentHash } from '../lib/parent-hash.js'
import { warn } from '../ui/output.js'
@@ -58,30 +58,6 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
staging = await loadStaging(ctx.root)
}
// Не-сущности (README/notes/CLAUDE/AGENTS и пр., в .md без type=project|issue|story) тоже не должны валить весь push.
// Чистим их автоматически — иначе одна забытая заметка блокирует отправку реальных правок.
const stagingAbs = (p: string): string => path.join(ctx.root, normalizeRelativePath(p))
const nonEntityInStaging: string[] = []
for (const p of staging.paths) {
try {
const raw = await fs.readFile(stagingAbs(p), 'utf8')
if (peekBlagoEntityType(raw) === undefined) {
nonEntityInStaging.push(normalizeRelativePath(p))
}
}
catch {
// нечитаемый файл — пусть основной цикл бросит понятную ошибку с путём
}
}
if (nonEntityInStaging.length > 0) {
const drop = new Set(nonEntityInStaging)
const kept = staging.paths.filter(p => !drop.has(normalizeRelativePath(p)))
await saveStaging(ctx.root, { paths: [...new Set(kept.map(p => normalizeRelativePath(p)))].sort() })
for (const p of nonEntityInStaging) {
warn(`Убрано из staging (нет blago-frontmatter type=project|issue|story): ${p}`)
}
staging = await loadStaging(ctx.root)
}
if (staging.paths.length === 0) {
throw new Error('Нечего отправлять. Добавьте файлы: blago add <путь | id проекта | projectId-issueId>')
}
@@ -99,24 +75,8 @@ export async function runPush(ctx: AuthenticatedContext): Promise<void> {
}
const abs = path.join(ctx.root, n)
const raw = await fs.readFile(abs, 'utf8')
let parsed: ReturnType<typeof parseBlagoMarkdown>
try {
parsed = parseBlagoMarkdown(raw)
}
catch (err) {
// Без префикса путём из staging вылезает «type: undefined» без указания файла.
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
let type: ReturnType<typeof validateParsedForPush>['type']
let hash: string
try {
({ type, hash } = validateParsedForPush(parsed))
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err)
throw new Error(`Файл «${n}»: ${msg}`)
}
const parsed = parseBlagoMarkdown(raw)
const { type, hash } = validateParsedForPush(parsed)
const entry = findByHash(index, type, hash)
const pKind = pendingKindForEntityType(type)
const pending = pKind ? await findPendingForParsed(ctx.root, pKind, hash) : undefined
+2
View File
@@ -367,6 +367,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
if (entry.entity_type === 'call_transcription') {
interface TranscriptionRestorePack {
transcription: {
matrixRoomId: string
roomId: string
startedAt: Date | string
endedAt: Date | string | null | undefined
@@ -393,6 +394,7 @@ export async function runRestore(ctx: AuthenticatedContext, userPath: string): P
}
const content = renderCallTranscriptionMarkdown(
{
matrixRoomId: tr.matrixRoomId,
roomId: tr.roomId,
startedAt: tr.startedAt,
endedAt: tr.endedAt,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@coopenomics/boot",
"type": "module",
"version": "2026.5.30-alpha-2",
"version": "2026.5.23-4",
"private": true,
"packageManager": "pnpm@9.0.6",
"description": "CLI-утилита инициализации блокчейна и кооператива",
+13 -22
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,34 +9,26 @@ if [ -f "$ROOT_DIR/.env" ]; then
set +a
fi
# Останавливаем и удаляем контейнеры (сеть НЕ трогаем — к ней может быть
# подключён внешний контейнер вроде provider-backend; down её снёс бы с ошибкой).
echo "Останавливаем и удаляем контейнеры..."
docker compose rm -fsv mongo postgres monoredis cooparser coopback || true
# Останавливаем и удаляем контейнеры вместе с volumes
echo "Останавливаем и удаляем контейнеры с volumes..."
docker compose down -v mongo postgres cooparser || true
# Останавливаем blockchain контейнер перед удалением данных
echo "Останавливаем blockchain контейнер..."
docker compose stop node || true
# Удаляем тома баз данных для чистого старта (named volumes rm -v не чистит)
echo "Удаляем тома баз данных..."
PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$ROOT_DIR")}"
docker volume rm "${PROJECT}_postgres_data" "${PROJECT}_mongo_data" 2>/dev/null || true
# Удаляем blockchain data через контейнер (alpine под root стирает данные
# независимо от владельца — без sudo на любой ноде; на проде nodeos пишет под root).
# Удаляем blockchain data
echo "Удаляем blockchain data..."
docker run --rm -v "$(cd .. && pwd)/blockchain-data:/d" alpine sh -c 'rm -rf /d/* /d/.[!.]* 2>/dev/null || true'
# sudo chmod -R 755 ../blockchain-data/ 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
@@ -51,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
@@ -60,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) {
@@ -1,11 +0,0 @@
{
"protocol_feature_type": "builtin",
"dependencies": [],
"description_digest": "2ad6b7aabba8edbc87b46f3340df4d0db0e4b65474db3e1cd5db33fb685a506b",
"subjective_restrictions": {
"earliest_allowed_activation_time": "1970-01-01T00:00:00.000",
"preactivation_required": true,
"enabled": true
},
"builtin_feature_codename": "ASSERT_RECOVER_KEY_ACCOUNT"
}
+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.30-alpha-2",
"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);
}
}
}
+1 -1
View File
@@ -239,7 +239,7 @@ static constexpr uint64_t _capital_program_id = 4;
#ifdef IS_TESTNET
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 1; /*!< минимальное количество членов совета (тест) */
#else
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 3; /*!< минимальное количество членов совета (прод) */
static constexpr uint64_t MIN_SOVIET_MEMBERS_COUNT = 5; /*!< минимальное количество членов совета (прод) */
#endif
/**
@@ -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 },
@@ -9,14 +9,10 @@
* @ingroup public_actions
* @ingroup public_soviet_actions
* @note Авторизация требуется от аккаунта: @p coopname
* @note Авторизация требуется от аккаунта: @p chairman
*/
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
// Авторизует кооператив (действие проводится через бэкенд ключом кооператива).
// Согласие председателя при этом не теряется: оно подтверждается криптографически
// его личной подписью на документе (verify_document_or_fail ниже) и проверкой
// is_valid_chairman — без валидного подписанного председателем документа авторизация невозможна.
require_auth(coopname);
void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t decision_id, document2 document) {
require_auth(chairman);
boards_index boards(_soviet, coopname.value);
autosigner_index autosigner(_soviet, coopname.value);
@@ -31,9 +27,7 @@ void soviet::authorize(eosio::name coopname, eosio::name chairman, uint64_t deci
verify_document_or_fail(document);
// RAM-плательщик — кооператив: транзакцию авторизует coopname (require_auth выше),
// поэтому увеличивать RAM аккаунта председателя нельзя (он tx не подписывал).
decisions.modify(decision, coopname, [&](auto &d){
decisions.modify(decision, chairman, [&](auto &d){
d.authorized_by = chairman;
d.authorized = !decision -> authorized;
d.authorization = document;
@@ -10,14 +10,11 @@
* @param coopname Имя кооператива
* @param decision_id Идентификатор решения для исполнения
*
* @note Авторизация требуется от аккаунта: @p coopname
* @note Авторизация требуется от аккаунта: @p executer
*/
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
// Исполнение проводится через бэкенд ключом кооператива. Параметр executer остаётся
// в payload как аудит-метка (кто инициировал исполнение) и не используется для авторизации
// inline-эффектов — те выполняются под собственными правами контракта (_soviet/_gateway/_fund).
require_auth(coopname);
void soviet::exec(eosio::name executer, eosio::name coopname, uint64_t decision_id) {
require_auth(executer);
decisions_index decisions(_soviet, coopname.value);
auto decision = decisions.find(decision_id);
@@ -124,7 +124,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
row.type = "user"_n;
row.coopname = coopname;
row.program_id = 0;
row.draft_id = 4;
row.draft_id = 3;
});
//создаём соглашение для шаблона политики конфиденциальности
@@ -132,7 +132,7 @@ void soviet::make_base_coagreements( eosio::name coopname, eosio::symbol govern_
row.type = "privacy"_n;
row.coopname = coopname;
row.program_id = 0;
row.draft_id = 3;
row.draft_id = 4;
});
}
@@ -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.30-alpha-2",
"version": "2026.5.23-4",
"private": true,
"type": "module",
"scripts": {
+6
View File
@@ -71,6 +71,12 @@ SENTRY_DSN=
GITHUB_TOKEN=
# OpenSearch (поисковая система документов, опционально)
OPENSEARCH_NODE=https://opensearch:9200
OPENSEARCH_ENABLED=false
OPENSEARCH_USERNAME=admin
OPENSEARCH_PASSWORD=
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
+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.30-alpha-2",
"version": "2026.5.23-4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-nodejs-express-app",
"version": "2026.5.30-alpha-2",
"version": "2026.5.23-4",
"license": "MIT",
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@coopenomics/controller",
"version": "2026.5.30-alpha-2",
"version": "2026.5.23-4",
"description": "Бэкенд GraphQL API кооператива на NestJS",
"private": true,
"bin": "bin/createNodejsApp.js",
@@ -72,11 +72,11 @@
],
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@apollo/subgraph": "^2",
"@aws-sdk/client-s3": "^3.1035.0",
"@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",
@@ -100,6 +100,7 @@
"@nestjs/typeorm": "^10.0.2",
"@novu/api": "^1.4.0",
"@octokit/rest": "^22.0.1",
"@opensearch-project/opensearch": "^3.5.1",
"@sentry/nestjs": "^10.32.1",
"@sentry/node": "^10.32.1",
"@types/jest": "^29.5.12",
@@ -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
+6 -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,12 +10,14 @@ 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';
import { EventsInfrastructureModule } from './infrastructure/events/events.module';
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
// Domain modules
@@ -57,7 +60,6 @@ import { QueueModule } from './application/queue/queue-app.module';
import { DocumentModule } from './application/document/document.module';
import { RedisAppModule } from './application/redis/redis-app.module';
import { DecisionModule } from './application/free-decision/decision.module';
import { DecisionAuthorizeModule } from './application/decision/decision.module';
import { AgreementModule } from './application/agreement/agreement.module';
import { ParticipantModule } from './application/participant/participant.module';
import { AgendaModule } from './application/agenda/agenda.module';
@@ -78,7 +80,6 @@ import { SettingsApplicationModule } from './application/settings/settings.modul
import { RegistrationModule } from './application/registration/registration.module';
import { OnboardingApplicationModule } from './application/onboarding/onboarding-application.module';
import { SearchModule } from './application/search/search.module';
import { SignedDocumentsModule } from './application/signed-documents/signed-documents.module';
import { MutationLoggingInterceptor } from './application/common/interceptors/mutation-logging.interceptor';
@Module({
@@ -86,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,
@@ -96,10 +98,12 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
MongooseModule.forRoot(config.mongoose.url),
DatabaseModule,
GraphqlModule,
ForkRegistryModule,
BlockchainModule,
GeneratorInfrastructureModule,
RedisModule,
NovuModule,
SearchInfrastructureModule,
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
@@ -142,7 +146,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
AccountModule,
AgreementModule,
AgendaModule,
DecisionAuthorizeModule,
AppStoreModule,
AuthModule,
DesktopModule,
@@ -171,7 +174,6 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
RegistrationModule,
OnboardingApplicationModule,
SearchModule,
SignedDocumentsModule,
],
providers: [
{
@@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { DecisionResolver } from './resolvers/decision.resolver';
import { DecisionService } from './services/decision.service';
import { DecisionInteractor } from './use-cases/decision.interactor';
@Module({
providers: [DecisionResolver, DecisionService, DecisionInteractor],
})
export class DecisionAuthorizeModule {}
@@ -1,24 +0,0 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsNumber, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { SignedDigitalDocumentInputDTO } from '~/application/document/dto/signed-digital-document-input.dto';
@InputType('AuthorizeDecisionInput')
export class AuthorizeDecisionInputDTO {
@Field(() => String, { description: 'Имя аккаунта кооператива' })
@IsString()
coopname!: string;
@Field(() => String, { description: 'Имя аккаунта председателя совета' })
@IsString()
chairman!: string;
@Field(() => Int, { description: 'Идентификатор решения' })
@IsNumber()
decision_id!: number;
@Field(() => SignedDigitalDocumentInputDTO, { description: 'Подписанный председателем документ утверждения решения' })
@ValidateNested()
@Type(() => SignedDigitalDocumentInputDTO)
document!: SignedDigitalDocumentInputDTO;
}
@@ -1,27 +0,0 @@
import { Resolver, Mutation, Args } from '@nestjs/graphql';
import { UseGuards } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
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 { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
import { DecisionService } from '../services/decision.service';
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
@Resolver()
export class DecisionResolver {
constructor(private readonly decisionService: DecisionService) {}
@Mutation(() => TransactionDTO, {
name: 'authorizeDecision',
description: 'Утвердить и исполнить решение совета',
})
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async authorizeDecision(
@Args('data', { type: () => AuthorizeDecisionInputDTO }) data: AuthorizeDecisionInputDTO
): Promise<TransactionDTO> {
return this.decisionService.authorizeDecision(data);
}
}
@@ -1,14 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DecisionInteractor } from '../use-cases/decision.interactor';
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
import { TransactionDTO } from '~/application/common/dto/transaction-result-response.dto';
@Injectable()
export class DecisionService {
constructor(private readonly decisionInteractor: DecisionInteractor) {}
public async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactionDTO> {
const result = await this.decisionInteractor.authorizeDecision(data);
return result as TransactionDTO;
}
}
@@ -1,32 +0,0 @@
import { Injectable, Inject } from '@nestjs/common';
import { SOVIET_BLOCKCHAIN_PORT, SovietBlockchainPort } from '~/domain/common/ports/soviet-blockchain.port';
import { DomainToBlockchainUtils } from '~/shared/utils/domain-to-blockchain.utils';
import { SovietContract } from 'cooptypes';
import type { TransactResult } from '@wharfkit/session';
import { AuthorizeDecisionInputDTO } from '../dto/authorize-decision-input.dto';
@Injectable()
export class DecisionInteractor {
constructor(
@Inject(SOVIET_BLOCKCHAIN_PORT) private readonly sovietBlockchainPort: SovietBlockchainPort,
private readonly domainToBlockchainUtils: DomainToBlockchainUtils
) {}
async authorizeDecision(data: AuthorizeDecisionInputDTO): Promise<TransactResult> {
const authorizeData: SovietContract.Actions.Decisions.Authorize.IAuthorize = {
coopname: data.coopname,
chairman: data.chairman,
decision_id: data.decision_id,
document: this.domainToBlockchainUtils.convertSignedDocumentToBlockchainFormat(data.document),
};
const execData: SovietContract.Actions.Decisions.Exec.IExec = {
// executer — аудит-метка инициатора; авторизация транзакции идёт ключом кооператива.
executer: data.chairman,
coopname: data.coopname,
decision_id: data.decision_id,
};
return await this.sovietBlockchainPort.authorizeDecision(authorizeData, execData);
}
}
@@ -9,12 +9,6 @@ import { DocumentPackageAggregator } from '~/domain/document/aggregators/documen
import { DocumentAggregator } from '~/domain/document/aggregators/document.aggregator';
import type { DocumentAggregateDomainInterface } from '~/domain/document/interfaces/document-domain-aggregate.interface';
import type { ISignedDocumentDomainInterface } from '~/domain/document/interfaces/signed-document-domain.interface';
import {
SIGNED_DOCUMENT_REPOSITORY,
type SignedDocumentRepository,
} from '~/domain/document/repository/signed-document.repository';
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
import config from '~/config/config';
@Injectable()
export class DocumentInteractor {
@@ -22,8 +16,7 @@ export class DocumentInteractor {
@Inject(forwardRef(() => DocumentDomainService)) private readonly documentDomainService: DocumentDomainService,
@Inject(forwardRef(() => DocumentPackageAggregator))
private readonly documentPackageAggregator: DocumentPackageAggregator,
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator,
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly signedDocumentRepository: SignedDocumentRepository
@Inject(forwardRef(() => DocumentAggregator)) private readonly documentAggregator: DocumentAggregator
) {}
public async generateDocument(data: GenerateDocumentDomainInterfaceWithOptions): Promise<DocumentDomainEntity> {
@@ -33,56 +26,52 @@ export class DocumentInteractor {
public async getDocumentsAggregate(
data: GetDocumentsInputDomainInterface
): Promise<PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface>> {
const { type, page = 1, limit = 100, query = {}, after_block, before_block, actions } = data;
const { type = 'newsubmitted', page = 1, limit = 100, query, after_block, before_block, actions } = data;
// Read-path из PG-реестра подписанных документов (C28-21): отдаём ГОТОВЫЙ агрегат,
// собранный на этапе ingestion/backfill — без обращений к explorer/Mongo и без сборки на лету.
const result = await this.signedDocumentRepository.findAggregates({
coopname: config.coopname,
status: this.mapTypeToStatus(type),
actions: actions && actions.length > 0 ? (actions as unknown as string[]) : undefined,
username: this.extractReceiverScope(query),
hash: this.extractHashFilter(query),
afterBlock: after_block,
beforeBlock: before_block,
// Добавляем фильтры по блокам в query
const blockFilters: Record<string, unknown> = { ...query };
if (after_block !== undefined || before_block !== undefined) {
const blockFilter: Record<string, unknown> = {};
if (after_block !== undefined) {
blockFilter.$gte = after_block;
}
if (before_block !== undefined) {
blockFilter.$lte = before_block;
}
blockFilters.block_num = blockFilter;
}
// Добавляем фильтр по массиву действий (data.action)
// DocumentAction enum значения автоматически преобразуются в строки
if (actions && actions.length > 0) {
blockFilters['data.action'] = { $in: actions };
}
const signedDocuments = await this.documentDomainService.getImmutableSignedDocuments({
type,
page,
limit,
query: blockFilters,
});
return {
items: result.items,
totalCount: result.total,
totalPages: Math.ceil(result.total / limit) || 0,
const response: PaginationResultDomainInterface<DocumentPackageAggregateDomainInterface> = {
items: [],
totalCount: signedDocuments.total,
totalPages: Math.ceil(signedDocuments.total / limit),
currentPage: page,
};
}
// Прежний фильтр был по ИМЕНИ действия, не по статусу-партиции:
// «Все входящие» (newsubmitted) — у каждого пакета есть submitted-действие, поэтому это ВСЕ
// документы кооператива (решённые/аннулированные тоже входят) → статус НЕ ограничиваем;
// «Только утверждённые» (newresolved) — лишь решённые → status=Resolved.
// (Партиция по статусу скрыла бы решённые документы из вкладки «Все входящие» — регрессия.)
private mapTypeToStatus(type?: 'newsubmitted' | 'newresolved'): SignedDocumentStatus | undefined {
return type === 'newresolved' ? SignedDocumentStatus.Resolved : undefined;
}
for (const raw_action_document of signedDocuments.results) {
const documentPackageAggregate = await this.documentPackageAggregator.buildDocumentPackageAggregate(
raw_action_document
);
// Скоуп выборки повторяет прежний explorer-фильтр `receiver: username` из DocumentService.
// Документ soviet через require_recipient уведомляет И кооператив, И пайщика-субъекта, поэтому:
// receiver === coopname → весь кооператив (фильтр только по coopname — как и было);
// receiver === <пайщик> → его документы (колонка username = субъект заявления).
private extractReceiverScope(query: Record<string, unknown>): string | undefined {
const receiver = query['receiver'];
if (typeof receiver === 'string' && receiver.length > 0 && receiver !== config.coopname) {
return receiver;
response.items.push(documentPackageAggregate);
}
return undefined;
}
// Фильтр по конкретному документу: Union-страница шлёт filter.document.hash (upper-case).
private extractHashFilter(query: Record<string, unknown>): string | undefined {
const document = query['document'] as Record<string, unknown> | undefined;
const hash = (document?.['hash'] ?? query['hash']) as unknown;
return typeof hash === 'string' && hash.length > 0 ? hash : undefined;
return response;
}
/**
@@ -17,4 +17,7 @@ export class Ledger2WalletDTO {
@Field(() => String, { description: 'Доступный баланс' })
available!: string;
@Field(() => String, { description: 'Заблокированный баланс' })
blocked!: string;
}
@@ -11,9 +11,6 @@ export class SearchResultDTO {
@Field(() => String, { description: 'Имя пользователя' })
username!: string;
@Field(() => String, { description: 'ФИО подписанта документа' })
signer!: string;
@Field(() => String, { description: 'Кооператив' })
coopname!: string;
@@ -1,50 +1,24 @@
import { Resolver, Query, Args } from '@nestjs/graphql';
import { Inject, UseGuards } from '@nestjs/common';
import { UseGuards } from '@nestjs/common';
import { SearchResultDTO } from '../dto/search-result.dto';
import { SearchDocumentsInputDTO } from '../dto/search-input.dto';
import {
SIGNED_DOCUMENT_REPOSITORY,
type SignedDocumentRepository,
} from '~/domain/document/repository/signed-document.repository';
import { DocumentSearchService } from '~/infrastructure/search/opensearch.service';
import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard';
import { CurrentUser } from '~/application/auth/decorators/current-user.decorator';
import type { MonoAccountDomainInterface } from '~/domain/account/interfaces/mono-account-domain.interface';
import config from '~/config/config';
// Роли членов совета — им доступен поиск по всему документообороту кооператива.
const COUNCIL_ROLES = ['chairman', 'member'];
@Resolver()
export class SearchResolver {
constructor(
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly signedDocuments: SignedDocumentRepository
) {}
constructor(private readonly documentSearch: DocumentSearchService) {}
// Поиск по документам кооператива. Член совета (chairman/member) ищет по всему кооперативу;
// обычный пайщик — ТОЛЬКО по своим документам (скоуп по username), чтобы не видеть чужие.
@Query(() => [SearchResultDTO], { description: 'Полнотекстовый поиск по документам кооператива' })
@UseGuards(GqlJwtAuthGuard)
async searchDocuments(
@Args('data') input: SearchDocumentsInputDTO,
@CurrentUser() user: MonoAccountDomainInterface
): Promise<SearchResultDTO[]> {
const isCouncil = COUNCIL_ROLES.includes(user?.role);
const hits = await this.signedDocuments.search({
coopname: config.coopname,
query: input.query,
limit: input.limit || 20,
username: isCouncil ? undefined : user.username,
});
if (!this.documentSearch.isAvailable()) {
return [];
}
return hits.map((hit) => ({
hash: hit.hash,
full_title: hit.full_title,
username: hit.username,
signer: hit.signer,
coopname: hit.coopname,
registry_id: hit.registry_id,
created_at: hit.created_at ?? undefined,
highlights: hit.highlights,
}));
return this.documentSearch.search(input.query, config.coopname, input.limit || 20);
}
}
@@ -1,12 +1,10 @@
import { Module } from '@nestjs/common';
import { SearchResolver } from './resolvers/search.resolver';
import { SearchEventService } from './services/search-event.service';
import { DocumentModule } from '~/application/document/document.module';
/**
* Поиск по документам кооператива (C28-21).
* searchDocuments читает PG-реестр подписанных документов через SIGNED_DOCUMENT_REPOSITORY
* (глобальный TypeOrmModule). OpenSearch и его индексатор удалены.
*/
@Module({
providers: [SearchResolver],
imports: [DocumentModule],
providers: [SearchResolver, SearchEventService],
})
export class SearchModule {}
@@ -0,0 +1,131 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { SovietContract } from 'cooptypes';
import { DocumentSearchService } from '~/infrastructure/search/opensearch.service';
import { GeneratorInfrastructureService } from '~/infrastructure/generator/generator.service';
import { DocumentInteractor } from '~/application/document/interactors/document.interactor';
import type { IAction } from '~/types/common';
/**
* Сервис индексации документов по событию blockchain.
* Слушает action::soviet::newsubmitted — подписанные и принятые документы.
*/
@Injectable()
export class SearchEventService implements OnModuleInit {
private readonly logger = new Logger(SearchEventService.name);
constructor(
private readonly documentSearch: DocumentSearchService,
private readonly generatorService: GeneratorInfrastructureService,
private readonly documentInteractor: DocumentInteractor,
) {}
async onModuleInit() {
if (this.documentSearch.isAvailable()) {
// Запускаем асинхронную ре-индексацию при старте
this.reindexAllDocuments().catch((err) => {
this.logger.error(`Ошибка при фоновой ре-индексации: ${err.message}`);
});
}
}
/**
* Метод полной ре-индексации только документов типа newsubmitted из блокчейна.
* Собирает агрегаты для получения полного содержимого документа.
*/
async reindexAllDocuments(): Promise<void> {
this.logger.log('Начало полной ре-индексации документов (newsubmitted)...');
let totalIndexed = 0;
let currentPage = 1;
const limit = 100;
try {
while (true) {
const result = await this.documentInteractor.getDocumentsAggregate({
type: 'newsubmitted',
page: currentPage,
limit,
query: {}, // Обязательное поле согласно GetDocumentsInputDomainInterface
});
if (!result.items || result.items.length === 0) {
break;
}
for (const packageAggregate of result.items) {
// В пакете может быть заявление, решение и акты. Индексируем всё, что есть.
const aggregatesToindex = [
packageAggregate.statement?.documentAggregate,
packageAggregate.decision?.documentAggregate,
...packageAggregate.acts.map(act => act.documentAggregate),
...packageAggregate.links
].filter(Boolean);
for (const aggregate of aggregatesToindex) {
const doc = aggregate?.rawDocument;
const signedDoc = aggregate?.document;
if (!doc || !signedDoc) continue;
try {
await this.documentSearch.indexDocument({
hash: doc.hash,
full_title: doc.full_title || '',
html: doc.html || '',
username: doc.meta?.username || signedDoc.meta?.username || '',
coopname: doc.meta?.coopname || signedDoc.meta?.coopname || '',
registry_id: doc.meta?.registry_id || signedDoc.meta?.registry_id || 0,
created_at: doc.meta?.created_at || signedDoc.meta?.created_at || '',
});
totalIndexed++;
} catch (docError: any) {
this.logger.warn(`Ошибка индексации документа ${doc.hash}: ${docError.message}`);
}
}
}
if (currentPage >= result.totalPages) {
break;
}
currentPage++;
}
this.logger.log(`Ре-индексация завершена. Проиндексировано документов: ${totalIndexed}`);
} catch (error: any) {
this.logger.error(`Ошибка при ре-индексации документов: ${error.message}`);
}
}
@OnEvent(`action::${SovietContract.contractName.production}::${SovietContract.Actions.Registry.NewSubmitted.actionName}`)
async onDocumentSubmitted(action: IAction): Promise<void> {
if (!this.documentSearch.isAvailable()) return;
try {
const docHash = action.data?.document?.hash;
if (!docHash) {
this.logger.debug('Событие newsubmitted без хеша документа, пропускаем');
return;
}
const document = await this.generatorService.getDocument({ hash: docHash });
if (!document) {
this.logger.debug(`Документ ${docHash.substring(0, 8)} не найден в MongoDB`);
return;
}
await this.documentSearch.indexDocument({
hash: document.hash,
full_title: document.full_title || '',
html: document.html || '',
username: document.meta?.username || action.data?.username || '',
coopname: document.meta?.coopname || action.data?.coopname || '',
registry_id: document.meta?.registry_id || 0,
created_at: document.meta?.created_at || '',
});
this.logger.log(`Документ проиндексирован: ${document.full_title?.substring(0, 50)}`);
} catch (error: any) {
this.logger.warn(`Ошибка индексации документа по событию newsubmitted: ${error?.message}`);
}
}
}
@@ -1,125 +0,0 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DocumentDomainService } from '~/domain/document/services/document-domain.service';
import {
SIGNED_DOCUMENT_REPOSITORY,
type SignedDocumentRepository,
} from '~/domain/document/repository/signed-document.repository';
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
import { SignedDocumentIngestionService } from './signed-document-ingestion.service';
import config from '~/config/config';
import type { IAction } from '~/types/common';
export interface BackfillStats {
scanned: number;
created: number;
updated: number;
skipped: number;
}
interface BackfillPass {
type: string;
status: SignedDocumentStatus;
}
// Порядок важен: сперва базовые записи (submitted/resolved), затем смена статусов поверх.
const PASSES: BackfillPass[] = [
{ type: 'newsubmitted', status: SignedDocumentStatus.Submitted },
{ type: 'newresolved', status: SignedDocumentStatus.Resolved },
// newdeclined пока не приходит как registry-action (см. C28-21); пройдёт пустым — это безопасно.
{ type: 'newdeclined', status: SignedDocumentStatus.Declined },
];
const PAGE_LIMIT = 100;
/**
* Разовый backfill реестра подписанных документов (C28-21).
*
* Сканирует все действия soviet (newsubmitted/newresolved/newdeclined) через explorer-источник,
* для каждого достаёт документ из factory-Mongo и складывает в Postgres-проекцию.
* Идемпотентен по (coopname, doc_hash) — повторный прогон безопасен (package НЕ уникален:
* в одном процессе несколько разных документов, поэтому ключ записи — doc_hash).
*
* Запуск автоматически ОДИН РАЗ при первом старте с этой фичей: если реестр по кооперативу пуст —
* затягиваем всю историю. Принудительный повторный прогон — флагом `SIGNED_DOCS_BACKFILL_ON_BOOT=true`.
* Выполняется детачем в onApplicationBootstrap (после инициализации всех модулей — генератор уже
* подключён к Mongo), чтобы не блокировать старт. Метод `run()` пригоден и для отдельного CLI.
*/
@Injectable()
export class SignedDocumentBackfillService implements OnApplicationBootstrap {
private readonly logger = new Logger(SignedDocumentBackfillService.name);
constructor(
private readonly documentDomainService: DocumentDomainService,
private readonly ingestion: SignedDocumentIngestionService,
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly repository: SignedDocumentRepository
) {}
onApplicationBootstrap(): void {
// Детачем: не блокируем старт приложения долгим сканом истории.
this.maybeRunOnBoot().catch((error) => {
this.logger.error(`Ошибка фонового backfill реестра: ${error?.message}`);
});
}
private async maybeRunOnBoot(): Promise<void> {
const forced = process.env.SIGNED_DOCS_BACKFILL_ON_BOOT === 'true';
if (!forced) {
const existing = await this.repository.count(config.coopname);
if (existing > 0) {
this.logger.log(`Реестр подписанных документов не пуст (${existing}) — авто-backfill пропущен.`);
return;
}
}
await this.run();
}
async run(): Promise<BackfillStats> {
const stats: BackfillStats = { scanned: 0, created: 0, updated: 0, skipped: 0 };
this.logger.log('Старт backfill реестра подписанных документов...');
for (const pass of PASSES) {
await this.runPass(pass, stats);
}
this.logger.log(
`Backfill завершён. scanned=${stats.scanned} created=${stats.created} updated=${stats.updated} skipped=${stats.skipped}`
);
return stats;
}
private async runPass(pass: BackfillPass, stats: BackfillStats): Promise<void> {
let page = 1;
while (true) {
// Скоуп ровно как у прежнего chairman-пути getDocuments: {name: type, receiver: coopname}.
// Без receiver explorer вернул бы все трейсы каждого действия (receiver=soviet/пайщик) и,
// возможно, другие кооперативы — лишняя обработка и чужие данные. receiver=coopname даёт
// один трейс на документ и только наш кооператив (coopname — всегда получатель soviet-документа).
const response = await this.documentDomainService.getImmutableSignedDocuments({
type: pass.type,
query: { receiver: config.coopname },
page,
limit: PAGE_LIMIT,
});
const results = (response?.results ?? []) as unknown as IAction[];
if (results.length === 0) break;
for (const action of results) {
stats.scanned++;
try {
const result = await this.ingestion.ingestAction(action, pass.status);
stats[result === 'created' ? 'created' : result === 'updated' ? 'updated' : 'skipped']++;
} catch (error: any) {
// Одна битая запись (напр. не-парсибельная дата у legacy-документа) не должна ронять
// весь прогон backfill — логируем и идём дальше.
stats.skipped++;
this.logger.warn(`Пропущен документ при backfill (${pass.type}): ${error?.message}`);
}
}
if (results.length < PAGE_LIMIT) break;
page++;
}
}
}
@@ -1,291 +0,0 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cooperative, SovietContract } from 'cooptypes';
import { DocumentPackageAggregator } from '~/domain/document/aggregators/document-package.aggregator';
import {
SIGNED_DOCUMENT_REPOSITORY,
type SignedDocumentPackageRow,
type SignedDocumentRepository,
} from '~/domain/document/repository/signed-document.repository';
import { SignedDocumentStatus } from '~/domain/document/enums/signed-document-status.enum';
import type { DocumentPackageAggregateDomainInterface } from '~/domain/document/interfaces/document-package-aggregate-domain.interface';
import config from '~/config/config';
import type { IAction } from '~/types/common';
import moment from 'moment-timezone';
const SOVIET = SovietContract.contractName.production;
const Registry = SovietContract.Actions.Registry;
export type IngestResult = 'created' | 'updated' | 'skipped';
/**
* Наполнение реестра подписанных документов (Postgres-проекция, C28-21).
*
* Источник — внутренняя событийная шина (EventEmitter2): парсер ретранслирует blockchain-события
* контракта soviet как `action::soviet::*`. На каждое релевантное событие собирается ГОТОВЫЙ агрегат
* пакета (DocumentPackageAggregator) и кладётся в PG целиком — чтобы getDocuments/поиск работали
* из Postgres без сборки на лету. Парсер не трогаем (parser1→parser2 прозрачно для подписчика).
*
* - newsubmitted/newresolved/newdeclined — несут заявление: пересобираем агрегат + ставим статус;
* - newdecision/newact/newlink — досборка: пересобираем агрегат по сохранённому действию-носителю,
* статус не меняем.
*/
@Injectable()
export class SignedDocumentIngestionService {
private readonly logger = new Logger(SignedDocumentIngestionService.name);
constructor(
@Inject(SIGNED_DOCUMENT_REPOSITORY) private readonly repository: SignedDocumentRepository,
private readonly aggregator: DocumentPackageAggregator
) {}
@OnEvent(`action::${SOVIET}::${Registry.NewSubmitted.actionName}`)
async onNewSubmitted(action: IAction): Promise<void> {
await this.safeStatusEvent(action, SignedDocumentStatus.Submitted, Registry.NewSubmitted.actionName);
}
@OnEvent(`action::${SOVIET}::${Registry.NewResolved.actionName}`)
async onNewResolved(action: IAction): Promise<void> {
await this.safeStatusEvent(action, SignedDocumentStatus.Resolved, Registry.NewResolved.actionName);
}
@OnEvent(`action::${SOVIET}::${Registry.NewDeclined.actionName}`)
async onNewDeclined(action: IAction): Promise<void> {
await this.safeStatusEvent(action, SignedDocumentStatus.Declined, Registry.NewDeclined.actionName);
}
@OnEvent(`action::${SOVIET}::${Registry.NewDecision.actionName}`)
async onNewDecision(action: IAction): Promise<void> {
await this.safeReassemble(action, Registry.NewDecision.actionName);
}
@OnEvent(`action::${SOVIET}::${Registry.NewAct.actionName}`)
async onNewAct(action: IAction): Promise<void> {
await this.safeReassemble(action, Registry.NewAct.actionName);
}
@OnEvent(`action::${SOVIET}::${Registry.NewLink.actionName}`)
async onNewLink(action: IAction): Promise<void> {
await this.safeReassemble(action, Registry.NewLink.actionName);
}
/**
* Идемпотентно записывает действие-носитель заявления в реестр.
* Используется и листенерами шины, и backfill'ом.
*/
async ingestAction(action: IAction, status: SignedDocumentStatus): Promise<IngestResult> {
const data = (action?.data ?? {}) as Record<string, any>;
// Ключ записи — doc_hash (идентичность документа). hash включает подписи, поэтому у одного
// документа бывает несколько hash (версии по мере накопления подписей — напр. акт приёма-передачи:
// первая/вторая подпись дают разные hash при одном doc_hash). Держим КРАЙНЮЮ версию по номеру блока,
// иначе документ задвоился бы в списке. package НЕ ключ: в одном процессе бывает несколько разных
// документов с разными подписантами (обмен в marketplace).
const docHash: string | undefined = data.document?.doc_hash;
if (!docHash) return 'skipped';
const hash: string = data.document?.hash || '';
const packageHash: string = data.package || '';
const coopname: string = data.coopname || config.coopname;
const incomingBlock = this.toBlockNum(action?.block_num);
const existing = await this.repository.getState(coopname, docHash);
if (existing) {
// Не понижаем статус: submitted не перетирает resolved/declined.
if (status === SignedDocumentStatus.Submitted && existing.status !== SignedDocumentStatus.Submitted) {
return 'skipped';
}
// Не откатываем на более старую версию подписи (меньший номер блока).
if (incomingBlock !== null && existing.blockNum !== null && incomingBlock < existing.blockNum) {
return 'skipped';
}
// Идемпотентность: тот же статус и не новее по блоку — обновлять нечего (повторный backfill безопасен).
if (
existing.status === status &&
(incomingBlock === null || existing.blockNum === null || incomingBlock <= existing.blockNum)
) {
return 'skipped';
}
}
await this.assembleAndUpsert(action, status, coopname, docHash, hash, packageHash);
return existing ? 'updated' : 'created';
}
private toBlockNum(value: unknown): number | null {
if (value === undefined || value === null) return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
private async safeStatusEvent(action: IAction, status: SignedDocumentStatus, label: string): Promise<void> {
try {
const result = await this.ingestAction(action, status);
if (result !== 'skipped') {
this.logger.log(`[${label}] запись реестра ${result}: package=${this.shortPackage(action)}`);
}
} catch (error: any) {
this.logger.warn(`Ошибка ingestion события ${label}: ${error?.message}`);
}
}
private async safeReassemble(action: IAction, label: string): Promise<void> {
try {
const data = (action?.data ?? {}) as Record<string, any>;
const packageHash: string | undefined = data.package;
if (!packageHash) return;
const coopname: string = data.coopname || config.coopname;
// decision/act/link относятся к процессу (package), а не к конкретному заявлению.
// Пересобираем агрегаты ВСЕХ заявлений пакета (их может быть несколько с разными
// подписантами), сохраняя статус каждого.
const rows = await this.repository.findByPackage(coopname, packageHash);
if (rows.length === 0) return; // заявлений по пакету ещё нет — нечего дособирать
let rebuilt = 0;
for (const row of rows) {
if (await this.reassembleRow(row, coopname)) rebuilt++;
}
if (rebuilt > 0) {
this.logger.log(`[${label}] пересобрано агрегатов: ${rebuilt}, package=${this.shortPackage(action)}`);
}
} catch (error: any) {
this.logger.warn(`Ошибка пересборки агрегата по событию ${label}: ${error?.message}`);
}
}
/**
* Пересобирает агрегат ОДНОЙ записи пакета по её сохранённому действию-носителю и обновляет
* ТОЛЬКО агрегат-производные колонки. Статус/версию/source НЕ трогаем — ими владеют статус-события
* (иначе пересборка, конкурентная с newresolved, откатила бы resolved→submitted). Если агрегат не
* собрался (null) — запись не портим, оставляем прежний агрегат.
*/
private async reassembleRow(row: SignedDocumentPackageRow, coopname: string): Promise<boolean> {
if (!row.sourceActionData) return false;
const aggregate = await this.buildAggregateSafe(row.sourceActionData as unknown as IAction);
if (!aggregate) return false;
const rawDoc = aggregate.statement?.documentAggregate?.rawDocument;
await this.repository.updateAggregate(coopname, row.doc_hash, {
document_aggregate: aggregate,
full_title: rawDoc?.full_title || '',
content_text: this.stripHtml(rawDoc?.html || ''),
signers_text: this.collectSignersText(aggregate),
});
return true;
}
private async assembleAndUpsert(
sourceAction: IAction,
status: SignedDocumentStatus,
coopname: string,
docHash: string,
hash: string,
packageHash: string
): Promise<void> {
const aggregate = await this.buildAggregateSafe(sourceAction);
const data = (sourceAction?.data ?? {}) as Record<string, any>;
const statement = aggregate?.statement;
const rawDoc = statement?.documentAggregate?.rawDocument;
const meta = (rawDoc?.meta ?? {}) as Record<string, any>;
await this.repository.upsert({
coopname,
packageHash,
doc_hash: docHash,
hash,
username: meta.username || data.username || '',
status,
action: data.action || '',
registry_id: typeof meta.registry_id === 'number' ? meta.registry_id : 0,
full_title: rawDoc?.full_title || '',
content_text: this.stripHtml(rawDoc?.html || ''),
signers_text: this.collectSignersText(aggregate),
block_num: this.resolveBlockNum(sourceAction?.block_num, meta.block_num),
document_created_at: this.parseDocumentDate(meta.created_at, meta.timezone),
document_aggregate: aggregate,
source_action_data: sourceAction as unknown as Record<string, unknown>,
});
}
private async buildAggregateSafe(action: IAction): Promise<DocumentPackageAggregateDomainInterface | null> {
try {
return await this.aggregator.buildDocumentPackageAggregate(action as unknown as Cooperative.Blockchain.IAction);
} catch (error: any) {
this.logger.debug(`Не удалось собрать агрегат пакета: ${error?.message}`);
return null;
}
}
/**
* Собирает ФИО/наименования всех подписантов пакета из signer_certificate всех частей агрегата
* (заявление/решение/акты/связанные). Это и есть основной индекс поиска по подписанту —
* соподписанты (председатель/совет) подписывают решение и акты, а не тело заявления,
* поэтому content_text их не покрывает. Username добавляется тоже (редкий поиск по нему).
*/
private collectSignersText(aggregate: DocumentPackageAggregateDomainInterface | null): string {
if (!aggregate) return '';
const docAggregates = [
aggregate.statement?.documentAggregate,
aggregate.decision?.documentAggregate,
...aggregate.acts.map((act) => act?.documentAggregate),
...aggregate.links,
];
const parts = new Set<string>();
for (const da of docAggregates) {
const signatures = da?.document?.signatures ?? [];
for (const sig of signatures) {
if (sig?.signer) parts.add(String(sig.signer));
const cert = sig?.signer_certificate as Record<string, any> | null | undefined;
if (!cert) continue;
for (const key of ['last_name', 'first_name', 'middle_name', 'short_name']) {
if (cert[key]) parts.add(String(cert[key]));
}
}
}
return Array.from(parts).join(' ');
}
private shortPackage(action: IAction): string {
const pkg = (action?.data as Record<string, any>)?.package;
return typeof pkg === 'string' ? pkg.substring(0, 8) : '?';
}
/**
* Номер блока для колонки block_num — ось упорядочивания ВЕРСИЙ подписи одного doc_hash.
* Это блок ON-CHAIN ДЕЙСТВИЯ (action.block_num), а НЕ блок генерации документа (meta.block_num):
* guard'ы в ingestAction сравнивают пришедший action.block_num с хранимым block_num, поэтому обе
* стороны должны мерить одну величину. Иначе (meta.block_num — блок генерации, всегда ≤ блока
* подачи в цепь) защита от отката на старую версию и идемпотентность по блоку не срабатывают.
* meta.block_num — только запасной вариант, если у действия нет block_num.
*/
private resolveBlockNum(actionBlockNum: unknown, metaBlockNum: unknown): string | null {
if (actionBlockNum !== undefined && actionBlockNum !== null) return String(actionBlockNum);
if (metaBlockNum !== undefined && metaBlockNum !== null) return String(metaBlockNum);
return null;
}
/**
* Парсит meta.created_at в Date для колонки timestamptz. Документы хранят дату в
* ЛОКАЛИЗОВАННОМ формате `DD.MM.YYYY HH:mm` + отдельное поле meta.timezone — именно так её
* пишет фабрика @coopenomics/factory (Generator.updateMetadata через moment.tz). Нативный
* `new Date(meta.created_at)` этот формат НЕ понимает и даёт Invalid Date → pg-драйвер пишет
* в timestamptz "0NaN-NaN-NaN…" и вся вставка падает. Парсим тем же moment.tz, что и фабрика;
* timezone берём из меты, иначе — из конфига кооператива. Невалидное/пустое значение → null
* (одна битая дата не должна ронять запись и весь backfill).
*/
private parseDocumentDate(createdAt: unknown, timezone: unknown): Date | null {
if (!createdAt || typeof createdAt !== 'string') return null;
const tz = typeof timezone === 'string' && timezone ? timezone : config.timezone;
const parsed = moment.tz(createdAt, 'DD.MM.YYYY HH:mm', tz);
return parsed.isValid() ? parsed.toDate() : null;
}
private stripHtml(html: string): string {
return html
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
}
@@ -1,20 +0,0 @@
import { Module } from '@nestjs/common';
import { DocumentDomainModule } from '~/domain/document/document.module';
import { SignedDocumentIngestionService } from './services/signed-document-ingestion.service';
import { SignedDocumentBackfillService } from './services/signed-document-backfill.service';
/**
* Реестр подписанных документов в Postgres (C28-21).
*
* Ingestion-листенер ловит blockchain-события soviet с внутренней шины и наполняет PG-проекцию;
* backfill — разовый перенос истории. Репозиторий (SIGNED_DOCUMENT_REPOSITORY) и генератор
* предоставляются глобальными модулями (TypeOrmModule / GeneratorInfrastructureModule).
*
* Это единый источник для getDocuments (read-path) и searchDocuments — OpenSearch удалён.
*/
@Module({
imports: [DocumentDomainModule],
providers: [SignedDocumentIngestionService, SignedDocumentBackfillService],
exports: [SignedDocumentIngestionService, SignedDocumentBackfillService],
})
export class SignedDocumentsModule {}
@@ -234,8 +234,7 @@ export class SystemInteractor {
union_link: config.union.link,
board_members,
features: {
// Поиск по документам теперь на PG-реестре подписанных документов (C28-21) — доступен всегда.
search: true,
search: process.env.OPENSEARCH_ENABLED === 'true',
},
});
}
@@ -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 -17
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'),
@@ -193,18 +227,6 @@ const envVarsSchema = z.object({
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
// apps-catalog proxy (Story 9.5.b). Витрина remote-пакетов — это HTTP-прокси
// через controller → ca-admin /v1/public/packages. Браузер напрямую в каталог
// не ходит, admin-secret в SPA не утекает (см. architecture-choice C).
APPS_CATALOG_URL: z
.string()
.optional()
.describe('Base URL ca-admin (apps-catalog). Пусто = резолвер вернёт пустой список (degraded mode).'),
APPS_CATALOG_API_KEY: z
.string()
.optional()
.describe('Admin API ключ для apps-catalog. Пусто = degraded mode.'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -243,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' : ''),
@@ -336,8 +362,4 @@ export default {
signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET,
public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL,
},
apps_catalog: {
url: envVars.data.APPS_CATALOG_URL,
api_key: envVars.data.APPS_CATALOG_API_KEY,
},
};
+16 -23
View File
@@ -26,29 +26,22 @@ const logger = winston.createLogger({
})
),
transports: [
// При генерации схемы (build-time codegen) файловые транспорты не подключаем:
// процесс запускается из-под пользователя рядом с работающим контейнером, чей logs/
// принадлежит root, — запись туда падает с EACCES. На рантайм не влияет (флаг ставится только в codegen).
...(process.env.CONTROLLER_SCHEMA_GEN
? []
: [
new DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d',
level: 'info',
}),
new DailyRotateFile({
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d',
level: 'error',
}),
]),
new DailyRotateFile({
filename: 'logs/application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d',
level: 'info',
}),
new DailyRotateFile({
filename: 'logs/error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '30d',
level: 'error',
}),
new winston.transports.Console({
stderrLevels: ['error'],
level: config.env === 'development' ? 'debug' : 'info',
@@ -25,14 +25,6 @@ export interface SovietBlockchainPort {
confirmAgreement(data: SovietContract.Actions.Agreements.ConfirmAgreement.IConfirmAgreement): Promise<TransactResult>;
declineAgreement(data: SovietContract.Actions.Agreements.DeclineAgreement.IDeclineAgreement): Promise<TransactResult>;
// Утверждение + исполнение решения совета одной транзакцией ключом кооператива
// (soviet::authorize + soviet::exec). Согласие председателя — в подписанном им
// документе внутри authorizeData.document.
authorizeDecision(
authorizeData: SovietContract.Actions.Decisions.Authorize.IAuthorize,
execData: SovietContract.Actions.Decisions.Exec.IExec
): Promise<TransactResult>;
}
export const SOVIET_BLOCKCHAIN_PORT = Symbol('SovietBlockchainPort');
@@ -1,18 +0,0 @@
/**
* Статус подписанного документа в реестре подписанных документов (Postgres-проекция).
*
* Жизненный цикл документа фиксируется блокчейн-событиями контракта soviet:
* - newsubmitted → Submitted
* - newresolved → Resolved
* - newdeclined → Declined
*
* Resolved — основной статус (на нём построено отображение реестра).
* Submitted — отправлен/подписан, но ещё не утверждён (сценарий доп. утверждения;
* сейчас фактически не используется — кандидат на вырезание, см. задачу C28-21).
* Declined — отклонён/аннулирован.
*/
export enum SignedDocumentStatus {
Submitted = 'submitted',
Resolved = 'resolved',
Declined = 'declined',
}
@@ -1,145 +0,0 @@
import type { SignedDocumentStatus } from '../enums/signed-document-status.enum';
import type { DocumentPackageAggregateDomainInterface } from '../interfaces/document-package-aggregate-domain.interface';
/**
* Данные для записи/обновления одной записи реестра подписанных документов.
* Одна запись = ОДИН документ (ключ — `doc_hash`, идентичность содержимого без подписей).
* `hash` — крайняя подписанная версия (накопленные подписи), `packageHash` — идентификатор процесса,
* общий для нескольких заявлений пакета (submitted↔resolved↔declined трекаются по `doc_hash`).
*/
export interface SignedDocumentUpsertInput {
coopname: string;
/** checksum256 процесса (`package` в blockchain-action; НЕ уникален — группирует документы) */
packageHash: string;
/** checksum256 содержимого документа — УНИКАЛЬНЫЙ ключ записи (без учёта подписей) */
doc_hash: string;
/** checksum256 подписанной версии (включает подписи) — крайняя версия документа */
hash: string;
username: string;
status: SignedDocumentStatus;
/** под-действие soviet (`data.action`) — для фильтрации списка по типам документов */
action: string;
registry_id: number;
full_title: string;
/** html заявления без тегов — для ILIKE-поиска */
content_text: string;
/** ФИО/наименования всех подписантов пакета + их username — для поиска по подписанту */
signers_text: string;
/** bigint из блокчейна приходит строкой */
block_num: string | null;
document_created_at: Date | null;
/** готовый агрегат пакета (statement/decision/acts/links) — отдаётся getDocuments как есть */
document_aggregate: DocumentPackageAggregateDomainInterface | null;
/** data действия-носителя заявления — для пересборки агрегата при доприходе decision/act/link */
source_action_data: Record<string, unknown> | null;
}
export interface SignedDocumentSearchParams {
coopname: string;
query: string;
limit: number;
/** ограничить поиск документами этого пайщика (для не-членов совета); undefined — весь кооператив */
username?: string;
}
/**
* Результат поиска — повторяет контракт SearchResultDTO (GraphQL `searchDocuments`).
*/
export interface SignedDocumentSearchHit {
hash: string;
/** Чистое наименование документа (meta.title), без служебного хвоста ` - - дата.pdf`. */
full_title: string;
username: string;
/** ФИО подписанта-субъекта («Иванов Иван Иванович») для отображения вместо username. */
signer: string;
coopname: string;
registry_id: number;
created_at: string | null;
highlights: string[];
}
/** Параметры выборки готовых агрегатов для списка документов (read-path getDocuments). */
export interface SignedDocumentListParams {
coopname: string;
status?: SignedDocumentStatus;
/** фильтр по под-действиям (data.action) */
actions?: string[];
/** фильтр по пайщику */
username?: string;
/** фильтр по хэшу конкретного документа-заявления */
hash?: string;
afterBlock?: number;
beforeBlock?: number;
page: number;
limit: number;
}
export interface SignedDocumentListResult {
items: DocumentPackageAggregateDomainInterface[];
total: number;
}
/**
* Частичное обновление записи при ПЕРЕСБОРКЕ агрегата (доприход decision/act/link).
* Затрагивает ТОЛЬКО агрегат-производные колонки. Поля жизненного цикла (status, block_num,
* source_action_data, document_created_at) сюда НЕ входят: ими владеют статус-события
* (newsubmitted/newresolved/newdeclined). Иначе пересборка, читающая старый row.status, откатила бы
* параллельно проставленный resolved обратно в submitted (гонка freedecision: newresolved+newdecision
* по одному package приходят одновременно).
*/
export interface SignedDocumentAggregateUpdate {
document_aggregate: DocumentPackageAggregateDomainInterface | null;
full_title: string;
content_text: string;
signers_text: string;
}
/** Состояние записи для guard'ов ingestion (не понижать статус / не откатывать версию подписи). */
export interface SignedDocumentState {
status: SignedDocumentStatus;
/** номер блока крайней версии (для сравнения «новее ли пришедшая версия») */
blockNum: number | null;
}
/** Строка реестра для пересборки агрегата: документ пакета + его действие-носитель. */
export interface SignedDocumentPackageRow {
doc_hash: string;
hash: string;
status: SignedDocumentStatus;
sourceActionData: Record<string, unknown> | null;
}
/**
* Репозиторий реестра подписанных документов (Postgres-проекция).
* Единый источник для отображения (getDocuments) и поиска (searchDocuments) — задача C28-21.
* Запись = один документ (ключ `doc_hash`); `package` группирует заявления одного процесса.
*/
export interface SignedDocumentRepository {
/** Вставить или обновить запись по (coopname, doc_hash). */
upsert(input: SignedDocumentUpsertInput): Promise<void>;
/** Частично обновить ТОЛЬКО агрегат-производные колонки записи (по coopname, doc_hash).
* Используется пересборкой при доприходе decision/act/link — status/block_num/source НЕ трогаются
* (см. SignedDocumentAggregateUpdate: защита от отката resolved→submitted в гонке). */
updateAggregate(coopname: string, docHash: string, fields: SignedDocumentAggregateUpdate): Promise<void>;
/** Статус + номер блока крайней версии документа по его doc_hash (для guard'ов ingestion);
* null, если записи нет. */
getState(coopname: string, docHash: string): Promise<SignedDocumentState | null>;
/** Кол-во записей реестра по кооперативу (для авто-триггера backfill на пустой базе). */
count(coopname: string): Promise<number>;
/** Все заявления одного процесса (package) — для пересборки агрегатов при доприходе
* decision/act/link (на пакет может приходиться несколько заявлений с разными подписантами). */
findByPackage(coopname: string, packageHash: string): Promise<SignedDocumentPackageRow[]>;
/** Поиск по реестру (ILIKE по signers_text/full_title/content_text/username). */
search(params: SignedDocumentSearchParams): Promise<SignedDocumentSearchHit[]>;
/** Список готовых агрегатов с пагинацией и фильтрами (read-path getDocuments). */
findAggregates(params: SignedDocumentListParams): Promise<SignedDocumentListResult>;
}
/** Токен для инъекции зависимости репозитория реестра подписанных документов. */
export const SIGNED_DOCUMENT_REPOSITORY = Symbol('SignedDocumentRepository');
@@ -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; // Перебрасываем ошибку для корректной обработки
}
}
}
@@ -1,59 +0,0 @@
import {
Controller,
Get,
Header,
HttpException,
HttpStatus,
Param,
Res,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { HttpJwtAuthGuard } from '~/application/auth/guards/http-jwt-auth.guard';
import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service';
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
/**
* Story 9.4.b — REST-проксирование install.js пакета из apps-catalog к
* desktop'у. Защищён HTTP JWT — пайщик должен быть залогинен в свой
* кооператив, иначе нельзя загружать remote-расширения. Admin-API ключ
* apps-catalog'а инкапсулирован в backend'е и не утекает в браузер
* (см. architecture-choice C в Epic 9.5.b).
*
* Endpoint:
* `GET /v1/apps-catalog/install/:scope/:name`
* → 200 text/javascript — CJS install.js
* → 404 если пакета нет (или ca-admin недоступен в degraded mode).
* → 401 без JWT.
*/
@Controller('v1/apps-catalog/install')
@UseGuards(HttpJwtAuthGuard)
export class AppsCatalogInstallScriptController {
constructor(private readonly catalog: AppsCatalogHttpService) {}
@Get(':scope/:name')
@Header('Content-Type', 'application/javascript; charset=utf-8')
@Header('Cache-Control', 'no-store')
async execute(
@Param('scope') scope: string,
@Param('name') name: string,
@Res({ passthrough: true }) res: Response,
): Promise<string> {
if (!NAME_REGEX.test(scope) || !NAME_REGEX.test(name)) {
throw new HttpException(
'Invalid package coordinates',
HttpStatus.NOT_FOUND,
);
}
const code = await this.catalog.fetchInstallScript(scope, name);
if (code === null) {
throw new HttpException(
'install.js not available',
HttpStatus.NOT_FOUND,
);
}
res.status(HttpStatus.OK);
return code;
}
}
@@ -1,43 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
/**
* GraphQL-проекция одного remote-пакета в публичном каталоге apps-catalog
* (Story 9.5.b). Источник — `GET /v1/public/packages` на ca-admin, который
* controller проксирует через `Query.appsCatalogRemotePackages`. На фронте
* подписи витрины строятся из этих полей; фильтр self/чужое и self-sub
* bypass делает desktop по `publisher === coopname`.
*/
@ObjectType()
export class AppsCatalogRemotePackageDTO {
@Field({ description: 'Идентификатор пакета (например, @voskhod/demoapp)' })
packageId!: string;
@Field({ description: 'Имя владельца пакета (кооператив-разработчик)' })
publisher!: string;
@Field(() => [String], {
description: 'Совместимые subnet (chain_id блокчейна ЦК)',
})
compatibleSubnets!: string[];
@Field(() => String, {
nullable: true,
description: 'Последняя активная версия (semver). null = релизов ещё нет.',
})
lastActiveVersion?: string | null;
@Field({
description: 'Заголовок пакета для UI (в MVP — packageId, в будущем из manifest)',
})
title!: string;
@Field({
description: 'Краткое описание (в MVP — заглушка, в будущем из manifest)',
})
description!: string;
@Field({
description: 'Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed)',
})
rubPerMonth!: number;
}
@@ -1,37 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
/**
* Входные данные `Mutation.publishPackage` (story 9.3.b-pub).
*
* Эта мутация — первичная регистрация пакета в каталоге восхода. Она
* прокидывается на ca-admin `POST /v1/admin/package` (action
* `apps::regpkg` на blockchain), а ca-admin сам подписывает on-chain от
* имени chairman'а кооператива-оператора (voskhod). Поэтому здесь нет
* никаких ключей пайщика — только координаты пакета.
*
* Multipart upload install.js под Federation v2 более НЕ нужен:
* расширение публикуется как npm-package + docker-image в Nexus, а
* developer пушит их отдельной процедурой (`npm publish` + `docker
* push`) через CA-auth scoped JWT. Story 9.3.b-pub оставляет только
* on-chain регистрацию; релизы с manifest'ом — story 9.3.b-rel.
*/
@InputType()
export class PublishPackageInputDTO {
@Field({
description:
'Идентификатор пакета в формате @scope/name, например @voskhod/demoapp',
})
packageId!: string;
@Field({
description:
'Antelope-имя владельца пакета (1..12 символов из [a-z1-5.])',
})
ownerUsername!: string;
@Field(() => [String], {
description:
'chain_id совместимых подсетей (каждый — 64 hex-символа), хотя бы одна',
})
compatibleSubnets!: string[];
}
@@ -1,27 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
/**
* Результат `Mutation.publishPackage` (story 9.3.b-pub).
*
* Discriminator `status` — `applied` если ca-admin вернул 200 (on-chain
* `apps::regpkg` улетел и подтверждён), `conflict` если пакет уже
* зарегистрирован (HTTP 409), `failed` для остальных ошибок (включая
* degraded-mode когда APPS_CATALOG_API_KEY не задан).
*/
@ObjectType()
export class PublishPackageResultDTO {
@Field({
description:
'Статус: applied (зарегистрирован on-chain), conflict (уже есть), failed (ошибка)',
})
status!: 'applied' | 'conflict' | 'failed';
@Field({
description:
'Идентификатор запроса (UUIDv4), который ca-admin использовал для идемпотентности',
})
requestId!: string;
@Field({ nullable: true, description: 'Человекочитаемое сообщение об ошибке' })
error?: string;
}
@@ -1,44 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-type-json';
/**
* Входные данные `Mutation.publishRelease` (story 9.3.b-rel).
*
* Эта мутация регистрирует новый релиз ранее зарегистрированного
* пакета (см. `publishPackage`/9.3.b-pub). Прокидывается на ca-admin
* `POST /v1/admin/releases` (action `apps::setrelease` on-chain), который
* сам подписывает от имени chairman'а кооператива-оператора.
*
* `manifest` под архитектуру E10 содержит ссылки на артефакты в Nexus:
* `coopenomics.backend.image` — docker-image (для subgraph'а
* расширения), `coopenomics.frontend.tarball` — npm tarball (для
* desktop-bundle). Сам manifest валидируется Zod-схемой
* `PackageManifestSchema` на стороне ca-admin (HTTP 422 → resolver
* мапит в `failed`).
*/
@InputType()
export class PublishReleaseInputDTO {
@Field({
description:
'Идентификатор пакета в формате @scope/name (должен быть уже зарегистрирован через publishPackage)',
})
packageId!: string;
@Field({ description: 'Версия релиза в формате semver, например 1.0.0' })
version!: string;
@Field(() => GraphQLJSON, {
description:
'Package manifest (валидируется Zod-схемой на ca-admin). ' +
'Содержит coopenomics.backend.image (docker) + coopenomics.frontend.tarball (npm), ' +
'requires/provides, GraphQL-схему и pricing-параметры.',
})
manifest!: Record<string, unknown>;
@Field({
nullable: true,
description:
'sha256 npm tarball\'а (HEX). Если не передан — ca-admin использует sentinel zero-hash.',
})
tarballSha256?: string;
}
@@ -1,40 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
/**
* Результат `Mutation.publishRelease` (story 9.3.b-rel).
*
* Discriminator `status`:
* - `applied` — ca-admin вернул 200, on-chain `apps::setrelease` улетел;
* - `invalidManifest` — HTTP 422 INVALID_MANIFEST (Zod-валидация
* manifest'а провалилась на стороне ca-admin); UI должен показать
* `error` как валидационное сообщение;
* - `failed` — прочие ошибки (network, 401, degraded-mode без
* APPS_CATALOG_API_KEY).
*/
@ObjectType()
export class PublishReleaseResultDTO {
@Field({
description:
'Статус: applied | invalidManifest | failed',
})
status!: 'applied' | 'invalidManifest' | 'failed';
@Field({
description:
'Идентификатор запроса (UUIDv4), который ca-admin использовал для идемпотентности',
})
requestId!: string;
@Field({
nullable: true,
description:
'Идентификатор blockchain-транзакции (если ca-admin её вернул)',
})
transactionId?: string;
@Field({
nullable: true,
description: 'Человекочитаемое сообщение об ошибке',
})
error?: string;
}
@@ -1,168 +0,0 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Logger, UseGuards } from '@nestjs/common';
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 { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service';
import { AppsCatalogRemotePackageDTO } from '../dto/apps-catalog-remote-package.dto';
import { PublishPackageInputDTO } from '../dto/publish-package-input.dto';
import { PublishPackageResultDTO } from '../dto/publish-package-result.dto';
import { PublishReleaseInputDTO } from '../dto/publish-release-input.dto';
import { PublishReleaseResultDTO } from '../dto/publish-release-result.dto';
/**
* Story 9.5.b — публичный каталог remote-пакетов на desktop'е магазина.
*
* Прокси-резолвер: controller ходит к ca-admin /v1/public/packages, маппит
* snake_case wire-формат в camelCase GraphQL-проекцию, добавляет UI-поля
* (title/description/rubPerMonth) для витрины. В V1 description и
* rubPerMonth — заглушки; в V2 будут читаться из package manifest и
* apps-catalog pricing соответственно.
*
* Guard: GqlJwtAuthGuard — каталог видят только авторизованные пайщики
* (по умолчанию роль `user`). Self-filter и self-sub bypass делает
* desktop по сравнению `publisher` и текущего coopname.
*/
@Resolver()
export class AppsCatalogProxyResolver {
private readonly logger = new Logger(AppsCatalogProxyResolver.name);
constructor(private readonly client: AppsCatalogHttpService) {}
@Query(() => [AppsCatalogRemotePackageDTO], {
name: 'appsCatalogRemotePackages',
description:
'Список remote-пакетов из публичного каталога apps-catalog. ' +
'Защищён JWT (видят только авторизованные пайщики). ' +
'Источник — ca-admin /v1/public/packages; controller проксирует.',
})
@UseGuards(GqlJwtAuthGuard)
async appsCatalogRemotePackages(
@Args('page', { type: () => Number, defaultValue: 1 }) page: number,
@Args('pageSize', { type: () => Number, defaultValue: 50 }) pageSize: number,
): Promise<AppsCatalogRemotePackageDTO[]> {
const packages = await this.client.listPublicPackages(page, pageSize);
return packages.map((p) => ({
packageId: p.packageId,
publisher: p.publisher,
compatibleSubnets: p.compatibleSubnets,
lastActiveVersion: p.lastActiveVersion,
title: this.buildTitle(p.packageId),
description: this.buildDescription(p.publisher),
rubPerMonth: 1000,
}));
}
private buildTitle(packageId: string): string {
const tail = packageId.split('/').pop() ?? packageId;
return tail.charAt(0).toUpperCase() + tail.slice(1);
}
private buildDescription(publisher: string): string {
return `Удалённое расширение от ${publisher} — устанавливается без перезагрузки сервера.`;
}
/**
* Story 9.3.b-pub — стол разработчика публикует пакет в каталоге восхода.
*
* Защита: только chairman кооператива-оператора (`voskhod` на dev).
* Сама подпись on-chain `apps::regpkg` делает ca-admin от имени chairman'а
* восхода — controller только проксирует HTTP-запрос и возвращает
* discriminated outcome (`applied | conflict | failed`).
*
* Multipart upload install.js здесь НЕ делается. Под архитектуру E10
* расширения публикуются как npm-package + docker-image в Nexus
* отдельной процедурой (`npm publish` / `docker push`) через scoped
* JWT от CA-auth. Эта мутация — только on-chain маркер «такой
* пакет существует».
*/
@Mutation(() => PublishPackageResultDTO, {
name: 'publishPackage',
description:
'Регистрирует пакет on-chain (action apps::regpkg) через ca-admin. ' +
'Подписывает chairman кооператива-оператора каталога. Доступно ' +
'только chairman\'у (стол разработчика).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async publishPackage(
@Args('data', { type: () => PublishPackageInputDTO })
data: PublishPackageInputDTO,
): Promise<PublishPackageResultDTO> {
const outcome = await this.client.registerPackage({
packageId: data.packageId,
ownerUsername: data.ownerUsername,
compatibleSubnets: data.compatibleSubnets,
});
if (outcome.status === 'applied') {
this.logger.log(
`publishPackage applied: ${data.packageId} (request ${outcome.requestId})`,
);
return { status: 'applied', requestId: outcome.requestId };
}
if (outcome.status === 'conflict') {
return {
status: 'conflict',
requestId: outcome.requestId,
error: outcome.error,
};
}
return {
status: 'failed',
requestId: outcome.requestId,
error: outcome.error,
};
}
/**
* Story 9.3.b-rel — стол разработчика выкладывает новый релиз пакета.
*
* Прокидывает manifest + версию на ca-admin `POST /v1/admin/releases`.
* ca-admin валидирует manifest Zod-схемой и подписывает on-chain
* `apps::setrelease` от имени chairman'а кооператива-оператора. Под
* архитектуру E10 manifest должен содержать ссылки на артефакты в
* Nexus (`coopenomics.backend.image` + `coopenomics.frontend.tarball`).
*/
@Mutation(() => PublishReleaseResultDTO, {
name: 'publishRelease',
description:
'Создаёт новый релиз пакета (action apps::setrelease) через ca-admin. ' +
'Подписывает chairman кооператива-оператора каталога. Доступно ' +
'только chairman\'у (стол разработчика).',
})
@UseGuards(GqlJwtAuthGuard, RolesGuard)
@AuthRoles(['chairman'])
async publishRelease(
@Args('data', { type: () => PublishReleaseInputDTO })
data: PublishReleaseInputDTO,
): Promise<PublishReleaseResultDTO> {
const outcome = await this.client.createRelease({
packageId: data.packageId,
version: data.version,
manifest: data.manifest,
tarballSha256: data.tarballSha256,
});
if (outcome.status === 'applied') {
this.logger.log(
`publishRelease applied: ${data.packageId}@${data.version} (request ${outcome.requestId}, tx ${outcome.transactionId ?? '-'})`,
);
return {
status: 'applied',
requestId: outcome.requestId,
transactionId: outcome.transactionId,
};
}
if (outcome.status === 'invalidManifest') {
return {
status: 'invalidManifest',
requestId: outcome.requestId,
error: outcome.error,
};
}
return {
status: 'failed',
requestId: outcome.requestId,
error: outcome.error,
};
}
}
@@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { AppsCatalogHttpService } from './infrastructure/apps-catalog-http.service';
import { AppsCatalogInstallScriptController } from './application/controllers/install-script.controller';
import { AppsCatalogProxyResolver } from './application/resolvers/apps-catalog-proxy.resolver';
/**
* Story 9.5.b + 9.4.b — модуль-прокси для публичного каталога apps-catalog.
*
* GraphQL `Query.appsCatalogRemotePackages` (9.5.b) + REST
* `GET /v1/apps-catalog/install/:scope/:name` (9.4.b) + HTTP-клиент к
* ca-admin. Никакого state, никаких миграций, без TypeORM. Включается в
* `ExtensionsModule` в общий imports/exports список.
*/
@Module({
controllers: [AppsCatalogInstallScriptController],
providers: [AppsCatalogHttpService, AppsCatalogProxyResolver],
exports: [AppsCatalogHttpService],
})
export class AppsCatalogProxyModule {}
@@ -1,248 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, AxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';
import config from '~/config/config';
interface PublicPackageWireFormat {
package_id: string;
owner_username: string;
compatible_subnets: string[];
last_active_version: string | null;
}
export interface AppsCatalogPackage {
packageId: string;
publisher: string;
compatibleSubnets: string[];
lastActiveVersion: string | null;
}
export interface RegisterPackageInput {
packageId: string;
ownerUsername: string;
compatibleSubnets: string[];
/** Optional. Если не передан — генерируется uuidv4. */
requestId?: string;
}
export type RegisterPackageOutcome =
| { status: 'applied'; requestId: string }
| { status: 'conflict'; requestId: string; error: string }
| { status: 'failed'; requestId: string; error: string };
export interface CreateReleaseInput {
packageId: string;
version: string;
manifest: Record<string, unknown>;
tarballSha256?: string;
requestId?: string;
}
export type CreateReleaseOutcome =
| { status: 'applied'; requestId: string; transactionId?: string }
| { status: 'invalidManifest'; requestId: string; error: string }
| { status: 'failed'; requestId: string; error: string };
/**
* HTTP-клиент к ca-admin (apps-catalog) для Story 9.5.b. Защищён admin-API
* ключом из env (APPS_CATALOG_API_KEY). Используется только сервером —
* ключ в браузер не утекает.
*
* Degraded mode: если APPS_CATALOG_URL / APPS_CATALOG_API_KEY не заданы —
* возвращает пустой список вместо ошибки, чтобы dev-окружение без
* apps-catalog (или CI без сети) не валило desktop boot.
*/
@Injectable()
export class AppsCatalogHttpService {
private readonly logger = new Logger(AppsCatalogHttpService.name);
private readonly client: AxiosInstance | null;
constructor() {
if (config.apps_catalog.url && config.apps_catalog.api_key) {
this.client = axios.create({
baseURL: config.apps_catalog.url,
timeout: 5000,
headers: {
Authorization: `Bearer ${config.apps_catalog.api_key}`,
},
});
} else {
this.client = null;
this.logger.warn(
'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы — apps-catalog-proxy в degraded mode (пустой каталог)',
);
}
}
async listPublicPackages(page = 1, pageSize = 50): Promise<AppsCatalogPackage[]> {
if (!this.client) return [];
try {
const res = await this.client.get<PublicPackageWireFormat[]>(
'/v1/public/packages',
{ params: { page, page_size: pageSize } },
);
return res.data.map((p) => ({
packageId: p.package_id,
publisher: p.owner_username,
compatibleSubnets: p.compatible_subnets,
lastActiveVersion: p.last_active_version,
}));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(`apps-catalog list failed: ${msg}`);
return [];
}
}
/**
* Story 9.4.b: получить install.js пакета remote-расширения.
*
* Возвращает plain CJS-текст из ca-admin endpoint'а
* `GET /v1/public/packages/:scope/:name/install.js`. На degraded mode
* (без env-config'а apps-catalog'а) либо если ca-admin отвечает 404
* — возвращает `null`, чтобы desktop'у было ясно «нет такого пакета /
* каталог недоступен» вместо ошибки 500 в браузере.
*
* @param scope владелец пакета (`voskhod` из `@voskhod/demo-app`).
* @param name short-name пакета (`demo-app`).
*/
/**
* Story 9.3.b-pub: первичная регистрация пакета в каталоге.
*
* Прокидывает входной DTO на ca-admin `POST /v1/admin/package`. ca-admin
* сам подписывает on-chain action `apps::regpkg` от имени chairman'а
* кооператива-оператора каталога (voskhod на dev) — поэтому здесь не
* требуется session-key пайщика.
*
* Discriminated outcome (vs. throw):
* - `applied` — HTTP 200, on-chain transaction подтверждён;
* - `conflict` — HTTP 409 (пакет уже зарегистрирован или дубликат
* `request_id`); resolver мапит на `status: 'conflict'`;
* - `failed` — любое прочее (network, 400, 401, degraded-mode без
* APPS_CATALOG_API_KEY); resolver мапит на `status: 'failed'`.
*
* Degraded mode (нет client'а) — возвращает `failed` с явным сообщением:
* mutation в degraded-стенде не делает silent no-op, чтобы UI не
* вводился в заблуждение «пакет опубликован».
*/
async registerPackage(
input: RegisterPackageInput,
): Promise<RegisterPackageOutcome> {
const requestId = input.requestId ?? uuidv4();
if (!this.client) {
const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы';
this.logger.warn(
`registerPackage refused (degraded mode): ${input.packageId}`,
);
return { status: 'failed', requestId, error };
}
try {
await this.client.post('/v1/admin/package', {
request_id: requestId,
package_id: input.packageId,
owner_username: input.ownerUsername,
compatible_subnets: input.compatibleSubnets,
});
return { status: 'applied', requestId };
} catch (err) {
const status = (err as AxiosError).response?.status;
const responseData = (err as AxiosError).response?.data;
const detail =
typeof responseData === 'object' && responseData
? JSON.stringify(responseData)
: err instanceof Error
? err.message
: String(err);
if (status === 409) {
this.logger.warn(
`registerPackage conflict для ${input.packageId}: ${detail}`,
);
return { status: 'conflict', requestId, error: detail };
}
this.logger.error(
`registerPackage failed для ${input.packageId}: ${detail}`,
);
return { status: 'failed', requestId, error: detail };
}
}
/**
* Story 9.3.b-rel: создание нового релиза пакета (action `apps::setrelease`).
*
* Прокидывает manifest + версию на ca-admin `POST /v1/admin/releases`.
* ca-admin сам валидирует manifest Zod-схемой и подписывает on-chain.
*
* Discriminated outcome:
* - `applied` — HTTP 200, on-chain прошёл; в payload может быть
* `transaction_id` (если адаптер не stub'овый);
* - `invalidManifest` — HTTP 422 INVALID_MANIFEST (manifest не прошёл
* Zod-валидацию на стороне ca-admin); `error` содержит детали
* валидации в стабильном формате;
* - `failed` — прочие ошибки (network, 401, 503, degraded-mode).
*/
async createRelease(input: CreateReleaseInput): Promise<CreateReleaseOutcome> {
const requestId = input.requestId ?? uuidv4();
if (!this.client) {
const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы';
this.logger.warn(
`createRelease refused (degraded mode): ${input.packageId}@${input.version}`,
);
return { status: 'failed', requestId, error };
}
try {
const res = await this.client.post<{
ok: boolean;
transaction_id?: string;
}>('/v1/admin/releases', {
request_id: requestId,
package_id: input.packageId,
version: input.version,
manifest: input.manifest,
...(input.tarballSha256
? { tarball_sha256: input.tarballSha256 }
: {}),
});
return {
status: 'applied',
requestId,
transactionId: res.data?.transaction_id,
};
} catch (err) {
const status = (err as AxiosError).response?.status;
const responseData = (err as AxiosError).response?.data;
const detail =
typeof responseData === 'object' && responseData
? JSON.stringify(responseData)
: err instanceof Error
? err.message
: String(err);
if (status === 422) {
this.logger.warn(
`createRelease invalidManifest для ${input.packageId}@${input.version}: ${detail}`,
);
return { status: 'invalidManifest', requestId, error: detail };
}
this.logger.error(
`createRelease failed для ${input.packageId}@${input.version}: ${detail}`,
);
return { status: 'failed', requestId, error: detail };
}
}
async fetchInstallScript(scope: string, name: string): Promise<string | null> {
if (!this.client) return null;
try {
const res = await this.client.get<string>(
`/v1/public/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/install.js`,
{ responseType: 'text', transformResponse: (data) => data as string },
);
return typeof res.data === 'string' ? res.data : String(res.data);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.logger.error(
`apps-catalog install.js fetch failed for ${scope}/${name}: ${msg}`,
);
return null;
}
}
}
@@ -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 (мс)' })

Some files were not shown because too many files have changed in this diff Show More