From 6b9752a82023fdd91e3115e177b95fa208295b23 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Tue, 31 Oct 2023 01:02:58 -0500 Subject: [PATCH 01/19] initial new abstraction for sync --- cspell.json | 6 +- src/async/asyncHelpers.ts | 31 +++++ src/iterableTypes.test.ts | 65 +++++++++++ src/iterableTypes.ts | 123 ++++++++++++++++++++ src/sync/deserialize.ts | 22 ++-- src/sync/handlers/tsonNumberGuard.ts | 26 ++--- src/sync/handlers/tsonUnknownObjectGuard.ts | 16 +-- src/sync/syncTypes.ts | 34 ++---- src/tsonAssert.ts | 97 +++++++++++++++ 9 files changed, 364 insertions(+), 56 deletions(-) create mode 100644 src/async/asyncHelpers.ts create mode 100644 src/iterableTypes.test.ts create mode 100644 src/iterableTypes.ts create mode 100644 src/tsonAssert.ts diff --git a/cspell.json b/cspell.json index bb39e3ea..4dcd1843 100644 --- a/cspell.json +++ b/cspell.json @@ -1,5 +1,7 @@ { - "dictionaries": ["typescript"], + "dictionaries": [ + "typescript" + ], "ignorePaths": [ ".github", "CHANGELOG.md", @@ -29,6 +31,8 @@ "outro", "packagejson", "quickstart", + "Streamified", + "Streamify", "stringifier", "superjson", "tson", diff --git a/src/async/asyncHelpers.ts b/src/async/asyncHelpers.ts new file mode 100644 index 00000000..19ac2268 --- /dev/null +++ b/src/async/asyncHelpers.ts @@ -0,0 +1,31 @@ +export async function* mapIterable( + iterable: AsyncIterable, + fn: (v: T) => TValue, +): AsyncIterable { + for await (const value of iterable) { + yield fn(value); + } +} + +export async function reduceIterable< + T, + TInitialValue extends Promise = Promise, + TKey extends PropertyKey = number, + TKeyFn extends (prev: TKey) => TKey = (prev: TKey) => TKey, +>( + iterable: AsyncIterable, + fn: (acc: Awaited, v: T, i: TKey) => Awaited, + initialValue: TInitialValue = Promise.resolve() as TInitialValue, + initialKey: TKey = 0 as TKey, + incrementKey: TKeyFn = ((prev) => (prev as number) + 1) as TKeyFn, +): Promise> { + let acc = initialValue; + let i = initialKey; + + for await (const value of iterable) { + acc = fn(await acc, value, i); + i = incrementKey(i); + } + + return Promise.resolve(acc); +} diff --git a/src/iterableTypes.test.ts b/src/iterableTypes.test.ts new file mode 100644 index 00000000..be0f3395 --- /dev/null +++ b/src/iterableTypes.test.ts @@ -0,0 +1,65 @@ +import * as v from "vitest"; + +import { + AsyncGenerator, + AsyncIterable, + AsyncIterableIterator, + Generator, + Iterable, + IterableIterator, +} from "./iterableTypes.js"; + +v.describe("Async Iterable Types", () => { + v.it("should be interchangeable with the original type signatures", () => { + const generator = (async function* () { + await Promise.resolve(); + yield 1; + yield 2; + yield 3; + })(); + + v.expectTypeOf(generator).toMatchTypeOf>(); + + const iterable = { + [Symbol.asyncIterator]: () => generator, + }; + + v.expectTypeOf(iterable).toMatchTypeOf>(); + + const iterableIterator = iterable[Symbol.asyncIterator](); + + v.expectTypeOf(iterableIterator).toMatchTypeOf< + AsyncIterableIterator + >(); + + const iterator = iterableIterator[Symbol.asyncIterator](); + + v.expectTypeOf(iterator).toMatchTypeOf>(); + }); +}); + +v.describe("Iterable Types", () => { + v.it("should be interchangeable with the original type signatures", () => { + const generator = (function* () { + yield 1; + yield 2; + yield 3; + })(); + + v.expectTypeOf(generator).toMatchTypeOf>(); + + const iterable = { + [Symbol.iterator]: () => generator, + }; + + v.expectTypeOf(iterable).toMatchTypeOf>(); + + const iterableIterator = iterable[Symbol.iterator](); + + v.expectTypeOf(iterableIterator).toMatchTypeOf>(); + + const iterator = iterableIterator[Symbol.iterator](); + + v.expectTypeOf(iterator).toMatchTypeOf>(); + }); +}); diff --git a/src/iterableTypes.ts b/src/iterableTypes.ts new file mode 100644 index 00000000..96250fb7 --- /dev/null +++ b/src/iterableTypes.ts @@ -0,0 +1,123 @@ +/** + * @file + * @see https://github.com/microsoft/TypeScript/issues/32682 + * @variation {Async} AsyncIterator, AsyncIterable, and AsyncIterableIterator can + * be substituted for Iterator, Iterable, and IterableIterator respectively in + * the below description. + * @description + * Native constructs which use Iterables discard the value of the return type + * and call `next()` with no arguments. While it is possible to instantiate + * an Iterable's Iterator manually, that iterator must be prepared to take + * `undefined` as an argument to `next()`, and expect that the return value + * may be discarded. Otherwise, it would break the Iterable contract. + * + * In other words, Iterators leave it to the consumer to decide what to do + * on each iteration (via the 'next' and 'return' methods), while Iterables + * enforce a contract that the consumer must follow, where these methods are + * optional. + * + * To preserve correctness, an Iterable's `Next` and `Return` types + * MUST be joined with undefined when passed as type parameters to the + * Iterator returned by its [Symbol.iterator] method. + * + * For IterableIterators, a TypeScript construct which extends the Iterator + * interface, the additional type parameters SHOULD NOT be joined with + * undefined when passed to the Iterator which the interface extends. By testing + * for the presence of a parameter in the `next()` method, an iterator can + * determine whether is being called manually or by a native construct. It is + * perfectly valid for an IterableIterator to require a parameter in it's own + * `next()` method, but not in the `next()` method of the iterator returned + * by its [Symbol.iterator]. + * + * As of Feb 4, 2022 (v4.6.1), the TS team had shelved adding the 2nd and 3rd + * type parameters to these interfaces, but had not ruled it out for the future. + */ + +/** + * A stronger type for Iterable + */ +export interface Iterable< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> { + [Symbol.iterator](): Iterator< + T, + TOptionalReturn | undefined, + TOptionalNext | undefined + >; +} + +/** + * A stronger type for IterableIterator. + */ +export interface IterableIterator< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> extends Iterator { + [Symbol.iterator](): IterableIterator< + T, + TOptionalReturn | undefined, + TOptionalNext | undefined + >; +} + +/** + * A stronger type for Generator + */ +export interface Generator< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> { + [Symbol.iterator](): Iterator< + T, + TOptionalReturn | undefined, + TOptionalNext | undefined + >; +} + +/** + * A stronger type for AsyncIterable + */ +export interface AsyncIterable< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> { + [Symbol.asyncIterator](): AsyncIterator< + T, + TOptionalReturn | undefined, + TOptionalNext | undefined + >; +} + +/** + * A stronger type for AsyncIterableIterator. + */ +export interface AsyncIterableIterator< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> extends AsyncIterator { + [Symbol.asyncIterator](): AsyncIterableIterator< + T, + TOptionalReturn | undefined, + TOptionalNext | undefined + >; +} +/** + * A stronger type for AsyncGenerator + */ +export interface AsyncGenerator< + T = unknown, + TOptionalReturn = unknown, + TOptionalNext = unknown, +> { + [Symbol.asyncIterator](): AsyncIterator< + T, + TOptionalReturn | undefined, + TOptionalNext | undefined + >; +} diff --git a/src/sync/deserialize.ts b/src/sync/deserialize.ts index da27568a..d4732e64 100644 --- a/src/sync/deserialize.ts +++ b/src/sync/deserialize.ts @@ -1,31 +1,35 @@ import { isTsonTuple } from "../internals/isTsonTuple.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; +import { TsonAssert, TsonGuard } from "../tsonAssert.js"; import { TsonDeserializeFn, + TsonMarshaller, TsonNonce, TsonOptions, TsonParseFn, TsonSerialized, - TsonTransformerSerializeDeserialize, } from "./syncTypes.js"; type WalkFn = (value: unknown) => unknown; type WalkerFactory = (nonce: TsonNonce) => WalkFn; -type AnyTsonTransformerSerializeDeserialize = - TsonTransformerSerializeDeserialize; +type AnyTsonMarshaller = TsonMarshaller; export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { - const typeByKey: Record = {}; - + const typeByKey: Record = {}; + const assertions: TsonGuard[] = []; for (const handler of opts.types) { if (handler.key) { if (typeByKey[handler.key]) { throw new Error(`Multiple handlers for key ${handler.key} found`); } - typeByKey[handler.key] = - handler as AnyTsonTransformerSerializeDeserialize; + typeByKey[handler.key] = handler as AnyTsonMarshaller; + } + + if ("assertion" in handler) { + assertions.push(handler); + continue; } } @@ -33,6 +37,10 @@ export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { const walk: WalkFn = (value) => { if (isTsonTuple(value, nonce)) { const [type, serializedValue] = value; + for (const assert of assertions) { + assert.assertion(value); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const transformer = typeByKey[type]!; return transformer.deserialize(walk(serializedValue)); diff --git a/src/sync/handlers/tsonNumberGuard.ts b/src/sync/handlers/tsonNumberGuard.ts index c3b4da57..d4bc9864 100644 --- a/src/sync/handlers/tsonNumberGuard.ts +++ b/src/sync/handlers/tsonNumberGuard.ts @@ -1,21 +1,19 @@ -import { TsonType } from "../syncTypes.js"; +import { tsonAssert } from "../../tsonAssert.js"; /** * Prevents `NaN` and `Infinity` from being serialized */ -export const tsonNumberGuard: TsonType = { - primitive: "number", - test: (v) => { - const value = v as number; - if (isNaN(value)) { - throw new Error("Encountered NaN"); - } +export const tsonAssertNotInfinite = tsonAssert((v) => { + if (typeof v !== "number") { + return; + } - if (!isFinite(value)) { - throw new Error("Encountered Infinity"); - } + if (isNaN(v)) { + throw new Error("Encountered NaN"); + } - return false; - }, -}; + if (!isFinite(v)) { + throw new Error("Encountered Infinity"); + } +}); diff --git a/src/sync/handlers/tsonUnknownObjectGuard.ts b/src/sync/handlers/tsonUnknownObjectGuard.ts index f4316d7e..78d995b9 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.ts @@ -1,6 +1,6 @@ import { TsonError } from "../../errors.js"; import { isPlainObject } from "../../internals/isPlainObject.js"; -import { TsonType } from "../syncTypes.js"; +import { TsonGuard, tsonAssert } from "../../tsonAssert.js"; export class TsonUnknownObjectGuardError extends TsonError { /** @@ -24,12 +24,8 @@ export class TsonUnknownObjectGuardError extends TsonError { * Make sure to define this last in the list of types. * @throws {TsonUnknownObjectGuardError} if an unknown object is found */ -export const tsonUnknownObjectGuard: TsonType = { - test: (v) => { - if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { - throw new TsonUnknownObjectGuardError(v); - } - - return false; - }, -}; +export const tsonUnknownObjectGuard = tsonAssert((v) => { + if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { + throw new TsonUnknownObjectGuardError(v); + } +}); diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index bb45241c..fee49540 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -1,3 +1,5 @@ +import { TsonGuard } from "../tsonAssert.js"; + const brand = Symbol("branded"); export type TsonBranded = TType & { [brand]: TBrand }; @@ -18,29 +20,18 @@ export type TsonAllTypes = | "string" | "symbol" | "undefined"; - -type SerializedType = +// Should this not be a recursive type? Any serialized objects should all have +// be json-serializable, right? +// Also, is it correct to be prescriptive about the type of the object? +// - ANY object can make itself json-serializable by way of [Symbol.toPrimitive] +export type SerializedType = | Record | boolean | number | string | unknown[]; -export interface TsonTransformerNone { - async?: false; - deserialize?: never; - - /** - * The key to use when serialized - */ - key?: never; - serialize?: never; - serializeIterator?: never; -} -export interface TsonTransformerSerializeDeserialize< - TValue, - TSerializedType extends SerializedType, -> { +export interface TsonMarshaller { async?: false; /** * From JSON-serializable value @@ -55,13 +46,8 @@ export interface TsonTransformerSerializeDeserialize< * JSON-serializable value */ serialize: (v: TValue) => TSerializedType; - serializeIterator?: never; } -export type TsonTransformer = - | TsonTransformerNone - | TsonTransformerSerializeDeserialize; - export interface TsonTypeTesterPrimitive { /** * The type of the primitive @@ -94,7 +80,7 @@ export type TsonType< * JSON-serializable value how it's stored after it's serialized */ TSerializedType extends SerializedType, -> = TsonTypeTester & TsonTransformer; +> = TsonTypeTester & TsonMarshaller; export interface TsonOptions { /** @@ -106,7 +92,7 @@ export interface TsonOptions { /** * The list of types to use */ - types: (TsonType | TsonType)[]; + types: (TsonGuard | TsonType)[]; } export const serialized = Symbol("serialized"); diff --git a/src/tsonAssert.ts b/src/tsonAssert.ts new file mode 100644 index 00000000..815a93e4 --- /dev/null +++ b/src/tsonAssert.ts @@ -0,0 +1,97 @@ +export const asserts = Symbol("asserted"); +export type Not = T extends true ? false : true; + +const secret = Symbol("secret"); +type Secret = typeof secret; + +export type IsNever = [T] extends [never] ? true : false; +export type IsAny = [T] extends [Secret] ? Not> : false; +export type IsUnknown = [unknown] extends [T] ? Not> : false; +/** + * The more I think about these semantics, the less they make sense. + * What is this API, really? What is the goal? + * Is it to provide a way to assert that a value is of a certain type? I think + * this only makes sense in a few limited cases. + * + * At what point in the pipeline would this occur? Would this happen for all + * values? If so, well... you've asserted that your parser only handles + * the type you're asserting. I don't know why you'd want to predetermine + * that at the time of configuration. Alternatively, if this isn't called + * for each value, then one of the following must be true: + * + * - you have also specified, somehow, *when* to apply this assertion + * - it is part of an array of operations that are attempted in order, + * and the first one that succeeds is used + * + * The first point could easily be accomplished by a conditional inside the + * assertion function (e.g. for the number guard, If not of type 'number' + * then it's definitely not NaN)... so no need for anything special like an + * array of type handler keys associated with the assertion. + * + * The second point is an option, since that's how custom tson types work. + * But is there really any utility to that? We're not zod validating... + * we're marshalling. We assume the type layer is accurate, without + * enforcing it. If we want to integrate runtime type validation, that + * seems like a feature request, potentially warranting it's own API. + * + * Ultimately, I feel like this functionality is easily served by a simple + * assertion function that throws for invalid values. For most (all?) except + * for the unknown object guard they would come first in the array, while + * the unknown object guard would come last. + */ +export interface TsonGuard { + /** + * A type assertion that narrows the type of the value + */ + assertion:

( + v: P, + ) => asserts v is IsAny

extends true + ? T extends infer N extends P + ? N + : T extends P + ? T + : P & T + : never; + /** + * A unique identifier for this assertion + */ + key: string; +} + +interface ValidationParser { + parse: (...args: unknown[]) => TValue; +} + +export interface TsonAssert { + is: , TValue = unknown>( + schema: TSchema, + ) => TsonGuard>; + (assertFn: ValidationParser["parse"]): TsonGuard; +} +/** + * @param assertFn - A type assertion that narrows the type of the value + * @function tsonAssert["is"] - returns a TsonGuard from a validation schema + * @returns a new TsonGuard for use in configuring TSON. + * The key of the guard is the name of the assertion function. + */ + +const tsonAssert = (assertFn: (v: unknown) => asserts v is TValue) => + ({ + assertion: assertFn, + key: assertFn.name, + }) satisfies TsonGuard; + +/** + * @param schema - A validation schema with a 'parse' method that throws an error + * if the value is invalid + * @returns a new TsonGuard for use in configuring TSON. + */ +tsonAssert.is = , TValue = unknown>( + schema: TSchema, +) => + ({ + assertion: schema.parse, + key: schema.parse.name, + }) satisfies TsonGuard>; + +export { tsonAssert }; From 98a8f8152d6504451b86c5f79d1a6a7cd9753815 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Tue, 14 Nov 2023 04:03:10 -0600 Subject: [PATCH 02/19] types: clean up interfaces in iterableTypes.ts feat: guards are distinct from type handlers chore: rename some things types: general type improvements chore: add more iterable utils --- cspell.json | 1 + src/async/asyncTypes.ts | 6 +- src/async/deserializeAsync.ts | 13 ++-- src/async/iterableUtils.ts | 74 +++++++++++++++++++++ src/async/serializeAsync.ts | 41 ++++++------ src/iterableTypes.ts | 121 +++++++--------------------------- src/sync/serialize.ts | 8 +-- src/sync/syncTypes.ts | 26 +++++--- 8 files changed, 146 insertions(+), 144 deletions(-) diff --git a/cspell.json b/cspell.json index 4dcd1843..b29d9f18 100644 --- a/cspell.json +++ b/cspell.json @@ -35,6 +35,7 @@ "Streamify", "stringifier", "superjson", + "Thunkable", "tson", "tsup", "tupleson", diff --git a/src/async/asyncTypes.ts b/src/async/asyncTypes.ts index 5490d866..c2c5d2c0 100644 --- a/src/async/asyncTypes.ts +++ b/src/async/asyncTypes.ts @@ -76,9 +76,5 @@ export interface TsonAsyncOptions { /** * The list of types to use */ - types: ( - | TsonAsyncType - | TsonType - | TsonType - )[]; + types: (TsonAsyncType | TsonType)[]; } diff --git a/src/async/deserializeAsync.ts b/src/async/deserializeAsync.ts index 68ddd842..05c328a2 100644 --- a/src/async/deserializeAsync.ts +++ b/src/async/deserializeAsync.ts @@ -6,9 +6,9 @@ import { assert } from "../internals/assert.js"; import { isTsonTuple } from "../internals/isTsonTuple.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { + TsonMarshaller, TsonNonce, TsonSerialized, - TsonTransformerSerializeDeserialize, } from "../sync/syncTypes.js"; import { TsonAbortError, TsonStreamInterruptedError } from "./asyncErrors.js"; import { @@ -27,9 +27,7 @@ import { TsonAsyncValueTuple } from "./serializeAsync.js"; type WalkFn = (value: unknown) => unknown; type WalkerFactory = (nonce: TsonNonce) => WalkFn; -type AnyTsonTransformerSerializeDeserialize = - | TsonAsyncType - | TsonTransformerSerializeDeserialize; +type AnyTsonMarshaller = TsonAsyncType | TsonMarshaller; export interface TsonParseAsyncOptions { /** @@ -56,7 +54,7 @@ type TsonParseAsync = ( type TsonDeserializeIterableValue = TsonAsyncValueTuple | TsonSerialized; type TsonDeserializeIterable = AsyncIterable; function createTsonDeserializer(opts: TsonAsyncOptions) { - const typeByKey: Record = {}; + const typeByKey: Record = {}; for (const handler of opts.types) { if (handler.key) { @@ -64,8 +62,7 @@ function createTsonDeserializer(opts: TsonAsyncOptions) { throw new Error(`Multiple handlers for key ${handler.key} found`); } - typeByKey[handler.key] = - handler as AnyTsonTransformerSerializeDeserialize; + typeByKey[handler.key] = handler as AnyTsonMarshaller; } } @@ -183,7 +180,7 @@ function createTsonDeserializer(opts: TsonAsyncOptions) { const walk = walker(head.nonce); try { - const walked = walk(head.json); + const walked = walk(head.tson); return walked; } finally { diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index 8dd2fbdf..b368abca 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -3,6 +3,7 @@ import { NodeJSReadableStreamEsque, WebReadableStreamEsque, } from "../internals/esque.js"; +import { AsyncGenerator, Generator } from "../iterableTypes.js"; export async function* readableStreamToAsyncIterable( stream: @@ -150,3 +151,76 @@ function addIfProvided( return `${key}: ${value as any}\n`; } + +export interface AsyncIterableEsque { + [Symbol.asyncIterator](): AsyncIterator; +} + +export function isAsyncIterableEsque( + maybeAsyncIterable: unknown, +): maybeAsyncIterable is AsyncIterableEsque & AsyncIterable { + return ( + !!maybeAsyncIterable && + (typeof maybeAsyncIterable === "object" || + typeof maybeAsyncIterable === "function") && + Symbol.asyncIterator in maybeAsyncIterable + ); +} + +export interface IterableEsque { + [Symbol.iterator](): unknown; +} + +export function isIterableEsque( + maybeIterable: unknown, +): maybeIterable is IterableEsque { + return ( + !!maybeIterable && + (typeof maybeIterable === "object" || + typeof maybeIterable === "function") && + Symbol.iterator in maybeIterable + ); +} + +type GeneratorFnEsque = (() => AsyncGenerator) | (() => Generator); + +export function isAsyncGeneratorFn( + maybeAsyncGeneratorFn: unknown, +): maybeAsyncGeneratorFn is GeneratorFnEsque { + return ( + typeof maybeAsyncGeneratorFn === "function" && + ["AsyncGeneratorFunction", "GeneratorFunction"].includes( + maybeAsyncGeneratorFn.constructor.name, + ) + ); +} + +export type PromiseEsque = PromiseLike; + +export function isPromiseEsque( + maybePromise: unknown, +): maybePromise is PromiseEsque { + return ( + !!maybePromise && + typeof maybePromise === "object" && + "then" in maybePromise && + typeof maybePromise.then === "function" + ); +} + +export type ThunkEsque = () => unknown; + +export function isThunkEsque(maybeThunk: unknown): maybeThunk is ThunkEsque { + return ( + !!maybeThunk && typeof maybeThunk === "function" && maybeThunk.length === 0 + ); +} + +export type Thunkable = + | AsyncIterableEsque + | GeneratorFnEsque + | IterableEsque + | PromiseEsque + | ThunkEsque; + +export type MaybePromise = Promise | T; diff --git a/src/async/serializeAsync.ts b/src/async/serializeAsync.ts index 92613317..1d359f69 100644 --- a/src/async/serializeAsync.ts +++ b/src/async/serializeAsync.ts @@ -33,7 +33,7 @@ function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { const iterators = new Map>(); - const iterator = { + const iterable: AsyncIterable = { async *[Symbol.asyncIterator]() { // race all active iterators and yield next value as they come // when one iterator is done, remove it from the list @@ -94,24 +94,25 @@ function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { walk: WalkFn, ) => TsonSerializedValue; - const $serialize: Serializer = handler.serializeIterator - ? (value): TsonTuple => { - const idx = asyncIndex++ as TsonAsyncIndex; - - const iterator = handler.serializeIterator({ - value, - }); - iterators.set(idx, iterator[Symbol.asyncIterator]()); - - return [handler.key as TsonTypeHandlerKey, idx, nonce]; - } - : handler.serialize - ? (value, nonce, walk): TsonTuple => [ - handler.key as TsonTypeHandlerKey, - walk(handler.serialize(value)), - nonce, - ] - : (value, _nonce, walk) => walk(value); + const $serialize: Serializer = + "serializeIterator" in handler + ? (value): TsonTuple => { + const idx = asyncIndex++ as TsonAsyncIndex; + + const iterator = handler.serializeIterator({ + value, + }); + iterators.set(idx, iterator[Symbol.asyncIterator]()); + + return [handler.key as TsonTypeHandlerKey, idx, nonce]; + } + : "serialize" in handler + ? (value, nonce, walk): TsonTuple => [ + handler.key as TsonTypeHandlerKey, + walk(handler.serialize(value)), + nonce, + ] + : (value, _nonce, walk) => walk(value); return { ...handler, $serialize, @@ -185,7 +186,7 @@ function walkerFactory(nonce: TsonNonce, types: TsonAsyncOptions["types"]) { return cacheAndReturn(mapOrReturn(value, walk)); }; - return [walk, iterator] as const; + return [walk, iterable] as const; } type TsonAsyncSerializer = ( diff --git a/src/iterableTypes.ts b/src/iterableTypes.ts index 96250fb7..ebf6b6b3 100644 --- a/src/iterableTypes.ts +++ b/src/iterableTypes.ts @@ -1,67 +1,32 @@ /** * @file - * @see https://github.com/microsoft/TypeScript/issues/32682 - * @variation {Async} AsyncIterator, AsyncIterable, and AsyncIterableIterator can - * be substituted for Iterator, Iterable, and IterableIterator respectively in - * the below description. - * @description - * Native constructs which use Iterables discard the value of the return type - * and call `next()` with no arguments. While it is possible to instantiate - * an Iterable's Iterator manually, that iterator must be prepared to take - * `undefined` as an argument to `next()`, and expect that the return value - * may be discarded. Otherwise, it would break the Iterable contract. - * - * In other words, Iterators leave it to the consumer to decide what to do - * on each iteration (via the 'next' and 'return' methods), while Iterables - * enforce a contract that the consumer must follow, where these methods are - * optional. - * - * To preserve correctness, an Iterable's `Next` and `Return` types - * MUST be joined with undefined when passed as type parameters to the - * Iterator returned by its [Symbol.iterator] method. - * - * For IterableIterators, a TypeScript construct which extends the Iterator - * interface, the additional type parameters SHOULD NOT be joined with - * undefined when passed to the Iterator which the interface extends. By testing - * for the presence of a parameter in the `next()` method, an iterator can - * determine whether is being called manually or by a native construct. It is - * perfectly valid for an IterableIterator to require a parameter in it's own - * `next()` method, but not in the `next()` method of the iterator returned - * by its [Symbol.iterator]. - * - * As of Feb 4, 2022 (v4.6.1), the TS team had shelved adding the 2nd and 3rd - * type parameters to these interfaces, but had not ruled it out for the future. + * This file originally contained types for the `iterable` and `asyncIterable` + * (as well as `iterableIterator` and `asyncIterableIterator`) types, but + * ultimately they were decided against, as they were not ultimately useful. + * The extensions essentially provided useless information, given that the + * `next` and `return` methods cannot be relied upon to be present. For that, + * Generators are a better choice, as they expose the `next` and `return` + * methods through the GeneratorFunction syntax. + * @see https://github.com/microsoft/TypeScript/issues/32682 for information + * about the types that were removed. */ +/* eslint-disable @typescript-eslint/no-empty-interface */ + /** - * A stronger type for Iterable + * A stronger type for Iterator */ -export interface Iterable< - T = unknown, - TOptionalReturn = unknown, - TOptionalNext = unknown, -> { - [Symbol.iterator](): Iterator< - T, - TOptionalReturn | undefined, - TOptionalNext | undefined - >; -} +export interface Iterator + extends globalThis.Iterator {} /** - * A stronger type for IterableIterator. + * A stronger type for AsyncIterator */ -export interface IterableIterator< +export interface AsyncIterator< T = unknown, - TOptionalReturn = unknown, - TOptionalNext = unknown, -> extends Iterator { - [Symbol.iterator](): IterableIterator< - T, - TOptionalReturn | undefined, - TOptionalNext | undefined - >; -} + TReturn = unknown, + TNextArg = unknown, +> extends globalThis.AsyncIterator {} /** * A stronger type for Generator @@ -70,43 +35,7 @@ export interface Generator< T = unknown, TOptionalReturn = unknown, TOptionalNext = unknown, -> { - [Symbol.iterator](): Iterator< - T, - TOptionalReturn | undefined, - TOptionalNext | undefined - >; -} - -/** - * A stronger type for AsyncIterable - */ -export interface AsyncIterable< - T = unknown, - TOptionalReturn = unknown, - TOptionalNext = unknown, -> { - [Symbol.asyncIterator](): AsyncIterator< - T, - TOptionalReturn | undefined, - TOptionalNext | undefined - >; -} - -/** - * A stronger type for AsyncIterableIterator. - */ -export interface AsyncIterableIterator< - T = unknown, - TOptionalReturn = unknown, - TOptionalNext = unknown, -> extends AsyncIterator { - [Symbol.asyncIterator](): AsyncIterableIterator< - T, - TOptionalReturn | undefined, - TOptionalNext | undefined - >; -} +> extends globalThis.Generator {} /** * A stronger type for AsyncGenerator */ @@ -114,10 +43,6 @@ export interface AsyncGenerator< T = unknown, TOptionalReturn = unknown, TOptionalNext = unknown, -> { - [Symbol.asyncIterator](): AsyncIterator< - T, - TOptionalReturn | undefined, - TOptionalNext | undefined - >; -} +> extends globalThis.AsyncGenerator {} + +/* eslint-enable @typescript-eslint/no-empty-interface */ diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index bc1d2d6f..1becd808 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -91,8 +91,8 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { ) { return cacheAndReturn([ primitiveHandler.key, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - walk(primitiveHandler.serialize!(value)), + + walk(primitiveHandler.serialize(value)), nonce, ] as TsonTuple); } @@ -101,8 +101,8 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { if (handler.test(value)) { return cacheAndReturn([ handler.key, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - walk(handler.serialize!(value)), + + walk(handler.serialize(value)), nonce, ] as TsonTuple); } diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index fee49540..69046bcb 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -7,7 +7,6 @@ export type TsonBranded = TType & { [brand]: TBrand }; export type TsonNonce = TsonBranded; export type TsonTypeHandlerKey = TsonBranded; export type TsonSerializedValue = unknown; - export type TsonTuple = [TsonTypeHandlerKey, TsonSerializedValue, TsonNonce]; // there's probably a better way of getting this type @@ -22,16 +21,17 @@ export type TsonAllTypes = | "undefined"; // Should this not be a recursive type? Any serialized objects should all have // be json-serializable, right? -// Also, is it correct to be prescriptive about the type of the object? -// - ANY object can make itself json-serializable by way of [Symbol.toPrimitive] export type SerializedType = - | Record + | { [key: string]: SerializedType } + | SerializedType[] | boolean | number - | string - | unknown[]; + | string; -export interface TsonMarshaller { +export interface TsonMarshaller< + TValue, + TSerializedType extends SerializedType, +> { async?: false; /** * From JSON-serializable value @@ -69,7 +69,7 @@ export interface TsonTypeTesterCustom { test: (v: unknown) => boolean; } -type TsonTypeTester = TsonTypeTesterCustom | TsonTypeTesterPrimitive; +export type TsonTypeTester = TsonTypeTesterCustom | TsonTypeTesterPrimitive; export type TsonType< /** @@ -83,6 +83,14 @@ export type TsonType< > = TsonTypeTester & TsonMarshaller; export interface TsonOptions { + /* eslint-disable jsdoc/informative-docs */ + /** + * The list of guards to apply to values before serializing them. + * Guards must throw on invalid values. + * @default [] + */ + /* eslint-enable jsdoc/informative-docs */ + guards?: TsonGuard[]; /** * The nonce function every time we start serializing a new object * Should return a unique value every time it's called @@ -92,7 +100,7 @@ export interface TsonOptions { /** * The list of types to use */ - types: (TsonGuard | TsonType)[]; + types: TsonType[]; } export const serialized = Symbol("serialized"); From f5881846fe7c587093a6a3d0a3eb5cf97e19c717 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 07:16:48 -0600 Subject: [PATCH 03/19] chore: refactor sync api feat!: new guard api --- cspell.json | 7 +- src/async/asyncHelpers.ts | 12 +- src/async/deserializeAsync.ts | 2 +- src/async/iterableUtils.ts | 36 ++++-- src/index.test.ts | 4 +- src/internals/isComplexValue.ts | 3 + src/internals/isPlainObject.ts | 4 +- src/iterableTypes.test.ts | 9 +- src/sync/deserialize.ts | 10 -- src/sync/handlers/tsonNumberGuard.test.ts | 3 +- src/sync/handlers/tsonNumberGuard.ts | 27 ++-- .../handlers/tsonUnknownObjectGuard.test.ts | 7 +- src/sync/handlers/tsonUnknownObjectGuard.ts | 15 ++- src/sync/serialize.ts | 117 ++++++++++-------- src/sync/syncTypes.ts | 9 +- src/tsonAssert.ts | 74 ++++------- 16 files changed, 167 insertions(+), 172 deletions(-) create mode 100644 src/internals/isComplexValue.ts diff --git a/cspell.json b/cspell.json index b29d9f18..17344265 100644 --- a/cspell.json +++ b/cspell.json @@ -26,16 +26,17 @@ "knip", "lcov", "markdownlintignore", + "marshaller", "npmpackagejsonlintrc", "openai", "outro", "packagejson", "quickstart", - "Streamified", - "Streamify", + "streamified", + "streamify", "stringifier", "superjson", - "Thunkable", + "thunkable", "tson", "tsup", "tupleson", diff --git a/src/async/asyncHelpers.ts b/src/async/asyncHelpers.ts index 19ac2268..2513b778 100644 --- a/src/async/asyncHelpers.ts +++ b/src/async/asyncHelpers.ts @@ -10,17 +10,17 @@ export async function* mapIterable( export async function reduceIterable< T, TInitialValue extends Promise = Promise, - TKey extends PropertyKey = number, - TKeyFn extends (prev: TKey) => TKey = (prev: TKey) => TKey, + TKey extends PropertyKey | bigint = bigint, + TKeyFn extends (prev?: TKey) => TKey = (prev?: TKey) => TKey, >( - iterable: AsyncIterable, + iterable: Iterable, fn: (acc: Awaited, v: T, i: TKey) => Awaited, initialValue: TInitialValue = Promise.resolve() as TInitialValue, - initialKey: TKey = 0 as TKey, - incrementKey: TKeyFn = ((prev) => (prev as number) + 1) as TKeyFn, + incrementKey: TKeyFn = ((prev?: bigint) => + prev === undefined ? 0n : prev + 1n) as TKeyFn, ): Promise> { let acc = initialValue; - let i = initialKey; + let i = incrementKey(); for await (const value of iterable) { acc = fn(await acc, value, i); diff --git a/src/async/deserializeAsync.ts b/src/async/deserializeAsync.ts index 05c328a2..c13b1b57 100644 --- a/src/async/deserializeAsync.ts +++ b/src/async/deserializeAsync.ts @@ -180,7 +180,7 @@ function createTsonDeserializer(opts: TsonAsyncOptions) { const walk = walker(head.nonce); try { - const walked = walk(head.tson); + const walked = walk(head.json); return walked; } finally { diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index b368abca..80954102 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -158,7 +158,7 @@ export interface AsyncIterableEsque { export function isAsyncIterableEsque( maybeAsyncIterable: unknown, -): maybeAsyncIterable is AsyncIterableEsque & AsyncIterable { +): maybeAsyncIterable is AsyncIterableEsque { return ( !!maybeAsyncIterable && (typeof maybeAsyncIterable === "object" || @@ -167,8 +167,8 @@ export function isAsyncIterableEsque( ); } -export interface IterableEsque { - [Symbol.iterator](): unknown; +export interface IterableEsque { + [Symbol.iterator](): Iterator; } export function isIterableEsque( @@ -182,11 +182,11 @@ export function isIterableEsque( ); } -type GeneratorFnEsque = (() => AsyncGenerator) | (() => Generator); +type SyncOrAsyncGeneratorFnEsque = AsyncGeneratorFnEsque | GeneratorFnEsque; -export function isAsyncGeneratorFn( +export function isMaybeAsyncGeneratorFn( maybeAsyncGeneratorFn: unknown, -): maybeAsyncGeneratorFn is GeneratorFnEsque { +): maybeAsyncGeneratorFn is SyncOrAsyncGeneratorFnEsque { return ( typeof maybeAsyncGeneratorFn === "function" && ["AsyncGeneratorFunction", "GeneratorFunction"].includes( @@ -195,6 +195,28 @@ export function isAsyncGeneratorFn( ); } +export type GeneratorFnEsque = () => Generator; + +export function isGeneratorFnEsque( + maybeGeneratorFn: unknown, +): maybeGeneratorFn is GeneratorFnEsque { + return ( + typeof maybeGeneratorFn === "function" && + maybeGeneratorFn.constructor.name === "GeneratorFunction" + ); +} + +export type AsyncGeneratorFnEsque = () => AsyncGenerator; + +export function isAsyncGeneratorFnEsque( + maybeAsyncGeneratorFn: unknown, +): maybeAsyncGeneratorFn is AsyncGeneratorFnEsque { + return ( + typeof maybeAsyncGeneratorFn === "function" && + maybeAsyncGeneratorFn.constructor.name === "AsyncGeneratorFunction" + ); +} + export type PromiseEsque = PromiseLike; export function isPromiseEsque( @@ -218,9 +240,9 @@ export function isThunkEsque(maybeThunk: unknown): maybeThunk is ThunkEsque { export type Thunkable = | AsyncIterableEsque - | GeneratorFnEsque | IterableEsque | PromiseEsque + | SyncOrAsyncGeneratorFnEsque | ThunkEsque; export type MaybePromise = Promise | T; diff --git a/src/index.test.ts b/src/index.test.ts index 21d52860..4eee8481 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,7 +5,7 @@ import { TsonOptions, TsonType, createTson } from "./index.js"; import { expectError, waitError } from "./internals/testUtils.js"; test("multiple handlers for primitive string found", () => { - const stringHandler: TsonType = { + const stringHandler: TsonType = { primitive: "string", }; const opts: TsonOptions = { @@ -98,7 +98,7 @@ test("async: duplicate keys", async () => { }); test("async: multiple handlers for primitive string found", async () => { - const stringHandler: TsonType = { + const stringHandler: TsonType = { primitive: "string", }; diff --git a/src/internals/isComplexValue.ts b/src/internals/isComplexValue.ts new file mode 100644 index 00000000..95ed14d6 --- /dev/null +++ b/src/internals/isComplexValue.ts @@ -0,0 +1,3 @@ +export function isComplexValue(arg: unknown): arg is object { + return arg !== null && (typeof arg === "object" || typeof arg === "function"); +} diff --git a/src/internals/isPlainObject.ts b/src/internals/isPlainObject.ts index 8f47a397..eeb16f24 100644 --- a/src/internals/isPlainObject.ts +++ b/src/internals/isPlainObject.ts @@ -1,4 +1,6 @@ -export const isPlainObject = (obj: unknown): obj is Record => { +export const isPlainObject = ( + obj: unknown, +): obj is Record => { if (!obj || typeof obj !== "object") { return false; } diff --git a/src/iterableTypes.test.ts b/src/iterableTypes.test.ts index be0f3395..aeee83a2 100644 --- a/src/iterableTypes.test.ts +++ b/src/iterableTypes.test.ts @@ -1,13 +1,6 @@ import * as v from "vitest"; -import { - AsyncGenerator, - AsyncIterable, - AsyncIterableIterator, - Generator, - Iterable, - IterableIterator, -} from "./iterableTypes.js"; +import { AsyncGenerator, Generator } from "./iterableTypes.js"; v.describe("Async Iterable Types", () => { v.it("should be interchangeable with the original type signatures", () => { diff --git a/src/sync/deserialize.ts b/src/sync/deserialize.ts index d4732e64..74f312c2 100644 --- a/src/sync/deserialize.ts +++ b/src/sync/deserialize.ts @@ -1,6 +1,5 @@ import { isTsonTuple } from "../internals/isTsonTuple.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; -import { TsonAssert, TsonGuard } from "../tsonAssert.js"; import { TsonDeserializeFn, TsonMarshaller, @@ -17,7 +16,6 @@ type AnyTsonMarshaller = TsonMarshaller; export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { const typeByKey: Record = {}; - const assertions: TsonGuard[] = []; for (const handler of opts.types) { if (handler.key) { if (typeByKey[handler.key]) { @@ -26,20 +24,12 @@ export function createTsonDeserialize(opts: TsonOptions): TsonDeserializeFn { typeByKey[handler.key] = handler as AnyTsonMarshaller; } - - if ("assertion" in handler) { - assertions.push(handler); - continue; - } } const walker: WalkerFactory = (nonce) => { const walk: WalkFn = (value) => { if (isTsonTuple(value, nonce)) { const [type, serializedValue] = value; - for (const assert of assertions) { - assert.assertion(value); - } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const transformer = typeByKey[type]!; diff --git a/src/sync/handlers/tsonNumberGuard.test.ts b/src/sync/handlers/tsonNumberGuard.test.ts index b5713517..3778ca51 100644 --- a/src/sync/handlers/tsonNumberGuard.test.ts +++ b/src/sync/handlers/tsonNumberGuard.test.ts @@ -5,7 +5,8 @@ import { expectError } from "../../internals/testUtils.js"; test("number", () => { const t = createTson({ - types: [tsonNumberGuard], + guards: [tsonNumberGuard], + types: [], }); const bad = [ diff --git a/src/sync/handlers/tsonNumberGuard.ts b/src/sync/handlers/tsonNumberGuard.ts index d4bc9864..5e569b7f 100644 --- a/src/sync/handlers/tsonNumberGuard.ts +++ b/src/sync/handlers/tsonNumberGuard.ts @@ -1,19 +1,22 @@ -import { tsonAssert } from "../../tsonAssert.js"; +import { TsonGuard } from "../../tsonAssert.js"; /** * Prevents `NaN` and `Infinity` from being serialized */ -export const tsonAssertNotInfinite = tsonAssert((v) => { - if (typeof v !== "number") { - return; - } +export const tsonNumberGuard: TsonGuard> = { + assert(v: unknown) { + if (typeof v !== "number") { + return; + } - if (isNaN(v)) { - throw new Error("Encountered NaN"); - } + if (isNaN(v)) { + throw new Error("Encountered NaN"); + } - if (!isFinite(v)) { - throw new Error("Encountered Infinity"); - } -}); + if (!isFinite(v)) { + throw new Error("Encountered Infinity"); + } + }, + key: "tsonAssertNotInfinite", +}; diff --git a/src/sync/handlers/tsonUnknownObjectGuard.test.ts b/src/sync/handlers/tsonUnknownObjectGuard.test.ts index 0739c857..b7d03899 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.test.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.test.ts @@ -11,11 +11,8 @@ import { expectError } from "../../internals/testUtils.js"; test("guard unwanted objects", () => { // Sets are okay, but not Maps const t = createTson({ - types: [ - tsonSet, - // defined last so it runs last - tsonUnknownObjectGuard, - ], + guards: [tsonUnknownObjectGuard], + types: [tsonSet], }); { diff --git a/src/sync/handlers/tsonUnknownObjectGuard.ts b/src/sync/handlers/tsonUnknownObjectGuard.ts index 78d995b9..6116e00a 100644 --- a/src/sync/handlers/tsonUnknownObjectGuard.ts +++ b/src/sync/handlers/tsonUnknownObjectGuard.ts @@ -1,6 +1,6 @@ import { TsonError } from "../../errors.js"; import { isPlainObject } from "../../internals/isPlainObject.js"; -import { TsonGuard, tsonAssert } from "../../tsonAssert.js"; +import { TsonGuard } from "../../tsonAssert.js"; export class TsonUnknownObjectGuardError extends TsonError { /** @@ -24,8 +24,11 @@ export class TsonUnknownObjectGuardError extends TsonError { * Make sure to define this last in the list of types. * @throws {TsonUnknownObjectGuardError} if an unknown object is found */ -export const tsonUnknownObjectGuard = tsonAssert((v) => { - if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { - throw new TsonUnknownObjectGuardError(v); - } -}); +export const tsonUnknownObjectGuard: TsonGuard> = { + assert(v: unknown) { + if (v && typeof v === "object" && !Array.isArray(v) && !isPlainObject(v)) { + throw new TsonUnknownObjectGuardError(v); + } + }, + key: "tsonUnknownObjectGuard", +}; diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index 1becd808..659dcc71 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -1,7 +1,9 @@ import { TsonCircularReferenceError } from "../errors.js"; import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; +import { isComplexValue } from "../internals/isComplexValue.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { + SerializedType, TsonAllTypes, TsonNonce, TsonOptions, @@ -9,6 +11,7 @@ import { TsonSerialized, TsonStringifyFn, TsonTuple, + TsonTypeHandlerKey, TsonTypeTesterCustom, TsonTypeTesterPrimitive, } from "./syncTypes.js"; @@ -19,30 +22,32 @@ type WalkerFactory = (nonce: TsonNonce) => WalkFn; function getHandlers(opts: TsonOptions) { type Handler = (typeof opts.types)[number]; - const byPrimitive: Partial< - Record> - > = {}; - const nonPrimitives: Extract[] = []; + const primitives = new Map< + TsonAllTypes, + Extract + >(); - for (const handler of opts.types) { - if (handler.primitive) { - if (byPrimitive[handler.primitive]) { + const customs = new Set>(); + + for (const marshaller of opts.types) { + if (marshaller.primitive) { + if (primitives.has(marshaller.primitive)) { throw new Error( - `Multiple handlers for primitive ${handler.primitive} found`, + `Multiple handlers for primitive ${marshaller.primitive} found`, ); } - byPrimitive[handler.primitive] = handler; + primitives.set(marshaller.primitive, marshaller); } else { - nonPrimitives.push(handler); + customs.add(marshaller); } } - const getNonce: GetNonce = opts.nonce - ? (opts.nonce as GetNonce) - : getDefaultNonce; + const getNonce = (opts.nonce ? opts.nonce : getDefaultNonce) as GetNonce; + + const guards = opts.guards ?? []; - return [getNonce, nonPrimitives, byPrimitive] as const; + return [getNonce, customs, primitives, guards] as const; } export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { @@ -53,65 +58,73 @@ export function createTsonStringify(opts: TsonOptions): TsonStringifyFn { } export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { - const [getNonce, nonPrimitive, byPrimitive] = getHandlers(opts); + const [getNonce, nonPrimitives, primitives, guards] = getHandlers(opts); const walker: WalkerFactory = (nonce) => { const seen = new WeakSet(); const cache = new WeakMap(); - const walk: WalkFn = (value) => { + const walk: WalkFn = (value: unknown) => { const type = typeof value; - const isComplex = !!value && type === "object"; - if (isComplex) { - if (seen.has(value)) { - const cached = cache.get(value); - if (!cached) { - throw new TsonCircularReferenceError(value); - } + const primitiveHandler = primitives.get(type); - return cached; + const handler = + primitiveHandler && + (!primitiveHandler.test || primitiveHandler.test(value)) + ? primitiveHandler + : Array.from(nonPrimitives).find((handler) => handler.test(value)); + + if (!handler) { + for (const guard of guards) { + // if ("assert" in guard) { + guard.assert(value); + // } + //todo: if this is implemented does it go before or after assert? + // if ("parse" in guard) { + // value = guard.parse(value); + // } } - seen.add(value); + return mapOrReturn(value, walk); } - const cacheAndReturn = (result: unknown) => { - if (isComplex) { - cache.set(value, result); - } - - return result; - }; + if (!isComplexValue(value)) { + return toTuple(value, handler); + } - const primitiveHandler = byPrimitive[type]; - if ( - primitiveHandler && - (!primitiveHandler.test || primitiveHandler.test(value)) - ) { - return cacheAndReturn([ - primitiveHandler.key, + // if this is a value-by-reference we've seen before, either: + // - We've serialized & cached it before and can return the cached value + // - We're attempting to serialize it, but one of its children is itself (circular reference) + if (cache.has(value)) { + return cache.get(value); + } - walk(primitiveHandler.serialize(value)), - nonce, - ] as TsonTuple); + if (seen.has(value)) { + throw new TsonCircularReferenceError(value); } - for (const handler of nonPrimitive) { - if (handler.test(value)) { - return cacheAndReturn([ - handler.key, + seen.add(value); - walk(handler.serialize(value)), - nonce, - ] as TsonTuple); - } - } + const tuple = toTuple(value, handler); - return cacheAndReturn(mapOrReturn(value, walk)); + cache.set(value, tuple); + + return tuple; }; return walk; + + function toTuple( + v: unknown, + handler: { key: string; serialize: (arg: unknown) => SerializedType }, + ) { + return [ + handler.key as TsonTypeHandlerKey, + walk(handler.serialize(v)), + nonce, + ] as TsonTuple; + } }; return ((obj): TsonSerialized => { diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index 69046bcb..bf574a23 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -19,14 +19,13 @@ export type TsonAllTypes = | "string" | "symbol" | "undefined"; -// Should this not be a recursive type? Any serialized objects should all have -// be json-serializable, right? + export type SerializedType = - | { [key: string]: SerializedType } - | SerializedType[] + | Record | boolean | number - | string; + | string + | unknown[]; export interface TsonMarshaller< TValue, diff --git a/src/tsonAssert.ts b/src/tsonAssert.ts index 815a93e4..efff045a 100644 --- a/src/tsonAssert.ts +++ b/src/tsonAssert.ts @@ -12,7 +12,7 @@ export type IsUnknown = [unknown] extends [T] ? Not> : false; * What is this API, really? What is the goal? * Is it to provide a way to assert that a value is of a certain type? I think * this only makes sense in a few limited cases. - * + * * At what point in the pipeline would this occur? Would this happen for all * values? If so, well... you've asserted that your parser only handles * the type you're asserting. I don't know why you'd want to predetermine @@ -33,65 +33,33 @@ export type IsUnknown = [unknown] extends [T] ? Not> : false; * we're marshalling. We assume the type layer is accurate, without * enforcing it. If we want to integrate runtime type validation, that * seems like a feature request, potentially warranting it's own API. - * + * * Ultimately, I feel like this functionality is easily served by a simple * assertion function that throws for invalid values. For most (all?) except * for the unknown object guard they would come first in the array, while * the unknown object guard would come last. */ -export interface TsonGuard { - /** - * A type assertion that narrows the type of the value - */ - assertion:

( - v: P, - ) => asserts v is IsAny

extends true - ? T extends infer N extends P - ? N - : T extends P - ? T - : P & T - : never; - /** - * A unique identifier for this assertion - */ +interface TsonGuardBase { key: string; } - -interface ValidationParser { - parse: (...args: unknown[]) => TValue; -} - -export interface TsonAssert { - is: , TValue = unknown>( - schema: TSchema, - ) => TsonGuard>; - (assertFn: ValidationParser["parse"]): TsonGuard; +interface TsonAssertionGuard extends TsonGuardBase { + /** + * @param v - The value to assert + * @returns `void | true` if the value is of the type + * @returns `false` if the value is not of the type + * @throws `any` if the value is not of the type + */ + assert: ((v: any) => asserts v is T) | ((v: any) => v is T); } -/** - * @param assertFn - A type assertion that narrows the type of the value - * @function tsonAssert["is"] - returns a TsonGuard from a validation schema - * @returns a new TsonGuard for use in configuring TSON. - * The key of the guard is the name of the assertion function. - */ - -const tsonAssert = (assertFn: (v: unknown) => asserts v is TValue) => - ({ - assertion: assertFn, - key: assertFn.name, - }) satisfies TsonGuard; -/** - * @param schema - A validation schema with a 'parse' method that throws an error - * if the value is invalid - * @returns a new TsonGuard for use in configuring TSON. - */ -tsonAssert.is = , TValue = unknown>( - schema: TSchema, -) => - ({ - assertion: schema.parse, - key: schema.parse.name, - }) satisfies TsonGuard>; +// // todo: maybe guard.parse can have guard.parse.input and guard.parse.output? +// interface TsonParserGuard extends TsonGuardBase { +// /** +// * +// * @param v - The value to parse +// * @returns {T} - A value that will be used in place of the original value +// */ +// parse: (v: any) => T; +// } -export { tsonAssert }; +export type TsonGuard = TsonAssertionGuard /* | TsonParserGuard */; From 78491c8ccfd1118c6163ae7c0ab6803e73239e11 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 07:19:02 -0600 Subject: [PATCH 04/19] types: fix new type signature for TsonType --- src/sync/syncTypes.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index bf574a23..ca95c074 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -79,7 +79,10 @@ export type TsonType< * JSON-serializable value how it's stored after it's serialized */ TSerializedType extends SerializedType, -> = TsonTypeTester & TsonMarshaller; +> = + | (TsonTypeTesterCustom & TsonMarshaller) + | (TsonTypeTesterPrimitive & + Partial>); export interface TsonOptions { /* eslint-disable jsdoc/informative-docs */ From 92ffe3387988a5d39bbe6cee132b83f3639c7290 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 18:44:03 -0600 Subject: [PATCH 05/19] fix: un-break guards and circular reference checks --- src/internals/isComplexValue.ts | 2 +- src/sync/serialize.ts | 107 +++++++++++++++++++++----------- src/sync/syncTypes.ts | 3 +- 3 files changed, 73 insertions(+), 39 deletions(-) diff --git a/src/internals/isComplexValue.ts b/src/internals/isComplexValue.ts index 95ed14d6..65be0251 100644 --- a/src/internals/isComplexValue.ts +++ b/src/internals/isComplexValue.ts @@ -1,3 +1,3 @@ export function isComplexValue(arg: unknown): arg is object { - return arg !== null && (typeof arg === "object" || typeof arg === "function"); + return (arg !== null && typeof arg === "object") || typeof arg === "function"; } diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index 659dcc71..c22a090c 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -3,8 +3,8 @@ import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; import { isComplexValue } from "../internals/isComplexValue.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { - SerializedType, TsonAllTypes, + TsonMarshaller, TsonNonce, TsonOptions, TsonSerializeFn, @@ -66,65 +66,100 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { const walk: WalkFn = (value: unknown) => { const type = typeof value; + const isComplex = isComplexValue(value); - const primitiveHandler = primitives.get(type); + if (isComplex) { + if (seen.has(value)) { + const cached = cache.get(value); + if (!cached) { + throw new TsonCircularReferenceError(value); + } - const handler = - primitiveHandler && - (!primitiveHandler.test || primitiveHandler.test(value)) - ? primitiveHandler - : Array.from(nonPrimitives).find((handler) => handler.test(value)); - - if (!handler) { - for (const guard of guards) { - // if ("assert" in guard) { - guard.assert(value); - // } - //todo: if this is implemented does it go before or after assert? - // if ("parse" in guard) { - // value = guard.parse(value); - // } + return cached; } - return mapOrReturn(value, walk); + seen.add(value); } - if (!isComplexValue(value)) { - return toTuple(value, handler); - } + const cacheAndReturn = (result: unknown) => { + if (isComplex) { + cache.set(value, result); + } - // if this is a value-by-reference we've seen before, either: - // - We've serialized & cached it before and can return the cached value - // - We're attempting to serialize it, but one of its children is itself (circular reference) - if (cache.has(value)) { - return cache.get(value); - } + return result; + }; - if (seen.has(value)) { - throw new TsonCircularReferenceError(value); + const primitiveHandler = primitives.get(type); + if ( + primitiveHandler && + (!primitiveHandler.test || primitiveHandler.test(value)) + ) { + return cacheAndReturn(toTuple(value, primitiveHandler)); } - seen.add(value); - - const tuple = toTuple(value, handler); + for (const handler of nonPrimitives) { + if (handler.test(value)) { + return cacheAndReturn(toTuple(value, handler)); + } + } - cache.set(value, tuple); + for (const guard of guards) { + // if ("assert" in guard) { + guard.assert(value); + // } + //todo: if this is implemented does it go before or after assert? + // if ("parse" in guard) { + // value = guard.parse(value); + // } + } - return tuple; + return cacheAndReturn(mapOrReturn(value, walk)); }; return walk; function toTuple( v: unknown, - handler: { key: string; serialize: (arg: unknown) => SerializedType }, + handler: + | (TsonTypeTesterCustom & TsonMarshaller) + | (TsonTypeTesterPrimitive & Partial>), ) { return [ handler.key as TsonTypeHandlerKey, - walk(handler.serialize(v)), + walk(handler.serialize?.(v)), nonce, ] as TsonTuple; } + + // if (!handler) { + // return mapOrReturn(value, walk); + // } + + // if (!isComplexValue(value)) { + // return toTuple(value, handler); + // } + + // // if this is a value-by-reference we've seen before, either: + // // - We've serialized & cached it before and can return the cached value + // // - We're attempting to serialize it, but one of its children is itself (circular reference) + // if (cache.has(value)) { + // return cache.get(value); + // } + + // if (seen.has(value)) { + // throw new TsonCircularReferenceError(value); + // } + + // seen.add(value); + + // const tuple = toTuple(value, handler); + + // cache.set(value, tuple); + + // return tuple; + // }; + + // return walk; }; return ((obj): TsonSerialized => { diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index ca95c074..b9503f0c 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -81,8 +81,7 @@ export type TsonType< TSerializedType extends SerializedType, > = | (TsonTypeTesterCustom & TsonMarshaller) - | (TsonTypeTesterPrimitive & - Partial>); + | (TsonTypeTesterPrimitive & TsonMarshaller); export interface TsonOptions { /* eslint-disable jsdoc/informative-docs */ From d7c8dbc1595db1b13074e67e1c4b1ffd7a119958 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 18:46:24 -0600 Subject: [PATCH 06/19] tests: fix irrelevant type error in test --- src/index.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index 4eee8481..27b33a6b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -5,9 +5,9 @@ import { TsonOptions, TsonType, createTson } from "./index.js"; import { expectError, waitError } from "./internals/testUtils.js"; test("multiple handlers for primitive string found", () => { - const stringHandler: TsonType = { + const stringHandler = { primitive: "string", - }; + } as TsonType; const opts: TsonOptions = { types: [stringHandler, stringHandler], }; @@ -98,9 +98,9 @@ test("async: duplicate keys", async () => { }); test("async: multiple handlers for primitive string found", async () => { - const stringHandler: TsonType = { + const stringHandler = { primitive: "string", - }; + } as TsonType; const err = await waitError(async () => { const iterator = createTsonAsync({ From 16944e93b4031ed6d9c6e2b7e0f9e7ada04a0a3e Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 19:48:39 -0600 Subject: [PATCH 07/19] chore: cleanup (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## PR Checklist - [ ] Addresses an existing open issue: fixes #000 - [ ] That issue was marked as [`status: accepting prs`](https://github.com/trpc/tupleson/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) - [ ] Steps in [CONTRIBUTING.md](https://github.com/trpc/tupleson/blob/main/.github/CONTRIBUTING.md) were taken ## Overview --- src/async/asyncHelpers.ts | 31 ---------------- src/async/iterableUtils.ts | 75 -------------------------------------- src/iterableTypes.ts | 15 -------- src/sync/syncTypes.ts | 2 - src/tsonAssert.ts | 41 --------------------- 5 files changed, 164 deletions(-) delete mode 100644 src/async/asyncHelpers.ts diff --git a/src/async/asyncHelpers.ts b/src/async/asyncHelpers.ts deleted file mode 100644 index 2513b778..00000000 --- a/src/async/asyncHelpers.ts +++ /dev/null @@ -1,31 +0,0 @@ -export async function* mapIterable( - iterable: AsyncIterable, - fn: (v: T) => TValue, -): AsyncIterable { - for await (const value of iterable) { - yield fn(value); - } -} - -export async function reduceIterable< - T, - TInitialValue extends Promise = Promise, - TKey extends PropertyKey | bigint = bigint, - TKeyFn extends (prev?: TKey) => TKey = (prev?: TKey) => TKey, ->( - iterable: Iterable, - fn: (acc: Awaited, v: T, i: TKey) => Awaited, - initialValue: TInitialValue = Promise.resolve() as TInitialValue, - incrementKey: TKeyFn = ((prev?: bigint) => - prev === undefined ? 0n : prev + 1n) as TKeyFn, -): Promise> { - let acc = initialValue; - let i = incrementKey(); - - for await (const value of iterable) { - acc = fn(await acc, value, i); - i = incrementKey(i); - } - - return Promise.resolve(acc); -} diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index 80954102..4d099d17 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -156,93 +156,18 @@ export interface AsyncIterableEsque { [Symbol.asyncIterator](): AsyncIterator; } -export function isAsyncIterableEsque( - maybeAsyncIterable: unknown, -): maybeAsyncIterable is AsyncIterableEsque { - return ( - !!maybeAsyncIterable && - (typeof maybeAsyncIterable === "object" || - typeof maybeAsyncIterable === "function") && - Symbol.asyncIterator in maybeAsyncIterable - ); -} - export interface IterableEsque { [Symbol.iterator](): Iterator; } -export function isIterableEsque( - maybeIterable: unknown, -): maybeIterable is IterableEsque { - return ( - !!maybeIterable && - (typeof maybeIterable === "object" || - typeof maybeIterable === "function") && - Symbol.iterator in maybeIterable - ); -} type SyncOrAsyncGeneratorFnEsque = AsyncGeneratorFnEsque | GeneratorFnEsque; -export function isMaybeAsyncGeneratorFn( - maybeAsyncGeneratorFn: unknown, -): maybeAsyncGeneratorFn is SyncOrAsyncGeneratorFnEsque { - return ( - typeof maybeAsyncGeneratorFn === "function" && - ["AsyncGeneratorFunction", "GeneratorFunction"].includes( - maybeAsyncGeneratorFn.constructor.name, - ) - ); -} - export type GeneratorFnEsque = () => Generator; -export function isGeneratorFnEsque( - maybeGeneratorFn: unknown, -): maybeGeneratorFn is GeneratorFnEsque { - return ( - typeof maybeGeneratorFn === "function" && - maybeGeneratorFn.constructor.name === "GeneratorFunction" - ); -} - export type AsyncGeneratorFnEsque = () => AsyncGenerator; -export function isAsyncGeneratorFnEsque( - maybeAsyncGeneratorFn: unknown, -): maybeAsyncGeneratorFn is AsyncGeneratorFnEsque { - return ( - typeof maybeAsyncGeneratorFn === "function" && - maybeAsyncGeneratorFn.constructor.name === "AsyncGeneratorFunction" - ); -} - export type PromiseEsque = PromiseLike; -export function isPromiseEsque( - maybePromise: unknown, -): maybePromise is PromiseEsque { - return ( - !!maybePromise && - typeof maybePromise === "object" && - "then" in maybePromise && - typeof maybePromise.then === "function" - ); -} - export type ThunkEsque = () => unknown; -export function isThunkEsque(maybeThunk: unknown): maybeThunk is ThunkEsque { - return ( - !!maybeThunk && typeof maybeThunk === "function" && maybeThunk.length === 0 - ); -} - -export type Thunkable = - | AsyncIterableEsque - | IterableEsque - | PromiseEsque - | SyncOrAsyncGeneratorFnEsque - | ThunkEsque; - -export type MaybePromise = Promise | T; diff --git a/src/iterableTypes.ts b/src/iterableTypes.ts index ebf6b6b3..0c33b885 100644 --- a/src/iterableTypes.ts +++ b/src/iterableTypes.ts @@ -13,21 +13,6 @@ /* eslint-disable @typescript-eslint/no-empty-interface */ -/** - * A stronger type for Iterator - */ -export interface Iterator - extends globalThis.Iterator {} - -/** - * A stronger type for AsyncIterator - */ -export interface AsyncIterator< - T = unknown, - TReturn = unknown, - TNextArg = unknown, -> extends globalThis.AsyncIterator {} - /** * A stronger type for Generator */ diff --git a/src/sync/syncTypes.ts b/src/sync/syncTypes.ts index b9503f0c..447e2751 100644 --- a/src/sync/syncTypes.ts +++ b/src/sync/syncTypes.ts @@ -68,8 +68,6 @@ export interface TsonTypeTesterCustom { test: (v: unknown) => boolean; } -export type TsonTypeTester = TsonTypeTesterCustom | TsonTypeTesterPrimitive; - export type TsonType< /** * The type of the value diff --git a/src/tsonAssert.ts b/src/tsonAssert.ts index efff045a..c9ebc78b 100644 --- a/src/tsonAssert.ts +++ b/src/tsonAssert.ts @@ -1,44 +1,3 @@ -export const asserts = Symbol("asserted"); -export type Not = T extends true ? false : true; - -const secret = Symbol("secret"); -type Secret = typeof secret; - -export type IsNever = [T] extends [never] ? true : false; -export type IsAny = [T] extends [Secret] ? Not> : false; -export type IsUnknown = [unknown] extends [T] ? Not> : false; -/** - * The more I think about these semantics, the less they make sense. - * What is this API, really? What is the goal? - * Is it to provide a way to assert that a value is of a certain type? I think - * this only makes sense in a few limited cases. - * - * At what point in the pipeline would this occur? Would this happen for all - * values? If so, well... you've asserted that your parser only handles - * the type you're asserting. I don't know why you'd want to predetermine - * that at the time of configuration. Alternatively, if this isn't called - * for each value, then one of the following must be true: - * - * - you have also specified, somehow, *when* to apply this assertion - * - it is part of an array of operations that are attempted in order, - * and the first one that succeeds is used - * - * The first point could easily be accomplished by a conditional inside the - * assertion function (e.g. for the number guard, If not of type 'number' - * then it's definitely not NaN)... so no need for anything special like an - * array of type handler keys associated with the assertion. - * - * The second point is an option, since that's how custom tson types work. - * But is there really any utility to that? We're not zod validating... - * we're marshalling. We assume the type layer is accurate, without - * enforcing it. If we want to integrate runtime type validation, that - * seems like a feature request, potentially warranting it's own API. - * - * Ultimately, I feel like this functionality is easily served by a simple - * assertion function that throws for invalid values. For most (all?) except - * for the unknown object guard they would come first in the array, while - * the unknown object guard would come last. - */ interface TsonGuardBase { key: string; } From 8737f513a75825145d1927df3cc28249259cb307 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 20:33:56 -0600 Subject: [PATCH 08/19] chore: cleanup --- src/async/iterableUtils.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index 4d099d17..c37b1569 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -1,4 +1,4 @@ -import { assert } from "../internals/assert.js"; +eimport { assert } from "../internals/assert.js"; import { NodeJSReadableStreamEsque, WebReadableStreamEsque, @@ -160,9 +160,6 @@ export interface IterableEsque { [Symbol.iterator](): Iterator; } - -type SyncOrAsyncGeneratorFnEsque = AsyncGeneratorFnEsque | GeneratorFnEsque; - export type GeneratorFnEsque = () => Generator; export type AsyncGeneratorFnEsque = () => AsyncGenerator; From ab9df8e72a20411bec164735efe64e6f9eced560 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 20:35:10 -0600 Subject: [PATCH 09/19] chore: fix typo --- src/async/iterableUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index c37b1569..43656274 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -1,4 +1,4 @@ -eimport { assert } from "../internals/assert.js"; +import { assert } from "../internals/assert.js"; import { NodeJSReadableStreamEsque, WebReadableStreamEsque, From f97876bb10e4f283e09dd0ed3280940651e5baf5 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 20:37:59 -0600 Subject: [PATCH 10/19] chore: cleanup --- src/async/iterableUtils.ts | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index 43656274..83a02379 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -1,4 +1,4 @@ -import { assert } from "../internals/assert.js"; +eimport { assert } from "../internals/assert.js"; import { NodeJSReadableStreamEsque, WebReadableStreamEsque, @@ -151,20 +151,3 @@ function addIfProvided( return `${key}: ${value as any}\n`; } - -export interface AsyncIterableEsque { - [Symbol.asyncIterator](): AsyncIterator; -} - -export interface IterableEsque { - [Symbol.iterator](): Iterator; -} - -export type GeneratorFnEsque = () => Generator; - -export type AsyncGeneratorFnEsque = () => AsyncGenerator; - -export type PromiseEsque = PromiseLike; - -export type ThunkEsque = () => unknown; - From c9b3e9afbf39b61f6763efcc1ef5e3ff0bb16c6e Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sat, 25 Nov 2023 20:42:10 -0600 Subject: [PATCH 11/19] chore: cleanup --- src/async/iterableUtils.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/async/iterableUtils.ts b/src/async/iterableUtils.ts index 83a02379..8dd2fbdf 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -1,9 +1,8 @@ -eimport { assert } from "../internals/assert.js"; +import { assert } from "../internals/assert.js"; import { NodeJSReadableStreamEsque, WebReadableStreamEsque, } from "../internals/esque.js"; -import { AsyncGenerator, Generator } from "../iterableTypes.js"; export async function* readableStreamToAsyncIterable( stream: From 7c74043d7c93da769492a657b1c7e62bd8e49333 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sun, 26 Nov 2023 14:25:36 -0600 Subject: [PATCH 12/19] chore: format --- cspell.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cspell.json b/cspell.json index 17344265..6c918372 100644 --- a/cspell.json +++ b/cspell.json @@ -1,7 +1,5 @@ { - "dictionaries": [ - "typescript" - ], + "dictionaries": ["typescript"], "ignorePaths": [ ".github", "CHANGELOG.md", From 2f1407502705c821e4e29a0e8c66b444686b068c Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sun, 26 Nov 2023 14:31:56 -0600 Subject: [PATCH 13/19] tests: catch missing coverage case, cleanup --- src/iterableTypes.test.ts | 30 ----------------------- src/sync/handlers/tsonNumberGuard.test.ts | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/src/iterableTypes.test.ts b/src/iterableTypes.test.ts index aeee83a2..da81f847 100644 --- a/src/iterableTypes.test.ts +++ b/src/iterableTypes.test.ts @@ -12,22 +12,6 @@ v.describe("Async Iterable Types", () => { })(); v.expectTypeOf(generator).toMatchTypeOf>(); - - const iterable = { - [Symbol.asyncIterator]: () => generator, - }; - - v.expectTypeOf(iterable).toMatchTypeOf>(); - - const iterableIterator = iterable[Symbol.asyncIterator](); - - v.expectTypeOf(iterableIterator).toMatchTypeOf< - AsyncIterableIterator - >(); - - const iterator = iterableIterator[Symbol.asyncIterator](); - - v.expectTypeOf(iterator).toMatchTypeOf>(); }); }); @@ -40,19 +24,5 @@ v.describe("Iterable Types", () => { })(); v.expectTypeOf(generator).toMatchTypeOf>(); - - const iterable = { - [Symbol.iterator]: () => generator, - }; - - v.expectTypeOf(iterable).toMatchTypeOf>(); - - const iterableIterator = iterable[Symbol.iterator](); - - v.expectTypeOf(iterableIterator).toMatchTypeOf>(); - - const iterator = iterableIterator[Symbol.iterator](); - - v.expectTypeOf(iterator).toMatchTypeOf>(); }); }); diff --git a/src/sync/handlers/tsonNumberGuard.test.ts b/src/sync/handlers/tsonNumberGuard.test.ts index 3778ca51..702afc2a 100644 --- a/src/sync/handlers/tsonNumberGuard.test.ts +++ b/src/sync/handlers/tsonNumberGuard.test.ts @@ -15,7 +15,7 @@ test("number", () => { Infinity, -Infinity, ]; - const good = [1, 0, -1, 1.1, -1.1]; + const good = [1, 0, -1, 1.1, -1.1, "01"]; const errors: unknown[] = []; From 0d467dee213c0729ba96d7af1c9362c49bf61725 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sun, 26 Nov 2023 14:36:39 -0600 Subject: [PATCH 14/19] fix: guard shouldn't pass if neither throws nor true --- src/sync/serialize.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index c22a090c..7cb92920 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -105,7 +105,11 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { for (const guard of guards) { // if ("assert" in guard) { - guard.assert(value); + if (!guard.assert(value)) { + throw new Error( + `Guard ${guard.key} failed on value ${String(value)}`, + ); + } // } //todo: if this is implemented does it go before or after assert? // if ("parse" in guard) { From 23bc039017341921a5b8c9a8ab2f24e57034e295 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sun, 26 Nov 2023 15:18:03 -0600 Subject: [PATCH 15/19] tests: add test for assert fix: correct assertion behavior docs: update readme for guards --- README.md | 14 +++++++++++ src/sync/serialize.ts | 3 ++- src/tsonAssert.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/tsonAssert.test.ts diff --git a/README.md b/README.md index e34276e0..fc4ee545 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,27 @@ const tson = createTson({ // Pick which types you want to support tsonSet, ], + // 🫷 Guard against unwanted values + guards: [tsonNumberGuard, tsonUnknownObjectGuard], }); +const scarryClass = new (class ScarryClass { + foo = "bar"; +})(); + +const invalidNumber = 1 / 0; + const myObj = { foo: "bar", set: new Set([1, 2, 3]), }; +tson.stringify(scarryClass); +// -> throws, since we didn't register a serializer for `ScarryClass`! + +tson.stringify(invalidNumber); +// -> throws, since we didn't register a serializer for `Infinity`! + const str = tson.stringify(myObj, 2); console.log(str); // (👀 All non-JSON values are replaced with a tuple, hence the name) diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index 7cb92920..992b8f35 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -105,7 +105,8 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { for (const guard of guards) { // if ("assert" in guard) { - if (!guard.assert(value)) { + const result = guard.assert(value); + if (typeof result === "boolean" && !result) { throw new Error( `Guard ${guard.key} failed on value ${String(value)}`, ); diff --git a/src/tsonAssert.test.ts b/src/tsonAssert.test.ts new file mode 100644 index 00000000..c30b9826 --- /dev/null +++ b/src/tsonAssert.test.ts @@ -0,0 +1,54 @@ +import * as v from "vitest"; + +import type { TsonGuard } from "./tsonAssert.js"; + +import { createTson } from "./index.js"; + +v.describe("TsonGuard", () => { + v.it("should work if the guard is a type guard", () => { + const guard = { + assert: (v: unknown): v is string => typeof v === "string", + key: "string", + }; + + v.expectTypeOf(guard).toMatchTypeOf>(); + + // create a tson instance with the guard + // serialize and deserialize a string + const tson = createTson({ guards: [guard], types: [] }); + const serialized = tson.stringify("hello"); + const deserialized = tson.parse(serialized); + v.expect(deserialized).toEqual("hello"); + + // serialize and deserialize a number should throw + + v.expect(() => + tson.parse(tson.stringify(1)), + ).toThrowErrorMatchingInlineSnapshot(`"Guard string failed on value 1"`); + }); + + v.it("should work if the guard is an assertion", () => { + const guard = { + assert: (v: unknown): asserts v is string => { + if (typeof v !== "string") { + throw new Error("Not a string"); + } + }, + key: "string", + }; + + v.expectTypeOf(guard).toMatchTypeOf>(); + + // create a tson instance with the guard + // serialize and deserialize a string + const tson = createTson({ guards: [guard], types: [] }); + const serialized = tson.stringify("hello"); + const deserialized = tson.parse(serialized); + v.expect(deserialized).toEqual("hello"); + + // serialize and deserialize a number should throw + v.expect(() => + tson.parse(tson.stringify(1)), + ).toThrowErrorMatchingInlineSnapshot(`"Not a string"`); + }); +}); From 14f2fa6f2a10d00bbf8106eda0afcf56fcfbc50b Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Sun, 10 Dec 2023 17:47:06 -0600 Subject: [PATCH 16/19] pff --- src/async/asyncSerialize.ts | 308 ++++++++++++++++++ src/async/asyncTypesNew.ts | 56 ++++ src/async/createFoldAsyncFn.ts | 127 ++++++++ src/async/createUnfoldAsyncFn.ts | 131 ++++++++ src/async/handlers/tsonPromise2.test.ts | 110 +++++++ src/async/handlers/tsonPromise2.ts | 102 ++++++ src/async/iterableUtils.ts | 30 ++ src/index.test.ts | 14 - .../createAcyclicCacheRegistrar.test.ts | 18 + src/internals/createAcyclicCacheRegistrar.ts | 33 ++ src/internals/isComplexValue.ts | 6 +- src/sync/serialize.ts | 99 ++---- 12 files changed, 943 insertions(+), 91 deletions(-) create mode 100644 src/async/asyncSerialize.ts create mode 100644 src/async/asyncTypesNew.ts create mode 100644 src/async/createFoldAsyncFn.ts create mode 100644 src/async/createUnfoldAsyncFn.ts create mode 100644 src/async/handlers/tsonPromise2.test.ts create mode 100644 src/async/handlers/tsonPromise2.ts create mode 100644 src/internals/createAcyclicCacheRegistrar.test.ts create mode 100644 src/internals/createAcyclicCacheRegistrar.ts diff --git a/src/async/asyncSerialize.ts b/src/async/asyncSerialize.ts new file mode 100644 index 00000000..0c91ed2c --- /dev/null +++ b/src/async/asyncSerialize.ts @@ -0,0 +1,308 @@ +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/asyncTypesNew.ts b/src/async/asyncTypesNew.ts new file mode 100644 index 00000000..879356f3 --- /dev/null +++ b/src/async/asyncTypesNew.ts @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..43e7530e --- /dev/null +++ b/src/async/createFoldAsyncFn.ts @@ -0,0 +1,127 @@ +import { TsonAbortError } from "./asyncErrors.js"; +import { TsonAsyncChildLabel } from "./asyncTypesNew.js"; +import { TsonReducerResult } from "./createFoldFn.js"; +import { + TsonAsyncHeadTuple, + TsonAsyncLeafTuple, + TsonAsyncTailTuple, + TsonAsyncUnfoldedValue, +} from "./createUnfoldAsyncFn.js"; +import { MaybePromise } from "./iterableUtils.js"; + +export type TsonAsyncReducer = ( + ctx: TsonReducerCtx, +) => Promise>; + +export type TsonAsyncReducerResult = Omit< + TsonReducerResult, + "accumulator" +> & { + accumulator: MaybePromise; +}; + +export type TsonAsyncFoldFn = ({ + initialAccumulator, + reduce, +}: { + initialAccumulator: TInitial; + reduce: TsonAsyncReducer; +}) => (sequence: TsonAsyncUnfoldedValue) => Promise; + +export type TsonReducerCtx = + | TsonAsyncReducerReturnCtx + | TsonAsyncReducerYieldCtx; + +// export type TsonAsyncFoldFnFactory = < +// T, +// TInitial = T, +// TReturn = undefined, +// >(opts: { +// initialAccumulator?: TInitial | undefined; +// reduce: TsonAsyncReducer; +// }) => TsonAsyncFoldFn; + +export const createTsonAsyncFoldFn = ({ + initializeAccumulator, + reduce, +}: { + initializeAccumulator: () => MaybePromise; + reduce: TsonAsyncReducer; +}) => { + let i = 0n; + + return async function fold(sequence: TsonAsyncUnfoldedValue) { + let result: { + abort?: boolean; + accumulator: MaybePromise; + error?: any; + return?: TsonAsyncTailTuple | undefined; + } = { + accumulator: initializeAccumulator(), + }; + + let current = await sequence.next(); + + if (current.done) { + const output = await reduce({ + accumulator: await result.accumulator, + current, + key: i++, + source: sequence, + }); + + return output.accumulator; + } + + while (!current.done) { + result = await reduce({ + accumulator: await result.accumulator, + current, + key: i++, + source: sequence, + }); + + if (result.abort) { + if (result.return) { + current = await sequence.return(result.return); + } + + current = await sequence.throw(result.error); + + if (!current.done) { + throw new TsonAbortError( + "Unexpected result from `throw` in reducer: expected done", + ); + } + } else { + current = await sequence.next(); + } + } + + const output = await reduce({ + accumulator: await result.accumulator, + current, + key: i++, + source: sequence, + }); + + return output.accumulator; + }; +}; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +interface TsonAsyncReducerYieldCtx { + accumulator: TAccumulator; + current: MaybePromise< + IteratorYieldResult + >; + key: TsonAsyncChildLabel; + source: TsonAsyncUnfoldedValue; +} + +interface TsonAsyncReducerReturnCtx { + accumulator: TAccumulator; + current: MaybePromise>; + key?: TsonAsyncChildLabel | undefined; + source?: TsonAsyncUnfoldedValue | undefined; +} diff --git a/src/async/createUnfoldAsyncFn.ts b/src/async/createUnfoldAsyncFn.ts new file mode 100644 index 00000000..c3cdfd45 --- /dev/null +++ b/src/async/createUnfoldAsyncFn.ts @@ -0,0 +1,131 @@ +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, +]; + +export type TsonAsyncUnfoldedValue = AsyncGenerator< + TsonAsyncHeadTuple | TsonAsyncLeafTuple, + TsonAsyncTailTuple, + // could insert something into the generator, but that's more complexity for plugin authors + never +>; + +// export interface TsonAsyncUnfoldFn +// extends Omit { +// (source: TSource, path: TsonAsyncPath): 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>; + +export function createTsonAsyncUnfoldFn< + TFactory extends TsonAsyncUnfolderFactory, +>( + factory: TFactory, +): ( + source: TFactory extends TsonAsyncUnfolderFactory + ? TSource + : never, +) => AsyncGenerator< + { chunk: unknown; key: number | string }, + number | undefined, + // could insert something into the generator, but that's more complexity for plugin authors + never +> { + return async function* unfold(source) { + const unfolder = factory(source); + const iterator = + Symbol.asyncIterator in unfolder + ? unfolder[Symbol.asyncIterator]() + : unfolder; + + let nextResult = await iterator.next(); + + while (!nextResult.done) { + yield nextResult.value; + nextResult = await iterator.next(); + } + + 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 new file mode 100644 index 00000000..f7d2f6c0 --- /dev/null +++ b/src/async/handlers/tsonPromise2.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "vitest"; + +import { createPromise } from "../../internals/testUtils.js"; +import { createTsonSerializeAsync } from "../asyncSerialize.js"; +import { tsonPromise } from "./tsonPromise2.js"; + +test("serialize promise", async () => { + const serialize = createTsonSerializeAsync({ + nonce: () => "__tson", + 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(); +}); + +// 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(); +// }); diff --git a/src/async/handlers/tsonPromise2.ts b/src/async/handlers/tsonPromise2.ts new file mode 100644 index 00000000..0b3b2be2 --- /dev/null +++ b/src/async/handlers/tsonPromise2.ts @@ -0,0 +1,102 @@ +import { + TsonPromiseRejectionError, + TsonStreamInterruptedError, +} from "../asyncErrors.js"; +import { TsonAsyncType } from "../asyncTypesNew.js"; + +function isPromise(value: unknown): value is Promise { + return ( + !!value && + typeof value === "object" && + "then" in value && + typeof (value as any).catch === "function" + ); +} + +const PROMISE_RESOLVED = 0; +const PROMISE_REJECTED = 1; + +type SerializedPromiseValue = + | [typeof PROMISE_REJECTED, unknown] + | [typeof PROMISE_RESOLVED, unknown]; + +export const tsonPromise: TsonAsyncType< + Promise, + SerializedPromiseValue +> = { + async: true, + fold: async function (iter) { + for await (const [key, chunk] of iter) { + if (key === PROMISE_RESOLVED) { + return chunk; + } + + throw TsonPromiseRejectionError.from(chunk); + } + + throw new TsonStreamInterruptedError("Expected promise value, got done"); + }, + key: "Promise", + test: isPromise, + unfold: async function* (source) { + let code; + + try { + const value = await source; + yield { chunk: [PROMISE_RESOLVED, value], key: "" }; + code = 200; + } catch (err) { + yield { chunk: [PROMISE_REJECTED, err], key: "" }; + code = 200; + } finally { + code ??= 500; + } + + 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 8dd2fbdf..f205ad6a 100644 --- a/src/async/iterableUtils.ts +++ b/src/async/iterableUtils.ts @@ -150,3 +150,33 @@ function addIfProvided( return `${key}: ${value as any}\n`; } + +export interface AsyncIterableEsque { + [Symbol.asyncIterator](): AsyncIterator; +} + +export function isAsyncIterableEsque( + maybeAsyncIterable: unknown, +): maybeAsyncIterable is AsyncIterableEsque { + return ( + !!maybeAsyncIterable && + (typeof maybeAsyncIterable === "object" || + typeof maybeAsyncIterable === "function") && + Symbol.asyncIterator in maybeAsyncIterable + ); +} + +export interface IterableEsque { + [Symbol.iterator](): Iterator; +} + +export function isIterableEsque( + maybeIterable: unknown, +): maybeIterable is IterableEsque { + return ( + !!maybeIterable && + (typeof maybeIterable === "object" || + typeof maybeIterable === "function") && + Symbol.iterator in maybeIterable + ); +} diff --git a/src/index.test.ts b/src/index.test.ts index 27b33a6b..27c55ef0 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -34,20 +34,6 @@ test("duplicate keys", () => { ); }); -test("no max call stack", () => { - const t = createTson({ - types: [], - }); - - const expected: Record = {}; - expected["a"] = expected; - - // stringify should fail b/c of JSON limitations - const err = expectError(() => t.stringify(expected)); - - expect(err.message).toMatchInlineSnapshot('"Circular reference detected"'); -}); - test("allow duplicate objects", () => { const t = createTson({ types: [], diff --git a/src/internals/createAcyclicCacheRegistrar.test.ts b/src/internals/createAcyclicCacheRegistrar.test.ts new file mode 100644 index 00000000..65d732a3 --- /dev/null +++ b/src/internals/createAcyclicCacheRegistrar.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from "vitest"; + +import { createTson } from "../index.js"; +import { expectError } from "./testUtils.js"; + +test("no max call stack", () => { + const t = createTson({ + types: [], + }); + + const expected: Record = {}; + expected["a"] = expected; + + // stringify should fail b/c of JSON limitations + const err = expectError(() => t.stringify(expected)); + + expect(err.message).toMatchInlineSnapshot('"Circular reference detected"'); +}); diff --git a/src/internals/createAcyclicCacheRegistrar.ts b/src/internals/createAcyclicCacheRegistrar.ts new file mode 100644 index 00000000..07383da0 --- /dev/null +++ b/src/internals/createAcyclicCacheRegistrar.ts @@ -0,0 +1,33 @@ +import { TsonCircularReferenceError } from "../index.js"; +import { isComplexValue } from "./isComplexValue.js"; + +export function createAcyclicCacheRegistrar() { + const seen = new WeakSet(); + const cache = new WeakMap(); + + return function register(value: T) { + const $unset = Symbol("Not undefined or null, but unset"); + let cached: T | typeof $unset = $unset; + + if (isComplexValue(value)) { + if (seen.has(value)) { + cached = cache.get(value) as T; + if (!cached) { + throw new TsonCircularReferenceError(value); + } + } else { + seen.add(value); + } + } + + return function cacheAndReturn(result: T) { + if (isComplexValue(value) && cached === $unset) { + cache.set(value, result); + + return result; + } + + return result; + }; + }; +} diff --git a/src/internals/isComplexValue.ts b/src/internals/isComplexValue.ts index 65be0251..93b7af19 100644 --- a/src/internals/isComplexValue.ts +++ b/src/internals/isComplexValue.ts @@ -1,3 +1,7 @@ export function isComplexValue(arg: unknown): arg is object { - return (arg !== null && typeof arg === "object") || typeof arg === "function"; + if (typeof arg === "function") { + throw new TypeError("Serializing functions is not currently supported."); + } + + return arg !== null && typeof arg === "object"; } diff --git a/src/sync/serialize.ts b/src/sync/serialize.ts index 992b8f35..0ac0c533 100644 --- a/src/sync/serialize.ts +++ b/src/sync/serialize.ts @@ -1,6 +1,5 @@ -import { TsonCircularReferenceError } from "../errors.js"; +import { createAcyclicCacheRegistrar } from "../internals/createAcyclicCacheRegistrar.js"; import { GetNonce, getDefaultNonce } from "../internals/getNonce.js"; -import { isComplexValue } from "../internals/isComplexValue.js"; import { mapOrReturn } from "../internals/mapOrReturn.js"; import { TsonAllTypes, @@ -11,6 +10,7 @@ import { TsonSerialized, TsonStringifyFn, TsonTuple, + TsonType, TsonTypeHandlerKey, TsonTypeTesterCustom, TsonTypeTesterPrimitive, @@ -61,110 +61,57 @@ export function createTsonSerialize(opts: TsonOptions): TsonSerializeFn { const [getNonce, nonPrimitives, primitives, guards] = getHandlers(opts); const walker: WalkerFactory = (nonce) => { - const seen = new WeakSet(); - const cache = new WeakMap(); + // create a persistent cache shared across recursions + const register = createAcyclicCacheRegistrar(); - const walk: WalkFn = (value: unknown) => { - const type = typeof value; - const isComplex = isComplexValue(value); + const walk: WalkFn = (value) => { + const cacheAndReturn = register(value); + const primitiveHandler = primitives.get(typeof value); - if (isComplex) { - if (seen.has(value)) { - const cached = cache.get(value); - if (!cached) { - throw new TsonCircularReferenceError(value); - } + let handler: TsonType | undefined; - return cached; - } - - seen.add(value); + // primitive handlers take precedence + if (!primitiveHandler?.test || primitiveHandler.test(value)) { + handler = primitiveHandler; } - const cacheAndReturn = (result: unknown) => { - if (isComplex) { - cache.set(value, result); - } + // first passing handler wins + handler ??= [...nonPrimitives].find((handler) => handler.test(value)); - return result; - }; - - const primitiveHandler = primitives.get(type); - if ( - primitiveHandler && - (!primitiveHandler.test || primitiveHandler.test(value)) - ) { - return cacheAndReturn(toTuple(value, primitiveHandler)); - } - - for (const handler of nonPrimitives) { - if (handler.test(value)) { - return cacheAndReturn(toTuple(value, handler)); - } + /* If we have a handler, cache and return a TSON tuple for + the result of recursively walking the serialized value */ + if (handler) { + return cacheAndReturn(recurseWithHandler(handler, value)); } + // apply guards to unhanded values for (const guard of guards) { - // if ("assert" in guard) { const result = guard.assert(value); if (typeof result === "boolean" && !result) { throw new Error( `Guard ${guard.key} failed on value ${String(value)}`, ); } - // } - //todo: if this is implemented does it go before or after assert? - // if ("parse" in guard) { - // value = guard.parse(value); - // } } + // recursively walk children return cacheAndReturn(mapOrReturn(value, walk)); }; return walk; - function toTuple( - v: unknown, + function recurseWithHandler( handler: | (TsonTypeTesterCustom & TsonMarshaller) - | (TsonTypeTesterPrimitive & Partial>), + | (TsonTypeTesterPrimitive & TsonMarshaller), + v: unknown, ) { return [ handler.key as TsonTypeHandlerKey, - walk(handler.serialize?.(v)), + walk(handler.serialize(v)), nonce, ] as TsonTuple; } - - // if (!handler) { - // return mapOrReturn(value, walk); - // } - - // if (!isComplexValue(value)) { - // return toTuple(value, handler); - // } - - // // if this is a value-by-reference we've seen before, either: - // // - We've serialized & cached it before and can return the cached value - // // - We're attempting to serialize it, but one of its children is itself (circular reference) - // if (cache.has(value)) { - // return cache.get(value); - // } - - // if (seen.has(value)) { - // throw new TsonCircularReferenceError(value); - // } - - // seen.add(value); - - // const tuple = toTuple(value, handler); - - // cache.set(value, tuple); - - // return tuple; - // }; - - // return walk; }; return ((obj): TsonSerialized => { From 88db45d0edbd188dd9f88ac83bf025987dc8f271 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Wed, 3 Jan 2024 17:23:42 -0600 Subject: [PATCH 17/19] 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 6c918372..6c684234 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 0c91ed2c..00000000 --- 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 00000000..421485ec --- /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 879356f3..00000000 --- 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 43e7530e..b7c8dd16 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 c3cdfd45..33703534 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 f7d2f6c0..731b2996 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 0b3b2be2..0ee840fd 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 f205ad6a..ea3e0165 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 00000000..d3f13b5f --- /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 00000000..f6e3225d --- /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 0ac0c533..bb0f2171 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)); From f367de14d03ff8f237c2940d98c45900f7b0b310 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Thu, 4 Jan 2024 00:14:44 -0600 Subject: [PATCH 18/19] fix: correct pointer values fix: don't stringify primitives prematurely tests: improved tests for new async stuff --- src/async/asyncTypes2.ts | 4 +- src/async/handlers/tsonPromise2.test.ts | 222 ++++++++++++++++----- src/async/serializeAsync2.test.ts | 253 +++++++++++------------- src/async/serializeAsync2.ts | 10 +- src/internals/testUtils.ts | 88 +++++++++ 5 files changed, 385 insertions(+), 192 deletions(-) diff --git a/src/async/asyncTypes2.ts b/src/async/asyncTypes2.ts index 421485ec..032e30c5 100644 --- a/src/async/asyncTypes2.ts +++ b/src/async/asyncTypes2.ts @@ -68,7 +68,7 @@ export const ChunkTypes = { ERROR: "ERROR", HEAD: "HEAD", LEAF: "LEAF", - REFERENCE: "REFERENCE", + REF: "REF", TAIL: "TAIL", } as const; @@ -114,7 +114,7 @@ export type TsonAsyncHeadTuple = [ ]; export type TsonAsyncReferenceTuple = [ - ChunkType: ChunkTypes["REFERENCE"], + ChunkType: ChunkTypes["REF"], Header: TsonAsyncTupleHeader, OriginalNodeId: `${TsonNonce}${number}`, ]; diff --git a/src/async/handlers/tsonPromise2.test.ts b/src/async/handlers/tsonPromise2.test.ts index 731b2996..bf59f09b 100644 --- a/src/async/handlers/tsonPromise2.test.ts +++ b/src/async/handlers/tsonPromise2.test.ts @@ -1,11 +1,21 @@ import { expect, test } from "vitest"; import { TsonType } from "../../index.js"; -import { createPromise } from "../../internals/testUtils.js"; -import { ChunkTypes, TsonStatus } from "../asyncTypes2.js"; +import { createPromise, expectSequence } from "../../internals/testUtils.js"; +import { ChunkTypes, TsonAsyncTuple, TsonStatus } from "../asyncTypes2.js"; import { createTsonSerializeAsync } from "../serializeAsync2.js"; import { tsonPromise } from "./tsonPromise2.js"; +const nonce = "__tson"; +const anyId = expect.stringMatching(`^${nonce}[0-9]+$`); +const idOf = (id: TsonAsyncTuple | number | string) => { + if (Array.isArray(id)) { + return id[1][0]; + } + + return `${nonce}${id}`; +}; + const tsonError: TsonType = { deserialize: (v) => { const err = new Error(v.message); @@ -19,8 +29,6 @@ const tsonError: TsonType = { }; test("serialize promise", async () => { - const nonce = "__tson"; - const serialize = createTsonSerializeAsync({ nonce: () => nonce, types: [tsonPromise], @@ -29,73 +37,102 @@ test("serialize promise", async () => { const promise = Promise.resolve(42); const iterator = serialize(promise); - const values = []; + const chunks: TsonAsyncTuple[] = []; for await (const value of iterator) { - values.push(value); + chunks.push(value); } - const promiseId = `${nonce}0`; - const arrayId = `${nonce}1`; + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); - 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], - ]); + expect(chunks.length).toBe(6); + expect(heads).toHaveLength(2); + expect(tails).toHaveLength(2); + expect(leaves).toHaveLength(2); + + expectSequence(chunks) + .toHaveAll(heads) + .beforeAll([...leaves, ...tails]); + + heads.forEach((_, i) => { + expectSequence(chunks).toHave(heads[i]!).beforeAll([tails[i]!, leaves[i]!]); + expectSequence(chunks).toHave(tails[i]!).afterAll([heads[i]!, leaves[i]!]); + }); }); test("serialize promise that returns a promise", async () => { - const nonce = "__tson"; const serialize = createTsonSerializeAsync({ nonce: () => nonce, types: [tsonPromise], }); + const expected = 42; + const obj = { promise: createPromise(() => { return { anotherPromise: createPromise(() => { - return 42; + return expected; }), }; }), }; const iterator = serialize(obj); - const values = []; + const chunks: TsonAsyncTuple[] = []; for await (const value of iterator) { - values.push(value); + chunks.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], + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + expect(chunks).toHaveLength(15); + expect(heads).toHaveLength(6); + expect(leaves).toHaveLength(3); + expect(tails).toHaveLength(6); + + heads.forEach((_, i) => { + expect(tails.filter((v) => v[1][1] === heads[i]![1][0])).toHaveLength(1); + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll( + [...tails, ...leaves].filter((v) => v[1][1] === heads[i]![1][0]), + ); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll( + [...heads, ...leaves].filter((v) => v[1][1] === tails[i]![1][0]), + ); + }); + + expect(heads[0]![1][0]).toBe(idOf(0)); + + expect(heads).toHaveLength(6); + expect(leaves).toHaveLength(3); + expect(tails).toHaveLength(6); + + expect(heads[0]).toStrictEqual([ChunkTypes.HEAD, [idOf(0), nonce, null]]); + expect(heads[1]).toStrictEqual([ + ChunkTypes.HEAD, + [anyId, idOf(0), "promise"], + tsonPromise.key, + ]); + + expect(heads[2]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null]]); + expect(heads[3]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, 1]]); + expect(heads[4]).toStrictEqual([ + ChunkTypes.HEAD, + [anyId, anyId, "anotherPromise"], + tsonPromise.key, ]); + expect(heads[5]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null]]); }); test("promise that rejects", async () => { - const nonce = "__tson"; const serialize = createTsonSerializeAsync({ nonce: () => nonce, types: [tsonPromise, tsonError], @@ -104,19 +141,104 @@ test("promise that rejects", async () => { const promise = Promise.reject(new Error("foo")); const iterator = serialize(promise); - const values = []; + const chunks: TsonAsyncTuple[] = []; const expected = { message: "foo" }; for await (const value of iterator) { - values.push(value); + chunks.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], + expect(chunks.length).toBe(6); + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + expectSequence(chunks) + .toHaveAll(heads) + .beforeAll([...leaves, ...tails]); + + heads.forEach((_, i) => { + expect(tails.filter((v) => v[1][1] === heads[i]![1][0])).toHaveLength(1); + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll( + [...tails, ...leaves].filter((v) => v[1][1] === heads[i]![1][0]), + ); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll( + [...heads, ...leaves].filter((v) => v[1][1] === tails[i]![1][0]), + ); + }); + + expect(heads[0]![1][0]).toBe(idOf(0)); + + expect(heads).toHaveLength(2); + expect(tails).toHaveLength(2); + expect(leaves).toHaveLength(2); + + expect(heads[0]).toStrictEqual([ + ChunkTypes.HEAD, + [idOf(0), nonce, null], + tsonPromise.key, ]); + + expect(heads[1]).toEqual([ChunkTypes.HEAD, [anyId, idOf(0), null]]); + expect(leaves[0]).toEqual([ChunkTypes.LEAF, [anyId, anyId, 0], 1]); + expect(leaves[1]).toEqual([ + ChunkTypes.LEAF, + [anyId, anyId, 1], + expected, + tsonError.key, + ]); +}); + +test("racing promises", async () => { + const serialize = createTsonSerializeAsync({ + nonce: () => nonce, + types: [tsonPromise], + }); + + const iterator = serialize({ + promise: createPromise(() => { + return { + promise1: createPromise(() => { + return 42; + }, Math.random() * 100), + promise2: createPromise(() => { + return 43; + }, Math.random() * 100), + }; + }), + }); + + const chunks: TsonAsyncTuple[] = []; + + for await (const value of iterator) { + chunks.push(value); + } + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + + expect(chunks).toHaveLength(21); + expect(heads).toHaveLength(8); + expect(leaves).toHaveLength(5); + expect(tails).toHaveLength(8); + + heads.forEach((_, i) => { + expect(tails.filter((v) => v[1][1] === heads[i]![1][0])).toHaveLength(1); + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll( + [...tails, ...leaves].filter((v) => v[1][1] === heads[i]![1][0]), + ); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll( + [...heads, ...leaves].filter((v) => v[1][1] === tails[i]![1][0]), + ); + }); }); diff --git a/src/async/serializeAsync2.test.ts b/src/async/serializeAsync2.test.ts index d3f13b5f..f57d3de1 100644 --- a/src/async/serializeAsync2.test.ts +++ b/src/async/serializeAsync2.test.ts @@ -1,15 +1,20 @@ -import { assertType, describe, test } from "vitest"; +import { assertType, describe, expect, test } from "vitest"; import { tsonBigint } from "../index.js"; +import { expectSequence } from "../internals/testUtils.js"; import { ChunkTypes, TsonAsyncTuple, TsonStatus } from "./asyncTypes2.js"; import { tsonPromise } from "./handlers/tsonPromise2.js"; import { createTsonSerializeAsync } from "./serializeAsync2.js"; +const nonce = "__tson"; +const anyId = expect.stringMatching(`^${nonce}[0-9]+$`); +const idOf = (id: number | string) => `${nonce}${id}`; + describe("serialize", (it) => { it("should handle primitives correctly", async ({ expect }) => { const options = { guards: [], - nonce: () => "__tsonNonce", + nonce: () => nonce, types: [ // Primitive handler mock { @@ -30,48 +35,136 @@ describe("serialize", (it) => { } expect(chunks.length).toBe(1); - expect(chunks[0]).toEqual([ - ChunkTypes.LEAF, - ["__tsonNonce0", "__tsonNonce", null], - "HELLO", - "string", + expect(chunks).toEqual([ + [ChunkTypes.LEAF, [idOf(0), nonce, null], "HELLO", "string"], ]); }); it("should handle circular references", async ({ expect }) => { - const options = { guards: [], nonce: () => "__tsonNonce", types: [] }; + const options = { guards: [], nonce: () => nonce, types: [] }; const serialize = createTsonSerializeAsync(options); const object: any = {}; - object.self = object; // Create a circular reference const chunks = []; + const rootId = idOf(0); + + // Create a circular reference + object.self = object; 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(chunks).toEqual([ + [ChunkTypes.HEAD, [rootId, nonce, null]], + [ChunkTypes.REF, [anyId, rootId, "self"], rootId], + [ChunkTypes.TAIL, [anyId, rootId, null], TsonStatus.OK], ]); + }); - expect - .soft(chunks[1]) - .toEqual([ - ChunkTypes.REFERENCE, - ["__tsonNonce1", "__tsonNonce0", "self"], - "__tsonNonce0", - ]); + test.each([ + ["number", 0], + ["string", "hello"], + ["boolean", true], + ["null", null], + ])( + `should serialize %s primitives without a handler`, + async (type, value) => { + const options = { guards: [], nonce: () => nonce, types: [] }; + const serialize = createTsonSerializeAsync(options); + const chunks: TsonAsyncTuple[] = []; + for await (const chunk of serialize(value)) { + chunks.push(chunk); + } - expect - .soft(chunks[2]) - .toEqual([ - ChunkTypes.TAIL, - ["__tsonNonce2", "__tsonNonce0", null], - TsonStatus.OK, + expect(chunks.length).toBe(1); + expect(chunks).toEqual([ + [ChunkTypes.LEAF, [idOf(0), nonce, null], value], ]); + }, + ); + + it("should serialize values with a sync handler", async ({ expect }) => { + const options = { + guards: [], + nonce: () => nonce, + 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).toEqual([ + [ChunkTypes.LEAF, [idOf(0), nonce, null], "0", "bigint"], + ]); + }); +}); + +describe("serializeAsync", (it) => { + it("should serialize values with an async handler", async ({ expect }) => { + const options = { + guards: [], + nonce: () => nonce, + types: [tsonPromise], + }; + + const serialize = createTsonSerializeAsync(options); + const source = Promise.resolve("hello"); + const chunks: TsonAsyncTuple[] = []; + + for await (const chunk of serialize(source)) { + chunks.push(chunk); + } + + const heads = chunks.filter((chunk) => chunk[0] === ChunkTypes.HEAD); + const tails = chunks.filter((chunk) => chunk[0] === ChunkTypes.TAIL); + const leaves = chunks.filter((chunk) => chunk[0] === ChunkTypes.LEAF); + + const head_1_id = heads[0]![1][0]; + const head_2_id = heads[1]![1][0]; + + expect(chunks.length).toBe(6); + + expectSequence(chunks) + .toHaveAll(heads) + .beforeAll([...leaves, ...tails]); + + heads.forEach((_, i) => { + expectSequence(chunks) + .toHave(heads[i]!) + .beforeAll([tails[i]!, leaves[i]!]); + expectSequence(chunks) + .toHave(tails[i]!) + .afterAll([heads[i]!, leaves[i]!]); + }); + + expect(head_1_id).toBe(idOf(0)); + + expect(heads).toHaveLength(2); + expect(tails).toHaveLength(2); + expect(leaves).toHaveLength(2); + + expect(heads).toStrictEqual([ + [ChunkTypes.HEAD, [head_1_id, nonce, null], tsonPromise.key], + [ChunkTypes.HEAD, [head_2_id, head_1_id, null]], + ]); + + expect(leaves).toStrictEqual([ + [ChunkTypes.LEAF, [anyId, head_2_id, 0], 0], + [ChunkTypes.LEAF, [anyId, head_2_id, 1], "hello"], + ]); + + expect(tails).toStrictEqual([ + [ChunkTypes.TAIL, [anyId, head_1_id, null], TsonStatus.OK], + [ChunkTypes.TAIL, [anyId, head_2_id, null], TsonStatus.OK], + ]); }); it("should apply guards and throw if they fail", async ({ expect }) => { @@ -224,110 +317,4 @@ describe("serialize", (it) => { 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 index f6e3225d..b3c44982 100644 --- a/src/async/serializeAsync2.ts +++ b/src/async/serializeAsync2.ts @@ -91,7 +91,7 @@ export function createTsonSerializeAsync(opts: TsonAsyncOptions) { return undefined; } - return [ChunkTypes.REFERENCE, [id, parentId, key], originalNodeId]; + return [ChunkTypes.REF, [id, parentId, key], originalNodeId]; }; const initializeIterable = ( @@ -117,7 +117,7 @@ export function createTsonSerializeAsync(opts: TsonAsyncOptions) { ] as TsonAsyncTailTuple); } - addToQueue(result.value.key ?? null, result.value.chunk, newId); + addToQueue(result.value.key ?? null, result.value.chunk, head[1][0]); return Promise.resolve([ ChunkTypes.BODY, [newId, head[1][0], null], @@ -169,11 +169,7 @@ export function createTsonSerializeAsync(opts: TsonAsyncOptions) { queue.set( thisId, - Promise.resolve([ - ChunkTypes.LEAF, - [thisId, parentId, key], - JSON.stringify(value), - ]), + Promise.resolve([ChunkTypes.LEAF, [thisId, parentId, key], value]), ); return; diff --git a/src/internals/testUtils.ts b/src/internals/testUtils.ts index f8e07362..9a4bf726 100644 --- a/src/internals/testUtils.ts +++ b/src/internals/testUtils.ts @@ -1,6 +1,8 @@ import http from "node:http"; import { expect } from "vitest"; +import { assert } from "./assert.js"; + export const expectError = (fn: () => unknown) => { let err: unknown; try { @@ -101,3 +103,89 @@ export const createPromise = (result: () => T, wait = 1) => { }, wait); }); }; + +export const expectSequence = (sequence: T[]) => ({ + toHave(value: T) { + expect(sequence).toContain(value); + assert(value); + + return { + after(preceding: T) { + expect(preceding).toBeDefined(); + assert(preceding); + + const index = sequence.indexOf(value); + const precedingIndex = sequence.indexOf(preceding); + expect(index).toBeGreaterThanOrEqual(0); + expect(precedingIndex).toBeGreaterThanOrEqual(0); + expect( + index, + `Expected ${JSON.stringify( + value, + null, + 2, + )} to come after ${JSON.stringify(preceding, null, 2)}`, + ).toBeGreaterThan(precedingIndex); + }, + afterAll(following: T[]) { + expect(following).toBeDefined(); + assert(following); + for (const followingValue of following) { + this.after(followingValue); + } + }, + before(following: T) { + expect(following, "following").toBeDefined(); + assert(following); + + const index = sequence.indexOf(value); + const followingIndex = sequence.indexOf(following); + expect(index).toBeGreaterThanOrEqual(0); + expect(followingIndex).toBeGreaterThanOrEqual(0); + expect( + index, + `Expected ${JSON.stringify( + value, + null, + 2, + )} to come before ${JSON.stringify(following, null, 2)}`, + ).toBeLessThan(followingIndex); + }, + beforeAll(following: T[]) { + for (const followingValue of following) { + this.before(followingValue); + } + }, + }; + }, + toHaveAll(values: T[]) { + const thisHas = this.toHave.bind(this); + + for (const value of values) { + thisHas(value); + } + + return { + after(preceding: T) { + for (const value of values) { + thisHas(value).after(preceding); + } + }, + afterAll(following: T[]) { + for (const value of values) { + thisHas(value).afterAll(following); + } + }, + before(following: T) { + for (const value of values) { + thisHas(value).before(following); + } + }, + beforeAll(following: T[]) { + for (const value of values) { + thisHas(value).beforeAll(following); + } + }, + }; + }, +}); From 9aa3dc11ae3208fe764b058edb78b6bdef73a434 Mon Sep 17 00:00:00 2001 From: Alec Helmturner Date: Thu, 4 Jan 2024 00:34:34 -0600 Subject: [PATCH 19/19] fix: disambiguate serialization output of builtins --- src/async/asyncTypes2.ts | 60 ++++++++++++++----------- src/async/handlers/tsonPromise2.test.ts | 10 ++--- src/async/serializeAsync2.test.ts | 42 ++++++++--------- src/async/serializeAsync2.ts | 44 +++++++++--------- 4 files changed, 84 insertions(+), 72 deletions(-) diff --git a/src/async/asyncTypes2.ts b/src/async/asyncTypes2.ts index 032e30c5..5298e2be 100644 --- a/src/async/asyncTypes2.ts +++ b/src/async/asyncTypes2.ts @@ -10,6 +10,38 @@ import { createTsonAsyncUnfoldFn, } from "./createUnfoldAsyncFn.js"; +export const ChunkTypes = { + BODY: "BODY", + ERROR: "ERROR", + HEAD: "HEAD", + LEAF: "LEAF", + REF: "REF", + 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 const TsonStructures = { + ARRAY: 0, + ITERABLE: 2, + POJO: 1, +} as const; + +export type TsonStructures = typeof TsonStructures; + export interface TsonAsyncChunk { chunk: T; key?: null | number | string | undefined; @@ -45,7 +77,6 @@ export type TsonAsyncType< TSerializedType extends SerializedType, > = TsonTypeTesterCustom & TsonAsyncMarshaller; - export interface TsonAsyncOptions { /** * A list of guards to apply to every value @@ -63,30 +94,6 @@ export interface TsonAsyncOptions { types: (TsonAsyncType | TsonType)[]; } -export const ChunkTypes = { - BODY: "BODY", - ERROR: "ERROR", - HEAD: "HEAD", - LEAF: "LEAF", - REF: "REF", - 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}`, @@ -110,7 +117,7 @@ export type TsonAsyncBodyTuple = [ export type TsonAsyncHeadTuple = [ ChunkType: ChunkTypes["HEAD"], Header: TsonAsyncTupleHeader, - TypeHandlerKey?: string | undefined, + TypeHandlerKey?: TsonStructures[keyof TsonStructures] | string | undefined, ]; export type TsonAsyncReferenceTuple = [ @@ -142,3 +149,4 @@ export type TsonAsyncTuple = | TsonAsyncLeafTuple | TsonAsyncReferenceTuple | TsonAsyncTailTuple; + diff --git a/src/async/handlers/tsonPromise2.test.ts b/src/async/handlers/tsonPromise2.test.ts index bf59f09b..7c990f2e 100644 --- a/src/async/handlers/tsonPromise2.test.ts +++ b/src/async/handlers/tsonPromise2.test.ts @@ -115,21 +115,21 @@ test("serialize promise that returns a promise", async () => { expect(leaves).toHaveLength(3); expect(tails).toHaveLength(6); - expect(heads[0]).toStrictEqual([ChunkTypes.HEAD, [idOf(0), nonce, null]]); + expect(heads[0]).toStrictEqual([ChunkTypes.HEAD, [idOf(0), nonce, null], 1]); expect(heads[1]).toStrictEqual([ ChunkTypes.HEAD, [anyId, idOf(0), "promise"], tsonPromise.key, ]); - expect(heads[2]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null]]); - expect(heads[3]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, 1]]); + expect(heads[2]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null], 0]); + expect(heads[3]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, 1], 1]); expect(heads[4]).toStrictEqual([ ChunkTypes.HEAD, [anyId, anyId, "anotherPromise"], tsonPromise.key, ]); - expect(heads[5]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null]]); + expect(heads[5]).toStrictEqual([ChunkTypes.HEAD, [anyId, anyId, null], 0]); }); test("promise that rejects", async () => { @@ -184,7 +184,7 @@ test("promise that rejects", async () => { tsonPromise.key, ]); - expect(heads[1]).toEqual([ChunkTypes.HEAD, [anyId, idOf(0), null]]); + expect(heads[1]).toEqual([ChunkTypes.HEAD, [anyId, idOf(0), null], 0]); expect(leaves[0]).toEqual([ChunkTypes.LEAF, [anyId, anyId, 0], 1]); expect(leaves[1]).toEqual([ ChunkTypes.LEAF, diff --git a/src/async/serializeAsync2.test.ts b/src/async/serializeAsync2.test.ts index f57d3de1..7f29491f 100644 --- a/src/async/serializeAsync2.test.ts +++ b/src/async/serializeAsync2.test.ts @@ -2,7 +2,12 @@ import { assertType, describe, expect, test } from "vitest"; import { tsonBigint } from "../index.js"; import { expectSequence } from "../internals/testUtils.js"; -import { ChunkTypes, TsonAsyncTuple, TsonStatus } from "./asyncTypes2.js"; +import { + ChunkTypes, + TsonAsyncTuple, + TsonStatus, + TsonStructures, +} from "./asyncTypes2.js"; import { tsonPromise } from "./handlers/tsonPromise2.js"; import { createTsonSerializeAsync } from "./serializeAsync2.js"; @@ -10,7 +15,7 @@ const nonce = "__tson"; const anyId = expect.stringMatching(`^${nonce}[0-9]+$`); const idOf = (id: number | string) => `${nonce}${id}`; -describe("serialize", (it) => { +describe("AsyncSerialize", (it) => { it("should handle primitives correctly", async ({ expect }) => { const options = { guards: [], @@ -56,7 +61,7 @@ describe("serialize", (it) => { expect(chunks.length).toBe(3); expect(chunks).toEqual([ - [ChunkTypes.HEAD, [rootId, nonce, null]], + [ChunkTypes.HEAD, [rootId, nonce, null], TsonStructures.POJO], [ChunkTypes.REF, [anyId, rootId, "self"], rootId], [ChunkTypes.TAIL, [anyId, rootId, null], TsonStatus.OK], ]); @@ -67,22 +72,17 @@ describe("serialize", (it) => { ["string", "hello"], ["boolean", true], ["null", null], - ])( - `should serialize %s primitives without a handler`, - async (type, value) => { - const options = { guards: [], nonce: () => nonce, types: [] }; - const serialize = createTsonSerializeAsync(options); - const chunks: TsonAsyncTuple[] = []; - for await (const chunk of serialize(value)) { - chunks.push(chunk); - } + ])(`should serialize %s primitives without a handler`, async (_, value) => { + const options = { guards: [], nonce: () => nonce, types: [] }; + const serialize = createTsonSerializeAsync(options); + const chunks: TsonAsyncTuple[] = []; + for await (const chunk of serialize(value)) { + chunks.push(chunk); + } - expect(chunks.length).toBe(1); - expect(chunks).toEqual([ - [ChunkTypes.LEAF, [idOf(0), nonce, null], value], - ]); - }, - ); + expect(chunks.length).toBe(1); + expect(chunks).toEqual([[ChunkTypes.LEAF, [idOf(0), nonce, null], value]]); + }); it("should serialize values with a sync handler", async ({ expect }) => { const options = { @@ -105,9 +105,7 @@ describe("serialize", (it) => { [ChunkTypes.LEAF, [idOf(0), nonce, null], "0", "bigint"], ]); }); -}); -describe("serializeAsync", (it) => { it("should serialize values with an async handler", async ({ expect }) => { const options = { guards: [], @@ -153,7 +151,7 @@ describe("serializeAsync", (it) => { expect(heads).toStrictEqual([ [ChunkTypes.HEAD, [head_1_id, nonce, null], tsonPromise.key], - [ChunkTypes.HEAD, [head_2_id, head_1_id, null]], + [ChunkTypes.HEAD, [head_2_id, head_1_id, null], 0], ]); expect(leaves).toStrictEqual([ @@ -166,7 +164,9 @@ describe("serializeAsync", (it) => { [ChunkTypes.TAIL, [anyId, head_2_id, null], TsonStatus.OK], ]); }); +}); +describe("TsonGuards", (it) => { it("should apply guards and throw if they fail", async ({ expect }) => { const options = { guards: [{ assert: (val: unknown) => val !== "fail", key: "testGuard" }], diff --git a/src/async/serializeAsync2.ts b/src/async/serializeAsync2.ts index b3c44982..cc7b984f 100644 --- a/src/async/serializeAsync2.ts +++ b/src/async/serializeAsync2.ts @@ -18,6 +18,7 @@ import { TsonAsyncTuple, TsonAsyncType, TsonStatus, + TsonStructures, } from "./asyncTypes2.js"; import { isAsyncIterableEsque, isIterableEsque } from "./iterableUtils.js"; @@ -149,7 +150,6 @@ export function createTsonSerializeAsync(opts: TsonAsyncOptions) { // Try to find a matching handler and initiate serialization const handler = selectHandler({ handlers, value }); - // fallback to parsing as json if (!handler) { applyGuards(value); @@ -161,6 +161,7 @@ export function createTsonSerializeAsync(opts: TsonAsyncOptions) { Promise.resolve([ ChunkTypes.HEAD, [thisId, parentId, key], + typeofStruct(value), ] as TsonAsyncHeadTuple).then(initializeIterable(iterator)), ); @@ -309,25 +310,28 @@ async function* toAsyncGenerator( } } -// 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"; -// } -// } +function typeofStruct< + T extends + | AsyncIterable + | Iterable + | Record + | any[], +>(item: T): TsonStructures[keyof TsonStructures] { + switch (true) { + case Symbol.asyncIterator in item: + return TsonStructures.ITERABLE; + case Array.isArray(item): + return TsonStructures.ARRAY; + case Symbol.iterator in item: + return TsonStructures.ITERABLE; + case typeof item === "object": + case typeof item === "function": + // we intentionally treat functions as pojos + return TsonStructures.POJO; + default: + throw new Error("Unexpected type"); + } +} // /** // * - Async iterables are iterated, and each value yielded is walked.