fix(native-delta): декодировать нативные строки ABI, а не JSON.parse
deserializeNativeDelta делал JSON.parse(rowRaw), но rowRaw нативных таблиц —
это ABI-сериализованные байты state_history, а не JSON. Каждая native-строка
всегда падала на десериализации, а BlockProcessor глотал ошибку в catch {} →
поток native-delta событий был всегда пустой (permission/account/resource_*).
Фикс:
- WharfkitDeserializer.deserializeNativeDelta декодит через Serializer.decode
с ship-ABI (type = имя таблицы, это variant *_v0) и распаковывает variant
[typeName, row] → плоскую строку.
- Deserializer/streamNativeDeltas/ShipClient прокидывают ship-ABI до метода
(ShipClient.abi getter); адаптер parser2 передаёт this.client.abi.
- BlockProcessor больше не глушит ошибку молча — warn с block_num/table/err.
- Тесты native-rows переписаны под ABI-байты + регресс-гард «JSON теперь бросает».
Проверено на live mono-ai-1: 30/30 native-дельт декодятся (было 0/90).
ship-reader 0.2.0→0.3.0 (breaking: +abi в Deserializer/streamNativeDeltas),
parser2 1.1.0→1.1.1 (багфикс, публичный API не менялся).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/parser2",
|
||||
"version": "1.1.0",
|
||||
"version": "1.1.1",
|
||||
"description": "Universal EOSIO/Antelope SHiP-to-Redis blockchain indexer (parser)",
|
||||
"license": "MIT",
|
||||
"author": "Coopenomics contributors",
|
||||
|
||||
@@ -86,6 +86,6 @@ export class ShipReaderAdapter implements ChainClient {
|
||||
* содержит hardcoded-схемы нативных таблиц (permission, account, …).
|
||||
*/
|
||||
deserializeNativeDelta(delta: ShipDelta): ShipNativeDeltaEvent {
|
||||
return this.client.deserializer.deserializeNativeDelta(delta)
|
||||
return this.client.deserializer.deserializeNativeDelta(delta, this.client.abi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { computeEventId } from '../events/eventId.js'
|
||||
import type { AbiBootstrapper } from '../abi/AbiBootstrapper.js'
|
||||
import type { AbiStore } from '../abi/AbiStore.js'
|
||||
import type { ChainClient } from '../ports/ChainClient.js'
|
||||
import { rootLogger, type Logger } from '../logger.js'
|
||||
|
||||
interface BlockProcessorOptions {
|
||||
/** Идентификатор цепи — проставляется в каждое событие. */
|
||||
@@ -68,6 +69,7 @@ export class BlockProcessor {
|
||||
private abiBootstrapper: AbiBootstrapper
|
||||
private abiStore: AbiStore
|
||||
private chainClient: ChainClient
|
||||
private log: Logger
|
||||
|
||||
constructor(opts: BlockProcessorOptions) {
|
||||
this.chainId = opts.chainId
|
||||
@@ -75,6 +77,7 @@ export class BlockProcessor {
|
||||
this.abiBootstrapper = opts.abiBootstrapper
|
||||
this.abiStore = opts.abiStore
|
||||
this.chainClient = opts.chainClient
|
||||
this.log = rootLogger.child({ component: 'BlockProcessor', chain_id: opts.chainId })
|
||||
this.queue = new PQueue({ concurrency: 1 })
|
||||
}
|
||||
|
||||
@@ -237,8 +240,13 @@ export class BlockProcessor {
|
||||
present: native.present,
|
||||
}
|
||||
nativeDeltaEvents.push({ ...partial, event_id: computeEventId(partial) })
|
||||
} catch {
|
||||
// Ошибки в отдельных нативных дельтах не должны прерывать весь блок
|
||||
} catch (err) {
|
||||
// Ошибки в отдельных нативных дельтах не должны прерывать весь блок,
|
||||
// но молча терять их нельзя — иначе регрессии десериализации невидимы.
|
||||
this.log.warn(
|
||||
{ block_num: blockNum, table: delta.name, err: err instanceof Error ? err.message : String(err) },
|
||||
'native delta deserialize failed',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@coopenomics/coopos-ship-reader",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Clean-room SHiP WebSocket client for EOSIO/Antelope blockchains",
|
||||
"license": "MIT",
|
||||
"author": "Coopenomics contributors",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ABI } from '@wharfkit/antelope'
|
||||
import type { ShipDelta } from './types/ship.js'
|
||||
import type { NativeDeltaEvent } from './native-tables/index.js'
|
||||
import { isNativeTableName } from './native-tables/index.js'
|
||||
@@ -10,9 +11,10 @@ export function filterNativeDeltas(deltas: readonly ShipDelta[]): ShipDelta[] {
|
||||
export function* streamNativeDeltas(
|
||||
deltas: readonly ShipDelta[],
|
||||
deserializer: WharfkitDeserializer,
|
||||
abi: ABI,
|
||||
): Generator<NativeDeltaEvent> {
|
||||
for (const delta of deltas) {
|
||||
if (!isNativeTableName(delta.name)) continue
|
||||
yield deserializer.deserializeNativeDelta(delta)
|
||||
yield deserializer.deserializeNativeDelta(delta, abi)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,15 @@ export class ShipClient {
|
||||
this.deserializer = new WharfkitDeserializer()
|
||||
}
|
||||
|
||||
/**
|
||||
* SHiP-ABI (state_history), доступен после connect().
|
||||
* Нужен для десериализации нативных дельт (их строки сериализованы этим ABI).
|
||||
*/
|
||||
get abi(): ShipAbi {
|
||||
if (!this.shipAbi) throw new ShipConnectionError('Call connect() first')
|
||||
return this.shipAbi
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return
|
||||
|
||||
|
||||
@@ -5,6 +5,6 @@ import type { NativeDeltaEvent } from '../native-tables/index.js'
|
||||
export interface Deserializer {
|
||||
deserializeAction<T = Record<string, unknown>>(trace: ShipTrace, abi: ABI): Action<T>
|
||||
deserializeContractRow<T = Record<string, unknown>>(delta: ShipDelta, abi: ABI): Delta<T>
|
||||
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta): NativeDeltaEvent<T>
|
||||
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta, abi: ABI): NativeDeltaEvent<T>
|
||||
readonly name: 'wharfkit'
|
||||
}
|
||||
|
||||
@@ -57,13 +57,18 @@ export class WharfkitDeserializer implements Deserializer {
|
||||
}
|
||||
}
|
||||
|
||||
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta): NativeDeltaEvent<T> {
|
||||
deserializeNativeDelta<T = Record<string, unknown>>(delta: ShipDelta, abi: ABI): NativeDeltaEvent<T> {
|
||||
if (!isNativeTableName(delta.name)) {
|
||||
throw new UnknownNativeTableError(delta.name)
|
||||
}
|
||||
const table = delta.name as NativeTableName
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(delta.rowRaw).toString('utf8')) as T
|
||||
// rowRaw нативных таблиц — это ABI-сериализованные байты state_history,
|
||||
// а НЕ JSON. Тип строки = имя таблицы: в ship-ABI это variant (*_v0).
|
||||
const decoded = Serializer.decode({ data: delta.rowRaw, type: table, abi })
|
||||
const objectified = Serializer.objectify(decoded as ABISerializable)
|
||||
// variant оформлен как [typeName, row] — распаковываем саму строку.
|
||||
const data = (Array.isArray(objectified) ? objectified[1] : objectified) as T
|
||||
const lookup_key = computeLookupKey(table, data as never)
|
||||
const present: boolean = delta.present
|
||||
return { present, table, data, lookup_key }
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ABI, Serializer } from '@wharfkit/antelope'
|
||||
import { WharfkitDeserializer } from '../../src/deserializers/WharfkitDeserializer.js'
|
||||
import { computeLookupKey } from '../../src/native-tables/index.js'
|
||||
import { isNativeTableName, NATIVE_TABLE_NAMES } from '../../src/native-tables/types.js'
|
||||
import { UnknownNativeTableError } from '../../src/errors.js'
|
||||
import { UnknownNativeTableError, DeserializationError } from '../../src/errors.js'
|
||||
import type { ShipDelta } from '../../src/types/ship.js'
|
||||
import type { NativePermissionRow, NativePermissionLinkRow } from '../../src/native-tables/types.js'
|
||||
|
||||
/**
|
||||
* Мини-ABI в стиле state_history: каждая нативная таблица — это variant (*_v0).
|
||||
* Достаточно полей, нужных computeLookupKey. Кодируем и декодируем одним ABI —
|
||||
* схема самосогласована, повторять реальный EOSIO целиком не требуется.
|
||||
*/
|
||||
function makeShipAbi(): ABI {
|
||||
return ABI.from({
|
||||
version: 'eosio::abi/1.1',
|
||||
types: [],
|
||||
structs: [
|
||||
{ name: 'permission_v0', base: '', fields: [
|
||||
{ name: 'owner', type: 'name' },
|
||||
{ name: 'name', type: 'name' },
|
||||
] },
|
||||
{ name: 'account_v0', base: '', fields: [
|
||||
{ name: 'name', type: 'name' },
|
||||
{ name: 'creation_date', type: 'string' },
|
||||
{ name: 'abi', type: 'string' },
|
||||
] },
|
||||
],
|
||||
actions: [],
|
||||
tables: [],
|
||||
variants: [
|
||||
{ name: 'permission', types: ['permission_v0'] },
|
||||
{ name: 'account', types: ['account_v0'] },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const SHIP_ABI = makeShipAbi()
|
||||
|
||||
/** Дельта с ABI-сериализованным rowRaw (как реально приходит из SHiP). */
|
||||
function abiDelta(table: string, obj: Record<string, unknown>, present = true): ShipDelta {
|
||||
const encoded = Serializer.encode({ object: [`${table}_v0`, obj], type: table, abi: SHIP_ABI })
|
||||
return { name: table, present, rowRaw: encoded.array }
|
||||
}
|
||||
|
||||
/** Дельта с JSON-байтами — раньше «работала» из-за бага JSON.parse, теперь должна падать. */
|
||||
function jsonDelta(name: string, data: unknown, present = true): ShipDelta {
|
||||
return {
|
||||
name,
|
||||
@@ -64,36 +103,38 @@ describe('computeLookupKey', () => {
|
||||
describe('WharfkitDeserializer — deserializeNativeDelta', () => {
|
||||
const deser = new WharfkitDeserializer()
|
||||
|
||||
it('deserializes permission delta with correct lookup_key', () => {
|
||||
const permData: NativePermissionRow = {
|
||||
owner: 'alice', name: 'active', parent: 'owner',
|
||||
last_updated: '2024-01-01T00:00:00.000',
|
||||
auth: { threshold: 1, keys: [], accounts: [], waits: [] },
|
||||
}
|
||||
const delta = jsonDelta('permission', permData)
|
||||
const event = deser.deserializeNativeDelta<NativePermissionRow>(delta)
|
||||
it('deserializes ABI-encoded permission delta with correct lookup_key', () => {
|
||||
const delta = abiDelta('permission', { owner: 'alice', name: 'active' })
|
||||
const event = deser.deserializeNativeDelta<NativePermissionRow>(delta, SHIP_ABI)
|
||||
expect(event.table).toBe('permission')
|
||||
expect(event.lookup_key).toBe('alice:active')
|
||||
expect(event.present).toBe(true)
|
||||
expect(event.data.owner).toBe('alice')
|
||||
expect(String(event.data.owner)).toBe('alice')
|
||||
})
|
||||
|
||||
it('unwraps the *_v0 variant into a flat row (not a [name, row] tuple)', () => {
|
||||
const delta = abiDelta('permission', { owner: 'alice', name: 'active' })
|
||||
const event = deser.deserializeNativeDelta<NativePermissionRow>(delta, SHIP_ABI)
|
||||
expect(Array.isArray(event.data)).toBe(false)
|
||||
expect(String(event.data.name)).toBe('active')
|
||||
})
|
||||
|
||||
it('present field is boolean (not string)', () => {
|
||||
const delta = jsonDelta('account', { name: 'bob', creation_date: '', abi: '' }, false)
|
||||
const event = deser.deserializeNativeDelta(delta)
|
||||
const delta = abiDelta('account', { name: 'bob', creation_date: '2024-01-01T00:00:00.000', abi: '' }, false)
|
||||
const event = deser.deserializeNativeDelta(delta, SHIP_ABI)
|
||||
expect(typeof event.present).toBe('boolean')
|
||||
expect(event.present).toBe(false)
|
||||
})
|
||||
|
||||
it('throws UnknownNativeTableError for unknown table', () => {
|
||||
const delta: ShipDelta = { name: 'my_custom_table', present: true, rowRaw: new Uint8Array([]) }
|
||||
expect(() => deser.deserializeNativeDelta(delta)).toThrow(UnknownNativeTableError)
|
||||
expect(() => deser.deserializeNativeDelta(delta, SHIP_ABI)).toThrow(UnknownNativeTableError)
|
||||
})
|
||||
|
||||
it('all NATIVE_TABLE_NAMES tables deserialize without throwing (smoke test)', () => {
|
||||
for (const table of NATIVE_TABLE_NAMES) {
|
||||
const delta = jsonDelta(table, { name: 'test' })
|
||||
expect(() => deser.deserializeNativeDelta(delta)).not.toThrow()
|
||||
}
|
||||
it('throws DeserializationError on non-ABI (JSON) bytes — no silent JSON.parse', () => {
|
||||
// Регресс-гард на исходный баг: нативные строки декодятся ABI, а не JSON.parse.
|
||||
// JSON-байты — не валидная ABI-сериализация, метод обязан бросить.
|
||||
const delta = jsonDelta('permission', { owner: 'alice', name: 'active' })
|
||||
expect(() => deser.deserializeNativeDelta(delta, SHIP_ABI)).toThrow(DeserializationError)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user