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
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -10,6 +10,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"bench": "vitest bench --run", "bench": "vitest bench --run",
+40 -9
View File
@@ -670,7 +670,20 @@ function emitDec(schema: AnySchema, ctx: Ctx): { pre: string; expr: string } {
const BARE_IDENT = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; const BARE_IDENT = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
export function compileObject(schema: ObjectSchema): CodegenResult { export interface CompileOptions {
/**
* If set, the generated decoder builds the result with
* `Object.create(${boundProtoExpr})` and assigns fields one by one, so the
* decoded value is an instance of the class whose prototype this expression
* resolves to. When omitted, the decoder returns a plain object literal.
*/
boundProtoExpr?: string;
}
export function compileObject(
schema: ObjectSchema,
options: CompileOptions = {},
): CodegenResult {
const encCtx = new Ctx('enc'); const encCtx = new Ctx('enc');
const decCtx = new Ctx('dec'); const decCtx = new Ctx('dec');
@@ -681,23 +694,35 @@ export function compileObject(schema: ObjectSchema): CodegenResult {
} }
const encodeBody = encSeg.build(); const encodeBody = encSeg.build();
// Decoder body: emit as inline object literal in return statement. // Decoder body. For fields whose inner.expr is already a bare identifier
// For fields whose inner.expr is already a bare identifier (declared via inner.pre), // (declared via inner.pre), skip the wrapping `const tmp = expr;`.
// skip the wrapping `const tmp = expr;` and use the identifier directly.
let pre = ''; let pre = '';
const props: string[] = []; const pairs: Array<{ key: string; expr: string; rawName: string }> = [];
for (const fname of Object.keys(schema.fields)) { for (const fname of Object.keys(schema.fields)) {
const inner = emitDec(schema.fields[fname]!, decCtx); const inner = emitDec(schema.fields[fname]!, decCtx);
let expr: string;
if (inner.pre !== '' && BARE_IDENT.test(inner.expr)) { if (inner.pre !== '' && BARE_IDENT.test(inner.expr)) {
pre += inner.pre; pre += inner.pre;
props.push(`${JSON.stringify(fname)}: ${inner.expr}`); expr = inner.expr;
} else { } else {
const tmp = decCtx.fresh(`f_${sanitize(fname)}`); const tmp = decCtx.fresh(`f_${sanitize(fname)}`);
pre += `${inner.pre} const ${tmp} = ${inner.expr};`; pre += `${inner.pre} const ${tmp} = ${inner.expr};`;
props.push(`${JSON.stringify(fname)}: ${tmp}`); expr = tmp;
} }
pairs.push({ key: JSON.stringify(fname), expr, rawName: fname });
}
let decodeBody: string;
if (options.boundProtoExpr) {
// Class-bound: build via Object.create(proto) + sequential assigns so the
// result satisfies `instanceof` and inherits prototype methods.
const out = decCtx.fresh('out');
const assigns = pairs.map((p) => `${out}[${p.key}] = ${p.expr};`).join('');
decodeBody = `${pre} const ${out} = Object.create(${options.boundProtoExpr}); ${assigns} r.pos = pos; return ${out};`;
} else {
const literal = pairs.map((p) => `${p.key}: ${p.expr}`).join(', ');
decodeBody = `${pre} r.pos = pos; return { ${literal} };`;
} }
const decodeBody = `${pre} r.pos = pos; return { ${props.join(', ')} };`;
const deps = new Map<string, { mode: 'enc' | 'dec'; targetName: string }>(); const deps = new Map<string, { mode: 'enc' | 'dec'; targetName: string }>();
for (const [k, v] of encCtx.deps) deps.set(k, v); for (const [k, v] of encCtx.deps) deps.set(k, v);
@@ -708,7 +733,13 @@ export function compileObject(schema: ObjectSchema): CodegenResult {
return { encodeBody, decodeBody, deps, closure }; return { encodeBody, decodeBody, deps, closure };
} }
export function compileUnion(schema: UnionSchema): CodegenResult { export function compileUnion(
schema: UnionSchema,
_options: CompileOptions = {},
): CodegenResult {
// Class-bound unions are not yet supported (each variant would need its own
// prototype binding). For now, ignore `boundProtoExpr` and emit a plain
// literal-based decoder.
const encCtx = new Ctx('enc'); const encCtx = new Ctx('enc');
const decCtx = new Ctx('dec'); const decCtx = new Ctx('dec');
+138 -47
View File
@@ -7,9 +7,18 @@
* that constructs the codec inline — no runtime `new Function`, no codegen * that constructs the codec inline — no runtime `new Function`, no codegen
* module needed at runtime. * module needed at runtime.
* *
* Also detects class declarations with `static [Serializable] = type(...)`:
* - the AOT codec's decoder uses `Object.create(ClassName.prototype)` so
* decoded values are real `instanceof ClassName` instances with prototype
* methods working;
* - the codec auto-registers itself into the runtime registry on module
* load (so `deserialize(bytes)` dispatches by id);
* - top-level `const X = registerClass(ClassName)` calls are rewritten to
* `const X = ClassName[Serializable]` so users can drop the call entirely.
*
* Scope (v1): * Scope (v1):
* - Same-file only (no cross-file schema references). * - Same-file only (no cross-file schema references).
* - Top-level `const X = type(...)` declarations (including `export const`). * - Top-level `const X = type(...)` and `static [Serializable] = type(...)`.
* - Field values may be: * - Field values may be:
* • imported primitive markers (u8 … f64, bool, str, bytes, *Array) * • imported primitive markers (u8 … f64, bool, str, bytes, *Array)
* • calls to imported combinators (list, opt, enumOf, flags, tuple) * • calls to imported combinators (list, opt, enumOf, flags, tuple)
@@ -22,16 +31,9 @@ import { parseSync } from 'oxc-parser';
import MagicString from 'magic-string'; import MagicString from 'magic-string';
import { compileObject, compileUnion } from '../codegen.ts'; import { compileObject, compileUnion } from '../codegen.ts';
import { s } from '../schema.ts'; import { s } from '../schema.ts';
import type { import type { AnySchema, ObjectSchema, UnionSchema } from '../descriptors.ts';
AnySchema,
ObjectSchema,
UnionSchema,
} from '../descriptors.ts';
const PKG_NAMES = new Set([ const PKG_NAMES = new Set(['@perf/serializer', '@perf/serializer/index']);
'@perf/serializer',
'@perf/serializer/index',
]);
interface ImportInfo { interface ImportInfo {
bindings: Map<string, string>; bindings: Map<string, string>;
@@ -41,10 +43,6 @@ interface CompiledCodec {
schemaName: string; schemaName: string;
schemaKind: 'object' | 'union'; schemaKind: 'object' | 'union';
fieldsDescriptor: string; fieldsDescriptor: string;
encodeBody: string;
decodeBody: string;
closure: Map<string, unknown>;
deps: Map<string, { mode: 'enc' | 'dec'; targetName: string }>;
id: number; id: number;
} }
@@ -64,8 +62,6 @@ const PRIMITIVES = new Set([
'f32Array', 'f64Array', 'u8Array', 'u16Array', 'u32Array', 'i32Array', 'f32Array', 'f64Array', 'u8Array', 'u16Array', 'u32Array', 'i32Array',
]); ]);
// oxc AST nodes vary widely per `type`; we treat them as loosely-typed objects
// and check `.type` before reading per-shape fields.
type AnyNode = Record<string, any>; type AnyNode = Record<string, any>;
function collectImportsFromSet(program: AnyNode, aliases: Set<string>): ImportInfo { function collectImportsFromSet(program: AnyNode, aliases: Set<string>): ImportInfo {
@@ -196,27 +192,58 @@ function collectFields(obj: AnyNode, scope: Scope): Record<string, AnySchema> |
interface TypeCallInfo { interface TypeCallInfo {
call: AnyNode; call: AnyNode;
/** Const name for `const X = type(...)` or class name for `class X { static [Serializable] = type(...) }`. */
declName: string; declName: string;
fn: 'type' | 'oneOf'; fn: 'type' | 'oneOf';
/** If set, the call is a static-field initializer inside this class. */
boundClass?: string;
}
function unwrapStmt(stmt: AnyNode): AnyNode {
if (stmt.type === 'ExportNamedDeclaration' && stmt.declaration) return stmt.declaration as AnyNode;
if (stmt.type === 'ExportDefaultDeclaration' && stmt.declaration) return stmt.declaration as AnyNode;
return stmt;
} }
function findTypeCalls(program: AnyNode, imports: ImportInfo): TypeCallInfo[] { function findTypeCalls(program: AnyNode, imports: ImportInfo): TypeCallInfo[] {
const calls: TypeCallInfo[] = []; const calls: TypeCallInfo[] = [];
for (const topStmt of program.body as AnyNode[]) { for (const topStmt of program.body as AnyNode[]) {
const stmt: AnyNode = const stmt = unwrapStmt(topStmt);
topStmt.type === 'ExportNamedDeclaration' && topStmt.declaration
? (topStmt.declaration as AnyNode) // Top-level `const X = type(...)` / `const X = oneOf(...)`
: topStmt; if (stmt.type === 'VariableDeclaration') {
if (stmt.type !== 'VariableDeclaration') continue; for (const decl of stmt.declarations as AnyNode[]) {
for (const decl of stmt.declarations as AnyNode[]) { const id = decl.id as AnyNode;
const id = decl.id as AnyNode; const init = decl.init as AnyNode | null;
const init = decl.init as AnyNode | null; if (!init || id.type !== 'Identifier' || init.type !== 'CallExpression') continue;
if (!init || id.type !== 'Identifier' || init.type !== 'CallExpression') continue; const callee = init.callee as AnyNode;
const callee = init.callee as AnyNode; if (callee.type !== 'Identifier') continue;
if (callee.type !== 'Identifier') continue; const exported = imports.bindings.get(callee.name as string);
const exported = imports.bindings.get(callee.name as string); if (exported === 'type' || exported === 'oneOf') {
if (exported === 'type' || exported === 'oneOf') { calls.push({ call: init, declName: id.name as string, fn: exported });
calls.push({ call: init, declName: id.name as string, fn: exported }); }
}
continue;
}
// `class X { static [Serializable] = type(...) }`
if (stmt.type === 'ClassDeclaration') {
const className = (stmt.id as AnyNode | null)?.name as string | undefined;
if (!className) continue;
const body = stmt.body as AnyNode;
for (const member of body.body as AnyNode[]) {
if (member.type !== 'PropertyDefinition' || !member.static || !member.computed) continue;
const key = member.key as AnyNode;
if (key.type !== 'Identifier') continue;
const exported = imports.bindings.get(key.name as string);
if (exported !== 'Serializable') continue;
const value = member.value as AnyNode | null;
if (!value || value.type !== 'CallExpression') continue;
const callee = value.callee as AnyNode;
if (callee.type !== 'Identifier') continue;
const fnName = imports.bindings.get(callee.name as string);
if (fnName !== 'type' && fnName !== 'oneOf') continue;
calls.push({ call: value, declName: className, fn: fnName, boundClass: className });
} }
} }
} }
@@ -298,7 +325,11 @@ function compileCall(
const schema = buildSchemaFromTypeCall(info, scope); const schema = buildSchemaFromTypeCall(info, scope);
if (!schema) return null; if (!schema) return null;
const cg = schema.kind === 'object' ? compileObject(schema) : compileUnion(schema); const protoExpr = info.boundClass ? `${info.boundClass}.prototype` : undefined;
const cg =
schema.kind === 'object'
? compileObject(schema, { boundProtoExpr: protoExpr })
: compileUnion(schema);
const id = fnv1a16(schema.name); const id = fnv1a16(schema.name);
const fname = sanitize(schema.name); const fname = sanitize(schema.name);
if (cg.deps.size > 0) return null; // ref/codec deps not supported yet if (cg.deps.size > 0) return null; // ref/codec deps not supported yet
@@ -326,7 +357,7 @@ function compileCall(
${cg.decodeBody} ${cg.decodeBody}
} }
const __desc = ${descriptorLit}; const __desc = ${descriptorLit};
const __codec = { const __codec = Object.freeze({
...__desc, ...__desc,
id: ${id}, id: ${id},
encode(v, into) { encode(v, into) {
@@ -342,10 +373,8 @@ function compileCall(
encodeInto(v, w) { encode_${fname}(w, v); }, encodeInto(v, w) { encode_${fname}(w, v); },
decodeFrom: decode_${fname}, decodeFrom: decode_${fname},
$infer: undefined, $infer: undefined,
}; });
Object.freeze(__codec); return __serRegisterPrecompiled(__codec);
__serRegisterPrecompiled(__codec, encode_${fname}, decode_${fname});
return __codec;
})()`; })()`;
return { return {
@@ -354,10 +383,6 @@ function compileCall(
schemaName: schema.name, schemaName: schema.name,
schemaKind: schema.kind, schemaKind: schema.kind,
fieldsDescriptor: descriptorLit, fieldsDescriptor: descriptorLit,
encodeBody: cg.encodeBody,
decodeBody: cg.decodeBody,
closure: cg.closure,
deps: cg.deps,
id, id,
}, },
}; };
@@ -384,14 +409,69 @@ function serializeClosureValue(v: unknown): string {
function makePrelude(importPath: string): string { function makePrelude(importPath: string): string {
return ` return `
import { Writer as __SerWriter, Reader as __SerReader } from ${JSON.stringify(importPath)}; import { Writer as __SerWriter, Reader as __SerReader, __registerPrecompiled as __serRegisterPrecompiled } from ${JSON.stringify(importPath)};
const __serRegistry = (globalThis.__serRegistry ??= new Map());
function __serRegisterPrecompiled(codec, enc, dec) {
__serRegistry.set(codec.id, codec);
}
`; `;
} }
/**
* Rewrite `const X = registerClass(Y)` (and statement-form `registerClass(Y)`)
* into a direct reference to the class's pre-attached codec. With the AOT codec
* already living at `Y[Serializable]`, the runtime `registerClass` call is
* redundant — we replace it so users can drop it entirely.
*/
function rewriteRegisterClassCalls(
program: AnyNode,
imports: ImportInfo,
ms: MagicString,
): number {
const serializableLocal = [...imports.bindings.entries()].find(
([, e]) => e === 'Serializable',
)?.[0];
// If user hasn't imported Serializable, they can't have written `static [Serializable] = ...`,
// so any registerClass(X) call is using the runtime path. Leave it alone.
if (!serializableLocal) return 0;
let count = 0;
for (const topStmt of program.body as AnyNode[]) {
const stmt = unwrapStmt(topStmt);
// `const X = registerClass(Y)` (or `let`/`var`)
if (stmt.type === 'VariableDeclaration') {
for (const decl of stmt.declarations as AnyNode[]) {
const init = decl.init as AnyNode | null;
if (!init || init.type !== 'CallExpression') continue;
if (!isRegisterClassCall(init, imports)) continue;
const arg = (init.arguments as AnyNode[])[0];
if (!arg || arg.type !== 'Identifier') continue;
ms.overwrite(
init.start as number,
init.end as number,
`${arg.name}[${serializableLocal}]`,
);
count++;
}
continue;
}
// Bare `registerClass(Y);` expression-statement
if (stmt.type === 'ExpressionStatement') {
const expr = stmt.expression as AnyNode;
if (expr.type !== 'CallExpression') continue;
if (!isRegisterClassCall(expr, imports)) continue;
// No-op now — codec already self-registers on class init.
ms.overwrite(stmt.start as number, stmt.end as number, '/* registerClass elided */');
count++;
}
}
return count;
}
function isRegisterClassCall(call: AnyNode, imports: ImportInfo): boolean {
const callee = call.callee as AnyNode;
if (callee.type !== 'Identifier') return false;
return imports.bindings.get(callee.name as string) === 'registerClass';
}
export interface TransformOptions { export interface TransformOptions {
importPath?: string; importPath?: string;
packageAliases?: string[]; packageAliases?: string[];
@@ -402,7 +482,11 @@ export interface TransformResult {
transformedCount: number; transformedCount: number;
} }
export function transform(source: string, filename = 'input.ts', options: TransformOptions = {}): TransformResult { export function transform(
source: string,
filename = 'input.ts',
options: TransformOptions = {},
): TransformResult {
const importPath = options.importPath ?? '@perf/serializer'; const importPath = options.importPath ?? '@perf/serializer';
const aliases = new Set<string>(PKG_NAMES); const aliases = new Set<string>(PKG_NAMES);
for (const a of options.packageAliases ?? []) aliases.add(a); for (const a of options.packageAliases ?? []) aliases.add(a);
@@ -420,7 +504,10 @@ export function transform(source: string, filename = 'input.ts', options: Transf
let hasTypeImport = false; let hasTypeImport = false;
for (const v of imports.bindings.values()) { for (const v of imports.bindings.values()) {
if (v === 'type' || v === 'oneOf') { hasTypeImport = true; break; } if (v === 'type' || v === 'oneOf') {
hasTypeImport = true;
break;
}
} }
if (!hasTypeImport) return { code: source, transformedCount: 0 }; if (!hasTypeImport) return { code: source, transformedCount: 0 };
@@ -452,6 +539,10 @@ export function transform(source: string, filename = 'input.ts', options: Transf
} }
if (transformedCount === 0) return { code: source, transformedCount: 0 }; if (transformedCount === 0) return { code: source, transformedCount: 0 };
// Now that codecs are inlined, rewrite registerClass(X) → X[Serializable].
rewriteRegisterClassCalls(program, imports, ms);
ms.prepend(makePrelude(importPath)); ms.prepend(makePrelude(importPath));
return { code: ms.toString(), transformedCount }; return { code: ms.toString(), transformedCount };
} }
+7 -28
View File
@@ -1,4 +1,4 @@
// ── Simplified façade (recommended) ──────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────────────
export { export {
type, type,
oneOf, oneOf,
@@ -10,32 +10,11 @@ export {
} from './api.ts'; } from './api.ts';
export type { TypeCodec, InferType, Router } from './api.ts'; export type { TypeCodec, InferType, Router } from './api.ts';
// ── Low-level API (advanced) ─────────────────────────────────────────────── // ── Low-level (writer/reader for hot paths, framing primitives) ────────────
export { Writer, Reader } from './io.ts'; export { Writer, Reader } from './io.ts';
export { s, defineSchema } from './schema.ts';
export type { SchemaBuilder } from './schema.ts'; // ── Class contract ─────────────────────────────────────────────────────────
export { Serializable } from './symbol.ts'; export { Serializable } from './symbol.ts';
export {
register, // ── Test / AOT helpers ─────────────────────────────────────────────────────
registerClass, export { clearRegistry, __registerPrecompiled } from './register.ts';
serialize,
deserialize,
clearRegistry,
} from './register.ts';
export type { Codec } from './register.ts';
export type {
AnySchema,
ObjectSchema,
UnionSchema,
ArraySchema,
OptionalSchema,
EnumSchema,
BitsetSchema,
TupleSchema,
RefSchema,
CodecSchema,
PrimitiveSchema,
TypedArraySchema,
PrimitiveKind,
TypedArrayKind,
} from './descriptors.ts';
+24 -45
View File
@@ -1,7 +1,6 @@
import { compileObject, compileUnion } from './codegen.ts'; import { compileObject, compileUnion } from './codegen.ts';
import type { AnySchema, ObjectSchema, UnionSchema } from './descriptors.ts'; import type { AnySchema, ObjectSchema, UnionSchema } from './descriptors.ts';
import { Reader, Writer } from './io.ts'; import type { Reader, Writer } from './io.ts';
import { Serializable } from './symbol.ts';
export interface Codec<T = unknown> { export interface Codec<T = unknown> {
readonly id: number; readonly id: number;
@@ -14,7 +13,6 @@ type AnyCodec = Codec<any>;
const byName = new Map<string, AnyCodec>(); const byName = new Map<string, AnyCodec>();
const byId = new Map<number, AnyCodec>(); const byId = new Map<number, AnyCodec>();
const byCtor = new WeakMap<object, AnyCodec>();
function fnv1a16(s: string): number { function fnv1a16(s: string): number {
let h = 0x811c9dc5; let h = 0x811c9dc5;
@@ -57,6 +55,10 @@ function sanIdent(name: string): string {
return name.replace(/[^A-Za-z0-9_]/g, '_'); return name.replace(/[^A-Za-z0-9_]/g, '_');
} }
/**
* Compile a schema into a `Codec` and register it in the lookup tables. Used
* internally by `type(...)` / `oneOf(...)`. Idempotent by schema name.
*/
export function register<T = unknown>(schema: ObjectSchema | UnionSchema): Codec<T> { export function register<T = unknown>(schema: ObjectSchema | UnionSchema): Codec<T> {
const existing = byName.get(schema.name); const existing = byName.get(schema.name);
if (existing) return existing as Codec<T>; if (existing) return existing as Codec<T>;
@@ -149,49 +151,26 @@ return function decode_${fname}(r) {
return codec; return codec;
} }
export function registerClass<T>(Ctor: new (...args: never[]) => T): Codec<T> { /** Reset the global codec registry. Test helper. */
const cached = byCtor.get(Ctor);
if (cached) return cached as Codec<T>;
const schema = (Ctor as unknown as Record<symbol, unknown>)[Serializable] as
| ObjectSchema
| UnionSchema
| undefined;
if (!schema) {
throw new Error(`${Ctor.name} has no [Serializable] schema`);
}
const codec = register<T>(schema);
byCtor.set(Ctor, codec);
return codec;
}
/**
* Encode a value into a framed Uint8Array (2-byte schema ID + body).
* If a Writer is passed, returns a view; otherwise returns a fresh copy.
*/
export function serialize<T>(value: T, codec: Codec<T>, writer?: Writer): Uint8Array {
if (writer) {
writer.u16(codec.id);
codec.encode(writer, value);
return writer.bytes();
}
const w = new Writer();
w.u16(codec.id);
codec.encode(w, value);
return w.bytesCopy();
}
/**
* Decode a framed Uint8Array by looking up its schema ID.
*/
export function deserialize<T = unknown>(bytes: Uint8Array): T {
const r = new Reader(bytes);
const id = r.u16();
const codec = byId.get(id);
if (!codec) throw new Error(`Unknown schema ID: 0x${id.toString(16)}`);
return codec.decode(r) as T;
}
export function clearRegistry(): void { export function clearRegistry(): void {
byName.clear(); byName.clear();
byId.clear(); byId.clear();
} }
/**
* Internal: AOT-generated codecs use this to inject themselves into the
* runtime registry on module load. Not meant for user code.
*/
export function __registerPrecompiled<T = unknown>(codec: Codec<T>): Codec<T> {
const existing = byName.get(codec.name);
if (existing) return existing as Codec<T>;
const idExisting = byId.get(codec.id);
if (idExisting && idExisting.name !== codec.name) {
throw new Error(
`Schema ID collision: "${codec.name}" and "${idExisting.name}" both hash to 0x${codec.id.toString(16)}`,
);
}
byName.set(codec.name, codec as AnyCodec);
byId.set(codec.id, codec as AnyCodec);
return codec;
}
-26
View File
@@ -189,112 +189,96 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-gnu@0.132.0': '@oxc-parser/binding-linux-arm64-gnu@0.132.0':
resolution: {integrity: sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw==} resolution: {integrity: sha512-sQBix5P2cW+IpzTcCwYxnh9yALrKSIkKJThspBvMGcygSMnbzkSvhN7SfuX1hvBk8y1XEChsdkU3ET0V5DmzUw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.126.0': '@oxc-parser/binding-linux-arm64-musl@0.126.0':
resolution: {integrity: sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==} resolution: {integrity: sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-arm64-musl@0.132.0': '@oxc-parser/binding-linux-arm64-musl@0.132.0':
resolution: {integrity: sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw==} resolution: {integrity: sha512-WozHg3Kc//8Sk756HXXgMbEAvqtG+Lzb9JOojwQzIGDtN78Az2dLttkb71akWYUF/8IgYfDSlfKh4Uot8is5Vw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.126.0': '@oxc-parser/binding-linux-ppc64-gnu@0.126.0':
resolution: {integrity: sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==} resolution: {integrity: sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-ppc64-gnu@0.132.0': '@oxc-parser/binding-linux-ppc64-gnu@0.132.0':
resolution: {integrity: sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA==} resolution: {integrity: sha512-CmX/ulNBOEwWTyVRmcpYKAcAizW6+OjtLJgo7fXoL9OqQvjF4VER8tPomv44vwzfSCy1BHbsB0ZlZYzYJNj4cA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.126.0': '@oxc-parser/binding-linux-riscv64-gnu@0.126.0':
resolution: {integrity: sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==} resolution: {integrity: sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.132.0': '@oxc-parser/binding-linux-riscv64-gnu@0.132.0':
resolution: {integrity: sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA==} resolution: {integrity: sha512-j9oQS+hM90SdhviNGWbPgT4+Rlq+ac++q/zjgwPD1mVHgxHzATvoRGtDx0sXGmFOQ9J9YkwAhYGb5MAHL6TAsA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.126.0': '@oxc-parser/binding-linux-riscv64-musl@0.126.0':
resolution: {integrity: sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==} resolution: {integrity: sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-riscv64-musl@0.132.0': '@oxc-parser/binding-linux-riscv64-musl@0.132.0':
resolution: {integrity: sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg==} resolution: {integrity: sha512-bLz+Xi+Agnfmd7kWPEsSVwCn2k4EyIalZkNBcQ0OGIv9rqn8VgCPLNd03tM9mKX/5TdlvDXalz0q71BIrOPNqg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.126.0': '@oxc-parser/binding-linux-s390x-gnu@0.126.0':
resolution: {integrity: sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==} resolution: {integrity: sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-s390x-gnu@0.132.0': '@oxc-parser/binding-linux-s390x-gnu@0.132.0':
resolution: {integrity: sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ==} resolution: {integrity: sha512-U6t2qbJU0ypTfyj9QV3W1Y6mITDTL8ai/OR6NUn85vyHthOvobKWgXzU4tu0EskSzlpuVFz1g0jFGulDIUKHxQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.126.0': '@oxc-parser/binding-linux-x64-gnu@0.126.0':
resolution: {integrity: sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==} resolution: {integrity: sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.132.0': '@oxc-parser/binding-linux-x64-gnu@0.132.0':
resolution: {integrity: sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg==} resolution: {integrity: sha512-WcEaSNHFk8yz5YFlQQAlhq6jOFmZBB/RKE7uzhyCIf+pF1Lmv9gUH4221mle2Gd9iHyWT3ySNph8yZgb1xYdWg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.126.0': '@oxc-parser/binding-linux-x64-musl@0.126.0':
resolution: {integrity: sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==} resolution: {integrity: sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-x64-musl@0.132.0': '@oxc-parser/binding-linux-x64-musl@0.132.0':
resolution: {integrity: sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ==} resolution: {integrity: sha512-iQrV4iJzQgRwK3BWRmQl1C3C6g3wYpXN2WLdQdyR+efoUnncdShZAVp9OgcojtlD3MDRbuOMGG3SjxF4fL4nlQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.126.0': '@oxc-parser/binding-openharmony-arm64@0.126.0':
resolution: {integrity: sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==} resolution: {integrity: sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==}
@@ -452,42 +436,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2': '@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.2': '@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.2': '@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.2': '@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.2': '@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.2': '@rolldown/binding-openharmony-arm64@1.0.2':
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
@@ -819,28 +797,24 @@ packages:
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0: lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0: lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0: lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'} engines: {node: '>= 12.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0: lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
+177
View File
@@ -18,6 +18,7 @@ import {
enumOf, enumOf,
flags, flags,
Writer, Writer,
Serializable,
} from '@perf/serializer'; } from '@perf/serializer';
// ─── Tee output to console + <pre id="out"> if we're in a browser ────────── // ─── 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); const dispatched2 = proto.decode(framedBook);
log(' dispatched ticker symbol:', (dispatched1 as Ticker).symbol); log(' dispatched ticker symbol:', (dispatched1 as Ticker).symbol);
log(' dispatched book bids[0]:', (dispatched2 as typeof book).bids[0]); 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}`,
);
+26 -28
View File
@@ -1,14 +1,14 @@
import { bench, describe } from 'vitest'; import { bench, describe } from 'vitest';
import { Reader, Writer, deserialize, serialize } from '../../plugin/index.ts'; import { Reader, Writer, router } from '../../plugin/index.ts';
import { import {
Book,
Order,
Ticker,
buildBook, buildBook,
buildOrder, buildOrder,
buildTicker, buildTicker,
registerAll,
} from './payloads.ts'; } from './payloads.ts';
const codecs = registerAll();
const ticker = buildTicker(); const ticker = buildTicker();
const order = buildOrder(); const order = buildOrder();
const book = buildBook(1000); const book = buildBook(1000);
@@ -23,11 +23,11 @@ const tickerJSON = JSON.stringify(ticker);
const orderJSON = JSON.stringify(order); const orderJSON = JSON.stringify(order);
const bookJSON = JSON.stringify(book); const bookJSON = JSON.stringify(book);
const tickerBin = serialize(ticker, codecs.ticker); const tickerBin = Ticker.encode(ticker);
const orderBin = serialize(order, codecs.order); const orderBin = Order.encode(order);
const bookBin = serialize(book, codecs.book); 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 // eslint-disable-next-line no-console
console.log( console.log(
'\n--- payload sizes ---\n' + '\n--- payload sizes ---\n' +
@@ -40,9 +40,9 @@ describe('encode ticker (5 fields)', () => {
bench('JSON.stringify', () => { bench('JSON.stringify', () => {
JSON.stringify(ticker); JSON.stringify(ticker);
}); });
bench('codec.encode (pooled)', () => { bench('codec.encodeInto (pooled)', () => {
wTicker.reset(); wTicker.reset();
codecs.ticker.encode(wTicker, ticker); Ticker.encodeInto(ticker, wTicker);
}); });
}); });
@@ -50,9 +50,9 @@ describe('encode order (10 fields + bitset)', () => {
bench('JSON.stringify', () => { bench('JSON.stringify', () => {
JSON.stringify(order); JSON.stringify(order);
}); });
bench('codec.encode (pooled)', () => { bench('codec.encodeInto (pooled)', () => {
wOrder.reset(); wOrder.reset();
codecs.order.encode(wOrder, order); Order.encodeInto(order, wOrder);
}); });
}); });
@@ -60,9 +60,9 @@ describe('encode book (1000 levels)', () => {
bench('JSON.stringify', () => { bench('JSON.stringify', () => {
JSON.stringify(book); JSON.stringify(book);
}); });
bench('codec.encode (pooled)', () => { bench('codec.encodeInto (pooled)', () => {
wBook.reset(); wBook.reset();
codecs.book.encode(wBook, book); Book.encodeInto(book, wBook);
}); });
}); });
@@ -70,10 +70,9 @@ describe('decode ticker', () => {
bench('JSON.parse', () => { bench('JSON.parse', () => {
JSON.parse(tickerJSON); JSON.parse(tickerJSON);
}); });
bench('codec.decode', () => { bench('codec.decodeFrom', () => {
const r = new Reader(tickerBin); const r = new Reader(tickerBin);
r.pos = 2; Ticker.decodeFrom(r);
codecs.ticker.decode(r);
}); });
}); });
@@ -81,10 +80,9 @@ describe('decode order', () => {
bench('JSON.parse', () => { bench('JSON.parse', () => {
JSON.parse(orderJSON); JSON.parse(orderJSON);
}); });
bench('codec.decode', () => { bench('codec.decodeFrom', () => {
const r = new Reader(orderBin); const r = new Reader(orderBin);
r.pos = 2; Order.decodeFrom(r);
codecs.order.decode(r);
}); });
}); });
@@ -92,10 +90,9 @@ describe('decode book (1000 levels)', () => {
bench('JSON.parse', () => { bench('JSON.parse', () => {
JSON.parse(bookJSON); JSON.parse(bookJSON);
}); });
bench('codec.decode', () => { bench('codec.decodeFrom', () => {
const r = new Reader(bookBin); const r = new Reader(bookBin);
r.pos = 2; Book.decodeFrom(r);
codecs.book.decode(r);
}); });
}); });
@@ -105,17 +102,18 @@ describe('roundtrip ticker', () => {
}); });
bench('codec (pooled)', () => { bench('codec (pooled)', () => {
wTicker.reset(); wTicker.reset();
codecs.ticker.encode(wTicker, ticker); Ticker.encodeInto(ticker, wTicker);
const r = new Reader(wTicker.bytes()); 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', () => { bench('JSON', () => {
JSON.parse(JSON.stringify(ticker)); JSON.parse(JSON.stringify(ticker));
}); });
bench('serialize/deserialize (framed)', () => { bench('router encode + decode (framed)', () => {
deserialize(serialize(ticker, codecs.ticker)); proto.decode(proto.encode(ticker, Ticker));
}); });
}); });
+51 -82
View File
@@ -1,73 +1,50 @@
import { defineSchema, register, s } from '../../plugin/index.ts'; import {
import type { Codec } from '../../plugin/index.ts'; type,
enumOf,
f64,
flags,
list,
str,
u53,
type TypeCodec,
} from '../../plugin/index.ts';
export const TickerSchema = defineSchema('BenchTicker', (s) => ({ export const Ticker = type('BenchTicker', {
symbol: s.str, symbol: str,
last: s.f64, last: f64,
bid: s.f64, bid: f64,
ask: s.f64, ask: f64,
volume: s.f64, volume: f64,
})); });
export const OrderSchema = defineSchema('BenchOrder', (s) => ({ export const Order = type('BenchOrder', {
id: s.u53, id: u53,
account: s.u53, account: u53,
symbol: s.str, symbol: str,
side: s.enum(['buy', 'sell'] as const), side: enumOf(['buy', 'sell'] as const),
type: s.enum(['limit', 'market', 'stop', 'stop_limit'] as const), type: enumOf(['limit', 'market', 'stop', 'stop_limit'] as const),
price: s.f64, price: f64,
qty: s.f64, qty: f64,
filledQty: s.f64, filledQty: f64,
ts: s.u53, ts: u53,
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const), flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
})); });
export const LevelSchema = defineSchema('BenchLevel', (s) => ({ export const Level = type('BenchLevel', { p: f64, q: f64 });
p: s.f64,
q: s.f64,
}));
export const BookSchema = defineSchema('BenchBook', (s) => ({ export const Book = type('BenchBook', {
symbol: s.str, symbol: str,
ts: s.u53, ts: u53,
bids: s.array(LevelSchema), bids: list(Level),
asks: s.array(LevelSchema), asks: list(Level),
})); });
export interface Ticker { export type TickerT = typeof Ticker.$infer;
symbol: string; export type OrderT = typeof Order.$infer;
last: number; export type LevelT = typeof Level.$infer;
bid: number; export type BookT = typeof Book.$infer;
ask: number;
volume: number;
}
export interface Order { export function buildTicker(): TickerT {
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 { return {
symbol: 'BTC-USD', symbol: 'BTC-USD',
last: 67891.23, last: 67891.23,
@@ -77,7 +54,7 @@ export function buildTicker(): Ticker {
}; };
} }
export function buildOrder(): Order { export function buildOrder(): OrderT {
return { return {
id: 9876543210, id: 9876543210,
account: 12345678, account: 12345678,
@@ -92,9 +69,9 @@ export function buildOrder(): Order {
}; };
} }
export function buildBook(depth: number): Book { export function buildBook(depth: number): BookT {
const bids: Level[] = new Array(depth); const bids: LevelT[] = new Array(depth);
const asks: Level[] = new Array(depth); const asks: LevelT[] = new Array(depth);
for (let i = 0; i < depth; i++) { for (let i = 0; i < depth; i++) {
bids[i] = { p: 67890 - i * 0.5, q: 0.1 + (i % 100) * 0.01 }; 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 }; 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 }; return { symbol: 'BTC-USD', ts: 1716100000123, bids, asks };
} }
export interface Codecs { export type AllCodecs = {
ticker: Codec<Ticker>; ticker: TypeCodec<TickerT>;
order: Codec<Order>; order: TypeCodec<OrderT>;
level: Codec<Level>; level: TypeCodec<LevelT>;
book: Codec<Book>; book: TypeCodec<BookT>;
} };
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 };
}
+29 -56
View File
@@ -1,79 +1,52 @@
import { test, expect } from 'vitest'; import { test, expect } from 'vitest';
import { import {
clearRegistry, clearRegistry,
defineSchema, enumOf,
deserialize, f64,
registerClass,
Serializable, Serializable,
serialize, type,
u53,
} from '../plugin/index.ts'; } from '../plugin/index.ts';
test('class with [Serializable] static schema registers and round-trips', () => { test('class with [Serializable] static codec round-trips', () => {
clearRegistry(); clearRegistry();
class Order { class Order {
id!: number; static [Serializable] = type('OrderClass', {
price!: number; id: u53,
qty!: number; price: f64,
side!: 'buy' | 'sell'; qty: f64,
side: enumOf(['buy', 'sell'] as const),
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 codec = Order[Serializable]!;
const v = { id: 42, price: 100.5, qty: 1.5, side: 'buy' as const }; const v = { id: 42, price: 100.5, qty: 1.5, side: 'buy' as const };
const bytes = serialize(v, codec); expect(codec.decode(codec.encode(v))).toEqual(v);
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', () => { test('Symbol.serializable is shared across module boundaries via Symbol.for', () => {
const looked = Symbol.for('@perf/serializable'); expect(Symbol.for('@perf/serializable')).toBe(Serializable);
expect(looked).toBe(Serializable);
}); });
test('codec.id is deterministic for the schema name', () => { test('codec.id is deterministic for the schema name', () => {
clearRegistry(); clearRegistry();
const A = defineSchema('SameName', (s) => ({ x: s.u8 }));
class A {
static [Serializable] = type('SameName', { x: u53 });
}
const idA = A[Serializable]!.id;
clearRegistry(); clearRegistry();
const codecA = registerClass( class B {
class extends Object { static [Serializable] = type('SameName', { y: f64 });
static [Serializable] = A; }
}, const idB = B[Serializable]!.id;
);
clearRegistry(); expect(idA).toBe(idB);
const codecB = registerClass( });
class extends Object {
static [Serializable] = A; test('class without [Serializable] has no codec', () => {
}, class Empty {}
); expect((Empty as unknown as Record<symbol, unknown>)[Serializable]).toBeUndefined();
expect(codecA.id).toBe(codecB.id);
}); });
+34 -34
View File
@@ -1,11 +1,16 @@
import { test, expect } from 'vitest'; import { test, expect } from 'vitest';
import { import {
clearRegistry, clearRegistry,
defineSchema, enumOf,
deserialize, f64,
register, flags,
s, list,
serialize, oneOf,
str,
type,
u32,
u53,
u8,
} from '../plugin/index.ts'; } from '../plugin/index.ts';
function rng(seed: number): () => number { function rng(seed: number): () => number {
@@ -34,8 +39,7 @@ function randFloat(): number {
} }
function randInt(maxBits = 32): number { function randInt(maxBits = 32): number {
const v = Math.floor(r() * 2 ** maxBits); return Math.floor(r() * 2 ** maxBits) >>> 0;
return v >>> 0;
} }
function randString(): string { function randString(): string {
@@ -47,14 +51,13 @@ function randString(): string {
test('fuzz: 2000 random ticker round-trips', () => { test('fuzz: 2000 random ticker round-trips', () => {
clearRegistry(); clearRegistry();
const Ticker = defineSchema('FuzzTicker', (s) => ({ const Ticker = type('FuzzTicker', {
symbol: s.str, symbol: str,
last: s.f64, last: f64,
volume: s.f64, volume: f64,
count: s.u32, count: u32,
asks: s.array(s.f64), asks: list(f64),
})); });
const codec = register(Ticker);
for (let i = 0; i < 2000; i++) { for (let i = 0; i < 2000; i++) {
const v = { const v = {
@@ -64,24 +67,22 @@ test('fuzz: 2000 random ticker round-trips', () => {
count: randInt(32), count: randInt(32),
asks: Array.from({ length: Math.floor(r() * 10) }, randFloat), 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', () => { test('fuzz: 1000 random nested orders', () => {
clearRegistry(); clearRegistry();
const Price = defineSchema('FuzzPrice', (s) => ({ value: s.f64, scale: s.u8 })); const Price = type('FuzzPrice', { value: f64, scale: u8 });
register(Price); const Order = type('FuzzOrder', {
const Order = defineSchema('FuzzOrder', (s) => ({ id: u53,
id: s.u53, symbol: str,
symbol: s.str,
price: Price, price: Price,
qty: s.f64, qty: f64,
side: s.enum(['buy', 'sell'] as const), side: enumOf(['buy', 'sell'] as const),
tags: s.array(s.str), tags: list(str),
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const), flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
})); });
const codec = register(Order);
for (let i = 0; i < 1000; i++) { for (let i = 0; i < 1000; i++) {
const v = { const v = {
@@ -97,18 +98,17 @@ test('fuzz: 1000 random nested orders', () => {
reduce_only: r() < 0.5, 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', () => { test('fuzz: 500 random unions', () => {
clearRegistry(); clearRegistry();
const Event = s.union('FuzzEvent', 'kind', { const Event = oneOf('FuzzEvent', 'kind', {
fill: { price: s.f64, qty: s.f64 }, fill: { price: f64, qty: f64 },
cancel: { reason: s.str }, cancel: { reason: str },
expire: { at: s.u53 }, expire: { at: u53 },
}); });
const codec = register(Event);
for (let i = 0; i < 500; i++) { for (let i = 0; i < 500; i++) {
const which = Math.floor(r() * 3); 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 if (which === 1) v = { kind: 'cancel', reason: randString() };
else v = { kind: 'expire', at: Math.floor(r() * 2 ** 40) }; 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
View File
@@ -1,11 +1,24 @@
import { test, expect } from 'vitest'; import { test, expect } from 'vitest';
import { import {
bool,
bytes,
clearRegistry, clearRegistry,
defineSchema, enumOf,
deserialize, f64,
register, f64Array,
s, flags,
serialize, i64,
list,
oneOf,
opt,
router,
str,
tuple,
type,
u32,
u53,
u64,
u8,
} from '../plugin/index.ts'; } from '../plugin/index.ts';
function fresh() { function fresh() {
@@ -14,65 +27,45 @@ function fresh() {
test('flat object with mixed primitives', () => { test('flat object with mixed primitives', () => {
fresh(); fresh();
const Ticker = defineSchema('Ticker', (s) => ({ const Ticker = type('Ticker', {
symbol: s.str, symbol: str,
last: s.f64, last: f64,
volume: s.f64, volume: f64,
count: s.u32, count: u32,
})); });
const codec = register(Ticker);
const value = { symbol: 'BTC-USD', last: 45123.45, volume: 1234.5678, count: 99999 }; const value = { symbol: 'BTC-USD', last: 45123.45, volume: 1234.5678, count: 99999 };
const bytes = serialize(value, codec); expect(Ticker.decode(Ticker.encode(value))).toEqual(value);
const decoded = deserialize<typeof value>(bytes);
expect(decoded).toEqual(value);
}); });
test('array of primitives', () => { test('array of primitives', () => {
fresh(); fresh();
const Tags = defineSchema('Tags', (s) => ({ const Tags = type('Tags', {
items: s.array(s.str), items: list(str),
counts: s.array(s.u32), counts: list(u32),
})); });
const codec = register(Tags);
const v = { items: ['a', 'b', 'hello'], counts: [1, 2, 3, 4, 5] }; 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(); fresh();
const Price = defineSchema('Price', (s) => ({ value: s.f64, scale: s.u8 })); const Price = type('Price', { value: f64, scale: u8 });
const Order = defineSchema('Order', (s) => ({ const Order = type('Order', { id: u53, price: Price, qty: f64 });
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 }; 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', () => { test('optional fields', () => {
fresh(); fresh();
const Maybe = defineSchema('Maybe', (s) => ({ const Maybe = type('Maybe', {
a: s.optional(s.str), a: opt(str),
b: s.optional(s.f64), b: opt(f64),
})); });
const codec = register(Maybe);
expect(deserialize(serialize({ a: 'hi', b: 3.14 }, codec))).toEqual({ expect(Maybe.decode(Maybe.encode({ a: 'hi', b: 3.14 }))).toEqual({ a: 'hi', b: 3.14 });
a: 'hi', expect(Maybe.decode(Maybe.encode({ a: undefined, b: 1 }))).toEqual({ a: undefined, b: 1 });
b: 3.14, expect(Maybe.decode(Maybe.encode({ a: undefined, b: undefined }))).toEqual({
});
expect(deserialize(serialize({ a: undefined, b: 1 }, codec))).toEqual({
a: undefined,
b: 1,
});
expect(deserialize(serialize({ a: undefined, b: undefined }, codec))).toEqual({
a: undefined, a: undefined,
b: undefined, b: undefined,
}); });
@@ -80,102 +73,90 @@ test('optional fields', () => {
test('enum field', () => { test('enum field', () => {
fresh(); fresh();
const Side = defineSchema('SidedOrder', (s) => ({ const Sided = type('SidedOrder', {
side: s.enum(['buy', 'sell'] as const), side: enumOf(['buy', 'sell'] as const),
qty: s.f64, qty: f64,
})); });
const codec = register(Side);
for (const side of ['buy', 'sell'] as const) { for (const side of ['buy', 'sell'] as const) {
const v = { side, qty: 1 }; 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)', () => { test('bitset field (≤8 flags)', () => {
fresh(); fresh();
const Flags = defineSchema('Flags', (s) => ({ const Flags = type('Flags', {
flags: s.bitset(['ioc', 'post_only', 'reduce_only'] as const), flags: flags(['ioc', 'post_only', 'reduce_only'] as const),
})); });
const codec = register(Flags);
const v = { flags: { ioc: true, post_only: false, reduce_only: true } }; 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)', () => { test('bitset field (>32 flags uses bigint)', () => {
fresh(); fresh();
const flagNames = Array.from({ length: 40 }, (_, i) => `f${i}`) as readonly string[]; const flagNames = Array.from({ length: 40 }, (_, i) => `f${i}`) as readonly string[];
const Flags = defineSchema('FlagsBig', (s) => ({ const FlagsBig = type('FlagsBig', {
flags: s.bitset(flagNames as readonly [string, ...string[]]), flags: flags(flagNames as readonly [string, ...string[]]),
})); });
const codec = register(Flags);
const flags: Record<string, boolean> = {}; const flagsValue: Record<string, boolean> = {};
for (let i = 0; i < 40; i++) flags[`f${i}`] = i % 3 === 0; for (let i = 0; i < 40; i++) flagsValue[`f${i}`] = i % 3 === 0;
const v = { flags }; const v = { flags: flagsValue };
expect(deserialize(serialize(v, codec))).toEqual(v); expect(FlagsBig.decode(FlagsBig.encode(v))).toEqual(v);
}); });
test('tuple field', () => { test('tuple field', () => {
fresh(); fresh();
const Point = defineSchema('Point3D', (s) => ({ const Point = type('Point3D', {
name: s.str, name: str,
coord: s.tuple(s.f64, s.f64, s.f64), coord: tuple(f64, f64, f64),
})); });
const codec = register(Point); const v = { name: 'p', coord: [1.5, 2.5, 3.5] as [number, number, number] };
expect(Point.decode(Point.encode(v))).toEqual(v);
const v = { name: 'p', coord: [1.5, 2.5, 3.5] };
expect(deserialize(serialize(v, codec))).toEqual(v);
}); });
test('array of nested objects', () => { test('array of nested objects', () => {
fresh(); fresh();
const Level = defineSchema('Level', (s) => ({ price: s.f64, qty: s.f64 })); const Level = type('Level', { price: f64, qty: f64 });
register(Level); const Book = type('Book', {
const Book = defineSchema('Book', (s) => ({ bids: list(Level),
bids: s.array(Level), asks: list(Level),
asks: s.array(Level), });
}));
const codec = register(Book);
const v = { const v = {
bids: [{ price: 100, qty: 1 }, { price: 99, qty: 2 }], 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 }], 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', () => { test('union with discriminator', () => {
fresh(); fresh();
const Event = s.union('Event', 'kind', { const Event = oneOf('Event', 'kind', {
fill: { price: s.f64, qty: s.f64 }, fill: { price: f64, qty: f64 },
cancel: { reason: s.str }, cancel: { reason: str },
expire: { at: s.u53 }, expire: { at: u53 },
}); });
const codec = register(Event);
const samples = [ const samples = [
{ kind: 'fill' as const, price: 100, qty: 0.5 }, { kind: 'fill', price: 100, qty: 0.5 },
{ kind: 'cancel' as const, reason: 'user' }, { kind: 'cancel', reason: 'user' },
{ kind: 'expire' as const, at: 1700000000 }, { kind: 'expire', at: 1700000000 },
]; ];
for (const v of samples) { 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', () => { test('typed array (f64Array) round-trip', () => {
fresh(); fresh();
const Signal = defineSchema('Signal', (s) => ({ const Signal = type('Signal', {
name: s.str, name: str,
samples: s.f64Array, samples: f64Array,
})); });
const codec = register(Signal);
const samples = new Float64Array([1.1, 2.2, 3.3, 4.4, 5.5]); const samples = new Float64Array([1.1, 2.2, 3.3, 4.4, 5.5]);
const v = { name: 'sig', samples }; 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.name).toBe('sig');
expect(decoded.samples).toBeInstanceOf(Float64Array); expect(decoded.samples).toBeInstanceOf(Float64Array);
expect(decoded.samples.length).toBe(5); expect(decoded.samples.length).toBe(5);
@@ -184,54 +165,50 @@ test('typed array (f64Array) round-trip', () => {
test('bigint u64/i64 round-trip', () => { test('bigint u64/i64 round-trip', () => {
fresh(); fresh();
const Big = defineSchema('Big', (s) => ({ const Big = type('Big', { u: u64, i: i64 });
u: s.u64,
i: s.i64,
}));
const codec = register(Big);
const v = { u: 1n << 50n, i: -(1n << 50n) }; 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', () => { test('bytes field', () => {
fresh(); fresh();
const Blob = defineSchema('Blob', (s) => ({ const Blob = type('Blob', { data: bytes });
data: s.bytes,
}));
const codec = register(Blob);
const data = new Uint8Array([0, 1, 2, 3, 254, 255]); 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)); expect(Array.from(decoded.data)).toEqual(Array.from(data));
}); });
test('serialize includes 2-byte schema ID frame', () => { test('bool field round-trip', () => {
fresh(); fresh();
const Sch = defineSchema('Sch', (s) => ({ x: s.u8 })); const T = type('Bools', { a: bool, b: bool });
const codec = register(Sch); expect(T.decode(T.encode({ a: true, b: false }))).toEqual({ a: true, b: false });
const bytes = serialize({ x: 7 }, codec); });
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); expect(bytes.length).toBeGreaterThanOrEqual(3);
const id = bytes[0]! | (bytes[1]! << 8); const id = bytes[0]! | (bytes[1]! << 8);
expect(id).toBe(codec.id); expect(id).toBe(Sch.id);
}); });
test('large nested order-book payload', () => { test('large nested order-book payload', () => {
fresh(); fresh();
const Level = defineSchema('LvlBig', (s) => ({ p: s.f64, q: s.f64 })); const Level = type('LvlBig', { p: f64, q: f64 });
register(Level); const Snap = type('Snap', {
const Snap = defineSchema('Snap', (s) => ({ symbol: str,
symbol: s.str, ts: u53,
ts: s.u53, bids: list(Level),
bids: s.array(Level), asks: list(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 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 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 v = { symbol: 'BTC-USD', ts: 1700000000123, bids, asks };
const bytes = serialize(v, codec); const decoded = Snap.decode(Snap.encode(v));
const decoded = deserialize<typeof v>(bytes);
expect(decoded.symbol).toBe(v.symbol); expect(decoded.symbol).toBe(v.symbol);
expect(decoded.ts).toBe(v.ts); expect(decoded.ts).toBe(v.ts);
expect(decoded.bids.length).toBe(1000); expect(decoded.bids.length).toBe(1000);
@@ -240,8 +217,10 @@ test('large nested order-book payload', () => {
expect(decoded.asks[999]).toEqual(v.asks[999]); expect(decoded.asks[999]).toEqual(v.asks[999]);
}); });
test('deserialize unknown schema ID throws', () => { test('router throws for unknown schema ID', () => {
fresh(); fresh();
const bytes = new Uint8Array([0xff, 0xff, 0]); const Sch = type('Sch2', { x: u8 });
expect(() => deserialize(bytes)).toThrow(/Unknown schema ID/); const proto = router(Sch);
const bogus = new Uint8Array([0xff, 0xff, 0]);
expect(() => proto.decode(bogus)).toThrow(/unknown schema ID/i);
}); });
+38 -55
View File
@@ -1,11 +1,16 @@
import { test, expect } from 'vitest'; import { test, expect } from 'vitest';
import { import {
bool,
clearRegistry, clearRegistry,
defineSchema, enumOf,
deserialize, f64,
register, list,
s, oneOf,
serialize, str,
type,
u32,
u53,
u8,
} from '../plugin/index.ts'; } from '../plugin/index.ts';
/** /**
@@ -15,51 +20,36 @@ import {
*/ */
test('decoded objects share key order matching schema field order', () => { test('decoded objects share key order matching schema field order', () => {
clearRegistry(); clearRegistry();
const Order = defineSchema('ShapeOrder', (s) => ({ const Order = type('ShapeOrder', {
id: s.u53, id: u53,
price: s.f64, price: f64,
qty: s.f64, qty: f64,
side: s.enum(['buy', 'sell'] as const), side: enumOf(['buy', 'sell'] as const),
tags: s.array(s.str), tags: list(str),
})); });
const codec = register(Order);
const expectedOrder = ['id', 'price', 'qty', 'side', 'tags']; const expectedOrder = ['id', 'price', 'qty', 'side', 'tags'];
const decoded1 = deserialize<Record<string, unknown>>( const d1 = Order.decode(Order.encode({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }));
serialize({ id: 1, price: 100, qty: 0.5, side: 'buy', tags: ['a'] }, codec), const d2 = Order.decode(Order.encode({ id: 999, price: 1e10, qty: 0, side: 'sell', tags: [] }));
); const d3 = Order.decode(
const decoded2 = deserialize<Record<string, unknown>>( Order.encode({ id: 2 ** 40, price: -1, qty: 1234, side: 'buy', tags: ['x', 'y', 'z'] }),
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(d1)).toEqual(expectedOrder);
expect(Object.keys(decoded2)).toEqual(expectedOrder); expect(Object.keys(d2)).toEqual(expectedOrder);
expect(Object.keys(decoded3)).toEqual(expectedOrder); expect(Object.keys(d3)).toEqual(expectedOrder);
}); });
test('decoded value types are consistent across instances', () => { test('decoded value types are consistent across instances', () => {
clearRegistry(); clearRegistry();
const T = defineSchema('Types', (s) => ({ const T = type('Types', { a: u32, b: f64, c: str, d: bool });
a: s.u32,
b: s.f64,
c: s.str,
d: s.bool,
}));
const codec = register(T);
const types = (o: Record<string, unknown>) => const types = (o: Record<string, unknown>) =>
Object.entries(o).map(([k, v]) => [k, typeof v]); Object.entries(o).map(([k, v]) => [k, typeof v]);
const a = deserialize<Record<string, unknown>>( const a = T.decode(T.encode({ a: 1, b: 1.5, c: 'a', d: true }));
serialize({ a: 1, b: 1.5, c: 'a', d: true }, codec), const b = T.decode(T.encode({ a: 0, b: 0, c: '', d: false }));
);
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(types(b));
expect(types(a)).toEqual([ expect(types(a)).toEqual([
['a', 'number'], ['a', 'number'],
@@ -71,35 +61,28 @@ test('decoded value types are consistent across instances', () => {
test('nested object key order is stable', () => { test('nested object key order is stable', () => {
clearRegistry(); clearRegistry();
const Price = defineSchema('SPrice', (s) => ({ value: s.f64, scale: s.u8 })); const Price = type('SPrice', { value: f64, scale: u8 });
register(Price); const Order = type('SOrder', { id: u53, price: Price, qty: f64 });
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 v = { id: 1, price: { value: 100, scale: 2 }, qty: 1 };
const d1 = deserialize<Record<string, unknown>>(serialize(v, codec)); const d1 = Order.decode(Order.encode(v));
const d2 = deserialize<Record<string, unknown>>(serialize({ ...v, id: 99 }, codec)); const d2 = Order.decode(Order.encode({ ...v, id: 99 }));
expect(Object.keys(d1)).toEqual(['id', 'price', 'qty']); expect(Object.keys(d1)).toEqual(['id', 'price', 'qty']);
expect(Object.keys(d2)).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(d1.price)).toEqual(['value', 'scale']);
expect(Object.keys(d2.price as Record<string, unknown>)).toEqual(['value', 'scale']); expect(Object.keys(d2.price)).toEqual(['value', 'scale']);
}); });
test('union decoded objects place discriminator first', () => { test('union decoded objects place discriminator first', () => {
clearRegistry(); clearRegistry();
const Event = s.union('SEvent', 'kind', { const Event = oneOf('SEvent', 'kind', {
a: { x: s.u32 }, a: { x: u32 },
b: { y: s.f64 }, b: { y: f64 },
}); });
const codec = register(Event);
const ea = deserialize<Record<string, unknown>>(serialize({ kind: 'a', x: 1 }, codec)); const ea = Event.decode(Event.encode({ kind: 'a', x: 1 } as never)) as Record<string, unknown>;
const eb = deserialize<Record<string, unknown>>(serialize({ kind: 'b', y: 2.5 }, codec)); 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(ea)[0]).toBe('kind');
expect(Object.keys(eb)[0]).toBe('kind'); expect(Object.keys(eb)[0]).toBe('kind');
}); });
+61
View File
@@ -161,6 +161,67 @@ export const T = type('TxSmoke', { x: u53, y: f64 });
expect(result.code).not.toContain("type('TxSmoke'"); 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(() => { afterAll(() => {
for (let i = 1; i <= counter; i++) { for (let i = 1; i <= counter; i++) {
const file = join(GEN_DIR, `__gen_${i}.ts`); const file = join(GEN_DIR, `__gen_${i}.ts`);