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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -29,10 +29,9 @@ export class AppendixDeltaMapper extends AbstractBlockchainDeltaMapper<IAppendix
|
||||
return null;
|
||||
}
|
||||
|
||||
// 🔥 ВАЖНО: Парсим документы ПЕРЕД возвратом
|
||||
// Парсим документы ПЕРЕД возвратом
|
||||
const appendix = DomainToBlockchainUtils.convertChainDocumentToDomainFormat(value.appendix);
|
||||
|
||||
// Парсим документы
|
||||
return { ...value, appendix };
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error mapping delta to blockchain data: ${error.message}`, error.stack);
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
+133
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user