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>
This commit was merged in pull request #57.
This commit is contained in:
2026-06-03 08:31:35 +00:00
9 changed files with 244 additions and 17 deletions
+14 -12
View File
@@ -90,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) — СТРОГО
@@ -253,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
@@ -298,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).
@@ -147,6 +147,17 @@ const envVarsSchema = z.object({
* (свойство сети, не оператора).
*/
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'),
@@ -257,6 +268,7 @@ export default {
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' : ''),
@@ -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);
@@ -8,6 +8,8 @@ import type {
ISyncResult,
} from '~/shared/interfaces/blockchain-sync.interface';
import { FORK_AWARE_MARKER, type IForkAwareSyncer } from '~/shared/sync/fork';
import { UnsupportedContractVersionError } from '~/shared/sync/errors/unsupported-contract-version.error';
import config from '~/config/config';
/**
* Абстрактный сервис для синхронизации сущностей с блокчейном
@@ -56,7 +58,21 @@ export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynch
// Маппинг дельты в блокчейн-данные
const blockchainData = this.mapper.mapDeltaToBlockchainData(delta);
if (!blockchainData) {
this.logger.warn(`Failed to map delta to blockchain data for ${this.entityName} ${syncValue}`);
// Story 6.5: silent loss заменён на audit-trail error. В strict-mode дополнительно
// throw UnsupportedContractVersionError — парсер не ACK'нет дельту, dead-letter сработает.
const ctx = {
contract: (delta as any).contract ?? (delta as any).code,
table: (delta as any).table,
primary_key: (delta as any).primary_key,
block_num: Number((delta as any).block_num),
};
this.logger.error(
`UNSUPPORTED_CONTRACT_VERSION: mapDeltaToBlockchainData returned null for ${this.entityName} ${syncValue}`,
{ entity: this.entityName, syncValue, ...ctx }
);
if (config.blockchain.unsupported_version_strict) {
throw new UnsupportedContractVersionError(this.entityName, ctx);
}
return null;
}
@@ -66,6 +82,9 @@ export abstract class AbstractEntitySyncService<TEntity extends IBlockchainSynch
// Обработка создания/обновления сущности
return await this.handleSyncDelta(syncKey, syncValue, blockchainData, blockNum, present);
} catch (error: any) {
// Story 6.5: UnsupportedContractVersionError пробрасываем дальше, чтобы парсер
// не ACK'нул дельту в strict-mode.
if (error instanceof UnsupportedContractVersionError) throw error;
this.logger.error(`Error processing ${this.entityName} delta: ${error.message}`, error.stack);
// Не перебрасываем ошибку, чтобы не падало приложение
return null;
@@ -19,7 +19,6 @@ export class BaseTypeormEntity {
@UpdateDateColumn({ type: 'timestamp' })
_updated_at!: Date;
/**
* Получить имя таблицы для сущности
* ДОЛЖЕН БЫТЬ ПЕРЕОПРЕДЕЛЕН в каждом наследнике!
@@ -0,0 +1,24 @@
/**
* Story 6.5 (Epic 6): helper для эталонной точки `mapStatusToDomain`.
* При попадании на default-ветку (unknown статус из цепи) пишет `logger.error`
* с контекстом (entity, статус, ожидаемые статусы) — это audit-trail для schema drift.
*
* Возврата нет — caller сам решает, какой UNDEFINED-fallback использовать.
*/
export interface AuditLoggerLike {
error(message: string, ...meta: any[]): void;
}
export function auditUnknownStatus(
entityName: string,
receivedStatus: unknown,
logger: AuditLoggerLike,
allowedStatuses?: ReadonlyArray<string>
): void {
const expected = allowedStatuses && allowedStatuses.length > 0 ? `[${allowedStatuses.join(', ')}]` : 'не указано';
logger.error(
`UNKNOWN_ENTITY_STATUS ${entityName}: получен '${String(receivedStatus)}', ожидаются ${expected}`,
{ entityName, receivedStatus, allowedStatuses }
);
}
@@ -0,0 +1,24 @@
/**
* Story 6.5 (Epic 6): сигнализирует, что mapper не смог разобрать дельту блокчейна
* (mapDeltaToBlockchainData вернул null). Бросается из `AbstractEntitySyncService.processDelta`
* в strict-mode (`config.blockchain.unsupported_version_strict=true`).
*
* В non-strict режиме (default) ошибка не бросается — пишется только `logger.error` для
* аудита; парсер ACK'нет fork-event-like (поведение совместимое с текущим).
*/
export class UnsupportedContractVersionError extends Error {
constructor(
public readonly entityName: string,
public readonly context: {
contract?: string;
table?: string;
primary_key?: string | number;
block_num?: number;
}
) {
super(
`Unsupported contract version while mapping delta for ${entityName}: ${JSON.stringify(context)}`
);
this.name = 'UnsupportedContractVersionError';
}
}
@@ -0,0 +1,133 @@
/**
* Story 6.5 (Epic 6): unit-тесты для:
* - `UnsupportedContractVersionError` — носит контекст (contract/table/primary_key/block_num).
* - `auditUnknownStatus` — пишет error в logger с ожидаемыми статусами.
* - `AbstractEntitySyncService.processDelta` — strict-mode → throw, default → log.error + null.
*
* Конфиг strict-mode мокается через `jest.mock('~/config/config', ...)`.
*/
let strictMode = false;
jest.mock('~/config/config', () => ({
__esModule: true,
default: {
get blockchain() {
return { unsupported_version_strict: strictMode };
},
},
}));
import { AbstractEntitySyncService } from '~/shared/services/abstract-entity-sync.service';
import { UnsupportedContractVersionError } from '~/shared/sync/errors/unsupported-contract-version.error';
import { auditUnknownStatus } from '~/shared/sync/errors/audit-unknown-status';
function makeLoggerStub(): any {
return {
setContext: jest.fn(),
log: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
}
function makeMapperStub(canMap: boolean): any {
return {
extractSyncValue: jest.fn(() => 'sync-value'),
extractSyncKey: jest.fn(() => 'id'),
mapDeltaToBlockchainData: jest.fn(() => (canMap ? { v: 1 } : null)),
getAllEventPatterns: jest.fn(() => []),
getSupportedTableNames: jest.fn(() => []),
getSupportedContractNames: jest.fn(() => []),
};
}
class TestSyncService extends AbstractEntitySyncService<any, any> {
protected readonly entityName = 'TestEntity';
}
describe('Story 6.5: UnsupportedContractVersionError', () => {
it('конструктор сохраняет entityName и context', () => {
const err = new UnsupportedContractVersionError('Project', {
contract: 'capital',
table: 'projects',
primary_key: 42,
block_num: 100,
});
expect(err.name).toBe('UnsupportedContractVersionError');
expect(err.entityName).toBe('Project');
expect(err.context.primary_key).toBe(42);
expect(err.message).toContain('Project');
expect(err.message).toContain('"table":"projects"');
});
});
describe('Story 6.5: auditUnknownStatus', () => {
it('пишет logger.error с контекстом и списком ожидаемых статусов', () => {
const logger = makeLoggerStub();
auditUnknownStatus('Project', 'something-unknown', logger, ['pending', 'active']);
expect(logger.error).toHaveBeenCalledTimes(1);
const [msg, meta] = logger.error.mock.calls[0];
expect(msg).toContain('UNKNOWN_ENTITY_STATUS');
expect(msg).toContain('Project');
expect(msg).toContain('something-unknown');
expect(msg).toContain('pending');
expect(meta).toMatchObject({ entityName: 'Project', receivedStatus: 'something-unknown' });
});
it('без allowedStatuses пишет "не указано"', () => {
const logger = makeLoggerStub();
auditUnknownStatus('Other', 'x', logger);
expect(logger.error.mock.calls[0][0]).toContain('не указано');
});
});
describe('Story 6.5: processDelta — silent loss заменён audit + опциональный throw', () => {
const delta = {
contract: 'capital',
table: 'projects',
primary_key: 'abc',
block_num: 100,
present: true,
value: {},
} as any;
beforeEach(() => {
strictMode = false;
});
it('non-strict: mapper вернул null → logger.error("UNSUPPORTED_CONTRACT_VERSION", ...) + return null', async () => {
const logger = makeLoggerStub();
const repo: any = {};
const service = new TestSyncService(repo, makeMapperStub(false), logger);
const result = await service.processDelta(delta);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalled();
expect(logger.error.mock.calls[0][0]).toContain('UNSUPPORTED_CONTRACT_VERSION');
});
it('strict: mapper вернул null → throw UnsupportedContractVersionError (парсер не ACK\'нет)', async () => {
strictMode = true;
const logger = makeLoggerStub();
const repo: any = {};
const service = new TestSyncService(repo, makeMapperStub(false), logger);
await expect(service.processDelta(delta)).rejects.toBeInstanceOf(UnsupportedContractVersionError);
expect(logger.error).toHaveBeenCalled();
});
it('mapper вернул данные → happy-path продолжается (handleSyncDelta)', async () => {
const logger = makeLoggerStub();
const repo: any = {
findBySyncKey: jest.fn(async () => null),
createIfNotExists: jest.fn(async () => undefined),
};
const service = new TestSyncService(repo, makeMapperStub(true), logger);
const result = await service.processDelta(delta);
expect(result).not.toBeNull();
expect(repo.createIfNotExists).toHaveBeenCalled();
});
});