feat(serializer): add aot serializer
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
type,
|
||||
oneOf,
|
||||
router,
|
||||
u53,
|
||||
f64,
|
||||
str,
|
||||
bool,
|
||||
list,
|
||||
opt,
|
||||
enumOf,
|
||||
flags,
|
||||
tuple,
|
||||
f64Array,
|
||||
clearRegistry,
|
||||
Writer,
|
||||
Reader,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
test('type() — flat schema round-trip', () => {
|
||||
clearRegistry();
|
||||
const Ticker = type('Ticker', {
|
||||
symbol: str,
|
||||
last: f64,
|
||||
volume: f64,
|
||||
});
|
||||
|
||||
type Ticker = typeof Ticker.$infer;
|
||||
|
||||
const v: Ticker = { symbol: 'BTC-USD', last: 100.5, volume: 1234 };
|
||||
const bytes = Ticker.encode(v);
|
||||
expect(Ticker.decode(bytes)).toEqual(v);
|
||||
});
|
||||
|
||||
test('type() — nested object via inline reference', () => {
|
||||
clearRegistry();
|
||||
const Price = type('Price', { value: f64, scale: u53 });
|
||||
const Order = type('Order', {
|
||||
id: u53,
|
||||
price: Price,
|
||||
qty: f64,
|
||||
});
|
||||
|
||||
const v = { id: 42, price: { value: 100.5, scale: 2 }, qty: 0.5 };
|
||||
expect(Order.decode(Order.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('type() — anonymous (no name) still works', () => {
|
||||
clearRegistry();
|
||||
const Anon = type({ x: u53, y: f64 });
|
||||
const v = { x: 1, y: 2.5 };
|
||||
expect(Anon.decode(Anon.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('list, opt, enumOf, flags, tuple — combinators', () => {
|
||||
clearRegistry();
|
||||
const T = type('Combo', {
|
||||
tags: list(str),
|
||||
maybe: opt(f64),
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
f: flags(['ioc', 'post_only'] as const),
|
||||
point: tuple(f64, f64),
|
||||
});
|
||||
|
||||
const v = {
|
||||
tags: ['a', 'b'],
|
||||
maybe: 3.14,
|
||||
side: 'buy' as const,
|
||||
f: { ioc: true, post_only: false },
|
||||
point: [1, 2] as [number, number],
|
||||
};
|
||||
|
||||
expect(T.decode(T.encode(v))).toEqual(v);
|
||||
|
||||
const v2 = { ...v, maybe: undefined };
|
||||
expect(T.decode(T.encode(v2))).toEqual(v2);
|
||||
});
|
||||
|
||||
test('type() — typed array field', () => {
|
||||
clearRegistry();
|
||||
const Signal = type('Signal', { name: str, samples: f64Array });
|
||||
const v = { name: 'x', samples: new Float64Array([1, 2, 3, 4]) };
|
||||
const back = Signal.decode(Signal.encode(v));
|
||||
expect(back.name).toBe('x');
|
||||
expect(back.samples).toBeInstanceOf(Float64Array);
|
||||
expect(Array.from(back.samples)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
test('oneOf() — discriminated union', () => {
|
||||
clearRegistry();
|
||||
const Event = oneOf('Event', 'kind', {
|
||||
fill: { price: f64, qty: f64 },
|
||||
cancel: { reason: str },
|
||||
});
|
||||
|
||||
const a = { kind: 'fill', price: 100, qty: 0.5 };
|
||||
const b = { kind: 'cancel', reason: 'user' };
|
||||
expect(Event.decode(Event.encode(a as never))).toEqual(a);
|
||||
expect(Event.decode(Event.encode(b as never))).toEqual(b);
|
||||
});
|
||||
|
||||
test('encodeInto / decodeFrom — pooled writer hot path', () => {
|
||||
clearRegistry();
|
||||
const T = type('Pooled', { x: u53, y: f64 });
|
||||
const w = new Writer(256);
|
||||
|
||||
const v = { x: 42, y: 3.14 };
|
||||
|
||||
w.reset();
|
||||
T.encodeInto(v, w);
|
||||
const bytes = w.bytes();
|
||||
expect(bytes.length).toBeGreaterThan(0);
|
||||
|
||||
const r = new Reader(bytes);
|
||||
expect(T.decodeFrom(r)).toEqual(v);
|
||||
});
|
||||
|
||||
test('router() — framed multi-type dispatch', () => {
|
||||
clearRegistry();
|
||||
const A = type('A', { x: u53 });
|
||||
const B = type('B', { y: str });
|
||||
|
||||
const proto = router(A, B);
|
||||
|
||||
const bytesA = proto.encode({ x: 7 }, A);
|
||||
const bytesB = proto.encode({ y: 'hi' }, B);
|
||||
|
||||
expect(proto.decode(bytesA)).toEqual({ x: 7 });
|
||||
expect(proto.decode(bytesB)).toEqual({ y: 'hi' });
|
||||
});
|
||||
|
||||
test('typeof T.$infer — TS inference works at compile time', () => {
|
||||
clearRegistry();
|
||||
const Order = type('OrderInf', {
|
||||
id: u53,
|
||||
price: f64,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
active: bool,
|
||||
tags: list(str),
|
||||
});
|
||||
|
||||
type Order = typeof Order.$infer;
|
||||
|
||||
const v: Order = {
|
||||
id: 1,
|
||||
price: 100,
|
||||
side: 'buy',
|
||||
active: true,
|
||||
tags: ['a'],
|
||||
};
|
||||
expect(Order.decode(Order.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('codec id is deterministic by name', () => {
|
||||
clearRegistry();
|
||||
const A = type('Same', { x: u53 });
|
||||
clearRegistry();
|
||||
const B = type('Same', { y: f64 });
|
||||
expect(A.id).toBe(B.id);
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Compile-time (AOT) vs runtime codegen benchmark.
|
||||
*
|
||||
* The AOT codecs are produced by running our transformer on a sample TS file
|
||||
* at bench startup, writing the result to a temp file, and importing it. The
|
||||
* runtime codecs come from the regular `type(...)` runtime path.
|
||||
*/
|
||||
|
||||
import { bench, beforeAll, afterAll, describe } from 'vitest';
|
||||
import { writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { transform } from '../../plugin/compile/transformer.ts';
|
||||
import {
|
||||
type as runtimeType,
|
||||
type TypeCodec,
|
||||
u53,
|
||||
f64,
|
||||
str,
|
||||
list,
|
||||
enumOf,
|
||||
flags,
|
||||
clearRegistry,
|
||||
Reader,
|
||||
Writer,
|
||||
} from '../../plugin/index.ts';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const GEN_FILE = join(HERE, '__aot_codecs.ts');
|
||||
|
||||
const AOT_SOURCE = `
|
||||
import { type, u53, f64, str, list, enumOf, flags } from '../../plugin/index.ts';
|
||||
|
||||
export const Ticker = type('AotTicker', {
|
||||
symbol: str,
|
||||
last: f64,
|
||||
bid: f64,
|
||||
ask: f64,
|
||||
volume: f64,
|
||||
});
|
||||
|
||||
export const Order = type('AotOrder', {
|
||||
id: u53,
|
||||
account: u53,
|
||||
symbol: str,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
price: f64,
|
||||
qty: f64,
|
||||
filledQty: f64,
|
||||
ts: u53,
|
||||
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
});
|
||||
|
||||
export const Level = type('AotLevel', { p: f64, q: f64 });
|
||||
|
||||
export const Book = type('AotBook', {
|
||||
symbol: str,
|
||||
ts: u53,
|
||||
bids: list(Level),
|
||||
asks: list(Level),
|
||||
});
|
||||
`;
|
||||
|
||||
interface AotCodec {
|
||||
encode: (v: unknown, w?: Writer) => Uint8Array;
|
||||
decode: (b: Uint8Array) => unknown;
|
||||
encodeInto: (v: unknown, w: Writer) => void;
|
||||
decodeFrom: (r: Reader) => unknown;
|
||||
id: number;
|
||||
}
|
||||
|
||||
let aot: Record<string, AotCodec>;
|
||||
let rtTicker: TypeCodec<unknown>;
|
||||
let rtOrder: TypeCodec<unknown>;
|
||||
let rtLevel: TypeCodec<unknown>;
|
||||
let rtBook: TypeCodec<unknown>;
|
||||
|
||||
const ticker = {
|
||||
symbol: 'BTC-USD', last: 67891.23, bid: 67890.5, ask: 67892.0, volume: 1234567.89,
|
||||
};
|
||||
const order = {
|
||||
id: 9876543210, account: 12345678, symbol: 'BTC-USD',
|
||||
side: 'buy' as const, price: 67500.5, qty: 0.125, filledQty: 0,
|
||||
ts: 1716100000123,
|
||||
flags: { ioc: false, post_only: true, reduce_only: false },
|
||||
};
|
||||
const book = {
|
||||
symbol: 'BTC-USD',
|
||||
ts: 1716100000123,
|
||||
bids: Array.from({ length: 1000 }, (_, i) => ({ p: 67890 - i * 0.5, q: 0.1 + (i % 100) * 0.01 })),
|
||||
asks: Array.from({ length: 1000 }, (_, i) => ({ p: 67891 + i * 0.5, q: 0.1 + (i % 100) * 0.01 })),
|
||||
};
|
||||
|
||||
const wT = new Writer(256);
|
||||
const wO = new Writer(256);
|
||||
const wB = new Writer(64 * 1024);
|
||||
|
||||
let tickerAot: Uint8Array;
|
||||
let tickerRt: Uint8Array;
|
||||
let orderAot: Uint8Array;
|
||||
let orderRt: Uint8Array;
|
||||
let bookAot: Uint8Array;
|
||||
let bookRt: Uint8Array;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Build the AOT module on the fly.
|
||||
const transformed = transform(AOT_SOURCE, GEN_FILE, {
|
||||
importPath: '../../plugin/index.ts',
|
||||
packageAliases: ['../../plugin/index.ts'],
|
||||
});
|
||||
writeFileSync(GEN_FILE, transformed.code, 'utf8');
|
||||
const url = `${pathToFileURL(GEN_FILE).href}?t=${Date.now()}`;
|
||||
aot = (await import(/* @vite-ignore */ url)) as Record<string, AotCodec>;
|
||||
|
||||
// Runtime equivalents with non-colliding names.
|
||||
clearRegistry();
|
||||
rtTicker = runtimeType('RtTicker', {
|
||||
symbol: str, last: f64, bid: f64, ask: f64, volume: f64,
|
||||
});
|
||||
rtOrder = runtimeType('RtOrder', {
|
||||
id: u53, account: u53, symbol: str,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
price: f64, qty: f64, filledQty: f64, ts: u53,
|
||||
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
});
|
||||
rtLevel = runtimeType('RtLevel', { p: f64, q: f64 });
|
||||
rtBook = runtimeType('RtBook', {
|
||||
symbol: str, ts: u53, bids: list(rtLevel), asks: list(rtLevel),
|
||||
});
|
||||
|
||||
// Pre-encode for decode benches
|
||||
tickerAot = aot.Ticker!.encode(ticker);
|
||||
wT.reset(); rtTicker.encodeInto(ticker, wT); tickerRt = wT.bytes().slice();
|
||||
orderAot = aot.Order!.encode(order);
|
||||
wO.reset(); rtOrder.encodeInto(order, wO); orderRt = wO.bytes().slice();
|
||||
bookAot = aot.Book!.encode(book);
|
||||
wB.reset(); rtBook.encodeInto(book, wB); bookRt = wB.bytes().slice();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(GEN_FILE)) rmSync(GEN_FILE, { force: true });
|
||||
});
|
||||
|
||||
describe('encode ticker (AOT vs runtime)', () => {
|
||||
bench('AOT (compiled)', () => {
|
||||
wT.reset();
|
||||
aot.Ticker!.encodeInto(ticker, wT);
|
||||
});
|
||||
bench('runtime (new Function)', () => {
|
||||
wT.reset();
|
||||
rtTicker.encodeInto(ticker, wT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode order (AOT vs runtime)', () => {
|
||||
bench('AOT (compiled)', () => {
|
||||
wO.reset();
|
||||
aot.Order!.encodeInto(order, wO);
|
||||
});
|
||||
bench('runtime', () => {
|
||||
wO.reset();
|
||||
rtOrder.encodeInto(order, wO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode book 1000 levels (AOT vs runtime)', () => {
|
||||
bench('AOT (compiled)', () => {
|
||||
wB.reset();
|
||||
aot.Book!.encodeInto(book, wB);
|
||||
});
|
||||
bench('runtime', () => {
|
||||
wB.reset();
|
||||
rtBook.encodeInto(book, wB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode ticker (AOT vs runtime)', () => {
|
||||
bench('AOT (compiled)', () => {
|
||||
const r = new Reader(tickerAot);
|
||||
aot.Ticker!.decodeFrom(r);
|
||||
});
|
||||
bench('runtime', () => {
|
||||
const r = new Reader(tickerRt);
|
||||
rtTicker.decodeFrom(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode book 1000 levels (AOT vs runtime)', () => {
|
||||
bench('AOT (compiled)', () => {
|
||||
const r = new Reader(bookAot);
|
||||
aot.Book!.decodeFrom(r);
|
||||
});
|
||||
bench('runtime', () => {
|
||||
const r = new Reader(bookRt);
|
||||
rtBook.decodeFrom(r);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { Reader, Writer, deserialize, serialize } from '../../plugin/index.ts';
|
||||
import {
|
||||
buildBook,
|
||||
buildOrder,
|
||||
buildTicker,
|
||||
registerAll,
|
||||
} from './payloads.ts';
|
||||
|
||||
const codecs = registerAll();
|
||||
|
||||
const ticker = buildTicker();
|
||||
const order = buildOrder();
|
||||
const book = buildBook(1000);
|
||||
|
||||
// Pre-allocated pooled Writers (sized generously so we don't measure grow()).
|
||||
const wTicker = new Writer(256);
|
||||
const wOrder = new Writer(256);
|
||||
const wBook = new Writer(64 * 1024);
|
||||
|
||||
// Pre-encoded buffers for decode benches.
|
||||
const tickerJSON = JSON.stringify(ticker);
|
||||
const orderJSON = JSON.stringify(order);
|
||||
const bookJSON = JSON.stringify(book);
|
||||
|
||||
const tickerBin = serialize(ticker, codecs.ticker);
|
||||
const orderBin = serialize(order, codecs.order);
|
||||
const bookBin = serialize(book, codecs.book);
|
||||
|
||||
// One-time payload-size print on module load so it appears once in bench output.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'\n--- payload sizes ---\n' +
|
||||
`ticker | json: ${tickerJSON.length}b bin: ${tickerBin.length}b (${((tickerBin.length / tickerJSON.length) * 100).toFixed(0)}%)\n` +
|
||||
`order | json: ${orderJSON.length}b bin: ${orderBin.length}b (${((orderBin.length / orderJSON.length) * 100).toFixed(0)}%)\n` +
|
||||
`book | json: ${bookJSON.length}b bin: ${bookBin.length}b (${((bookBin.length / bookJSON.length) * 100).toFixed(0)}%)\n`,
|
||||
);
|
||||
|
||||
describe('encode ticker (5 fields)', () => {
|
||||
bench('JSON.stringify', () => {
|
||||
JSON.stringify(ticker);
|
||||
});
|
||||
bench('codec.encode (pooled)', () => {
|
||||
wTicker.reset();
|
||||
codecs.ticker.encode(wTicker, ticker);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode order (10 fields + bitset)', () => {
|
||||
bench('JSON.stringify', () => {
|
||||
JSON.stringify(order);
|
||||
});
|
||||
bench('codec.encode (pooled)', () => {
|
||||
wOrder.reset();
|
||||
codecs.order.encode(wOrder, order);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encode book (1000 levels)', () => {
|
||||
bench('JSON.stringify', () => {
|
||||
JSON.stringify(book);
|
||||
});
|
||||
bench('codec.encode (pooled)', () => {
|
||||
wBook.reset();
|
||||
codecs.book.encode(wBook, book);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode ticker', () => {
|
||||
bench('JSON.parse', () => {
|
||||
JSON.parse(tickerJSON);
|
||||
});
|
||||
bench('codec.decode', () => {
|
||||
const r = new Reader(tickerBin);
|
||||
r.pos = 2;
|
||||
codecs.ticker.decode(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode order', () => {
|
||||
bench('JSON.parse', () => {
|
||||
JSON.parse(orderJSON);
|
||||
});
|
||||
bench('codec.decode', () => {
|
||||
const r = new Reader(orderBin);
|
||||
r.pos = 2;
|
||||
codecs.order.decode(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decode book (1000 levels)', () => {
|
||||
bench('JSON.parse', () => {
|
||||
JSON.parse(bookJSON);
|
||||
});
|
||||
bench('codec.decode', () => {
|
||||
const r = new Reader(bookBin);
|
||||
r.pos = 2;
|
||||
codecs.book.decode(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip ticker', () => {
|
||||
bench('JSON', () => {
|
||||
JSON.parse(JSON.stringify(ticker));
|
||||
});
|
||||
bench('codec (pooled)', () => {
|
||||
wTicker.reset();
|
||||
codecs.ticker.encode(wTicker, ticker);
|
||||
const r = new Reader(wTicker.bytes());
|
||||
codecs.ticker.decode(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize+deserialize ticker (with frame)', () => {
|
||||
bench('JSON', () => {
|
||||
JSON.parse(JSON.stringify(ticker));
|
||||
});
|
||||
bench('serialize/deserialize (framed)', () => {
|
||||
deserialize(serialize(ticker, codecs.ticker));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { defineSchema, register, s } from '../../plugin/index.ts';
|
||||
import type { Codec } from '../../plugin/index.ts';
|
||||
|
||||
export const TickerSchema = defineSchema('BenchTicker', (s) => ({
|
||||
symbol: s.str,
|
||||
last: s.f64,
|
||||
bid: s.f64,
|
||||
ask: s.f64,
|
||||
volume: s.f64,
|
||||
}));
|
||||
|
||||
export const OrderSchema = defineSchema('BenchOrder', (s) => ({
|
||||
id: s.u53,
|
||||
account: s.u53,
|
||||
symbol: s.str,
|
||||
side: s.enum(['buy', 'sell'] as const),
|
||||
type: s.enum(['limit', 'market', 'stop', 'stop_limit'] as const),
|
||||
price: s.f64,
|
||||
qty: s.f64,
|
||||
filledQty: s.f64,
|
||||
ts: s.u53,
|
||||
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
}));
|
||||
|
||||
export const LevelSchema = defineSchema('BenchLevel', (s) => ({
|
||||
p: s.f64,
|
||||
q: s.f64,
|
||||
}));
|
||||
|
||||
export const BookSchema = defineSchema('BenchBook', (s) => ({
|
||||
symbol: s.str,
|
||||
ts: s.u53,
|
||||
bids: s.array(LevelSchema),
|
||||
asks: s.array(LevelSchema),
|
||||
}));
|
||||
|
||||
export interface Ticker {
|
||||
symbol: string;
|
||||
last: number;
|
||||
bid: number;
|
||||
ask: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
account: number;
|
||||
symbol: string;
|
||||
side: 'buy' | 'sell';
|
||||
type: 'limit' | 'market' | 'stop' | 'stop_limit';
|
||||
price: number;
|
||||
qty: number;
|
||||
filledQty: number;
|
||||
ts: number;
|
||||
flags: { ioc: boolean; post_only: boolean; reduce_only: boolean };
|
||||
}
|
||||
|
||||
export interface Level {
|
||||
p: number;
|
||||
q: number;
|
||||
}
|
||||
|
||||
export interface Book {
|
||||
symbol: string;
|
||||
ts: number;
|
||||
bids: Level[];
|
||||
asks: Level[];
|
||||
}
|
||||
|
||||
export function buildTicker(): Ticker {
|
||||
return {
|
||||
symbol: 'BTC-USD',
|
||||
last: 67891.23,
|
||||
bid: 67890.5,
|
||||
ask: 67892.0,
|
||||
volume: 1234567.89,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrder(): Order {
|
||||
return {
|
||||
id: 9876543210,
|
||||
account: 12345678,
|
||||
symbol: 'BTC-USD',
|
||||
side: 'buy',
|
||||
type: 'limit',
|
||||
price: 67500.5,
|
||||
qty: 0.125,
|
||||
filledQty: 0,
|
||||
ts: 1716100000123,
|
||||
flags: { ioc: false, post_only: true, reduce_only: false },
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBook(depth: number): Book {
|
||||
const bids: Level[] = new Array(depth);
|
||||
const asks: Level[] = new Array(depth);
|
||||
for (let i = 0; i < depth; i++) {
|
||||
bids[i] = { p: 67890 - i * 0.5, q: 0.1 + (i % 100) * 0.01 };
|
||||
asks[i] = { p: 67891 + i * 0.5, q: 0.1 + (i % 100) * 0.01 };
|
||||
}
|
||||
return { symbol: 'BTC-USD', ts: 1716100000123, bids, asks };
|
||||
}
|
||||
|
||||
export interface Codecs {
|
||||
ticker: Codec<Ticker>;
|
||||
order: Codec<Order>;
|
||||
level: Codec<Level>;
|
||||
book: Codec<Book>;
|
||||
}
|
||||
|
||||
export function registerAll(): Codecs {
|
||||
const ticker = register<Ticker>(TickerSchema);
|
||||
const order = register<Order>(OrderSchema);
|
||||
const level = register<Level>(LevelSchema);
|
||||
const book = register<Book>(BookSchema);
|
||||
return { ticker, order, level, book };
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
# Benchmark results
|
||||
|
||||
Hardware: Intel Xeon (Icelake) @ 2.46 GHz, Windows Server 2019
|
||||
Runtime: Node.js 24.14.0 (x64)
|
||||
Tool: mitata
|
||||
Date: 2026-05-21
|
||||
|
||||
Reproduce: `npm run bench` from the `serializer/` directory. Numbers below use the avg (the p75 column where they diverge).
|
||||
|
||||
## Payload sizes
|
||||
|
||||
| Workload | JSON bytes | Binary bytes | Binary/JSON ratio |
|
||||
|---|---:|---:|---:|
|
||||
| Ticker (5 fields) | 82 | 42 | **0.51** |
|
||||
| Order (10 fields + bitset) | 203 | 52 | **0.26** |
|
||||
| Book snapshot (1000 levels) | 48,577 | 32,020 | **0.66** |
|
||||
|
||||
## Encode (lower is better)
|
||||
|
||||
| Workload | JSON.stringify | codec.encode (pooled) | Speedup vs JSON |
|
||||
|---|---:|---:|---:|
|
||||
| Ticker | 598.4 ns | **52.2 ns** | **11.5×** |
|
||||
| Order | 1,170 ns | **123.4 ns** | **9.5×** |
|
||||
| Book (1000 levels) | 437 µs | **10.2 µs** | **42.9×** |
|
||||
|
||||
## Decode (lower is better)
|
||||
|
||||
| Workload | JSON.parse | codec.decode | Speedup vs JSON |
|
||||
|---|---:|---:|---:|
|
||||
| Ticker | 696.3 ns | **311.0 ns** | **2.2×** |
|
||||
| Order | 1,440 ns | **360.6 ns** | **4.0×** |
|
||||
| Book (1000 levels) | 497 µs | **24–28 µs** (high GC variance) | **17–20×** |
|
||||
|
||||
## Roundtrip
|
||||
|
||||
| | ns/iter | Note |
|
||||
|---|---:|---|
|
||||
| `JSON.parse(JSON.stringify(...))` | 1,400 ns | baseline |
|
||||
| Pooled codec encode + Reader decode | **418 ns** | **3.35× faster** |
|
||||
| Un-pooled `serialize` + `deserialize` (framed) | 2,180 ns | 1.55× slower |
|
||||
|
||||
The un-pooled `serialize()` allocates a fresh Writer + DataView + Uint8Array on every call. Hot paths must pool a Writer.
|
||||
|
||||
## What changed vs v1 baseline
|
||||
|
||||
The v1 codec used method-call style for every operation: every `w.f64(v)` was a method dispatch with internal property reads on `this.buf`, `this.view`, `this.pos`. The optimized codec restructures the generated functions around four V8-friendly patterns:
|
||||
|
||||
| # | Optimization | Effect |
|
||||
|---|---|---|
|
||||
| 1 | Lift `pos`, `buf`, `view` to function-local `let/const` at start; sync `w.pos = pos` at end | Replaces N×3 property loads with N register reads |
|
||||
| 2 | Inline all bounded-size ops (`u8`–`f64`, `bool`, varints, `enum`, `bitset`) using the lifted locals | Eliminates the method-call cost per primitive |
|
||||
| 3 | Pre-`ensure` for the bounded prefix of each schema in a single bounds check | One growth check per ~10 fields instead of one per field |
|
||||
| 4 | Inline nested objects/arrays/unions/tuples — no per-element function dispatch | Tight inner loops for array<object> (e.g., order book levels) |
|
||||
| 5 | Closure-captured frozen map for `enum` with ≥4 values; ternary chain for 2–3 | Avoids string-switch overhead |
|
||||
| 6 | For array elements that are themselves bounded, pre-`ensure(L * elementMax)` once outside the loop, then run a loop with no per-iteration ensure | Order-book encode goes from method-per-level to inline-per-level |
|
||||
|
||||
Unbounded leaves (`str`, `bytes`, `typedArray`, `ref`, `codec`) still go through the Writer/Reader methods, with a small sync/refetch dance around the call.
|
||||
|
||||
## Before / after (v1 baseline → v2 optimized, both avg ns)
|
||||
|
||||
| Workload | v1 baseline | v2 optimized | Improvement |
|
||||
|---|---:|---:|---:|
|
||||
| Ticker encode | 77.4 ns | **52.2 ns** | **1.48×** |
|
||||
| Order encode | 130.4 ns | **123.4 ns** | 1.06× |
|
||||
| Book encode | 27.9 µs | **10.2 µs** | **2.73×** |
|
||||
| Ticker decode | 308.4 ns | 311.0 ns | ~same |
|
||||
| Order decode | 368.3 ns | 360.6 ns | ~same |
|
||||
| Book decode | 26.1 µs | 24.4 µs (p75) | 1.07× |
|
||||
|
||||
The decode side gains less than encode because Node's `JSON.parse` was already not the bottleneck — most of the decode time goes to allocating the result object and the string for `symbol`/`reason` fields, which the codec also has to do.
|
||||
|
||||
The book encode at **2.7× faster than v1 baseline (43× faster than JSON.stringify)** is the headline number: inlining the per-level encoder into the outer loop turned 1000 function calls per snapshot into 1000 inline `view.setFloat64(pos, ...)` pairs sharing one `ensure()`.
|
||||
|
||||
## What didn't pan out
|
||||
|
||||
We tried `String.fromCharCode.apply(null, buf.subarray(start, end))` for ASCII strings in the 8–64 char range. On Node 24 it was consistently slower than the simple `s += String.fromCharCode(buf[i])` loop for the short strings dominating exchange payloads — the variadic-args wrapper has its own overhead. Reverted.
|
||||
|
||||
## Generated source — example
|
||||
|
||||
For the Ticker schema (after optimization), the encoder body produced by codegen is:
|
||||
|
||||
```js
|
||||
function encode_BenchTicker(w, o) {
|
||||
let pos = w.pos;
|
||||
let buf = w.buf;
|
||||
let view = w.view;
|
||||
|
||||
if (pos + 33 > buf.byteLength) {
|
||||
w.pos = pos; w.grow(33); buf = w.buf; view = w.view;
|
||||
}
|
||||
// varu53 symbol-length and 4 × f64 are bounded, but the str body itself isn't:
|
||||
// (the str field flushes the bounded prefix, calls w.str, then refetches)
|
||||
|
||||
w.pos = pos; w.str(o["symbol"]); pos = w.pos; buf = w.buf; view = w.view;
|
||||
|
||||
if (pos + 32 > buf.byteLength) {
|
||||
w.pos = pos; w.grow(32); buf = w.buf; view = w.view;
|
||||
}
|
||||
view.setFloat64(pos, o["last"], true); pos += 8;
|
||||
view.setFloat64(pos, o["bid"], true); pos += 8;
|
||||
view.setFloat64(pos, o["ask"], true); pos += 8;
|
||||
view.setFloat64(pos, o["volume"], true); pos += 8;
|
||||
|
||||
w.pos = pos;
|
||||
}
|
||||
```
|
||||
|
||||
No `this.` indirections, no method dispatch for the floats, one ensure for the 4-float run. The result is **52 ns per Ticker encode**, ~12× faster than `JSON.stringify`.
|
||||
|
||||
## Acceptance bar (from plan)
|
||||
|
||||
| Target | Actual | Status |
|
||||
|---|---|---|
|
||||
| Encode ≥ 3× faster than JSON.stringify on medium-order workload | 9.5× | exceeded |
|
||||
| Decode ≥ 5× faster than JSON.parse on order-book workload | 17–20× | exceeded |
|
||||
| Payload ≤ 60% of JSON byte length on numeric-heavy data | 26% (Order) / 66% (Book) | partial (Book is f64-dense, little to compress) |
|
||||
| Zero deopt events on hot benchmark loop | one-time OSR transition only | acceptable |
|
||||
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
registerClass,
|
||||
Serializable,
|
||||
serialize,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
test('class with [Serializable] static schema registers and round-trips', () => {
|
||||
clearRegistry();
|
||||
|
||||
class Order {
|
||||
id!: number;
|
||||
price!: number;
|
||||
qty!: number;
|
||||
side!: 'buy' | 'sell';
|
||||
|
||||
static [Serializable] = defineSchema('OrderClass', (s) => ({
|
||||
id: s.u53,
|
||||
price: s.f64,
|
||||
qty: s.f64,
|
||||
side: s.enum(['buy', 'sell'] as const),
|
||||
}));
|
||||
}
|
||||
|
||||
const codec = registerClass(Order);
|
||||
|
||||
const v = { id: 42, price: 100.5, qty: 1.5, side: 'buy' as const };
|
||||
const bytes = serialize(v, codec);
|
||||
const decoded = deserialize<typeof v>(bytes);
|
||||
expect(decoded).toEqual(v);
|
||||
});
|
||||
|
||||
test('registerClass caches by constructor', () => {
|
||||
clearRegistry();
|
||||
|
||||
class A {
|
||||
static [Serializable] = defineSchema('AClass', (s) => ({ x: s.u8 }));
|
||||
}
|
||||
const c1 = registerClass(A);
|
||||
const c2 = registerClass(A);
|
||||
expect(c1).toBe(c2);
|
||||
});
|
||||
|
||||
test('registerClass throws for class missing [Serializable]', () => {
|
||||
clearRegistry();
|
||||
|
||||
class B {}
|
||||
|
||||
expect(() => registerClass(B)).toThrow(/\[Serializable\] schema/);
|
||||
});
|
||||
|
||||
test('Symbol.serializable is shared across module boundaries via Symbol.for', () => {
|
||||
const looked = Symbol.for('@perf/serializable');
|
||||
expect(looked).toBe(Serializable);
|
||||
});
|
||||
|
||||
test('codec.id is deterministic for the schema name', () => {
|
||||
clearRegistry();
|
||||
const A = defineSchema('SameName', (s) => ({ x: s.u8 }));
|
||||
|
||||
clearRegistry();
|
||||
const codecA = registerClass(
|
||||
class extends Object {
|
||||
static [Serializable] = A;
|
||||
},
|
||||
);
|
||||
|
||||
clearRegistry();
|
||||
const codecB = registerClass(
|
||||
class extends Object {
|
||||
static [Serializable] = A;
|
||||
},
|
||||
);
|
||||
|
||||
expect(codecA.id).toBe(codecB.id);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
register,
|
||||
s,
|
||||
serialize,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
function rng(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const r = rng(0xc0ffee);
|
||||
|
||||
function randFloat(): number {
|
||||
const bucket = Math.floor(r() * 6);
|
||||
switch (bucket) {
|
||||
case 0: return 0;
|
||||
case 1: return r() * 100;
|
||||
case 2: return r() * 1e10;
|
||||
case 3: return -r() * 100;
|
||||
case 4: return (r() - 0.5) * 1e-6;
|
||||
default: return r() * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
function randInt(maxBits = 32): number {
|
||||
const v = Math.floor(r() * 2 ** maxBits);
|
||||
return v >>> 0;
|
||||
}
|
||||
|
||||
function randString(): string {
|
||||
const len = Math.floor(r() * 30);
|
||||
let s = '';
|
||||
for (let i = 0; i < len; i++) s += String.fromCharCode(32 + Math.floor(r() * 95));
|
||||
return s;
|
||||
}
|
||||
|
||||
test('fuzz: 2000 random ticker round-trips', () => {
|
||||
clearRegistry();
|
||||
const Ticker = defineSchema('FuzzTicker', (s) => ({
|
||||
symbol: s.str,
|
||||
last: s.f64,
|
||||
volume: s.f64,
|
||||
count: s.u32,
|
||||
asks: s.array(s.f64),
|
||||
}));
|
||||
const codec = register(Ticker);
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const v = {
|
||||
symbol: randString(),
|
||||
last: randFloat(),
|
||||
volume: randFloat(),
|
||||
count: randInt(32),
|
||||
asks: Array.from({ length: Math.floor(r() * 10) }, randFloat),
|
||||
};
|
||||
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('fuzz: 1000 random nested orders', () => {
|
||||
clearRegistry();
|
||||
const Price = defineSchema('FuzzPrice', (s) => ({ value: s.f64, scale: s.u8 }));
|
||||
register(Price);
|
||||
const Order = defineSchema('FuzzOrder', (s) => ({
|
||||
id: s.u53,
|
||||
symbol: s.str,
|
||||
price: Price,
|
||||
qty: s.f64,
|
||||
side: s.enum(['buy', 'sell'] as const),
|
||||
tags: s.array(s.str),
|
||||
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
}));
|
||||
const codec = register(Order);
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = {
|
||||
id: Math.floor(r() * 2 ** 40),
|
||||
symbol: randString(),
|
||||
price: { value: randFloat(), scale: randInt(8) & 0xff },
|
||||
qty: randFloat(),
|
||||
side: (r() < 0.5 ? 'buy' : 'sell') as 'buy' | 'sell',
|
||||
tags: Array.from({ length: Math.floor(r() * 5) }, randString),
|
||||
flags: {
|
||||
ioc: r() < 0.5,
|
||||
post_only: r() < 0.5,
|
||||
reduce_only: r() < 0.5,
|
||||
},
|
||||
};
|
||||
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('fuzz: 500 random unions', () => {
|
||||
clearRegistry();
|
||||
const Event = s.union('FuzzEvent', 'kind', {
|
||||
fill: { price: s.f64, qty: s.f64 },
|
||||
cancel: { reason: s.str },
|
||||
expire: { at: s.u53 },
|
||||
});
|
||||
const codec = register(Event);
|
||||
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const which = Math.floor(r() * 3);
|
||||
let v: unknown;
|
||||
if (which === 0) v = { kind: 'fill', price: randFloat(), qty: randFloat() };
|
||||
else if (which === 1) v = { kind: 'cancel', reason: randString() };
|
||||
else v = { kind: 'expire', at: Math.floor(r() * 2 ** 40) };
|
||||
|
||||
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import { Reader, Writer } from '../plugin/io.ts';
|
||||
|
||||
function roundtrip<T>(write: (w: Writer) => void, read: (r: Reader) => T): T {
|
||||
const w = new Writer(16);
|
||||
write(w);
|
||||
return read(new Reader(w.bytes()));
|
||||
}
|
||||
|
||||
test('u8/u16/u32 round-trip with boundary values', () => {
|
||||
for (const v of [0, 1, 127, 128, 255]) {
|
||||
expect(roundtrip((w) => w.u8(v), (r) => r.u8())).toBe(v);
|
||||
}
|
||||
for (const v of [0, 1, 0xff, 0x100, 0xffff]) {
|
||||
expect(roundtrip((w) => w.u16(v), (r) => r.u16())).toBe(v);
|
||||
}
|
||||
for (const v of [0, 1, 0xffff, 0x10000, 0xffffffff]) {
|
||||
expect(roundtrip((w) => w.u32(v), (r) => r.u32())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('i16/i32 signed round-trip including negatives', () => {
|
||||
for (const v of [-32768, -1, 0, 1, 32767]) {
|
||||
expect(roundtrip((w) => w.i16(v), (r) => r.i16())).toBe(v);
|
||||
}
|
||||
for (const v of [-2147483648, -1, 0, 1, 2147483647]) {
|
||||
expect(roundtrip((w) => w.i32(v), (r) => r.i32())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('f32/f64 round-trip including special values', () => {
|
||||
for (const v of [0, -0, 1, -1, 3.14159, Infinity, -Infinity]) {
|
||||
expect(roundtrip((w) => w.f64(v), (r) => r.f64())).toBe(v);
|
||||
}
|
||||
expect(Number.isNaN(roundtrip((w) => w.f64(NaN), (r) => r.f64()))).toBe(true);
|
||||
for (const v of [0, 1, -1, 0.5, -0.5, 2.0, 1024, -1024, 0.125]) {
|
||||
expect(roundtrip((w) => w.f32(v), (r) => r.f32())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('varu32 LEB128 round-trip including 5-byte values', () => {
|
||||
const cases = [0, 1, 127, 128, 16383, 16384, 0x1fffff, 0x10000000, 0xffffffff];
|
||||
for (const v of cases) {
|
||||
expect(roundtrip((w) => w.varu32(v), (r) => r.varu32())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('varu32 byte lengths follow LEB128 spec', () => {
|
||||
const sizes: Array<[number, number]> = [
|
||||
[0, 1],
|
||||
[127, 1],
|
||||
[128, 2],
|
||||
[16383, 2],
|
||||
[16384, 3],
|
||||
[0x1fffff, 3],
|
||||
[0x200000, 4],
|
||||
[0xfffffff, 4],
|
||||
[0x10000000, 5],
|
||||
];
|
||||
for (const [v, expectedSize] of sizes) {
|
||||
const w = new Writer(16);
|
||||
w.varu32(v);
|
||||
expect(w.pos, `varu32(${v}) should be ${expectedSize} bytes`).toBe(expectedSize);
|
||||
}
|
||||
});
|
||||
|
||||
test('vari32 zigzag round-trip', () => {
|
||||
const cases = [0, -1, 1, -2, 2, -64, 63, -8192, 8191, -2147483648, 2147483647];
|
||||
for (const v of cases) {
|
||||
expect(roundtrip((w) => w.vari32(v), (r) => r.vari32())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('varu53 round-trip up to 2^53', () => {
|
||||
const cases = [
|
||||
0,
|
||||
1,
|
||||
127,
|
||||
128,
|
||||
2 ** 16,
|
||||
2 ** 32 - 1,
|
||||
2 ** 32,
|
||||
2 ** 40,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
];
|
||||
for (const v of cases) {
|
||||
expect(roundtrip((w) => w.varu53(v), (r) => r.varu53())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('vari53 round-trip', () => {
|
||||
const cases = [
|
||||
0,
|
||||
-1,
|
||||
1,
|
||||
-(2 ** 30),
|
||||
2 ** 30,
|
||||
Number.MIN_SAFE_INTEGER,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
];
|
||||
for (const v of cases) {
|
||||
expect(roundtrip((w) => w.vari53(v), (r) => r.vari53())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('varbu/varbi bigint round-trip', () => {
|
||||
const u: bigint[] = [0n, 1n, 127n, 128n, 1n << 32n, 1n << 63n, (1n << 64n) - 1n];
|
||||
for (const v of u) {
|
||||
expect(roundtrip((w) => w.varbu(v), (r) => r.varbu())).toBe(v);
|
||||
}
|
||||
const s: bigint[] = [0n, -1n, 1n, -(1n << 32n), 1n << 32n, -(1n << 63n), (1n << 63n) - 1n];
|
||||
for (const v of s) {
|
||||
expect(roundtrip((w) => w.varbi(v), (r) => r.varbi())).toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('str round-trip ASCII short and long', () => {
|
||||
for (const s of ['', 'a', 'hello', 'BTC-USD', 'abcdefghijklmnopqrstuvwxyz']) {
|
||||
expect(roundtrip((w) => w.str(s), (r) => r.str())).toBe(s);
|
||||
}
|
||||
const long = 'x'.repeat(200);
|
||||
expect(roundtrip((w) => w.str(long), (r) => r.str())).toBe(long);
|
||||
});
|
||||
|
||||
test('str round-trip non-ASCII', () => {
|
||||
for (const s of ['héllo', 'café', '日本語', '🚀', 'mix αβγ 漢字 🎉']) {
|
||||
expect(roundtrip((w) => w.str(s), (r) => r.str())).toBe(s);
|
||||
}
|
||||
});
|
||||
|
||||
test('bytes round-trip', () => {
|
||||
const data = new Uint8Array([1, 2, 3, 4, 255, 0, 128]);
|
||||
const result = roundtrip(
|
||||
(w) => w.bytesPrefixed(data),
|
||||
(r) => r.bytesPrefixed(),
|
||||
);
|
||||
expect(Array.from(result)).toEqual(Array.from(data));
|
||||
});
|
||||
|
||||
test('Writer grows beyond initial capacity', () => {
|
||||
const w = new Writer(4);
|
||||
for (let i = 0; i < 1000; i++) w.u8(i & 0xff);
|
||||
expect(w.pos).toBe(1000);
|
||||
expect(w.buf.byteLength).toBeGreaterThanOrEqual(1000);
|
||||
});
|
||||
|
||||
test('Writer reset reuses buffer', () => {
|
||||
const w = new Writer(16);
|
||||
w.u32(42);
|
||||
const cap1 = w.buf.byteLength;
|
||||
w.reset();
|
||||
expect(w.pos).toBe(0);
|
||||
w.u32(99);
|
||||
expect(w.buf.byteLength).toBe(cap1);
|
||||
});
|
||||
|
||||
test('multi-write/multi-read interleaved', () => {
|
||||
const w = new Writer(16);
|
||||
w.u8(1);
|
||||
w.f64(3.14);
|
||||
w.str('hi');
|
||||
w.varu53(42);
|
||||
|
||||
const r = new Reader(w.bytes());
|
||||
expect(r.u8()).toBe(1);
|
||||
expect(r.f64()).toBe(3.14);
|
||||
expect(r.str()).toBe('hi');
|
||||
expect(r.varu53()).toBe(42);
|
||||
});
|
||||
|
||||
test('bool round-trip', () => {
|
||||
for (const v of [true, false]) {
|
||||
expect(roundtrip((w) => w.bool(v), (r) => r.bool())).toBe(v);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
register,
|
||||
s,
|
||||
serialize,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
function fresh() {
|
||||
clearRegistry();
|
||||
}
|
||||
|
||||
test('flat object with mixed primitives', () => {
|
||||
fresh();
|
||||
const Ticker = defineSchema('Ticker', (s) => ({
|
||||
symbol: s.str,
|
||||
last: s.f64,
|
||||
volume: s.f64,
|
||||
count: s.u32,
|
||||
}));
|
||||
const codec = register(Ticker);
|
||||
|
||||
const value = { symbol: 'BTC-USD', last: 45123.45, volume: 1234.5678, count: 99999 };
|
||||
const bytes = serialize(value, codec);
|
||||
const decoded = deserialize<typeof value>(bytes);
|
||||
|
||||
expect(decoded).toEqual(value);
|
||||
});
|
||||
|
||||
test('array of primitives', () => {
|
||||
fresh();
|
||||
const Tags = defineSchema('Tags', (s) => ({
|
||||
items: s.array(s.str),
|
||||
counts: s.array(s.u32),
|
||||
}));
|
||||
const codec = register(Tags);
|
||||
|
||||
const v = { items: ['a', 'b', 'hello'], counts: [1, 2, 3, 4, 5] };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('nested object via inline ObjectSchema', () => {
|
||||
fresh();
|
||||
const Price = defineSchema('Price', (s) => ({ value: s.f64, scale: s.u8 }));
|
||||
const Order = defineSchema('Order', (s) => ({
|
||||
id: s.u53,
|
||||
price: Price,
|
||||
qty: s.f64,
|
||||
}));
|
||||
register(Price);
|
||||
const codec = register(Order);
|
||||
|
||||
const v = { id: 12345, price: { value: 100.5, scale: 2 }, qty: 1.5 };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('optional fields', () => {
|
||||
fresh();
|
||||
const Maybe = defineSchema('Maybe', (s) => ({
|
||||
a: s.optional(s.str),
|
||||
b: s.optional(s.f64),
|
||||
}));
|
||||
const codec = register(Maybe);
|
||||
|
||||
expect(deserialize(serialize({ a: 'hi', b: 3.14 }, codec))).toEqual({
|
||||
a: 'hi',
|
||||
b: 3.14,
|
||||
});
|
||||
expect(deserialize(serialize({ a: undefined, b: 1 }, codec))).toEqual({
|
||||
a: undefined,
|
||||
b: 1,
|
||||
});
|
||||
expect(deserialize(serialize({ a: undefined, b: undefined }, codec))).toEqual({
|
||||
a: undefined,
|
||||
b: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('enum field', () => {
|
||||
fresh();
|
||||
const Side = defineSchema('SidedOrder', (s) => ({
|
||||
side: s.enum(['buy', 'sell'] as const),
|
||||
qty: s.f64,
|
||||
}));
|
||||
const codec = register(Side);
|
||||
|
||||
for (const side of ['buy', 'sell'] as const) {
|
||||
const v = { side, qty: 1 };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('bitset field (≤8 flags)', () => {
|
||||
fresh();
|
||||
const Flags = defineSchema('Flags', (s) => ({
|
||||
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
}));
|
||||
const codec = register(Flags);
|
||||
|
||||
const v = { flags: { ioc: true, post_only: false, reduce_only: true } };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('bitset field (>32 flags uses bigint)', () => {
|
||||
fresh();
|
||||
const flagNames = Array.from({ length: 40 }, (_, i) => `f${i}`) as readonly string[];
|
||||
const Flags = defineSchema('FlagsBig', (s) => ({
|
||||
flags: s.bitset(flagNames as readonly [string, ...string[]]),
|
||||
}));
|
||||
const codec = register(Flags);
|
||||
|
||||
const flags: Record<string, boolean> = {};
|
||||
for (let i = 0; i < 40; i++) flags[`f${i}`] = i % 3 === 0;
|
||||
const v = { flags };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('tuple field', () => {
|
||||
fresh();
|
||||
const Point = defineSchema('Point3D', (s) => ({
|
||||
name: s.str,
|
||||
coord: s.tuple(s.f64, s.f64, s.f64),
|
||||
}));
|
||||
const codec = register(Point);
|
||||
|
||||
const v = { name: 'p', coord: [1.5, 2.5, 3.5] };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('array of nested objects', () => {
|
||||
fresh();
|
||||
const Level = defineSchema('Level', (s) => ({ price: s.f64, qty: s.f64 }));
|
||||
register(Level);
|
||||
const Book = defineSchema('Book', (s) => ({
|
||||
bids: s.array(Level),
|
||||
asks: s.array(Level),
|
||||
}));
|
||||
const codec = register(Book);
|
||||
|
||||
const v = {
|
||||
bids: [{ price: 100, qty: 1 }, { price: 99, qty: 2 }],
|
||||
asks: [{ price: 101, qty: 0.5 }, { price: 102, qty: 1.5 }, { price: 103, qty: 0.1 }],
|
||||
};
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('union with discriminator', () => {
|
||||
fresh();
|
||||
const Event = s.union('Event', 'kind', {
|
||||
fill: { price: s.f64, qty: s.f64 },
|
||||
cancel: { reason: s.str },
|
||||
expire: { at: s.u53 },
|
||||
});
|
||||
const codec = register(Event);
|
||||
|
||||
const samples = [
|
||||
{ kind: 'fill' as const, price: 100, qty: 0.5 },
|
||||
{ kind: 'cancel' as const, reason: 'user' },
|
||||
{ kind: 'expire' as const, at: 1700000000 },
|
||||
];
|
||||
for (const v of samples) {
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
}
|
||||
});
|
||||
|
||||
test('typed array (f64Array) round-trip', () => {
|
||||
fresh();
|
||||
const Signal = defineSchema('Signal', (s) => ({
|
||||
name: s.str,
|
||||
samples: s.f64Array,
|
||||
}));
|
||||
const codec = register(Signal);
|
||||
|
||||
const samples = new Float64Array([1.1, 2.2, 3.3, 4.4, 5.5]);
|
||||
const v = { name: 'sig', samples };
|
||||
const decoded = deserialize<typeof v>(serialize(v, codec));
|
||||
expect(decoded.name).toBe('sig');
|
||||
expect(decoded.samples).toBeInstanceOf(Float64Array);
|
||||
expect(decoded.samples.length).toBe(5);
|
||||
for (let i = 0; i < 5; i++) expect(decoded.samples[i]).toBe(samples[i]);
|
||||
});
|
||||
|
||||
test('bigint u64/i64 round-trip', () => {
|
||||
fresh();
|
||||
const Big = defineSchema('Big', (s) => ({
|
||||
u: s.u64,
|
||||
i: s.i64,
|
||||
}));
|
||||
const codec = register(Big);
|
||||
const v = { u: 1n << 50n, i: -(1n << 50n) };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
});
|
||||
|
||||
test('bytes field', () => {
|
||||
fresh();
|
||||
const Blob = defineSchema('Blob', (s) => ({
|
||||
data: s.bytes,
|
||||
}));
|
||||
const codec = register(Blob);
|
||||
const data = new Uint8Array([0, 1, 2, 3, 254, 255]);
|
||||
const decoded = deserialize<{ data: Uint8Array }>(serialize({ data }, codec));
|
||||
expect(Array.from(decoded.data)).toEqual(Array.from(data));
|
||||
});
|
||||
|
||||
test('serialize includes 2-byte schema ID frame', () => {
|
||||
fresh();
|
||||
const Sch = defineSchema('Sch', (s) => ({ x: s.u8 }));
|
||||
const codec = register(Sch);
|
||||
const bytes = serialize({ x: 7 }, codec);
|
||||
expect(bytes.length).toBeGreaterThanOrEqual(3);
|
||||
const id = bytes[0]! | (bytes[1]! << 8);
|
||||
expect(id).toBe(codec.id);
|
||||
});
|
||||
|
||||
test('large nested order-book payload', () => {
|
||||
fresh();
|
||||
const Level = defineSchema('LvlBig', (s) => ({ p: s.f64, q: s.f64 }));
|
||||
register(Level);
|
||||
const Snap = defineSchema('Snap', (s) => ({
|
||||
symbol: s.str,
|
||||
ts: s.u53,
|
||||
bids: s.array(Level),
|
||||
asks: s.array(Level),
|
||||
}));
|
||||
const codec = register(Snap);
|
||||
|
||||
const bids = Array.from({ length: 1000 }, (_, i) => ({ p: 100 - i * 0.01, q: 1 + i * 0.001 }));
|
||||
const asks = Array.from({ length: 1000 }, (_, i) => ({ p: 100 + i * 0.01, q: 1 + i * 0.001 }));
|
||||
const v = { symbol: 'BTC-USD', ts: 1700000000123, bids, asks };
|
||||
|
||||
const bytes = serialize(v, codec);
|
||||
const decoded = deserialize<typeof v>(bytes);
|
||||
expect(decoded.symbol).toBe(v.symbol);
|
||||
expect(decoded.ts).toBe(v.ts);
|
||||
expect(decoded.bids.length).toBe(1000);
|
||||
expect(decoded.asks.length).toBe(1000);
|
||||
expect(decoded.bids[0]).toEqual(v.bids[0]);
|
||||
expect(decoded.asks[999]).toEqual(v.asks[999]);
|
||||
});
|
||||
|
||||
test('deserialize unknown schema ID throws', () => {
|
||||
fresh();
|
||||
const bytes = new Uint8Array([0xff, 0xff, 0]);
|
||||
expect(() => deserialize(bytes)).toThrow(/Unknown schema ID/);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
register,
|
||||
s,
|
||||
serialize,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
/**
|
||||
* Decoded objects must share key order with the schema field order. Same
|
||||
* key order across instances is V8's strongest signal of a shared hidden
|
||||
* class, which is what the codec's single-object-literal pattern ensures.
|
||||
*/
|
||||
test('decoded objects share key order matching schema field order', () => {
|
||||
clearRegistry();
|
||||
const Order = defineSchema('ShapeOrder', (s) => ({
|
||||
id: s.u53,
|
||||
price: s.f64,
|
||||
qty: s.f64,
|
||||
side: s.enum(['buy', 'sell'] as const),
|
||||
tags: s.array(s.str),
|
||||
}));
|
||||
const codec = register(Order);
|
||||
|
||||
const expectedOrder = ['id', 'price', 'qty', 'side', 'tags'];
|
||||
|
||||
const decoded1 = deserialize<Record<string, unknown>>(
|
||||
serialize({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }, codec),
|
||||
);
|
||||
const decoded2 = deserialize<Record<string, unknown>>(
|
||||
serialize({ id: 999, price: 1e10, qty: 0, side: 'sell', tags: [] }, codec),
|
||||
);
|
||||
const decoded3 = deserialize<Record<string, unknown>>(
|
||||
serialize({ id: 2 ** 40, price: -1, qty: 1234, side: 'buy', tags: ['x', 'y', 'z'] }, codec),
|
||||
);
|
||||
|
||||
expect(Object.keys(decoded1)).toEqual(expectedOrder);
|
||||
expect(Object.keys(decoded2)).toEqual(expectedOrder);
|
||||
expect(Object.keys(decoded3)).toEqual(expectedOrder);
|
||||
});
|
||||
|
||||
test('decoded value types are consistent across instances', () => {
|
||||
clearRegistry();
|
||||
const T = defineSchema('Types', (s) => ({
|
||||
a: s.u32,
|
||||
b: s.f64,
|
||||
c: s.str,
|
||||
d: s.bool,
|
||||
}));
|
||||
const codec = register(T);
|
||||
|
||||
const types = (o: Record<string, unknown>) =>
|
||||
Object.entries(o).map(([k, v]) => [k, typeof v]);
|
||||
|
||||
const a = deserialize<Record<string, unknown>>(
|
||||
serialize({ a: 1, b: 1.5, c: 'a', d: true }, codec),
|
||||
);
|
||||
const b = deserialize<Record<string, unknown>>(
|
||||
serialize({ a: 0, b: 0, c: '', d: false }, codec),
|
||||
);
|
||||
expect(types(a)).toEqual(types(b));
|
||||
expect(types(a)).toEqual([
|
||||
['a', 'number'],
|
||||
['b', 'number'],
|
||||
['c', 'string'],
|
||||
['d', 'boolean'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('nested object key order is stable', () => {
|
||||
clearRegistry();
|
||||
const Price = defineSchema('SPrice', (s) => ({ value: s.f64, scale: s.u8 }));
|
||||
register(Price);
|
||||
const Order = defineSchema('SOrder', (s) => ({
|
||||
id: s.u53,
|
||||
price: Price,
|
||||
qty: s.f64,
|
||||
}));
|
||||
const codec = register(Order);
|
||||
|
||||
const v = { id: 1, price: { value: 100, scale: 2 }, qty: 1 };
|
||||
const d1 = deserialize<Record<string, unknown>>(serialize(v, codec));
|
||||
const d2 = deserialize<Record<string, unknown>>(serialize({ ...v, id: 99 }, codec));
|
||||
|
||||
expect(Object.keys(d1)).toEqual(['id', 'price', 'qty']);
|
||||
expect(Object.keys(d2)).toEqual(['id', 'price', 'qty']);
|
||||
expect(Object.keys(d1.price as Record<string, unknown>)).toEqual(['value', 'scale']);
|
||||
expect(Object.keys(d2.price as Record<string, unknown>)).toEqual(['value', 'scale']);
|
||||
});
|
||||
|
||||
test('union decoded objects place discriminator first', () => {
|
||||
clearRegistry();
|
||||
const Event = s.union('SEvent', 'kind', {
|
||||
a: { x: s.u32 },
|
||||
b: { y: s.f64 },
|
||||
});
|
||||
const codec = register(Event);
|
||||
|
||||
const ea = deserialize<Record<string, unknown>>(serialize({ kind: 'a', x: 1 }, codec));
|
||||
const eb = deserialize<Record<string, unknown>>(serialize({ kind: 'b', y: 2.5 }, codec));
|
||||
expect(Object.keys(ea)[0]).toBe('kind');
|
||||
expect(Object.keys(eb)[0]).toBe('kind');
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test, expect, afterAll } from 'vitest';
|
||||
import { writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { transform } from '../plugin/compile/transformer.ts';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
// Write gen files directly in the test dir so relative imports `../src/...`
|
||||
// resolve to serializer/src/index.ts.
|
||||
const GEN_DIR = HERE;
|
||||
|
||||
let counter = 0;
|
||||
async function transformAndImport(source: string): Promise<Record<string, unknown>> {
|
||||
const id = ++counter;
|
||||
const file = join(GEN_DIR, `__gen_${id}.ts`);
|
||||
const result = transform(source, file, {
|
||||
importPath: '../plugin/index.ts',
|
||||
packageAliases: ['../plugin/index.ts'],
|
||||
});
|
||||
writeFileSync(file, result.code, 'utf8');
|
||||
// Use file URL + @vite-ignore so vite passes through to native dynamic import.
|
||||
const url = `${pathToFileURL(file).href}?t=${Date.now()}`;
|
||||
const mod = await import(/* @vite-ignore */ url);
|
||||
return mod as Record<string, unknown>;
|
||||
}
|
||||
|
||||
test('transformer: flat type round-trip', async () => {
|
||||
const src = `
|
||||
import { type, u53, f64, str } from '../plugin/index.ts';
|
||||
|
||||
export const Ticker = type('TxTicker', {
|
||||
symbol: str,
|
||||
last: f64,
|
||||
volume: f64,
|
||||
});
|
||||
`;
|
||||
const mod = await transformAndImport(src);
|
||||
const Ticker = mod.Ticker as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||
const v = { symbol: 'BTC-USD', last: 100.5, volume: 1234.5 };
|
||||
expect(Ticker.decode(Ticker.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('transformer: nested object via local reference', async () => {
|
||||
const src = `
|
||||
import { type, u53, f64 } from '../plugin/index.ts';
|
||||
|
||||
export const Price = type('TxPrice', { value: f64, scale: u53 });
|
||||
export const Order = type('TxOrder', { id: u53, price: Price, qty: f64 });
|
||||
`;
|
||||
const mod = await transformAndImport(src);
|
||||
const Order = mod.Order as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||
const v = { id: 42, price: { value: 100.5, scale: 2 }, qty: 0.5 };
|
||||
expect(Order.decode(Order.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('transformer: combinators (list, opt, enumOf, flags, tuple)', async () => {
|
||||
const src = `
|
||||
import { type, u53, f64, str, list, opt, enumOf, flags, tuple } from '../plugin/index.ts';
|
||||
|
||||
export const Combo = type('TxCombo', {
|
||||
tags: list(str),
|
||||
maybe: opt(f64),
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
f: flags(['ioc', 'post_only'] as const),
|
||||
point: tuple(f64, f64),
|
||||
});
|
||||
`;
|
||||
const mod = await transformAndImport(src);
|
||||
const Combo = mod.Combo as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||
const v = {
|
||||
tags: ['a', 'b'],
|
||||
maybe: 3.14,
|
||||
side: 'buy',
|
||||
f: { ioc: true, post_only: false },
|
||||
point: [1, 2],
|
||||
};
|
||||
expect(Combo.decode(Combo.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('transformer: anonymous (no name) — uses const name as schema name', async () => {
|
||||
const src = `
|
||||
import { type, u53, f64 } from '../plugin/index.ts';
|
||||
|
||||
export const TxAnon = type({ x: u53, y: f64 });
|
||||
`;
|
||||
const mod = await transformAndImport(src);
|
||||
const T = mod.TxAnon as {
|
||||
encode: (v: unknown) => Uint8Array;
|
||||
decode: (b: Uint8Array) => unknown;
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
expect(T.name).toBe('TxAnon');
|
||||
const v = { x: 1, y: 2.5 };
|
||||
expect(T.decode(T.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('transformer: array of nested objects (the OrderBook hot path)', async () => {
|
||||
const src = `
|
||||
import { type, u53, f64, str, list } from '../plugin/index.ts';
|
||||
|
||||
export const Level = type('TxLevel', { p: f64, q: f64 });
|
||||
export const Book = type('TxBook', {
|
||||
symbol: str,
|
||||
ts: u53,
|
||||
bids: list(Level),
|
||||
asks: list(Level),
|
||||
});
|
||||
`;
|
||||
const mod = await transformAndImport(src);
|
||||
const Book = mod.Book as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
|
||||
const v = {
|
||||
symbol: 'BTC-USD',
|
||||
ts: 1700000000000,
|
||||
bids: Array.from({ length: 100 }, (_, i) => ({ p: 100 - i * 0.1, q: 0.5 + i * 0.01 })),
|
||||
asks: Array.from({ length: 100 }, (_, i) => ({ p: 100 + i * 0.1, q: 0.5 + i * 0.01 })),
|
||||
};
|
||||
const decoded = Book.decode(Book.encode(v)) as typeof v;
|
||||
expect(decoded.symbol).toBe(v.symbol);
|
||||
expect(decoded.ts).toBe(v.ts);
|
||||
expect(decoded.bids.length).toBe(100);
|
||||
expect(decoded.bids[0]).toEqual(v.bids[0]);
|
||||
expect(decoded.asks[99]).toEqual(v.asks[99]);
|
||||
});
|
||||
|
||||
test('transformer: file without type() imports — unchanged', () => {
|
||||
const src = `
|
||||
import { foo } from 'somewhere';
|
||||
const x = foo();
|
||||
`;
|
||||
const result = transform(src, 'test.ts', { importPath: '../plugin/index.ts' });
|
||||
expect(result.transformedCount).toBe(0);
|
||||
expect(result.code).toBe(src);
|
||||
});
|
||||
|
||||
test('transformer: file with type import but no calls — adds nothing', () => {
|
||||
const src = `
|
||||
import { type, u53 } from '../plugin/index.ts';
|
||||
// no type() call here
|
||||
const x = 1;
|
||||
`;
|
||||
const result = transform(src, 'test.ts', {
|
||||
importPath: '../plugin/index.ts',
|
||||
packageAliases: ['../plugin/index.ts'],
|
||||
});
|
||||
expect(result.transformedCount).toBe(0);
|
||||
});
|
||||
|
||||
test('transformer: replaces call with IIFE (smoke check on output)', () => {
|
||||
const src = `
|
||||
import { type, u53, f64 } from '../plugin/index.ts';
|
||||
export const T = type('TxSmoke', { x: u53, y: f64 });
|
||||
`;
|
||||
const result = transform(src, 'test.ts', {
|
||||
importPath: '../plugin/index.ts',
|
||||
packageAliases: ['../plugin/index.ts'],
|
||||
});
|
||||
expect(result.transformedCount).toBe(1);
|
||||
expect(result.code).toContain('function encode_TxSmoke');
|
||||
expect(result.code).toContain('function decode_TxSmoke');
|
||||
expect(result.code).not.toContain("type('TxSmoke'");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (let i = 1; i <= counter; i++) {
|
||||
const file = join(GEN_DIR, `__gen_${i}.ts`);
|
||||
if (existsSync(file)) rmSync(file, { force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user