diff --git a/packages/flatpack-json/src/Flatpack.mts b/packages/flatpack-json/src/Flatpack.mts index 149d1d3605a1..02fbeb92e22b 100644 --- a/packages/flatpack-json/src/Flatpack.mts +++ b/packages/flatpack-json/src/Flatpack.mts @@ -1,4 +1,7 @@ +import assert from 'node:assert'; + import { FlatpackedWrapper } from './flatpackUtil.mjs'; +import { proxyDate, proxyObject, proxySet } from './proxy.mjs'; import { ArrayRefElement, BigIntRefElement, @@ -8,6 +11,7 @@ import { ObjectRefElement, ObjectWrapperRefElement, PrimitiveRefElement, + PrimitiveRefElementBase, RefElements, RegExpRefElement, SetRefElement, @@ -76,6 +80,8 @@ export class FlatpackStore { private cachedSets = new Map(); private cachedMaps = new Map>(); + private cachedProxies = new WeakMap(); + /** * Cache of strings that have been deduped and stored in the data array. */ @@ -275,7 +281,7 @@ export class FlatpackStore { private dedupeSetRefs(value: Set, element: SetRefElement): SetRefElement { if (!this.dedupe) return element; - const values = element.values(); + const values = element.valueRefs(); const found = this.cachedSets.get(values); if (!found) { this.cachedSets.set(values, element); @@ -287,6 +293,10 @@ export class FlatpackStore { return found; } + private proxySetRef(ref: SetRefElement): Set { + return proxySet(new Set(this.#toValue(ref.valueRefs()) as Serializable[]), () => {}); + } + private createUniqueKeys(keys: Serializable[]): ArrayRefElement { const cacheValue = false; let k = this.arrToRef(keys, cacheValue); @@ -320,8 +330,8 @@ export class FlatpackStore { private dedupeMapRefs(value: Map, element: MapRefElement): MapRefElement { if (!this.dedupe) return element; - const keys = element.keys(); - const values = element.values(); + const keys = element.keyRefs(); + const values = element.valueRefs(); let found = this.cachedMaps.get(keys); if (!found) { found = new Map(); @@ -340,6 +350,10 @@ export class FlatpackStore { return element; } + private proxyMapRef(_ref: MapRefElement): Map { + return new Map(); + } + private cvtRegExpToRef(value: RegExp): RegExpRefElement { const found = this.cache.get(value); if (found !== undefined) { @@ -361,6 +375,10 @@ export class FlatpackStore { return this.addValueAndElement(value, new DateRefElement(value.getTime())); } + private proxyDateRef(ref: DateRefElement): Date { + return proxyDate(ref.value, (date) => ref.setTime(date.getTime())); + } + private cvtBigintToRef(value: bigint): BigIntRefElement { const found = this.cache.get(value); if (found !== undefined) { @@ -415,8 +433,8 @@ export class FlatpackStore { private dedupeObject(value: PrimitiveObject | ObjectWrapper, element: ObjectRefElement): ObjectRefElement { if (!this.dedupe) return element; - const keys = element.keys(); - const values = element.values(); + const keys = element.keyRefs(); + const values = element.valueRefs(); let found = this.cachedObjects.get(keys); if (!found) { found = new Map(); @@ -435,6 +453,18 @@ export class FlatpackStore { return element; } + private proxyObjectRef(ref: ObjectRefElement): PrimitiveObject { + const keys = this.#toValue(ref.keyRefs()) as string[] | undefined; + const values = this.#toValue(ref.valueRefs()) as Serializable[] | undefined; + const obj = keys && values ? Object.fromEntries(keys.map((key, i) => [key, values[i]])) : {}; + return proxyObject(obj, (_value) => {}); + } + + private proxyObjectWrapperRef(ref: ObjectWrapperRefElement): PrimitiveObject { + const value = Object(this.#toValue(ref.valueRef())) as PrimitiveObject; + return proxyObject(value, (_value) => {}); + } + /** * * @param value - The array converted to an ArrayRefElement. @@ -482,6 +512,11 @@ export class FlatpackStore { return this.dedupeArray(value, element, cacheValue); } + private proxyArrayRef(ref: ArrayRefElement): PrimitiveArray { + const arr = ref.valueRefs().map((v) => this.#toValue(v)); + return proxyObject(arr, (_value) => {}); + } + private valueToRef(value: Serializable): RefElements { if (value === null) { return this.primitiveToRef(value); @@ -619,6 +654,26 @@ export class FlatpackStore { } } + #resolveToValueProxy(ref: RefElements | undefined): Unpacked { + if (!ref) return undefined; + if (ref instanceof ArrayRefElement) return this.proxyArrayRef(ref); + if (ref instanceof ObjectRefElement) return this.proxyObjectRef(ref); + if (ref instanceof PrimitiveRefElementBase) return ref.value; + if (isStringRefElements(ref)) return ref.value; + if (ref instanceof MapRefElement) return this.proxyMapRef(ref); + if (ref instanceof SetRefElement) return this.proxySetRef(ref); + if (ref instanceof BigIntRefElement) return ref.value; + if (ref instanceof RegExpRefElement) return ref.value; + if (ref instanceof DateRefElement) return this.proxyDateRef(ref); + if (ref instanceof ObjectWrapperRefElement) return this.proxyObjectWrapperRef(ref); + assert(false, 'Unknown ref type'); + } + + #toValue(ref: RefElements | undefined): Unpacked { + if (!ref) return undefined; + return getOrResolve(this.cachedProxies, ref, (ref) => this.#resolveToValueProxy(ref)); + } + toJSON(): Flatpacked { const data = [dataHeader] as Flatpacked; const idxLookup = this.assignedElements; @@ -654,6 +709,10 @@ export class FlatpackStore { toValue(): Unpacked { return fromJSON(this.toJSON()); } + + _toValueProxy(): Unpacked { + return this.#toValue(this.root); + } } type TrieData = StringRefElements; @@ -686,3 +745,15 @@ export function toJSON(json: V, options?: FlatpackOption export function stringify(data: Unpacked, pretty = true): string { return pretty ? stringifyFlatpacked(toJSON(data)) : JSON.stringify(toJSON(data)); } + +type WeakOrNever = K extends WeakKey ? WeakMap : never; +type SupportedMap = Map | WeakOrNever; + +function getOrResolve(map: SupportedMap, key: K, resolver: (key: K) => V): V { + let value = map.get(key); + if (value === undefined && !map.has(key)) { + value = resolver(key); + map.set(key, value); + } + return value as V; +} diff --git a/packages/flatpack-json/src/Flatpack.test.mts b/packages/flatpack-json/src/Flatpack.test.mts index 66f789bf120a..89d563e92e29 100644 --- a/packages/flatpack-json/src/Flatpack.test.mts +++ b/packages/flatpack-json/src/Flatpack.test.mts @@ -7,6 +7,7 @@ import { describe, expect, test } from 'vitest'; import { FlatpackStore, stringify, toJSON } from './Flatpack.mjs'; import { stringifyFlatpacked } from './stringify.mjs'; import { fromJSON } from './unpack.mjs'; +import { deepEqual } from './proxy.mts'; const urlFileList = new URL('../fixtures/fileList.txt', import.meta.url); const baseFilename = new URL(import.meta.url).pathname.split('/').slice(-1).join('').split('.').slice(0, -2).join('.'); @@ -189,6 +190,23 @@ describe('Flatpack', async () => { expect(fromJSON(fp.toJSON())).toEqual(data); expect(fp.toJSON()).not.toEqual(v); }); + + test.each` + data + ${undefined} + ${'string'} + ${1} + ${1.1} + ${null} + ${true} + ${false} + ${new Date()} + ${/[a-z]+/} + `('toValue $data', ({ data }) => { + const fp = new FlatpackStore(data); + expect(fp.toValue()).toEqual(data); + expect(fp._toValueProxy()).toEqual(data); + }); }); async function sampleFileList() { @@ -249,3 +267,34 @@ function sampleNestedData() { cValues, }; } + +describe('Flatpack value proxy', () => { + test.each` + value + ${undefined} + ${'string'} + ${1} + ${1.1} + ${null} + ${true} + ${false} + ${[]} + ${[1, 2]} + ${['a', 'b', 'a', 'b']} + ${{}} + ${{ a: 1 }} + ${{ a: { b: 1 } }} + ${{ a: { a: 'a', b: 42 } }} + ${{ a: [1] }} + ${new Set(['apple', 'banana', 'pineapple'])} + ${new Map([['apple', 1], ['banana', 2], ['pineapple', 3]])} + ${/[\p{L}\p{M}]+/gu} + ${new Date('2024-01-01')} + `('identity $value', ({ value }) => { + const fp = new FlatpackStore(value); + const proxy = fp._toValueProxy(); + expect(deepEqual(proxy, value)).toBe(true); + !(proxy instanceof Map || proxy instanceof Set) && expect(proxy).toEqual(value); + expect(fp._toValueProxy()).toBe(proxy); + }); +}); diff --git a/packages/flatpack-json/src/RefElements.mts b/packages/flatpack-json/src/RefElements.mts index 0b254eb0b267..afe015957341 100644 --- a/packages/flatpack-json/src/RefElements.mts +++ b/packages/flatpack-json/src/RefElements.mts @@ -124,11 +124,11 @@ export class ObjectRefElement extends BaseRefElement implements RefElement !!r); } - keys(): ArrayRefElement | undefined { + keyRefs(): ArrayRefElement | undefined { return this.#k; } - values(): ArrayRefElement | undefined { + valueRefs(): ArrayRefElement | undefined { return this.#v; } @@ -286,6 +290,10 @@ export class RegExpRefElement extends BaseRefElement implements RefElement void): Date { + class PDate extends Date { + constructor() { + super(); + super.setTime(date.getTime()); + proxy(this); + } + } + + type KeyofDate = keyof Date; + + function proxy(obj: PDate): Date { + const props = Object.getOwnPropertyNames(Object.getPrototypeOf(Object.getPrototypeOf(obj))) as KeyofDate[]; + for (const prop of props) { + if (prop.toString() === 'constructor') continue; + if (typeof obj[prop] === 'function') { + const fn: (...args: any[]) => any = obj[prop]; + const callback = prop.toString().startsWith('set') ? onUpdate : undefined; + const fObj = obj as Record void>; + fObj[prop] = function (...args: any[]) { + let r = fn.apply(obj, args); + if (date[prop] !== fn) return r; + r = fn.apply(date, args); + callback?.(date); + return r; + }; + } + } + return obj; + } + + return new PDate(); +} + +const debug = false; +const log: typeof console.log = debug ? console.log : () => {}; + +export function proxyObject( + obj: T, + onUpdate?: (obj: T, prop: keyof T, value: T[keyof T]) => void, +): T { + const proxy = new Proxy(obj, { + get(target, prop, _receiver) { + const value = target[prop as keyof T]; + // log('get %o', { prop, match: value === obj[prop as keyof T] }); + if (value instanceof Function) { + return value.bind(target); + } + return value; + }, + + ownKeys(target) { + const result = Reflect.ownKeys(target); + // log('ownKeys %o', { result, r_orig: Reflect.ownKeys(obj), match: deepEqual(result, Reflect.ownKeys(obj)) }); + return result; + }, + + getOwnPropertyDescriptor(target, prop) { + const result = Reflect.getOwnPropertyDescriptor(target, prop); + // log('getOwnPropertyDescriptor %o', { + // prop, + // result, + // r_orig: Object.getOwnPropertyDescriptor(obj, prop), + // match: deepEqual(result, Object.getOwnPropertyDescriptor(obj, prop)), + // }); + return result; + }, + + has(target, prop) { + const result = Reflect.has(target, prop); + // log('has %o', { prop, result, match: result === prop in obj }); + return result; + }, + + set(target, prop, value) { + // log('set %o', { prop }); + const r = Reflect.set(target, prop, value); + onUpdate?.(target, prop as keyof T, value); + return r; + }, + + deleteProperty(target, prop) { + // log('deleteProperty %o', { prop }); + return Reflect.deleteProperty(target, prop); + }, + + defineProperty(target, prop, descriptor) { + log('defineProperty %o', { prop }); + return Reflect.defineProperty(target, prop, descriptor); + }, + + getPrototypeOf(target) { + const result = Reflect.getPrototypeOf(target); + // log('getPrototypeOf %o', { + // match: result === Object.getPrototypeOf(obj) ? true : Object.getPrototypeOf(obj), + // }); + return result; + }, + + setPrototypeOf(target, prototype) { + log('setPrototypeOf'); + return Reflect.setPrototypeOf(target, prototype); + }, + + isExtensible(target) { + const result = Reflect.isExtensible(target); + // log('isExtensible %o', { result, match: result === Object.isExtensible(obj) }); + return result; + }, + + preventExtensions(target) { + log('preventExtensions'); + return Reflect.preventExtensions(target); + }, + + apply(target, thisArg, argArray) { + log('apply'); + return Reflect.apply(target as any, thisArg, argArray); + }, + + construct(target, argArray, newTarget) { + log('construct'); + return Reflect.construct(target as any, argArray, newTarget); + }, + }); + return proxy; +} + +export function deepEqual(a: any, b: any): boolean { + if (a === b) return true; + if (typeof a === 'function' && typeof b === 'function') return true; + if (typeof a !== 'object' || typeof b !== 'object') return false; + for (const key in a) { + if (!(key in b)) return false; + if (!deepEqual(a[key], b[key])) return false; + } + return true; +} + +export function proxyMap( + values: Iterable<[K, V]>, + onUpdate?: (map: Map, key: K | undefined, value: V | undefined) => void, +): Map { + class PMap extends Map { + constructor() { + super(); + // Initialize the map with the values from the original map. + // This needs to be done instead of `super(fromMap)`, otherwise `this.set` will be called. + for (const [key, value] of values) { + super.set(key, value); + } + } + + set(key: K, value: V): this { + const r = super.set(key, value); + console.log('set', key, value); + onUpdate?.(this, key, value); + return r; + } + + delete(key: K): boolean { + const r = super.delete(key); + onUpdate?.(this, key, undefined); + return r; + } + + clear(): void { + super.clear(); + onUpdate?.(this, undefined, undefined); + } + } + + return new PMap(); +} + +export function proxySet(values: Iterable, onUpdate?: (set: Set, value: T | undefined) => void): Set { + class PSet extends Set { + constructor() { + super(); + // Initialize the set with the values from the original set. + // This needs to be done instead of `super(fromSet)`, otherwise `this.add` will be called. + for (const value of values) { + super.add(value); + } + } + + add(value: T): this { + const r = super.add(value); + onUpdate?.(this, value); + return r; + } + + delete(value: T): boolean { + const r = super.delete(value); + onUpdate?.(this, value); + return r; + } + + clear(): void { + super.clear(); + onUpdate?.(this, undefined); + } + } + + return new PSet(); +} diff --git a/packages/flatpack-json/src/proxy.test.mts b/packages/flatpack-json/src/proxy.test.mts new file mode 100644 index 000000000000..ab845c53f07d --- /dev/null +++ b/packages/flatpack-json/src/proxy.test.mts @@ -0,0 +1,198 @@ +import { describe, expect, test, vi } from 'vitest'; + +import { deepEqual, proxyDate, proxyMap, proxyObject, proxySet } from './proxy.mjs'; + +describe('proxy', () => { + test.each` + value + ${{ a: 'apple', b: 2 }} + ${new WithSecret('password')} + ${[1, 2, 3]} + `('proxyObject $value', ({ value }) => { + const p = proxyObject(value); + expect(p).toEqual(value); + }); + + test.each` + value + ${/regexp/} + ${new Date()} + ${new Map()} + ${new Set()} + `('not supported proxyObject $value', ({ value }) => { + const p = proxyObject(value); + expect(p).not.toEqual(value); + // The proxy object should have the same properties as the original object. + expect(deepEqual(p, value)).toBe(true); + }); + + test('proxyObject', () => { + const obj = { + a: 'apple', + b: 2, + }; + const callback = vi.fn(); + const p = proxyObject(obj, callback); + // expect(p).toEqual(obj); + expect(p.a).toBe('apple'); + expect(p.a).toBe(obj.a); + + p.a = 'banana'; + expect(callback).toHaveBeenCalledTimes(1); + expect(p.a).toEqual(obj.a); + expect(p.b).toEqual(obj.b); + expect(p).toEqual(obj); + }); + + test('proxyObject object with methods', () => { + const obj = { + a: 'apple', + b: 2, + v() { + return this.a; + }, + }; + const callback = vi.fn(); + const p = proxyObject(obj, callback); + // expect(p).toEqual(obj); + expect(p.v()).toBe('apple'); + expect(p.v()).toBe(obj.v()); + + p.a = 'banana'; + expect(callback).toHaveBeenCalledTimes(1); + expect(p.a).toEqual(obj.a); + expect(p.b).toEqual(obj.b); + // fails on v() because the function is bound to the original object. + expect(p).not.toEqual(obj); + expect(p.v).not.toEqual(obj.v); + }); + + test('proxyObject class', () => { + class MyClass { + a = 'apple'; + b = 2; + v() { + return this.a; + } + } + const obj = new MyClass(); + const callback = vi.fn(); + const p = proxyObject(obj, callback); + // expect(p).toEqual(obj); + expect(p.v()).toBe('apple'); + expect(p.v()).toBe(obj.v()); + + p.a = 'banana'; + expect(callback).toHaveBeenCalledTimes(1); + expect(p.a).toEqual(obj.a); + expect(p.b).toEqual(obj.b); + expect(p).toEqual(obj); + }); + + test('proxyObject Array', () => { + const obj = ['apple', 2, 'banana', 4, undefined]; + const callback = vi.fn(); + const p = proxyObject(obj, callback); + // expect(p).toEqual(obj); + expect(p[0]).toBe('apple'); + expect(p['0']).toBe(obj[0]); + + p[6] = 'banana'; + expect(callback).toHaveBeenCalledTimes(1); + expect(p).toEqual(obj); + expect(p.length).toBe(obj.length); + p.length = 10; + expect(callback).toHaveBeenCalledTimes(2); + expect(obj.length).toBe(10); + expect(p).toEqual(obj); + }); + + test('date', () => { + const d = new Date(); + const callback = vi.fn(); + const p = proxyDate(d, callback); + expect(p).toBeInstanceOf(Date); + expect(p).toEqual(d); + + expect(callback).toHaveBeenCalledTimes(0); + + p.setTime(Date.now() + 1000); + expect(callback).toHaveBeenCalledTimes(1); + expect(p).toEqual(d); + + // Updating the original object won't trigger a callback. + d.setTime(Date.now() + 2000); + expect(callback).toHaveBeenCalledTimes(1); + expect(p).toEqual(d); + + p.setFullYear(2020); + expect(callback).toHaveBeenCalledTimes(2); + expect(p).toEqual(d); + }); + + test('proxyMap', () => { + const callback = vi.fn(); + const src = new Map(Object.entries({ a: 'apple', b: 2 })); + const map = proxyMap(src, callback); + expect(map).toBeInstanceOf(Map); + expect(map.get('a')).toBe('apple'); + expect(map.get('b')).toBe(2); + // inherited Maps are not equal to the original Map, the method do not match. + expect(map).not.toEqual(src); + expect(new Map(map)).toEqual(src); + map.set('a', 'banana'); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith(map, 'a', 'banana'); + expect(map.get('a')).toBe('banana'); + + map.delete('a'); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(map, 'a', undefined); + expect(map.get('a')).toBe(undefined); + + map.clear(); + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenLastCalledWith(map, undefined, undefined); + }); + + test('proxySet', () => { + const callback = vi.fn(); + const src = new Set(Object.keys({ a: 'apple', b: 2 })); + const s = proxySet(src, callback); + expect(s).toBeInstanceOf(Set); + expect(s.has('a')).toBe(true); + expect(s.has('b')).toBe(true); + expect(s.add('a')).toBe(s); + expect(s.size).toBe(2); + expect(s).not.toEqual(src); + expect(new Set(s)).toEqual(src); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenLastCalledWith(s, 'a'); + + s.delete('a'); + expect(callback).toHaveBeenCalledTimes(2); + expect(callback).toHaveBeenLastCalledWith(s, 'a'); + expect(s.has('a')).toBe(false); + expect(s.size).toBe(1); + + s.clear(); + expect(callback).toHaveBeenCalledTimes(3); + expect(callback).toHaveBeenLastCalledWith(s, undefined); + }); +}); + +class WithSecret { + #secret: string; + + constructor(secret: string) { + this.#secret = secret; + } + + getSecret() { + return this.#secret; + } + + get value() { + return this.#secret; + } +}