feat(serializer): add class aot serialization support
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import { bench, describe } from 'vitest';
|
||||
import { Reader, Writer, deserialize, serialize } from '../../plugin/index.ts';
|
||||
import { Reader, Writer, router } from '../../plugin/index.ts';
|
||||
import {
|
||||
Book,
|
||||
Order,
|
||||
Ticker,
|
||||
buildBook,
|
||||
buildOrder,
|
||||
buildTicker,
|
||||
registerAll,
|
||||
} from './payloads.ts';
|
||||
|
||||
const codecs = registerAll();
|
||||
|
||||
const ticker = buildTicker();
|
||||
const order = buildOrder();
|
||||
const book = buildBook(1000);
|
||||
@@ -23,11 +23,11 @@ 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);
|
||||
const tickerBin = Ticker.encode(ticker);
|
||||
const orderBin = Order.encode(order);
|
||||
const bookBin = Book.encode(book);
|
||||
|
||||
// One-time payload-size print on module load so it appears once in bench output.
|
||||
// One-time payload-size print on module load.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'\n--- payload sizes ---\n' +
|
||||
@@ -40,9 +40,9 @@ describe('encode ticker (5 fields)', () => {
|
||||
bench('JSON.stringify', () => {
|
||||
JSON.stringify(ticker);
|
||||
});
|
||||
bench('codec.encode (pooled)', () => {
|
||||
bench('codec.encodeInto (pooled)', () => {
|
||||
wTicker.reset();
|
||||
codecs.ticker.encode(wTicker, ticker);
|
||||
Ticker.encodeInto(ticker, wTicker);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,9 +50,9 @@ describe('encode order (10 fields + bitset)', () => {
|
||||
bench('JSON.stringify', () => {
|
||||
JSON.stringify(order);
|
||||
});
|
||||
bench('codec.encode (pooled)', () => {
|
||||
bench('codec.encodeInto (pooled)', () => {
|
||||
wOrder.reset();
|
||||
codecs.order.encode(wOrder, order);
|
||||
Order.encodeInto(order, wOrder);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,9 +60,9 @@ describe('encode book (1000 levels)', () => {
|
||||
bench('JSON.stringify', () => {
|
||||
JSON.stringify(book);
|
||||
});
|
||||
bench('codec.encode (pooled)', () => {
|
||||
bench('codec.encodeInto (pooled)', () => {
|
||||
wBook.reset();
|
||||
codecs.book.encode(wBook, book);
|
||||
Book.encodeInto(book, wBook);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,10 +70,9 @@ describe('decode ticker', () => {
|
||||
bench('JSON.parse', () => {
|
||||
JSON.parse(tickerJSON);
|
||||
});
|
||||
bench('codec.decode', () => {
|
||||
bench('codec.decodeFrom', () => {
|
||||
const r = new Reader(tickerBin);
|
||||
r.pos = 2;
|
||||
codecs.ticker.decode(r);
|
||||
Ticker.decodeFrom(r);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,10 +80,9 @@ describe('decode order', () => {
|
||||
bench('JSON.parse', () => {
|
||||
JSON.parse(orderJSON);
|
||||
});
|
||||
bench('codec.decode', () => {
|
||||
bench('codec.decodeFrom', () => {
|
||||
const r = new Reader(orderBin);
|
||||
r.pos = 2;
|
||||
codecs.order.decode(r);
|
||||
Order.decodeFrom(r);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,10 +90,9 @@ describe('decode book (1000 levels)', () => {
|
||||
bench('JSON.parse', () => {
|
||||
JSON.parse(bookJSON);
|
||||
});
|
||||
bench('codec.decode', () => {
|
||||
bench('codec.decodeFrom', () => {
|
||||
const r = new Reader(bookBin);
|
||||
r.pos = 2;
|
||||
codecs.book.decode(r);
|
||||
Book.decodeFrom(r);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,17 +102,18 @@ describe('roundtrip ticker', () => {
|
||||
});
|
||||
bench('codec (pooled)', () => {
|
||||
wTicker.reset();
|
||||
codecs.ticker.encode(wTicker, ticker);
|
||||
Ticker.encodeInto(ticker, wTicker);
|
||||
const r = new Reader(wTicker.bytes());
|
||||
codecs.ticker.decode(r);
|
||||
Ticker.decodeFrom(r);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialize+deserialize ticker (with frame)', () => {
|
||||
describe('framed ticker via router', () => {
|
||||
const proto = router(Ticker, Order, Book);
|
||||
bench('JSON', () => {
|
||||
JSON.parse(JSON.stringify(ticker));
|
||||
});
|
||||
bench('serialize/deserialize (framed)', () => {
|
||||
deserialize(serialize(ticker, codecs.ticker));
|
||||
bench('router encode + decode (framed)', () => {
|
||||
proto.decode(proto.encode(ticker, Ticker));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,73 +1,50 @@
|
||||
import { defineSchema, register, s } from '../../plugin/index.ts';
|
||||
import type { Codec } from '../../plugin/index.ts';
|
||||
import {
|
||||
type,
|
||||
enumOf,
|
||||
f64,
|
||||
flags,
|
||||
list,
|
||||
str,
|
||||
u53,
|
||||
type TypeCodec,
|
||||
} 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 Ticker = type('BenchTicker', {
|
||||
symbol: str,
|
||||
last: f64,
|
||||
bid: f64,
|
||||
ask: f64,
|
||||
volume: 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 Order = type('BenchOrder', {
|
||||
id: u53,
|
||||
account: u53,
|
||||
symbol: str,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
type: enumOf(['limit', 'market', 'stop', 'stop_limit'] as const),
|
||||
price: f64,
|
||||
qty: f64,
|
||||
filledQty: f64,
|
||||
ts: u53,
|
||||
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
});
|
||||
|
||||
export const LevelSchema = defineSchema('BenchLevel', (s) => ({
|
||||
p: s.f64,
|
||||
q: s.f64,
|
||||
}));
|
||||
export const Level = type('BenchLevel', { p: f64, q: f64 });
|
||||
|
||||
export const BookSchema = defineSchema('BenchBook', (s) => ({
|
||||
symbol: s.str,
|
||||
ts: s.u53,
|
||||
bids: s.array(LevelSchema),
|
||||
asks: s.array(LevelSchema),
|
||||
}));
|
||||
export const Book = type('BenchBook', {
|
||||
symbol: str,
|
||||
ts: u53,
|
||||
bids: list(Level),
|
||||
asks: list(Level),
|
||||
});
|
||||
|
||||
export interface Ticker {
|
||||
symbol: string;
|
||||
last: number;
|
||||
bid: number;
|
||||
ask: number;
|
||||
volume: number;
|
||||
}
|
||||
export type TickerT = typeof Ticker.$infer;
|
||||
export type OrderT = typeof Order.$infer;
|
||||
export type LevelT = typeof Level.$infer;
|
||||
export type BookT = typeof Book.$infer;
|
||||
|
||||
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 {
|
||||
export function buildTicker(): TickerT {
|
||||
return {
|
||||
symbol: 'BTC-USD',
|
||||
last: 67891.23,
|
||||
@@ -77,7 +54,7 @@ export function buildTicker(): Ticker {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOrder(): Order {
|
||||
export function buildOrder(): OrderT {
|
||||
return {
|
||||
id: 9876543210,
|
||||
account: 12345678,
|
||||
@@ -92,9 +69,9 @@ export function buildOrder(): Order {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBook(depth: number): Book {
|
||||
const bids: Level[] = new Array(depth);
|
||||
const asks: Level[] = new Array(depth);
|
||||
export function buildBook(depth: number): BookT {
|
||||
const bids: LevelT[] = new Array(depth);
|
||||
const asks: LevelT[] = 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 };
|
||||
@@ -102,17 +79,9 @@ export function buildBook(depth: number): Book {
|
||||
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 };
|
||||
}
|
||||
export type AllCodecs = {
|
||||
ticker: TypeCodec<TickerT>;
|
||||
order: TypeCodec<OrderT>;
|
||||
level: TypeCodec<LevelT>;
|
||||
book: TypeCodec<BookT>;
|
||||
};
|
||||
|
||||
@@ -1,79 +1,52 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
registerClass,
|
||||
enumOf,
|
||||
f64,
|
||||
Serializable,
|
||||
serialize,
|
||||
type,
|
||||
u53,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
test('class with [Serializable] static schema registers and round-trips', () => {
|
||||
test('class with [Serializable] static codec 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),
|
||||
}));
|
||||
static [Serializable] = type('OrderClass', {
|
||||
id: u53,
|
||||
price: f64,
|
||||
qty: f64,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
});
|
||||
}
|
||||
|
||||
const codec = registerClass(Order);
|
||||
|
||||
const codec = Order[Serializable]!;
|
||||
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/);
|
||||
expect(codec.decode(codec.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('Symbol.serializable is shared across module boundaries via Symbol.for', () => {
|
||||
const looked = Symbol.for('@perf/serializable');
|
||||
expect(looked).toBe(Serializable);
|
||||
expect(Symbol.for('@perf/serializable')).toBe(Serializable);
|
||||
});
|
||||
|
||||
test('codec.id is deterministic for the schema name', () => {
|
||||
clearRegistry();
|
||||
const A = defineSchema('SameName', (s) => ({ x: s.u8 }));
|
||||
|
||||
class A {
|
||||
static [Serializable] = type('SameName', { x: u53 });
|
||||
}
|
||||
const idA = A[Serializable]!.id;
|
||||
|
||||
clearRegistry();
|
||||
const codecA = registerClass(
|
||||
class extends Object {
|
||||
static [Serializable] = A;
|
||||
},
|
||||
);
|
||||
class B {
|
||||
static [Serializable] = type('SameName', { y: f64 });
|
||||
}
|
||||
const idB = B[Serializable]!.id;
|
||||
|
||||
clearRegistry();
|
||||
const codecB = registerClass(
|
||||
class extends Object {
|
||||
static [Serializable] = A;
|
||||
},
|
||||
);
|
||||
|
||||
expect(codecA.id).toBe(codecB.id);
|
||||
expect(idA).toBe(idB);
|
||||
});
|
||||
|
||||
test('class without [Serializable] has no codec', () => {
|
||||
class Empty {}
|
||||
expect((Empty as unknown as Record<symbol, unknown>)[Serializable]).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
register,
|
||||
s,
|
||||
serialize,
|
||||
enumOf,
|
||||
f64,
|
||||
flags,
|
||||
list,
|
||||
oneOf,
|
||||
str,
|
||||
type,
|
||||
u32,
|
||||
u53,
|
||||
u8,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
function rng(seed: number): () => number {
|
||||
@@ -34,8 +39,7 @@ function randFloat(): number {
|
||||
}
|
||||
|
||||
function randInt(maxBits = 32): number {
|
||||
const v = Math.floor(r() * 2 ** maxBits);
|
||||
return v >>> 0;
|
||||
return Math.floor(r() * 2 ** maxBits) >>> 0;
|
||||
}
|
||||
|
||||
function randString(): string {
|
||||
@@ -47,14 +51,13 @@ function randString(): string {
|
||||
|
||||
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);
|
||||
const Ticker = type('FuzzTicker', {
|
||||
symbol: str,
|
||||
last: f64,
|
||||
volume: f64,
|
||||
count: u32,
|
||||
asks: list(f64),
|
||||
});
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const v = {
|
||||
@@ -64,24 +67,22 @@ test('fuzz: 2000 random ticker round-trips', () => {
|
||||
count: randInt(32),
|
||||
asks: Array.from({ length: Math.floor(r() * 10) }, randFloat),
|
||||
};
|
||||
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||
expect(Ticker.decode(Ticker.encode(v)), `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,
|
||||
const Price = type('FuzzPrice', { value: f64, scale: u8 });
|
||||
const Order = type('FuzzOrder', {
|
||||
id: u53,
|
||||
symbol: 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);
|
||||
qty: f64,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
tags: list(str),
|
||||
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
});
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const v = {
|
||||
@@ -97,18 +98,17 @@ test('fuzz: 1000 random nested orders', () => {
|
||||
reduce_only: r() < 0.5,
|
||||
},
|
||||
};
|
||||
expect(deserialize(serialize(v, codec)), `iteration ${i}`).toEqual(v);
|
||||
expect(Order.decode(Order.encode(v)), `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 Event = oneOf('FuzzEvent', 'kind', {
|
||||
fill: { price: f64, qty: f64 },
|
||||
cancel: { reason: str },
|
||||
expire: { at: u53 },
|
||||
});
|
||||
const codec = register(Event);
|
||||
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const which = Math.floor(r() * 3);
|
||||
@@ -117,6 +117,6 @@ test('fuzz: 500 random unions', () => {
|
||||
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);
|
||||
expect(Event.decode(Event.encode(v as never)), `iteration ${i}`).toEqual(v);
|
||||
}
|
||||
});
|
||||
|
||||
+110
-131
@@ -1,11 +1,24 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
bool,
|
||||
bytes,
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
register,
|
||||
s,
|
||||
serialize,
|
||||
enumOf,
|
||||
f64,
|
||||
f64Array,
|
||||
flags,
|
||||
i64,
|
||||
list,
|
||||
oneOf,
|
||||
opt,
|
||||
router,
|
||||
str,
|
||||
tuple,
|
||||
type,
|
||||
u32,
|
||||
u53,
|
||||
u64,
|
||||
u8,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
function fresh() {
|
||||
@@ -14,65 +27,45 @@ function fresh() {
|
||||
|
||||
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 Ticker = type('Ticker', {
|
||||
symbol: str,
|
||||
last: f64,
|
||||
volume: f64,
|
||||
count: u32,
|
||||
});
|
||||
|
||||
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);
|
||||
expect(Ticker.decode(Ticker.encode(value))).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 Tags = type('Tags', {
|
||||
items: list(str),
|
||||
counts: list(u32),
|
||||
});
|
||||
const v = { items: ['a', 'b', 'hello'], counts: [1, 2, 3, 4, 5] };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
expect(Tags.decode(Tags.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('nested object via inline ObjectSchema', () => {
|
||||
test('nested object via inline reference', () => {
|
||||
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 Price = type('Price', { value: f64, scale: u8 });
|
||||
const Order = type('Order', { id: u53, price: Price, qty: f64 });
|
||||
const v = { id: 12345, price: { value: 100.5, scale: 2 }, qty: 1.5 };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
expect(Order.decode(Order.encode(v))).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);
|
||||
const Maybe = type('Maybe', {
|
||||
a: opt(str),
|
||||
b: opt(f64),
|
||||
});
|
||||
|
||||
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({
|
||||
expect(Maybe.decode(Maybe.encode({ a: 'hi', b: 3.14 }))).toEqual({ a: 'hi', b: 3.14 });
|
||||
expect(Maybe.decode(Maybe.encode({ a: undefined, b: 1 }))).toEqual({ a: undefined, b: 1 });
|
||||
expect(Maybe.decode(Maybe.encode({ a: undefined, b: undefined }))).toEqual({
|
||||
a: undefined,
|
||||
b: undefined,
|
||||
});
|
||||
@@ -80,102 +73,90 @@ test('optional fields', () => {
|
||||
|
||||
test('enum field', () => {
|
||||
fresh();
|
||||
const Side = defineSchema('SidedOrder', (s) => ({
|
||||
side: s.enum(['buy', 'sell'] as const),
|
||||
qty: s.f64,
|
||||
}));
|
||||
const codec = register(Side);
|
||||
|
||||
const Sided = type('SidedOrder', {
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
qty: f64,
|
||||
});
|
||||
for (const side of ['buy', 'sell'] as const) {
|
||||
const v = { side, qty: 1 };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
expect(Sided.decode(Sided.encode(v))).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 Flags = type('Flags', {
|
||||
flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
|
||||
});
|
||||
const v = { flags: { ioc: true, post_only: false, reduce_only: true } };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
expect(Flags.decode(Flags.encode(v))).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 FlagsBig = type('FlagsBig', {
|
||||
flags: flags(flagNames as readonly [string, ...string[]]),
|
||||
});
|
||||
|
||||
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);
|
||||
const flagsValue: Record<string, boolean> = {};
|
||||
for (let i = 0; i < 40; i++) flagsValue[`f${i}`] = i % 3 === 0;
|
||||
const v = { flags: flagsValue };
|
||||
expect(FlagsBig.decode(FlagsBig.encode(v))).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);
|
||||
const Point = type('Point3D', {
|
||||
name: str,
|
||||
coord: tuple(f64, f64, f64),
|
||||
});
|
||||
const v = { name: 'p', coord: [1.5, 2.5, 3.5] as [number, number, number] };
|
||||
expect(Point.decode(Point.encode(v))).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 Level = type('Level', { price: f64, qty: f64 });
|
||||
const Book = type('Book', {
|
||||
bids: list(Level),
|
||||
asks: list(Level),
|
||||
});
|
||||
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);
|
||||
expect(Book.decode(Book.encode(v))).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 Event = oneOf('Event', 'kind', {
|
||||
fill: { price: f64, qty: f64 },
|
||||
cancel: { reason: str },
|
||||
expire: { at: 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 },
|
||||
{ kind: 'fill', price: 100, qty: 0.5 },
|
||||
{ kind: 'cancel', reason: 'user' },
|
||||
{ kind: 'expire', at: 1700000000 },
|
||||
];
|
||||
for (const v of samples) {
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
expect(Event.decode(Event.encode(v as never))).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 Signal = type('Signal', {
|
||||
name: str,
|
||||
samples: f64Array,
|
||||
});
|
||||
|
||||
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));
|
||||
const decoded = Signal.decode(Signal.encode(v));
|
||||
expect(decoded.name).toBe('sig');
|
||||
expect(decoded.samples).toBeInstanceOf(Float64Array);
|
||||
expect(decoded.samples.length).toBe(5);
|
||||
@@ -184,54 +165,50 @@ test('typed array (f64Array) round-trip', () => {
|
||||
|
||||
test('bigint u64/i64 round-trip', () => {
|
||||
fresh();
|
||||
const Big = defineSchema('Big', (s) => ({
|
||||
u: s.u64,
|
||||
i: s.i64,
|
||||
}));
|
||||
const codec = register(Big);
|
||||
const Big = type('Big', { u: u64, i: i64 });
|
||||
const v = { u: 1n << 50n, i: -(1n << 50n) };
|
||||
expect(deserialize(serialize(v, codec))).toEqual(v);
|
||||
expect(Big.decode(Big.encode(v))).toEqual(v);
|
||||
});
|
||||
|
||||
test('bytes field', () => {
|
||||
fresh();
|
||||
const Blob = defineSchema('Blob', (s) => ({
|
||||
data: s.bytes,
|
||||
}));
|
||||
const codec = register(Blob);
|
||||
const Blob = type('Blob', { data: bytes });
|
||||
const data = new Uint8Array([0, 1, 2, 3, 254, 255]);
|
||||
const decoded = deserialize<{ data: Uint8Array }>(serialize({ data }, codec));
|
||||
const decoded = Blob.decode(Blob.encode({ data }));
|
||||
expect(Array.from(decoded.data)).toEqual(Array.from(data));
|
||||
});
|
||||
|
||||
test('serialize includes 2-byte schema ID frame', () => {
|
||||
test('bool field round-trip', () => {
|
||||
fresh();
|
||||
const Sch = defineSchema('Sch', (s) => ({ x: s.u8 }));
|
||||
const codec = register(Sch);
|
||||
const bytes = serialize({ x: 7 }, codec);
|
||||
const T = type('Bools', { a: bool, b: bool });
|
||||
expect(T.decode(T.encode({ a: true, b: false }))).toEqual({ a: true, b: false });
|
||||
});
|
||||
|
||||
test('router prepends 2-byte schema ID frame', () => {
|
||||
fresh();
|
||||
const Sch = type('Sch', { x: u8 });
|
||||
const proto = router(Sch);
|
||||
const bytes = proto.encode({ x: 7 }, Sch);
|
||||
expect(bytes.length).toBeGreaterThanOrEqual(3);
|
||||
const id = bytes[0]! | (bytes[1]! << 8);
|
||||
expect(id).toBe(codec.id);
|
||||
expect(id).toBe(Sch.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 Level = type('LvlBig', { p: f64, q: f64 });
|
||||
const Snap = type('Snap', {
|
||||
symbol: str,
|
||||
ts: u53,
|
||||
bids: list(Level),
|
||||
asks: list(Level),
|
||||
});
|
||||
|
||||
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);
|
||||
const decoded = Snap.decode(Snap.encode(v));
|
||||
expect(decoded.symbol).toBe(v.symbol);
|
||||
expect(decoded.ts).toBe(v.ts);
|
||||
expect(decoded.bids.length).toBe(1000);
|
||||
@@ -240,8 +217,10 @@ test('large nested order-book payload', () => {
|
||||
expect(decoded.asks[999]).toEqual(v.asks[999]);
|
||||
});
|
||||
|
||||
test('deserialize unknown schema ID throws', () => {
|
||||
test('router throws for unknown schema ID', () => {
|
||||
fresh();
|
||||
const bytes = new Uint8Array([0xff, 0xff, 0]);
|
||||
expect(() => deserialize(bytes)).toThrow(/Unknown schema ID/);
|
||||
const Sch = type('Sch2', { x: u8 });
|
||||
const proto = router(Sch);
|
||||
const bogus = new Uint8Array([0xff, 0xff, 0]);
|
||||
expect(() => proto.decode(bogus)).toThrow(/unknown schema ID/i);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { test, expect } from 'vitest';
|
||||
import {
|
||||
bool,
|
||||
clearRegistry,
|
||||
defineSchema,
|
||||
deserialize,
|
||||
register,
|
||||
s,
|
||||
serialize,
|
||||
enumOf,
|
||||
f64,
|
||||
list,
|
||||
oneOf,
|
||||
str,
|
||||
type,
|
||||
u32,
|
||||
u53,
|
||||
u8,
|
||||
} from '../plugin/index.ts';
|
||||
|
||||
/**
|
||||
@@ -15,51 +20,36 @@ import {
|
||||
*/
|
||||
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 Order = type('ShapeOrder', {
|
||||
id: u53,
|
||||
price: f64,
|
||||
qty: f64,
|
||||
side: enumOf(['buy', 'sell'] as const),
|
||||
tags: list(str),
|
||||
});
|
||||
|
||||
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),
|
||||
const d1 = Order.decode(Order.encode({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }));
|
||||
const d2 = Order.decode(Order.encode({ id: 999, price: 1e10, qty: 0, side: 'sell', tags: [] }));
|
||||
const d3 = Order.decode(
|
||||
Order.encode({ id: 2 ** 40, price: -1, qty: 1234, side: 'buy', tags: ['x', 'y', 'z'] }),
|
||||
);
|
||||
|
||||
expect(Object.keys(decoded1)).toEqual(expectedOrder);
|
||||
expect(Object.keys(decoded2)).toEqual(expectedOrder);
|
||||
expect(Object.keys(decoded3)).toEqual(expectedOrder);
|
||||
expect(Object.keys(d1)).toEqual(expectedOrder);
|
||||
expect(Object.keys(d2)).toEqual(expectedOrder);
|
||||
expect(Object.keys(d3)).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 T = type('Types', { a: u32, b: f64, c: str, d: bool });
|
||||
|
||||
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),
|
||||
);
|
||||
const a = T.decode(T.encode({ a: 1, b: 1.5, c: 'a', d: true }));
|
||||
const b = T.decode(T.encode({ a: 0, b: 0, c: '', d: false }));
|
||||
expect(types(a)).toEqual(types(b));
|
||||
expect(types(a)).toEqual([
|
||||
['a', 'number'],
|
||||
@@ -71,35 +61,28 @@ test('decoded value types are consistent across instances', () => {
|
||||
|
||||
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 Price = type('SPrice', { value: f64, scale: u8 });
|
||||
const Order = type('SOrder', { id: u53, price: Price, qty: f64 });
|
||||
|
||||
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));
|
||||
const d1 = Order.decode(Order.encode(v));
|
||||
const d2 = Order.decode(Order.encode({ ...v, id: 99 }));
|
||||
|
||||
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']);
|
||||
expect(Object.keys(d1.price)).toEqual(['value', 'scale']);
|
||||
expect(Object.keys(d2.price)).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 Event = oneOf('SEvent', 'kind', {
|
||||
a: { x: u32 },
|
||||
b: { y: 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));
|
||||
const ea = Event.decode(Event.encode({ kind: 'a', x: 1 } as never)) as Record<string, unknown>;
|
||||
const eb = Event.decode(Event.encode({ kind: 'b', y: 2.5 } as never)) as Record<string, unknown>;
|
||||
expect(Object.keys(ea)[0]).toBe('kind');
|
||||
expect(Object.keys(eb)[0]).toBe('kind');
|
||||
});
|
||||
|
||||
@@ -161,6 +161,67 @@ export const T = type('TxSmoke', { x: u53, y: f64 });
|
||||
expect(result.code).not.toContain("type('TxSmoke'");
|
||||
});
|
||||
|
||||
test('transformer: class with [Serializable] — decoder returns class instances', async () => {
|
||||
const src = `
|
||||
import { type, Serializable, registerClass, f64, enumOf } from '../plugin/index.ts';
|
||||
|
||||
export class TxPos {
|
||||
side;
|
||||
qty;
|
||||
entryPrice;
|
||||
|
||||
static [Serializable] = type('TxPos', {
|
||||
side: enumOf(['long', 'short']),
|
||||
qty: f64,
|
||||
entryPrice: f64,
|
||||
});
|
||||
|
||||
get notional() { return this.qty * this.entryPrice; }
|
||||
}
|
||||
|
||||
export const TxPosCodec = registerClass(TxPos);
|
||||
`;
|
||||
const mod = await transformAndImport(src);
|
||||
const TxPos = mod.TxPos as new () => {
|
||||
side: 'long' | 'short';
|
||||
qty: number;
|
||||
entryPrice: number;
|
||||
notional: number;
|
||||
};
|
||||
const codec = mod.TxPosCodec as {
|
||||
encode: (v: unknown) => Uint8Array;
|
||||
decode: (b: Uint8Array) => { side: 'long' | 'short'; qty: number; entryPrice: number };
|
||||
};
|
||||
|
||||
const v = { side: 'long' as const, qty: 2, entryPrice: 100 };
|
||||
const bytes = codec.encode(v);
|
||||
const back = codec.decode(bytes);
|
||||
|
||||
// Decoded value must be an actual TxPos instance — methods/getters work.
|
||||
expect(back instanceof TxPos).toBe(true);
|
||||
expect((back as InstanceType<typeof TxPos>).notional).toBe(200);
|
||||
expect(back.side).toBe('long');
|
||||
expect(back.qty).toBe(2);
|
||||
});
|
||||
|
||||
test('transformer: registerClass(X) is rewritten to X[Serializable]', () => {
|
||||
const src = `
|
||||
import { type, Serializable, registerClass, f64 } from '../plugin/index.ts';
|
||||
|
||||
export class TxBox {
|
||||
static [Serializable] = type('TxBox', { v: f64 });
|
||||
}
|
||||
|
||||
export const TxBoxCodec = registerClass(TxBox);
|
||||
`;
|
||||
const result = transform(src, 'test.ts', {
|
||||
importPath: '../plugin/index.ts',
|
||||
packageAliases: ['../plugin/index.ts'],
|
||||
});
|
||||
expect(result.code).toContain('TxBox[Serializable]');
|
||||
expect(result.code).not.toContain('registerClass(TxBox)');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (let i = 1; i <= counter; i++) {
|
||||
const file = join(GEN_DIR, `__gen_${i}.ts`);
|
||||
|
||||
Reference in New Issue
Block a user