diff --git a/bun.lockb b/bun.lockb index dbc0048..47c0a71 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 0540540..d6aff04 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/react": "18.0.0", "react": "18.0.0", "bun-types": "1.0.21", - "typescript": "^5.3.3" + "typescript": "^5.3.3", + "fast-check": "^3.15.0" } } diff --git a/src/plugin-sync.ts b/src/plugin-sync.ts index 750eb36..4cc046c 100644 --- a/src/plugin-sync.ts +++ b/src/plugin-sync.ts @@ -45,7 +45,7 @@ export type SyncOptions> = { * * Uses `JSON.stringify` by default. */ - hash?: (state: T, ...extra: ExtraArgs) => string; + hash?: (state: T, ...extra: ExtraArgs) => string | number; }; /** Configure how to pull a new state into the store. */ @@ -67,7 +67,7 @@ export type PullOptions

> = { * * Uses `JSON.stringify` by default. */ - hash?: (...args: Parameters

) => string; + hash?: (...args: Parameters

) => string | number; }; /** Extracts a record of the user-defined sync functions from a setup object. */ diff --git a/src/util-cache.ts b/src/util-cache.ts index 78a1b48..ef2f3bb 100644 --- a/src/util-cache.ts +++ b/src/util-cache.ts @@ -1,17 +1,20 @@ /** A function that returns a value. */ export type ValueFactory = () => T; +/** A key to store a value under in the cache. */ +export type CacheKey = string | number; + /** A function that returns a value from the cache. */ export type CacheGetter = { /** Gets the value from the cache, or `null` if it is not found. */ - (key: string): T | null; + (key: CacheKey): T | null; /** * Gets the value from the cache, or creates (and stores) it if it is not found. * @param key The key of the value to get. * @param create A factory function that returns a value. * @param lifetime (Optionally) How long to keep the value in the cache, in milliseconds. */ - (key: string, create: ValueFactory, lifetime?: number): T; + (key: CacheKey, create: ValueFactory, lifetime?: number): T; }; /** A cache of values that can be either transient or permanent. */ @@ -28,7 +31,7 @@ export type Cache = { * @param input A value, or a tuple of a value and a lifetime in milliseconds. * @param lifetime (Optionally) How long to keep the value in the cache, in milliseconds. */ - set: (key: string, input: T, lifetime?: number) => T; + set: (key: CacheKey, input: T, lifetime?: number) => T; /** * Removes a value from the cache, optionally after a delay. * @param key The key of the value to evict. @@ -36,7 +39,7 @@ export type Cache = { * If set to zero or less, evicts the value immediately (default) * If set to `Infinity`, makes the function no-op. */ - evict: (key: string, delay?: number) => void; + evict: (key: CacheKey, delay?: number) => void; }; /** Configure how to create a cache. */ @@ -64,10 +67,10 @@ export type CacheOptions = { * ``` */ export default function createCache(options: CacheOptions = {}): Cache { - const entries = new Map(); + const entries = new Map(); const defaultLifetime = options.lifetime ?? Infinity; - const set = (key: string, value: T, lifetime = defaultLifetime) => { + const set = (key: CacheKey, value: T, lifetime = defaultLifetime) => { if (lifetime > 0) { entries.set(key, value); evict(key, lifetime); @@ -76,7 +79,7 @@ export default function createCache(options: CacheOptions = {}): Cache { return value; }; - const get = ((key: string, create?: ValueFactory, lifetime?: number) => { + const get = ((key: CacheKey, create?: ValueFactory, lifetime?: number) => { const entry = entries.get(key); if (entry) return entry; @@ -84,7 +87,7 @@ export default function createCache(options: CacheOptions = {}): Cache { else return set(key, create(), lifetime); }) as CacheGetter; - const evict = (key: string, delay?: number) => { + const evict = (key: CacheKey, delay?: number) => { if (delay === Infinity) return; if (!delay || delay < 0) entries.delete(key); else setTimeout(() => entries.delete(key), delay); diff --git a/src/util-dedupe.ts b/src/util-dedupe.ts index 4c13b07..872079f 100644 --- a/src/util-dedupe.ts +++ b/src/util-dedupe.ts @@ -23,7 +23,7 @@ export type DedupeOptions = { * * Uses `JSON.stringify` by default. */ - hash?: (...args: Parameters) => string; + hash?: (...args: Parameters) => string | number; }; /** @@ -44,7 +44,7 @@ export default function dedupe>( fn: T, options: DedupeOptions = {} ): T { - let oldHash: string | undefined; + let oldHash: string | number | undefined; let promise: Promise | undefined; let handle: any; diff --git a/src/util-deep-equals.spec.ts b/src/util-deep-equals.spec.ts new file mode 100644 index 0000000..638d1b1 --- /dev/null +++ b/src/util-deep-equals.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, describe } from "bun:test"; +import deepEquals from "./util-deep-equals"; + +describe("value equality", () => { + test("null", () => expect(deepEquals(null, null)).toBe(true)); + test("undefined", () => expect(deepEquals(undefined, undefined)).toBe(true)); + test("string", () => expect(deepEquals("a", "a")).toBe(true)); + test("number", () => expect(deepEquals(1, 1)).toBe(true)); + test("boolean", () => expect(deepEquals(true, true)).toBe(true)); + test("empty array", () => expect(deepEquals([], [])).toBe(true)); + test("empty object", () => expect(deepEquals({}, {})).toBe(true)); +}); + +describe("value inequality", () => { + test("null vs undefined", () => + expect(deepEquals(null, undefined)).toBe(false)); + test("null vs string", () => expect(deepEquals(null, "a")).toBe(false)); + test("null vs number", () => expect(deepEquals(null, 1)).toBe(false)); + test("null vs boolean", () => expect(deepEquals(null, true)).toBe(false)); + test("null vs array", () => expect(deepEquals(null, [])).toBe(false)); + test("null vs object", () => expect(deepEquals(null, {})).toBe(false)); + test("undefined vs string", () => + expect(deepEquals(undefined, "a")).toBe(false)); + test("undefined vs number", () => + expect(deepEquals(undefined, 1)).toBe(false)); + test("undefined vs boolean", () => + expect(deepEquals(undefined, true)).toBe(false)); + test("undefined vs array", () => + expect(deepEquals(undefined, [])).toBe(false)); + test("undefined vs object", () => + expect(deepEquals(undefined, {})).toBe(false)); + test("string vs number", () => expect(deepEquals("a", 1)).toBe(false)); + test("string vs boolean", () => expect(deepEquals("a", true)).toBe(false)); + test("string vs array", () => expect(deepEquals("a", [])).toBe(false)); + test("string vs object", () => expect(deepEquals("a", {})).toBe(false)); + test("number vs boolean", () => expect(deepEquals(1, true)).toBe(false)); + test("number vs array", () => expect(deepEquals(1, [])).toBe(false)); + test("number vs object", () => expect(deepEquals(1, {})).toBe(false)); + test("boolean vs array", () => expect(deepEquals(true, [])).toBe(false)); + test("boolean vs object", () => expect(deepEquals(true, {})).toBe(false)); + test("array vs object", () => expect(deepEquals([], {})).toBe(false)); +}); + +describe("array equality", () => { + test("single element", () => expect(deepEquals([1], [1])).toBe(true)); + test("multiple elements", () => + expect(deepEquals([1, 2, 3], [1, 2, 3])).toBe(true)); + test("nested arrays", () => + expect(deepEquals([1, [2, 3], 4], [1, [2, 3], 4])).toBe(true)); +}); + +describe("array inequality", () => { + test("different lengths", () => + expect(deepEquals([1, 2], [1, 2, 3])).toBe(false)); + test("different elements", () => + expect(deepEquals([1, 2, 3], [1, 2, 4])).toBe(false)); + test("different nested arrays", () => + expect(deepEquals([1, [2, 3], 4], [1, [2, 4], 4])).toBe(false)); +}); + +describe("object equality", () => { + test("single key", () => expect(deepEquals({ a: 1 }, { a: 1 })).toBe(true)); + test("multiple keys", () => + expect(deepEquals({ a: 1, b: 2 }, { a: 1, b: 2 })).toBe(true)); + test("nested objects", () => + expect(deepEquals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } })).toBe( + true + )); +}); + +describe("object inequality", () => { + test("different keys", () => + expect(deepEquals({ a: 1 }, { b: 1 })).toBe(false)); + test("different values", () => + expect(deepEquals({ a: 1 }, { a: 2 })).toBe(false)); + test("different nested objects", () => + expect(deepEquals({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 3 } })).toBe( + false + )); +}); diff --git a/src/util-deep-equals.ts b/src/util-deep-equals.ts new file mode 100644 index 0000000..aad3a32 --- /dev/null +++ b/src/util-deep-equals.ts @@ -0,0 +1,60 @@ +import isObject from "./util-is-object"; + +/** An object that is either a plain array or plain object. */ +type Indexed = { [key: string | number]: any }; + +/** + * Checks if the given values are equal. + * + * Note: Only plain objects and arrays are compared recursively. + * Classes and other objects are compared with `Object.is`. + * @param left The left value to compare. + * @param right The right value to compare. + * @example + * ```ts + * // Both true: + * deepEquals( + * { a: 1 }, + * { a: 1 } + * ) + * deepEquals( + * { a: 1, b: { c: 2 } }, + * { a: 1, b: { c: 2 } } + * ) + * + * // Both false: + * deepEquals( + * { a: 1 }, + * { a: 2, b: 2 } + * ) + * deepEquals( + * { a: 1, b: { c: [] } }, + * { a: 1, b: { c: [] } } + * ) + * ``` + */ +const deepEquals = (left: T, right: unknown): right is T => { + if (Object.is(left, right)) return true; + if (!isIndexed(left) || !isIndexed(right)) return false; + if (!isSamePrototype(left, right)) return false; + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length !== rightKeys.length) return false; + + for (const key of leftKeys) { + if (!(key in right)) return false; + if (!deepEquals(left[key], right[key])) return false; + } + + return true; +}; + +const isIndexed = (value: unknown): value is Indexed => + Array.isArray(value) || isObject(value); + +const isSamePrototype = (left: T, right: unknown): right is T => + Object.getPrototypeOf(left) === Object.getPrototypeOf(right); + +export default deepEquals; diff --git a/src/util-deep-hash.spec.ts b/src/util-deep-hash.spec.ts new file mode 100644 index 0000000..945f8a9 --- /dev/null +++ b/src/util-deep-hash.spec.ts @@ -0,0 +1,137 @@ +import { expect, test } from "bun:test"; +import fc from "fast-check"; +import deepHash from "./util-deep-hash"; +import { deepEquals } from "bun"; + +const VERBOSE = false; +const COLLISION_TEST = true; +const ITERATIONS = process.env.CI ? 10_000 : 100_000; + +test("clones have the same hash", () => { + fc.assert( + fc.property( + fc.anything(), + (value) => deepHash(value) === deepHash(structuredClone(value)) + ) + ); +}); + +test("true !== false", () => { + expect(deepHash(true)).not.toBe(deepHash(false)); +}); + +test("null !== undefined", () => { + expect(deepHash(null)).not.toBe(deepHash(undefined)); +}); + +test("increment changes", () => { + for (let i = -10000000; i < 10000000; i += 2) { + expect(deepHash(i)).not.toBe(deepHash(i + 1)); + } +}); + +test.if(!process.env.CI)("16-bit range", () => { + const map = new Map(); + + for (let i = -32768; i < -32767; i++) { + const hash = deepHash(i); + expect(map.has(hash)).toBe(false); + map.set(hash, i); + } +}); + +const fuzzTest = (arb: fc.Arbitrary, count = 1) => { + return (name: string, runs: number) => { + test.skipIf(!COLLISION_TEST)(`hash ${name} (x${runs})`, () => { + let collisions = 0; + let identical = 0; + + fc.assert( + fc.property( + fc.array(arb, { minLength: count, maxLength: count }), + fc.array(arb, { minLength: count, maxLength: count }), + (left, right) => { + if (deepEquals(left, right)) { + identical++; + if (VERBOSE) { + console.log("Identical:", left, "=", right); + } + return; + } + + if (VERBOSE) { + console.log("Test:", left, "!==", right); + } + + const leftHash = deepHash(...left); + const rightHash = deepHash(...right); + + if (leftHash === rightHash) { + console.error("FAIL:", left, "===", right); + collisions++; + } + } + ), + { + numRuns: runs, + seed: Date.now(), + ignoreEqualValues: true, + } + ); + + console.log( + `Collisions: ${collisions}/${runs - identical}. ` + + `(${identical} identical)` + ); + expect(collisions).toBe(0); + }); + }; +}; + +fuzzTest(fc.integer({ min: -25_000_000, max: 25_000_000 }))( + "reasonable integers", + ITERATIONS +); + +fuzzTest(fc.double({ min: -25_000_000, max: 25_000_000 }))( + "reasonable doubles", + ITERATIONS +); + +fuzzTest(fc.string({ minLength: 1, maxLength: 32 }), 10)( + "short strings", + ITERATIONS +); + +fuzzTest(fc.string({ minLength: 32, maxLength: 256 }), 10)( + "long strings", + ITERATIONS +); + +fuzzTest(fc.unicodeString({ minLength: 1, maxLength: 32 }), 10)( + "short unicode strings", + ITERATIONS +); + +fuzzTest(fc.unicodeString({ minLength: 32, maxLength: 256 }), 10)( + "long unicode strings", + ITERATIONS +); + +fuzzTest( + fc.record({ + id: fc.uuid(), + email: fc.emailAddress({ size: "small" }), + username: fc.option(fc.asciiString({ minLength: 1, maxLength: 12 })), + }) +)("user states", ITERATIONS); + +fuzzTest( + fc.array( + fc.record({ + id: fc.uuid(), + text: fc.string({ minLength: 1, maxLength: 32 }), + completed: fc.boolean(), + }) + ) +)("todo lists", ITERATIONS); diff --git a/src/util-deep-hash.ts b/src/util-deep-hash.ts new file mode 100644 index 0000000..6d53d77 --- /dev/null +++ b/src/util-deep-hash.ts @@ -0,0 +1,81 @@ +import isObject from "./util-is-object"; + +const view = new DataView(new ArrayBuffer(8)); + +function* serialize(input: unknown): Iterable { + if (input === undefined) { + yield 0x1def9d; + } else if (input === null) { + yield 0xbadbad; + } else if (input === true) { + yield 0x17d0e5; + } else if (input === false) { + yield 0x0ff0ff; + } else if (typeof input === "number") { + yield 0xfffffff1; + view.setFloat64(0, input); + yield view.getInt32(0); + yield view.getInt16(4); + yield view.getInt16(6); + } else if (typeof input === "bigint") { + yield 0xfffffff2; + yield* serialize(input.toString(36)); + } else if (typeof input === "string") { + yield 0xfffffff3; + yield input.length | 0; + for (let i = 0; i < input.length; i += 2) { + yield input.charCodeAt(i) | (input.charCodeAt(i + 1) << 16); + } + } else if (Array.isArray(input)) { + yield 0xfffffff4; + for (let i = 0; i < input.length; i++) { + yield i; + yield* serialize(input[i]); + } + } else if (isObject(input)) { + yield 0xfffffff5; + for (const [key, value] of Object.entries(input)) { + yield* serialize(key); + yield* serialize(value); + } + } + + yield 0xee00ff; +} + +/** + * Hashes the a sequence of 32-bit numbers using the cyrb53a algorithm + * created by https://github.com/bryc (with slight modifications). + * @param input A sequence of 32-bit numbers to hash. + * @param seed (Optional) A seed to use for the hash. + */ +const cyrb53a = (input: Iterable, seed = 0) => { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + + for (const value of input) { + h1 = Math.imul(h1 ^ value, 0x85ebca77); + h2 = Math.imul(h2 ^ value, 0xc2b2ae3d); + } + + h1 ^= Math.imul(h1 ^ (h2 >>> 15), 0x735a2d97); + h2 ^= Math.imul(h2 ^ (h1 >>> 15), 0xcaf649a9); + h1 ^= h2 >>> 16; + h2 ^= h1 >>> 16; + + return 2097152 * (h2 >>> 0) + (h1 >>> 11); +}; + +/** + * Serializes the given arguments into a sequence of numbers, + * then hashes the sequence using the cyrb53a algorithm + * producing a number containing 53 bits of entropy. + * + * **Note:** This function does not hash anything that is not + * a plain object, array, string, number, boolean, null, or undefined. + * + * @param args The arguments to hash. + */ +const deepHash = (...args: any[]) => cyrb53a(serialize(args)); + +export default deepHash; diff --git a/src/util-deep-merge.spec.ts b/src/util-deep-merge.spec.ts new file mode 100644 index 0000000..181a00e --- /dev/null +++ b/src/util-deep-merge.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from "bun:test"; +import deepMerge from "./util-deep-merge"; + +test("merge deep", () => { + const source = { + a: 1, + b: { c: 2, x: [1] }, + c: null, + d: undefined, + e: undefined, + }; + const patch = { + a: 2, + b: { d: 3, x: [2, 3] }, + c: {}, + d: undefined, + e: {}, + f: 4, + }; + + const result = deepMerge(source, patch); + + expect(result).toEqual({ + a: 2, + b: { x: [2, 3], c: 2, d: 3 }, + c: {}, + d: undefined, + e: {}, + f: 4, + }); +}); diff --git a/src/util-deep-merge.ts b/src/util-deep-merge.ts new file mode 100644 index 0000000..e4e8aaf --- /dev/null +++ b/src/util-deep-merge.ts @@ -0,0 +1,63 @@ +import isObject, { PlainObject } from "./util-is-object"; + +/** + * Merge two object types using the following semantics: + * - Merge keys recursively if both values are plain objects. + * - Otherwise, overwrite the target type with the source type. + * - Additional keys in the patch type are appended the target type. + * + * Note: Arrays types are not merged. + */ +export type DeepMerged = { + [K in keyof T]: K extends keyof P + ? T[K] extends PlainObject + ? P[K] extends PlainObject + ? DeepMerged + : Required

[K] + : Required

[K] + : T[K]; +} & Omit; + +/** + * Merges two objects using the following semantics: + * - Merge keys recursively if both values are plain objects. + * - Otherwise, overwrite the target value with the patch value. + * - Additional keys in the patch object are appended the target object. + * + * **Note:** Only plain objects are merged; not arrays or classes. + * @param target The target object to merge into. + * @param patch The patch object to merge from. + * @example + * ``` + * import storeHook from "tyin/hook"; + * import extend from "tyin/extend"; + * import objectAPI from "tyin/object"; + * import deepMerge from "tyin/util-deep-merge"; + * + * const useExample = extend(storeHook({ a: 1, b: { c: 2 } })) + * .with(objectAPI({ merge: deepMerge })) + * .seal(); + * ``` + */ +export default function deepMerge( + target: T, + patch: P +): DeepMerged { + const merged: PlainObject = {}; + + for (const key of Object.keys(target)) { + if (!(key in patch)) { + merged[key] = target[key]; + } else if (isObject(target[key]) && isObject(patch[key])) { + merged[key] = deepMerge(target[key], patch[key]); + } + } + + for (const key of Object.keys(patch)) { + if (!(key in merged)) { + merged[key] = patch[key]; + } + } + + return merged as DeepMerged; +} diff --git a/src/util-is-object.spec.ts b/src/util-is-object.spec.ts new file mode 100644 index 0000000..0cd790e --- /dev/null +++ b/src/util-is-object.spec.ts @@ -0,0 +1,20 @@ +import { describe, test, expect } from "bun:test"; +import isObject from "./util-is-object"; + +describe("supported examples", () => { + test("empty object", () => expect(isObject({})).toBe(true)); + test("object with keys", () => expect(isObject({ a: 1 })).toBe(true)); + test("object with null prototype", () => + expect(isObject(Object.create(null))).toBe(true)); +}); + +describe("unsupported examples", () => { + test("array", () => expect(isObject([])).toBe(false)); + test("class", () => expect(isObject(new Event("click"))).toBe(false)); + test("null", () => expect(isObject(null)).toBe(false)); + test("undefined", () => expect(isObject(undefined)).toBe(false)); + test("string", () => expect(isObject("")).toBe(false)); + test("number", () => expect(isObject(0)).toBe(false)); + test("boolean", () => expect(isObject(false)).toBe(false)); + test("function", () => expect(isObject(() => {})).toBe(false)); +}); diff --git a/src/util-is-object.ts b/src/util-is-object.ts new file mode 100644 index 0000000..66238f0 --- /dev/null +++ b/src/util-is-object.ts @@ -0,0 +1,27 @@ +/** An object that is not null, not an array, and not a class. */ +export type PlainObject = { [key: string]: any }; + +/** + * Checks if the given value is a plain object; + * not null, not an array, not a class. + * @param value The value to check. + * @example + * ```ts + * // Both true: + * isObject({}); + * isObject(Object.create(null)); + * + * // Both false: + * isObject([]); + * isObject(new Event()); + * ``` + */ +const isObject = (value: unknown): value is PlainObject => + Boolean(value) && + typeof value === "object" && + hasPlainObjectPrototype(Object.getPrototypeOf(value)); + +const hasPlainObjectPrototype = (prototype: object) => + prototype === Object.prototype || prototype === null; + +export default isObject;