feat(serializer): add class aot serialization support
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +192,26 @@ 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;
|
||||||
@@ -219,6 +223,29 @@ function findTypeCalls(program: AnyNode, imports: ImportInfo): TypeCallInfo[] {
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Generated
-26
@@ -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==}
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
Reference in New Issue
Block a user