feat(serializer): add class aot serialization support

This commit is contained in:
2026-05-21 17:24:42 +07:00
parent f327e64a6a
commit 720b8fbe2f
15 changed files with 736 additions and 541 deletions
+177
View File
@@ -18,6 +18,7 @@ import {
enumOf,
flags,
Writer,
Serializable,
} from '@perf/serializer';
// ─── Tee output to console + <pre id="out"> if we're in a browser ──────────
@@ -206,3 +207,179 @@ const dispatched1 = proto.decode(framedTicker);
const dispatched2 = proto.decode(framedBook);
log(' dispatched ticker symbol:', (dispatched1 as Ticker).symbol);
log(' dispatched book bids[0]:', (dispatched2 as typeof book).bids[0]);
// ─── Example 7: class with [Symbol.serializable] contract ──────────────────
//
// Attach the schema to a class via the well-known `Symbol.serializable`. The
// AOT plugin sees this and:
// • generates a class-aware decoder that returns `Object.create(Position.prototype)`
// instances, so methods/getters work on decoded values;
// • auto-registers the codec into the runtime registry on module load — no
// `registerClass(Position)` call needed;
// • makes `Position[Serializable]` the codec itself, usable directly.
class Position {
side!: 'long' | 'short';
qty!: number;
entryPrice!: number;
static [Serializable] = type('Position', {
side: enumOf(['long', 'short'] as const),
qty: f64,
entryPrice: f64,
});
get notional(): number {
return this.qty * this.entryPrice;
}
pnl(currentPrice: number): number {
return this.side === 'long'
? (currentPrice - this.entryPrice) * this.qty
: (this.entryPrice - currentPrice) * this.qty;
}
}
const pos = Object.assign(new Position(), {
side: 'long' as const,
qty: 2,
entryPrice: 67500,
});
// `Position[Serializable]` IS the codec — no `registerClass(...)` needed.
const PositionCodec = Position[Serializable]!;
const posBytes = PositionCodec.encode(pos);
const back = PositionCodec.decode(posBytes) as Position;
log('\nExample 7: class with Symbol.serializable');
log(` encoded: ${posBytes.length}b`);
log(` pos.notional: ${pos.notional}`);
log(` back.notional: ${back.notional} (method works on decoded value after AOT)`);
log(` back instanceof Position: ${back instanceof Position}`);
log(` back.pnl(68000): ${back.pnl(68000)}`);
// ─── Example 8: deeply-nested portfolio (5 levels, ~770 objects) ────────────
//
// Realistic deep-tree data: Portfolio → Accounts → Holdings → Trades →
// Executions. Demonstrates that the codec handles arbitrary nesting; under AOT
// the inner-loop encoder/decoder is fully inlined — no per-level function
// dispatch.
const Execution = type('Execution', {
id: u53,
ts: u53,
price: f64,
qty: f64,
venue: enumOf(['NYSE', 'NASDAQ', 'BATS', 'IEX'] as const),
});
const Trade = type('Trade', {
id: u53,
symbol: str,
side: enumOf(['buy', 'sell'] as const),
executions: list(Execution),
});
const Holding = type('Holding', {
symbol: str,
qty: f64,
avgEntry: f64,
trades: list(Trade),
});
const Account = type('Account', {
id: u53,
name: str,
currency: enumOf(['USD', 'EUR', 'GBP'] as const),
balance: f64,
holdings: list(Holding),
});
const Portfolio = type('Portfolio', {
ownerId: u53,
ts: u53,
accounts: list(Account),
});
type PortfolioT = typeof Portfolio.$infer;
function buildPortfolio(): PortfolioT {
const venues = ['NYSE', 'NASDAQ', 'BATS', 'IEX'] as const;
const symbols = ['AAPL', 'MSFT', 'GOOG', 'AMZN', 'TSLA'] as const;
const currencies = ['USD', 'EUR', 'GBP'] as const;
return {
ownerId: 100001,
ts: 1716100000000,
accounts: Array.from({ length: 3 }, (_, a) => ({
id: 10 + a,
name: `Account-${a + 1}`,
currency: currencies[a]!,
balance: 100_000 * (a + 1),
holdings: Array.from({ length: 5 }, (_, p) => ({
symbol: symbols[p % symbols.length]!,
qty: 100 * (p + 1),
avgEntry: 100 + p * 25,
trades: Array.from({ length: 10 }, (_, t) => ({
id: a * 1000 + p * 100 + t,
symbol: symbols[(a + p + t) % symbols.length]!,
side: t % 2 === 0 ? ('buy' as const) : ('sell' as const),
executions: Array.from({ length: 4 }, (_, e) => ({
id: a * 10000 + p * 1000 + t * 10 + e,
ts: 1716100000000 + (a * 100 + p * 50 + t * 5 + e) * 1000,
price: 100 + ((a + p + t + e) % 100) * 2,
qty: 5 + ((p + t + e) % 50),
venue: venues[e % 4]!,
})),
})),
})),
})),
};
}
const portfolio = buildPortfolio();
// Counts at each level.
const nAccounts = portfolio.accounts.length;
const nHoldings = portfolio.accounts.reduce((s, a) => s + a.holdings.length, 0);
const nTrades = portfolio.accounts.reduce(
(s, a) => s + a.holdings.reduce((ss, h) => ss + h.trades.length, 0),
0,
);
const nExecs = portfolio.accounts.reduce(
(s, a) =>
s +
a.holdings.reduce(
(ss, h) => ss + h.trades.reduce((sss, t) => sss + t.executions.length, 0),
0,
),
0,
);
const tEnc0 = performance.now();
const portfolioBytes = Portfolio.encode(portfolio);
const tEnc1 = performance.now();
const portfolioJSON = JSON.stringify(portfolio);
const tEnc2 = performance.now();
const decodedPortfolio = Portfolio.decode(portfolioBytes);
const tEnc3 = performance.now();
JSON.parse(portfolioJSON);
const tEnc4 = performance.now();
const total = 1 + nAccounts + nHoldings + nTrades + nExecs;
log('\nExample 8: deeply-nested portfolio (5 levels)');
log(
` ${nAccounts} accounts × ${nHoldings / nAccounts} holdings × ${nTrades / nHoldings} trades × ${nExecs / nTrades} executions = ${total} objects total`,
);
log(
` encoded: ${portfolioBytes.length}b in ${(tEnc1 - tEnc0).toFixed(2)}ms | JSON: ${portfolioJSON.length}b in ${(tEnc2 - tEnc1).toFixed(2)}ms (${((portfolioBytes.length / portfolioJSON.length) * 100).toFixed(0)}% of JSON)`,
);
log(
` decoded: ${(tEnc3 - tEnc2).toFixed(2)}ms | JSON.parse: ${(tEnc4 - tEnc3).toFixed(2)}ms`,
);
const sampleExec = decodedPortfolio.accounts[0]!.holdings[0]!.trades[0]!.executions[0]!;
const origExec = portfolio.accounts[0]!.holdings[0]!.trades[0]!.executions[0]!;
log(
` round-trip preserves leaf data: ${sampleExec.id === origExec.id && sampleExec.venue === origExec.venue}`,
);