From 88db45d0edbd188dd9f88ac83bf025987dc8f271 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Wed, 3 Jan 2024 17:23:42 -0600 Subject: [PATCH] chore: right serialization result, wrong indexes --- cspell.json | 5 +- src/async/asyncSerialize.ts | 308 ------------------- src/async/asyncTypes2.ts | 144 +++++++++ src/async/asyncTypesNew.ts | 56 ---- src/async/createFoldAsyncFn.ts | 67 ++-- src/async/createUnfoldAsyncFn.ts | 127 ++------ src/async/handlers/tsonPromise2.test.ts | 192 ++++++------ src/async/handlers/tsonPromise2.ts | 67 +--- src/async/iterableUtils.ts | 2 + src/async/serializeAsync2.test.ts | 333 ++++++++++++++++++++ src/async/serializeAsync2.ts | 390 ++++++++++++++++++++++++ src/sync/serialize.ts | 22 +- 12 files changed, 1058 insertions(+), 655 deletions(-) delete mode 100644 src/async/asyncSerialize.ts create mode 100644 src/async/asyncTypes2.ts delete mode 100644 src/async/asyncTypesNew.ts create mode 100644 src/async/serializeAsync2.test.ts create mode 100644 src/async/serializeAsync2.ts diff --git a/cspell.json b/cspell.json index 6c91837..6c68423 100644 --- a/cspell.json +++ b/cspell.json @@ -1,5 +1,7 @@ { - "dictionaries": ["typescript"], + "dictionaries": [ + "typescript" + ], "ignorePaths": [ ".github", "CHANGELOG.md", @@ -9,6 +11,7 @@ "pnpm-lock.yaml" ], "words": [ + "asyncs", "clsx", "Codecov", "codespace", diff --git a/src/async/asyncSerialize.ts b/src/async/asyncSerialize.ts deleted file mode 100644 index 0c91ed2..0000000 --- a/src/async/asyncSerialize.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { TsonCircularReferenceError } from "../errors.js"; -import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; -import { isComplexValue } from "../internals/isComplexValue.js"; -import { - TsonAllTypes, - TsonType, - TsonTypeHandlerKey, - TsonTypeTesterCustom, - TsonTypeTesterPrimitive, -} from "../sync/syncTypes.js"; -import { - TsonAsyncOptions, - TsonAsyncPath, - TsonAsyncType, -} from "./asyncTypesNew.js"; -import { - TsonAsyncHeadTuple, - TsonAsyncLeafTuple, - TsonAsyncReferenceTuple, - TsonAsyncTailTuple, -} from "./createUnfoldAsyncFn.js"; -import { isAsyncIterableEsque, isIterableEsque } from "./iterableUtils.js"; - -const TSON_STATUS = { - //MULTI_STATUS: 207, - ERROR: 500, - INCOMPLETE: 203, - OK: 200, -} as const; - -function getHandlers(opts: TsonAsyncOptions) { - const primitives = new Map< - TsonAllTypes, - Extract, TsonTypeTesterPrimitive> - >(); - - const asyncs = new Set>(); - const syncs = new Set, TsonTypeTesterCustom>>(); - - for (const marshaller of opts.types) { - if (marshaller.primitive) { - if (primitives.has(marshaller.primitive)) { - throw new Error( - `Multiple handlers for primitive ${marshaller.primitive} found`, - ); - } - - primitives.set(marshaller.primitive, marshaller); - } else if (marshaller.async) { - asyncs.add(marshaller); - } else { - syncs.add(marshaller); - } - } - - const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; - - const guards = opts.guards ?? []; - - return [getNonce, { asyncs, primitives, syncs }, guards] as const; -} - -export const createTsonSerializeAsync = (opts: TsonAsyncOptions) => { - const [getNonce, handlers, guards] = getHandlers(opts); - - const serializer = async function* ( - v: unknown, - ): AsyncGenerator< - TsonAsyncHeadTuple | TsonAsyncLeafTuple | TsonAsyncReferenceTuple, - TsonAsyncTailTuple | undefined, - undefined - > { - const seen = new WeakSet(); - const cache = new WeakMap(); - const results = new Map(); - const queue = new Map< - AsyncGenerator< - { chunk: unknown; key: number | string }, - number | undefined, - undefined - >, - { - next: Promise< - IteratorResult< - { - chunk: unknown; - key: number | string; - }, - number | undefined - > - >; - path: TsonAsyncPath; - } - >(); - - let iter; - let result; - let value = v; - let path = [getNonce()] as TsonAsyncPath; - - do { - let cached = undefined; - - if (isComplexValue(value)) { - if (seen.has(value)) { - cached = cache.get(value); - // if (!cached) { - // throw new TsonCircularReferenceError(value); - // } - } else { - seen.add(value); - } - } - - if (cached) { - const tuple = ["ref", path, cached] satisfies TsonAsyncReferenceTuple; - yield tuple; - } else { - const handler = selectHandler({ handlers, value }); - if (handler) { - const head = [ - "head", - path, - handler.key as TsonTypeHandlerKey, - ] satisfies TsonAsyncHeadTuple; - - yield head; - - if ("unfold" in handler) { - //? - iter = handler.unfold(value); - queue.set(iter, { next: iter.next(), path }); - } else { - const key = path.pop() as number | string; - iter = toAsyncGenerator({ - [key]: handler.serialize(value) as unknown, - }); - queue.set(iter, { next: iter.next(), path }); //? - } - } else { - for (const guard of guards) { - const result = guard.assert(value); - if (typeof result === "boolean" && !result) { - throw new Error( - `Guard ${guard.key} failed on value ${String(value)}`, - ); - } - } - - if (isComplexValue(value)) { - const kind = typeofStruct(value); - const head = [ - "default", - path, - kind === "array" ? "[]" : kind === "pojo" ? "{}" : "@@", - ] satisfies TsonAsyncHeadTuple; - yield head; - iter = toAsyncGenerator(value); - queue.set(iter, { next: iter.next(), path }); - } else { - const leaf = ["leaf", path, value] satisfies TsonAsyncLeafTuple; - yield leaf; - } - } - } - - ({ iter, path, result } = await Promise.race( - Array.from(queue.entries()).map(([iter, { next, path }]) => { - return next.then((result) => ({ iter, path, result })); - }), - )); - - if (result.done) { - queue.delete(iter); - if (isComplexValue(value)) { - cache.set(value, path); - } - - results.set(path, result.value ?? TSON_STATUS.OK); - continue; - } - - value = result.value.chunk; - path = [...path, result.value.key]; - } while (queue.size); - - // return the results - return [ - "tail", - path, - Array.from(results.entries()).reduce((acc, [path, statusCode]) => { - return statusCode === TSON_STATUS.OK ? acc : TSON_STATUS.INCOMPLETE; - }, 200), - ] satisfies TsonAsyncTailTuple; - }; - - return serializer; -}; - -function typeofStruct< - T extends - | AsyncIterable - | Iterable - | Record - | any[], ->(item: T): "array" | "iterable" | "pojo" { - switch (true) { - case Symbol.asyncIterator in item: - return "iterable"; - case Array.isArray(item): - return "array"; - case Symbol.iterator in item: - return "iterable"; - default: - // we intentionally treat functions as pojos - return "pojo"; - } -} - -/** - * - Async iterables are iterated, and each value yielded is walked. - * To be able to reconstruct the reference graph, each value is - * assigned a negative-indexed label indicating both the order in - * which it was yielded, and that it is a child of an async iterable. - * Upon deserialization, each [key, value] pair is set as a property - * on an object with a [Symbol.asyncIterator] method which yields - * the values, preserving the order. - * - * - Arrays are iterated with their indices as labels and - * then reconstructed as arrays. - * - * - Maps are iterated as objects - * - * - Sets are iterated as arrays - * - * - All other iterables are iterated as if they were async. - * - * - All other objects are iterated with their keys as labels and - * reconstructed as objects, effectively replicating - * the behavior of `Object.fromEntries(Object.entries(obj))` - * @yields {{ chunk: unknown; key: number | string; }} - */ -async function* toAsyncGenerator( - item: T, -): AsyncGenerator< - { - chunk: unknown; - key: number | string; - }, - number, - never -> { - let code; - - try { - if (isIterableEsque(item) || isAsyncIterableEsque(item)) { - let i = 0; - for await (const chunk of item) { - yield { - chunk, - key: i++, - }; - } - } else { - for (const key in item) { - yield { - chunk: item[key], - key, - }; - } - } - - code = TSON_STATUS.OK; - return code; - } catch { - code = TSON_STATUS.ERROR; - return code; - } finally { - code ??= TSON_STATUS.INCOMPLETE; - } -} - -function selectHandler({ - handlers: { asyncs, primitives, syncs }, - value, -}: { - handlers: { - asyncs: Set>; - primitives: Map< - TsonAllTypes, - Extract, TsonTypeTesterPrimitive> - >; - syncs: Set, TsonTypeTesterCustom>>; - }; - value: unknown; -}) { - let handler; - const maybePrimitive = primitives.get(typeof value); - - if (!maybePrimitive?.test || maybePrimitive.test(value)) { - handler = maybePrimitive; - } - - handler ??= [...syncs].find((handler) => handler.test(value)); - handler ??= [...asyncs].find((handler) => handler.test(value)); - - return handler; -} diff --git a/src/async/asyncTypes2.ts b/src/async/asyncTypes2.ts new file mode 100644 index 0000000..421485e --- /dev/null +++ b/src/async/asyncTypes2.ts @@ -0,0 +1,144 @@ +import { + SerializedType, + TsonNonce, + TsonType, + TsonTypeTesterCustom, +} from "../sync/syncTypes.js"; +import { TsonGuard } from "../tsonAssert.js"; +import { + TsonAsyncUnfolderFactory, + createTsonAsyncUnfoldFn, +} from "./createUnfoldAsyncFn.js"; + +export interface TsonAsyncChunk { + chunk: T; + key?: null | number | string | undefined; +} + +export interface TsonAsyncMarshaller< + TValue, + TSerializedType extends SerializedType, +> { + async: true; + fold: ( + iter: AsyncGenerator< + TsonAsyncChunk, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + number | undefined | void, + undefined + >, + ) => Promise>; + key: string; + unfold: ReturnType< + typeof createTsonAsyncUnfoldFn> + >; +} + +export type TsonAsyncType< + /** + * The type of the value + */ + TValue, + /** + * JSON-serializable value how it's stored after it's serialized + */ + TSerializedType extends SerializedType, +> = TsonTypeTesterCustom & TsonAsyncMarshaller; + + +export interface TsonAsyncOptions { + /** + * A list of guards to apply to every value + */ + guards?: TsonGuard[]; + /** + * The nonce function every time we start serializing a new object + * Should return a unique value every time it's called + * @default `${crypto.randomUUID} if available, otherwise a random string generated by Math.random` + */ + nonce?: () => string; + /** + * The list of types to use + */ + types: (TsonAsyncType | TsonType)[]; +} + +export const ChunkTypes = { + BODY: "BODY", + ERROR: "ERROR", + HEAD: "HEAD", + LEAF: "LEAF", + REFERENCE: "REFERENCE", + TAIL: "TAIL", +} as const; + +export type ChunkTypes = { + [key in keyof typeof ChunkTypes]: (typeof ChunkTypes)[key]; +}; + +export const TsonStatus = { + //MULTI_STATUS: 207, + ERROR: 500, + INCOMPLETE: 203, + OK: 200, +} as const; + +export type TsonStatus = { + [key in keyof typeof TsonStatus]: (typeof TsonStatus)[key]; +}; + +export type TsonAsyncTupleHeader = [ + Id: `${TsonNonce}${number}`, + ParentId: `${TsonNonce}${"" | number}`, + Key?: null | number | string | undefined, +]; + +export type TsonAsyncLeafTuple = [ + ChunkType: ChunkTypes["LEAF"], + Header: TsonAsyncTupleHeader, + Value: unknown, + TypeHandlerKey?: string | undefined, +]; + +export type TsonAsyncBodyTuple = [ + ChunkType: ChunkTypes["BODY"], + Header: TsonAsyncTupleHeader, + Head: TsonAsyncHeadTuple, + TypeHandlerKey?: string | undefined, +]; + +export type TsonAsyncHeadTuple = [ + ChunkType: ChunkTypes["HEAD"], + Header: TsonAsyncTupleHeader, + TypeHandlerKey?: string | undefined, +]; + +export type TsonAsyncReferenceTuple = [ + ChunkType: ChunkTypes["REFERENCE"], + Header: TsonAsyncTupleHeader, + OriginalNodeId: `${TsonNonce}${number}`, +]; + +export type TsonAsyncErrorTuple = [ + ChunkType: ChunkTypes["ERROR"], + Header: TsonAsyncTupleHeader, + Error: unknown, +]; + +export type TsonAsyncTailTuple = [ + ChunkType: ChunkTypes["TAIL"], + Header: [ + Id: TsonAsyncTupleHeader[0], + ParentId: TsonAsyncTupleHeader[1], + Key?: null | undefined, + ], + StatusCode: number, +]; + +export type TsonAsyncTuple = + | TsonAsyncBodyTuple + | TsonAsyncErrorTuple + | TsonAsyncHeadTuple + | TsonAsyncLeafTuple + | TsonAsyncReferenceTuple + | TsonAsyncTailTuple; diff --git a/src/async/asyncTypesNew.ts b/src/async/asyncTypesNew.ts deleted file mode 100644 index 879356f..0000000 --- a/src/async/asyncTypesNew.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - SerializedType, - TsonNonce, - TsonType, - TsonTypeTesterCustom, -} from "../sync/syncTypes.js"; -import { TsonGuard } from "../tsonAssert.js"; -import { - TsonAsyncUnfolderFactory, - createTsonAsyncUnfoldFn, -} from "./createUnfoldAsyncFn.js"; - -export interface TsonAsyncMarshaller< - TValue, - TSerializedType extends SerializedType, -> { - async: true; - // deserialize: ( - // gen: AsyncGenerator, - // ) => AsyncIterable; - fold: (iter: AsyncIterable) => Promise>; - key: string; - unfold: ReturnType< - typeof createTsonAsyncUnfoldFn> - >; -} - -export type TsonAsyncType< - /** - * The type of the value - */ - TValue, - /** - * JSON-serializable value how it's stored after it's serialized - */ - TSerializedType extends SerializedType, -> = TsonTypeTesterCustom & TsonAsyncMarshaller; -export type TsonAsyncChildLabel = bigint | number | string; -export type TsonAsyncPath = [TsonNonce, ...TsonAsyncChildLabel[]]; - -export interface TsonAsyncOptions { - /** - * A list of guards to apply to every value - */ - guards?: TsonGuard[]; - /** - * The nonce function every time we start serializing a new object - * Should return a unique value every time it's called - * @default `${crypto.randomUUID} if available, otherwise a random string generated by Math.random` - */ - nonce?: () => bigint | number | string; - /** - * The list of types to use - */ - types: (TsonAsyncType | TsonType)[]; -} diff --git a/src/async/createFoldAsyncFn.ts b/src/async/createFoldAsyncFn.ts index 43e7530..b7c8dd1 100644 --- a/src/async/createFoldAsyncFn.ts +++ b/src/async/createFoldAsyncFn.ts @@ -1,54 +1,48 @@ import { TsonAbortError } from "./asyncErrors.js"; -import { TsonAsyncChildLabel } from "./asyncTypesNew.js"; -import { TsonReducerResult } from "./createFoldFn.js"; import { - TsonAsyncHeadTuple, - TsonAsyncLeafTuple, + TsonAsyncBodyTuple, TsonAsyncTailTuple, - TsonAsyncUnfoldedValue, -} from "./createUnfoldAsyncFn.js"; + TsonAsyncTuple, +} from "./asyncTypes2.js"; +import { TsonAsyncUnfoldedValue } from "./createUnfoldAsyncFn.js"; import { MaybePromise } from "./iterableUtils.js"; -export type TsonAsyncReducer = ( - ctx: TsonReducerCtx, -) => Promise>; +export type TsonAsyncReducer = ( + ctx: TsonReducerCtx, +) => Promise>; -export type TsonAsyncReducerResult = Omit< - TsonReducerResult, - "accumulator" -> & { +export interface TsonAsyncReducerResult { + abort?: boolean; accumulator: MaybePromise; -}; + error?: any; + return?: TsonAsyncTailTuple | undefined; +} -export type TsonAsyncFoldFn = ({ +export type TsonAsyncFoldFn = ({ initialAccumulator, reduce, }: { initialAccumulator: TInitial; - reduce: TsonAsyncReducer; + reduce: TsonAsyncReducer; }) => (sequence: TsonAsyncUnfoldedValue) => Promise; -export type TsonReducerCtx = - | TsonAsyncReducerReturnCtx - | TsonAsyncReducerYieldCtx; +export type TsonReducerCtx = + | TsonAsyncReducerReturnCtx + | TsonAsyncReducerYieldCtx; -// export type TsonAsyncFoldFnFactory = < -// T, -// TInitial = T, -// TReturn = undefined, -// >(opts: { -// initialAccumulator?: TInitial | undefined; -// reduce: TsonAsyncReducer; -// }) => TsonAsyncFoldFn; +export type TsonAsyncFoldFnFactory = (opts: { + initialAccumulator?: TInitial | undefined; +}) => TsonAsyncFoldFn; -export const createTsonAsyncFoldFn = ({ +export const createTsonAsyncFoldFn = ({ initializeAccumulator, reduce, }: { initializeAccumulator: () => MaybePromise; - reduce: TsonAsyncReducer; + reduce: TsonAsyncReducer; }) => { - let i = 0n; + //TODO: would it be better to use bigint for generator indexes? Can one imagine a request that long, with that many items? + let i = 0; return async function fold(sequence: TsonAsyncUnfoldedValue) { let result: { @@ -109,19 +103,18 @@ export const createTsonAsyncFoldFn = ({ }; }; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -interface TsonAsyncReducerYieldCtx { +interface TsonAsyncReducerYieldCtx { accumulator: TAccumulator; current: MaybePromise< - IteratorYieldResult + IteratorYieldResult> >; - key: TsonAsyncChildLabel; + key?: null | number | string | undefined; source: TsonAsyncUnfoldedValue; } -interface TsonAsyncReducerReturnCtx { +interface TsonAsyncReducerReturnCtx { accumulator: TAccumulator; - current: MaybePromise>; - key?: TsonAsyncChildLabel | undefined; + current: MaybePromise>; + key?: null | number | string | undefined; source?: TsonAsyncUnfoldedValue | undefined; } diff --git a/src/async/createUnfoldAsyncFn.ts b/src/async/createUnfoldAsyncFn.ts index c3cdfd4..3370353 100644 --- a/src/async/createUnfoldAsyncFn.ts +++ b/src/async/createUnfoldAsyncFn.ts @@ -1,119 +1,50 @@ -import { TsonTypeHandlerKey } from "../sync/syncTypes.js"; -import { TsonAsyncPath } from "./asyncTypesNew.js"; - -// type MapFold = ( -// foldFn: (initial: R, element: T2) => R, -// mapFn?: (element: T1) => T2, -// ) => (forest: Iterable) => R; - -// type UnfoldMap = ( -// unfoldFn: (source: R) => Iterable, -// mapFn?: (element: T1) => T2, -// ) => (source: R) => Iterable; - -// type MapFoldTransform = ( -// foldFn: (initial: R, element: T2) => R, -// mapFn?: (element: T1) => T2, -// transformFn?: (from: R) => Z, -// ) => (forest: Iterable) => R; - -// type TransformUnfoldMap = ( -// unfoldFn: (source: Z) => Iterable, -// mapFn?: (element: T1) => T2, -// transformFn?: (from: R) => Z, -// ) => (source: R) => Iterable; - -interface TsonAsyncChunk { - path: TsonAsyncPath; -} - -export type TsonAsyncHead = TsonAsyncChunk & - ( - | { - handler: TsonTypeHandlerKey; - type: "head"; - } - | { - initial: "@@" | "[]" | "{}"; - type: "default"; - } - ); - -export type TsonAsyncLeaf = TsonAsyncChunk & { - type: "leaf"; - value: unknown; -}; - -export interface TsonAsyncReference extends TsonAsyncChunk { - target: TsonAsyncPath; - type: "ref"; -} - -export interface TsonAsyncTail extends TsonAsyncChunk { - statusCode?: number; - type: "tail"; -} - -export type TsonAsyncHeadTuple = - | ["default", path: TsonAsyncPath, initial: "@@" | "[]" | "{}"] - | ["head", path: TsonAsyncPath, handler: TsonTypeHandlerKey]; - -export type TsonAsyncLeafTuple = [ - "leaf", - path: TsonAsyncPath, - value: unknown, - handler?: TsonTypeHandlerKey | undefined, -]; -export type TsonAsyncReferenceTuple = [ - "ref", - path: TsonAsyncPath, - target: TsonAsyncPath, -]; -export type TsonAsyncTailTuple = [ - "tail", - path: TsonAsyncPath, - statusCode?: number | undefined, -]; +import { + TsonAsyncChunk, + TsonAsyncHeadTuple, + TsonAsyncLeafTuple, + TsonAsyncTailTuple, +} from "./asyncTypes2.js"; +import { MaybePromise } from "./iterableUtils.js"; export type TsonAsyncUnfoldedValue = AsyncGenerator< TsonAsyncHeadTuple | TsonAsyncLeafTuple, TsonAsyncTailTuple, // could insert something into the generator, but that's more complexity for plugin authors - never + undefined >; -// export interface TsonAsyncUnfoldFn -// extends Omit { -// (source: TSource, path: TsonAsyncPath): MaybePromise; -// } +export interface TsonAsyncUnfoldFn + extends Omit { + (source: TSource): MaybePromise; +} export type TsonAsyncUnfolderFactory = ( source: T, ) => - | AsyncGenerator<{ chunk: unknown; key: number | string }, number | undefined> - | AsyncIterable<{ chunk: unknown; key: number | string }> - | AsyncIterator<{ chunk: unknown; key: number | string }, number | undefined>; + | AsyncGenerator + | AsyncIterable + | AsyncIterator; export function createTsonAsyncUnfoldFn< TFactory extends TsonAsyncUnfolderFactory, >( factory: TFactory, ): ( - source: TFactory extends TsonAsyncUnfolderFactory - ? TSource - : never, + source: TFactory extends TsonAsyncUnfolderFactory ? TSource + : never, ) => AsyncGenerator< - { chunk: unknown; key: number | string }, - number | undefined, + TsonAsyncChunk, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + number | undefined | void, // could insert something into the generator, but that's more complexity for plugin authors - never + undefined > { return async function* unfold(source) { const unfolder = factory(source); const iterator = - Symbol.asyncIterator in unfolder - ? unfolder[Symbol.asyncIterator]() - : unfolder; + Symbol.asyncIterator in unfolder ? + unfolder[Symbol.asyncIterator]() + : unfolder; let nextResult = await iterator.next(); @@ -122,10 +53,10 @@ export function createTsonAsyncUnfoldFn< nextResult = await iterator.next(); } - return typeof nextResult.value === "number" - ? nextResult.value - : nextResult.value instanceof Error - ? 500 - : 200; + return ( + typeof nextResult.value === "number" ? nextResult.value + : nextResult.value instanceof Error ? 500 + : 200 + ); }; } diff --git a/src/async/handlers/tsonPromise2.test.ts b/src/async/handlers/tsonPromise2.test.ts index f7d2f6c..731b299 100644 --- a/src/async/handlers/tsonPromise2.test.ts +++ b/src/async/handlers/tsonPromise2.test.ts @@ -1,110 +1,122 @@ import { expect, test } from "vitest"; +import { TsonType } from "../../index.js"; import { createPromise } from "../../internals/testUtils.js"; -import { createTsonSerializeAsync } from "../asyncSerialize.js"; +import { ChunkTypes, TsonStatus } from "../asyncTypes2.js"; +import { createTsonSerializeAsync } from "../serializeAsync2.js"; import { tsonPromise } from "./tsonPromise2.js"; +const tsonError: TsonType = { + deserialize: (v) => { + const err = new Error(v.message); + return err; + }, + key: "Error", + serialize: (v) => ({ + message: v.message, + }), + test: (v): v is Error => v instanceof Error, +}; + test("serialize promise", async () => { + const nonce = "__tson"; + const serialize = createTsonSerializeAsync({ - nonce: () => "__tson", + nonce: () => nonce, types: [tsonPromise], }); const promise = Promise.resolve(42); - const iterator = serialize(promise); - const head = await iterator.next(); - expect(head).toMatchInlineSnapshot(` - { - "done": false, - "value": [ - "head", - [ - "__tson", - ], - "Promise", - ], - } - `); const values = []; for await (const value of iterator) { values.push(value); } - expect(values).toMatchInlineSnapshot(); + const promiseId = `${nonce}0`; + const arrayId = `${nonce}1`; + + expect(values).toEqual([ + [ChunkTypes.HEAD, [promiseId, nonce, null], tsonPromise.key], + [ChunkTypes.HEAD, [arrayId, promiseId, null]], + [ChunkTypes.LEAF, [`${nonce}2`, arrayId, 0], 0], + [ChunkTypes.TAIL, [`${nonce}3`, promiseId, null], TsonStatus.OK], + [ChunkTypes.LEAF, [`${nonce}4`, arrayId, 1], 42], + [ChunkTypes.TAIL, [`${nonce}5`, arrayId, null], TsonStatus.OK], + ]); }); -// test("serialize promise that returns a promise", async () => { -// const serialize = createTsonSerializeAsync({ -// nonce: () => "__tson", -// types: [tsonPromise], -// }); - -// const obj = { -// promise: createPromise(() => { -// return { -// anotherPromise: createPromise(() => { -// return 42; -// }), -// }; -// }), -// }; - -// const iterator = serialize(obj); -// const head = await iterator.next(); -// expect(head).toMatchInlineSnapshot(` -// { -// "done": false, -// "value": [ -// "default", -// [ -// "__tson", -// ], -// "{}", -// ], -// } -// `); - -// const values = []; -// for await (const value of iterator) { -// values.push(value); -// } - -// expect(values).toHaveLength(2); - -// expect(values).toMatchInlineSnapshot(); -// }); - -// test("promise that rejects", async () => { -// const serialize = createTsonSerializeAsync({ -// nonce: () => "__tson", -// types: [tsonPromise], -// }); - -// const promise = Promise.reject(new Error("foo")); - -// const iterator = serialize(promise); -// const head = await iterator.next(); - -// expect(head).toMatchInlineSnapshot(` -// { -// "done": false, -// "value": [ -// "head", -// [ -// "__tson", -// ], -// "Promise", -// ], -// } -// `); - -// const values = []; - -// for await (const value of iterator) { -// values.push(value); -// } - -// expect(values).toMatchInlineSnapshot(); -// }); +test("serialize promise that returns a promise", async () => { + const nonce = "__tson"; + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise], + }); + + const obj = { + promise: createPromise(() => { + return { + anotherPromise: createPromise(() => { + return 42; + }), + }; + }), + }; + + const iterator = serialize(obj); + const values = []; + + for await (const value of iterator) { + values.push(value); + } + + expect(values).toEqual([ + /* + TODO: The parent IDs are wrong here. They're not correct in the implementation, + TODO: either, and I don't know what they should be yet. + */ + [ChunkTypes.HEAD, [`${nonce}0`, `${nonce}`, null]], + [ChunkTypes.HEAD, [`${nonce}1`, `${nonce}0`, "promise"], "Promise"], + [ChunkTypes.TAIL, [`${nonce}2`, `${nonce}0`, null], 200], + [ChunkTypes.HEAD, [`${nonce}3`, `${nonce}1`, null]], + [ChunkTypes.TAIL, [`${nonce}4`, `${nonce}1`, null], 200], + [ChunkTypes.LEAF, [`${nonce}5`, `${nonce}3`, 0], 0], + [ChunkTypes.HEAD, [`${nonce}6`, `${nonce}3`, 1]], + [ChunkTypes.HEAD, [`${nonce}7`, `${nonce}6`, "anotherPromise"], "Promise"], + [ChunkTypes.TAIL, [`${nonce}8`, `${nonce}6`, null], 200], + [ChunkTypes.TAIL, [`${nonce}9`, `${nonce}7`, null], 200], + [ChunkTypes.HEAD, [`${nonce}10`, `${nonce}6`, null]], + [ChunkTypes.TAIL, [`${nonce}11`, `${nonce}9`, null], 200], + [ChunkTypes.LEAF, [`${nonce}12`, `${nonce}12`, 0], 0], + [ChunkTypes.LEAF, [`${nonce}13`, `${nonce}11`, 1], 42], + [ChunkTypes.TAIL, [`${nonce}14`, `${nonce}11`, null], 200], + ]); +}); + +test("promise that rejects", async () => { + const nonce = "__tson"; + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise, tsonError], + }); + + const promise = Promise.reject(new Error("foo")); + const iterator = serialize(promise); + + const values = []; + const expected = { message: "foo" }; + + for await (const value of iterator) { + values.push(value); + } + + expect(values).toEqual([ + [ChunkTypes.HEAD, [`${nonce}0`, `${nonce}`, null], "Promise"], + [ChunkTypes.HEAD, [`${nonce}1`, `${nonce}0`, null]], + [ChunkTypes.LEAF, [`${nonce}2`, `${nonce}1`, 0], 1], + [ChunkTypes.TAIL, [`${nonce}3`, `${nonce}0`, null], 200], + [ChunkTypes.LEAF, [`${nonce}5`, `${nonce}1`, 1], expected, "Error"], + [ChunkTypes.TAIL, [`${nonce}6`, `${nonce}1`, null], 200], + ]); +}); diff --git a/src/async/handlers/tsonPromise2.ts b/src/async/handlers/tsonPromise2.ts index 0b3b2be..0ee840f 100644 --- a/src/async/handlers/tsonPromise2.ts +++ b/src/async/handlers/tsonPromise2.ts @@ -2,7 +2,7 @@ import { TsonPromiseRejectionError, TsonStreamInterruptedError, } from "../asyncErrors.js"; -import { TsonAsyncType } from "../asyncTypesNew.js"; +import { TsonAsyncType } from "../asyncTypes2.js"; function isPromise(value: unknown): value is Promise { return ( @@ -26,15 +26,19 @@ export const tsonPromise: TsonAsyncType< > = { async: true, fold: async function (iter) { - for await (const [key, chunk] of iter) { - if (key === PROMISE_RESOLVED) { - return chunk; - } + const result = await iter.next(); + if (result.done) { + throw new TsonStreamInterruptedError("Expected promise value, got done"); + } + + const value = result.value.chunk; + const [status, resultValue] = value; - throw TsonPromiseRejectionError.from(chunk); + if (status === PROMISE_RESOLVED) { + return resultValue; } - throw new TsonStreamInterruptedError("Expected promise value, got done"); + throw TsonPromiseRejectionError.from(resultValue); }, key: "Promise", test: isPromise, @@ -43,10 +47,10 @@ export const tsonPromise: TsonAsyncType< try { const value = await source; - yield { chunk: [PROMISE_RESOLVED, value], key: "" }; + yield { chunk: [PROMISE_RESOLVED, value] }; code = 200; } catch (err) { - yield { chunk: [PROMISE_REJECTED, err], key: "" }; + yield { chunk: [PROMISE_REJECTED, err] }; code = 200; } finally { code ??= 500; @@ -55,48 +59,3 @@ export const tsonPromise: TsonAsyncType< return code; }, }; - -// fold: (opts) => { -// const promise = new Promise((resolve, reject) => { -// async function _handle() { -// const next = await opts.reader.read(); -// opts.close(); - -// if (next.done) { -// throw new TsonPromiseRejectionError( -// "Expected promise value, got done", -// ); -// } - -// const { value } = next; - -// if (value instanceof TsonStreamInterruptedError) { -// reject(TsonPromiseRejectionError.from(value)); -// return; -// } - -// const [status, result] = value; - -// status === PROMISE_RESOLVED -// ? resolve(result) -// : reject(TsonPromiseRejectionError.from(result)); -// } - -// void _handle().catch(reject); -// }); - -// promise.catch(() => { -// // prevent unhandled promise rejection -// }); -// return promise; -// }, - -// unfold(opts) { -// const value = opts.value -// .then((value): SerializedPromiseValue => [PROMISE_RESOLVED, value]) -// .catch((err): SerializedPromiseValue => [PROMISE_REJECTED, err]); -// return (async function* generator() { -// yield await value; -// })(); -// }, -// }; diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index f205ad6..ea3e016 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -180,3 +180,5 @@ export function isIterableEsque( Symbol.iterator in maybeIterable ); } + +export type MaybePromise = Promise | T; diff --git a/src/async/serializeAsync2.test.ts b/src/async/serializeAsync2.test.ts new file mode 100644 index 0000000..d3f13b5 --- /dev/null +++ b/src/async/serializeAsync2.test.ts @@ -0,0 +1,333 @@ +import { assertType, describe, test } from "vitest"; + +import { tsonBigint } from "../index.js"; +import { ChunkTypes, TsonAsyncTuple, TsonStatus } from "./asyncTypes2.js"; +import { tsonPromise } from "./handlers/tsonPromise2.js"; +import { createTsonSerializeAsync } from "./serializeAsync2.js"; + +describe("serialize", (it) => { + it("should handle primitives correctly", async ({ expect }) => { + const options = { + guards: [], + nonce: () => "__tsonNonce", + types: [ + // Primitive handler mock + { + deserialize: (val: string) => val.toLowerCase(), + key: "string", + primitive: "string" as const, + serialize: (val: string) => val.toUpperCase(), + }, + ], + }; + + const serialize = createTsonSerializeAsync(options); + const source = "hello"; + const chunks: TsonAsyncTuple[] = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + expect(chunks.length).toBe(1); + expect(chunks[0]).toEqual([ + ChunkTypes.LEAF, + ["__tsonNonce0", "__tsonNonce", null], + "HELLO", + "string", + ]); + }); + + it("should handle circular references", async ({ expect }) => { + const options = { guards: [], nonce: () => "__tsonNonce", types: [] }; + const serialize = createTsonSerializeAsync(options); + const object: any = {}; + object.self = object; // Create a circular reference + const chunks = []; + + for await (const chunk of serialize(object)) { + chunks.push(chunk); + } + + //console.log(chunks); + + expect(chunks.length).toBe(3); + expect(chunks[0]).toEqual([ + ChunkTypes.HEAD, + ["__tsonNonce0", "__tsonNonce", null], + ]); + + expect + .soft(chunks[1]) + .toEqual([ + ChunkTypes.REFERENCE, + ["__tsonNonce1", "__tsonNonce0", "self"], + "__tsonNonce0", + ]); + + expect + .soft(chunks[2]) + .toEqual([ + ChunkTypes.TAIL, + ["__tsonNonce2", "__tsonNonce0", null], + TsonStatus.OK, + ]); + }); + + it("should apply guards and throw if they fail", async ({ expect }) => { + const options = { + guards: [{ assert: (val: unknown) => val !== "fail", key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const failingValue = "fail"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(failingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + "message", + "Guard testGuard failed on value fail", + ); + }); + + it("should apply guards and not throw if they pass", async ({ expect }) => { + const options = { + guards: [{ assert: (val: unknown) => val !== "fail", key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + }); + + it("should apply guards and not throw if they return undefined", async ({ + expect, + }) => { + const options = { + guards: [{ assert: () => undefined, key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + }); + + it("should apply guards and throw if they return false", async ({ + expect, + }) => { + const options = { + guards: [{ assert: () => false, key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty( + "message", + "Guard testGuard failed on value pass", + ); + }); + + it("should apply guards and not throw if they return true", async ({ + expect, + }) => { + const options = { + guards: [{ assert: () => true, key: "testGuard" }], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeUndefined(); + }); + + it("should apply guards and throw if they throw", async ({ expect }) => { + const options = { + guards: [ + { + assert: () => { + throw new Error("testGuard error"); + }, + key: "testGuard", + }, + ], + types: [], + }; + const serialize = createTsonSerializeAsync(options); + const passingValue = "pass"; + let error; + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of serialize(passingValue)) { + // Do nothing + } + } catch (e) { + error = e; + } + + expect(error).toBeDefined(); + expect(error).toBeInstanceOf(Error); + expect(error).toHaveProperty("message", "testGuard error"); + }); + + it("should serialize JSON-serializable values without a handler", async ({ + expect, + }) => { + const options = { guards: [], nonce: () => "__tsonNonce", types: [] }; + const serialize = createTsonSerializeAsync(options); + + const source = 1; + const chunks: TsonAsyncTuple[] = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + const source2 = "hello"; + const chunks2: TsonAsyncTuple[] = []; + + for await (const chunk of serialize(source2)) { + chunks2.push(chunk); + } + + test.each([ + [source, chunks], + [source2, chunks2], + ])(`chunks`, (original, result) => { + expect(result.length).toBe(1); + expect(result[0]).toEqual([ + ChunkTypes.LEAF, + ["__tsonNonce1", "__tsonNonce", null], + JSON.stringify(original), + ]); + }); + }); + + it("should serialize values with a sync handler", async ({ expect }) => { + const options = { + guards: [], + nonce: () => "__tsonNonce", + types: [tsonBigint], + }; + + const serialize = createTsonSerializeAsync(options); + const source = 0n; + const chunks = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + assertType(chunks); + expect(chunks.length).toBe(1); + expect(chunks[0]).toEqual([ + ChunkTypes.LEAF, + ["__tsonNonce0", "__tsonNonce", null], + "0", + "bigint", + ]); + }); + + it("should serialize values with an async handler", async ({ expect }) => { + const options = { + guards: [], + nonce: () => "__tsonNonce", + types: [tsonPromise], + }; + const serialize = createTsonSerializeAsync(options); + const source = Promise.resolve("hello"); + const chunks = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + //console.log(chunks); + expect(chunks.length).toBe(6); + expect + .soft(chunks[0]) + .toEqual([ + ChunkTypes.HEAD, + ["__tsonNonce0", "__tsonNonce", null], + "Promise", + ]); + expect + .soft(chunks[1]) + .toEqual([ChunkTypes.HEAD, ["__tsonNonce1", "__tsonNonce0", null]]); + expect + .soft(chunks[2]) + .toEqual([ChunkTypes.LEAF, ["__tsonNonce2", "__tsonNonce1", 0], "0"]); + expect + .soft(chunks[3]) + .toEqual([ + ChunkTypes.TAIL, + ["__tsonNonce3", "__tsonNonce0", null], + TsonStatus.OK, + ]); + expect + .soft(chunks[4]) + .toEqual([ChunkTypes.LEAF, ["__tsonNonce4", "__tsonNonce1", 1], "hello"]); + expect + .soft(chunks[5]) + .toEqual([ + ChunkTypes.TAIL, + ["__tsonNonce5", "__tsonNonce1", null], + TsonStatus.OK, + ]); + }); +}); diff --git a/src/async/serializeAsync2.ts b/src/async/serializeAsync2.ts new file mode 100644 index 0000000..f6e3225 --- /dev/null +++ b/src/async/serializeAsync2.ts @@ -0,0 +1,390 @@ +import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; +import { isComplexValue } from "../internals/isComplexValue.js"; +import { + TsonAllTypes, + TsonNonce, + TsonType, + TsonTypeTesterCustom, + TsonTypeTesterPrimitive, +} from "../sync/syncTypes.js"; +import { + ChunkTypes, + TsonAsyncBodyTuple, + TsonAsyncChunk, + TsonAsyncHeadTuple, + TsonAsyncOptions, + TsonAsyncReferenceTuple, + TsonAsyncTailTuple, + TsonAsyncTuple, + TsonAsyncType, + TsonStatus, +} from "./asyncTypes2.js"; +import { isAsyncIterableEsque, isIterableEsque } from "./iterableUtils.js"; + +function getHandlers(opts: TsonAsyncOptions) { + const primitives = new Map< + TsonAllTypes, + Extract, TsonTypeTesterPrimitive> + >(); + + const asyncs = new Set>(); + const syncs = new Set, TsonTypeTesterCustom>>(); + + for (const marshaller of opts.types) { + if (marshaller.primitive) { + if (primitives.has(marshaller.primitive)) { + throw new Error( + `Multiple handlers for primitive ${marshaller.primitive} found`, + ); + } + + primitives.set(marshaller.primitive, marshaller); + } else if (marshaller.async) { + asyncs.add(marshaller); + } else { + syncs.add(marshaller); + } + } + + const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; + + function applyGuards(value: unknown) { + for (const guard of opts.guards ?? []) { + const isOk = guard.assert(value); + if (typeof isOk === "boolean" && !isOk) { + throw new Error(`Guard ${guard.key} failed on value ${String(value)}`); + } + } + } + + return [getNonce, { asyncs, primitives, syncs }, applyGuards] as const; +} + +// Serializer factory function +export function createTsonSerializeAsync(opts: TsonAsyncOptions) { + let currentId = 0; + const objectCache = new WeakMap(); + /** + * A cache of running iterators mapped to their header tuple. + * When a head is emitted for an iterator, it is added to this map. + * When the iterator is done, a tail is emitted and the iterator is removed from the map. + */ + const workerMap = new WeakMap< + TsonAsyncHeadTuple, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + AsyncGenerator + >(); + + const queue = new Map<`${TsonNonce}${number}`, Promise>(); + const [getNonce, handlers, applyGuards] = getHandlers(opts); + const nonce = getNonce(); + const getNextId = () => `${nonce}${currentId++}` as const; + + const createCircularRefChunk = ( + key: null | number | string, + value: object, + id: `${TsonNonce}${number}`, + parentId: `${TsonNonce}${"" | number}`, + ): TsonAsyncReferenceTuple | undefined => { + const originalNodeId = objectCache.get(value); + if (originalNodeId === undefined) { + return undefined; + } + + return [ChunkTypes.REFERENCE, [id, parentId, key], originalNodeId]; + }; + + const initializeIterable = ( + source: AsyncGenerator< + TsonAsyncChunk, + // eslint-disable-next-line @typescript-eslint/no-invalid-void-type + number | undefined | void, + undefined + >, + ): ((head: TsonAsyncHeadTuple) => TsonAsyncHeadTuple) => { + return (head) => { + workerMap.set(head, source); + const newId = getNextId(); + queue.set( + newId, + source.next().then(async (result) => { + if (result.done) { + workerMap.delete(head); + return Promise.resolve([ + ChunkTypes.TAIL, + [newId, head[1][0], null], + result.value ?? TsonStatus.OK, + ] as TsonAsyncTailTuple); + } + + addToQueue(result.value.key ?? null, result.value.chunk, newId); + return Promise.resolve([ + ChunkTypes.BODY, + [newId, head[1][0], null], + head, + ] as TsonAsyncBodyTuple); + }), + ); + + return head; + }; + }; + + const addToQueue = ( + key: null | number | string, + value: unknown, + parentId: `${TsonNonce}${"" | number}`, + ) => { + const thisId = getNextId(); + if (isComplexValue(value)) { + const circularRef = createCircularRefChunk(key, value, thisId, parentId); + if (circularRef) { + queue.set(circularRef[1][0], Promise.resolve(circularRef)); + return; + } + + objectCache.set(value, thisId); + } + + // Try to find a matching handler and initiate serialization + const handler = selectHandler({ handlers, value }); + + // fallback to parsing as json + if (!handler) { + applyGuards(value); + + if (isComplexValue(value)) { + const iterator = toAsyncGenerator(value); + + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.HEAD, + [thisId, parentId, key], + ] as TsonAsyncHeadTuple).then(initializeIterable(iterator)), + ); + + return; + } + + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.LEAF, + [thisId, parentId, key], + JSON.stringify(value), + ]), + ); + + return; + } + + if (!handler.async) { + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.LEAF, + [thisId, parentId, key], + handler.serialize(value), + handler.key, + ]), + ); + + return; + } + + // Async handler + const iterator = handler.unfold(value); + + // Ensure the head is sent before the body + queue.set( + thisId, + Promise.resolve([ + ChunkTypes.HEAD, + [thisId, parentId, key], + handler.key, + ] as TsonAsyncHeadTuple).then(initializeIterable(iterator)), + ); + }; + + return async function* serialize(source: unknown) { + addToQueue(null, source, `${nonce}`); + + while (queue.size > 0) { + const chunk = await Promise.race([...queue.values()]); + + if (chunk[0] !== ChunkTypes.BODY) { + queue.delete(chunk[1][0]); + yield chunk; + continue; + } + + const headId = chunk[2][1][0]; + const chunkId = chunk[1][0]; + const chunkKey = chunk[1][2] ?? null; + const worker = workerMap.get(chunk[2]); + + if (!worker) { + throw new Error("Worker not found"); + } + + queue.set( + chunkId, + worker.next().then(async (result) => { + if (result.done) { + workerMap.delete(chunk[2]); + return Promise.resolve([ + ChunkTypes.TAIL, + [chunkId, headId, chunkKey], + result.value ?? TsonStatus.OK, + ] as TsonAsyncTailTuple); + } + + addToQueue(result.value.key ?? null, result.value.chunk, headId); + + return Promise.resolve([ + ChunkTypes.BODY, + [chunkId, headId, chunkKey], + chunk[2], + ] as TsonAsyncBodyTuple); + }), + ); + } + }; +} + +function selectHandler({ + handlers: { asyncs, primitives, syncs }, + value, +}: { + handlers: { + asyncs: Set>; + primitives: Map< + TsonAllTypes, + Extract, TsonTypeTesterPrimitive> + >; + syncs: Set, TsonTypeTesterCustom>>; + }; + value: unknown; +}) { + let handler; + const maybePrimitive = primitives.get(typeof value); + + if (!maybePrimitive?.test || maybePrimitive.test(value)) { + handler = maybePrimitive; + } + + handler ??= [...syncs].find((handler) => handler.test(value)); + handler ??= [...asyncs].find((handler) => handler.test(value)); + + return handler; +} + +async function* toAsyncGenerator( + item: T, +): AsyncGenerator { + let code; + + try { + if (isIterableEsque(item) || isAsyncIterableEsque(item)) { + let i = 0; + for await (const chunk of item) { + yield { + chunk, + key: i++, + }; + } + } else { + for (const key in item) { + yield { + chunk: item[key], + key, + }; + } + } + + code = TsonStatus.OK; + return code; + } catch { + code = TsonStatus.ERROR; + return code; + } finally { + code ??= TsonStatus.INCOMPLETE; + } +} + +// function typeofStruct< +// T extends +// | AsyncIterable +// | Iterable +// | Record +// | any[], +// >(item: T): "array" | "iterable" | "pojo" { +// switch (true) { +// case Symbol.asyncIterator in item: +// return "iterable"; +// case Array.isArray(item): +// return "array"; +// case Symbol.iterator in item: +// return "iterable"; +// default: +// // we intentionally treat functions as pojos +// return "pojo"; +// } +// } + +// /** +// * - Async iterables are iterated, and each value yielded is walked. +// * To be able to reconstruct the reference graph, each value is +// * assigned a negative-indexed label indicating both the order in +// * which it was yielded, and that it is a child of an async iterable. +// * Upon deserialization, each [key, value] pair is set as a property +// * on an object with a [Symbol.asyncIterator] method which yields +// * the values, preserving the order. +// * +// * - Arrays are iterated with their indices as labels and +// * then reconstructed as arrays. +// * +// * - Maps are iterated as objects +// * +// * - Sets are iterated as arrays +// * +// * - All other iterables are iterated as if they were async. +// * +// * - All other objects are iterated with their keys as labels and +// * reconstructed as objects, effectively replicating +// * the behavior of `Object.fromEntries(Object.entries(obj))` +// * @yields {TsonAsyncChunk} +// */ +// async function* toAsyncGenerator( +// item: T, +// ): AsyncGenerator { +// let code; + +// try { +// if (isIterableEsque(item) || isAsyncIterableEsque(item)) { +// let i = 0; +// for await (const chunk of item) { +// yield { +// chunk, +// key: i++, +// }; +// } +// } else { +// for (const key in item) { +// yield { +// chunk: item[key], +// key, +// }; +// } +// } + +// code = TSON_STATUS.OK; +// return code; +// } catch { +// code = TSON_STATUS.ERROR; +// return code; +// } finally { +// code ??= TSON_STATUS.INCOMPLETE; +// } +// } diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index 0ac0c53..bb0f217 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -45,9 +45,16 @@ function getHandlers(opts: TsonOptions) { const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; - const guards = opts.guards ?? []; + function runGuards(value: unknown) { + for (const guard of opts.guards ?? []) { + const isOk = guard.assert(value); + if (typeof isOk === "boolean" && !isOk) { + throw new Error(`Guard ${guard.key} failed on value ${String(value)}`); + } + } + } - return [getNonce, customs, primitives, guards] as const; + return [getNonce, customs, primitives, runGuards] as const; } export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { @@ -58,7 +65,7 @@ export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { } export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { - const [getNonce, nonPrimitives, primitives, guards] = getHandlers(opts); + const [getNonce, nonPrimitives, primitives, runGuards] = getHandlers(opts); const walker: WalkerFactory = (nonce) => { // create a persistent cache shared across recursions @@ -85,14 +92,7 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { } // apply guards to unhanded values - for (const guard of guards) { - const result = guard.assert(value); - if (typeof result === "boolean" && !result) { - throw new Error( - `Guard ${guard.key} failed on value ${String(value)}`, - ); - } - } + runGuards(value); // recursively walk children return cacheAndReturn(mapOrReturn(value, walk));